SLOPSHOPPER

swarm-autopsy

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…

newpaneguardcommandprocesstimer
v1.0.1no licenseupdated 2026-10-20agryu-in/swarm-autopsy-claude-mod/swarm-autopsy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swarm-autopsy
│ ┃ Swarm Autopsy ✕ › fix the failing auth test and add an audit log call │ ┃ ◢ Swarm Autopsy [ ⏸ pause ] │ ┃ ● ctx 49% · ◐ 0 active of 0 agents · ● 5 sig ⏺ Read(src/auth.ts) │ ┃ [ 1 Live ] [ 2 Trail ] [ 3 Collisions ] [ 4 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────╮ ╭─────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ tokens processed · ses… │ │ main context ⏺ Bash(bun test) │ ┃ │ ∅ none recorded │ │ ● 49% ⎿ 3 pass, 1 fail │ ┃ │ no model request heard │ │ ██████▍······ │ ┃ │ yet │ │ 97k of 200k to ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────╯ ╰─────────────── │ ┃ ● ◆ ◇ ? ∅ evidence · ● ≈ ∅ numbers ✻ Worked for 42s · done 4:20 PM │ ┃ dc-legend │ ┃ › /swarm-autopsy │ ┃ ╭─────────────────────────────────────────── ⎿ swarm-autopsy: Swarm Autopsy opened. 0 subagents observed (0 act │ ┃ │ card-main │ ┃ │ cardrow-main-2 │ ┃ │ cardrow-main-3 │ ┃ │ cardrow-main-4 │ ┃ │ cardrow-main-7 │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ No subagents observed in this session yet. │ ┃ The collector is listening. When the session │ ┃ work through the Agent tool, each agent appe │ ┃ with its task, context, tokens and report. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swarm Autopsy
◢ Swarm Autopsy [ ⏸ pause ] [ ⇔ wider ] ● ctx 49% · ◐ 0 active of 0 agents · ● 5 signals · ? 4 … [ 1 Live ] [ 2 Trail ] [ 3 Collisions ] [ 4 Forensics ] [ 5 ╭─────────────────────────╮ ╭─────────────────────────╮ ╭─── │ tokens processed · ses… │ │ main context │ │ su │ ∅ none recorded │ │ ● 49% │ │ ● │ no model request heard │ │ ██████▍······ │ │ no │ yet │ │ 97k of 200k tokens │ ╰─── ╰─────────────────────────╯ ╰─────────────────────────╯ ● ◆ ◇ ? ∅ evidence · ● ≈ ∅ numbers dc-legend ╭──────────────────────────────────────────────────────╮ │ card-main │ │ cardrow-main-2 │ │ cardrow-main-3 │ │ cardrow-main-4 │ │ cardrow-main-7 │ ╰──────────────────────────────────────────────────────╯ No subagents observed in this session yet. The collector is listening. When the session delegates work through the Agent tool, each agent appears here with its task, context, tokens and report. ╭──────────────────────────────────────────────────────╮ │ ▌ Health · 5 of 5 signals from existing analyses │ │ ? unresolved · Unverified change · ◎ main │ │ ? unresolved · The latest captured run of … · ◎ main │ │ ● observed · 1 turn reported by turn.compl… · ◎ main │ │ [ all 5 in Health ] │ ╰──────────────────────────────────────────────────────╯ ──────────────────────────────────────────────────────── dc-instruments
README

Swarm Autopsy · Claude Mod

version 1.0.1 564 tests 0 dependencies 0 model calls 0 network calls

A live forensic pane for multi-agent Claude Code sessions. It shows:

  • what each subagent was asked to do;
  • what it observably did, and what its tokens were;
  • how its report connects to the edits and tests that followed;
  • where agents collided, and what is still unresolved.

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:

  • Spooky: dark gray and a little haunted;
  • Cozy: warm lamplight and golden hour.

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.

Swarm Autopsy in the Claude desktop app, over the Cozy scene Twilight Flower Shop: the toolbar, four summary cards, the coordinator, five subagents' cards, recent activity and health

<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%">

Why Swarm Autopsy

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 transcriptWith Swarm Autopsy
A subagent's workWhat it reported backEvery 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 accountThe check's own exit status, read step by step
Two agents on one filePieced together by handSeven collision signals, decided by event order
Was a report used?Not shownA verbatim match or a shared path, labelled by how sure it is

Highlights:

  • Deterministic. Fixed rules over ids, paths and event order: no model calls, no network.
  • Every claim labelled: ● OBSERVED, ◆ LINKED, ◇ INFERRED, ? UNRESOLVED or ∅ UNAVAILABLE. The labels →
  • | tail and || true never count as a pass. A test passes only when the check itself set the exit status. Test verdicts →
  • Seven collision signals, across agents and worktrees. Collisions →
  • A per-request token ledger: each agent's own requests (direct) and its whole spawn tree (subtree), in four categories. Spend →
  • Read-only and scriptable. Results pass through unchanged, and the whole session exports as JSON. Export →

At a glance:

  • Runs in: Claude Code 2.1.287 or later, in the terminal and the desktop app (requirements).
  • Records: live, from the moment the session loads the mod. It can't replay a session that has already happened.
  • Footprint:
  • TypeScript source, loaded through one entry module (hooks/register.tsx);
  • twelve MP3 tracks, about 20 MB;
  • no dependencies and no build step.
  • Licence: no open-source licence. It is free to install and use; ask before reusing the code (details).

Contents:

Install

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:

  1. Click + next to the prompt box, then Plugins.
  2. Choose Add marketplace and enter agryu-in/swarm-autopsy-claude-mod.
  3. Choose Add plugin, then swarm-autopsy.

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:

  • Claude Code with mods (function hooks): 2.1.287 or later. Tested with Claude Code 2.1.303 (CLI) and 2.1.299 (the desktop app's engine). The mod API is early access and may change between releases.
  • For the music: macOS, or Linux with PulseAudio or PipeWire (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.

Use it

CommandWhat it does
/swarm-autopsyOpens the pane: docked beside the transcript, or inline above the prompt in a non-fullscreen terminal
/swarm-autopsy liveOpens it on a view; also trail, collisions, forensics, spend or health
/swarm-autopsy exportWrites the collected session to a JSON file under the session's root and says where
/swarm-autopsy closeCloses 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.

Try it in a minute

  1. Install it (above), then open Claude Code in a project that has tests.
  2. Run /swarm-autopsy. The mod records from the moment the session loads it, so the pane can be opened at any time.
  3. Give the session work that splits across subagents, for example:
   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.
  1. Watch Live while they run.
  2. Then open:
  3. Trail: did a check pass after each file's last edit?
  4. Collisions: did the agents touch the same files?
  5. Health: what is still open?
  6. Run /swarm-autopsy export to get the whole session as one JSON file.

The views

ViewShows
LiveThe 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.
TrailPer 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).
CollisionsA 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.
ForensicsEvery 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.
SpendThe 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.
HealthThe 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.

Reading the evidence labels

LabelGlyphMeaning
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:

  • OBSERVED ●: read from an event.
  • ESTIMATED ≈: computed from a stated assumption, such as a ≈$ figure, or a subagent's % on the same model as the main session.
  • UNAVAILABLE ∅: not exposed or not yet captured.

Features in depth

Each view below gives:

  1. what it is for;
  2. its main rule;
  3. the rest of its details, folded (click to open).
You want to knowViewDecided by
Who spawned which agent, and what was it asked?LiveThe spawn record
Did anything check this file after its last edit?TrailA linked check after the file's last write
Did that test really pass?Test verdictsThe check's own exit status
Did two agents edit one file, or overwrite each other?CollisionsThe writers, in event order
Was a subagent's report used?TrailVerbatim text or a shared path
Which agent used the most tokens, on which model?SpendEach request's own usage
What share of the tokens went to verified work?SpendOne outcome bucket per agent
How close is each agent to its context limit?LiveThe main session's reading, or a labelled estimate
What should I look at first?HealthEvery finding above, with its evidence

Live

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:

AgentHow it is measuredLabel
Main sessionClaude Code's own session.measure reading● OBSERVED
Subagent on the main session's modelIts latest prompt ÷ the main session's window≈ ESTIMATED
Subagent on another modelIts 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 yetNot shown∅ UNAVAILABLE

Claude Code does not expose a subagent's window to mods, which is why a subagent's figure is always an estimate.

  • Order. The coordinator first, then each subagent in order of first sight.
  • Cards.
  • The first 4 to 12 subagents get full cards; the pane's height sets how many (rows ÷ 8). The rest get one line each.
  • The list stops at 40 agents, with "N more agents".
  • The terminal's compact pane (below 56 columns) draws one line per agent.
  • Filters: all, active, completed, failed.
  • State.
  • From how the agent's turn ended: an answer is completed, aborted is stopped, and any other end is failed.
  • Or from SubagentStop, or from Claude Code's agent list.
  • The task is the spawn's description, quoted. It is never invented: missing, it reads task description unavailable.
  • The inspector. Press any line of a card to open it: the delegated prompt, files, report, context history, token ledger, outcome and events.
  • Medium pane: under the card.
  • Wide pane: in a detail column.
  • Compact pane: instead of the list.
  • From 140 columns: three columns (cards, details, and activity with Health).
  • Stable while you read. A press holds the drawing's data for 20 s, so lines don't move under the pointer. ⏸ pause holds it until you resume.

Trail

From delegation to verification: did each agent's work lead anywhere, and was it checked?

The Trail view over Twilight Flower Shop: the outcome so far (5 subagents, 0 files changed), two verification commands whose latest runs were inconclusive, with the reason, spend by outcome, and the contribution flow with its lanes

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:

StageHolds
1. delegationThe agent's prompt
2. investigationIts reads, searches and read-only commands
3. findingIts report
4. changeEach file it wrote successfully
5. verificationIts 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:

EdgeDrawn whenLabel
parent → delegationThe 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 → parentThe report came back to the agent's parent◆ LINKED
report → a later delegation12 or more words of the report appear verbatim in the later prompt◆ LINKED (7 to 11 words: ◇ INFERRED)
report → a later delegationThe report and the later prompt name the same path◇ INFERRED
report → another agent's later changeThe 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 changeIts prompt names the file it then edited◆ LINKED by path, ◇ INFERRED by file name
change → verificationA 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):

  • An unlinked contribution: a report that reached nothing captured, from an agent with no change or check of its own.
  • An unverified change: no linked check after the file's last write.
  • A failing or inconclusive check: a command whose latest run failed or was inconclusive.
  • Verbatim text is matched as word 7-grams. A match needs at least 7 words and 40 characters, and the longest run wins. It runs on the kept text: the first 2,000 characters of each report and prompt.
  • A briefing the coordinator gave both agents doesn't count as forwarding: text already in the author's own prompt is ignored.
  • Path mentions in reports and prompts are read with a pattern: directories, then a name with a 1–8 character extension.
  • A check names a file by its path, its file name, or its stem as a whole word. The stem must be 4 or more characters, and not a generic one like index, main or utils.
  • Checks. Only the latest passed or failed run of each command counts, and at most 3 checks per change. A check from before the last write is kept, but marked as not covering the final version.
  • Order alone never links anything.

Test verdicts

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:

CommandExitVerdictBasis, as the pane shows it
npx vitest run0passedthe tool reported success (no error result)
npx vitest run1failedthe tool reported an error: exit code 1
cd api && pytest -q0passedthe tool reported success (no error result)
`pytest -q \tail -5`0inconclusivethe command exited 0, but its exit status comes from a later step (tail -5), not from the check itself
`npm test \\true`0inconclusivethe command exited 0, but its exit status comes from a later step (true), not from the check itself
`npm run build \\npm test`0inconclusivethe command exited 0, but the check runs only when an earlier step fails
npm test &0inconclusivethe command exited 0, but the check was sent to the background with &
  • A leading cd. Trail and Collisions group cd api && pytest -q under the api directory.
  • Never passed:
  • refused, interrupted, timed-out and backgrounded runs;
  • a non-zero exit that the tool read as success.

These are inconclusive, and never failed either.

  • What verifies a change. Only passed and failed runs are linked to a change, and only a pass verifies it.
KindHeads (an excerpt; also via npx, pnpm exec, bunx, yarn)
Testspytest, 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
Checkstsc, eslint, ruff, mypy, pyright, npm run lint / build / typecheck, cargo check / clippy, go vet, claude plugin validate

The full list is in the reference.

Collisions

Where agents got in each other's way.

The Collisions view in a session where no agent wrote a file: it says why the matrix is empty and that no collision signals were raised

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.

SignalRaised whenLabelSeverity
Shared write2 or more agents wrote the file. Interleaved when the order returns to an agent (A → B → A)● OBSERVEDinvestigate if interleaved, else notice
OverwriteA later write replaced text a different agent wrote: a whole-file Write, or 12 or more characters of the same text● OBSERVED / ◆ LINKEDinvestigate
Stale writeAn agent wrote a file it had read, after another agent wrote it in between◇ INFERREDinvestigate
Worktree divergenceThe same relative path was written in 2 worktrees of one repository● OBSERVEDnotice
Test during changeAnother agent's write landed while a test or check was running◇ INFERREDinvestigate
Repeated failureA command errored twice or more in one directory, or 2 writes to one path errored● OBSERVEDinvestigate
Contradictory resultsThe same command in the same directory both passed and failed● OBSERVEDinvestigate 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
  • Cells:
  • 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.
  • Caps. The matrix draws up to 24 files, most writers first, and counts the rest. On a narrow pane some agent columns are left out, with a count.
  • Worktree roots. They come from 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.

Forensics

Every event, in order, with the evidence behind it.

The Forensics view: 96 events on page 1 of 3, chips to filter by kind (all, agents, files, tests, failures, signals) and by agent, and each event's number, time, agent, summary and result

Use it to trace any claim in another view back to the exact events it rests on.

The selected event shows:

  • its source, for example tool.call: tool_use_id …, agentId …; result captured;
  • its paths and access;
  • the command and its kind;
  • the diff excerpt;
  • the result and the verdict's basis;
  • the signals it is par
Source 76 files
hooks/register.tsx 736 lines
1// 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}
736
hooks/lib/clarity.ts 18 lines
1// 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)
18
hooks/lib/classify.ts 251 lines
1// 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}
251
hooks/lib/collisions.ts 344 lines
1// 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}
344
hooks/lib/collect.ts 119 lines
1// 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}
119
hooks/lib/format.ts 170 lines
1// 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}
170
hooks/lib/ledger.ts 449 lines
1// 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}
449
hooks/lib/outcome.ts 576 lines
1// 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}
576
hooks/lib/pricing.ts 321 lines
1// 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}
321
hooks/lib/reduce.ts 603 lines
1// 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}
603
hooks/lib/scenes.ts 399 lines
1// 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])
399
hooks/music.ts 202 lines
1// 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