SLOPSHOPPER

away-receipt

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

newpaneguardcommandtoastprompt
★ 1v0.2.0MITupdated 2026-10-06mangow314/mango-mods/away-receipt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · away-receipt
│ ┃ 離開期間 ✕ › fix the failing auth test and add an audit log call │ ┃ 離開 0m · 跑了 1 輪 · $0.00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 驗證 按 1 把失敗的指令填進提示框 ⎿ Read 6 lines │ ┃ 1: ✗ exit ? bun test ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ q: 關閉 ⏺ 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 │ │ › /receipt │ ⎿ away-receipt: [away-receipt] 已打開收據 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 離開期間
離開 0m · 跑了 1 輪 · $0.00 驗證 按 1 把失敗的指令填進提示框 1: ✗ exit ? bun test q: 關閉
README

away-receipt

繁體中文版: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.

The pane

away-receipt in a live session: a new commit, an uncommitted file, one failing and one passing test

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.

  • First line: how long you were away (since you last sent a message), how many turns the main conversation ran in that time (subagent turns excluded), and what it cost (the difference in the session's cumulative cost).
  • One section per touched repo: branch, new commits while you were away (short hash + subject), uncommitted files (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").
  • Verification: test commands that ran and their exit codes. A command that ran several times is listed once, with the last exit code and ×N. Failures are red.
  • Background work: subagents and background shells that finished or failed, using the summary from the notification. Anything not completed is red, with its status.
  • When all three sections are empty: "這段時間沒有動到 repo、沒跑測試、沒有背景工作" ("no repos touched, no tests run, no background work in this time").
  • Colors follow ctx-relay: repo names sky blue #56B4E9, commit hashes orange #E69F00, notes gray #7d8794; failures use vermillion #D55E00 from the same color-blind-friendly palette.

When it opens

  • If you last sent a message more than 20 minutes ago, it opens automatically when you come back and type the first character into the prompt (the draft going from empty to non-empty; typing or pasting both count). It opens automatically only once per absence. If you start Claude and leave without ever sending a message, typing on your return does not open it.
  • Automatic opening does not steal focus; you can keep typing.
  • /receipt: opens it at any time, drawn at any width.
  • Closing: press 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).
  • The receipt is computed when it opens; if you then send messages and Claude keeps working, the pane does not change until the next time it opens.

Pressing a failed test

  • In the verification section, failed tests are buttons: with the pane focused, press the number key (the first 9; the number in 1: ✗) or click the ✗.
  • This puts "<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).
  • An empty prompt gets it as is; if there is already text, it goes on the next line without overwriting what you typed.
  • If the prompt cannot take the text (for example a dialog is open), a toast appears and the receipt stays open.

What counts as "away"

  • From the moment you yourself send a message (from the prompt, or Remote Control) until the receipt opens.
  • Messages a plugin sends for you (ctx-relay's resume, your-turn's report), background-work notifications, and scheduled triggers do not count as you coming back.
  • Typing /receipt is not sending a message, so the record does not restart.
  • The record lives in the mod's module variables: it survives /clear, so a ctx-relay automatic /clear handoff does not break it. Reloading the mod (hot reload, restarting Claude) starts over.

What counts

  • Touched repos (recognized the same way as repo-ledger, in both the main conversation and subagents): the repo of any file changed with Edit / Write / NotebookEdit; literal paths after 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.
  • Test commands: a Bash command containing 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: 0 on success; when Bash reports an error, the Exit code N in the error text; exit ? when the number cannot be found.
  • Background work: <status> and <summary> from the notification (<task-notification>) Claude Code sends when background work ends.

Known limits

  • New commits are filtered by commit time with git log --since: a rebase, cherry-pick, or a commit with a wrong clock during your absence may be listed extra or missed.
  • The cost is the difference in the session's cumulative cost; if /clear resets the total, it accumulates again from the reset (whether /clear resets it has not been tested).
  • Automatic opening is a proactive open: the terminal must be at least 144 columns wide for it to draw (110 once you have opened it with /receipt and not closed it by hand since). When narrower, the pane waits undrawn and a toast suggests typing /receipt.
  • git runs only when you type the first character on your return: with many touched repos, that first character may lag a little.
  • Long lines are truncated.
Source 2 files
hooks/register.tsx 354 lines
1import { 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}
354
types/index.d.ts 17 lines
1// 驗證:跑過的測試指令;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