SLOPSHOPPER

subagent-dispatch

Shows the background agents that are running above the prompt box, and re-dispatches the ones that were interrupted in one press

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-dispatch
│ ┃ subagent-dispatch-stats ✕ › fix the failing auth test and add an audit log call │ ┃ subagent-dispatch stats ─────────────── Upda │ ┃ ● subagent-dispatch: backfill: 0 session(s) added, 0 file(s) skipped │ ┃ May Jun Jul Aug … ⏺ Read(src/auth.ts) │ ┃ Mon · · · · · · · · · · · · · · · · · · · · ⎿ Read 6 lines │ ┃ · · · · ⏺ Update(src/auth.ts) │ ┃ · · · · · · · · · · · · · · · · · · · · ⎿ Added 2 lines, removed 1 line │ ┃ · · · · ⏺ Bash(bun test) │ ┃ Wed · · · · · · · · · · · · · · · · · · · · ⎿ 3 pass, 1 fail │ ┃ · · · · │ ┃ · · · · · · · · · · · · · · · · · · · · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · · · · │ ┃ Fri · · · · · · · · · · · · · · · · · · · · ✻ Worked for 42s · done 4:20 PM │ ┃ · · · │ ┃ · · · · · · · · · · · · · · · · · · · · › /subagent-dispatch │ ┃ · · · ⎿ subagent-dispatch: Usage: /subagent-dispatch redispatch, /subage │ ┃ · · · · · · · · · · · · · · · · · · · · │ ┃ · · · │ ┃ Less ■■■■ More None ■ No data · │ ┃ └───────┘ │ ┃ │ ┃ p: days h: earlier Sep 10 – Oct 9 l: later │ ┃ │ ┃ No usage recorded yet (recording starts once │ ┃ the ledger setting is on and │ ┃ subagent-dispatch is loaded). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · subagent-dispatch-stats
subagent-dispatch stats ─────────────── Updated 08:53:20 May Jun Jul Aug Sep Oct Mon · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Wed · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Fri · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Less ■■■■ More None ■ No data · └───────┘ p: days h: earlier Sep 10 – Oct 9 l: later t: today No usage recorded yet (recording starts once the ledger setting is on and subagent-dispatch is loaded). Tab moves · Esc closes
README

cc-mod-trimtab

English | 繁體中文

Small mods that keep Claude Code from losing your work, your secrets and your tokens.

A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.

  • Fewer mistakes: risky git and rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.
  • Fewer tokens: long command output is trimmed before the model reads it; turn on session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on.
  • More fault tolerance: interrupted background agents come back in one key press, open questions survive /clear, and with project-memory on, what you learn in one session is waiting in the next.
  • No extra services, no surprise spending: no mod contacts a service of its own. The few features that call a model go through Claude Code's own model API with your account, and they are off until you turn them on.

The mods

ModWhat it does for you
secret-scrubMasks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache.
tool-guardHolds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through.
output-trimmerWhen a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file.
subagent-dispatchBrings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn.
open-itemsKeeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press.
session-keeperShows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive.
self-checkAsks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code.
project-memoryOnce turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md.

Quick start

Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).

claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab

This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.

The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.

Documentation

License

MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.

Source 6 files
hooks/register.tsx 1523 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { WatchedAgent } from '../types'
5import { configCommand, LANGUAGE, loadConfig, pick, type ConfigIO, type Lang, type Specs } from './config'
6import { fitColumns, fitLegend, listBarWidth, movePeriod, periodMark, type Column } from './layout'
7import { COLORS, emptyState, hintLine, MAIN_SHADES, switcher as switchIn, TONE } from './ui'
8import {
9  addDays,
10  applyMeta,
11  buildHeatmap,
12  buildReport,
13  cells,
14  clockTime,
15  shortCount,
16  shortDay,
17  span,
18  countKinds,
19  DAY_KEEP,
20  DAY_PREFIX,
21  dayHead,
22  dayLabel,
23  dayOf,
24  dayRow,
25  daysBetween,
26  heatLevels,
27  heatStart,
28  heatWeeks,
29  liveFrom,
30  MAX_WEEKS,
31  monthMarks,
32  parseSubagent,
33  parseTranscript,
34  recordedDays,
35  settledDays,
36  transcriptLedger,
37  weekdayLabels,
38  windowReport,
39  type HeatDay,
40  elapsed,
41  DEFAULT_DAYS,
42  GROUP_BYS,
43  groups,
44  type Group,
45  type GroupBy,
46  n,
47  repoName,
48  sessionName,
49  stamp,
50  share,
51  spawnParts,
52  tokens,
53  type Spawn,
54  emptyText,
55  fit,
56  emptyLedger,
57  daysLabel,
58  readLedger,
59  LEDGER_PREFIX,
60  LEDGER_SPEC,
61  MAX_DAYS,
62  promptSummary,
63  recordSpawn,
64  recordStep,
65  renderStats,
66  RETENTION_MS,
67  type Ledger,
68  type Report,
69  type SessionMeta,
70} from './ledger'
71
72// A list shows this many rows, and this many more at each "more".
73const PAGE = 10
74
75const running = atom({ plugin: 'subagent-dispatch', key: 'running' } as const, [])
76const stopped = atom({ plugin: 'subagent-dispatch', key: 'stopped' } as const, [])
77// What the stats pane shows: the period's length and its last day (`YYYY-MM-DD`, '' for today), the
78// grouping, the row opened to its spawns, their order, how many rows are listed, and the spawn
79// opened in place.
80const view = atom({ plugin: 'subagent-dispatch', key: 'view' } as const, { days: 30, by: 'repo', item: '', sort: 'tokens', limit: PAGE, spawn: '', end: '' })
81
82// How an opened row orders its spawns: most tokens, newest, or longest run first.
83type SortBy = 'tokens' | 'time' | 'duration'
84const SORTS: Record<SortBy, (spawn: Spawn) => number> = {
85  tokens: spawn => tokens(spawn),
86  time: spawn => spawn.at,
87  duration: spawn => spawn.last - spawn.at,
88}
89
90const MOD = 'subagent-dispatch'
91const REDISPATCH = 'redispatch'
92const STATS = 'stats'
93const BACKFILL = 'backfill'
94const PANE = 'subagent-dispatch-stats'
95const PERIODS = [1, 7, 30, 90]
96// Below this many body columns the heatmap is not drawn: four weeks and the labels.
97const HEAT_COLUMNS = 12
98// The heatmap's colors (DESIGN.md「熱圖色階」): no use, levels 1–4, the day of a 1-day period.
99const HEAT = { none: '#4a4a52', levels: ['#1f5a6d', '#2a7f9c', '#3aa6cc', '#86d6f0'], chosen: '#ff8c42' } as const
100// Under `backfill`: whether the transcripts were read into ledgers, so it runs once per store.
101const BACKFILL_KEY = 'backfill'
102// A run marked `running` this long ago is taken as dead (its session closed partway).
103const BACKFILL_STALE_MS = 10 * 60_000
104// `$.fs.read` rejects a file over this; a larger transcript is read through `cat`.
105const READ_LIMIT = 4 * 1024 * 1024
106// From this many body columns an opened row's bars are twice as long.
107const WIDE_COLUMNS = 80
108const SPECS: Specs = { ledger: LEDGER_SPEC, language: LANGUAGE }
109const POLL_MS = 3000
110const STOPPED = new Set(['killed', 'failed'])
111const FINISHED = new Set(['completed', 'idle'])
112
113function label(agent: WatchedAgent): string {
114  return agent.model ? `${agent.type}/${agent.model}` : agent.type
115}
116
117// Sent to Claude, so always English.
118export function redispatchPrompt(agents: WatchedAgent[]): string {
119  const list = agents.map(agent => `- ${agent.description} (${label(agent)}, agent id ${agent.id})`).join('\n')
120
121  return `These background agents were just interrupted. Dispatch them again with their original prompt, subagent_type and model (the original Agent calls are earlier in the conversation):\n${list}`
122}
123
124// Moves agents the engine reports as finished or stopped out of `running`.
125async function refresh($: EngineInterface, lang: Lang): Promise<void> {
126  const tracked = await read($, running)
127  if (tracked.length === 0) {
128    return
129  }
130  const live = new Map((await $.agent.list()).map(info => [info.id, info.status]))
131  const stillRunning: WatchedAgent[] = []
132  const newlyStopped: WatchedAgent[] = []
133  for (const agent of tracked) {
134    const status = live.get(agent.id)
135    if (status !== undefined && STOPPED.has(status)) {
136      newlyStopped.push({ ...agent, status })
137    } else if (status !== undefined && !FINISHED.has(status)) {
138      stillRunning.push(agent)
139    }
140  }
141
142  await update($, running, () => stillRunning)
143  if (newlyStopped.length > 0) {
144    await update($, stopped, list => [...list, ...newlyStopped])
145    $.ui.toast(
146      pick(lang, {
147        en: `subagent-dispatch: ${newlyStopped.length} background agent(s) were interrupted; type /${MOD} ${REDISPATCH} to dispatch them again`,
148        'zh-TW': `subagent-dispatch:${newlyStopped.length} 個背景 agent 被中斷,輸入 /${MOD} ${REDISPATCH} 重派`,
149      }),
150      { timeoutMs: 8000 },
151    )
152  }
153  await showStatus($, lang)
154}
155
156// Surfaces without the band above the prompt (mobile, the IDE) get a one-line summary instead.
157async function showStatus($: EngineInterface, lang: Lang): Promise<void> {
158  const surface = await $.session.surface()
159  if (surface === 'terminal' || surface === 'desktop') {
160    return
161  }
162  const count = (await read($, running)).length
163  const lost = (await read($, stopped)).length
164  const runningText = pick(lang, { en: `${count} background agent(s)`, 'zh-TW': `${count} 個背景 agent` })
165  const interrupted = pick(lang, { en: `${lost} interrupted (/${MOD} ${REDISPATCH})`, 'zh-TW': `${lost} 個被中斷(/${MOD} ${REDISPATCH})` })
166  const parts = [count > 0 ? runningText : '', lost > 0 ? interrupted : '']
167  const text = parts.filter(Boolean).join(' · ')
168  $.ui.status(text === '' ? undefined : text)
169}
170
171async function fillRedispatch($: EngineInterface, lang: Lang): Promise<string> {
172  const agents = await read($, stopped)
173  if (agents.length === 0) {
174    return pick(lang, { en: 'No interrupted background agents.', 'zh-TW': '沒有被中斷的背景 agent。' })
175  }
176  await $.prompt.fill({ text: redispatchPrompt(agents), mode: 'replace' })
177  await update($, stopped, () => [])
178  await showStatus($, lang)
179
180  return pick(lang, {
181    en: `Put the redispatch instructions for ${agents.length} agent(s) in the prompt box; press Enter to confirm.`,
182    'zh-TW': `已把 ${agents.length} 個 agent 的重派指示放進輸入框,確認後按 Enter。`,
183  })
184}
185
186// `meta` belongs to session `sid`; `agents` maps an agent id to the ledger key it was spawned
187// under, at most AGENT_KEYS of them; `redraw` says a redraw of the stats pane is waiting;
188// `backfilling` is the backfill running in this process; `snapshot` is the store as the pane last
189// read it.
190type LedgerState = {
191  recording: boolean
192  writing: Promise<void>
193  sid: string
194  meta: SessionMeta
195  agents: Map<string, string>
196  redraw: boolean
197  backfilling?: Promise<void>
198  snapshot?: Snapshot
199}
200
201// Every ledger as stored, every day row by its day, and when they were read.
202type Snapshot = { at: number; ledgers: unknown[]; dayRows: Record<string, unknown> }
203
204const AGENT_KEYS = 500
205// A write redraws the stats pane this long after it, once for every write meanwhile.
206const REDRAW_MS = 1000
207// A redraw of the pane this soon after the last read of the store reuses it.
208const SNAPSHOT_MS = 30_000
209
210// `/clear` starts a new session id in the same process without a session.start: the new
211// session starts now and is named after its own first prompt.
212async function sessionKey($: EngineInterface, state: LedgerState): Promise<string> {
213  const sid = await $.session.id()
214  if (state.sid !== sid) {
215    if (state.sid !== '') {
216      state.meta = { ...state.meta, started: await $.clock.now(), prompt: '' }
217    }
218    state.sid = sid
219  }
220
221  return `${LEDGER_PREFIX}${sid}`
222}
223
224// Applies `change` to this session's own ledger key, or for a step of an agent spawned under an
225// earlier session id (one that outlived /clear) to that session's. Parallel subagents step at
226// the same time and each write reads the ledger back, so they take turns. Bookkeeping must
227// never break the engine's call.
228function record($: EngineInterface, state: LedgerState, where: string, change: (ledger: Ledger, at: number) => void, agentId?: string): Promise<void> {
229  if (!state.recording) {
230    return Promise.resolve()
231  }
232  state.writing = state.writing.then(async () => {
233    try {
234      const own = await sessionKey($, state)
235      const key = (agentId === undefined ? undefined : state.agents.get(agentId)) ?? own
236      const stored = await $.store.get(key)
237      const ledger = readLedger(stored) ?? emptyLedger()
238      change(ledger, await $.clock.now())
239      if (key === own) {
240        applyMeta(ledger, state.meta)
241      }
242      await $.store.set(key, ledger)
243      redrawStats($, state)
244    } catch (error) {
245      $.ui.log(`${where}: ledger not written: ${String(error)}`, { to: 'debug' })
246    }
247  })
248
249  return state.writing
250}
251
252// The pane reads the ledgers from `$.store`, whose writes redraw nothing by themselves. Parallel
253// subagents write at every step, so the writes of a second share one redraw, which reads the store
254// afresh. It also redraws this mod's band above the prompt, which reads one value.
255function redrawStats($: EngineInterface, state: LedgerState): void {
256  state.snapshot = undefined
257  if (state.redraw) {
258    return
259  }
260  state.redraw = true
261  $.clock.after(REDRAW_MS, () => {
262    state.redraw = false
263    $.ui.invalidate('ui.render')
264  })
265}
266
267// Deletes the ledgers no session has written to for RETENTION_MS.
268async function pruneLedgers($: EngineInterface): Promise<void> {
269  const cutoff = (await $.clock.now()) - RETENTION_MS
270  for (const key of (await $.store.keys()).filter(name => name.startsWith(LEDGER_PREFIX))) {
271    const stored = (await $.store.get(key)) as Partial<Ledger> | undefined
272    if (typeof stored?.updated !== 'number' || stored.updated < cutoff) {
273      await $.store.delete(key)
274    }
275  }
276}
277
278// Every ledger as stored, and every day row by its day: a read per key.
279async function loadStore($: EngineInterface): Promise<Snapshot> {
280  const keys = await $.store.keys()
281  const ledgers = await Promise.all(keys.filter(name => name.startsWith(LEDGER_PREFIX)).map(key => $.store.get(key)))
282  const dayKeys = keys.filter(name => name.startsWith(DAY_PREFIX))
283  const rows = await Promise.all(dayKeys.map(key => $.store.get(key)))
284
285  return { at: await $.clock.now(), ledgers, dayRows: Object.fromEntries(dayKeys.map((key, i) => [key.slice(DAY_PREFIX.length), rows[i]])) }
286}
287
288// The store for a render of the pane. Reading every key is most of a render's time once there are
289// a few hundred sessions, and a resize redraws many times a second, so a redraw within SNAPSHOT_MS
290// reuses the last read; this session's writes and opening the pane drop it.
291async function paneStore($: EngineInterface, state: LedgerState): Promise<Snapshot> {
292  const now = await $.clock.now()
293  if (state.snapshot === undefined || now - state.snapshot.at >= SNAPSHOT_MS) {
294    state.snapshot = await loadStore($)
295  }
296
297  return state.snapshot
298}
299
300async function loadReport($: EngineInterface, days: number): Promise<Report> {
301  return buildReport((await loadStore($)).ledgers, days, await $.clock.now())
302}
303
304// Writes the day rows that are due: each day a ledger recorded up to the day before yesterday that
305// has no row yet, and each of `redo` the ledgers still hold whole (a backfill adds to them).
306// Deletes the rows older than DAY_KEEP days. Runs before the ledgers are pruned.
307async function writeDayRows($: EngineInterface, redo: Set<string> = new Set()): Promise<void> {
308  const now = await $.clock.now()
309  const { ledgers, dayRows } = await loadStore($)
310  const read = ledgers.map(readLedger).filter((ledger): ledger is Ledger => ledger !== undefined)
311  const live = liveFrom(now)
312  for (const day of settledDays(read, now)) {
313    if (!(day in dayRows) || (redo.has(day) && day >= live)) {
314      await $.store.set(`${DAY_PREFIX}${day}`, dayRow(read, day, now))
315    }
316  }
317  const oldest = addDays(dayOf(now), -(DAY_KEEP - 1))
318  for (const day of Object.keys(dayRows).filter(day => day < oldest)) {
319    await $.store.delete(`${DAY_PREFIX}${day}`)
320  }
321}
322
323type BackfillMark = { state: 'running' | 'done'; at: number; sessions: number; skipped: number }
324
325// A file's text: through `$.fs.read` up to its limit, through `cat` above it, or undefined where
326// no process can be run (desktop).
327async function readLarge($: EngineInterface, path: string, size: number): Promise<string | undefined> {
328  if (size <= READ_LIMIT) {
329    return (await $.fs.read(path)) as string
330  }
331  try {
332    let text = ''
333    for await (const piece of $.process.spawn({ argv: ['cat', path] })) {
334      if (piece.stream === 'stdout') {
335        text += piece.text
336      }
337    }
338
339    return text
340  } catch {
341    return undefined
342  }
343}
344
345// The main working tree of a directory, as a live session names its repo: git's common directory
346// without its `.git`. A directory gone since is taken as is, or for a worktree under
347// `.claude/worktrees/` the folder that held it.
348async function repoOf($: EngineInterface, cwd: string, known: Map<string, string>): Promise<string> {
349  if (cwd === '') {
350    return ''
351  }
352  const found = known.get(cwd)
353  if (found !== undefined) {
354    return found
355  }
356  let repo = cwd.split('/.claude/worktrees/')[0] ?? cwd
357  try {
358    const run = await $.process.run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { timeoutMs: 5000 })
359    const common = run.stdout.trim()
360    if (run.exitCode === 0 && common.endsWith('/.git')) {
361      repo = common.slice(0, -'/.git'.length)
362    }
363  } catch {
364    // No process here (desktop): keep the guess.
365  }
366  known.set(cwd, repo)
367
368  return repo
369}
370
371// The spawns of a session from `<dir>/subagents/agent-<id>.jsonl` and their `.meta.json`.
372async function readSpawns($: EngineInterface, dir: string, skip: (path: string, why: string) => void): Promise<Spawn[]> {
373  const entries = await $.fs.list(`${dir}/subagents`).catch(() => [])
374  const spawns: Spawn[] = []
375  for (const entry of entries.filter(item => item.kind === 'file' && /^agent-.+\.jsonl$/.test(item.name))) {
376    const path = `${dir}/subagents/${entry.name}`
377    try {
378      const text = await readLarge($, path, entry.size)
379      if (text === undefined) {
380        skip(path, 'too large to read here')
381        continue
382      }
383      const meta = await $.fs
384        .read(path.replace(/\.jsonl$/, '.meta.json'))
385        .then(raw => JSON.parse(raw as string) as unknown)
386        .catch(() => undefined)
387      const spawn = parseSubagent(meta, text, entry.name.slice('agent-'.length, -'.jsonl'.length))
388      if (spawn !== undefined) {
389        spawns.push(spawn)
390      }
391    } catch (error) {
392      skip(path, String(error))
393    }
394  }
395
396  return spawns
397}
398
399// Rebuilds a ledger from the transcript of each session on this machine that has none (ADR 0025),
400// one file at a time, then writes the day rows. Unless `force`, only once per store: a run another
401// session marked `running` within BACKFILL_STALE_MS is left to it. Answers how many sessions it
402// added, or undefined when it did not run.
403async function backfill($: EngineInterface, lang: Lang, force: boolean): Promise<number | undefined> {
404  const started = await $.clock.now()
405  const mark = (await $.store.get(BACKFILL_KEY)) as BackfillMark | undefined
406  if (!force && (mark?.state === 'done' || (mark?.state === 'running' && started - mark.at < BACKFILL_STALE_MS))) {
407    return undefined
408  }
409  await $.store.set(BACKFILL_KEY, { state: 'running', at: started, sessions: 0, skipped: 0 } satisfies BackfillMark)
410  const root = `${await configIO($).configDir()}/projects`
411  const own = await $.session.id()
412  const repos = new Map<string, string>()
413  const redo = new Set<string>()
414  let sessions = 0
415  let skipped = 0
416  let since = Infinity
417  const skip = (path: string, why: string) => {
418    skipped += 1
419    $.ui.log(`backfill: skipped ${path}: ${why}`, { to: 'debug' })
420  }
421  const projects = await $.fs.list(root).catch((error: unknown) => {
422    $.ui.log(`backfill: ${root} not read: ${String(error)}`, { to: 'debug' })
423
424    return undefined
425  })
426  // Nothing to read is not done: a later session tries again.
427  if (projects === undefined) {
428    await $.store.delete(BACKFILL_KEY)
429
430    return undefined
431  }
432  for (const project of projects.filter(item => item.kind === 'dir')) {
433    const dir = `${root}/${project.name}`
434    const files = await $.fs.list(dir).catch(() => [])
435    for (const file of files.filter(item => item.kind === 'file' && item.name.endsWith('.jsonl'))) {
436      const sid = file.name.slice(0, -'.jsonl'.length)
437      const key = `${LEDGER_PREFIX}${sid}`
438      if (sid === own) {
439        continue
440      }
441      try {
442        if ((await $.store.get(key)) !== undefined) {
443          continue
444        }
445        const text = await readLarge($, `${dir}/${file.name}`, file.size)
446        if (text === undefined) {
447          skip(`${dir}/${file.name}`, 'too large to read here')
448          continue
449        }
450        const parsed = parseTranscript(text)
451        const spawns = await readSpawns($, `${dir}/${sid}`, skip)
452        if (parsed.steps.length === 0 && spawns.length === 0) {
453          continue
454        }
455        const ledger = transcriptLedger(parsed, spawns, await repoOf($, parsed.cwd, repos))
456        // A live session may have written its own since: its record wins.
457        if ((await $.store.get(key)) !== undefined) {
458          continue
459        }
460        await $.store.set(key, ledger)
461        sessions += 1
462        since = Math.min(since, ledger.started || ledger.updated)
463        for (const day of recordedDays(ledger)) {
464          redo.add(day)
465        }
466      } catch (error) {
467        skip(`${dir}/${file.name}`, String(error))
468      }
469    }
470  }
471  // The day rows before the mark: should they fail, the mark stays `running` and a later session
472  // redoes it all once it is stale.
473  await writeDayRows($, redo)
474  await $.store.set(BACKFILL_KEY, { state: 'done', at: await $.clock.now(), sessions, skipped } satisfies BackfillMark)
475  $.ui.log(`backfill: ${sessions} session(s) added, ${skipped} file(s) skipped`, { to: 'debug' })
476  if (sessions > 0) {
477    const from = dayLabel(lang, since)
478    $.ui.toast(
479      pick(lang, {
480        en: `${MOD}: added ${sessions} session(s) from the transcripts (since ${from})`,
481        'zh-TW': `${MOD}:已從 transcript 補上 ${sessions} 個 session(${from} 起)`,
482      }),
483    )
484  }
485
486  return sessions
487}
488
489// `stats [--text] [days]`: opens the pane, or prints the report with `--text`, in a `-p` run
490// or where the pane cannot be placed (a narrow terminal).
491async function stats($: EngineInterface, lang: Lang, args: string[], interactive: boolean): Promise<string> {
492  const text = args.includes('--text')
493  const rest = args.filter(arg => arg !== '--text')
494  const days = rest[0] === undefined ? DEFAULT_DAYS : Number(rest[0])
495  if (rest.length > 1 || !Number.isInteger(days) || days < 1 || days > MAX_DAYS) {
496    return pick(lang, {
497      en: `Usage: /${MOD} ${STATS} [--text] [days], days from 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})`,
498      'zh-TW': `用法:/${MOD} ${STATS} [--text] [天數],天數 1 到 ${MAX_DAYS}(預設 ${DEFAULT_DAYS})`,
499    })
500  }
501  if (!text && interactive) {
502    await update($, view, current => ({ ...current, days, end: '', item: '', limit: PAGE }))
503    const opened = await $.ui.open({ id: PANE, title: pick(lang, { en: 'subagent-dispatch usage', 'zh-TW': 'subagent-dispatch 用量' }), focus: true, closeOnEscape: true })
504    if (opened.isPlaced) {
505      return pick(lang, { en: 'Opened the usage pane (Esc closes it).', 'zh-TW': '已開啟用量 pane(Esc 關閉)。' })
506    }
507    $.ui.log(`stats: pane not placed (${opened.reason}), printing the text report`, { to: 'debug' })
508    await $.ui.close({ id: PANE }).catch(() => undefined)
509  }
510
511  return renderStats(lang, await loadReport($, days))
512}
513
514// Starts a backfill in the background unless one is running in this process; a pane open meanwhile
515// redraws with what it added. Answers whether it started one.
516function startBackfill($: EngineInterface, lang: Lang, state: LedgerState, force: boolean): boolean {
517  if (state.backfilling !== undefined) {
518    return false
519  }
520  state.backfilling = backfill($, lang, force)
521    .then(added => {
522      if (added !== undefined && added > 0) {
523        redrawStats($, state)
524      }
525    })
526    .catch(error => $.ui.log(`backfill: stopped: ${String(error)}`, { to: 'debug' }))
527    .finally(() => {
528      state.backfilling = undefined
529    })
530
531  return true
532}
533
534// How many cells of `width` a bar for `part` of `most` fills: at least one when there is any.
535function barCells(part: number, most: number, width: number): number {
536  return part <= 0 ? 0 : Math.max(1, Math.round((part / most) * width))
537}
538
539// One colored part of a summary's bar and of the line above it: `ink` on the terminal, `fill` on desktop.
540type Part = { key: string; label: string; value: number; ink: string; fill: string }
541
542// `width` cells shared out by `values`, each part with any value at least one cell.
543function splitCells(values: number[], width: number): number[] {
544  const total = values.reduce((sum, value) => sum + value, 0)
545  if (total === 0) {
546    return values.map(() => 0)
547  }
548  const cells = values.map(value => (value > 0 ? Math.max(1, Math.round((value / total) * width)) : 0))
549  // Rounding can miss the width by a cell or two: the largest part takes the difference.
550  const largest = cells.indexOf(Math.max(...cells))
551  cells[largest] = (cells[largest] ?? 0) + width - cells.reduce((sum, cell) => sum + cell, 0)
552
553  return cells
554}
555
556function svgStack(parts: { cells: number; fill: string }[]): string {
557  const unit = 8
558  let x = 0
559  const rects = parts.map(part => {
560    const rect = part.cells === 0 ? '' : `<rect x="${x * unit}" width="${part.cells * unit}" height="8" fill="${part.fill}"/>`
561    x += part.cells
562
563    return rect
564  })
565
566  return `<svg xmlns="http://www.w3.org/2000/svg" width="${x * unit}" height="8">${rects.join('')}</svg>`
567}
568
569// The heatmap on desktop: a 10px cell every 12px, a week a column, the month row above and the
570// weekday names at the left, each cell colored by its use alone; under the grid, the bracket and
571// length of the period from `from` to `to`, or for one day (`one`) a `▲` and its `name`. Colors as
572// DESIGN.md「熱圖色階」sets them.
573function svgHeatmap(lang: Lang, days: HeatDay[], from: string, to: string, one: boolean, level: (value: number | null) => number, length: string, name: string): string {
574  const [left, top, pitch, size] = [28, 14, 12, 10]
575  const weeks = Math.ceil(days.length / 7)
576  const width = left + weeks * pitch
577  const rects = days.map((item, i) => {
578    const x = left + Math.floor(i / 7) * pitch
579    const y = top + (i % 7) * pitch
580    if (one && item.day === to) {
581      return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${HEAT.chosen}"/>`
582    }
583    if (item.tokens === null) {
584      return `<rect x="${x + 0.5}" y="${y + 0.5}" width="${size - 1}" height="${size - 1}" rx="2" fill="none" stroke="${COLORS.default.fill}" stroke-dasharray="2 2"/>`
585    }
586    if (item.tokens === 0) {
587      return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${COLORS.default.fill}" fill-opacity="0.25"/>`
588    }
589
590    return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${COLORS.sub.fill}" fill-opacity="${[0.35, 0.55, 0.75, 1][level(item.tokens) - 1]}"/>`
591  })
592  const text = (x: number, y: number, words: string, extra = '') =>
593    `<text x="${x}" y="${y}" font-size="9" fill="${COLORS.default.fill}" font-family="sans-serif"${extra}>${words}</text>`
594  const months = monthMarks(lang, days).map(mark => text(left + mark.week * pitch, 9, mark.label))
595  const names = weekdayLabels(lang).flatMap((label, row) => (label === '' ? [] : [text(0, top + row * pitch + 8, label)]))
596  // The period's weeks; none where it ends before the first.
597  const last = days.findIndex(item => item.day === to)
598  const firstAt = days.findIndex(item => item.day >= from)
599  const bottom = top + 7 * pitch
600  const marks: string[] = []
601  if (last >= 0 && firstAt >= 0) {
602    const [w0, w1] = [Math.floor(firstAt / 7), Math.floor(last / 7)]
603    const [x0, x1] = [left + w0 * pitch, left + w1 * pitch + size]
604    if (one) {
605      // About 6px a cell; where the name runs past the edge it goes before the mark.
606      const after = `▲ ${name}`
607      const fits = x0 + cells(after) * 6 <= width
608      marks.push(
609        `<text x="${fits ? x0 : x1}" y="${bottom + 12}" font-size="10" font-weight="700" fill="${HEAT.chosen}" font-family="sans-serif"${fits ? '' : ' text-anchor="end"'}>${fits ? after : `${name} ▲`}</text>`,
610      )
611    } else {
612      marks.push(`<path d="M${x0} ${bottom + 1}V${bottom + 6}H${x1}V${bottom + 1}" fill="none" stroke="#a8a8b0"/>`)
613      marks.push(`<text x="${(x0 + x1) / 2}" y="${bottom + 17}" font-size="10" fill="#d4d4d4" font-family="sans-serif" text-anchor="middle">${length}</text>`)
614    }
615  }
616
617  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${bottom + 22}">${[...months, ...names, ...rects, ...marks].join('')}</svg>`
618}
619
620// A spawn's key in the pane: its agent id, or for one recorded without, when it started.
621function spawnId(spawn: Spawn): string {
622  return spawn.agentId ?? String(spawn.at)
623}
624
625// Where a spawn's model came from: forked, named, or the default.
626function kindColor(spawn: Spawn): { ink: string; fill: string } {
627  return spawn.fork ? COLORS.fork : spawn.requested !== undefined ? COLORS.named : COLORS.default
628}
629
630// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
631function configIO($: EngineInterface): ConfigIO {
632  return {
633    read: async path => {
634      try {
635        return await $.fs.read(path)
636      } catch {
637        return ''
638      }
639    },
640    write: (path, text) => $.fs.write(path, text),
641    isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
642    configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
643    root: () => $.session.root(),
644    repoRoot: async () => (await $.session.repo())?.root,
645    sessionId: () => $.session.id(),
646    now: () => $.clock.now(),
647    storeGet: key => $.store.get(key),
648    storeSet: (key, value) => $.store.set(key, value),
649  }
650}
651// --- end configIO ---
652
653export const register: Register = (on, options) => {
654  // Re-read at session start and after `/subagent-dispatch set`.
655  let lang: Lang = 'en'
656  // A `-p` run has no pane: `stats` prints its report there.
657  let interactive = true
658  const ledger: LedgerState = { recording: true, writing: Promise.resolve(), sid: '', meta: { repo: '', started: 0, prompt: '' }, agents: new Map(), redraw: false }
659
660  on('session.start', async ($, e, next) => {
661    interactive = e.isInteractive
662    try {
663      const config = await loadConfig(configIO($), MOD, SPECS, options)
664      lang = config.lang
665      ledger.recording = config.values.ledger !== false
666    } catch (error) {
667      $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
668    }
669    try {
670      ledger.meta = { ...ledger.meta, repo: (await $.session.repo())?.root ?? e.cwd, started: await $.clock.now() }
671    } catch (error) {
672      $.ui.log(`session.start: repo not read: ${String(error)}`, { to: 'debug' })
673    }
674    // The day rows first: a ledger pruned now has its days in rows already.
675    try {
676      await writeDayRows($)
677    } catch (error) {
678      $.ui.log(`session.start: day rows not written: ${String(error)}`, { to: 'debug' })
679    }
680    try {
681      await pruneLedgers($)
682    } catch (error) {
683      $.ui.log(`session.start: ledgers not pruned: ${String(error)}`, { to: 'debug' })
684    }
685    try {
686      await $.command.register({
687        name: MOD,
688        description: pick(lang, {
689          en: 'Put the redispatch instructions for interrupted background agents in the prompt box (redispatch), or show token use by subagents and models (stats)',
690          'zh-TW': '把被中斷的背景 agent 重派指示放進輸入框(redispatch),或看 subagent 與各模型的 token 用量(stats)',
691        }),
692        argumentHint: pick(lang, { en: 'redispatch | stats [--text] [days] | backfill | status', 'zh-TW': 'redispatch | stats [--text] [天數] | backfill | status' }),
693      })
694    } catch (error) {
695      $.ui.log(`session.start: /${MOD} not registered: ${String(error)}`, { to: 'debug' })
696      $.ui.toast(pick(lang, { en: `${MOD}: /${MOD} was not registered (${String(error)})`, 'zh-TW': `${MOD}:/${MOD} 指令沒有註冊成功(${String(error)})` }))
697    }
698    $.clock.every(POLL_MS, () => void refresh($, lang))
699    // No hook runs at install: the first session to find no mark reads the transcripts, in the
700    // background. A `-p` run may end before it does, so it leaves them to the next session.
701    if (ledger.recording && interactive) {
702      startBackfill($, lang, ledger, false)
703    }
704
705    return next(e)
706  })
707
708  on('command.run', { command: MOD }, async ($, e) => {
709    if (e.args.trim().split(/\s+/)[0] === REDISPATCH) {
710      return { text: await fillRedispatch($, lang) }
711    }
712    const [command, ...rest] = e.args.trim().split(/\s+/)
713    if (command === STATS) {
714      ledger.snapshot = undefined
715
716      return { text: await stats($, lang, rest, interactive) }
717    }
718    if (command === BACKFILL) {
719      if (!ledger.recording) {
720        return { text: pick(lang, { en: 'The ledger setting is off; nothing to backfill into.', 'zh-TW': 'ledger 設定已關閉,不回填。' }) }
721      }
722
723      return {
724        text: startBackfill($, lang, ledger, true)
725          ? pick(lang, {
726              en: 'Reading the transcripts of sessions with no usage details, in the background; a toast says how many were added.',
727              'zh-TW': '在背景讀取還沒有用量明細的 session 的 transcript,補上後會跳 toast 說補了幾個。',
728            })
729          : pick(lang, { en: 'A backfill is already running.', 'zh-TW': '回填已經在跑。' }),
730      }
731    }
732    const text = await configCommand(configIO($), MOD, SPECS, options, e.args, resolved => {
733      lang = resolved.lang
734      ledger.recording = resolved.values.ledger !== false
735    })
736
737    return {
738      text:
739        text ??
740        pick(lang, {
741          en: `Usage: /${MOD} ${REDISPATCH}, /${MOD} ${STATS} [--text] [days], /${MOD} ${BACKFILL}, /${MOD} status`,
742          'zh-TW': `用法:/${MOD} ${REDISPATCH}、/${MOD} ${STATS} [--text] [天數]、/${MOD} ${BACKFILL}、/${MOD} status`,
743        }),
744    }
745  })
746
747  on('agent.spawn', async ($, e, next) => {
748    const spawned = await next(e)
749    if (spawned.deny !== undefined) {
750      return spawned
751    }
752    await record($, ledger, 'agent.spawn', (entries, at) =>
753      recordSpawn(entries, at, {
754        ...(spawned.agentId === undefined ? {} : { agentId: spawned.agentId }),
755        type: e.subagentType,
756        model: spawned.model,
757        ...(e.model === undefined ? {} : { requested: e.model }),
758        fork: e.fork || e.subagentType === 'fork',
759        background: e.background,
760        description: e.description,
761      }),
762    )
763    if (spawned.agentId !== undefined && ledger.recording) {
764      ledger.agents.set(spawned.agentId, await sessionKey($, ledger))
765      for (const old of [...ledger.agents.keys()].slice(0, -AGENT_KEYS)) {
766        ledger.agents.delete(old)
767      }
768    }
769
770    return spawned
771  })
772
773  // The session is named after its first prompt that is not a slash command; a resumed one
774  // keeps the name it has. Mods cannot read the session's own title.
775  on('prompt.submit', async ($, e, next) => {
776    await sessionKey($, ledger).catch(() => undefined)
777    if (ledger.meta.prompt === '' && ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && !e.text.trimStart().startsWith('/')) {
778      ledger.meta = { ...ledger.meta, prompt: promptSummary(e.text) }
779    }
780
781    return next(e)
782  })
783
784  on('turn.step', async function* ($, e, next) {
785    const result = yield* next(e)
786    const usage = result.usage
787    if (usage !== null) {
788      await record($, ledger, 'turn.step', (entries, at) =>
789        recordStep(entries, at, {
790          ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
791          model: usage.model || e.model,
792          input: usage.input_tokens,
793          cacheWrite: usage.cache_creation_input_tokens,
794          cacheRead: usage.cache_read_input_tokens,
795          output: usage.output_tokens,
796        }),
797        e.agentId,
798      )
799    }
800
801    return result
802  })
803
804  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
805    const ran = await next(e)
806    const result = ran.result as { status?: string; agentId?: string; resolvedModel?: string } | undefined
807    if (e.agentId === undefined && result?.status === 'async_launched' && result.agentId) {
808      const agent: WatchedAgent = {
809        id: result.agentId,
810        description: e.description,
811        type: e.subagent_type ?? 'general-purpose',
812        startedAt: await $.clock.now(),
813        status: 'running',
814        ...((e.model ?? result.resolvedModel) ? { model: e.model ?? result.resolvedModel } : {}),
815      }
816      await update($, running, list => [...list, agent])
817      await showStatus($, lang)
818    }
819
820    return ran
821  }).catch(($, e, next) => next(e))
822
823  // The band is one instance shared by every plugin: draw above what the plugins beneath drew
824  // (open-items), never in place of it.
825  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
826    const lost = await read($, stopped)
827    if (e.props.hasSurvey || lost.length === 0) {
828      return next(e)
829    }
830    const below = await next(e)
831    const { Box, Button, Text } = $.ui.resolve(e)
832
833    return (
834      <Box flexDirection="column">
835        <Box flexDirection="row">
836          <Text color={TONE.danger} wrap="truncate-end">
837            {pick(lang, { en: `${lost.length} agent(s) interrupted`, 'zh-TW': `${lost.length} 個 agent 被中斷` })}
838            {pick(lang, { en: ': ', 'zh-TW': ':' })}
839            {lost.map(agent => agent.description).join(pick(lang, { en: ', ', 'zh-TW': '、' }))}{' '}
840          </Text>
841          <Button key="redispatch" label={pick(lang, { en: 'Redispatch', 'zh-TW': '重派' })} hotkey="r" onPress={() => void fillRedispatch($, lang)} />
842          <Button key="dismiss" label={pick(lang, { en: 'Dismiss', 'zh-TW': '略過' })} hotkey="d" onPress={() => void update($, stopped, () => [])} />
843        </Box>
844        {below}
845      </Box>
846    )
847  })
848
849  // Laid out by the body's columns, never padded with spaces (a CJK character takes two cells).
850  // The overview: the heatmap, the period, its summary and a row a line; a row opens to its spawns,
851  // Back returns.
852  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
853    const parts = $.ui.resolve(e)
854    const { Box, Button, Text } = parts
855    // Desktop draws the bars as Svg; the terminal has no Svg and gets colored spaces. The runtime
856    // hands every constructor over, so the surface decides, not their presence.
857    const Svg = e.surface !== 'terminal' && 'Svg' in parts ? parts.Svg : undefined
858    const shown = await read($, view)
859    const by = (GROUP_BYS as readonly string[]).includes(shown.by) ? (shown.by as GroupBy) : 'repo'
860    const now = await $.clock.now()
861    const store = await paneStore($, ledger)
862    const body = e.props.bodyColumns
863    const today = dayOf(now)
864    // The period: `days` days to its last day, today unless moved back, never before the day rows reach.
865    const oldest = addDays(today, -(DAY_KEEP - 1))
866    const days = Number.isInteger(shown.days) && shown.days >= 1 && shown.days <= MAX_DAYS ? shown.days : DEFAULT_DAYS
867    const end = typeof shown.end === 'string' && shown.end >= oldest && shown.end < today ? shown.end : today
868    const begin = addDays(end, -(days - 1))
869    const report = windowReport(store.ledgers, store.dayRows, begin, end, now)
870    const heat = buildHeatmap(store.ledgers, store.dayRows, MAX_WEEKS, now).days
871    const all = report.main + report.sub
872    const rows = report.kept ? groups(lang, report, by) : []
873    const open = rows.find(row => row.key === shown.item)
874    const limit = Math.max(PAGE, Number(shown.limit) || PAGE)
875    const crumbLabel: Record<GroupBy, string> = {
876      repo: 'repo',
877      session: 'session',
878      model: pick(lang, { en: 'model', 'zh-TW': '模型' }),
879      type: pick(lang, { en: 'type', 'zh-TW': '種類' }),
880    }
881    const switcher = (key: string, label: string, hotkey: string, options: { value: string; label: string }[], value: string, onPick: (value: string) => void) =>
882      switchIn(parts, e.surface, key, label, hotkey, options, value, onPick)
883    // The period's days: one day by its name, more by their first and last.
884    const periodName = (tight = false) => (days === 1 ? dayHead(lang, end) : span(lang, begin, end, tight))
885    // As in open-items: the command's name over a dim rule at the top, here with when the ledgers were
886    // read at its right (the time alone where both do not fit), and the hint line at the foot. A dock
887    // is as tall as its room, so the frame fills it; an inline pane keeps its own height.
888    const heading = `${MOD} ${STATS}`
889    const updated = pick(lang, { en: `Updated ${clockTime(store.at)}`, 'zh-TW': `更新於 ${clockTime(store.at)}` })
890    const stamped = cells(heading) + cells(updated) + 6 <= body ? updated : clockTime(store.at)
891    const room = e.props.placement === 'dock' ? (e.props.scroll as { bodyRows?: number } | undefined)?.bodyRows : undefined
892    const frame = room === undefined ? {} : { minHeight: room }
893    const title = (
894      <Box key="title" flexDirection="row" gap={1} marginBottom={1}>
895        <Text bold>{heading}</Text>
896        <Text dimColor>{'─'.repeat(Math.max(1, body - cells(heading) - cells(stamped) - 2))}</Text>
897        <Text dimColor>{stamped}</Text>
898      </Box>
899    )
900    // A dim rule across the pane, under a list's column names or over an opened row's list.
901    const rule = (
902      <Box key="list-rule">
903        <Text dimColor>{'─'.repeat(body)}</Text>
904      </Box>
905    )
906    // A session by its first prompt.
907    const promptOf = (session: Report['sessions'][number]) => session.prompt || pick(lang, { en: '(no prompt)', 'zh-TW': '(沒有 prompt)' })
908
909    // A bar of `width` cells shared by `of`: colored spaces in the terminal, an Svg on desktop.
910    const stack = (of: Part[], width: number) => {
911      const shares = splitCells(of.map(part => part.value), width)
912      if (Svg !== undefined) {
913        return <Svg source={svgStack(of.map((part, i) => ({ cells: shares[i] ?? 0, fill: part.fill })))} alt={of.map(part => `${part.label} ${n(part.value)}`).join(', ')} width={width * 8} height={8} />
914      }
915
916      return <Text>{of.map((part, i) => ((shares[i] ?? 0) === 0 ? '' : <Text backgroundColor={part.ink}>{' '.repeat(shares[i] ?? 0)}</Text>))}</Text>
917    }
918    // A list row's bar: `cells` of `width` in `▆`, lines apart from the rows above and below
919    // (DESIGN.md「長條」), parts in their own colors.
920    const rowBar = (of: { cells: number; ink: string; fill: string }[], width: number) => {
921      if (Svg !== undefined) {
922        return <Svg source={svgStack(of)} alt="" width={Math.max(1, of.reduce((sum, part) => sum + part.cells, 0)) * 8} height={8} />
923      }
924
925      return <Text>{of.map(part => (part.cells === 0 ? '' : <Text color={part.ink}>{'▆'.repeat(part.cells)}</Text>))}</Text>
926    }
927    // A figure in two lines: its total and unit, what it is made of at the right where that fits, and
928    // a bar the width of the pane.
929    const figure = (key: string, total: string, unit: string, of: Part[], listed: Part[], count: (value: number) => string) => {
930      const aside = listed.map(part => `${part.label} ${count(part.value)}`).join('  ')
931      const fits = cells(`${total} ${unit}`) + 2 + cells(aside) <= body
932
933      return [
934        <Box key={`${key}-line`} flexDirection="row" justifyContent="space-between">
935          <Text>
936            <Text bold>{total}</Text> <Text dimColor>{unit}</Text>
937          </Text>
938          {fits ? <Text>{listed.map((part, i) => [i === 0 ? '' : '  ', <Text color={part.ink}>{part.label}</Text>, ` ${count(part.value)}`])}</Text> : null}
939        </Box>,
940        <Box key={`${key}-bar`}>{stack(of, body)}</Box>,
941      ]
942    }
943    const tokenParts = (main: number, sub: number): Part[] => [
944      { key: 'main', label: pick(lang, { en: 'main', 'zh-TW': '主' }), value: main, ...COLORS.main },
945      { key: 'sub', label: 'sub', value: sub, ...COLORS.sub },
946    ]
947    // An opened session's main tokens by model, most first, in alternating shades of purple.
948    const modelParts = (byModel: [string, number][]): Part[] =>
949      [...byModel]
950        .sort((a, b) => b[1] - a[1])
951        .map(([model, value], i) => ({ key: `main:${model}`, label: model, value, ...MAIN_SHADES[i % MAIN_SHADES.length]! }))
952    const kindParts = (s: Report['spawned']): Part[] => [
953      { key: 'named', label: pick(lang, { en: 'named', 'zh-TW': '指定' }), value: s.explicit, ...COLORS.named },
954      { key: 'default', label: pick(lang, { en: 'default', 'zh-TW': '預設' }), value: s.defaults, ...COLORS.default },
955      { key: 'fork', label: 'fork', value: s.forks, ...COLORS.fork },
956    ]
957    // The tokens and the spawns, each a figure, then the cache read they leave out.
958    const summary = (made: Part[], spawned: Report['spawned'], cacheRead: number) => (
959      <Box key="summary" flexDirection="column" marginTop={1}>
960        {figure('tokens', shortCount(made.reduce((sum, part) => sum + part.value, 0)), 'token', made, made.filter(part => part.value > 0), shortCount)}
961        {spawned.all === 0 ? null : figure('spawns', String(spawned.all), pick(lang, { en: 'spawns', 'zh-TW': '次派出' }), kindParts(spawned), kindParts(spawned), String)}
962        <Box key="cache-read">
963          <Text dimColor>{pick(lang, { en: `Cache read ${shortCount(cacheRead)}, not counted`, 'zh-TW': `快取讀取 ${shortCount(cacheRead)} 另計` })}</Text>
964        </Box>
965      </Box>
966    )
967    const note = (key: string, text: string) => (
968      <Box key={key} marginTop={1}>
969        {emptyState(parts, text)}
970      </Box>
971    )
972
973    if (open !== undefined) {
974      const sort: SortBy = shown.sort in SORTS ? (shown.sort as SortBy) : 'tokens'
975      const listed = open.detail
976        .flatMap(part => part.spawns.map(spawn => ({ spawn, session: part.session })))
977        .sort((a, b) => SORTS[sort](b.spawn) - SORTS[sort](a.spawn))
978      const openSub = open.tokens - open.main
979      const counted = countKinds(listed.map(item => item.spawn))
980      const top = Math.max(1, ...listed.map(item => tokens(item.spawn)))
981      const barWidth = body >= WIDE_COLUMNS ? 16 : 8
982      // A row with no spawns lists its sessions instead, by tokens or by when they started.
983      const bySessions = listed.length === 0
984      const sessionRows = bySessions ? [...open.detail].sort((a, b) => (sort === 'time' ? b.session.started - a.session.started : b.main - a.main)) : []
985      const topMain = Math.max(1, ...sessionRows.map(part => part.main))
986      const count = bySessions ? sessionRows.length : listed.length
987      const cellsOf = (part: number, whole: number) => barCells(part, whole, barWidth)
988      // What a row's name leaves the rest: the dot, the bar, the tokens (9), the date (6) and four gaps.
989      // The name is cut to it by cells, with two to spare for what the terminal draws around a button:
990      // a Button's `truncate-end` wraps instead once CJK and Latin mix (DESIGN.md「控制項」).
991      const nameCells = Math.max(1, body - 20 - barWidth - 2)
992
993      return (
994        <Box flexDirection="column" {...frame}>
995          {title}
996          {/* The back button and the crumb keep their width; only the row's name gives way. */}
997          <Box key="crumb" flexDirection="row" gap={1}>
998            <Box flexShrink={0}>
999              <Button key="back" plain hotkey="b" onPress={() => void update($, view, current => ({ ...current, item: '', limit: PAGE }))}>
1000                <Text>{pick(lang, { en: '‹ Back', 'zh-TW': '‹ 返回' })}</Text>
1001              </Button>
1002            </Box>
1003            <Box flexShrink={0}>
1004              <Text dimColor> {crumbLabel[by]} ›</Text>
1005            </Box>
1006            <Box flexShrink={1} minWidth={0}>
1007              <Text bold wrap="truncate-end">
1008                {open.label}
1009              </Text>
1010            </Box>
1011          </Box>
1012          {/* The period as the overview set it, to read only. */}
1013          <Box key="period" marginTop={1}>
1014            <Text wrap="truncate-end">
1015              <Text dimColor>{pick(lang, { en: 'Period ', 'zh-TW': '期間 ' })}</Text>
1016              {daysLabel(lang, days)}
1017              <Text dimColor> · </Text>
1018              <Text bold>{periodName()}</Text>
1019            </Text>
1020          </Box>
1021          {summary(
1022            by === 'session' ? [...modelParts(open.detail.flatMap(part => part.session.mainByModel)), ...tokenParts(open.main, openSub).slice(1)] : tokenParts(open.main, openSub),
1023            counted,
1024            open.cacheRead,
1025          )}
1026          {/* Under a rule: the sort, the column names, then a row per spawn, or for a row with no spawns
1027              (a model only the main session used, or the main session by type) a row per session. */}
1028          <Box key="list" flexDirection="column" marginTop={1}>
1029            {rule}
1030            <Box>
1031              {switcher(
1032                'sort',
1033                pick(lang, { en: 'Sort ', 'zh-TW': '排序 ' }),
1034                's',
1035                [
1036                  { value: 'tokens', label: pick(lang, { en: 'most tokens', 'zh-TW': 'token 多到少' }) },
1037                  { value: 'time', label: pick(lang, { en: 'time', 'zh-TW': '時間' }) },
1038                  ...(bySessions ? [] : [{ value: 'duration', label: pick(lang, { en: 'duration', 'zh-TW': '耗時' }) }]),
1039                ],
1040                sort,
1041                value => void update($, view, current => ({ ...current, sort: value, limit: PAGE })),
1042              )}
1043            </Box>
1044            <Box key="list-columns" flexDirection="row" gap={1} marginTop={1}>
1045              <Box width={1} flexShrink={0} />
1046              <Box flexGrow={1} flexShrink={1}>
1047                <Text dimColor>{bySessions ? 'session' : pick(lang, { en: 'Spawns', 'zh-TW': '派出' })}</Text>
1048              </Box>
1049              <Box width={barWidth} flexShrink={0} />
1050              <Box width={9} flexShrink={0} justifyContent="flex-end">
1051                <Text dimColor>token</Text>
1052              </Box>
1053              <Box width={6} flexShrink={0} justifyContent="flex-end">
1054                <Text dimColor>{pick(lang, { en: 'date', 'zh-TW': '日期' })}</Text>
1055              </Box>
1056            </Box>
1057            {sessionRows.slice(0, limit).map(part => {
1058              const id = `${part.session.repo}|${part.session.started}`
1059
1060              return (
1061                <Box key={`session:${id}`} flexDirection="row" gap={1}>
1062                  <Box width={1} flexShrink={0}>
1063                    <Text color={COLORS.main.ink}>●</Text>
1064                  </Box>
1065                  <Box flexGrow={1} flexShrink={1}>
1066                    <Text wrap="truncate-end">{fit(promptOf(part.session), nameCells)}</Text>
1067                  </Box>
1068                  <Box width={barWidth} flexShrink={0}>
1069                    {rowBar([{ cells: cellsOf(part.main, topMain), ...COLORS.main }], barWidth)}
1070                  </Box>
1071                  <Box width={9} flexShrink={0} justifyContent="flex-end">
1072                    <Text>{n(part.main)}</Text>
1073                  </Box>
1074                  <Box width={6} flexShrink={0} justifyContent="flex-end">
1075                    <Text dimColor>{dayLabel(lang, part.session.started)}</Text>
1076                  </Box>
1077                </Box>
1078              )
1079            })}
1080            {listed.slice(0, limit).map(({ spawn, session }) => {
1081              const id = spawnId(spawn)
1082              const color = kindColor(spawn)
1083              const how = spawn.fork ? 'fork' : spawn.requested === undefined ? pick(lang, { en: 'default', 'zh-TW': '預設' }) : pick(lang, { en: 'named', 'zh-TW': '指定' })
1084              const lines: [string, string, RenderNode][] = [
1085                ['type', pick(lang, { en: 'Type', 'zh-TW': '種類' }), spawn.type],
1086                [
1087                  'model',
1088                  pick(lang, { en: 'Model', 'zh-TW': '模型' }),
1089                  <Text wrap="truncate-end">
1090                    <Text color={color.ink}>{how}</Text>
1091                    {spawn.requested === undefined || spawn.fork ? '' : ` ${spawn.requested}`} → {spawn.model}
1092                  </Text>,
1093                ],
1094                [
1095                  'run',
1096                  pick(lang, { en: 'Run', 'zh-TW': '執行' }),
1097                  [
1098                    ...(spawn.background ? [pick(lang, { en: 'background', 'zh-TW': '背景' })] : []),
1099                    pick(lang, { en: `${spawn.steps} steps`, 'zh-TW': `${spawn.steps} 步` }),
1100                    elapsed(spawn.last - spawn.at),
1101                  ].join(' · '),
1102                ],
1103                ['session', 'session', sessionName(lang, session)],
1104              ]
1105
1106              return [
1107                <Box key={`line:${id}`} flexDirection="row" gap={1}>
1108                  <Box key={`dot:${id}`} width={1} flexShrink={0}>
1109                    <Text color={kindColor(spawn).ink}>●</Text>
1110                  </Box>
1111                  <Box flexGrow={1} flexShrink={1}>
1112                    <Button key={`spawn:${id}`} plain onPress={() => void update($, view, current => ({ ...current, spawn: current.spawn === id ? '' : id }))}>
1113                      <Text>{fit(spawnParts(lang, spawn)[0], nameCells)}</Text>
1114                    </Button>
1115                  </Box>
1116                  <Box width={barWidth} flexShrink={0}>
1117                    {rowBar([{ cells: cellsOf(tokens(spawn), top), ...COLORS.sub }], barWidth)}
1118                  </Box>
1119                  <Box width={9} flexShrink={0} justifyContent="flex-end">
1120                    <Text>{n(tokens(spawn))}</Text>
1121                  </Box>
1122                  <Box width={6} flexShrink={0} justifyContent="flex-end">
1123                    <Text dimColor>{dayLabel(lang, spawn.at)}</Text>
1124                  </Box>
1125                </Box>,
1126                shown.spawn === id ? (
1127                  // Opened in place: a line in the color of where its model came from runs down the left
1128                  // (a Box border cannot be drawn on one side only).
1129                  <Box key={`detail:${id}`} flexDirection="column" marginLeft={1} marginTop={1} marginBottom={1}>
1130                    {lines.map(([key, name, value]) => (
1131                      <Box key={`detail:${id}:${key}`} flexDirection="row" gap={1}>
1132                        <Box width={2} flexShrink={0}>
1133                          <Text color={color.ink}>│</Text>
1134                        </Box>
1135                        <Box width={8} flexShrink={0}>
1136                          <Text dimColor>{name}</Text>
1137                        </Box>
1138                        <Box flexShrink={1}>{typeof value === 'string' ? <Text wrap="truncate-end">{value}</Text> : value}</Box>
1139                      </Box>
1140                    ))}
1141                  </Box>
1142                ) : null,
1143              ]
1144            })}
1145            {count > limit ? (
1146              <Box paddingLeft={2}>
1147                <Button key="more" plain hotkey="m" onPress={() => void update($, view, current => ({ ...current, limit: limit + PAGE }))}>
1148                  <Text>{pick(lang, { en: `${count - limit} more ▸`, 'zh-TW': `還有 ${count - limit} 筆 ▸` })}</Text>
1149                </Button>
1150              </Box>
1151            ) : null}
1152          </Box>
1153          <Box key="spacer" flexGrow={1} />
1154          {hintLine(parts, lang, [
1155            // The back, sort and more buttons already show their keys.
1156            { en: 'Tab moves', 'zh-TW': 'Tab 移動' },
1157            ...(bySessions ? [] : [{ en: 'Enter opens a spawn', 'zh-TW': 'Enter 展開一筆' }]),
1158          ])}
1159        </Box>
1160      )
1161    }
1162
1163    // The heatmap: a cell a day, a week a column, colored by use alone (dimming would read as gray, as
1164    // no use does), and under it the period's mark beside as much of the legend as fits.
1165    const heatmap = () => {
1166      if (body < HEAT_COLUMNS) {
1167        return null
1168      }
1169      const weeks = heatWeeks(body)
1170      const first = heatStart(weeks, now)
1171      const drawn = heat.filter(item => item.day >= first)
1172      const level = heatLevels(drawn)
1173      const one = (day: string) => days === 1 && day === end
1174      const peak = drawn.reduce<HeatDay | undefined>((most, item) => ((item.tokens ?? 0) > (most?.tokens ?? 0) ? item : most), undefined)
1175      const ink = (item: HeatDay) => (one(item.day) ? HEAT.chosen : item.tokens === 0 ? HEAT.none : HEAT.levels[level(item.tokens) - 1])
1176      const weekOf = (day: string) => Math.floor((daysBetween(first, day) - 1) / 7)
1177      const lengthLabel = daysLabel(lang, days)
1178      const legend: { width: number; node: RenderNode }[] = [
1179        {
1180          width: cells(pick(lang, { en: 'Less ■■■■ More', 'zh-TW': '少 ■■■■ 多' })),
1181          node: (
1182            <Text>
1183              <Text dimColor>{pick(lang, { en: 'Less ', 'zh-TW': '少 ' })}</Text>
1184              {HEAT.levels.map(color => (
1185                <Text color={color}>■</Text>
1186              ))}
1187              <Text dimColor>{pick(lang, { en: ' More', 'zh-TW': ' 多' })}</Text>
1188            </Text>
1189          ),
1190        },
1191        {
1192          width: cells(pick(lang, { en: 'None ■', 'zh-TW': '無用量 ■' })),
1193          node: (
1194            <Text>
1195              <Text dimColor>{pick(lang, { en: 'None ', 'zh-TW': '無用量 ' })}</Text>
1196              <Text color={HEAT.none}>■</Text>
1197            </Text>
1198          ),
1199        },
1200        { width: cells(pick(lang, { en: 'No data ·', 'zh-TW': '無資料 ·' })), node: <Text dimColor>{pick(lang, { en: 'No data ·', 'zh-TW': '無資料 ·' })}</Text> },
hooks/config.ts 792 lines
1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23  return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28  return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33  return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45  category: Category
46  type: 'boolean' | 'number' | 'string' | 'list'
47  default: Value
48  repo: RepoRule
49  description: string | Text
50  min?: number
51  max?: number
52  // The values a string may take.
53  choices?: readonly string[]
54  // A feature `/<mod> on|off` switches: the value each one sets.
55  toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63  category: 'plain',
64  type: 'string',
65  default: 'en',
66  repo: 'never',
67  choices: ['en', 'zh-TW'],
68  description: {
69    en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70    'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71  },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77  // The language the values come to (`language`, English without it).
78  lang: Lang
79  values: Readonly<Record<string, Value>>
80  sources: Readonly<Record<string, Layer>>
81  ignored: readonly Ignored[]
82  // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83  until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91  options: PluginOptions
92  user: Raw
93  repo: Raw
94  local: Raw
95  session: Raw
96  // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97  until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115  default: { en: 'default', 'zh-TW': '預設' },
116  user: { en: 'user', 'zh-TW': '使用者' },
117  repo: { en: 'repo', 'zh-TW': 'repo' },
118  local: { en: 'local', 'zh-TW': 'local' },
119  session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123  protect: { en: 'protection', 'zh-TW': '保護' },
124  cost: { en: 'cost', 'zh-TW': '花費' },
125  plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129  any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130  'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131  'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132  'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133  'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134  append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135  never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139  return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143  return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150  switch (spec.type) {
151    case 'boolean':
152      return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153    case 'number': {
154      if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155        return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156      }
157      if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158        const min = spec.min ?? '-∞'
159        const max = spec.max ?? '∞'
160
161        return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162      }
163
164      return { value: raw }
165    }
166    case 'string':
167      if (typeof raw !== 'string') {
168        return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169      }
170      if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171        return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172      }
173
174      return { value: raw }
175    case 'list':
176      return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177        ? { value: raw as readonly string[] }
178        : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179  }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185  const t = text.trim()
186  switch (spec.type) {
187    case 'boolean': {
188      const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189      const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191      return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192    }
193    case 'number':
194      return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195    case 'string':
196      return parseValue(spec, t, lang)
197    case 'list': {
198      if (t.startsWith('[')) {
199        try {
200          return parseValue(spec, JSON.parse(t), lang)
201        } catch {
202          return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203        }
204      }
205
206      return { value: t === '' ? [] : [t] }
207    }
208  }
209}
210
211function same(a: Value, b: Value): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217  // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218  if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219    return { value }
220  }
221  switch (spec.repo) {
222    case 'only-true':
223      return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224    case 'only-false':
225      return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226    case 'only-lower':
227      return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228    case 'only-higher':
229      return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230    case 'append':
231      return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232    case 'never':
233      return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234  }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240  return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244  let value = spec.default
245  let source: Layer = 'default'
246  const take = (layer: Layer, raw: unknown) => {
247    if (raw === undefined) {
248      return
249    }
250    const parsed = parseValue(spec, raw, lang)
251    if ('error' in parsed) {
252      ignored.push({ key, layer, reason: parsed.error })
253
254      return
255    }
256    const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257    if ('error' in applied) {
258      ignored.push({ key, layer, reason: applied.error })
259
260      return
261    }
262    if (layer === 'user' && same(applied.value, spec.default)) {
263      // `/config` hands over defaults filled in: the same value is no choice of the person's.
264      return
265    }
266    value = applied.value
267    source = layer
268  }
269  // Lists have no `/config` row; their user layer is the file.
270  take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271  take('repo', layers.repo[key])
272  take('local', layers.local[key])
273  take('session', layers.session[key])
274
275  return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279  const values: Record<string, Value> = {}
280  const sources: Record<string, Layer> = {}
281  const ignored: Ignored[] = []
282  // The language first: the other keys' reasons are worded in it.
283  const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284  const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285  for (const [key, spec] of Object.entries(specs)) {
286    const one = resolveKey(key, spec, layers, lang, ignored)
287    values[key] = one.value
288    sources[key] = one.source
289  }
290  for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291    for (const key of Object.keys(layers[layer])) {
292      const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293      if (spec === undefined) {
294        ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295      } else if (layer === 'user' && !isFileValue(key, spec)) {
296        ignored.push({
297          key,
298          layer,
299          reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300        })
301      }
302    }
303  }
304
305  const until: Record<string, number> = {}
306  for (const [key, at] of Object.entries(layers.until ?? {})) {
307    if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308      until[key] = at
309    }
310  }
311
312  return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317  const t = text.trim().toLowerCase()
318  const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319  if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320    return undefined
321  }
322  const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324  return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329  const minutes = Math.ceil(ms / 60_000)
330  if (ms < 60_000) {
331    return '<1m'
332  }
333  const h = Math.floor(minutes / 60)
334  const m = minutes % 60
335
336  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341  const parts = Object.entries(until)
342    .filter(([, at]) => at > now)
343    .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345  return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350  const times = Object.values(resolved.until)
351
352  return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356  return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360  const lang = resolved.lang
361  const keys = Object.keys(specs)
362  if (keys.length === 0) {
363    return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364  }
365  const lines = [
366    pick(lang, {
367      en: `${mod} settings (precedence: session > local > repo > user > default)`,
368      'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369    }),
370  ]
371  for (const key of keys) {
372    const spec = specs[key] as KeySpec
373    const value = resolved.values[key] as Value
374    const source = resolved.sources[key] as Layer
375    const at = resolved.until[key]
376    const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377    const kind = pick(lang, CATEGORY_NAMES[spec.category])
378    const rule = pick(lang, RULE_NAMES[spec.repo])
379    lines.push(`  ${key} = ${show(value)}  [${pick(lang, LAYER_NAMES[source])}${left}]  ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380    lines.push(`    ${say(lang, spec.description)}`)
381  }
382  if (resolved.ignored.length > 0) {
383    lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384    for (const item of resolved.ignored) {
385      const layer = pick(lang, LAYER_NAMES[item.layer])
386      lines.push(pick(lang, { en: `  ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': `  ${layer} 的 ${item.key}:${item.reason}` }))
387    }
388  }
389  lines.push(
390    pick(lang, {
391      en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392      'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393    }),
394  )
395  const toggles = toggleKeys(specs)
396  if (toggles.length > 0) {
397    const names = listOf(lang, toggles)
398    const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399    lines.push(
400      pick(lang, {
401        en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402        'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403      }),
404    )
405  }
406
407  return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411  if (text.trim() === '') {
412    return {}
413  }
414  const parsed: unknown = JSON.parse(text)
415  const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417  return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423  const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424  const section = { ...modSection(text, mod) }
425  if (value === undefined) {
426    delete section[key]
427  } else {
428    section[key] = value
429  }
430  if (Object.keys(section).length === 0) {
431    delete all[mod]
432  } else {
433    all[mod] = section
434  }
435
436  return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440  const line = `/${LOCAL_FILE}`
441  if (text.split('\n').some(l => l.trim() === line)) {
442    return undefined
443  }
444
445  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452  const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453  if (targets.length > 1) {
454    return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455  }
456  const text = args.replace(TARGET_FLAG, '$1').trim()
457  const key = text.split(/\s+/)[0] ?? ''
458  if (key === '') {
459    return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460  }
461
462  return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470  // The file's text, '' when it is missing.
471  read: (path: string) => Promise<string>
472  write: (path: string, text: string) => Promise<void>
473  isDir: (path: string) => Promise<boolean>
474  // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475  configDir: () => Promise<string>
476  root: () => Promise<string>
477  // The main working tree, whose `.git/info/exclude` covers every worktree.
478  repoRoot: () => Promise<string | undefined>
479  sessionId: () => Promise<string>
480  now: () => Promise<number>
481  storeGet: (key: string) => Promise<unknown>
482  storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490  try {
491    const own = modSection(text, mod)
492    const shared = modSection(text, SHARED)
493    for (const key of Object.keys(shared)) {
494      if (key !== 'language') {
495        broken.push({
496          key: `${SHARED}.${key}`,
497          layer,
498          reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499        })
500      }
501    }
502
503    return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504  } catch {
505    broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507    return {}
508  }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517  const now = await io.now()
518  const kept: Record<string, T & { written: number }> = {}
519  for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520    if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521      kept[other] = stored
522    }
523  }
524  kept[id] = { ...entry, written: now }
525  await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529  const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531  return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536  const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537  if (stored === undefined) {
538    return { values: {}, until: {} }
539  }
540  const timed = Object.entries(stored.until ?? {})
541  // Read only when needed: a mod without features to switch never asks the clock.
542  const now = timed.length === 0 ? 0 : await io.now()
543  const values: Record<string, unknown> = { ...stored.values }
544  const until: Record<string, number> = {}
545  for (const [key, at] of timed) {
546    if (at > now) {
547      until[key] = at
548    } else {
549      delete values[key]
550    }
551  }
552
553  return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557  const current = await sessionValues(io)
558  const values: Record<string, unknown> = { ...current.values }
559  const times = { ...current.until }
560  delete values[key]
561  delete times[key]
562  if (value !== undefined) {
563    values[key] = value
564  }
565  if (value !== undefined && until !== undefined) {
566    times[key] = until
567  }
568  await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573  const sessionId = await io.sessionId()
574  const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575  if (keys.includes(key)) {
576    return false
577  }
578  await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580  return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584  const root = (await io.root()).replace(/\/$/, '')
585
586  return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590  const paths = await filePaths(io)
591  const broken: Problem[] = []
592  const session = await sessionValues(io)
593  const layers = {
594    options,
595    user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596    repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597    local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598    session: session.values,
599    until: session.until,
600  }
601
602  return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606  const { layers, broken } = await loadLayers(io, mod, options)
607  const resolved = resolve(specs, layers)
608
609  const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611  return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615  if (target === 'session') {
616    await writeSession(io, key, value, undefined)
617
618    return
619  }
620  const paths = await filePaths(io)
621  const path = target === 'repo' ? paths.repo : paths.local
622  await io.write(path, withSetting(await io.read(path), mod, key, value))
623  if (target === 'local') {
624    const repo = await io.repoRoot()
625    if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626      const exclude = `${repo}/.git/info/exclude`
627      const added = withLocalExclude(await io.read(exclude))
628      if (added !== undefined) {
629        await io.write(exclude, added)
630      }
631    }
632  }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638  io: ConfigIO,
639  mod: string,
640  specs: Specs,
641  options: PluginOptions,
642  args: string,
643  reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645  const sub = args.trim().split(/\s+/)[0] ?? ''
646  const rest = args.trim().slice(sub.length)
647  if (sub === 'status') {
648    const resolved = await loadConfig(io, mod, specs, options)
649    reload(resolved)
650
651    return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652  }
653  if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654    return switchFeature(io, mod, specs, options, sub, rest, reload)
655  }
656  if (sub !== 'set' && sub !== 'unset') {
657    return undefined
658  }
659  const before = await loadLayers(io, mod, options)
660  const lang = resolve(specs, before.layers).lang
661  const parsed = parseSetArgs(rest, lang)
662  if ('error' in parsed) {
663    return parsed.error
664  }
665  const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666  if (spec === undefined) {
667    return pick(lang, {
668      en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669      'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670    })
671  }
672  let value: Value | undefined
673  if (sub === 'set') {
674    const input = parseInput(spec, parsed.rest, lang)
675    if ('error' in input) {
676      return `${parsed.key} ${input.error}`
677    }
678    value = input.value
679  }
680  if (parsed.target === 'repo' && value !== undefined) {
681    // Do not commit a value every reader would ignore.
682    const { layers } = before
683    const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684    const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685    if (refused !== undefined) {
686      return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687    }
688  }
689  await writeSetting(io, mod, parsed.key, value, parsed.target)
690  const resolved = await loadConfig(io, mod, specs, options)
691  reload(resolved)
692  const shown = show(resolved.values[parsed.key] as Value)
693  const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694  const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695  const where =
696    parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697  const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699  return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703  return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708  const toggles = toggleKeys(specs)
709  let key: string | undefined
710  let ms: number | undefined
711  for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712    if (key === undefined && toggles.includes(word)) {
713      key = word
714      continue
715    }
716    const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717    if (duration === undefined) {
718      const names = listOf(lang, toggles)
719      const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721      return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722    }
723    ms = duration
724  }
725  if (key === undefined && toggles.length === 1) {
726    key = toggles[0]
727  }
728  if (key === undefined) {
729    return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730  }
731
732  return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736  io: ConfigIO,
737  mod: string,
738  specs: Specs,
739  options: PluginOptions,
740  sub: 'on' | 'off',
741  args: string,
742  reload: (resolved: Resolved) => void,
743): Promise<string> {
744  const { layers } = await loadLayers(io, mod, options)
745  const lang = resolve(specs, layers).lang
746  const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747  if ('error' in parsed) {
748    return parsed.error
749  }
750  const { key, ms } = parsed
751  const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752  const rest: Record<string, unknown> = { ...layers.session }
753  delete rest[key]
754  const below = resolve(specs, { ...layers, session: rest })
755  const belowValue = below.values[key] as Value
756  const isOnBelow = !same(belowValue, toggle.off)
757  const now = await io.now()
758  const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759  if (sub === 'on') {
760    if (isOnBelow) {
761      const wasOff = Object.hasOwn(layers.session, key)
762      await writeSession(io, key, undefined, undefined)
763      reload(await loadConfig(io, mod, specs, options))
764      const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766      return wasOff
767        ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768        : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769    }
770    await writeSession(io, key, toggle.on, now + ms)
771    reload(await loadConfig(io, mod, specs, options))
772    const at = new Date(now + ms)
773    const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774    const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776    return pick(lang, {
777      en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778      'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779    })
780  }
781  // Off for the rest of the session; nothing to keep when the layers below are off already.
782  await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783  reload(await loadConfig(io, mod, specs, options))
784
785  return isOnBelow
786    ? pick(lang, {
787        en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788        'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789      })
790    : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792
hooks/layout.ts 102 lines
1// How the stats pane lays itself out by width (DESIGN.md「欄位清單」「日曆熱圖」「期間」): pure,
2// so each rule is tested without drawing.
3import { addDays, cells } from './ledger'
4
5// The heatmap's weekday labels take this many columns before the first week.
6export const HEAT_LABELS = 4
7
8// A column of the overview list: its width in cells and how much it matters (1 most).
9export type Column = { key: string; width: number; priority: number }
10
11// The columns that fit beside a name of `nameMin` cells, two cells apart, taken by priority and
12// kept in their own order. The first that does not fit stops the rest, so which columns show does
13// not jump around as the pane is resized. `used` is the cells they take with their gaps.
14export function fitColumns<C extends Column>(columns: C[], body: number, nameMin: number): { shown: C[]; used: number } {
15  const on = new Set<string>()
16  let used = 0
17  for (const column of [...columns].sort((a, b) => a.priority - b.priority)) {
18    if (nameMin + used + 2 + column.width > body) {
19      break
20    }
21    on.add(column.key)
22    used += 2 + column.width
23  }
24
25  return { shown: columns.filter(column => on.has(column.key)), used }
26}
27
28// A list row's bar: 12% of the width, 6 to 14 cells.
29export function listBarWidth(body: number): number {
30  return Math.min(14, Math.max(6, Math.round(body * 0.12)))
31}
32
33// The period's last day moved by its own length, back (`-1`) or forward (`1`): never after
34// `today`, never before `first`.
35export function movePeriod(end: string, days: number, step: -1 | 1, today: string, first: string): string {
36  const next = addDays(end, step * days)
37
38  return next > today ? today : next < first ? first : next
39}
40
41// Where the period is marked under the heatmap, by the weeks of its first and last day (negative
42// before the first week drawn): a bracket over its weeks with `label` inside where it fits, or for
43// one day (`name` given) a `▲` under it with the day's name after it, or before it where the line
44// ends first. `at` is the column it starts at, the weekday labels included.
45export function periodMark(body: number, firstWeek: number, lastWeek: number, label: string, name: string | undefined): { at: number; text: string } | undefined {
46  if (lastWeek < 0) {
47    return undefined
48  }
49  if (name !== undefined) {
50    const at = HEAT_LABELS + lastWeek * 2
51    const after = `▲ ${name}`
52    if (at + cells(after) <= body) {
53      return { at, text: after }
54    }
55    const before = `${name} ▲`
56
57    return { at: at - cells(before) + 1, text: before }
58  }
59  const from = Math.max(0, firstWeek)
60  const width = (lastWeek - from) * 2 + 1
61  const at = HEAT_LABELS + from * 2
62  if (width < 3) {
63    return { at, text: '▲' }
64  }
65  const inner = width - 2
66  const inside = ` ${label} `
67  if (inner < cells(inside) + 2) {
68    return { at, text: `└${'─'.repeat(inner)}┘` }
69  }
70  const dashes = inner - cells(inside)
71  const left = Math.floor(dashes / 2)
72
73  return { at, text: `└${'─'.repeat(left)}${inside}${'─'.repeat(dashes - left)}┘` }
74}
75
76// How many of the legend's items (`widths`, in order, two cells apart) fit on the mark's line, and
77// the column they start at: left of the mark after the weekday labels, else right of it flush with
78// the edge, else none. An item that does not fit stops the rest.
79export function fitLegend(body: number, mark: { at: number; width: number } | undefined, widths: number[]): { count: number; at: number } {
80  const fit = (room: number) => {
81    let count = 0
82    let used = 0
83    for (const width of widths) {
84      const need = count === 0 ? width : used + 2 + width
85      if (need > room) {
86        break
87      }
88      count += 1
89      used = need
90    }
91
92    return { count, used }
93  }
94  const left = fit((mark?.at ?? body) - 2 - HEAT_LABELS)
95  if (left.count > 0) {
96    return { count: left.count, at: HEAT_LABELS }
97  }
98  const right = fit(body - ((mark === undefined ? body : mark.at + mark.width) + 2))
99
100  return right.count > 0 ? { count: right.count, at: body - right.used } : { count: 0, at: 0 }
101}
102
hooks/ui.tsx 114 lines
1// The parts every mod's screens share, as DESIGN.md sets them out. Mods cannot import outside their
2// own folder, so each mod that draws a pane or a band keeps a copy; scripts/check-copies.sh checks
3// they match.
4import type { Elements, RenderNode } from 'claude-code'
5
6import { pick, type Lang } from './config'
7
8type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>
9type Words = { en: string; 'zh-TW': string }
10
11// What a color means: yellow waits on the user, red is an error, an interruption or a risk.
12export const TONE = { attention: 'yellow', danger: 'red' } as const
13
14// A card, and the yellow frame of a pane that holds Claude until the user answers.
15export const CARD = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
16export const CONFIRM = { borderStyle: 'round', borderColor: TONE.attention, paddingX: 1 } as const
17
18// Where a part of a bar comes from: `ink` on the terminal, `fill` in desktop's Svg. Main session
19// purple, subagent cyan, named model yellow, default model gray, fork green.
20export const COLORS = {
21  main: { ink: 'magenta', fill: '#8a5cc7' },
22  sub: { ink: 'cyan', fill: '#2f8fb0' },
23  named: { ink: 'yellow', fill: '#c98a12' },
24  default: { ink: 'gray', fill: '#8b8b93' },
25  fork: { ink: 'green', fill: '#3f9a4f' },
26} as const
27
28// The main session's parts (its models), one after another in the same bar.
29export const MAIN_SHADES = [COLORS.main, { ink: 'magentaBright', fill: '#b48ee6' }, { ink: 'magenta', fill: '#6b3fa8' }]
30
31export type Option = { value: string; label: string }
32
33// Switches in place, so nothing below moves (a Select pushes it down when it opens): in the
34// terminal one button that steps to the next value (its hotkey too) and wraps, elsewhere a segment
35// per value with the hotkey on the one after the current, so it steps forward the same way.
36export function switcher(
37  { Box, Button, Text }: Kit,
38  surface: string,
39  key: string,
40  label: string,
41  hotkey: string,
42  options: Option[],
43  value: string,
44  onPick: (value: string) => void,
45): RenderNode {
46  const at = Math.max(0, options.findIndex(option => option.value === value))
47  if (surface === 'terminal') {
48    const next = options[(at + 1) % options.length]!.value
49
50    return (
51      <Box key={`switch:${key}`} flexDirection="row" flexShrink={0}>
52        {label !== '' && <Text dimColor>{label}</Text>}
53        <Button key={key} plain hotkey={hotkey} onPress={() => onPick(next)}>
54          <Text bold>‹ {options[at]?.label ?? value} ›</Text>
55        </Button>
56      </Box>
57    )
58  }
59
60  return (
61    <Box key={`switch:${key}`} flexDirection="row" gap={1} flexShrink={0}>
62      {label !== '' && <Text dimColor>{label}</Text>}
63      {options.map((option, i) => (
64        <Button
65          key={`${key}:${option.value}`}
66          label={option.label}
67          {...(i === (at + 1) % options.length ? { hotkey } : {})}
68          {...(i === at ? { variant: 'primary' as const } : { dimColor: true })}
69          onPress={() => onPick(option.value)}
70        />
71      ))}
72    </Box>
73  )
74}
75
76// A pane's last line: each key and what it does, then how to close it unless `closes` is false
77// (a pane that waits for an answer). The key keeps the default color and the rest is dim, so the
78// eye finds the keys first: the first word ("d done"), or the key in brackets ("‹ Back (b) returns").
79export function hintLine({ Box, Text }: Kit, lang: Lang, items: Words[], closes = true): RenderNode {
80  const all = closes ? [...items, { en: 'Esc closes', 'zh-TW': 'Esc 關閉' }] : items
81  const parts = all.flatMap((item, i) => {
82    const text = pick(lang, item)
83    const m = /^(.*?[((])(\S)([))].*)$/.exec(text) ?? (text.startsWith('‹') ? null : /^()(\S+)(.*)$/.exec(text))
84    const sep = i === 0 ? [] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>]
85    if (m === null) {
86      return [...sep, <Text key={`all${i}`} dimColor>{text}</Text>]
87    }
88
89    return [
90      ...sep,
91      ...(m[1] === '' ? [] : [<Text key={`pre${i}`} dimColor>{m[1]}</Text>]),
92      <Text key={`key${i}`}>{m[2]}</Text>,
93      <Text key={`does${i}`} dimColor>{m[3]}</Text>,
94    ]
95  })
96
97  return (
98    <Box key="hint" marginTop={1}>
99      <Text wrap="wrap">{parts}</Text>
100    </Box>
101  )
102}
103
104// What a pane shows when it has nothing to list; under a header, the caller sets it a line apart.
105export function emptyState({ Box, Text }: Kit, text: string): RenderNode {
106  return (
107    <Box key="empty">
108      <Text dimColor wrap="wrap">
109        {text}
110      </Text>
111    </Box>
112  )
113}
114
hooks/ledger.ts 989 lines
1import { pick, type KeySpec, type Lang } from './config'
2
3// What one session wrote under `ledger:<sessionId>` in $.store. Each session writes only its
4// own key, so concurrent sessions never overwrite each other; `stats` reads every key.
5export type Tally = { input: number; cacheWrite: number; cacheRead: number; output: number; steps: number }
6
7// One Agent spawn and the tokens its steps used. `type` is `fork` for a fork and `unknown`
8// for steps of an agent id the ledger never saw spawn (one spawned before a hot reload).
9export type Spawn = Tally & {
10  agentId?: string
11  // ms of the spawn and of its last step: the duration is `last - at`.
12  at: number
13  last: number
14  description: string
15  type: string
16  // The Agent tool's `model` as given; absent when the spawn took the default.
17  requested?: string
18  // The model the spawn resolved to.
19  model: string
20  fork: boolean
21  background: boolean
22}
23
24export type Ledger = {
25  // ms of the last write; keys not written for 90 days are deleted at session start.
26  updated: number
27  // The main working tree's root, so worktrees of one repo group together.
28  repo: string
29  started: number
30  // The first 40 characters of the session's first prompt.
31  prompt: string
32  // Local day (`2026-10-12`) -> model -> the main session's own tokens that day.
33  daily: Record<string, Record<string, Tally>>
34  // Oldest first, at most SPAWN_CAP.
35  spawns: Spawn[]
36  // Set on a ledger rebuilt from the session's transcript rather than recorded live.
37  source?: 'transcript'
38}
39
40// What the session knows before anything is written; merged into the ledger on each write.
41export type SessionMeta = { repo: string; started: number; prompt: string }
42
43export const LEDGER_PREFIX = 'ledger:'
44export const RETENTION_MS = 90 * 86_400_000
45export const SPAWN_CAP = 500
46export const PROMPT_CHARS = 40
47export const DEFAULT_DAYS = 30
48export const MAX_DAYS = 90
49export const DAY_PREFIX = 'day:'
50// Day rows are kept this many days, the heatmap's 53 weeks.
51export const DAY_KEEP = 366
52// The heatmap reads the ledgers for this many days ending today and the day rows before. One day
53// short of the retention: a ledger last written on the 90th day back is pruned partway through it.
54export const LIVE_DAYS = MAX_DAYS - 1
55export const MAX_WEEKS = 53
56export const MIN_WEEKS = 4
57
58export const LEDGER_SPEC: KeySpec = {
59  category: 'plain',
60  type: 'boolean',
61  default: true,
62  repo: 'never',
63  description: {
64    en: 'Record each spawn and the token use of the main session and every subagent on this machine, for /subagent-dispatch stats. Off records nothing; existing records stay',
65    'zh-TW': '把每次派出,以及主 session 與各 subagent 的 token 用量記進本機的用量明細,供 /subagent-dispatch stats 使用。關閉後不再記錄,已有的紀錄保留',
66  },
67}
68
69export function emptyLedger(): Ledger {
70  return { updated: 0, repo: '', started: 0, prompt: '', daily: {}, spawns: [] }
71}
72
73export function promptSummary(text: string): string {
74  const flat = text.replace(/\s+/g, ' ').trim()
75
76  return flat.length > PROMPT_CHARS ? `${flat.slice(0, PROMPT_CHARS)}…` : flat
77}
78
79// Fills what the ledger does not have yet: a resumed session keeps its first start and prompt.
80export function applyMeta(ledger: Ledger, meta: SessionMeta): void {
81  ledger.repo ||= meta.repo
82  ledger.started ||= meta.started
83  ledger.prompt ||= meta.prompt
84}
85
86function zero(): Tally {
87  return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, steps: 0 }
88}
89
90export function recordSpawn(
91  ledger: Ledger,
92  at: number,
93  spawn: { agentId?: string; type: string; model: string; requested?: string; fork: boolean; background: boolean; description: string },
94): void {
95  ledger.spawns = [
96    ...ledger.spawns,
97    {
98      ...zero(),
99      ...(spawn.agentId === undefined ? {} : { agentId: spawn.agentId }),
100      at,
101      last: at,
102      description: spawn.description,
103      type: spawn.fork ? 'fork' : spawn.type,
104      ...(spawn.requested === undefined || spawn.fork ? {} : { requested: spawn.requested }),
105      model: spawn.model,
106      fork: spawn.fork,
107      background: spawn.background,
108    },
109  ].slice(-SPAWN_CAP)
110  ledger.updated = at
111}
112
113export function recordStep(
114  ledger: Ledger,
115  at: number,
116  step: { agentId?: string; model: string; input: number; cacheWrite: number; cacheRead: number; output: number },
117): void {
118  let tally: Tally
119  if (step.agentId === undefined) {
120    tally = (ledger.daily[dayOf(at)] ??= {})[step.model] ??= zero()
121  } else {
122    let spawn = ledger.spawns.findLast(item => item.agentId === step.agentId)
123    if (spawn === undefined) {
124      spawn = { ...zero(), agentId: step.agentId, at, last: at, description: '', type: 'unknown', model: step.model, fork: false, background: false }
125      ledger.spawns = [...ledger.spawns, spawn].slice(-SPAWN_CAP)
126    }
127    spawn.last = at
128    tally = spawn
129  }
130  tally.input += step.input
131  tally.cacheWrite += step.cacheWrite
132  tally.cacheRead += step.cacheRead
133  tally.output += step.output
134  tally.steps += 1
135  ledger.updated = at
136}
137
138// The local calendar day of a time.
139export function dayOf(ms: number): string {
140  const d = new Date(ms)
141
142  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
143}
144
145const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
146
147// A local day without the year: `Oct 12`, `10/12`.
148function monthDay(lang: Lang, month: number, day: number): string {
149  return pick(lang, { en: `${MONTHS[month - 1]} ${day}`, 'zh-TW': `${month}/${day}` })
150}
151
152// A session's start in local time, named with its prompt: `Oct 12 02:11`, `10/12 02:11`.
153export function stamp(lang: Lang, ms: number): string {
154  const d = new Date(ms)
155  const two = (value: number) => String(value).padStart(2, '0')
156
157  return `${monthDay(lang, d.getMonth() + 1, d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
158}
159
160// A local time of day to the second: `19:04:05`.
161export function clockTime(ms: number): string {
162  const d = new Date(ms)
163  const two = (value: number) => String(value).padStart(2, '0')
164
165  return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
166}
167
168// The local day of a time: `Oct 12`, `10/12`.
169export function dayLabel(lang: Lang, ms: number): string {
170  const d = new Date(ms)
171
172  return monthDay(lang, d.getMonth() + 1, d.getDate())
173}
174
175// A day in the stats pane: `Oct 2`, `10/02`.
176export function shortDay(lang: Lang, day: string): string {
177  const [, month, date] = day.split('-').map(Number) as [number, number, number]
178
179  return pick(lang, { en: `${MONTHS[month - 1]} ${date}`, 'zh-TW': `${String(month).padStart(2, '0')}/${String(date).padStart(2, '0')}` })
180}
181
182// A stats period's days: `Sep 15 – Oct 14`, `09/15 – 10/14`; `tight` drops the spaces around the dash.
183export function span(lang: Lang, from: string, to: string, tight = false): string {
184  return `${shortDay(lang, from)}${tight ? '–' : ' – '}${shortDay(lang, to)}`
185}
186
187// The days a report covers: `Sep 13 – Oct 12`, `9/13 至 10/12`.
188export function range(lang: Lang, report: Report): string {
189  const label = (day: string) => {
190    const [, month, date] = day.split('-').map(Number) as [number, number, number]
191
192    return monthDay(lang, month, date)
193  }
194
195  return pick(lang, { en: `${label(report.from)} – ${label(report.to)}`, 'zh-TW': `${label(report.from)} 至 ${label(report.to)}` })
196}
197
198// A period: `1 day`, `30 days`, `30 天`.
199export function daysLabel(lang: Lang, days: number): string {
200  return pick(lang, { en: days === 1 ? '1 day' : `${days} days`, 'zh-TW': `${days} 天` })
201}
202
203// The first day a report covers: `days` local days ending today, today included.
204function firstDay(now: number, days: number): string {
205  const d = new Date(now)
206  d.setDate(d.getDate() - (days - 1))
207
208  return dayOf(d.getTime())
209}
210
211// A local day `count` days after `day` (before, when negative).
212export function addDays(day: string, count: number): string {
213  const [year, month, date] = day.split('-').map(Number) as [number, number, number]
214
215  return dayOf(new Date(year, month - 1, date + count).getTime())
216}
217
218const WEEKDAYS = {
219  en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
220  'zh-TW': ['一', '二', '三', '四', '五', '六', '日'],
221} as const
222
223// Monday 0 to Sunday 6.
224export function weekday(day: string): number {
225  const [year, month, date] = day.split('-').map(Number) as [number, number, number]
226
227  return (new Date(year, month - 1, date).getDay() + 6) % 7
228}
229
230// A chosen day: `Fri Oct 2`, `10/02 週五`.
231export function dayName(lang: Lang, day: string): string {
232  const [, month, date] = day.split('-').map(Number) as [number, number, number]
233  const two = (value: number) => String(value).padStart(2, '0')
234
235  return pick(lang, {
236    en: `${WEEKDAYS.en[weekday(day)]} ${monthDay(lang, month, date)}`,
237    'zh-TW': `${two(month)}/${two(date)} 週${WEEKDAYS['zh-TW'][weekday(day)]}`,
238  })
239}
240
241// A chosen day at the head of a sentence: `Sat Oct 4`, `10/04(六)`.
242export function dayHead(lang: Lang, day: string): string {
243  const [, month, date] = day.split('-').map(Number) as [number, number, number]
244  const two = (value: number) => String(value).padStart(2, '0')
245
246  return pick(lang, { en: dayName(lang, day), 'zh-TW': `${two(month)}/${two(date)}(${WEEKDAYS['zh-TW'][weekday(day)]})` })
247}
248
249// The heatmap's row labels, Monday first: only Monday, Wednesday and Friday are named.
250export function weekdayLabels(lang: Lang): string[] {
251  return WEEKDAYS[lang].map((name, i) => (i % 2 === 0 && i < 5 ? name : ''))
252}
253
254export function tokens(t: Tally): number {
255  return t.input + t.cacheWrite + t.output
256}
257
258// `cacheByModel` is the main session's cache read per model, `lastByModel` the last day in the
259// window each model had main session tokens.
260export type SessionRow = {
261  repo: string
262  started: number
263  prompt: string
264  main: number
265  mainByModel: [string, number][]
266  cacheByModel: Record<string, number>
267  lastByModel: Record<string, string>
268  cacheRead: number
269  spawns: Spawn[]
270}
271
272export type Report = {
273  // When the ledgers were read.
274  at: number
275  days: number
276  from: string
277  to: string
278  main: number
279  sub: number
280  cacheRead: number
281  spawned: { all: number; explicit: number; opus: number; forks: number; defaults: number }
282  // Newest session first.
283  sessions: SessionRow[]
284}
285
286// Adds `spawns` to `counts` by where their model came from, in three kinds that never overlap:
287// a fork runs on the main session's model, so it named none and took no default. A spawn the
288// ledger only saw step is not counted.
289function addKinds(counts: Report['spawned'], spawns: Spawn[]): void {
290  for (const spawn of spawns.filter(spawn => spawn.type !== 'unknown')) {
291    counts.all += 1
292    if (spawn.fork) {
293      counts.forks += 1
294    } else if (spawn.requested !== undefined) {
295      counts.explicit += 1
296      if (spawn.requested.toLowerCase().includes('opus')) {
297        counts.opus += 1
298      }
299    } else {
300      counts.defaults += 1
301    }
302  }
303}
304
305export function countKinds(spawns: Spawn[]): Report['spawned'] {
306  const counts = { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }
307  addKinds(counts, spawns)
308
309  return counts
310}
311
312// A stored ledger in the current format, or undefined for anything else, such as a ledger of the
313// unreleased first format (spawns per day). A ledger written before 0.4.0 kept the main
314// session's tokens per model only, as `main`: they count on the day of its last write.
315export function readLedger(value: unknown): Ledger | undefined {
316  if (typeof value !== 'object' || value === null || !Array.isArray((value as Ledger).spawns)) {
317    return undefined
318  }
319  const { main, ...rest } = value as Ledger & { main?: Record<string, Tally> }
320  if (typeof rest.daily === 'object' && rest.daily !== null) {
321    return { ...emptyLedger(), ...rest }
322  }
323  if (typeof main !== 'object' || main === null) {
324    return undefined
325  }
326
327  return { ...emptyLedger(), ...rest, daily: Object.keys(main).length === 0 ? {} : { [dayOf(rest.updated)]: main } }
328}
329
330// Every session written to in the window, with its main tokens of the days in the window and
331// its spawns whose last step falls in it. The window is `days` days ending today, or `day` alone.
332export function buildReport(stored: unknown[], days: number, now: number, day?: string): Report {
333  return ledgerReport(stored, day ?? firstDay(now, days), day ?? dayOf(now), now)
334}
335
336// The days from `from` to `to`, both included.
337export function daysBetween(from: string, to: string): number {
338  return Math.round((Date.parse(`${to}T12:00:00`) - Date.parse(`${from}T12:00:00`)) / 86_400_000) + 1
339}
340
341// The stats pane's period, `from` to `to`: the days from liveFrom on from the ledgers, with their
342// sessions, and the days before from their day rows, counted in the totals alone. `kept` says
343// whether any of it reaches the ledgers, so there are rows to list.
344export function windowReport(stored: unknown[], dayRows: Record<string, unknown>, from: string, to: string, now: number): Report & { kept: boolean } {
345  const live = liveFrom(now)
346  const kept = to >= live
347  const report = ledgerReport(kept ? stored : [], from < live ? live : from, to, now)
348  report.from = from
349  report.days = daysBetween(from, to)
350  for (let day = from; day <= to && day < live; day = addDays(day, 1)) {
351    const row = readDayRow(dayRows[day])
352    if (row === undefined) {
353      continue
354    }
355    const part = dayReport(row, day, now)
356    report.main += part.main
357    report.sub += part.sub
358    report.cacheRead += part.cacheRead
359    for (const key of Object.keys(report.spawned) as (keyof Report['spawned'])[]) {
360      report.spawned[key] += part.spawned[key]
361    }
362  }
363
364  return { ...report, kept }
365}
366
367function ledgerReport(stored: unknown[], from: string, to: string, now: number): Report {
368  const report: Report = { at: now, days: daysBetween(from, to), from, to, main: 0, sub: 0, cacheRead: 0, spawned: { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }, sessions: [] }
369  for (const ledger of stored.map(readLedger)) {
370    if (ledger === undefined || dayOf(ledger.updated) < from) {
371      continue
372    }
373    const spawns = ledger.spawns.filter(spawn => dayOf(spawn.last) >= from && dayOf(spawn.last) <= to)
374    const main = new Map<string, number>()
375    const cacheByModel: Record<string, number> = {}
376    const lastByModel: Record<string, string> = {}
377    let cacheRead = 0
378    for (const [day, models] of Object.entries(ledger.daily)) {
379      if (day < from || day > to) {
380        continue
381      }
382      for (const [model, tally] of Object.entries(models)) {
383        main.set(model, (main.get(model) ?? 0) + tokens(tally))
384        cacheByModel[model] = (cacheByModel[model] ?? 0) + tally.cacheRead
385        if (tokens(tally) > 0 && day > (lastByModel[model] ?? '')) {
386          lastByModel[model] = day
387        }
388        cacheRead += tally.cacheRead
389      }
390    }
391    const mainByModel = [...main]
392    const row: SessionRow = {
393      repo: ledger.repo,
394      started: ledger.started || ledger.updated,
395      prompt: ledger.prompt,
396      main: mainByModel.reduce((sum, [, count]) => sum + count, 0),
397      mainByModel,
398      cacheByModel,
399      lastByModel,
400      cacheRead,
401      spawns,
402    }
403    if (row.main === 0 && spawns.length === 0) {
404      continue
405    }
406    report.sessions.push(row)
407    report.main += row.main
408    report.cacheRead += row.cacheRead
409    for (const spawn of spawns) {
410      report.sub += tokens(spawn)
411      report.cacheRead += spawn.cacheRead
412    }
413    addKinds(report.spawned, spawns)
414  }
415  report.sessions.sort((a, b) => b.started - a.started)
416
417  return report
418}
419
420// --- Day rows: one per local day under `day:YYYY-MM-DD`, kept DAY_KEEP days, so the heatmap
421// outlives the ledgers. Each is computed whole from the ledgers, never added to (ADR 0024).
422
423export type DayRow = {
424  // The main session's tokens that day, by model.
425  main: Record<string, Tally>
426  // The tokens of the spawns whose last step fell that day, by the model each resolved to.
427  sub: Record<string, Tally>
428  spawned: Report['spawned']
429  // When the row was computed.
430  at: number
431}
432
433export function readDayRow(value: unknown): DayRow | undefined {
434  const row = value as DayRow | undefined
435  const isObject = (part: unknown) => typeof part === 'object' && part !== null
436  if (!isObject(row) || !isObject(row!.main) || !isObject(row!.sub) || !isObject(row!.spawned)) {
437    return undefined
438  }
439
440  return row
441}
442
443function add(into: Tally, from: Tally): void {
444  into.input += from.input
445  into.cacheWrite += from.cacheWrite
446  into.cacheRead += from.cacheRead
447  into.output += from.output
448  into.steps += from.steps
449}
450
451// The days a ledger has a record of: from its start to its last write, at most DAY_KEEP of them
452// back from the last, and any day it counted tokens on (a ledger of the old format has no start).
453export function recordedDays(ledger: Ledger): Set<string> {
454  const days = new Set(Object.keys(ledger.daily))
455  const last = dayOf(ledger.updated)
456  const first = dayOf(ledger.started || ledger.updated)
457  for (let day = last, i = 0; day >= first && i < DAY_KEEP; day = addDays(day, -1), i += 1) {
458    days.add(day)
459  }
460
461  return days
462}
463
464export function dayRow(ledgers: Ledger[], day: string, at: number): DayRow {
465  const row: DayRow = { main: {}, sub: {}, spawned: { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }, at }
466  for (const ledger of ledgers) {
467    for (const [model, tally] of Object.entries(ledger.daily[day] ?? {})) {
468      add((row.main[model] ??= zero()), tally)
469    }
470    const spawns = ledger.spawns.filter(spawn => dayOf(spawn.last) === day)
471    for (const spawn of spawns) {
472      add((row.sub[spawn.model] ??= zero()), spawn)
473    }
474    addKinds(row.spawned, spawns)
475  }
476
477  return row
478}
479
480// The days a ledger recorded up to the day before yesterday (yesterday a session may still write
481// to), oldest first: the days a day row may be computed for.
482export function settledDays(ledgers: Ledger[], now: number): string[] {
483  const last = addDays(dayOf(now), -2)
484  const days = new Set<string>()
485  for (const ledger of ledgers) {
486    for (const day of recordedDays(ledger)) {
487      if (day <= last) {
488        days.add(day)
489      }
490    }
491  }
492
493  return [...days].sort()
494}
495
496function sumTokens(tallies: Record<string, Tally>): number {
497  return Object.values(tallies).reduce((total, tally) => total + tokens(tally), 0)
498}
499
500// A one-day report from a day row alone: the cards' figures, no sessions.
501export function dayReport(row: DayRow, day: string, now: number): Report {
502  const cacheRead = [...Object.values(row.main), ...Object.values(row.sub)].reduce((total, tally) => total + tally.cacheRead, 0)
503
504  return { at: now, days: 1, from: day, to: day, main: sumTokens(row.main), sub: sumTokens(row.sub), cacheRead, spawned: { ...row.spawned }, sessions: [] }
505}
506
507// The first day the heatmap and a chosen day read from the ledgers; before it, from day rows.
508export function liveFrom(now: number): string {
509  return firstDay(now, LIVE_DAYS)
510}
511
512// --- The calendar heatmap.
513
514// One day: its tokens (main session and spawns, without cache read), 0 for a day recorded with no
515// use, null for a day nothing recorded.
516export type HeatDay = { day: string; tokens: number | null }
517
518// How many weeks a pane of `bodyColumns` fits: a week takes two columns after four for the labels.
519export function heatWeeks(bodyColumns: number): number {
520  return Math.min(MAX_WEEKS, Math.max(MIN_WEEKS, Math.floor((bodyColumns - 4) / 2)))
521}
522
523// The heatmap's first day: the Monday `weeks - 1` weeks before this week's.
524export function heatStart(weeks: number, now: number): string {
525  const today = dayOf(now)
526
527  return addDays(today, -weekday(today) - (weeks - 1) * 7)
528}
529
530// `weeks` weeks of days, Monday first, ending today: the last LIVE_DAYS days from the ledgers, as
531// the rest of the pane counts them, and the days before from their day rows.
532export function buildHeatmap(stored: unknown[], dayRows: Record<string, unknown>, weeks: number, now: number): { days: HeatDay[] } {
533  const today = dayOf(now)
534  const live = liveFrom(now)
535  const recorded = new Set<string>()
536  const used = new Map<string, number>()
537  const count = (day: string, value: number) => used.set(day, (used.get(day) ?? 0) + value)
538  for (const ledger of stored.map(readLedger)) {
539    if (ledger === undefined) {
540      continue
541    }
542    for (const day of recordedDays(ledger)) {
543      recorded.add(day)
544    }
545    for (const [day, models] of Object.entries(ledger.daily)) {
546      count(day, sumTokens(models))
547    }
548    for (const spawn of ledger.spawns) {
549      count(dayOf(spawn.last), tokens(spawn))
550    }
551  }
552  const days: HeatDay[] = []
553  for (let day = heatStart(weeks, now); day <= today; day = addDays(day, 1)) {
554    if (day >= live) {
555      days.push({ day, tokens: recorded.has(day) ? (used.get(day) ?? 0) : null })
556    } else {
557      const row = readDayRow(dayRows[day])
558      days.push({ day, tokens: row === undefined ? null : sumTokens(row.main) + sumTokens(row.sub) })
559    }
560  }
561
562  return { days }
563}
564
565// The level 1–4 of a day's tokens among the used days drawn, by quartile; 0 for no use.
566export function heatLevels(days: HeatDay[]): (value: number | null) => number {
567  const values = days.flatMap(item => (item.tokens !== null && item.tokens > 0 ? [item.tokens] : [])).sort((a, b) => a - b)
568  const at = (p: number) => values[Math.max(0, Math.ceil(values.length * p) - 1)] ?? 0
569  const [q1, q2, q3] = [at(0.25), at(0.5), at(0.75)]
570
571  return value => (value === null || value <= 0 ? 0 : value <= q1 ? 1 : value <= q2 ? 2 : value <= q3 ? 3 : 4)
572}
573
574// The month row over the heatmap's weeks: a label where a month first shows (the week whose
575// Sunday falls in it); on the first week only when its Monday is the 1st.
576export function monthMarks(lang: Lang, days: HeatDay[]): { week: number; label: string }[] {
577  const marks: { week: number; label: string }[] = []
578  let last = ''
579  for (let week = 0; week * 7 < days.length; week += 1) {
580    const monday = days[week * 7]!.day
581    const sunday = addDays(monday, 6)
582    if (sunday.slice(0, 7) !== last && (week > 0 || monday.endsWith('-01'))) {
583      const month = Number(sunday.slice(5, 7))
584      marks.push({ week, label: pick(lang, { en: MONTHS[month - 1]!, 'zh-TW': `${month}月` }) })
585    }
586    last = sunday.slice(0, 7)
587  }
588
589  return marks
590}
591
592// A count in a few characters: `950`, `12k`, `1.2M`, `15M`; rounding never reads `1000k` or `10.0M`.
593export function shortCount(value: number): string {
594  const millions = value / 1e6
595  if (millions >= 9.95) {
596    return `${Math.round(millions)}M`
597  }
598  if (value >= 999_500) {
599    return `${millions.toFixed(1)}M`
600  }
601
602  return value >= 999.5 ? `${Math.round(value / 1e3)}k` : String(Math.round(value))
603}
604
605// --- Rebuilding ledgers from transcripts: `<projects>/<project>/<sid>.jsonl` is a session, and
606// `<project>/<sid>/subagents/agent-<id>.jsonl` with its `.meta.json` one of its spawns.
607
608export type ParsedStep = { at: number; model: string; tally: Tally }
609export type Parsed = { started: number; updated: number; prompt: string; cwd: string; steps: ParsedStep[] }
610
611type Line = {
612  type?: string
613  timestamp?: string
614  cwd?: string
615  isSidechain?: boolean
616  isMeta?: boolean
617  message?: { id?: string; model?: string; content?: unknown; usage?: Record<string, number | undefined> }
618}
619
620function promptText(content: unknown): string {
621  if (typeof content === 'string') {
622    return content
623  }
624  if (!Array.isArray(content)) {
625    return ''
626  }
627
628  return content.flatMap(block => ((block as { type?: string }).type === 'text' ? [String((block as { text?: unknown }).text ?? '')] : [])).join(' ')
629}
630
631// A transcript's first and last time, first prompt, first directory and steps. A reply is written
632// as a line per content block: one step, with the usage of its last line. The lines of a subagent
633// (`isSidechain`) count only with `sidechain`, for a subagent's own transcript.
634export function parseTranscript(text: string, sidechain = false): Parsed {
635  const parsed: Parsed = { started: 0, updated: 0, prompt: '', cwd: '', steps: [] }
636  const steps = new Map<string, ParsedStep>()
637  for (const raw of text.split('\n')) {
638    let line: Line
639    try {
640      line = JSON.parse(raw) as Line
641    } catch {
642      continue
643    }
644    if (typeof line !== 'object' || line === null) {
645      continue
646    }
647    const at = typeof line.timestamp === 'string' ? Date.parse(line.timestamp) : Number.NaN
648    if (!Number.isNaN(at)) {
649      parsed.started ||= at
650      parsed.updated = Math.max(parsed.updated, at)
651    }
652    parsed.cwd ||= typeof line.cwd === 'string' ? line.cwd : ''
653    if (line.isSidechain === true && !sidechain) {
654      continue
655    }
656    if (line.type === 'user' && parsed.prompt === '' && line.isMeta !== true) {
657      const words = promptText(line.message?.content).trim()
658      if (words !== '' && !words.startsWith('<')) {
659        parsed.prompt = promptSummary(words)
660      }
661    }
662    const usage = line.message?.usage
663    const model = line.message?.model ?? ''
664    if (line.type !== 'assistant' || typeof usage !== 'object' || model === '' || model === '<synthetic>' || Number.isNaN(at)) {
665      continue
666    }
667    steps.set(line.message?.id ?? `${at}:${steps.size}`, {
668      at,
669      model,
670      tally: {
671        input: usage.input_tokens ?? 0,
672        cacheWrite: usage.cache_creation_input_tokens ?? 0,
673        cacheRead: usage.cache_read_input_tokens ?? 0,
674        output: usage.output_tokens ?? 0,
675        steps: 1,
676      },
677    })
678  }
679  parsed.steps = [...steps.values()]
680
681  return parsed
682}
683
684// What a subagent's `.meta.json` holds: `model` is the Agent tool's as given, `inherit` on a fork.
685type SubagentMeta = { agentType?: string; description?: string; model?: string; requestShape?: string; isFork?: boolean }
686
687// A spawn from a subagent's transcript and its meta; undefined for a transcript with no time in it.
688export function parseSubagent(meta: unknown, text: string, agentId: string): Spawn | undefined {
689  const info = (typeof meta === 'object' && meta !== null ? meta : {}) as SubagentMeta
690  const parsed = parseTranscript(text, true)
691  const first = parsed.steps[0]
692  const at = first?.at ?? parsed.started
693  if (at === 0) {
694    return undefined
695  }
696  const fork = info.isFork === true || info.agentType === 'fork'
697  const spawn: Spawn = {
698    ...zero(),
699    agentId,
700    at,
701    last: parsed.steps.at(-1)?.at ?? at,
702    description: info.description ?? '',
703    type: fork ? 'fork' : (info.agentType ?? 'general-purpose'),
704    ...(fork || typeof info.model !== 'string' ? {} : { requested: info.model }),
705    model: first?.model ?? 'unknown',
706    fork,
707    background: info.requestShape !== 'foreground',
708  }
709  for (const step of parsed.steps) {
710    add(spawn, step.tally)
711  }
712
713  return spawn
714}
715
716// A session's ledger rebuilt from its transcript and its spawns'.
717export function transcriptLedger(parsed: Parsed, spawns: Spawn[], repo: string): Ledger {
718  const ledger: Ledger = { ...emptyLedger(), repo, started: parsed.started, prompt: parsed.prompt, source: 'transcript' }
719  for (const step of parsed.steps) {
720    add(((ledger.daily[dayOf(step.at)] ??= {})[step.model] ??= zero()), step.tally)
721  }
722  ledger.spawns = [...spawns].sort((a, b) => a.at - b.at).slice(-SPAWN_CAP)
723  ledger.updated = Math.max(parsed.updated, ...ledger.spawns.map(spawn => spawn.last))
724
725  return ledger
726}
727
728// Tokens per model over the report: the main session's by the model each step named, a
729// spawn's by the model it resolved to.
730export function byModel(report: Report): [string, number][] {
731  const totals = new Map<string, number>()
732  for (const session of report.sessions) {
733    for (const [model, count] of session.mainByModel) {
734      totals.set(model, (totals.get(model) ?? 0) + count)
735    }
736    for (const spawn of session.spawns) {
737      totals.set(spawn.model, (totals.get(spawn.model) ?? 0) + tokens(spawn))
738    }
739  }
740
741  return [...totals].sort((a, b) => b[1] - a[1])
742}
743
744// The report's sessions grouped by repo, the repo of the newest session first.
745export function byRepo(report: Report): [string, SessionRow[]][] {
746  const groups = new Map<string, SessionRow[]>()
747  for (const session of report.sessions) {
748    groups.set(session.repo, [...(groups.get(session.repo) ?? []), session])
749  }
750
751  return [...groups]
752}
753
754export type GroupBy = 'repo' | 'session' | 'model' | 'type'
755export const GROUP_BYS: readonly GroupBy[] = ['repo', 'session', 'model', 'type']
756
757// One row of the pane's grouping, and what it opens to: per session, the main tokens that
758// fall in the group and the spawns that do. `main` is the main session's part of `tokens`,
759// `cacheRead` both parts' cache read, and `last` the last day either used tokens ('' for none).
760export type Group = {
761  key: string
762  label: string
763  tokens: number
764  main: number
765  spawns: number
766  cacheRead: number
767  last: string
768  detail: { session: SessionRow; main: number; spawns: Spawn[] }[]
769}
770
771// What of a session's main tokens falls in a group: those of `models`, or of every model.
772type MainPart = { tokens: number; cacheRead: number; last: string }
773
774function mainPart(session: SessionRow, models = session.mainByModel.map(([model]) => model)): MainPart {
775  const tokensOf = new Map(session.mainByModel)
776  const part: MainPart = { tokens: 0, cacheRead: 0, last: '' }
777  for (const model of models) {
778    part.tokens += tokensOf.get(model) ?? 0
779    part.cacheRead += session.cacheByModel[model] ?? 0
780    const last = session.lastByModel[model] ?? ''
781    if (last > part.last) {
782      part.last = last
783    }
784  }
785
786  return part
787}
788
789export function groups(lang: Lang, report: Report, by: GroupBy): Group[] {
790  const found = new Map<string, Group>()
791  const none: MainPart = { tokens: 0, cacheRead: 0, last: '' }
792  const add = (key: string, label: string, session: SessionRow, part: MainPart, spawns: Spawn[]) => {
793    const main = part.tokens
794    let group = found.get(key)
795    if (group === undefined) {
796      group = { key, label, tokens: 0, main: 0, spawns: 0, cacheRead: 0, last: '', detail: [] }
797      found.set(key, group)
798    }
799    group.tokens += main + spawns.reduce((sum, spawn) => sum + tokens(spawn), 0)
800    group.main += main
801    group.cacheRead += part.cacheRead + spawns.reduce((sum, spawn) => sum + spawn.cacheRead, 0)
802    for (const day of [part.last, ...spawns.filter(spawn => tokens(spawn) > 0).map(spawn => dayOf(spawn.last))]) {
803      if (day > group.last) {
804        group.last = day
805      }
806    }
807    group.spawns += spawns.filter(spawn => spawn.type !== 'unknown').length
808    const last = group.detail.at(-1)
809    if (last?.session === session) {
810      last.main += main
811      last.spawns.push(...spawns)
812    } else {
813      group.detail.push({ session, main, spawns: [...spawns] })
814    }
815  }
816  for (const session of report.sessions) {
817    if (by === 'repo') {
818      add(session.repo, repoName(lang, session.repo), session, mainPart(session), session.spawns)
819    } else if (by === 'session') {
820      add(`${session.repo}|${session.started}`, sessionName(lang, session), session, mainPart(session), session.spawns)
821    } else if (by === 'model') {
822      for (const [model] of session.mainByModel) {
823        add(model, model, session, mainPart(session, [model]), [])
824      }
825      for (const spawn of session.spawns) {
826        add(spawn.model, spawn.model, session, none, [spawn])
827      }
828    } else {
829      if (session.main > 0) {
830        add('main', pick(lang, { en: 'main session', 'zh-TW': '主 session' }), session, mainPart(session), [])
831      }
832      for (const spawn of session.spawns) {
833        add(spawn.type, spawn.type, session, none, [spawn])
834      }
835    }
836  }
837
838  return [...found.values()].sort((a, b) => b.tokens - a.tokens)
839}
840
841export const n = (value: number): string => value.toLocaleString('en-US')
842
843// Characters a terminal draws two cells wide (CJK, full-width forms, emoji).
844const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
845
846export function cells(text: string): number {
847  let count = 0
848  for (const ch of text) {
849    count += WIDE.test(ch) ? 2 : 1
850  }
851
852  return count
853}
854
855// Cut to `width` cells, ending in "…" when cut: a Button's text does not truncate on its own and
856// wraps instead once CJK and Latin mix.
857export function fit(text: string, width: number): string {
858  if (cells(text) <= width) {
859    return text
860  }
861  let out = ''
862  let used = 0
863  for (const ch of text) {
864    const w = WIDE.test(ch) ? 2 : 1
865    if (used + w > width - 1) {
866      break
867    }
868    out += ch
869    used += w
870  }
871
872  return `${out}…`
873}
874
875export function share(part: number, whole: number): string {
876  return whole === 0 ? '0%' : `${Math.round((part / whole) * 100)}%`
877}
878
879export function elapsed(ms: number): string {
880  const s = Math.max(0, Math.floor(ms / 1000))
881  if (s < 60) {
882    return `${s}s`
883  }
884  const m = Math.floor(s / 60)
885  if (m < 60) {
886    return `${m}m${String(s % 60).padStart(2, '0')}s`
887  }
888
889  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
890}
891
892export function sessionName(lang: Lang, session: SessionRow): string {
893  return `${stamp(lang, session.started)} ${session.prompt || pick(lang, { en: '(no prompt)', 'zh-TW': '(沒有 prompt)' })}`
894}
895
896// A repo by its folder's name, as the pane's rows show it.
897export function repoName(lang: Lang, repo: string): string {
898  return repo.split('/').filter(Boolean).at(-1) ?? pick(lang, { en: '(no repo)', 'zh-TW': '(沒有 repo)' })
899}
900
901// A spawn in three parts: what it was asked, what ran it, and what it cost.
902export function spawnParts(lang: Lang, spawn: Spawn): [string, string, string] {
903  return [
904    spawn.description || pick(lang, { en: '(spawned before it was recorded)', 'zh-TW': '(派出時沒記到)' }),
905    `${spawn.type} · ${spawn.requested ?? pick(lang, { en: 'default', 'zh-TW': '預設' })} → ${spawn.model}`,
906    [
907      ...(spawn.background ? [pick(lang, { en: 'background', 'zh-TW': '背景' })] : []),
908      pick(lang, { en: `${n(tokens(spawn))} tokens`, 'zh-TW': `${n(tokens(spawn))} token` }),
909      pick(lang, { en: `${spawn.steps} steps`, 'zh-TW': `${spawn.steps} 步` }),
910      elapsed(spawn.last - spawn.at),
911    ].join(' · '),
912  ]
913}
914
915export function spawnLine(lang: Lang, spawn: Spawn): string {
916  return spawnParts(lang, spawn).join(' · ')
917}
918
919export function emptyText(lang: Lang): string {
920  return pick(lang, {
921    en: 'No usage recorded yet (recording starts once the ledger setting is on and subagent-dispatch is loaded).',
922    'zh-TW': '還沒有用量紀錄(ledger 設定開啟且載入 subagent-dispatch 後才開始記)。',
923  })
924}
925
926export function summaryLines(lang: Lang, report: Report): string[] {
927  const all = report.main + report.sub
928  const s = report.spawned
929
930  return [
931    pick(lang, {
932      en: `Tokens (input + cache write + output): ${n(all)}`,
933      'zh-TW': `Token(input + cache write + output):${n(all)}`,
934    }),
935    pick(lang, { en: `  main session  ${n(report.main)}`, 'zh-TW': `  主 session  ${n(report.main)}` }),
936    pick(lang, {
937      en: `  subagents     ${n(report.sub)} (${share(report.sub, all)} of all tokens)`,
938      'zh-TW': `  subagent    ${n(report.sub)}(佔全部 ${share(report.sub, all)})`,
939    }),
940    pick(lang, { en: `Cache read (not counted above): ${n(report.cacheRead)}`, 'zh-TW': `Cache read(不含在上面):${n(report.cacheRead)}` }),
941    pick(lang, {
942      en: `Spawns: ${s.all} (named a model ${s.explicit}, Opus among them ${s.opus}; default model ${s.defaults}; fork ${s.forks})`,
943      'zh-TW': `派出:${s.all} 次(指定模型 ${s.explicit},其中 Opus ${s.opus};沿用預設 ${s.defaults};fork ${s.forks})`,
944    }),
945  ]
946}
947
948export const NOT_INCLUDED: Record<Lang, string> = {
949  en: "Not included: mods' own model calls ($.model.fork/complete), cloud sessions, and sessions whose transcripts this machine no longer had when recording began.",
950  'zh-TW': '不含:各 mod 自己呼叫模型($.model.fork/complete)的用量、雲端 session,以及開始記錄時這台電腦已經沒有 transcript 的 session。',
951}
952
953// `/subagent-dispatch stats --text [days]`, and what `stats` prints where no pane can be placed.
954export function renderStats(lang: Lang, report: Report): string {
955  if (report.sessions.length === 0) {
956    return emptyText(lang)
957  }
958  const all = report.main + report.sub
959  const lines: string[] = [
960    pick(lang, {
961      en: `subagent-dispatch usage, ${daysLabel(lang, report.days)}: ${range(lang, report)}`,
962      'zh-TW': `subagent-dispatch 用量,${daysLabel(lang, report.days)}:${range(lang, report)}`,
963    }),
964    '',
965    ...summaryLines(lang, report),
966    '',
967    pick(lang, { en: 'Tokens per model', 'zh-TW': '各模型 token' }),
968    ...byModel(report).map(([model, count]) => `  ${model.padEnd(28)}${n(count)} (${share(count, all)})`),
969    '',
970    pick(lang, { en: 'Sessions', 'zh-TW': '各 session' }),
971  ]
972  for (const [repo, sessions] of byRepo(report)) {
973    lines.push(repo || pick(lang, { en: '(no repo)', 'zh-TW': '(沒有 repo)' }))
974    for (const session of sessions) {
975      lines.push(
976        `  ${sessionName(lang, session)}  ` +
977          pick(lang, {
978            en: `main ${n(session.main)} · ${session.spawns.length} spawn(s)`,
979            'zh-TW': `主 ${n(session.main)} · 派出 ${session.spawns.length} 次`,
980          }),
981      )
982      lines.push(...session.spawns.map(spawn => `    - ${spawnLine(lang, spawn)}`))
983    }
984  }
985  lines.push('', NOT_INCLUDED[lang])
986
987  return lines.join('\n')
988}
989
types/index.d.ts 22 lines
1export type WatchedAgent = {
2  id: string
3  description: string
4  type: string
5  model?: string
6  startedAt: number
7  // 'running' while it works; 'killed' or 'failed' once it stopped without finishing.
8  status: string
9}
10
11// What the stats pane shows: the period in days, the grouping (repo, session, model or type),
12// the key of the row opened to its spawns ('' for none), their order (tokens, time or
13// duration), how many rows are listed, the spawn opened in place ('' for none), and the period's
14// last day (`YYYY-MM-DD`, '' for today). A view saved before the last four existed lacks them.
15export type StatsView = { days: number; by: string; item: string; sort: string; limit: number; spawn: string; end: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'subagent-dispatch': { running: WatchedAgent[]; stopped: WatchedAgent[]; view: StatsView }
20  }
21}
22