Reports this Claude Code session (model, effort, plan, agents, context, cost, git, failures) to a local Podium fleet console.

<img src="docs/images/podium-logo.png" alt="Podium" width="480">
A Claude Code session is waiting on you...
Run a few sessions at once and it happens fast. One is stopped at a permission prompt. One has filled its context window. A subagent failed ten minutes ago and nothing told you. The only way to find out is to click through every terminal, and that takes more effort than the work itself.
Podium is a single pane of glass for Claude Code. Whether it's just you or your whole team, every session shows up in one browser tab, so you can see what all of them are doing at a glance. Each session appears within about two seconds of its first prompt and leaves when it ends, with no refresh.
There are other tools like this. Podium started as something I built for myself, because I run several Claude Code sessions at once and was tired of clicking through terminals to find the one that needed me. It turned out to be useful enough to share. I haven't compared it with every similar tool, so if another one fits how you work better, use that one. If you try Podium and something is missing, I'd like to hear about it.

For every session you see:
Podium watches. The one thing it can send is an answer to a session that waits on you (a question, a plan, a tool's permission prompt, a message, /clear or /compact), and only once the server's Admin and the session's owner have both turned that on. It never types into a terminal.
Run it for yourself on one machine, or point a whole team's sessions at one server and the console groups the wall by person. The Claude Code mod, podium-reporter, sends the data.
New here? docs/USER_GUIDE.md walks through setup and reading the dashboard.
Cost in Podium is an estimate from token counts and a price table. The table starts as Anthropic's list prices and you can change any price in Admin > Model prices. It is never a bill.
Requires Node 24 and npm.
npm install
npm run dev # server on http://127.0.0.1:4317, Vite UI on http://localhost:5173
Open http://localhost:5173/#/. With nothing reporting yet, the wall says how to start a session.
To see it with simulated sessions instead of real ones, in a second terminal:
npm run podium:sim -- --sessions 5 --chaos
--chaos adds sessions that go silent, more failures and more context blowouts. --help lists the other options (--url, --speed, --seed, --duration, --agents, --token, --person, --unnamed, --print). Stop it with Ctrl-C; it ends every session it started, and they leave the dashboard a few seconds later. If the server has a token (podiumctl start sets one), pass it: --token "$(podiumctl token)".
To see the people view, give the simulator people with a token each from Admin: --person "Ed E.=<Ed's token>" --person "Priya N.=<Priya's token>" --unnamed. Sessions go to the people in turn, each on a laptop and every third one on a build box; --unnamed keeps a share of sessions with no name, which show under Admin when they send the admin secret with --token.
To serve the built UI and the API without a container, npm run build and then npm run start -w server (http://127.0.0.1:4317). podiumctl, below, does the same thing and manages it for you.
The more sessions and agents report, the more Podium needs. Every agent adds data that the server stores and sends to the browser, and the browser draws it, so the work grows with the amount of data consumed.
As one example, Chrome on an M5 MacBook Pro used about 24% GPU with ten simulated sessions and a hundred agents on screen, against about 15% for a plain page (#81). That is one machine and one browser, not a promise. To measure your own, open a tab and run scripts/gpu-sample.sh (macOS; --help lists its options).
To lower it, keep fewer sessions on screen, leave the tab hidden (animations pause while it is), use wall mode (w), which hides the side panes and top bar so the browser stops drawing them, and close the dashboard when nobody is looking.
bin/podiumctl starts, stops and watches the server without a dev setup. It runs Podium either in a Podman container (the default when Podman is installed) or as a standalone Node process on the host, and has no dependencies of its own. Install it on your PATH from the repo with npm link, or run bin/podiumctl <command> (no node in front) or npm run podiumctl --.
git clone https://github.com/eengelking/podium.git && cd podium && npm link
podiumctl start # builds the image on first use, then runs it on http://127.0.0.1:4317
podiumctl status # mode, state, health, URL, boot setting (exit 3 when stopped)
podiumctl logs -f # follow the log
podiumctl restart # after changing the config
podiumctl stop
podiumctl enable # start on boot / login (launchd on macOS, systemd user unit on Linux)
podiumctl disable
podiumctl start --mode standalone # no container: needs `podiumctl build --mode standalone` once
podiumctl rm --purge --yes # remove the container, its data volume and the image
--mode, --port and --bind are saved, so later commands reuse them. Container mode keeps the database in the Podman volume podium-data; standalone keeps it in ~/.podium/data/. By default, container mode publishes the port on 127.0.0.1 only; --bind sets the address it publishes on.
Auth token. The first start generates a random token and saves it as PODIUM_TOKEN in ~/.podium/config.env (mode 0600). Find it any time with podiumctl token; podiumctl token --rotate makes a new one. This is the admin secret: it opens the Admin section (below) and, on a one-person setup, you can also enter it in the mod's token option (see "Report a real session") and in the X-Podium-Token header of the settings hooks below. To run without auth, podiumctl config set PODIUM_TOKEN= (empty on purpose). Admin is switched off then, because nothing would protect it.
Admin and per-person tokens. When several people report to one Podium, open Admin (#/admin, or the button in the top bar's upper right once you are signed in to the console as an Administrator) and sign in with the name Admin and the admin secret. Create a user and Podium makes a token for them, shown once with the settings to paste: their name and the token. A token only works with the name it was made for, so a leaked token alone cannot claim someone else's name. From Admin you can also rotate or revoke a token, put people in groups (a label for arranging the console, nothing more), turn off reporting with the admin secret, and read the admin log. Tokens are stored hashed, so Podium cannot show one again. Reporting with the admin secret keeps working, and those sessions show under the owner Admin. Admin locks after 10 idle minutes, and five wrong names or tokens lock it for 15 minutes. Tokens travel in a header as they do today, so a server reachable over a network needs a trusted network or a TLS proxy.
Several people on one Podium. The server listens on 127.0.0.1 by default, so only your own machine can reach it. To let other people report to it:
podiumctl restart --bind 0.0.0.0 (all interfaces), or the one interface they use. The container then publishes the port on that address too. Allow the port (4317 by default) in the firewall for the people who need it and no one else. The server refuses a request whose Host is a name it does not expect (see "A page on another site" below). With 0.0.0.0 an address typed in the bar (http://192.168.1.5:4317) works as is. To reach it by a name, list the name: podiumctl config set PODIUM_ALLOWED_HOSTS=box.lan and podiumctl restart.serverUrl to http://<your host>:4317, token to their token and userName to the name you gave them (see "Report a real session").A page on another site. A web page can point its own name at 127.0.0.1 after it has loaded (DNS rebinding) and then read the console's open routes as if they were its own. So the server answers 403 to any request whose Host is not 127.0.0.1, localhost, [::1], the --bind address or a name in PODIUM_ALLOWED_HOSTS (comma-separated; the port is not compared). A server bound to 0.0.0.0 or :: also takes any IP address, which a page cannot rebind. The refusal is logged as http.host_refused with the host and nothing else.
Know what that costs. Reading the console needs no token, so anyone who can reach the port sees every session: names, machines, paths, branches, plan steps, commands from failed calls and the excerpts listed under "What leaves the session". Tokens and reports cross the network in plain HTTP, so use a network you trust or put a TLS proxy in front. Anyone with a valid token can delete any session. The name shown on a session comes from the token that sent it: with the shared admin secret every session shows as Admin, and with the server's auth turned off every session is Unassigned, because then anyone could claim any name. Per-person read permissions are not part of Podium.
Other settings (STALE_AFTER_MS, RETAIN_*, the PODIUM_LOG_* logging settings, and so on, see the table below) are set with podiumctl config set KEY=VALUE and applied on restart. "Logs" in the user guide covers what the server logs, sending it to VictoriaLogs, and pointing an agent at it. Set PODIUMCTL_HOME to keep the files somewhere other than ~/.podium. podiumctl --help lists everything.
Start on boot: in container mode the boot service runs podiumctl start, which also starts the Podman machine on macOS; in standalone mode it runs the server in the foreground and restarts it if it crashes. On Linux, loginctl enable-linger $USER makes the user unit start before you log in. (The Linux unit has run on Bazzite, but not yet through a reboot.)
The reporter is a Claude Code mod in mod/podium-reporter/. Install it from the eengelking marketplace, in Claude Code:
/plugin marketplace add eengelking/skills
/plugin install podium-reporter@eengelking-marketplace
If the server uses a token (podiumctl start generates one), enter it with /plugin configure podium-reporter@eengelking-marketplace. On your own machine that is the admin secret (podiumctl token). If someone runs the server for a group, they make you a token in Admin: enter it as token, and your name as userName. The token option is secret, so it has no row in the /config menu. Without a token the server accepts, or with a token that does not belong to your name, the reporter gets HTTP 401, logs it once, and tries again about once a minute. /podium and the status line say so.
To work on the mod itself, load it from a checkout for one session instead:
claude --plugin-dir /path/to/podium/mod/podium-reporter
The session appears in Podium once you send its first prompt. A session that has done nothing yet is kept off the wall, because Claude Code's background daemon also runs idle twins and spares that report too (see "Sessions that have not done anything yet" in docs/USER_GUIDE.md). In the session, /podium prints the dashboard link, the connection state and report counts, and /podium name <text> gives the session a display name. The status line under the prompt reads podium vX.Y.Z ● N live (N is the cards on the wall, so a session that has not run yet reads 0), or podium vX.Y.Z ○ offline while the server is unreachable (reports queue up and go out when it is back). When the mod and the server are on versions that do not match, it ends in · update mod or · upgrade server, and in · update available when the server supports a newer mod than yours. To update the mod, run claude plugin update podium-reporter@eengelking-marketplace and then /reload-plugins in the session (see "Updating the mod" in docs/USER_GUIDE.md). Claude Code puts a ⚠ and the mod's name in front of the line; that is how it draws every mod's status line, not a Podium warning (see docs/GOTCHAS.md). Effort is read from each model request, so a change made with /effort shows after the session's next prompt, not when you pick it.
Set the mod's options with /plugin configure podium-reporter@eengelking-marketplace, or in ~/.claude/settings.json under pluginConfigs, podium-reporter@eengelking-marketplace, options (podium-reporter for a copy loaded with --plugin-dir). Claude Code keeps token in its secure storage instead. Everything except token also has a row in the /config menu.
| Option | Default | Meaning |
|---|---|---|
serverUrl | http://127.0.0.1:4317 | Where reports go |
token | empty | Sent as X-Podium-Token: a per-person token from Admin, or the admin secret (PODIUM_TOKEN) on a one-person setup |
userName | empty | The name your token was made for. The server accepts a report only when the token and this name match. Leave empty when you report with the admin secret. Nothing that names you or your machine is sent until you set it; with it set, reports also carry this machine's name (see below) |
flushIntervalMs | 1000 | Minimum time between reports while something changes |
heartbeatMs | 5000 | Report at least this often |
sessionName | empty | Display name override |
showStatus | true | Show the status line entry |
fullPrompts | false | Send each agent's whole task prompt and final answer (up to 20,000 characters each, secrets redacted) so the console can open them in a modal. Off by default: only the first 400 characters leave the session. Everyone who can open the console can read them |
activityFeed | false | Send an agent's recent activity: while someone has that agent's panel open in the console, its last 20 tool calls (the tool's name and a short summary of its arguments) and the text it writes between them, each cut to 300 characters with secrets redacted. Nothing is sent for an agent nobody is watching, and the server keeps it in memory only. The orchestrator's own loop works the same way. Everyone who can open the console can watch. The server's Admin can also switch the feed off for everyone under Admin > Settings > Feature flags (on by default); while it is off the server asks no mod for it |
respond | off | Respond from Podium: what this session accepts from Podium, sent by its owner or an Admin. off (nothing), questions (answers to questions, and new messages while idle), questions-and-plans (the above, plus approving or rejecting a plan), everything (the above, plus allowing or denying a tool, allow once only, and /clear, /compact). It needs activityFeed on too, and the server's Respond from Podium switch in Admin > Settings > Feature flags (off by default) |
explainFailures | false | Ask a small model (Haiku, low effort) for a two-sentence explanation of each failed agent, from the redacted failure record only. Costs a little; off by default |
Only to the server URL above, and only this:
fullPrompts;activityFeed is on, and only when you have turned it on, the server's Admin has not turned the feed off, and someone is watching an agent's (or the orchestrator's) panel right now: that loop's last 20 tool calls, each the tool's name and a short summary of its arguments (a Bash command, a file path, a search pattern), and the text the model wrote between them, each cut to 300 characters. The model's thinking is never sent. When nobody is watching, nothing of it is read or sent, and the server forgets it soon after the panel closes;respond (and activityFeed), and the server takes answers: what the session accepts, and while it waits on you, the open question (its questions, options and previews), plan (its text and file path) or tool call (the tool and its arguments), each cut to fixed limits and redacted, so its owner can answer from the console. It is shown only to the session's owner or an Admin, and the server keeps it in memory only. After a /clear, the new session also names the one it continues, so History can link them;userName: that name, and which machine the session runs on: its short host name (what hostname prints, up to the first dot) and a random 12-character id the mod makes the first time and keeps in one file (~/.claude/podium-machine-id, or under CLAUDE_CONFIG_DIR when that is set), so two machines with the same host name stay apart and every copy of the mod on a machine reports the same id. The id is random, not read from the hardware, and the operating system user name and git config are never read.Before anything is sent, text is cut to those limits and anything that looks like a secret is replaced with [redacted]: sk-ant-... keys, ghp_... tokens, AKIA... keys, Bearer ... values, and the values after password=, token=, secret=, apikey= and api_key=. Podium never reads transcripts or files, sends a whole prompt or answer only for fullPrompts, and sends what an agent is doing only for activityFeed, while someone watches.
Claude Code's settings hooks can report a reduced view of sessions that were started without the mod: the session, its working directory and model, agents by type, and waiting or ended states. They carry no token counts, so context, cost and the plan stay empty, and Podium marks those sessions "settings hooks". Add this to ~/.claude/settings.json (or a project's .claude/settings.json):
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"SubagentStart": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }]
}
}
Each hook posts the payload Claude Code gives it on stdin and always exits 0, so a stopped server never gets in the session's way. When the server sets PODIUM_TOKEN, add -H 'X-Podium-Token: <token>'.
A session that the mod reports is never changed by hooks: the server acknowledges a hook for it and drops it, so enabling both is harmless. Settings hooks send no heartbeat, so a hook-only session is allowed to stay silent for HOOK_STALE_AFTER_MS (10 minutes) before it shows as stale. A finishing subagent does not mark the session as waiting on you; the Stop hook does, unless it lists background work or a scheduled wake-up still in flight, in which case the session shows what it is waiting on instead (Podium reads the hook's background_tasks and session_crons, never a shell's command).
Environment variables (see .env.example). Under podiumctl you set them with podiumctl config set, and it chooses PORT, HOST and PODIUM_DB itself.
| Variable | Default | Meaning |
|---|---|---|
PORT | 4317 | Server port (the Vite proxy follows it) |
HOST | 127.0.0.1 | Bind address. Podium is for one machine; leave it on localhost |
PODIUM_DB | data/podium.sqlite | SQLite database file (podiumctl uses /data/podium.sqlite in the container and ~/.podium/data/podium.sqlite standalone) |
PODIUM_HOST_HOME | the server's own home | Home directory to show as ~ in session paths. podiumctl sets it to your home in the container, where the server's own home is /home/node. Ignored unless it is an absolute path |
PODIUM_TOKEN | empty | The admin secret. When set, POST /api/fleet/report, POST /api/fleet/hook and DELETE require X-Podium-Token (this or a person's token), and it opens Admin |
TZ | the server's own zone | Timezone whose midnight starts "today" for "Est. cost today". podiumctl gives the container your host's zone; set it with podiumctl config set TZ=Europe/Berlin to override |
STALE_AFTER_MS | 15000 | A session not heard from this long shows as stale |
| `H
hooks/register.ts 1425 lines1// Podium reporter: hook wiring only. Every hook observes: it calls next(e) once and returns what that
2// resolved to, unchanged; its own work is wrapped so a failure is recorded and never reaches the
3// session; and no hook on the tool path awaits the network (reports go out from a timer).
4//
5// The engine reads this file's use of `$` statically: `$` is only ever passed to functions declared
6// at the top of the file and always spelled `$.noun.event(...)`. So every helper that touches `$` is
7// a top-level function taking `$`, and the runtime lives in module variables (a hot reload re-runs
8// register, which resets them, and session.start rebuilds the state from the engine).
9import type { EngineInterface, Register } from 'claude-code';
10import { lastSegment, readChanges, readGit } from './git.ts';
11import { shortName } from './models.ts';
12import { buildReport, repoFromRemote } from './report.ts';
13import {
14 addEvent,
15 agentRequested,
16 agentSpawned,
17 compaction,
18 drainEvents,
19 measure,
20 newSession,
21 notification,
22 permissionRequest,
23 PLAN_TOOL,
24 reconcile,
25 resetForNewId,
26 describeBackground,
27 setBackground,
28 sessionEnd,
29 NO_FILES,
30 setAttached,
31 setFiles,
32 setGit,
33 setExplanation,
34 markTextSent,
35 type SentTexts,
36 setIdentity,
37 addActivity,
38 isWatched,
39 setWatched,
40 skillLoaded,
41 step,
42 toolResult,
43 toolStarted,
44 turnComplete,
45 turnStart,
46 type ListedAgent,
47 type SessionState,
48} from './state.ts';
49import { nextCronAt } from './cron.ts';
50import { isDelivery, Transport, type FetchLike } from './transport.ts';
51import { activityCall, activityText, explainPrompt } from './failures.ts';
52import { pickIdentity, worthHolding, type Identity } from './identity.ts';
53import { hostOf, isMachineId, MACHINE_ID_KEY, machineIdPath, machineInfo, newMachineId, parseMachineIdFile } from './machine.ts';
54import type { Ask, Delivery, DeliveryResult, MachineInfo, RespondInfo, RespondKind, RespondLevel } from './protocol.ts';
55import {
56 answeredLine,
57 answersMap,
58 LINE_MESSAGES_ON,
59 LINE_OPEN,
60 planApproval,
61 planAsk,
62 planFeedback,
63 planRejection,
64 questionAsk,
65 respondInfo,
66 respondLevel,
67 terminalAnswers,
68 toolAsk,
69 toolDecision,
70 toolDenied,
71} from './respond.ts';
72
73export const VERSION = '1.1.0';
74const GIT_POLL_MS = 10_000;
75/** session.end has one ~1.5 s bound for every hook and core together; stay well inside it. */
76const END_FLUSH_MS = 1000;
77const ERRORS_KEPT = 20;
78/** Text read from one watched step before the rest is let through unread: far more than one 300-character entry needs. */
79const STEP_TEXT_MAX = 8000;
80
81type Engine = EngineInterface;
82
83interface Options {
84 serverUrl: string;
85 token: string;
86 flushIntervalMs: number;
87 heartbeatMs: number;
88 sessionName: string;
89 userName: string;
90 showStatus: boolean;
91 explainFailures: boolean;
92 fullPrompts: boolean;
93 activityFeed: boolean;
94 /** What this session accepts from Podium (#206). */
95 respond: RespondLevel;
96}
97
98interface HookStat {
99 calls: number;
100 totalMs: number;
101 maxMs: number;
102}
103
104interface Runtime {
105 state: SessionState;
106 transport: Transport;
107 timers: { cancel: () => void }[];
108 storedName: string | null;
109 engineVersion: string | null;
110 hostSurface: string | null;
111 flushing: boolean;
112 lastStatus: string | null;
113 toastedOk: boolean;
114 toastedFail: boolean;
115 /** Whether the plain-HTTP notice has been toasted in this session (#306). */
116 toastedPlain: boolean;
117 /** The refusal notice last toasted, so a later refusal is news again once a report has gone through. */
118 toastedRefusal: string | null;
119 /** The version notice last toasted, so each distinct one shows once. */
120 toastedVersion: string | null;
121 /** Skill tool calls in flight: a skill.prompt during one belongs to that call's loop. */
122 skillCalls: number;
123 /** This machine, read only when a userName is set; null otherwise, so nothing identifying is sent. */
124 machine: MachineInfo | null;
125 /** The session this one continues after a /clear (#206); sent with every report under the new id. */
126 clearedFrom: string | null;
127 /** Whether a person's client is on this session: false for a `claude -p` run, which never responds. */
128 interactive: boolean;
129}
130
131export function readOptions(o: Record<string, unknown>): Options {
132 const s = (v: unknown, d: string) => (typeof v === 'string' ? v : d);
133 const n = (v: unknown, d: number, min: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(min, v) : d);
134 return {
135 serverUrl: s(o.serverUrl, 'http://127.0.0.1:4317') || 'http://127.0.0.1:4317',
136 token: s(o.token, ''),
137 flushIntervalMs: n(o.flushIntervalMs, 1000, 200),
138 heartbeatMs: n(o.heartbeatMs, 5000, 1000),
139 sessionName: s(o.sessionName, ''),
140 userName: s(o.userName, ''),
141 showStatus: typeof o.showStatus === 'boolean' ? o.showStatus : true,
142 explainFailures: o.explainFailures === true,
143 fullPrompts: o.fullPrompts === true,
144 activityFeed: o.activityFeed === true,
145 respond: respondLevel(o.respond),
146 };
147}
148
149// ---- module state (reset by register) -----------------------------------------------------------
150
151let opts: Options = readOptions({});
152/** The name and token this session reports as: taken once at session.start and held (#226), never read live from the options. */
153let who: Identity = { userName: '', token: '' };
154const IDENTITY = { plugin: 'podium-reporter', key: 'identity' } as const;
155let rt: Runtime | null = null;
156/** Bumped by each register (a reload, or a new test): a wait for an answer started under an older one stops. */
157let generation = 0;
158let errors: { at: number; where: string; message: string }[] = [];
159let stats: Record<string, HookStat> = {};
160/** Agents whose failure was sent for an explanation: at most one call per failure, never retried. */
161let explained = new Set<string>();
162
163// ---- responding (#206, SPEC 5.3) --------------------------------------------------------------
164
165/** One ask open in this session, and what each of its hooks knows about it. */
166interface OpenAsk {
167 ask: Ask;
168 sessionId: string;
169 /** The questions as Claude asked them, unredacted: Podium's answers by position are turned back into these. */
170 questions?: { question: string; options: { label: string }[] }[];
171 /** The race is over (answered, closed, or the tool call ended): every loop on it stops. */
172 done: boolean;
173 announced: boolean;
174 /** A plan: the approval handed to the PermissionRequest hook, and whoever waits for it. */
175 approval: Delivery | null;
176 approvalWaiters: ((d: Delivery | null) => void)[];
177 /** The approval was reported not taken: whatever the terminal does next is the terminal's. */
178 notTaken: boolean;
179 /** A tool: the answer the PermissionRequest hook returned. */
180 answered: Delivery | null;
181 /**
182 * Something still waits on the server for this ask's answer. Once the wait gives up (responding turned off, the ask
183 * closed there) the ask stops riding in reports, so the server never learns it again and takes an answer nobody collects.
184 */
185 listening: boolean;
186}
187
188/** A close or a result for the server, sent from the flush timer (Esc aborts a hook's own fetches). */
189interface OutItem {
190 path: string;
191 body: Record<string, unknown>;
192}
193
194const OUTBOX_MAX = 50;
195const ACTED_KEPT = 100;
196const ACTED_KEY = 'respond:acted';
197const HOLD_SECONDS = 20;
198/** How long an approval has to take before it is reported not taken. */
199const APPROVAL_CONFIRM_MS = 5000;
200/** How long after tool.check says `ask` a PermissionRequest must fire before the hooks are taken as not running. */
201const PERMISSION_CHECK_MS = 2000;
202
203let asks = new Map<string, OpenAsk>();
204let outbox: OutItem[] = [];
205let acted: string[] = [];
206/** null until known; false once a prompt showed with no PermissionRequest; true once one fired. */
207let permissionHooks: boolean | null = null;
208let lastPermissionAt = 0;
209let checks = new Map<string, { cancel: () => void }>();
210/** Each loop's latest tool call, so a PermissionRequest (which has no tool_use_id) can be paired with it. */
211let lastCalls = new Map<string, { tool: string; input: string; id: string }>();
212let messagesLineShown = false;
213const EXPLAIN_MODEL = 'haiku';
214
215const perf = typeof performance !== 'undefined' && typeof performance.now === 'function' ? () => performance.now() : () => Date.now();
216const nowMs = () => Date.now();
217
218/** Read-only view of the runtime, for tests and /podium. */
219export function runtimeInfo(): { sessionId: string | null; errors: readonly { where: string; message: string }[]; stats: Readonly<Record<string, HookStat>> } {
220 return { sessionId: rt?.state.id ?? null, errors, stats };
221}
222
223function remember(where: string, err: unknown): string {
224 const message = String((err as Error)?.message ?? err).slice(0, 200);
225 errors.push({ at: nowMs(), where, message });
226 if (errors.length > ERRORS_KEPT) errors.shift();
227 return message;
228}
229
230function record($: Engine, where: string, err: unknown): void {
231 const message = remember(where, err);
232 try {
233 $.ui.log(`podium-reporter: ${where}: ${message}`, { to: 'debug' });
234 } catch {
235 // logging is best effort
236 }
237}
238
239/** Runs reporter logic for a hook: any throw is remembered, never raised. Timed for /podium. */
240function safe(where: string, fn: (r: Runtime) => void): void {
241 const t0 = perf();
242 try {
243 if (rt) fn(rt);
244 } catch (err) {
245 remember(where, err);
246 } finally {
247 const ms = perf() - t0;
248 const st = (stats[where] ??= { calls: 0, totalMs: 0, maxMs: 0 });
249 st.calls++;
250 st.totalMs += ms;
251 st.maxMs = Math.max(st.maxMs, ms);
252 }
253}
254
255/**
256 * What the session says about responding in its reports. Sent with level `off` too: only a reporter older than 0.14.0
257 * leaves it out, which Podium reads as "Reporter can't answer" (SPEC 6.2), so off has to say so.
258 */
259function respondNow(r: Runtime): RespondInfo {
260 return respondInfo(opts.respond, opts.activityFeed, r.interactive, permissionHooks);
261}
262
263/** Whether the mod answers anything from Podium now: its own switches, and the server's last word. */
264function responding(r: Runtime | null, kind: RespondKind): boolean {
265 return !!r && r.transport.respond && !r.state.ended && respondNow(r).accepts.includes(kind);
266}
267
268function openAsks(r: Runtime): Ask[] {
269 return [...asks.values()].filter((a) => !a.done && a.listening && a.sessionId === r.state.id).map((a) => a.ask);
270}
271
272function metaOf(r: Runtime, now: number) {
273 return {
274 version: VERSION,
275 engineVersion: r.engineVersion,
276 hostSurface: r.hostSurface,
277 now,
278 storedName: r.storedName,
279 optionName: opts.sessionName || null,
280 userName: who.userName || null,
281 machine: r.machine,
282 fullText: opts.fullPrompts,
283 activity: opts.activityFeed,
284 respond: respondNow(r),
285 asks: r.transport.respond && opts.respond !== 'off' ? openAsks(r) : null,
286 clearedFrom: r.clearedFrom,
287 };
288}
289
290function showStatus($: Engine, r: Runtime): void {
291 if (!opts.showStatus) return;
292 const text = r.transport.statusText();
293 if (text === r.lastStatus) return;
294 r.lastStatus = text;
295 try {
296 $.ui.status(text);
297 } catch {
298 // no status line on this host
299 }
300}
301
302function toastOnce($: Engine, r: Runtime, ok: boolean): void {
303 try {
304 if (ok && !r.toastedOk) {
305 r.toastedOk = true;
306 $.ui.toast(`Podium: reporting to ${opts.serverUrl}`);
307 } else if (!ok && !r.toastedFail) {
308 r.toastedFail = true;
309 $.ui.toast(`Podium: cannot reach ${opts.serverUrl}; will keep retrying`);
310 }
311 } catch {
312 // no toasts on this host
313 }
314}
315
316function toastRefusalOnce($: Engine, r: Runtime): void {
317 const notice = r.transport.refusalNotice() ?? r.transport.ownerNotice();
318 if (!notice || notice === r.toastedRefusal) return;
319 r.toastedRefusal = notice;
320 try {
321 $.ui.toast(`Podium: ${notice}`);
322 } catch {
323 // no toasts on this host
324 }
325}
326
327function toastPlainHttpOnce($: Engine, r: Runtime): void {
328 const notice = r.transport.plainHttpNotice();
329 if (!notice || r.toastedPlain) return;
330 r.toastedPlain = true;
331 try {
332 $.ui.toast(`Podium: ${notice}`);
333 } catch {
334 // no toasts on this host
335 }
336}
337
338function toastVersionOnce($: Engine, r: Runtime): void {
339 const notice = r.transport.versionNotice();
340 if (!notice) {
341 r.toastedVersion = null; // matched again: a later mismatch is news again
342 return;
343 }
344 if (notice === r.toastedVersion) return;
345 r.toastedVersion = notice;
346 try {
347 $.ui.toast(`Podium: ${notice}`);
348 } catch {
349 // no toasts on this host
350 }
351}
352
353/** Moves queued events to the transport and posts one report if one is due (or `force`). */
354async function flushOnce($: Engine, r: Runtime, now: number, force: boolean): Promise<void> {
355 const [st, events] = drainEvents(r.state);
356 r.state = st;
357 r.transport.enqueue(events);
358 if (!force && !r.transport.due(now, r.state.dirty)) return;
359 const snapshot = r.state;
360 r.state = { ...r.state, dirty: false };
361 let sentTexts: SentTexts = {};
362 const outcome = await r.transport.send((evs) => {
363 const report = buildReport(snapshot, evs, metaOf(r, now));
364 sentTexts = Object.fromEntries(report.agents.map((a) => [a.id, { promptText: a.promptText, answerText: a.answerText }]));
365 return report;
366 }, now);
367 if (outcome === 'failed') {
368 r.state = { ...r.state, dirty: true };
369 toastOnce($, r, false);
370 } else if (outcome === 'refused') {
371 r.state = { ...r.state, dirty: true };
372 toastRefusalOnce($, r);
373 } else if (outcome === 'rejected') {
374 toastRefusalOnce($, r); // a 409 (this session belongs to someone else) is told once; any other rejection has no notice (#209)
375 } else if (outcome === 'ok') {
376 if (opts.fullPrompts) r.state = markTextSent(r.state, sentTexts);
377 // The reply names the loops someone is watching; with the option off nothing is ever watched (#118).
378 r.state = setWatched(r.state, opts.activityFeed ? r.transport.watch : []);
379 r.toastedRefusal = null; // accepted again: a later refusal is news again
380 toastOnce($, r, true);
381 handleDeliveries($, r);
382 messagesLine($, r);
383 }
384 else if (outcome === 'busy') r.state = { ...r.state, dirty: true };
385 toastVersionOnce($, r);
386 toastPlainHttpOnce($, r);
387 showStatus($, r);
388}
389
390async function readUsage($: Engine, r: Runtime): Promise<void> {
391 try {
392 const u = await $.session.usage();
393 r.state = measure(r.state, { context: u.context, cost: u.cost ?? null, rateLimits: u.rateLimits, startedAt: u.startedAt });
394 } catch (err) {
395 record($, 'session.usage', err);
396 }
397}
398
399/** The roster of attached clients. An empty one is a background session with nobody on it; a failed read says nothing. */
400async function readSurfaces($: Engine, r: Runtime): Promise<void> {
401 try {
402 const clients = await $.session.surfaces();
403 r.state = setAttached(r.state, clients.length > 0);
404 } catch (err) {
405 record($, 'session.surfaces', err);
406 }
407}
408
409async function readAgents($: Engine, r: Runtime, now: number): Promise<void> {
410 try {
411 const list = (await $.agent.list()) as ListedAgent[];
412 r.state = reconcile(r.state, list, now);
413 } catch (err) {
414 record($, 'agent.list', err);
415 }
416}
417
418async function pollGit($: Engine, r: Runtime): Promise<void> {
419 try {
420 const cwd = r.state.cwd;
421 const run = async (argv: string[]) => {
422 const res = await $.process.run(argv, { cwd, timeoutMs: 5000 });
423 return { exitCode: res.exitCode, stdout: res.stdout };
424 };
425 const git = await readGit(run, cwd);
426 r.state = setGit(r.state, git);
427 const id = r.state.id;
428 let base = r.state.gitBase ?? (await loadBase($, id));
429 // Tried even when readGit gave null: a repository with no commits yet fails that (no HEAD) but has files to list.
430 const changes = await readChanges(run, cwd, base);
431 if (!changes) {
432 if (!git) r.state = setFiles(r.state, NO_FILES, r.state.gitBase);
433 return;
434 }
435 if (r.state.id !== id) return;
436 if (changes.base !== base) await attempt(() => $.store.set(`base:${id}`, changes.base), undefined);
437 base = changes.base;
438 r.state = setFiles(r.state, changes.files, base);
439 } catch (err) {
440 record($, 'git', err);
441 }
442}
443
444/** The starting commit saved by an earlier run of this session (a hot reload keeps the process, a restart does not). */
445async function loadBase($: Engine, id: string): Promise<string | null> {
446 try {
447 const v = await $.store.get(`base:${id}`);
448 return typeof v === 'string' && v ? v : null;
449 } catch {
450 return null;
451 }
452}
453
454async function loadName($: Engine, id: string): Promise<string | null> {
455 try {
456 const v = await $.store.get(`name:${id}`);
457 return typeof v === 'string' && v.trim() ? v.trim() : null;
458 } catch {
459 return null;
460 }
461}
462
463/** A /clear happened: the old id gets its final report, then reporting starts under the new id. */
464async function switchSession($: Engine, r: Runtime, newId: string, now: number): Promise<void> {
465 const oldId = r.state.id;
466 const cleared = !r.state.ended || r.state.endReason === 'clear';
467 for (const a of asks.values()) if (a.sessionId === oldId) a.done = true;
468 if (!r.state.ended) {
469 r.state = sessionEnd(r.state, 'clear', r.state.id, now);
470 await r.transport.idle();
471 await flushOnce($, r, now, true);
472 }
473 r.transport.queue = [];
474 let startedAt = now;
475 try {
476 startedAt = (await $.session.usage()).startedAt || now;
477 } catch {
478 // keep now
479 }
480 const effort = r.state.main.effort;
481 r.state = addEvent(resetForNewId(r.state, newId, startedAt), 'start', null, `session.start · ${shortName(r.state.main.model)}${effort ? ` ${effort}` : ''}`, now);
482 r.storedName = await loadName($, newId);
483 r.clearedFrom = cleared ? oldId : null;
484 messagesLineShown = false;
485}
486
487/** The flush timer: notice a session id change, refresh usage and agents, post a report if due. */
488async function flush($: Engine): Promise<void> {
489 const r = rt;
490 if (!r || r.flushing) return;
491 r.flushing = true;
492 try {
493 // Scheduling (heartbeat, backoff) runs on the engine's clock; hooks stamp events with Date.now().
494 const now = await engineNow($);
495 let id: string | null = null;
496 try {
497 id = await $.session.id();
498 } catch (err) {
499 record($, 'session.id', err);
500 }
501 if (id && id !== r.state.id) await switchSession($, r, id, now);
502 await sendOutbox($);
503 if (r.state.ended) return;
504 await readUsage($, r);
505 await readSurfaces($, r);
506 await readAgents($, r, now);
507 if (opts.explainFailures) requestExplanations($, r);
508 await flushOnce($, r, now, false);
509 } catch (err) {
510 record($, 'flush', err);
511 } finally {
512 r.flushing = false;
513 }
514}
515
516/** The engine's time; Date.now() where the host has no clock noun. */
517async function engineNow($: Engine): Promise<number> {
518 try {
519 return await $.clock.now();
520 } catch {
521 return nowMs();
522 }
523}
524
525/** T5.2, off by default: one small-model explanation per new failure, from the redacted record only. */
526function requestExplanations($: Engine, r: Runtime): void {
527 for (const id of r.state.order) {
528 const a = r.state.agents[id];
529 // Keyed by the failure itself (its time), not just the agent: a retried agent that fails again is a new failure.
530 const key = a?.failure ? `${r.state.id}:${id}:${a.failure.at}` : '';
531 if (!a?.failure || a.failure.explanation || explained.has(key)) continue;
532 explained.add(key);
533 void explain($, r.state.id, id, explainPrompt(a.failure));
534 }
535}
536
537async function explain($: Engine, sessionId: string, agentId: string, prompt: string): Promise<void> {
538 try {
539 const res = await $.model.complete({ model: EXPLAIN_MODEL, effort: 'low', prompt, maxTokens: 200, timeoutMs: 30_000 });
540 if (!res.isAnswered || !rt || rt.state.id !== sessionId) return;
541 rt.state = setExplanation(rt.state, agentId, res.text, res.usage, EXPLAIN_MODEL);
542 } catch (err) {
543 record($, 'model.complete', err);
544 }
545}
546
547/** A random number in [0, 1): the platform's generator where the sandbox has one. */
548function random(): number {
549 const c = (globalThis as { crypto?: { getRandomValues?: (a: Uint32Array) => Uint32Array } }).crypto;
550 if (c?.getRandomValues) return c.getRandomValues(new Uint32Array(1))[0]! / 2 ** 32;
551 return Math.random();
552}
553
554/**
555 * The machine's id, one for every load of the mod on it (#267). The shared file under the Claude config
556 * directory decides; without a valid one, the id this load's store already holds is kept (so an install
557 * keeps its row) and shared, else a new one is made and shared. The file is read back after a write. Two loads
558 * that start in the same instant can still each keep the id they wrote for that session (the last write is
559 * the file's), and agree from their next session on. Where the file cannot be used, the store alone
560 * keeps the id, as before.
561 */
562async function machineId($: Engine): Promise<string> {
563 const path = machineIdPath(
564 await attempt(() => $.env.get('CLAUDE_CONFIG_DIR'), undefined),
565 await attempt(() => $.env.get('HOME'), undefined),
566 );
567 const shared = async () => (path ? parseMachineIdFile(await attempt(async () => (await $.fs.read(path)) as string, null)) : null);
568 const fromFile = await shared();
569 const kept = await attempt(() => $.store.get(MACHINE_ID_KEY), undefined);
570 if (fromFile) {
571 // Keep this load's store in step, so the id survives the shared file being deleted.
572 if (fromFile !== kept) await attempt(() => $.store.set(MACHINE_ID_KEY, fromFile), undefined);
573 return fromFile;
574 }
575 const id = isMachineId(kept) ? kept : newMachineId(random);
576 if (id !== kept) await attempt(() => $.store.set(MACHINE_ID_KEY, id), undefined);
577 if (!path) return id;
578 await attempt(() => $.fs.write(path, `${id}\n`), undefined);
579 return (await shared()) ?? id;
580}
581
582/**
583 * This machine's short host name and the random id the mod keeps for it in its own store (made on first
584 * use). Read only when a userName is set: an unnamed reporter sends nothing that identifies the machine.
585 */
586async function readMachine($: Engine): Promise<MachineInfo | null> {
587 if (!who.userName.trim()) return null;
588 try {
589 const id = await machineId($);
590 const host = await attempt(async () => {
591 const res = await $.process.run(['hostname'], { timeoutMs: 2000 });
592 return res.exitCode === 0 ? hostOf(res.stdout) : null;
593 }, null);
594 return machineInfo(host, id);
595 } catch (err) {
596 record($, 'machine', err);
597 return null;
598 }
599}
600
601async function attempt<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
602 try {
603 return await fn();
604 } catch {
605 return fallback;
606 }
607}
608
609// ---- responding: talking to the server (#206) -------------------------------------------------
610
611function serverBase(): string {
612 return opts.serverUrl.replace(/\/+$/, '');
613}
614
615function sessionPath(sessionId: string): string {
616 return `/api/fleet/sessions/${encodeURIComponent(sessionId)}`;
617}
618
619/** One request to the server with the reporter's token; null when it could not be made (offline, or aborted by Esc). */
620async function podiumCall($: Engine, method: string, path: string, body?: unknown): Promise<{ status: number; text: string } | null> {
621 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
622 if (who.token) headers['X-Podium-Token'] = who.token;
623 try {
624 const res = await $.http.fetch(serverBase() + path, body === undefined ? { method, headers } : { method, headers, body: JSON.stringify(body) });
625 return { status: res.status, text: res.text };
626 } catch {
627 return null;
628 }
629}
630
631/** A line in the transcript; the engine draws it as `podium-reporter: <text>`. */
632function transcriptLine($: Engine, text: string): void {
633 try {
634 $.ui.log(text);
635 } catch {
636 // no transcript on this host
637 }
638}
639
640function queueOut(item: OutItem): void {
641 outbox.push(item);
642 if (outbox.length > OUTBOX_MAX) outbox.splice(0, outbox.length - OUTBOX_MAX);
643}
644
645function queueResult(sessionId: string, deliveryId: string, result: DeliveryResult): void {
646 queueOut({ path: `${sessionPath(sessionId)}/deliveries/${encodeURIComponent(deliveryId)}/result`, body: { result, at: nowMs() } });
647}
648
649function queueClose(a: OpenAsk, outcome: 'terminal' | 'closed' | 'ended', extra: { answer?: string | null; planApproved?: boolean } = {}): void {
650 queueOut({ path: `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}/close`, body: { outcome, at: nowMs(), ...extra } });
651}
652
653/** The flush timer sends what the hooks queued. A failure keeps the rest for the next tick; a refusal drops the item. */
654async function sendOutbox($: Engine): Promise<void> {
655 while (outbox.length) {
656 const item = outbox[0]!;
657 const res = await podiumCall($, 'POST', item.path, item.body);
658 if (!res) return;
659 outbox.shift();
660 }
661}
662
663async function loadActed($: Engine): Promise<string[]> {
664 const v = await attempt(() => $.store.get(ACTED_KEY), undefined);
665 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(-ACTED_KEPT) : [];
666}
667
668/** Notes a delivery as acted on, here and in the store, so one handed over twice (a lost reply, a reload) acts once. */
669function markActed($: Engine, id: string): boolean {
670 if (acted.includes(id)) return false;
671 acted.push(id);
672 if (acted.length > ACTED_KEPT) acted.splice(0, acted.length - ACTED_KEPT);
673 void attempt(() => $.store.set(ACTED_KEY, acted), undefined);
674 return true;
675}
676
677/** Announces an ask (PUT). The first time it lands, the terminal says it is also open in Podium. */
678async function announce($: Engine, a: OpenAsk): Promise<number> {
679 const res = await podiumCall($, 'PUT', `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}`, a.ask);
680 if (res?.status === 200 && !a.announced) {
681 a.announced = true;
682 transcriptLine($, LINE_OPEN);
683 }
684 return res?.status ?? 0;
685}
686
687function openAsk(r: Runtime, ask: Ask, questions?: OpenAsk['questions']): OpenAsk {
688 const a: OpenAsk = { ask, sessionId: r.state.id, questions, done: false, announced: false, approval: null, approvalWaiters: [], notTaken: false, answered: null, listening: true };
689 asks.set(ask.id, a);
690 r.state = { ...r.state, dirty: true };
691 return a;
692}
693
694function finish(a: OpenAsk): void {
695 a.done = true;
696 for (const w of a.approvalWaiters.splice(0)) w(null);
697 asks.delete(a.ask.id);
698 if (rt) rt.state = { ...rt.state, dirty: true };
699}
700
701/**
702 * Waits on the server for the next answer to an ask that `wanted` takes, until one comes, the ask is done, or the server
703 * stops taking answers. An answer of another kind is refused (or, for a plan, handed to `other`); one that `skip` names
704 * is left alone for another wait on the same ask to act on.
705 */
706async function waitForAnswer($: Engine, a: OpenAsk, wanted: (d: Delivery) => boolean, other?: (d: Delivery) => void, skip?: (d: Delivery) => boolean): Promise<Delivery | null> {
707 const d = await pollAnswer($, a, wanted, other, skip);
708 if (!d && !a.done) {
709 a.listening = false;
710 if (rt) rt.state = { ...rt.state, dirty: true };
711 }
712 return d;
713}
714
715/**
716 * Waits `ms` on the engine's clock. False when the sleep fails: its dispatch was aborted or let go, so the engine
717 * behind this wait is gone, and a loop that went round again would never wait at all.
718 */
719async function pause($: Engine, ms: number): Promise<boolean> {
720 try {
721 await $.clock.sleep(ms);
722 return true;
723 } catch {
724 return false;
725 }
726}
727
728async function pollAnswer($: Engine, a: OpenAsk, wanted: (d: Delivery) => boolean, other?: (d: Delivery) => void, skip?: (d: Delivery) => boolean): Promise<Delivery | null> {
729 // `rt` is module state, so a wait from an older register would otherwise read a newer session's switches.
730 const gen = generation;
731 while (!a.done && gen === generation && rt?.transport.respond) {
732 const res = await podiumCall($, 'GET', `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}/answer?wait=${HOLD_SECONDS}`);
733 if (a.done) {
734 const d = deliveryOf(res);
735 if (d && markActed($, d.id)) queueResult(a.sessionId, d.id, 'closed');
736 return null;
737 }
738 if (!res) {
739 if (!(await pause($, Transport.backoff(1)))) return null;
740 continue;
741 }
742 if (res.status === 204) continue;
743 if (res.status === 404) {
744 if ((await announce($, a)) !== 200 && !(await pause($, Transport.backoff(2)))) return null;
745 continue;
746 }
747 const d = deliveryOf(res);
748 if (!d) return null;
749 if (d.askId === a.ask.id && skip?.(d)) {
750 // Another wait on this ask acts on it (a plan's feedback is the tool.call hook's); ask again once it has.
751 if (!(await pause($, 500))) return null;
752 continue;
753 }
754 if (!markActed($, d.id)) {
755 // Already acted on and waiting for its result to reach the server: ask again shortly.
756 if (!(await pause($, 1000))) return null;
757 continue;
758 }
759 if (d.askId === a.ask.id && wanted(d)) return d;
760 if (d.askId === a.ask.id && other) {
761 other(d);
762 continue;
763 }
764 queueResult(a.sessionId, d.id, 'refused');
765 }
766 return null;
767}
768
769function deliveryOf(res: { status: number; text: string } | null): Delivery | null {
770 if (!res || res.status !== 200) return null;
771 try {
772 const d = (JSON.parse(res.text) as { delivery?: unknown }).delivery;
773 return isDelivery(d) ? d : null;
774 } catch {
775 return null;
776 }
777}
778
779// ---- responding: the hooks' races --------------------------------------------------------------
780
781type ToolResult = { result?: unknown; text?: string; isError?: boolean; deny?: string };
782type Race = { res: ToolResult; d: null } | { res: null; d: Delivery };
783
784/** Whichever comes first: the terminal's answer, or one from Podium. A Podium wait that ends empty leaves the terminal. */
785function race(terminal: Promise<ToolResult>, podium: Promise<Delivery | null>): Promise<Race> {
786 const t: Promise<Race> = terminal.then((res) => ({ res, d: null }));
787 const p: Promise<Race> = podium.then((d) => (d ? { res: null, d } : t));
788 return Promise.race([t, p]);
789}
790
791/** AskUserQuestion: the terminal's dialog (`next(e)`) against an answer from Podium. The first wins. */
792async function questionRace($: Engine, r: Runtime, e: { tool_use_id: string; agentId?: string; questions?: unknown }, terminal: Promise<ToolResult>): Promise<ToolResult> {
793 const input = e as unknown as Record<string, unknown>;
794 const ask = questionAsk(e.tool_use_id, e.agentId ?? null, input, nowMs());
795 if (!ask) return terminal;
796 const raw = (Array.isArray(e.questions) ? e.questions : []) as OpenAsk['questions'];
797 const a = openAsk(r, ask, raw);
798 await announce($, a);
799 const podium = waitForAnswer($, a, (d) => (d.kind === 'question' || d.kind === 'reply') && responding(rt, 'question'));
800 const first = await race(terminal, podium);
801 if (first.d) {
802 const d = first.d;
803 finish(a);
804 queueResult(a.sessionId, d.id, 'took');
805 transcriptLine($, answeredLine(d, nowMs()));
806 const questions = e.questions;
807 return d.kind === 'reply' ? { result: { questions, answers: {}, response: d.text ?? '' } } : { result: { questions, answers: answersMap(a.questions ?? [], d.answers ?? []) } };
808 }
809 const res = first.res as ToolResult;
810 finish(a);
811 // A rejection is Esc, an aborted turn, or the terminal's own "Chat about this": closed without an answer.
812 if (res.isError || res.deny !== undefined) queueClose(a, 'closed');
813 else queueClose(a, 'terminal', { answer: terminalAnswers(res) });
814 return res;
815}
816
817/**
818 * ExitPlanMode: one ask, two hooks. Feedback ("No, keep planning", or a message in its place) is answered here as
819 * `{ deny }`; an approval is handed to the waiting PermissionRequest hook, and counts only once the call resolves as
820 * approved, within APPROVAL_CONFIRM_MS.
821 */
822async function planRace($: Engine, r: Runtime, e: { tool_use_id: string; agentId?: string }, terminal: Promise<ToolResult>): Promise<ToolResult> {
823 const a = openAsk(r, planAsk(e.tool_use_id, e.agentId ?? null, e as unknown as Record<string, unknown>, nowMs()));
824 await announce($, a);
825 const podium = waitForAnswer($, a, (d) => (d.kind === 'plan-feedback' || d.kind === 'reply') && responding(rt, 'plan-feedback'), (d) => takeApproval($, a, d));
826 const first = await race(terminal, podium);
827 if (first.d) {
828 finish(a);
829 queueResult(a.sessionId, first.d.id, 'took');
830 transcriptLine($, answeredLine(first.d, nowMs()));
831 return planFeedback(first.d);
832 }
833 const res = first.res as ToolResult;
834 const approved = !res.isError && res.deny === undefined;
835 const approval = a.notTaken ? null : a.approval;
836 finish(a);
837 if (approval && approved) {
838 queueResult(a.sessionId, approval.id, 'took');
839 // Only now, once Claude Code took it: the terminal never reads "Approved from Podium" for one that didn't.
840 transcriptLine($, answeredLine(approval, nowMs()));
841 } else {
842 if (approval) queueResult(a.sessionId, approval.id, 'closed');
843 if (approved) queueClose(a, 'terminal', { planApproved: true });
844 else if (planRejection(res.text) === 'feedback') queueClose(a, 'terminal', { answer: 'No, keep planning' });
845 else queueClose(a, 'closed');
846 }
847 return res;
848}
849
850/** A plan's approval, from either wait on its ask: handed to the PermissionRequest hook, and checked that it took. */
851function takeApproval($: Engine, a: OpenAsk, d: Delivery): void {
852 if (d.kind !== 'plan-approve' || !responding(rt, 'plan-approve') || a.approval) {
853 queueResult(a.sessionId, d.id, 'refused');
854 return;
855 }
856 a.approval = d;
857 for (const w of a.approvalWaiters.splice(0)) w(d);
858 void confirmApproval($, a, d);
859}
860
861/**
862 * The PermissionRequest hook's wait for a plan's approval. It polls the server itself, so a held request is in flight
863 * the whole time: that stops the hook's 10-second budget (HookBudget), where waiting on a promise alone would not, and
864 * an approval sent long after the dialog opened still finds the hook. An approval the tool.call hook's wait got first
865 * arrives through `approvalOf`; feedback and replies are left to that wait.
866 */
867async function approvalFromPodium($: Engine, a: OpenAsk): Promise<Delivery | null> {
868 const fromServer = waitForAnswer(
869 $,
870 a,
871 (d) => d.kind === 'plan-approve',
872 undefined,
873 (d) => d.kind === 'plan-feedback' || d.kind === 'reply',
874 ).then((d) => {
875 if (d) takeApproval($, a, d);
876 return a.approval;
877 });
878 return Promise.race([approvalOf(a), fromServer]);
879}
880
881/** An approval that the plan's tool call has not resolved as approved within the confirm window did not take. */
882async function confirmApproval($: Engine, a: OpenAsk, d: Delivery): Promise<void> {
883 await attempt(() => $.clock.sleep(APPROVAL_CONFIRM_MS), undefined);
884 if (a.done) return;
885 a.notTaken = true;
886 queueResult(a.sessionId, d.id, 'not-taken');
887}
888
889/** The plan ask of a loop, for its PermissionRequest hook. */
890function planAskOf(agentId: string | undefined): OpenAsk | undefined {
891 return [...asks.values()].reverse().find((a) => !a.done && a.ask.kind === 'plan' && a.ask.agentId === (agentId ?? null));
892}
893
894/** Waits for the approval of a plan ask, or null once the ask is done. */
895function approvalOf(a: OpenAsk): Promise<Delivery | null> {
896 if (a.approval) return Promise.resolve(a.approval);
897 if (a.done) return Promise.resolve(null);
898 return new Promise((resolve) => a.approvalWaiters.push(resolve));
899}
900
901function loopKey(agentId: string | null | undefined): string {
902 return agentId ?? '@main';
903}
904
905function inputKey(input: unknown): string {
906 try {
907 return JSON.stringify(input ?? null);
908 } catch {
909 return '';
910 }
911}
912
913/** Any PermissionRequest shows the hooks run here: the hooks are no longer taken as missing. */
914function permissionSeen(r: Runtime, id: string | null): void {
915 lastPermissionAt = nowMs();
916 if (id) {
917 checks.get(id)?.cancel();
918 checks.delete(id);
919 }
920 if (permissionHooks !== true) {
921 permissionHooks = true;
922 r.state = { ...r.state, dirty: true };
923 }
924}
925
926/** tool.check said a prompt is about to show: if no PermissionRequest follows, the hooks don't run here (SPEC 5.3). */
927function startPermissionCheck($: Engine, id: string): void {
928 const startedAt = nowMs();
929 checks.get(id)?.cancel();
930 try {
931 const h = $.clock.after(PERMISSION_CHECK_MS, () => {
932 checks.delete(id);
933 if (lastPermissionAt >= startedAt || !rt) return;
934 permissionHooks = false;
935 rt.state = { ...rt.state, dirty: true };
936 });
937 checks.set(id, h);
938 } catch {
939 // no clock: leave it unknown
940 }
941}
942
943/**
944 * A tool's permission prompt: announced under the tool call it pairs with, and answered from Podium if an answer comes
945 * while it is up. The engine does not end this hook when the terminal answers first; the tool call ending does
946 * (`settleToolAsk`).
947 */
948async function permissionAsk($: Engine, r: Runtime, e: { tool_name: string; tool_input: unknown; agent_id?: string }): Promise<{ decision: { behavior: 'allow' } | { behavior: 'deny'; message: string } } | null> {
949 const call = lastCalls.get(loopKey(e.agent_id));
950 if (!call || call.tool !== e.tool_name || call.input !== inputKey(e.tool_input) || asks.has(call.id)) return null;
951 const a = openAsk(r, toolAsk(call.id, e.agent_id ?? null, e.tool_name, e.tool_input, nowMs()));
952 await announce($, a);
953 const d = await waitForAnswer($, a, (x) => (x.kind === 'allow-once' ? !a.ask.tool?.cut : x.kind === 'deny' || x.kind === 'reply') && responding(rt, 'permission'));
954 if (!d || a.done) return null;
955 a.answered = d;
956 transcriptLine($, answeredLine(d, nowMs()));
957 return toolDecision(d.kind === 'reply' ? { ...d, kind: 'deny' } : d);
958}
959
960/**
961 * The tool call of a permission ask ended: what happened is read from how it ended, since nothing else says (SPEC 5.3).
962 * An answer from Podium that matches what happened took; otherwise the terminal answered first.
963 */
964function settleToolAsk(id: string, res: ToolResult | undefined): void {
965 const a = asks.get(id);
966 if (!a || a.ask.kind !== 'permission') return;
967 const d = a.answered;
968 const sent = d && d.kind !== 'allow-once' ? (toolDecision(d.kind === 'reply' ? { ...d, kind: 'deny' } : d).decision as { message?: string }).message : undefined;
969 const denied = toolDenied(res, sent);
970 finish(a);
971 if (d && (d.kind === 'allow-once') === !denied) queueResult(a.sessionId, d.id, 'took');
972 else {
973 if (d) queueResult(a.sessionId, d.id, 'closed');
974 queueClose(a, 'terminal', { answer: denied ? 'denied' : 'allowed' });
975 }
976}
977
978// ---- responding: messages and commands ---------------------------------------------------------
979
980/** Messages and commands from the reply to a report: each acted on once, within the session's own level. */
981function handleDeliveries($: Engine, r: Runtime): void {
982 const list = r.transport.deliveries;
983 r.transport.deliveries = [];
984 for (const d of list) {
985 if (!markActed($, d.id)) continue;
986 const sid = r.state.id;
987 const running = r.state.status === 'running';
988 if (d.kind === 'message' && typeof d.text === 'string' && d.text.trim() && responding(r, 'message')) {
989 if (running) queueResult(sid, d.id, 'queued');
990 void submitMessage($, sid, d);
991 } else if (d.kind === 'command' && (d.command === 'clear' || d.command === 'compact') && responding(r, 'command')) {
992 if (running) queueResult(sid, d.id, 'queued');
993 void runCommand($, sid, d, d.command);
994 } else queueResult(sid, d.id, 'refused');
995 }
996}
997
998/** A message from Podium starts a turn of its own once the session is idle, under the plugin's name (not as the person). */
999async function submitMessage($: Engine, sid: string, d: Delivery): Promise<void> {
1000 try {
1001 await $.prompt.submit({ text: d.text ?? '' });
1002 transcriptLine($, answeredLine(d, nowMs()));
1003 queueResult(sid, d.id, 'took');
1004 } catch (err) {
1005 record($, 'prompt.submit', err);
1006 queueResult(sid, d.id, 'refused');
1007 }
1008}
1009
1010/** /clear or /compact, as if the person typed it, once the session is idle. Never any other command. */
1011async function runCommand($: Engine, sid: string, d: Delivery, command: 'clear' | 'compact'): Promise<void> {
1012 try {
1013 await $.command.run({ command });
1014 transcriptLine($, answeredLine(d, nowMs()));
1015 queueResult(sid, d.id, 'took');
1016 } catch (err) {
1017 record($, 'command.run', err);
1018 queueResult(sid, d.id, 'refused');
1019 }
1020}
1021
1022/** "messages from Podium are on." once, the first time the session is idle with messages allowed; again after it stopped, or after a /clear (a new, empty transcript). */
1023function messagesLine($: Engine, r: Runtime): void {
1024 const on = responding(r, 'message');
1025 if (!on) {
1026 messagesLineShown = false;
1027 return;
1028 }
1029 if (messagesLineShown || !r.state.mainIdle) return;
1030 messagesLineShown = true;
1031 transcriptLine($, LINE_MESSAGES_ON);
1032}
1033
1034/**
1035 * The identity this session reports as (#226): the one it already holds (a reload re-runs register with the
1036 * options as they are now, which may be someone else's), else this terminal's PODIUM_USER and PODIUM_TOKEN,
1037 * else the shared options. Held for the rest of the session once it has a token.
1038 */
1039async function takeIdentity($: Engine): Promise<Identity> {
1040 let held: Identity | undefined;
1041 let readable = true;
1042 try {
1043 held = (await $.state.get(IDENTITY)).value ?? undefined;
1044 } catch (err) {
1045 readable = false;
1046 record($, 'identity', err);
1047 }
1048 const env = { user: await attempt(() => $.env.get('PODIUM_USER'), undefined), token: await attempt(() => $.env.get('PODIUM_TOKEN'), undefined) };
1049 const picked = pickIdentity({ held, env, options: { userName: opts.userName, token: opts.token } });
1050 // When the held identity could not be read, do not overwrite what may be there.
1051 if (readable && worthHolding(picked) && (!held || held.userName !== picked.userName || held.token !== picked.token)) {
1052 await attempt(() => $.state.set(IDENTITY, picked), undefined);
1053 }
1054 return picked;
1055}
1056
1057/** session.start (also after a hot reload): build the state, register /podium and the plan tool, start timers. */
1058async function start($: Engine, e: { cwd: string; surface: string | null }): Promise<void> {
1059 for (const t of rt?.timers ?? []) t.cancel();
1060 const now = nowMs();
1061 const id = await attempt(() => $.session.id(), `unknown-${now}`);
1062 who = await takeIdentity($);
1063 const model = await attempt<string | null>(() => $.session.model(), null);
1064 const root = await attempt(() => $.session.root(), e.cwd);
1065 const repo = await attempt(() => $.session.repo(), null);
1066 const version = await attempt(() => $.session.version(), null);
1067 const usage = await attempt(() => $.session.usage(), null);
1068 const projectRoot = repo?.root ?? root ?? e.cwd;
1069 const same = !!rt && rt.state.id === id;
1070 let state = same && rt ? rt.state : newSession({ id, cwd: e.cwd, startedAt: usage?.startedAt || now, model });
1071 state = setIdentity(state, { cwd: e.cwd, projectRoot, repoName: repo?.name ?? repoFromRemote(repo?.remote) ?? lastSegment(projectRoot), model });
1072 if (usage) state = measure(state, { context: usage.context, cost: usage.cost ?? null, rateLimits: usage.rateLimits, startedAt: usage.startedAt });
1073 if (!same) state = addEvent(state, 'start', null, `session.start · ${shortName(model)}`, now, { surface: e.surface });
1074 const fetch: FetchLike = (url, init) => $.http.fetch(url, init);
1075 const transport =
1076 rt?.transport ??
1077 new Transport(fetch, { serverUrl: opts.serverUrl, token: who.token, heartbeatMs: opts.heartbeatMs, version: VERSION }, (line) => remember('transport', line));
1078 rt = {
1079 state,
1080 transport,
1081 timers: [],
1082 storedName: await loadName($, id),
1083 engineVersion: version?.version ?? null,
1084 hostSurface: e.surface,
1085 flushing: false,
1086 lastStatus: null,
1087 toastedOk: rt?.toastedOk ?? false,
1088 toastedFail: rt?.toastedFail ?? false,
1089 toastedPlain: rt?.toastedPlain ?? false,
1090 toastedRefusal: rt?.toastedRefusal ?? null,
1091 toastedVersion: rt?.toastedVersion ?? null,
1092 skillCalls: 0,
1093 machine: rt?.machine ?? (await readMachine($)),
1094 clearedFrom: same && rt ? rt.clearedFrom : null,
1095 interactive: e.surface !== null,
1096 };
1097 acted = await loadActed($);
1098 const r = rt;
1099 await readAgents($, r, now);
1100 try {
1101 await $.command.register({ name: 'podium', description: 'Podium fleet console: link, connection state, and naming this session', argumentHint: '[name <text>]' });
1102 } catch (err) {
1103 record($, 'command.register', err);
1104 }
1105 try {
1106 await $.tool.register({
1107 name: 'plan',
1108 description: 'Names the plan this session is working through, for the Podium fleet console. Call once with a short title when you start a multi-step plan.',
1109 inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Short plan title' }, phase: { type: 'string', description: 'Optional phase name' } }, required: ['title'] },
1110 });
1111 } catch (err) {
1112 record($, 'tool.register', err);
1113 }
1114 r.timers.push($.clock.every(opts.flushIntervalMs, () => void flush($)));
1115 r.timers.push($.clock.every(GIT_POLL_MS, () => void pollGit($, r)));
1116 void pollGit($, r);
1117 showStatus($, r);
1118}
1119
1120/** session.end: the final report, awaited, inside a deadline on the engine's clock. */
1121async function end($: Engine, e: { reason: string; sessionId: string; resume?: { id: string } }, signal: AbortSignal | undefined): Promise<void> {
1122 const r = rt;
1123 if (!r) return;
1124 // The server closes a finished session's asks itself; the races here just stop.
1125 for (const a of [...asks.values()]) if (a.sessionId === (e.sessionId || r.state.id)) finish(a);
1126 const now = await engineNow($);
1127 if (e.sessionId === r.state.id || !e.sessionId) {
1128 r.state = sessionEnd(r.state, e.reason, e.resume?.id ?? e.sessionId ?? r.state.id, now);
1129 // The hooks sandbox has no setTimeout: the deadline is the engine's clock. A sleep the dispatch
1130 // abandons rejects; either way the race is over.
1131 const deadline = $.clock.sleep(END_FLUSH_MS, signal ? { signal } : undefined).catch(() => undefined);
1132 await Promise.race([
1133 (async () => {
1134 await r.transport.idle();
1135 await flushOnce($, r, now, true);
1136 })(),
1137 deadline,
1138 ]);
1139 }
1140 // After /clear the process goes on under a new id (the flusher picks it up); otherwise it is ending.
1141 if (e.reason !== 'clear') {
1142 for (const t of r.timers) t.cancel();
1143 r.timers = [];
1144 }
1145}
1146
1147/** /podium and /podium name <text>. */
1148async function podiumCommand($: Engine, args: string): Promise<string> {
1149 const r = rt;
1150 const m = /^name\s+([\s\S]+)$/i.exec(args.trim());
1151 if (m && r) {
1152 const name = m[1]!.trim().slice(0, 80);
1153 try {
1154 await $.store.set(`name:${r.state.id}`, name);
1155 } catch (err) {
1156 record($, 'store.set', err);
1157 }
1158 r.storedName = name;
1159 r.state = { ...r.state, dirty: true };
1160 return `Podium: this session is now named "${name}".`;
1161 }
1162 if (!r) return 'Podium reporter is loaded but the session has not started yet.';
1163 const t = r.transport;
1164 const hooks = Object.entries(stats)
1165 .filter(([, s]) => s.calls)
1166 .map(([k, s]) => `${k} ${(s.totalMs / s.calls).toFixed(2)} ms avg, ${s.maxMs.toFixed(2)} max`)
1167 .slice(0, 8);
1168 return [
1169 `Podium fleet console: ${opts.serverUrl.replace(/\/+$/, '')}/#/`,
1170 `Connection: ${t.online === null ? 'not tried yet' : t.online ? 'online' : 'offline, retrying'}${t.liveSessions !== null ? ` (${t.liveSessions} live sessions)` : ''}`,
1171 `Versions: reporter ${VERSION}${t.serverVersion ? `, server ${t.serverVersion}` : ''}`,
1172 t.ownerNotice() ?? t.versionNotice() ?? t.updateNotice() ?? '',
1173 `Reports sent: ${t.sent}, failed: ${t.failed}, rejected: ${t.rejected}; events queued: ${t.queue.length}${t.dropped ? `, dropped: ${t.dropped}` : ''}`,
1174 `Last error: ${t.lastError ?? errors[errors.length - 1]?.message ?? 'none'}`,
1175 `Session: ${r.state.id}${r.storedName ? ` (named "${r.storedName}")` : ''}`,
1176 describeBackground(r.state),
1177 hooks.length ? `Reporter time per hook: ${hooks.join('; ')}` : '',
1178 'Name this session: /podium name <text>',
1179 ]
1180 .filter(Boolean)
1181 .join('\n');
1182}
1183
1184// ---- registration -------------------------------------------------------------------------------
1185
1186export const register: Register = (on, rawOptions) => {
1187 opts = readOptions(rawOptions as Record<string, unknown>);
1188 who = { userName: '', token: '' };
1189 rt = null;
1190 generation++;
1191 errors = [];
1192 stats = {};
1193 explained = new Set();
1194 asks = new Map();
1195 outbox = [];
1196 acted = [];
1197 permissionHooks = null;
1198 lastPermissionAt = 0;
1199 for (const c of checks.values()) c.cancel();
1200 checks = new Map();hooks/git.ts 210 lines1// Git state of the session's working directory (SPEC 5.7). Parsing is pure; `readGit` runs the
2// queries through an injected runner (the mod passes `$.process.run`).
3import type { ChangedFile, FileStatus, FilesReport, GitInfo } from './protocol.ts';
4
5export interface RunResult {
6 exitCode: number;
7 stdout: string;
8}
9export type Runner = (argv: string[]) => Promise<RunResult>;
10
11/** POSIX path resolution of `p` against `base` (no Node in the hooks sandbox). */
12export function resolvePath(base: string, p: string): string {
13 const parts = (p.startsWith('/') ? p : `${base}/${p}`).split('/');
14 const out: string[] = [];
15 for (const part of parts) {
16 if (!part || part === '.') continue;
17 if (part === '..') out.pop();
18 else out.push(part);
19 }
20 return '/' + out.join('/');
21}
22
23export interface StatusLine {
24 ahead: number;
25 behind: number;
26 dirty: number;
27}
28
29/** `git status --porcelain=v1 -b`: the `##` line gives ahead/behind; every other line is one changed path. */
30export function parseStatus(stdout: string): StatusLine {
31 const lines = stdout.split('\n').filter((l) => l.length > 0);
32 let ahead = 0;
33 let behind = 0;
34 const head = lines[0]?.startsWith('##') ? lines[0] : null;
35 if (head) {
36 const a = /ahead (\d+)/.exec(head);
37 const b = /behind (\d+)/.exec(head);
38 ahead = a ? Number(a[1]) : 0;
39 behind = b ? Number(b[1]) : 0;
40 }
41 return { ahead, behind, dirty: lines.length - (head ? 1 : 0) };
42}
43
44export function lastSegment(p: string): string {
45 return p.split('/').filter(Boolean).pop() ?? p;
46}
47
48/** Reads branch, worktree and status; null when `cwd` is not in a repository or a query fails. */
49export async function readGit(run: Runner, cwd: string): Promise<GitInfo | null> {
50 const ok = async (argv: string[]) => {
51 const r = await run(argv);
52 if (r.exitCode !== 0) throw new Error(`${argv.join(' ')} exited ${r.exitCode}`);
53 return r.stdout.trim();
54 };
55 try {
56 const [abbrev, gitDir, commonDir, top, status] = await Promise.all([
57 ok(['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
58 ok(['git', 'rev-parse', '--git-dir']),
59 ok(['git', 'rev-parse', '--git-common-dir']),
60 ok(['git', 'rev-parse', '--show-toplevel']),
61 run(['git', 'status', '--porcelain=v1', '-b']),
62 ]);
63 const detached = abbrev === 'HEAD';
64 const branch = detached ? await ok(['git', 'rev-parse', '--short', 'HEAD']).catch(() => null) : abbrev;
65 const isWorktree = resolvePath(cwd, gitDir) !== resolvePath(cwd, commonDir);
66 const st = status.exitCode === 0 ? parseStatus(status.stdout) : { ahead: 0, behind: 0, dirty: 0 };
67 return { branch, detached, isWorktree, worktreePath: isWorktree ? top : null, ...st };
68 } catch {
69 return null;
70 }
71}
72
73/** The empty tree: the base for a repository with no commits yet. */
74export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
75/** The most files listed in a report; the totals still cover every file. */
76export const MAX_FILES = 200;
77/** Untracked files are line-counted by one `git grep` per chunk of this many paths. */
78const GREP_CHUNK = 100;
79/** Empty and binary untracked files `git grep` leaves out get their own `git diff --no-index`, this many per read; the rest stay uncounted. */
80const NUMSTAT_LIMIT = 20;
81
82export interface NameStatus {
83 status: FileStatus;
84 path: string;
85 from: string | null;
86}
87
88/** `git diff --name-status -z -M`: `M\0path\0`, and `R100\0old\0new\0` for a rename. */
89export function parseNameStatus(stdout: string): NameStatus[] {
90 const t = stdout.split('\0');
91 const out: NameStatus[] = [];
92 for (let i = 0; i < t.length; i++) {
93 const code = t[i];
94 if (!code) continue;
95 const c = code[0];
96 if (c === 'R' || c === 'C') {
97 const from = t[i + 1];
98 const path = t[i + 2];
99 i += 2;
100 if (path) out.push({ status: c === 'R' ? 'renamed' : 'added', path, from: c === 'R' ? (from ?? null) : null });
101 } else {
102 const path = t[++i];
103 if (path) out.push({ status: c === 'A' ? 'added' : c === 'D' ? 'deleted' : 'modified', path, from: null });
104 }
105 }
106 return out;
107}
108
109export interface LineCount {
110 added: number | null;
111 removed: number | null;
112}
113
114const count = (v: string): number | null => (v === '-' ? null : Number(v));
115
116/** `git diff --numstat -z -M`, keyed by the (new) path. Binary files have `-` counts; a rename has an empty path and the two names follow. */
117export function parseNumstat(stdout: string): Map<string, LineCount> {
118 const t = stdout.split('\0');
119 const out = new Map<string, LineCount>();
120 for (let i = 0; i < t.length; i++) {
121 const m = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(t[i] ?? '');
122 if (!m) continue;
123 let path = m[3] ?? '';
124 if (path === '') {
125 path = t[i + 2] ?? '';
126 i += 2;
127 }
128 if (path) out.set(path, { added: count(m[1]!), removed: count(m[2]!) });
129 }
130 return out;
131}
132
133export interface Changes {
134 /** The starting commit, to hand back on the next read. */
135 base: string;
136 files: FilesReport;
137}
138
139/**
140 * The files changed since the session started (SPEC 5.7). `base` is the commit HEAD was at when the
141 * session began, or null on the first read, which takes HEAD (or the empty tree before the first
142 * commit). Returns null when a query fails, so the caller keeps what it had.
143 */
144export async function readChanges(run: Runner, cwd: string, base: string | null): Promise<Changes | null> {
145 try {
146 const top = (await run(['git', 'rev-parse', '--show-toplevel'])).stdout.trim() || cwd;
147 const git = (...args: string[]) => ['git', '-C', top, ...args];
148 const out = async (argv: string[], okCodes: number[] = [0]) => {
149 const r = await run(argv);
150 if (!okCodes.includes(r.exitCode)) throw new Error(`${argv.join(' ')} exited ${r.exitCode}`);
151 return r.stdout;
152 };
153 const headRes = await run(git('rev-parse', 'HEAD'));
154 const head = headRes.exitCode === 0 ? headRes.stdout.trim() : null;
155 const start = base ?? head ?? EMPTY_TREE;
156 let ref: string;
157 let since: FilesReport['since'] = 'start';
158 if (start === EMPTY_TREE) ref = EMPTY_TREE;
159 else if ((await run(git('merge-base', '--is-ancestor', start, 'HEAD'))).exitCode === 0) ref = start;
160 else {
161 // A rebase, an amend or a branch switch dropped the starting commit.
162 const up = await run(git('merge-base', 'HEAD', '@{upstream}'));
163 const mb = up.exitCode === 0 ? up.stdout.trim() : '';
164 since = mb ? 'upstream' : 'worktree';
165 ref = mb || head || EMPTY_TREE;
166 }
167 const [names, nums, others] = await Promise.all([
168 out(git('diff', '--name-status', '-z', '-M', ref)),
169 out(git('diff', '--numstat', '-z', '-M', ref)),
170 out(git('ls-files', '-z', '--others', '--exclude-standard', '--full-name')),
171 ]);
172 const lines = parseNumstat(nums);
173 const all: ChangedFile[] = parseNameStatus(names).map((n) => ({ path: n.path, status: n.status, from: n.from, added: lines.get(n.path)?.added ?? null, removed: lines.get(n.path)?.removed ?? null }));
174 for (const p of others.split('\0').filter(Boolean)) all.push({ path: p, status: 'added', from: null, added: null, removed: null });
175 all.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
176 // Only the files that will be listed need counts, so pick them after sorting; the rest still count toward the total.
177 const tracked = new Set(parseNameStatus(names).map((n) => n.path));
178 const pending = all.slice(0, MAX_FILES).filter((f) => !tracked.has(f.path));
179 // One `git grep -c` per chunk counts the lines of every text file in it. It leaves out empty and binary
180 // files, which keep the per-file numstat result below, for the first few only.
181 const grepable = pending.filter((f) => !f.path.includes('\n'));
182 const counted = new Set<string>();
183 for (let i = 0; i < grepable.length; i += GREP_CHUNK) {
184 const chunk = new Map(grepable.slice(i, i + GREP_CHUNK).map((f) => [f.path, f]));
185 const counts = await out(['git', '-C', top, '--literal-pathspecs', 'grep', '--no-index', '-I', '-c', '-z', '', '--', ...chunk.keys()], [0, 1]);
186 for (const line of counts.split('\n')) {
187 const cut = line.lastIndexOf('\0');
188 const f = cut > 0 ? chunk.get(line.slice(0, cut)) : undefined;
189 const n = Number(line.slice(cut + 1));
190 if (!f || !Number.isInteger(n)) continue;
191 f.added = n;
192 f.removed = 0;
193 counted.add(f.path);
194 }
195 }
196 const rest = pending.filter((f) => !counted.has(f.path)).slice(0, NUMSTAT_LIMIT);
197 const got = await Promise.all(
198 rest.map(async (f) => parseNumstat(await out(git('diff', '--no-index', '--numstat', '-z', '--', '/dev/null', f.path), [0, 1])).get(f.path) ?? { added: null, removed: null }),
199 );
200 rest.forEach((f, j) => {
201 f.added = got[j]!.added;
202 f.removed = got[j]!.removed;
203 });
204 const sum = (k: 'added' | 'removed') => all.reduce((n, f) => n + (f[k] ?? 0), 0);
205 return { base: start, files: { files: all.slice(0, MAX_FILES), total: all.length, added: sum('added'), removed: sum('removed'), since } };
206 } catch {
207 return null;
208 }
209}
210hooks/models.ts 101 lines1// Model families, context windows and the price table (SPEC 6.5). Pure; no `$` here.
2//
3// SOURCE: Anthropic's pricing sheet (platform.claude.com/docs/en/about-claude/pricing), taken 2026-10-13.
4// Prices are first-party API list prices in USD per million tokens. Cache writes are priced at the
5// 5-minute TTL (1.25x input). Haiku 5.5 is priced at its <=100K-prompt tier ($0.10 / $0.50); longer
6// prompts cost 5x, which this estimate ignores.
7// Since #117 these figures are only the fallback for a server that predates per-model usage: a current
8// server prices the `usageByModel` totals in each report with its own table and the Admin's prices.
9// Every figure here is an ESTIMATE: Podium never presents it as a bill.
10
11export const PRICES_AS_OF = '2026-10-13';
12
13export type Family = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'other';
14
15export interface Price {
16 input: number;
17 output: number;
18 cacheRead: number;
19 cacheWrite: number;
20}
21
22export interface Tokens {
23 input: number;
24 output: number;
25 cacheRead: number;
26 cacheWrite: number;
27}
28
29/**
30 * Lowercase substring match: `opus`, `sonnet`, `haiku`, `fable`, else `other`. Only a Claude model
31 * counts: a bare alias (`opus`, `sonnet[1m]`) or an id containing `claude`. A local model such as
32 * `my-sonnet-finetune:latest` is `other` even though its name holds a family word.
33 */
34export function family(model: string | null | undefined): Family {
35 const m = (model ?? '').toLowerCase();
36 if (!/^(opus|sonnet|haiku|fable|opusplan)(\[[^\]]*\])?$/.test(m) && !m.includes('claude')) return 'other';
37 if (m.includes('opus')) return 'opus';
38 if (m.includes('sonnet')) return 'sonnet';
39 if (m.includes('haiku')) return 'haiku';
40 if (m.includes('fable')) return 'fable';
41 return 'other';
42}
43
44function price(input: number, output: number, cacheRead: number): Price {
45 return { input, output, cacheRead, cacheWrite: Math.round(input * 1.25 * 1000) / 1000 };
46}
47
48/** Current generation per family (an alias such as `opus` resolves to these). */
49const FAMILY_PRICE: Record<Exclude<Family, 'other'>, Price> = {
50 opus: price(4, 20, 0.2), // claude-opus-5-5
51 sonnet: price(2, 10, 0.1), // claude-sonnet-5-5
52 haiku: price(0.1, 0.5, 0.01), // claude-haiku-5-5 (<=100K prompt tier)
53 fable: price(10, 50, 0.25), // claude-fable-5-1
54};
55
56/** Older ids that are still served and priced differently from their family's current model. */
57const ID_PRICE: [RegExp, Price][] = [
58 [/fable-5(?!-1)/, price(10, 50, 1)], // claude-fable-5 (and mythos-5)
59 [/opus-5(?!-5)/, price(5, 25, 0.5)], // claude-opus-5
60 [/opus-4-1(?!\d)|opus-4(?!-[5-8])(?!\d)/, price(15, 75, 1.5)], // claude-opus-4-1 and claude-opus-4
61 [/opus-4/, price(5, 25, 0.5)], // claude-opus-4-5 .. 4-8
62 [/sonnet-5(?!-5)/, price(2, 10, 0.2)], // claude-sonnet-5
63 [/sonnet-4/, price(3, 15, 0.3)], // claude-sonnet-4-6
64 [/haiku-4/, price(1, 5, 0.1)], // claude-haiku-4-5
65 [/haiku-3-5/, price(0.8, 4, 0.08)], // claude-haiku-3-5
66];
67
68/** USD per million tokens for a model id, an alias, or a family name. `null` for a non-Claude model. */
69export function priceOf(model: string | null | undefined): Price | null {
70 const m = (model ?? '').toLowerCase();
71 const f = family(m);
72 if (f === 'other') return null;
73 for (const [re, p] of ID_PRICE) if (re.test(m)) return p;
74 return FAMILY_PRICE[f];
75}
76
77/**
78 * Context window in tokens, or `null` for a non-Claude model. Every current family has a 1M window; Haiku 4.5 (and older Haiku and
79 * Claude 3 ids) have 200K. For the main loop prefer `$.session.usage().context.window`.
80 */
81export function windowOf(model: string | null | undefined): number | null {
82 const m = (model ?? '').toLowerCase();
83 if (family(m) === 'other') return null;
84 if (/haiku-4|haiku-3|claude-3/.test(m)) return 200_000;
85 return 1_000_000;
86}
87
88/** Estimated cost in USD: Σ tokens × price / 1e6. `null` when the model has no price. */
89export function costOf(t: Tokens, model: string | null | undefined): number | null {
90 const p = priceOf(model);
91 if (!p) return null;
92 return (t.input * p.input + t.output * p.output + t.cacheRead * p.cacheRead + t.cacheWrite * p.cacheWrite) / 1e6;
93}
94
95/** `Opus`, `Sonnet`, `Haiku`, `Fable`, or the raw model string. */
96export function shortName(model: string | null | undefined): string {
97 const f = family(model);
98 if (f === 'other') return model ?? 'unknown';
99 return f[0]!.toUpperCase() + f.slice(1);
100}
101hooks/report.ts 211 lines1// SessionState -> Report (SPEC 6.2). Pure. Every free-text field passes through redaction and is cut
2// to its limit here, so nothing leaves the machine unredacted.
3import { clip, excerpt, LIMITS } from './failures.ts';
4import { lastSegment } from './git.ts';
5import { family, windowOf } from './models.ts';
6import { toPlan } from './plan.ts';
7import type { ActivityEntry, ActivityEvent, AgentReport, Ask, Failure, LoopReport, MachineInfo, ModelUsage, Report, RespondInfo } from './protocol.ts';
8import { mergeUsage, resolveOwners, type AgentState, type LoopStats, type SessionState } from './state.ts';
9
10export const REPORTER_NAME = 'podium-reporter';
11export const MAX_EVENTS_PER_REPORT = 100;
12export const MAX_AGENTS_PER_REPORT = 200;
13/** Full prompt and answer characters in one report; the server accepts a 512 kB body. The rest goes in later reports. */
14export const MAX_FULL_TEXT_PER_REPORT = 120_000;
15/** Activity-feed characters in one report (#118), as the server takes them: the orchestrator's first, then the agents'. */
16export const MAX_ACTIVITY_PER_REPORT = 60_000;
17
18export interface ReportMeta {
19 version: string;
20 engineVersion: string | null;
21 hostSurface: string | null;
22 now: number;
23 /** Set with `/podium name`, kept in $.store per session id. */
24 storedName: string | null;
25 /** The `sessionName` option. */
26 optionName: string | null;
27 /** The `userName` option: the person this reporter is configured as. The server checks it against the token. */
28 userName?: string | null;
29 /** This machine; sent only together with a userName. */
30 machine?: MachineInfo | null;
31 /** The `fullPrompts` option: send each agent's whole prompt and answer, once. */
32 fullText?: boolean;
33 /** The `activityFeed` option: send the feed of each loop the server says is watched (#118). */
34 activity?: boolean;
35 /** Whether and how far the session can be answered from Podium (#206); omitted by nothing that responds. */
36 respond?: RespondInfo | null;
37 /** The asks open now, sent only while the server takes answers (#206). */
38 asks?: Ask[] | null;
39 /** The session this one continues after a /clear. */
40 clearedFrom?: string | null;
41}
42
43/** Display name (SPEC 6.6): /podium name, the option, a worktree's branch or folder, the repo folder, the id. */
44export function displayName(s: Pick<SessionState, 'id' | 'git' | 'projectRoot' | 'cwd'>, storedName: string | null, optionName: string | null): string {
45 const pick = (v: string | null | undefined) => (v && v.trim() ? v.trim() : null);
46 const name =
47 pick(storedName) ??
48 pick(optionName) ??
49 (s.git?.isWorktree ? pick(s.git.branch && !s.git.detached ? lastSegment(s.git.branch) : null) ?? pick(s.git.worktreePath ? lastSegment(s.git.worktreePath) : null) : null) ??
50 pick(s.projectRoot ? lastSegment(s.projectRoot) : null) ??
51 `session-${s.id.slice(0, 6)}`;
52 return clip(name, 80);
53}
54
55/** `owner/name` from an origin URL (`git@github.com:o/n.git`, `https://host/o/n`). */
56export function repoFromRemote(remote: string | null | undefined): string | null {
57 if (!remote) return null;
58 const m = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote.trim());
59 return m ? `${m[1]}/${m[2]}` : null;
60}
61
62function cleanFailure(f: Failure | null): Failure | null {
63 if (!f) return null;
64 return {
65 ...f,
66 tool: f.tool ? clip(f.tool, 120) : null,
67 call: f.call ? clip(f.call, 300) : null,
68 outputTail: f.outputTail.slice(-12).map((l) => clip(l, 240)),
69 evidence: f.evidence.slice(0, 5).map((l) => clip(l, 240)),
70 explanation: f.explanation ? clip(f.explanation, 400) : null,
71 };
72}
73
74function pct(tokens: number | null, window: number | null): number | null {
75 if (tokens === null || !window) return null;
76 return Math.round(Math.min(100, (tokens / window) * 100) * 10) / 10;
77}
78
79/** The fields an agent entry and the main loop's block share: skills, last tool, call counts and steps. */
80function loopFields(l: LoopStats): LoopReport {
81 return {
82 skills: l.skills.slice(0, 50).map((k) => clip(k, 120)),
83 lastTool: l.lastTool,
84 toolCalls: l.toolCalls,
85 toolCounts: Object.fromEntries(Object.entries(l.toolCounts).map(([k, n]) => [clip(k, 120), n])),
86 steps: { done: l.steps, total: null },
87 };
88}
89
90/** The rows the server accepts: model ids clipped, and no more than it takes. */
91function usageRows(rows: ModelUsage[]): ModelUsage[] {
92 return rows.slice(0, 40).map((r) => ({ ...r, model: clip(r.model, 120) }));
93}
94
95/** A loop's feed as it is sent: every entry redacted and cut again here, like every other free-text field. */
96function feed(list: readonly ActivityEntry[]): ActivityEntry[] {
97 return list.map((e) => ({ at: e.at, kind: e.kind, text: clip(e.text, LIMITS.activity) }));
98}
99
100export function agentReport(a: AgentState, fullText = false, activity = false): AgentReport {
101 const window = a.window ?? (a.model ? windowOf(a.model) : null);
102 return {
103 id: clip(a.id, 200),
104 parentId: a.parentId,
105 kind: a.kind,
106 type: clip(a.type, 120),
107 name: a.name ? clip(a.name, 80) : null,
108 description: clip(a.description, 240),
109 model: a.model,
110 effort: a.effort,
111 effortSource: a.effortSource,
112 status: a.status,
113 startedAt: a.startedAt,
114 endedAt: a.endedAt,
115 context: { tokens: a.ctxTokens, window, percent: pct(a.ctxTokens, window) },
116 tokens: { ...a.tokens },
117 costUsdEst: a.model && family(a.model) === 'other' ? null : Math.round(a.cost * 1e6) / 1e6,
118 usageByModel: usageRows(mergeUsage(a.usage)),
119 ...loopFields(a),
120 blowouts: a.blowouts,
121 promptExcerpt: excerpt(a.promptExcerpt),
122 answerExcerpt: a.answerExcerpt ? excerpt(a.answerExcerpt) : null,
123 ...(fullText && a.promptText && !a.promptSent ? { promptText: a.promptText } : {}),
124 ...(fullText && a.answerText && !a.answerSent ? { answerText: a.answerText } : {}),
125 ...(activity && a.activity.length ? { activity: feed(a.activity) } : {}),
126 failure: cleanFailure(a.failure),
127 };
128}
129
130/** Drops full texts past the per-report budget (oldest agents first kept); they are still unsent, so a later report carries them. */
131function withinTextBudget(reports: AgentReport[]): AgentReport[] {
132 let left = MAX_FULL_TEXT_PER_REPORT;
133 return reports.map((r) => {
134 const { promptText, answerText, ...rest } = r;
135 const out: AgentReport = rest;
136 for (const [k, v] of [['promptText', promptText], ['answerText', answerText]] as const) {
137 if (v === undefined) continue;
138 if (v.length <= left || left === MAX_FULL_TEXT_PER_REPORT) {
139 out[k] = v;
140 left -= v.length;
141 }
142 }
143 return out;
144 });
145}
146
147/** Leaves out the feeds past the per-report budget, the orchestrator's first, then the agents' in order, as the server would. */
148function withinActivityBudget(r: Report): Report {
149 let left = MAX_ACTIVITY_PER_REPORT;
150 const fits = (list: ActivityEntry[]) => {
151 const n = list.reduce((sum, e) => sum + e.text.length, 0);
152 if (n > left) return false;
153 left -= n;
154 return true;
155 };
156 if (r.session.activity && !fits(r.session.activity)) delete r.session.activity;
157 for (const a of r.agents) if (a.activity && !fits(a.activity)) delete a.activity;
158 return r;
159}
160
161/** A full snapshot of the session plus the events given (at most 100). */
162export function buildReport(s0: SessionState, events: readonly ActivityEvent[], meta: ReportMeta): Report {
163 const s = resolveOwners(s0);
164 const ctxWindow = s.context.window ?? s.main.window ?? (s.main.model ? windowOf(s.main.model) : null);
165 const ctxTokens = s.context.tokens ?? s.main.ctxTokens;
166 const ctxPercent = s.context.percent ?? pct(ctxTokens, ctxWindow);
167 // Most recent agents win if a session ever has more than the server accepts.
168 const agents = s.order.map((id) => s.agents[id]).filter((a): a is AgentState => !!a).slice(-MAX_AGENTS_PER_REPORT);
169 const activity = meta.activity === true;
170 return withinActivityBudget({
171 v: 1,
172 sentAt: meta.now,
173 reporter: { name: REPORTER_NAME, version: meta.version, engineVersion: meta.engineVersion, hostSurface: meta.hostSurface },
174 ...(meta.userName && meta.userName.trim() ? { userName: clip(meta.userName.trim(), 80), ...(meta.machine ? { machine: { ...meta.machine } } : {}) } : {}),
175 session: {
176 id: s.id,
177 name: displayName(s, meta.storedName, meta.optionName),
178 status: s.status,
179 waitReason: s.status === 'waiting' ? s.waitReason : null,
180 waitNote: s.status === 'waiting' && s.waitNote ? clip(s.waitNote, 160) : null,
181 endReason: s.status === 'done' ? s.endReason : null,
182 resumeId: s.resumeId,
183 startedAt: s.startedAt,
184 cwd: s.cwd,
185 projectRoot: s.projectRoot,
186 repoName: s.repoName,
187 git: s.git,
188 model: s.main.model,
189 effort: s.main.effort,
190 context: { tokens: ctxTokens, window: ctxWindow, percent: ctxPercent === null ? null : Math.max(0, Math.min(100, ctxPercent)) },
191 costUsd: s.main.model && family(s.main.model) === 'other' ? null : s.costUsd,
192 tokens: { ...s.main.tokens },
193 usageByModel: usageRows(mergeUsage(s.main.usage, ...s.order.map((id) => s.agents[id]?.usage))),
194 rateLimits: s.rateLimits.slice(0, 10),
195 blowouts: s.main.blowouts,
196 plan: toPlan(s.plan),
197 loop: loopFields(s.main),
198 attached: s.attached,
199 ...(s.status === 'waiting' && s.background ? { background: s.background } : {}),
200 files: { ...s.files, files: s.files.files.map((f) => ({ ...f, path: clip(f.path, 300), from: f.from === null ? null : clip(f.from, 300) })) },
201 activityFeed: activity,
202 ...(activity && s.main.activity.length ? { activity: feed(s.main.activity) } : {}),
203 ...(meta.respond ? { respond: meta.respond } : {}),
204 ...(meta.asks ? { asks: meta.asks.slice(0, 8) } : {}),
205 ...(meta.clearedFrom ? { clearedFrom: meta.clearedFrom } : {}),
206 },
207 agents: withinTextBudget(agents.map((a) => agentReport(a, meta.fullText === true, activity))),
208 events: events.slice(0, MAX_EVENTS_PER_REPORT).map((e) => ({ ...e, text: clip(e.text, 240) })),
209 });
210}
211hooks/state.ts 911 lines1// The in-memory model of one session, and the pure reducers the hooks feed it (SPEC 5.3, 5.6).
2// Every reducer returns a new state and leaves its input alone. No `$` here, and nothing UI: a pane
3// added later can read this state as it is.
4import {
5 buildFailure,
6 candidateFrom,
7 classify,
8 evidence,
9 excerpt,
10 fullText,
11 clip,
12 type EndReason,
13 type FailureCandidate,
14} from './failures.ts';
15import { costOf, shortName, windowOf } from './models.ts';
16import { assignOwners, emptyPlan, setTitle, taskCreate, taskUpdate, todoWrite, type PlanState } from './plan.ts';
17import { backgroundSummary, MAIN_LOOP, type ActivityEntry, type ActivityEvent, type AgentStatus, type BackgroundKind, type BackgroundTask, type BackgroundWait, type Effort, type Failure, type FilesReport, type GitInfo, type ModelUsage, type RateLimit } from './protocol.ts';
18
19export const BLOWOUT_DEDUP_MS = 2000;
20const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
21export const EVENT_QUEUE_MAX = 500;
22/** Nothing changed yet (also what a session that is not in a repository reports). */
23export const NO_FILES: FilesReport = { files: [], total: 0, added: 0, removed: 0, since: 'start' };
24export const PLAN_TOOL = 'mcp__podium-reporter__plan';
25/** Activity entries a watched loop keeps (#118). */
26export const ACTIVITY_KEPT = 20;
27
28export interface Tokens {
29 input: number;
30 output: number;
31 cacheRead: number;
32 cacheWrite: number;
33}
34
35export interface Usage {
36 input_tokens: number;
37 output_tokens: number;
38 cache_read_input_tokens: number;
39 cache_creation_input_tokens: number;
40 /** Where Claude Code gives it: the cache writes split by how long the cache entry lives. */
41 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } | null;
42 model?: string;
43}
44
45/** A request whose prompt (input + cache reads + cache writes) is over this is priced at a model's long-prompt tier. */
46export const LONG_PROMPT_TOKENS = 100_000;
47
48const usageKey = (model: string, tier: 'std' | 'long') => `${model}\u0000${tier}`;
49
50/** Adds one request's tokens to a loop's per-model, per-tier totals (issue #117). The server prices them. */
51export function addUsage(usage: Record<string, ModelUsage> | undefined, model: string | null, u: Usage): Record<string, ModelUsage> {
52 const prior = usage ?? {};
53 if (!model) return prior;
54 const input = u.input_tokens || 0;
55 const cacheRead = u.cache_read_input_tokens || 0;
56 const written = u.cache_creation_input_tokens || 0;
57 // The 1-hour part, when Claude Code says; the rest of the cache writes is the 5-minute rate.
58 const hour = Math.min(written, Math.max(0, u.cache_creation?.ephemeral_1h_input_tokens || 0));
59 const tier = input + cacheRead + written > LONG_PROMPT_TOKENS ? 'long' : 'std';
60 const key = usageKey(model, tier);
61 const was = prior[key]?.tokens ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
62 return {
63 ...prior,
64 [key]: {
65 model,
66 tier,
67 tokens: {
68 input: was.input + input,
69 output: was.output + (u.output_tokens || 0),
70 cacheRead: was.cacheRead + cacheRead,
71 cacheWrite: was.cacheWrite + (written - hour),
72 cacheWrite1h: was.cacheWrite1h + hour,
73 },
74 },
75 };
76}
77
78/** Folds several loops' totals into one list, one row per model and tier, in a stable order. */
79export function mergeUsage(...sets: (Record<string, ModelUsage> | undefined)[]): ModelUsage[] {
80 const out = new Map<string, ModelUsage>();
81 for (const set of sets) {
82 for (const row of Object.values(set ?? {})) {
83 const key = usageKey(row.model, row.tier);
84 const was = out.get(key)?.tokens;
85 out.set(key, {
86 model: row.model,
87 tier: row.tier,
88 tokens: {
89 input: (was?.input ?? 0) + row.tokens.input,
90 output: (was?.output ?? 0) + row.tokens.output,
91 cacheRead: (was?.cacheRead ?? 0) + row.tokens.cacheRead,
92 cacheWrite: (was?.cacheWrite ?? 0) + row.tokens.cacheWrite,
93 cacheWrite1h: (was?.cacheWrite1h ?? 0) + row.tokens.cacheWrite1h,
94 },
95 });
96 }
97 }
98 return [...out.values()].sort((a, b) => (a.model < b.model ? -1 : a.model > b.model ? 1 : a.tier < b.tier ? -1 : a.tier > b.tier ? 1 : 0));
99}
100
101/** The most distinct tool names kept per loop; later new names count in toolCalls only. */
102export const MAX_TOOL_NAMES = 100;
103
104export interface LoopStats {
105 model: string | null;
106 effort: Effort | null;
107 ctxTokens: number | null;
108 window: number | null;
109 tokens: Tokens;
110 /** Tokens per model and prompt-size tier, for the server to price (issue #117). */
111 usage: Record<string, ModelUsage>;
112 cost: number;
113 /** Model requests seen. */
114 steps: number;
115 toolCalls: number;
116 /** Finished calls per tool name; at most MAX_TOOL_NAMES distinct names. */
117 toolCounts: Record<string, number>;
118 lastTool: string | null;
119 skills: string[];
120 blowouts: number;
121 blowTimes: number[];
122 lastStopReason: string | null;
123 candidate: FailureCandidate | null;
124 failedCalls: Record<string, number>;
125 reads: Record<string, number>;
126 /** The loop's last 20 activity entries (#118), oldest first. Empty unless the loop is watched. */
127 activity: ActivityEntry[];
128}
129
130export interface AgentState extends LoopStats {
131 id: string;
132 parentId: string | null;
133 kind: 'subagent' | 'fork' | 'teammate';
134 type: string;
135 name: string | null;
136 description: string;
137 effortSource: 'requested' | 'observed' | 'inherited' | 'unknown';
138 status: AgentStatus;
139 startedAt: number;
140 endedAt: number | null;
141 promptExcerpt: string;
142 answerExcerpt: string | null;
143 /** The whole redacted text, kept so the `fullPrompts` option can send it; null until known. */
144 promptText: string | null;
145 answerText: string | null;
146 /** The current text has gone out in an accepted report; it is not sent again. */
147 promptSent: boolean;
148 answerSent: boolean;
149 failure: Failure | null;
150 toolUseId: string | null;
151}
152
153export interface PendingSpawn {
154 effort: Effort | null;
155 model: string | null;
156 subagentType: string | null;
157 description: string | null;
158 name: string | null;
159 at: number;
160}
161
162export interface SessionState {
163 id: string;
164 startedAt: number;
165 cwd: string;
166 projectRoot: string | null;
167 repoName: string | null;
168 git: GitInfo | null;
169 /** The commit HEAD was at when the session began; the files list is the diff against it. */
170 gitBase: string | null;
171 files: FilesReport;
172 /** Whether any client is attached; null until the roster has been read. */
173 attached: boolean | null;
174 status: 'running' | 'waiting' | 'done';
175 /** The main loop is between turns (waiting for the person). */
176 mainIdle: boolean;
177 waitReason: 'permission' | 'input' | null;
178 waitNote: string | null;
179 /** Background work or a scheduled wake-up the idle main loop is waiting on (#119); null when none. */
180 background: BackgroundWait | null;
181 endReason: string | null;
182 resumeId: string | null;
183 ended: boolean;
184 main: LoopStats;
185 /** The main loop's context as the status line reads it (preferred over step usage). */
186 context: { tokens: number | null; window: number | null; percent: number | null };
187 costUsd: number | null;
188 rateLimits: RateLimit[];
189 plan: PlanState;
190 agents: Record<string, AgentState>;
191 /** Agent ids in spawn order. */
192 order: string[];
193 /** Agent tool calls seen before (or after) their spawn, by tool_use_id. */
194 pending: Record<string, PendingSpawn>;
195 events: ActivityEvent[];
196 evSeq: number;
197 dirty: boolean;
198 /** The loops the server last said someone is watching (agent ids, MAIN_LOOP for the main loop); always empty with the activityFeed option off. */
199 watched: string[];
200}
201
202export function emptyLoop(model: string | null = null): LoopStats {
203 return {
204 model,
205 effort: null,
206 ctxTokens: null,
207 window: null,
208 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
209 usage: {},
210 cost: 0,
211 steps: 0,
212 toolCalls: 0,
213 toolCounts: {},
214 lastTool: null,
215 skills: [],
216 blowouts: 0,
217 blowTimes: [],
218 lastStopReason: null,
219 candidate: null,
220 failedCalls: {},
221 reads: {},
222 activity: [],
223 };
224}
225
226export function newSession(i: { id: string; cwd: string; startedAt: number; model?: string | null }): SessionState {
227 return {
228 id: i.id,
229 startedAt: i.startedAt,
230 cwd: i.cwd,
231 projectRoot: null,
232 repoName: null,
233 git: null,
234 gitBase: null,
235 files: NO_FILES,
236 attached: null,
237 status: 'waiting',
238 mainIdle: true,
239 waitReason: 'input',
240 waitNote: null,
241 background: null,
242 endReason: null,
243 resumeId: null,
244 ended: false,
245 main: emptyLoop(i.model ?? null),
246 context: { tokens: null, window: null, percent: null },
247 costUsd: null,
248 rateLimits: [],
249 plan: emptyPlan,
250 agents: {},
251 order: [],
252 pending: {},
253 events: [],
254 evSeq: 0,
255 dirty: true,
256 watched: [],
257 };
258}
259
260/** A fresh session under a new id (after /clear), keeping where it runs. */
261export function resetForNewId(s: SessionState, id: string, startedAt: number): SessionState {
262 const n = newSession({ id, cwd: s.cwd, startedAt, model: s.main.model });
263 return { ...n, projectRoot: s.projectRoot, repoName: s.repoName, git: s.git, main: { ...n.main, effort: s.main.effort } };
264}
265
266// ---- helpers ------------------------------------------------------------------------------------
267
268export function asEffort(v: unknown): Effort | null {
269 return typeof v === 'string' && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : null;
270}
271
272function str(v: unknown): string | null {
273 return typeof v === 'string' && v ? v : null;
274}
275
276/** Recomputes the session status from its parts. */
277function withStatus(s: SessionState): SessionState {
278 let status: SessionState['status'];
279 let waitReason = s.waitReason;
280 if (s.ended) {
281 status = 'done';
282 waitReason = null;
283 } else if (s.waitReason === 'permission') status = 'waiting';
284 else if (s.mainIdle && !Object.values(s.agents).some((a) => a.status === 'running' || a.status === 'queued')) {
285 status = 'waiting';
286 // Idle on background work or a wake-up is not a wait on the person (#119).
287 waitReason = s.background ? null : 'input';
288 } else {
289 status = 'running';
290 waitReason = null;
291 }
292 const waitNote = waitReason ? s.waitNote : null;
293 return { ...s, status, waitReason, waitNote, dirty: true };
294}
295
296export function addEvent(s: SessionState, kind: ActivityEvent['kind'], agentId: string | null, text: string, t: number, data: Record<string, unknown> | null = null): SessionState {
297 const n = s.evSeq + 1;
298 const ev: ActivityEvent = { id: `${s.id}:${agentId ?? 'main'}:${kind}:${t}:${n}`, t, kind, agentId, text: clip(text.replace(/\s+/g, ' ').trim(), 240), data };
299 const events = [...s.events, ev];
300 return { ...s, evSeq: n, events: events.length > EVENT_QUEUE_MAX ? events.slice(-EVENT_QUEUE_MAX) : events, dirty: true };
301}
302
303/** Takes the events queued since the last call. */
304export function drainEvents(s: SessionState): [SessionState, ActivityEvent[]] {
305 return [{ ...s, events: [] }, s.events];
306}
307
308function loopOf(s: SessionState, agentId: string | null | undefined): LoopStats | undefined {
309 return agentId ? s.agents[agentId] : s.main;
310}
311
312function putLoop(s: SessionState, agentId: string | null | undefined, loop: LoopStats): SessionState {
313 if (!agentId) return { ...s, main: loop, dirty: true };
314 const a = s.agents[agentId];
315 if (!a) return s;
316 return { ...s, agents: { ...s.agents, [agentId]: { ...a, ...loop } }, dirty: true };
317}
318
319function loopLabel(s: SessionState, agentId: string | null | undefined): string {
320 if (!agentId) return 'on the orchestrator';
321 const a = s.agents[agentId];
322 return a ? `in “${a.description}”` : 'in an agent';
323}
324
325export function agentLabel(a: Pick<AgentState, 'kind' | 'type' | 'model' | 'effort'>): string {
326 return `${a.kind === 'fork' ? 'fork' : a.type} · ${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''}`;
327}
328
329/** An agent the engine lists that this state has not seen spawn (a reload, a missed event). */
330function placeholderAgent(id: string, now: number, over: Partial<AgentState> = {}): AgentState {
331 return {
332 ...emptyLoop(),
333 id,
334 parentId: null,
335 kind: 'subagent',
336 type: 'general-purpose',
337 name: null,
338 description: 'agent',
339 effortSource: 'unknown',
340 status: 'running',
341 startedAt: now,
342 endedAt: null,
343 promptExcerpt: '',
344 answerExcerpt: null,
345 promptText: null,
346 answerText: null,
347 promptSent: false,
348 answerSent: false,
349 failure: null,
350 toolUseId: null,
351 ...over,
352 };
353}
354
355// ---- identity -----------------------------------------------------------------------------------
356
357export function setIdentity(
358 s: SessionState,
359 i: { cwd?: string | null; projectRoot?: string | null; repoName?: string | null; model?: string | null; startedAt?: number | null },
360): SessionState {
361 return {
362 ...s,
363 cwd: i.cwd ?? s.cwd,
364 projectRoot: i.projectRoot === undefined ? s.projectRoot : i.projectRoot,
365 repoName: i.repoName === undefined ? s.repoName : i.repoName,
366 startedAt: i.startedAt ?? s.startedAt,
367 main: i.model ? { ...s.main, model: i.model } : s.main,
368 dirty: true,
369 };
370}
371
372export function setGit(s: SessionState, git: GitInfo | null): SessionState {
373 if (JSON.stringify(git) === JSON.stringify(s.git)) return s;
374 return { ...s, git, dirty: true };
375}
376
377/** The changed-files list; a report is due only when it differs from the last one. */
378export function setFiles(s: SessionState, files: FilesReport, base: string | null): SessionState {
379 const same = JSON.stringify(files) === JSON.stringify(s.files);
380 if (same && base === s.gitBase) return s;
381 return { ...s, files, gitBase: base, dirty: s.dirty || !same };
382}
383
384/** Whether any client is attached; a report is due only when it changes. */
385export function setAttached(s: SessionState, attached: boolean): SessionState {
386 if (attached === s.attached) return s;
387 return { ...s, attached, dirty: true };
388}
389
390// ---- turns --------------------------------------------------------------------------------------
391
392export function turnStart(s: SessionState): SessionState {
393 // A new turn means the background work (or the wake-up) that woke it is no longer what the session waits on.
394 return withStatus({ ...s, mainIdle: false, background: null, waitReason: s.waitReason === 'permission' ? 'permission' : null });
395}
396
397export interface BackgroundInput {
398 /** The Stop hook's background_tasks. */
399 tasks: { id?: string; type?: string; status?: string; description?: string; command?: string }[];
400 /** The Stop hook's session_crons; `nextAt` is when the schedule next fires, worked out by the caller. */
401 crons: { id?: string; schedule?: string; recurring?: boolean; prompt?: string; nextAt?: number | null }[];
402}
403
404const TASK_DONE = /^(completed|complete|done|failed|killed|stopped|cancelled|canceled|error)$/i;
405const TASK_KINDS: Record<string, BackgroundKind> = { shell: 'shell', bash: 'shell', subagent: 'agent', agent: 'agent', monitor: 'monitor', workflow: 'workflow' };
406/** Claude Code's artifact live-update watchers (#128): they run as long as the session does, so they are not work it waits on. */
407const ARTIFACT_WATCHER = /^live updates for artifact /i;
408const MAX_BACKGROUND_TASKS = 20;
409const BACKGROUND_DESC = 120;
410
411/**
412 * The Stop hook's list of work still in flight. Finished tasks are dropped. A shell's command never leaves the
413 * machine: a task with no description is named by the first word of its command (never an NAME=value assignment), and no more.
414 */
415export function setBackground(s: SessionState, i: BackgroundInput, now: number): SessionState {
416 const tasks: BackgroundTask[] = [];
417 for (const t of i.tasks) {
418 const status = t.status || 'running';
419 if (TASK_DONE.test(status) || tasks.length >= MAX_BACKGROUND_TASKS) continue;
420 const type = TASK_KINDS[(t.type ?? '').toLowerCase()] ?? 'other';
421 if (type === 'monitor' && ARTIFACT_WATCHER.test((t.description ?? '').trim())) continue;
422 // The first word that is not an NAME=value assignment, so an inline secret (API_KEY=...) is never kept.
423 const word = (t.command ?? '').trim().split(/\s+/).find((w) => w !== '' && !w.includes('='))?.split('/').pop() ?? '';
424 tasks.push({ type, description: clip((t.description ?? '').replace(/\s+/g, ' ').trim() || word || type, BACKGROUND_DESC), status: clip(status, 40) });
425 }
426 const times: number[] = [];
427 let unreadable = false;
428 for (const c of i.crons) {
429 if (tasks.length >= MAX_BACKGROUND_TASKS) break;
430 tasks.push({ type: 'wakeup', description: clip((c.prompt ?? '').replace(/\s+/g, ' ').trim() || 'scheduled wake-up', BACKGROUND_DESC), status: 'scheduled' });
431 if (typeof c.nextAt === 'number') times.push(c.nextAt);
432 else unreadable = true;
433 }
434 // One wake-up whose time cannot be read may fire first, so no time is better than a wrong one.
435 const wakeAt = times.length && !unreadable ? Math.min(...times) : null;
436 const background: BackgroundWait | null = tasks.length ? { tasks, wakeAt } : null;
437 if (JSON.stringify(background) === JSON.stringify(s.background)) return s;
438 let next: SessionState = { ...s, background };
439 // The feed gets one row each time what it waits on changes, not one per Stop.
440 if (background && (!s.background || backgroundSummary(background) !== backgroundSummary(s.background))) next = addEvent(next, 'wait', null, `waiting on background: ${backgroundSummary(background)}`, now);
441 return withStatus(next);
442}
443
444/** What `/podium` prints for the background work the mod holds (#128): each entry's type, status and description. */
445export function describeBackground(s: SessionState): string {
446 const tasks = s.background?.tasks ?? [];
447 if (!tasks.length) return 'Background work: none';
448 return [`Background work (${tasks.length}):`, ...tasks.map((t) => ` ${t.type}, ${t.status}: ${t.description}`)].join('\n');
449}
450
451export interface StepInput {
452 agentId?: string;
453 model?: string | null;
454 effort?: unknown;
455 usage: Usage | null;
456 stopReason: string | null;
457}
458
459/** One model request finished: model, observed effort, context, tokens, cost, and stop-reason blowouts. */
460export function step(s0: SessionState, i: StepInput, now: number): SessionState {
461 let s = s0;
462 if (i.agentId && !s.agents[i.agentId]) {
463 s = { ...s, agents: { ...s.agents, [i.agentId]: placeholderAgent(i.agentId, now, { model: i.model ?? null }) }, order: [...s.order, i.agentId] };
464 }
465 const loop = loopOf(s, i.agentId);
466 if (!loop) return s;
467 const u = i.usage;
468 const model = u?.model || i.model || loop.model;
469 const effort = asEffort(i.effort);
470 const next: LoopStats = { ...loop, model: model ?? null, steps: loop.steps + 1, lastStopReason: i.stopReason };
471 if (effort) next.effort = effort;
472 if (u) {
473 const t = { input: u.input_tokens || 0, output: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0 };
474 next.tokens = { input: loop.tokens.input + t.input, output: loop.tokens.output + t.output, cacheRead: loop.tokens.cacheRead + t.cacheRead, cacheWrite: loop.tokens.cacheWrite + t.cacheWrite };
475 next.usage = addUsage(loop.usage, model ?? null, u);
476 next.cost = loop.cost + (costOf(t, model) ?? 0);
477 next.ctxTokens = t.input + t.cacheRead + t.cacheWrite;
478 next.window = windowOf(model);
479 }
480 s = putLoop(s, i.agentId, next);
481 if (i.agentId && effort) {
482 const a = s.agents[i.agentId]!;
483 if (a.kind !== 'fork') s = { ...s, agents: { ...s.agents, [a.id]: { ...a, effortSource: 'observed' } } };
484 }
485 if (!i.agentId && effort) s = { ...s, main: { ...s.main, effort } };
486 if (i.stopReason === 'model_context_window_exceeded') s = blowout(s, i.agentId ?? null, 'window-exceeded', now, next.ctxTokens, null);
487 else if (i.stopReason === 'compaction') s = blowout(s, i.agentId ?? null, 'server-compaction', now, next.ctxTokens, null);
488 return s;
489}
490
491/**
492 * Counts a blowout on a loop. A second signal for the same loop within 2 s (a window-exceeded stop
493 * followed by the auto-compaction it triggers) is the same blowout and only adds detail.
494 */
495export function blowout(s: SessionState, agentId: string | null, kind: string, now: number, tokensBefore: number | null, tokensAfter: number | null): SessionState {
496 const loop = loopOf(s, agentId);
497 if (!loop) return s;
498 const last = loop.blowTimes[loop.blowTimes.length - 1];
499 if (last !== undefined && now - last < BLOWOUT_DEDUP_MS) return s;
500 const n = loop.blowouts + 1;
501 const ctx = tokensAfter !== null && loop.window ? tokensAfter : loop.ctxTokens;
502 let next = putLoop(s, agentId, { ...loop, blowouts: n, blowTimes: [...loop.blowTimes, now].slice(-20), ctxTokens: ctx });
503 const after = tokensAfter !== null && loop.window ? `, compacted to ${Math.round((tokensAfter / loop.window) * 100)}%` : '';
504 next = addEvent(next, 'blow', agentId, `context window exceeded (blowout #${n}) ${loopLabel(s, agentId)}${after}`, now, {
505 kind,
506 tokensBefore,
507 tokensAfter,
508 });
509 return next;
510}
511
512export interface CompactInput {
513 agentId?: string;
514 trigger: string;
515 tokensBefore?: number;
516 tokensAfter?: number;
517}
518
519/** A compaction ran: automatic is a blowout; manual or a plugin's is only an event. */
520export function compaction(s: SessionState, i: CompactInput, now: number): SessionState {
521 if (i.trigger === 'auto') return blowout(s, i.agentId ?? null, 'auto-compact', now, i.tokensBefore ?? null, i.tokensAfter ?? null);
522 if (i.trigger === 'manual' || i.trigger === 'plugin') {
523 const loop = loopOf(s, i.agentId);
524 const next = loop && i.tokensAfter !== undefined ? putLoop(s, i.agentId, { ...loop, ctxTokens: i.tokensAfter }) : s;
525 return addEvent(next, 'compact', i.agentId ?? null, `${i.trigger === 'manual' ? 'manual' : 'plugin'} compaction ${loopLabel(s, i.agentId)}`, now, {
526 tokensBefore: i.tokensBefore ?? null,
527 tokensAfter: i.tokensAfter ?? null,
528 });
529 }
530 return s;
531}
532
533export interface CompleteInput {
534 agentId?: string;
535 reason: EndReason;
536 answer?: string;
537}
538
539/** A loop's turn ended. Main loop: idle. Agent: done, aborted or failed with a failure record. */
540/** A new answer text, to be sent again even if an earlier one already went out. */
541function answerText(answer: string): { answerText: string | null; answerSent: boolean } {
542 return { answerText: fullText(answer) || null, answerSent: false };
543}
544
545/** The full texts an accepted report carried, by agent id. */
546export type SentTexts = Record<string, { promptText?: string | undefined; answerText?: string | undefined }>;
547
548/** The texts in an accepted report have gone out; the next reports leave them out. Without `sent`, every text held counts as sent. */
549export function markTextSent(s: SessionState, sent?: SentTexts): SessionState {
550 const agents: Record<string, AgentState> = {};
551 let changed = false;
552 for (const [id, a] of Object.entries(s.agents)) {
553 // only a text that is still the one the report carried; a newer one goes out next time
554 const was = sent ? sent[id] : { promptText: a.promptText ?? undefined, answerText: a.answerText ?? undefined };
555 const next = {
556 ...a,
557 promptSent: a.promptSent || (!!was?.promptText && was.promptText === a.promptText),
558 answerSent: a.answerSent || (!!was?.answerText && was.answerText === a.answerText),
559 };
560 if (next.promptSent !== a.promptSent || next.answerSent !== a.answerSent) changed = true;
561 agents[id] = next;
562 }
563 return changed ? { ...s, agents } : s;
564}
565
566export function turnComplete(s: SessionState, i: CompleteInput, now: number): SessionState {
567 if (!i.agentId) {
568 let next: SessionState = { ...s, mainIdle: true, waitReason: s.waitReason === 'permission' ? null : s.waitReason, main: { ...s.main, candidate: i.reason === 'answer' ? null : s.main.candidate } };
569 const cand = s.main.candidate && now - s.main.candidate.at <= 60_000 ? s.main.candidate : null;
570 if (i.reason === 'error' && cand) {
571 next = addEvent(next, 'fail', null, `orchestrator turn ended with an error after a failing call: ${cand.call}`, now, { reason: 'error', tool: cand.tool, call: cand.call, exitCode: cand.exitCode });
572 } else if (i.reason === 'error') next = addEvent(next, 'fail', null, 'orchestrator turn ended with an API error', now, { reason: 'error' });
573 else if (i.reason === 'refusal') next = addEvent(next, 'fail', null, 'orchestrator turn ended with a refusal', now, { reason: 'refusal' });
574 return withStatus(next);
575 }
576 const a = s.agents[i.agentId];
577 if (!a || a.status === 'done' || a.status === 'failed' || a.status === 'aborted') {
578 // a teammate's later turn, or a duplicate: keep the answer only
579 if (a && i.answer) return { ...s, agents: { ...s.agents, [a.id]: { ...a, answerExcerpt: excerpt(i.answer), ...answerText(i.answer) } }, dirty: true };
580 return s;
581 }
582 const cls = classify({ reason: i.reason, candidate: a.candidate, stopReason: a.lastStopReason, blowouts: a.blowouts, now });
583 let next: AgentState;
584 if (i.reason === 'answer') {
585 next = { ...a, status: a.kind === 'teammate' ? 'running' : 'done', endedAt: a.kind === 'teammate' ? null : now, answerExcerpt: i.answer ? excerpt(i.answer) : a.answerExcerpt, ...(i.answer ? answerText(i.answer) : {}), candidate: null, failure: null };
586 } else {
587 const ev = evidence({ blowoutTimes: a.blowTimes, failedCalls: a.failedCalls, reads: a.reads, denyText: a.candidate?.denied ? a.candidate.denyText : null });
588 const fresh = a.candidate && now - a.candidate.at <= 60_000 ? a.candidate : null;
589 const failure = buildFailure(cls ?? 'Unknown', fresh, ev, now, cls === 'Model API error' || cls === 'Context window exhausted' ? { tool: 'model request', call: a.model ? `model request (${a.model}${a.effort ? `, effort ${a.effort}` : ''})` : 'model request' } : {});
590 next = { ...a, status: i.reason === 'aborted' ? 'aborted' : 'failed', endedAt: now, failure, answerExcerpt: i.answer ? excerpt(i.answer) : a.answerExcerpt, ...(i.answer ? answerText(i.answer) : {}) };
591 }
592 let out: SessionState = { ...s, agents: { ...s.agents, [a.id]: next } };
593 if (next.status === 'done') out = addEvent(out, 'done', a.id, `finished: ${a.description}`, now);
594 else if (next.status === 'failed') out = addEvent(out, 'fail', a.id, `failed: ${a.description} (${next.failure?.class ?? 'Unknown'})`, now, { class: next.failure?.class ?? 'Unknown' });
595 else if (next.status === 'aborted') out = addEvent(out, 'note', a.id, `aborted: ${a.description}`, now);
596 return withStatus(out);
597}
598
599// ---- agents -------------------------------------------------------------------------------------
600
601/** The model's Agent tool call, before the agent starts: requested effort and model, by tool_use_id. */
602export function agentRequested(s: SessionState, toolUseId: string, input: Record<string, unknown>, now: number): SessionState {
603 const p: PendingSpawn = {
604 effort: asEffort(input.effort),
605 model: str(input.model),
606 subagentType: str(input.subagent_type),
607 description: str(input.description),
608 name: str(input.name),
609 at: now,
610 };
611 // The spawn may have come first (either order is tolerated): merge into that agent.
612 const existing = Object.values(s.agents).find((a) => a.toolUseId === toolUseId);
613 if (existing) {
614 if (existing.kind === 'fork' || !p.effort || existing.effortSource === 'observed') return s;
615 return { ...s, agents: { ...s.agents, [existing.id]: { ...existing, effort: p.effort, effortSource: 'requested' } }, dirty: true };
616 }
617 return { ...s, pending: { ...s.pending, [toolUseId]: p } };
618}
619
620export interface SpawnInput {
621 toolUseId: string;
622 agentId: string;
623 model: string | null;
624 subagentType: string;
625 description: string;
626 prompt: string;
627 parentAgentId?: string;
628 fork: boolean;
629 isTeammate?: boolean;
630 name?: string;
631}
632
633/** An agent started. Effort: requested at the Agent call; a fork inherits its parent's. */
634export function agentSpawned(s: SessionState, i: SpawnInput, now: number): SessionState {
635 const pending = s.pending[i.toolUseId];
636 const parent = i.parentAgentId ? s.agents[i.parentAgentId] : undefined;
637 const parentLoop: LoopStats = parent ?? s.main;
638 const kind: AgentState['kind'] = i.fork ? 'fork' : i.isTeammate ? 'teammate' : 'subagent';
639 const model = i.fork ? parentLoop.model ?? i.model : i.model ?? pending?.model ?? null;
640 let effort: Effort | null = null;
641 let effortSource: AgentState['effortSource'] = 'unknown';
642 if (i.fork) {
643 effort = parentLoop.effort;
644 effortSource = 'inherited';
645 } else if (pending?.effort) {
646 effort = pending.effort;
647 effortSource = 'requested';
648 }
649 const prev = s.agents[i.agentId];
650 const a: AgentState = {
651 ...(prev ?? placeholderAgent(i.agentId, now)),
652 parentId: i.parentAgentId ?? null,
653 kind,
654 type: i.fork ? 'fork' : i.subagentType || pending?.subagentType || 'general-purpose',
655 name: i.name ?? pending?.name ?? null,
656 description: clip(i.description || pending?.description || 'agent', 240),
657 model,
658 effort: prev?.effortSource === 'observed' && !i.fork ? prev.effort : effort,
659 effortSource: prev?.effortSource === 'observed' && !i.fork ? 'observed' : effortSource,
660 status: 'running',
661 startedAt: prev?.startedAt ?? now,
662 promptExcerpt: excerpt(i.prompt),
663 promptText: fullText(i.prompt) || null,
664 promptSent: false,
665 toolUseId: i.toolUseId,
666 window: windowOf(model),
667 };
668 const { [i.toolUseId]: _used, ...rest } = s.pending;
669 let out: SessionState = { ...s, agents: { ...s.agents, [a.id]: a }, order: prev ? s.order : [...s.order, a.id], pending: rest };
670 const desc = i.fork ? `fork (inherits ${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''})` : `${a.type} (${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''})`;
671 out = addEvent(out, 'spawn', a.id, `${i.fork ? 'forked' : 'spawned'} ${desc}: ${a.description}`, now, { kind });
672 // Match plan steps while the agent runs, so a step keeps its owner after the agent finishes.
673 return withStatus(resolveOwners(out));
674}
675
676/** The engine's list of agents: the authority for which exist and how each stands (SPEC 5.4). */
677export interface ListedAgent {
678 id: string;
679 description: string;
680 type: string;
681 status: string;
682 parentId?: string;
683 name?: string;
684}
685
686export function reconcile(s: SessionState, list: readonly ListedAgent[], now: number): SessionState {
687 let agents = s.agents;
688 let order = s.order;
689 let changed = false;
690 for (const l of list) {
691 const a = agents[l.id];
692 const status: AgentStatus | null =
693 l.status === 'completed' ? 'done' : l.status === 'failed' ? 'failed' : l.status === 'killed' ? 'aborted' : l.status === 'pending' ? 'queued' : 'running';
694 if (!a) {
695 agents = {
696 ...agents,
697 [l.id]: placeholderAgent(l.id, now, {
698 description: clip(l.description || 'agent', 240),
699 type: l.type === 'fork' ? 'fork' : l.type || 'general-purpose',
700 kind: l.type === 'fork' ? 'fork' : l.type === 'teammate' ? 'teammate' : 'subagent',
701 parentId: l.parentId ?? null,
702 name: l.name ?? null,
703 status: status ?? 'running',
704 endedAt: status === 'done' || status === 'failed' || status === 'aborted' ? now : null,
705 }),
706 };
707 order = [...order, l.id];
708 changed = true;
709 continue;
710 }
711 // Only move forward: a finished agent never becomes running again from the list.
712 const final = a.status === 'done' || a.status === 'failed' || a.status === 'aborted';
713 if (!final && status && status !== a.status && status !== 'running') {
714 const failure = status === 'failed' && !a.failure ? buildFailure('Unknown', a.candidate, evidence({ blowoutTimes: a.blowTimes, failedCalls: a.failedCalls, reads: a.reads, denyText: null }), now) : a.failure;
715 agents = { ...agents, [l.id]: { ...a, status, endedAt: status === 'queued' ? null : a.endedAt ?? now, failure } };
716 changed = true;
717 } else if (!final && a.status === 'queued' && status === 'running') {
718 agents = { ...agents, [l.id]: { ...a, status: 'running' } };
719 changed = true;
720 }
721 }
722 return changed ? withStatus({ ...s, agents, order }) : s;
723}
724
725// ---- tools --------------------------------------------------------------------------------------
726
727/** A tool call is about to run in a loop: its last tool, and the end of any permission wait there. */
728export function toolStarted(s: SessionState, agentId: string | undefined, tool: string): SessionState {
729 const loop = loopOf(s, agentId);
730 if (!loop) return s;
731 let next = putLoop(s, agentId, { ...loop, lastTool: clip(tool, 120) });
732 if (next.waitReason === 'permission' && !agentId) next = withStatus({ ...next, waitReason: null, waitNote: null });
733 return next;
734}
735
736export interface ToolResultInput {
737 agentId?: string;
738 tool: string;
739 input: Record<string, unknown>;
740 result: { deny?: string; isError?: boolean; text?: string; result?: unknown };
741}
742
743/** A tool call finished: counts, failure candidates, skills and the plan. */
744export function toolResult(s0: SessionState, i: ToolResultInput, now: number): SessionState {
745 let s = s0;
746 const loop = loopOf(s, i.agentId);
747 if (loop) {
748 const tool = clip(i.tool, 120);
749 const next: LoopStats = { ...loop, toolCalls: loop.toolCalls + 1, lastTool: tool };
750 if (Object.hasOwn(loop.toolCounts, tool) || Object.keys(loop.toolCounts).length < MAX_TOOL_NAMES) {
751 next.toolCounts = { ...loop.toolCounts, [tool]: (Object.hasOwn(loop.toolCounts, tool) ? loop.toolCounts[tool]! : 0) + 1 };
752 }
753 if (i.tool === 'Read' && typeof i.input.file_path === 'string') {
754 const f = clip(i.input.file_path, 200);
755 next.reads = { ...loop.reads, [f]: (loop.reads[f] ?? 0) + 1 };
756 }
757 const cand = candidateFrom(i.tool, i.input, i.result, now);
758 if (cand) {
759 next.candidate = cand;
760 next.failedCalls = { ...loop.failedCalls, [cand.call]: (loop.failedCalls[cand.call] ?? 0) + 1 };
761 }
762 s = putLoop(s, i.agentId, next);
763 }
764 if (s.waitReason === 'permission' && !i.agentId) s = withStatus({ ...s, waitReason: null, waitNote: null });
765 const ok = typeof i.result.deny !== 'string' && i.result.isError !== true;
766 if (!ok) return s;
767 if (i.tool === 'Skill' && typeof i.input.skill === 'string') s = skillLoaded(s, i.agentId, i.input.skill, now);
768 else if (i.tool === 'TaskCreate') s = withPlan(s, taskCreate(s.plan, i.input, i.result), now);
769 else if (i.tool === 'TaskUpdate') s = withPlan(s, taskUpdate(s.plan, i.input, i.result), now);
770 else if (i.tool === 'TodoWrite' && !i.agentId) s = withPlan(s, todoWrite(s.plan, i.input), now);
771 else if (i.tool === PLAN_TOOL) s = withPlan(s, setTitle(s.plan, i.input), now);
772 return s;
773}
774
775function withPlan(s: SessionState, plan: PlanState, now: number): SessionState {
776 if (plan === s.plan) return s;
777 let out: SessionState = resolveOwners({ ...s, plan, dirty: true });
778 const before = new Map(s.plan.tasks.map((t) => [t.id, t.status]));
779 for (const t of plan.tasks) {
780 const was = before.get(t.id);
781 if (t.status === 'in_progress' && was !== 'in_progress') out = addEvent(out, 'plan', null, `step started: ${t.text}`, now);
782 }
783 if (plan.tasks.length && plan.tasks.every((t) => t.status === 'completed') && s.plan.tasks.some((t) => t.status !== 'completed')) {
784 out = addEvent(out, 'plan', null, `plan complete: ${plan.toolTitle ?? plan.metaTitle ?? `${plan.tasks.length} steps`}`, now);
785 }
786 if (!s.plan.tasks.length && plan.tasks.length) out = addEvent(out, 'plan', null, `plan started (${plan.source ?? 'tasks'})`, now);
787 return out;
788}
789
790/** A skill was loaded into a loop (the Skill tool, or skill.prompt for the main loop). */
791export function skillLoaded(s: SessionState, agentId: string | undefined, skill: string, now: number): SessionState {
792 const loop = loopOf(s, agentId);
793 if (!loop || loop.skills.includes(skill)) return s;
794 const next = putLoop(s, agentId, { ...loop, skills: [...loop.skills, clip(skill, 120)].slice(-50) });
795 const where = agentId ? (s.agents[agentId]?.kind === 'fork' ? 'fork' : s.agents[agentId]?.type ?? 'agent') : 'orchestrator';
796 return addEvent(next, 'skill', agentId ?? null, `${skill} loaded into ${where}`, now, { skill });
797}
798
799/** The engine raised a permission dialog. */
800export function permissionRequest(s: SessionState, toolName: string, toolInput: unknown, now: number): SessionState {
801 let short = '';
802 if (toolInput && typeof toolInput === 'object') {
803 const v = (toolInput as Record<string, unknown>).command ?? (toolInput as Record<string, unknown>).file_path ?? Object.values(toolInput as Record<string, unknown>)[0];
804 short = typeof v === 'string' ? v : v === undefined ? '' : JSON.stringify(v);
805 }
806 const note = clip(`${toolName}(${short.replace(/\s+/g, ' ').trim()})`, 160);
807 const next = withStatus({ ...s, waitReason: 'permission', waitNote: note });
808 return addEvent(next, 'wait', null, `waiting on you: ${note}`, now);
809}
810
811/** A notification: a permission prompt or idle-for-input is a fallback signal for waiting. */
812export function notification(s: SessionState, type: string, message: string, now: number): SessionState {
813 if (/permission/i.test(type) && s.waitReason !== 'permission') {
814 const next = withStatus({ ...s, waitReason: 'permission', waitNote: clip(message.replace(/\s+/g, ' ').trim(), 160) || null });
815 return addEvent(next, 'wait', null, `waiting on you: ${next.waitNote ?? 'permission'}`, now);
816 }
817 if (/idle/i.test(type)) return withStatus({ ...s, mainIdle: true });
818 return s;
819}
820
821// ---- activity feed (#118) ----------------------------------------------------------------------
822
823/** Whether the server last said someone is watching this loop (no agent id: the main loop). */
824export function isWatched(s: SessionState, agentId: string | null | undefined): boolean {
825 return s.watched.includes(agentId || MAIN_LOOP);
826}
827
828/**
829 * The loops the server's reply says are watched. A loop that drops off the list forgets its entries, so a later
830 * watch starts empty; a loop that is listed starts keeping entries from now.
831 */
832export function setWatched(s: SessionState, ids: readonly string[]): SessionState {
833 const next = [...new Set(ids)].sort();
834 if (next.length === s.watched.length && next.every((id, i) => id === s.watched[i])) return s;
835 const keep = new Set(next);
836 const main = keep.has(MAIN_LOOP) || !s.main.activity.length ? s.main : { ...s.main, activity: [] };
837 let agents = s.agents;
838 for (const [id, a] of Object.entries(s.agents)) {
839 if (!keep.has(id) && a.activity.length) agents = { ...agents, [id]: { ...a, activity: [] } };
840 }
841 return { ...s, watched: next, main, agents };
842}
843
844/** Adds an entry to a watched loop's feed, keeping the last 20. A loop nobody watches keeps nothing. */
845export function addActivity(s: SessionState, agentId: string | null | undefined, entry: ActivityEntry): SessionState {
846 if (!isWatched(s, agentId)) return s;
847 const loop = loopOf(s, agentId);
848 if (!loop) return s;
849 return putLoop(s, agentId, { ...loop, activity: [...loop.activity, entry].slice(-ACTIVITY_KEPT) });
850}
851
852// ---- usage, end ---------------------------------------------------------------------------------
853
854export interface MeasureInput {
855 context?: { tokens?: number; window?: number; percent?: number } | null;
856 cost?: { usd: number } | null;
857 rateLimits?: { kind: string; percentUsed: number; resetsAt?: string }[] | null;
858 startedAt?: number;
859}
860
861/** The status line's figures: main-loop context, the session's real cost, rate limits. */
862export function measure(s: SessionState, i: MeasureInput): SessionState {
863 const c = i.context;
864 const context = c
865 ? {
866 tokens: typeof c.tokens === 'number' ? c.tokens : s.context.tokens,
867 window: typeof c.window === 'number' ? c.window : s.context.window,
868 percent: typeof c.percent === 'number' ? c.percent : s.context.percent,
869 }
870 : s.context;
871 const rateLimits = Array.isArray(i.rateLimits)
872 ? i.rateLimits.slice(0, 10).map((r) => ({ kind: clip(String(r.kind), 120), percentUsed: Number(r.percentUsed) || 0, resetsAt: r.resetsAt ?? null }))
873 : s.rateLimits;
874 const costUsd = i.cost && typeof i.cost.usd === 'number' ? i.cost.usd : s.costUsd;
875 const startedAt = typeof i.startedAt === 'number' && i.startedAt > 0 ? i.startedAt : s.startedAt;
876 const same = JSON.stringify([context, rateLimits, costUsd, startedAt]) === JSON.stringify([s.context, s.rateLimits, s.costUsd, s.startedAt]);
877 return same ? s : { ...s, context, rateLimits, costUsd, startedAt, dirty: true };
878}
879
880/** The session ended: final status, and every open agent aborted. */
881export function sessionEnd(s: SessionState, reason: string, resumeId: string | null, now: number): SessionState {
882 if (s.ended) return s;
883 const agents: Record<string, AgentState> = {};
884 for (const [id, a] of Object.entries(s.agents)) {
885 agents[id] = a.status === 'running' || a.status === 'queued' ? { ...a, status: 'aborted', endedAt: now } : a;
886 }
887 const next = withStatus({ ...s, agents, ended: true, endReason: clip(reason, 120), resumeId });
888 return addEvent(next, 'end', null, `session.end · ${reason}`, now, { reason });
889}
890
891/** Plan owners resolved against the current agents (sticky once matched). */
892export function resolveOwners(s: SessionState): SessionState {
893 const plan = assignOwners(
894 s.plan,
895 s.order.map((id) => s.agents[id]!).filter(Boolean).map((a) => ({ id: a.id, name: a.name, description: a.description, running: a.status === 'running', label: agentLabel(a) })),
896 );
897 return plan === s.plan ? s : { ...s, plan };
898}
899
900/** Stores a failure explanation (T5.2); its own tokens count in the agent's estimated cost. */
901export function setExplanation(s: SessionState, agentId: string, text: string, usage: Usage | null, model: string): SessionState {
902 const a = s.agents[agentId];
903 if (!a || !a.failure) return s;
904 const explanation = excerpt(text, 400);
905 if (!explanation) return s;
906 const cost = usage
907 ? costOf({ input: usage.input_tokens || 0, output: usage.output_tokens || 0, cacheRead: usage.cache_read_input_tokens || 0, cacheWrite: usage.cache_creation_input_tokens || 0 }, model) ?? 0
908 : 0;
909 return { ...s, agents: { ...s.agents, [agentId]: { ...a, failure: { ...a.failure, explanation }, cost: a.cost + cost, usage: usage ? addUsage(a.usage, model, usage) : a.usage } }, dirty: true };
910}
911hooks/cron.ts 55 lines1// When a cron expression next fires, in the machine's local time (the zone Claude Code reads it in).
2// Five fields: minute hour day-of-month month day-of-week, each `*`, a number, a range `a-b`, a list, or
3// a step `*/n` or `a-b/n`. Returns null for anything it cannot read, so a schedule it does not understand
4// shows no time instead of a wrong one.
5
6function field(text: string, min: number, max: number): Set<number> | null {
7 const out = new Set<number>();
8 for (const part of text.split(',')) {
9 const m = /^(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
10 if (!m) return null;
11 const step = m[4] ? Number(m[4]) : 1;
12 if (step < 1) return null;
13 const lo = m[1] ? min : Number(m[2]);
14 const hi = m[1] ? max : m[3] ? Number(m[3]) : m[4] ? max : lo;
15 if (lo < min || hi > max || lo > hi) return null;
16 for (let v = lo; v <= hi; v += step) out.add(v);
17 }
18 return out;
19}
20
21export function nextCronAt(schedule: string, from: number): number | null {
22 const f = schedule.trim().split(/\s+/);
23 if (f.length !== 5) return null;
24 const minute = field(f[0]!, 0, 59);
25 const hour = field(f[1]!, 0, 23);
26 const dom = field(f[2]!, 1, 31);
27 const month = field(f[3]!, 1, 12);
28 const dowRaw = field(f[4]!, 0, 7);
29 if (!minute || !hour || !dom || !month || !dowRaw) return null;
30 const dow = new Set([...dowRaw].map((d) => d % 7));
31 // Like cron, a day matches when either day field matches if both are restricted.
32 const domAny = f[2] === '*';
33 const dowAny = f[4] === '*';
34 const t = new Date(from);
35 t.setSeconds(0, 0);
36 t.setMinutes(t.getMinutes() + 1);
37 // Bounded by a year of steps; each step is at most a minute, usually a day or an hour.
38 for (let i = 0; i < 366 * 24 * 60; i++) {
39 const dayOk = domAny && dowAny ? true : domAny ? dow.has(t.getDay()) : dowAny ? dom.has(t.getDate()) : dom.has(t.getDate()) || dow.has(t.getDay());
40 // Skip a whole day or hour that cannot match instead of walking it a minute at a time.
41 if (!month.has(t.getMonth() + 1) || !dayOk) {
42 t.setDate(t.getDate() + 1);
43 t.setHours(0, 0, 0, 0);
44 continue;
45 }
46 if (!hour.has(t.getHours())) {
47 t.setHours(t.getHours() + 1, 0, 0, 0);
48 continue;
49 }
50 if (minute.has(t.getMinutes())) return t.getTime();
51 t.setMinutes(t.getMinutes() + 1);
52 }
53 return null;
54}
55hooks/transport.ts 305 lines1// Getting reports to the server (SPEC 6.1, 6.7): an event queue capped at 500, one POST at a time,
2// acks by count, backoff 1 s, 2 s, 5 s, 10 s while the server is unreachable. Nothing here awaits
3// anything a tool call waits on: hooks only call `enqueue`; the flush timer calls `send`.
4import type { ActivityEvent, Delivery, Report } from './protocol.ts';
5
6export const QUEUE_MAX = 500;
7export const BACKOFF_MS = [1000, 2000, 5000, 10_000] as const;
8export const EVENTS_PER_REPORT = 100;
9/** How long to wait after the server refuses the token or name: it will not change until the settings do. */
10export const REFUSED_RETRY_MS = 60_000;
11/** More watched loops than a report can carry agents is not a list the mod takes whole. */
12const MAX_WATCHED = 201;
13/** Messages and commands one reply can hand over (#206). */
14const MAX_DELIVERIES = 20;
15
16/** A delivery as the server sends it: only its shape is checked here; what it may do is the mod's to decide (SPEC 5.3). */
17export function isDelivery(v: unknown): v is Delivery {
18 const d = v as Partial<Delivery> | null;
19 return !!d && typeof d === 'object' && typeof d.id === 'string' && typeof d.kind === 'string' && typeof d.by === 'string' && typeof d.at === 'number';
20}
21
22/** Whether a server URL is plain `http:` to a host that is not this machine, so the token it sends is not encrypted in transit (#306). */
23export function plainHttpRemote(serverUrl: string): boolean {
24 let u: URL;
25 try {
26 u = new URL(serverUrl);
27 } catch {
28 return false;
29 }
30 if (u.protocol !== 'http:') return false;
31 const h = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
32 return !(h === 'localhost' || h === '::1' || /^127(\.\d{1,3}){3}$/.test(h));
33}
34
35export interface HttpResult {
36 status: number;
37 ok: boolean;
38 text: string;
39}
40export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<HttpResult>;
41
42export interface TransportConfig {
43 serverUrl: string;
44 token: string;
45 heartbeatMs: number;
46 /** This reporter's own version; passed in so the transport never imports it. */
47 version: string;
48}
49
50/** The oldest and newest reporter the server reads correctly (SPEC 6.8). */
51export interface ReporterSupport {
52 min: string;
53 max: string;
54}
55
56/** Major and minor of a version as numbers; null when it is not one. */
57function minorOf(v: string): [number, number] | null {
58 const m = /^(\d+)\.(\d+)(?:\.|$)/.exec(v);
59 return m ? [Number(m[1]), Number(m[2])] : null;
60}
61
62export type SendOutcome = 'ok' | 'rejected' | 'refused' | 'failed' | 'busy';
63
64export class Transport {
65 queue: ActivityEvent[] = [];
66 /** null until the first attempt. */
67 online: boolean | null = null;
68 sent = 0;
69 failed = 0;
70 rejected = 0;
71 dropped = 0;
72 failures = 0;
73 lastError: string | null = null;
74 lastSentAt = 0;
75 nextAttemptAt = 0;
76 liveSessions: number | null = null;
77 /** Where the server places this reporter's version; null until a reply says (an older server never does). */
78 compat: 'ok' | 'outdated' | 'newer' | null = null;
79 serverVersion: string | null = null;
80 /** What the server reads correctly; null until a reply says (an older server never does). */
81 support: ReporterSupport | null = null;
82 /** The loops the last accepted report's reply says someone is watching (#118); empty from a server that predates it. */
83 watch: string[] = [];
84 /** Whether the last accepted report's reply said the server takes answers for this session (#206); false from an older server. */
85 respond = false;
86 /** Messages and commands from Podium the last reply handed over (#206). */
87 deliveries: Delivery[] = [];
88 inFlight = false;
89 private current: Promise<SendOutcome> | null = null;
90 private loggedReject = false;
91 private loggedRefusal = false;
92 /** True while the server's last answer was a 401. */
93 refused = false;
94 /** True while the server's last answer was a 409: this session already belongs to another owner (#209). */
95 ownerRefused = false;
96 private loggedOwner = false;
97 private rejectStreak = 0;
98
99 constructor(
100 private readonly fetch: FetchLike,
101 readonly cfg: TransportConfig,
102 private readonly log: (line: string) => void = () => {},
103 ) {}
104
105 get url(): string {
106 return `${this.cfg.serverUrl.replace(/\/+$/, '')}/api/fleet/report`;
107 }
108
109 /** Queues events; past 500 the oldest are dropped. */
110 enqueue(events: readonly ActivityEvent[]): void {
111 if (!events.length) return;
112 this.queue.push(...events);
113 if (this.queue.length > QUEUE_MAX) {
114 this.dropped += this.queue.length - QUEUE_MAX;
115 this.queue.splice(0, this.queue.length - QUEUE_MAX);
116 }
117 }
118
119 /** Whether a report should go now: something changed, or the heartbeat is due, and no backoff holds. */
120 due(now: number, dirty: boolean): boolean {
121 if (this.inFlight || now < this.nextAttemptAt) return false;
122 return dirty || this.queue.length > 0 || this.lastSentAt === 0 || now - this.lastSentAt >= this.cfg.heartbeatMs;
123 }
124
125 /** The delay before retry number `failures` (1-based). */
126 static backoff(failures: number): number {
127 return BACKOFF_MS[Math.min(Math.max(failures, 1), BACKOFF_MS.length) - 1]!;
128 }
129
130 /**
131 * Posts one report built from the oldest queued events (at most 100). 200 acks them; 400 or 413 drops
132 * that report and its events (it would never be accepted) and backs off before the next attempt;
133 * anything else keeps them and backs off.
134 */
135 send(build: (events: ActivityEvent[]) => Report, now: number): Promise<SendOutcome> {
136 if (this.inFlight) return Promise.resolve('busy');
137 this.inFlight = true;
138 const p = this.post(build, now);
139 this.current = p;
140 return p;
141 }
142
143 /** Resolves when no POST is in flight (at once when none is). */
144 async idle(): Promise<void> {
145 if (this.current) await this.current.catch(() => undefined);
146 }
147
148 private async post(build: (events: ActivityEvent[]) => Report, now: number): Promise<SendOutcome> {
149 const events = this.queue.slice(0, EVENTS_PER_REPORT);
150 try {
151 const report = build(events);
152 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
153 if (this.cfg.token) headers['X-Podium-Token'] = this.cfg.token;
154 const res = await this.fetch(this.url, { method: 'POST', headers, body: JSON.stringify(report) });
155 if (res.ok) {
156 let acked = events.length;
157 try {
158 const body = JSON.parse(res.text) as { ackedEvents?: unknown; liveSessions?: unknown; reporterCompat?: unknown; serverVersion?: unknown; reporterSupport?: unknown; watch?: unknown; respond?: unknown; deliveries?: unknown };
159 if (typeof body.ackedEvents === 'number') acked = Math.max(0, Math.min(events.length, body.ackedEvents));
160 if (typeof body.liveSessions === 'number') this.liveSessions = body.liveSessions;
161 this.compat = body.reporterCompat === 'outdated' || body.reporterCompat === 'newer' || body.reporterCompat === 'ok' ? body.reporterCompat : null;
162 this.serverVersion = typeof body.serverVersion === 'string' ? body.serverVersion : null;
163 const sup = body.reporterSupport as Partial<ReporterSupport> | null | undefined;
164 this.support = sup && typeof sup.min === 'string' && typeof sup.max === 'string' ? { min: sup.min, max: sup.max } : null;
165 this.watch = Array.isArray(body.watch) ? body.watch.filter((id): id is string => typeof id === 'string').slice(0, MAX_WATCHED) : [];
166 this.respond = body.respond === true;
167 this.deliveries = this.respond && Array.isArray(body.deliveries) ? body.deliveries.filter(isDelivery).slice(0, MAX_DELIVERIES) : [];
168 } catch {
169 // a 200 without a JSON body still acks what was sent
170 }
171 this.ack(events, acked);
172 this.online = true;
173 this.failures = 0;
174 this.rejectStreak = 0;
175 this.nextAttemptAt = 0;
176 this.lastSentAt = now;
177 this.lastError = null;
178 this.sent++;
179 this.refused = false;
180 this.loggedRefusal = false;
181 this.ownerRefused = false;
182 this.loggedOwner = false;
183 return 'ok';
184 }
185 // 409: the session id already belongs to another owner (server-side); it is as permanent as a 400, so it is not an outage.
186 if (res.status === 400 || res.status === 409 || res.status === 413) {
187 this.ack(events, events.length);
188 this.rejected++;
189 // Back off like a failure: a snapshot the server keeps refusing (a mod/server version mismatch, say)
190 // would otherwise be rebuilt and re-posted on every flush tick while state is dirty.
191 this.rejectStreak++;
192 this.nextAttemptAt = now + Transport.backoff(this.rejectStreak);
193 this.lastSentAt = now;
194 this.lastError = `server rejected a report (${res.status})`;
195 // The server read the credential and answered about the report, so an earlier 401 no longer stands.
196 this.refused = false;
197 this.loggedRefusal = false;
198 if (res.status === 409) {
199 this.ownerRefused = true;
200 if (!this.loggedOwner) {
201 this.loggedOwner = true;
202 this.log(`podium-reporter: ${this.ownerNotice()}`);
203 }
204 return 'rejected';
205 }
206 this.ownerRefused = false;
207 if (!this.loggedReject) {
208 this.loggedReject = true;
209 this.log(`podium-reporter: server rejected a report (${res.status}): ${res.text.slice(0, 200)}`);
210 }
211 return 'rejected';
212 }
213 if (res.status === 401) {
214 // The token is wrong, revoked, or does not belong to the configured name. Keep the events, wait a long
215 // while (nothing changes until the settings do), and say so once. The token is never put in a message.
216 this.refused = true;
217 this.ownerRefused = false;
218 this.online = false;
219 this.nextAttemptAt = now + REFUSED_RETRY_MS;
220 this.lastSentAt = now;
221 this.lastError = 'server refused the token or name (401)';
222 if (!this.loggedRefusal) {
223 this.loggedRefusal = true;
224 this.log('podium-reporter: the server refused this reporter (401): check the Podium token and user name settings');
225 }
226 return 'refused';
227 }
228 throw new Error(`HTTP ${res.status}`);
229 } catch (err) {
230 this.failures++;
231 this.failed++;
232 this.online = false;
233 this.lastError = (err as Error)?.message ?? 'network error';
234 this.nextAttemptAt = now + Transport.backoff(this.failures);
235 return 'failed';
236 } finally {
237 this.inFlight = false;
238 this.current = null;
239 }
240 }
241
242 /** Removes the first `n` of `sent` from the queue (events queued meanwhile stay). */
243 private ack(sent: readonly ActivityEvent[], n: number): void {
244 const ids = new Set(sent.slice(0, n).map((e) => e.id));
245 this.queue = this.queue.filter((e) => !ids.has(e.id));
246 }
247
248 /**
249 * True when the server supports a newer major.minor than this reporter but still reads it (so it is not
250 * `outdated`). Patch releases are not flagged: `max` moves with the report format, not with every release.
251 */
252 get behind(): boolean {
253 if (!this.support || this.compat === 'outdated' || this.compat === 'newer') return false;
254 const mine = minorOf(this.cfg.version);
255 const max = minorOf(this.support.max);
256 if (!mine || !max) return false;
257 return mine[0] < max[0] || (mine[0] === max[0] && mine[1] < max[1]);
258 }
259
260 statusText(): string {
261 const name = `${plainHttpRemote(this.cfg.serverUrl) ? '⚠ ' : ''}podium v${this.cfg.version}`;
262 if (this.refused) return `${name} ○ token refused`;
263 if (this.ownerRefused) return `${name} ○ refused: session belongs to someone else`;
264 if (this.online === false) return `${name} ○ offline`;
265 if (this.online === null) return `${name} ○ connecting`;
266 const live = `${name} ● ${this.liveSessions ?? 1} live`;
267 if (this.compat === 'outdated') return `${live} · update mod`;
268 if (this.compat === 'newer') return `${live} · upgrade server`;
269 if (this.behind) return `${live} · update available`;
270 return live;
271 }
272
273 /** What to tell the user when the server is plain http beyond this machine (#306); null when it is not. Names no address and no token. */
274 plainHttpNotice(): string | null {
275 return plainHttpRemote(this.cfg.serverUrl)
276 ? 'The Podium server is reached over plain HTTP beyond this machine, so the token and everything reported are not encrypted in transit. Use an https:// server URL (a TLS proxy in front of Podium) in the podium-reporter settings.'
277 : null;
278 }
279
280 /** What to tell the user when the server refused the token or name; null when it did not. */
281 refusalNotice(): string | null {
282 return this.refused ? 'The server refused this reporter. Check that the Podium token belongs to the user name in the podium-reporter settings (claude plugin configure podium-reporter), or ask the server owner for a new token.' : null;
283 }
284
285 /** What to tell the user when the server says this session already belongs to another owner (#209); null when it did not. */
286 ownerNotice(): string | null {
287 return this.ownerRefused ? 'The server refused this report: this session belongs to someone else. Start a new session to report as yourself.' : null;
288 }
289
290 /** What to tell the user when the server says this reporter and it do not match; null when they do (or it cannot tell). */
291 versionNotice(): string | null {
292 const server = this.serverVersion ? ` (server ${this.serverVersion})` : '';
293 if (this.compat === 'outdated') return `This reporter is older than the Podium server supports${server}: update the podium-reporter mod (claude plugin update podium-reporter@eengelking-marketplace, then /reload-plugins).`;
294 if (this.compat === 'newer') return `This reporter is newer than the Podium server supports${server}: upgrade the Podium server.`;
295 return null;
296 }
297
298 /** The mild hint behind `· update available`; for `/podium` only, never a toast. Null when not behind. */
299 updateNotice(): string | null {
300 if (!this.behind || !this.support) return null;
301 const server = this.serverVersion ? ` (server ${this.serverVersion})` : '';
302 return `A newer podium-reporter is supported by the server${server}: it reads reporters up to ${this.support.max}, this one is ${this.cfg.version}. Update with claude plugin update podium-reporter@eengelking-marketplace, then /reload-plugins.`;
303 }
304}
305hooks/failures.ts 246 lines1// Failure classification, excerpts and redaction (SPEC 6.4). Pure: no `$` here.
2import type { Failure, FailureClass } from './protocol.ts';
3
4export const LIMITS = {
5 call: 300,
6 line: 240,
7 outputLines: 12,
8 evidenceLines: 5,
9 excerpt: 400,
10 /** The whole prompt or answer, when the fullPrompts option is on. */
11 fullText: 20_000,
12 text: 240,
13 /** One activity-feed entry (#118). */
14 activity: 300,
15 /** A candidate older than this when its loop fails is not the cause. */
16 candidateMaxAgeMs: 60_000,
17} as const;
18
19const SECRET_PATTERNS: RegExp[] = [
20 /sk-ant-[A-Za-z0-9_-]{10,}/g,
21 /ghp_[A-Za-z0-9]{20,}/g,
22 /AKIA[0-9A-Z]{16}/g,
23 /Bearer [A-Za-z0-9._-]{20,}/g,
24];
25// `password=hunter2`, `token: abc`, `"api_key": "abc"`: keep the key, redact the value.
26const KEY_VALUE = /((?:password|passwd|token|secret|apikey|api_key)["']?\s*[=:]\s*["']?)([^\s"'&,;]+)/gi;
27
28/** Replaces anything that looks like a secret with `[redacted]`. */
29export function redact(text: string): string {
30 let out = text;
31 for (const re of SECRET_PATTERNS) out = out.replace(re, '[redacted]');
32 return out.replace(KEY_VALUE, (_m, key: string) => `${key}[redacted]`);
33}
34
35/** Redacts, then cuts to `max` characters. */
36export function clip(text: string, max: number): string {
37 const r = redact(text);
38 return r.length > max ? r.slice(0, max) : r;
39}
40
41/** The first 400 characters with whitespace collapsed, redacted. */
42export function excerpt(text: string | null | undefined, max: number = LIMITS.excerpt): string {
43 if (!text) return '';
44 return clip(String(text).replace(/\s+/g, ' ').trim(), max);
45}
46
47const CUT_MARKER = '\n\n[cut off at 20,000 characters]';
48
49/** The whole text redacted, line breaks kept, cut to 20,000 characters with a visible marker. */
50export function fullText(text: string | null | undefined): string {
51 if (!text) return '';
52 const r = redact(String(text).replace(/\r\n?/g, '\n')).trim();
53 return r.length > LIMITS.fullText ? r.slice(0, LIMITS.fullText) + CUT_MARKER : r;
54}
55
56/** The last `n` lines of a text, each redacted and cut to 240 characters. */
57export function tailLines(text: string | null | undefined, n: number = LIMITS.outputLines): string[] {
58 if (!text) return [];
59 const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
60 while (lines.length && lines[lines.length - 1]!.trim() === '') lines.pop();
61 return lines.slice(-n).map((l) => clip(l, LIMITS.line));
62}
63
64/** The exit code a Bash error result reports in its text ("Exit code 1"), best effort. */
65export function parseExitCode(text: string | null | undefined): number | null {
66 if (!text) return null;
67 const m = /(?:exit code|exit status|exited with(?: code)?)[\s:]*(-?\d{1,3})\b/i.exec(text);
68 return m ? Number(m[1]) : null;
69}
70
71/** Event fields that are the engine's own, not a tool's arguments. */
72const ENGINE_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta']);
73
74/** A one-line summary of a tool call (SPEC 6.4): Bash -> the command; Edit/Read/Write -> tool + path. */
75export function summarizeCall(tool: string, input: Record<string, unknown>): string {
76 const str = (v: unknown) => (typeof v === 'string' ? v : v === undefined || v === null ? '' : JSON.stringify(v));
77 let s: string;
78 if (tool === 'Bash') s = str(input.command);
79 else if (tool === 'Edit' || tool === 'Read' || tool === 'Write' || tool === 'NotebookEdit' || tool === 'MultiEdit') s = `${tool} ${str(input.file_path ?? input.notebook_path)}`.trim();
80 else {
81 const first = Object.entries(input).find(([k]) => !ENGINE_KEYS.has(k));
82 s = first ? `${tool} ${str(first[1])}` : tool;
83 }
84 return clip(s.replace(/\s+/g, ' ').trim(), LIMITS.call);
85}
86
87/** How much raw text an activity entry is built from: more than enough for 300 characters after redaction, and a bound on the work. */
88const ACTIVITY_RAW = 4000;
89const FILE_TOOLS = new Set(['Edit', 'Read', 'Write', 'NotebookEdit', 'MultiEdit']);
90
91/**
92 * One activity-feed entry for a tool call (#118): the tool's name, a space, and a short summary of its arguments.
93 * Bash: the command; file tools: the path; Grep: the pattern and where; Agent: the description; Skill: the skill;
94 * anything else: its first argument. Redacted, whitespace collapsed, cut to 300 characters.
95 */
96export function activityCall(tool: string, input: Record<string, unknown>): string {
97 const str = (v: unknown) => (typeof v === 'string' ? v : v === undefined || v === null ? '' : JSON.stringify(v) ?? '');
98 let arg: string;
99 if (tool === 'Bash') arg = str(input.command);
100 else if (FILE_TOOLS.has(tool)) arg = str(input.file_path ?? input.notebook_path);
101 else if (tool === 'Grep' || tool === 'Glob') arg = `${tool === 'Grep' ? `"${str(input.pattern)}"` : str(input.pattern)}${input.path ? ` in ${str(input.path)}` : ''}`;
102 else if (tool === 'Agent') arg = str(input.description);
103 else if (tool === 'Skill') arg = str(input.skill);
104 else {
105 const first = Object.entries(input).find(([k]) => !ENGINE_KEYS.has(k));
106 arg = first ? str(first[1]) : '';
107 }
108 return clip(flatten(`${tool} ${arg}`), LIMITS.activity);
109}
110
111/**
112 * Whitespace collapsed first, then cut to ACTIVITY_RAW, so redaction always sees whole what the entry can show: a
113 * secret is never cut into a stub that no longer matches, even after a long run of whitespace before it.
114 */
115function flatten(text: string): string {
116 return text.replace(/\s+/g, ' ').trim().slice(0, ACTIVITY_RAW);
117}
118
119/** One activity-feed entry for the text a model request wrote: whitespace collapsed, redacted, cut to 300 characters. Empty for text that is only whitespace. */
120export function activityText(text: string): string {
121 return clip(flatten(text), LIMITS.activity);
122}
123
124export interface FailureCandidate {
125 at: number;
126 tool: string;
127 call: string;
128 exitCode: number | null;
129 outputTail: string[];
130 denied: boolean;
131 denyText: string | null;
132}
133
134/** Builds a candidate from a tool result that errored or was denied. */
135export function candidateFrom(
136 tool: string,
137 input: Record<string, unknown>,
138 result: { deny?: string; isError?: boolean; text?: string; result?: unknown },
139 at: number,
140): FailureCandidate | null {
141 const denied = typeof result.deny === 'string';
142 if (!denied && result.isError !== true) return null;
143 const text = denied ? result.deny! : typeof result.text === 'string' ? result.text : typeof result.result === 'string' ? result.result : '';
144 return {
145 at,
146 tool,
147 call: summarizeCall(tool, input),
148 exitCode: denied ? null : parseExitCode(text),
149 outputTail: tailLines(text),
150 denied,
151 denyText: denied ? clip(text.replace(/\s+/g, ' ').trim(), LIMITS.line) : null,
152 };
153}
154
155export type EndReason = 'answer' | 'aborted' | 'refusal' | 'error';
156
157export interface ClassifyInput {
158 reason: EndReason;
159 candidate: FailureCandidate | null;
160 /** The loop's last model stop reason. */
161 stopReason: string | null;
162 blowouts: number;
163 now: number;
164}
165
166/**
167 * The failure class for a loop that ended with `error`, `refusal` or `aborted` (SPEC 6.4); null for
168 * `answer` (a loop that answered is done, and its candidate is discarded).
169 *
170 * Order: a denial, a refusal, an exhausted context window, an API error with no failing call, an
171 * abort, then a tool error or Unknown. The window check comes before the API error because the
172 * engine's `error` reason includes running out of context (see ARCHITECTURE.md, Spec questions).
173 */
174export function classify(i: ClassifyInput): FailureClass | null {
175 if (i.reason === 'answer') return null;
176 const fresh = i.candidate && i.now - i.candidate.at <= LIMITS.candidateMaxAgeMs ? i.candidate : null;
177 if (fresh?.denied) return 'Blocked by permission';
178 if (i.reason === 'refusal') return 'Refusal';
179 if (i.stopReason === 'model_context_window_exceeded' || i.blowouts >= 3) return 'Context window exhausted';
180 if (i.reason === 'error' && !fresh) return 'Model API error';
181 if (i.reason === 'aborted') return 'Aborted';
182 return fresh ? 'Tool error' : 'Unknown';
183}
184
185export interface EvidenceInput {
186 blowoutTimes: number[];
187 /** How often each failing call summary failed in this loop. */
188 failedCalls: Record<string, number>;
189 /** How often each file was read in this loop. */
190 reads: Record<string, number>;
191 denyText: string | null;
192}
193
194function hhmmss(t: number): string {
195 const d = new Date(t);
196 return [d.getHours(), d.getMinutes(), d.getSeconds()].map((v) => String(v).padStart(2, '0')).join(':');
197}
198
199/** Short facts the reporter observed; never invented. At most 5 lines. */
200export function evidence(i: EvidenceInput): string[] {
201 const out: string[] = [];
202 if (i.blowoutTimes.length) {
203 const n = i.blowoutTimes.length;
204 out.push(`exceeded the context window ${n} time${n > 1 ? 's' : ''} (${i.blowoutTimes.slice(-3).map(hhmmss).join(', ')})`);
205 }
206 for (const [call, n] of Object.entries(i.failedCalls).sort((a, b) => b[1] - a[1])) {
207 if (n >= 2) out.push(clip(`same call failed ${n} times: ${call}`, LIMITS.line));
208 }
209 for (const [file, n] of Object.entries(i.reads).sort((a, b) => b[1] - a[1])) {
210 if (n >= 3) out.push(clip(`${file} read ${n} times`, LIMITS.line));
211 }
212 if (i.denyText) out.push(clip(`permission denied: ${i.denyText}`, LIMITS.line));
213 return out.slice(0, LIMITS.evidenceLines);
214}
215
216export function buildFailure(cls: FailureClass, candidate: FailureCandidate | null, ev: string[], at: number, extra: { tool?: string; call?: string } = {}): Failure {
217 return {
218 class: cls,
219 at,
220 tool: candidate?.tool ?? extra.tool ?? null,
221 call: candidate?.call ?? (extra.call ? clip(extra.call, LIMITS.call) : null),
222 exitCode: candidate?.exitCode ?? null,
223 outputTail: candidate ? candidate.outputTail.slice(-LIMITS.outputLines) : [],
224 evidence: ev.slice(0, LIMITS.evidenceLines),
225 explanation: null,
226 retriedAs: null,
227 };
228}
229
230/**
231 * The prompt for an optional one-paragraph explanation (SPEC T5.2). Built only from the failure
232 * record, which is already redacted and cut to its limits: never from the transcript.
233 */
234export function explainPrompt(f: Failure): string {
235 const lines = [
236 'A Claude Code agent failed. From this record alone, say in at most two short sentences what most likely went wrong and what would fix it. Plain text, no preamble, no markdown.',
237 `Class: ${f.class}`,
238 f.tool ? `Tool: ${f.tool}` : '',
239 f.call ? `Call: ${f.call}` : '',
240 f.exitCode !== null ? `Exit code: ${f.exitCode}` : '',
241 f.outputTail.length ? `Last output lines:\n${f.outputTail.join('\n')}` : '',
242 f.evidence.length ? `Observed:\n${f.evidence.map((e) => `- ${e}`).join('\n')}` : '',
243 ];
244 return redact(lines.filter(Boolean).join('\n'));
245}
246hooks/identity.ts 39 lines1/**
2 * Which Podium identity (name and token) a session reports as (#226).
3 *
4 * The reporter's options are one set per Claude config directory, so two terminals on one machine share
5 * them, and Claude Code reloads every session's module when an option changes. Reading the options
6 * live would turn a running session into whoever signed in last. So a session takes its identity once
7 * and holds it (in `$.state`, which survives a reload); a terminal can name its own through
8 * `PODIUM_USER` and `PODIUM_TOKEN`, which win over the shared options.
9 */
10
11export interface Identity {
12 userName: string;
13 token: string;
14}
15
16export interface EnvIdentity {
17 user?: string;
18 token?: string;
19}
20
21/**
22 * Whether an identity is worth holding for the rest of the session: it has a token. A name alone is not
23 * signed in, so a token added later still applies; a session with no token has nothing to protect.
24 */
25export const worthHolding = (i: Identity): boolean => i.token !== '';
26
27/**
28 * The identity to report as: the one the session already holds, else the terminal's environment (taken
29 * whole when either variable is set, never mixed with the shared options), else the shared options.
30 * An identity without a token is not held (see worthHolding).
31 */
32export function pickIdentity(from: { held: Identity | undefined; env: EnvIdentity; options: Identity }): Identity {
33 if (from.held && worthHolding(from.held)) return from.held;
34 const user = (from.env.user ?? '').trim();
35 const token = (from.env.token ?? '').trim();
36 if (user || token) return { userName: user, token };
37 return { userName: from.options.userName, token: from.options.token };
38}
39hooks/machine.ts 55 lines1// Which machine a session runs on (issue #34). Pure: the mod passes in what `hostname` printed and a
2// random source. The id is random and kept in one file under the Claude config directory (and in the
3// mod's own store as a fallback), so two machines that share a host name stay apart, two loads of the
4// mod on one machine agree, and nothing about the hardware is read.
5import type { MachineInfo } from './protocol.ts';
6
7/** The `$.store` key the machine id is kept under. */
8export const MACHINE_ID_KEY = 'machineId';
9const HOST_MAX = 64;
10
11/** The short host name from `hostname` output: the first line, up to the first dot (`Eds-MBP.local` is `Eds-MBP`). Null when empty. */
12export function hostOf(stdout: string | null | undefined): string | null {
13 const line = (stdout ?? '').split(/\r?\n/)[0] ?? '';
14 // eslint-disable-next-line no-control-regex
15 const host = line.replace(/[\u0000-\u001f\u007f]/g, '').trim().split('.')[0]!.trim();
16 return host ? host.slice(0, HOST_MAX) : null;
17}
18
19/** A stored id this reporter made: 8 to 32 lowercase hex characters. */
20export function isMachineId(v: unknown): v is string {
21 return typeof v === 'string' && /^[0-9a-f]{8,32}$/.test(v);
22}
23
24/** The file name the machine id is shared under, in the Claude config directory (#267). */
25export const MACHINE_ID_FILE = 'podium-machine-id';
26
27/**
28 * Where the shared machine id file lives: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`. Null when
29 * neither is known, in which case the id stays in the mod's store alone.
30 */
31export function machineIdPath(configDir: string | undefined, home: string | undefined): string | null {
32 const trim = (p: string | undefined) => (p ?? '').trim().replace(/\/+$/, '');
33 const dir = trim(configDir) || (trim(home) ? `${trim(home)}/.claude` : '');
34 // A relative directory would land under each session's own working directory, so loads would disagree.
35 return /^(\/|[A-Za-z]:[\\/])/.test(dir) ? `${dir}/${MACHINE_ID_FILE}` : null;
36}
37
38/** The id the shared file holds: its one line, when that is an id this reporter made. Null otherwise. */
39export function parseMachineIdFile(text: string | null | undefined): string | null {
40 const id = (text ?? '').trim();
41 return isMachineId(id) ? id : null;
42}
43
44/** A new random id of 12 hex characters. */
45export function newMachineId(random: () => number): string {
46 let s = '';
47 for (let i = 0; i < 12; i++) s += Math.floor(random() * 16).toString(16);
48 return s;
49}
50
51/** The machine as the report carries it; a host with no name is labelled by its id. */
52export function machineInfo(host: string | null, id: string): MachineInfo {
53 return { name: host ?? `machine-${id.slice(0, 4)}`, id };
54}
55hooks/protocol.ts 434 lines1// Report types for the Podium reporter (SPEC 6.2). Types only.
2// The block between the PROTOCOL markers is identical to the one in server/src/fleet/protocol.ts;
3// a server test compares the two after whitespace normalization, so edit both together.
4
5// <protocol:begin>
6export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
7
8export type AgentStatus = 'queued' | 'running' | 'done' | 'failed' | 'aborted';
9
10export type FailureClass =
11 | 'Tool error'
12 | 'Blocked by permission'
13 | 'Model API error'
14 | 'Refusal'
15 | 'Context window exhausted'
16 | 'Aborted'
17 | 'Unknown';
18
19export type EventKind =
20 | 'start' | 'end' | 'spawn' | 'skill' | 'done' | 'fail' | 'blow' | 'compact' | 'plan' | 'wait' | 'note';
21
22export interface TokenCounts {
23 input: number;
24 output: number;
25 cacheRead: number;
26 cacheWrite: number;
27}
28
29/** `cacheWrite` is the 5-minute cache write; `cacheWrite1h` the 1-hour one (0 when the reporter cannot split them). */
30export interface UsageTokens extends TokenCounts {
31 cacheWrite1h: number;
32}
33
34/**
35 * One model's token totals, so the server prices them with the Admin's prices (issue #117). `tier` is `long`
36 * for requests whose prompt (input + cache reads + cache writes) is over 100,000 tokens.
37 */
38export interface ModelUsage {
39 model: string;
40 tier: 'std' | 'long';
41 tokens: UsageTokens;
42}
43
44export interface ContextFill {
45 tokens: number | null;
46 window: number | null;
47 percent: number | null;
48}
49
50export interface GitInfo {
51 branch: string | null;
52 detached: boolean;
53 isWorktree: boolean;
54 worktreePath: string | null;
55 ahead: number;
56 behind: number;
57 dirty: number;
58}
59
60export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
61
62/** One file the session changed. `added` and `removed` are null for a binary file. */
63export interface ChangedFile {
64 path: string;
65 status: FileStatus;
66 added: number | null;
67 removed: number | null;
68 /** The old path of a renamed file. */
69 from: string | null;
70}
71
72/**
73 * What the session changed in its repository: the files (at most 200, by path) and the totals over
74 * all of them. `since` says what the diff is against: the commit HEAD was at when the session began,
75 * the merge base with the upstream branch when that commit is gone, or HEAD alone.
76 */
77export interface FilesReport {
78 files: ChangedFile[];
79 total: number;
80 added: number;
81 removed: number;
82 since: 'start' | 'upstream' | 'worktree';
83}
84
85export interface RateLimit {
86 kind: string;
87 percentUsed: number;
88 resetsAt: string | null;
89}
90
91export interface PlanStep {
92 id: string;
93 text: string;
94 state: 'pending' | 'active' | 'done' | 'blocked';
95 activeText: string | null;
96 ownerAgentId: string | null;
97 ownerLabel: string | null;
98}
99
100export interface Plan {
101 title: string | null;
102 source: 'TaskCreate' | 'TodoWrite' | 'podium_plan';
103 steps: PlanStep[];
104}
105
106export interface Failure {
107 class: FailureClass;
108 at: number;
109 tool: string | null;
110 call: string | null;
111 exitCode: number | null;
112 outputTail: string[];
113 evidence: string[];
114 explanation: string | null;
115 retriedAs: string | null;
116}
117
118/**
119 * What the orchestrator's main loop reports about its own work, in the shapes an AgentReport uses for
120 * the fields of the same name. Calls made by agents it launched are not counted here.
121 */
122export interface LoopReport {
123 skills: string[];
124 lastTool: string | null;
125 toolCalls: number;
126 toolCounts: Record<string, number>;
127 steps: { done: number; total: null };
128}
129
130/** The loop key of the orchestrator's own feed, in the report reply's `watch` list and the activity route (#118). Engine agent ids never start with `@`. */
131export const MAIN_LOOP = '@orchestrator';
132
133/** `reasoning` is reserved for the model's thinking; the reporter does not send it. */
134export type ActivityKind = 'tool' | 'text' | 'reasoning';
135
136/**
137 * One entry of a loop's activity feed (#118): a tool call (the tool's name, a space, and a short summary of its
138 * arguments) or the text the model wrote in one step. Redacted and cut to 300 characters by the reporter. Sent only
139 * with the `activityFeed` option on, and only for a loop the server's reply lists as watched.
140 */
141export interface ActivityEntry {
142 at: number;
143 kind: ActivityKind;
144 text: string;
145}
146
147export interface AgentReport {
148 id: string;
149 parentId: string | null;
150 kind: 'subagent' | 'fork' | 'teammate';
151 type: string;
152 name: string | null;
153 description: string;
154 model: string | null;
155 effort: Effort | null;
156 effortSource: 'requested' | 'observed' | 'inherited' | 'unknown';
157 status: AgentStatus;
158 startedAt: number;
159 endedAt: number | null;
160 context: ContextFill;
161 tokens: TokenCounts;
162 costUsdEst: number | null;
163 /** Tokens per model and prompt-size tier. Absent when the reporter is too old to send it. */
164 usageByModel?: ModelUsage[];
165 skills: string[];
166 lastTool: string | null;
167 toolCalls: number;
168 /** Finished calls per tool name. null when the reporter is too old to send it. */
169 toolCounts: Record<string, number> | null;
170 blowouts: number;
171 steps: { done: number; total: number | null } | null;
172 promptExcerpt: string;
173 answerExcerpt: string | null;
174 /**
175 * The whole task prompt and final answer, redacted, line breaks kept. Sent only when the reporter's
176 * `fullPrompts` option is on, and only once per text (the server keeps what it was given).
177 */
178 promptText?: string;
179 answerText?: string;
180 /** The agent's last 20 activity entries, oldest first: only while someone watches it, with the `activityFeed` option on (0.13.0 and later). */
181 activity?: ActivityEntry[];
182 failure: Failure | null;
183}
184
185export interface ActivityEvent {
186 id: string;
187 t: number;
188 kind: EventKind;
189 agentId: string | null;
190 text: string;
191 data: Record<string, unknown> | null;
192}
193
194/** What a session idle between turns is still waiting on: background work in flight, or a scheduled wake-up (#119). */
195export type BackgroundKind = 'shell' | 'agent' | 'monitor' | 'workflow' | 'wakeup' | 'other';
196
197export interface BackgroundTask {
198 type: BackgroundKind;
199 /** A short description; never a whole shell command. */
200 description: string;
201 status: string;
202}
203
204export interface BackgroundWait {
205 tasks: BackgroundTask[];
206 /** When the next scheduled wake-up fires (epoch ms); null when there is none or its time is unknown. */
207 wakeAt: number | null;
208}
209
210export interface SessionReport {
211 id: string;
212 name: string;
213 status: 'running' | 'waiting' | 'done';
214 waitReason: 'permission' | 'input' | null;
215 waitNote: string | null;
216 endReason: string | null;
217 resumeId: string | null;
218 startedAt: number;
219 cwd: string;
220 projectRoot: string | null;
221 repoName: string | null;
222 git: GitInfo | null;
223 model: string | null;
224 effort: Effort | null;
225 context: ContextFill;
226 costUsd: number | null;
227 tokens: TokenCounts;
228 /** Tokens per model and tier for the whole session, agents included. Absent when the reporter is too old to send it. */
229 usageByModel?: ModelUsage[];
230 rateLimits: RateLimit[];
231 blowouts: number;
232 plan: Plan | null;
233 /** The main loop's steps, tools and skills. null when the reporter is too old to send it. */
234 loop: LoopReport | null;
235 /** The files the session changed. null when the reporter is too old to send it. */
236 files: FilesReport | null;
237 /** Whether any client (a terminal, a phone) is attached. false for a background session left running after /exit. null when unknown: a reporter too old to send it. */
238 attached: boolean | null;
239 /** Background work the session is idle on. Absent from a reporter older than 0.11.0 and when there is none. */
240 background?: BackgroundWait | null;
241 /** Whether the reporter's `activityFeed` option is on. Absent from a reporter older than 0.13.0. */
242 activityFeed?: boolean;
243 /** The main loop's last 20 activity entries, as on an agent: only while someone watches the orchestrator. */
244 activity?: ActivityEntry[];
245 /** Whether and how far the session can be answered from Podium (#206). Absent from a reporter older than 0.14.0. */
246 respond?: RespondInfo;
247 /** The asks open now, at most 8; sent only while the last reply said `respond: true`. */
248 asks?: Ask[];
249 /** The session id this one continues after a /clear (SPEC 5.8). */
250 clearedFrom?: string | null;
251}
252
253export interface Report {
254 v: 1;
255 sentAt: number;
256 reporter: { name: 'podium-reporter'; version: string; engineVersion: string | null; hostSurface: string | null };
257 session: SessionReport;
258 agents: AgentReport[];
259 events: ActivityEvent[];
260 /** The person this reporter is configured as. The server checks it against the token's owner and refuses a mismatch. Omitted or null when none is set. */
261 userName?: string | null;
262 /** The machine the session runs on. Sent only with a userName, so nothing identifying leaves an unnamed reporter. Omitted or null from a reporter older than 0.9.0. */
263 machine?: MachineInfo | null;
264}
265
266/** Which machine a session runs on: its host name, and a random id the reporter keeps so two machines with one host name stay apart. */
267export interface MachineInfo {
268 name: string;
269 id: string;
270}
271/** "1 shell, 1 agent": the counts of a background wait by kind. The activity feed's wait row is read from it. */
272export function backgroundSummary(b: BackgroundWait): string {
273 const n = new Map<string, number>();
274 for (const t of b.tasks) n.set(t.type, (n.get(t.type) ?? 0) + 1);
275 return [...n].map(([k, c]) => `${c} ${k}`).join(', ');
276}
277
278/** A waiting session whose only wait is on background work or a wake-up, not on the person (#119). */
279export function idleOnBackground<T extends { status: string; waitReason: string | null; background?: BackgroundWait | null }>(s: T): s is T & { background: BackgroundWait } {
280 return s.status === 'waiting' && s.waitReason === null && !!s.background;
281}
282
283// ---- Responding (#206, SPEC 5.3, 6.2, 7.1) --------------------------------------------------
284
285/** The reporter's `respond` option: what this session accepts from Podium. */
286export type RespondLevel = 'off' | 'questions' | 'questions-and-plans' | 'everything';
287export const RESPOND_LEVELS: readonly RespondLevel[] = ['off', 'questions', 'questions-and-plans', 'everything'];
288
289/** One kind of answer a session can take from Podium. */
290export type RespondKind = 'question' | 'plan-approve' | 'plan-feedback' | 'permission' | 'message' | 'command';
291export const RESPOND_KINDS: readonly RespondKind[] = ['question', 'plan-approve', 'plan-feedback', 'permission', 'message', 'command'];
292
293/** Why a kind the level allows is missing from `accepts`. */
294export type RespondLimit = 'no-permission-hooks' | 'not-interactive';
295export const RESPOND_LIMIT_NAMES: readonly RespondLimit[] = ['no-permission-hooks', 'not-interactive'];
296
297/** The kinds each level allows. Each level allows what the one before it does. */
298export const LEVEL_KINDS: Record<RespondLevel, readonly RespondKind[]> = {
299 off: [],
300 questions: ['question', 'message'],
301 'questions-and-plans': ['question', 'message', 'plan-approve', 'plan-feedback'],
302 everything: ['question', 'message', 'plan-approve', 'plan-feedback', 'permission', 'command'],
303};
304
305/** Whether and how far a session can be answered from Podium (Report.session.respond). */
306export interface RespondInfo {
307 level: RespondLevel;
308 /** What the session takes now: the level's kinds, less what `limits` removes; empty while the activityFeed option is off. */
309 accepts: RespondKind[];
310 limits: RespondLimit[];
311}
312
313/** The kinds a level allows once the limits are applied. */
314export function acceptsFor(level: RespondLevel, limits: readonly RespondLimit[], activityFeed: boolean): RespondKind[] {
315 if (!activityFeed || limits.includes('not-interactive')) return [];
316 const drop: RespondKind[] = limits.includes('no-permission-hooks') ? ['plan-approve', 'permission'] : [];
317 return LEVEL_KINDS[level].filter((k) => !drop.includes(k));
318}
319
320export type AskKind = 'question' | 'plan' | 'permission';
321
322export interface AskOption {
323 label: string;
324 description: string;
325 preview: string | null;
326}
327
328export interface AskQuestion {
329 question: string;
330 header: string;
331 multiSelect: boolean;
332 options: AskOption[];
333}
334
335/**
336 * One thing a session waits on its person for that Podium can answer. Its id is the tool call's `tool_use_id`.
337 * Redacted by the reporter, and cut to RESPOND_LIMITS with a visible marker.
338 */
339export interface Ask {
340 id: string;
341 kind: AskKind;
342 /** The loop that asked: set for a subagent's permission prompt; null for the main loop. */
343 agentId: string | null;
344 openedAt: number;
345 questions?: AskQuestion[];
346 plan?: { text: string; filePath: string | null };
347 /** The tool and its arguments; `cut` when anything was cut off, and then Podium cannot allow it. */
348 tool?: { name: string; input: Record<string, unknown>; cut: boolean };
349}
350
351export const RESPOND_LIMITS = {
352 asks: 8,
353 questions: 4,
354 options: 4,
355 question: 1000,
356 header: 40,
357 label: 200,
358 description: 1000,
359 preview: 4000,
360 plan: 40_000,
361 toolInput: 20_000,
362 /** A typed answer, a deny reason, a close's answer summary. */
363 answer: 2000,
364 /** A message, a reply in place of an answer, plan feedback. */
365 message: 20_000,
366 /** Messages and commands waiting for one session. */
367 deliveries: 20,
368 /** The longest a held request for an answer waits, in seconds ($.http.fetch gives up at 30). */
369 holdSeconds: 20,
370} as const;
371
372/** What a delivery carries. `reply` is a message sent in place of an answer. */
373export type DeliveryKind = 'question' | 'reply' | 'plan-approve' | 'plan-feedback' | 'allow-once' | 'deny' | 'message' | 'command';
374export const DELIVERY_KINDS: readonly DeliveryKind[] = ['question', 'reply', 'plan-approve', 'plan-feedback', 'allow-once', 'deny', 'message', 'command'];
375
376/** One question's answer by position: the chosen options' indexes, a typed answer, or null for skipped. */
377export type QuestionAnswer = { options: number[] } | { text: string } | null;
378
379export type PlanMode = 'auto' | 'acceptEdits' | 'default';
380export const PLAN_MODES: readonly PlanMode[] = ['auto', 'acceptEdits', 'default'];
381
382export type PodiumCommand = 'clear' | 'compact';
383export const PODIUM_COMMANDS: readonly PodiumCommand[] = ['clear', 'compact'];
384
385/** One answer or message sent from Podium, as the server hands it to the mod. */
386export interface Delivery {
387 id: string;
388 /** The ask it answers; null for a message or a command. */
389 askId: string | null;
390 /** Who sent it: a person's name, or `Admin` for the admin secret. */
391 by: string;
392 at: number;
393 kind: DeliveryKind;
394 answers?: QuestionAnswer[];
395 mode?: PlanMode;
396 text?: string;
397 command?: PodiumCommand;
398}
399
400/** What became of a delivery, as the mod reports it. */
401export type DeliveryResult = 'took' | 'queued' | 'not-taken' | 'refused' | 'closed';
402export const DELIVERY_RESULTS: readonly DeliveryResult[] = ['took', 'queued', 'not-taken', 'refused', 'closed'];
403
404/** How an ask closed without an answer from Podium. */
405export type AskCloseOutcome = 'terminal' | 'closed' | 'ended';
406
407export interface AskClose {
408 outcome: AskCloseOutcome;
409 at: number;
410 /** A short summary of the terminal's answer: a question's answers joined with "; ", `No, keep planning`, `allowed` or `denied`. */
411 answer?: string | null;
412 /** True for a plan approved in the terminal (which mode is not known). */
413 planApproved?: boolean;
414}
415
416/** The reply to an accepted report. Older servers send only some of it. */
417export interface ReportReply {
418 ok: true;
419 serverTime: number;
420 ackedEvents: number;
421 sessionId: string;
422 liveSessions: number;
423 serverVersion: string;
424 reporterSupport: { min: string; max: string };
425 reporterCompat: 'ok' | 'outdated' | 'newer' | 'unknown';
426 watch: string[];
427 /** Whether the server takes answers for this session now; the mod announces asks only while it is true. */
428 respond: boolean;
429 /** Messages and commands from Podium waiting for this session, oldest first; empty while `respond` is false. */
430 deliveries: Delivery[];
431}
432
433// <protocol:end>
434hooks/respond.ts 258 lines1// Responding to a waiting session from Podium (#206, SPEC 5.3): the pure parts. No `$` here. What the session accepts,
2// the asks it announces (redacted and cut), what an answer from Podium turns into for Claude Code, and the lines the
3// reporter writes in the transcript.
4import { redact } from './failures.ts';
5import {
6 acceptsFor,
7 RESPOND_LEVELS,
8 RESPOND_LIMITS,
9 type Ask,
10 type AskQuestion,
11 type Delivery,
12 type PlanMode,
13 type QuestionAnswer,
14 type RespondInfo,
15 type RespondLevel,
16 type RespondLimit,
17} from './protocol.ts';
18
19/** The `respond` option; anything else reads as `off`. */
20export function respondLevel(v: unknown): RespondLevel {
21 return typeof v === 'string' && (RESPOND_LEVELS as readonly string[]).includes(v) ? (v as RespondLevel) : 'off';
22}
23
24/** Report.session.respond: the level as set, what the session takes now, and why a kind is missing. */
25export function respondInfo(level: RespondLevel, activityFeed: boolean, interactive: boolean, permissionHooks: boolean | null): RespondInfo {
26 const limits: RespondLimit[] = [];
27 if (permissionHooks === false) limits.push('no-permission-hooks');
28 if (!interactive) limits.push('not-interactive');
29 return { level, accepts: acceptsFor(level, limits, activityFeed), limits };
30}
31
32/** 40000 -> "40,000". */
33function thousands(n: number): string {
34 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
35}
36
37/** Redacts and cuts a text to `max`, ending a cut one with a visible marker. Line breaks are kept. */
38export function cut(text: unknown, max: number): string {
39 const r = redact(String(text ?? '').replace(/\r\n?/g, '\n'));
40 return r.length > max ? `${r.slice(0, max)}\n\n[cut off at ${thousands(max)} characters]` : r;
41}
42
43/** A one-line field (a header, a label): redacted, line breaks folded, cut without a marker. */
44function line(text: unknown, max: number): string {
45 const r = redact(String(text ?? '').replace(/\s+/g, ' ').trim());
46 return r.length > max ? r.slice(0, max) : r;
47}
48
49const L = RESPOND_LIMITS;
50
51/** The questions of an AskUserQuestion call, as the ask carries them. Undefined when the input is not one Claude Code makes. */
52export function askQuestions(input: Record<string, unknown>): AskQuestion[] | undefined {
53 const qs = input.questions;
54 if (!Array.isArray(qs) || !qs.length || qs.length > L.questions) return undefined;
55 const out: AskQuestion[] = [];
56 for (const q of qs) {
57 if (!q || typeof q !== 'object') return undefined;
58 const o = q as Record<string, unknown>;
59 if (typeof o.question !== 'string' || !Array.isArray(o.options) || !o.options.length || o.options.length > L.options) return undefined;
60 out.push({
61 question: cut(o.question, L.question),
62 header: line(o.header, L.header),
63 multiSelect: o.multiSelect === true,
64 options: (o.options as unknown[]).map((op) => {
65 const p = (op && typeof op === 'object' ? op : {}) as Record<string, unknown>;
66 return { label: line(p.label, L.label), description: cut(p.description ?? '', L.description), preview: typeof p.preview === 'string' && p.preview ? cut(p.preview, L.preview) : null };
67 }),
68 });
69 }
70 return out;
71}
72
73export function questionAsk(id: string, agentId: string | null, input: Record<string, unknown>, now: number): Ask | null {
74 const questions = askQuestions(input);
75 return questions ? { id, kind: 'question', agentId, openedAt: now, questions } : null;
76}
77
78/** A plan ask from ExitPlanMode's input, which carries `plan` and `planFilePath` (verified on 2.1.301). */
79export function planAsk(id: string, agentId: string | null, input: Record<string, unknown>, now: number): Ask {
80 const filePath = typeof input.planFilePath === 'string' && input.planFilePath ? line(input.planFilePath, 1024) : null;
81 return { id, kind: 'plan', agentId, openedAt: now, plan: { text: cut(input.plan ?? '', L.plan), filePath } };
82}
83
84const cutMark = (n: number) => ` [cut off at ${thousands(n)} characters]`;
85const CUT_MARK = / \[cut off at [\d,]+ characters\]$/;
86
87/** Every string in a parsed tool input, as the container and key that hold it. */
88function stringLeaves(v: unknown, out: { at: Record<string | number, unknown>; key: string | number }[] = []) {
89 if (!v || typeof v !== 'object') return out;
90 for (const [k, x] of Object.entries(v)) {
91 const key = Array.isArray(v) ? Number(k) : k;
92 if (typeof x === 'string') out.push({ at: v as Record<string | number, unknown>, key });
93 else stringLeaves(x, out);
94 }
95 return out;
96}
97
98/**
99 * A tool's arguments, redacted string by string and held to RESPOND_LIMITS.toolInput (20,000) as JSON; `cut` when
100 * anything was. Past the limit the longest text is cut, with its marker, until the whole fits, and the rest is kept.
101 */
102export function toolInput(input: unknown): { input: Record<string, unknown>; cut: boolean } {
103 let wasCut = false;
104 const walk = (v: unknown, depth: number): unknown => {
105 if (typeof v === 'string') {
106 const r = redact(v);
107 if (r.length > L.toolInput) {
108 wasCut = true;
109 return `${r.slice(0, L.toolInput)}${cutMark(L.toolInput)}`;
110 }
111 return r;
112 }
113 if (v && typeof v === 'object') {
114 if (depth > 6) {
115 wasCut = true;
116 return '[cut off]';
117 }
118 if (Array.isArray(v)) {
119 if (v.length > 50) wasCut = true;
120 return v.slice(0, 50).map((x) => walk(x, depth + 1));
121 }
122 return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x, depth + 1)]));
123 }
124 return v;
125 };
126 const obj = input && typeof input === 'object' && !Array.isArray(input) ? (walk(input, 0) as Record<string, unknown>) : {};
127 // Each pass takes the overflow (plus room for a marker) off the longest text. JSON escaping only makes a text count
128 // for more, so this shrinks the whole by at least the overflow and settles in a few passes.
129 for (let pass = 0; pass < 20; pass++) {
130 const over = JSON.stringify(obj).length - L.toolInput;
131 if (over <= 0) break;
132 const leaves = stringLeaves(obj);
133 if (!leaves.length) break;
134 const text = (l: (typeof leaves)[number]) => String(l.at[l.key]);
135 const longest = leaves.reduce((a, b) => (text(b).length > text(a).length ? b : a));
136 const raw = text(longest).replace(CUT_MARK, '');
137 const keep = Math.max(0, raw.length - over - 40);
138 longest.at[longest.key] = `${raw.slice(0, keep)}${cutMark(keep)}`;
139 wasCut = true;
140 }
141 if (JSON.stringify(obj).length > L.toolInput) return { input: { note: 'The input is too long to show here.' }, cut: true };
142 return { input: obj, cut: wasCut };
143}
144
145export function toolAsk(id: string, agentId: string | null, tool: string, input: unknown, now: number): Ask {
146 return { id, kind: 'permission', agentId, openedAt: now, tool: { name: line(tool, 120), ...toolInput(input) } };
147}
148
149/**
150 * Claude Code's `answers` map from a delivery's answers by position, against the questions as Claude asked them (not
151 * the redacted copy Podium saw): an option's label, a multi-select's labels joined with ", ", or the typed text.
152 * A skipped question is left out.
153 */
154export function answersMap(questions: readonly { question: string; options: readonly { label: string }[] }[], answers: readonly QuestionAnswer[]): Record<string, string> {
155 const out: Record<string, string> = {};
156 questions.forEach((q, i) => {
157 const a = answers[i];
158 if (!a) return;
159 if ('text' in a) out[q.question] = a.text;
160 else {
161 const labels = a.options.map((n) => q.options[n]?.label).filter((l): l is string => typeof l === 'string');
162 if (labels.length) out[q.question] = labels.join(', ');
163 }
164 });
165 return out;
166}
167
168/**
169 * The PermissionRequest decision that approves a plan from Podium. `updatedInput` is the event's own `tool_input`,
170 * unchanged: without it Claude Code ignores the allow for ExitPlanMode (anthropics/claude-code#97656). A test fails if
171 * it ever goes missing. The only `setMode` the reporter ever sends.
172 */
173export function planApproval(toolInput: unknown, mode: PlanMode) {
174 return {
175 decision: {
176 behavior: 'allow' as const,
177 updatedInput: toolInput as Record<string, unknown>,
178 updatedPermissions: [{ type: 'setMode' as const, mode, destination: 'session' as const }],
179 },
180 };
181}
182
183/** What the reporter says when Podium denies a tool without a reason. */
184export const DENIED_FROM_PODIUM = 'Denied from Podium.';
185
186/** A tool's decision from Podium: allow once, or deny with the reason. Never `updatedPermissions`, never `interrupt`. */
187export function toolDecision(d: Delivery): { decision: { behavior: 'allow' } | { behavior: 'deny'; message: string } } {
188 if (d.kind === 'allow-once') return { decision: { behavior: 'allow' } };
189 const message = (d.text ?? '').trim() || DENIED_FROM_PODIUM;
190 return { decision: { behavior: 'deny', message } };
191}
192
193/**
194 * "No, keep planning" (or a message in place of an answer) for ExitPlanMode's tool.call. Framed as the terminal frames
195 * "Tell Claude what to change", naming Podium: the bare text read to Claude as an injected instruction (2.1.301).
196 */
197export function planFeedback(d: Delivery): { deny: string } {
198 const text = (d.text ?? '').trim();
199 return {
200 deny: text
201 ? `The user doesn't want to proceed with this plan yet. They sent this from Podium: ${text}`
202 : `The user doesn't want to proceed with this plan yet. They chose "No, keep planning" from Podium.`,
203 };
204}
205
206/** How a rejected ExitPlanMode ended in the terminal: feedback from "Tell Claude what to change", or Esc (verified on 2.1.301). */
207export function planRejection(text: string | undefined): 'feedback' | 'closed' {
208 return /the user said:/i.test(text ?? '') ? 'feedback' : 'closed';
209}
210
211/** Whether a tool call ended refused by a permission decision (the terminal's No, or a hook's deny), not run. */
212export function toolDenied(res: { deny?: string; isError?: boolean; text?: string; result?: unknown } | null | undefined, sent?: string): boolean {
213 if (!res) return false;
214 if (res.deny !== undefined) return true;
215 if (res.isError !== true) return false;
216 const text = res.text ?? '';
217 // A PermissionRequest deny ends the call as an error whose text is the deny's own message, with no stock wording
218 // (seen live on 2.1.301), so the message this mod sent is how its own Deny is recognized.
219 if (sent && text.includes(sent)) return true;
220 return /doesn't want to proceed with this tool use|Denied by PermissionRequest hook|Permission to use .* (?:has been|was) denied/i.test(text);
221}
222
223/** A short summary of a question answered in the terminal, for Podium's "Answered in the terminal" (≤ 2,000 characters). */
224export function terminalAnswers(result: unknown): string | null {
225 const answers = (result as { result?: { answers?: unknown } } | null)?.result?.answers;
226 if (!answers || typeof answers !== 'object') return null;
227 // Claude Code's option labels carry "(Recommended)"; Podium shows the answer without it, as the mockup does.
228 const s = Object.values(answers as Record<string, unknown>)
229 .filter((v): v is string => typeof v === 'string' && v.trim() !== '')
230 .map((v) => v.replace(/\s*\(Recommended\)/gi, ''))
231 .join('; ');
232 return s ? line(s, RESPOND_LIMITS.answer) : null;
233}
234
235/** `HH:MM` in the session machine's local time. */
236export function clockTime(at: number): string {
237 const d = new Date(at);
238 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
239}
240
241export const LINE_OPEN = 'also open in Podium. Answer here or there.';
242export const LINE_MESSAGES_ON = 'messages from Podium are on.';
243
244/** The transcript line after an answer from Podium, naming who sent it and when (the mockup's section 4). */
245export function answeredLine(d: Delivery, at: number): string {
246 const verb = {
247 question: 'Answered',
248 reply: 'Replied',
249 'plan-approve': 'Approved',
250 'plan-feedback': 'Sent back',
251 'allow-once': 'Allowed once',
252 deny: 'Denied',
253 message: 'Sent',
254 command: 'Sent',
255 }[d.kind];
256 return `${verb} from Podium by ${d.by} at ${clockTime(at)}`;
257}
258