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 href="https://sonarcloud.io/summary/overall?id=eengelking_podium"><img alt="Quality Gate" height="20" src="https://sonarcloud.io/api/project_badges/measure?project=eengelking_podium&metric=alert_status"></a> <a href="https://sonarcloud.io/summary/overall?id=eengelking_podium"><img alt="Coverage" height="20" src="https://sonarcloud.io/api/project_badges/measure?project=eengelking_podium&metric=coverage"></a> <a href="https://sonarcloud.io/summary/overall?id=eengelking_podium"><img alt="Reliability" height="20" src="https://sonarcloud.io/api/project_badges/measure?project=eengelking_podium&metric=reliability_rating"></a> <a href="https://sonarcloud.io/summary/overall?id=eengelking_podium"><img alt="Security" height="20" src="https://sonarcloud.io/api/project_badges/measure?project=eengelking_podium&metric=security_rating"></a> <a href="https://sonarcloud.io/summary/overall?id=eengelking_podium"><img alt="Maintainability" height="20" src="https://sonarcloud.io/api/project_badges/measure?project=eengelking_podium&metric=sqale_rating"></a> <a href="https://github.com/eengelking/podium/actions/workflows/codeql.yml"><img alt="CodeQL" height="20" src="https://github.com/eengelking/podium/actions/workflows/codeql.yml/badge.svg"></a>
A Claude Code session is waiting on you...
Official homepage and documentation: podium.engelking.dev
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? The user guide walks through setup and reading the console, and the documentation table below lists everything else.
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 console 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). To run it as a managed service, see Running the server.
podium.engelking.dev is the official homepage and documentation site for Podium, with a menu, search and a version picker. The files below are the same ones, and they read fine on GitHub too.
| Read | To |
|---|---|
| Introduction | What Podium is, what it does and does not do, and where to read next |
| User guide | Set Podium up, read the console and use Admin |
| Running the server | Run it with podiumctl, let a team report to one server, put it behind a reverse proxy or turn on built-in TLS, and change its settings |
| The reporter mod | Install podium-reporter, set its options and see what leaves a session |
| Orchestrator messaging | Let your own orchestrators on different machines message each other through Podium: setup, rights, limits, Traffic, troubleshooting and an end-to-end test |
| Session recording and replay | Record a session whole and play it back: the three yes answers, what is and is not captured, the notice, retention, limits and who can read it |
| Gotchas | Sharp edges that are easy to hit, including why one machine shows several rows on the Reporters page |
| UI standards | How the UI looks and is built: tokens, themes, every component with its states, patterns, voice and accessibility. Open the page in a browser |
| Atlas | A short table of every part of Podium and every reporter hook, and where each runs. Open the interactive version in a browser |
| Demo | A script and a paste-in prompt for showing Podium to other people |
| Spec | The build spec: intended behavior and requirements |
| Architecture | How it was built, where it departs from the spec and why, what was verified, known issues |
| Decisions | Why the significant choices were made |
| Changelog | What changed in each release |
| Contributing | How to propose and land a change, and the tests and checks to run (PRs go to development, not main) |
| Security | How to report a vulnerability (privately) |
| Code of conduct | Community expectations |
| Community | Join the Discord, and how the server is set up and kept safe |
| Third-party notices | Credits and licenses for the color theme palettes |
Questions, show-and-tell and release news live in the Podium Discord server. The Code of conduct applies there. Bugs and feature requests go in GitHub Issues, and a security problem never goes in Discord: report it privately as described in the security policy. How to join and how the server is run is in Community.
Podium is released under the MIT License.
hooks/register.ts 2227 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). The exceptions answer the
4// mod's own tools: the messaging tools (#414) wait, at most 8 s, for a call the flush timer makes for them.
5//
6// The engine reads this file's use of `$` statically: `$` is only ever passed to functions declared
7// at the top of the file and always spelled `$.noun.event(...)`. So every helper that touches `$` is
8// a top-level function taking `$`, and the runtime lives in module variables (a hot reload re-runs
9// register, which resets them, and session.start rebuilds the state from the engine).
10import { modBuild } from './build.ts';
11import type { EngineInterface, Register } from 'claude-code';
12import { lastSegment, readChanges, readGit } from './git.ts';
13import { readFileDiff } from './fileDiff.ts';
14import { follow } from './place.ts';
15import { shortName } from './models.ts';
16import { buildReport, repoFromRemote } from './report.ts';
17import {
18 addEvent,
19 agentRequested,
20 agentSpawned,
21 compaction,
22 drainEvents,
23 measure,
24 newSession,
25 notification,
26 permissionRequest,
27 PLAN_TOOL,
28 reconcile,
29 resetForNewId,
30 describeBackground,
31 setBackground,
32 sessionEnd,
33 NO_FILES,
34 setAttached,
35 setFiles,
36 setGit,
37 setExplanation,
38 markTextSent,
39 type SentTexts,
40 setIdentity,
41 addActivity,
42 finishActivity,
43 isWatched,
44 isWatchedOutput,
45 setWatched,
46 setWatchedOutput,
47 skillLoaded,
48 step,
49 toolResult,
50 toolStarted,
51 turnComplete,
52 turnStart,
53 type ListedAgent,
54 type SessionState,
55} from './state.ts';
56import { nextCronAt } from './cron.ts';
57import { isDelivery, Transport, type FetchLike } from './transport.ts';
58import { findConfig, startLink, unusableLink, type Link } from './tlsLink.ts';
59import { activityCall, activityText, explainPrompt } from './failures.ts';
60import { activityEntry, finishedCall, type BashResult } from './activityDetail.ts';
61import { nameArg, pickIdentity, worthHolding, type Identity } from './identity.ts';
62import { hostOf, isMachineId, MACHINE_ID_KEY, machineIdPath, machineNamePath, localHostNameOf, parseMachineNameFile, machineInfo, newMachineId, platformRandom, type RandomSource, osFromOsRelease, osFromSwVers, osFromVer, parseMachineIdFile, trimTrailingSlashes } from './machine.ts';
63import { MAIN_LOOP, RECORDING_LIMITS, type Ask, type Delivery, type DeliveryResult, type MachineInfo, type PeerDelivery, type PeerRequest, type PeerResult, type RecordingGrant, type RespondInfo, type RespondKind, type RespondLevel } from './protocol.ts';
64import { isRecordingGrant } from './transport.ts';
65import { limitLine, Recorder, refusedLine, startLine, stoppedLine, unframeSent } from './recording.ts';
66import { redactText } from './diffRedact.ts';
67import {
68 accessAnswer,
69 answerPaused,
70 appliedLevel,
71 deliveredLine,
72 errorOf,
73 findRequest,
74 isAccessAnswer,
75 isRequestId,
76 listAnswer,
77 noticePrompt,
78 oneLine,
79 openRequestsLine,
80 parseAnswer,
81 peerPrompt,
82 refusalAnswer,
83 requestAnsweredLine,
84 requestLine,
85 requestToast,
86 sendAnswer,
87 shortId,
88 SUBAGENT_ACCESS,
89 SUBAGENT_PEERS,
90 SUBAGENT_SEND,
91 TOOL_DESCRIPTIONS,
92} from './peers.ts';
93import {
94 answeredLine,
95 answersMap,
96 decisionRecord,
97 LINE_MESSAGES_ON,
98 LINE_OPEN,
99 planApproval,
100 planAsk,
101 planFeedback,
102 planRejection,
103 questionAsk,
104 respondInfo,
105 respondLevel,
106 terminalAnswers,
107 toolAsk,
108 toolDecision,
109 toolDenied,
110} from './respond.ts';
111
112export const VERSION = '1.3.1';
113const GIT_POLL_MS = 10_000;
114/** session.end has one ~1.5 s bound for every hook and core together; stay well inside it. */
115const END_FLUSH_MS = 1000;
116const ERRORS_KEPT = 20;
117/** Text read from one watched step before the rest is let through unread: far more than one 300-character entry needs. */
118const STEP_TEXT_MAX = 8000;
119
120type Engine = EngineInterface;
121
122interface Options {
123 serverUrl: string;
124 token: string;
125 flushIntervalMs: number;
126 heartbeatMs: number;
127 sessionName: string;
128 userName: string;
129 showStatus: boolean;
130 explainFailures: boolean;
131 fullPrompts: boolean;
132 activityFeed: boolean;
133 /** A Bash call's output may go out with its entry, for a loop a viewer has the feed open on (#431). Needs `activityFeed`. */
134 activityOutput: boolean;
135 /** Answer requests for one file's diff (#319). */
136 fileDiff: boolean;
137 /** What this session accepts from Podium (#206). */
138 respond: RespondLevel;
139 /** Message the owner's other orchestrators through Podium (#347, #414). */
140 peers: boolean;
141 /** The owner's consent to a recording of this machine's sessions (#353): off by default, enforced here and never by the server. */
142 sessionRecording: boolean;
143}
144
145interface HookStat {
146 calls: number;
147 totalMs: number;
148 maxMs: number;
149}
150
151interface Runtime {
152 state: SessionState;
153 transport: Transport;
154 timers: { cancel: () => void }[];
155 /**
156 * Runs a flush PEER_KICK_MS from now on the engine the timers run on (session.start's), so one a tool asks for runs as
157 * the timer's does (#414). `first` runs just before it.
158 */
159 flushSoon: (first: () => void) => { cancel: () => void };
160 /** The flush a tool asked for, waiting to run; null when none waits (`kickFlush`). */
161 kick: { cancel: () => void } | null;
162 storedName: string | null;
163 /** Claude Code's own name for a session, from a hook's `session_title`, with the id it was taken under; null until a hook carries one (#322). It counts only while that id is the session being reported, so a /clear or resume never inherits it. The hook's own `session_id` is not used: the reporter keys everything on `$.session.id()`. */
164 claudeName: { id: string; name: string } | null;
165 engineVersion: string | null;
166 hostSurface: string | null;
167 /** The commit of a dev build of this mod, found once per load (#400); null for a release or an installed copy. */
168 build: string | null;
169 flushing: boolean;
170 /** A tool's early run is making calls (`flushPeers`); kept apart from `flushing` so a slow report never holds it. */
171 peerFlushing: boolean;
172 lastStatus: string | null;
173 toastedOk: boolean;
174 toastedFail: boolean;
175 /** Whether the plain-HTTP notice has been toasted in this session (#306). */
176 toastedPlain: boolean;
177 /** The refusal notice last toasted, so a later refusal is news again once a report has gone through. */
178 toastedRefusal: string | null;
179 /** The version notice last toasted, so each distinct one shows once. */
180 toastedVersion: string | null;
181 /** Skill tool calls in flight: a skill.prompt during one belongs to that call's loop. */
182 skillCalls: number;
183 /** This machine, read only when a userName is set; null otherwise, so nothing identifying is sent. */
184 machine: MachineInfo | null;
185 /** The session this one continues after a /clear (#206); sent with every report under the new id. */
186 clearedFrom: string | null;
187 /** Whether a person's client is on this session: false for a `claude -p` run, which never responds. */
188 interactive: boolean;
189 /** What `$.session.cwd()` last answered, so a move it reports is told apart from an answer that stayed put while the hooks carry the new folder (#382). */
190 sessionCwd: string | null;
191}
192
193export function readOptions(o: Record<string, unknown>): Options {
194 const s = (v: unknown, d: string) => (typeof v === 'string' ? v : d);
195 const n = (v: unknown, d: number, min: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(min, v) : d);
196 return {
197 serverUrl: s(o.serverUrl, 'http://127.0.0.1:4317') || 'http://127.0.0.1:4317',
198 token: s(o.token, ''),
199 flushIntervalMs: n(o.flushIntervalMs, 1000, 200),
200 heartbeatMs: n(o.heartbeatMs, 5000, 1000),
201 sessionName: s(o.sessionName, ''),
202 userName: s(o.userName, ''),
203 showStatus: typeof o.showStatus === 'boolean' ? o.showStatus : true,
204 explainFailures: o.explainFailures === true,
205 fullPrompts: o.fullPrompts === true,
206 activityFeed: o.activityFeed === true,
207 activityOutput: o.activityOutput === true,
208 fileDiff: o.fileDiff === true,
209 respond: respondLevel(o.respond),
210 peers: o.peers === true,
211 sessionRecording: o.sessionRecording === true,
212 };
213}
214
215// ---- module state (reset by register) -----------------------------------------------------------
216
217let opts: Options = readOptions({});
218/** The name and token this session reports as: taken once at session.start and held (#226), never read live from the options. */
219let who: Identity = { userName: '', token: '' };
220const IDENTITY = { plugin: 'podium-reporter', key: 'identity' } as const;
221let rt: Runtime | null = null;
222/** Where the name in `who` came from, for the wrong-config message (#647); empty when `who` has none. */
223let nameSource = '';
224/** True when that name is a setting the person can empty (the option or PODIUM_USER), false for the held identity. */
225let nameIsSetting = false;
226/** The helper that makes the TLS connection, when a reporter config file exists (#584); null sends reports as before, through `$.http.fetch`. */
227let link: Link | null = null;
228/** The start of `link` that is under way, so two overlapping session.start events share it. */
229let linking: Promise<void> | null = null;
230/** Bumped by each register (a reload, or a new test): a wait for an answer started under an older one stops. */
231let generation = 0;
232let errors: { at: number; where: string; message: string }[] = [];
233let stats: Record<string, HookStat> = {};
234/** Agents whose failure was sent for an explanation: at most one call per failure, never retried. */
235let explained = new Set<string>();
236
237// ---- responding (#206, SPEC 5.3) --------------------------------------------------------------
238
239/** One ask open in this session, and what each of its hooks knows about it. */
240interface OpenAsk {
241 ask: Ask;
242 sessionId: string;
243 /** The questions as Claude asked them, unredacted: Podium's answers by position are turned back into these. */
244 questions?: { question: string; options: { label: string }[] }[];
245 /** The race is over (answered, closed, or the tool call ended): every loop on it stops. */
246 done: boolean;
247 announced: boolean;
248 /** A plan: the approval handed to the PermissionRequest hook, and whoever waits for it. */
249 approval: Delivery | null;
250 approvalWaiters: ((d: Delivery | null) => void)[];
251 /** The approval was reported not taken: whatever the terminal does next is the terminal's. */
252 notTaken: boolean;
253 /** A tool: the answer the PermissionRequest hook returned. */
254 answered: Delivery | null;
255 /** When the PermissionRequest hook returned that answer (the recording keeps the moment it was decided). */
256 answeredAt: number;
257 /**
258 * Something still waits on the server for this ask's answer. Once the wait gives up (responding turned off, the ask
259 * closed there) the ask stops riding in reports, so the server never learns it again and takes an answer nobody collects.
260 */
261 listening: boolean;
262}
263
264/** A close or a result for the server, sent from the flush timer (Esc aborts a hook's own fetches). */
265interface OutItem {
266 path: string;
267 body: Record<string, unknown>;
268}
269
270const OUTBOX_MAX = 50;
271const ACTED_KEPT = 100;
272const ACTED_KEY = 'respond:acted';
273const HOLD_SECONDS = 20;
274/** How long an approval has to take before it is reported not taken. */
275const APPROVAL_CONFIRM_MS = 5000;
276/** How long after tool.check says `ask` a PermissionRequest must fire before the hooks are taken as not running. */
277const PERMISSION_CHECK_MS = 2000;
278
279let asks = new Map<string, OpenAsk>();
280let outbox: OutItem[] = [];
281let acted: string[] = [];
282/** Requests for a file's diff already answered (#319), so one listed in two replies is read once. */
283const DIFFS_KEPT = 50;
284let diffsDone: string[] = [];
285/** null until known; false once a prompt showed with no PermissionRequest; true once one fired. */
286let permissionHooks: boolean | null = null;
287let lastPermissionAt = 0;
288let checks = new Map<string, { cancel: () => void }>();
289/** Each loop's latest tool call, so a PermissionRequest (which has no tool_use_id) can be paired with it. */
290let lastCalls = new Map<string, { tool: string; input: string; id: string }>();
291/** The `seq` a recorded tool call got, by its tool_use_id, so an answer or approval from Podium points at its call (#353). Dropped when the call ends. */
292let callSeqs = new Map<string, number>();
293/** A /clear or /compact sent from Podium and running now: the first mark of that kind is Podium's, and only while the command runs (#353). */
294let ranFromPodium: { command: 'clear' | 'compact'; by: string } | null = null;
295let messagesLineShown = false;
296const EXPLAIN_MODEL = 'haiku';
297
298// ---- orchestrator messaging (#347, #414, SPEC 5.3) ---------------------------------------------
299// Kept apart from responding: a peer message only ever becomes a framed `$.prompt.submit`, through its own functions
300// below. It never reaches an ask's race, a plan's approval, a tool's decision or `$.command.run`.
301
302/** A call a messaging tool waits on, made by the flush timer (Esc aborts a hook's own fetches). */
303interface PeerCall {
304 method: 'GET' | 'POST';
305 path: string;
306 body?: unknown;
307 /** The timer has started the request: the hook can no longer take it back. */
308 sent: boolean;
309 /** The hook stopped waiting: whatever comes back is nobody's. */
310 abandoned: boolean;
311 resolve: (res: { status: number; text: string } | null) => void;
312}
313
314/** How long a messaging tool waits for the flush timer's answer: inside a hook's 10-second budget, which a plain wait spends. */
315const PEER_CALL_WAIT_MS = 8000;
316/** How soon after a tool queues a call the flush timer runs for it: at once, but from a timer (`kickFlush`). */
317const PEER_KICK_MS = 50;
318/** Requests remembered for `/podium allow|decline|deliver`, after they leave the reply too, so a late answer hears how it closed. */
319const PEER_REQUESTS_KEPT = 50;
320
321let peerCalls: PeerCall[] = [];
322/** The three tools were registered in this load: once the reply first says the session takes part. */
323let peerToolsRegistered = false;
324/** Requests seen in this load, by id, oldest first: each is toasted once. */
325let peerKnown = new Map<string, PeerRequest>();
326/** The ids the last reply listed as open. */
327let peerOpen: string[] = [];
328
329const perf = typeof performance !== 'undefined' && typeof performance.now === 'function' ? () => performance.now() : () => Date.now();
330const nowMs = () => Date.now();
331
332/** Read-only view of the runtime, for tests and /podium. */
333export function runtimeInfo(): { sessionId: string | null; errors: readonly { where: string; message: string }[]; stats: Readonly<Record<string, HookStat>> } {
334 return { sessionId: rt?.state.id ?? null, errors, stats };
335}
336
337function remember(where: string, err: unknown): string {
338 const message = String((err as Error)?.message ?? err).slice(0, 200);
339 errors.push({ at: nowMs(), where, message });
340 if (errors.length > ERRORS_KEPT) errors.shift();
341 return message;
342}
343
344function record($: Engine, where: string, err: unknown): void {
345 const message = remember(where, err);
346 try {
347 $.ui.log(`podium-reporter: ${where}: ${message}`, { to: 'debug' });
348 } catch {
349 // logging is best effort
350 }
351}
352
353/** Runs reporter logic for a hook: any throw is remembered, never raised. Timed for /podium. */
354function safe(where: string, fn: (r: Runtime) => void): void {
355 const t0 = perf();
356 try {
357 if (rt) fn(rt);
358 } catch (err) {
359 remember(where, err);
360 } finally {
361 const ms = perf() - t0;
362 const st = (stats[where] ??= { calls: 0, totalMs: 0, maxMs: 0 });
363 st.calls++;
364 st.totalMs += ms;
365 st.maxMs = Math.max(st.maxMs, ms);
366 }
367}
368
369/**
370 * What the session says about responding in its reports. Sent with level `off` too: only a reporter older than 0.14.0
371 * leaves it out, which Podium reads as "Reporter can't answer" (SPEC 6.2), so off has to say so.
372 */
373function respondNow(r: Runtime): RespondInfo {
374 return respondInfo(opts.respond, opts.activityFeed, r.interactive, permissionHooks);
375}
376
377/** Whether the mod answers anything from Podium now: its own switches, and the server's last word. */
378function responding(r: Runtime | null, kind: RespondKind): boolean {
379 return !!r && r.transport.respond && !r.state.ended && respondNow(r).accepts.includes(kind);
380}
381
382/** Whether this session's orchestrator may say it takes part in messaging: the option on, and a person on the session. */
383function peersWanted(r: Runtime): boolean {
384 return opts.peers && r.interactive;
385}
386
387/** Whether the mod takes part now (SPEC 5.3 "When the mod takes part"): its own switches, and the server's last word. */
388function peersOn(r: Runtime | null): r is Runtime {
389 return !!r && peersWanted(r) && r.transport.peers && !r.state.ended;
390}
391
392function openAsks(r: Runtime): Ask[] {
393 return [...asks.values()].filter((a) => !a.done && a.listening && a.sessionId === r.state.id).map((a) => a.ask);
394}
395
396/** Keep the name Claude Code gives the session, under the id being reported. A hook with no title leaves the last one alone. */
397function takeClaudeName(r: Runtime, hook: { session_title?: string }): void {
398 const name = typeof hook.session_title === 'string' ? hook.session_title.trim().slice(0, 80) : '';
399 const id = r.state.id;
400 if (!name || (r.claudeName?.id === id && r.claudeName.name === name)) return;
401 r.claudeName = { id, name };
402 r.state = { ...r.state, dirty: true };
403}
404
405function metaOf(r: Runtime, now: number) {
406 return {
407 version: VERSION,
408 engineVersion: r.engineVersion,
409 hostSurface: r.hostSurface,
410 build: r.build,
411 now,
412 storedName: r.storedName,
413 claudeName: r.claudeName?.id === r.state.id ? r.claudeName.name : null,
414 optionName: opts.sessionName || null,
415 userName: who.userName || null,
416 machine: r.machine,
417 fullText: opts.fullPrompts,
418 activity: opts.activityFeed,
419 activityOutput: opts.activityOutput,
420 fileDiff: opts.fileDiff,
421 sessionRecording: opts.sessionRecording,
422 respond: respondNow(r),
423 asks: r.transport.respond && opts.respond !== 'off' ? openAsks(r) : null,
424 clearedFrom: r.clearedFrom,
425 peers: peersWanted(r),
426 };
427}
428
429/** A version with `· Dev: <commit>` after it for a dev build (#400). */
430function devNote(version: string, build: string | null | undefined): string {
431 return build ? `${version} · Dev: ${build}` : version;
432}
433
434function showStatus($: Engine, r: Runtime): void {
435 if (!opts.showStatus) return;
436 const text = r.transport.statusText(link?.problem()?.status);
437 if (text === r.lastStatus) return;
438 r.lastStatus = text;
439 try {
440 $.ui.status(text);
441 } catch {
442 // no status line on this host
443 }
444}
445
446function toastOnce($: Engine, r: Runtime, ok: boolean): void {
447 try {
448 if (ok && !r.toastedOk) {
449 r.toastedOk = true;
450 $.ui.toast(`Podium: reporting to ${serverBase()}`);
451 } else if (!ok && !r.toastedFail) {
452 r.toastedFail = true;
453 $.ui.toast(`Podium: cannot reach ${serverBase()}; will keep retrying`);
454 }
455 } catch {
456 // no toasts on this host
457 }
458}
459
460function toastRefusalOnce($: Engine, r: Runtime): void {
461 // With a config file there is no token to blame: the link's certificate message stands in for the token one (#646).
462 const refusal = r.transport.refused && link?.problem() ? link.problem()?.text : r.transport.refusalNotice();
463 const notice = refusal ?? r.transport.ownerNotice();
464 if (!notice || notice === r.toastedRefusal) return;
465 r.toastedRefusal = notice;
466 try {
467 $.ui.toast(`Podium: ${notice}`);
468 } catch {
469 // no toasts on this host
470 }
471}
472
473function toastPlainHttpOnce($: Engine, r: Runtime): void {
474 const notice = r.transport.plainHttpNotice();
475 if (!notice || r.toastedPlain) return;
476 r.toastedPlain = true;
477 try {
478 $.ui.toast(`Podium: ${notice}`);
479 } catch {
480 // no toasts on this host
481 }
482}
483
484function toastVersionOnce($: Engine, r: Runtime): void {
485 const notice = r.transport.versionNotice();
486 if (!notice) {
487 r.toastedVersion = null; // matched again: a later mismatch is news again
488 return;
489 }
490 if (notice === r.toastedVersion) return;
491 r.toastedVersion = notice;
492 try {
493 $.ui.toast(`Podium: ${notice}`);
494 } catch {
495 // no toasts on this host
496 }
497}
498
499/** Moves queued events to the transport and posts one report if one is due (or `force`). */
500async function flushOnce($: Engine, r: Runtime, now: number, force: boolean): Promise<void> {
501 const [st, events] = drainEvents(r.state);
502 r.state = st;
503 r.transport.enqueue(events);
504 if (!force && !r.transport.due(now, r.state.dirty)) return;
505 const snapshot = r.state;
506 r.state = { ...r.state, dirty: false };
507 let sentTexts: SentTexts = {};
508 const outcome = await r.transport.send((evs) => {
509 const report = buildReport(snapshot, evs, metaOf(r, now));
510 sentTexts = Object.fromEntries(report.agents.map((a) => [a.id, { promptText: a.promptText, answerText: a.answerText }]));
511 return report;
512 }, now);
513 if (outcome === 'failed') {
514 r.state = { ...r.state, dirty: true };
515 toastOnce($, r, false);
516 } else if (outcome === 'refused') {
517 r.state = { ...r.state, dirty: true };
518 toastRefusalOnce($, r);
519 } else if (outcome === 'rejected') {
520 toastRefusalOnce($, r); // a 409 (this session belongs to someone else) is told once; any other rejection has no notice (#209)
521 } else if (outcome === 'ok') {
522 if (opts.fullPrompts) r.state = markTextSent(r.state, sentTexts);
523 // The reply names the loops someone is watching; with the option off nothing is ever watched (#118).
524 r.state = setWatched(r.state, opts.activityFeed ? r.transport.watch : []);
525 // A loop a viewer has the feed open on gets its Bash output read, with both options on (#431). Never a loop that is only in the history's list.
526 r.state = setWatchedOutput(r.state, opts.activityFeed && opts.activityOutput ? r.transport.watchOutput.filter((id) => r.state.watched.includes(id)) : []);
527 r.toastedRefusal = null; // accepted again: a later refusal is news again
528 toastOnce($, r, true);
529 handleDeliveries($, r);
530 handleDiffs($, r);
531 handlePeers($, r);
532 messagesLine($, r);
533 takeRecording($, r.transport.recording);
534 }
535 else if (outcome === 'busy') r.state = { ...r.state, dirty: true };
536 toastVersionOnce($, r);
537 toastPlainHttpOnce($, r);
538 showStatus($, r);
539}
540
541async function readUsage($: Engine, r: Runtime): Promise<void> {
542 try {
543 const u = await $.session.usage();
544 r.state = measure(r.state, { context: u.context, cost: u.cost ?? null, rateLimits: u.rateLimits, startedAt: u.startedAt });
545 } catch (err) {
546 record($, 'session.usage', err);
547 }
548}
549
550/** The roster of attached clients. An empty one is a background session with nobody on it; a failed read says nothing. */
551async function readSurfaces($: Engine, r: Runtime): Promise<void> {
552 try {
553 const clients = await $.session.surfaces();
554 r.state = setAttached(r.state, clients.length > 0);
555 } catch (err) {
556 record($, 'session.surfaces', err);
557 }
558}
559
560async function readAgents($: Engine, r: Runtime, now: number): Promise<void> {
561 try {
562 const list = (await $.agent.list()) as ListedAgent[];
563 r.state = reconcile(r.state, list, now);
564 } catch (err) {
565 record($, 'agent.list', err);
566 }
567}
568
569/** Take the folder the session has moved to (#382; place.ts decides). Returns whether the place changed. */
570async function movePlace($: Engine, r: Runtime, hookCwd?: string): Promise<boolean> {
571 const m = await follow($, { sessionCwd: r.sessionCwd, cwd: r.state.cwd, projectRoot: r.state.projectRoot }, hookCwd);
572 r.sessionCwd = m.sessionCwd;
573 if (m.place) r.state = setIdentity(r.state, m.place);
574 return !!m.place;
575}
576
577/** The commit this mod runs from when that is a dev checkout (#400); null for a release or an installed copy. */
578async function findBuild($: Engine): Promise<string | null> {
579 const root = await attempt<string | null>(() => Promise.resolve($.plugin.root), null);
580 if (!root) return null;
581 return modBuild(async (argv) => {
582 const res = await $.process.run(argv, { timeoutMs: 5000 });
583 return { exitCode: res.exitCode, stdout: res.stdout };
584 }, root);
585}
586
587async function pollGit($: Engine, r: Runtime): Promise<void> {
588 try {
589 await movePlace($, r);
590 const cwd = r.state.cwd;
591 const run = async (argv: string[]) => {
592 const res = await $.process.run(argv, { cwd, timeoutMs: 5000 });
593 return { exitCode: res.exitCode, stdout: res.stdout };
594 };
595 const git = await readGit(run, cwd);
596 r.state = setGit(r.state, git);
597 const id = r.state.id;
598 let base = r.state.gitBase ?? (await loadBase($, id));
599 // Tried even when readGit gave null: a repository with no commits yet fails that (no HEAD) but has files to list.
600 const changes = await readChanges(run, cwd, base);
601 if (!changes) {
602 if (!git) r.state = setFiles(r.state, NO_FILES, r.state.gitBase);
603 return;
604 }
605 if (r.state.id !== id) return;
606 if (changes.base !== base) await attempt(() => $.store.set(`base:${id}`, changes.base), undefined);
607 base = changes.base;
608 r.state = setFiles(r.state, changes.files, base);
609 } catch (err) {
610 record($, 'git', err);
611 }
612}
613
614/** The starting commit saved by an earlier run of this session (a hot reload keeps the process, a restart does not). */
615async function loadBase($: Engine, id: string): Promise<string | null> {
616 try {
617 const v = await $.store.get(`base:${id}`);
618 return typeof v === 'string' && v ? v : null;
619 } catch {
620 return null;
621 }
622}
623
624async function loadName($: Engine, id: string): Promise<string | null> {
625 try {
626 const v = await $.store.get(`name:${id}`);
627 return typeof v === 'string' && v.trim() ? v.trim() : null;
628 } catch {
629 return null;
630 }
631}
632
633/** A /clear happened: the old id gets its final report, then reporting starts under the new id. */
634async function switchSession($: Engine, r: Runtime, newId: string, now: number): Promise<void> {
635 const oldId = r.state.id;
636 const cleared = !r.state.ended || r.state.endReason === 'clear';
637 for (const a of asks.values()) if (a.sessionId === oldId) a.done = true;
638 if (!r.state.ended) {
639 recorder.clear(MAIN_LOOP, nowMs(), takePodiumCommand('clear'));
640 r.state = sessionEnd(r.state, 'clear', r.state.id, now);
641 await r.transport.idle();
642 await flushOnce($, r, now, true);
643 }
644 // What the old id recorded goes under the old id, then the new one starts over: it is recorded only if its own replies say so.
645 recorder.close();
646 await sendRecording($, now);
647 recorder.begin(newId);
648 r.transport.queue = [];
649 let startedAt = now;
650 try {
651 startedAt = (await $.session.usage()).startedAt || now;
652 } catch {
653 // keep now
654 }
655 const effort = r.state.main.effort;
656 r.state = addEvent(resetForNewId(r.state, newId, startedAt), 'start', null, `session.start · ${shortName(r.state.main.model)}${effort ? ` ${effort}` : ''}`, now);
657 r.storedName = await loadName($, newId);
658 r.clearedFrom = cleared ? oldId : null;
659 messagesLineShown = false;
660}
661
662/**
663 * The flush timer: notice a session id change, refresh usage and agents, post a report if due. `peersOnly` is the early
664 * run a tool asks for (`kickFlush`): it makes the tools' calls and nothing else, so the report keeps its interval.
665 */
666async function flush($: Engine, peersOnly = false): Promise<void> {
667 const r = rt;
668 if (!r) return;
669 if (peersOnly) return flushPeers($, r);
670 if (r.flushing) return;
671 r.flushing = true;
672 let now: number | null = null;
673 try {
674 // Scheduling (heartbeat, backoff) runs on the engine's clock; hooks stamp events with Date.now().
675 now = await engineNow($);
676 let id: string | null = null;
677 try {
678 id = await $.session.id();
679 } catch (err) {
680 record($, 'session.id', err);
681 }
682 if (id && id !== r.state.id) await switchSession($, r, id, now);
683 // A tool's call first: it waits seconds at most, and the outbox can hold 50 results after an outage (#414).
684 await sendPeerCalls($, r, now);
685 await sendOutbox($, r, now);
686 if (r.state.ended) return;
687 await readUsage($, r);
688 await readSurfaces($, r);
689 await readAgents($, r, now);
690 if (opts.explainFailures) requestExplanations($, r);
691 await flushOnce($, r, now, false);
692 await sendRecording($, now);
693 } catch (err) {
694 record($, 'flush', err);
695 } finally {
696 r.flushing = false;
697 // A tool queued a call after this flush made its calls: flush again soon rather than at the next tick.
698 if (now !== null && !peerCallsHeld(r, now) && peerCalls.some((c) => !c.abandoned)) kickFlush(r);
699 }
700}
701
702/**
703 * A tool's early run: only the tools' calls, under its own guard, so it goes while a regular flush is still reading
704 * usage or posting a slow report, which can take longer than the tool's wait. Both drain the same queue, one call at a
705 * time each, and both hold back while the report backs off.
706 */
707async function flushPeers($: Engine, r: Runtime): Promise<void> {
708 if (r.peerFlushing) return;
709 r.peerFlushing = true;
710 let now: number | null = null;
711 try {
712 now = await engineNow($);
713 await sendPeerCalls($, r, now);
714 } catch (err) {
715 record($, 'flush', err);
716 } finally {
717 r.peerFlushing = false;
718 if (now !== null && !peerCallsHeld(r, now) && peerCalls.some((c) => !c.abandoned)) kickFlush(r);
719 }
720}
721
722/** The engine's time; Date.now() where the host has no clock noun. */
723async function engineNow($: Engine): Promise<number> {
724 try {
725 return await $.clock.now();
726 } catch {
727 return nowMs();
728 }
729}
730
731/** T5.2, off by default: one small-model explanation per new failure, from the redacted record only. */
732function requestExplanations($: Engine, r: Runtime): void {
733 for (const id of r.state.order) {
734 const a = r.state.agents[id];
735 // Keyed by the failure itself (its time), not just the agent: a retried agent that fails again is a new failure.
736 const key = a?.failure ? `${r.state.id}:${id}:${a.failure.at}` : '';
737 if (!a?.failure || a.failure.explanation || explained.has(key)) continue;
738 explained.add(key);
739 void explain($, r.state.id, id, explainPrompt(a.failure));
740 }
741}
742
743async function explain($: Engine, sessionId: string, agentId: string, prompt: string): Promise<void> {
744 try {
745 const res = await $.model.complete({ model: EXPLAIN_MODEL, effort: 'low', prompt, maxTokens: 200, timeoutMs: 30_000 });
746 if (!res.isAnswered || !rt || rt.state.id !== sessionId) return;
747 rt.state = setExplanation(rt.state, agentId, res.text, res.usage, EXPLAIN_MODEL);
748 } catch (err) {
749 record($, 'model.complete', err);
750 }
751}
752
753/** A random number in [0, 1): the platform's generator where the sandbox has one. */
754function random(): number {
755 return platformRandom((globalThis as { crypto?: RandomSource }).crypto);
756}
757
758/**
759 * The machine's id, one for every load of the mod on it (#267). The shared file under the Claude config
760 * directory decides; without a valid one, the id this load's store already holds is kept (so an install
761 * keeps its row) and shared, else a new one is made and shared. The file is read back after a write. Two loads
762 * that start in the same instant can still each keep the id they wrote for that session (the last write is
763 * the file's), and agree from their next session on. Where the file cannot be used, the store alone
764 * keeps the id, as before.
765 */
766async function machineId($: Engine): Promise<string> {
767 const path = machineIdPath(
768 await attempt(() => $.env.get('CLAUDE_CONFIG_DIR'), undefined),
769 await attempt(() => $.env.get('HOME'), undefined),
770 );
771 const shared = async () => (path ? parseMachineIdFile(await attempt(async () => (await $.fs.read(path)) as string, null)) : null);
772 const fromFile = await shared();
773 const kept = await attempt(() => $.store.get(MACHINE_ID_KEY), undefined);
774 if (fromFile) {
775 // Keep this load's store in step, so the id survives the shared file being deleted.
776 if (fromFile !== kept) await attempt(() => $.store.set(MACHINE_ID_KEY, fromFile), undefined);
777 return fromFile;
778 }
779 const id = isMachineId(kept) ? kept : newMachineId(random);
780 if (id !== kept) await attempt(() => $.store.set(MACHINE_ID_KEY, id), undefined);
781 if (!path) return id;
782 await attempt(() => $.fs.write(path, `${id}\n`), undefined);
783 return (await shared()) ?? id;
784}
785
786/**
787 * The machine's short host name, pinned (#715). `hostname` follows the network on a Mac with no `HostName` set (a
788 * DHCP or reverse-DNS answer such as `Mac`, or the stable `LocalHostName`), and can change on Linux with a DHCP
789 * hostname or a recreated container, so one machine showed two names. The first name read is kept in
790 * `podium-machine-name` beside the id file and every later load reports it. Where the file cannot be used, the live
791 * name is reported as before. Delete the file to take a new name.
792 */
793async function machineName($: Engine): Promise<string | null> {
794 const path = machineNamePath(
795 await attempt(() => $.env.get('CLAUDE_CONFIG_DIR'), undefined),
796 await attempt(() => $.env.get('HOME'), undefined),
797 );
798 const pinnedName = async () => (path ? parseMachineNameFile(await attempt(async () => (await $.fs.read(path)) as string, null)) : null);
799 const pinned = await pinnedName();
800 if (pinned) return pinned;
801 const run = async (argv: string[]) => {
802 const res = await $.process.run(argv, { timeoutMs: 2000 });
803 return res.exitCode === 0 ? res.stdout : null;
804 };
805 // macOS first: LocalHostName is the Sharing name and does not follow the network. A missing `scutil` rejects.
806 const live =
807 (await attempt(async () => localHostNameOf(await run(['scutil', '--get', 'LocalHostName'])), null)) ??
808 (await attempt(async () => hostOf(await run(['hostname'])), null));
809 if (!live || !path) return live;
810 await attempt(() => $.fs.write(path, `${live}\n`), undefined);
811 // Two loads starting together both write; each reports whichever name the file ended with, as machineId does.
812 return (await pinnedName()) ?? live;
813}
814
815/**
816 * This machine's short host name and the random id the mod keeps for it in its own store (made on first
817 * use). Read only when a userName is set: an unnamed reporter sends nothing that identifies the machine.
818 */
819async function readMachine($: Engine): Promise<MachineInfo | null> {
820 if (!who.userName.trim()) return null;
821 try {
822 const id = await machineId($);
823 // The host name and the OS at once: session.start waits on both, and each can take a process's timeout.
824 const [host, os] = await Promise.all([machineName($), readOs($)]);
825 return machineInfo(host, id, os);
826 } catch (err) {
827 record($, 'machine', err);
828 return null;
829 }
830}
831
832/**
833 * The machine's OS and version (#414, SPEC 5.3 "The OS"): the first of `sw_vers` (macOS), `/etc/os-release` (Linux)
834 * and `cmd /c ver` (Windows) that works. A missing executable rejects, so each is tried in turn. Null when none does.
835 */
836async function readOs($: Engine): Promise<string | null> {
837 const run = async (argv: string[]) => {
838 const res = await $.process.run(argv, { timeoutMs: 2000 });
839 return res.exitCode === 0 ? res.stdout : null;
840 };
841 return (
842 (await attempt(async () => osFromSwVers(await run(['sw_vers'])), null)) ??
843 (await attempt(async () => osFromOsRelease((await $.fs.read('/etc/os-release')) as string), null)) ??
844 (await attempt(async () => osFromVer(await run(['cmd', '/c', 'ver'])), null))
845 );
846}
847
848async function attempt<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
849 try {
850 return await fn();
851 } catch {
852 return fallback;
853 }
854}
855
856// ---- responding: talking to the server (#206) -------------------------------------------------
857
858function serverBase(): string {
859 return trimTrailingSlashes(link?.serverUrl ?? opts.serverUrl);
860}
861
862function sessionPath(sessionId: string): string {
863 return `/api/fleet/sessions/${encodeURIComponent(sessionId)}`;
864}
865
866/** One request to the server with the reporter's token; null when it could not be made (offline, or aborted by Esc). */
867async function podiumCall($: Engine, method: string, path: string, body?: unknown): Promise<{ status: number; text: string } | null> {
868 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
869 if (who.token) headers['X-Podium-Token'] = who.token;
870 try {
871 const init = body === undefined ? { method, headers } : { method, headers, body: JSON.stringify(body) };
872 const res = await (link ? link.fetch(serverBase() + path, init) : $.http.fetch(serverBase() + path, init));
873 return { status: res.status, text: res.text };
874 } catch {
875 return null;
876 }
877}
878
879/** A line in the transcript; the engine draws it as `podium-reporter: <text>`. */
880function transcriptLine($: Engine, text: string): void {
881 try {
882 $.ui.log(text);
883 } catch {
884 // no transcript on this host
885 }
886}
887
888function queueOut(item: OutItem): void {
889 outbox.push(item);
890 if (outbox.length > OUTBOX_MAX) outbox.splice(0, outbox.length - OUTBOX_MAX);
891}
892
893function queueResult(sessionId: string, deliveryId: string, result: DeliveryResult): void {
894 queueOut({ path: `${sessionPath(sessionId)}/deliveries/${encodeURIComponent(deliveryId)}/result`, body: { result, at: nowMs() } });
895}
896
897function queueClose(a: OpenAsk, outcome: 'terminal' | 'closed' | 'ended', extra: { answer?: string | null; planApproved?: boolean } = {}): void {
898 queueOut({ path: `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}/close`, body: { outcome, at: nowMs(), ...extra } });
899}
900
901/**
902 * The flush timer sends what the hooks queued. A failure keeps the rest for the next tick; a refusal drops the item.
903 * A call a tool queued meanwhile goes between items, ahead of the rest (#414).
904 */
905async function sendOutbox($: Engine, r: Runtime, now: number): Promise<void> {
906 while (outbox.length) {
907 const item = outbox[0]!;
908 const res = await podiumCall($, 'POST', item.path, item.body);
909 if (!res) return;
910 outbox.shift();
911 await sendPeerCalls($, r, now);
912 }
913}
914
915async function loadActed($: Engine): Promise<string[]> {
916 const v = await attempt(() => $.store.get(ACTED_KEY), undefined);
917 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(-ACTED_KEPT) : [];
918}
919
920/** Notes a delivery as acted on, here and in the store, so one handed over twice (a lost reply, a reload) acts once. */
921function markActed($: Engine, id: string): boolean {
922 if (acted.includes(id)) return false;
923 acted.push(id);
924 if (acted.length > ACTED_KEPT) acted.splice(0, acted.length - ACTED_KEPT);
925 void attempt(() => $.store.set(ACTED_KEY, acted), undefined);
926 return true;
927}
928
929/** Announces an ask (PUT). The first time it lands, the terminal says it is also open in Podium. */
930async function announce($: Engine, a: OpenAsk): Promise<number> {
931 const res = await podiumCall($, 'PUT', `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}`, a.ask);
932 if (res?.status === 200 && !a.announced) {
933 a.announced = true;
934 transcriptLine($, LINE_OPEN);
935 }
936 return res?.status ?? 0;
937}
938
939function openAsk(r: Runtime, ask: Ask, questions?: OpenAsk['questions']): OpenAsk {
940 const a: OpenAsk = { ask, sessionId: r.state.id, questions, done: false, announced: false, approval: null, approvalWaiters: [], notTaken: false, answered: null, answeredAt: 0, listening: true };
941 asks.set(ask.id, a);
942 r.state = { ...r.state, dirty: true };
943 return a;
944}
945
946function finish(a: OpenAsk): void {
947 a.done = true;
948 for (const w of a.approvalWaiters.splice(0)) w(null);
949 asks.delete(a.ask.id);
950 if (rt) rt.state = { ...rt.state, dirty: true };
951}
952
953/**
954 * Waits on the server for the next answer to an ask that `wanted` takes, until one comes, the ask is done, or the server
955 * stops taking answers. An answer of another kind is refused (or, for a plan, handed to `other`); one that `skip` names
956 * is left alone for another wait on the same ask to act on.
957 */
958async function waitForAnswer($: Engine, a: OpenAsk, wanted: (d: Delivery) => boolean, other?: (d: Delivery) => void, skip?: (d: Delivery) => boolean): Promise<Delivery | null> {
959 const d = await pollAnswer($, a, wanted, other, skip);
960 if (!d && !a.done) {
961 a.listening = false;
962 if (rt) rt.state = { ...rt.state, dirty: true };
963 }
964 return d;
965}
966
967/**
968 * Waits `ms` on the engine's clock. False when the sleep fails: its dispatch was aborted or let go, so the engine
969 * behind this wait is gone, and a loop that went round again would never wait at all.
970 */
971async function pause($: Engine, ms: number): Promise<boolean> {
972 try {
973 await $.clock.sleep(ms);
974 return true;
975 } catch {
976 return false;
977 }
978}
979
980async function pollAnswer($: Engine, a: OpenAsk, wanted: (d: Delivery) => boolean, other?: (d: Delivery) => void, skip?: (d: Delivery) => boolean): Promise<Delivery | null> {
981 // `rt` is module state, so a wait from an older register would otherwise read a newer session's switches.
982 const gen = generation;
983 while (!a.done && gen === generation && rt?.transport.respond) {
984 const res = await podiumCall($, 'GET', `${sessionPath(a.sessionId)}/asks/${encodeURIComponent(a.ask.id)}/answer?wait=${HOLD_SECONDS}`);
985 if (a.done) {
986 const d = deliveryOf(res);
987 if (d && markActed($, d.id)) queueResult(a.sessionId, d.id, 'closed');
988 return null;
989 }
990 if (!res) {
991 if (!(await pause($, Transport.backoff(1)))) return null;
992 continue;
993 }
994 if (res.status === 204) continue;
995 if (res.status === 404) {
996 if ((await announce($, a)) !== 200 && !(await pause($, Transport.backoff(2)))) return null;
997 continue;
998 }
999 const d = deliveryOf(res);
1000 if (!d) return null;
1001 if (d.askId === a.ask.id && skip?.(d)) {
1002 // Another wait on this ask acts on it (a plan's feedback is the tool.call hook's); ask again once it has.
1003 if (!(await pause($, 500))) return null;
1004 continue;
1005 }
1006 if (!markActed($, d.id)) {
1007 // Already acted on and waiting for its result to reach the server: ask again shortly.
1008 if (!(await pause($, 1000))) return null;
1009 continue;
1010 }
1011 if (d.askId === a.ask.id && wanted(d)) return d;
1012 if (d.askId === a.ask.id && other) {
1013 other(d);
1014 continue;
1015 }
1016 queueResult(a.sessionId, d.id, 'refused');
1017 }
1018 return null;
1019}
1020
1021function deliveryOf(res: { status: number; text: string } | null): Delivery | null {
1022 if (!res || res.status !== 200) return null;
1023 try {
1024 const d = (JSON.parse(res.text) as { delivery?: unknown }).delivery;
1025 return isDelivery(d) ? d : null;
1026 } catch {
1027 return null;
1028 }
1029}
1030
1031// ---- responding: the hooks' races --------------------------------------------------------------
1032
1033type ToolResult = { result?: unknown; text?: string; isError?: boolean; deny?: string };
1034type Race = { res: ToolResult; d: null } | { res: null; d: Delivery };
1035
1036/** Whichever comes first: the terminal's answer, or one from Podium. A Podium wait that ends empty leaves the terminal. */
1037function race(terminal: Promise<ToolResult>, podium: Promise<Delivery | null>): Promise<Race> {
1038 const t: Promise<Race> = terminal.then((res) => ({ res, d: null }));
1039 const p: Promise<Race> = podium.then((d) => (d ? { res: null, d } : t));
1040 return Promise.race([t, p]);
1041}
1042
1043/** AskUserQuestion: the terminal's dialog (`next(e)`) against an answer from Podium. The first wins. */
1044async function questionRace($: Engine, r: Runtime, e: { tool_use_id: string; agentId?: string; questions?: unknown }, terminal: Promise<ToolResult>): Promise<ToolResult> {
1045 const input = e as unknown as Record<string, unknown>;
1046 const ask = questionAsk(e.tool_use_id, e.agentId ?? null, input, nowMs());
1047 if (!ask) return terminal;
1048 const raw = (Array.isArray(e.questions) ? e.questions : []) as OpenAsk['questions'];
1049 const a = openAsk(r, ask, raw);
1050 await announce($, a);
1051 const podium = waitForAnswer($, a, (d) => (d.kind === 'question' || d.kind === 'reply') && responding(rt, 'question'));
1052 const first = await race(terminal, podium);
1053 if (first.d) {
1054 const d = first.d;
1055 finish(a);
1056 queueResult(a.sessionId, d.id, 'took');
1057 transcriptLine($, answeredLine(d, nowMs()));
1058 recordDecision(e.agentId, e.tool_use_id, d, 'question', a.questions);
1059 const questions = e.questions;
1060 return d.kind === 'reply' ? { result: { questions, answers: {}, response: d.text ?? '' } } : { result: { questions, answers: answersMap(a.questions ?? [], d.answers ?? []) } };
1061 }
1062 const res = first.res as ToolResult;
1063 finish(a);
1064 // A rejection is Esc, an aborted turn, or the terminal's own "Chat about this": closed without an answer.
1065 if (res.isError || res.deny !== undefined) queueClose(a, 'closed');
1066 else queueClose(a, 'terminal', { answer: terminalAnswers(res) });
1067 return res;
1068}
1069
1070/**
1071 * ExitPlanMode: one ask, two hooks. Feedback ("No, keep planning", or a message in its place) is answered here as
1072 * `{ deny }`; an approval is handed to the waiting PermissionRequest hook, and counts only once the call resolves as
1073 * approved, within APPROVAL_CONFIRM_MS.
1074 */
1075async function planRace($: Engine, r: Runtime, e: { tool_use_id: string; agentId?: string }, terminal: Promise<ToolResult>): Promise<ToolResult> {
1076 const a = openAsk(r, planAsk(e.tool_use_id, e.agentId ?? null, e as unknown as Record<string, unknown>, nowMs()));
1077 await announce($, a);
1078 const podium = waitForAnswer($, a, (d) => (d.kind === 'plan-feedback' || d.kind === 'reply') && responding(rt, 'plan-feedback'), (d) => takeApproval($, a, d));
1079 const first = await race(terminal, podium);
1080 if (first.d) {
1081 finish(a);
1082 queueResult(a.sessionId, first.d.id, 'took');
1083 transcriptLine($, answeredLine(first.d, nowMs()));
1084 recordDecision(e.agentId, e.tool_use_id, first.d, 'plan');
1085 return planFeedback(first.d);
1086 }
1087 const res = first.res as ToolResult;
1088 const approved = !res.isError && res.deny === undefined;
1089 const approval = a.notTaken ? null : a.approval;
1090 finish(a);
1091 if (approval && approved) {
1092 queueResult(a.sessionId, approval.id, 'took');
1093 // Only now, once Claude Code took it: the terminal never reads "Approved from Podium" for one that didn't.
1094 transcriptLine($, answeredLine(approval, nowMs()));
1095 recordDecision(e.agentId, e.tool_use_id, approval, 'plan');
1096 } else {
1097 if (approval) queueResult(a.sessionId, approval.id, 'closed');
1098 if (approved) queueClose(a, 'terminal', { planApproved: true });
1099 else if (planRejection(res.text) === 'feedback') queueClose(a, 'terminal', { answer: 'No, keep planning' });
1100 else queueClose(a, 'closed');
1101 }
1102 return res;
1103}
1104
1105/** A plan's approval, from either wait on its ask: handed to the PermissionRequest hook, and checked that it took. */
1106function takeApproval($: Engine, a: OpenAsk, d: Delivery): void {
1107 if (d.kind !== 'plan-approve' || !responding(rt, 'plan-approve') || a.approval) {
1108 queueResult(a.sessionId, d.id, 'refused');
1109 return;
1110 }
1111 a.approval = d;
1112 for (const w of a.approvalWaiters.splice(0)) w(d);
1113 void confirmApproval($, a, d);
1114}
1115
1116/**
1117 * The PermissionRequest hook's wait for a plan's approval. It polls the server itself, so a held request is in flight
1118 * the whole time: that stops the hook's 10-second budget (HookBudget), where waiting on a promise alone would not, and
1119 * an approval sent long after the dialog opened still finds the hook. An approval the tool.call hook's wait got first
1120 * arrives through `approvalOf`; feedback and replies are left to that wait.
1121 */
1122async function approvalFromPodium($: Engine, a: OpenAsk): Promise<Delivery | null> {
1123 const fromServer = waitForAnswer(
1124 $,
1125 a,
1126 (d) => d.kind === 'plan-approve',
1127 undefined,
1128 (d) => d.kind === 'plan-feedback' || d.kind === 'reply',
1129 ).then((d) => {
1130 if (d) takeApproval($, a, d);
1131 return a.approval;
1132 });
1133 return Promise.race([approvalOf(a), fromServer]);
1134}
1135
1136/** An approval that the plan's tool call has not resolved as approved within the confirm window did not take. */
1137async function confirmApproval($: Engine, a: OpenAsk, d: Delivery): Promise<void> {
1138 await attempt(() => $.clock.sleep(APPROVAL_CONFIRM_MS), undefined);
1139 if (a.done) return;
1140 a.notTaken = true;
1141 queueResult(a.sessionId, d.id, 'not-taken');
1142}
1143
1144/** The plan ask of a loop, for its PermissionRequest hook. */
1145function planAskOf(agentId: string | undefined): OpenAsk | undefined {
1146 return [...asks.values()].reverse().find((a) => !a.done && a.ask.kind === 'plan' && a.ask.agentId === (agentId ?? null));
1147}
1148
1149/** Waits for the approval of a plan ask, or null once the ask is done. */
1150function approvalOf(a: OpenAsk): Promise<Delivery | null> {
1151 if (a.approval) return Promise.resolve(a.approval);
1152 if (a.done) return Promise.resolve(null);
1153 return new Promise((resolve) => a.approvalWaiters.push(resolve));
1154}
1155
1156function loopKey(agentId: string | null | undefined): string {
1157 return agentId ?? '@main';
1158}
1159
1160function inputKey(input: unknown): string {
1161 try {
1162 return JSON.stringify(input ?? null);
1163 } catch {
1164 return '';
1165 }
1166}
1167
1168/** Any PermissionRequest shows the hooks run here: the hooks are no longer taken as missing. */
1169function permissionSeen(r: Runtime, id: string | null): void {
1170 lastPermissionAt = nowMs();
1171 if (id) {
1172 checks.get(id)?.cancel();
1173 checks.delete(id);
1174 }
1175 if (permissionHooks !== true) {
1176 permissionHooks = true;
1177 r.state = { ...r.state, dirty: true };
1178 }
1179}
1180
1181/** tool.check said a prompt is about to show: if no PermissionRequest follows, the hooks don't run here (SPEC 5.3). */
1182function startPermissionCheck($: Engine, id: string): void {
1183 const startedAt = nowMs();
1184 checks.get(id)?.cancel();
1185 try {
1186 const h = $.clock.after(PERMISSION_CHECK_MS, () => {
1187 checks.delete(id);
1188 if (lastPermissionAt >= startedAt || !rt) return;
1189 permissionHooks = false;
1190 rt.state = { ...rt.state, dirty: true };
1191 });
1192 checks.set(id, h);
1193 } catch {
1194 // no clock: leave it unknown
1195 }
1196}
1197
1198/**
1199 * A tool's permission prompt: announced under the tool call it pairs with, and answered from Podium if an answer comes
1200 * while it is up. The engine does not end this hook when the terminal answers first; the tool call ending doeshooks/build.ts 32 lines1// Which build of the mod is running (#400): a release has none, a dev build is the commit it was run from. An installed copy has
2// no .git, so only a mod run straight from a checkout of the repository can be a dev build.
3import type { Runner } from './git.ts'
4
5/** The hash of a dev build, with `-dirty` after it for local edits; null at a clean release tag (vMAJOR.MINOR.PATCH). */
6export function buildId(hash: string, tag: string, dirty: boolean): string | null {
7 if (!dirty && /^v\d+\.\d+\.\d+$/.test(tag)) return null
8 return dirty ? `${hash}-dirty` : hash
9}
10
11/** The build of the mod in `root` (the plugin's folder, `$.plugin.root`); null for a release, an installed copy or when git cannot say. */
12export async function modBuild(run: Runner, root: string): Promise<string | null> {
13 const git = (...args: string[]) => run(['git', '-C', root, ...args])
14 try {
15 // The folder must be a tracked part of a repository: a mod copied under some other repo (a home directory kept in git) is no dev build.
16 if ((await git('ls-files', '--error-unmatch', '.claude-plugin/plugin.json')).exitCode !== 0) return null
17 const head = await git('rev-parse', '--short=8', 'HEAD')
18 const hash = head.stdout.trim()
19 if (head.exitCode !== 0 || !hash) return null
20 const tag = await git('describe', '--exact-match', '--tags', 'HEAD')
21 const status = await git('status', '--porcelain', '--', '.')
22 return buildId(hash, tag.exitCode === 0 ? tag.stdout.trim() : '', status.exitCode === 0 && status.stdout.trim() !== '')
23 } catch {
24 return null
25 }
26}
27
28/** `podium v1.1.0` with `| dev: a1b2c3d4` after it for a dev build. */
29export function withBuild(version: string, build: string | null | undefined): string {
30 return build ? `${version} | dev: ${build}` : version
31}
32hooks/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
133/** What the Files list diffs against, and which of the three bases that is. A rebase, an amend or a branch switch can drop the starting commit. */
134export async function resolveRef(run: Runner, top: string, start: string, head: string | null): Promise<{ ref: string; since: FilesReport['since'] }> {
135 const git = (...args: string[]) => ['git', '-C', top, ...args];
136 if (start === EMPTY_TREE) return { ref: EMPTY_TREE, since: 'start' };
137 if ((await run(git('merge-base', '--is-ancestor', start, 'HEAD'))).exitCode === 0) return { ref: start, since: 'start' };
138 const up = await run(git('merge-base', 'HEAD', '@{upstream}'));
139 const mb = up.exitCode === 0 ? up.stdout.trim() : '';
140 return { ref: mb || head || EMPTY_TREE, since: mb ? 'upstream' : 'worktree' };
141}
142
143export interface Changes {
144 /** The starting commit, to hand back on the next read. */
145 base: string;
146 files: FilesReport;
147}
148
149/**
150 * The files changed since the session started (SPEC 5.7). `base` is the commit HEAD was at when the
151 * session began, or null on the first read, which takes HEAD (or the empty tree before the first
152 * commit). Returns null when a query fails, so the caller keeps what it had.
153 */
154export async function readChanges(run: Runner, cwd: string, base: string | null): Promise<Changes | null> {
155 try {
156 const top = (await run(['git', 'rev-parse', '--show-toplevel'])).stdout.trim() || cwd;
157 const git = (...args: string[]) => ['git', '-C', top, ...args];
158 const out = async (argv: string[], okCodes: number[] = [0]) => {
159 const r = await run(argv);
160 if (!okCodes.includes(r.exitCode)) throw new Error(`${argv.join(' ')} exited ${r.exitCode}`);
161 return r.stdout;
162 };
163 const headRes = await run(git('rev-parse', 'HEAD'));
164 const head = headRes.exitCode === 0 ? headRes.stdout.trim() : null;
165 const start = base ?? head ?? EMPTY_TREE;
166 const { ref, since } = await resolveRef(run, top, start, head);
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/fileDiff.ts 123 lines1// Reading one file's diff on request (SPEC 6.4, "File diff", #319). `readFileDiff` runs git through the injected runner
2// and returns what the mod sends back: redacted, cut to the limits, against the same base the Files list counts from.
3// Nothing is read for a path the list does not name, and nothing for a path that usually holds secrets.
4import { EMPTY_TREE, resolveRef, type Runner } from './git.ts'
5import { redactDiff, withheldPath } from './diffRedact.ts'
6import type { FileDiffReply, FilesReport } from './protocol.ts'
7
8export const DIFF_LIMITS = {
9 /** Lines and bytes sent; more is cut at a line boundary. Bytes of `git diff` read before it answers `too-large`. The server holds the same numbers (FILE_DIFF_LIMITS). */
10 lines: 2000,
11 bytes: 200_000,
12 readBytes: 5_000_000,
13 /** A single line is cut to this many characters, so one minified file cannot fill the diff. */
14 lineChars: 4000,
15 /** A file the list counts at more changed lines than this is not read at all. */
16 countedLines: 200_000,
17} as const
18
19const LINE_CUT = ' … [line cut]'
20
21const reply = (path: string, status: FileDiffReply['status'], over: Partial<FileDiffReply> = {}): FileDiffReply => ({
22 path,
23 status,
24 text: null,
25 truncated: false,
26 shownLines: 0,
27 totalLines: 0,
28 redactions: 0,
29 base: null,
30 ...over,
31})
32
33/** The UTF-8 length of a string (no Buffer in the hooks sandbox). */
34function utf8Length(s: string): number {
35 let n = 0
36 for (let i = 0; i < s.length; i++) {
37 const c = s.charCodeAt(i)
38 if (c < 0x80) n += 1
39 else if (c < 0x800) n += 2
40 else if (c >= 0xd800 && c <= 0xdbff) {
41 n += 4
42 i++
43 } else n += 3
44 }
45 return n
46}
47
48/** The hunks of a `git diff`: everything from the first `@@`. The file headers say nothing the console shows. */
49function hunksOf(out: string): string {
50 const at = out.startsWith('@@') ? 0 : out.indexOf('\n@@')
51 if (at < 0) return ''
52 let end = out.length
53 while (end > 0 && out[end - 1] === '\n') end--
54 return out.slice(at === 0 ? 0 : at + 1, end)
55}
56
57const TOKEN_CHAR = /[A-Za-z0-9+/_=~.-]/
58
59/** `s` without the run of token characters at its end: a token a cut may have split is dropped whole. */
60function dropTrailingToken(s: string): string {
61 let end = s.length
62 while (end > 0 && TOKEN_CHAR.test(s[end - 1]!)) end--
63 return s.slice(0, end)
64}
65
66const isBinary = (out: string): boolean => out.split('\n').some((l) => l.startsWith('Binary files ') && l.endsWith(' differ')) || out.includes('\nGIT binary patch')
67
68/**
69 * The diff of `path`, which must be one the Files list names. `base` is the starting commit the mod keeps for the
70 * session. Never throws: a failing git answers `unavailable`.
71 */
72export async function readFileDiff(run: Runner, cwd: string, base: string | null, path: string, files: FilesReport | null): Promise<FileDiffReply> {
73 const entry = files?.files.find((f) => f.path === path)
74 if (!entry) return reply(path, 'unavailable')
75 if (withheldPath(path)) return reply(path, 'withheld')
76 if ((entry.added ?? 0) + (entry.removed ?? 0) > DIFF_LIMITS.countedLines) return reply(path, 'too-large')
77 try {
78 const top = (await run(['git', 'rev-parse', '--show-toplevel'])).stdout.trim() || cwd
79 const headRes = await run(['git', '-C', top, 'rev-parse', 'HEAD'])
80 const head = headRes.exitCode === 0 ? headRes.stdout.trim() : null
81 const start = base ?? head ?? EMPTY_TREE
82 const { ref, since } = await resolveRef(run, top, start, head)
83 const label = since === 'worktree' ? 'worktree' : ref === EMPTY_TREE ? null : ref.slice(0, 7)
84 const paths = entry.status === 'renamed' && entry.from ? [entry.from, path] : [path]
85 const git = (...args: string[]) => ['git', '-C', top, ...args]
86 let res = await run(git('diff', '--no-color', '--no-ext-diff', '-M', ref, '--', ...paths))
87 if (res.exitCode !== 0) return reply(path, 'unavailable')
88 // A file git does not track yet has no diff against the base: read it against nothing.
89 if (!res.stdout.trim() && entry.status === 'added') {
90 res = await run(git('diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', path))
91 if (res.exitCode !== 0 && res.exitCode !== 1) return reply(path, 'unavailable')
92 }
93 if (res.stdout.length > DIFF_LIMITS.readBytes) return reply(path, 'too-large')
94 if (isBinary(res.stdout)) return reply(path, 'binary')
95 const all = hunksOf(res.stdout)
96 const lines = all === '' ? [] : all.split('\n')
97 const kept: string[] = []
98 let bytes = 0
99 for (const line of lines) {
100 const cost = Math.min(utf8Length(line), DIFF_LIMITS.lineChars) + 1
101 if (kept.length >= DIFF_LIMITS.lines || bytes + cost > DIFF_LIMITS.bytes) break
102 kept.push(line)
103 bytes += cost
104 }
105 // Cut long lines before redacting: the keyed-value pattern is quadratic on a long unbroken run (a minified file).
106 // A token the cut may have split is dropped back to its start, so half a secret is never left for the rules to miss.
107 const cutAt = new Set<number>()
108 const shortened = kept.map((l, i) => {
109 if (l.length <= DIFF_LIMITS.lineChars) return l
110 cutAt.add(i)
111 return dropTrailingToken(l.slice(0, DIFF_LIMITS.lineChars))
112 })
113 const red = redactDiff(shortened.join('\n'))
114 const text = red.text
115 .split('\n')
116 .map((l, i) => (cutAt.has(i) ? l + LINE_CUT : l.length > DIFF_LIMITS.lineChars ? l.slice(0, DIFF_LIMITS.lineChars) + LINE_CUT : l))
117 .join('\n')
118 return reply(path, 'ok', { text, truncated: kept.length < lines.length, shownLines: kept.length, totalLines: lines.length, redactions: red.redactions, base: label })
119 } catch {
120 return reply(path, 'unavailable')
121 }
122}
123hooks/place.ts 95 lines1// Where a session works, and how the reporter notices it moved (#382, SPEC 6.7). Pure: the mod passes what
2// `$.session` answered and a runner for git, so the server's test run can measure it (the mod's own tests cannot be).
3import { lastSegment, type Runner } from './git.ts';
4import { repoFromRemote } from './report.ts';
5
6export interface Place {
7 projectRoot: string;
8 repoName: string;
9}
10
11/** What `$.session.root()` and `$.session.repo()` answered. */
12export interface SessionPlace {
13 root: string | null;
14 repo: { root: string; remote: string | null; name: string | null } | null;
15}
16
17/** Whether `dir` is `folder` or inside it. */
18export function holds(folder: string | null | undefined, dir: string): boolean {
19 return !!folder && (dir === folder || dir.startsWith(folder.endsWith('/') ? folder : `${folder}/`));
20}
21
22/**
23 * The folder the session is in now. A hook's `cwd` wins, except when it is inside the place already reported: it follows
24 * the shell, so a `cd` into a subfolder is not a move. Without a hook, `$.session.cwd()` counts once its answer differs
25 * from the last one (`lastSeen`); an answer that stayed put does not undo a move a hook reported.
26 */
27export function nextCwd(i: { hookCwd?: string; seen: string | null; lastSeen: string | null; current: string; projectRoot: string | null }): string {
28 const inside = !!i.hookCwd && (holds(i.current, i.hookCwd) || holds(i.projectRoot, i.hookCwd));
29 const sessionMoved = !!i.seen && i.seen !== i.lastSeen;
30 return (inside ? null : i.hookCwd) || (sessionMoved ? i.seen : null) || i.current;
31}
32
33/**
34 * The project root and repository name for `cwd`: from `$.session` while its answer holds `cwd`, else from git run in
35 * `cwd` (the common dir's parent and the origin remote), else the folder itself.
36 */
37export async function placeOf(session: SessionPlace, cwd: string, run: Runner): Promise<Place> {
38 const base = session.repo?.root ?? session.root;
39 if (base && holds(base, cwd)) {
40 const { repo } = session;
41 return { projectRoot: base, repoName: repo?.name ?? repoFromRemote(repo?.remote) ?? lastSegment(base) };
42 }
43 const git = async (argv: string[]) => {
44 const res = await run(argv);
45 return res.exitCode === 0 ? res.stdout.trim() : '';
46 };
47 const common = await git(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir']);
48 const dir = common.endsWith('/') ? common.slice(0, -1) : common;
49 const projectRoot = dir.endsWith('/.git') ? dir.slice(0, -'/.git'.length) : dir || cwd;
50 return { projectRoot, repoName: repoFromRemote(await git(['git', 'config', '--get', 'remote.origin.url'])) ?? lastSegment(projectRoot) };
51}
52
53/** The parts of `$` that the place logic reads, so a test can stand in for them. */
54export interface PlaceEngine {
55 session: {
56 cwd(): Promise<string>;
57 root(): Promise<string>;
58 repo(): Promise<{ root: string; remote: string | null; name: string | null } | null>;
59 };
60 process: { run(argv: string[], init: { cwd: string; timeoutMs: number }): Promise<{ exitCode: number; stdout: string }> };
61}
62
63/** What the reporter holds about the place. */
64export interface PlaceMemory {
65 sessionCwd: string | null;
66 cwd: string;
67 projectRoot: string | null;
68}
69
70async function orElse<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
71 try {
72 return await fn();
73 } catch {
74 return fallback;
75 }
76}
77
78/**
79 * Follow a session that moved (#382): the desktop app moves a pre-warmed spare to the project the person picked without
80 * restarting it. `place` is null when the folder is the one already held. `sessionCwd` is what `$.session.cwd()` answered, to
81 * hold for the next call.
82 */
83export async function follow($: PlaceEngine, mem: PlaceMemory, hookCwd?: string): Promise<{ sessionCwd: string | null; place: (Place & { cwd: string }) | null }> {
84 const seen = await orElse<string | null>(() => $.session.cwd(), null);
85 const cwd = nextCwd({ hookCwd, seen, lastSeen: mem.sessionCwd, current: mem.cwd, projectRoot: mem.projectRoot });
86 const sessionCwd = seen || mem.sessionCwd;
87 if (cwd === mem.cwd) return { sessionCwd, place: null };
88 const session = { root: await orElse<string | null>(() => $.session.root(), null), repo: await orElse(() => $.session.repo(), null) };
89 const run: Runner = async (argv) => {
90 const res = await orElse(() => $.process.run(argv, { cwd, timeoutMs: 5000 }), null);
91 return { exitCode: res?.exitCode ?? 1, stdout: res?.stdout ?? '' };
92 };
93 return { sessionCwd, place: { cwd, ...(await placeOf(session, cwd, run)) } };
94}
95hooks/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 304 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 { DETAIL_CHARS_PER_REPORT } from './activityDetail.ts';
4import { clip, excerpt, LIMITS } from './failures.ts';
5import { lastSegment } from './git.ts';
6import { family, windowOf } from './models.ts';
7import { toPlan } from './plan.ts';
8import type { ActivityEntry, ActivityEvent, AgentReport, Ask, Failure, LoopReport, MachineInfo, ModelUsage, Report, RespondInfo } from './protocol.ts';
9import { utf8Bytes } from './recording.ts';
10import { RECORDING_LIMITS } from './protocol.ts';
11import { mergeUsage, resolveOwners, type AgentState, type LoopStats, type SessionState } from './state.ts';
12
13export const REPORTER_NAME = 'podium-reporter';
14export const MAX_EVENTS_PER_REPORT = 100;
15export const MAX_AGENTS_PER_REPORT = 200;
16/** Full prompt and answer characters in one report; the server accepts a 512 kB body. The rest goes in later reports. */
17export const MAX_FULL_TEXT_PER_REPORT = 120_000;
18/** Activity-feed characters in one report (#118), as the server takes them: the orchestrator's first, then the agents'. */
19export const MAX_ACTIVITY_PER_REPORT = 60_000;
20
21export interface ReportMeta {
22 version: string;
23 engineVersion: string | null;
24 hostSurface: string | null;
25 /** The commit of a dev build of the mod (#400); null or absent for a release. */
26 build?: string | null;
27 now: number;
28 /** Set with `/podium name`, kept in $.store per session id. */
29 storedName: string | null;
30 /** The session's own name in Claude Code (`/rename`, or its title), from a hook's `session_title` (#322). */
31 claudeName?: string | null;
32 /** The `sessionName` option. */
33 optionName: string | null;
34 /** The `userName` option: the person this reporter is configured as. The server checks it against the token. */
35 userName?: string | null;
36 /** This machine; sent only together with a userName. */
37 machine?: MachineInfo | null;
38 /** The `fullPrompts` option: send each agent's whole prompt and answer, once. */
39 fullText?: boolean;
40 /** The `activityFeed` option: send the feed of each loop the server says is watched (#118). */
41 activity?: boolean;
42 /** The `activityOutput` option: a Bash call's output may go out with its entry, for a loop the reply lists in `watchOutput` (#431). */
43 activityOutput?: boolean;
44 /** The `fileDiff` option: answer requests for one file's diff (#319). */
45 fileDiff?: boolean;
46 /** The `sessionRecording` option: the owner's consent to be recorded (#353). */
47 sessionRecording?: boolean;
48 /** Whether and how far the session can be answered from Podium (#206); omitted by nothing that responds. */
49 respond?: RespondInfo | null;
50 /** The asks open now, sent only while the server takes answers (#206). */
51 asks?: Ask[] | null;
52 /** The session this one continues after a /clear. */
53 clearedFrom?: string | null;
54 /** The `peers` option, for an interactive session (#347, #414): whether this orchestrator may take part in messaging. */
55 peers?: boolean;
56}
57
58/** Display name (SPEC 6.6): /podium name, Claude Code's session name, the option, a worktree's branch or folder, the repo folder, the id. */
59export function displayName(s: Pick<SessionState, 'id' | 'git' | 'projectRoot' | 'cwd'>, storedName: string | null, claudeName: string | null | undefined, optionName: string | null): string {
60 const pick = (v: string | null | undefined) => (v && v.trim() ? v.trim() : null);
61 const name =
62 pick(storedName) ??
63 pick(claudeName) ??
64 pick(optionName) ??
65 (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) ??
66 pick(s.projectRoot ? lastSegment(s.projectRoot) : null) ??
67 `session-${s.id.slice(0, 6)}`;
68 return clip(name, 80);
69}
70
71/** `owner/name` from an origin URL (`git@github.com:o/n.git`, `https://host/o/n`). */
72export function repoFromRemote(remote: string | null | undefined): string | null {
73 if (!remote) return null;
74 const m = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote.trim());
75 return m ? `${m[1]}/${m[2]}` : null;
76}
77
78function cleanFailure(f: Failure | null): Failure | null {
79 if (!f) return null;
80 return {
81 ...f,
82 tool: f.tool ? clip(f.tool, 120) : null,
83 call: f.call ? clip(f.call, 300) : null,
84 outputTail: f.outputTail.slice(-12).map((l) => clip(l, 240)),
85 evidence: f.evidence.slice(0, 5).map((l) => clip(l, 240)),
86 explanation: f.explanation ? clip(f.explanation, 400) : null,
87 };
88}
89
90function pct(tokens: number | null, window: number | null): number | null {
91 if (tokens === null || !window) return null;
92 return Math.round(Math.min(100, (tokens / window) * 100) * 10) / 10;
93}
94
95/** The fields an agent entry and the main loop's block share: skills, last tool, call counts and steps. */
96function loopFields(l: LoopStats): LoopReport {
97 return {
98 skills: l.skills.slice(0, 50).map((k) => clip(k, 120)),
99 lastTool: l.lastTool,
100 toolCalls: l.toolCalls,
101 toolCounts: Object.fromEntries(Object.entries(l.toolCounts).map(([k, n]) => [clip(k, 120), n])),
102 steps: { done: l.steps, total: null },
103 };
104}
105
106/** The rows the server accepts: model ids clipped, and no more than it takes. */
107function usageRows(rows: ModelUsage[]): ModelUsage[] {
108 return rows.slice(0, 40).map((r) => ({ ...r, model: clip(r.model, 120) }));
109}
110
111/** The most a command or an output may hold as the server takes it: the 20,000-character cut plus the marker (`activityDetail`). */
112const DETAIL_MAX = 20_100;
113
114/** A loop's feed as it is sent: every summary redacted and cut again here, like every other free-text field. A command and an output were redacted and cut when captured; here they are only held to the server's limit. */
115function feed(list: readonly ActivityEntry[], withOutput: boolean): ActivityEntry[] {
116 return list.map((e) => {
117 const out: ActivityEntry = { at: e.at, kind: e.kind, text: clip(e.text, LIMITS.activity) };
118 if (e.kind !== 'tool') return out;
119 if (e.id !== undefined) out.id = clip(e.id, 200);
120 if (e.command !== undefined) {
121 out.command = e.command.slice(0, DETAIL_MAX);
122 if (e.commandCut) out.commandCut = true;
123 }
124 if (withOutput && e.output !== undefined) {
125 out.output = e.output.slice(0, DETAIL_MAX);
126 if (e.outputCut) out.outputCut = true;
127 }
128 // How the call ended is not output: it goes with the feed (#598).
129 if (e.done) out.done = true;
130 if (e.exitCode !== undefined) out.exitCode = e.exitCode;
131 if (e.failed) out.failed = true;
132 return out;
133 });
134}
135
136export function agentReport(a: AgentState, fullText = false, activity = false, activityOutput = false): AgentReport {
137 const window = a.window ?? (a.model ? windowOf(a.model) : null);
138 return {
139 id: clip(a.id, 200),
140 parentId: a.parentId,
141 kind: a.kind,
142 type: clip(a.type, 120),
143 name: a.name ? clip(a.name, 80) : null,
144 description: clip(a.description, 240),
145 model: a.model,
146 effort: a.effort,
147 effortSource: a.effortSource,
148 status: a.status,
149 startedAt: a.startedAt,
150 endedAt: a.endedAt,
151 context: { tokens: a.ctxTokens, window, percent: pct(a.ctxTokens, window) },
152 tokens: { ...a.tokens },
153 costUsdEst: a.model && family(a.model) === 'other' ? null : Math.round(a.cost * 1e6) / 1e6,
154 usageByModel: usageRows(mergeUsage(a.usage)),
155 ...loopFields(a),
156 blowouts: a.blowouts,
157 promptExcerpt: excerpt(a.promptExcerpt),
158 answerExcerpt: a.answerExcerpt ? excerpt(a.answerExcerpt) : null,
159 ...(fullText && a.promptText && !a.promptSent ? { promptText: a.promptText } : {}),
160 ...(fullText && a.answerText && !a.answerSent ? { answerText: a.answerText } : {}),
161 ...(activity && a.activity.length ? { activity: feed(a.activity, activityOutput) } : {}),
162 failure: cleanFailure(a.failure),
163 };
164}
165
166/** Drops full texts past the per-report budget (oldest agents first kept); they are still unsent, so a later report carries them. */
167function withinTextBudget(reports: AgentReport[]): AgentReport[] {
168 let left = MAX_FULL_TEXT_PER_REPORT;
169 return reports.map((r) => {
170 const { promptText, answerText, ...rest } = r;
171 const out: AgentReport = rest;
172 for (const [k, v] of [['promptText', promptText], ['answerText', answerText]] as const) {
173 if (v === undefined) continue;
174 if (v.length <= left || left === MAX_FULL_TEXT_PER_REPORT) {
175 out[k] = v;
176 left -= v.length;
177 }
178 }
179 return out;
180 });
181}
182
183/** Leaves out the feeds past the per-report budget, the orchestrator's first, then the agents' in order, as the server would. */
184function withinActivityBudget(r: Report): Report {
185 let left = MAX_ACTIVITY_PER_REPORT;
186 const fits = (list: ActivityEntry[]) => {
187 const n = list.reduce((sum, e) => sum + e.text.length, 0);
188 if (n > left) return false;
189 left -= n;
190 return true;
191 };
192 if (r.session.activity && !fits(r.session.activity)) delete r.session.activity;
193 for (const a of r.agents) if (a.activity && !fits(a.activity)) delete a.activity;
194 return r;
195}
196
197const OUTPUT_FIELDS = ['output', 'outputCut'] as const;
198const COMMAND_FIELDS = ['command', 'commandCut'] as const;
199
200/** The feeds of a report, the orchestrator's first, then each agent's in order, as the server reads them. */
201function feeds(r: Report): ActivityEntry[][] {
202 const lists: ActivityEntry[][] = [];
203 if (r.session.activity) lists.push(r.session.activity);
204 for (const a of r.agents) if (a.activity) lists.push(a.activity);
205 return lists;
206}
207
208const detailChars = (e: ActivityEntry): number => (e.command?.length ?? 0) + (e.output?.length ?? 0);
209
210/**
211 * Keeps commands and output within the report's own 200,000 characters (#431), apart from the summaries' 60,000: in feed order and
212 * newest entry first, as the server counts them. An entry that does not fit loses its output, and then its command, and keeps its summary.
213 */
214function withinDetailBudget(r: Report): Report {
215 let room = DETAIL_CHARS_PER_REPORT;
216 for (const list of feeds(r)) {
217 for (let i = list.length - 1; i >= 0; i--) {
218 const e = list[i]!;
219 if (detailChars(e) <= room) {
220 room -= detailChars(e);
221 continue;
222 }
223 for (const k of OUTPUT_FIELDS) delete e[k];
224 if (detailChars(e) <= room) room -= detailChars(e);
225 else for (const k of COMMAND_FIELDS) delete e[k];
226 }
227 }
228 return r;
229}
230
231/**
232 * Keeps the serialized report under the server's 512 KB body, as a recording's request does (480,000 bytes): the character budget
233 * does not see a multi-byte text. Output goes first, then commands, from the last loop's oldest entry back, until it fits.
234 */
235function withinSize(r: Report): void {
236 const lists = feeds(r);
237 if (!lists.length || utf8Bytes(JSON.stringify(r)) <= RECORDING_LIMITS.bodyBytes) return;
238 const order = lists.slice().reverse().flatMap((list) => list);
239 for (const fields of [OUTPUT_FIELDS, COMMAND_FIELDS]) {
240 for (const e of order) {
241 if (!fields.some((k) => k in e)) continue;
242 for (const k of fields) delete e[k];
243 if (utf8Bytes(JSON.stringify(r)) <= RECORDING_LIMITS.bodyBytes) return;
244 }
245 }
246}
247
248/** A full snapshot of the session plus the events given (at most 100). */
249export function buildReport(s0: SessionState, events: readonly ActivityEvent[], meta: ReportMeta): Report {
250 const s = resolveOwners(s0);
251 const ctxWindow = s.context.window ?? s.main.window ?? (s.main.model ? windowOf(s.main.model) : null);
252 const ctxTokens = s.context.tokens ?? s.main.ctxTokens;
253 const ctxPercent = s.context.percent ?? pct(ctxTokens, ctxWindow);
254 // Most recent agents win if a session ever has more than the server accepts.
255 const agents = s.order.map((id) => s.agents[id]).filter((a): a is AgentState => !!a).slice(-MAX_AGENTS_PER_REPORT);
256 const activity = meta.activity === true;
257 const report = withinDetailBudget(withinActivityBudget({
258 v: 1,
259 sentAt: meta.now,
260 reporter: { name: REPORTER_NAME, version: meta.version, engineVersion: meta.engineVersion, hostSurface: meta.hostSurface, ...(meta.build ? { build: meta.build } : {}) },
261 ...(meta.userName && meta.userName.trim() ? { userName: clip(meta.userName.trim(), 80), ...(meta.machine ? { machine: { ...meta.machine } } : {}) } : {}),
262 session: {
263 id: s.id,
264 name: displayName(s, meta.storedName, meta.claudeName, meta.optionName),
265 status: s.status,
266 waitReason: s.status === 'waiting' ? s.waitReason : null,
267 waitNote: s.status === 'waiting' && s.waitNote ? clip(s.waitNote, 160) : null,
268 endReason: s.status === 'done' ? s.endReason : null,
269 resumeId: s.resumeId,
270 startedAt: s.startedAt,
271 cwd: s.cwd,
272 projectRoot: s.projectRoot,
273 repoName: s.repoName,
274 git: s.git,
275 model: s.main.model,
276 effort: s.main.effort,
277 context: { tokens: ctxTokens, window: ctxWindow, percent: ctxPercent === null ? null : Math.max(0, Math.min(100, ctxPercent)) },
278 costUsd: s.main.model && family(s.main.model) === 'other' ? null : s.costUsd,
279 tokens: { ...s.main.tokens },
280 usageByModel: usageRows(mergeUsage(s.main.usage, ...s.order.map((id) => s.agents[id]?.usage))),
281 rateLimits: s.rateLimits.slice(0, 10),
282 blowouts: s.main.blowouts,
283 plan: toPlan(s.plan),
284 loop: loopFields(s.main),
285 attached: s.attached,
286 ...(s.status === 'waiting' && s.background ? { background: s.background } : {}),
287 files: { ...s.files, files: s.files.files.map((f) => ({ ...f, path: clip(f.path, 300), from: f.from === null ? null : clip(f.from, 300) })) },
288 activityFeed: activity,
289 activityOutput: meta.activityOutput === true,
290 fileDiff: meta.fileDiff === true,
291 sessionRecording: meta.sessionRecording === true,
292 ...(activity && s.main.activity.length ? { activity: feed(s.main.activity, meta.activityOutput === true) } : {}),
293 ...(meta.respond ? { respond: meta.respond } : {}),
294 ...(meta.asks ? { asks: meta.asks.slice(0, 8) } : {}),
295 ...(meta.clearedFrom ? { clearedFrom: meta.clearedFrom } : {}),
296 peers: meta.peers === true,
297 },
298 agents: withinTextBudget(agents.map((a) => agentReport(a, meta.fullText === true, activity, meta.activityOutput === true))),
299 events: events.slice(0, MAX_EVENTS_PER_REPORT).map((e) => ({ ...e, text: clip(e.text, 240) })),
300 }));
301 withinSize(report);
302 return report;
303}
304hooks/state.ts 957 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 /** The loops of `watched` the server's reply says a viewer has the feed open on (#431); a Bash call's output is read only for these. Empty with the activityOutput option off. */
201 watchedOutput: string[];
202}
203
204export function emptyLoop(model: string | null = null): LoopStats {
205 return {
206 model,
207 effort: null,
208 ctxTokens: null,
209 window: null,
210 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
211 usage: {},
212 cost: 0,
213 steps: 0,
214 toolCalls: 0,
215 toolCounts: {},
216 lastTool: null,
217 skills: [],
218 blowouts: 0,
219 blowTimes: [],
220 lastStopReason: null,
221 candidate: null,
222 failedCalls: {},
223 reads: {},
224 activity: [],
225 };
226}
227
228export function newSession(i: { id: string; cwd: string; startedAt: number; model?: string | null }): SessionState {
229 return {
230 id: i.id,
231 startedAt: i.startedAt,
232 cwd: i.cwd,
233 projectRoot: null,
234 repoName: null,
235 git: null,
236 gitBase: null,
237 files: NO_FILES,
238 attached: null,
239 status: 'waiting',
240 mainIdle: true,
241 waitReason: 'input',
242 waitNote: null,
243 background: null,
244 endReason: null,
245 resumeId: null,
246 ended: false,
247 main: emptyLoop(i.model ?? null),
248 context: { tokens: null, window: null, percent: null },
249 costUsd: null,
250 rateLimits: [],
251 plan: emptyPlan,
252 agents: {},
253 order: [],
254 pending: {},
255 events: [],
256 evSeq: 0,
257 dirty: true,
258 watched: [],
259 watchedOutput: [],
260 };
261}
262
263/** A fresh session under a new id (after /clear), keeping where it runs. */
264export function resetForNewId(s: SessionState, id: string, startedAt: number): SessionState {
265 const n = newSession({ id, cwd: s.cwd, startedAt, model: s.main.model });
266 return { ...n, projectRoot: s.projectRoot, repoName: s.repoName, git: s.git, main: { ...n.main, effort: s.main.effort } };
267}
268
269// ---- helpers ------------------------------------------------------------------------------------
270
271export function asEffort(v: unknown): Effort | null {
272 return typeof v === 'string' && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : null;
273}
274
275function str(v: unknown): string | null {
276 return typeof v === 'string' && v ? v : null;
277}
278
279/** Recomputes the session status from its parts. */
280function withStatus(s: SessionState): SessionState {
281 let status: SessionState['status'];
282 let waitReason = s.waitReason;
283 if (s.ended) {
284 status = 'done';
285 waitReason = null;
286 } else if (s.waitReason === 'permission') status = 'waiting';
287 else if (s.mainIdle && !Object.values(s.agents).some((a) => a.status === 'running' || a.status === 'queued')) {
288 status = 'waiting';
289 // Idle on background work or a wake-up is not a wait on the person (#119).
290 waitReason = s.background ? null : 'input';
291 } else {
292 status = 'running';
293 waitReason = null;
294 }
295 const waitNote = waitReason ? s.waitNote : null;
296 return { ...s, status, waitReason, waitNote, dirty: true };
297}
298
299export function addEvent(s: SessionState, kind: ActivityEvent['kind'], agentId: string | null, text: string, t: number, data: Record<string, unknown> | null = null): SessionState {
300 const n = s.evSeq + 1;
301 const ev: ActivityEvent = { id: `${s.id}:${agentId ?? 'main'}:${kind}:${t}:${n}`, t, kind, agentId, text: clip(text.replace(/\s+/g, ' ').trim(), 240), data };
302 const events = [...s.events, ev];
303 return { ...s, evSeq: n, events: events.length > EVENT_QUEUE_MAX ? events.slice(-EVENT_QUEUE_MAX) : events, dirty: true };
304}
305
306/** Takes the events queued since the last call. */
307export function drainEvents(s: SessionState): [SessionState, ActivityEvent[]] {
308 return [{ ...s, events: [] }, s.events];
309}
310
311function loopOf(s: SessionState, agentId: string | null | undefined): LoopStats | undefined {
312 return agentId ? s.agents[agentId] : s.main;
313}
314
315function putLoop(s: SessionState, agentId: string | null | undefined, loop: LoopStats): SessionState {
316 if (!agentId) return { ...s, main: loop, dirty: true };
317 const a = s.agents[agentId];
318 if (!a) return s;
319 return { ...s, agents: { ...s.agents, [agentId]: { ...a, ...loop } }, dirty: true };
320}
321
322function loopLabel(s: SessionState, agentId: string | null | undefined): string {
323 if (!agentId) return 'on the orchestrator';
324 const a = s.agents[agentId];
325 return a ? `in “${a.description}”` : 'in an agent';
326}
327
328export function agentLabel(a: Pick<AgentState, 'kind' | 'type' | 'model' | 'effort'>): string {
329 return `${a.kind === 'fork' ? 'fork' : a.type} · ${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''}`;
330}
331
332/** An agent the engine lists that this state has not seen spawn (a reload, a missed event). */
333function placeholderAgent(id: string, now: number, over: Partial<AgentState> = {}): AgentState {
334 return {
335 ...emptyLoop(),
336 id,
337 parentId: null,
338 kind: 'subagent',
339 type: 'general-purpose',
340 name: null,
341 description: 'agent',
342 effortSource: 'unknown',
343 status: 'running',
344 startedAt: now,
345 endedAt: null,
346 promptExcerpt: '',
347 answerExcerpt: null,
348 promptText: null,
349 answerText: null,
350 promptSent: false,
351 answerSent: false,
352 failure: null,
353 toolUseId: null,
354 ...over,
355 };
356}
357
358// ---- identity -----------------------------------------------------------------------------------
359
360export function setIdentity(
361 s: SessionState,
362 i: { cwd?: string | null; projectRoot?: string | null; repoName?: string | null; model?: string | null; startedAt?: number | null },
363): SessionState {
364 return {
365 ...s,
366 cwd: i.cwd ?? s.cwd,
367 projectRoot: i.projectRoot === undefined ? s.projectRoot : i.projectRoot,
368 repoName: i.repoName === undefined ? s.repoName : i.repoName,
369 startedAt: i.startedAt ?? s.startedAt,
370 main: i.model ? { ...s.main, model: i.model } : s.main,
371 dirty: true,
372 };
373}
374
375export function setGit(s: SessionState, git: GitInfo | null): SessionState {
376 if (JSON.stringify(git) === JSON.stringify(s.git)) return s;
377 return { ...s, git, dirty: true };
378}
379
380/** The changed-files list; a report is due only when it differs from the last one. */
381export function setFiles(s: SessionState, files: FilesReport, base: string | null): SessionState {
382 const same = JSON.stringify(files) === JSON.stringify(s.files);
383 if (same && base === s.gitBase) return s;
384 return { ...s, files, gitBase: base, dirty: s.dirty || !same };
385}
386
387/** Whether any client is attached; a report is due only when it changes. */
388export function setAttached(s: SessionState, attached: boolean): SessionState {
389 if (attached === s.attached) return s;
390 return { ...s, attached, dirty: true };
391}
392
393// ---- turns --------------------------------------------------------------------------------------
394
395export function turnStart(s: SessionState): SessionState {
396 // A new turn means the background work (or the wake-up) that woke it is no longer what the session waits on.
397 return withStatus({ ...s, mainIdle: false, background: null, waitReason: s.waitReason === 'permission' ? 'permission' : null });
398}
399
400export interface BackgroundInput {
401 /** The Stop hook's background_tasks. */
402 tasks: { id?: string; type?: string; status?: string; description?: string; command?: string }[];
403 /** The Stop hook's session_crons; `nextAt` is when the schedule next fires, worked out by the caller. */
404 crons: { id?: string; schedule?: string; recurring?: boolean; prompt?: string; nextAt?: number | null }[];
405}
406
407const TASK_DONE = /^(completed|complete|done|failed|killed|stopped|cancelled|canceled|error)$/i;
408const TASK_KINDS: Record<string, BackgroundKind> = { shell: 'shell', bash: 'shell', subagent: 'agent', agent: 'agent', monitor: 'monitor', workflow: 'workflow' };
409/** Claude Code's artifact live-update watchers (#128): they run as long as the session does, so they are not work it waits on. */
410const ARTIFACT_WATCHER = /^live updates for artifact /i;
411const MAX_BACKGROUND_TASKS = 20;
412const BACKGROUND_DESC = 120;
413
414/**
415 * The Stop hook's list of work still in flight. Finished tasks are dropped. A shell's command never leaves the
416 * machine: a task with no description is named by the first word of its command (never an NAME=value assignment), and no more.
417 */
418export function setBackground(s: SessionState, i: BackgroundInput, now: number): SessionState {
419 const tasks: BackgroundTask[] = [];
420 for (const t of i.tasks) {
421 const status = t.status || 'running';
422 if (TASK_DONE.test(status) || tasks.length >= MAX_BACKGROUND_TASKS) continue;
423 const type = TASK_KINDS[(t.type ?? '').toLowerCase()] ?? 'other';
424 if (type === 'monitor' && ARTIFACT_WATCHER.test((t.description ?? '').trim())) continue;
425 // The first word that is not an NAME=value assignment, so an inline secret (API_KEY=...) is never kept.
426 const word = (t.command ?? '').trim().split(/\s+/).find((w) => w !== '' && !w.includes('='))?.split('/').pop() ?? '';
427 tasks.push({ type, description: clip((t.description ?? '').replace(/\s+/g, ' ').trim() || word || type, BACKGROUND_DESC), status: clip(status, 40) });
428 }
429 const times: number[] = [];
430 let unreadable = false;
431 for (const c of i.crons) {
432 if (tasks.length >= MAX_BACKGROUND_TASKS) break;
433 tasks.push({ type: 'wakeup', description: clip((c.prompt ?? '').replace(/\s+/g, ' ').trim() || 'scheduled wake-up', BACKGROUND_DESC), status: 'scheduled' });
434 if (typeof c.nextAt === 'number') times.push(c.nextAt);
435 else unreadable = true;
436 }
437 // One wake-up whose time cannot be read may fire first, so no time is better than a wrong one.
438 const wakeAt = times.length && !unreadable ? Math.min(...times) : null;
439 const background: BackgroundWait | null = tasks.length ? { tasks, wakeAt } : null;
440 if (JSON.stringify(background) === JSON.stringify(s.background)) return s;
441 let next: SessionState = { ...s, background };
442 // The feed gets one row each time what it waits on changes, not one per Stop.
443 if (background && (!s.background || backgroundSummary(background) !== backgroundSummary(s.background))) next = addEvent(next, 'wait', null, `waiting on background: ${backgroundSummary(background)}`, now);
444 return withStatus(next);
445}
446
447/** What `/podium` prints for the background work the mod holds (#128): each entry's type, status and description. */
448export function describeBackground(s: SessionState): string {
449 const tasks = s.background?.tasks ?? [];
450 if (!tasks.length) return 'Background work: none';
451 return [`Background work (${tasks.length}):`, ...tasks.map((t) => ` ${t.type}, ${t.status}: ${t.description}`)].join('\n');
452}
453
454export interface StepInput {
455 agentId?: string;
456 model?: string | null;
457 effort?: unknown;
458 usage: Usage | null;
459 stopReason: string | null;
460}
461
462/** One model request finished: model, observed effort, context, tokens, cost, and stop-reason blowouts. */
463export function step(s0: SessionState, i: StepInput, now: number): SessionState {
464 let s = s0;
465 if (i.agentId && !s.agents[i.agentId]) {
466 s = { ...s, agents: { ...s.agents, [i.agentId]: placeholderAgent(i.agentId, now, { model: i.model ?? null }) }, order: [...s.order, i.agentId] };
467 }
468 const loop = loopOf(s, i.agentId);
469 if (!loop) return s;
470 const u = i.usage;
471 const model = u?.model || i.model || loop.model;
472 const effort = asEffort(i.effort);
473 const next: LoopStats = { ...loop, model: model ?? null, steps: loop.steps + 1, lastStopReason: i.stopReason };
474 if (effort) next.effort = effort;
475 if (u) {
476 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 };
477 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 };
478 next.usage = addUsage(loop.usage, model ?? null, u);
479 next.cost = loop.cost + (costOf(t, model) ?? 0);
480 next.ctxTokens = t.input + t.cacheRead + t.cacheWrite;
481 next.window = windowOf(model);
482 }
483 s = putLoop(s, i.agentId, next);
484 if (i.agentId && effort) {
485 const a = s.agents[i.agentId]!;
486 if (a.kind !== 'fork') s = { ...s, agents: { ...s.agents, [a.id]: { ...a, effortSource: 'observed' } } };
487 }
488 if (!i.agentId && effort) s = { ...s, main: { ...s.main, effort } };
489 if (i.stopReason === 'model_context_window_exceeded') s = blowout(s, i.agentId ?? null, 'window-exceeded', now, next.ctxTokens, null);
490 else if (i.stopReason === 'compaction') s = blowout(s, i.agentId ?? null, 'server-compaction', now, next.ctxTokens, null);
491 return s;
492}
493
494/**
495 * Counts a blowout on a loop. A second signal for the same loop within 2 s (a window-exceeded stop
496 * followed by the auto-compaction it triggers) is the same blowout and only adds detail.
497 */
498export function blowout(s: SessionState, agentId: string | null, kind: string, now: number, tokensBefore: number | null, tokensAfter: number | null): SessionState {
499 const loop = loopOf(s, agentId);
500 if (!loop) return s;
501 const last = loop.blowTimes[loop.blowTimes.length - 1];
502 if (last !== undefined && now - last < BLOWOUT_DEDUP_MS) return s;
503 const n = loop.blowouts + 1;
504 const ctx = tokensAfter !== null && loop.window ? tokensAfter : loop.ctxTokens;
505 let next = putLoop(s, agentId, { ...loop, blowouts: n, blowTimes: [...loop.blowTimes, now].slice(-20), ctxTokens: ctx });
506 const after = tokensAfter !== null && loop.window ? `, compacted to ${Math.round((tokensAfter / loop.window) * 100)}%` : '';
507 next = addEvent(next, 'blow', agentId, `context window exceeded (blowout #${n}) ${loopLabel(s, agentId)}${after}`, now, {
508 kind,
509 tokensBefore,
510 tokensAfter,
511 });
512 return next;
513}
514
515export interface CompactInput {
516 agentId?: string;
517 trigger: string;
518 tokensBefore?: number;
519 tokensAfter?: number;
520}
521
522/** A compaction ran: automatic is a blowout; manual or a plugin's is only an event. */
523export function compaction(s: SessionState, i: CompactInput, now: number): SessionState {
524 if (i.trigger === 'auto') return blowout(s, i.agentId ?? null, 'auto-compact', now, i.tokensBefore ?? null, i.tokensAfter ?? null);
525 if (i.trigger === 'manual' || i.trigger === 'plugin') {
526 const loop = loopOf(s, i.agentId);
527 const next = loop && i.tokensAfter !== undefined ? putLoop(s, i.agentId, { ...loop, ctxTokens: i.tokensAfter }) : s;
528 return addEvent(next, 'compact', i.agentId ?? null, `${i.trigger === 'manual' ? 'manual' : 'plugin'} compaction ${loopLabel(s, i.agentId)}`, now, {
529 tokensBefore: i.tokensBefore ?? null,
530 tokensAfter: i.tokensAfter ?? null,
531 });
532 }
533 return s;
534}
535
536export interface CompleteInput {
537 agentId?: string;
538 reason: EndReason;
539 answer?: string;
540}
541
542/** A loop's turn ended. Main loop: idle. Agent: done, aborted or failed with a failure record. */
543/** A new answer text, to be sent again even if an earlier one already went out. */
544function answerText(answer: string): { answerText: string | null; answerSent: boolean } {
545 return { answerText: fullText(answer) || null, answerSent: false };
546}
547
548/** The full texts an accepted report carried, by agent id. */
549export type SentTexts = Record<string, { promptText?: string | undefined; answerText?: string | undefined }>;
550
551/** The texts in an accepted report have gone out; the next reports leave them out. Without `sent`, every text held counts as sent. */
552export function markTextSent(s: SessionState, sent?: SentTexts): SessionState {
553 const agents: Record<string, AgentState> = {};
554 let changed = false;
555 for (const [id, a] of Object.entries(s.agents)) {
556 // only a text that is still the one the report carried; a newer one goes out next time
557 const was = sent ? sent[id] : { promptText: a.promptText ?? undefined, answerText: a.answerText ?? undefined };
558 const next = {
559 ...a,
560 promptSent: a.promptSent || (!!was?.promptText && was.promptText === a.promptText),
561 answerSent: a.answerSent || (!!was?.answerText && was.answerText === a.answerText),
562 };
563 if (next.promptSent !== a.promptSent || next.answerSent !== a.answerSent) changed = true;
564 agents[id] = next;
565 }
566 return changed ? { ...s, agents } : s;
567}
568
569export function turnComplete(s: SessionState, i: CompleteInput, now: number): SessionState {
570 if (!i.agentId) {
571 let next: SessionState = { ...s, mainIdle: true, waitReason: s.waitReason === 'permission' ? null : s.waitReason, main: { ...s.main, candidate: i.reason === 'answer' ? null : s.main.candidate } };
572 const cand = s.main.candidate && now - s.main.candidate.at <= 60_000 ? s.main.candidate : null;
573 if (i.reason === 'error' && cand) {
574 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 });
575 } else if (i.reason === 'error') next = addEvent(next, 'fail', null, 'orchestrator turn ended with an API error', now, { reason: 'error' });
576 else if (i.reason === 'refusal') next = addEvent(next, 'fail', null, 'orchestrator turn ended with a refusal', now, { reason: 'refusal' });
577 return withStatus(next);
578 }
579 const a = s.agents[i.agentId];
580 if (!a || a.status === 'done' || a.status === 'failed' || a.status === 'aborted') {
581 // a teammate's later turn, or a duplicate: keep the answer only
582 if (a && i.answer) return { ...s, agents: { ...s.agents, [a.id]: { ...a, answerExcerpt: excerpt(i.answer), ...answerText(i.answer) } }, dirty: true };
583 return s;
584 }
585 const cls = classify({ reason: i.reason, candidate: a.candidate, stopReason: a.lastStopReason, blowouts: a.blowouts, now });
586 let next: AgentState;
587 if (i.reason === 'answer') {
588 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 };
589 } else {
590 const ev = evidence({ blowoutTimes: a.blowTimes, failedCalls: a.failedCalls, reads: a.reads, denyText: a.candidate?.denied ? a.candidate.denyText : null });
591 const fresh = a.candidate && now - a.candidate.at <= 60_000 ? a.candidate : null;
592 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' } : {});
593 next = { ...a, status: i.reason === 'aborted' ? 'aborted' : 'failed', endedAt: now, failure, answerExcerpt: i.answer ? excerpt(i.answer) : a.answerExcerpt, ...(i.answer ? answerText(i.answer) : {}) };
594 }
595 let out: SessionState = { ...s, agents: { ...s.agents, [a.id]: next } };
596 if (next.status === 'done') out = addEvent(out, 'done', a.id, `finished: ${a.description}`, now);
597 else if (next.status === 'failed') out = addEvent(out, 'fail', a.id, `failed: ${a.description} (${next.failure?.class ?? 'Unknown'})`, now, { class: next.failure?.class ?? 'Unknown' });
598 else if (next.status === 'aborted') out = addEvent(out, 'note', a.id, `aborted: ${a.description}`, now);
599 return withStatus(out);
600}
601
602// ---- agents -------------------------------------------------------------------------------------
603
604/** The model's Agent tool call, before the agent starts: requested effort and model, by tool_use_id. */
605export function agentRequested(s: SessionState, toolUseId: string, input: Record<string, unknown>, now: number): SessionState {
606 const p: PendingSpawn = {
607 effort: asEffort(input.effort),
608 model: str(input.model),
609 subagentType: str(input.subagent_type),
610 description: str(input.description),
611 name: str(input.name),
612 at: now,
613 };
614 // The spawn may have come first (either order is tolerated): merge into that agent.
615 const existing = Object.values(s.agents).find((a) => a.toolUseId === toolUseId);
616 if (existing) {
617 if (existing.kind === 'fork' || !p.effort || existing.effortSource === 'observed') return s;
618 return { ...s, agents: { ...s.agents, [existing.id]: { ...existing, effort: p.effort, effortSource: 'requested' } }, dirty: true };
619 }
620 return { ...s, pending: { ...s.pending, [toolUseId]: p } };
621}
622
623export interface SpawnInput {
624 toolUseId: string;
625 agentId: string;
626 model: string | null;
627 subagentType: string;
628 description: string;
629 prompt: string;
630 parentAgentId?: string;
631 fork: boolean;
632 isTeammate?: boolean;
633 name?: string;
634}
635
636/** An agent started. Effort: requested at the Agent call; a fork inherits its parent's. */
637export function agentSpawned(s: SessionState, i: SpawnInput, now: number): SessionState {
638 const pending = s.pending[i.toolUseId];
639 const parent = i.parentAgentId ? s.agents[i.parentAgentId] : undefined;
640 const parentLoop: LoopStats = parent ?? s.main;
641 const kind: AgentState['kind'] = i.fork ? 'fork' : i.isTeammate ? 'teammate' : 'subagent';
642 const model = i.fork ? parentLoop.model ?? i.model : i.model ?? pending?.model ?? null;
643 let effort: Effort | null = null;
644 let effortSource: AgentState['effortSource'] = 'unknown';
645 if (i.fork) {
646 effort = parentLoop.effort;
647 effortSource = 'inherited';
648 } else if (pending?.effort) {
649 effort = pending.effort;
650 effortSource = 'requested';
651 }
652 const prev = s.agents[i.agentId];
653 const a: AgentState = {
654 ...(prev ?? placeholderAgent(i.agentId, now)),
655 parentId: i.parentAgentId ?? null,
656 kind,
657 type: i.fork ? 'fork' : i.subagentType || pending?.subagentType || 'general-purpose',
658 name: i.name ?? pending?.name ?? null,
659 description: clip(i.description || pending?.description || 'agent', 240),
660 model,
661 effort: prev?.effortSource === 'observed' && !i.fork ? prev.effort : effort,
662 effortSource: prev?.effortSource === 'observed' && !i.fork ? 'observed' : effortSource,
663 status: 'running',
664 startedAt: prev?.startedAt ?? now,
665 promptExcerpt: excerpt(i.prompt),
666 promptText: fullText(i.prompt) || null,
667 promptSent: false,
668 toolUseId: i.toolUseId,
669 window: windowOf(model),
670 };
671 const { [i.toolUseId]: _used, ...rest } = s.pending;
672 let out: SessionState = { ...s, agents: { ...s.agents, [a.id]: a }, order: prev ? s.order : [...s.order, a.id], pending: rest };
673 const desc = i.fork ? `fork (inherits ${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''})` : `${a.type} (${shortName(a.model)}${a.effort ? ` ${a.effort}` : ''})`;
674 out = addEvent(out, 'spawn', a.id, `${i.fork ? 'forked' : 'spawned'} ${desc}: ${a.description}`, now, { kind });
675 // Match plan steps while the agent runs, so a step keeps its owner after the agent finishes.
676 return withStatus(resolveOwners(out));
677}
678
679/** The engine's list of agents: the authority for which exist and how each stands (SPEC 5.4). */
680export interface ListedAgent {
681 id: string;
682 description: string;
683 type: string;
684 status: string;
685 parentId?: string;
686 name?: string;
687}
688
689export function reconcile(s: SessionState, list: readonly ListedAgent[], now: number): SessionState {
690 let agents = s.agents;
691 let order = s.order;
692 let changed = false;
693 for (const l of list) {
694 const a = agents[l.id];
695 const status: AgentStatus | null =
696 l.status === 'completed' ? 'done' : l.status === 'failed' ? 'failed' : l.status === 'killed' ? 'aborted' : l.status === 'pending' ? 'queued' : 'running';
697 if (!a) {
698 agents = {
699 ...agents,
700 [l.id]: placeholderAgent(l.id, now, {
701 description: clip(l.description || 'agent', 240),
702 type: l.type === 'fork' ? 'fork' : l.type || 'general-purpose',
703 kind: l.type === 'fork' ? 'fork' : l.type === 'teammate' ? 'teammate' : 'subagent',
704 parentId: l.parentId ?? null,
705 name: l.name ?? null,
706 status: status ?? 'running',
707 endedAt: status === 'done' || status === 'failed' || status === 'aborted' ? now : null,
708 }),
709 };
710 order = [...order, l.id];
711 changed = true;
712 continue;
713 }
714 // Only move forward: a finished agent never becomes running again from the list.
715 const final = a.status === 'done' || a.status === 'failed' || a.status === 'aborted';
716 if (!final && status && status !== a.status && status !== 'running') {
717 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;
718 agents = { ...agents, [l.id]: { ...a, status, endedAt: status === 'queued' ? null : a.endedAt ?? now, failure } };
719 changed = true;
720 } else if (!final && a.status === 'queued' && status === 'running') {
721 agents = { ...agents, [l.id]: { ...a, status: 'running' } };
722 changed = true;
723 }
724 }
725 return changed ? withStatus({ ...s, agents, order }) : s;
726}
727
728// ---- tools --------------------------------------------------------------------------------------
729
730/** A tool call is about to run in a loop: its last tool, and the end of any permission wait there. */
731export function toolStarted(s: SessionState, agentId: string | undefined, tool: string): SessionState {
732 const loop = loopOf(s, agentId);
733 if (!loop) return s;
734 let next = putLoop(s, agentId, { ...loop, lastTool: clip(tool, 120) });
735 if (next.waitReason === 'permission' && !agentId) next = withStatus({ ...next, waitReason: null, waitNote: null });
736 return next;
737}
738
739export interface ToolResultInput {
740 agentId?: string;
741 tool: string;
742 input: Record<string, unknown>;
743 result: { deny?: string; isError?: boolean; text?: string; result?: unknown };
744}
745
746/** A tool call finished: counts, failure candidates, skills and the plan. */
747export function toolResult(s0: SessionState, i: ToolResultInput, now: number): SessionState {
748 let s = s0;
749 const loop = loopOf(s, i.agentId);
750 if (loop) {
751 const tool = clip(i.tool, 120);
752 const next: LoopStats = { ...loop, toolCalls: loop.toolCalls + 1, lastTool: tool };
753 if (Object.hasOwn(loop.toolCounts, tool) || Object.keys(loop.toolCounts).length < MAX_TOOL_NAMES) {
754 next.toolCounts = { ...loop.toolCounts, [tool]: (Object.hasOwn(loop.toolCounts, tool) ? loop.toolCounts[tool]! : 0) + 1 };
755 }
756 if (i.tool === 'Read' && typeof i.input.file_path === 'string') {
757 const f = clip(i.input.file_path, 200);
758 next.reads = { ...loop.reads, [f]: (loop.reads[f] ?? 0) + 1 };
759 }
760 const cand = candidateFrom(i.tool, i.input, i.result, now);
761 if (cand) {
762 next.candidate = cand;
763 next.failedCalls = { ...loop.failedCalls, [cand.call]: (loop.failedCalls[cand.call] ?? 0) + 1 };
764 }
765 s = putLoop(s, i.agentId, next);
766 }
767 if (s.waitReason === 'permission' && !i.agentId) s = withStatus({ ...s, waitReason: null, waitNote: null });
768 const ok = typeof i.result.deny !== 'string' && i.result.isError !== true;
769 if (!ok) return s;
770 if (i.tool === 'Skill' && typeof i.input.skill === 'string') s = skillLoaded(s, i.agentId, i.input.skill, now);
771 else if (i.tool === 'TaskCreate') s = withPlan(s, taskCreate(s.plan, i.input, i.result), now);
772 else if (i.tool === 'TaskUpdate') s = withPlan(s, taskUpdate(s.plan, i.input, i.result), now);
773 else if (i.tool === 'TodoWrite' && !i.agentId) s = withPlan(s, todoWrite(s.plan, i.input), now);
774 else if (i.tool === PLAN_TOOL) s = withPlan(s, setTitle(s.plan, i.input), now);
775 return s;
776}
777
778function withPlan(s: SessionState, plan: PlanState, now: number): SessionState {
779 if (plan === s.plan) return s;
780 let out: SessionState = resolveOwners({ ...s, plan, dirty: true });
781 const before = new Map(s.plan.tasks.map((t) => [t.id, t.status]));
782 for (const t of plan.tasks) {
783 const was = before.get(t.id);
784 if (t.status === 'in_progress' && was !== 'in_progress') out = addEvent(out, 'plan', null, `step started: ${t.text}`, now);
785 }
786 if (plan.tasks.length && plan.tasks.every((t) => t.status === 'completed') && s.plan.tasks.some((t) => t.status !== 'completed')) {
787 out = addEvent(out, 'plan', null, `plan complete: ${plan.toolTitle ?? plan.metaTitle ?? `${plan.tasks.length} steps`}`, now);
788 }
789 if (!s.plan.tasks.length && plan.tasks.length) out = addEvent(out, 'plan', null, `plan started (${plan.source ?? 'tasks'})`, now);
790 return out;
791}
792
793/** A skill was loaded into a loop (the Skill tool, or skill.prompt for the main loop). */
794export function skillLoaded(s: SessionState, agentId: string | undefined, skill: string, now: number): SessionState {
795 const loop = loopOf(s, agentId);
796 if (!loop || loop.skills.includes(skill)) return s;
797 const next = putLoop(s, agentId, { ...loop, skills: [...loop.skills, clip(skill, 120)].slice(-50) });
798 const where = agentId ? (s.agents[agentId]?.kind === 'fork' ? 'fork' : s.agents[agentId]?.type ?? 'agent') : 'orchestrator';
799 return addEvent(next, 'skill', agentId ?? null, `${skill} loaded into ${where}`, now, { skill });
800}
801
802/** The engine raised a permission dialog. */
803export function permissionRequest(s: SessionState, toolName: string, toolInput: unknown, now: number): SessionState {
804 let short = '';
805 if (toolInput && typeof toolInput === 'object') {
806 const v = (toolInput as Record<string, unknown>).command ?? (toolInput as Record<string, unknown>).file_path ?? Object.values(toolInput as Record<string, unknown>)[0];
807 short = typeof v === 'string' ? v : v === undefined ? '' : JSON.stringify(v);
808 }
809 const note = clip(`${toolName}(${short.replace(/\s+/g, ' ').trim()})`, 160);
810 const next = withStatus({ ...s, waitReason: 'permission', waitNote: note });
811 return addEvent(next, 'wait', null, `waiting on you: ${note}`, now);
812}
813
814/** A notification: a permission prompt or idle-for-input is a fallback signal for waiting. */
815export function notification(s: SessionState, type: string, message: string, now: number): SessionState {
816 if (/permission/i.test(type) && s.waitReason !== 'permission') {
817 const next = withStatus({ ...s, waitReason: 'permission', waitNote: clip(message.replace(/\s+/g, ' ').trim(), 160) || null });
818 return addEvent(next, 'wait', null, `waiting on you: ${next.waitNote ?? 'permission'}`, now);
819 }
820 if (/idle/i.test(type)) return withStatus({ ...s, mainIdle: true });
821 return s;
822}
823
824// ---- activity feed (#118) ----------------------------------------------------------------------
825
826/** Whether the server last said someone is watching this loop (no agent id: the main loop). */
827export function isWatched(s: SessionState, agentId: string | null | undefined): boolean {
828 return s.watched.includes(agentId || MAIN_LOOP);
829}
830
831/**
832 * The loops the server's reply says are watched. A loop that drops off the list forgets its entries, so a later
833 * watch starts empty; a loop that is listed starts keeping entries from now.
834 */
835export function setWatched(s: SessionState, ids: readonly string[]): SessionState {
836 const next = [...new Set(ids)].sort((a, b) => a.localeCompare(b));
837 if (next.length === s.watched.length && next.every((id, i) => id === s.watched[i])) return s;
838 const keep = new Set(next);
839 const main = keep.has(MAIN_LOOP) || !s.main.activity.length ? s.main : { ...s.main, activity: [] };
840 let agents = s.agents;
841 for (const [id, a] of Object.entries(s.agents)) {
842 if (!keep.has(id) && a.activity.length) agents = { ...agents, [id]: { ...a, activity: [] } };
843 }
844 // A loop that is no longer watched has no output lease either.
845 return setWatchedOutput({ ...s, watched: next, main, agents }, s.watchedOutput.filter((id) => keep.has(id)));
846}
847
848/** Whether the server last said a viewer has this loop's feed open (no agent id: the main loop): its Bash output may be read (#431). */
849export function isWatchedOutput(s: SessionState, agentId: string | null | undefined): boolean {
850 return s.watchedOutput.includes(agentId || MAIN_LOOP);
851}
852
853/** The output an entry holds (#431): it goes when the loop's output lease ends. How the call ended (`done`, `failed`, `exitCode`) is not output and stays (#598). */
854const OUTPUT_KEYS = ['output', 'outputCut'] as const;
855
856function withoutOutput(list: ActivityEntry[]): ActivityEntry[] {
857 return list.map((e) => {
858 if (!OUTPUT_KEYS.some((k) => k in e)) return e;
859 const rest = { ...e };
860 for (const k of OUTPUT_KEYS) delete rest[k];
861 return rest;
862 });
863}
864
865/**
866 * The loops the server's reply says a viewer has the feed open on (`watchOutput`, #431). A loop that drops off the list forgets
867 * the output its entries held, so nothing a viewer left behind stays in memory; the commands stay.
868 */
869export function setWatchedOutput(s: SessionState, ids: readonly string[]): SessionState {
870 const next = [...new Set(ids)].sort((a, b) => a.localeCompare(b));
871 if (next.length === s.watchedOutput.length && next.every((id, i) => id === s.watchedOutput[i])) return s;
872 const keep = new Set(next);
873 const main = keep.has(MAIN_LOOP) ? s.main : { ...s.main, activity: withoutOutput(s.main.activity) };
874 let agents = s.agents;
875 for (const [id, a] of Object.entries(s.agents)) {
876 if (!keep.has(id)) agents = { ...agents, [id]: { ...a, activity: withoutOutput(a.activity) } };
877 }
878 return { ...s, watchedOutput: next, main, agents };
879}
880
881/** Adds what a finished call produced to its entry in the loop's feed (the one with that id). No such entry, nothing changes. */
882export function finishActivity(s: SessionState, agentId: string | null | undefined, id: string, patch: Partial<ActivityEntry>): SessionState {
883 const loop = loopOf(s, agentId);
884 const at = loop ? loop.activity.findIndex((e) => e.id === id) : -1;
885 if (!loop || at < 0) return s;
886 const activity = loop.activity.map((e, i) => (i === at ? { ...e, ...patch } : e));
887 return putLoop(s, agentId, { ...loop, activity });
888}
889
890/** Adds an entry to a watched loop's feed, keeping the last 20. A loop nobody watches keeps nothing. */
891export function addActivity(s: SessionState, agentId: string | null | undefined, entry: ActivityEntry): SessionState {
892 if (!isWatched(s, agentId)) return s;
893 const loop = loopOf(s, agentId);
894 if (!loop) return s;
895 return putLoop(s, agentId, { ...loop, activity: [...loop.activity, entry].slice(-ACTIVITY_KEPT) });
896}
897
898// ---- usage, end ---------------------------------------------------------------------------------
899
900export interface MeasureInput {
901 context?: { tokens?: number; window?: number; percent?: number } | null;
902 cost?: { usd: number } | null;
903 rateLimits?: { kind: string; percentUsed: number; resetsAt?: string }[] | null;
904 startedAt?: number;
905}
906
907/** The status line's figures: main-loop context, the session's real cost, rate limits. */
908export function measure(s: SessionState, i: MeasureInput): SessionState {
909 const c = i.context;
910 const context = c
911 ? {
912 tokens: typeof c.tokens === 'number' ? c.tokens : s.context.tokens,
913 window: typeof c.window === 'number' ? c.window : s.context.window,
914 percent: typeof c.percent === 'number' ? c.percent : s.context.percent,
915 }
916 : s.context;
917 const rateLimits = Array.isArray(i.rateLimits)
918 ? i.rateLimits.slice(0, 10).map((r) => ({ kind: clip(String(r.kind), 120), percentUsed: Number(r.percentUsed) || 0, resetsAt: r.resetsAt ?? null }))
919 : s.rateLimits;
920 const costUsd = i.cost && typeof i.cost.usd === 'number' ? i.cost.usd : s.costUsd;
921 const startedAt = typeof i.startedAt === 'number' && i.startedAt > 0 ? i.startedAt : s.startedAt;
922 const same = JSON.stringify([context, rateLimits, costUsd, startedAt]) === JSON.stringify([s.context, s.rateLimits, s.costUsd, s.startedAt]);
923 return same ? s : { ...s, context, rateLimits, costUsd, startedAt, dirty: true };
924}
925
926/** The session ended: final status, and every open agent aborted. */
927export function sessionEnd(s: SessionState, reason: string, resumeId: string | null, now: number): SessionState {
928 if (s.ended) return s;
929 const agents: Record<string, AgentState> = {};
930 for (const [id, a] of Object.entries(s.agents)) {
931 agents[id] = a.status === 'running' || a.status === 'queued' ? { ...a, status: 'aborted', endedAt: now } : a;
932 }
933 const next = withStatus({ ...s, agents, ended: true, endReason: clip(reason, 120), resumeId });
934 return addEvent(next, 'end', null, `session.end · ${reason}`, now, { reason });
935}
936
937/** Plan owners resolved against the current agents (sticky once matched). */
938export function resolveOwners(s: SessionState): SessionState {
939 const plan = assignOwners(
940 s.plan,
941 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) })),
942 );
943 return plan === s.plan ? s : { ...s, plan };
944}
945
946/** Stores a failure explanation (T5.2); its own tokens count in the agent's estimated cost. */
947export function setExplanation(s: SessionState, agentId: string, text: string, usage: Usage | null, model: string): SessionState {
948 const a = s.agents[agentId];
949 if (!a || !a.failure) return s;
950 const explanation = excerpt(text, 400);
951 if (!explanation) return s;
952 const cost = usage
953 ? 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
954 : 0;
955 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 };
956}
957hooks/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 370 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 { withBuild } from './build.ts';
5import { trimTrailingSlashes } from './machine.ts';
6import { PEER_LIMITS, RECORDING_STATES, type RecordingGrant, type ActivityEvent, type Delivery, type DiffRequest, type PeerDelivery, type PeerRequest, type Report } from './protocol.ts';
7
8export const QUEUE_MAX = 500;
9export const BACKOFF_MS = [1000, 2000, 5000, 10_000] as const;
10export const EVENTS_PER_REPORT = 100;
11/** How long to wait after the server refuses the token or name: it will not change until the settings do. */
12export const REFUSED_RETRY_MS = 60_000;
13/** More watched loops than a report can carry agents is not a list the mod takes whole. */
14const MAX_WATCHED = 201;
15/** File diffs one reply can ask for (#319). */
16const MAX_DIFF_REQUESTS = 4;
17/** Messages and commands one reply can hand over (#206). */
18const MAX_DELIVERIES = 20;
19
20/** 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). */
21export function isDelivery(v: unknown): v is Delivery {
22 const d = v as Partial<Delivery> | null;
23 return !!d && typeof d === 'object' && typeof d.id === 'string' && typeof d.kind === 'string' && typeof d.by === 'string' && typeof d.at === 'number';
24}
25
26/** A message or notice for this orchestrator as the server sends it (#347): only its shape is checked here; whether it is taken is the mod's to decide (SPEC 5.3). */
27export function isPeerDelivery(v: unknown): v is PeerDelivery {
28 const d = v as Partial<PeerDelivery> | null;
29 return !!d && typeof d === 'object' && typeof d.id === 'string' && d.id.length > 0 && (d.kind === 'message' || d.kind === 'notice') && typeof d.at === 'number' && typeof d.text === 'string';
30}
31
32/** One of the owner's open requests as the server sends it (#347): only its shape is checked here. */
33export function isPeerRequest(v: unknown): v is PeerRequest {
34 const r = v as Partial<PeerRequest> | null;
35 const side = (s: unknown) => !!s && typeof s === 'object' && typeof (s as { machine?: unknown }).machine === 'string';
36 return !!r && typeof r === 'object' && typeof r.id === 'string' && r.id.length > 0 && (r.kind === 'proposal' || r.kind === 'access' || r.kind === 'message') && side(r.from) && side(r.to) && typeof r.by === 'string';
37}
38
39/** A grant as the server sends it: only its shape is checked here. */
40export function isRecordingGrant(v: unknown): v is RecordingGrant {
41 const g = v as Partial<RecordingGrant> | null;
42 if (!g || typeof g !== 'object') return false;
43 const c = g.consent as Partial<RecordingGrant['consent']> | null | undefined;
44 return (
45 (RECORDING_STATES as readonly unknown[]).includes(g.state) &&
46 !!c &&
47 typeof c === 'object' &&
48 typeof c.admin === 'boolean' &&
49 typeof c.chosen === 'boolean' &&
50 typeof c.owner === 'boolean' &&
51 Array.isArray(g.loops) &&
52 g.loops.every((l) => typeof l === 'string')
53 );
54}
55
56/** A request for a file's diff as the server sends it (#319): only its shape is checked here. */
57export function isDiffRequest(v: unknown): v is DiffRequest {
58 const d = v as Partial<DiffRequest> | null;
59 return !!d && typeof d === 'object' && typeof d.id === 'string' && d.id.length > 0 && typeof d.path === 'string' && d.path.length > 0;
60}
61
62/** 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). */
63export function plainHttpRemote(serverUrl: string): boolean {
64 let u: URL;
65 try {
66 u = new URL(serverUrl);
67 } catch {
68 return false;
69 }
70 if (u.protocol !== 'http:') return false;
71 const h = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
72 return !(h === 'localhost' || h === '::1' || /^127(\.\d{1,3}){3}$/.test(h));
73}
74
75export interface HttpResult {
76 status: number;
77 ok: boolean;
78 text: string;
79}
80export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<HttpResult>;
81
82export interface TransportConfig {
83 serverUrl: string;
84 token: string;
85 heartbeatMs: number;
86 /** This reporter's own version; passed in so the transport never imports it. */
87 version: string;
88 /** The commit of a dev build of this reporter (#400); absent or null for a release. */
89 build?: string | null;
90}
91
92/** The oldest and newest reporter the server reads correctly (SPEC 6.8). */
93export interface ReporterSupport {
94 min: string;
95 max: string;
96}
97
98/** Major and minor of a version as numbers; null when it is not one. */
99function minorOf(v: string): [number, number] | null {
100 const m = /^(\d+)\.(\d+)(?:\.|$)/.exec(v);
101 return m ? [Number(m[1]), Number(m[2])] : null;
102}
103
104export type SendOutcome = 'ok' | 'rejected' | 'refused' | 'failed' | 'busy';
105
106export class Transport {
107 queue: ActivityEvent[] = [];
108 /** null until the first attempt. */
109 online: boolean | null = null;
110 sent = 0;
111 failed = 0;
112 rejected = 0;
113 dropped = 0;
114 failures = 0;
115 lastError: string | null = null;
116 lastSentAt = 0;
117 nextAttemptAt = 0;
118 liveSessions: number | null = null;
119 /** Where the server places this reporter's version; null until a reply says (an older server never does). */
120 compat: 'ok' | 'outdated' | 'newer' | null = null;
121 serverVersion: string | null = null;
122 /** The commit of a dev build of the server (#400); null for a release, and from a server that predates it. */
123 serverBuild: string | null = null;
124 /** What the server reads correctly; null until a reply says (an older server never does). */
125 support: ReporterSupport | null = null;
126 /** The loops the last accepted report's reply says someone is watching (#118); empty from a server that predates it. */
127 watch: string[] = [];
128 /** The loops of `watch` a viewer has the feed open on (#431); empty from a server that predates it, which asks for no output. */
129 watchOutput: string[] = [];
130 /** Whether the last accepted report's reply said the server takes answers for this session (#206); false from an older server. */
131 respond = false;
132 /** Messages and commands from Podium the last reply handed over (#206). */
133 deliveries: Delivery[] = [];
134 /** The file diffs the session's owner is waiting for, from the last reply (#319); empty from a server that predates it. */
135 diffs: DiffRequest[] = [];
136 /** Whether the last accepted report's reply said this orchestrator takes part in messaging (#347); false from an older server. */
137 peers = false;
138 /** Messages and notices for this orchestrator from the last reply (#347); empty while `peers` is false. */
139 peerDeliveries: PeerDelivery[] = [];
140 /** The owner's open requests about this reporter, from the last reply (#347); empty while `peers` is false. */
141 peerRequests: PeerRequest[] = [];
142 /** Session recording's state from the last accepted report's reply (#353); null from a server that predates it, which a mod reads as off. */
143 recording: RecordingGrant | null = null;
144 inFlight = false;
145 private current: Promise<SendOutcome> | null = null;
146 private loggedReject = false;
147 private loggedRefusal = false;
148 /** True while the server's last answer was a 401. */
149 refused = false;
150 /** True while the server's last answer was a 409: this session already belongs to another owner (#209). */
151 ownerRefused = false;
152 private loggedOwner = false;
153 private rejectStreak = 0;
154
155 constructor(
156 private readonly fetch: FetchLike,
157 readonly cfg: TransportConfig,
158 private readonly log: (line: string) => void = () => {},
159 ) {}
160
161 get url(): string {
162 return `${trimTrailingSlashes(this.cfg.serverUrl)}/api/fleet/report`;
163 }
164
165 /** Queues events; past 500 the oldest are dropped. */
166 enqueue(events: readonly ActivityEvent[]): void {
167 if (!events.length) return;
168 this.queue.push(...events);
169 if (this.queue.length > QUEUE_MAX) {
170 this.dropped += this.queue.length - QUEUE_MAX;
171 this.queue.splice(0, this.queue.length - QUEUE_MAX);
172 }
173 }
174
175 /** Whether a report should go now: something changed, or the heartbeat is due, and no backoff holds. */
176 due(now: number, dirty: boolean): boolean {
177 if (this.inFlight || now < this.nextAttemptAt) return false;
178 return dirty || this.queue.length > 0 || this.lastSentAt === 0 || now - this.lastSentAt >= this.cfg.heartbeatMs;
179 }
180
181 /** The delay before retry number `failures` (1-based). */
182 static backoff(failures: number): number {
183 return BACKOFF_MS[Math.min(Math.max(failures, 1), BACKOFF_MS.length) - 1]!;
184 }
185
186 /**
187 * Posts one report built from the oldest queued events (at most 100). 200 acks them; 400 or 413 drops
188 * that report and its events (it would never be accepted) and backs off before the next attempt;
189 * anything else keeps them and backs off.
190 */
191 send(build: (events: ActivityEvent[]) => Report, now: number): Promise<SendOutcome> {
192 if (this.inFlight) return Promise.resolve('busy');
193 this.inFlight = true;
194 const p = this.post(build, now);
195 this.current = p;
196 return p;
197 }
198
199 /** Resolves when no POST is in flight (at once when none is). */
200 async idle(): Promise<void> {
201 if (this.current) await this.current.catch(() => undefined);
202 }
203
204 private async post(build: (events: ActivityEvent[]) => Report, now: number): Promise<SendOutcome> {
205 const events = this.queue.slice(0, EVENTS_PER_REPORT);
206 try {
207 const report = build(events);
208 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
209 if (this.cfg.token) headers['X-Podium-Token'] = this.cfg.token;
210 const res = await this.fetch(this.url, { method: 'POST', headers, body: JSON.stringify(report) });
211 if (res.ok) {
212 let acked = events.length;
213 try {
214 const body = JSON.parse(res.text) as { ackedEvents?: unknown; liveSessions?: unknown; reporterCompat?: unknown; serverVersion?: unknown; serverBuild?: unknown; reporterSupport?: unknown; watch?: unknown; watchOutput?: unknown; respond?: unknown; deliveries?: unknown; diffs?: unknown; peers?: unknown; peerDeliveries?: unknown; peerRequests?: unknown; recording?: unknown };
215 if (typeof body.ackedEvents === 'number') acked = Math.max(0, Math.min(events.length, body.ackedEvents));
216 if (typeof body.liveSessions === 'number') this.liveSessions = body.liveSessions;
217 this.compat = body.reporterCompat === 'outdated' || body.reporterCompat === 'newer' || body.reporterCompat === 'ok' ? body.reporterCompat : null;
218 this.serverVersion = typeof body.serverVersion === 'string' ? body.serverVersion : null;
219 this.serverBuild = typeof body.serverBuild === 'string' ? body.serverBuild : null;
220 const sup = body.reporterSupport as Partial<ReporterSupport> | null | undefined;
221 this.support = sup && typeof sup.min === 'string' && typeof sup.max === 'string' ? { min: sup.min, max: sup.max } : null;
222 this.watch = Array.isArray(body.watch) ? body.watch.filter((id): id is string => typeof id === 'string').slice(0, MAX_WATCHED) : [];
223 this.watchOutput = Array.isArray(body.watchOutput) ? body.watchOutput.filter((id): id is string => typeof id === 'string').slice(0, MAX_WATCHED) : [];
224 this.respond = body.respond === true;
225 this.deliveries = this.respond && Array.isArray(body.deliveries) ? body.deliveries.filter(isDelivery).slice(0, MAX_DELIVERIES) : [];
226 this.diffs = Array.isArray(body.diffs) ? body.diffs.filter(isDiffRequest).slice(0, MAX_DIFF_REQUESTS) : [];
227 this.peers = body.peers === true;
228 this.peerDeliveries = this.peers && Array.isArray(body.peerDeliveries) ? body.peerDeliveries.filter(isPeerDelivery).slice(0, PEER_LIMITS.deliveries) : [];
229 this.peerRequests = this.peers && Array.isArray(body.peerRequests) ? body.peerRequests.filter(isPeerRequest).slice(0, PEER_LIMITS.requests) : [];
230 this.recording = isRecordingGrant(body.recording) ? body.recording : null;
231 } catch {
232 // a 200 without a JSON body still acks what was sent
233 }
234 this.ack(events, acked);
235 this.online = true;
236 this.failures = 0;
237 this.rejectStreak = 0;
238 this.nextAttemptAt = 0;
239 this.lastSentAt = now;
240 this.lastError = null;
241 this.sent++;
242 this.refused = false;
243 this.loggedRefusal = false;
244 this.ownerRefused = false;
245 this.loggedOwner = false;
246 return 'ok';
247 }
248 // 409: the session id already belongs to another owner (server-side); it is as permanent as a 400, so it is not an outage.
249 if (res.status === 400 || res.status === 409 || res.status === 413) {
250 this.ack(events, events.length);
251 this.rejected++;
252 // Back off like a failure: a snapshot the server keeps refusing (a mod/server version mismatch, say)
253 // would otherwise be rebuilt and re-posted on every flush tick while state is dirty.
254 this.rejectStreak++;
255 this.nextAttemptAt = now + Transport.backoff(this.rejectStreak);
256 this.lastSentAt = now;
257 this.lastError = `server rejected a report (${res.status})`;
258 // The server read the credential and answered about the report, so an earlier 401 no longer stands.
259 this.refused = false;
260 this.loggedRefusal = false;
261 if (res.status === 409) {
262 this.ownerRefused = true;
263 if (!this.loggedOwner) {
264 this.loggedOwner = true;
265 this.log(`podium-reporter: ${this.ownerNotice()}`);
266 }
267 return 'rejected';
268 }
269 this.ownerRefused = false;
270 if (!this.loggedReject) {
271 this.loggedReject = true;
272 this.log(`podium-reporter: server rejected a report (${res.status}): ${res.text.slice(0, 200)}`);
273 }
274 return 'rejected';
275 }
276 if (res.status === 401) {
277 // The token is wrong, revoked, or does not belong to the configured name. Keep the events, wait a long
278 // while (nothing changes until the settings do), and say so once. The token is never put in a message.
279 this.refused = true;
280 this.ownerRefused = false;
281 this.online = false;
282 this.nextAttemptAt = now + REFUSED_RETRY_MS;
283 this.lastSentAt = now;
284 this.lastError = 'server refused the token or name (401)';
285 if (!this.loggedRefusal) {
286 this.loggedRefusal = true;
287 this.log('podium-reporter: the server refused this reporter (401): check the Podium token and user name settings');
288 }
289 return 'refused';
290 }
291 throw new Error(`HTTP ${res.status}`);
292 } catch (err) {
293 this.failures++;
294 this.failed++;
295 this.online = false;
296 this.lastError = (err as Error)?.message ?? 'network error';
297 this.nextAttemptAt = now + Transport.backoff(this.failures);
298 return 'failed';
299 } finally {
300 this.inFlight = false;
301 this.current = null;
302 }
303 }
304
305 /** Removes the first `n` of `sent` from the queue (events queued meanwhile stay). */
306 private ack(sent: readonly ActivityEvent[], n: number): void {
307 const ids = new Set(sent.slice(0, n).map((e) => e.id));
308 this.queue = this.queue.filter((e) => !ids.has(e.id));
309 }
310
311 /**
312 * True when the server supports a newer major.minor than this reporter but still reads it (so it is not
313 * `outdated`). Patch releases are not flagged: `max` moves with the report format, not with every release.
314 */
315 get behind(): boolean {
316 if (!this.support || this.compat === 'outdated' || this.compat === 'newer') return false;
317 const mine = minorOf(this.cfg.version);
318 const max = minorOf(this.support.max);
319 if (!mine || !max) return false;
320 return mine[0] < max[0] || (mine[0] === max[0] && mine[1] < max[1]);
321 }
322
323 /** `problem` is what the TLS link says is wrong (#584), shown ahead of the connection state. */
324 statusText(problem?: string | null): string {
325 const name = `${plainHttpRemote(this.cfg.serverUrl) ? '⚠ ' : ''}${withBuild(`podium v${this.cfg.version}`, this.cfg.build)}`;
326 if (problem) return `${name} ○ ${problem}`;
327 if (this.refused) return `${name} ○ token refused`;
328 if (this.ownerRefused) return `${name} ○ refused: session belongs to someone else`;
329 if (this.online === false) return `${name} ○ offline`;
330 if (this.online === null) return `${name} ○ connecting`;
331 const live = `${name} ● ${this.liveSessions ?? 1} live`;
332 if (this.compat === 'outdated') return `${live} · update mod`;
333 if (this.compat === 'newer') return `${live} · upgrade server`;
334 if (this.behind) return `${live} · update available`;
335 return live;
336 }
337
338 /** 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. */
339 plainHttpNotice(): string | null {
340 return plainHttpRemote(this.cfg.serverUrl)
341 ? '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.'
342 : null;
343 }
344
345 /** What to tell the user when the server refused the token or name; null when it did not. */
346 refusalNotice(): string | null {
347 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;
348 }
349
350 /** What to tell the user when the server says this session already belongs to another owner (#209); null when it did not. */
351 ownerNotice(): string | null {
352 return this.ownerRefused ? 'The server refused this report: this session belongs to someone else. Start a new session to report as yourself.' : null;
353 }
354
355 /** What to tell the user when the server says this reporter and it do not match; null when they do (or it cannot tell). */
356 versionNotice(): string | null {
357 const server = this.serverVersion ? ` (server ${this.serverVersion})` : '';
358 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).`;
359 if (this.compat === 'newer') return `This reporter is newer than the Podium server supports${server}: upgrade the Podium server.`;
360 return null;
361 }
362
363 /** The mild hint behind `· update available`; for `/podium` only, never a toast. Null when not behind. */
364 updateNotice(): string | null {
365 if (!this.behind || !this.support) return null;
366 const server = this.serverVersion ? ` (server ${this.serverVersion})` : '';
367 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.`;
368 }
369}
370hooks/tlsLink.ts 387 lines1// The mod's side of the TLS connection (#584, SPEC 5.9): find the reporter config file, find `node`, start helper.mjs
2// and send the mod's requests through its Unix socket. The mod cannot give $.http.fetch a CA or a client certificate,
3// so the helper makes the connection. Nothing here ever falls back from `https://` to `http://`.
4import { newestConfig, parseConfigHeaders, podiumHome, socketPathFor, SOCKET_PATH_MAX, utf8Length, type ConfigHeaders } from './reporterConfig.ts';
5import { trimTrailingSlashes } from './machine.ts';
6import type { FetchLike, HttpResult } from './transport.ts';
7
8/** How long to wait for the helper's `ready` line. */
9export const READY_MS = 5000;
10/** Do not restart a helper that exited more often than this. */
11export const RESTART_MIN_MS = 10_000;
12const HELPER_ERROR_HEADER = 'x-podium-helper-error';
13const NODE_PLACES = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/opt/local/bin/node', '/snap/bin/node'];
14
15/** What is wrong, said so a person can act on it: a short word for the status line and a sentence for `/podium`. */
16export interface Problem {
17 code: string;
18 status: string;
19 text: string;
20}
21
22/** The slice of the engine's `$` this file uses, so a test can stand in for it. */
23export interface LinkEngine {
24 env: { get: (name: string) => Promise<string | undefined> };
25 fs: {
26 read: (path: string) => Promise<unknown>;
27 list: (path?: string) => Promise<Array<{ name: string; kind: string }>>;
28 exists: (path: string) => Promise<boolean>;
29 };
30 process: {
31 run: (argv: readonly string[], init?: { timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string }>;
32 spawn: (request: { argv: readonly string[]; env?: Record<string, string> }) => AsyncIterable<{ stream: string; text: string }>;
33 };
34 http: { fetch: (url: string, init?: { method?: string; headers?: Record<string, string>; body?: string; socketPath?: string }) => Promise<HttpResult & { headers: Record<string, string> }> };
35 clock: { sleep: (ms: number) => Promise<void> };
36 plugin: { root: string };
37}
38
39interface EnvValues {
40 config: string | undefined;
41 home: string | undefined;
42 node: string | undefined;
43 tmp: string | undefined;
44}
45
46/** `$.env.get` takes a name written out in the source, so each one is spelled here. */
47async function readEnv($: LinkEngine): Promise<EnvValues> {
48 const get = async (p: Promise<string | undefined>) => {
49 try {
50 return await p;
51 } catch {
52 return undefined;
53 }
54 };
55 return {
56 config: await get($.env.get('PODIUM_REPORTER_CONFIG')),
57 home: await get($.env.get('HOME')),
58 node: await get($.env.get('PODIUM_NODE')),
59 tmp: await get($.env.get('TMPDIR')),
60 };
61}
62
63async function readText($: LinkEngine, path: string): Promise<string | null> {
64 try {
65 const t = await $.fs.read(path);
66 return typeof t === 'string' ? t : null;
67 } catch {
68 return null;
69 }
70}
71
72export interface FoundConfig {
73 path: string;
74 headers: ConfigHeaders;
75}
76
77/** `PODIUM_REPORTER_CONFIG` if set, otherwise the newest `reporter-config-*.pem` in `~/.podium/`. Null: no config, the reporter works as today. */
78export async function findConfig($: LinkEngine, env?: EnvValues): Promise<{ found: FoundConfig | null; problem: Problem | null; env: EnvValues }> {
79 const e = env ?? (await readEnv($));
80 if (e.config) {
81 const text = await readText($, e.config);
82 const headers = text === null ? null : parseConfigHeaders(text);
83 if (headers) return { found: { path: e.config, headers }, problem: null, env: e };
84 return {
85 found: null,
86 env: e,
87 problem: { code: 'CONFIG_INVALID', status: 'config unreadable', text: `PODIUM_REPORTER_CONFIG names ${e.config}, which is missing or is not a Podium reporter config file (version 1, with a name, an https server and an issued-at line).` },
88 };
89 }
90 if (!e.home) return { found: null, problem: null, env: e };
91 const dir = podiumHome(e.home);
92 let names: Array<{ name: string; kind: string }> = [];
93 try {
94 names = await $.fs.list(dir);
95 } catch {
96 return { found: null, problem: null, env: e };
97 }
98 const files: Array<{ path: string; text: string }> = [];
99 for (const n of names) {
100 if (n.kind !== 'file' || !/^reporter-config-.+\.pem$/.test(n.name)) continue;
101 const text = await readText($, `${dir}/${n.name}`);
102 if (text !== null) files.push({ path: `${dir}/${n.name}`, text });
103 }
104 return { found: newestConfig(files), problem: null, env: e };
105}
106
107/** `node` by `PODIUM_NODE`, then the PATH the mod runs with, then the usual places (a desktop launcher often has neither nvm's nor Homebrew's PATH). */
108export async function findNode($: LinkEngine, env: EnvValues): Promise<string | null> {
109 if (env.node) return (await $.fs.exists(env.node).catch(() => false)) ? env.node : null;
110 try {
111 const r = await $.process.run(['node', '-p', 'process.execPath'], { timeoutMs: 5000 });
112 const p = r.stdout.trim();
113 if (r.exitCode === 0 && p.startsWith('/')) return p;
114 } catch {
115 // not on this PATH
116 }
117 const home = env.home === undefined ? undefined : trimTrailingSlashes(env.home);
118 const places = [...NODE_PLACES];
119 if (home) {
120 places.push(`${home}/.volta/bin/node`, `${home}/.local/bin/node`);
121 try {
122 const versions = (await $.fs.list(`${home}/.nvm/versions/node`)).map((v) => v.name).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse();
123 for (const v of versions) places.push(`${home}/.nvm/versions/node/${v}/bin/node`);
124 } catch {
125 // no nvm
126 }
127 }
128 for (const p of places) if (await $.fs.exists(p).catch(() => false)) return p;
129 return null;
130}
131
132/** Clocks closer than this are not the reason a certificate looks expired or not yet valid. */
133export const CLOCK_SKEW_MS = 5 * 60_000;
134
135/** The two clocks a certificate date is judged by: this machine's now, and the server's when a reply has told us (epoch ms). */
136export interface ClockView {
137 now: number;
138 serverNow?: number;
139}
140
141const stamp = (ms: number): string => `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
142
143/**
144 * A server certificate that looks expired or not yet valid says the clock may be wrong when the server's time is unknown
145 * (the handshake failed before any reply) or differs from this machine's by more than CLOCK_SKEW_MS (#650). It never says
146 * the clock is wrong for certain: the certificate could be expired. Null when the clock is not in question.
147 */
148function clockProblem(code: string, clock: ClockView | undefined): Problem | null {
149 if (!clock || (code !== 'CERT_HAS_EXPIRED' && code !== 'CERT_NOT_YET_VALID')) return null;
150 const look = code === 'CERT_HAS_EXPIRED' ? 'expired' : 'not yet valid';
151 if (clock.serverNow === undefined) {
152 return { code, status: 'check the clock', text: `The server's certificate looks ${look} from here. This machine's clock says it is ${stamp(clock.now)}; if that is not right, a wrong clock is the likely cause. Check the system date and time. If it is right, tell whoever runs the Podium server.` };
153 }
154 if (Math.abs(clock.serverNow - clock.now) <= CLOCK_SKEW_MS) return null;
155 return { code, status: 'check the clock', text: `The server's certificate looks ${look} from here. This machine's clock says it is ${stamp(clock.now)}, and the server's says ${stamp(clock.serverNow)}. Clocks that far apart can make a good certificate look wrong. Check the system date and time. If this machine's is right, the server's clock may be the wrong one: tell whoever runs the Podium server.` };
156}
157
158/** What a helper error code means to the person. Null for a network failure, which is an outage the reporter retries as today. */
159export function problemFor(code: string, host: string, clock?: ClockView, expiredOn?: string): Problem | null {
160 const skewed = clockProblem(code, clock);
161 if (skewed) return skewed;
162 switch (code) {
163 case 'CLIENT_CERT_REJECTED':
164 return { code, status: 'certificate refused', text: 'The server did not accept your certificate: it was revoked, has expired, or the server was reset. Ask an Admin to reissue your reporter config.' };
165 case 'CERT_EXPIRED':
166 return { code, status: 'certificate expired', text: `Your Podium certificate expired${expiredOn ? ` on ${expiredOn}` : ''}. Ask an Admin to generate a new reporter config.` };
167 case 'CERT_RENEWAL_REFUSED':
168 return { code, status: 'certificate refused', text: 'The server would not renew your certificate: it was revoked, or is not one this server issued. Ask an Admin to reissue your reporter config.' };
169 case 'CLOSED_AFTER_HANDSHAKE':
170 return { code, status: 'server closed connection', text: 'The server closed the connection right after the handshake. Either it restarted, or it did not accept your certificate (revoked or expired). If this goes on, ask an Admin to reissue your reporter config.' };
171 case 'CERT_HAS_EXPIRED':
172 return { code, status: 'server certificate expired', text: "The server's own certificate has expired. Tell whoever runs the Podium server." };
173 case 'ALTNAME':
174 return { code, status: 'wrong server name', text: `The server's certificate is not for ${host}. The address in the reporter config file does not match the server.` };
175 case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
176 case 'SELF_SIGNED_CERT_IN_CHAIN':
177 case 'DEPTH_ZERO_SELF_SIGNED_CERT':
178 return { code, status: 'server not trusted', text: 'The server’s certificate is not signed by the CA in your reporter config file. Ask an Admin for a new file.' };
179 default:
180 return code.startsWith('CERT_') ? { code, status: 'server certificate refused', text: `The server's certificate was refused (${code}).` } : null;
181 }
182}
183
184/** The helper's first line of output when it cannot start: `error CODE message`. Split by hand: one regex over the line could backtrack. */
185function startupWords(line: string): [string, string] | null {
186 const t = line.trim();
187 if (!t.startsWith('error ')) return null;
188 const rest = t.slice(6);
189 const space = rest.search(/\s/);
190 const code = space < 0 ? rest : rest.slice(0, space);
191 return code ? [code, space < 0 ? '' : rest.slice(space).trim()] : null;
192}
193
194export function problemFromStartup(line: string, path: string): Problem {
195 const w = startupWords(line);
196 const code = w?.[0] ?? 'HELPER_EXITED';
197 if (code === 'CONFIG_PERMISSIONS') return { code, status: 'config too open', text: `The reporter config file ${path} can be read by other people, and it holds your private key. Run: chmod 600 ${path}` };
198 if (code === 'CONFIG_INVALID' || code === 'CONFIG_MISSING') return { code, status: 'config unreadable', text: `The reporter config file ${path} cannot be used: ${w?.[1] ?? code}.` };
199 return { code, status: 'helper stopped', text: `The Podium connection helper stopped${w ? `: ${w[1] || code}` : ''}. It restarts on the next session.` };
200}
201
202export interface Link {
203 /** The address in the config file. */
204 serverUrl: string;
205 /** The name in the config file. */
206 user: string;
207 configPath: string;
208 /** Null for a link that stands in for a file that could not be read. */
209 headers: ConfigHeaders | null;
210 fetch: FetchLike;
211 /** What is wrong right now, or null. */
212 problem: () => Problem | null;
213 /** Ends the helper (the session is over; a `clear` does not call this). */
214 stop: () => void;
215}
216
217/** A link that never connects and says why: nothing is sent, and nothing falls back to http. */
218function brokenLink(found: FoundConfig, problem: Problem): Link {
219 return {
220 serverUrl: found.headers.server,
221 user: found.headers.user,
222 configPath: found.path,
223 headers: found.headers,
224 fetch: () => Promise.reject(new Error(problem.text)),
225 problem: () => problem,
226 stop: () => {},
227 };
228}
229
230/** Stands in for a config file that was named and cannot be used: nothing is sent, over https or http, and the status line says why (#645). */
231export function unusableLink(configPath: string, serverUrl: string, problem: Problem): Link {
232 return { serverUrl, user: '', configPath, headers: null, fetch: () => Promise.reject(new Error(problem.text)), problem: () => problem, stop: () => {} };
233}
234
235export interface LinkOptions {
236 /** The name this session reports as; empty when it has none, and then the file's name is taken. */
237 sessionName: string;
238 /** Where that name came from, in words for the message (#647); empty when unknown. */
239 nameSource?: string;
240 /** True when that name is a setting the person can empty; false when it is the identity the session holds (change it with `/podium name`). */
241 nameIsSetting?: boolean;
242 /** Epoch milliseconds now. */
243 now: () => number;
244 log?: (line: string) => void;
245}
246
247/**
248 * Starts the helper for `found` and returns a link whose `fetch` sends through it, or a link that says what is wrong.
249 * Checked before anything starts: the name matches the session's and `node` exists. An expired certificate does not stop it: the helper says it has expired, with the date, instead of connecting.
250 */
251export async function startLink($: LinkEngine, found: FoundConfig, env: EnvValues, o: LinkOptions): Promise<Link> {
252 const { headers } = found;
253 const log = o.log ?? (() => {});
254 if (o.sessionName && o.sessionName !== headers.user) {
255 return brokenLink(found, { code: 'NAME_MISMATCH', status: 'wrong config for this name', text: `The reporter config file is for "${headers.user}", but this session reports as "${o.sessionName}"${o.nameSource ? `, and "${o.sessionName}" comes from ${o.nameSource}` : ''}. Use the file for "${o.sessionName}", or ${o.nameSource && o.nameIsSetting ? `leave that empty so the session takes the file's name` : 'change the name'}.` });
256 }
257 // An expired certificate is not renewed (#665): the server's reporter port drops it at the handshake. The status line says so from the start.
258 const expires = headers.expires ? Date.parse(headers.expires) : Number.NaN;
259 const expiredOn = Number.isFinite(expires) && expires < o.now() ? headers.expires?.slice(0, 10) : undefined;
260 const expiredNote: Problem | null = expiredOn ? problemFor('CERT_EXPIRED', '', undefined, expiredOn) : null;
261 const node = await findNode($, env);
262 if (!node) {
263 return brokenLink(found, {
264 code: 'NO_NODE',
265 status: 'no node',
266 text: 'Podium needs node to make its secure connection and cannot find it. Install Node.js, put it on the PATH Claude Code starts with, or set PODIUM_NODE to its full path.',
267 });
268 }
269
270 const home = env.home === undefined ? undefined : trimTrailingSlashes(env.home);
271 // The home folder first. The temporary folder is the fallback only where the platform names a private one (TMPDIR); a shared /tmp is never assumed.
272 const dirs = [home ? `${podiumHome(home)}/run` : null, env.tmp ? `${trimTrailingSlashes(env.tmp)}/podium-run` : null];
273 const sessionKey = `${found.path}:${o.now()}`;
274 const socket = dirs.filter((d): d is string => !!d).map((d) => socketPathFor(d, sessionKey)).find((p) => utf8Length(p) < SOCKET_PATH_MAX);
275 if (!socket) {
276 return brokenLink(found, { code: 'SOCKET_PATH', status: 'helper cannot start', text: 'No folder is short enough to hold the helper’s socket (a Unix socket path is at most 104 bytes).' });
277 }
278
279 const helper = `${$.plugin.root}/hooks/helper.mjs`;
280 const serverUrl = trimTrailingSlashes(headers.server);
281 const host = new URL(serverUrl).host;
282
283 let problem: Problem | null = null;
284 let running = false;
285 let starting: Promise<boolean> | null = null;
286 let lastStart = 0;
287 let iterator: AsyncIterator<{ stream: string; text: string }> | null = null;
288 let stopped = false;
289 // The server's clock minus this machine's, from the Date header of the last reply the server itself sent; undefined until one comes.
290 let serverOffsetMs: number | undefined;
291
292 const start = (): Promise<boolean> => {
293 if (starting) return starting;
294 lastStart = o.now();
295 starting = new Promise<boolean>((resolve) => {
296 let settled = false;
297 const settle = (ok: boolean) => {
298 if (!settled) {
299 settled = true;
300 resolve(ok);
301 }
302 };
303 // The environment is cleared of the two variables that turned verification off in the spike (the helper also forces it on).
304 const stream = $.process.spawn({ argv: [node, helper, found.path, socket, serverUrl], env: { NODE_TLS_REJECT_UNAUTHORIZED: '', NODE_OPTIONS: '' } });
305 void (async () => {
306 let said = '';
307 try {
308 iterator = stream[Symbol.asyncIterator]();
309 for (;;) {
310 const next = await iterator.next();
311 if (next.done) break;
312 for (const line of next.value.text.split('\n')) {
313 if (line.trim() === 'ready') {
314 running = true;
315 problem = expiredNote;
316 settle(true);
317 } else if (line.startsWith('error ')) said = line;
318 else if (line.trim()) log(`podium-reporter helper: ${line.trim().slice(0, 200)}`);
319 }
320 }
321 } catch (err) {
322 log(`podium-reporter helper: ${err instanceof Error ? err.message : String(err)}`);
323 }
324 running = false;
325 iterator = null;
326 if (!stopped) problem = problemFromStartup(said, found.path);
327 settle(false);
328 })();
329 // A sleep the engine abandons (the module unloads) rejects: nothing is waiting on it then.
330 void $.clock
331 .sleep(READY_MS)
332 .then(() => {
333 if (!settled) {
334 problem = { code: 'HELPER_SLOW', status: 'helper not ready', text: 'The Podium connection helper did not say it was ready in time.' };
335 settle(false);
336 }
337 })
338 .catch(() => {});
339 }).finally(() => {
340 starting = null;
341 });
342 return starting;
343 };
344
345 // Not awaited: a helper that is slow to start must not hold up session.start. The first request waits for it instead.
346 void start();
347
348 const link: Link = {
349 serverUrl,
350 user: headers.user,
351 configPath: found.path,
352 headers,
353 problem: () => problem,
354 stop: () => {
355 stopped = true;
356 void iterator?.return?.();
357 },
358 fetch: async (url, init) => {
359 if (url !== serverUrl && !url.startsWith(`${serverUrl}/`)) throw new Error('refusing to send to a server other than the one in the reporter config file');
360 if (stopped) throw new Error('the Podium connection helper has been stopped');
361 if (!running) {
362 if (starting) await starting;
363 else if (o.now() - lastStart >= RESTART_MIN_MS) await start();
364 if (!running) throw new Error(problem?.text ?? 'the Podium connection helper is not running');
365 }
366 const res = await $.http.fetch(`http://podium${url.slice(serverUrl.length) || '/'}`, { ...init, socketPath: socket });
367 const code = res.headers?.[HELPER_ERROR_HEADER];
368 if (code) {
369 // An outage has no words of its own: it must not wipe a standing certificate problem.
370 problem = problemFor(code, host, { now: o.now(), serverNow: serverOffsetMs === undefined ? undefined : o.now() + serverOffsetMs }, expiredOn) ?? problem;
371 return res;
372 }
373 // A reply without the helper's error header came from the server and carries its Date (#650).
374 const sent = Date.parse(res.headers?.date ?? '');
375 if (Number.isFinite(sent)) serverOffsetMs = sent - o.now();
376 if (res.status === 401) {
377 // The server read the certificate and turned it away (revoked, expired, not one it issued). A person with a config file has no token to blame (#646).
378 problem = problemFor('CLIENT_CERT_REJECTED', host) ?? problem;
379 } else if (res.ok) {
380 problem = null;
381 }
382 return res;
383 },
384 };
385 return link;
386}
387hooks/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
47export const 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 docs/architecture/, 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}
246