SLOPSHOPPER

time-blame

Splits each Claude Code turn's time into Claude, tools, you and other, and prints a /blame receipt for the session. Nothing leaves the machine. Claude Code…

panespinnerguardcommand
★ 1v0.2.1MITupdated 2026-10-17karanb192/time-blame
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-blame
│ ┃ time-blame ✕ › fix the failing auth test and add an audit log call │ ┃ this session, this machine · 1 turn · 42s of │ ┃ turn time ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ████████████████████████████████████████████ ⏺ Update(src/auth.ts) │ ┃ ██████████ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ██ Claude 0.0s 0% ⎿ 3 pass, 1 fail │ ┃ before output 0.0s, writing 0.0s │ ┃ ██ tools 0.0s 0% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ bun test 0.0s │ ┃ ██ you 0.0s 0% │ ┃ ██ other 42s 100% ██████████ time-blame 42s · bun test 0.0s · other 42s │ ┃ not attributed: hooks, local work, gaps │ ┃ › /blame │ ┃ Summed waits in this session, not server │ ┃ speed. Esc closes. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · time-blame
this session, this machine · 1 turn · 42s of turn time ██████████████████████████████████████████████████████ ██ Claude 0.0s 0% before output 0.0s, writing 0.0s ██ tools 0.0s 0% bun test 0.0s ██ you 0.0s 0% ██ other 42s 100% not attributed: hooks, local work, gaps Summed waits in this session, not server speed. Esc closes.
Turn line
██████████ time-blame 42s · bun test 0.0s · other 42s
README

time-blame

A Claude Code mod that answers one question after every turn: was Claude slow, or was it your npm test?

It splits each turn's time by cause and shows it under the answer: in the terminal as a coloured line in place of Claude Code's "Worked for" line, elsewhere as a dim log line. /blame opens a panel with the whole session's split as a coloured bar. Claude never reads any of it, and nothing leaves your machine.

time-blame in a real Claude Code session: after the answer, the line shows Claude 15s and npm test 27s, then /blame opens the panel

A real run on Claude Code 2.1.302, in a small demo project whose test suite takes about 13 seconds. The waits are shown at 6× speed, as the clip says. Of the 47 seconds, Claude took 15 and npm test took 27. Watch it as a video.

The same two outputs in plain text, from an earlier live session. This is what you get outside the terminal, or where nothing can draw a panel:

⏺ time-blame: 26s · Claude 8.0s (7.2s before output) · sleep 4.7s · you 12s · other 0.3s
⏺ time-blame: this session, this machine · 1 turn · 26s of turn time
⏺ time-blame: Claude     8.0s   31%  before output 7.2s, writing 0.8s
⏺ time-blame: tools      4.7s   19%  sleep 4.7s
⏺ time-blame: you         12s   49%  approvals 12s
⏺ time-blame: other      0.3s    1%  not attributed: hooks, local work, gaps
⏺ time-blame: longest turn 26s. Times are summed waits measured in this session, not server speed.

Both blocks are copied as text from that session, where a permission dialog was left open for 12 seconds before it was approved.

Install

claude plugin marketplace add karanb192/time-blame
claude plugin install time-blame@time-blame

Then restart Claude Code or run /reload-plugins. Mods are on by default from Claude Code 2.1.287; time-blame needs 2.1.302 or later. To try it for one session without installing, clone the repo and run claude --plugin-dir ./time-blame.

Landing page: https://timeblame.aidojo.si/

What each bucket means

Every millisecond of a turn lands in exactly one bucket, so the parts always add up to the total.

BucketMeasured asWhen two overlap
youA permission dialog, from the moment it opens to the moment the tool starts. Also the whole of an AskUserQuestion or ExitPlanMode call.Wins over everything
toolsThe tool's own execution time, which Claude Code reports with each result. Named by tool, and by command for Bash.Wins over Claude
Claude, before outputFrom the moment a model request is sent to its first text, thinking or tool-call chunk. Includes queueing, retries and network.
Claude, writingFrom that first chunk to the end of the request.
otherWhatever is left: hooks, local work, the gaps between steps. Always shown, never hidden.

Two or more tools running at once count as parallel tools, rather than blaming whichever one was slowest to return.

A subagent's time sits inside the Agent call that started it, labelled subagent. Its own steps are not counted again.

The line appears for turns of 3 seconds or more. /blame counts every turn.

Things to know

  • The total is longer than Claude Code's "Worked for". Claude Code's counter leaves out the time a dialog waits for you. In the run above it said 13s, and time-blame said 26s: the 12 seconds of the open dialog, plus rounding.
  • Bash commands are named only from a fixed list. Common programs (npm, pytest, cargo, git, make and others) and their common subcommands (npm test, cargo build) are shown. A script name, a path or anything else shows as Bash, because a command line can carry private names. In a chained command such as git stash && npm test, the test or build step names the call, since that is where the time usually goes.
  • On a managed Claude Code install, approvals count as tool time. Approvals are timed with the PermissionRequest and PostToolUse settings-hook events. Where an organization manages Claude Code, those events skip plugins a person installs. The dialog wait then falls into the tool's time.
  • Retries are not a separate bucket. Claude Code logs a retried request only after it gives up, so a retry that succeeds leaves no record a mod can read. Retry time shows up as "before output".
  • Colour needs the terminal for the turn line. Only the terminal lets a mod redraw the line that closes a turn. In the Desktop app and VS Code the turn line is a dim log line, and an interrupted turn always is. The /blame panel draws in all three.
  • Totals live in memory. They reset on /clear, /resume and when the plugin reloads.
  • It measures this session on this machine. It says nothing about Anthropic's servers, and it keeps nothing after the session ends.

What it can reach

Validated on Claude Code 2.1.302:

❯ ./register.ts hooks: session.start, command.run{command=blame}, ui.render{component=Pane}, ui.render{component=TurnDuration}, session.end, turn.start, turn.step, classic.PermissionRequest, classic.PostToolUse, classic.PostToolUseFailure, tool.call, turn.complete ❯ ./register.ts calls: $.command.register, $.session.surfaces, $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve

Reach L0, draws and remembers. It sees every tool call and every model request, as timing only. It draws in two places: the line that closes a turn in the terminal, and its own /blame panel.

Threat model for time-blame (reach L0, draws and remembers)

  1. Reads: which apps are drawing the session (terminal, Desktop, VS Code); timestamps of turns, model requests and tool calls; the kind of each streamed chunk (never its text); the tool name; for Bash, the command line, matched against a fixed list of program names and dropped; the execution time Claude Code reports with each result
  2. Runs: nothing
  3. Sends: nothing leaves the machine
  4. Persists: nothing; the session's totals live in memory and reset on /clear and /resume
  5. Hostile input: a crafted command line can only change which allowlisted label is shown or turn it into "Bash"; no input reaches a process, a file, a prompt or the network, and what it shows goes to the screen only, as drawn lines, a panel or dim log lines, none of which Claude reads

If a hook throws, Claude Code skips it and the tool call or request runs as it would without the mod.

Develop

claude plugin validate .claude-plugin/plugin.json
claude plugin test .

hooks/ledger.ts holds the accounting and the labels as pure functions; hooks/register.ts only collects timestamps. For type checking, run /plugin-types in a session in this folder, then npx tsc -p .. The generated types are git-ignored.

License

MIT. The landing page uses icons from Lucide (ISC) and the GitHub mark from Simple Icons (CC0).

Source 2 files
hooks/register.ts 233 lines
1import type { Register } from 'claude-code'
2import { add, bucketTimes, cells, COLORS, emptyTotals, fmt, receipt, receiptModel, split, toolLabel, turnLine, turnSegments } from './ledger'
3import type { Span, Totals, TurnReport } from './ledger'
4
5const MIN_LINE_MS = 3000
6const PANE = 'time-blame'
7const LINE_CELLS = 10
8
9// Tools whose whole call is a person deciding, so their time is yours.
10const USER_TOOLS: Record<string, string> = { AskUserQuestion: 'questions', ExitPlanMode: 'plan approval' }
11
12type Open = { start: number; first: number; kind: 'step' } | { start: number; label: string; tool: string; kind: 'call' }
13
14type Turn = {
15  id: string
16  start: number
17  spans: Span[]
18  open: Set<Open>
19}
20
21let turn: Turn | null = null
22let totals: Totals = emptyTotals()
23let paneOpened = false
24// Reports by the turn's durationMs: the TurnDuration site carries the same
25// number, which is how its redraw finds the turn it closes.
26// A report waits here by duration until its closing line first draws, then
27// moves to that line's message id, so it draws at one place only. A duration
28// already waiting maps to null and the newer turn gets a log line instead.
29const recent = new Map<number, TurnReport | null>()
30const byMessage = new Map<string, TurnReport>()
31const KEEP = 1000
32
33function remember<K, V>(map: Map<K, V>, key: K, value: V) {
34  map.set(key, value)
35  if (map.size > KEEP) map.delete(map.keys().next().value as K)
36}
37const execs = new Map<string, { start: number; end: number }>()
38const asks: Array<{ at: number; tool: string; claimed: boolean }> = []
39
40function finish(t: Turn, end: number, durationMs: number, aborted: boolean) {
41  for (const o of t.open) {
42    if (o.kind === 'step') {
43      if (o.first) t.spans.push({ kind: 'waiting', start: o.start, end: o.first, label: '' }, { kind: 'writing', start: o.first, end, label: '' })
44      else t.spans.push({ kind: 'waiting', start: o.start, end, label: '' })
45    } else t.spans.push({ kind: USER_TOOLS[o.tool] ? 'you' : 'tool', start: o.start, end, label: USER_TOOLS[o.tool] || o.label })
46  }
47  t.open.clear()
48  const start = Math.min(t.start, end - durationMs)
49  return split(t.spans, start, end, aborted)
50}
51
52export const register: Register = on => {
53  on('session.start', async ($, e, next) => {
54    const r = await next(e)
55    try {
56      await $.command.register({ name: 'blame', description: 'Where this session\'s time went: Claude, tools, you' })
57    } catch {}
58    return r
59  })
60
61  on('command.run', { command: 'blame' }, async $ => {
62    const surfaces = await $.session.surfaces()
63    if (surfaces.length === 0) {
64      for (const line of receipt(totals)) $.ui.log(line)
65      return {}
66    }
67    const opened = await $.ui.open({ id: PANE, title: 'time-blame', focus: true, closeOnEscape: true })
68    if (opened.isPlaced) paneOpened = true
69    else for (const line of receipt(totals)) $.ui.log(line)
70    return {}
71  })
72
73  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
74    if (e.requestId !== PANE) return next(e)
75    const { Box, Text } = $.ui.resolve(e)
76    const m = receiptModel(totals)
77    if (!m) return Box({ flexDirection: 'column', children: [Text({ children: ['No finished turns yet in this session.'] }), Text({ dimColor: true, children: ['Esc closes this pane.'] })] })
78    const width = Math.max(10, Math.min(60, (e.props.bodyColumns || 40) - 2))
79    const bar = Text({ children: cells(m.times, width).map(b => Text({ color: COLORS[b], children: ['█'] })) })
80    return Box({
81      flexDirection: 'column',
82      children: [
83        Text({ children: [m.header] }),
84        Text({ children: [' '] }),
85        bar,
86        Text({ children: [' '] }),
87        ...m.rows.flatMap(r => [
88          Text({ children: [Text({ color: COLORS[r.bucket], children: ['██ '] }), r.name.padEnd(8), Text({ weight: 'bold', children: [fmt(r.ms).padStart(7)] }), (r.pct + '%').padStart(6)] }),
89          ...(r.note ? [Text({ dimColor: true, children: ['   ' + r.note] })] : []),
90        ]),
91        Text({ children: [' '] }),
92        Text({ dimColor: true, children: ['Summed waits in this session, not server speed. Esc closes.'] }),
93      ],
94    })
95  })
96
97  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
98    let r = byMessage.get(e.requestId)
99    if (!r) {
100      const match = recent.get(e.props.durationMs)
101      if (!match) return next(e)
102      r = match
103      recent.delete(e.props.durationMs)
104      remember(byMessage, e.requestId, r)
105    }
106    const { Box, Text } = $.ui.resolve(e)
107    const line = Text({
108      children: [
109        ...cells(bucketTimes(r), LINE_CELLS).map(b => Text({ color: COLORS[b], children: ['█'] })),
110        ' ',
111        Text({ dimColor: true, children: ['time-blame '] }),
112        ...turnSegments(r).map(seg =>
113          seg.bucket ? Text({ color: COLORS[seg.bucket], children: [seg.text] }) : seg.strong ? Text({ weight: 'bold', children: [seg.text] }) : seg.text,
114        ),
115      ],
116    })
117    return Box({ marginTop: 1, children: [line] })
118  })
119
120  on('session.end', async ($, e, next) => {
121    if (e.reason === 'clear' || e.reason === 'resume') {
122      totals = emptyTotals()
123      turn = null
124      recent.clear()
125      byMessage.clear()
126    }
127    return next(e)
128  })
129
130  on('turn.start', async ($, e, next) => {
131    if (e.agentId === undefined) {
132      turn = { id: e.turnId, start: Date.now(), spans: [], open: new Set() }
133      execs.clear()
134      asks.length = 0
135    }
136    return next(e)
137  })
138
139  on('turn.step', async function* ($, e, next) {
140    const t = e.agentId === undefined && turn && turn.id === e.turnId ? turn : null
141    if (!t) return yield* next(e)
142    const o: Open = { kind: 'step', start: Date.now(), first: 0 }
143    t.open.add(o)
144    const gen = next(e)
145    let done = false
146    try {
147      while (true) {
148        const step = await gen.next()
149        if (step.done) { done = true; return step.value }
150        const kind = step.value.kind
151        if (!o.first && (kind === 'text' || kind === 'thinking' || kind === 'tool')) o.first = Date.now()
152        yield step.value
153      }
154    } finally {
155      if (!done) await gen.return(undefined as never)
156      if (t.open.delete(o)) {
157        const end = Date.now()
158        if (o.first) t.spans.push({ kind: 'waiting', start: o.start, end: o.first, label: '' }, { kind: 'writing', start: o.first, end, label: '' })
159        else t.spans.push({ kind: 'waiting', start: o.start, end, label: '' })
160      }
161    }
162  })
163
164  on('classic.PermissionRequest', async ($, e, next) => {
165    const r = await next(e)
166    if (turn) asks.push({ at: Date.now(), tool: e.tool_name, claimed: false })
167    return r
168  })
169
170  on('classic.PostToolUse', async ($, e, next) => {
171    const r = await next(e)
172    if (typeof e.duration_ms === 'number') { const end = Date.now(); execs.set(e.tool_use_id, { start: end - e.duration_ms, end }) }
173    return r
174  })
175
176  on('classic.PostToolUseFailure', async ($, e, next) => {
177    const r = await next(e)
178    if (typeof e.duration_ms === 'number') { const end = Date.now(); execs.set(e.tool_use_id, { start: end - e.duration_ms, end }) }
179    return r
180  })
181
182  on('tool.call', async ($, e, next) => {
183    const t = turn
184    if (!t) return next(e)
185    const o: Open = { kind: 'call', start: Date.now(), tool: e.tool, label: toolLabel(e.tool, e as { command?: unknown }) }
186    const sub = e.agentId !== undefined
187    if (!sub) t.open.add(o)
188    try {
189      return await next(e)
190    } finally {
191      const end = Date.now()
192      const exec = execs.get(e.tool_use_id)
193      execs.delete(e.tool_use_id)
194      const ask = USER_TOOLS[e.tool] ? undefined : asks.find(a => !a.claimed && a.tool === e.tool && a.at >= o.start && a.at <= end)
195      if (ask) {
196        ask.claimed = true
197        t.spans.push({ kind: 'you', start: ask.at, end: exec ? exec.start : end, label: 'approvals' })
198      }
199      if (!sub && t.open.delete(o)) {
200        if (USER_TOOLS[e.tool]) t.spans.push({ kind: 'you', start: o.start, end, label: USER_TOOLS[e.tool] as string })
201        else if (exec) t.spans.push({ kind: 'tool', start: exec.start, end: exec.end, label: o.label })
202        else t.spans.push({ kind: 'tool', start: ask ? end : o.start, end, label: o.label })
203      }
204    }
205  })
206
207  on('turn.complete', async ($, e, next) => {
208    const r = await next(e)
209    if (e.agentId === undefined && turn && turn.id === e.turnId) {
210      const report = finish(turn, Date.now(), e.durationMs, e.isAborted)
211      turn = null
212      totals = add(totals, report)
213      if (paneOpened) $.ui.invalidate('ui.render')
214      if (report.wall >= MIN_LINE_MS) {
215        // A terminal-only session draws the line in place of "Worked for";
216        // anywhere else, and for an interrupted turn, it is a log line.
217        const surfaces = await $.session.surfaces()
218        const terminalOnly = surfaces.length > 0 && surfaces.every(s => s === 'terminal')
219        if (terminalOnly && !report.aborted) {
220          if (recent.has(e.durationMs)) {
221            recent.set(e.durationMs, null)
222            $.ui.log(turnLine(report))
223          } else {
224            remember(recent, e.durationMs, report)
225            $.ui.invalidate('ui.render')
226          }
227        } else $.ui.log(turnLine(report))
228      }
229    }
230    return r
231  })
232}
233
hooks/ledger.ts 261 lines
1export type Kind = 'you' | 'tool' | 'writing' | 'waiting'
2
3export type Span = { kind: Kind; start: number; end: number; label: string }
4
5export type TurnReport = {
6  wall: number
7  aborted: boolean
8  waiting: number
9  writing: number
10  you: number
11  other: number
12  tools: Record<string, number>
13  youBy: Record<string, number>
14}
15
16// When spans overlap, the earlier kind in this list owns the time. A tool
17// started while Claude is still streaming is the tool's time; a dialog open
18// while a tool is running is yours.
19const PRIORITY: Kind[] = ['you', 'tool', 'writing', 'waiting']
20
21export const PARALLEL = 'parallel tools'
22
23export function split(spans: Span[], start: number, end: number, aborted = false): TurnReport {
24  const report: TurnReport = { wall: Math.max(0, end - start), aborted, waiting: 0, writing: 0, you: 0, other: 0, tools: {}, youBy: {} }
25  const clipped = spans
26    .map(s => ({ ...s, start: Math.max(s.start, start), end: Math.min(s.end, end) }))
27    .filter(s => s.end > s.start)
28  const cuts = new Set<number>([start, end])
29  for (const s of clipped) { cuts.add(s.start); cuts.add(s.end) }
30  const points = [...cuts].sort((a, b) => a - b)
31  for (let i = 0; i + 1 < points.length; i++) {
32    const a = points[i] as number, b = points[i + 1] as number, len = b - a
33    const over = clipped.filter(s => s.start <= a && s.end >= b)
34    const kind = PRIORITY.find(k => over.some(s => s.kind === k))
35    if (!kind) { report.other += len; continue }
36    if (kind === 'tool') {
37      const labels = new Set(over.filter(s => s.kind === 'tool').map(s => s.label))
38      const label = labels.size === 1 ? [...labels][0] as string : PARALLEL
39      report.tools[label] = (report.tools[label] || 0) + len
40    } else if (kind === 'you') {
41      const label = over.find(s => s.kind === 'you')!.label
42      report.you += len
43      report.youBy[label] = (report.youBy[label] || 0) + len
44    } else report[kind] += len
45  }
46  return report
47}
48
49export function toolTotal(r: { tools: Record<string, number> }): number {
50  return Object.values(r.tools).reduce((a, b) => a + b, 0)
51}
52
53export function fmt(ms: number): string {
54  if (ms < 10_000) return (Math.round(ms / 100) / 10).toFixed(1) + 's'
55  const s = Math.round(ms / 1000)
56  if (s < 60) return s + 's'
57  if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
58  return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm'
59}
60
61function ranked(m: Record<string, number>): Array<[string, number]> {
62  return Object.entries(m).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
63}
64
65export type Bucket = 'claude' | 'tools' | 'you' | 'other'
66
67export const COLORS: Record<Bucket, string> = { claude: '#d97757', tools: '#6a9bcc', you: '#788c5d', other: '#b0aea5' }
68
69export type Segment = { text: string; bucket?: Bucket; strong?: boolean }
70
71// The turn line as pieces, so a surface that draws colour can tint each
72// bucket's name while the plain line stays the same text.
73export function turnSegments(r: TurnReport): Segment[] {
74  const out: Segment[] = [{ text: fmt(r.wall), strong: true }]
75  const sep = () => out.push({ text: ' · ' })
76  const claude = r.waiting + r.writing
77  if (claude > 0) {
78    sep()
79    out.push({ text: 'Claude', bucket: 'claude' }, { text: ' ' }, { text: fmt(claude), strong: true })
80    if (r.waiting > 0) out.push({ text: ' (' + fmt(r.waiting) + ' before output)' })
81  }
82  const tools = ranked(r.tools)
83  const only = tools.length === 1 ? tools[0] : undefined
84  if (only) {
85    sep()
86    out.push({ text: only[0], bucket: 'tools' }, { text: ' ' }, { text: fmt(only[1]), strong: true })
87  } else if (tools.length > 1) {
88    const top = tools.slice(0, 2).map(([k, v]) => k + ' ' + fmt(v)).join(', ')
89    sep()
90    out.push({ text: 'tools', bucket: 'tools' }, { text: ' ' }, { text: fmt(toolTotal(r)), strong: true }, { text: ' (' + top + (tools.length > 2 ? ', +' + (tools.length - 2) : '') + ')' })
91  }
92  if (r.you > 0) {
93    sep()
94    out.push({ text: 'you', bucket: 'you' }, { text: ' ' }, { text: fmt(r.you), strong: true })
95  }
96  sep()
97  out.push({ text: 'other', bucket: 'other' }, { text: ' ' }, { text: fmt(r.other), strong: true })
98  if (r.aborted) out.push({ text: ' · interrupted' })
99  return out
100}
101
102export function turnLine(r: TurnReport): string {
103  return turnSegments(r).map(s => s.text).join('')
104}
105
106export function bucketTimes(r: TurnReport): Record<Bucket, number> {
107  return { claude: r.waiting + r.writing, tools: toolTotal(r), you: r.you, other: r.other }
108}
109
110// Splits `width` cells between the buckets in proportion to their time, by
111// largest remainder. A bucket with time but no cell then takes one from the
112// biggest bucket, so a short wait stays visible without skewing the rest.
113export function cells(times: Record<Bucket, number>, width: number): Bucket[] {
114  const order: Bucket[] = ['claude', 'tools', 'you', 'other']
115  const total = order.reduce((a, k) => a + times[k], 0)
116  if (total <= 0 || width <= 0) return []
117  const live = order.filter(k => times[k] > 0)
118  const exact = live.map(k => ({ k, share: (times[k] / total) * width }))
119  const counts = new Map<Bucket, number>(exact.map(({ k, share }) => [k, Math.floor(share)]))
120  let left = width - exact.reduce((a, { share }) => a + Math.floor(share), 0)
121  for (const { k } of [...exact].sort((x, y) => (y.share % 1) - (x.share % 1))) {
122    if (left <= 0) break
123    counts.set(k, (counts.get(k) as number) + 1)
124    left--
125  }
126  for (const k of live) {
127    if ((counts.get(k) as number) > 0) continue
128    const donor = live.reduce((a, b) => ((counts.get(b) as number) > (counts.get(a) as number) ? b : a))
129    if ((counts.get(donor) as number) <= 1) break
130    counts.set(donor, (counts.get(donor) as number) - 1)
131    counts.set(k, 1)
132  }
133  return live.flatMap(k => Array<Bucket>(counts.get(k) as number).fill(k))
134}
135
136export type Totals = TurnReport & { turns: number; interrupted: number; longest: number }
137
138export function emptyTotals(): Totals {
139  return { wall: 0, aborted: false, waiting: 0, writing: 0, you: 0, other: 0, tools: {}, youBy: {}, turns: 0, interrupted: 0, longest: 0 }
140}
141
142export function add(t: Totals, r: TurnReport): Totals {
143  const sum = (a: Record<string, number>, b: Record<string, number>) => {
144    const out = { ...a }
145    for (const [k, v] of Object.entries(b)) out[k] = (out[k] || 0) + v
146    return out
147  }
148  return {
149    ...t,
150    wall: t.wall + r.wall,
151    waiting: t.waiting + r.waiting,
152    writing: t.writing + r.writing,
153    you: t.you + r.you,
154    other: t.other + r.other,
155    tools: sum(t.tools, r.tools),
156    youBy: sum(t.youBy, r.youBy),
157    turns: t.turns + 1,
158    interrupted: t.interrupted + (r.aborted ? 1 : 0),
159    longest: Math.max(t.longest, r.wall),
160  }
161}
162
163export type ReceiptRow = { bucket: Bucket; name: string; ms: number; pct: number; note: string }
164
165export type ReceiptModel = { header: string; rows: ReceiptRow[]; times: Record<Bucket, number>; footer: string }
166
167export function receiptModel(t: Totals): ReceiptModel | null {
168  if (t.turns === 0) return null
169  const pct = (ms: number) => (t.wall > 0 ? Math.round((ms / t.wall) * 100) : 0)
170  const tools = ranked(t.tools)
171  const toolNote = tools.slice(0, 3).map(([k, v]) => k + ' ' + fmt(v)).join(', ') + (tools.length > 3 ? ', +' + (tools.length - 3) + ' more' : '')
172  const youNote = ranked(t.youBy).map(([k, v]) => k + ' ' + fmt(v)).join(', ')
173  const times = bucketTimes(t)
174  return {
175    header: 'this session, this machine · ' + t.turns + ' turn' + (t.turns === 1 ? '' : 's') + ' · ' + fmt(t.wall) + ' of turn time' + (t.interrupted ? ' · ' + t.interrupted + ' interrupted' : ''),
176    rows: [
177      { bucket: 'claude', name: 'Claude', ms: times.claude, pct: pct(times.claude), note: 'before output ' + fmt(t.waiting) + ', writing ' + fmt(t.writing) },
178      { bucket: 'tools', name: 'tools', ms: times.tools, pct: pct(times.tools), note: toolNote },
179      { bucket: 'you', name: 'you', ms: times.you, pct: pct(times.you), note: youNote },
180      { bucket: 'other', name: 'other', ms: times.other, pct: pct(times.other), note: 'not attributed: hooks, local work, gaps' },
181    ],
182    times,
183    footer: 'longest turn ' + fmt(t.longest) + '. Times are summed waits measured in this session, not server speed.',
184  }
185}
186
187export function receiptRow(r: ReceiptRow): string {
188  return r.name.padEnd(8) + fmt(r.ms).padStart(7) + (r.pct + '%').padStart(6) + (r.note ? '  ' + r.note : '')
189}
190
191export function receipt(t: Totals): string[] {
192  const m = receiptModel(t)
193  if (!m) return ['No finished turns yet in this session.']
194  return [m.header, ...m.rows.map(receiptRow), m.footer]
195}
196
197const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
198const PM_SUBCOMMANDS = new Set(['test', 'install', 'i', 'ci', 'build', 'lint', 'dev', 'start', 'typecheck', 'format', 'add', 'exec'])
199const SCRIPT_NAMES = new Set(['test', 'build', 'lint', 'dev', 'start', 'typecheck', 'format', 'check', 'e2e'])
200const SUBCOMMANDS: Record<string, Set<string>> = {
201  cargo: new Set(['test', 'build', 'run', 'check', 'clippy', 'fmt', 'bench']),
202  go: new Set(['test', 'build', 'run', 'vet', 'mod', 'generate']),
203  git: new Set(['status', 'diff', 'log', 'commit', 'push', 'pull', 'fetch', 'clone', 'checkout', 'switch', 'rebase', 'merge', 'add', 'show', 'stash', 'grep']),
204  docker: new Set(['build', 'run', 'compose', 'pull', 'push']),
205  gh: new Set(['pr', 'issue', 'run', 'api', 'repo']),
206  dotnet: new Set(['test', 'build', 'run', 'restore']),
207  swift: new Set(['test', 'build', 'run']),
208}
209const PROGRAMS = new Set([
210  'npm', 'pnpm', 'yarn', 'bun', 'npx', 'node', 'deno', 'tsc', 'jest', 'vitest', 'eslint', 'prettier', 'playwright',
211  'python', 'python3', 'pip', 'pip3', 'uv', 'pytest', 'ruff', 'mypy', 'poetry',
212  'cargo', 'rustc', 'go', 'make', 'cmake', 'ninja', 'gradle', 'gradlew', 'mvn', 'java', 'dotnet', 'swift', 'xcodebuild',
213  'git', 'gh', 'docker', 'kubectl', 'terraform', 'curl', 'wget',
214  'ls', 'cat', 'grep', 'rg', 'find', 'sed', 'awk', 'head', 'tail', 'wc', 'sleep', 'echo', 'mkdir', 'rm', 'cp', 'mv', 'chmod', 'tar', 'jq', 'expr',
215])
216
217// A chained command runs several programs; the one that ran tests or a build
218// is almost always where the time went, so it names the call over the others.
219const RUNNERS = /^(?:(?:npm|pnpm|yarn|bun) (?:test|run (?:test|build|e2e|typecheck)|build|e2e|typecheck)|npx|node --test|pytest|tsc|jest|vitest|playwright|cargo (?:test|build|bench)|go (?:test|build)|make|cmake|ninja|gradle|gradlew|mvn|dotnet (?:test|build)|swift (?:test|build)|xcodebuild)$/
220
221// Only a program on PROGRAMS, and only a subcommand on its own list, is ever
222// shown: a script name or path can be private, so anything else reads "Bash".
223export function bashLabel(command: unknown): string {
224  if (typeof command !== 'string') return 'Bash'
225  const labels = command
226    .split(/&&|\|\||;|\n/)
227    .map(part => labelOne((part.split('|')[0] as string).trim()))
228    .filter((label): label is string => label !== null)
229  return labels.find(label => RUNNERS.test(label)) || labels.find(label => label !== 'Bash') || 'Bash'
230}
231
232function labelOne(segment: string): string | null {
233  const words = segment.replace(/[()]/g, ' ').split(/\s+/).filter(Boolean)
234  let i = 0
235  while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] as string) || words[i] === 'time' || words[i] === 'env')) i++
236  const raw = words[i]
237  if (!raw || raw === 'cd') return null
238  const program = raw.replace(/^\.\//, '').split('/').pop() as string
239  if (!PROGRAMS.has(program)) return 'Bash'
240  const next = words[i + 1]
241  const after = words[i + 2]
242  if ((program === 'python' || program === 'python3') && next === '-m' && after === 'pytest') return 'pytest'
243  if (program === 'node' && next === '--test') return 'node --test'
244  if (PACKAGE_MANAGERS.has(program) && next) {
245    if (next === 'run' && after && SCRIPT_NAMES.has(after)) return program + ' run ' + after
246    if (PM_SUBCOMMANDS.has(next)) return program + ' ' + next
247    if (program !== 'npm' && SCRIPT_NAMES.has(next)) return program + ' ' + next
248    return program
249  }
250  const subs = SUBCOMMANDS[program]
251  if (subs && next && subs.has(next)) return program + ' ' + next
252  return program
253}
254
255export function toolLabel(tool: string, input: { command?: unknown }): string {
256  if (tool === 'Bash') return bashLabel(input.command)
257  if (tool.startsWith('mcp__')) return 'MCP'
258  if (tool === 'Agent' || tool === 'Task') return 'subagent'
259  return tool
260}
261