What each turn did, in a band above the prompt once it ends (files changed with lines, commands and failures, reads, subagents), and a toast when the model…

简体中文 · English
Claude 每轮回复结束后,输入框上方会留下一行回执:改了几个文件、增删多少行、跑了几条命令、有几条失败。不用翻记录,就知道这一轮做了什么。Claude 原地打转时还会弹提示。全程不消耗 token。

✓ 正常回答、◼ 被中断、✗ 出错)和用时,再列出有内容的几项:改动的文件和增删行数、执行的命令和失败数、读取次数(Read、Grep、Glob)、开了几个子代理、产生了几条预警。/ 或 @ 选择器时也会让开。/receipt 里也会写明。只改 Markdown、纯文本这类文档不算。识别 npm test、pytest、go test、cargo check、tsc、eslint、make、bash scripts/check.sh 等常见写法,包括 uv run、npx 这类前缀。ctrl+x Tab 聚焦横条后按 r),也可以输入 /receipt replay,打开面板逐处查看这一轮每次编辑的 diff:文件、增删行数、从第几行开始改,n / p 前后翻,c 或 Esc 关闭。diff 取自编辑实际执行后的结果,被拒绝或失败的编辑不会出现;子代理的编辑也在里面。每轮最多记录前 50 处,每处最多 120 行。repeatFailures 次(默认 3),中间没有任何改动。编辑文件或跑了会改东西的命令(如 sed -i、npm install)算在推进,计数重来;cat、ls 这类只读命令不算。flipFlops 次(默认 2)。claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install receipt@hoobnn-agent-mods
/receipt:完整列出上一轮的回执:每个文件的增删行数(新建的文件标「新建」)、每条失败的命令、每条预警。/receipt replay:打开回放面板,逐处查看上一轮每次编辑的 diff。/receipt off、/receipt on:隐藏或显示回执。这个设置会写回 /config,以后的会话也会沿用。在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:
| 选项 | 作用 | 默认 |
|---|---|---|
visible | 两轮之间在输入框上方显示回执 | 开 |
repeatFailures | 同一调用连续失败几次后提醒;0 关闭 | 3 |
flipFlops | 同一文件来回改几次后提醒;0 关闭 | 2 |
flagUnverified | 最后一次改代码之后没跑测试、构建或检查时,回执标「未验证」 | 开 |
language | 界面语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ru | auto |
language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。
hoobnn-agent-mods 里还有任务进度条(todo-bar)、状态栏 HUD(hud)、运行动画和宠物(spinner)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。
hooks/register.tsx:钩子(会话开始时的语言和 /receipt,回合开始与结束,读取工具调用,命令和横条)。hooks/ledger.ts:根据每次调用的内容生成回执,以及两条打转规则。hooks/config.ts:选项,一次性读成类型化的 Config。hooks/i18n.ts:各语言文案。hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。hooks/register.tsx 316 lines1// What the last turn did, in a band above the prompt once it ends, and a toast
2// when the main thread goes in circles. Read from the turn's own tool calls
3// once they have run: no tool of its own, nothing in the prompt, no tokens.
4import { atom, derive, read, update } from 'claude-code'
5import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
6
7import type { Receipt, Step } from '../types'
8import { readConfig } from './config'
9import { m, setLang } from './i18n'
10import { isPickerOpen, stackAbove } from './kit/band'
11import { resolveLanguage } from './kit/lang'
12import { persist, switchArg } from './kit/prefs'
13import type { Prefs } from './kit/prefs'
14import { NO_WATCH, addCall, finish, formatDuration, isEmpty, newReceipt, totals, watchCall } from './ledger'
15import type { Alert } from './ledger'
16
17const receipt = atom({ plugin: 'receipt', key: 'receipt' } as const, null as Receipt | null)
18const watch = atom({ plugin: 'receipt', key: 'watch' } as const, NO_WATCH)
19// The band shows between turns, for a turn that used a tool.
20const isShown = atom({ plugin: 'receipt', key: 'isShown' } as const, false)
21// Set this session by `/receipt` or session.start; null in a session resumed or
22// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
23const hiddenSet = atom({ plugin: 'receipt', key: 'isHidden' } as const, null as boolean | null)
24// The `visible` row's, set in register.
25let isRowHidden = false
26const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
27// True while a picker is open above the band (see kit/band).
28const isPicking = atom({ plugin: 'receipt', key: 'isPicking' } as const, false)
29// The replay pane: the edit it shows, and whether it is open (the band steps aside meanwhile).
30const replayAt = atom({ plugin: 'receipt', key: 'replayAt' } as const, 0)
31const isReplaying = atom({ plugin: 'receipt', key: 'isReplaying' } as const, false)
32
33const PANE = 'receipt-replay'
34// Rows the pane takes besides the diff: the header, the file, the buttons and the gaps.
35const PANE_CHROME = 6
36
37const SEP = ' · '
38
39/** The kit's hold on this mod's store and `/config` rows. */
40function prefsOf($: EngineInterface): Prefs {
41 return {
42 kept: key => $.store.get(key),
43 forget: key => $.store.delete(key),
44 write: (field, value) => $.config.set({ key: `receipt.${field}`, value }),
45 }
46}
47
48function alertText(alert: Alert): string {
49 return alert.kind === 'repeat' ? m('alert.repeat', { n: alert.n, label: alert.label }) : m('alert.flip', { n: alert.n, path: alert.path })
50}
51
52/** The receipt's headline: how the turn went and how long it took (so far, while it runs). */
53function headline(r: Receipt, now: number): string {
54 if (r.durationMs === null) return m('band.running', { d: formatDuration(now - r.startedAt) })
55 const d = formatDuration(r.durationMs)
56 return r.reason === 'aborted' ? m('band.aborted', { d }) : r.reason === 'answer' ? m('band.answer', { d }) : m('band.error', { d })
57}
58
59type Segment = { text: string; color?: string }
60
61/** The counts after the headline, each only when it has something to say; a count may be several segments. */
62function counts(r: Receipt, flagUnverified: boolean): Segment[][] {
63 const parts: Segment[][] = []
64 if (r.files.length > 0) {
65 const { added, removed } = totals(r)
66 parts.push([{ text: `${m('band.edited', { n: r.files.length })} ` }, { text: `+${added}`, color: 'green' }, { text: ' ' }, { text: `−${removed}`, color: 'red' }])
67 }
68 if (r.commands > 0) parts.push([{ text: m('band.commands', { n: r.commands }) }])
69 if (r.failed.length > 0) parts.push([{ text: m('band.failed', { n: r.failed.length }), color: 'red' }])
70 if (r.reads > 0) parts.push([{ text: m('band.reads', { n: r.reads }) }])
71 if (r.agents > 0) parts.push([{ text: m('band.agents', { n: r.agents }) }])
72 if (r.warnings.length > 0) parts.push([{ text: `⚠ ${r.warnings.length}`, color: 'yellow' }])
73 if (flagUnverified && r.isUnverified) parts.push([{ text: m('band.unverified'), color: 'yellow' }])
74 return parts
75}
76
77const plain = (part: Segment[]) => part.map(s => s.text).join('')
78
79/** `/receipt` alone: the headline, then every file, failed command and warning. */
80async function listing($: EngineInterface, flagUnverified: boolean): Promise<string> {
81 const r = await read($, receipt)
82 if (!r) return m('cmd.none')
83 const lines = [[headline(r, await $.clock.now()), ...counts(r, flagUnverified).map(plain)].join(SEP)]
84 if (r.files.length > 0) {
85 lines.push('', m('list.files'))
86 for (const f of r.files) lines.push(` ${f.path} +${f.added} −${f.removed}${f.isNew ? ` ${m('list.new')}` : ''}`)
87 }
88 if (r.failed.length > 0) lines.push('', m('list.failed'), ...r.failed.map(c => ` ✗ ${c}`))
89 if (r.warnings.length > 0) lines.push('', m('list.warnings'), ...r.warnings.map(w => ` ⚠ ${w}`))
90 if (flagUnverified && r.isUnverified) lines.push('', `⚠ ${m('list.unverified')}`)
91 if ((r.steps ?? []).length > 0) lines.push('', m('list.replay'))
92 return lines.join('\n')
93}
94
95/** Opens the replay on the turn's first edit; false when the last turn changed no file. */
96async function openReplay($: EngineInterface): Promise<boolean> {
97 const r = await read($, receipt)
98 if (!r || (r.steps ?? []).length === 0) return false
99 await update($, replayAt, () => 0)
100 // A band Button pressed holds the keys: the band steps aside first, so the pane can take them.
101 await update($, isReplaying, () => true)
102 await $.clock.sleep(150)
103 try {
104 await $.ui.open({ id: PANE, title: m('replay.title'), focus: true, closeOnEscape: true })
105 } catch (err) {
106 await update($, isReplaying, () => false)
107 throw err
108 }
109 return true
110}
111
112export const register: Register = (on, options) => {
113 const config = readConfig(options)
114 isRowHidden = !config.isVisible
115 const rules = { repeatFailures: config.repeatFailures, flipFlops: config.flipFlops }
116
117 on('session.start', async ($, e, next) => {
118 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
119 const locale = await Promise.all([
120 $.env.get('LC_ALL').catch(() => undefined),
121 $.env.get('LC_MESSAGES').catch(() => undefined),
122 $.env.get('LANG').catch(() => undefined),
123 ])
124 setLang(resolveLanguage(config.language, settings.language, locale))
125 await $.command.register({ name: 'receipt', description: m('cmd.description'), argumentHint: '[replay|off|on]' })
126 await update($, hiddenSet, () => !config.isVisible)
127 return next(e)
128 })
129
130 // Only the main loop raises turn.start: a new receipt, and the last one leaves the band.
131 on('turn.start', async ($, e, next) => {
132 const now = await $.clock.now()
133 await update($, receipt, () => newReceipt(e.turnId, now))
134 await update($, watch, () => NO_WATCH)
135 await update($, isShown, () => false)
136 return next(e)
137 })
138
139 // Every call of the turn once it has run, its subagents' included; a refused call did nothing.
140 on('tool.call', async ($, e, next) => {
141 const ran = await next(e)
142 if (ran.deny !== undefined) return ran
143 const was = await read($, receipt)
144 if (!was || was.durationMs !== null) return ran
145 const { tool, tool_use_id: _, agentId, ...input } = e as unknown as Record<string, unknown> & { tool: string }
146 const call = { tool, input, result: ran.result, isError: ran.isError === true, isReadOnly: ran.isReadOnly === true }
147 const cwd = await $.session.cwd().catch(() => '')
148 let next_ = addCall(was, call, cwd, agentId === undefined)
149 if (agentId === undefined) {
150 const seen = watchCall(await read($, watch), call, rules, cwd)
151 await update($, watch, () => seen.watch)
152 if (seen.alert) {
153 const text = alertText(seen.alert)
154 next_ = { ...next_, warnings: [...next_.warnings, text] }
155 $.ui.toast(`⚠ ${text}`, { timeoutMs: 8000 })
156 }
157 }
158 await update($, receipt, () => next_)
159 return ran
160 })
161
162 on('turn.complete', async ($, e, next) => {
163 if (e.agentId !== undefined) return next(e)
164 const was = await read($, receipt)
165 if (was && was.durationMs === null) {
166 const done = finish(was, e.durationMs, e.reason)
167 await update($, receipt, () => done)
168 await update($, isShown, () => !isEmpty(done))
169 }
170 return next(e)
171 })
172
173 on('command.run', { command: 'receipt' }, async ($, e) => {
174 const arg = e.args.trim().toLowerCase()
175 if (arg === 'replay') {
176 if (!(await openReplay($))) return { text: m('cmd.noEdits') }
177 return { text: m('cmd.replaying', { n: ((await read($, receipt))?.steps ?? []).length }) }
178 }
179 if (arg !== 'off' && arg !== 'on') return { text: await listing($, config.flagUnverified) }
180 const was = await read($, isHidden)
181 const hidden = await update($, hiddenSet, () => switchArg(arg, was))
182 if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
183 return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
184 })
185
186 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
187 on('prompt.edit', async ($, e, next) => {
188 const box = await next(e)
189 const isOpen = isPickerOpen(box.text, box.cursor)
190 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
191 return box
192 })
193 on('prompt.submit', async ($, e, next) => {
194 if (await read($, isPicking)) await update($, isPicking, () => false)
195 return next(e)
196 })
197
198 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
199 const r = await read($, receipt)
200 if (e.props.hasSurvey || r === null || !(await read($, isShown)) || (await read($, isHidden)) || (await read($, isPicking))) return next(e)
201 if (await read($, isReplaying)) return next(e)
202 const ui = $.ui.resolve(e)
203 return stackAbove(ui, drawReceipt(ui, r, config.flagUnverified, () => void openReplay($)), await next(e))
204 })
205
206 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
207 const steps = (await read($, receipt))?.steps ?? []
208 const total = steps.length
209 const k = Math.max(0, Math.min(await read($, replayAt), total - 1))
210 const go = (to: number) => void update($, replayAt, () => Math.max(0, Math.min(to, total - 1)))
211 const close = () => void $.ui.close({ id: PANE })
212 const rows = Math.max(4, Math.min(30, e.props.scroll.bodyRows - PANE_CHROME))
213 return drawReplay($.ui.resolve(e), steps, k, rows, e.props.bodyColumns, { go, close })
214 })
215
216 // However the pane closes (its button, Escape, the person's close mark), the band comes back.
217 on('ui.close', async ($, e, next) => {
218 if (e.id === PANE) await update($, isReplaying, () => false)
219 return next(e)
220 })
221}
222
223const DIFF_COLOR: Record<string, string> = { '+': 'green', '-': 'red' }
224
225/** One edit: where it stands among the turn's, its file and counts, its diff, and the buttons that move. */
226function drawReplay(
227 ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>,
228 steps: Step[],
229 k: number,
230 rows: number,
231 columns: number,
232 act: { go: (to: number) => void; close: () => void },
233): RenderElement {
234 const { Box, Text, Button } = ui
235 const step = steps[k]
236 if (!step) return <Text dimColor>{m('cmd.noEdits')}</Text>
237 const shown = step.lines.slice(0, rows)
238 const more = step.lines.length - shown.length + step.more
239 // A strip of the steps, the one shown inverted, while they fit on a row.
240 const strip = steps.length > 1 && steps.length * 4 <= columns ? steps : []
241 return (
242 <Box flexDirection="column">
243 <Box flexDirection="row" columnGap={2}>
244 <Text bold color="magenta">{`▶ ${m('replay.step', { k: k + 1, n: steps.length })}`}</Text>
245 {strip.length > 0 ? (
246 <Text>
247 {strip.map((_, i) => (
248 <Text key={String(i)} inverse={i === k} dimColor={i !== k}>{` ${i + 1} `}</Text>
249 ))}
250 </Text>
251 ) : null}
252 </Box>
253 <Box flexDirection="row" columnGap={1}>
254 <Box flexShrink={1}>
255 <Text bold color="cyan" wrap="truncate-start">{step.path}</Text>
256 </Box>
257 <Box flexShrink={0}>
258 <Text>
259 <Text dimColor>{`${step.tool}${step.isNew ? ` · ${m('list.new')}` : ''} `}</Text>
260 <Text color="green">{`+${step.added}`}</Text>
261 <Text> </Text>
262 <Text color="red">{`−${step.removed}`}</Text>
263 </Text>
264 </Box>
265 </Box>
266 <Box flexDirection="column" marginTop={1}>
267 {shown.length === 0 ? <Text dimColor>{m('replay.empty')}</Text> : null}
268 {shown.map((line, i) =>
269 line.startsWith('@') ? (
270 <Text key={String(i)} dimColor>{`⋯ ${m('replay.line', { n: line.slice(1) })}`}</Text>
271 ) : (
272 <Text key={String(i)} color={DIFF_COLOR[line[0] ?? '']} dimColor={!DIFF_COLOR[line[0] ?? '']} wrap="truncate-end">
273 {`${line[0] ?? ' '} ${line.slice(1)}`}
274 </Text>
275 ),
276 )}
277 {more > 0 ? <Text dimColor>{m('replay.more', { n: more })}</Text> : null}
278 </Box>
279 <Box flexDirection="row" columnGap={2} marginTop={1}>
280 <Button key="prev" label={`◀ ${m('replay.prev')}`} hotkey="p" dimColor={k === 0} onPress={() => act.go(k - 1)} />
281 <Button key="next" label={`${m('replay.next')} ▶`} hotkey="n" autoFocus variant="primary" onPress={() => act.go(k + 1)} />
282 <Button key="close" label={m('replay.close')} hotkey="c" role="dismiss" onPress={act.close} />
283 </Box>
284 </Box>
285 )
286}
287
288/** One row: a mark for how the turn ended, the headline, the counts, then a button to replay the edits. */
289function drawReceipt(ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>, r: Receipt, flagUnverified: boolean, replay: () => void): RenderElement {
290 const { Box, Text, Button } = ui
291 const mark = r.reason === 'answer' ? { glyph: '✓', color: 'green' } : r.reason === 'aborted' ? { glyph: '◼', color: 'yellow' } : { glyph: '✗', color: 'red' }
292 return (
293 <Box flexDirection="row" columnGap={1}>
294 <Text color={mark.color} bold>{mark.glyph}</Text>
295 <Box flexGrow={1} flexShrink={1}>
296 <Text wrap="truncate-end">
297 <Text dimColor>{headline(r, 0)}</Text>
298 {counts(r, flagUnverified).map((p, i) => (
299 <Text key={String(i)}>
300 <Text dimColor>{SEP}</Text>
301 {p.map((seg, j) => (
302 <Text key={String(j)} color={seg.color}>{seg.text}</Text>
303 ))}
304 </Text>
305 ))}
306 </Text>
307 </Box>
308 {(r.steps ?? []).length > 0 ? (
309 <Box flexShrink={0}>
310 <Button key="replay" label={m('band.replay')} hotkey="r" dimColor onPress={replay} />
311 </Box>
312 ) : null}
313 </Box>
314 )
315}
316hooks/config.ts 27 lines1// receipt's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { count, flag, text } from './kit/options'
5
6export type Config = {
7 isVisible: boolean
8 /** The same call failing this many times in a row toasts; 0 off. */
9 repeatFailures: number
10 /** A file edited back to what it was this many times toasts; 0 off. */
11 flipFlops: number
12 /** Mark a turn that changed code after its last test, build or check. */
13 flagUnverified: boolean
14 /** `auto` or a language; the kit resolves it (kit/lang.ts). */
15 language: string
16}
17
18export function readConfig(options: PluginOptions): Config {
19 return {
20 isVisible: flag(options.visible, true),
21 repeatFailures: count(options.repeatFailures, 3, { min: 0, isInteger: true }),
22 flipFlops: count(options.flipFlops, 2, { min: 0, isInteger: true }),
23 flagUnverified: flag(options.flagUnverified, true),
24 language: text(options.language, 'auto'),
25 }
26}
27hooks/i18n.ts 397 lines1// receipt's messages; the language is resolved by the kit (kit/lang.ts).
2// Counts are written label first (`Commands 6`), so no language needs plural forms.
3import { createMessages } from './kit/lang'
4import type { Lang } from './kit/lang'
5
6export { parseLanguage, resolveLanguage } from './kit/lang'
7
8export type Key =
9 | 'cmd.description'
10 | 'cmd.hidden'
11 | 'cmd.shown'
12 | 'cmd.none'
13 | 'band.answer'
14 | 'band.aborted'
15 | 'band.error'
16 | 'band.running'
17 | 'band.edited'
18 | 'band.commands'
19 | 'band.failed'
20 | 'band.reads'
21 | 'band.agents'
22 | 'band.unverified'
23 | 'list.files'
24 | 'list.failed'
25 | 'list.warnings'
26 | 'list.new'
27 | 'list.unverified'
28 | 'band.replay'
29 | 'list.replay'
30 | 'cmd.noEdits'
31 | 'cmd.replaying'
32 | 'replay.title'
33 | 'replay.step'
34 | 'replay.line'
35 | 'replay.more'
36 | 'replay.empty'
37 | 'replay.prev'
38 | 'replay.next'
39 | 'replay.close'
40 | 'alert.repeat'
41 | 'alert.flip'
42
43export const MESSAGES: Record<Lang, Record<Key, string>> = {
44 en: {
45 'cmd.description': "What the last turn did: files changed, commands, failures, warnings; replay steps through the edits; off / on hides or shows the band (kept across sessions)",
46 'cmd.hidden': 'Turn receipt hidden',
47 'cmd.shown': 'Turn receipt shown',
48 'cmd.none': 'No turn yet: the receipt appears once a turn has used a tool',
49 'band.answer': 'Last turn {d}',
50 'band.aborted': 'Interrupted after {d}',
51 'band.error': 'Ended by an error after {d}',
52 'band.running': 'Turn running, {d} so far',
53 'band.edited': 'Edited {n}',
54 'band.commands': 'Commands {n}',
55 'band.failed': '{n} failed',
56 'band.reads': 'Reads {n}',
57 'band.agents': 'Agents {n}',
58 'band.unverified': 'unverified',
59 'list.files': 'Files changed',
60 'list.failed': 'Failed commands',
61 'list.warnings': 'Warnings',
62 'list.new': 'new',
63 'list.unverified': 'Code changed after the last test, build or check run, or none ran.',
64 'band.replay': 'Replay',
65 'list.replay': "/receipt replay steps through each edit's diff.",
66 'cmd.noEdits': 'The last turn changed no file',
67 'cmd.replaying': 'Replaying {n} edits',
68 'replay.title': 'Replay',
69 'replay.step': 'edit {k} of {n}',
70 'replay.line': 'line {n}',
71 'replay.more': '… {n} more lines',
72 'replay.empty': '(no diff to show)',
73 'replay.prev': 'Prev',
74 'replay.next': 'Next',
75 'replay.close': 'Close',
76 'alert.repeat': 'Failed {n} times in a row: {label}',
77 'alert.flip': '{path}: edited back and forth {n} times',
78 },
79 'zh-Hans': {
80 'cmd.description': '上一轮做了什么:改动的文件、命令、失败与预警;replay 逐处回放改动;off / on 隐藏或显示回执(跨会话保持)',
81 'cmd.hidden': '回合回执已隐藏',
82 'cmd.shown': '回合回执已显示',
83 'cmd.none': '还没有回合:一轮用过工具后会出现回执',
84 'band.answer': '上一轮 {d}',
85 'band.aborted': '已中断,用时 {d}',
86 'band.error': '因出错结束,用时 {d}',
87 'band.running': '本轮进行中,已 {d}',
88 'band.edited': '改动 {n} 个文件',
89 'band.commands': '命令 {n}',
90 'band.failed': '{n} 失败',
91 'band.reads': '读取 {n}',
92 'band.agents': '子代理 {n}',
93 'band.unverified': '未验证',
94 'list.files': '改动的文件',
95 'list.failed': '失败的命令',
96 'list.warnings': '预警',
97 'list.new': '新建',
98 'list.unverified': '最后一次改代码之后没有跑过测试、构建或检查。',
99 'band.replay': '回放',
100 'list.replay': '/receipt replay 可逐处查看每次改动的 diff。',
101 'cmd.noEdits': '上一轮没有改动文件',
102 'cmd.replaying': '回放 {n} 处改动',
103 'replay.title': '改动回放',
104 'replay.step': '第 {k}/{n} 处',
105 'replay.line': '第 {n} 行',
106 'replay.more': '… 还有 {n} 行',
107 'replay.empty': '(没有可显示的 diff)',
108 'replay.prev': '上一处',
109 'replay.next': '下一处',
110 'replay.close': '关闭',
111 'alert.repeat': '同一调用已连续失败 {n} 次:{label}',
112 'alert.flip': '{path}:来回改了 {n} 次',
113 },
114 'zh-Hant': {
115 'cmd.description': '上一輪做了什麼:改動的檔案、命令、失敗與預警;replay 逐處回放改動;off / on 隱藏或顯示回執(跨工作階段保留)',
116 'cmd.hidden': '回合回執已隱藏',
117 'cmd.shown': '回合回執已顯示',
118 'cmd.none': '還沒有回合:一輪用過工具後會出現回執',
119 'band.answer': '上一輪 {d}',
120 'band.aborted': '已中斷,用時 {d}',
121 'band.error': '因出錯結束,用時 {d}',
122 'band.running': '本輪進行中,已 {d}',
123 'band.edited': '改動 {n} 個檔案',
124 'band.commands': '命令 {n}',
125 'band.failed': '{n} 失敗',
126 'band.reads': '讀取 {n}',
127 'band.agents': '子代理 {n}',
128 'band.unverified': '未驗證',
129 'list.files': '改動的檔案',
130 'list.failed': '失敗的命令',
131 'list.warnings': '預警',
132 'list.new': '新建',
133 'list.unverified': '最後一次改程式碼之後沒有跑過測試、建置或檢查。',
134 'band.replay': '回放',
135 'list.replay': '/receipt replay 可逐處查看每次改動的 diff。',
136 'cmd.noEdits': '上一輪沒有改動檔案',
137 'cmd.replaying': '回放 {n} 處改動',
138 'replay.title': '改動回放',
139 'replay.step': '第 {k}/{n} 處',
140 'replay.line': '第 {n} 行',
141 'replay.more': '… 還有 {n} 行',
142 'replay.empty': '(沒有可顯示的 diff)',
143 'replay.prev': '上一處',
144 'replay.next': '下一處',
145 'replay.close': '關閉',
146 'alert.repeat': '同一呼叫已連續失敗 {n} 次:{label}',
147 'alert.flip': '{path}:來回改了 {n} 次',
148 },
149 ja: {
150 'cmd.description': '直前のターンで何をしたか:変更ファイル、コマンド、失敗、警告。replay で変更を順に再生。off / on で表示を切り替え(セッションをまたいで保持)',
151 'cmd.hidden': 'ターンのレシートを非表示にしました',
152 'cmd.shown': 'ターンのレシートを表示しました',
153 'cmd.none': 'まだターンがありません。ツールを使ったターンの後に表示されます',
154 'band.answer': '直前のターン {d}',
155 'band.aborted': '中断({d})',
156 'band.error': 'エラーで終了({d})',
157 'band.running': 'ターン実行中、{d} 経過',
158 'band.edited': '変更 {n} ファイル',
159 'band.commands': 'コマンド {n}',
160 'band.failed': '失敗 {n}',
161 'band.reads': '読み取り {n}',
162 'band.agents': 'サブエージェント {n}',
163 'band.unverified': '未検証',
164 'list.files': '変更したファイル',
165 'list.failed': '失敗したコマンド',
166 'list.warnings': '警告',
167 'list.new': '新規',
168 'list.unverified': '最後のコード変更のあと、テスト・ビルド・チェックを実行していません。',
169 'band.replay': '再生',
170 'list.replay': '/receipt replay で変更ごとの diff を順に確認できます。',
171 'cmd.noEdits': '直前のターンはファイルを変更していません',
172 'cmd.replaying': '{n} 件の変更を再生',
173 'replay.title': '変更の再生',
174 'replay.step': '{k}/{n} 件目',
175 'replay.line': '{n} 行目',
176 'replay.more': '… ほか {n} 行',
177 'replay.empty': '(表示できる diff はありません)',
178 'replay.prev': '前へ',
179 'replay.next': '次へ',
180 'replay.close': '閉じる',
181 'alert.repeat': '同じ呼び出しが {n} 回続けて失敗:{label}',
182 'alert.flip': '{path}:{n} 回行ったり来たりの編集',
183 },
184 ko: {
185 'cmd.description': '직전 턴에서 한 일: 변경 파일, 명령, 실패, 경고. replay로 변경 다시 보기. off / on으로 숨기기·표시(세션 간 유지)',
186 'cmd.hidden': '턴 영수증을 숨겼습니다',
187 'cmd.shown': '턴 영수증을 표시했습니다',
188 'cmd.none': '아직 턴이 없습니다. 도구를 쓴 턴이 끝나면 나타납니다',
189 'band.answer': '직전 턴 {d}',
190 'band.aborted': '중단됨 ({d})',
191 'band.error': '오류로 종료 ({d})',
192 'band.running': '턴 진행 중, {d} 경과',
193 'band.edited': '파일 변경 {n}',
194 'band.commands': '명령 {n}',
195 'band.failed': '실패 {n}',
196 'band.reads': '읽기 {n}',
197 'band.agents': '서브에이전트 {n}',
198 'band.unverified': '미검증',
199 'list.files': '변경한 파일',
200 'list.failed': '실패한 명령',
201 'list.warnings': '경고',
202 'list.new': '새 파일',
203 'list.unverified': '마지막 코드 변경 이후 테스트·빌드·검사를 실행하지 않았습니다.',
204 'band.replay': '다시 보기',
205 'list.replay': '/receipt replay로 변경마다 diff를 차례로 볼 수 있습니다.',
206 'cmd.noEdits': '직전 턴에서 변경된 파일이 없습니다',
207 'cmd.replaying': '변경 {n}건 다시 보기',
208 'replay.title': '변경 다시 보기',
209 'replay.step': '{k}/{n}번째',
210 'replay.line': '{n}번째 줄',
211 'replay.more': '… {n}줄 더',
212 'replay.empty': '(표시할 diff 없음)',
213 'replay.prev': '이전',
214 'replay.next': '다음',
215 'replay.close': '닫기',
216 'alert.repeat': '같은 호출이 {n}번 연속 실패: {label}',
217 'alert.flip': '{path}: {n}번 되돌렸다 고쳤다 반복',
218 },
219 es: {
220 'cmd.description': 'Qué hizo el último turno: archivos, comandos, fallos y avisos; replay recorre las ediciones; off / on oculta o muestra la barra (se mantiene entre sesiones)',
221 'cmd.hidden': 'Resumen del turno oculto',
222 'cmd.shown': 'Resumen del turno visible',
223 'cmd.none': 'Aún no hay turnos: el resumen aparece tras un turno que use herramientas',
224 'band.answer': 'Último turno {d}',
225 'band.aborted': 'Interrumpido tras {d}',
226 'band.error': 'Terminó por un error tras {d}',
227 'band.running': 'Turno en curso, {d} hasta ahora',
228 'band.edited': 'Archivos editados: {n}',
229 'band.commands': 'Comandos: {n}',
230 'band.failed': 'fallidos: {n}',
231 'band.reads': 'Lecturas: {n}',
232 'band.agents': 'Subagentes: {n}',
233 'band.unverified': 'sin verificar',
234 'list.files': 'Archivos cambiados',
235 'list.failed': 'Comandos fallidos',
236 'list.warnings': 'Avisos',
237 'list.new': 'nuevo',
238 'list.unverified': 'No se ejecutaron pruebas, compilación ni comprobaciones tras el último cambio de código.',
239 'band.replay': 'Repetir',
240 'list.replay': '/receipt replay recorre el diff de cada edición.',
241 'cmd.noEdits': 'El último turno no cambió archivos',
242 'cmd.replaying': 'Repitiendo {n} ediciones',
243 'replay.title': 'Repetición',
244 'replay.step': 'edición {k} de {n}',
245 'replay.line': 'línea {n}',
246 'replay.more': '… {n} líneas más',
247 'replay.empty': '(sin diff que mostrar)',
248 'replay.prev': 'Anterior',
249 'replay.next': 'Siguiente',
250 'replay.close': 'Cerrar',
251 'alert.repeat': 'Falló {n} veces seguidas: {label}',
252 'alert.flip': '{path}: editado de ida y vuelta {n} veces',
253 },
254 fr: {
255 'cmd.description': "Ce qu'a fait le dernier tour : fichiers, commandes, échecs, alertes ; replay rejoue les modifications ; off / on masque ou affiche la barre (conservé entre les sessions)",
256 'cmd.hidden': 'Récapitulatif du tour masqué',
257 'cmd.shown': 'Récapitulatif du tour affiché',
258 'cmd.none': "Pas encore de tour : le récapitulatif apparaît après un tour qui utilise un outil",
259 'band.answer': 'Dernier tour {d}',
260 'band.aborted': 'Interrompu après {d}',
261 'band.error': 'Terminé par une erreur après {d}',
262 'band.running': 'Tour en cours, {d} écoulées',
263 'band.edited': 'Fichiers modifiés : {n}',
264 'band.commands': 'Commandes : {n}',
265 'band.failed': 'échecs : {n}',
266 'band.reads': 'Lectures : {n}',
267 'band.agents': 'Sous-agents : {n}',
268 'band.unverified': 'non vérifié',
269 'list.files': 'Fichiers modifiés',
270 'list.failed': 'Commandes en échec',
271 'list.warnings': 'Alertes',
272 'list.new': 'nouveau',
273 'list.unverified': 'Aucun test, build ni vérification lancé après la dernière modification du code.',
274 'band.replay': 'Rejouer',
275 'list.replay': '/receipt replay parcourt le diff de chaque modification.',
276 'cmd.noEdits': "Le dernier tour n'a modifié aucun fichier",
277 'cmd.replaying': 'Relecture de {n} modifications',
278 'replay.title': 'Relecture',
279 'replay.step': 'modification {k} sur {n}',
280 'replay.line': 'ligne {n}',
281 'replay.more': '… {n} lignes de plus',
282 'replay.empty': '(aucun diff à afficher)',
283 'replay.prev': 'Précédent',
284 'replay.next': 'Suivant',
285 'replay.close': 'Fermer',
286 'alert.repeat': 'Échec {n} fois de suite : {label}',
287 'alert.flip': '{path} : modifié dans un sens puis dans l’autre {n} fois',
288 },
289 de: {
290 'cmd.description': 'Was der letzte Durchlauf getan hat: Dateien, Befehle, Fehlschläge, Warnungen; replay spielt die Änderungen ab; off / on blendet die Leiste aus oder ein (bleibt über Sitzungen erhalten)',
291 'cmd.hidden': 'Durchlauf-Beleg ausgeblendet',
292 'cmd.shown': 'Durchlauf-Beleg eingeblendet',
293 'cmd.none': 'Noch kein Durchlauf: Der Beleg erscheint nach einem Durchlauf mit Werkzeugen',
294 'band.answer': 'Letzter Durchlauf {d}',
295 'band.aborted': 'Abgebrochen nach {d}',
296 'band.error': 'Durch einen Fehler beendet nach {d}',
297 'band.running': 'Durchlauf läuft, bisher {d}',
298 'band.edited': 'Geänderte Dateien: {n}',
299 'band.commands': 'Befehle: {n}',
300 'band.failed': 'fehlgeschlagen: {n}',
301 'band.reads': 'Gelesen: {n}',
302 'band.agents': 'Subagenten: {n}',
303 'band.unverified': 'ungeprüft',
304 'list.files': 'Geänderte Dateien',
305 'list.failed': 'Fehlgeschlagene Befehle',
306 'list.warnings': 'Warnungen',
307 'list.new': 'neu',
308 'list.unverified': 'Nach der letzten Codeänderung lief kein Test, Build oder Check.',
309 'band.replay': 'Abspielen',
310 'list.replay': '/receipt replay geht den Diff jeder Änderung durch.',
311 'cmd.noEdits': 'Die letzte Runde hat keine Datei geändert',
312 'cmd.replaying': '{n} Änderungen abspielen',
313 'replay.title': 'Wiedergabe',
314 'replay.step': 'Änderung {k} von {n}',
315 'replay.line': 'Zeile {n}',
316 'replay.more': '… {n} weitere Zeilen',
317 'replay.empty': '(kein Diff zum Anzeigen)',
318 'replay.prev': 'Zurück',
319 'replay.next': 'Weiter',
320 'replay.close': 'Schließen',
321 'alert.repeat': '{n}-mal hintereinander fehlgeschlagen: {label}',
322 'alert.flip': '{path}: {n}-mal hin und zurück geändert',
323 },
324 'pt-BR': {
325 'cmd.description': 'O que o último turno fez: arquivos, comandos, falhas e avisos; replay percorre as edições; off / on oculta ou mostra a barra (mantido entre sessões)',
326 'cmd.hidden': 'Resumo do turno oculto',
327 'cmd.shown': 'Resumo do turno visível',
328 'cmd.none': 'Ainda não há turnos: o resumo aparece após um turno que use ferramentas',
329 'band.answer': 'Último turno {d}',
330 'band.aborted': 'Interrompido após {d}',
331 'band.error': 'Encerrado por um erro após {d}',
332 'band.running': 'Turno em andamento, {d} até agora',
333 'band.edited': 'Arquivos editados: {n}',
334 'band.commands': 'Comandos: {n}',
335 'band.failed': 'falhas: {n}',
336 'band.reads': 'Leituras: {n}',
337 'band.agents': 'Subagentes: {n}',
338 'band.unverified': 'não verificado',
339 'list.files': 'Arquivos alterados',
340 'list.failed': 'Comandos com falha',
341 'list.warnings': 'Avisos',
342 'list.new': 'novo',
343 'list.unverified': 'Nenhum teste, build ou verificação rodou depois da última mudança no código.',
344 'band.replay': 'Rever',
345 'list.replay': '/receipt replay percorre o diff de cada edição.',
346 'cmd.noEdits': 'O último turno não alterou arquivos',
347 'cmd.replaying': 'Revendo {n} edições',
348 'replay.title': 'Revisão',
349 'replay.step': 'edição {k} de {n}',
350 'replay.line': 'linha {n}',
351 'replay.more': '… mais {n} linhas',
352 'replay.empty': '(nenhum diff para mostrar)',
353 'replay.prev': 'Anterior',
354 'replay.next': 'Próxima',
355 'replay.close': 'Fechar',
356 'alert.repeat': 'Falhou {n} vezes seguidas: {label}',
357 'alert.flip': '{path}: editado de um lado para o outro {n} vezes',
358 },
359 ru: {
360 'cmd.description': 'Что сделал последний ход: файлы, команды, сбои, предупреждения; replay показывает правки по очереди; off / on скрывает или показывает панель (сохраняется между сессиями)',
361 'cmd.hidden': 'Сводка хода скрыта',
362 'cmd.shown': 'Сводка хода показана',
363 'cmd.none': 'Ходов пока нет: сводка появится после хода с инструментами',
364 'band.answer': 'Последний ход {d}',
365 'band.aborted': 'Прерван через {d}',
366 'band.error': 'Завершён ошибкой через {d}',
367 'band.running': 'Ход идёт, уже {d}',
368 'band.edited': 'Изменено файлов: {n}',
369 'band.commands': 'Команды: {n}',
370 'band.failed': 'сбоев: {n}',
371 'band.reads': 'Чтений: {n}',
372 'band.agents': 'Субагенты: {n}',
373 'band.unverified': 'не проверено',
374 'list.files': 'Изменённые файлы',
375 'list.failed': 'Команды со сбоем',
376 'list.warnings': 'Предупреждения',
377 'list.new': 'новый',
378 'list.unverified': 'После последнего изменения кода не запускались тесты, сборка или проверки.',
379 'band.replay': 'Повтор',
380 'list.replay': '/receipt replay показывает diff каждой правки по очереди.',
381 'cmd.noEdits': 'Прошлый ход не менял файлы',
382 'cmd.replaying': 'Повтор {n} правок',
383 'replay.title': 'Повтор правок',
384 'replay.step': 'правка {k} из {n}',
385 'replay.line': 'строка {n}',
386 'replay.more': '… ещё {n} строк',
387 'replay.empty': '(нет diff для показа)',
388 'replay.prev': 'Назад',
389 'replay.next': 'Далее',
390 'replay.close': 'Закрыть',
391 'alert.repeat': 'Сбой {n} раз подряд: {label}',
392 'alert.flip': '{path}: правки туда и обратно {n} раз',
393 },
394}
395
396export const { m, setLang } = createMessages(MESSAGES)
397hooks/kit/band.tsx 31 lines1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16 const before = text.slice(0, cursor)
17 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21 const { Box } = ui
22 return (
23 <Box flexDirection="column">
24 <Box flexDirection="column" paddingLeft={2}>
25 {mine}
26 </Box>
27 {below}
28 </Box>
29 )
30}
31hooks/kit/lang.ts 68 lines1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16 en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17 zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18 simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19 zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20 繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21 ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22 ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23 es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24 fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25 de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26 pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27 ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34 if (typeof text !== 'string' || !text.trim()) return null
35 const bare = text.split('.')[0]!.split('@')[0]!
36 const named = NAMES[fold(bare)]
37 if (named) return named
38 // A tag with a region or script this table does not list: its first subtag.
39 const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40 if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41 return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46 if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47 return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58 let current: Lang = 'en'
59 return {
60 m: (key: K, params: Params = {}, lang: Lang = current): string =>
61 (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62 setLang: (lang: Lang): void => {
63 current = lang
64 },
65 lang: (): Lang => current,
66 }
67}
68hooks/kit/prefs.ts 60 lines1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15 kept: (key: string) => Promise<unknown>
16 forget: (key: string) => Promise<void>
17 /** `$.config.set` on `<plugin>.<field>`. */
18 write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23 const result = await prefs.write(field, value).catch(() => null)
24 return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32 const rows: Record<string, ConfigValue> = {}
33 for (const [key, move] of Object.entries(moves)) {
34 const kept = await prefs.kept(key)
35 const target = kept === undefined ? null : move(kept)
36 if (target) rows[target[0]] = target[1]
37 }
38 return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48 for (const [key, move] of Object.entries(moves)) {
49 const kept = await prefs.kept(key)
50 if (kept === undefined) continue
51 const target = move(kept)
52 if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53 }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58 return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60hooks/ledger.ts 228 lines1// What a turn did, from the calls it made once they have run, and the loop
2// rules over the main thread's calls. Pure: register.tsx hands in each call.
3import type { FileChange, Receipt, Step, Watch } from '../types'
4
5/** One call as it ran: the tool, its arguments, what it answered; `isReadOnly` when the tool held it read-only. */
6export type Call = { tool: string; input: Record<string, unknown>; result: unknown; isError: boolean; isReadOnly?: boolean }
7
8/** A loop the rules caught: a call failing again and again, or a file edited back and forth. */
9export type Alert = { kind: 'repeat'; label: string; n: number } | { kind: 'flip'; path: string; n: number }
10
11/** The thresholds; 0 turns a rule off. */
12export type Rules = { repeatFailures: number; flipFlops: number }
13
14const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
15const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
16const SHELLS = new Set(['Bash', 'PowerShell'])
17const AGENTS = new Set(['Agent', 'Task'])
18// A command that checks the code, at the head of the line or after `&&`, `;`, `|`, `(`,
19// past env assignments and a runner (`uv run`, `npx`, `bash`): a test runner, a type
20// checker or linter, a build, or a project's own check script.
21const CHECK = new RegExp(
22 String.raw`(?:^|[;&|(]\s*)(?:\S+=\S*\s+)*(?:(?:bash|sh|zsh|time|npx|bunx|uv\s+run|poetry\s+run|pnpm\s+exec|bundle\s+exec)\s+)*(?:` +
23 [
24 String.raw`(?:npm|pnpm|yarn|bun|deno)\s+(?:run\s+)?(?:test|build|lint|check|typecheck|tsc)\b`,
25 String.raw`(?:jest|vitest|mocha|pytest|rspec|phpunit|tox|nox|tsc|eslint|biome|ruff|mypy|pyright|rubocop|golangci-lint|shellcheck|make|playwright\s+test)\b`,
26 String.raw`python3?\s+-m\s+(?:pytest|unittest|mypy)\b`,
27 String.raw`go\s+(?:test|build|vet)\b`,
28 String.raw`cargo\s+(?:test|check|build|clippy)\b`,
29 String.raw`(?:swift|dotnet|mix|mvn|gradle|\.\/gradlew|zig|xcodebuild)\s+(?:test|build)\b`,
30 String.raw`claude\s+plugin\s+(?:test|validate)\b`,
31 String.raw`\S*(?:check|test|verify)\S*\.sh\b`,
32 ].join('|') +
33 ')',
34)
35// Files whose edits nothing runs to check: prose.
36const PROSE = /\.(?:md|mdx|markdown|txt|rst|adoc)$/i
37
38// Edits kept per file for the back-and-forth rule.
39const KEEP_EDITS = 20
40// Cells a call's label keeps, in a toast and in /receipt.
41const LABEL_CELLS = 80
42// What /receipt replay keeps of a turn: its first edits, each diff's first lines.
43export const KEEP_STEPS = 50
44const KEEP_LINES = 120
45
46const str = (v: unknown) => (typeof v === 'string' ? v : '')
47const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
48const clip = (s: string) => (s.length > LABEL_CELLS ? `${s.slice(0, LABEL_CELLS - 1)}…` : s)
49
50export function newReceipt(turnId: string, now: number): Receipt {
51 return {
52 turnId,
53 startedAt: now,
54 durationMs: null,
55 reason: null,
56 files: [],
57 commands: 0,
58 failed: [],
59 errors: 0,
60 reads: 0,
61 agents: 0,
62 warnings: [],
63 checks: 0,
64 isUnverified: false,
65 steps: [],
66 }
67}
68
69export const NO_WATCH: Watch = { failKey: null, failCount: 0, edits: {}, flips: {} }
70
71/** `path` relative to `cwd` when it lies under it. */
72export function relative(path: string, cwd: string): string {
73 const base = cwd.replace(/\/+$/, '')
74 return base && path.startsWith(base + '/') ? path.slice(base.length + 1) : path
75}
76
77/** A short label for a call: the command for a shell, else the tool and its file. */
78export function labelOf(call: Pick<Call, 'tool' | 'input'>, cwd: string): string {
79 const { tool, input } = call
80 if (SHELLS.has(tool)) return clip(oneLine(str(input.command)))
81 const file = str(input.file_path) || str(input.notebook_path) || str(input.path)
82 return clip(file ? `${tool}: ${relative(file, cwd)}` : tool)
83}
84
85/** The lines an edit added and removed: git's own count, else the patch's, else a new file's length. */
86export function changeOf(call: Call, cwd: string): FileChange | null {
87 if (!EDITS.has(call.tool) || call.isError) return null
88 const path = str(call.input.file_path) || str(call.input.notebook_path)
89 if (!path) return null
90 const result = (call.result ?? {}) as Record<string, unknown>
91 const isNew = result.type === 'create'
92 const git = result.gitDiff as { additions?: unknown; deletions?: unknown } | undefined
93 if (typeof git?.additions === 'number' && typeof git.deletions === 'number') {
94 return { path: relative(path, cwd), added: git.additions, removed: git.deletions, isNew }
95 }
96 let added = 0
97 let removed = 0
98 for (const hunk of Array.isArray(result.structuredPatch) ? (result.structuredPatch as { lines?: unknown }[]) : []) {
99 for (const line of Array.isArray(hunk.lines) ? hunk.lines : []) {
100 if (typeof line !== 'string') continue
101 if (line.startsWith('+')) added++
102 else if (line.startsWith('-')) removed++
103 }
104 }
105 if (added === 0 && removed === 0 && isNew) added = str(result.content).split('\n').length
106 return { path: relative(path, cwd), added, removed, isNew }
107}
108
109/**
110 * The edit's diff for `/receipt replay`, from the hunks its result carried
111 * (`structuredPatch`), else a new file's content.
112 */
113export function stepOf(call: Call, change: FileChange): Step {
114 const result = (call.result ?? {}) as Record<string, unknown>
115 const lines: string[] = []
116 for (const hunk of Array.isArray(result.structuredPatch) ? (result.structuredPatch as Record<string, unknown>[]) : []) {
117 const at = typeof hunk.newStart === 'number' ? hunk.newStart : typeof hunk.oldStart === 'number' ? hunk.oldStart : 0
118 lines.push(`@${at}`)
119 for (const line of Array.isArray(hunk.lines) ? hunk.lines : []) if (typeof line === 'string') lines.push(line)
120 }
121 if (lines.length === 0 && change.isNew && str(result.content)) {
122 lines.push('@1', ...str(result.content).replace(/\n$/, '').split('\n').map(l => `+${l}`))
123 }
124 return { ...change, tool: call.tool, lines: lines.slice(0, KEEP_LINES), more: Math.max(0, lines.length - KEEP_LINES) }
125}
126
127/** Whether a shell command checks the code: a test, build, lint or type check. */
128export function isCheck(command: string): boolean {
129 return CHECK.test(command)
130}
131
132/** The receipt with one more call counted. */
133export function addCall(receipt: Receipt, call: Call, cwd: string, isMain: boolean): Receipt {
134 const next = { ...receipt }
135 if (call.isError) next.errors++
136 const change = changeOf(call, cwd)
137 if (change) {
138 const had = receipt.files.find(f => f.path === change.path)
139 next.files = had
140 ? receipt.files.map(f => (f === had ? { ...f, added: f.added + change.added, removed: f.removed + change.removed } : f))
141 : [...receipt.files, change]
142 if (!PROSE.test(change.path)) next.isUnverified = true
143 const steps = receipt.steps ?? []
144 if (steps.length < KEEP_STEPS) next.steps = [...steps, stepOf(call, change)]
145 } else if (SHELLS.has(call.tool)) {
146 next.commands++
147 if (call.isError) next.failed = [...receipt.failed, labelOf(call, cwd)]
148 // A check that ran counts whatever it found: its failure is on the receipt already.
149 if (isCheck(str(call.input.command))) {
150 next.checks = (receipt.checks ?? 0) + 1
151 next.isUnverified = false
152 }
153 } else if (READS.has(call.tool)) {
154 next.reads++
155 } else if (AGENTS.has(call.tool) && isMain) {
156 next.agents++
157 }
158 return next
159}
160
161export function finish(receipt: Receipt, durationMs: number, reason: string): Receipt {
162 return { ...receipt, durationMs, reason }
163}
164
165/** Lines added and removed across the turn's files. */
166export function totals(receipt: Receipt): { added: number; removed: number } {
167 return receipt.files.reduce((t, f) => ({ added: t.added + f.added, removed: t.removed + f.removed }), { added: 0, removed: 0 })
168}
169
170/** Whether the turn did anything worth a receipt: a chat-only turn has none. */
171export function isEmpty(receipt: Receipt): boolean {
172 return receipt.files.length === 0 && receipt.commands === 0 && receipt.reads === 0 && receipt.agents === 0 && receipt.errors === 0
173}
174
175// What identifies a call for the repeat rule: the tool and its arguments.
176function keyOf(call: Call): string {
177 if (SHELLS.has(call.tool)) return `${call.tool}:${oneLine(str(call.input.command))}`
178 const { description: _, ...rest } = call.input
179 return `${call.tool}:${JSON.stringify(rest)}`
180}
181
182/**
183 * The main thread's watch after one more call, and the alert it raises, if any.
184 *
185 * Repeat: the same call failing `repeatFailures` times in a row with nothing
186 * changed in between: no edit, no shell command that was not read-only
187 * (re-running a test after `sed -i` or an edit is work, not a loop).
188 * Back and forth: an edit that puts back exactly what an earlier one took out
189 * of the same file, the `flipFlops`th time.
190 */
191export function watchCall(watch: Watch, call: Call, rules: Rules, cwd: string): { watch: Watch; alert: Alert | null } {
192 const edited = EDITS.has(call.tool) && !call.isError
193 const changed = edited || (SHELLS.has(call.tool) && !call.isError && call.isReadOnly !== true)
194 let next: Watch = watch
195 let alert: Alert | null = null
196
197 if (call.isError) {
198 const key = keyOf(call)
199 const failCount = watch.failKey === key ? watch.failCount + 1 : 1
200 next = { ...next, failKey: key, failCount }
201 if (rules.repeatFailures > 0 && failCount === rules.repeatFailures) alert = { kind: 'repeat', label: labelOf(call, cwd), n: failCount }
202 } else if (changed || keyOf(call) === watch.failKey) {
203 next = { ...next, failKey: null, failCount: 0 }
204 }
205
206 if (edited && (call.tool === 'Edit' || call.tool === 'MultiEdit')) {
207 const path = relative(str(call.input.file_path), cwd)
208 const pairs =
209 call.tool === 'Edit'
210 ? [{ from: str(call.input.old_string), to: str(call.input.new_string) }]
211 : (Array.isArray(call.input.edits) ? (call.input.edits as Record<string, unknown>[]) : []).map(e => ({ from: str(e.old_string), to: str(e.new_string) }))
212 const before = watch.edits[path] ?? []
213 const isFlip = pairs.some(p => p.from !== p.to && before.some(b => b.from === p.to && b.to === p.from))
214 const flips = isFlip ? (watch.flips[path] ?? 0) + 1 : (watch.flips[path] ?? 0)
215 next = { ...next, edits: { ...watch.edits, [path]: [...before, ...pairs].slice(-KEEP_EDITS) }, flips: { ...watch.flips, [path]: flips } }
216 if (isFlip && rules.flipFlops > 0 && flips === rules.flipFlops) alert = { kind: 'flip', path, n: flips }
217 }
218 return { watch: next, alert }
219}
220
221/** 42s, 3m 12s, 1h 05m. */
222export function formatDuration(ms: number): string {
223 const sec = Math.max(0, Math.round(ms / 1000))
224 if (sec < 60) return `${sec}s`
225 if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
226 return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`
227}
228hooks/kit/options.ts 31 lines1// Generated from claude-code/kit/options.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// Readers for `register`'s options: each takes the raw value and the field's
3// default, so a mod's `readConfig` is one typed line per `userConfig` field.
4// The engine has validated the type already; these hold the ranges.
5
6export function text(value: unknown, fallback = ''): string {
7 return typeof value === 'string' ? value.trim() : fallback
8}
9
10export function flag(value: unknown, fallback: boolean): boolean {
11 return typeof value === 'boolean' ? value : fallback
12}
13
14/** A finite number, clamped to `min` / `max` and floored with `isInteger`. */
15export function count(
16 value: unknown,
17 fallback: number,
18 range: { min?: number; max?: number; isInteger?: boolean } = {},
19): number {
20 let n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
21 if (range.isInteger) n = Math.floor(n)
22 if (range.min !== undefined) n = Math.max(range.min, n)
23 if (range.max !== undefined) n = Math.min(range.max, n)
24 return n
25}
26
27/** One of `values`, else the fallback. */
28export function oneOf<const T extends string>(value: unknown, values: readonly T[], fallback: T): T {
29 return values.find(v => v === value) ?? fallback
30}
31types/index.d.ts 55 lines1/** One file a turn changed: the lines it gained and lost, and whether the turn made it. */
2export type FileChange = { path: string; added: number; removed: number; isNew: boolean }
3
4/**
5 * One edit as `/receipt replay` steps through it: the file, the tool, what it
6 * added and removed, and its diff as the edit's result carried it: lines that
7 * start `+`, `-` or a space, and `@<n>` where a hunk starts at line n.
8 * `more` counts the diff's lines past what was kept.
9 */
10export type Step = { path: string; tool: string; isNew: boolean; added: number; removed: number; lines: string[]; more: number }
11
12/**
13 * What one main-thread turn did, from its tool calls (its subagents' included):
14 * the files it changed, the commands it ran and those that failed, the reads,
15 * the subagents it started, the loops it was warned about, and whether code
16 * changed after the last command that checks it (a test, build, lint or type check).
17 * `durationMs` and `reason` are set when the turn ends.
18 */
19export type Receipt = {
20 turnId: string
21 startedAt: number
22 durationMs: number | null
23 reason: string | null
24 files: FileChange[]
25 commands: number
26 failed: string[]
27 errors: number
28 reads: number
29 agents: number
30 warnings: string[]
31 /** Commands that check the code: tests, builds, linters, type checkers. */
32 checks: number
33 /** A code file (not prose) changed after the last check, or with none run. */
34 isUnverified: boolean
35 /** Each edit in order, for `/receipt replay`; absent in a receipt kept from before 0.2. */
36 steps?: Step[]
37}
38
39/** The main thread's recent calls, as the loop rules read them. */
40export type Watch = {
41 /** The last call that failed and how many times in a row it has, no file changed between. */
42 failKey: string | null
43 failCount: number
44 /** Each file's recent edits, oldest first. */
45 edits: Record<string, { from: string; to: string }[]>
46 /** Each file's edits that put back what an earlier edit took out. */
47 flips: Record<string, number>
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 receipt: { receipt: Receipt | null; watch: Watch; isHidden: boolean | null; isPicking: boolean; isShown: boolean; replayAt: number; isReplaying: boolean }
53 }
54}
55