SLOPSHOPPER

cue

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

newbandcommandpromptmodel
v0.1.0MITupdated 2026-10-10AbyssCN/claude-lead-harness/mods/cue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cue
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /enhance ⎿ cue: cue:这次没生成出可用的 prompt,输入框未改动。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cue(提词)

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 时当作空闲。

配置(plugin userConfig)

  • 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-harness
Source 3 files
hooks/register.tsx 174 lines
1import { 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}
174
hooks/seat.ts 163 lines
1// 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}
163
types/index.d.ts 13 lines
1export 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