離開一段時間回來時,對話旁列出離開期間發生了什麼:多久、跑了幾輪、花多少,動過的 repo、跑過的測試、做完的背景工作

繁體中文版:README.zh-TW.md. The mod's on-screen text is in Traditional Chinese.
A Claude Code mod for when you leave Claude running and come back a while later: a pane beside the conversation lists what happened while you were away. No more asking "what did you do while I was gone": how long, how many turns, the cost, which repos were touched, whether tests passed, whether background work failed — all on one receipt. The mod only runs git to read state; it never runs other commands for you.
Tested on: Claude Code 2.1.289 (0.1.0, loaded with --plugin-dir; tested in a 160-column tmux: opening with /receipt, new commits, uncommitted files, a failing make test with exit 2 in red, claude plugin test with exit 0, a finished background shell, closing with q, and the record still there on the next /receipt; typing a character 22 minutes after sending a message opened it automatically with focus left in the prompt, and after closing it, clearing the draft and typing again did not reopen it; 0.2.0 was tested pressing 1 to put the failed make test into the prompt, the receipt closing, and typing on into the prompt). The mods API is still in early access, so a Claude Code release may require changes.

A live Claude Code 2.1.290 session: in this stretch, 1 new commit and 1 uncommitted file; make test failed and npm test passed. With background work there is also a 背景工作 ("background work") section below 驗證 ("verification"); with other worktrees, the repo section gets a ⎇ <path> (<branch>) line.
git status --porcelain as is), other worktrees. At most 10 new commits and 10 uncommitted files are listed; the rest become "還有 N 個" ("N more"). A clean repo with no new commits gets a single line, "✓ 沒有新 commit、沒有未 commit 檔" ("no new commits, no uncommitted files").×N. Failures are red.#56B4E9, commit hashes orange #E69F00, notes gray #7d8794; failures use vermillion #D55E00 from the same color-blind-friendly palette./receipt: opens it at any time, drawn at any width.q or ctrl+x x while the pane has focus. Opened with /receipt, the pane has focus; opened automatically, focus stays in the prompt, so switch over with ctrl+x tab first (with focus in the prompt, ctrl+x x does not close it, measured 2026-10-05).1: ✗) or click the ✗.<command> 失敗(exit N),幫我找出原因並修好。" ("<command> failed (exit N); find the cause and fix it.") into the prompt, closes the receipt, and returns focus to the prompt; press Enter to send (measured 2026-10-05)./receipt is not sending a message, so the record does not restart./clear, so a ctx-relay automatic /clear handoff does not break it. Reloading the mod (hot reload, restarting Claude) starts over.cd / pushd / git -C in Bash commands (anything with $ or backticks cannot be seen through and is ignored); the working directory when git runs without a cd.npm / pnpm / yarn / bun / deno / cargo / go / make / just / mix / dotnet / gradle / mvn / swift / zig followed by test (optionally with run in between), or pytest, unittest, bats, jest, vitest, mocha, prove, rspec, phpunit, ctest, tox, tsc, claude plugin test, claude plugin validate. The shell's test -f does not count. Commands sent to the background have no visible end and are listed under background work instead. Only the command's first line is shown.Exit code N in the error text; exit ? when the number cannot be found.<status> and <summary> from the notification (<task-notification>) Claude Code sends when background work ends.git log --since: a rebase, cherry-pick, or a commit with a wrong clock during your absence may be listed extra or missed./clear resets the total, it accumulates again from the reset (whether /clear resets it has not been tested)./receipt and not closed it by hand since). When narrower, the pane waits undrawn and a toast suggests typing /receipt.hooks/register.tsx 354 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Receipt, RepoPart, Task, Test } from '../types'
5
6// away-receipt:對話旁的 pane 列出「你離開期間發生了什麼」。
7// 離開從你上次自己送出訊息(提示框或 Remote Control)算起;plugin 代送的(ctx-relay 接續、your-turn 回報)、
8// 背景工作通知、排程觸發都不算你回來。
9// 收據:第一行離開多久、跑了幾輪、花多少;每個動過的 repo 一段(新 commit、未 commit 檔、分支、別的 worktree);
10// 驗證(跑過的測試指令和 exit code,失敗的紅色);背景工作(做完或失敗的子代理和 shell,失敗的紅色)。
11// 你上次送出超過 20 分鐘、回來開始打字時自動打開(同一次離開只開一次;開了 Claude 還沒送出過不算),也可以打 /receipt 打開;
12// pane 有焦點時按 q(或點「關閉」)或 ctrl+x x 關掉;自動打開時焦點在提示框,要先 ctrl+x tab。
13// 失敗的測試按數字鍵(或點 ✗):把「修這個」填進提示框、關掉收據,Enter 就送出。
14//
15// 離開期間的紀錄放模組變數:/clear 會把 $.state 歸零、模組變數保留(repo-ledger 實測),
16// ctx-relay 在你離開時自動 /clear 接續,紀錄才不會斷。$.state 只放打開當下算好的收據,給 pane 畫。
17
18const PANE = 'away-receipt'
19const TITLE = '離開期間'
20
21// 和 ctx-relay 同一組 dark-daltonized 可分辨色;失敗用同一組的 vermillion
22const ORANGE = '#E69F00'
23const SKY = '#56B4E9'
24const LABEL = '#7d8794'
25const VALUE = '#f5f7fa'
26const RED = '#D55E00'
27const GIT = '' // Nerd Font md-git U+F02A2
28
29// 離開超過這麼久,回來打字時自動打開
30const AWAY_MS = 20 * 60_000
31// Button 的 hotkey 只收一個數字:失敗的測試前 9 條有數字鍵
32const MAX_KEYS = 9
33// 新 commit、未 commit 檔各最多列幾條,其餘寫「還有 N 個」
34const MAX_ITEMS = 10
35
36// 算測試的指令:常見測試工具、型別檢查、claude plugin test/validate。單獨的 test(shell 的 test -f)不算
37const TEST_RE = /\b(?:(?:npm|pnpm|yarn|bun|deno|cargo|go|make|just|mix|dotnet|gradle|mvn|swift|zig)\s+(?:run\s+)?test|pytest|unittest|bats|jest|vitest|mocha|prove|rspec|phpunit|ctest|tox|tsc|claude\s+plugin\s+(?:test|validate))\b/
38
39const receiptAtom = atom({ plugin: 'away-receipt', key: 'receipt' } as const, null as Receipt | null)
40
41// 這次離開的紀錄
42let since = 0 // 你上次送出訊息的時間($.clock.now());0=還沒開始算
43let turns = 0 // 主對話跑完的輪數(子代理的不算)
44let spent = 0 // 花費(USD)
45let lastCost = 0 // 上次讀到的 session 累計花費
46let isShown = false // 這次離開已經自動打開過
47let hasSubmitted = false // 你送出過訊息;開了 Claude 還沒送出就放著,回來打字不算「離開回來」
48const tests = new Map<string, Test>()
49const tasks: Task[] = []
50const roots: string[] = [] // 動過的 repo 根目錄(第一次碰到的順序)
51// 目錄 → repo 根目錄;不是 repo 記 null,不重問 git(跨離開保留)
52const rootOf = new Map<string, string | null>()
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const result = await next(e)
57 // reload、/clear 都會重跑:已經在算就不重來
58 if (since === 0) {
59 since = await $.clock.now()
60 await costDelta($)
61 }
62 // 名稱衝突會丟例外,包住免得中斷 hook
63 try {
64 await $.command.register({ name: 'receipt', description: 'away-receipt:打開離開期間的收據' })
65 } catch (err) {
66 $.ui.log(`[away-receipt] 註冊 /receipt 失敗:${String(err)}`)
67 }
68 return result
69 })
70
71 on('prompt.submit', async ($, e, next) => {
72 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
73 // 你自己送出:重新開始算
74 since = await $.clock.now()
75 await costDelta($)
76 turns = 0
77 spent = 0
78 isShown = false
79 hasSubmitted = true
80 tests.clear()
81 tasks.length = 0
82 roots.length = 0
83 } else if (e.origin.kind === 'task-notification') {
84 // 一則通知可能帶好幾個背景工作,各自一個 <task-notification> 區塊
85 for (const block of e.text.split('<task-notification>').slice(1)) {
86 const status = /<status>([^<]*)<\/status>/.exec(block)?.[1]?.trim()
87 if (!status) continue
88 tasks.push({ status, summary: /<summary>([^<]*)<\/summary>/.exec(block)?.[1]?.trim() || status })
89 }
90 }
91 return next(e)
92 })
93
94 on('turn.complete', async ($, e, next) => {
95 const result = await next(e)
96 if (!e.agentId) {
97 turns += 1
98 spent += await costDelta($)
99 }
100 return result
101 })
102
103 // 主對話和子代理的工具都算
104 on('tool.call', async ($, e, next) => {
105 const result = await next(e)
106 if (result.deny !== undefined) return result
107 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
108 if (result.isError === true) return result
109 const file = await absolute($, await $.session.cwd(), e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)
110 await track($, dirname(file))
111 } else if (e.tool === 'Bash') {
112 const cwd = await $.session.cwd()
113 const dirs = commandDirs(e.command)
114 // 沒有 cd/git -C 就跑 git:在工作目錄跑的
115 if (/\bgit\b/.test(e.command) && !MOVE_RE.test(e.command)) dirs.push(cwd)
116 for (const dir of dirs) await track($, await absolute($, cwd, dir))
117 // 丟到背景跑的看不到結束;結束時的通知列在背景工作
118 const isBackground = result.isError !== true && typeof (result.result as { backgroundTaskId?: unknown }).backgroundTaskId === 'string'
119 if (TEST_RE.test(e.command) && !isBackground) {
120 const cmd = e.command.trim().split('\n')[0] ?? ''
121 const code = result.isError === true ? exitCode(result.text) : 0
122 tests.set(cmd, { cmd, code, runs: (tests.get(cmd)?.runs ?? 0) + 1 })
123 }
124 }
125 return result
126 })
127
128 // 你回來開始打字:草稿從空變成有字、距離上次送出超過 20 分鐘,這次離開還沒自動打開過。
129 // 主動打開:終端機 ≥144 欄才畫(/receipt 開過一次後降到 110 欄),沒畫出來就跳 toast。
130 // 不要焦點:你正在打字,鍵盤留在提示框
131 on('prompt.edit', async ($, e, next) => {
132 const result = await next(e)
133 if (!hasSubmitted || isShown || e.text !== '' || e.inputText === '') return result
134 if ((await $.clock.now()) - since <= AWAY_MS) return result
135 isShown = true
136 const receipt = await snapshot($)
137 try {
138 const opened = await $.ui.open({ id: PANE, title: TITLE })
139 if (!opened.isPlaced) $.ui.toast(`away-receipt:離開 ${duration(receipt.awayMs)},終端機太窄沒畫出來,打 /receipt 看收據`, { timeoutMs: 8000 })
140 } catch (err) {
141 $.ui.log(`[away-receipt] 打開 pane 失敗:${String(err)}`)
142 }
143 return result
144 })
145
146 // 你打的指令=asked:任何寬度都畫
147 on('command.run', { command: 'receipt' }, async $ => {
148 await snapshot($)
149 await $.ui.open({ id: PANE, title: TITLE, focus: true })
150 return { text: '[away-receipt] 已打開收據' }
151 })
152
153 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154 const { Box, Button, Text } = $.ui.resolve(e)
155 const r = await read($, receiptAtom)
156 if (r === null) return <Box flexDirection="column"><Text color={LABEL}>還沒有收據,打 /receipt</Text></Box>
157 const lines: RenderElement[] = []
158 // 每行一個帶 key 的 Box:太長截斷
159 const line = (key: string, ...parts: RenderElement[]) => lines.push(<Box key={key}><Text wrap="truncate-end">{parts}</Text></Box>)
160 const gap = (key: string) => lines.push(<Box key={key}><Text>{' '}</Text></Box>)
161 const more = (key: string, n: number, unit: string) => {
162 if (n > MAX_ITEMS) line(key, <Text color={LABEL}>{` 還有 ${n - MAX_ITEMS} ${unit}`}</Text>)
163 }
164
165 line('head',
166 <Text color={LABEL}>離開 </Text>, <Text color={VALUE} bold>{duration(r.awayMs)}</Text>,
167 <Text color={LABEL}> · 跑了 </Text>, <Text color={VALUE} bold>{String(r.turns)}</Text>, <Text color={LABEL}> 輪 · </Text>,
168 <Text color={VALUE} bold>{`$${r.usd.toFixed(2)}`}</Text>)
169
170 r.repos.forEach((repo, i) => {
171 gap(`repo-${i}-gap`)
172 const isClean = repo.commits.length === 0 && repo.files.length === 0
173 line(`repo-${i}`, <Text color={SKY} bold>{`${GIT} ${repo.name}`}</Text>, <Text color={VALUE}>{` (${repo.branch})`}</Text>,
174 isClean ? <Text color={LABEL}>{' ✓ 沒有新 commit、沒有未 commit 檔'}</Text> : <Text>{''}</Text>)
175 if (repo.commits.length > 0) line(`repo-${i}-commits`, <Text color={LABEL}>{` 新 commit ${repo.commits.length} 個`}</Text>)
176 repo.commits.slice(0, MAX_ITEMS).forEach((c, j) => {
177 const [hash = '', ...subject] = c.split(' ')
178 line(`repo-${i}-c${j}`, <Text color={ORANGE}>{` ${hash}`}</Text>, <Text color={VALUE}>{` ${subject.join(' ')}`}</Text>)
179 })
180 more(`repo-${i}-cmore`, repo.commits.length, '個')
181 if (repo.files.length > 0) line(`repo-${i}-files`, <Text color={LABEL}>{` 未 commit ${repo.files.length} 檔`}</Text>)
182 repo.files.slice(0, MAX_ITEMS).forEach((f, j) => line(`repo-${i}-f${j}`, <Text color={VALUE}>{` ${f}`}</Text>))
183 more(`repo-${i}-fmore`, repo.files.length, '檔')
184 repo.worktrees.forEach((w, j) => line(`repo-${i}-w${j}`, <Text color={LABEL}>{` ⎇ ${w}`}</Text>))
185 })
186
187 if (r.tests.length > 0) {
188 gap('tests-gap')
189 const failed = r.tests.filter(t => t.code !== 0)
190 const hint = failed.length === 0 ? '' : ` 按 ${failed.length === 1 ? '1' : `1–${Math.min(MAX_KEYS, failed.length)}`} 把失敗的指令填進提示框`
191 line('tests', <Text color={VALUE} bold>驗證</Text>, <Text color={LABEL}>{hint}</Text>)
192 // 失敗的測試是按鈕:按下把「修這個」填進提示框、關掉收據,像 quickfix 跳到該處(前 9 條有數字鍵)
193 let k = 0
194 r.tests.forEach((t, j) => {
195 const runs = t.runs > 1 ? ` ×${t.runs}` : ''
196 if (t.code === 0) {
197 line(`test-${j}`, <Text color={LABEL}>{` ✔ exit 0${runs} `}</Text>, <Text color={VALUE}>{t.cmd}</Text>)
198 return
199 }
200 k += 1
201 const press = () => askFix($, t)
202 lines.push(
203 <Box key={`test-${j}`} flexDirection="row">
204 <Text>{' '}</Text>
205 {k <= MAX_KEYS
206 ? <Button key={`fix-${k}`} plain hotkey={String(k)} label="✗" onPress={press} />
207 : <Button key={`fix-${k}`} plain label="✗" onPress={press} />}
208 <Text color={RED} wrap="truncate-end">{` exit ${t.code ?? '?'}${runs} ${t.cmd}`}</Text>
209 </Box>,
210 )
211 })
212 }
213
214 if (r.tasks.length > 0) {
215 gap('tasks-gap')
216 line('tasks', <Text color={VALUE} bold>背景工作</Text>)
217 r.tasks.forEach((t, j) => {
218 const isOk = t.status === 'completed'
219 line(`task-${j}`, <Text color={isOk ? LABEL : RED}>{` ${isOk ? '✔' : '✗'} `}</Text>,
220 <Text color={isOk ? VALUE : RED}>{isOk ? t.summary : `${t.summary}(${t.status})`}</Text>)
221 })
222 }
223
224 if (r.repos.length === 0 && r.tests.length === 0 && r.tasks.length === 0) {
225 gap('none-gap')
226 line('none', <Text color={LABEL}>這段時間沒有動到 repo、沒跑測試、沒有背景工作</Text>)
227 }
228
229 return (
230 <Box flexDirection="column">
231 {lines}
232 <Box marginTop={1}>
233 <Button key="close" plain hotkey="q" label="關閉" onPress={() => $.ui.close({ id: PANE })} />
234 </Box>
235 </Box>
236 )
237 })
238}
239
240// 失敗的測試:把「修這個」填進提示框(草稿是空的就直接放,有字就接在下一行,不蓋掉你打的),再關掉收據回提示框
241async function askFix($: EngineInterface, t: Test) {
242 const text = `${t.cmd} 失敗(exit ${t.code ?? '?'}),幫我找出原因並修好。`
243 const { text: draft } = await $.prompt.read()
244 const filled = await $.prompt.fill(draft === '' ? { text } : { text: `\n${text}`, mode: 'append' })
245 if (!filled.isFilled) {
246 $.ui.toast('away-receipt:提示框現在收不到字(可能有對話框開著),沒有填進去', { timeoutMs: 6000 })
247 return
248 }
249 await $.ui.close({ id: PANE })
250}
251
252// 算好打開當下的收據,存給 pane 畫
253async function snapshot($: EngineInterface): Promise<Receipt> {
254 spent += await costDelta($)
255 const now = await $.clock.now()
256 const repos: RepoPart[] = []
257 for (const root of roots) {
258 const part = await inspect($, root, since)
259 if (part !== null) repos.push(part)
260 }
261 const receipt = { awayMs: now - since, turns, usd: spent, repos, tests: [...tests.values()], tasks: [...tasks] }
262 await update($, receiptAtom, () => receipt)
263 return receipt
264}
265
266// 從上次讀到現在花了多少。/clear 可能讓 session 累計重來:比上次少就當成從 0 起算
267async function costDelta($: EngineInterface): Promise<number> {
268 const usd = (await $.session.usage()).cost?.usd ?? 0
269 const delta = usd >= lastCost ? usd - lastCost : usd
270 lastCost = usd
271 return delta
272}
273
274// Bash 回報錯誤時的文字開頭是「Exit code N」;看不出來回 null
275function exitCode(text: string | undefined): number | null {
276 const m = /Exit code (\d+)/.exec(text ?? '')
277 return m ? Number(m[1]) : null
278}
279
280function duration(ms: number): string {
281 const m = Math.floor(ms / 60_000)
282 return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}m`
283}
284
285// Bash 指令裡 cd/pushd/git -C 後面的路徑;帶 $ 或反引號的看不穿,不認(同 repo-ledger)
286const DIR_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s+(?:--\s+)?('[^']*'|"[^"]*"|[^\s;&|)]+)|\bgit\s+-C\s+('[^']*'|"[^"]*"|[^\s;&|)]+)/g
287const MOVE_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s|\bgit\s+-C\s/
288
289function commandDirs(command: string): string[] {
290 const dirs: string[] = []
291 for (const m of command.matchAll(DIR_RE)) {
292 const dir = (m[1] ?? m[2] ?? '').replace(/^(['"])(.*)\1$/, '$2')
293 if (dir === '' || dir === '-' || /[$`]/.test(dir)) continue
294 dirs.push(dir)
295 }
296 return dirs
297}
298
299async function absolute($: EngineInterface, cwd: string, path: string): Promise<string> {
300 if (path.startsWith('/')) return path
301 if (path === '~' || path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
302 return `${cwd}/${path}`
303}
304
305function dirname(path: string): string {
306 return path.slice(0, path.lastIndexOf('/')) || '/'
307}
308
309// 目錄所屬 repo 加進這次離開動過的清單
310async function track($: EngineInterface, dir: string): Promise<void> {
311 let root = rootOf.get(dir)
312 if (root === undefined) {
313 const run = await $.process.run(['git', '-C', dir, '-c', 'core.fsmonitor=false', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
314 root = run.exitCode === 0 ? run.stdout.trim() || null : null
315 rootOf.set(dir, root)
316 }
317 if (root !== null && !roots.includes(root)) roots.push(root)
318}
319
320// 一個 repo 的一段;git 失敗(repo 不見了)回 null。repo 自己設的 core.fsmonitor 一律關掉
321async function inspect($: EngineInterface, root: string, from: number): Promise<RepoPart | null> {
322 const git = (...args: string[]) => $.process.run(['git', '-C', root, '-c', 'core.fsmonitor=false', ...args], { timeoutMs: 10_000 })
323 const status = await git('status', '--porcelain=v1', '--branch')
324 if (status.exitCode !== 0) return null
325 const [head = '', ...files] = status.stdout.split('\n')
326 const log = await git('log', `--since=@${Math.floor(from / 1000)}`, '--format=%h %s')
327 const worktrees = await git('worktree', 'list', '--porcelain')
328 return {
329 name: root.split('/').filter(Boolean).at(-1) ?? root,
330 branch: branchOf(head),
331 commits: log.exitCode === 0 ? log.stdout.split('\n').filter(l => l.trim() !== '') : [],
332 files: files.filter(l => l.trim() !== ''),
333 worktrees: worktrees.exitCode === 0 ? otherWorktrees(worktrees.stdout, root) : [],
334 }
335}
336
337// `## master...origin/master [ahead 1]`、`## No commits yet on main`、`## HEAD (no branch)`(同 repo-ledger)
338function branchOf(head: string): string {
339 const text = head.replace(/^## /, '')
340 const unborn = /^No commits yet on (\S+)/.exec(text)
341 if (unborn) return unborn[1] ?? text
342 return text.split(/\.\.\.| /)[0] ?? text
343}
344
345// `git worktree list --porcelain`:每個 worktree 一段,空行隔開;列出 root 以外的「路徑 (分支)」
346function otherWorktrees(out: string, root: string): string[] {
347 return out.split('\n\n').flatMap(block => {
348 const path = /^worktree (.+)$/m.exec(block)?.[1]
349 if (path === undefined || path === root) return []
350 const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? 'detached'
351 return [`${path} (${branch})`]
352 })
353}
354types/index.d.ts 17 lines1// 驗證:跑過的測試指令;code=exit code(null=失敗但看不出幾號),runs=跑了幾次(code 是最後一次的)
2export type Test = { cmd: string; code: number | null; runs: number }
3// 背景工作:通知裡的 <status>(completed/failed/killed)與 <summary>
4export type Task = { status: string; summary: string }
5// 一個 repo 的一段:新 commit(短 hash+標題)、未 commit 檔(porcelain 原樣)、別的 worktree(路徑+分支)
6export type RepoPart = { name: string; branch: string; commits: string[]; files: string[]; worktrees: string[] }
7// 打開時算好的收據;pane 只畫這份,之後的事不會改它
8export type Receipt = { awayMs: number; turns: number; usd: number; repos: RepoPart[]; tests: Test[]; tasks: Task[] }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'away-receipt': {
13 receipt: Receipt | null
14 }
15 }
16}
17