SLOPSHOPPER

podium-reporter

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

guardcommandstatusprompttoolmodel
v1.1.0MITupdated 2026-10-18IluvatarLabs/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.1.0 ⎿ 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.1.0 ○ connecting
README

<img src="docs/images/podium-logo.png" alt="Podium" width="480">

A Claude Code session is waiting on you...

Run a few sessions at once and it happens fast. One is stopped at a permission prompt. One has filled its context window. A subagent failed ten minutes ago and nothing told you. The only way to find out is to click through every terminal, and that takes more effort than the work itself.

Podium is a single pane of glass for Claude Code. Whether it's just you or your whole team, every session shows up in one browser tab, so you can see what all of them are doing at a glance. Each session appears within about two seconds of its first prompt and leaves when it ends, with no refresh.

There are other tools like this. Podium started as something I built for myself, because I run several Claude Code sessions at once and was tired of clicking through terminals to find the one that needed me. It turned out to be useful enough to share. I haven't compared it with every similar tool, so if another one fits how you work better, use that one. If you try Podium and something is missing, I'd like to hear about it.

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? docs/USER_GUIDE.md walks through setup and reading the dashboard.

Cost in Podium is an estimate from token counts and a price table. The table starts as Anthropic's list prices and you can change any price in Admin > Model prices. It is never a bill.

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 dashboard a few seconds later. If the server has a token (podiumctl start sets one), pass it: --token "$(podiumctl token)".

To see the people view, give the simulator people with a token each from Admin: --person "Ed E.=<Ed's token>" --person "Priya N.=<Priya's token>" --unnamed. Sessions go to the people in turn, each on a laptop and every third one on a build box; --unnamed keeps a share of sessions with no name, which show under Admin when they send the admin secret with --token.

To serve the built UI and the API without a container, npm run build and then npm run start -w server (http://127.0.0.1:4317). podiumctl, below, does the same thing and manages it for you.

What it costs to run

The more sessions and agents report, the more Podium needs. Every agent adds data that the server stores and sends to the browser, and the browser draws it, so the work grows with the amount of data consumed.

  • Server CPU and memory grow with ingest, the SQLite writes and the state sent to every open client. This load is there whether or not a browser is open.
  • Browser CPU, memory and GPU grow with the rows, charts, animations and updates to draw. This load is per open tab.

As one example, Chrome on an M5 MacBook Pro used about 24% GPU with ten simulated sessions and a hundred agents on screen, against about 15% for a plain page (#81). That is one machine and one browser, not a promise. To measure your own, open a tab and run scripts/gpu-sample.sh (macOS; --help lists its options).

To lower it, keep fewer sessions on screen, leave the tab hidden (animations pause while it is), use wall mode (w), which hides the side panes and top bar so the browser stops drawing them, and close the dashboard when nobody is looking.

Run it as a service (podiumctl)

bin/podiumctl starts, stops and watches the server without a dev setup. It runs Podium either in a Podman container (the default when Podman is installed) or as a standalone Node process on the host, and has no dependencies of its own. Install it on your PATH from the repo with npm link, or run bin/podiumctl <command> (no node in front) or npm run podiumctl --.

git clone https://github.com/eengelking/podium.git && cd podium && npm link
podiumctl start                     # builds the image on first use, then runs it on http://127.0.0.1:4317
podiumctl status                    # mode, state, health, URL, boot setting (exit 3 when stopped)
podiumctl logs -f                   # follow the log
podiumctl restart                   # after changing the config
podiumctl stop
podiumctl enable                    # start on boot / login (launchd on macOS, systemd user unit on Linux)
podiumctl disable
podiumctl start --mode standalone   # no container: needs `podiumctl build --mode standalone` once
podiumctl rm --purge --yes          # remove the container, its data volume and the image

--mode, --port and --bind are saved, so later commands reuse them. Container mode keeps the database in the Podman volume podium-data; standalone keeps it in ~/.podium/data/. By default, container mode publishes the port on 127.0.0.1 only; --bind sets the address it publishes on.

Auth token. The first start generates a random token and saves it as PODIUM_TOKEN in ~/.podium/config.env (mode 0600). Find it any time with podiumctl token; podiumctl token --rotate makes a new one. This is the admin secret: it opens the Admin section (below) and, on a one-person setup, you can also enter it in the mod's token option (see "Report a real session") and in the X-Podium-Token header of the settings hooks below. To run without auth, podiumctl config set PODIUM_TOKEN= (empty on purpose). Admin is switched off then, because nothing would protect it.

Admin and per-person tokens. When several people report to one Podium, open Admin (#/admin, or the button in the top bar's upper right once you are signed in to the console as an Administrator) and sign in with the name Admin and the admin secret. Create a user and Podium makes a token for them, shown once with the settings to paste: their name and the token. A token only works with the name it was made for, so a leaked token alone cannot claim someone else's name. From Admin you can also rotate or revoke a token, put people in groups (a label for arranging the console, nothing more), turn off reporting with the admin secret, and read the admin log. Tokens are stored hashed, so Podium cannot show one again. Reporting with the admin secret keeps working, and those sessions show under the owner Admin. Admin locks after 10 idle minutes, and five wrong names or tokens lock it for 15 minutes. Tokens travel in a header as they do today, so a server reachable over a network needs a trusted network or a TLS proxy.

Several people on one Podium. The server listens on 127.0.0.1 by default, so only your own machine can reach it. To let other people report to it:

  1. Restart it on an address the others can reach: podiumctl restart --bind 0.0.0.0 (all interfaces), or the one interface they use. The container then publishes the port on that address too. Allow the port (4317 by default) in the firewall for the people who need it and no one else. The server refuses a request whose Host is a name it does not expect (see "A page on another site" below). With 0.0.0.0 an address typed in the bar (http://192.168.1.5:4317) works as is. To reach it by a name, list the name: podiumctl config set PODIUM_ALLOWED_HOSTS=box.lan and podiumctl restart.
  2. Make each person a token in Admin. They set serverUrl to http://<your host>:4317, token to their token and userName to the name you gave them (see "Report a real session").
  3. Once a session reports with a person's token, the console groups the wall by person (see "People" in docs/USER_GUIDE.md).

A page on another site. A web page can point its own name at 127.0.0.1 after it has loaded (DNS rebinding) and then read the console's open routes as if they were its own. So the server answers 403 to any request whose Host is not 127.0.0.1, localhost, [::1], the --bind address or a name in PODIUM_ALLOWED_HOSTS (comma-separated; the port is not compared). A server bound to 0.0.0.0 or :: also takes any IP address, which a page cannot rebind. The refusal is logged as http.host_refused with the host and nothing else.

Know what that costs. Reading the console needs no token, so anyone who can reach the port sees every session: names, machines, paths, branches, plan steps, commands from failed calls and the excerpts listed under "What leaves the session". Tokens and reports cross the network in plain HTTP, so use a network you trust or put a TLS proxy in front. Anyone with a valid token can delete any session. The name shown on a session comes from the token that sent it: with the shared admin secret every session shows as Admin, and with the server's auth turned off every session is Unassigned, because then anyone could claim any name. Per-person read permissions are not part of Podium.

Other settings (STALE_AFTER_MS, RETAIN_*, the PODIUM_LOG_* logging settings, and so on, see the table below) are set with podiumctl config set KEY=VALUE and applied on restart. "Logs" in the user guide covers what the server logs, sending it to VictoriaLogs, and pointing an agent at it. Set PODIUMCTL_HOME to keep the files somewhere other than ~/.podium. podiumctl --help lists everything.

Start on boot: in container mode the boot service runs podiumctl start, which also starts the Podman machine on macOS; in standalone mode it runs the server in the foreground and restarts it if it crashes. On Linux, loginctl enable-linger $USER makes the user unit start before you log in. (The Linux unit has run on Bazzite, but not yet through a reboot.)

Report a real session

The reporter is a Claude Code mod in mod/podium-reporter/. Install it from the eengelking marketplace, in Claude Code:

/plugin marketplace add eengelking/skills
/plugin install podium-reporter@eengelking-marketplace

If the server uses a token (podiumctl start generates one), enter it with /plugin configure podium-reporter@eengelking-marketplace. On your own machine that is the admin secret (podiumctl token). If someone runs the server for a group, they make you a token in Admin: enter it as token, and your name as userName. The token option is secret, so it has no row in the /config menu. Without a token the server accepts, or with a token that does not belong to your name, the reporter gets HTTP 401, logs it once, and tries again about once a minute. /podium and the status line say so.

To work on the mod itself, load it from a checkout for one session instead:

claude --plugin-dir /path/to/podium/mod/podium-reporter

The session appears in Podium once you send its first prompt. A session that has done nothing yet is kept off the wall, because Claude Code's background daemon also runs idle twins and spares that report too (see "Sessions that have not done anything yet" in docs/USER_GUIDE.md). In the session, /podium prints the dashboard link, the connection state and report counts, and /podium name <text> gives the session a display name. The status line under the prompt reads podium vX.Y.Z ● N live (N is the cards on the wall, so a session that has not run yet reads 0), or podium vX.Y.Z ○ offline while the server is unreachable (reports queue up and go out when it is back). When the mod and the server are on versions that do not match, it ends in · update mod or · upgrade server, and in · update available when the server supports a newer mod than yours. To update the mod, run claude plugin update podium-reporter@eengelking-marketplace and then /reload-plugins in the session (see "Updating the mod" in docs/USER_GUIDE.md). Claude Code puts a ⚠ and the mod's name in front of the line; that is how it draws every mod's status line, not a Podium warning (see docs/GOTCHAS.md). Effort is read from each model request, so a change made with /effort shows after the session's next prompt, not when you pick it.

Set the mod's options with /plugin configure podium-reporter@eengelking-marketplace, or in ~/.claude/settings.json under pluginConfigs, podium-reporter@eengelking-marketplace, options (podium-reporter for a copy loaded with --plugin-dir). Claude Code keeps token in its secure storage instead. Everything except token also has a row in the /config menu.

OptionDefaultMeaning
serverUrlhttp://127.0.0.1:4317Where reports go
tokenemptySent as X-Podium-Token: a per-person token from Admin, or the admin secret (PODIUM_TOKEN) on a one-person setup
userNameemptyThe name your token was made for. The server accepts a report only when the token and this name match. Leave empty when you report with the admin secret. Nothing that names you or your machine is sent until you set it; with it set, reports also carry this machine's name (see below)
flushIntervalMs1000Minimum time between reports while something changes
heartbeatMs5000Report at least this often
sessionNameemptyDisplay name override
showStatustrueShow the status line entry
fullPromptsfalseSend each agent's whole task prompt and final answer (up to 20,000 characters each, secrets redacted) so the console can open them in a modal. Off by default: only the first 400 characters leave the session. Everyone who can open the console can read them
activityFeedfalseSend an agent's recent activity: while someone has that agent's panel open in the console, its last 20 tool calls (the tool's name and a short summary of its arguments) and the text it writes between them, each cut to 300 characters with secrets redacted. Nothing is sent for an agent nobody is watching, and the server keeps it in memory only. The orchestrator's own loop works the same way. Everyone who can open the console can watch. The server's Admin can also switch the feed off for everyone under Admin > Settings > Feature flags (on by default); while it is off the server asks no mod for it
respondoffRespond from Podium: what this session accepts from Podium, sent by its owner or an Admin. off (nothing), questions (answers to questions, and new messages while idle), questions-and-plans (the above, plus approving or rejecting a plan), everything (the above, plus allowing or denying a tool, allow once only, and /clear, /compact). It needs activityFeed on too, and the server's Respond from Podium switch in Admin > Settings > Feature flags (off by default)
explainFailuresfalseAsk a small model (Haiku, low effort) for a two-sentence explanation of each failed agent, from the redacted failure record only. Costs a little; off by default

What leaves the session

Only to the server URL above, and only this:

  • the session's id, working directory, repository name, git branch and counts (ahead, behind, modified), model, effort, context fill, cost, token counts (also totalled per model, and per model for each agent, so the server can price them) and rate-limit percentages;
  • the paths of the files the session changed in its repository, each with its status and its added and removed line counts (at most 200 files; never file contents or diff text);
  • the plan's step titles (from TaskCreate, TaskUpdate or TodoWrite; a session that does not use these tools shows "No plan reported. Agents were launched ad hoc.", and plan mode does not make a plan appear, see Gotchas);
  • for the orchestrator's own loop: how many model requests it made, the name of every tool it used with how many times, and the skills it loaded;
  • per agent: type, description, model, effort, status, token counts, skills it loaded, the name of every tool it used with how many times, the first 400 characters of its task prompt and of its final answer, or the whole of each (up to 20,000 characters) only when you have turned on fullPrompts;
  • for a failed call: the tool, a one-line summary of the call (a Bash command, or a tool and a file path), its exit code and the last 12 lines of its output;
  • when a session is idle on background work or a scheduled wake-up: each task's type (shell, agent, monitor, workflow, wake-up), a short description of at most 120 characters, its status and when the wake-up fires. A shell's command is never sent;
  • short activity lines ("spawned Explore (Haiku low): Map call sites");
  • whether activityFeed is on, and only when you have turned it on, the server's Admin has not turned the feed off, and someone is watching an agent's (or the orchestrator's) panel right now: that loop's last 20 tool calls, each the tool's name and a short summary of its arguments (a Bash command, a file path, a search pattern), and the text the model wrote between them, each cut to 300 characters. The model's thinking is never sent. When nobody is watching, nothing of it is read or sent, and the server forgets it soon after the panel closes;
  • only when you have set respond (and activityFeed), and the server takes answers: what the session accepts, and while it waits on you, the open question (its questions, options and previews), plan (its text and file path) or tool call (the tool and its arguments), each cut to fixed limits and redacted, so its owner can answer from the console. It is shown only to the session's owner or an Admin, and the server keeps it in memory only. After a /clear, the new session also names the one it continues, so History can link them;
  • only when you have set userName: that name, and which machine the session runs on: its short host name (what hostname prints, up to the first dot) and a random 12-character id the mod makes the first time and keeps in one file (~/.claude/podium-machine-id, or under CLAUDE_CONFIG_DIR when that is set), so two machines with the same host name stay apart and every copy of the mod on a machine reports the same id. The id is random, not read from the hardware, and the operating system user name and git config are never read.

Before anything is sent, text is cut to those limits and anything that looks like a secret is replaced with [redacted]: sk-ant-... keys, ghp_... tokens, AKIA... keys, Bearer ... values, and the values after password=, token=, secret=, apikey= and api_key=. Podium never reads transcripts or files, sends a whole prompt or answer only for fullPrompts, and sends what an agent is doing only for activityFeed, while someone watches.

Sessions without the mod

Claude Code's settings hooks can report a reduced view of sessions that were started without the mod: the session, its working directory and model, agents by type, and waiting or ended states. They carry no token counts, so context, cost and the plan stay empty, and Podium marks those sessions "settings hooks". Add this to ~/.claude/settings.json (or a project's .claude/settings.json):

{
  "hooks": {
    "SessionStart":     [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "Stop":             [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "SubagentStart":    [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "SubagentStop":     [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "Notification":     [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }],
    "SessionEnd":       [{ "hooks": [{ "type": "command", "command": "curl -s -m 2 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:4317/api/fleet/hook >/dev/null || true" }] }]
  }
}

Each hook posts the payload Claude Code gives it on stdin and always exits 0, so a stopped server never gets in the session's way. When the server sets PODIUM_TOKEN, add -H 'X-Podium-Token: <token>'.

A session that the mod reports is never changed by hooks: the server acknowledges a hook for it and drops it, so enabling both is harmless. Settings hooks send no heartbeat, so a hook-only session is allowed to stay silent for HOOK_STALE_AFTER_MS (10 minutes) before it shows as stale. A finishing subagent does not mark the session as waiting on you; the Stop hook does, unless it lists background work or a scheduled wake-up still in flight, in which case the session shows what it is waiting on instead (Podium reads the hook's background_tasks and session_crons, never a shell's command).

Server settings

Environment variables (see .env.example). Under podiumctl you set them with podiumctl config set, and it chooses PORT, HOST and PODIUM_DB itself.

VariableDefaultMeaning
PORT4317Server port (the Vite proxy follows it)
HOST127.0.0.1Bind address. Podium is for one machine; leave it on localhost
PODIUM_DBdata/podium.sqliteSQLite database file (podiumctl uses /data/podium.sqlite in the container and ~/.podium/data/podium.sqlite standalone)
PODIUM_HOST_HOMEthe server's own homeHome directory to show as ~ in session paths. podiumctl sets it to your home in the container, where the server's own home is /home/node. Ignored unless it is an absolute path
PODIUM_TOKENemptyThe admin secret. When set, POST /api/fleet/report, POST /api/fleet/hook and DELETE require X-Podium-Token (this or a person's token), and it opens Admin
TZthe server's own zoneTimezone whose midnight starts "today" for "Est. cost today". podiumctl gives the container your host's zone; set it with podiumctl config set TZ=Europe/Berlin to override
STALE_AFTER_MS15000A session not heard from this long shows as stale

| `H

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