A live forensic pane for multi-agent Claude Code sessions: what every subagent was asked, did and cost, how their work connects, where they collided, over a…

A live forensic pane for multi-agent Claude Code sessions. It shows:
Every claim carries an evidence label, so you can always tell what was observed from what was inferred.
In the Claude desktop app the pane is drawn over a still, animated scene that never moves while you scroll. There are two categories of six scenes:
Every scene has its own lofi track and liquid-glass cards whose transparency you choose. In the terminal it draws a clean classic look.
A community mod, not made by or affiliated with Anthropic.

<img src="docs/images/side-pane-spooky-fogbound-cemetery.jpg" alt="The default side pane, over the Spooky scene Fogbound Cemetery: the toolbar, the four summary cards two to a row, the coordinator and the subagents" width="23%"> <img src="docs/images/pane-spooky-fogbound-cemetery.jpg" alt="The expanded pane over Fogbound Cemetery, with five subagents of five different types, the code reviewer still active" width="75%">
general-purpose, a coder, a test engineer and a code reviewer) fixing a bug in a demo project, live.</i></p>
<img src="docs/images/pane-cozy-rainy-forest-cabin.jpg" alt="The same session over the Cozy scene Rainy Forest Cabin" width="49%"> <img src="docs/images/pane-spooky-hollow-library.jpg" alt="The same session over the Spooky scene Hollow Library" width="49%"> <img src="docs/images/pane-spooky-drowned-lighthouse.jpg" alt="The same session over the Spooky scene Drowned Lighthouse" width="49%"> <img src="docs/images/pane-spooky-mire-lights.jpg" alt="The same session over the Spooky scene Mire Lights" width="49%">
When a session hands work to subagents, the transcript shows what each one reported back. It does not lay out what each one did and cost, file by file and request by request, or how its work met everyone else's. Swarm Autopsy records the session's own events as they happen and turns them into answers you can check.
| Reading the transcript | With Swarm Autopsy | |
|---|---|---|
| A subagent's work | What it reported back | Every tool call it made (delegations as spawns), with its files, command and a result excerpt, each with the hook it came from |
| Did the tests pass? | The agent's own account | The check's own exit status, read step by step |
| Two agents on one file | Pieced together by hand | Seven collision signals, decided by event order |
| Was a report used? | Not shown | A verbatim match or a shared path, labelled by how sure it is |
Highlights:
| tail and || true never count as a pass. A test passes only when the check itself set the exit status. Test verdicts →At a glance:
hooks/register.tsx);Contents:
At the prompt of a Claude Code terminal session:
/plugin install swarm-autopsy --marketplace agryu-in/swarm-autopsy-claude-mod
Answer y to add the marketplace, then press Enter to choose the user scope, so the mod is installed for every project.
From a shell (it asks nothing and installs at the user scope):
claude plugin install swarm-autopsy --marketplace agryu-in/swarm-autopsy-claude-mod
claude plugin list then shows swarm-autopsy@swarm-autopsy. Start a new claude session, and run /swarm-autopsy.
In the Claude desktop app, where /plugin does not run:
agryu-in/swarm-autopsy-claude-mod.A mod installed from a terminal at the user scope loads in the desktop app's local sessions too. In a chat that was already open, run /reload-plugins.
Requirements:
paplay and pactl). Without either, the pane simply says there is no audio player.git is optional: the Collisions view uses it to resolve worktree roots.| Command | What it does |
|---|---|
/swarm-autopsy | Opens the pane: docked beside the transcript, or inline above the prompt in a non-fullscreen terminal |
/swarm-autopsy live | Opens it on a view; also trail, collisions, forensics, spend or health |
/swarm-autopsy export | Writes the collected session to a JSON file under the session's root and says where |
/swarm-autopsy close | Closes the pane |
In the terminal, 1 to 6 switch views while the pane has the keyboard (ctrl+x tab, or a click). Tab walks the buttons, and Esc hands the keys back. On the desktop, click the tabs.
/swarm-autopsy. The mod records from the moment the session loads it, so the pane can be opened at any time. Use three subagents in parallel: one to find where the date helpers are tested,
one to fix the failing date test, and one to review the fix and run the tests.
/swarm-autopsy export to get the whole session as one JSON file.| View | Shows |
|---|---|
| Live | The coordinating session, and one card per subagent: type, parent, task, state, last event, current tool, files, whether a report was captured, its own token usage (and its spawn tree's), and a context gauge. A card opens into its delegated prompt, files, report, context history, token ledger, spend-vs-outcome classification and event history. |
| Trail | Per agent, the chain from delegation to verification, and the edges between chains. Each edge carries its evidence and the rule that drew it. On the desktop, a map draws the same graph (solid = LINKED, dashed = INFERRED). |
| Collisions | A file × agent matrix (W written, r read, × failed edit), and signals: shared or interleaved writes, one agent replacing text another wrote, writes after a stale read, the same path changed in separate worktrees, edits during a running test, repeated failures, contradictory results. |
| Forensics | Every observed event in order, filterable (all / agents / files / tests / failures / signals) and by agent, paged, with the selected event's evidence: source hook, ids, paths, command, diff excerpt, result, and verdict basis. |
| Spend | The token ledger: per agent, its own requests (direct) and its whole spawn tree (subtree), with the four token categories kept apart, and session totals. A ≈$ API-equivalent estimate is shown only after you turn it on. Below it, spend vs outcome: each agent's tokens grouped by what its work amounted to (verified, changed but unverified, failed or inconclusive, report only, unresolved, mixed), with the denominator stated. |
| Health | The findings the other analyses already raised, grouped by source, each with its evidence label, its agents and its events. No score and no traffic light. |
| Label | Glyph | Meaning |
|---|---|---|
| OBSERVED | ● | Reported directly by an event, message, command result or tool result. |
| LINKED | ◆ | Connected by explicit identifiers: a spawn's parent, agentId on the agent's own events, a verbatim excerpt of one agent's report in another's delegation prompt, a delegation prompt naming the exact path the agent then edited, or an edit replacing text another agent wrote. |
| INFERRED | ◇ | Plausible from indirect evidence: a shared path or file stem plus event order. A candidate, not proof. |
| UNRESOLVED | ? | Not enough evidence to decide: an unlinked contribution, an unverified change, a latest test run that failed. |
| UNAVAILABLE | ∅ | The interface does not expose it: a subagent's context %, the task of an agent spawned before the mod loaded. |
Numbers carry a simpler label:
Each view below gives:
| You want to know | View | Decided by |
|---|---|---|
| Who spawned which agent, and what was it asked? | Live | The spawn record |
| Did anything check this file after its last edit? | Trail | A linked check after the file's last write |
| Did that test really pass? | Test verdicts | The check's own exit status |
| Did two agents edit one file, or overwrite each other? | Collisions | The writers, in event order |
| Was a subagent's report used? | Trail | Verbatim text or a shared path |
| Which agent used the most tokens, on which model? | Spend | Each request's own usage |
| What share of the tokens went to verified work? | Spend | One outcome bucket per agent |
| How close is each agent to its context limit? | Live | The main session's reading, or a labelled estimate |
| What should I look at first? | Health | Every finding above, with its evidence |
The swarm right now: who is working, on what, and how close each agent is to its limit.
Use it to spot a stuck or failing agent, one near its context limit, or one burning tokens.
The context gauge:
| Agent | How it is measured | Label |
|---|---|---|
| Main session | Claude Code's own session.measure reading | ● OBSERVED |
| Subagent on the main session's model | Its latest prompt ÷ the main session's window | ≈ ESTIMATED |
| Subagent on another model | Its latest prompt ÷ that model's window, from a dated table | ≈ ESTIMATED |
| No usage yet, a model with no known window, or a prompt larger than the window; for the main session, no reading yet | Not shown | ∅ UNAVAILABLE |
Claude Code does not expose a subagent's window to mods, which is why a subagent's figure is always an estimate.
SubagentStop, or from Claude Code's agent list.From delegation to verification: did each agent's work lead anywhere, and was it checked?

Use it to find edits that were never checked after their last change, and see whose reports fed the next step.
The chain. Each agent's work moves through five stages:
| Stage | Holds |
|---|---|
| 1. delegation | The agent's prompt |
| 2. investigation | Its reads, searches and read-only commands |
| 3. finding | Its report |
| 4. change | Each file it wrote successfully |
| 5. verification | Its test and check runs |
The edges. Edges join the chains. Each is drawn by a fixed rule, and carries its label and that rule's basis:
| Edge | Drawn when | Label |
|---|---|---|
| parent → delegation | The spawn record (its parent, or the main session, and its tool_use_id), or Claude Code's agent list (a row with no parent is read as the main session), with the agent's task known | ◆ LINKED |
| report → parent | The report came back to the agent's parent | ◆ LINKED |
| report → a later delegation | 12 or more words of the report appear verbatim in the later prompt | ◆ LINKED (7 to 11 words: ◇ INFERRED) |
| report → a later delegation | The report and the later prompt name the same path | ◇ INFERRED |
| report → another agent's later change | The report names the changed file, and that agent could have received it: the author's parent, or an agent spawned after the report (or whose spawn was not seen) | ◇ INFERRED |
| delegation → the agent's own change | Its prompt names the file it then edited | ◆ LINKED by path, ◇ INFERRED by file name |
| change → verification | A passed or failed check after the change, that names the file, was run by the same agent, or names no file and ran in its working tree | ◇ INFERRED |
A trail, sketched. This illustrates the rules; it is not a capture:
flowchart LR
subgraph A["A · Explore"]
A1[delegation] --> A3["finding: report"]
end
subgraph B["B · coder"]
B1[delegation] --> B4["change: src/auth/token.ts"]
end
subgraph C["C · test engineer"]
C1[delegation] --> C5["verification: npx vitest run src/auth/token.test.ts · passed"]
end
A3 ==>|"LINKED · 14 words verbatim in B's prompt"| B1
B4 -.->|"INFERRED · the command names token, the file's stem"| C5
What stays open (? UNRESOLVED):
index, main or utils.A test passes only when the check itself set the exit status.
A command is split into steps at &&, ||, |, ;, & and new lines, with quotes and heredoc bodies blanked out. Each step is read by the program at its head, never by words inside it. Then:
| Command | Exit | Verdict | Basis, as the pane shows it | ||
|---|---|---|---|---|---|
npx vitest run | 0 | passed | the tool reported success (no error result) | ||
npx vitest run | 1 | failed | the tool reported an error: exit code 1 | ||
cd api && pytest -q | 0 | passed | the tool reported success (no error result) | ||
| `pytest -q \ | tail -5` | 0 | inconclusive | the command exited 0, but its exit status comes from a later step (tail -5), not from the check itself | |
| `npm test \ | \ | true` | 0 | inconclusive | the command exited 0, but its exit status comes from a later step (true), not from the check itself |
| `npm run build \ | \ | npm test` | 0 | inconclusive | the command exited 0, but the check runs only when an earlier step fails |
npm test & | 0 | inconclusive | the command exited 0, but the check was sent to the background with & |
cd. Trail and Collisions group cd api && pytest -q under the api directory.These are inconclusive, and never failed either.
| Kind | Heads (an excerpt; also via npx, pnpm exec, bunx, yarn) |
|---|---|
| Tests | pytest, jest, vitest, mocha, npm test, go test, cargo test, dotnet test, node --test, make test, mvn test, gradle test, python -m unittest, claude plugin test |
| Checks | tsc, eslint, ruff, mypy, pyright, npm run lint / build / typecheck, cargo check / clippy, go vet, claude plugin validate |
The full list is in the reference.
Where agents got in each other's way.

Use it to find the usual reasons a swarm's result doesn't hold together: two agents editing the same code, one undoing another, or a test run against a moving target.
| Signal | Raised when | Label | Severity |
|---|---|---|---|
| Shared write | 2 or more agents wrote the file. Interleaved when the order returns to an agent (A → B → A) | ● OBSERVED | investigate if interleaved, else notice |
| Overwrite | A later write replaced text a different agent wrote: a whole-file Write, or 12 or more characters of the same text | ● OBSERVED / ◆ LINKED | investigate |
| Stale write | An agent wrote a file it had read, after another agent wrote it in between | ◇ INFERRED | investigate |
| Worktree divergence | The same relative path was written in 2 worktrees of one repository | ● OBSERVED | notice |
| Test during change | Another agent's write landed while a test or check was running | ◇ INFERRED | investigate |
| Repeated failure | A command errored twice or more in one directory, or 2 writes to one path errored | ● OBSERVED | investigate |
| Contradictory results | The same command in the same directory both passed and failed | ● OBSERVED | investigate if the last run failed, else notice |
Signals are decided by the session's own event order, with no clock windows.
The matrix has a row per file and a column per agent. An illustration (the note after the row is not part of it):
A B C
⇄ src/auth/token.ts W2 W1 r ← written by A, then B, then A: an interleaved shared write
src/auth/index.ts r W1 ·
tests/token.test.ts · × W1
W is writes, with a count;r is read only;× is only failed or refused edits;· is untouched;⇄ marks a file that several agents wrote.git rev-parse --show-toplevel --git-common-dir, run only for directories that were written, with a 5 s timeout. Without git, the matrix still works, but no worktree signal can be raised.Every event, in order, with the evidence behind it.

Use it to trace any claim in another view back to the exact events it rests on.
The selected event shows:
tool.call: tool_use_id …, agentId …; result captured;hooks/register.tsx 736 lines1// Swarm Autopsy: collects what the session's agents observably did and draws
2// it as a forensic pane, opened with /swarm-autopsy.
3//
4// The collector reduces each hook input to an observation (lib/collect) and
5// folds it into a snapshot (lib/reduce) held in a module cache. Everything
6// slower than that (resolving git roots, asking the agent roster, writing the
7// snapshot to `$.state`, the optional export) runs on a background tick, never
8// on the path a tool call, a spawn or a turn returns by. The pane reads
9// `$.state`, so it redraws as the snapshot moves and survives hot reloads.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, RenderInput, Timer } from 'claude-code'
13
14import type { LiveFilter, Snapshot, TimelineFilter, TrailFilter, UiState, View } from '../types'
15import { cleanClarity } from './lib/clarity.ts'
16import { dirName } from './lib/classify'
17import { analyzeCollisions } from './lib/collisions'
18import {
19 rosterObservation,
20 spawnObservation,
21 toolEndObservation,
22 toolFacts,
23 toolStartObservation,
24 turnEndObservation,
25} from './lib/collect'
26import { compact, plural, subagents } from './lib/format'
27import { buildLedgerView, emptyLedger, usageRecordFrom } from './lib/ledger'
28import { buildSpendOutcome } from './lib/outcome'
29import { parsePriceTable, type PriceTable } from './lib/pricing'
30import { emptySnapshot, reduce, type Observation } from './lib/reduce'
31import { cleanMood, currentScene, withMode, withScene, type Mood } from './lib/scenes.ts'
32import { MUSIC_TICK_MS, detectAudio, musicTick, setMusicVolume, stopMusic, toggleMusic } from './music'
33import { clearStillFault, planStillPane, registerStillPane, reopenStillPane, StillPane, wantsStillPane } from './ui/client/still-pane'
34import { applyTheme, themeFor } from './lib/themes'
35import { gatherSignals, unresolvedOf } from './lib/signals'
36import { buildTrail } from './lib/trail'
37import type { Actions, ViewContext } from './ui/common'
38import { layoutFor } from './ui/kit'
39import { cleanTree } from './ui/clean'
40import { drawingFailed } from './ui/failed'
41import { Pane, PaneContent, type Analyses } from './ui/pane'
42
43const PANE_ID = 'swarm-autopsy'
44const TITLE = 'Swarm Autopsy'
45const TICK_MS = 250
46/** The snapshot reaches `$.state` at most this often while it keeps changing: each write redraws the pane, and on
47 * desktop a redraw re-mints every press handle, so a click racing it is lost. */
48const FLUSH_MS = 1000
49/** The terminal dock width asked for when the person wants a wider pane: a request it may not grant. The desktop app
50 * sizes its dock itself, so the button is drawn only on the terminal (common.tsx sizesPaneOnRequest). */
51const WIDE_COLUMNS = 120
52/**
53 * How long after a press the pane keeps drawing the data it had. On desktop every redraw re-mints every press handle,
54 * so in a busy session a click carrying the last drawing's handle was lost (`ui_press not handled` in the desktop app's
55 * log, 2026-10-18); an identical drawing keeps its handles.
56 */
57const HOLD_MS = 20_000
58const DEFAULT_ROWS = 40
59const GIT_TIMEOUT_MS = 5000
60const VIEW_NAMES: readonly View[] = ['live', 'trail', 'collisions', 'forensics', 'spend', 'health']
61const INTERRUPTED = 'The call ended without a result (interrupted or aborted).'
62
63// A tool.call hook that awaits the Agent tool would sit above that subagent's
64// whole run, and the engine never runs a hook beneath its own frame: it would
65// not hear the subagent's tool calls. Delegation is read from agent.spawn and
66// the report from turn.complete instead, so these tools are left out here.
67const COLLECTED_TOOLS = /^(?!(Agent|Task|Workflow)$)/
68
69const DEFAULT_UI: UiState = { view: 'live', filter: 'all', page: 0 }
70
71const snapshotAtom = atom({ plugin: 'swarm-autopsy', key: 'snapshot' } as const, emptySnapshot(0))
72const uiAtom = atom({ plugin: 'swarm-autopsy', key: 'ui' } as const, DEFAULT_UI)
73
74// The module's caches and its background queue. `$.state` holds the truth a
75// reload or a drawing reads; these only speed collection up between ticks.
76let cache: Snapshot | undefined
77let dirty = false
78let ready: Promise<void> | undefined
79let tickTimer: Timer | undefined
80let musicTimer: Timer | undefined
81/** This chat's session id: who owns the music. */
82let musicMe = ''
83let ticking = false
84let lastFlushAt = Number.NEGATIVE_INFINITY
85/** Until when the pane draws held data after the person's last press, and the data and clock it holds. */
86let holdUntil = Number.NEGATIVE_INFINITY
87let frozen: { snapshot: Snapshot; now: number } | undefined
88/** The data and clock of the last drawing, and the clock when it was drawn: what a press freezes. */
89let lastDrawn: { snapshot: Snapshot; now: number } | undefined
90let lastClockNow = Number.NEGATIVE_INFINITY
91let afterClear = false
92let rosterDue = false
93let exportDue = false
94const pendingDirs = new Set<string>()
95let memo: { key: string; analyses: Analyses } | undefined
96
97/** A replacement price table from SWARM_AUTOPSY_PRICES, read once per session start; the built-in one otherwise. */
98type Prices = { table?: PriceTable; path?: string; error?: string }
99let prices: Prices = {}
100
101/** The version 2 shape: the same snapshot without the usage ledger. */
102type SnapshotBeforeLedger = Omit<Snapshot, 'version' | 'ledger' | 'compactionMarks' | 'instruments'> & {
103 version: 2
104 instruments: Omit<Snapshot['instruments'], 'step'>
105}
106
107/** A stored value is reused only when it is this contract's shape (or the one before it) and this session's. */
108function isCurrent(value: unknown, sessionId: string | undefined): value is Snapshot | SnapshotBeforeLedger {
109 if (typeof value !== 'object' || value === null) return false
110 const v = value as Partial<Snapshot> | Partial<SnapshotBeforeLedger>
111 return (
112 (v.version === 3 || v.version === 2) &&
113 v.sessionId !== undefined &&
114 v.sessionId === sessionId &&
115 Array.isArray(v.events) &&
116 Array.isArray(v.openTools) &&
117 typeof v.agents === 'object' &&
118 typeof v.repos === 'object'
119 )
120}
121
122/** A version 2 snapshot kept across a reload gains an empty ledger, and says usage starts now. */
123function upgrade(stored: Snapshot | SnapshotBeforeLedger, at: number): Snapshot {
124 if (stored.version === 3) return stored
125 const since = { at, missedEarlier: true, basis: 'the mod was reloaded over a snapshot that predates the usage ledger' }
126 return reduce(
127 {
128 ...stored,
129 version: 3,
130 ledger: { ...emptyLedger(), since },
131 compactionMarks: [],
132 instruments: { ...stored.instruments, step: 0 },
133 },
134 {
135 kind: 'note',
136 at,
137 text: 'Token usage collection started when the mod was reloaded; requests before that were not recorded.',
138 },
139 )
140}
141
142/** Loads the session's snapshot after a (re)load, or starts a fresh one. */
143async function hydrate($: EngineInterface): Promise<void> {
144 const sessionId = await $.session.id().catch(() => undefined)
145 const stored = await $.state.get({ plugin: 'swarm-autopsy', key: 'snapshot' })
146 const value = stored.value as unknown
147 if (isCurrent(value, sessionId)) {
148 cache = upgrade(value, await $.clock.now())
149 dirty = cache !== value
150 return
151 }
152 const now = await $.clock.now()
153 const cwd = await $.session.cwd().catch(() => undefined)
154 const turns = afterClear ? 0 : await $.session.turns().catch(() => 0)
155 const base = emptySnapshot(now, sessionId)
156 const since = {
157 at: now,
158 missedEarlier: turns > 0,
159 basis:
160 turns > 0
161 ? `the collector started after ${plural(turns, 'prompt')} in this session`
162 : 'the collector started with the session (or after /clear)',
163 }
164 let fresh: Snapshot = { ...base, cwd, ledger: { ...base.ledger, since } }
165 if (turns > 0) {
166 fresh = reduce(fresh, {
167 kind: 'note',
168 at: now,
169 text: `The collector started after ${plural(turns, 'prompt')} in this session; activity before that was not captured.`,
170 })
171 }
172 const roster = await $.agent.list().catch(() => [])
173 if (roster.length > 0) fresh = reduce(fresh, rosterObservation(roster, now))
174 afterClear = false
175 cache = fresh
176 dirty = true
177}
178
179function ensureReady($: EngineInterface): Promise<void> {
180 if (!ready) {
181 ready = hydrate($).catch(async () => {
182 cache = cache ?? emptySnapshot(await $.clock.now().catch(() => 0))
183 })
184 }
185 return ready
186}
187
188/** Folds one observation into the cache. Never throws: collection must not disturb the session. */
189async function observe($: EngineInterface, make: (at: number) => Observation): Promise<void> {
190 try {
191 await ensureReady($)
192 const at = await $.clock.now()
193 const next = reduce(cache ?? emptySnapshot(at), make(at))
194 dirty = dirty || next !== cache
195 cache = next
196 } catch {
197 // A failed observation is dropped; the session goes on untouched.
198 }
199}
200
201/** An agent id the snapshot has never seen: ask the roster for its type, task and parent on the next tick. */
202function noteAgent(agentId: string | undefined): void {
203 if (agentId !== undefined && cache !== undefined && !cache.agents[agentId]) rosterDue = true
204}
205
206async function flush($: EngineInterface): Promise<void> {
207 if (!dirty || !cache) return
208 dirty = false
209 try {
210 await $.state.set({ plugin: 'swarm-autopsy', key: 'snapshot' }, cache)
211 lastFlushAt = await $.clock.now().catch(() => lastFlushAt)
212 } catch {
213 dirty = true
214 }
215}
216
217/** The tick's flush: at most once per FLUSH_MS while changes keep coming. Commands and session events flush at once. */
218async function flushIfDue($: EngineInterface): Promise<void> {
219 if (!dirty) return
220 const now = await $.clock.now().catch(() => Number.POSITIVE_INFINITY)
221 if (now - lastFlushAt >= FLUSH_MS) await flush($)
222}
223
224/** Reads the price table file a person named, if any. A bad file is reported, never silently replaced. */
225async function loadPrices($: EngineInterface): Promise<Prices> {
226 const path = await $.env.get('SWARM_AUTOPSY_PRICES').catch(() => undefined)
227 if (!path) return {}
228 try {
229 const parsed = parsePriceTable(JSON.parse(await $.fs.read(path)))
230 return 'table' in parsed ? { table: parsed.table, path } : { path, error: parsed.error }
231 } catch (error) {
232 return { path, error: error instanceof Error ? error.message : 'the file could not be read as JSON' }
233 }
234}
235
236/** Every analysis the views draw, computed once per snapshot revision and cost setting. */
237function analysesOf(snapshot: Snapshot, showCost = false): Analyses {
238 const key = `${snapshot.sessionId ?? ''}:${snapshot.rev}:${showCost}`
239 if (memo?.key !== key) {
240 const trail = buildTrail(snapshot)
241 const ledger = buildLedgerView(snapshot.ledger, snapshot, {
242 showCost,
243 engineCostUsd: snapshot.engineCost?.usd,
244 prices: prices.table,
245 })
246 const collisions = analyzeCollisions(snapshot)
247 const spend = buildSpendOutcome(snapshot, trail, ledger)
248 memo = {
249 key,
250 analyses: {
251 trail,
252 collisions,
253 ledger,
254 spend,
255 health: gatherSignals({ snapshot, trail, collisions, ledger, spend }),
256 prices: { path: prices.path, error: prices.error },
257 },
258 }
259 }
260 return memo.analyses
261}
262
263/** Writes the collected session and its analysis to `path` (the export's format: the render pipeline's seed). */
264async function writeExport($: EngineInterface, path: string): Promise<void> {
265 if (!cache) return
266 const at = await $.clock.now()
267 const body = {
268 kind: 'swarm-autopsy-export',
269 exportedAt: at,
270 note: 'Observed session data and its deterministic analysis; see README for the evidence labels.',
271 snapshot: cache,
272 // The export carries the ≈$ estimates (labelled ESTIMATED, dated) whatever the pane shows.
273 analysis: analysesOf(cache, true),
274 }
275 await $.fs.write(path, JSON.stringify(body, null, 2))
276}
277
278async function exportSnapshot($: EngineInterface): Promise<void> {
279 try {
280 const path = await $.env.get('SWARM_AUTOPSY_EXPORT')
281 if (path) await writeExport($, path)
282 } catch {
283 // Export is a debugging aid; its failure changes nothing else.
284 }
285}
286
287/**
288 * `/swarm-autopsy export`: the collected session written now, beside the session's root, so a pane problem can be
289 * reproduced from the person's own data (the render pipeline takes the file as its seed). Says where, or why not.
290 */
291async function exportNow($: EngineInterface): Promise<string> {
292 try {
293 const root = await $.session.root()
294 const path = `${root.replace(/\/+$/, '')}/swarm-autopsy-export-${Math.round(await $.clock.now())}.json`
295 await writeExport($, path)
296 return `Swarm Autopsy: exported the collected session to ${path}.`
297 } catch (error) {
298 return `Swarm Autopsy: the export failed: ${error instanceof Error ? error.message : String(error)}`
299 }
300}
301
302/** The git worktree root and the repository's common dir for one directory, or nulls. */
303async function gitRoot($: EngineInterface, dir: string): Promise<{ root: string | null; commonDir: string | null }> {
304 const both = await $.process.run(['git', '-C', dir, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], {
305 timeoutMs: GIT_TIMEOUT_MS,
306 })
307 if (both.exitCode === 0) {
308 const [root, commonDir] = both.stdout.trim().split('\n')
309 return { root: root || null, commonDir: commonDir || null }
310 }
311 const rootOnly = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS })
312 return { root: rootOnly.exitCode === 0 ? rootOnly.stdout.trim() || null : null, commonDir: null }
313}
314
315async function drainRoots($: EngineInterface): Promise<void> {
316 for (const dir of [...pendingDirs]) {
317 pendingDirs.delete(dir)
318 if (cache && dir in cache.roots) continue
319 const found = await gitRoot($, dir).catch(() => ({ root: null, commonDir: null }))
320 await observe($, () => ({ kind: 'root', dir, root: found.root, commonDir: found.commonDir }))
321 }
322}
323
324async function refreshRoster($: EngineInterface): Promise<void> {
325 if (!rosterDue) return
326 rosterDue = false
327 const roster = await $.agent.list().catch(() => [])
328 if (roster.length > 0) await observe($, at => rosterObservation(roster, at))
329}
330
331/** The background tick: slow lookups, the `$.state` write, the export. One at a time. */
332async function tick($: EngineInterface): Promise<void> {
333 if (ticking) return
334 ticking = true
335 try {
336 await drainRoots($)
337 await refreshRoster($)
338 await flushIfDue($)
339 if (exportDue) {
340 exportDue = false
341 await exportSnapshot($)
342 }
343 } finally {
344 ticking = false
345 }
346}
347
348/** A press: the pane holds its data for HOLD_MS from now, so the next press finds the same handles. */
349function noteInteraction($: EngineInterface): void {
350 // At once, from the last drawing's clock, so the press's own redraw is already held; then from the clock itself.
351 holdUntil = Math.max(holdUntil, lastClockNow + HOLD_MS)
352 if (!frozen && lastDrawn) frozen = lastDrawn
353 void $.clock
354 .now()
355 .then(at => {
356 holdUntil = Math.max(holdUntil, at + HOLD_MS)
357 })
358 .catch(() => undefined)
359}
360
361function actionsFor($: EngineInterface): Actions {
362 const set = (fn: (ui: UiState) => UiState) => {
363 noteInteraction($)
364 void update($, uiAtom, fn)
365 }
366 return {
367 view: (view: View) => set(ui => ({ ...ui, view })),
368 select: (id: string | undefined) => set(ui => ({ ...ui, selected: id })),
369 filter: (filter: TimelineFilter) => set(ui => ({ ...ui, filter, page: 0 })),
370 agentFilter: (agentId: string | undefined) => set(ui => ({ ...ui, agentFilter: agentId, page: 0 })),
371 page: (page: number) => set(ui => ({ ...ui, page: Math.max(0, page) })),
372 openEvent: (eventId: string) =>
373 set(ui => ({ ...ui, view: 'forensics', selected: `ev:${eventId}`, filter: 'all', agentFilter: undefined, page: 0 })),
374 toggleCost: () => set(ui => ({ ...ui, showCost: ui.showCost !== true })),
375 focus: (agentId: string | undefined) => set(ui => ({ ...ui, focus: agentId })),
376 toggleFocus: (agentId: string) => set(ui => ({ ...ui, focus: ui.focus === agentId ? undefined : agentId })),
377 toggleOpen: (id: string) =>
378 set(ui => {
379 const open = ui.open ?? []
380 return { ...ui, open: open.includes(id) ? open.filter(x => x !== id) : [...open, id] }
381 }),
382 trailFilter: (trailFilter: TrailFilter) => set(ui => ({ ...ui, trailFilter })),
383 liveFilter: (liveFilter: LiveFilter) => set(ui => ({ ...ui, liveFilter })),
384 requestWidth: (wide: boolean) => {
385 set(ui => ({ ...ui, wide }))
386 void $.ui.open({ id: PANE_ID, title: TITLE, ...(wide ? { columns: WIDE_COLUMNS } : {}) }).catch(() => undefined)
387 },
388 showNode: (nodeId: string) => set(ui => ({ ...ui, view: 'trail', selected: `node:${nodeId}` })),
389 togglePause: () => set(ui => ({ ...ui, paused: ui.paused !== true })),
390 mode: (mode: string) => void changeMood($, mood => withMode(mood, mode)),
391 scene: (scene: string) => void changeMood($, mood => withScene(mood, scene)),
392 play: () => void toggleMusic($, musicMe).then(() => syncMusic($)).catch(() => undefined),
393 volume: (step: number) => void setMusicVolume($, step).then(() => syncMusic($)).catch(() => undefined),
394 glass: (step: number) => void changeGlass($, step),
395 part: (index: number) => set(ui => ({ ...ui, parts: { ...(ui.parts ?? {}), [ui.view]: Math.max(0, Math.floor(index)) } })),
396 }
397}
398
399/**
400 * The music for this chat, every MUSIC_TICK_MS and after a press: plays or stops the scene's track as the store and the
401 * owner say, and mirrors the shared sound and this chat's standing into the UI state (only when they change, so the
402 * pane redraws for nothing else).
403 */
404async function syncMusic($: EngineInterface): Promise<void> {
405 try {
406 const ui = await read($, uiAtom)
407 const track = currentScene(cleanMood(ui.mood)).music
408 const isListening = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
409 const { sound, player } = await musicTick($, musicMe, track, isListening)
410 if (JSON.stringify(ui.sound) !== JSON.stringify(sound) || JSON.stringify(ui.player) !== JSON.stringify(player)) {
411 await update($, uiAtom, now => ({ ...now, sound, player }))
412 }
413 } catch {
414 // the music never gets in the collector's way
415 }
416}
417
418/** The store key every chat reads the chosen mode and scenes from. */
419const MOOD_KEY = 'mood'
420
421/** The mood the store holds, into this chat's UI state, so the pane opens on the scene the person chose last. */
422async function loadMood($: EngineInterface): Promise<void> {
423 const stored = cleanMood(await $.store.get(MOOD_KEY).catch(() => undefined))
424 await update($, uiAtom, ui => ({ ...ui, mood: stored }))
425}
426
427/** The store key every chat reads the liquid glass's clarity from. */
428const GLASS_KEY = 'glass'
429
430/** The glass clarity the store holds, into this chat's UI state. */
431async function loadGlass($: EngineInterface): Promise<void> {
432 const stored = cleanClarity(await $.store.get(GLASS_KEY).catch(() => undefined))
433 await update($, uiAtom, ui => ({ ...ui, glass: stored }))
434}
435
436/** A clarity step from the toolbar: drawn at once, and kept in the store for every chat. */
437async function changeGlass($: EngineInterface, step: number): Promise<void> {
438 noteInteraction($)
439 const next = cleanClarity(step)
440 await update($, uiAtom, ui => ({ ...ui, glass: next }))
441 await $.store.set(GLASS_KEY, next).catch(() => undefined)
442}
443
444/** A new mood from the pickers: drawn at once, and kept in the store for every chat. */
445async function changeMood($: EngineInterface, change: (mood: Mood) => Mood): Promise<void> {
446 noteInteraction($)
447 let next: Mood | undefined
448 await update($, uiAtom, ui => {
449 next = change(cleanMood(ui.mood))
450 return { ...ui, mood: next }
451 })
452 if (next) await $.store.set(MOOD_KEY, next).catch(() => undefined)
453}
454
455function summaryOf(snapshot: Snapshot | undefined): string {
456 if (!snapshot) return 'Nothing collected yet.'
457 const agents = subagents(snapshot)
458 const active = agents.filter(a => a.state === 'active').length
459 const { collisions, ledger, health } = analysesOf(snapshot)
460 const requests = ledger.session.requests + ledger.session.requestsWithoutUsage
461 return `${plural(agents.length, 'subagent')} observed (${active} active), ${plural(snapshot.events.length, 'event')} recorded, ${plural(requests, 'model request')} (${compact(ledger.session.processed)} tokens processed), ${plural(collisions.signals.length, 'collision signal')}, ${unresolvedOf(health).length} unresolved.`
462}
463
464/** What the pane draws when a view throws: the error, and a way back to the Live view. */
465function DrawFailed(ctx: ViewContext, error: unknown) {
466 const { Box, Text, Button } = ctx.els
467 const message = error instanceof Error ? error.message : String(error)
468 return (
469 <Box flexDirection="column" width={ctx.width}>
470 <Text weight="bold">Swarm Autopsy could not draw the {ctx.ui.view} view</Text>
471 <Text wrap="wrap">{message}</Text>
472 <Text dimColor wrap="wrap">
473 The collector keeps running. Another tab may still draw; after a fix, /reload-plugins.
474 </Text>
475 <Box flexDirection="row" columnGap={1}>
476 <Button key="tab-live" label="Live" onPress={() => ctx.act.view('live')} />
477 <Button key="tab-health" label="Health" onPress={() => ctx.act.view('health')} />
478 </Box>
479 </Box>
480 )
481}
482
483function resetSession(at: number): void {
484 cache = emptySnapshot(at)
485 ready = undefined
486 dirty = true
487 afterClear = true
488 rosterDue = false
489 exportDue = false
490 lastFlushAt = Number.NEGATIVE_INFINITY
491 holdUntil = Number.NEGATIVE_INFINITY
492 frozen = undefined
493 lastDrawn = undefined
494 pendingDirs.clear()
495 clearStillFault()
496}
497
498export const register: Register = on => {
499 on('session.start', async ($, e, next) => {
500 const started = await next(e)
501 await $.command
502 .register({
503 name: 'swarm-autopsy',
504 description: 'Swarm Autopsy: what each subagent did, its tokens and context, and what stays unresolved, over a still Spooky or Cozy scene',
505 argumentHint: `[${VIEW_NAMES.join('|')}|export|close]`,
506 })
507 .catch(() => undefined)
508 prices = await loadPrices($)
509 await loadMood($)
510 await loadGlass($)
511 memo = undefined
512 tickTimer?.cancel()
513 tickTimer = $.clock.every(TICK_MS, () => void tick($))
514 musicMe = (await $.session.id().catch(() => undefined)) ?? 'chat'
515 await detectAudio($).catch(() => undefined)
516 musicTimer?.cancel()
517 musicTimer = $.clock.every(MUSIC_TICK_MS, () => void syncMusic($))
518 await ensureReady($)
519 await flush($)
520 return started
521 })
522
523 on('session.end', async ($, e, next) => {
524 musicTimer?.cancel()
525 await stopMusic($, musicMe).catch(() => undefined)
526 await exportSnapshot($)
527 const ended = await next(e)
528 if (e.reason === 'clear' || e.reason === 'resume') {
529 resetSession(await $.clock.now().catch(() => 0))
530 // The process goes on under a new session id, and no session.start follows: the music's tick starts again here.
531 musicMe = (await $.session.id().catch(() => undefined)) ?? 'chat'
532 musicTimer = $.clock.every(MUSIC_TICK_MS, () => void syncMusic($))
533 await flush($)
534 }
535 return ended
536 })
537
538 on('agent.spawn', async ($, e, next) => {
539 const started = await next(e)
540 await observe($, at => spawnObservation(e, started, at))
541 return started
542 }).catch(($, e, next) => next(e))
543
544 on('turn.start', async ($, e, next) => {
545 noteAgent(e.agentId)
546 await observe($, at => ({ kind: 'turn-start', at, turnId: e.turnId, agentId: e.agentId, text: e.text }))
547 return next(e)
548 })
549
550 // One model request, main's or a subagent's. The response streams through
551 // untouched; its usage is recorded once it is whole. turn.step is the only
552 // source of ledger totals (turn.complete's usage is the turn's sum of these).
553 // A request that never came back whole (an API error, an abort that stopped
554 // the stream) is still a request: it is recorded without usage, and the error
555 // or the stop goes on to the caller untouched.
556 on('turn.step', async function* ($, e, next) {
557 let whole = false
558 try {
559 const result = yield* next(e)
560 whole = true
561 noteAgent(e.agentId)
562 await observe($, at => ({ kind: 'step', record: usageRecordFrom(e, result, at) }))
563 return result
564 } finally {
565 if (!whole) {
566 noteAgent(e.agentId)
567 await observe($, at => ({ kind: 'step', record: usageRecordFrom(e, { stopReason: null, usage: null }, at) }))
568 }
569 }
570 })
571
572 on('turn.complete', async ($, e, next) => {
573 await observe($, at => turnEndObservation(e, at))
574 if (e.agentId === undefined) exportDue = true
575 return next(e)
576 })
577
578 on('tool.call', { tool: COLLECTED_TOOLS }, async ($, e, next) => {
579 if (next.origin.plugin !== 'engine') return next(e)
580 const facts = toolFacts(e)
581 noteAgent(e.agentId)
582 await observe($, at => toolStartObservation(e, facts, at))
583 let ran: Awaited<ReturnType<typeof next>>
584 try {
585 ran = await next(e)
586 } catch (error) {
587 await observe($, at => toolEndObservation(e, facts, { isError: true, text: INTERRUPTED }, at))
588 throw error
589 }
590 await observe($, at => toolEndObservation(e, facts, ran, at))
591 if (facts.access === 'write' && ran.deny === undefined && ran.isError !== true) {
592 for (const path of facts.paths) pendingDirs.add(dirName(path))
593 }
594 return ran
595 }).catch(($, e, next) => next(e))
596
597 on('classic.SubagentStart', async ($, e, next) => {
598 await observe($, at => ({ kind: 'agent-start', at, agentId: e.agent_id, agentType: e.agent_type }))
599 return next(e)
600 }).catch(($, e, next) => next(e))
601
602 on('classic.SubagentStop', async ($, e, next) => {
603 await observe($, at => ({
604 kind: 'agent-stop',
605 at,
606 agentId: e.agent_id,
607 agentType: e.agent_type,
608 transcriptPath: e.agent_transcript_path,
609 lastMessage: e.last_assistant_message,
610 }))
611 return next(e)
612 }).catch(($, e, next) => next(e))
613
614 on('session.compact', async ($, e, next) => {
615 const compacted = await next(e)
616 if (compacted.skip === undefined) {
617 const usage = compacted.usage
618 await observe($, at => ({
619 kind: 'compaction',
620 at,
621 agentId: e.agentId,
622 trigger: e.trigger,
623 tokensBefore: compacted.tokensBefore,
624 tokensAfter: compacted.tokensAfter,
625 summarizerTokens: usage
626 ? {
627 input: usage.input_tokens,
628 cacheRead: usage.cache_read_input_tokens,
629 cacheWrite: usage.cache_creation_input_tokens,
630 output: usage.output_tokens,
631 }
632 : undefined,
633 }))
634 }
635 return compacted
636 }).catch(($, e, next) => next(e))
637
638 on('session.measure', async ($, e, next) => {
639 await observe($, at => ({ kind: 'context', at, tokens: e.context.tokens, window: e.context.window, percent: e.context.percent }))
640 const usd = e.cost?.usd
641 if (usd !== undefined) await observe($, at => ({ kind: 'cost', at, usd }))
642 return next(e)
643 })
644
645 on('command.run', { command: 'swarm-autopsy' }, async ($, e) => {
646 await ensureReady($)
647 await flush($)
648 const arg = e.args.trim().toLowerCase()
649 if (arg === 'close') {
650 await $.ui.close({ id: PANE_ID })
651 return { text: 'Swarm Autopsy closed.' }
652 }
653 if (arg === 'export') return { text: await exportNow($) }
654 const view = VIEW_NAMES.find(name => name === arg)
655 if (view) await update($, uiAtom, ui => ({ ...ui, view }))
656 // Opening the pane gives a still scene that had given up after faults its tries back.
657 if (reopenStillPane()) $.ui.invalidate('ui.render')
658 const opened = await $.ui.open({ id: PANE_ID, title: TITLE, focus: true })
659 const summary = summaryOf(cache)
660 return {
661 text: opened.isPlaced
662 ? `Swarm Autopsy opened. ${summary}`
663 : `Swarm Autopsy: no surface placed the pane here (a narrow terminal or a headless run). ${summary}`,
664 }
665 })
666
667 // A render hook that fails before any view draws (a read rejects) or runs past its budget would leave the engine's
668 // own "Nothing to show yet", with the reason only in a debug log: both draw the reason instead.
669 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
670 try {
671 return await drawPane($, e)
672 } catch (error) {
673 return cleanTree(drawingFailed($.ui.resolve(e), paneWidth(e), { kind: 'throw', message: messageOf(error), budget: 0 }))
674 }
675 }).catch(($, e, next) => cleanTree(drawingFailed($.ui.resolve(e), paneWidth(e), next.error)))
676
677 registerStillPane(on, PANE_ID)
678
679}
680
681/** The pane's width in cells, never under the narrowest layout. */
682const paneWidth = (e: RenderInput<'Pane'>): number => Math.max(30, e.props.bodyColumns)
683
684const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
685
686/** The pane's drawing: the held or live data, through the views, every string cleaned. */
687async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
688 const live = await read($, snapshotAtom)
689 const ui = await read($, uiAtom)
690 const clockNow = await $.clock.now()
691 // Paused, or browsing (a press in the last HOLD_MS): draw the data and clock held at the hold's first drawing, so
692 // the drawing stays identical while the session moves on and its press handles keep working.
693 const held = ui.paused === true ? 'paused' : clockNow < holdUntil ? 'browsing' : undefined
694 if (!held) frozen = undefined
695 else if (!frozen) frozen = { snapshot: live, now: clockNow }
696 const snapshot = held && frozen ? frozen.snapshot : live
697 const now = held && frozen ? frozen.now : clockNow
698 lastClockNow = clockNow
699 lastDrawn = { snapshot, now }
700 // The theme this drawing reads through GLASS and ROLE: the chosen scene's palette, the terminal always classic.
701 applyTheme(themeFor(e.surface, currentScene(cleanMood(ui.mood)).id))
702 const els = $.ui.resolve(e)
703 const width = paneWidth(e)
704 const ctx: ViewContext = {
705 els,
706 surface: e.surface,
707 width,
708 layout: layoutFor(width),
709 ...(held ? { held } : {}),
710 rows: e.props.scroll?.bodyRows ?? e.viewport?.rows ?? DEFAULT_ROWS,
711 scrollOffset: e.props.scroll?.offset ?? 0,
712 now,
713 snapshot,
714 ui,
715 act: actionsFor($),
716 }
717 // Every drawn string is cleaned last: one control character in captured text (a command's colour escapes) makes
718 // the engine refuse the whole tree, and the pane then draws nothing and its buttons stop answering.
719 try {
720 const analyses = analysesOf(snapshot, ui.showCost === true)
721 // The desktop draws the content over a still scene (two Clients); past a Client's bounds or after a fault, the engine
722 // tree with the scene as a frame behind it, and a dim line saying why.
723 if (wantsStillPane(ctx)) {
724 // At the pane's own width: on the page every card and panel holds to its column (max-width), so the thin scrollbar
725 // never pushes a fact out of a narrow card.
726 const plan = planStillPane(ctx, cleanTree(PaneContent({ ...ctx, noSelect: true }, analyses)))
727 if (plan.ok) return StillPane(ctx, plan)
728 return cleanTree(Pane(ctx, analyses, plan.reason))
729 }
730 return cleanTree(Pane(ctx, analyses))
731 } catch (error) {
732 // A view that throws on the data would leave the pane blank, with the reason only in a debug log.
733 return cleanTree(DrawFailed(ctx, error))
734 }
735}
736hooks/lib/clarity.ts 18 lines1// How clear the liquid glass is: the person's choice in the toolbar, five steps from frosted (1) to crystal (5), kept in
2// the store so every chat draws the same glass. Clearer glass lets more of the still scene through and blurs it more,
3// so text over it still reads. Pure.
4
5export const CLARITY_STEPS = 5
6export const DEFAULT_CLARITY = 3
7
8/** Each step in words, frosted to crystal: what the toolbar says beside the steps. */
9export const CLARITY_WORDS = ['frosted', 'misted', 'balanced', 'clear', 'crystal'] as const
10
11/** A stored or pressed step, checked: anything but a whole step 1–5 is the default. */
12export function cleanClarity(value: unknown): number {
13 return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= CLARITY_STEPS ? value : DEFAULT_CLARITY
14}
15
16/** A step as a share: 0 frosted, 1 crystal. */
17export const clarityShare = (step: unknown): number => (cleanClarity(step) - 1) / (CLARITY_STEPS - 1)
18hooks/lib/classify.ts 251 lines1// Deterministic reading of tool calls: what a call touches, whether it is a
2// verification command, and what its result says. No guessing about meaning:
3// every rule here is a string or field test a reader can check.
4
5import type { Access, EditExcerpt, ToolFacts, ToolOutcome, VerificationVerdict } from '../../types'
6import { statusNote, verificationKind } from './command'
7
8export const COMMAND_CAP = 400
9export const RESULT_CAP = 600
10export const EDIT_CAP = 1200
11export const MENTION_CAP = 40
12export const REPORT_CAP = 2000
13
14/** The tool a background subagent hands its report back through. */
15export const HANDBACK_TOOL = 'SubagentHandback'
16
17const READ_TOOLS = new Set(['Read', 'NotebookRead', 'LSP'])
18const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
19const SEARCH_TOOLS = new Set(['Glob', 'Grep', 'LS'])
20const EXEC_TOOLS = new Set(['Bash', 'PowerShell'])
21const PATH_FIELDS = ['file_path', 'notebook_path', 'filePath'] as const
22
23// A path-like token: optional ./ ../ or /, directory parts, a name with an
24// extension that starts with a letter.
25const PATH_TOKEN =
26 /(?:^|[\s`'"([<{,:=])((?:\.{1,2}\/|\/)?(?:[\w@+-][\w@.+-]*\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7})(?=$|[\s`'")\]>},:;.!?#])/g
27
28const EXIT_CODE = /Exit code (\d+)/
29
30// Error results that mean the command never ran, as the engine words them.
31const NOT_RUN: ReadonlyArray<readonly [RegExp, string]> = [
32 [/^Permission to use \S+ has been denied/, 'permission was denied'],
33 [/^PreToolUse:\S+ hook error/, 'a PreToolUse hook blocked it'],
34]
35
36/** Why an error result means the command never ran, when its text says so. */
37export function notRunReason(text: string | undefined): string | undefined {
38 const trimmed = (text ?? '').trimStart()
39 return NOT_RUN.find(([pattern]) => pattern.test(trimmed))?.[1]
40}
41
42// Dotted abbreviations the path pattern would otherwise read as file names.
43const ABBREVIATIONS = new Set(['e.g', 'i.e', 'a.k.a', 'n.b', 'p.s', 'vs.', 'etc.', 'cf.'])
44
45export function accessFor(tool: string): Access {
46 if (READ_TOOLS.has(tool)) return 'read'
47 if (WRITE_TOOLS.has(tool)) return 'write'
48 if (SEARCH_TOOLS.has(tool)) return 'search'
49 if (EXEC_TOOLS.has(tool)) return 'exec'
50 return 'other'
51}
52
53export function cap(text: string, limit: number): string {
54 return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
55}
56
57export { verificationKind }
58
59/** Path-like tokens in free text, deduplicated, in order of appearance. */
60export function pathMentions(text: string): string[] {
61 const found: string[] = []
62 for (const match of text.matchAll(PATH_TOKEN)) {
63 const token = (match[1] ?? '').replace(/^\.\//, '')
64 const start = match.index ?? 0
65 const isUrlPart = text.slice(Math.max(0, start - 3), start + 3).includes('://')
66 if (ABBREVIATIONS.has(token.toLowerCase()) || isUrlPart || found.includes(token)) continue
67 found.push(token)
68 if (found.length >= MENTION_CAP) break
69 }
70 return found
71}
72
73export function baseName(path: string): string {
74 const slash = path.lastIndexOf('/')
75 return slash === -1 ? path : path.slice(slash + 1)
76}
77
78export function dirName(path: string): string {
79 const slash = path.lastIndexOf('/')
80 return slash <= 0 ? '/' : path.slice(0, slash)
81}
82
83export function stemOf(path: string): string {
84 const base = baseName(path)
85 const dot = base.indexOf('.')
86 return dot <= 0 ? base : base.slice(0, dot)
87}
88
89export type MentionMatch = 'path' | 'basename' | 'stem'
90
91/**
92 * How a mention names a concrete path: `path` when the mention carries a
93 * directory and the path ends with it, `basename` when only the file name
94 * matches.
95 */
96export function matchMention(mention: string, path: string): MentionMatch | undefined {
97 const clean = mention.replace(/^\.\//, '')
98 if (clean.includes('/')) {
99 const tail = clean.startsWith('/') ? clean : `/${clean}`
100 return path === clean || path.endsWith(tail) ? 'path' : undefined
101 }
102 return baseName(path) === clean ? 'basename' : undefined
103}
104
105/**
106 * The strongest way a text refers to `path`: by a mention carrying its
107 * directory, by its file name, or (weakest) by its stem as a whole word.
108 */
109export function textNamesPath(text: string, mentions: readonly string[], path: string): MentionMatch | undefined {
110 let best: MentionMatch | undefined
111 for (const mention of mentions) {
112 const kind = matchMention(mention, path)
113 if (kind === 'path') return 'path'
114 if (kind === 'basename') best = 'basename'
115 }
116 if (best) return best
117 const stem = stemOf(path)
118 if (stem.length < 4 || GENERIC_STEMS.has(stem.toLowerCase())) return undefined
119 const word = new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(stem)}([^A-Za-z0-9]|$)`, 'i')
120 return word.test(text) ? 'stem' : undefined
121}
122
123// File stems too common to tie a command to one file by name alone.
124const GENERIC_STEMS = new Set([
125 'index', 'main', 'test', 'tests', 'spec', 'build', 'utils', 'util', 'types', 'type', 'config',
126 'setup', 'init', 'app', 'lib', 'src', 'mod', 'core', 'common', 'helpers', 'helper', 'base', 'readme',
127])
128
129export function escapeRegExp(text: string): string {
130 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
131}
132
133export function normalizeCommand(command: string): string {
134 return command.replace(/\s+/g, ' ').trim()
135}
136
137function stringField(input: Readonly<Record<string, unknown>>, key: string): string | undefined {
138 const value = input[key]
139 return typeof value === 'string' && value.length > 0 ? value : undefined
140}
141
142function editExcerpt(tool: string, input: Readonly<Record<string, unknown>>): EditExcerpt | undefined {
143 if (tool === 'Edit') {
144 return {
145 old: cap(stringField(input, 'old_string') ?? '', EDIT_CAP),
146 new: cap(stringField(input, 'new_string') ?? '', EDIT_CAP),
147 replaceAll: input.replace_all === true,
148 }
149 }
150 if (tool === 'Write') {
151 return { old: '', new: cap(stringField(input, 'content') ?? '', EDIT_CAP), replaceAll: false }
152 }
153 if (tool === 'NotebookEdit') {
154 return { old: '', new: cap(stringField(input, 'new_source') ?? '', EDIT_CAP), replaceAll: false }
155 }
156 return undefined
157}
158
159/** What a tool call's arguments say, as plain data. */
160export function factsFor(tool: string, input: Readonly<Record<string, unknown>>): ToolFacts {
161 const access = accessFor(tool)
162 const paths = PATH_FIELDS.map(key => stringField(input, key)).filter((p): p is string => p !== undefined)
163 const searchRoot = access === 'search' ? stringField(input, 'path') : undefined
164 const command = stringField(input, 'command')
165 const content = stringField(input, 'content')
166 const message = tool === HANDBACK_TOOL ? stringField(input, 'message') : undefined
167 const mentionSource = command ?? message
168 const verification = command && access === 'exec' ? verificationKind(command) : undefined
169 return {
170 tool,
171 access,
172 paths: searchRoot ? [...paths, searchRoot] : paths,
173 command: command ? cap(command, COMMAND_CAP) : undefined,
174 mentions: mentionSource ? pathMentions(mentionSource) : [],
175 verification,
176 statusNote: verification && command ? statusNote(command) : undefined,
177 edit: editExcerpt(tool, input),
178 bytes: content?.length,
179 text: message ? cap(message, REPORT_CAP) : undefined,
180 textLength: message?.length,
181 }
182}
183
184/** The fields of a tool's result this module reads; every one optional. */
185export type RawResult = {
186 deny?: string
187 isError?: boolean
188 text?: string
189 isReadOnly?: boolean
190 result?: unknown
191}
192
193function resultFlag(result: unknown, key: string): unknown {
194 return typeof result === 'object' && result !== null ? (result as Record<string, unknown>)[key] : undefined
195}
196
197/**
198 * The verdict of a verification command's captured result. `passed` only
199 * when the tool reported no error and nothing made the status ambiguous.
200 */
201export function verdictFor(raw: RawResult, note?: string): { verdict: VerificationVerdict; basis: string } {
202 if (raw.deny !== undefined) return { verdict: 'inconclusive', basis: `the call was refused: ${cap(raw.deny, 120)}` }
203 const result = raw.result
204 if (resultFlag(result, 'interrupted') === true) return { verdict: 'inconclusive', basis: 'the command was interrupted' }
205 if (typeof resultFlag(result, 'backgroundTaskId') === 'string') {
206 return { verdict: 'inconclusive', basis: 'moved to the background; its final status was not captured' }
207 }
208 if (typeof resultFlag(result, 'timedOutAfterMs') === 'number') {
209 return { verdict: 'inconclusive', basis: 'hit its timeout and was backgrounded' }
210 }
211 if (raw.isError === true) {
212 const notRun = notRunReason(raw.text)
213 if (notRun) return { verdict: 'inconclusive', basis: `the command did not run: ${notRun}` }
214 const code = EXIT_CODE.exec(raw.text ?? '')?.[1]
215 if (code && note) return { verdict: 'inconclusive', basis: `exit code ${code}, but ${note}` }
216 if (code) return { verdict: 'failed', basis: `the tool reported an error: exit code ${code}` }
217 return { verdict: 'inconclusive', basis: 'the tool reported an error with no exit code, so the command may not have run' }
218 }
219 if (note) return { verdict: 'inconclusive', basis: `the command exited 0, but ${note}` }
220 const interpretation = resultFlag(result, 'returnCodeInterpretation')
221 if (typeof interpretation === 'string' && interpretation.length > 0) {
222 return { verdict: 'inconclusive', basis: `non-zero exit read as non-error: ${cap(interpretation, 120)}` }
223 }
224 return { verdict: 'passed', basis: 'the tool reported success (no error result)' }
225}
226
227function resultText(raw: RawResult): string {
228 if (raw.deny !== undefined) return raw.deny
229 if (typeof raw.text === 'string') return raw.text
230 const stdout = resultFlag(raw.result, 'stdout')
231 const stderr = resultFlag(raw.result, 'stderr')
232 return [stdout, stderr].filter((s): s is string => typeof s === 'string' && s.length > 0).join('\n')
233}
234
235/** What a tool call's captured result says. */
236export function outcomeFor(facts: ToolFacts, raw: RawResult): ToolOutcome {
237 const text = resultText(raw)
238 const blocked = raw.isError === true && notRunReason(text) !== undefined
239 const status = raw.deny !== undefined || blocked ? 'denied' : raw.isError === true ? 'error' : 'ok'
240 const code = EXIT_CODE.exec(text)?.[1]
241 const base: ToolOutcome = {
242 status,
243 text: cap(text, RESULT_CAP),
244 exitCode: code !== undefined ? Number(code) : undefined,
245 isReadOnly: raw.isReadOnly === true ? true : undefined,
246 }
247 if (!facts.verification) return base
248 const { verdict, basis } = verdictFor(raw, facts.statusNote)
249 return { ...base, verdict, verdictBasis: basis }
250}
251hooks/lib/collisions.ts 344 lines1// Collaboration collisions: which agents touched which files, and the
2// patterns worth investigating. A signal is a prompt to look, never a verdict:
3// each carries the events it rests on and how strongly they support it.
4
5import type { Evidence, ForensicEvent, Snapshot } from '../../types'
6import { baseName, cap, dirName, normalizeCommand, textNamesPath } from './classify'
7import { leadingDirectory, resolveDir } from './command'
8
9const OVERLAP_MIN = 12
10const MAX_FILES = 40
11
12export type FileTouch = {
13 agentId: string
14 reads: number
15 writes: number
16 failedWrites: number
17 firstSeq: number
18 lastSeq: number
19}
20
21export type FileRow = {
22 path: string
23 root: string | null
24 rel: string
25 touches: FileTouch[]
26 writers: number
27 lastSeq: number
28}
29
30export type SignalKind =
31 | 'shared-write'
32 | 'overwrite'
33 | 'stale-write'
34 | 'worktree-divergence'
35 | 'test-during-change'
36 | 'repeated-failure'
37 | 'contradictory-results'
38
39export type Signal = {
40 id: string
41 kind: SignalKind
42 severity: 'investigate' | 'notice'
43 evidence: Extract<Evidence, 'OBSERVED' | 'LINKED' | 'INFERRED'>
44 title: string
45 detail: string[]
46 agents: string[]
47 paths: string[]
48 eventIds: string[]
49}
50
51export type Collisions = { files: FileRow[]; totalFiles: number; agents: string[]; signals: Signal[] }
52
53const ok = (e: ForensicEvent) => e.outcome?.status === 'ok'
54const refusedOrFailed = (e: ForensicEvent) => e.outcome?.status === 'error' || e.outcome?.status === 'denied'
55const isWriteAttempt = (e: ForensicEvent) => e.kind === 'tool' && e.tool?.access === 'write'
56/** A write whose result came back without error: the file was changed. */
57const isWrite = (e: ForensicEvent) => isWriteAttempt(e) && ok(e)
58const isRead = (e: ForensicEvent) => e.kind === 'tool' && (e.tool?.access === 'read' || e.tool?.access === 'search') && ok(e)
59/** A command that ran and failed (a refusal is a command that never ran). */
60const ranAndFailed = (e: ForensicEvent) => e.outcome?.status === 'error'
61
62function rootOf(snapshot: Snapshot, path: string): string | null {
63 const root = snapshot.roots[dirName(path)]
64 return root === undefined ? null : root
65}
66
67function relOf(path: string, root: string | null): string {
68 return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
69}
70
71function fileRows(snapshot: Snapshot): FileRow[] {
72 const rows = new Map<string, Map<string, FileTouch>>()
73 for (const event of snapshot.events) {
74 if (!isWriteAttempt(event) && !isRead(event)) continue
75 for (const path of event.tool?.paths ?? []) {
76 const touches = rows.get(path) ?? new Map<string, FileTouch>()
77 const prior = touches.get(event.agentId) ?? { agentId: event.agentId, reads: 0, writes: 0, failedWrites: 0, firstSeq: event.seq, lastSeq: event.seq }
78 touches.set(event.agentId, {
79 ...prior,
80 reads: prior.reads + (isRead(event) ? 1 : 0),
81 writes: prior.writes + (isWrite(event) ? 1 : 0),
82 failedWrites: prior.failedWrites + (isWriteAttempt(event) && refusedOrFailed(event) ? 1 : 0),
83 lastSeq: event.seq,
84 })
85 rows.set(path, touches)
86 }
87 }
88 return [...rows.entries()]
89 .map(([path, touches]) => {
90 const list = [...touches.values()].sort((a, b) => a.firstSeq - b.firstSeq)
91 const root = rootOf(snapshot, path)
92 return {
93 path,
94 root,
95 rel: relOf(path, root),
96 touches: list,
97 writers: list.filter(t => t.writes > 0).length,
98 lastSeq: Math.max(...list.map(t => t.lastSeq)),
99 }
100 })
101 .filter(row => row.writers > 0 || row.touches.some(t => t.failedWrites > 0))
102 .sort((a, b) => b.writers - a.writers || b.lastSeq - a.lastSeq)
103}
104
105function writesTo(snapshot: Snapshot, path: string): ForensicEvent[] {
106 return snapshot.events.filter(e => isWrite(e) && e.tool?.paths.includes(path))
107}
108
109function sharedWriteSignals(snapshot: Snapshot, rows: readonly FileRow[]): Signal[] {
110 return rows
111 .filter(row => row.writers >= 2)
112 .map(row => {
113 const writes = writesTo(snapshot, row.path)
114 const order = writes.map(e => e.agentId).filter((id, i, all) => i === 0 || all[i - 1] !== id)
115 const interleaved = order.length > new Set(order).size
116 const agents = [...new Set(writes.map(e => e.agentId))]
117 const labels = agents.map(id => snapshot.agents[id]?.label ?? id)
118 return {
119 id: `s:shared:${row.path}`,
120 kind: 'shared-write' as const,
121 severity: interleaved ? ('investigate' as const) : ('notice' as const),
122 evidence: 'OBSERVED' as const,
123 title: `${interleaved ? 'Interleaved' : 'Sequential'} edits to ${baseName(row.path)} by ${labels.join(', ')}`,
124 detail: [
125 `${row.path}: one physical file in ${row.root ? `the worktree at ${row.root}` : 'an unresolved working tree'}`,
126 `write order: ${writes.map(e => `${snapshot.agents[e.agentId]?.label ?? e.agentId}#${e.seq}`).join(' → ')}`,
127 ],
128 agents,
129 paths: [row.path],
130 eventIds: writes.map(e => e.id),
131 }
132 })
133}
134
135function overlapOf(earlier: string, later: string): boolean {
136 const added = earlier.trim()
137 const replaced = later.trim()
138 if (added.length >= OVERLAP_MIN && replaced.includes(added)) return true
139 return replaced.length >= OVERLAP_MIN && added.includes(replaced)
140}
141
142function overwriteSignals(snapshot: Snapshot, rows: readonly FileRow[]): Signal[] {
143 const signals: Signal[] = []
144 for (const row of rows.filter(r => r.writers >= 2)) {
145 const writes = writesTo(snapshot, row.path)
146 writes.forEach((later, i) => {
147 const earlier = writes.slice(0, i).reverse().find(e => e.agentId !== later.agentId)
148 if (!earlier?.tool?.edit || !later.tool?.edit) return
149 const rewrote = later.tool.tool === 'Write'
150 if (!rewrote && !overlapOf(earlier.tool.edit.new, later.tool.edit.old)) return
151 const who = snapshot.agents[later.agentId]?.label ?? later.agentId
152 const whom = snapshot.agents[earlier.agentId]?.label ?? earlier.agentId
153 signals.push({
154 id: `s:overwrite:${later.id}`,
155 kind: 'overwrite',
156 severity: 'investigate',
157 evidence: rewrote ? 'OBSERVED' : 'LINKED',
158 title: rewrote
159 ? `${who} rewrote all of ${baseName(row.path)} after ${whom} edited it`
160 : `${who} replaced text in ${baseName(row.path)} that ${whom} had written`,
161 detail: rewrote
162 ? [`#${earlier.seq} ${whom} edited; #${later.seq} ${who} wrote the whole file`]
163 : [`#${earlier.seq} ${whom} added: ${earlier.tool.edit.new.trim()}`, `#${later.seq} ${who} replaced: ${later.tool.edit.old.trim()}`],
164 agents: [later.agentId, earlier.agentId],
165 paths: [row.path],
166 eventIds: [earlier.id, later.id],
167 })
168 })
169 }
170 return signals
171}
172
173function staleWriteSignals(snapshot: Snapshot, rows: readonly FileRow[]): Signal[] {
174 const signals: Signal[] = []
175 for (const row of rows.filter(r => r.writers >= 2)) {
176 const touching = snapshot.events.filter(e => (isRead(e) || isWrite(e)) && e.tool?.paths.includes(row.path))
177 touching.forEach((write, i) => {
178 if (!isWrite(write)) return
179 const before = touching.slice(0, i)
180 const lastOwn = [...before].reverse().find(e => e.agentId === write.agentId)
181 if (!lastOwn || !isRead(lastOwn)) return
182 const between = before.filter(e => e.seq > lastOwn.seq && isWrite(e) && e.agentId !== write.agentId)
183 const other = between[between.length - 1]
184 if (!other) return
185 const who = snapshot.agents[write.agentId]?.label ?? write.agentId
186 const whom = snapshot.agents[other.agentId]?.label ?? other.agentId
187 signals.push({
188 id: `s:stale:${write.id}`,
189 kind: 'stale-write',
190 severity: 'investigate',
191 evidence: 'INFERRED',
192 title: `${who} wrote ${baseName(row.path)} after ${whom} changed it since ${who}'s last read`,
193 detail: [`#${lastOwn.seq} ${who} read · #${other.seq} ${whom} wrote · #${write.seq} ${who} wrote without reading again`],
194 agents: [write.agentId, other.agentId],
195 paths: [row.path],
196 eventIds: [lastOwn.id, other.id, write.id],
197 })
198 })
199 }
200 return signals
201}
202
203/** Worktrees of one repository share its common git dir; separate repositories do not. */
204function worktreeSignals(snapshot: Snapshot, rows: readonly FileRow[]): Signal[] {
205 const byRel = new Map<string, FileRow[]>()
206 for (const row of rows.filter(r => r.root !== null && r.writers > 0)) {
207 const common = snapshot.repos[row.root ?? '']
208 if (!common) continue
209 const key = `${common}\u0000${row.rel}`
210 byRel.set(key, [...(byRel.get(key) ?? []), row])
211 }
212 return [...byRel.entries()]
213 .filter(([, group]) => new Set(group.map(r => r.root)).size >= 2)
214 .map(([key, group]) => {
215 const rel = key.slice(key.indexOf('\u0000') + 1)
216 const agents = [...new Set(group.flatMap(r => r.touches.filter(t => t.writes > 0).map(t => t.agentId)))]
217 return {
218 id: `s:worktree:${rel}`,
219 kind: 'worktree-divergence' as const,
220 severity: 'notice' as const,
221 evidence: 'OBSERVED' as const,
222 title: `${rel} edited in ${group.length} separate worktrees`,
223 detail: [
224 `Separate worktrees of one repository (${snapshot.repos[group[0]?.root ?? ''] ?? ''}): not a physical conflict. The copies may diverge when the branches are merged.`,
225 ...group.map(r => `${r.root}: ${r.touches.filter(t => t.writes > 0).map(t => snapshot.agents[t.agentId]?.label ?? t.agentId).join(', ')}`),
226 ],
227 agents,
228 paths: group.map(r => r.path),
229 eventIds: group.flatMap(r => writesTo(snapshot, r.path).map(e => e.id)),
230 }
231 })
232}
233
234function duringTestSignals(snapshot: Snapshot): Signal[] {
235 const signals: Signal[] = []
236 const tests = snapshot.events.filter(e => e.tool?.verification && e.endSeq !== undefined)
237 for (const test of tests) {
238 const writes = snapshot.events.filter(
239 e => isWrite(e) && e.agentId !== test.agentId && e.seq > test.seq && e.seq < (test.endSeq ?? test.seq),
240 )
241 for (const write of writes) {
242 const path = write.tool?.paths[0] ?? ''
243 const named = textNamesPath(test.tool?.command ?? '', test.tool?.mentions ?? [], path)
244 const tester = snapshot.agents[test.agentId]?.label ?? test.agentId
245 const editor = snapshot.agents[write.agentId]?.label ?? write.agentId
246 signals.push({
247 id: `s:during:${test.id}:${write.id}`,
248 kind: 'test-during-change',
249 severity: 'investigate',
250 evidence: 'INFERRED',
251 title: `${editor} edited ${baseName(path)} while ${tester}'s \`${normalizeCommand(test.tool?.command ?? '')}\` ran`,
252 detail: [
253 `the run spans #${test.seq}–#${test.endSeq}; the edit is #${write.seq}`,
254 named ? 'the command names the edited file, so its result may not reflect this edit' : 'the command does not name the edited file',
255 ],
256 agents: [test.agentId, write.agentId],
257 paths: [path],
258 eventIds: [test.id, write.id],
259 })
260 }
261 }
262 return signals
263}
264
265function failureSignals(snapshot: Snapshot): Signal[] {
266 const signals: Signal[] = []
267 const byCommand = new Map<string, ForensicEvent[]>()
268 for (const event of snapshot.events.filter(e => e.tool?.command && e.endSeq !== undefined)) {
269 const base = snapshot.agents[event.agentId]?.cwd ?? snapshot.cwd
270 const cwd = resolveDir(leadingDirectory(event.tool?.command ?? ''), base) ?? ''
271 const key = `${cwd}\u0000${normalizeCommand(event.tool?.command ?? '')}`
272 byCommand.set(key, [...(byCommand.get(key) ?? []), event])
273 }
274 for (const [key, runs] of byCommand) {
275 const command = cap(key.slice(key.indexOf('\u0000') + 1), 80)
276 const failures = runs.filter(ranAndFailed)
277 if (failures.length >= 2) {
278 signals.push({
279 id: `s:repeat:${key}`,
280 kind: 'repeated-failure',
281 severity: 'investigate',
282 evidence: 'OBSERVED',
283 title: `\`${command}\` failed ${failures.length} times`,
284 detail: failures.map(e => `#${e.seq} ${snapshot.agents[e.agentId]?.label ?? e.agentId}: ${e.outcome?.text.split('\n')[0] ?? ''}`),
285 agents: [...new Set(failures.map(e => e.agentId))],
286 paths: [],
287 eventIds: failures.map(e => e.id),
288 })
289 }
290 const verdicts = runs.map(e => e.outcome?.verdict).filter(v => v === 'passed' || v === 'failed')
291 if (verdicts.includes('passed') && verdicts.includes('failed')) {
292 const last = verdicts[verdicts.length - 1]
293 signals.push({
294 id: `s:contradict:${key}`,
295 kind: 'contradictory-results',
296 severity: last === 'failed' ? 'investigate' : 'notice',
297 evidence: 'OBSERVED',
298 title: last === 'failed' ? `\`${command}\` passed, then failed later` : `\`${command}\` failed, then passed later`,
299 detail: runs.map(e => `#${e.seq} ${snapshot.agents[e.agentId]?.label ?? e.agentId}: ${e.outcome?.verdict ?? 'running'}`),
300 agents: [...new Set(runs.map(e => e.agentId))],
301 paths: [],
302 eventIds: runs.map(e => e.id),
303 })
304 }
305 }
306 const failedEdits = new Map<string, ForensicEvent[]>()
307 for (const event of snapshot.events.filter(e => isWriteAttempt(e) && ranAndFailed(e))) {
308 for (const path of event.tool?.paths ?? []) failedEdits.set(path, [...(failedEdits.get(path) ?? []), event])
309 }
310 for (const [path, events] of failedEdits) {
311 if (events.length < 2) continue
312 signals.push({
313 id: `s:repeat-edit:${path}`,
314 kind: 'repeated-failure',
315 severity: 'investigate',
316 evidence: 'OBSERVED',
317 title: `${events.length} failed edits of ${baseName(path)}`,
318 detail: events.map(e => `#${e.seq} ${snapshot.agents[e.agentId]?.label ?? e.agentId}: ${e.outcome?.text.split('\n')[0] ?? ''}`),
319 agents: [...new Set(events.map(e => e.agentId))],
320 paths: [path],
321 eventIds: events.map(e => e.id),
322 })
323 }
324 return signals
325}
326
327export function analyzeCollisions(snapshot: Snapshot): Collisions {
328 const all = fileRows(snapshot)
329 const files = all.slice(0, MAX_FILES)
330 const agents = [...new Set(files.flatMap(f => f.touches.map(t => t.agentId)))].sort(
331 (a, b) => snapshot.agentOrder.indexOf(a) - snapshot.agentOrder.indexOf(b),
332 )
333 const signals = [
334 ...sharedWriteSignals(snapshot, all),
335 ...overwriteSignals(snapshot, all),
336 ...staleWriteSignals(snapshot, all),
337 ...worktreeSignals(snapshot, all),
338 ...duringTestSignals(snapshot),
339 ...failureSignals(snapshot),
340 ]
341 const rank = (s: Signal) => (s.severity === 'investigate' ? 0 : 1)
342 return { files, totalFiles: all.length, agents, signals: signals.sort((a, b) => rank(a) - rank(b)) }
343}
344hooks/lib/collect.ts 119 lines1// Hook inputs to observations. Each function reads only fields the event's
2// declaration documents, and copies nothing it cannot name a source for.
3
4import type { ToolFacts } from '../../types'
5import { factsFor, outcomeFor, type RawResult } from './classify'
6import type { Observation, RosterEntry, SpawnObservation, TurnUsageLite } from './reduce'
7
8/** The fields of `agent.spawn`'s input this collector reads. */
9export type SpawnInput = {
10 tool_use_id: string
11 prompt: string
12 description: string
13 subagentType: string
14 parentAgentId?: string
15 background: boolean
16 cwd?: string
17 name?: string
18 isTeammate?: true
19 workflow?: { agentIndex: number }
20 resume?: { agentId: string }
21}
22
23/** The fields of `agent.spawn`'s result this collector reads. */
24export type SpawnResult = { agentId?: string; model?: string; deny?: string }
25
26export function spawnObservation(e: SpawnInput, result: SpawnResult, at: number): SpawnObservation {
27 return {
28 kind: 'spawn',
29 at,
30 toolUseId: e.tool_use_id,
31 agentId: result.deny === undefined ? result.agentId : undefined,
32 parentAgentId: e.parentAgentId,
33 subagentType: e.subagentType,
34 description: e.description,
35 prompt: e.prompt,
36 background: e.background,
37 cwd: e.cwd,
38 name: e.name,
39 model: result.deny === undefined ? result.model : undefined,
40 isTeammate: e.isTeammate === true ? true : undefined,
41 workflowIndex: e.workflow?.agentIndex,
42 resumeOf: e.resume?.agentId,
43 denied: result.deny,
44 }
45}
46
47type UsageInput = {
48 input_tokens: number
49 output_tokens: number
50 cache_read_input_tokens?: number | null
51 cache_creation_input_tokens?: number | null
52 model?: string
53}
54
55function usageOf(usage: UsageInput | undefined): TurnUsageLite | undefined {
56 if (!usage) return undefined
57 return {
58 input: usage.input_tokens,
59 output: usage.output_tokens,
60 cacheRead: usage.cache_read_input_tokens ?? 0,
61 cacheWrite: usage.cache_creation_input_tokens ?? 0,
62 model: usage.model,
63 }
64}
65
66export type TurnEndInput = {
67 turnId: string
68 agentId?: string
69 reason: string
70 answer: string
71 durationMs: number
72 usage?: UsageInput
73}
74
75export function turnEndObservation(e: TurnEndInput, at: number): Observation {
76 return {
77 kind: 'turn-end',
78 at,
79 turnId: e.turnId,
80 agentId: e.agentId,
81 reason: e.reason,
82 answer: e.answer,
83 durationMs: e.durationMs,
84 usage: usageOf(e.usage),
85 }
86}
87
88/** A tool call's input as plain fields: `tool`, `tool_use_id`, `agentId`, its arguments. */
89export type ToolInput = { tool: string; tool_use_id: string; agentId?: string }
90
91const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
92
93export function toolFacts(e: ToolInput): ToolFacts {
94 const args = Object.fromEntries(Object.entries(e as Record<string, unknown>).filter(([key]) => !RESERVED.has(key)))
95 return factsFor(String(e.tool), args)
96}
97
98export function toolStartObservation(e: ToolInput, facts: ToolFacts, at: number): Observation {
99 return { kind: 'tool-start', at, toolUseId: e.tool_use_id, agentId: e.agentId, facts }
100}
101
102export function toolEndObservation(e: ToolInput, facts: ToolFacts, result: RawResult, at: number): Observation {
103 return { kind: 'tool-end', at, toolUseId: e.tool_use_id, agentId: e.agentId, facts, outcome: outcomeFor(facts, result) }
104}
105
106/** `$.agent.list()` rows, reduced to the fields the roster observation keeps. */
107export type AgentListRow = { id: string; type: string; description: string; status: string; parentId?: string }
108
109export function rosterObservation(rows: readonly AgentListRow[], at: number): Observation {
110 const agents: RosterEntry[] = rows.map(row => ({
111 id: row.id,
112 type: row.type,
113 description: row.description,
114 status: row.status,
115 parentId: row.parentId,
116 }))
117 return { kind: 'roster', at, agents }
118}
119hooks/lib/format.ts 170 lines1// Small display helpers shared by the views and the export. Pure.
2
3import type { AgentRecord, AgentState, Evidence, Measure, Snapshot, SpendBucket, VerificationVerdict } from '../../types'
4import { ROLE } from './palette'
5
6/** Elapsed time since the collector's start, as +m:ss or +h:mm:ss. */
7export function offset(startedAt: number, at: number): string {
8 const total = Math.max(0, Math.round((at - startedAt) / 1000))
9 const h = Math.floor(total / 3600)
10 const m = Math.floor((total % 3600) / 60)
11 const sec = String(total % 60).padStart(2, '0')
12 return h > 0 ? `+${h}:${String(m).padStart(2, '0')}:${sec}` : `+${m}:${sec}`
13}
14
15export function ago(now: number, at: number): string {
16 const secs = Math.max(0, Math.round((now - at) / 1000))
17 if (secs < 60) return `${secs}s ago`
18 if (secs < 3600) return `${Math.floor(secs / 60)}m ago`
19 return `${Math.floor(secs / 3600)}h ago`
20}
21
22export function compact(n: number): string {
23 if (n < 1000) return String(n)
24 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
25 return `${(n / 1_000_000).toFixed(1)}M`
26}
27
28export function plural(n: number, word: string, many = `${word}s`): string {
29 return `${n} ${n === 1 ? word : many}`
30}
31
32/** A path shown relative to the session's directory when it lies beneath it. */
33export function displayPath(path: string, cwd: string | undefined): string {
34 if (cwd && path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
35 return path
36}
37
38export function truncate(text: string, width: number): string {
39 const flat = text.replace(/\s+/g, ' ').trim()
40 if (width <= 1) return flat.slice(0, Math.max(0, width))
41 return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`
42}
43
44export function agentName(agent: AgentRecord | undefined, fallbackId?: string): string {
45 if (!agent) return fallbackId ? `agent ${shortId(fallbackId)}` : 'unknown agent'
46 if (agent.isMain) return 'main'
47 return agent.type ? `${agent.label} · ${agent.type}` : `${agent.label} · type unknown`
48}
49
50export function shortId(id: string): string {
51 return id.length > 10 ? `${id.slice(0, 8)}…` : id
52}
53
54/** Agent state glyphs: `●` is left to OBSERVED, so completed is `◉`. */
55export const STATE_GLYPH: Record<AgentState, string> = {
56 active: '◐',
57 completed: '◉',
58 failed: '✕',
59 stopped: '■',
60 unknown: '◌',
61}
62
63export const EVIDENCE_GLYPH: Record<Evidence, string> = {
64 OBSERVED: '●',
65 LINKED: '◆',
66 INFERRED: '◇',
67 UNRESOLVED: '?',
68 UNAVAILABLE: '∅',
69}
70
71/** Evidence colours: LINKED is an analytical link, never the green of a passed check. */
72export const EVIDENCE_COLOR: Record<Evidence, string> = {
73 get OBSERVED() {
74 return ROLE.text
75 },
76 get LINKED() {
77 return ROLE.link
78 },
79 get INFERRED() {
80 return ROLE.caution
81 },
82 get UNRESOLVED() {
83 return ROLE.caution
84 },
85 get UNAVAILABLE() {
86 return ROLE.muted
87 },
88}
89
90export const MEASURE_GLYPH: Record<Measure, string> = {
91 OBSERVED: '●',
92 ESTIMATED: '≈',
93 UNAVAILABLE: '∅',
94}
95
96export const MEASURE_COLOR: Record<Measure, string> = {
97 get OBSERVED() {
98 return ROLE.text
99 },
100 get ESTIMATED() {
101 return ROLE.caution
102 },
103 get UNAVAILABLE() {
104 return ROLE.muted
105 },
106}
107
108export const VERDICT_GLYPH: Record<VerificationVerdict, string> = {
109 passed: '✓',
110 failed: '✕',
111 inconclusive: '~',
112 running: '…',
113}
114
115export const VERDICT_COLOR: Record<VerificationVerdict, string> = {
116 get passed() {
117 return ROLE.ok
118 },
119 get failed() {
120 return ROLE.fail
121 },
122 get inconclusive() {
123 return ROLE.caution
124 },
125 get running() {
126 return ROLE.context
127 },
128}
129
130/**
131 * A share as text, with one rounding rule everywhere: rounded to one decimal
132 * first, then whole numbers as they are, one decimal below 10%, whole numbers above, so 9.96 reads `10%`, never `10.0%`.
133 * `<0.1%` for a positive share too small to show, `n/a` for none.
134 */
135export function pct(value: number | undefined): string {
136 if (value === undefined || !Number.isFinite(value)) return 'n/a'
137 if (value > 0 && value < 0.1) return '<0.1%'
138 const tenth = Math.round(value * 10) / 10
139 if (Number.isInteger(tenth)) return `${tenth}%`
140 return tenth < 10 ? `${tenth.toFixed(1)}%` : `${Math.round(value)}%`
141}
142
143/** One wording for each outcome bucket, used by every view. */
144export const BUCKET_WORDS: Record<SpendBucket['bucket'], string> = {
145 coordinator: 'coordinator',
146 verified: 'verified',
147 'changed-unverified': 'changed, unverified',
148 'failed-or-inconclusive': 'failed or inconclusive',
149 'report-only': 'report only',
150 mixed: 'mixed',
151 unresolved: 'unresolved',
152 'no-usage-recorded': 'no usage recorded',
153}
154
155/** Thousands separated, independent of locale. */
156export function exact(n: number): string {
157 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
158}
159
160export function stateLabel(agent: AgentRecord): string {
161 if (agent.isMain && agent.state === 'completed') return 'idle'
162 return agent.state
163}
164
165export function subagents(snapshot: Snapshot): AgentRecord[] {
166 return snapshot.agentOrder
167 .map(id => snapshot.agents[id])
168 .filter((a): a is AgentRecord => a !== undefined && !a.isMain)
169}
170hooks/lib/ledger.ts 449 lines1// The token ledger: `turn.step` results normalised into request records,
2// folded into exact per-agent sums, and read back as a view with direct and
3// subtree totals. Pure and immutable; no `$`.
4
5import type {
6 AgentLedger,
7 AgentRecord,
8 CostEstimate,
9 Evidence,
10 LedgerRow,
11 LedgerState,
12 LedgerTotals,
13 LedgerView,
14 ModelTally,
15 Snapshot,
16 TokenCounts,
17 TurnTally,
18 UsageRecord,
19} from '../../types'
20import { agentName, compact, offset, plural, shortId } from './format'
21import { PRICE_TABLE, cacheWriteRule, estimateCost, formatUsd, type Loop, type PriceTable } from './pricing'
22import { LARGE_PROMPT_TOKENS, ZERO_TOKENS, addTokens, processedOf, promptTokensOf, sameTokens, sumCosts } from './tokens'
23
24/** The fields of `turn.step`'s input this ledger reads. */
25export type StepInputLite = { turnId: string; index: number; model: string; agentId?: string }
26
27/** The fields of `turn.step`'s result this ledger reads. */
28export type StepResultLite = {
29 stopReason: string | null
30 usage: {
31 input_tokens: number
32 output_tokens: number
33 cache_read_input_tokens: number
34 cache_creation_input_tokens: number
35 model: string
36 } | null
37}
38
39/** What `turn.complete` said the turn cost: the engine's sum, for reconciliation only. */
40export type TurnReport = { turnId: string; agentId: string; at: number; tokens?: TokenCounts; model?: string }
41
42export type LedgerViewOptions = { showCost: boolean; engineCostUsd?: number; prices?: PriceTable }
43
44/**
45 * Request keys remembered for dedupe. A duplicate arises when a
46 * hook re-sends a step (same `turnId:index`) right away, so 500 recent keys
47 * cover it comfortably. The window is kept small because the snapshot is
48 * written to `$.state` on every dirty tick (every 250 ms while busy): 500 keys
49 * of ~45 bytes is ~22 KB a write, where 4000 would add ~180 KB. A duplicate
50 * arriving after this many newer requests would be counted again; the view's
51 * notes say so once the window is full.
52 */
53export const SEEN_CAP = 500
54/** Requests kept per agent for the inspector and the context sparkline; the sums stay exact past it. */
55export const HISTORY_CAP = 40
56/** Turns kept for reconciliation, oldest dropped first; totals never depend on them. */
57export const TURNS_CAP = 400
58
59const MAIN = 'main'
60const NO_STOP_REASON = 'none'
61/** The byModel key for a request whose response and request both named no model. */
62export const UNKNOWN_MODEL = 'unknown model'
63const MAIN_BASIS = 'turn.step carried no agentId, which the API defines as the main loop'
64const NO_USAGE_BASIS = 'no turn.step heard for this agent'
65const ENGINE_COST_SOURCE = "session.measure cost.usd: Claude Code's own total over every priced response"
66
67// ── records ──
68
69/** A table's own entry only, so an id such as `constructor` never reads Object.prototype. */
70function own<T>(table: Readonly<Record<string, T>>, key: string): T | undefined {
71 return Object.hasOwn(table, key) ? table[key] : undefined
72}
73
74/** One reported count. The API reads a count a response left out as zero; anything not a finite, non-negative number reads the same, so it can never poison a sum. */
75function countOf(value: unknown): number {
76 return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
77}
78
79export function usageRecordFrom(input: StepInputLite, result: StepResultLite, at: number): UsageRecord {
80 const usage = result.usage
81 return {
82 key: `${input.turnId}:${input.index}`,
83 source: 'turn.step',
84 at,
85 agentId: input.agentId ?? MAIN,
86 attribution: input.agentId === undefined ? 'main-loop' : 'agentId',
87 turnId: input.turnId,
88 index: input.index,
89 requestedModel: input.model,
90 model: usage?.model,
91 tokens: usage
92 ? {
93 input: countOf(usage.input_tokens),
94 cacheRead: countOf(usage.cache_read_input_tokens),
95 cacheWrite: countOf(usage.cache_creation_input_tokens),
96 output: countOf(usage.output_tokens),
97 }
98 : undefined,
99 stopReason: result.stopReason,
100 }
101}
102
103/** Where a request is tallied: the model that answered, else the one requested, else UNKNOWN_MODEL (left unpriced). */
104function modelKeyOf(record: UsageRecord): string {
105 return record.model || record.requestedModel || UNKNOWN_MODEL
106}
107
108export function emptyLedger(): LedgerState {
109 return {
110 agents: {},
111 seen: [],
112 duplicates: 0,
113 duplicateTokens: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
114 turns: {},
115 turnOrder: [],
116 turnsDropped: 0,
117 }
118}
119
120/** One total order over records: time, then position in the turn, then key. */
121function compareRecords(a: UsageRecord, b: UsageRecord): number {
122 return a.at - b.at || a.index - b.index || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
123}
124
125// ── folding a request in ──
126
127function newAgentLedger(record: UsageRecord): AgentLedger {
128 return {
129 agentId: record.agentId,
130 attribution: record.attribution,
131 requests: 0,
132 requestsWithoutUsage: 0,
133 tokens: ZERO_TOKENS,
134 byModel: {},
135 stopReasons: {},
136 firstAt: record.at,
137 lastAt: record.at,
138 history: [],
139 historyDropped: 0,
140 }
141}
142
143function foldTally(tally: ModelTally | undefined, tokens: TokenCounts): ModelTally {
144 const base = tally ?? { requests: 0, tokens: ZERO_TOKENS, largeRequests: 0, largeTokens: ZERO_TOKENS }
145 const isLarge = promptTokensOf(tokens) > LARGE_PROMPT_TOKENS
146 return {
147 requests: base.requests + 1,
148 tokens: addTokens(base.tokens, tokens),
149 largeRequests: base.largeRequests + (isLarge ? 1 : 0),
150 largeTokens: isLarge ? addTokens(base.largeTokens, tokens) : base.largeTokens,
151 }
152}
153
154function countUsage(agent: AgentLedger, record: UsageRecord): AgentLedger {
155 if (!record.tokens) return { ...agent, requestsWithoutUsage: agent.requestsWithoutUsage + 1 }
156 const model = modelKeyOf(record)
157 return {
158 ...agent,
159 requests: agent.requests + 1,
160 tokens: addTokens(agent.tokens, record.tokens),
161 byModel: { ...agent.byModel, [model]: foldTally(own(agent.byModel, model), record.tokens) },
162 }
163}
164
165type Peak = NonNullable<AgentLedger['peak']>
166
167/** The larger single-request prompt; on a tie the earlier `at` (then the smaller key), so arrival order never matters. */
168function peakOf(current: Peak | undefined, record: UsageRecord): Peak | undefined {
169 if (!record.tokens) return current
170 const candidate: Peak = { promptTokens: promptTokensOf(record.tokens), at: record.at, key: record.key }
171 if (!current || candidate.promptTokens > current.promptTokens) return candidate
172 if (candidate.promptTokens < current.promptTokens) return current
173 return candidate.at < current.at || (candidate.at === current.at && candidate.key < current.key) ? candidate : current
174}
175
176/** Keeps the newest HISTORY_CAP records in order; the same set whatever order they arrived in. */
177function withHistory(agent: AgentLedger, record: UsageRecord): Pick<AgentLedger, 'history' | 'historyDropped'> {
178 const merged = [...agent.history, record].sort(compareRecords)
179 const excess = Math.max(0, merged.length - HISTORY_CAP)
180 return { history: merged.slice(excess), historyDropped: agent.historyDropped + excess }
181}
182
183function foldRecord(agent: AgentLedger, record: UsageRecord): AgentLedger {
184 const counted = countUsage(agent, record)
185 const reason = record.stopReason ?? NO_STOP_REASON
186 const peak = peakOf(agent.peak, record)
187 return {
188 ...counted,
189 stopReasons: { ...agent.stopReasons, [reason]: (own(agent.stopReasons, reason) ?? 0) + 1 },
190 firstAt: Math.min(agent.firstAt, record.at),
191 lastAt: Math.max(agent.lastAt, record.at),
192 latest: agent.latest && compareRecords(agent.latest, record) > 0 ? agent.latest : record,
193 ...withHistory(agent, record),
194 ...(peak ? { peak } : {}),
195 }
196}
197
198/**
199 * Updates one turn's tally, creating it when new. `turnOrder` runs from least
200 * to most recently touched, so a long turn still receiving steps (a subagent's
201 * whole run is one turn) is not dropped ahead of idle ones past TURNS_CAP.
202 */
203function withTurn(ledger: LedgerState, turnId: string, agentId: string, update: (tally: TurnTally) => TurnTally): LedgerState {
204 const tally = update(own(ledger.turns, turnId) ?? { turnId, agentId, steps: 0, stepTokens: ZERO_TOKENS })
205 const order = [...ledger.turnOrder.filter(id => id !== turnId), turnId]
206 const excess = Math.max(0, order.length - TURNS_CAP)
207 const evicted = new Set(order.slice(0, excess))
208 const kept = excess > 0 ? Object.fromEntries(Object.entries(ledger.turns).filter(([id]) => !evicted.has(id))) : ledger.turns
209 return { ...ledger, turns: { ...kept, [turnId]: tally }, turnOrder: order.slice(excess), turnsDropped: ledger.turnsDropped + excess }
210}
211
212/** Folds one request in. A key already counted only bumps `duplicates` and sets its tokens aside. */
213export function applyUsage(ledger: LedgerState, record: UsageRecord): LedgerState {
214 if (ledger.seen.includes(record.key)) {
215 const setAside = ledger.duplicateTokens ?? ZERO_TOKENS
216 return {
217 ...ledger,
218 duplicates: ledger.duplicates + 1,
219 duplicateTokens: record.tokens ? addTokens(setAside, record.tokens) : setAside,
220 }
221 }
222 const agent = foldRecord(own(ledger.agents, record.agentId) ?? newAgentLedger(record), record)
223 const counted: LedgerState = {
224 ...ledger,
225 seen: [...ledger.seen, record.key].slice(-SEEN_CAP),
226 agents: { ...ledger.agents, [record.agentId]: agent },
227 }
228 return withTurn(counted, record.turnId, record.agentId, tally => ({
229 ...tally,
230 steps: tally.steps + 1,
231 stepTokens: record.tokens ? addTokens(tally.stepTokens, record.tokens) : tally.stepTokens,
232 }))
233}
234
235/** Records the engine's turn total for reconciliation; never added to any total. */
236export function applyTurnReport(ledger: LedgerState, report: TurnReport): LedgerState {
237 return withTurn(ledger, report.turnId, report.agentId, tally => ({
238 ...tally,
239 reportedAt: report.at,
240 ...(report.tokens ? { reported: report.tokens } : {}),
241 ...(report.model ? { reportedModel: report.model } : {}),
242 }))
243}
244
245// ── the view ──
246
247type Placed = { id: string; depth: number; members: string[] }
248
249/** The snapshot's record of an agent, own entries only. */
250function agentRecord(snapshot: Snapshot, id: string): AgentRecord | undefined {
251 return own(snapshot.agents, id)
252}
253
254/** The agent's parent when it names one the snapshot knows (and not itself). */
255function knownParent(snapshot: Snapshot, id: string): string | undefined {
256 const parent = agentRecord(snapshot, id)?.parentId
257 return parent !== undefined && parent !== id && agentRecord(snapshot, parent) ? parent : undefined
258}
259
260/** Child ids per parent, in roster order; a parent that is unknown or the agent itself is no parent. */
261function childrenOf(snapshot: Snapshot): Map<string, string[]> {
262 const tree = new Map<string, string[]>()
263 for (const id of snapshot.agentOrder) {
264 const parent = knownParent(snapshot, id)
265 if (parent !== undefined) tree.set(parent, [...(tree.get(parent) ?? []), id])
266 }
267 return tree
268}
269
270/** Depth-first from one root; each agent placed once, so a parent cycle cannot loop. */
271function place(id: string, depth: number, tree: Map<string, string[]>, visited: Set<string>): Placed[] {
272 visited.add(id)
273 const below: Placed[][] = []
274 for (const child of tree.get(id) ?? []) if (!visited.has(child)) below.push(place(child, depth + 1, tree, visited))
275 const members = [id, ...below.flatMap(branch => branch[0]?.members ?? [])]
276 return [{ id, depth, members }, ...below.flat()]
277}
278
279/** Main's tree, then known agents main's tree does not reach (no parent first, then cycles), then ledger ids no agent record names. */
280function placeAll(ledger: LedgerState, snapshot: Snapshot): Placed[] {
281 const tree = childrenOf(snapshot)
282 const visited = new Set<string>()
283 const known = snapshot.agentOrder.filter(id => agentRecord(snapshot, id) !== undefined)
284 const fromMain = agentRecord(snapshot, MAIN) ? place(MAIN, 0, tree, visited) : []
285 const orphans = known.filter(id => knownParent(snapshot, id) === undefined).flatMap(id => (visited.has(id) ? [] : place(id, 0, tree, visited)))
286 const cycles = known.flatMap(id => (visited.has(id) ? [] : place(id, 0, tree, visited)))
287 const unknown = Object.values(ledger.agents)
288 .filter(agent => !agentRecord(snapshot, agent.agentId))
289 .sort((a, b) => a.firstAt - b.firstAt || (a.agentId < b.agentId ? -1 : 1))
290 .map(agent => ({ id: agent.agentId, depth: 0, members: [agent.agentId] }))
291 return [...fromMain, ...orphans, ...cycles, ...unknown]
292}
293
294/** The loop an agent's requests ran in, for its cache-write TTL: the main loop's, or a subagent's. */
295function loopOf(agent: AgentLedger | undefined): Loop {
296 return agent?.attribution === 'main-loop' ? 'main-loop' : 'subagent'
297}
298
299function totalsOf(agent: AgentLedger | undefined, prices: PriceTable | undefined): LedgerTotals {
300 const tokens = agent?.tokens ?? ZERO_TOKENS
301 const totals = { requests: agent?.requests ?? 0, requestsWithoutUsage: agent?.requestsWithoutUsage ?? 0, tokens, processed: processedOf(tokens) }
302 return prices ? { ...totals, cost: estimateCost(prices, agent?.byModel ?? {}, loopOf(agent)) } : totals
303}
304
305function sumTotals(list: readonly LedgerTotals[], prices: PriceTable | undefined): LedgerTotals {
306 const tokens = list.reduce((sum, t) => addTokens(sum, t.tokens), ZERO_TOKENS)
307 const totals = {
308 requests: list.reduce((sum, t) => sum + t.requests, 0),
309 requestsWithoutUsage: list.reduce((sum, t) => sum + t.requestsWithoutUsage, 0),
310 tokens,
311 processed: processedOf(tokens),
312 }
313 if (!prices) return totals
314 const cost: CostEstimate = sumCosts(list.map(t => t.cost)) ?? estimateCost(prices, {})
315 return { ...totals, cost }
316}
317
318function byModelOf(agent: AgentLedger | undefined, prices: PriceTable | undefined): LedgerRow['byModel'] {
319 return Object.entries(agent?.byModel ?? {})
320 .map(([model, tally]) => ({ model, tally, ...(prices ? { cost: estimateCost(prices, { [model]: tally }, loopOf(agent)) } : {}) }))
321 .sort((a, b) => processedOf(b.tally.tokens) - processedOf(a.tally.tokens) || (a.model < b.model ? -1 : 1))
322}
323
324function attributionOf(id: string, record: AgentRecord | undefined, agent: AgentLedger | undefined): { evidence: Evidence; basis: string } {
325 if (!record) {
326 return {
327 evidence: 'UNRESOLVED',
328 basis: `agent id ${id} was never seen as an agent (an engine fork, a workflow agent, or a spawn the collector missed); its requests stay on their own row, not folded into main`,
329 }
330 }
331 if (!agent || agent.requests + agent.requestsWithoutUsage === 0) return { evidence: 'UNAVAILABLE', basis: NO_USAGE_BASIS }
332 if (record.isMain) return { evidence: 'OBSERVED', basis: MAIN_BASIS }
333 return { evidence: 'OBSERVED', basis: `turn.step carried agentId ${id}, the id this agent is known by (from ${record.knownFrom})` }
334}
335
336/**
337 * What a turn reported with no step on record means. With no turn ever
338 * dropped, no step was heard; once turns have aged out of the window, its
339 * steps may have been heard and dropped, and the ledger cannot tell which.
340 */
341function reportOnlyText(count: number, turnsDropped: number): string {
342 const what = `${plural(count, 'turn')} reported by turn.complete with no turn.step on record`
343 return turnsDropped > 0 ? `${what} (none heard, or its steps aged out of the reconciliation window)` : `${what} (none heard)`
344}
345
346function reconcile(turns: readonly TurnTally[], agentId: string, turnsDropped: number): LedgerRow['reconciliation'] {
347 const mine = turns.filter(t => t.agentId === agentId)
348 const compared = mine.filter(t => t.reported !== undefined && t.steps > 0)
349 const mismatched = compared.filter(t => !sameTokens(t.stepTokens, t.reported ?? ZERO_TOKENS)).length
350 const reportedOnly = mine.filter(t => t.reported !== undefined && t.steps === 0).length
351 const parts = [
352 compared.length === 0
353 ? ''
354 : mismatched === 0
355 ? `all ${plural(compared.length, 'turn')} match turn.complete's own total`
356 : `${mismatched} of ${plural(compared.length, 'turn')} differ from turn.complete's own total (a step heard before the mod loaded, a response another hook supplied, or a re-sent request can cause this)`,
357 reportedOnly === 0 ? '' : reportOnlyText(reportedOnly, turnsDropped),
358 ].filter(Boolean)
359 return { turnsCompared: compared.length, mismatched, reportedOnly, ...(parts.length ? { note: `${parts.join('; ')}.` } : {}) }
360}
361
362function rowFor(placed: Placed, ledger: LedgerState, snapshot: Snapshot, direct: Map<string, LedgerTotals>, prices: PriceTable | undefined, turns: TurnTally[]): LedgerRow {
363 const record = agentRecord(snapshot, placed.id)
364 const agent = own(ledger.agents, placed.id)
365 const directTotals = direct.get(placed.id) ?? totalsOf(undefined, prices)
366 const subtree = sumTotals(placed.members.map(id => direct.get(id) ?? totalsOf(undefined, prices)), prices)
367 return {
368 agentId: placed.id,
369 label: record ? agentName(record) : `? ${shortId(placed.id)}`,
370 ...(record?.parentId !== undefined ? { parentId: record.parentId } : {}),
371 depth: placed.depth,
372 models: Object.keys(agent?.byModel ?? {}).sort(),
373 byModel: byModelOf(agent, prices),
374 direct: directTotals,
375 subtree: { ...subtree, agents: placed.members.length },
376 attribution: attributionOf(placed.id, record, agent),
377 reconciliation: reconcile(turns, placed.id, ledger.turnsDropped),
378 }
379}
380
381function coverageNote(ledger: LedgerState, snapshot: Snapshot): string {
382 const since = ledger.since
383 if (!since) return 'When token counting began is unknown, so requests made before the collector loaded may be missing.'
384 const start = offset(snapshot.startedAt, since.at)
385 return since.missedEarlier
386 ? `Token counts start at ${start} (${since.basis}); requests before that were not recorded.`
387 : `Token counts cover the session from ${start} (${since.basis}).`
388}
389
390function costNotes(view: Pick<LedgerView, 'session' | 'engineCost'>): string[] {
391 const cost = view.session.cost
392 if (!cost) return []
393 return [
394 'The ≈$ figures are API-equivalent estimates at list prices: not a charge, and not what a subscription costs.',
395 ...(cost.complete
396 ? []
397 : [`No price for ${cost.unpricedModels.join(', ')}: those tokens are left out of the ≈$ estimate (figures marked + leave them out).`]),
398 ...(view.engineCost
399 ? [
400 `Claude Code's own session figure (${formatUsd(view.engineCost.usd)}, OBSERVED) covers requests no hook sees and uses its own prices: a different figure from the ≈ estimate.`,
401 ]
402 : []),
403 ]
404}
405
406function notesFor(ledger: LedgerState, snapshot: Snapshot, rows: readonly LedgerRow[], view: Pick<LedgerView, 'session' | 'engineCost'>): string[] {
407 const unknown = rows.filter(r => r.attribution.evidence === 'UNRESOLVED').length
408 const mismatched = rows.reduce((sum, r) => sum + r.reconciliation.mismatched, 0)
409 const reportedOnly = rows.reduce((sum, r) => sum + r.reconciliation.reportedOnly, 0)
410 const withoutUsage = view.session.requestsWithoutUsage
411 const dup = ledger.duplicates
412 return [
413 coverageNote(ledger, snapshot),
414 "Counts come from this mod's turn.step hook, which may miss the engine's own side requests (compaction summary, titles, memory) and the steps of an agent spawned inside a hook this one wraps.",
415 dup > 0
416 ? `${plural(dup, 'request')} arrived with a key already counted and ${dup === 1 ? 'was' : 'were'} set aside (${compact(processedOf(ledger.duplicateTokens ?? ZERO_TOKENS))} tokens). If a hook re-sent a request, those were real and the totals are low by that much; reconciliation would show it.`
417 : '',
418 withoutUsage > 0 ? `${plural(withoutUsage, 'request')} resolved with no usage (no response, interrupted, or none reported): counted apart, adding no tokens.` : '',
419 unknown > 0 ? `${plural(unknown, 'agent id')} never seen as an agent ${unknown === 1 ? 'keeps its' : 'keep their'} own row, flagged UNRESOLVED, not folded into main.` : '',
420 mismatched > 0 ? `${plural(mismatched, 'turn')} differ from turn.complete's own total; the ledger counts turn.step results only and never adds turn.complete's figure.` : '',
421 reportedOnly > 0 ? `${reportOnlyText(reportedOnly, ledger.turnsDropped)}.` : '',
422 ledger.turnsDropped > 0
423 ? `Reconciliation keeps the ${TURNS_CAP} most recently active turns; ${plural(ledger.turnsDropped, 'other')} ${ledger.turnsDropped === 1 ? 'was' : 'were'} dropped from it (totals stay exact).`
424 : '',
425 ledger.seen.length >= SEEN_CAP ? `Duplicate detection covers the most recent ${SEEN_CAP} requests.` : '',
426 ...costNotes(view),
427 ].filter(Boolean)
428}
429
430/** Rows in spawn-tree order with direct and subtree totals; the session total is the sum of direct totals. */
431export function buildLedgerView(ledger: LedgerState, snapshot: Snapshot, options: LedgerViewOptions): LedgerView {
432 const table = options.prices ?? PRICE_TABLE
433 const prices = options.showCost ? table : undefined
434 const placed = placeAll(ledger, snapshot)
435 const direct = new Map(placed.map(p => [p.id, totalsOf(own(ledger.agents, p.id), prices)] as const))
436 const turns = Object.values(ledger.turns)
437 const rows = placed.map(p => rowFor(p, ledger, snapshot, direct, prices, turns))
438 const session = sumTotals(rows.map(r => r.direct), prices)
439 const engineUsd = options.engineCostUsd ?? snapshot.engineCost?.usd
440 const engineCost = options.showCost && engineUsd !== undefined ? { usd: engineUsd, measure: 'OBSERVED' as const, source: ENGINE_COST_SOURCE } : undefined
441 return {
442 rows,
443 session,
444 ...(engineCost ? { engineCost } : {}),
445 priceTable: { date: table.date, source: table.source, assumptions: table.assumptions, cacheWrites: cacheWriteRule(table) },
446 notes: notesFor(ledger, snapshot, rows, { session, engineCost }),
447 }
448}
449hooks/lib/outcome.ts 576 lines1// Spend vs outcome: each agent's recorded usage beside what the
2// contribution trail can establish about its work. Deterministic rules over
3// trail nodes, edges and flags; no model calls, no causation from order
4// alone. An agent's usage is never split across its contributions.
5
6import type {
7 AgentOutcome,
8 AgentRecord,
9 ContributionOutcome,
10 CostEstimate,
11 Evidence,
12 ForensicEvent,
13 LedgerRow,
14 LedgerTotals,
15 LedgerView,
16 OutcomeCategory,
17 Snapshot,
18 SpendBucket,
19 SpendOutcome,
20} from '../../types'
21import { cap } from './classify'
22import { sumCosts, ZERO_TOKENS } from './tokens'
23import { checkKey, UNVERIFIED_TEXT, UNLINKED_TEXT, type Trail, type TrailEdge, type TrailFlag, type TrailNode } from './trail'
24
25export type BucketName = SpendBucket['bucket']
26
27export const BUCKET_ORDER: readonly BucketName[] = [
28 'coordinator',
29 'verified',
30 'changed-unverified',
31 'failed-or-inconclusive',
32 'report-only',
33 'mixed',
34 'unresolved',
35 'no-usage-recorded',
36]
37
38export const BUCKET_MEANING: Record<BucketName, string> = {
39 coordinator: 'The main loop: delegating, reading reports and its own edits. Its own contributions are listed but do not set this bucket.',
40 verified:
41 'Every contribution is a change tied by a trail edge to a check that passed after its last edit (with no tied check failing or inconclusive after it), or such a passing check. The tie is INFERRED at best: it rests on a file name, the same agent or the same working tree, not on proof that the check exercised the change.',
42 'changed-unverified':
43 'Changed files with no tied check after the last edit: none was captured, or every tied check ran before the file was edited again. Unverified means not shown either way, not broken.',
44 'failed-or-inconclusive':
45 'A check tied to the change after its last edit failed or was inconclusive, or the agent\'s own check ended that way. A check can fail for reasons unrelated to the change; inconclusive means its result could not be read as a pass or a failure.',
46 'report-only':
47 'Handed back a report and made no captured change or check. A linked report is tied by an edge to a later delegation or change; an unlinked one has no such tie captured. A missing link is not evidence about the report\'s value: research, review and ruling options out leave no edit, and the events cannot show how a report was used.',
48 mixed: 'The agent\'s work (its changes and checks) falls in more than one category; a report it also handed back does not count. The agent is counted once, here, and its usage is not split between them.',
49 unresolved:
50 'The captured events do not establish an outcome: still running, ended without a captured report, a passing check tied to no change after its last edit, or usage from a loop never seen as an agent.',
51 'no-usage-recorded':
52 'No request that reported token usage was recorded for these agents (for example, they ran before the mod loaded, were started by another hook, or every request came back without usage). Their usage is unknown, not zero; they add nothing to the total.',
53}
54
55export const DENOMINATOR_BASIS =
56 'Tokens processed in the session: input + cache read + cache write + output, summed over every recorded turn.step request, each counted once (the ledger session total). Cache reads usually dominate, so a share here is a share of tokens processed, not of cost. Requests the hook cannot see are not included.'
57
58const COMMAND_SHOWN = 80
59const SERIES_SHOWN = 5
60
61const EMPTY_TOTALS: LedgerTotals = { requests: 0, requestsWithoutUsage: 0, tokens: ZERO_TOKENS, processed: 0 }
62
63type Index = {
64 snapshot: Snapshot
65 trail: Trail
66 nodesByAgent: ReadonlyMap<string, TrailNode[]>
67 nodeById: ReadonlyMap<string, TrailNode>
68 edgesFrom: ReadonlyMap<string, TrailEdge[]>
69 edgesTo: ReadonlyMap<string, TrailEdge[]>
70 eventById: ReadonlyMap<string, ForensicEvent>
71}
72
73function groupBy<T>(items: readonly T[], keyOf: (item: T) => string): Map<string, T[]> {
74 const groups = new Map<string, T[]>()
75 for (const item of items) groups.set(keyOf(item), [...(groups.get(keyOf(item)) ?? []), item])
76 return groups
77}
78
79function indexOf(snapshot: Snapshot, trail: Trail): Index {
80 const nodes = trail.lanes.flatMap(l => l.nodes)
81 return {
82 snapshot,
83 trail,
84 nodesByAgent: groupBy(nodes, n => n.agentId),
85 nodeById: new Map(nodes.map(n => [n.id, n])),
86 edgesFrom: groupBy(trail.edges, e => e.from),
87 edgesTo: groupBy(trail.edges, e => e.to),
88 eventById: new Map(snapshot.events.map(e => [e.id, e])),
89 }
90}
91
92const shown = (command: string | undefined): string => `\`${cap(command ?? '', COMMAND_SHOWN)}\``
93const isDefined = <T>(value: T | undefined): value is T => value !== undefined
94
95/** The agent record for an id, read as an own property: an id such as `constructor` is an agent, not Object's member. */
96export function agentRecord(snapshot: Snapshot, id: string): AgentRecord | undefined {
97 return Object.hasOwn(snapshot.agents, id) ? snapshot.agents[id] : undefined
98}
99const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
100const unique = <T>(items: readonly T[]): T[] => [...new Set(items)]
101
102/** Strongest first: a chain of claims is only as strong as its weakest link. */
103const EVIDENCE_RANK: readonly Evidence[] = ['OBSERVED', 'LINKED', 'INFERRED', 'UNRESOLVED', 'UNAVAILABLE']
104const weakest = (...labels: Evidence[]): Evidence =>
105 labels.reduce((worst, label) => (EVIDENCE_RANK.indexOf(label) > EVIDENCE_RANK.indexOf(worst) ? label : worst), 'OBSERVED')
106const strongest = (labels: readonly Evidence[]): Evidence =>
107 labels.reduce((best, label) => (EVIDENCE_RANK.indexOf(label) < EVIDENCE_RANK.indexOf(best) ? label : best), 'UNAVAILABLE')
108
109const notPassing = (verdict: string | undefined): boolean => verdict === 'failed' || verdict === 'inconclusive'
110
111function summaryOf(trail: Trail, check: TrailNode) {
112 const key = checkKey(check.command ?? '', check.cwd)
113 return trail.outcome.verifications.find(v => checkKey(v.command, v.cwd) === key)
114}
115
116function commandFlags(trail: Trail, command: string | undefined, cwd: string | undefined): TrailFlag[] {
117 return trail.flags.filter(f => (f.kind === 'still-failing' || f.kind === 'inconclusive') && f.command === command && f.cwd === cwd)
118}
119
120/** Every run of the same command in the same directory, oldest first. */
121function runsOf(ix: Index, check: TrailNode): TrailNode[] {
122 const key = checkKey(check.command ?? '', check.cwd)
123 return [...ix.nodeById.values()]
124 .filter(n => n.stage === 'verification' && checkKey(n.command ?? '', n.cwd) === key)
125 .sort((a, b) => a.seq - b.seq)
126}
127
128function nodeLabel(ix: Index, id: string): string {
129 const node = ix.nodeById.get(id)
130 if (!node) return id
131 const owner = agentRecord(ix.snapshot, node.agentId)?.label ?? node.agentId
132 if (node.stage === 'change') return `${owner}'s change to ${node.path ?? 'a file'}`
133 if (node.stage === 'verification') return `${owner}'s run of ${shown(node.command)}`
134 return `${owner}'s ${node.stage}`
135}
136
137type Linked = { edge: TrailEdge; check: TrailNode }
138
139function linkedChecks(ix: Index, change: TrailNode): Linked[] {
140 return (ix.edgesFrom.get(change.id) ?? [])
141 .map(edge => ({ edge, check: ix.nodeById.get(edge.to) }))
142 .filter((l): l is Linked => l.check?.stage === 'verification')
143 .sort((a, b) => a.check.seq - b.check.seq)
144}
145
146/** The change's writes as the timeline holds them: the last successful one, and any still without a result. */
147function writesOf(ix: Index, change: TrailNode): { lastWriteSeq: number; lastShown: number; pending: ForensicEvent[] } {
148 const writes = change.eventIds.map(id => ix.eventById.get(id)).filter(isDefined)
149 const done = writes.filter(e => e.outcome?.status === 'ok')
150 const lastWriteSeq = change.lastWriteSeq ?? Math.max(change.seq, ...done.map(e => e.endSeq ?? e.seq))
151 const last = done.find(e => (e.endSeq ?? e.seq) === lastWriteSeq)
152 const pending = writes.filter(e => e.outcome === undefined || e.outcome.status === 'running')
153 return { lastWriteSeq, lastShown: last?.seq ?? lastWriteSeq, pending }
154}
155
156type ChangeBase = Omit<ContributionOutcome, 'category' | 'evidence' | 'basis' | 'flagIds'>
157
158/** The evidence of "this check bears on this change": the change, the trail edge and the run, weakest wins. */
159const chainOf = (change: TrailNode, l: Linked): Evidence => weakest(change.evidence, l.edge.evidence, l.check.evidence)
160const runText = (l: Linked): string => `${shown(l.check.command)} ${l.check.verdict} at #${l.check.seq}`
161const flagIdsOf = (ix: Index, linked: readonly Linked[]): string[] =>
162 linked.flatMap(l => commandFlags(ix.trail, l.check.command, l.check.cwd)).map(f => f.id)
163
164/** A linked run after the last edit that does not stand as a pass: it failed, or its command's latest run did not pass. */
165function standsAsPass(ix: Index, l: Linked): boolean {
166 return l.check.verdict === 'passed' && !notPassing(summaryOf(ix.trail, l.check)?.latest)
167}
168
169/**
170 * Verified needs a linked check that passed after the last edit and whose
171 * command's latest run did not fail or end inconclusive. A linked check after
172 * the last edit that failed (or whose command later did) makes the change
173 * failed-or-inconclusive; checks that all ran before the last edit are stale.
174 */
175function classifyChange(ix: Index, change: TrailNode): ContributionOutcome {
176 const linked = linkedChecks(ix, change)
177 const ownFlags = ix.trail.flags.filter(f => f.nodeId === change.id).map(f => f.id)
178 const base: ChangeBase = {
179 id: `c:${change.id}`,
180 agentId: change.agentId,
181 title: `Changed ${change.path ?? 'a file'}`,
182 nodeIds: [change.id],
183 eventIds: change.eventIds,
184 edgeIds: linked.map(l => l.edge.id),
185 }
186 const unverified = (basis: string, extraFlags: readonly string[] = []): ContributionOutcome => ({
187 ...base,
188 category: 'changed-unverified',
189 evidence: 'UNRESOLVED',
190 basis,
191 flagIds: unique([...ownFlags, ...extraFlags]),
192 })
193 const { lastWriteSeq, lastShown, pending } = writesOf(ix, change)
194 const firstPending = pending[0]
195 if (firstPending) return unverified(`An edit to this file (#${firstPending.seq}) has no captured result yet, so no check is known to have run after the last edit.`)
196 const after = linked.filter(l => l.check.seq > lastWriteSeq)
197 if (after.length === 0) {
198 const unclear = unclearRunsAfter(ix, change, lastWriteSeq)
199 if (unclear.length > 0) return unclearChange(ix, change, base, ownFlags, unclear, lastShown)
200 }
201 if (linked.length === 0) return unverified(UNVERIFIED_TEXT)
202 if (after.length === 0) return unverified(staleBasis(linked, lastShown), flagIdsOf(ix, linked))
203 const failing = after.filter(l => !standsAsPass(ix, l))
204 if (failing.length > 0) return failedChange(ix, change, base, ownFlags, after, failing, lastShown)
205 const chosen = after[after.length - 1] as Linked
206 return verifiedChange(ix, change, base, ownFlags, chosen, lastWriteSeq, lastShown)
207}
208
209/** The same agent's runs of one command (in one directory), whose latest captured run was inconclusive. */
210type UnclearCheck = { runs: TrailNode[]; latest: TrailNode }
211
212/**
213 * Checks the same agent ran after the change's last write whose command's latest
214 * run is inconclusive. trail.ts links only decided runs, so this reuses only its
215 * same-agent rule; its file-name and whole-suite rules are not applied here.
216 */
217function unclearRunsAfter(ix: Index, change: TrailNode, lastWriteSeq: number): UnclearCheck[] {
218 const own = (ix.nodesByAgent.get(change.agentId) ?? []).filter(n => n.stage === 'verification')
219 const keys = unique(own.filter(n => n.seq > lastWriteSeq).map(n => checkKey(n.command ?? '', n.cwd)))
220 return keys.flatMap(key => {
221 const runs = own.filter(n => checkKey(n.command ?? '', n.cwd) === key)
222 const all = runsOf(ix, runs[0] as TrailNode)
223 const latest = all[all.length - 1]
224 return latest?.verdict === 'inconclusive' ? [{ runs, latest }] : []
225 })
226}
227
228function unclearChange(
229 ix: Index,
230 change: TrailNode,
231 base: ChangeBase,
232 ownFlags: readonly string[],
233 unclear: readonly UnclearCheck[],
234 lastShown: number,
235): ContributionOutcome {
236 const runs = unclear.flatMap(u => u.runs)
237 return {
238 ...base,
239 category: 'failed-or-inconclusive',
240 evidence: weakest(change.evidence, 'INFERRED', ...runs.map(r => r.evidence)),
241 basis: `After the last edit at #${lastShown} the same agent ran ${unclear.map(u => unclearText(ix, u)).join('; ')}, and no decided linked check covers the final version. Tie (the trail's same-agent rule): the same agent ran this verification after editing. Only the same agent's runs are tied here: trail.ts links only runs with a pass or fail result, so an inconclusive run by another agent is not considered.`,
242 ...withUnclear(ix, base, change, ownFlags, unclear),
243 }
244}
245
246function unclearText(ix: Index, { latest }: UnclearCheck): string {
247 const verdictBasis = ix.eventById.get(latest.eventIds[0] ?? '')?.outcome?.verdictBasis ?? 'verdict basis not captured'
248 return `${shown(latest.command)} (latest run #${latest.seq} inconclusive: ${verdictBasis})`
249}
250
251/** A change's refs with the same agent's inconclusive runs folded in, so they are not a second contribution. */
252function withUnclear(ix: Index, base: ChangeBase, change: TrailNode, ownFlags: readonly string[], unclear: readonly UnclearCheck[]) {
253 const runs = unclear.flatMap(u => u.runs)
254 return {
255 nodeIds: [...base.nodeIds, ...runs.map(r => r.id)],
256 eventIds: unique([...change.eventIds, ...runs.flatMap(r => r.eventIds)]),
257 flagIds: unique([...ownFlags, ...unclear.flatMap(u => commandFlags(ix.trail, u.latest.command, u.latest.cwd)).map(f => f.id)]),
258 }
259}
260
261function staleBasis(linked: readonly Linked[], lastShown: number): string {
262 const pass = [...linked].reverse().find(l => l.check.verdict === 'passed')
263 const lead = pass ? `Edited again at #${lastShown} after the passing check at #${pass.check.seq} (${shown(pass.check.command)}). ` : ''
264 return `${lead}Every linked check ran before the last edit at #${lastShown}, so none covers the final version: ${linked.map(runText).join('; ')}.`
265}
266
267function failedChange(
268 ix: Index,
269 change: TrailNode,
270 base: ChangeBase,
271 ownFlags: readonly string[],
272 after: readonly Linked[],
273 failing: readonly Linked[],
274 lastShown: number,
275): ContributionOutcome {
276 const describe = (l: Linked): string => {
277 const latest = summaryOf(ix.trail, l.check)?.latest
278 const later =
279 l.check.verdict === 'passed' ? `, but the latest captured run of that command in the same directory ${latest === 'failed' ? 'failed' : 'was inconclusive'}` : ''
280 return `${runText(l)}${later}`
281 }
282 const passing = after.filter(l => !failing.includes(l))
283 const conflict = passing.length ? ` A linked check also passed (${passing.map(runText).join('; ')}); a failing or inconclusive result outweighs a pass.` : ''
284 const first = failing[0] as Linked
285 return {
286 ...base,
287 category: 'failed-or-inconclusive',
288 evidence: strongest(failing.map(l => chainOf(change, l))),
289 basis: `After the last edit at #${lastShown}: ${failing.map(describe).join('; ')}.${conflict} Why that check bears on this file (trail rule): ${first.edge.basis}.`,
290 edgeIds: after.map(l => l.edge.id),
291 flagIds: unique([...ownFlags, ...flagIdsOf(ix, after)]),
292 }
293}
294
295/** Only reached when every linked check after the last edit stands as a pass. */
296/**
297 * Only reached when every linked check after the last edit stands as a pass. The
298 * agent's own inconclusive checks after the last edit are folded in as context:
299 * the decided pass covers the final version, and they are not a second contribution.
300 */
301function verifiedChange(
302 ix: Index,
303 change: TrailNode,
304 base: ChangeBase,
305 ownFlags: readonly string[],
306 chosen: Linked,
307 lastWriteSeq: number,
308 lastShown: number,
309): ContributionOutcome {
310 const newer = runsOf(ix, chosen.check).filter(n => n.seq > chosen.check.seq && n.verdict === 'running')
311 const running = newer[0] ? ` A newer run of that command (#${newer[0].seq}) is still running.` : ''
312 const unclear = unclearRunsAfter(ix, change, lastWriteSeq)
313 const own = unclear.length
314 ? ` Its own check ${unclear.map(u => unclearText(ix, u)).join('; ')} after the edit was inconclusive; the passing ${shown(chosen.check.command)} at #${chosen.check.seq} covers the final version.`
315 : ''
316 return {
317 ...base,
318 category: 'verified',
319 evidence: chainOf(change, chosen),
320 basis: `${shown(chosen.check.command)} passed at #${chosen.check.seq}, after the last edit at #${lastShown}. Why that check bears on this file (trail rule): ${chosen.edge.basis}.${running}${own}`,
321 edgeIds: [chosen.edge.id],
322 ...withUnclear(ix, base, change, ownFlags, unclear),
323 }
324}
325
326function seriesText(runs: readonly TrailNode[]): string {
327 const verdicts = runs.map(r => r.verdict ?? 'running')
328 if (verdicts.length === 1) return `1 run, ${verdicts[0]}`
329 const tail = verdicts.slice(-SERIES_SHOWN).join(', then ')
330 return `${runs.length} runs: ${verdicts.length > SERIES_SHOWN ? `…, ${tail}` : tail}`
331}
332
333/** One contribution per command and directory: the agent's latest run decides, every run is referenced. */
334function classifyChecks(ix: Index, checks: readonly TrailNode[]): ContributionOutcome[] {
335 const groups = groupBy([...checks].sort((a, b) => a.seq - b.seq), n => checkKey(n.command ?? '', n.cwd))
336 return [...groups.values()].map(runs => {
337 const latest = runs[runs.length - 1] as TrailNode
338 const verdict = latest.verdict ?? 'running'
339 const where = latest.cwd ? ` in ${latest.cwd}` : ''
340 const verdictBasis = ix.eventById.get(latest.eventIds[0] ?? '')?.outcome?.verdictBasis ?? 'verdict basis not captured'
341 const edges = runs.flatMap(r => ix.edgesTo.get(r.id) ?? [])
342 const changes = [...new Set(edges.map(e => e.from))]
343 const flags = commandFlags(ix.trail, latest.command, latest.cwd)
344 const sessionLatest = summaryOf(ix.trail, latest)?.latest
345 const head = `${shown(latest.command)}${where}: ${seriesText(runs)}`
346 const base = {
347 id: `c:${latest.agentId}:check:${latest.cwd ?? ''}|${latest.command ?? ''}`,
348 agentId: latest.agentId,
349 title: `Ran ${shown(latest.command)}${runs.length > 1 ? ` (${runs.length} runs)` : ''} → ${verdict}`,
350 nodeIds: runs.map(r => r.id),
351 eventIds: runs.flatMap(r => r.eventIds),
352 edgeIds: edges.map(e => e.id),
353 flagIds: flags.map(f => f.id),
354 }
355 if (verdict === 'running') {
356 return { ...base, category: 'unresolved', evidence: 'UNRESOLVED', basis: `${head}; the latest run (#${latest.seq}) has no captured result yet.` }
357 }
358 if (verdict !== 'passed') {
359 return { ...base, category: 'failed-or-inconclusive', evidence: 'OBSERVED', basis: `${head}; latest ${verdict} (${verdictBasis}).` }
360 }
361 const covering = edges.filter(e => coversFinalVersion(ix, e))
362 const first = covering[0]
363 if (first) {
364 const evidence = strongest(covering.map(e => chainEvidence(ix, e)))
365 const targets = unique(covering.map(e => nodeLabel(ix, e.from))).join(', ')
366 return {
367 ...base,
368 category: 'verified',
369 evidence,
370 basis: `${head}; latest passed (${verdictBasis}), after the last edit of ${targets}. Why that check bears on it (trail rule): ${first.basis}.`,
371 }
372 }
373 return { ...base, category: 'unresolved', evidence: 'UNRESOLVED', basis: `${head}; latest passed (${verdictBasis}), but ${uncoveredReason(ix, edges, changes, sessionLatest)}.` }
374 })
375}
376
377/** Whether a change → check edge ends at a passing run after the change's last edit whose command's latest run did not fail. */
378function coversFinalVersion(ix: Index, edge: TrailEdge): boolean {
379 const change = ix.nodeById.get(edge.from)
380 const check = ix.nodeById.get(edge.to)
381 if (change?.stage !== 'change' || !check) return false
382 const { lastWriteSeq, pending } = writesOf(ix, change)
383 return pending.length === 0 && check.seq > lastWriteSeq && standsAsPass(ix, { edge, check })
384}
385
386function chainEvidence(ix: Index, edge: TrailEdge): Evidence {
387 const change = ix.nodeById.get(edge.from)
388 const check = ix.nodeById.get(edge.to)
389 return change && check ? chainOf(change, { edge, check }) : 'UNRESOLVED'
390}
391
392function uncoveredReason(ix: Index, edges: readonly TrailEdge[], changes: readonly string[], sessionLatest: string | undefined): string {
393 if (notPassing(sessionLatest)) return `a later run of that command in the same directory ${sessionLatest === 'failed' ? 'failed' : 'was inconclusive'}`
394 if (changes.length === 0) return 'it is linked to no captured change, so it verifies nothing the trail can name'
395 const linked = unique(changes).map(id => ix.nodeById.get(id)).filter(isDefined)
396 const writes = linked.map(c => writesOf(ix, c))
397 if (writes.some(w => w.pending.length > 0)) return 'a linked change has an edit with no captured result yet, so the run is not known to follow its last edit'
398 const lastEdits = writes.map(w => `#${w.lastShown}`).join(', ')
399 return `it ran before the last edit of the linked ${plural(linked.length, 'change')} (${lastEdits}), so it does not cover the final version`
400}
401
402/** For an agent with no change or check: its report, linked to later work or flagged unlinked by the trail. */
403function classifyFinding(ix: Index, finding: TrailNode): ContributionOutcome | undefined {
404 const later = (ix.edgesFrom.get(finding.id) ?? []).filter(e => !e.to.startsWith('agent:'))
405 const base = { id: `c:${finding.id}`, agentId: finding.agentId, category: 'report-only' as const, title: finding.title, nodeIds: [finding.id], eventIds: finding.eventIds }
406 const best = later.find(e => e.evidence === 'LINKED') ?? later[0]
407 if (best) {
408 const more = later.length > 1 ? ` (${plural(later.length - 1, 'more link')})` : ''
409 return {
410 ...base,
411 evidence: best.evidence,
412 basis: `report linked to later work: ${best.basis} (→ ${nodeLabel(ix, best.to)})${more}`,
413 edgeIds: later.map(e => e.id),
414 flagIds: [],
415 }
416 }
417 const unlinked = ix.trail.flags.find(f => f.nodeId === finding.id && f.kind === 'unlinked')
418 if (!unlinked) return undefined
419 return { ...base, evidence: 'UNRESOLVED', basis: UNLINKED_TEXT, edgeIds: [], flagIds: [unlinked.id] }
420}
421
422function noOutcome(ix: Index, agent: AgentRecord): ContributionOutcome {
423 const nodes = ix.nodesByAgent.get(agent.id) ?? []
424 const flags = ix.trail.flags.filter(f => f.agentId === agent.id && (f.kind === 'no-report' || f.kind === 'no-delegation'))
425 const noReport = flags.find(f => f.kind === 'no-report')
426 const delegation = flags.some(f => f.kind === 'no-delegation') ? ' Its delegation was not observed.' : ''
427 const [evidence, basis]: [Evidence, string] =
428 agent.state === 'active'
429 ? ['UNRESOLVED', `It is still active: no change, verification or report has been captured yet.${delegation}`]
430 : noReport
431 ? ['UNAVAILABLE', `${noReport.text} No change or verification was captured either.${delegation}`]
432 : [
433 'UNAVAILABLE',
434 `No change, verification or report was captured, and the end of its work was not observed (state ${agent.state}: ${agent.stateBasis}).${delegation}`,
435 ]
436 return {
437 id: `c:${agent.id}:no-outcome`,
438 agentId: agent.id,
439 category: 'unresolved',
440 evidence,
441 basis,
442 title: 'No captured outcome',
443 nodeIds: nodes.map(n => n.id),
444 eventIds: nodes.flatMap(n => n.eventIds),
445 edgeIds: [],
446 flagIds: flags.map(f => f.id),
447 }
448}
449
450function bucketOf(agent: AgentRecord, contributions: readonly ContributionOutcome[]): AgentOutcome['bucket'] {
451 if (agent.isMain) return 'coordinator'
452 const categories = new Set<OutcomeCategory>(contributions.map(c => c.category))
453 const [only] = [...categories]
454 if (categories.size === 1 && only) return only
455 return categories.size === 0 ? 'unresolved' : 'mixed'
456}
457
458function firstSeq(ix: Index, contribution: ContributionOutcome): number {
459 return Math.min(...contribution.nodeIds.map(id => ix.nodeById.get(id)?.seq ?? Number.MAX_SAFE_INTEGER))
460}
461
462function agentOutcome(ix: Index, agent: AgentRecord, row: LedgerRow | undefined): AgentOutcome {
463 const nodes = ix.nodesByAgent.get(agent.id) ?? []
464 const changes = nodes.filter(n => n.stage === 'change').map(n => classifyChange(ix, n))
465 // Runs folded into a change (an inconclusive-only check) are not a second contribution.
466 const folded = new Set(
467 changes
468 .flatMap(c => c.nodeIds.map(id => ix.nodeById.get(id)))
469 .filter((n): n is TrailNode => n?.stage === 'verification')
470 .map(n => checkKey(n.command ?? '', n.cwd)),
471 )
472 const checks = classifyChecks(ix, nodes.filter(n => n.stage === 'verification' && !folded.has(checkKey(n.command ?? '', n.cwd))))
473 // A report is a contribution only when the agent has no change or check of its own; otherwise it is "also reported back".
474 const work = [...changes, ...checks]
475 const reports = work.length === 0 ? nodes.filter(n => n.stage === 'finding').map(n => classifyFinding(ix, n)).filter(isDefined) : []
476 const found = [...work, ...reports].sort((a, b) => firstSeq(ix, a) - firstSeq(ix, b))
477 const contributions = found.length === 0 && !agent.isMain ? [noOutcome(ix, agent)] : found
478 return { agentId: agent.id, bucket: bucketOf(agent, contributions), contributions, usage: row?.direct ?? EMPTY_TOTALS }
479}
480
481function orphanOutcome(row: LedgerRow): AgentOutcome {
482 return {
483 agentId: row.agentId,
484 bucket: 'unresolved',
485 usage: row.direct,
486 contributions: [
487 {
488 id: `c:${row.agentId}:usage-only`,
489 agentId: row.agentId,
490 category: 'unresolved',
491 evidence: 'UNAVAILABLE',
492 basis: `usage from a loop never seen as an agent: no agent record, delegation or tool call was captured for it (${row.attribution.basis}).`,
493 title: 'Usage only: no agent record',
494 nodeIds: [],
495 eventIds: [],
496 edgeIds: [],
497 flagIds: [],
498 },
499 ],
500 }
501}
502
503/**
504 * The bucket's ≈$: the rows' estimates summed, marked incomplete when some of
505 * its tokens carry no estimate (a row without one, or session usage in no row).
506 */
507function costOf(rows: readonly LedgerRow[], hasUnpricedGap: boolean): CostEstimate | undefined {
508 const cost = sumCosts(rows.map(r => r.direct.cost))
509 if (!cost) return undefined
510 const missing = hasUnpricedGap || rows.some(r => r.direct.processed > 0 && r.direct.cost === undefined)
511 return missing ? { ...cost, complete: false } : cost
512}
513
514const spendBucketOf = (agent: AgentOutcome): BucketName => (agent.usage.processed > 0 ? agent.bucket : 'no-usage-recorded')
515
516function bucketsFor(agents: readonly AgentOutcome[], rows: readonly LedgerRow[], denominator: number, gap: number): SpendBucket[] {
517 const byAgent = new Map(agents.map(a => [a.agentId, spendBucketOf(a)]))
518 return BUCKET_ORDER.flatMap(bucket => {
519 const members = agents.filter(a => byAgent.get(a.agentId) === bucket).map(a => a.agentId)
520 const own = rows.filter(r => byAgent.get(r.agentId) === bucket)
521 const processed = own.reduce((n, r) => n + r.direct.processed, 0) + (bucket === 'unresolved' && gap > 0 ? gap : 0)
522 if (members.length === 0 && processed === 0) return []
523 const cost = costOf(own, bucket === 'unresolved' && gap > 0)
524 return [
525 {
526 bucket,
527 agents: members,
528 processed,
529 ...(denominator > 0 ? { percent: (processed / denominator) * 100 } : {}),
530 ...(cost ? { cost } : {}),
531 meaning: BUCKET_MEANING[bucket],
532 },
533 ]
534 })
535}
536
537function notesFor(snapshot: Snapshot, agents: readonly AgentOutcome[], orphans: readonly AgentOutcome[], denominator: number, gap: number): string[] {
538 const label = (id: string) => agentRecord(snapshot, id)?.label ?? id
539 const mixed = agents.filter(a => a.bucket === 'mixed')
540 return [
541 'Categories describe what the captured evidence establishes about each contribution, not the value of the work.',
542 ...(mixed.length
543 ? [`${plural(mixed.length, 'agent')} (${mixed.map(a => label(a.agentId)).join(', ')}) have contributions in more than one category; each is counted once, under mixed, and its usage is not split.`]
544 : []),
545 ...(orphans.length
546 ? [`${plural(orphans.length, 'ledger row')} carry usage from a loop never seen as an agent (${orphans.map(a => a.agentId).join(', ')}); counted under unresolved.`]
547 : []),
548 ...(gap > 0 ? [`${gap} tokens in the session total belong to no ledger row; counted under unresolved, with no agent.`] : []),
549 ...(gap < 0 ? [`The ledger rows add up to ${-gap} more tokens than the session total; shares use the session total.`] : []),
550 ...(denominator === 0 ? ['No token usage has been recorded yet, so no shares are shown.'] : []),
551 ]
552}
553
554export function buildSpendOutcome(snapshot: Snapshot, trail: Trail, ledger: LedgerView): SpendOutcome {
555 const ix = indexOf(snapshot, trail)
556 const rowFor = new Map<string, LedgerRow>()
557 for (const row of ledger.rows) if (!rowFor.has(row.agentId)) rowFor.set(row.agentId, row)
558 // Every agent record, in roster order first: one missing from agentOrder must still carry its usage into a bucket.
559 const ids = unique([...snapshot.agentOrder, ...Object.keys(snapshot.agents)])
560 const known = ids
561 .map(id => agentRecord(snapshot, id))
562 .filter(isDefined)
563 .map(agent => agentOutcome(ix, agent, rowFor.get(agent.id)))
564 const orphans = [...rowFor.values()].filter(row => !agentRecord(snapshot, row.agentId)).map(orphanOutcome)
565 const agents = [...known, ...orphans]
566 const denominator = ledger.session.processed
567 const gap = denominator - ledger.rows.reduce((n, r) => n + r.direct.processed, 0)
568 return {
569 agents,
570 buckets: bucketsFor(agents, ledger.rows, denominator, gap),
571 denominator,
572 denominatorBasis: DENOMINATOR_BASIS,
573 notes: notesFor(snapshot, agents, orphans, denominator, gap),
574 }
575}
576hooks/lib/pricing.ts 321 lines1// The token ledger's API-equivalent price estimates. A dated list-price
2// table, an exact model-id match, and a strict parser for a table the person
3// supplies. Every figure is ESTIMATED: never an actual charge, never what a
4// Claude subscription costs. Pure; no `$`, no network.
5
6import type { CostEstimate, ModelTally, TokenCounts } from '../../types'
7import { processedOf } from './tokens'
8
9/**
10 * USD per million tokens, one rate per token category. `cacheWritePerMTok` is a
11 * 5-minute-TTL write; `cacheWrite1hPerMTok` a 1-hour one, 2× input when left out.
12 */
13export type Rates = {
14 inputPerMTok: number
15 outputPerMTok: number
16 cacheReadPerMTok: number
17 cacheWritePerMTok: number
18 cacheWrite1hPerMTok?: number
19}
20
21export type ModelPrice = { model: string; large?: Rates; note?: string } & Rates
22
23/** A prompt-cache entry's lifetime, as the API prices its write. */
24export type CacheTtl = '5m' | '1h'
25
26/** Which loop a tally's requests came from: the main loop's or a subagent's. */
27export type Loop = 'main-loop' | 'subagent'
28
29/** Which TTL each loop's cache writes are priced at; the built-in rule when a table leaves it out. */
30export type CacheWriteTtl = { mainLoop: CacheTtl; subagents: CacheTtl }
31
32export type PriceTable = { date: string; source: string; assumptions: string[]; models: ModelPrice[]; cacheWriteTtl?: CacheWriteTtl }
33
34/** A 5-minute-TTL cache write: 1.25× the input rate. */
35export const CACHE_WRITE_MULTIPLIER = 1.25
36/** A 1-hour-TTL cache write: 2× the input rate. */
37export const CACHE_WRITE_1H_MULTIPLIER = 2
38
39/**
40 * How Claude Code wrote its prompt cache in every recorded session (2.1.299–2.1.301,
41 * transcripts of 5 sessions on 2026-10-16/17): the main loop 1-hour, subagents
42 * 5-minute. The hook's usage does not carry the TTL, so this stays an assumption.
43 */
44export const DEFAULT_CACHE_WRITE_TTL: CacheWriteTtl = { mainLoop: '1h', subagents: '5m' }
45
46const PER_MTOK = 1_000_000
47const RATE_KEYS: ReadonlyArray<keyof Rates> = ['inputPerMTok', 'outputPerMTok', 'cacheReadPerMTok', 'cacheWritePerMTok']
48const DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/
49const BRACKET_SUFFIX = /\[[^\]]*\]$/
50const DATE_SUFFIX = /-\d{8}$/
51
52function rates(input: number, output: number, cacheRead: number): Rates {
53 return {
54 inputPerMTok: input,
55 outputPerMTok: output,
56 cacheReadPerMTok: cacheRead,
57 cacheWritePerMTok: input * CACHE_WRITE_MULTIPLIER,
58 cacheWrite1hPerMTok: input * CACHE_WRITE_1H_MULTIPLIER,
59 }
60}
61
62function price(model: string, input: number, output: number, cacheRead: number): ModelPrice {
63 return { model, ...rates(input, output, cacheRead) }
64}
65
66/** List prices as of 2026-10-06. */
67export const PRICE_TABLE: PriceTable = {
68 date: '2026-10-06',
69 source:
70 'Claude API reference bundled with Claude Code 2.1.299 (claude-api skill, "Current Models (cached: 2026-10-06)", shared/models.md, shared/prompt-caching.md): first-party list rates, USD per million tokens',
71 cacheWriteTtl: DEFAULT_CACHE_WRITE_TTL,
72 assumptions: [
73 "Cache writes: the hook's usage does not say whether a write was 5-minute (1.25× input) or 1-hour (2× input). In every recorded session (Claude Code 2.1.299–2.1.301, 5 sessions' transcripts) the main loop wrote 1-hour cache and subagents 5-minute, so they are priced that way. cacheWriteTtl in SWARM_AUTOPSY_PRICES changes it.",
74 'Standard speed. Fast mode (Opus 5.5: $8/$40 per million) is not visible to the mod.',
75 'First-party API rates. Bedrock and Vertex differ, and ids with a provider prefix stay unpriced.',
76 'API-equivalent figures, not what a Claude Max or Pro subscription charges.',
77 ],
78 models: [
79 price('claude-fable-5-1', 10, 50, 0.25),
80 price('claude-mythos-5-1', 10, 50, 0.25),
81 price('claude-fable-5', 10, 50, 1),
82 price('claude-mythos-5', 10, 50, 1),
83 price('claude-opus-5-5', 4, 20, 0.2),
84 price('claude-opus-5', 5, 25, 0.5),
85 price('claude-opus-4-8', 5, 25, 0.5),
86 price('claude-opus-4-7', 5, 25, 0.5),
87 price('claude-opus-4-6', 5, 25, 0.5),
88 price('claude-sonnet-5-5', 2, 10, 0.2),
89 price('claude-sonnet-5', 2, 10, 0.2),
90 price('claude-sonnet-4-6', 3, 15, 0.3),
91 {
92 ...price('claude-haiku-5-5', 0.1, 0.5, 0.01),
93 large: rates(0.5, 2.5, 0.05),
94 note: 'tiered by prompt size: requests over 100K prompt tokens use the large rates',
95 },
96 price('claude-haiku-4-5', 1, 5, 0.1),
97 ],
98}
99
100/** The id a price is listed under: a trailing `[...]` (e.g. `[1m]`) and then a trailing `-YYYYMMDD` removed. */
101function listedId(model: string): string {
102 return model.replace(BRACKET_SUFFIX, '').replace(DATE_SUFFIX, '')
103}
104
105/** The table's price for a model id, matched exactly after removing a bracket and a date suffix; provider-prefixed ids stay unpriced. */
106export function priceFor(table: PriceTable, model: string): ModelPrice | undefined {
107 const id = listedId(model)
108 return table.models.find(p => p.model === model) ?? table.models.find(p => p.model === id)
109}
110
111function subtract(a: TokenCounts, b: TokenCounts): TokenCounts {
112 return { input: a.input - b.input, cacheRead: a.cacheRead - b.cacheRead, cacheWrite: a.cacheWrite - b.cacheWrite, output: a.output - b.output }
113}
114
115/** The TTL a loop's cache writes are priced at; a loop nobody named gets the lower, 5-minute rate. */
116export function ttlFor(table: PriceTable, loop: Loop | undefined): CacheTtl {
117 if (loop === undefined) return '5m'
118 const rule = table.cacheWriteTtl ?? DEFAULT_CACHE_WRITE_TTL
119 return loop === 'main-loop' ? rule.mainLoop : rule.subagents
120}
121
122const TTL_WORDS: Record<CacheTtl, string> = { '5m': '5-minute', '1h': '1-hour' }
123
124/** The cache-write rule a table applies, in words, for the Spend panel. */
125export function cacheWriteRule(table: PriceTable): string {
126 const rule = table.cacheWriteTtl ?? DEFAULT_CACHE_WRITE_TTL
127 return `Cache writes: the main loop at the ${TTL_WORDS[rule.mainLoop]} rate, subagents at the ${TTL_WORDS[rule.subagents]} rate (the hook does not report which; see the assumptions).`
128}
129
130function writeRate(r: Rates, ttl: CacheTtl): number {
131 return ttl === '1h' ? (r.cacheWrite1hPerMTok ?? r.inputPerMTok * CACHE_WRITE_1H_MULTIPLIER) : r.cacheWritePerMTok
132}
133
134function priceTokens(t: TokenCounts, r: Rates, ttl: CacheTtl): number {
135 return (t.input * r.inputPerMTok + t.cacheRead * r.cacheReadPerMTok + t.cacheWrite * writeRate(r, ttl) + t.output * r.outputPerMTok) / PER_MTOK
136}
137
138/** One model's tally at its rates: the large part at the large rates when the model has them. */
139function priceTally(tally: ModelTally, p: ModelPrice, ttl: CacheTtl): number {
140 return priceTokens(subtract(tally.tokens, tally.largeTokens), p, ttl) + priceTokens(tally.largeTokens, p.large ?? p, ttl)
141}
142
143/**
144 * ≈$ for a set of model tallies from one loop, its cache writes at that loop's
145 * TTL (`ttlFor`). A model with tokens but no price is left out and named in
146 * `unpricedModels`.
147 */
148export function estimateCost(table: PriceTable, byModel: Record<string, ModelTally>, loop?: Loop): CostEstimate {
149 const ttl = ttlFor(table, loop)
150 const models = Object.keys(byModel).sort()
151 const priced = models.flatMap(model => {
152 const tally = byModel[model]
153 const p = priceFor(table, model)
154 return tally && p ? [priceTally(tally, p, ttl)] : []
155 })
156 const unpricedModels = models.filter(model => {
157 const tally = byModel[model]
158 return tally !== undefined && processedOf(tally.tokens) > 0 && priceFor(table, model) === undefined
159 })
160 return {
161 usd: priced.reduce((sum, usd) => sum + usd, 0),
162 measure: 'ESTIMATED',
163 priceDate: table.date,
164 unpricedModels,
165 complete: unpricedModels.length === 0,
166 }
167}
168
169/** Dollars for display: two decimals, `<$0.01` for a non-zero amount below a cent, whole dollars from $1000. */
170export function formatUsd(usd: number): string {
171 if (usd > 0 && usd < 0.01) return '<$0.01'
172 return usd >= 1000 ? `$${usd.toFixed(0)}` : `$${usd.toFixed(2)}`
173}
174
175// ── parsing a person's own table (SWARM_AUTOPSY_PRICES) ──
176//
177// The file is untrusted: every value is checked, every string is bounded and
178// free of control characters (it is drawn in a terminal), and the table is
179// built from fresh values, so no key or array of the input is carried over.
180
181type Parsed<T> = { value: T } | { error: string }
182
183/** The highest rate accepted, USD per million tokens: far above any list price, low enough that no sum overflows. */
184export const MAX_RATE_PER_MTOK = 10_000
185const MAX_MODELS = 200
186const MAX_ASSUMPTIONS = 12
187const MAX_SOURCE_CHARS = 300
188const MAX_ASSUMPTION_CHARS = 400
189const MAX_NOTE_CHARS = 200
190const MAX_MODEL_ID_CHARS = 100
191/** Model ids as providers spell them: letters, digits and . _ - : @ / [ ] (e.g. `claude-opus-5-5[1m]`, `…-v1:0`). */
192const MODEL_ID_SHAPE = /^[A-Za-z0-9._:@/[\]-]+$/
193const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/
194
195const isRecord = (value: unknown): value is Record<string, unknown> =>
196 typeof value === 'object' && value !== null && !Array.isArray(value)
197
198const isRate = (value: unknown): value is number =>
199 typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_RATE_PER_MTOK
200
201/** A YYYY-MM-DD string naming a real calendar day. */
202function isCalendarDate(value: unknown): value is string {
203 if (typeof value !== 'string' || !DATE_SHAPE.test(value)) return false
204 const [year, month, day] = value.split('-').map(Number) as [number, number, number]
205 const date = new Date(Date.UTC(year, month - 1, day))
206 return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
207}
208
209/** One line of display text: non-empty after trimming, within `max` characters, no control characters. */
210function parseText(value: unknown, path: string, max: number): Parsed<string> {
211 if (typeof value !== 'string' || value.trim() === '') return { error: `${path} must be non-empty text` }
212 if (value.length > max) return { error: `${path} must be at most ${max} characters` }
213 if (CONTROL_CHARS.test(value)) return { error: `${path} must be one line of plain text (no control characters)` }
214 return { value }
215}
216
217function parseRates(raw: unknown, path: string): Parsed<Rates> {
218 if (!isRecord(raw)) return { error: `${path} must be an object of rates` }
219 const bad = RATE_KEYS.find(key => !isRate(raw[key]))
220 if (bad) return { error: `${path}.${bad} must be a number from 0 to ${MAX_RATE_PER_MTOK} (USD per million tokens)` }
221 const hour = raw.cacheWrite1hPerMTok
222 if (hour !== undefined && !isRate(hour)) {
223 return { error: `${path}.cacheWrite1hPerMTok must be a number from 0 to ${MAX_RATE_PER_MTOK} (USD per million tokens), or left out for 2× input` }
224 }
225 return {
226 value: {
227 inputPerMTok: raw.inputPerMTok as number,
228 outputPerMTok: raw.outputPerMTok as number,
229 cacheReadPerMTok: raw.cacheReadPerMTok as number,
230 cacheWritePerMTok: raw.cacheWritePerMTok as number,
231 ...(hour !== undefined ? { cacheWrite1hPerMTok: hour } : {}),
232 },
233 }
234}
235
236const isTtl = (value: unknown): value is CacheTtl => value === '5m' || value === '1h'
237
238/** The optional cache-write TTL rule: both loops named, each '5m' or '1h'. */
239function parseCacheWriteTtl(raw: unknown): Parsed<CacheWriteTtl | undefined> {
240 if (raw === undefined) return { value: undefined }
241 if (!isRecord(raw) || !isTtl(raw.mainLoop) || !isTtl(raw.subagents)) {
242 return { error: 'cacheWriteTtl must be { "mainLoop": "5m" or "1h", "subagents": "5m" or "1h" }, or left out for the built-in rule' }
243 }
244 return { value: { mainLoop: raw.mainLoop, subagents: raw.subagents } }
245}
246
247function parseModelId(value: unknown, path: string): Parsed<string> {
248 const text = parseText(value, path, MAX_MODEL_ID_CHARS)
249 if ('error' in text) return text
250 return MODEL_ID_SHAPE.test(text.value) ? text : { error: `${path} must be a model id (letters, digits and . _ - : @ / [ ] only)` }
251}
252
253function parseModel(raw: unknown, path: string): Parsed<ModelPrice> {
254 if (!isRecord(raw)) return { error: `${path} must be an object` }
255 const model = parseModelId(raw.model, `${path}.model`)
256 if ('error' in model) return model
257 const note = raw.note === undefined ? undefined : parseText(raw.note, `${path}.note`, MAX_NOTE_CHARS)
258 if (note && 'error' in note) return note
259 const base = parseRates(raw, path)
260 if ('error' in base) return base
261 const large = raw.large === undefined ? undefined : parseRates(raw.large, `${path}.large`)
262 if (large && 'error' in large) return large
263 return {
264 value: {
265 model: model.value,
266 ...base.value,
267 ...(large ? { large: large.value } : {}),
268 ...(note ? { note: note.value } : {}),
269 },
270 }
271}
272
273function parseModels(raw: unknown): Parsed<ModelPrice[]> {
274 if (!Array.isArray(raw) || raw.length === 0) return { error: 'models must be a non-empty list' }
275 if (raw.length > MAX_MODELS) return { error: `models must list at most ${MAX_MODELS} entries` }
276 const models: ModelPrice[] = []
277 for (const [i, entry] of raw.entries()) {
278 const parsed = parseModel(entry, `models[${i}]`)
279 if ('error' in parsed) return parsed
280 if (models.some(m => m.model === parsed.value.model)) return { error: `models[${i}].model ${parsed.value.model} is listed twice` }
281 models.push(parsed.value)
282 }
283 return { value: models }
284}
285
286function parseAssumptions(raw: unknown): Parsed<string[]> {
287 const list = raw ?? []
288 if (!Array.isArray(list)) return { error: 'assumptions must be a list of text' }
289 if (list.length > MAX_ASSUMPTIONS) return { error: `assumptions must list at most ${MAX_ASSUMPTIONS} entries` }
290 const assumptions: string[] = []
291 for (const [i, entry] of list.entries()) {
292 const text = parseText(entry, `assumptions[${i}]`, MAX_ASSUMPTION_CHARS)
293 if ('error' in text) return text
294 assumptions.push(text.value)
295 }
296 return { value: assumptions }
297}
298
299/** Validates an untrusted price table read from JSON and rebuilds it from fresh values; anything wrong is refused with a plain message naming the field. */
300export function parsePriceTable(json: unknown): { table: PriceTable } | { error: string } {
301 if (!isRecord(json)) return { error: 'the price table must be a JSON object' }
302 if (!isCalendarDate(json.date)) return { error: "date must be the prices' effective date, a real day as YYYY-MM-DD" }
303 const source = parseText(json.source, 'source', MAX_SOURCE_CHARS)
304 if ('error' in source) return { error: `${source.error}, saying where the prices come from` }
305 const assumptions = parseAssumptions(json.assumptions)
306 if ('error' in assumptions) return assumptions
307 const models = parseModels(json.models)
308 if ('error' in models) return models
309 const ttl = parseCacheWriteTtl(json.cacheWriteTtl)
310 if ('error' in ttl) return ttl
311 return {
312 table: {
313 date: json.date,
314 source: source.value,
315 assumptions: assumptions.value,
316 models: models.value,
317 ...(ttl.value ? { cacheWriteTtl: ttl.value } : {}),
318 },
319 }
320}
321hooks/lib/reduce.ts 603 lines1// The collector's reducer: one observation in, a new snapshot out. Pure and
2// immutable, so the same observations always give the same snapshot, and
3// every record keeps the hook and field it came from.
4
5import type {
6 AgentCounts,
7 AgentRecord,
8 AgentReport,
9 AgentState,
10 CompactionMark,
11 ForensicEvent,
12 Instruments,
13 Snapshot,
14 TokenCounts,
15 ToolFacts,
16 ToolOutcome,
17 UsageRecord,
18} from '../../types'
19import { HANDBACK_TOOL, REPORT_CAP, baseName, cap, pathMentions } from './classify'
20import { applyTurnReport, applyUsage, emptyLedger } from './ledger'
21
22export const MAIN = 'main'
23export const MAX_EVENTS = 1200
24export const PROMPT_CAP = 2000
25const DETAIL_CAP = 300
26const NOTE_CAP = 12
27const OPEN_TOOLS_CAP = 200
28const COMPACTION_MARKS_CAP = 60
29
30export type TurnUsageLite = {
31 input: number
32 output: number
33 cacheRead: number
34 cacheWrite: number
35 model?: string
36}
37
38export type RosterEntry = {
39 id: string
40 type: string
41 description: string
42 status: string
43 parentId?: string
44}
45
46export type SpawnObservation = {
47 kind: 'spawn'
48 at: number
49 toolUseId: string
50 agentId?: string
51 parentAgentId?: string
52 subagentType: string
53 description: string
54 prompt: string
55 background: boolean
56 cwd?: string
57 name?: string
58 model?: string
59 isTeammate?: boolean
60 workflowIndex?: number
61 resumeOf?: string
62 denied?: string
63}
64
65export type Observation =
66 | SpawnObservation
67 | { kind: 'turn-start'; at: number; turnId: string; agentId?: string; text: string }
68 | {
69 kind: 'turn-end'
70 at: number
71 turnId: string
72 agentId?: string
73 reason: string
74 answer: string
75 durationMs: number
76 usage?: TurnUsageLite
77 }
78 | { kind: 'tool-start'; at: number; toolUseId: string; agentId?: string; facts: ToolFacts }
79 | { kind: 'tool-end'; at: number; toolUseId: string; agentId?: string; facts: ToolFacts; outcome: ToolOutcome }
80 | { kind: 'agent-start'; at: number; agentId: string; agentType: string }
81 | { kind: 'agent-stop'; at: number; agentId: string; agentType: string; transcriptPath?: string; lastMessage?: string }
82 | { kind: 'roster'; at: number; agents: RosterEntry[] }
83 | {
84 kind: 'compaction'
85 at: number
86 agentId?: string
87 trigger: string
88 tokensBefore?: number
89 tokensAfter?: number
90 summarizerTokens?: TokenCounts
91 }
92 | { kind: 'context'; at: number; tokens?: number; window: number; percent?: number }
93 | { kind: 'step'; record: UsageRecord }
94 | { kind: 'cost'; at: number; usd: number }
95 | { kind: 'root'; dir: string; root: string | null; commonDir?: string | null }
96 | { kind: 'note'; at: number; text: string }
97
98const ZERO_COUNTS: AgentCounts = { tools: 0, reads: 0, writes: 0, execs: 0, errors: 0 }
99
100const ZERO_INSTRUMENTS: Instruments = {
101 spawn: 0,
102 turnStart: 0,
103 turnEnd: 0,
104 tool: 0,
105 agentStart: 0,
106 agentStop: 0,
107 roster: 0,
108 compaction: 0,
109 measure: 0,
110 step: 0,
111}
112
113export function labelFor(ordinal: number): string {
114 if (ordinal <= 0) return MAIN
115 let n = ordinal
116 let label = ''
117 while (n > 0) {
118 const rem = (n - 1) % 26
119 label = String.fromCharCode(65 + rem) + label
120 n = Math.floor((n - 1) / 26)
121 }
122 return label
123}
124
125function newAgent(id: string, ordinal: number, at: number, seq: number, knownFrom: AgentRecord['knownFrom']): AgentRecord {
126 const isMain = id === MAIN
127 return {
128 id,
129 ordinal,
130 label: labelFor(ordinal),
131 isMain,
132 knownFrom,
133 promptMentions: [],
134 parentEvidence: isMain ? 'OBSERVED' : 'UNAVAILABLE',
135 parentBasis: isMain ? 'the coordinating session' : 'no delegation event was observed for this agent',
136 state: 'unknown',
137 stateBasis: 'no lifecycle event observed yet',
138 firstSeq: seq,
139 firstAt: at,
140 lastSeq: seq,
141 lastAt: at,
142 lastSummary: '',
143 turns: 0,
144 compactions: 0,
145 counts: ZERO_COUNTS,
146 }
147}
148
149export function emptySnapshot(at: number, sessionId?: string): Snapshot {
150 return {
151 version: 3,
152 sessionId,
153 startedAt: at,
154 seq: 0,
155 rev: 0,
156 agents: { [MAIN]: newAgent(MAIN, 0, at, 0, 'session') },
157 agentOrder: [MAIN],
158 events: [],
159 droppedEvents: 0,
160 roots: {},
161 repos: {},
162 openTools: [],
163 ledger: emptyLedger(),
164 compactionMarks: [],
165 instruments: ZERO_INSTRUMENTS,
166 notes: [],
167 }
168}
169
170const idOf = (agentId: string | undefined): string => agentId ?? MAIN
171
172/** The agent recorded under `id`: an own entry only, so an id like `constructor` is never mistaken for one. */
173function agentIn(s: Snapshot, id: string): AgentRecord | undefined {
174 return Object.hasOwn(s.agents, id) ? s.agents[id] : undefined
175}
176
177function ensureAgent(s: Snapshot, id: string, at: number, knownFrom: AgentRecord['knownFrom']): Snapshot {
178 if (agentIn(s, id)) return s
179 const agent = newAgent(id, s.agentOrder.length, at, s.seq, knownFrom)
180 return { ...s, agents: { ...s.agents, [id]: agent }, agentOrder: [...s.agentOrder, id] }
181}
182
183function patchAgent(s: Snapshot, id: string, patch: (agent: AgentRecord) => AgentRecord): Snapshot {
184 const agent = agentIn(s, id)
185 return agent ? { ...s, agents: { ...s.agents, [id]: patch(agent) } } : s
186}
187
188function touch(agent: AgentRecord, event: ForensicEvent): AgentRecord {
189 return { ...agent, lastSeq: event.seq, lastAt: event.at, lastSummary: event.summary }
190}
191
192function hasEvent(s: Snapshot, id: string): boolean {
193 return s.events.some(e => e.id === id)
194}
195
196/** Appends an event at the next sequence number, dropping the oldest past the cap. */
197function appendEvent(s: Snapshot, event: Omit<ForensicEvent, 'seq'>): [Snapshot, ForensicEvent] {
198 const seq = s.seq + 1
199 const full: ForensicEvent = { ...event, seq }
200 const events = [...s.events, full]
201 const overflow = Math.max(0, events.length - MAX_EVENTS)
202 return [
203 { ...s, seq, events: overflow > 0 ? events.slice(overflow) : events, droppedEvents: s.droppedEvents + overflow },
204 full,
205 ]
206}
207
208function count(s: Snapshot, key: keyof Instruments): Snapshot {
209 return { ...s, instruments: { ...s.instruments, [key]: s.instruments[key] + 1 } }
210}
211
212function setState(agent: AgentRecord, state: AgentState, basis: string): AgentRecord {
213 return { ...agent, state, stateBasis: basis }
214}
215
216function reportFrom(text: string, at: number, seq: number, reason: string, source: string): AgentReport {
217 return { excerpt: cap(text, REPORT_CAP), length: text.length, at, seq, reason, mentions: pathMentions(text), source }
218}
219
220function stateForReason(reason: string): AgentState {
221 if (reason === 'answer') return 'completed'
222 if (reason === 'aborted') return 'stopped'
223 return 'failed'
224}
225
226function addCounts(counts: AgentCounts, facts: ToolFacts): AgentCounts {
227 return {
228 ...counts,
229 tools: counts.tools + 1,
230 reads: counts.reads + (facts.access === 'read' || facts.access === 'search' ? 1 : 0),
231 writes: counts.writes + (facts.access === 'write' ? 1 : 0),
232 execs: counts.execs + (facts.access === 'exec' ? 1 : 0),
233 }
234}
235
236export function toolLabel(facts: ToolFacts): string {
237 if (facts.command) return `${facts.tool}: ${cap(facts.command.split('\n')[0] ?? '', 80)}`
238 const path = facts.paths[0]
239 if (path) return `${facts.tool} ${baseName(path)}`
240 return facts.tool
241}
242
243function reduceSpawn(s: Snapshot, o: SpawnObservation): Snapshot {
244 const counted = count(s, 'spawn')
245 if (o.denied !== undefined || o.agentId === undefined) {
246 const id = `spawn:${o.toolUseId}:${o.workflowIndex ?? 'none'}`
247 if (hasEvent(counted, id)) return counted
248 const isDenied = o.denied !== undefined
249 const why = isDenied ? `refused: ${cap(o.denied ?? '', 120)}` : 'started no local agent (a remote or workflow run)'
250 const [next] = appendEvent(counted, {
251 id,
252 at: o.at,
253 kind: isDenied ? 'spawn-denied' : 'spawn',
254 agentId: idOf(o.parentAgentId),
255 summary: `Spawn of ${o.subagentType} "${cap(o.description, 60)}" ${why}`,
256 source: isDenied ? 'agent.spawn: answered with a deny' : 'agent.spawn: the result carried no agentId',
257 toolUseId: o.toolUseId,
258 detail: cap(o.prompt, DETAIL_CAP),
259 })
260 return next
261 }
262 const agentId = o.agentId
263 const id = `spawn:${o.toolUseId}:${agentId}`
264 if (hasEvent(counted, id)) return counted
265 const parentId = idOf(o.parentAgentId)
266 const withParent = ensureAgent(counted, parentId, o.at, 'events')
267 const withAgent = ensureAgent(withParent, agentId, o.at, 'spawn')
268 const [next, event] = appendEvent(withAgent, {
269 id,
270 at: o.at,
271 kind: 'spawn',
272 agentId,
273 summary: `Delegated to ${o.subagentType}: "${cap(o.description, 80)}"${o.background ? ' (background)' : ''}`,
274 source: `agent.spawn: tool_use_id ${o.toolUseId}, result agentId ${agentId}`,
275 toolUseId: o.toolUseId,
276 detail: cap(o.prompt, DETAIL_CAP),
277 })
278 const parentBasis = o.parentAgentId
279 ? `agent.spawn raised in loop ${o.parentAgentId} (parentAgentId)`
280 : 'agent.spawn raised in the main loop (no parentAgentId)'
281 return patchAgent(next, agentId, agent => {
282 const merged: AgentRecord = {
283 ...agent,
284 knownFrom: 'spawn',
285 type: o.subagentType,
286 description: o.description,
287 prompt: cap(o.prompt, PROMPT_CAP),
288 promptLength: o.prompt.length,
289 promptMentions: pathMentions(o.prompt),
290 parentId,
291 parentEvidence: 'LINKED',
292 parentBasis: `${parentBasis}; tool_use_id ${o.toolUseId}`,
293 spawnToolUseId: o.toolUseId,
294 spawnSeq: event.seq,
295 background: o.background,
296 cwd: o.cwd,
297 model: o.model,
298 name: o.name,
299 }
300 const started = agent.state === 'unknown' || o.resumeOf !== undefined
301 return touch(started ? setState(merged, 'active', 'agent.spawn started it') : merged, event)
302 })
303}
304
305function reduceTurnStart(s: Snapshot, o: Extract<Observation, { kind: 'turn-start' }>): Snapshot {
306 const id = `turn:${o.turnId}:start`
307 const counted = count(s, 'turnStart')
308 if (hasEvent(counted, id)) return counted
309 const agentId = idOf(o.agentId)
310 const withAgent = ensureAgent(counted, agentId, o.at, 'events')
311 const isMain = agentId === MAIN
312 const [next, event] = appendEvent(withAgent, {
313 id,
314 at: o.at,
315 kind: 'turn-start',
316 agentId,
317 summary: isMain ? 'Main turn started' : 'Agent turn started',
318 source: `turn.start: turnId ${o.turnId}${o.agentId ? `, agentId ${o.agentId}` : ' (main loop)'}`,
319 turnId: o.turnId,
320 detail: o.text ? cap(o.text, DETAIL_CAP) : undefined,
321 })
322 return patchAgent(next, agentId, agent => touch(setState(agent, 'active', 'turn.start: a turn is running'), event))
323}
324
325function reduceTurnEnd(s: Snapshot, o: Extract<Observation, { kind: 'turn-end' }>): Snapshot {
326 const id = `turn:${o.turnId}:end`
327 const counted = count(s, 'turnEnd')
328 if (hasEvent(counted, id)) return counted
329 const agentId = idOf(o.agentId)
330 const withAgent = ensureAgent(counted, agentId, o.at, 'events')
331 const hasAnswer = o.answer.trim().length > 0
332 const [next, event] = appendEvent(withAgent, {
333 id,
334 at: o.at,
335 kind: 'turn-end',
336 agentId,
337 summary: `Turn ended (${o.reason})${hasAnswer ? `: answer of ${o.answer.length} chars` : ', no answer text'}`,
338 source: `turn.complete: turnId ${o.turnId}, reason ${o.reason}${o.agentId ? `, agentId ${o.agentId}` : ' (main loop)'}`,
339 turnId: o.turnId,
340 detail: hasAnswer ? cap(o.answer, DETAIL_CAP) : undefined,
341 })
342 // turn.complete's usage is the engine's sum of the turn's requests: it goes to
343 // the ledger for reconciliation only, never into a total (turn.step is the source).
344 const tokens = o.usage
345 ? { input: o.usage.input, cacheRead: o.usage.cacheRead, cacheWrite: o.usage.cacheWrite, output: o.usage.output }
346 : undefined
347 const reconciled = { ...next, ledger: applyTurnReport(next.ledger, { turnId: o.turnId, agentId, at: o.at, tokens, model: o.usage?.model }) }
348 return patchAgent(reconciled, agentId, agent => {
349 const report = hasAnswer
350 ? reportFrom(o.answer, o.at, event.seq, o.reason, `turn.complete answer (turnId ${o.turnId})`)
351 : agent.report
352 const ended = setState(agent, stateForReason(o.reason), `turn.complete reason=${o.reason}`)
353 return touch({ ...ended, turns: agent.turns + 1, report, currentTool: undefined }, event)
354 })
355}
356
357function reduceToolStart(s: Snapshot, o: Extract<Observation, { kind: 'tool-start' }>): Snapshot {
358 const id = `tool:${o.toolUseId}`
359 if (hasEvent(s, id)) return s
360 const agentId = idOf(o.agentId)
361 const counted = count(ensureAgent(s, agentId, o.at, 'events'), 'tool')
362 const withAgent = { ...counted, openTools: [...counted.openTools, o.toolUseId].slice(-OPEN_TOOLS_CAP) }
363 const label = toolLabel(o.facts)
364 const [next, event] = appendEvent(withAgent, {
365 id,
366 at: o.at,
367 kind: 'tool',
368 agentId,
369 summary: label,
370 source: `tool.call: tool_use_id ${o.toolUseId}${o.agentId ? `, agentId ${o.agentId}` : ' (main loop)'}`,
371 toolUseId: o.toolUseId,
372 tool: o.facts,
373 outcome: { status: 'running', text: '', verdict: o.facts.verification ? 'running' : undefined },
374 })
375 return patchAgent(next, agentId, agent => {
376 const active = agent.state === 'unknown' ? setState(agent, 'active', 'tool activity observed (no lifecycle event yet)') : agent
377 const handback = o.facts.tool === HANDBACK_TOOL && o.facts.text !== undefined && o.facts.text.length > 0
378 const report =
379 handback && (!agent.report || agent.report.source.startsWith('classic.SubagentStop'))
380 ? {
381 excerpt: o.facts.text ?? '',
382 length: o.facts.textLength ?? 0,
383 at: o.at,
384 seq: event.seq,
385 reason: 'handback',
386 mentions: o.facts.mentions,
387 source: `${HANDBACK_TOOL} tool call message (tool_use_id ${o.toolUseId})`,
388 }
389 : agent.report
390 return touch(
391 {
392 ...active,
393 counts: addCounts(agent.counts, o.facts),
394 currentTool: { tool: o.facts.tool, toolUseId: o.toolUseId, at: o.at, label },
395 report,
396 },
397 event,
398 )
399 })
400}
401
402function reduceToolEnd(s: Snapshot, o: Extract<Observation, { kind: 'tool-end' }>): Snapshot {
403 const id = `tool:${o.toolUseId}`
404 const existing = s.events.find(e => e.id === id)
405 if (existing?.endSeq !== undefined) return s
406 const agentId = idOf(o.agentId)
407 const isError = o.outcome.status === 'error' || o.outcome.status === 'denied'
408 const wasOpen = s.openTools.includes(o.toolUseId)
409 const closed = { ...s, openTools: s.openTools.filter(t => t !== o.toolUseId) }
410 if (existing) {
411 const endSeq = s.seq + 1
412 const ended: ForensicEvent = { ...existing, outcome: o.outcome, endSeq, endAt: o.at, source: `${existing.source}; result captured` }
413 const replaced = { ...closed, seq: endSeq, events: closed.events.map(e => (e.id === id ? ended : e)) }
414 return patchAgent(replaced, agentId, agent => ({
415 ...agent,
416 counts: isError ? { ...agent.counts, errors: agent.counts.errors + 1 } : agent.counts,
417 currentTool: agent.currentTool?.toolUseId === o.toolUseId ? undefined : agent.currentTool,
418 lastSeq: endSeq,
419 lastAt: o.at,
420 }))
421 }
422 const withAgent = wasOpen ? ensureAgent(closed, agentId, o.at, 'events') : count(ensureAgent(closed, agentId, o.at, 'events'), 'tool')
423 const [next, event] = appendEvent(withAgent, {
424 id,
425 at: o.at,
426 kind: 'tool',
427 agentId,
428 summary: toolLabel(o.facts),
429 source: wasOpen
430 ? `tool.call result: tool_use_id ${o.toolUseId} (its start record aged out of the timeline)`
431 : `tool.call result: tool_use_id ${o.toolUseId} (start not observed)`,
432 toolUseId: o.toolUseId,
433 tool: o.facts,
434 outcome: o.outcome,
435 endSeq: s.seq + 1,
436 endAt: o.at,
437 })
438 return patchAgent(next, agentId, agent => {
439 const active = agent.state === 'unknown' ? setState(agent, 'active', 'tool activity observed (no lifecycle event yet)') : agent
440 const counts = wasOpen ? agent.counts : addCounts(agent.counts, o.facts)
441 const currentTool = agent.currentTool?.toolUseId === o.toolUseId ? undefined : agent.currentTool
442 return touch({ ...active, currentTool, counts: isError ? { ...counts, errors: counts.errors + 1 } : counts }, event)
443 })
444}
445
446function reduceAgentStart(s: Snapshot, o: Extract<Observation, { kind: 'agent-start' }>): Snapshot {
447 const counted = count(s, 'agentStart')
448 const withAgent = ensureAgent(counted, o.agentId, o.at, 'events')
449 const agent = agentIn(withAgent, o.agentId)
450 const id = `agent-start:${o.agentId}:${agent?.turns ?? 0}`
451 if (hasEvent(withAgent, id)) return withAgent
452 const [next, event] = appendEvent(withAgent, {
453 id,
454 at: o.at,
455 kind: 'agent-start',
456 agentId: o.agentId,
457 summary: `Subagent started (${o.agentType})`,
458 source: `classic.SubagentStart: agent_id ${o.agentId}, agent_type ${o.agentType}`,
459 })
460 return patchAgent(next, o.agentId, a => touch({ ...a, type: a.type ?? o.agentType }, event))
461}
462
463function reduceAgentStop(s: Snapshot, o: Extract<Observation, { kind: 'agent-stop' }>): Snapshot {
464 const counted = count(s, 'agentStop')
465 const withAgent = ensureAgent(counted, o.agentId, o.at, 'events')
466 const agent = agentIn(withAgent, o.agentId)
467 const id = `agent-stop:${o.agentId}:${agent?.turns ?? 0}`
468 if (hasEvent(withAgent, id)) return withAgent
469 const [next, event] = appendEvent(withAgent, {
470 id,
471 at: o.at,
472 kind: 'agent-stop',
473 agentId: o.agentId,
474 summary: 'Subagent stopped',
475 source: `classic.SubagentStop: agent_id ${o.agentId}${o.transcriptPath ? `, transcript ${o.transcriptPath}` : ''}`,
476 detail: o.lastMessage ? cap(o.lastMessage, DETAIL_CAP) : undefined,
477 })
478 return patchAgent(next, o.agentId, a => {
479 const report =
480 a.report ?? (o.lastMessage ? reportFrom(o.lastMessage, o.at, event.seq, 'stop', 'classic.SubagentStop last_assistant_message') : undefined)
481 const stopped = a.state === 'active' || a.state === 'unknown' ? setState(a, 'completed', 'classic.SubagentStop') : a
482 return touch({ ...stopped, type: a.type ?? o.agentType, transcriptPath: o.transcriptPath ?? a.transcriptPath, report }, event)
483 })
484}
485
486function rosterState(status: string): AgentState | undefined {
487 if (status === 'pending' || status === 'running' || status === 'waiting') return 'active'
488 if (status === 'idle' || status === 'completed') return 'completed'
489 if (status === 'failed') return 'failed'
490 if (status === 'killed') return 'stopped'
491 return undefined
492}
493
494function reduceRoster(s: Snapshot, o: Extract<Observation, { kind: 'roster' }>): Snapshot {
495 return o.agents.reduce((acc, entry) => {
496 const isNew = !agentIn(acc, entry.id)
497 const withAgent = ensureAgent(acc, entry.id, o.at, 'roster')
498 const withParent = entry.parentId ? ensureAgent(withAgent, entry.parentId, o.at, 'events') : withAgent
499 const [next, event] = isNew
500 ? appendEvent(withParent, {
501 id: `roster:${entry.id}`,
502 at: o.at,
503 kind: 'roster',
504 agentId: entry.id,
505 summary: `Agent found in the roster (${entry.type}, ${entry.status})`,
506 source: '$.agent.list() at load or on first sight of an unknown agent',
507 })
508 : [withParent, undefined]
509 return patchAgent(next, entry.id, agent => {
510 const state = rosterState(entry.status)
511 const hasParent = agent.parentEvidence === 'LINKED'
512 const filled: AgentRecord = {
513 ...agent,
514 type: agent.type ?? entry.type,
515 description: agent.description ?? (entry.description || undefined),
516 parentId: hasParent ? agent.parentId : (entry.parentId ?? MAIN),
517 parentEvidence: 'LINKED',
518 parentBasis: hasParent ? agent.parentBasis : `$.agent.list() ${entry.parentId ? `parentId ${entry.parentId}` : 'lists no parentId (spawned by the main loop)'}`,
519 }
520 const withState = state ? setState(filled, state, `$.agent.list() status=${entry.status}`) : filled
521 return event ? touch(withState, event) : withState
522 })
523 }, count(s, 'roster'))
524}
525
526function reduceCompaction(s: Snapshot, o: Extract<Observation, { kind: 'compaction' }>): Snapshot {
527 const agentId = idOf(o.agentId)
528 const withAgent = count(ensureAgent(s, agentId, o.at, 'events'), 'compaction')
529 const sizes =
530 o.tokensBefore !== undefined && o.tokensAfter !== undefined ? ` (${o.tokensBefore} → ${o.tokensAfter} tokens)` : ''
531 const [next, event] = appendEvent(withAgent, {
532 id: `compaction:${agentId}:${withAgent.seq + 1}`,
533 at: o.at,
534 kind: 'compaction',
535 agentId,
536 summary: `Context compacted (${o.trigger})${sizes}`,
537 source: `session.compact: trigger ${o.trigger}${o.agentId ? `, agentId ${o.agentId}` : ' (main loop)'}`,
538 })
539 const mark: CompactionMark = {
540 agentId,
541 at: o.at,
542 seq: event.seq,
543 trigger: o.trigger,
544 tokensBefore: o.tokensBefore,
545 tokensAfter: o.tokensAfter,
546 summarizerTokens: o.summarizerTokens,
547 }
548 const marked = { ...next, compactionMarks: [...next.compactionMarks, mark].slice(-COMPACTION_MARKS_CAP) }
549 return patchAgent(marked, agentId, agent => touch({ ...agent, compactions: agent.compactions + 1 }, event))
550}
551
552/** One `turn.step` result: counted as heard, then folded into the ledger (which drops a duplicate key). */
553function reduceStep(s: Snapshot, o: Extract<Observation, { kind: 'step' }>): Snapshot {
554 return { ...count(s, 'step'), ledger: applyUsage(s.ledger, o.record) }
555}
556
557/** Folds one observation in; the revision moves whenever anything changed. */
558export function reduce(s: Snapshot, o: Observation): Snapshot {
559 const next = reduceOne(s, o)
560 return next === s ? s : { ...next, rev: s.rev + 1 }
561}
562
563function reduceOne(s: Snapshot, o: Observation): Snapshot {
564 switch (o.kind) {
565 case 'spawn':
566 return reduceSpawn(s, o)
567 case 'turn-start':
568 return reduceTurnStart(s, o)
569 case 'turn-end':
570 return reduceTurnEnd(s, o)
571 case 'tool-start':
572 return reduceToolStart(s, o)
573 case 'tool-end':
574 return reduceToolEnd(s, o)
575 case 'agent-start':
576 return reduceAgentStart(s, o)
577 case 'agent-stop':
578 return reduceAgentStop(s, o)
579 case 'roster':
580 return reduceRoster(s, o)
581 case 'compaction':
582 return reduceCompaction(s, o)
583 case 'context':
584 return count({ ...s, context: { tokens: o.tokens, window: o.window, percent: o.percent, at: o.at } }, 'measure')
585 case 'step':
586 return reduceStep(s, o)
587 case 'cost':
588 return s.engineCost?.usd === o.usd ? s : { ...s, engineCost: { usd: o.usd, at: o.at } }
589 case 'root':
590 return {
591 ...s,
592 roots: { ...s.roots, [o.dir]: o.root },
593 repos: o.root && o.commonDir !== undefined ? { ...s.repos, [o.root]: o.commonDir } : s.repos,
594 }
595 case 'note':
596 return s.notes.includes(o.text) ? s : { ...s, notes: [...s.notes, o.text].slice(-NOTE_CAP) }
597 }
598}
599
600export function reduceAll(s: Snapshot, observations: readonly Observation[]): Snapshot {
601 return observations.reduce(reduce, s)
602}
603hooks/lib/scenes.ts 399 lines1// The look as two categories (modes) of scenes. Spooky: dark gray, a little haunted, each lit by one cold or
2// guttering light. Cozy: warm and pleasant, lamplight and golden hour, each with a lofi track. A scene carries its
3// title, motto, palette, the name of the context gauge it draws and its music. Its palette recolours the whole pane
4// (lib/themes.ts). Pure data.
5
6export type ModeId = 'spooky' | 'cozy'
7
8export type SceneId =
9 | 'fogbound-cemetery'
10 | 'drowned-lighthouse'
11 | 'hollow-library'
12 | 'crooked-manor'
13 | 'clocktower'
14 | 'mire-lights'
15 | 'cat-cafe'
16 | 'fireside-tea'
17 | 'golden-porch'
18 | 'sunset-pier'
19 | 'flower-shop'
20 | 'forest-cabin'
21
22export type Palette = {
23 /** Sky gradient: top, middle, horizon. */
24 sky: readonly [string, string, string]
25 /** The pane's background; the scene fades into it. */
26 base: string
27 /** Panels on top of the base. */
28 surface: string
29 /** Main text and headings. */
30 ink: string
31 /** Secondary text and quiet marks. */
32 muted: string
33 /** The one light of the scene: brand mark, section markers, the gauge. */
34 accent: string
35 /** A second, quieter light. */
36 accent2: string
37 /** The empty part of a gauge, a bar's track. */
38 track: string
39}
40
41export type Scene = {
42 id: SceneId
43 mode: ModeId
44 title: string
45 motto: string
46 palette: Palette
47 /** The context gauge drawn into the scene, as its caption names it. */
48 gauge: string
49 /** What the gauge is, for alt text (`a lantern on the gate post`). */
50 gaugeThing: string
51 /** What each active subagent appears as in the scene (`bat`), for alt text. */
52 swarmThing: string
53 /** The scene's music, a file of the mod's (`music/<scene>.mp3`); absent, the scene stays silent. */
54 music?: string
55 /** The track's title and author, as music/CREDITS.md lists them. */
56 musicTitle?: string
57 /**
58 * What fills the air the pane adds above the painting (a tall pane): night stars, a slow high mist, or warm dust
59 * motes drifting in lamplight (an interior).
60 */
61 air: 'stars' | 'mist' | 'motes'
62}
63
64export type Mode = { id: ModeId; label: string; blurb: string; scenes: readonly SceneId[] }
65
66export const MODES: readonly Mode[] = [
67 {
68 id: 'spooky',
69 label: 'Spooky',
70 blurb: 'fog, midnight and lights that should not be there',
71 scenes: ['fogbound-cemetery', 'drowned-lighthouse', 'hollow-library', 'crooked-manor', 'clocktower', 'mire-lights'],
72 },
73 {
74 id: 'cozy',
75 label: 'Cozy',
76 blurb: 'lamplight, golden hour and lofi',
77 scenes: ['cat-cafe', 'fireside-tea', 'golden-porch', 'sunset-pier', 'flower-shop', 'forest-cabin'],
78 },
79]
80
81export const SCENES: Readonly<Record<SceneId, Scene>> = {
82 'fogbound-cemetery': {
83 id: 'fogbound-cemetery',
84 mode: 'spooky',
85 air: 'mist',
86 title: 'Fogbound Cemetery',
87 motto: 'Every agent leaves a stone.',
88 palette: {
89 sky: ['#24272c', '#3a3e45', '#5a5e64'],
90 base: '#151619',
91 surface: '#1f2125',
92 ink: '#e7e4dc',
93 muted: '#9b9a95',
94 accent: '#dcb46a',
95 accent2: '#86ad92',
96 track: '#2c2e33',
97 },
98 gauge: 'LANTERN',
99 gaugeThing: 'a lantern on the gate post, its oil burning',
100 swarmThing: 'crow',
101 music: 'music/fogbound-cemetery.mp3',
102 musicTitle: 'The Past · TAD',
103 },
104 'drowned-lighthouse': {
105 id: 'drowned-lighthouse',
106 mode: 'spooky',
107 air: 'mist',
108 title: 'Drowned Lighthouse',
109 motto: 'The beam finds what the fog hides.',
110 palette: {
111 sky: ['#1d2329', '#333d46', '#525e67'],
112 base: '#121619',
113 surface: '#1b2126',
114 ink: '#e3e8eb',
115 muted: '#93a0a8',
116 accent: '#b9d6e4',
117 accent2: '#e2b45e',
118 track: '#26303a',
119 },
120 gauge: 'BEACON',
121 gaugeThing: 'the lamp room of the lighthouse',
122 swarmThing: 'gull',
123 music: 'music/drowned-lighthouse.mp3',
124 musicTitle: 'Lofi Again · omfgdude',
125 },
126 'hollow-library': {
127 id: 'hollow-library',
128 mode: 'spooky',
129 air: 'mist',
130 title: 'Hollow Library',
131 motto: 'The books remember who wrote them.',
132 palette: {
133 sky: ['#1b191a', '#2a2627', '#3b3536'],
134 base: '#141213',
135 surface: '#201d1e',
136 ink: '#ece4d8',
137 muted: '#a39888',
138 accent: '#e39b52',
139 accent2: '#9a8bc2',
140 track: '#2e292a',
141 },
142 gauge: 'CANDLE',
143 gaugeThing: 'a tall candle burning down on the reading desk',
144 swarmThing: 'floating book',
145 music: 'music/hollow-library.mp3',
146 musicTitle: 'Chill Lofi Inspired · omfgdude',
147 },
148 'crooked-manor': {
149 id: 'crooked-manor',
150 mode: 'spooky',
151 air: 'stars',
152 title: 'Crooked Manor',
153 motto: 'One window is always lit.',
154 palette: {
155 sky: ['#111216', '#1d1f26', '#33353e'],
156 base: '#0e0f12',
157 surface: '#191a1f',
158 ink: '#e5e3ea',
159 muted: '#8f8d99',
160 accent: '#e8dcae',
161 accent2: '#c7584c',
162 track: '#25262d',
163 },
164 gauge: 'MOON',
165 gaugeThing: 'the moon waxing over the manor',
166 swarmThing: 'bat',
167 music: 'music/crooked-manor.mp3',
168 musicTitle: 'Lofi Hip Hop · omfgdude',
169 },
170 clocktower: {
171 id: 'clocktower',
172 mode: 'spooky',
173 air: 'stars',
174 title: 'Clocktower at Midnight',
175 motto: 'Every turn is counted.',
176 palette: {
177 sky: ['#15171b', '#252930', '#3d424a'],
178 base: '#101114',
179 surface: '#1b1d21',
180 ink: '#e6e6e1',
181 muted: '#999b9d',
182 accent: '#7fbca6',
183 accent2: '#e3bb62',
184 track: '#292c31',
185 },
186 gauge: 'HOUR',
187 gaugeThing: 'the clock face filling like a dial',
188 swarmThing: 'raven',
189 music: 'music/clocktower.mp3',
190 musicTitle: 'Since 2 A.M. · TAD',
191 },
192 'mire-lights': {
193 id: 'mire-lights',
194 mode: 'spooky',
195 air: 'stars',
196 title: 'Mire Lights',
197 motto: 'Follow no light you did not start.',
198 palette: {
199 sky: ['#0f1311', '#1a211d', '#2b342f'],
200 base: '#0c0f0d',
201 surface: '#161b18',
202 ink: '#e1e8de',
203 muted: '#8e9a8f',
204 accent: '#c4e58f',
205 accent2: '#8fb4da',
206 track: '#212923',
207 },
208 gauge: 'WISPS',
209 gaugeThing: 'a jar of gathered will-o-wisps',
210 swarmThing: 'wisp',
211 music: 'music/mire-lights.mp3',
212 musicTitle: 'Lofi Hip Hop Loop · omfgdude',
213 },
214
215 // ── Cozy: warm, pleasant, lamplit; each scored with a lofi track ──
216
217 'cat-cafe': {
218 id: 'cat-cafe',
219 mode: 'cozy',
220 air: 'motes',
221 title: 'Rainy Cat Café',
222 motto: 'Stay for one more cup.',
223 palette: {
224 sky: ['#2b2a3d', '#4a4560', '#7a6475'],
225 base: '#1a1517',
226 surface: '#261e20',
227 ink: '#f3e7da',
228 muted: '#b3a092',
229 accent: '#f2b36b',
230 accent2: '#8fb8c9',
231 track: '#33292b',
232 },
233 gauge: 'LATTE',
234 gaugeThing: 'a latte on the window counter, filling to the brim',
235 swarmThing: 'umbrella',
236 music: 'music/cat-cafe.mp3',
237 musicTitle: 'Cat Caffe · TAD',
238 },
239 'fireside-tea': {
240 id: 'fireside-tea',
241 mode: 'cozy',
242 air: 'motes',
243 title: 'Fireside Tea',
244 motto: 'The kettle knows when to sing.',
245 palette: {
246 sky: ['#1c1420', '#2e1f2a', '#4a2d32'],
247 base: '#150f10',
248 surface: '#221819',
249 ink: '#f5e6d6',
250 muted: '#b39c8c',
251 accent: '#ff9a52',
252 accent2: '#9cc2a4',
253 track: '#2e2223',
254 },
255 gauge: 'HEARTH',
256 gaugeThing: 'the fire in the hearth, burning higher',
257 swarmThing: 'candle',
258 music: 'music/fireside-tea.mp3',
259 musicTitle: 'A Cup of Tea · TAD',
260 },
261 'golden-porch': {
262 id: 'golden-porch',
263 mode: 'cozy',
264 air: 'mist',
265 title: 'Golden Hour Porch',
266 motto: 'Nowhere to be but here.',
267 palette: {
268 sky: ['#3a2a4a', '#8a5a6a', '#e0a070'],
269 base: '#1b1412',
270 surface: '#271c18',
271 ink: '#f7ead8',
272 muted: '#bba48e',
273 accent: '#ffc46b',
274 accent2: '#d9826a',
275 track: '#33261f',
276 },
277 gauge: 'HONEY',
278 gaugeThing: 'a jar of honey on the porch rail, filling',
279 swarmThing: 'swallow',
280 music: 'music/golden-porch.mp3',
281 musicTitle: 'Countryside · TAD',
282 },
283 'sunset-pier': {
284 id: 'sunset-pier',
285 mode: 'cozy',
286 air: 'stars',
287 title: 'Sunset Pier',
288 motto: 'The tide keeps its own time.',
289 palette: {
290 sky: ['#2a2346', '#6b4466', '#e08a6e'],
291 base: '#14131c',
292 surface: '#1f1d29',
293 ink: '#f3e9e4',
294 muted: '#ab9ea6',
295 accent: '#ff9e7a',
296 accent2: '#7cc4c0',
297 track: '#2b2836',
298 },
299 gauge: 'LANTERNS',
300 gaugeThing: 'the lanterns along the pier, lit one by one',
301 swarmThing: 'sailboat',
302 music: 'music/sunset-pier.mp3',
303 musicTitle: 'Oceanside · TAD',
304 },
305 'flower-shop': {
306 id: 'flower-shop',
307 mode: 'cozy',
308 air: 'stars',
309 title: 'Twilight Flower Shop',
310 motto: 'Something is always about to bloom.',
311 palette: {
312 sky: ['#232a45', '#3d3f63', '#6c5a7a'],
313 base: '#16141a',
314 surface: '#221f27',
315 ink: '#f4e8ee',
316 muted: '#ae9fab',
317 accent: '#ffa6b8',
318 accent2: '#a8d08d',
319 track: '#2d2934',
320 },
321 gauge: 'VASE',
322 gaugeThing: 'a glass vase in the shop window, filling as the bouquet opens',
323 swarmThing: 'butterfly',
324 music: 'music/flower-shop.mp3',
325 musicTitle: 'Florist · TAD',
326 },
327 'forest-cabin': {
328 id: 'forest-cabin',
329 mode: 'cozy',
330 air: 'mist',
331 title: 'Rainy Forest Cabin',
332 motto: 'Let it rain a little longer.',
333 palette: {
334 sky: ['#1a2422', '#2c3a36', '#4a5a50'],
335 base: '#111613',
336 surface: '#1b221e',
337 ink: '#eef0e2',
338 muted: '#a3ab98',
339 accent: '#ffbf5e',
340 accent2: '#8fc7a8',
341 track: '#26302a',
342 },
343 gauge: 'RAIN BARREL',
344 gaugeThing: 'a rain barrel by the cabin door, filling',
345 swarmThing: 'firefly',
346 music: 'music/forest-cabin.mp3',
347 musicTitle: 'Rainy Forest · TAD',
348 },
349}
350
351export const SCENE_IDS = Object.keys(SCENES) as SceneId[]
352
353/** The chosen mode and the scene remembered for each mode. */
354export type Mood = { mode: ModeId; sceneByMode: Record<ModeId, SceneId> }
355
356export const DEFAULT_MOOD: Mood = {
357 mode: 'spooky',
358 sceneByMode: { spooky: 'fogbound-cemetery', cozy: 'cat-cafe' },
359}
360
361/** The modes before the two categories (0.3 and earlier): each now a part of Spooky. */
362const OLD_SPOOKY_MODES = new Set(['gray-hour', 'midnight', 'witching'])
363
364const isMode = (value: unknown): value is ModeId => MODES.some(m => m.id === value)
365const modeOf = (id: ModeId): Mode => MODES.find(m => m.id === id) ?? MODES[0]!
366
367/**
368 * A stored mood, checked: unknown modes or scenes, or a scene of another mode, fall back to the defaults. A mood stored
369 * before the two categories (Gray Hour, Midnight, Witching Hour) becomes Spooky, on the scene it was showing.
370 */
371export function cleanMood(value: unknown): Mood {
372 if (typeof value !== 'object' || value === null) return DEFAULT_MOOD
373 const raw = value as { mode?: unknown; sceneByMode?: unknown }
374 const stored = typeof raw.sceneByMode === 'object' && raw.sceneByMode !== null ? (raw.sceneByMode as Record<string, unknown>) : {}
375 const old = typeof raw.mode === 'string' && OLD_SPOOKY_MODES.has(raw.mode) ? raw.mode : undefined
376 const mode = isMode(raw.mode) ? raw.mode : old !== undefined ? 'spooky' : DEFAULT_MOOD.mode
377 const sceneByMode = { ...DEFAULT_MOOD.sceneByMode }
378 const remembered = (m: Mode): unknown => stored[m.id] ?? (m.id === 'spooky' && old !== undefined ? stored[old] : undefined)
379 for (const m of MODES) {
380 const id = remembered(m)
381 if (typeof id === 'string' && m.scenes.includes(id as SceneId)) sceneByMode[m.id] = id as SceneId
382 }
383 return { mode, sceneByMode }
384}
385
386export const currentScene = (mood: Mood): Scene => SCENES[mood.sceneByMode[mood.mode]] ?? SCENES[DEFAULT_MOOD.sceneByMode[DEFAULT_MOOD.mode]]
387
388/** The mood switched to `mode`, its remembered scene with it. */
389export const withMode = (mood: Mood, mode: string): Mood => (isMode(mode) ? { ...mood, mode } : mood)
390
391/** The mood showing `scene`: its mode chosen, and the scene remembered there. */
392export function withScene(mood: Mood, scene: string): Mood {
393 const found = SCENES[scene as SceneId]
394 if (!found) return mood
395 return { mode: found.mode, sceneByMode: { ...mood.sceneByMode, [found.mode]: found.id } }
396}
397
398export const scenesOf = (mode: ModeId): readonly Scene[] => modeOf(mode).scenes.map(id => SCENES[id])
399hooks/music.ts 202 lines1// The scenes' music: one looping track per scene, played by one chat at a time, and only by a chat whose pane is open.
2// Play/pause and the volume live in the mod's store, so every chat agrees; the chat named as owner plays and renews a
3// heartbeat, and when it closes or goes quiet another chat takes over (lib/music.ts `decide`). On Linux the player is
4// `paplay` through PulseAudio (its volume moved live with `pactl`), on macOS the engine's own `$.audio.play`.
5// Every helper that takes `$` is a function declaration (the plugin scan reads `$` only through those).
6
7import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
8
9import type { PlayerValue, SoundValue } from '../types'
10import { BEAT_MS, cleanOwner, cleanSound, decide, volumeOf } from './lib/music.ts'
11
12type $ = EngineInterface
13
14/** The store keys the music lives under (this mod's own store). */
15export const MUSIC_KEYS = { sound: 'sound', owner: 'music-owner' } as const
16/** How often a chat checks the store and its player. */
17export const MUSIC_TICK_MS = 1500
18/** The name the player's stream carries, so a volume change finds it (and never another mod's). */
19const MUSIC_CLIENT = 'swarm-autopsy-music'
20
21type Backend = 'paplay' | 'audio' | 'none'
22type Playing = { track: string; volume: number; stop: () => void }
23
24let backend: Backend = 'none'
25let backendNote: string | undefined
26let isDetected = false
27let playing: Playing | undefined
28let lastBeat = 0
29
30/** Finds this machine's audio player, once: `paplay` with a PulseAudio server, else the engine's own on macOS. */
31export async function detectAudio($: $): Promise<void> {
32 if (isDetected) return
33 isDetected = true
34 let platform = ''
35 try {
36 platform = (await $.process.run(['uname', '-s'], { timeoutMs: 4000 })).stdout.trim()
37 } catch {
38 platform = ''
39 }
40 try {
41 const info = await $.process.run(['pactl', 'info'], { timeoutMs: 4000 })
42 const version = await $.process.run(['paplay', '--version'], { timeoutMs: 4000 })
43 if (info.exitCode === 0 && version.exitCode === 0) {
44 backend = 'paplay'
45 backendNote = undefined
46 return
47 }
48 backendNote = 'no PulseAudio server answered'
49 } catch {
50 backendNote = 'paplay is not installed'
51 }
52 if (platform === 'Darwin') {
53 backend = 'audio'
54 backendNote = undefined
55 return
56 }
57 backend = 'none'
58}
59
60function startPaplay($: $, track: string, step: number): Playing {
61 const path = `${$.plugin.root}/${track}`
62 let stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | undefined
63 const state = { isStopped: false }
64 const handle: Playing = {
65 track,
66 volume: step,
67 stop: () => {
68 state.isStopped = true
69 void stream?.return(undefined as never)
70 },
71 }
72 void (async () => {
73 let failures = 0
74 while (!state.isStopped) {
75 const startedAt = await $.clock.now()
76 // A stop that lands while the clock is read must not start a player nothing can stop.
77 if (state.isStopped) break
78 // Each pass plays the track once at the volume the handle holds now; the loop repeats it.
79 stream = $.process.spawn({
80 argv: ['paplay', `--volume=${Math.round(65536 * volumeOf(handle.volume))}`, `--client-name=${MUSIC_CLIENT}`, '--stream-name=swarm-autopsy', path],
81 })
82 try {
83 for await (const _piece of stream) {
84 // paplay writes nothing while it plays; the loop is its life.
85 }
86 } catch (error) {
87 backendNote = error instanceof Error ? error.message : String(error)
88 }
89 if (state.isStopped) break
90 failures = (await $.clock.now()) - startedAt < 2000 ? failures + 1 : 0
91 if (failures >= 3) {
92 backendNote = `paplay could not play ${track}`
93 backend = 'none'
94 break
95 }
96 }
97 })().catch(() => {
98 // the chat or the module ended under the loop: nothing is left to play for
99 })
100 return handle
101}
102
103function startAudio($: $, track: string, step: number): Playing {
104 const controller = new AbortController()
105 void $.audio.play({ asset: track }, { shouldLoop: true, gain: volumeOf(step) * 1.4, signal: controller.signal }).catch(error => {
106 backendNote = error instanceof Error ? error.message : String(error)
107 })
108 return { track, volume: step, stop: () => controller.abort() }
109}
110
111function startTrack($: $, track: string, step: number): Playing {
112 return backend === 'paplay' ? startPaplay($, track, step) : startAudio($, track, step)
113}
114
115/** Moves the playing stream to `step` without restarting it; restarts it when that fails. */
116async function changeVolume($: $, step: number): Promise<void> {
117 if (!playing) return
118 if (backend === 'paplay') {
119 try {
120 const listed = await $.process.run(['pactl', '-f', 'json', 'list', 'sink-inputs'], { timeoutMs: 4000 })
121 const inputs = JSON.parse(listed.stdout) as { index: number; properties?: Record<string, string> }[]
122 const ours = inputs.filter(input => input.properties?.['application.name'] === MUSIC_CLIENT)
123 if (ours.length > 0) {
124 for (const input of ours) {
125 await $.process.run(['pactl', 'set-sink-input-volume', String(input.index), `${Math.round(volumeOf(step) * 100)}%`], { timeoutMs: 4000 })
126 }
127 playing.volume = step
128 return
129 }
130 } catch {
131 // fall through to a restart
132 }
133 }
134 const track = playing.track
135 playing.stop()
136 playing = startTrack($, track, step)
137}
138
139function ensureStopped(): void {
140 playing?.stop()
141 playing = undefined
142}
143
144/**
145 * One look at the store and the player: plays the scene's `track` here when this chat owns the music and its pane is
146 * open (`isListening`), stops it when not, keeps the heartbeat. Resolves what the toolbar shows: the shared sound and
147 * where this chat stands.
148 */
149export async function musicTick(
150 $: $,
151 me: string,
152 track: string | undefined,
153 isListening: boolean,
154): Promise<{ sound: SoundValue; player: PlayerValue }> {
155 const sound = cleanSound(await $.store.get(MUSIC_KEYS.sound))
156 const owner = cleanOwner(await $.store.get(MUSIC_KEYS.owner))
157 const now = await $.clock.now()
158 const decision = decide({ me, now, sound, owner, hasTrack: track !== undefined, canPlay: backend !== 'none', isListening })
159 if (decision.owner === 'claim' || (decision.owner === 'beat' && now - lastBeat >= BEAT_MS)) {
160 lastBeat = now
161 await $.store.set(MUSIC_KEYS.owner, { session: me, beatAt: now })
162 } else if (decision.owner === 'release') {
163 await $.store.delete(MUSIC_KEYS.owner)
164 }
165 if (decision.playHere && track) {
166 if (playing?.track !== track) {
167 playing?.stop()
168 playing = startTrack($, track, sound.volume)
169 } else if (playing.volume !== sound.volume) {
170 await changeVolume($, sound.volume)
171 }
172 } else {
173 ensureStopped()
174 }
175 const note = decision.where === 'unavailable' ? (backendNote ?? 'no audio player on this machine') : undefined
176 return { sound, player: note ? { where: decision.where, note } : { where: decision.where } }
177}
178
179/** Play or pause for every chat; the chat that pressed play takes the music. */
180export async function toggleMusic($: $, me: string): Promise<void> {
181 const sound = cleanSound(await $.store.get(MUSIC_KEYS.sound))
182 const next = { ...sound, isPlaying: !sound.isPlaying }
183 if (next.isPlaying) {
184 lastBeat = await $.clock.now()
185 await $.store.set(MUSIC_KEYS.owner, { session: me, beatAt: lastBeat })
186 }
187 await $.store.set(MUSIC_KEYS.sound, next)
188}
189
190/** The shared volume step, 1–5. */
191export async function setMusicVolume($: $, step: number): Promise<void> {
192 const sound = cleanSound(await $.store.get(MUSIC_KEYS.sound))
193 await $.store.set(MUSIC_KEYS.sound, { ...sound, volume: Math.min(5, Math.max(1, Math.round(step))) })
194}
195
196/** The chat is closing: stop the music here and hand it on. */
197export async function stopMusic($: $, me: string): Promise<void> {
198 ensureStopped()
199 const owner = cleanOwner(await $.store.get(MUSIC_KEYS.owner))
200 if (owner?.session === me) await $.store.delete(MUSIC_KEYS.owner)
201}
202