SLOPSHOPPER

podium-reporter

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

guardcommandstatusprompttoolmodel
★ 84v1.3.1MITupdated 2026-10-26eengelking/podium/mod/podium-reporter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · podium-reporter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /podium ⎿ podium-reporter: Podium fleet console: http://127.0.0.1:4317/#/ ⎿ podium-reporter: Connection: not tried yet ⎿ podium-reporter: Versions: reporter 1.3.1 ⎿ podium-reporter: Reports sent: 0, failed: 0, rejected: 0; events queued: 0 ⎿ podium-reporter: Last error: none ⎿ podium-reporter: Session: preview-session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ podium-reporter: podium v1.3.1 ○ connecting
README

<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&amp;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&amp;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&amp;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&amp;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&amp;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.

Podium showing nine simulated Claude Code sessions: running, stale and failing orchestrators, context blowouts, and the activity feed

For every session you see:

  • its model, effort and plan, and the git branch or worktree it works in
  • the agents it launched (subagents and forks), and what each one costs
  • how full each context window is, and how often one blew out
  • why anything failed

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.

Quick start

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.

Documentation

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.

ReadTo
IntroductionWhat Podium is, what it does and does not do, and where to read next
User guideSet Podium up, read the console and use Admin
Running the serverRun 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 modInstall podium-reporter, set its options and see what leaves a session
Orchestrator messagingLet 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 replayRecord 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
GotchasSharp edges that are easy to hit, including why one machine shows several rows on the Reporters page
UI standardsHow the UI looks and is built: tokens, themes, every component with its states, patterns, voice and accessibility. Open the page in a browser
AtlasA short table of every part of Podium and every reporter hook, and where each runs. Open the interactive version in a browser
DemoA script and a paste-in prompt for showing Podium to other people
SpecThe build spec: intended behavior and requirements
ArchitectureHow it was built, where it departs from the spec and why, what was verified, known issues
DecisionsWhy the significant choices were made
ChangelogWhat changed in each release
ContributingHow to propose and land a change, and the tests and checks to run (PRs go to development, not main)
SecurityHow to report a vulnerability (privately)
Code of conductCommunity expectations
CommunityJoin the Discord, and how the server is set up and kept safe
Third-party noticesCredits and licenses for the color theme palettes

Community

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.

Source 23 files
hooks/register.ts 2227 lines
1// 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 does
hooks/build.ts 32 lines
1// 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}
32
hooks/git.ts 210 lines
1// 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}
210
hooks/fileDiff.ts 123 lines
1// 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}
123
hooks/place.ts 95 lines
1// 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}
95
hooks/models.ts 101 lines
1// 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}
101
hooks/report.ts 304 lines
1// 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}
304
hooks/state.ts 957 lines
1// 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}
957
hooks/cron.ts 55 lines
1// 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}
55
hooks/transport.ts 370 lines
1// 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}
370
hooks/tlsLink.ts 387 lines
1// 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}
387
hooks/failures.ts 246 lines
1// 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