提词:每次回答后,站在你的位置写好下一条完整 prompt;空输入框按 1 或点 Enhance 写进草稿,从不替你发送。/enhance 把你的粗稿改写成完整结构

After each answer, drafts the next full prompt from your seat into the input box (never sends it); /enhance rewrites your rough draft into a complete prompt.
主 session 每回答完一条(≥ 80 字),cue 让同一个模型 fork 一次,站在你的位置写好下一条完整 prompt:要什么 · 关键事实 · 边界 · 判据 · 停在哪回什么。默认只给一条;回答停在真岔口时才多给一条「方向 B」。cue 只往输入框写草稿,从不替你发送。
1,或点 Enhance · 标题:把整条 prompt 写进输入框(覆盖),改完自己回车。2:方向 B(只在真岔口出现)。/enhance:把输入框里的粗稿改写成完整结构,写回输入框;输入框为空时等于按 1。/cue stats:累计生成次数、各类 token 合计与平均、按 1/2/enhance 的次数、发出时原样/改写/弃用的比例。relay 自动续跑(relay.mode === 'running')期间不生成、不出按钮;没装 relay 时当作空闲。
engine:fork(默认,主模型带完整上下文,上下文走主线缓存)· haiku(只看最后一条回答,便宜但浅)· off(回答后不自动生成,/enhance 仍可用)。minChars:回答短于此字符数不生成,默认 80。fork 每次都会重发主线最近一次请求的全部上下文,这部分走缓存读,另外加上 cue 自己那段提示和输出(不走缓存)。每次的 usage 都记在 $.store,用 /cue stats 查看。如果太贵,把 engine 设为 off,只在需要时用 /enhance。
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install cue@claude-lead-harnesshooks/register.tsx 174 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CueSet } from '../types'
5import type { Cue, ForkLog, PickLog } from './seat'
6import { clip, editKind, editRatio, enhancePrompt, oneLine, parseCues, seatPrompt, stats } from './seat'
7
8const current = atom({ plugin: 'cue', key: 'current' } as const, null)
9const relayMode = { plugin: 'relay', key: 'mode' } as const
10
11const KEEP = 2000 // log entries kept per kind
12const PREVIEW = 60
13
14// C-4: silent while relay drives. Relay not installed / unreadable → idle.
15async function relayRunning($: EngineInterface): Promise<boolean> {
16 try {
17 const { value } = await $.state.get(relayMode)
18 return value === 'running'
19 } catch {
20 return false
21 }
22}
23
24async function commands($: EngineInterface) {
25 const all = await $.command.list()
26 const known = new Set(all.map(c => c.name))
27 const menu = all.filter(c => c.source !== 'mcp' && c.source !== 'builtin').map(c => c.name)
28 return { known, menu }
29}
30
31async function append<T>($: EngineInterface, key: string, entry: T) {
32 const list = ((await $.store.get(key)) as T[] | undefined) ?? []
33 list.push(entry)
34 await $.store.set(key, list.slice(-KEEP))
35}
36
37/** One generation: fork (or haiku), usage logged, reply parsed. */
38async function generate($: EngineInterface, engine: string, kind: ForkLog['kind'], prompt: string, known: Set<string>, max: number) {
39 const useHaiku = engine === 'haiku'
40 const reply = useHaiku
41 ? await $.model.complete({ model: 'haiku', prompt, maxTokens: 1500 })
42 : await $.model.fork({ prompt })
43 if ('usage' in reply) {
44 const u = reply.usage
45 const entry: ForkLog = {
46 ts: await $.clock.now(),
47 kind,
48 engine: useHaiku ? 'haiku' : 'fork',
49 input_tokens: u.input_tokens,
50 output_tokens: u.output_tokens,
51 cache_read_input_tokens: u.cache_read_input_tokens,
52 cache_creation_input_tokens: u.cache_creation_input_tokens,
53 }
54 await append($, 'forks', entry)
55 }
56 return reply.isAnswered ? parseCues(reply.text, known, max) : []
57}
58
59async function fillAndLog($: EngineInterface, slot: PickLog['slot'], text: string) {
60 const filled = await $.prompt.fill({ text, mode: 'replace' })
61 if (!filled.isFilled) return false
62 await append<PickLog>($, 'picks', { ts: await $.clock.now(), slot, text })
63 return true
64}
65
66export const register: Register = (on, options) => {
67 const engine = String(options.engine ?? 'fork')
68 const minChars = Number(options.minChars ?? 80)
69 // The turn whose cues are wanted; a newer prompt makes older work stale.
70 let wanted = ''
71 let lastAnswer = ''
72
73 on('session.start', async ($, e, next) => {
74 await $.command.register({ name: 'enhance', description: 'cue:把输入框里的粗稿改写成完整 prompt(空输入框则给下一步),写回输入框不发送' })
75 await $.command.register({ name: 'cue', description: 'cue:用量与按下统计', argumentHint: '[stats]' })
76 return next(e)
77 })
78
79 on('prompt.submit', async ($, e, next) => {
80 wanted = ''
81 await update($, current, () => null)
82 // Resolve the last unresolved pick against what was actually sent.
83 if (e.origin.kind === 'composer') {
84 const picks = ((await $.store.get('picks')) as PickLog[] | undefined) ?? []
85 const last = picks[picks.length - 1]
86 if (last && !last.edited) {
87 const ratio = Number(editRatio(last.text, e.text).toFixed(3))
88 last.edited = editKind(ratio)
89 last.ratio = ratio
90 await $.store.set('picks', picks)
91 }
92 }
93 return next(e)
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 const result = await next(e)
98 if (e.agentId || e.reason !== 'answer') return result
99 lastAnswer = e.answer
100 if (engine === 'off' || e.answer.trim().length < minChars) return result
101 if (await relayRunning($)) return result
102
103 wanted = e.turnId
104 const { known, menu } = await commands($)
105 const items = await generate($, engine, 'answer', seatPrompt(e.answer, menu), known, 2)
106 if (wanted !== e.turnId || items.length === 0) return result
107 if (await relayRunning($)) return result
108 const fresh: CueSet = { turnId: e.turnId, items }
109 await update($, current, () => fresh)
110 return result
111 })
112
113 on('command.run', { command: 'enhance' }, async $ => {
114 const { text: draft } = await $.prompt.read()
115 let pick: Cue | undefined
116 let slot: PickLog['slot'] = 'enhance'
117 if (draft.trim()) {
118 const { known, menu } = await commands($)
119 pick = (await generate($, engine, 'enhance', enhancePrompt(draft, menu), known, 1))[0]
120 } else {
121 // Empty draft: the post-answer cue, reused if it is already on the band.
122 const shown = await read($, current)
123 pick = shown?.items[0]
124 if (pick) slot = '1'
125 else {
126 const { known, menu } = await commands($)
127 const answer = lastAnswer || '(见上文主 session 的最后一条回答)'
128 pick = (await generate($, engine, 'answer', seatPrompt(answer, menu), known, 1))[0]
129 }
130 }
131 if (!pick) return { text: 'cue:这次没生成出可用的 prompt,输入框未改动。' }
132 const ok = await fillAndLog($, slot, pick.prompt)
133 await update($, current, () => null)
134 return { text: ok ? `cue:已写入输入框(未发送)· ${pick.title}` : 'cue:输入框不可写(对话框打开中或无输入框)。' }
135 })
136
137 on('command.run', { command: 'cue' }, async $ => {
138 const forks = ((await $.store.get('forks')) as ForkLog[] | undefined) ?? []
139 const picks = ((await $.store.get('picks')) as PickLog[] | undefined) ?? []
140 return { text: stats(forks, picks) }
141 })
142
143 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144 if (e.props.hasSurvey || e.props.isWorking) return next(e)
145 const shown = await read($, current)
146 if (!shown || shown.items.length === 0) return next(e)
147 if (await relayRunning($)) return next(e)
148
149 const { Box, Button, Text } = $.ui.resolve(e)
150 const width = e.props.bodyColumns - 4
151 const press = (slot: '1' | '2', text: string) => async () => {
152 await fillAndLog($, slot, text)
153 await update($, current, () => null)
154 }
155
156 return (
157 <Box flexDirection="column">
158 <Text dimColor>cue · 空输入框按数字或点击,写进草稿(不会发送)</Text>
159 {shown.items.map((c, i) => {
160 const slot = i === 0 ? '1' : '2'
161 const name = `${i === 0 ? 'Enhance' : '方向 B'} · ${c.title}`
162 const room = Math.max(0, Math.min(PREVIEW, width - name.length - 6))
163 return (
164 <Button key={`s${slot}`} plain hotkey={slot} label={name} onPress={press(slot, c.prompt)}>
165 {name}
166 {room > 8 ? <Text dimColor>{' ' + clip(oneLine(c.prompt), room)}</Text> : null}
167 </Button>
168 )
169 })}
170 </Box>
171 )
172 })
173}
174hooks/seat.ts 163 lines1// Pure helpers: the seat prompt (C-3), reply parsing, edit measure, stats. No engine here.
2
3export type Cue = { title: string; prompt: string }
4export type ForkLog = {
5 ts: number
6 kind: 'answer' | 'enhance'
7 engine: string
8 input_tokens: number
9 output_tokens: number
10 cache_read_input_tokens: number
11 cache_creation_input_tokens: number
12}
13export type Edit = 'equal' | 'edited' | 'discarded'
14export type PickLog = { ts: number; slot: '1' | '2' | 'enhance'; text: string; edited?: Edit; ratio?: number }
15
16const MAX_ANSWER = 6000
17const MAX_DRAFT = 4000
18
19const SHAPE = [
20 '每条 prompt 写成用户的口吻,是一条完整的指令,按顺序含:',
21 '1. 一句话要什么(回答在等拍板时,第一句就是用户的拍板 + 一句理由)',
22 '2. 关键事实或证据指针(文件路径、提交号、读数),只放模型会用到的',
23 '3. 边界:不碰什么、不做什么;顺手写进你的自查(最可能错的那个结论先验)和更简单替代的判断',
24 '4. 判据:怎么算做完,能写成可跑命令就写命令',
25 '5. 停在哪、回我什么(例:「验完给我读数,不要问要不要继续」)',
26 '限定终点和边界,不规定做法和第一步。点名执行方最可能漏的那一处。',
27]
28
29function menu(commands: string[]): string {
30 return commands.length > 0
31 ? `可以用这些真实存在的命令开头(只用这些):${commands.map(c => '/' + c).join(' ')}`
32 : '不要以 slash 命令开头。'
33}
34
35/** C-3: the lead in the user's seat writes the next prompt after an answer. */
36export function seatPrompt(answer: string, commands: string[]): string {
37 const tail = answer.length > MAX_ANSWER ? '…' + answer.slice(-MAX_ANSWER) : answer
38 return [
39 '你是这个仓库的 lead(总架构师),但现在你坐在用户的位置上:主 session 的 agent 刚回答完下面这条。用户下一句要发什么,才能让模型想得最深、方向最对?替他写好,他按一下就进输入框,改几个字就发。',
40 '',
41 '怎么想(不要写进输出):',
42 '- 先判这条回答停在哪:在等用户拍板?做完但没验证?方向本身可能错?还是有明显的下一步?',
43 '- 站在 lead 的标准看回答:哪个结论是推出来的而不是看到的?有没有更简单的替代?有没有被跳过的脏场景(并发、部分失败、读失败显示成 0、量级膨胀)?',
44 '- 用你从对话和仓库规则里知道的事实,不编造路径和命令。',
45 '',
46 ...SHAPE,
47 '',
48 '给几条:',
49 '- 默认只给 1 条:你作为 lead 推荐的下一步。自查和更简单替代的判断写进它的边界和判据里,不要拆成别的条。',
50 '- 只有回答停在真岔口(两个方向都站得住、取决于用户的偏好)时,才追加第 2 条「方向 B」;否则不给。',
51 '',
52 menu(commands),
53 '',
54 '只输出 JSON 数组,长度 1 或 2:[{"title":"按钮上的短标题,≤18 字","prompt":"完整 prompt"}],不要其他文字。',
55 '',
56 '<最新回答>',
57 tail,
58 '</最新回答>',
59 ].join('\n')
60}
61
62/** /enhance: rewrite the user's rough draft into the full structure, keeping his intent. */
63export function enhancePrompt(draft: string, commands: string[]): string {
64 const text = draft.length > MAX_DRAFT ? draft.slice(0, MAX_DRAFT) + '…' : draft
65 return [
66 '你是这个仓库的 lead(总架构师),但现在你坐在用户的位置上:他在输入框里写了一份粗稿(下方 <草稿>),要发给主 session 的 agent。把它改写成能让模型想得最深、方向最对的完整 prompt。',
67 '',
68 '规则(不要写进输出):',
69 '- 粗稿里的意图、拍板和原话决定一律保留,不加他没要的新方向;粗稿含糊处按对话上下文补成具体的事实与指针,补不出就留给模型去查,不编造路径和命令。',
70 '- 粗稿已经是 slash 命令开头的,保留那个命令开头。',
71 '',
72 ...SHAPE,
73 '',
74 menu(commands),
75 '',
76 '只输出 JSON 数组,长度 1:[{"title":"≤18 字","prompt":"完整 prompt"}],不要其他文字。',
77 '',
78 '<草稿>',
79 text,
80 '</草稿>',
81 ].join('\n')
82}
83
84/** Reply → 1–2 cues; drops malformed items and those opening with an unknown slash command. */
85export function parseCues(text: string, known: Set<string>, max = 2): Cue[] {
86 const start = text.indexOf('[')
87 const end = text.lastIndexOf(']')
88 if (start < 0 || end <= start) return []
89 let raw: unknown
90 try {
91 raw = JSON.parse(text.slice(start, end + 1))
92 } catch {
93 return []
94 }
95 if (!Array.isArray(raw)) return []
96 const out: Cue[] = []
97 for (const item of raw) {
98 if (!item || typeof item !== 'object') continue
99 const { title, prompt } = item as { title?: unknown; prompt?: unknown }
100 if (typeof prompt !== 'string' || !prompt.trim()) continue
101 const body = prompt.trim()
102 // A slash command the session does not have would fail when sent: drop it.
103 const cmd = /^\/([\w:.-]+)/.exec(body)
104 if (cmd && !known.has(cmd[1]!)) continue
105 if (out.some(c => c.prompt === body)) continue
106 const head = typeof title === 'string' && title.trim() ? title.trim() : body.slice(0, 18)
107 out.push({ title: oneLine(head), prompt: body })
108 if (out.length === max) break
109 }
110 return out
111}
112
113export function oneLine(s: string): string {
114 return s.replace(/\s+/g, ' ').trim()
115}
116
117export function clip(s: string, room: number): string {
118 const n = Math.max(4, room)
119 return s.length > n ? s.slice(0, n - 1) + '…' : s
120}
121
122/** How much of the filled text the sent text changed: 0 = same, 1 = nothing kept. */
123export function editRatio(filled: string, sent: string): number {
124 const a = filled.trim()
125 const b = sent.trim()
126 if (a === b) return 0
127 const longest = Math.max(a.length, b.length)
128 if (longest === 0) return 0
129 let pre = 0
130 while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
131 let suf = 0
132 while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++
133 return Math.min(1, Math.max(0, 1 - (pre + suf) / longest))
134}
135
136export function editKind(ratio: number): Edit {
137 return ratio === 0 ? 'equal' : ratio >= 0.8 ? 'discarded' : 'edited'
138}
139
140export function stats(forks: ForkLog[], picks: PickLog[]): string {
141 const sum = (k: keyof Omit<ForkLog, 'ts' | 'kind' | 'engine'>) => forks.reduce((s, f) => s + (f[k] || 0), 0)
142 const n = forks.length
143 const avg = (v: number) => (n ? Math.round(v / n) : 0)
144 const read = sum('cache_read_input_tokens')
145 const write = sum('cache_creation_input_tokens')
146 const input = sum('input_tokens')
147 const output = sum('output_tokens')
148 const bySlot = (s: PickLog['slot']) => picks.filter(p => p.slot === s).length
149 const resolved = picks.filter(p => p.edited)
150 const count = (k: Edit) => resolved.filter(p => p.edited === k).length
151 const meanRatio = resolved.length
152 ? (resolved.reduce((s, p) => s + (p.ratio ?? 0), 0) / resolved.length).toFixed(2)
153 : '-'
154 const pct = (k: Edit) => (resolved.length ? Math.round((count(k) / resolved.length) * 100) + '%' : '-')
155 return [
156 `cue stats · 生成 ${n} 次(回答后 ${forks.filter(f => f.kind === 'answer').length} · /enhance ${forks.filter(f => f.kind === 'enhance').length})`,
157 `tokens 合计:缓存读 ${read} · 缓存写 ${write} · 输入 ${input} · 输出 ${output}`,
158 `每次平均:缓存读 ${avg(read)} · 缓存写 ${avg(write)} · 输入 ${avg(input)} · 输出 ${avg(output)}`,
159 `按下 ${picks.length} 次:1 = ${bySlot('1')} · 2(方向 B)= ${bySlot('2')} · /enhance = ${bySlot('enhance')}`,
160 `发出时:原样 ${count('equal')}(${pct('equal')})· 改写 ${count('edited')}(${pct('edited')})· 弃用 ${count('discarded')}(${pct('discarded')})· 未发 ${picks.length - resolved.length} · 平均改写比例 ${meanRatio}`,
161 ].join('\n')
162}
163types/index.d.ts 13 lines1export type Cue = { title: string; prompt: string }
2export type CueSet = { turnId: string; items: Cue[] }
3
4/** C-4: relay's session mode, read only here; declared locally (no dependency on relay). */
5export type RelayMode = 'idle' | 'running' | 'waiting-owner' | 'done'
6
7declare module 'claude-code' {
8 interface PluginState {
9 cue: { current: CueSet | null }
10 relay: { mode: RelayMode }
11 }
12}
13