Always-on status line and a pane for this session's Bash calls, background tasks and Codex/Pi/Devin/OpenCodeReview runs: elapsed time, output freshness…

<img alt="agent-shell-watch: see at a glance that your shells and delegated agents are moving" src=".github/assets/banner-light.svg" width="100%">

<sub>Recorded with vhs from demo/demo.tape; codex, pi and devin are stand-ins from demo/bin with synthetic sessions and limits, so the recording is free and repeatable.</sub>
A Claude Code mod that watches the agent runs you delegate through the shell — Codex, Pi, Devin and OpenCodeReview (ocr) — live in the status line and a pane. scope: all widens the watch to every Bash call and background task of the session.
agent-shell-watch shows at a glance that the work is moving: time ticking, output fresh, nothing failed.
/shell-watch pane, grouped by who made the calls (main and each subagent), with every call's state, command and last output line.codex, pi, devin, ocr, with their live output file and their guard verdict (DONE, RATE_LIMIT, STALLED, BUSY).quietMin / hangMin minutes is flagged.TaskStop.This repository is its own marketplace:
/plugin marketplace add apolenkov/agent-shell-watch
/plugin install agent-shell-watch@agent-shell-watch
It is also listed, with its sibling mods, in the agent-mods marketplace:
/plugin marketplace add apolenkov/agent-mods
/plugin install agent-shell-watch@agent-mods
Requirements: Claude Code 2.1.287 or later (mods are on by default), and tail on PATH.
Shown while an agent run is live, or one failed in the last 2 minutes (scope: all counts every Bash call the same way). The most urgent call leads (hung, failed, quiet, running; a runner before a plain shell), the rest are counted as +N hung, +N failed, +N quiet, +N bg, +N running. The leading call says whose it is: main or the subagent's type. Claude Code labels the line with the plugin's name:
agent-shell-watch: ◐ main · codex · Review diff 2:13 · output 4s ago · › applying patch src/a.ts · +1 bg
agent-shell-watch: ⚠ quiet 6m pi-runner · pi · Fix flaky test 7:40 · +2 running
agent-shell-watch: ✗ general-purpose · devin · Review spec RATE_LIMIT 1790000000
agent-shell-watch: ⚠ hung 12m main · Wait loop · +1 failed (scope: all)
[ f fold ] [ c clear ] [ q close ]
[ 1 ▾ ] ⚠ main · 3 calls · 1 live
[ 2 ▸ ] ⚠ 9:12 hung · no output · 9m Wait loop [ s stop ]
bg · sleep 3600
[ 3 ▸ ] ● 0:01 exit 0 List files
ls -1 | head -3
› README.md
[ 4 ▾ ] ✗ general-purpose: Review spec · 2 calls
[ 5 ▸ ] ✗ 0:03 RATE_LIMIT 1790000000 devin · Review spec
devin -p 'review the spec' 2>&1 | tee /tmp/d.log
✗ RATE_LIMIT 1790000000
[ 6 ▸ ] ● Explore: Find callers · 4 calls · › 12 matches
1–9 open · f fold · c clear · q close · Esc → prompt
Calls are grouped by who made them: main (this session's own loop) and each subagent (type: description; one started by another subagent ends ↳ <its parent>). A runner is a row in the group of the agent that ran it. Each group's header carries its rollup: the worst live call (hung, quiet, running), a failure in the last 2 minutes, the agent's own status (✗ when it failed), or a dim ◌ idle for an agent at rest. The most urgent group comes first; finished groups start folded, and a folded header shows its leading call's last line.
Inside a group, live calls come first, then failures, finished calls and denied ones, the newest first in each; what does not fit the pane's height becomes a dim +N older line. Opened above the prompt, the pane asks for the rows it needs (6 to 30); short of room, rows shrink to one line, but a running runner keeps its last output line. A row is its state before the label (a narrow pane cuts the label, never the time or outcome), its command (bg · for a background one), and its last output line (a failure's last error in red, a denial's reason, dim).
| Command / key | What it does |
|---|---|
/shell-watch | Opens the pane and gives it the keyboard (refocuses it if already open) |
/shell-watch runners | Shows only the external agent CLIs (Pi, Devin, Codex), flat: the verdict and who started each one |
/shell-watch agents | Back to the calls grouped by agent (groups works too); a bare /shell-watch keeps the view you left |
/shell-watch clear | Forgets finished calls |
/shell-watch stop | Closes the pane |
1–9 | Folds or opens a group (on its header) or expands a row: full command, last 40 lines, stderr, output and watch files |
f | Folds every group, or opens them all when all are folded |
r | Flips between the agents view and the runners view |
c | Clears finished calls |
s | Stops the running background call (when there is one) with TaskStop |
q | Closes the pane |
| Tab / Enter | Walks the buttons / presses one |
| Esc | Hands the keys back to the prompt; the pane stays |
The first line's [ 1 ▾ ] holds the focus.
/shell-watch runners (or r) drops the groups and lists only the calls of Pi, Devin, Codex and ocr, most urgent first: a summary line (runners · N · k live · f failed), then one row per run with its verdict (DONE 0, RATE_LIMIT …, WAITING …, FAILED …, from the runner guard) and, after its label, who started it (← main, ← general-purpose: review spec). Rows expand and stop as in the agents view. The view is remembered between sessions.
Under the summary a limits: line says whether each executor has room (limits: devin ok · pi ok · codex 100% until Sat 16:48). It reads ~/.local/state/executor-limits/<name> (one epoch in seconds) and, for Codex, the end of the newest rollout in ~/.codex/sessions (token_count's rate_limits.primary): an executor is blocked while its epoch is ahead, or Codex is at 100% with the window's reset ahead. Damaged or missing data reads as ok. The files are read only while the runners view is open or a runner is live, at most every 30 s; the status line adds ⏳ codex limit while a block is active.
After ← main a row says what the run spent (← main · 23k tok · $0.002). The numbers come from the run's own session file, found by the start time in its name (the call's cwd is unknown): Pi sums message.usage over the last 256 KiB of ~/.pi/agent/sessions (tokens and dollars; ≥ when the file is larger), Codex shows total_tokens of the last token_count in ~/.codex/sessions (cached input included, no cost). Devin keeps no usage, and a run whose file is not the only Pi one started in its window (parallel runners) is — too; with several Codex rollouts in the window the one started nearest to the call is taken. They are read only while the runners view is open, for the rows shown, at most every 30 s per run; a narrow pane drops them before it cuts by.
| Glyph | Meaning |
|---|---|
◐ | running |
● | done |
✗ | failed |
⚠ | quiet or hung |
○ | stopped |
○ denied | (dim) refused before it ran (a permission rule, a hook, you); never reaches the status line |
○ no match | (dim) a search or test (grep, rg, ag, ack, git grep, diff, test/[, cmp, pgrep) ended a command with exit 1; never reaches the status line |
— | the time of a call rebuilt from the transcript without a duration |
A runner's outcome is its guard verdict (DONE n, RATE_LIMIT epoch, STALLED reason, BUSY pid file), else the exit code.
Set in /config.
| Option | Default | Meaning |
|---|---|---|
columns | 52 | Width asked for the docked pane |
openOnStart | false | Open the pane at start until you have opened or closed it |
maxCalls | 50 | Calls kept, the oldest finished dropped first |
quietMin | 5 | Minutes without new output before a running call is quiet |
hangMin | 10 | Minutes without new output before a running call is hung |
statusLine | true | Show the status line |
scope | runners | runners: only delegated agent runs (Codex, Pi, Devin, ocr, and any agent-runner-guard --watch-file call). all: every Bash call and background task too |
| Event / timer | What agent-shell-watch does |
|---|---|
session.start | Registers /shell-watch, rebuilds calls made before the mod loaded, starts the tick and poll |
tool.call (Bash) | Records the call (label from the input description; under scope: runners only an agent run — a runner or a --watch-file call — is kept), awaits it, records exit and output |
session.append | A <task-notification> row settles its background call (status, exit code) |
| tick, every 1 s | Advances elapsed time and redraws the status line |
| poll, every 2 s | fs.stat of the watch or output file → freshness, quiet, hung; tail -n 40 for runner rows |
ui.render (Pane) | Draws the pane; the selected row's tail is read once when it is selected |
ui.open | Rebuilds missed calls from $.session.messages() (main loop and running agents) |
ui.close, command.run | Opens and closes the pane; the choice is kept in $.store for the next session |
A runner is a command whose executable (past NAME=value and cd … &&, after a guard's --, or inside bash -c '…' / sh -c "…") is codex, pi, devin or ocr. Its live output is the guard's --watch-file, its | tee [-a] /path, or its absolute stdout redirect (> /path/run.log); with no description its label is the first words of its prompt. The verdict is read from the Bash output once the run ends. agent-shell-watch sees the command as the model wrote it, before a PreToolUse settings hook wraps it, and recognises both forms. A TaskStop (the model's or the pane's) settles its call as stopped; an interrupted call is stopped, not failed. Every hook passes its event on unchanged.
No network, no telemetry. It reads only this session's transcript and the output and watch files of its own Bash calls, keeps its list in the session's memory, and stores one value across sessions: whether the pane was left open. See SECURITY.md.
See CONTRIBUTING.md: npm ci, then npm run check; try it live with claude --plugin-dir .. Releases are cut by release-please; the history before 0.2.0 comes from agent-mods. Questions: SUPPORT.md.
Live checks run locally: npm run eval (headless, on your Claude login; not in CI).
MIT. engine-types/claude-code.d.ts is © Anthropic PBC and not covered by the MIT license; see engine-types/NOTICE.md.
hooks/register.tsx 293 lines1/**
2 * agent-shell-watch: an always-on status line and a pane for this session's Bash
3 * calls, background tasks and runner (Codex, Pi, Devin, OpenCodeReview) runs.
4 * Every hook observes and passes its event on unchanged. The poller lives
5 * here: the engine follows `$` only into functions of the same file.
6 */
7import type { AgentInfo, EngineInterface, Register } from "claude-code";
8import { atom, read, update } from "claude-code";
9
10import type { ShellAgents, ShellCall, ShellView } from "../types";
11import { onLimitsStart } from "./limits.ts";
12import { backfilled, merged, type MessageRow } from "./model/backfill.ts";
13import { classified, hasLive, polled, tailed } from "./model/calls.ts";
14import { type Config, configOf } from "./model/config.ts";
15import { statusLineOf } from "./model/format.ts";
16import { agentTableOf } from "./model/groups.ts";
17import { blockedOf, NO_LIMITS } from "./model/limits.ts";
18import { rowsWantedFor } from "./model/pane-items.ts";
19import {
20 isRepairDue,
21 isTailDue,
22 liveAgentsOf,
23 tailPathOf,
24 watchedOf,
25} from "./model/poll.ts";
26import { onRender } from "./pane.tsx";
27import { onClose, onCommand, PANE } from "./slash-command.ts";
28import { onAppend, onToolCall } from "./track.ts";
29
30const NO_CALLS: readonly ShellCall[] = [];
31const NO_AGENTS: ShellAgents = {};
32const callsAtom = atom(
33 { plugin: "agent-shell-watch", key: "calls" } as const,
34 NO_CALLS,
35);
36const NO_IDS: readonly string[] = [];
37// The ids `clear` forgot, so a backfill does not bring them back.
38const clearedAtom = atom(
39 { plugin: "agent-shell-watch", key: "cleared" } as const,
40 NO_IDS,
41);
42
43const configAtom = atom(
44 { plugin: "agent-shell-watch", key: "config" } as const,
45 configOf({}),
46);
47const nowAtom = atom({ plugin: "agent-shell-watch", key: "now" } as const, 0);
48const agentsAtom = atom(
49 { plugin: "agent-shell-watch", key: "agentInfo" } as const,
50 NO_AGENTS,
51);
52const NO_FOLDS: Readonly<Record<string, boolean>> = {};
53const foldsAtom = atom(
54 { plugin: "agent-shell-watch", key: "folds" } as const,
55 NO_FOLDS,
56);
57const viewAtom = atom(
58 { plugin: "agent-shell-watch", key: "view" } as const,
59 "agents" as ShellView,
60);
61const limitsAtom = atom(
62 { plugin: "agent-shell-watch", key: "limits" } as const,
63 NO_LIMITS,
64);
65const openAtom = atom(
66 { plugin: "agent-shell-watch", key: "isOpen" } as const,
67 false,
68);
69
70// When a hung call last sent the poller to the transcripts.
71const lookAtom = atom({ plugin: "agent-shell-watch", key: "look" } as const, 0);
72const TICK_MS = 1000;
73const POLL_MS = 2000;
74const TAIL_LINES = "40";
75
76type Engine = Readonly<EngineInterface>;
77
78// undefined when the list fails, so the last snapshot stays.
79const agentsOf = async (
80 $: Engine,
81): Promise<readonly AgentInfo[] | undefined> => {
82 try {
83 return await $.agent.list();
84 } catch {
85 return;
86 }
87};
88
89// The list's entries win: they carry the agents' fresh status.
90const refreshAgents = async (
91 $: Engine,
92 listed: readonly AgentInfo[] | undefined,
93): Promise<void> => {
94 const table = agentTableOf(listed ?? []);
95 await update($, agentsAtom, (known) => ({ ...known, ...table }));
96};
97
98const tick = async ($: Engine, config: Config): Promise<void> => {
99 const now = await $.clock.now();
100 const calls = await read($, callsAtom);
101 if (hasLive(calls)) {
102 await update($, nowAtom, () => now);
103 }
104 const status = statusLineOf(calls, now, {
105 agents: await read($, agentsAtom),
106 blocked: blockedOf(await read($, limitsAtom), now),
107 });
108 $.ui.status(config.statusLine ? status : undefined);
109};
110
111const tailOf = async ($: Engine, path: string): Promise<string | undefined> => {
112 try {
113 const ran = await $.process.run(["tail", "-n", TAIL_LINES, path]);
114 return ran.exitCode === 0 ? ran.stdout : undefined;
115 } catch {
116 return;
117 }
118};
119
120const statOf = async (
121 $: Engine,
122 path: string,
123): Promise<{ size: number; mtimeMs: number } | undefined> => {
124 try {
125 return await $.fs.stat(path);
126 } catch {
127 // A runner's watch file may not exist yet: silence, not failure.
128 return;
129 }
130};
131
132const pollCall = async (
133 $: Engine,
134 call: ShellCall,
135 isTailWanted: boolean,
136): Promise<void> => {
137 const stat = await statOf($, call.watchPath ?? call.outputPath ?? "");
138 const isNew = stat !== undefined && stat.size !== call.outputBytes;
139 const text = isTailDue(call, { isNew, isWanted: isTailWanted })
140 ? await tailOf($, tailPathOf(call) ?? "")
141 : undefined;
142 const now = await $.clock.now();
143 await update($, callsAtom, (calls) =>
144 calls.map((one) => {
145 const fresh =
146 stat !== undefined && one.id === call.id ? polled(one, stat, now) : one;
147 return text !== undefined && one.id === call.id
148 ? tailed(fresh, text)
149 : fresh;
150 }),
151 );
152};
153
154const poll = async ($: Engine, config: Config): Promise<void> => {
155 // Live rows are always drawn, so an open pane keeps each one's tail fresh.
156 const isOpen = await read($, openAtom);
157 const calls = await read($, callsAtom);
158 const watched = watchedOf(calls);
159 await Promise.all(
160 watched.map(async (call) =>
161 pollCall($, call, call.runner !== undefined || isOpen),
162 ),
163 );
164 const now = await $.clock.now();
165 await update($, callsAtom, (calls) =>
166 calls.map((call) => classified(call, now, config.limits)),
167 );
168 // A hung call may have ended unseen: the transcript says so.
169 const last = await read($, lookAtom);
170 if (isRepairDue(await read($, callsAtom), now, last)) {
171 await update($, lookAtom, () => now);
172 await backfill($, config);
173 }
174 // Subagents' statuses feed their groups' rollups.
175 if (calls.some((call) => call.agentId !== undefined)) {
176 await refreshAgents($, await agentsOf($));
177 }
178 // The status line shows what this poll found, not only the next tick.
179 await tick($, config);
180};
181
182// The person's last choice outlives the session in $.store; openOnStart is
183// the default until they have made one.
184const restore = async ($: Engine, config: Config): Promise<void> => {
185 const view = await $.store.get("view");
186 if (view === "agents" || view === "runners") {
187 await update($, viewAtom, () => view);
188 }
189 const stored = await $.store.get("paneOpen");
190 const isOpen = typeof stored === "boolean" ? stored : config.openOnStart;
191 if (!isOpen) {
192 return;
193 }
194 await $.ui.open({
195 id: PANE,
196 title: "shell-watch",
197 columns: config.columns,
198 rows: rowsWantedFor({
199 view: await read($, viewAtom),
200 calls: await read($, callsAtom),
201 agents: await read($, agentsAtom),
202 folds: await read($, foldsAtom),
203 now: await $.clock.now(),
204 }),
205 });
206 await update($, openAtom, () => true);
207};
208
209const rowsOf = async (
210 $: Engine,
211 agentId?: string,
212): Promise<readonly MessageRow[]> => {
213 try {
214 const rows =
215 agentId === undefined
216 ? await $.session.messages()
217 : await $.session.messages({ agentId });
218 return Array.isArray(rows) ? rows : [];
219 } catch {
220 return [];
221 }
222};
223
224// Calls made before the mod loaded, or whose ending it missed: rebuilt from
225// the main transcript and each agent's that runs or holds a running call.
226const backfill = async ($: Engine, config: Config): Promise<void> => {
227 const now = await $.clock.now();
228 const listed = await agentsOf($);
229 await refreshAgents($, listed);
230 const holders = liveAgentsOf(await read($, callsAtom));
231 const agents = (listed ?? []).filter(
232 (one) => one.status === "running" || holders.has(one.id),
233 );
234 const main = backfilled(await rowsOf($), undefined, now);
235 const subs = await Promise.all(
236 agents.map(async (agent) =>
237 backfilled(await rowsOf($, agent.id), agent.id, now),
238 ),
239 );
240 const cleared = await read($, clearedAtom);
241 await update($, callsAtom, (calls) =>
242 merged(calls, [...main, ...subs.flat()], {
243 max: config.maxCalls,
244 cleared,
245 scope: config.scope,
246 }),
247 );
248};
249
250/**
251 * Wires agent-shell-watch's hooks.
252 * @param on the registrar
253 * @param options the `userConfig` values
254 */
255export const register: Register = (on, options) => {
256 const config = configOf(options);
257 // session.start fires again on a hot reload, which dropped the timers:
258 // the poller restarts here and picks up the calls the state still holds.
259 on("session.start", async ($, e, next) => {
260 const started = await next(e);
261 await update($, configAtom, () => config);
262 await $.command.register({
263 name: PANE,
264 description:
265 "Live agent runs (Codex, Pi, Devin, ocr); scope: all adds every Bash call (runners|agents|clear|stop)",
266 argumentHint: "[runners|agents|clear|stop]",
267 immediate: true,
268 });
269 $.clock.every(TICK_MS, () => {
270 void tick($, config);
271 });
272 $.clock.every(POLL_MS, () => {
273 void poll($, config);
274 });
275 await backfill($, config);
276 await restore($, config);
277 return started;
278 });
279 on("session.start", { isInteractive: true }, onLimitsStart);
280 on("tool.call", onToolCall);
281 on("session.append", onAppend);
282 on("command.run", { command: "shell-watch" }, onCommand);
283 on("ui.close", onClose);
284 // Opening the pane (the command, a restore) picks up what was missed.
285 on("ui.open", async ($, e, next) => {
286 if (e.id === PANE) {
287 await backfill($, config);
288 }
289 return next(e);
290 });
291 on("ui.render", { component: "Pane", requestId: "shell-watch" }, onRender);
292};
293hooks/limits.ts 294 lines1/**
2 * The executors' subscription limits and the runners' tokens and cost: read
3 * here, in the file that holds `$` (the engine follows `$` only into functions
4 * of the same file), on a timer of its own started by `session.start`. Limits
5 * only while the pane shows the runners view or a runner is live, and at most
6 * every 30 s (`readAt` in the `limits` atom); usage only while the pane shows
7 * the runners view, for the runners it shows, at most every 30 s per call
8 * (`readAt` in the `usage` atom). What the data means is `model/limits.ts` and
9 * `model/usage.ts`.
10 */
11import type {
12 EngineInterface,
13 Next,
14 SessionStartInput,
15 SessionStartResult,
16} from "claude-code";
17import { atom, read, update } from "claude-code";
18
19import type {
20 CallUsage,
21 ShellCall,
22 ShellLimits,
23 ShellUsage,
24 ShellView,
25} from "../types";
26import {
27 hasPrimary,
28 isLimitsDue,
29 LIMIT_NAMES,
30 limitPathOf,
31 limitsOf,
32 type Listing,
33 newestRolloutsOf,
34 NO_LIMITS,
35 sessionDirectoriesOf,
36 TAIL_BYTES,
37} from "./model/limits.ts";
38import { shownRunnerCallsOf } from "./model/pane-items.ts";
39import {
40 CODEX_TAIL_BYTES,
41 codexFilesOf,
42 dueUsageOf,
43 type Found,
44 keptWhenEmpty,
45 PI_TAIL_BYTES,
46 pickSession,
47 piDirectoriesOf,
48 piFilesOf,
49 type SessionFile,
50 usageFrom,
51 windowOf,
52} from "./model/usage.ts";
53
54const NO_CALLS: readonly ShellCall[] = [];
55const callsAtom = atom(
56 { plugin: "agent-shell-watch", key: "calls" } as const,
57 NO_CALLS,
58);
59const viewAtom = atom(
60 { plugin: "agent-shell-watch", key: "view" } as const,
61 "agents" as ShellView,
62);
63const openAtom = atom(
64 { plugin: "agent-shell-watch", key: "isOpen" } as const,
65 false,
66);
67const limitsAtom = atom(
68 { plugin: "agent-shell-watch", key: "limits" } as const,
69 NO_LIMITS,
70);
71
72const NO_USAGE: ShellUsage = {};
73const usageAtom = atom(
74 { plugin: "agent-shell-watch", key: "usage" } as const,
75 NO_USAGE,
76);
77
78const CHECK_MS = 2000;
79const SLACK_MS = 2000;
80const ROLLOUTS_LOOKED_AT = 3;
81
82type Engine = Readonly<EngineInterface>;
83
84// A missing file or a failed call is "nothing found", not an error.
85const attempt = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
86 try {
87 return await run();
88 } catch {
89 return undefined;
90 }
91};
92
93// A session file is 2 to 10 MB and `$.fs.read` stops at 4 MiB: only its end.
94const endOf = async (
95 $: Engine,
96 path: string,
97 bytes: string,
98): Promise<string | undefined> => {
99 const ran = await attempt(async () =>
100 $.process.run(["tail", "-c", bytes, path]),
101 );
102 return ran?.exitCode === 0 ? ran.stdout : undefined;
103};
104
105const listingsOf = async (
106 $: Engine,
107 directories: readonly string[],
108): Promise<readonly Listing[]> =>
109 Promise.all(
110 directories.map(async (directory) => ({
111 directory,
112 entries: (await attempt(async () => $.fs.list(directory))) ?? [],
113 })),
114 );
115
116const readLimits = async (
117 $: Engine,
118 home: string,
119 now: number,
120): Promise<ShellLimits["by"]> => {
121 const texts = await Promise.all(
122 LIMIT_NAMES.map(async (name) =>
123 attempt(async () => $.fs.read(limitPathOf(home, name))),
124 ),
125 );
126 const listings = await listingsOf($, sessionDirectoriesOf(home, now));
127 // A run stopped at a spend cap leaves a rollout with no `primary`: the
128 // first of the newest few that has one speaks.
129 const tails = await Promise.all(
130 newestRolloutsOf(ROLLOUTS_LOOKED_AT, listings).map(async (path) =>
131 endOf($, path, TAIL_BYTES),
132 ),
133 );
134 const rollout = tails.find((tail) => tail !== undefined && hasPrimary(tail));
135 return limitsOf({ texts, rollout }, now);
136};
137
138const refreshLimits = async ($: Engine): Promise<void> => {
139 const now = await $.clock.now();
140 const { readAt } = await read($, limitsAtom);
141 const isDue = isLimitsDue({
142 view: await read($, viewAtom),
143 isOpen: await read($, openAtom),
144 calls: await read($, callsAtom),
145 readAt,
146 now,
147 });
148 const home = isDue ? await attempt(async () => $.env.get("HOME")) : undefined;
149 if (home === undefined || home === "") {
150 return;
151 }
152 // Stamped first: a slow read is not started twice.
153 await update($, limitsAtom, (known) => ({ ...known, readAt: now }));
154 const by = await readLimits($, home, now);
155 await update($, limitsAtom, () => ({ readAt: now, by }));
156};
157
158const mtimeOf = async ($: Engine, path: string): Promise<number> => {
159 const stat = await attempt(async () => $.fs.stat(path));
160 return stat?.mtimeMs ?? 0;
161};
162
163// Pi keeps a directory per cwd, which a call does not know: list those
164// touched since the earliest due call began, then the files in them.
165const piFilesIn = async (
166 $: Engine,
167 home: string,
168 since: number,
169): Promise<readonly SessionFile[]> => {
170 const root = `${home}/.pi/agent/sessions`;
171 const [top] = await listingsOf($, [root]);
172 // `$.fs.list` has mtimeMs 0 for a directory: `$.fs.stat` has the real one.
173 const stats = await Promise.all(
174 (top?.entries ?? [])
175 .filter((entry) => entry.kind === "dir")
176 .map(async ({ name }) => ({
177 name,
178 mtimeMs: await mtimeOf($, `${root}/${name}`),
179 })),
180 );
181 return piFilesOf(
182 await listingsOf(
183 $,
184 piDirectoriesOf(stats, since).map((name) => `${root}/${name}`),
185 ),
186 );
187};
188
189const foundFor = async (
190 $: Engine,
191 call: ShellCall,
192 file: SessionFile | undefined,
193): Promise<Found> => {
194 const { runner } = call;
195 const bytes = runner === "pi" ? PI_TAIL_BYTES : CODEX_TAIL_BYTES;
196 return runner === undefined || file === undefined
197 ? {}
198 : usageFrom(runner, await endOf($, file.path, bytes), file.size);
199};
200
201// Pi's session must be the only one in the window; Codex's the nearest start.
202const sessionOf = (
203 call: ShellCall,
204 files: Readonly<Record<"pi" | "codex", readonly SessionFile[]>>,
205 now: number,
206): SessionFile | undefined => {
207 const window = windowOf(call, now);
208 const own = call.runner === "pi" ? files.pi : files.codex;
209 const near = call.runner === "codex" ? call.startedAt : undefined;
210 return window === undefined ? undefined : pickSession(own, window, near);
211};
212
213const readUsage = async (
214 $: Engine,
215 due: readonly ShellCall[],
216 { home, now }: Readonly<{ home: string; now: number }>,
217): Promise<ShellUsage> => {
218 const since = Math.min(...due.map((call) => call.startedAt)) - SLACK_MS;
219 const files = {
220 pi: due.some((call) => call.runner === "pi")
221 ? await piFilesIn($, home, since)
222 : [],
223 codex: codexFilesOf(
224 due.some((call) => call.runner === "codex")
225 ? await listingsOf($, sessionDirectoriesOf(home, now))
226 : [],
227 ),
228 };
229 const entries = await Promise.all(
230 due.map(async (call): Promise<readonly [string, CallUsage]> => {
231 const file = sessionOf(call, files, now);
232 return [call.id, { ...(await foundFor($, call, file)), readAt: now }];
233 }),
234 );
235 return Object.fromEntries(entries);
236};
237
238const refreshUsage = async ($: Engine): Promise<void> => {
239 const isShown =
240 (await read($, openAtom)) && (await read($, viewAtom)) === "runners";
241 const now = await $.clock.now();
242 const due = isShown
243 ? dueUsageOf(
244 shownRunnerCallsOf(await read($, callsAtom)),
245 await read($, usageAtom),
246 now,
247 )
248 : [];
249 const home =
250 due.length > 0 ? await attempt(async () => $.env.get("HOME")) : "";
251 if (home === undefined || home === "" || due.length === 0) {
252 return;
253 }
254 // Stamped first: a slow read is not started twice.
255 await update($, usageAtom, (known) => ({
256 ...known,
257 ...Object.fromEntries(
258 due.map((call) => [call.id, { ...known[call.id], readAt: now }]),
259 ),
260 }));
261 const found = await readUsage($, due, { home, now });
262 // Only the calls still listed keep a cell (`clear` forgets the rest).
263 const listed = await read($, callsAtom);
264 const ids = new Set(listed.map((call) => call.id));
265 await update($, usageAtom, (known) =>
266 Object.fromEntries(
267 Object.entries({ ...known, ...keptWhenEmpty(known, found) }).filter(
268 ([id]) => ids.has(id),
269 ),
270 ),
271 );
272};
273
274/**
275 * `session.start`: starts the limits timer (it fires again on a hot reload,
276 * which dropped the timers).
277 * @param $ the engine
278 * @param e the start
279 * @param next the rest of the chain
280 * @returns what the chain answered
281 */
282export const onLimitsStart = async (
283 $: Engine,
284 e: Readonly<SessionStartInput>,
285 next: Next<"session.start">,
286): Promise<SessionStartResult> => {
287 const started = await next(e);
288 $.clock.every(CHECK_MS, () => {
289 void refreshLimits($);
290 void refreshUsage($);
291 });
292 return started;
293};
294hooks/model/backfill.ts 159 lines1/**
2 * Bash calls rebuilt from the transcript, for calls made before the mod
3 * loaded (enabled mid-session, a hot reload, an update).
4 */
5import type { ShellCall, ShellScope } from "../../types";
6import {
7 isInScope,
8 isLive,
9 noticed,
10 settled,
11 started,
12 trimmed,
13} from "./calls.ts";
14import { outcomeOf } from "./outcome.ts";
15import { noticesOf, type TaskNotice } from "./parse.ts";
16
17/** One tool use as `$.session.messages()` reports it. */
18export interface ToolUseRow {
19 readonly tool_use_id: string;
20 readonly tool: string;
21 readonly input: Readonly<Record<string, unknown>>;
22 readonly result?: unknown;
23 readonly text?: string;
24 readonly isError?: true;
25 readonly durationMs?: number;
26}
27
28/** One transcript message as `$.session.messages()` reports it. */
29export interface MessageRow {
30 readonly text: string;
31 readonly toolUses: readonly ToolUseRow[];
32}
33
34const stringOf = (value: unknown): string | undefined =>
35 typeof value === "string" ? value : undefined;
36
37// A TaskStop the model made ends its task without a <task-notification>.
38const stopsOf = (uses: readonly ToolUseRow[]): readonly TaskNotice[] =>
39 uses
40 .filter((use) => use.tool === "TaskStop" && use.isError !== true)
41 .map(
42 (use) =>
43 stringOf(use.input["task_id"]) ?? stringOf(use.input["shell_id"]),
44 )
45 .filter((taskId) => taskId !== undefined)
46 .map((taskId) => ({ taskId, status: "stopped" }));
47
48const isAnswered = (use: ToolUseRow): boolean =>
49 use.result !== undefined || use.text !== undefined;
50
51const callOf = (
52 use: ToolUseRow,
53 agentId: string | undefined,
54 now: number,
55): ShellCall => {
56 const call = started(
57 {
58 tool_use_id: use.tool_use_id,
59 command: stringOf(use.input["command"]) ?? "",
60 description: stringOf(use.input["description"]),
61 agentId,
62 },
63 now - (use.durationMs ?? 0),
64 );
65 const answer =
66 use.isError === true
67 ? { isError: true as const, result: use.result, text: use.text ?? "" }
68 : { result: use.result, text: use.text ?? "" };
69 const rebuilt = settled(call, outcomeOf(answer), now);
70 return use.durationMs === undefined
71 ? { ...rebuilt, isTimeUnknown: true }
72 : rebuilt;
73};
74
75/**
76 * The answered Bash calls of one conversation, each settled from its result;
77 * a background call stays running unless a task notification ended it. An
78 * unanswered foreground call is left out: nothing would ever settle it.
79 * @param rows the conversation's messages
80 * @param agentId the subagent's id, undefined for the main loop
81 * @param now the clock's time (no timestamps: elapsed counts from here)
82 * @returns the calls, transcript order
83 */
84export const backfilled = (
85 rows: readonly MessageRow[],
86 agentId: string | undefined,
87 now: number,
88): readonly ShellCall[] => {
89 const uses = rows.flatMap((row) => row.toolUses);
90 const calls = uses
91 .filter((use) => use.tool === "Bash" && isAnswered(use))
92 .map((use) => callOf(use, agentId, now));
93 return noticed(
94 calls,
95 [...noticesOf(rows.map((row) => row.text)), ...stopsOf(uses)],
96 now,
97 );
98};
99
100// The transcript knows how a call ended even when the mod missed the event:
101// a call the state still holds as running takes its ending from the rebuilt
102// one (its own start time and label stay).
103const reconciled = (known: ShellCall, rebuilt: ShellCall): ShellCall =>
104 isLive(known) && !isLive(rebuilt)
105 ? {
106 ...known,
107 status: rebuilt.status,
108 ...(rebuilt.endedAt !== undefined && { endedAt: rebuilt.endedAt }),
109 tail: rebuilt.tail,
110 stderr: rebuilt.stderr,
111 ...(rebuilt.exitCode !== undefined && { exitCode: rebuilt.exitCode }),
112 ...(rebuilt.verdict !== undefined && { verdict: rebuilt.verdict }),
113 ...(rebuilt.needsTail !== undefined && {
114 needsTail: rebuilt.needsTail,
115 }),
116 }
117 : known;
118
119/** How many calls to keep, which ids never to bring back, and the scope. */
120export interface Keep {
121 readonly max: number;
122 readonly cleared: readonly string[];
123 /** The watch scope; absent means `all`. */
124 readonly scope?: ShellScope;
125}
126
127/**
128 * The state's calls and the rebuilt ones it lacks, in transcript order (the
129 * state's own copy wins, unless it still runs and the transcript says it
130 * ended; calls only the state knows follow), without the
131 * ones the person cleared, cut to `max` (the oldest finished dropped first).
132 * @param known the calls in state
133 * @param rebuilt the calls rebuilt from the transcript, oldest first
134 * @param keep how many to keep (`max`) and the ids the person cleared
135 * @returns the list
136 */
137export const merged = (
138 known: readonly ShellCall[],
139 rebuilt: readonly ShellCall[],
140 keep: Keep,
141): readonly ShellCall[] => {
142 const { max, cleared } = keep;
143 const gone = new Set(cleared);
144 const byId = new Map(known.map((call) => [call.id, call]));
145 const rebuiltIds = new Set(rebuilt.map((call) => call.id));
146 const ordered = [
147 ...rebuilt
148 .filter(
149 (call) => !gone.has(call.id) && isInScope(call, keep.scope ?? "all"),
150 )
151 .map((call) => {
152 const own = byId.get(call.id);
153 return own === undefined ? call : reconciled(own, call);
154 }),
155 ...known.filter((call) => !rebuiltIds.has(call.id) && !gone.has(call.id)),
156 ];
157 return trimmed(ordered, max);
158};
159hooks/model/calls.ts 373 lines1/**
2 * Pure transitions of the call list: a call starts, settles, is polled for
3 * freshness, read for its tail, noticed finished in the background, trimmed.
4 */
5import type { ShellCall, ShellScope, ShellStatus } from "../../types";
6import {
7 exitCodeOf,
8 isNoMatchCommand,
9 labelOf,
10 lastLines,
11 outputPathOf,
12 promptWordsOf,
13 runnerOf,
14 type TaskNotice,
15 verdictOf,
16 watchPathOf,
17} from "./parse.ts";
18
19const TAIL_LINES = 40;
20
21/** Silence, in ms, after which a running call is quiet, then hung. */
22export interface Limits {
23 readonly quietMs: number;
24 readonly hangMs: number;
25}
26
27/** What a Bash call's input carries that the list keeps. */
28export interface BashStart {
29 readonly tool_use_id: string;
30 readonly command: string;
31 readonly description?: string | undefined;
32 readonly agentId?: string | undefined;
33}
34
35/** What a Bash call's result carries that the list keeps. */
36export interface BashOutcome {
37 readonly isError: boolean;
38 readonly text: string;
39 readonly stdout?: string;
40 readonly stderr?: string;
41 readonly interrupted?: boolean;
42 readonly backgroundTaskId?: string | undefined;
43 readonly denied?: string;
44}
45
46/** What `$.fs.stat` answered for a watched file. */
47export interface FileStat {
48 readonly size: number;
49 readonly mtimeMs: number;
50}
51
52const LIVE: ReadonlySet<ShellStatus> = new Set(["running", "quiet", "hung"]);
53const RANK: Readonly<Record<ShellStatus, number>> = {
54 hung: 0,
55 failed: 1,
56 quiet: 2,
57 running: 3,
58 stopped: 4,
59 denied: 6,
60 done: 5,
61 nomatch: 5,
62};
63
64/**
65 * Whether the call still runs (running, quiet or hung).
66 * @param call the call
67 * @returns true while it runs
68 */
69export const isLive = (call: ShellCall): boolean => LIVE.has(call.status);
70
71/**
72 * Whether any call still runs.
73 * @param calls the list
74 * @returns true when one does
75 */
76export const hasLive = (calls: readonly ShellCall[]): boolean =>
77 calls.some((call) => LIVE.has(call.status));
78
79/**
80 * Whether the watch scope keeps the call: `all` keeps every call, `runners`
81 * only delegated agent runs — a recognised runner (Codex, Pi, Devin, `ocr`)
82 * or a guard-wrapped one (a `--watch-file` it watches).
83 * @param call the call
84 * @param scope the configured scope
85 * @returns true when the call is watched under the scope
86 */
87export const isInScope = (call: ShellCall, scope: ShellScope): boolean =>
88 scope === "all" || call.runner !== undefined || call.watchPath !== undefined;
89
90/**
91 * The calls that still run: what `clear` keeps.
92 * @param calls the list
93 * @returns the live calls
94 */
95export const liveOnly = (calls: readonly ShellCall[]): readonly ShellCall[] =>
96 calls.filter((call) => LIVE.has(call.status));
97
98/**
99 * The ids of the finished calls: what `clear` forgets for good.
100 * @param calls the list
101 * @returns their ids
102 */
103export const finishedIds = (calls: readonly ShellCall[]): readonly string[] =>
104 calls.filter((call) => !LIVE.has(call.status)).map((call) => call.id);
105
106/**
107 * A call as the Bash input starts it.
108 * @param input the tool call's input
109 * @param now the clock's time
110 * @returns the running call
111 */
112export const started = (input: BashStart, now: number): ShellCall => {
113 const runner = runnerOf(input.command);
114 const watchPath = watchPathOf(input.command);
115 return {
116 id: input.tool_use_id,
117 ...(input.agentId !== undefined && { agentId: input.agentId }),
118 label: labelOf(
119 input.description,
120 runner === undefined
121 ? input.command
122 : (promptWordsOf(input.command) ?? input.command),
123 ),
124 command: input.command,
125 startedAt: now,
126 background: false,
127 ...(runner !== undefined && { runner }),
128 ...(watchPath !== undefined && { watchPath }),
129 tail: [],
130 stderr: [],
131 status: "running",
132 };
133};
134
135const ended = (
136 call: ShellCall,
137 status: ShellStatus,
138 now: number,
139): ShellCall => ({ ...call, status, endedAt: now });
140
141// A search or test tool's exit 1 is "no match" (or "differ", "false").
142const NO_MATCH = 1;
143
144/** How a call ended: its status and exit code, if known. */
145interface Ending {
146 readonly status: ShellStatus;
147 readonly exitCode: number | undefined;
148}
149
150const endedWith = (call: ShellCall, ending: Ending, now: number): ShellCall =>
151 ending.status === "failed" &&
152 ending.exitCode === NO_MATCH &&
153 isNoMatchCommand(call.command)
154 ? { ...ended(call, "nomatch", now), verdict: "no match" }
155 : ended(call, ending.status, now);
156
157const backgrounded = (
158 call: ShellCall,
159 taskId: string,
160 text: string,
161): ShellCall => {
162 const outputPath = outputPathOf(text);
163 return {
164 ...call,
165 background: true,
166 taskId,
167 ...(outputPath !== undefined && { outputPath }),
168 };
169};
170
171const finished = (
172 call: ShellCall,
173 outcome: BashOutcome,
174 now: number,
175): ShellCall => {
176 const lines = lastLines(
177 outcome.isError ? outcome.text : (outcome.stdout ?? outcome.text),
178 TAIL_LINES,
179 );
180 const verdict = call.runner === undefined ? undefined : verdictOf(lines);
181 const exitCode = outcome.isError ? exitCodeOf(outcome.text) : 0;
182 return {
183 ...endedWith(
184 call,
185 { status: outcome.isError ? "failed" : "done", exitCode },
186 now,
187 ),
188 ...(exitCode !== undefined && { exitCode }),
189 tail: outcome.isError ? call.tail : lines,
190 stderr: outcome.isError
191 ? lines
192 : lastLines(outcome.stderr ?? "", TAIL_LINES),
193 ...(verdict !== undefined && { verdict }),
194 };
195};
196
197const stoppedOrFinished = (
198 call: ShellCall,
199 outcome: BashOutcome,
200 now: number,
201): ShellCall =>
202 outcome.interrupted === true || outcome.denied !== undefined
203 ? {
204 ...ended(
205 call,
206 outcome.denied === undefined ? "stopped" : "denied",
207 now,
208 ),
209 ...(outcome.denied !== undefined && {
210 verdict: "denied",
211 stderr: lastLines(outcome.denied, TAIL_LINES),
212 }),
213 }
214 : finished(call, outcome, now);
215
216/**
217 * A call as its result leaves it: finished, stopped, or gone to background.
218 * @param call the running call
219 * @param outcome the result's fields
220 * @param now the clock's time
221 * @returns the call after the result
222 */
223export const settled = (
224 call: ShellCall,
225 outcome: BashOutcome,
226 now: number,
227): ShellCall => {
228 const taskId = outcome.isError ? undefined : outcome.backgroundTaskId;
229 return taskId === undefined
230 ? stoppedOrFinished(call, outcome, now)
231 : backgrounded(call, taskId, outcome.text);
232};
233
234const NOTICED: Readonly<Record<string, ShellStatus>> = {
235 completed: "done",
236 failed: "failed",
237};
238
239const settledBy = (
240 call: ShellCall,
241 notice: TaskNotice,
242 now: number,
243): ShellCall => ({
244 ...endedWith(
245 call,
246 {
247 status: NOTICED[notice.status] ?? "stopped",
248 exitCode: notice.exitCode,
249 },
250 now,
251 ),
252 ...(notice.exitCode !== undefined && { exitCode: notice.exitCode }),
253 ...(call.outputPath !== undefined && { needsTail: true }),
254});
255
256/**
257 * The calls after background tasks' notifications: each one's call settled,
258 * and its output marked for one last read (a runner's verdict, the final
259 * lines of an expanded row).
260 * @param calls the list
261 * @param notices the notifications
262 * @param now the clock's time
263 * @returns the list
264 */
265export const noticed = (
266 calls: readonly ShellCall[],
267 notices: readonly TaskNotice[],
268 now: number,
269): readonly ShellCall[] =>
270 calls.map((call) => {
271 const notice = notices.find((one) => one.taskId === call.taskId);
272 return notice !== undefined && isLive(call)
273 ? settledBy(call, notice, now)
274 : call;
275 });
276
277const quietOr = (silent: number, limits: Limits): ShellStatus =>
278 silent > limits.quietMs ? "quiet" : "running";
279
280/**
281 * A live watched call's status by how long its output has been silent.
282 * @param call the call
283 * @param now the clock's time
284 * @param limits quiet and hang thresholds
285 * @returns the call, reclassified
286 */
287export const classified = (
288 call: ShellCall,
289 now: number,
290 limits: Limits,
291): ShellCall => {
292 const isWatched =
293 call.outputPath !== undefined || call.watchPath !== undefined;
294 const silent = now - (call.lastOutputAt ?? call.startedAt);
295 const status: ShellStatus =
296 silent > limits.hangMs ? "hung" : quietOr(silent, limits);
297 return isWatched && isLive(call) ? { ...call, status } : call;
298};
299
300/**
301 * A call after a stat of its watched file: new bytes are new output; an
302 * empty file is no output yet.
303 * @param call the call
304 * @param stat the file's size and mtime
305 * @param now the clock's time
306 * @returns the call with its freshness
307 */
308export const polled = (
309 call: ShellCall,
310 stat: FileStat,
311 now: number,
312): ShellCall =>
313 stat.size === call.outputBytes
314 ? call
315 : {
316 ...call,
317 outputBytes: stat.size,
318 ...(stat.size > 0 && { lastOutputAt: Math.min(stat.mtimeMs, now) }),
319 };
320
321/**
322 * A call after a read of its output file: the tail, and a runner's verdict;
323 * a pending final read is done.
324 * @param call the call
325 * @param text the file's text
326 * @returns the call with its tail
327 */
328export const tailed = (call: ShellCall, text: string): ShellCall => {
329 const tail = lastLines(text, TAIL_LINES);
330 const verdict = call.runner === undefined ? undefined : verdictOf(tail);
331 return {
332 ...call,
333 tail,
334 needsTail: false,
335 ...(verdict !== undefined && { verdict }),
336 };
337};
338
339/**
340 * The list cut to `max`, the oldest finished calls dropped first.
341 * @param calls the list, oldest first
342 * @param max how many to keep
343 * @returns the list
344 */
345export const trimmed = (
346 calls: readonly ShellCall[],
347 max: number,
348): readonly ShellCall[] => {
349 const drop = new Set(
350 calls
351 .filter((call) => !isLive(call))
352 .slice(0, Math.max(0, calls.length - max))
353 .map((call) => call.id),
354 );
355 return calls.filter((call) => !drop.has(call.id));
356};
357
358/**
359 * The calls most urgent first: hung, failed, quiet, running; runners ahead
360 * of plain shells within a rank, then the newest.
361 * @param calls the list
362 * @returns a sorted copy
363 */
364export const urgencyOrder = (
365 calls: readonly ShellCall[],
366): readonly ShellCall[] =>
367 calls.toSorted(
368 (a, b) =>
369 RANK[a.status] - RANK[b.status] ||
370 Number(a.runner === undefined) - Number(b.runner === undefined) ||
371 b.startedAt - a.startedAt,
372 );
373hooks/model/config.ts 43 lines1/**
2 * The mod's `userConfig` values, checked and defaulted.
3 */
4import type { PluginOptions } from "claude-code";
5
6import type { ShellConfig } from "../../types";
7
8const MINUTE = 60_000;
9const DEFAULTS = {
10 columns: 52,
11 maxCalls: 50,
12 quietMin: 5,
13 hangMin: 10,
14} as const;
15
16/** What the hooks read from the options. */
17export type Config = ShellConfig;
18
19const positive = (
20 options: PluginOptions,
21 key: keyof typeof DEFAULTS,
22): number => {
23 const value = options[key];
24 return typeof value === "number" && value > 0 ? value : DEFAULTS[key];
25};
26
27/**
28 * The config from the options `register` receives.
29 * @param options the plugin's `userConfig` values
30 * @returns the config
31 */
32export const configOf = (options: PluginOptions): Config => ({
33 columns: positive(options, "columns"),
34 openOnStart: options["openOnStart"] === true,
35 maxCalls: positive(options, "maxCalls"),
36 limits: {
37 quietMs: positive(options, "quietMin") * MINUTE,
38 hangMs: positive(options, "hangMin") * MINUTE,
39 },
40 statusLine: options["statusLine"] !== false,
41 scope: options["scope"] === "all" ? "all" : "runners",
42});
43hooks/model/format.ts 277 lines1/**
2 * Pure text of the status line and the pane's rows: elapsed time, output
3 * freshness, a runner's last words and its verdict.
4 */
5import type { ShellCall, ShellStatus } from "../../types";
6import { isLive, urgencyOrder } from "./calls.ts";
7
8const SECOND = 1000;
9const SIXTY = 60;
10const MINUTE = SIXTY * SECOND;
11const HOUR = SIXTY * MINUTE;
12const FAILED_SHOWN_MS = 120_000;
13const PAD = 2;
14const NAME_MAX = 40;
15const SAYS_MAX = 40;
16
17/** The glyph a status draws with. */
18export const GLYPH: Readonly<Record<ShellStatus, string>> = {
19 running: "◐",
20 quiet: "⚠",
21 hung: "⚠",
22 done: "●",
23 failed: "✗",
24 stopped: "○",
25 denied: "○",
26 nomatch: "○",
27};
28
29const pad = (n: number): string => String(n).padStart(PAD, "0");
30
31/**
32 * Elapsed time as a clock: `2:13`, `1:02:03`.
33 * @param ms milliseconds
34 * @returns the clock text
35 */
36export const clockOf = (ms: number): string => {
37 const s = Math.max(0, Math.floor(ms / SECOND));
38 const m = Math.floor(s / SIXTY) % SIXTY;
39 const h = Math.floor(s / SIXTY / SIXTY);
40 const tail = `${pad(m)}:${pad(s % SIXTY)}`;
41 return h > 0 ? `${String(h)}:${tail}` : tail.replace(/^0/u, "");
42};
43
44const UNITS: readonly (readonly [number, string])[] = [
45 [HOUR, "h"],
46 [MINUTE, "m"],
47];
48
49/**
50 * An age in its largest unit: `4s`, `6m`, `2h`.
51 * @param ms milliseconds
52 * @returns the age text
53 */
54export const agoOf = (ms: number): string => {
55 const [size, unit] = UNITS.find(([at]) => ms >= at) ?? [SECOND, "s"];
56 return `${String(Math.max(0, Math.floor(ms / size)))}${unit}`;
57};
58
59const cut = (text: string, max: number): string =>
60 text.length > max ? `${text.slice(0, max - 1)}…` : text;
61
62/**
63 * The call's name: `codex · <label>` for a runner, the label otherwise.
64 * @param call the call
65 * @returns the name, cut to fit a status line
66 */
67export const nameOf = (call: ShellCall): string =>
68 cut(
69 call.runner === undefined ? call.label : `${call.runner} · ${call.label}`,
70 NAME_MAX,
71 );
72
73/**
74 * How the call ended: a runner's verdict, else `exit n`, else nothing.
75 * @param call the call
76 * @returns the outcome text, empty while unknown
77 */
78export const outcomeOf = (call: ShellCall): string =>
79 call.verdict ??
80 (call.exitCode === undefined ? "" : `exit ${String(call.exitCode)}`);
81
82/**
83 * The last non-empty output line the call has shown.
84 * @param call the call
85 * @returns the line, or undefined
86 */
87const saysOf = (call: ShellCall): string | undefined =>
88 call.tail.findLast((line) => line.trim() !== "")?.trim();
89
90/**
91 * How long the call has run, or `—` when its start is unknown (rebuilt from
92 * a transcript that kept no duration).
93 * @param call the call
94 * @param until when to count to: now, or when it ended
95 * @returns the clock text
96 */
97const elapsedOf = (call: ShellCall, until: number): string =>
98 call.isTimeUnknown === true ? "—" : clockOf(until - call.startedAt);
99
100const silentOf = (call: ShellCall, now: number): string =>
101 agoOf(now - (call.lastOutputAt ?? call.startedAt));
102
103const isWatched = (call: ShellCall): boolean =>
104 call.outputPath !== undefined || call.watchPath !== undefined;
105
106/**
107 * How fresh a live call's output is: `output 4s ago`, `no output · 45s` for a
108 * watched file still empty, nothing when nothing is watched.
109 * @param call the call
110 * @param now the clock's time
111 * @returns the phrase, empty when unknown
112 */
113const silenceOf = (call: ShellCall, now: number): string => {
114 const since =
115 elapsedOf(call, now) === "—" ? "" : ` · ${agoOf(now - call.startedAt)}`;
116 return isWatched(call) ? `no output${since}` : "";
117};
118
119const freshOf = (call: ShellCall, now: number): string =>
120 call.lastOutputAt === undefined
121 ? silenceOf(call, now)
122 : `output ${agoOf(now - call.lastOutputAt)} ago`;
123
124const runningSegment = (call: ShellCall, now: number, name: string): string => {
125 const says = saysOf(call);
126 return [
127 `◐ ${name} ${elapsedOf(call, now)}`,
128 freshOf(call, now),
129 ...(says === undefined ? [] : [`› ${cut(says, SAYS_MAX)}`]),
130 ]
131 .filter((part) => part !== "")
132 .join(" · ");
133};
134
135/**
136 * Line 1's state, before the label so a narrow pane cuts the label first:
137 * elapsed time, then freshness while live, else the outcome or the status.
138 * @param call the call
139 * @param now the clock's time
140 * @returns `0:51 output 1s ago`, `6:00 quiet · output 6m ago`, `0:01 exit 0`
141 */
142export const stateOf = (call: ShellCall, now: number): string => {
143 const elapsed = elapsedOf(call, call.endedAt ?? now);
144 const live = [
145 call.status === "running" ? "" : call.status,
146 freshOf(call, now),
147 ];
148 const ended = [outcomeOf(call) === "" ? call.status : outcomeOf(call)];
149 const parts = (isLive(call) ? live : ended).filter((part) => part !== "");
150 return [elapsed, parts.join(" · ")].filter((part) => part !== "").join(" ");
151};
152
153/** What a row's third line says, and in which tone. */
154export interface Note {
155 readonly tone: "output" | "error" | "denied";
156 readonly text: string;
157}
158
159const lastOf = (lines: readonly string[]): string | undefined =>
160 lines.findLast((line) => line.trim() !== "")?.trim();
161
162const TONE: Readonly<Partial<Record<ShellStatus, Note["tone"]>>> = {
163 failed: "error",
164 denied: "denied",
165};
166
167/**
168 * A row's note: a denial's reason, a failure's last error line (else its
169 * last output), otherwise the last output line.
170 * @param call the call
171 * @returns the note, or undefined when there is nothing to say
172 */
173export const noteOf = (call: ShellCall): Note | undefined => {
174 const tone = TONE[call.status] ?? "output";
175 const text =
176 tone === "output"
177 ? lastOf(call.tail)
178 : (lastOf(call.stderr) ?? lastOf(call.tail));
179 return text === undefined ? undefined : { tone, text };
180};
181
182const SEGMENT: Readonly<
183 Record<ShellStatus, (call: ShellCall, now: number, name: string) => string>
184> = {
185 running: runningSegment,
186 quiet: (call, now, name) =>
187 `⚠ quiet ${silentOf(call, now)} ${name} ${elapsedOf(call, now)}`,
188 hung: (call, now, name) => `⚠ hung ${silentOf(call, now)} ${name}`,
189 failed: (call, _now, name) => `✗ ${name} ${outcomeOf(call)}`.trimEnd(),
190 done: (_call, _now, name) => `● ${name}`,
191 stopped: (_call, _now, name) => `○ ${name}`,
192 denied: (_call, _now, name) => `○ ${name} denied`,
193 nomatch: (_call, _now, name) => `○ ${name} no match`,
194};
195
196const MAIN = "main";
197const OWNER_MAX = 16;
198
199/** What the status line needs of an agent: its type. */
200export type OwnerTable = Readonly<Record<string, Readonly<{ type: string }>>>;
201
202/**
203 * Whose a call is, in a word: `main`, the agent's type, or `agent <id>`.
204 * @param key the call's `agentId`, or `main`
205 * @param agents the known agents
206 * @returns the owner
207 */
208export const ownerOf = (key: string, agents: OwnerTable): string =>
209 key === MAIN ? MAIN : (agents[key]?.type ?? `agent ${key}`);
210
211interface Counted {
212 readonly status: ShellStatus;
213 readonly isBackground?: boolean;
214 readonly lead: string;
215 readonly unit: string;
216}
217
218const COUNTED: readonly Counted[] = [
219 { status: "hung", lead: "+", unit: " hung" },
220 { status: "failed", lead: "+", unit: " failed" },
221 { status: "quiet", lead: "+", unit: " quiet" },
222 { status: "running", isBackground: true, lead: "+", unit: " bg" },
223 { status: "running", isBackground: false, lead: "+", unit: " running" },
224];
225
226const countsOf = (rest: readonly ShellCall[]): readonly string[] =>
227 COUNTED.map((rule) => ({
228 n: rest.filter(
229 (call) =>
230 call.status === rule.status &&
231 (rule.isBackground ?? call.background) === call.background,
232 ).length,
233 rule,
234 }))
235 .filter(({ n }) => n > 0)
236 .map(({ n, rule }) => `${rule.lead}${String(n)}${rule.unit}`);
237
238/**
239 * The always-on line: the most urgent call in full, the rest counted.
240 * @param calls the list
241 * @param now the clock's time
242 * @param context the known agents, to say whose the leading call is, and the
243 * executors whose subscription limit is active now
244 * @returns the line, or undefined when nothing runs, recently failed or is blocked
245 */
246export const statusLineOf = (
247 calls: readonly ShellCall[],
248 now: number,
249 context: Readonly<{
250 agents?: OwnerTable;
251 blocked?: readonly string[];
252 }> = {},
253): string | undefined => {
254 const { agents = {}, blocked = [] } = context;
255 const shown = urgencyOrder(
256 calls.filter(
257 (call) =>
258 isLive(call) ||
259 (call.status === "failed" &&
260 now - (call.endedAt ?? now) < FAILED_SHOWN_MS),
261 ),
262 );
263 const [head, ...rest] = shown;
264 const owner = cut(ownerOf(head?.agentId ?? MAIN, agents), OWNER_MAX);
265 const limit = blocked.length > 0 ? [`⏳ ${blocked.join(", ")} limit`] : [];
266 const segments =
267 head === undefined
268 ? []
269 : [
270 SEGMENT[head.status](head, now, `${owner} · ${nameOf(head)}`),
271 ...countsOf(rest),
272 ];
273 return segments.length + limit.length === 0
274 ? undefined
275 : [...segments, ...limit].join(" · ");
276};
277hooks/model/groups.ts 188 lines1/**
2 * Calls grouped by the agent that made them: the main loop, each subagent.
3 * A group's rollup status sorts the most urgent group first.
4 */
5import type { ShellAgentInfo, ShellCall, ShellStatus } from "../../types";
6import { isLive } from "./calls.ts";
7import { paneOrder } from "./layout.ts";
8
9/** A group's status: a call's, or `idle` for a running agent at rest. */
10export type GroupStatus = ShellStatus | "idle";
11
12/** The calls of one agent and how the group stands. */
13export interface Group {
14 readonly key: string;
15 readonly label: string;
16 readonly calls: readonly ShellCall[];
17 readonly status: GroupStatus;
18 readonly live: number;
19}
20
21/** Agents by id, as the state keeps them. */
22export type AgentTable = Readonly<Record<string, ShellAgentInfo>>;
23
24/** Group keys mapped to the person's fold choices (true: folded). */
25export type Folds = Readonly<Record<string, boolean>>;
26
27const MAIN = "main";
28// The main loop is always there: it counts as an agent that is running, so
29// its old failures read idle rather than failed for ever.
30const MAIN_AGENT: ShellAgentInfo = {
31 type: MAIN,
32 description: "",
33 status: "running",
34};
35const RECENT_FAILURE_MS = 120_000;
36const RANK: Readonly<Record<GroupStatus, number>> = {
37 hung: 0,
38 failed: 1,
39 quiet: 2,
40 running: 3,
41 idle: 4,
42 stopped: 5,
43 done: 6,
44 nomatch: 6,
45 denied: 7,
46};
47const OPEN_BY_DEFAULT: ReadonlySet<GroupStatus> = new Set([
48 "hung",
49 "failed",
50 "quiet",
51 "running",
52 "idle",
53]);
54// An agent's own status, where it decides the group's.
55const BY_AGENT: Readonly<Partial<Record<string, GroupStatus>>> = {
56 failed: "failed",
57 killed: "failed",
58 running: "idle",
59};
60
61const ownLabelOf = (key: string, agents: AgentTable): string => {
62 const agent = agents[key];
63 return agent === undefined
64 ? `agent ${key}`
65 : `${agent.type}: ${agent.description}`;
66};
67
68/**
69 * A group's label: `main`, the agent's `type: description` (`agent <id>`
70 * when unknown), and for a nested agent ` ↳ <parent's label>`.
71 * @param key the group's key
72 * @param agents the known agents
73 * @returns the label
74 */
75export const groupLabelOf = (key: string, agents: AgentTable): string => {
76 const parent = agents[key]?.parentId;
77 const nested = parent === undefined ? "" : ` ↳ ${ownLabelOf(parent, agents)}`;
78 return key === MAIN ? MAIN : ownLabelOf(key, agents) + nested;
79};
80
81const worstOf = (statuses: readonly GroupStatus[]): GroupStatus | undefined =>
82 statuses.toSorted((a, b) => RANK[a] - RANK[b])[0];
83
84const settledOf = (
85 calls: readonly ShellCall[],
86 agent: ShellAgentInfo | undefined,
87 now: number,
88): GroupStatus => {
89 const isRecentFailure = calls.some(
90 (call) =>
91 call.status === "failed" &&
92 now - (call.endedAt ?? now) < RECENT_FAILURE_MS,
93 );
94 const byAgent = BY_AGENT[agent?.status ?? ""];
95 const settled = calls
96 .map((call) => call.status)
97 .filter((status) => status !== "failed");
98 return isRecentFailure ? "failed" : (byAgent ?? worstOf(settled) ?? "done");
99};
100
101const groupOf = (
102 key: string,
103 calls: readonly ShellCall[],
104 context: Readonly<{ agents: AgentTable; now: number }>,
105): Group => {
106 const live = calls.filter(isLive);
107 return {
108 key,
109 label: groupLabelOf(key, context.agents),
110 calls: paneOrder(calls),
111 status:
112 worstOf(live.map((call) => call.status)) ??
113 settledOf(
114 calls,
115 key === MAIN ? MAIN_AGENT : context.agents[key],
116 context.now,
117 ),
118 live: live.length,
119 };
120};
121
122const newestOf = (group: Group): number =>
123 Math.max(...group.calls.map((call) => call.startedAt));
124
125/**
126 * The calls grouped by agent (`agentId`, else `main`), the most urgent group
127 * first (hung, failed, quiet, running, idle, then settled), then the newest.
128 * @param calls the list
129 * @param agents the known agents
130 * @param now the clock's time
131 * @returns the groups, each with its calls in pane order
132 */
133export const groupsOf = (
134 calls: readonly ShellCall[],
135 agents: AgentTable,
136 now: number,
137): readonly Group[] => {
138 const keys = [...new Set(calls.map((call) => call.agentId ?? MAIN))];
139 return keys
140 .map((key) =>
141 groupOf(
142 key,
143 calls.filter((call) => (call.agentId ?? MAIN) === key),
144 { agents, now },
145 ),
146 )
147 .toSorted(
148 (a, b) => RANK[a.status] - RANK[b.status] || newestOf(b) - newestOf(a),
149 );
150};
151
152/**
153 * Whether a group is folded: the person's choice, else folded once nothing
154 * in it is live, failed or idle.
155 * @param group the group
156 * @param folds the person's choices
157 * @returns true when folded
158 */
159export const isFolded = (group: Group, folds: Folds): boolean =>
160 folds[group.key] ?? !OPEN_BY_DEFAULT.has(group.status);
161
162/** What `$.agent.list()` says of one agent, as far as groups need it. */
163export interface ListedAgent {
164 readonly id: string;
165 readonly type: string;
166 readonly description: string;
167 readonly status: string;
168 readonly parentId?: string | undefined;
169}
170
171/**
172 * The agent table from `$.agent.list()`.
173 * @param listed the session's agents
174 * @returns their type, description, status and parent by id
175 */
176export const agentTableOf = (listed: readonly ListedAgent[]): AgentTable =>
177 Object.fromEntries(
178 listed.map((agent) => [
179 agent.id,
180 {
181 type: agent.type,
182 description: agent.description,
183 status: agent.status,
184 ...(agent.parentId !== undefined && { parentId: agent.parentId }),
185 },
186 ]),
187 );
188hooks/model/limits.ts 335 lines1/**
2 * Subscription limits of the external runners: one verdict per executor from
3 * its `executor-limits/<name>` epoch and, for Codex, the last rate-limit
4 * record in its newest rollout. Damaged or missing data reads as ok.
5 */
6import type {
7 ExecutorLimit,
8 LimitName,
9 ShellCall,
10 ShellLimits,
11 ShellView,
12} from "../../types";
13import { parsedOf } from "../json.ts";
14import { isLive } from "./calls.ts";
15
16/** Nothing read yet: every executor ok. */
17export const NO_LIMITS: ShellLimits = {
18 by: { devin: {}, pi: {}, codex: {} },
19};
20
21/** The executors, in the order the limits line lists them. */
22export const LIMIT_NAMES: readonly LimitName[] = ["devin", "pi", "codex"];
23
24/** Bytes of a rollout's end the reader asks `tail -c` for. */
25export const TAIL_BYTES = "65536";
26
27const SECOND_MS = 1000;
28const DAY_MS = 86_400_000;
29const THROTTLE_MS = 30_000;
30const FULL = 100;
31const EPOCH = /^\d{1,13}$/u;
32
33/**
34 * The path of an executor's limits file.
35 * @param home the home directory
36 * @param name the executor
37 * @returns the path
38 */
39export const limitPathOf = (home: string, name: LimitName): string =>
40 `${home}/.local/state/executor-limits/${name}`;
41
42/**
43 * When the executor is blocked until, from its limits file: one epoch in
44 * seconds. Past, empty or damaged reads as not blocked.
45 * @param text the file's text
46 * @param now the clock's time, ms
47 * @returns the block's end in ms, or undefined
48 */
49export const limitFileOf = (text: string, now: number): number | undefined => {
50 const trimmed = text.trim();
51 const until = EPOCH.test(trimmed) ? Number(trimmed) * SECOND_MS : 0;
52 return until > now ? until : undefined;
53};
54
55interface Primary {
56 readonly percent: number;
57 readonly resetsAt: number;
58}
59
60const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
61 typeof value === "object" && value !== null && !Array.isArray(value);
62
63const fieldOf = (value: unknown, key: string): unknown =>
64 isRecord(value) ? value[key] : undefined;
65
66const isNumber = (value: unknown): value is number =>
67 typeof value === "number" && Number.isFinite(value);
68
69// The `primary` window of a `token_count` line; none on anything else.
70const primaryOf = (line: string): readonly Primary[] => {
71 const row = line.includes('"token_count"') ? parsedOf(line) : undefined;
72 const payload = fieldOf(row, "payload");
73 const primary = fieldOf(fieldOf(payload, "rate_limits"), "primary");
74 const used = fieldOf(primary, "used_percent");
75 const resets = fieldOf(primary, "resets_at");
76 return fieldOf(row, "type") === "event_msg" &&
77 fieldOf(payload, "type") === "token_count" &&
78 isNumber(used) &&
79 isNumber(resets)
80 ? [{ percent: used, resetsAt: resets * SECOND_MS }]
81 : [];
82};
83
84const lastPrimaryOf = (tail: string): Primary | undefined =>
85 tail
86 .split("\n")
87 .flatMap((line) => primaryOf(line.trim()))
88 .at(-1);
89
90/**
91 * Whether the end of a rollout holds a `token_count` with a `primary` window
92 * (a run stopped at a spend cap writes one with `primary: null`).
93 * @param tail the end of the rollout
94 * @returns true when there is one
95 */
96export const hasPrimary = (tail: string): boolean =>
97 lastPrimaryOf(tail) !== undefined;
98
99/**
100 * Codex's window from the end of a rollout: the newest `token_count` that
101 * carries a `primary` (the last one may not), line by line, so a first line
102 * cut by `tail -c` or any other surprise is skipped. Blocked at 100% with the
103 * reset ahead; the percent shows while that window is still the current one.
104 * @param tail the end of the rollout
105 * @param now the clock's time, ms
106 * @returns the limit, empty when nothing usable was found
107 */
108export const codexLimitOf = (tail: string, now: number): ExecutorLimit => {
109 const primary = lastPrimaryOf(tail);
110 return primary === undefined || primary.resetsAt <= now
111 ? {}
112 : {
113 percent: Math.floor(primary.percent),
114 ...(primary.percent >= FULL && { blockedUntil: primary.resetsAt }),
115 };
116};
117
118/**
119 * What one read found: the text of each limits file (in `LIMIT_NAMES` order,
120 * undefined for a missing one), and Codex's rollout tail.
121 */
122export interface LimitReads {
123 readonly texts: readonly (string | undefined)[];
124 readonly rollout?: string | undefined;
125}
126
127const laterOf = (
128 first: number | undefined,
129 second: number | undefined,
130): number | undefined =>
131 first === undefined || second === undefined
132 ? (first ?? second)
133 : Math.max(first, second);
134
135/**
136 * One verdict per executor: blocked when its file's epoch is ahead or, for
137 * Codex, the rollout's window is full with its reset ahead; the later end holds.
138 * @param reads the files' texts and the rollout's tail
139 * @param now the clock's time, ms
140 * @returns each executor's limit
141 */
142export const limitsOf = (reads: LimitReads, now: number): ShellLimits["by"] => {
143 const codex = codexLimitOf(reads.rollout ?? "", now);
144 const of = (name: LimitName): ExecutorLimit => {
145 const isCodex = name === "codex";
146 const until = laterOf(
147 limitFileOf(reads.texts[LIMIT_NAMES.indexOf(name)] ?? "", now),
148 isCodex ? codex.blockedUntil : undefined,
149 );
150 return {
151 ...(isCodex && codex.percent !== undefined && { percent: codex.percent }),
152 ...(until !== undefined && { blockedUntil: until }),
153 };
154 };
155 return { devin: of("devin"), pi: of("pi"), codex: of("codex") };
156};
157
158/**
159 * Executors still blocked at `now`.
160 * @param limits what the last read found
161 * @param now the clock's time, ms
162 * @returns their names, in the line's order
163 */
164export const blockedOf = (
165 limits: ShellLimits,
166 now: number,
167): readonly LimitName[] =>
168 LIMIT_NAMES.filter((name) => (limits.by[name].blockedUntil ?? 0) > now);
169
170/**
171 * The date and time fields of a moment in a zone, by their `Intl` part names.
172 * @param at the moment, ms
173 * @param zone an IANA time zone; the host's when not given
174 * @returns the parts, as strings
175 */
176export const partsOf = (
177 at: number,
178 zone: string | undefined,
179): Readonly<Record<string, string>> =>
180 Object.fromEntries(
181 new Intl.DateTimeFormat("en-US", {
182 weekday: "short",
183 year: "numeric",
184 month: "2-digit",
185 day: "2-digit",
186 hour: "2-digit",
187 minute: "2-digit",
188 hourCycle: "h23",
189 timeZone: zone,
190 })
191 .formatToParts(at)
192 .map((part) => [part.type, part.value]),
193 );
194
195const dayOf = (parts: Readonly<Record<string, string>>): string =>
196 `${parts["year"] ?? ""}-${parts["month"] ?? ""}-${parts["day"] ?? ""}`;
197
198/**
199 * When a block ends: `HH:MM` if that is today, else weekday and `HH:MM`.
200 * @param until the block's end, ms
201 * @param now the clock's time, ms
202 * @param zone an IANA time zone; the host's when not given
203 * @returns the text
204 */
205export const limitTimeOf = (
206 until: number,
207 now: number,
208 zone?: string,
209): string => {
210 const end = partsOf(until, zone);
211 const time = `${end["hour"] ?? ""}:${end["minute"] ?? ""}`;
212 return dayOf(end) === dayOf(partsOf(now, zone))
213 ? time
214 : `${end["weekday"] ?? ""} ${time}`;
215};
216
217const cellOf = (
218 name: LimitName,
219 limit: ExecutorLimit,
220 when: Readonly<{ now: number; zone?: string }>,
221): string => {
222 const { blockedUntil, percent } = limit;
223 const shown = percent === undefined ? "" : `${String(percent)}%`;
224 return blockedUntil !== undefined && blockedUntil > when.now
225 ? [
226 name,
227 shown === "" ? "limit" : shown,
228 "until",
229 limitTimeOf(blockedUntil, when.now, when.zone),
230 ].join(" ")
231 : [name, "ok", shown].filter((part) => part !== "").join(" ");
232};
233
234/**
235 * The runners view's line: `limits: devin ok · pi ok · codex 100% until Sat 13:48`.
236 * @param limits what the last read found
237 * @param now the clock's time, ms
238 * @param zone an IANA time zone; the host's when not given
239 * @returns the line; `limits: …` before the first read
240 */
241export const limitsLineOf = (
242 limits: ShellLimits,
243 now: number,
244 zone?: string,
245): string =>
246 limits.readAt === undefined
247 ? "limits: …"
248 : `limits: ${LIMIT_NAMES.map((name) =>
249 cellOf(name, limits.by[name], {
250 now,
251 ...(zone !== undefined && { zone }),
252 }),
253 ).join(" · ")}`;
254
255/** What decides whether the limits are read now. */
256export interface LimitsDue {
257 readonly view: ShellView;
258 readonly isOpen: boolean;
259 readonly calls: readonly ShellCall[];
260 readonly readAt?: number | undefined;
261 readonly now: number;
262}
263
264/**
265 * Whether to read the limits: only while the pane shows the runners view or a
266 * runner is live, and at most every 30 s.
267 * @param state the view, the pane, the calls, the last read and the time
268 * @returns true when a read is due
269 */
270export const isLimitsDue = (state: LimitsDue): boolean =>
271 (state.readAt === undefined || state.now - state.readAt >= THROTTLE_MS) &&
272 ((state.isOpen && state.view === "runners") ||
273 state.calls.some((call) => call.runner !== undefined && isLive(call)));
274
275const PAD = 2;
276const pad = (value: number): string => String(value).padStart(PAD, "0");
277
278const sessionDirectoryOf = (home: string, at: number): string => {
279 const day = new Date(at);
280 return `${home}/.codex/sessions/${String(day.getFullYear())}/${pad(day.getMonth() + 1)}/${pad(day.getDate())}`;
281};
282
283/**
284 * Codex's session directories worth a look: today's, then yesterday's.
285 * @param home the home directory
286 * @param now the clock's time, ms
287 * @returns the two paths
288 */
289export const sessionDirectoriesOf = (
290 home: string,
291 now: number,
292): readonly string[] => [
293 sessionDirectoryOf(home, now),
294 sessionDirectoryOf(home, now - DAY_MS),
295];
296
297/** One directory listing, as `$.fs.list` answers. */
298export interface Listing {
299 readonly directory: string;
300 readonly entries: readonly Readonly<{
301 name: string;
302 kind: string;
303 mtimeMs: number;
304 size?: number;
305 }>[];
306}
307
308/**
309 * The newest rollout files of the listings, by `mtimeMs`.
310 * @param count how many to keep
311 * @param listings the directories and their entries
312 * @returns the paths, newest first; empty when there is no rollout
313 */
314export const newestRolloutsOf = (
315 count: number,
316 listings: readonly Listing[],
317): readonly string[] =>
318 listings
319 .flatMap(({ directory, entries }) =>
320 entries
321 .filter(
322 (entry) =>
323 entry.kind === "file" &&
324 entry.name.startsWith("rollout-") &&
325 entry.name.endsWith(".jsonl"),
326 )
327 .map((entry) => ({
328 path: `${directory}/${entry.name}`,
329 at: entry.mtimeMs,
330 })),
331 )
332 .toSorted((first, second) => second.at - first.at)
333 .slice(0, count)
334 .map(({ path }) => path);
335hooks/model/pane-items.ts 140 lines1/**
2 * The pane's lines, grouped: each group's header, then the rows of an open
3 * group, fitted to the height.
4 */
5import type { ShellCall, ShellView } from "../../types";
6import {
7 type AgentTable,
8 type Folds,
9 type Group,
10 groupsOf,
11 isFolded,
12} from "./groups.ts";
13import { rowsOf, type Visible, visibleOf } from "./layout.ts";
14import { type Runner, runnersOf } from "./runners.ts";
15
16/** One line the pane leads with a button: a group's header or a call. */
17export type PaneItem =
18 | Readonly<{ kind: "header"; group: Group; isFolded: boolean }>
19 | Readonly<{ kind: "row"; call: ShellCall }>;
20
21/** The pane's lines, what did not fit, and how the rows draw. */
22export interface PaneLayout extends Pick<Visible, "isCompact" | "detailRoom"> {
23 readonly items: readonly PaneItem[];
24 readonly hidden: number;
25}
26
27const CHROME = 2;
28// The runners view's summary line and limits line.
29const SUMMARY = 2;
30const ROWS_MIN = 6;
31const ROWS_MAX = 30;
32
33const sumOf = (values: readonly number[]): number =>
34 values.reduce((total, value) => total + value, 0);
35
36const openCallsOf = (
37 groups: readonly Group[],
38 folds: Folds,
39): readonly ShellCall[] =>
40 groups
41 .filter((group) => !isFolded(group, folds))
42 .flatMap((group) => group.calls);
43
44/**
45 * The pane's lines for `budget` rows: every group's header (a folded group
46 * costs one line), and the rows of open groups, as one list in group order,
47 * fitted by `visibleOf` to what the headers leave (compact, then `+N older`).
48 * @param groups the groups, most urgent first
49 * @param folds the person's fold choices
50 * @param room the expanded row's id and the lines headers and rows may take
51 * @returns the lines, the count left out, and how rows draw
52 */
53export const paneLayoutOf = (
54 groups: readonly Group[],
55 folds: Folds,
56 room: Readonly<{ selected: string; budget: number }>,
57): PaneLayout => {
58 const { selected, budget } = room;
59 const rows = visibleOf(
60 openCallsOf(groups, folds),
61 selected,
62 budget - groups.length,
63 );
64 const shown = new Set(rows.shown.map((call) => call.id));
65 return {
66 items: groups.flatMap((group): readonly PaneItem[] => [
67 { kind: "header", group, isFolded: isFolded(group, folds) },
68 ...group.calls
69 .filter((call) => !isFolded(group, folds) && shown.has(call.id))
70 .map((call) => ({ kind: "row" as const, call })),
71 ]),
72 hidden: rows.hidden,
73 isCompact: rows.isCompact,
74 detailRoom: rows.detailRoom,
75 };
76};
77
78/**
79 * The body rows to ask for when the pane opens inline: every header and the
80 * rows of open groups in full, with the toolbar and the hint, 6 to 30.
81 * @param groups the groups
82 * @param folds the person's fold choices
83 * @returns the rows
84 */
85export const rowsWantedOf = (
86 groups: readonly Group[],
87 folds: Folds,
88): number => {
89 const rows = openCallsOf(groups, folds).map((call) => rowsOf(call, ""));
90 const full = CHROME + groups.length + sumOf(rows);
91 return Math.min(ROWS_MAX, Math.max(ROWS_MIN, full));
92};
93
94/**
95 * The body rows to ask for in the runners view: the toolbar, the hint, the
96 * summary and limits lines, and every runner in full, 6 to 30.
97 * @param runners the runners
98 * @returns the rows
99 */
100export const runnerRowsWantedOf = (runners: readonly Runner[]): number => {
101 const full =
102 CHROME + SUMMARY + sumOf(runners.map(({ call }) => rowsOf(call, "")));
103 return Math.min(ROWS_MAX, Math.max(ROWS_MIN, full));
104};
105
106/**
107 * The runner calls the pane can show at its tallest: what the usage reader
108 * is worth spending on (a taller pane than asked for never exists).
109 * @param calls the list
110 * @returns the calls, most urgent first
111 */
112export const shownRunnerCallsOf = (
113 calls: readonly ShellCall[],
114): readonly ShellCall[] =>
115 visibleOf(
116 runnersOf(calls, {}).map(({ call }) => call),
117 "",
118 ROWS_MAX - CHROME - SUMMARY,
119 ).shown;
120
121/** What the pane's height depends on. */
122export interface WantedState {
123 readonly view: ShellView;
124 readonly calls: readonly ShellCall[];
125 readonly agents: AgentTable;
126 readonly folds: Folds;
127 readonly now: number;
128}
129
130/**
131 * The body rows to ask for now: the open view's own count, 6 to 30. The one
132 * place the command, the pane's keys and a restore ask.
133 * @param state the view, the calls, the agents, the folds and the time
134 * @returns the rows
135 */
136export const rowsWantedFor = (state: WantedState): number =>
137 state.view === "runners"
138 ? runnerRowsWantedOf(runnersOf(state.calls, state.agents))
139 : rowsWantedOf(groupsOf(state.calls, state.agents, state.now), state.folds);
140hooks/model/poll.ts 79 lines1/**
2 * What the poller looks at: which calls to stat, and when to read a tail.
3 */
4import type { ShellCall } from "../../types";
5import { isLive } from "./calls.ts";
6
7/** Whether a stat saw new output, and whether the call's tail is wanted. */
8export interface TailNeed {
9 readonly isNew: boolean;
10 readonly isWanted: boolean;
11}
12
13/**
14 * The calls the poller stats: live ones with an output or watch file, and
15 * runners whose verdict is still to be read.
16 * @param calls the list
17 * @returns the calls to poll
18 */
19export const watchedOf = (calls: readonly ShellCall[]): readonly ShellCall[] =>
20 calls.filter(
21 (call) =>
22 call.needsTail === true ||
23 (isLive(call) &&
24 (call.watchPath !== undefined || call.outputPath !== undefined)),
25 );
26
27/**
28 * Which file's tail to read: live progress from the watch file, else the
29 * Bash output; once finished, the output (where the guard's verdict is).
30 * @param call the call
31 * @returns the path, or undefined when there is none
32 */
33export const tailPathOf = (call: ShellCall): string | undefined =>
34 call.needsTail === true
35 ? (call.outputPath ?? call.watchPath)
36 : (call.watchPath ?? call.outputPath);
37
38/**
39 * Whether to read the call's output tail now.
40 * @param call the call
41 * @param need what the stat saw and whether the tail is wanted
42 * @returns true when the tail should be read
43 */
44export const isTailDue = (call: ShellCall, need: TailNeed): boolean =>
45 tailPathOf(call) !== undefined &&
46 (call.needsTail === true || (need.isNew && need.isWanted));
47
48const REPAIR_MS = 30_000;
49
50/**
51 * Whether a hung call should send the poller to the transcripts again: one
52 * may have ended unseen, and the last look was a while ago.
53 * @param calls the list
54 * @param now the clock's time
55 * @param repairedAt when the poller last looked
56 * @returns true when a look is due
57 */
58export const isRepairDue = (
59 calls: readonly ShellCall[],
60 now: number,
61 repairedAt: number,
62): boolean =>
63 calls.some((call) => call.status === "hung") && now - repairedAt > REPAIR_MS;
64
65/**
66 * The ids of the agents that hold a running call.
67 * @param calls the list
68 * @returns their ids
69 */
70export const liveAgentsOf = (
71 calls: readonly ShellCall[],
72): ReadonlySet<string> =>
73 new Set(
74 calls
75 .filter(isLive)
76 .map((call) => call.agentId)
77 .filter((id) => id !== undefined),
78 );
79hooks/pane.tsx 216 lines1/**
2 * The pane's drawing: reads the calls and the pane's state, and hands the
3 * view its button handlers (fold, view, clear, close, select, stop).
4 */
5import type { EngineInterface, RenderElement, RenderInput } from "claude-code";
6import { atom, read, update } from "claude-code";
7
8import type { ShellAgents, ShellCall, ShellUsage, ShellView } from "../types";
9import { finishedIds, liveOnly, noticed, tailed } from "./model/calls.ts";
10import { configOf } from "./model/config.ts";
11import { NO_LIMITS } from "./model/limits.ts";
12import { rowsWantedFor } from "./model/pane-items.ts";
13import { type PaneActions, paneTree } from "./view/pane.tsx";
14
15const NO_CALLS: readonly ShellCall[] = [];
16const NO_AGENTS: ShellAgents = {};
17const callsAtom = atom(
18 { plugin: "agent-shell-watch", key: "calls" } as const,
19 NO_CALLS,
20);
21const NO_IDS: readonly string[] = [];
22// The ids `clear` forgot, so a backfill does not bring them back.
23const clearedAtom = atom(
24 { plugin: "agent-shell-watch", key: "cleared" } as const,
25 NO_IDS,
26);
27
28const agentsAtom = atom(
29 { plugin: "agent-shell-watch", key: "agentInfo" } as const,
30 NO_AGENTS,
31);
32const NO_FOLDS: Readonly<Record<string, boolean>> = {};
33const foldsAtom = atom(
34 { plugin: "agent-shell-watch", key: "folds" } as const,
35 NO_FOLDS,
36);
37const configAtom = atom(
38 { plugin: "agent-shell-watch", key: "config" } as const,
39 configOf({}),
40);
41const viewAtom = atom(
42 { plugin: "agent-shell-watch", key: "view" } as const,
43 "agents" as ShellView,
44);
45const limitsAtom = atom(
46 { plugin: "agent-shell-watch", key: "limits" } as const,
47 NO_LIMITS,
48);
49const NO_USAGE: ShellUsage = {};
50const usageAtom = atom(
51 { plugin: "agent-shell-watch", key: "usage" } as const,
52 NO_USAGE,
53);
54const nowAtom = atom({ plugin: "agent-shell-watch", key: "now" } as const, 0);
55const openAtom = atom(
56 { plugin: "agent-shell-watch", key: "isOpen" } as const,
57 false,
58);
59const selectedAtom = atom(
60 { plugin: "agent-shell-watch", key: "selected" } as const,
61 "",
62);
63
64const PANE = "shell-watch";
65const TAIL_LINES = "40";
66
67type Engine = Readonly<EngineInterface>;
68
69// ponytail: the cleared ids list is capped; a transcript keeps 4096 entries.
70const CLEARED_MAX = 4096;
71
72const clearCalls = async ($: Readonly<EngineInterface>): Promise<void> => {
73 const calls = await read($, callsAtom);
74 await update($, clearedAtom, (ids) =>
75 [...ids, ...finishedIds(calls)].slice(-CLEARED_MAX),
76 );
77 await update($, callsAtom, liveOnly);
78};
79
80const tailOf = async ($: Engine, path: string): Promise<string | undefined> => {
81 try {
82 const ran = await $.process.run(["tail", "-n", TAIL_LINES, path]);
83 return ran.exitCode === 0 ? ran.stdout : undefined;
84 } catch {
85 return;
86 }
87};
88
89const select = async ($: Engine, id: string): Promise<void> => {
90 const selected = await update($, selectedAtom, (current) =>
91 current === id ? "" : id,
92 );
93 const calls = await read($, callsAtom);
94 const path = calls.find((one) => one.id === id)?.outputPath;
95 const text =
96 selected === id && path !== undefined ? await tailOf($, path) : undefined;
97 if (text === undefined) {
98 return;
99 }
100 await update($, callsAtom, (list) =>
101 list.map((one) => (one.id === id ? tailed(one, text) : one)),
102 );
103};
104
105const stop = async ($: Engine, taskId: string): Promise<void> => {
106 const ran = await $.tool.call({ tool: "TaskStop", task_id: taskId });
107 if (ran.deny !== undefined || ran.isError === true) {
108 return;
109 }
110 const now = await $.clock.now();
111 await update($, callsAtom, (calls) =>
112 noticed(calls, [{ taskId, status: "stopped" }], now),
113 );
114};
115
116const close = async ($: Engine): Promise<void> => {
117 await $.store.set("paneOpen", false);
118 await update($, openAtom, () => false);
119 await $.ui.close({ id: PANE });
120};
121
122const reopen = async ($: Engine): Promise<void> => {
123 const { columns } = await read($, configAtom);
124 await $.ui.open({
125 id: PANE,
126 title: PANE,
127 columns,
128 rows: rowsWantedFor({
129 view: await read($, viewAtom),
130 calls: await read($, callsAtom),
131 agents: await read($, agentsAtom),
132 folds: await read($, foldsAtom),
133 now: await $.clock.now(),
134 }),
135 focus: true,
136 });
137};
138
139const toggleView = async ($: Engine): Promise<void> => {
140 const view = await update($, viewAtom, (current) =>
141 current === "agents" ? "runners" : "agents",
142 );
143 await $.store.set("view", view);
144 await reopen($);
145};
146
147const setFolds = async (
148 $: Engine,
149 keys: readonly string[],
150 isFolded: boolean,
151): Promise<void> => {
152 await update($, foldsAtom, (known) => ({
153 ...known,
154 ...Object.fromEntries(keys.map((key) => [key, isFolded])),
155 }));
156 // An inline pane got the rows it asked for at open: ask again for what the
157 // groups now open need ("each open sets it anew"), keeping the keyboard.
158 await reopen($);
159};
160
161const actionsOf = ($: Engine): PaneActions => ({
162 clear: () => {
163 void clearCalls($);
164 },
165 close: () => {
166 void close($);
167 },
168 select: (id) => {
169 void select($, id);
170 },
171 stop: (taskId) => {
172 void stop($, taskId);
173 },
174 fold: (key, isFolded) => {
175 void setFolds($, [key], isFolded);
176 },
177 foldAll: (keys, isFolded) => {
178 void setFolds($, keys, isFolded);
179 },
180 toggle: () => {
181 void toggleView($);
182 },
183});
184
185/**
186 * `ui.render` of the `agent-shell-watch` pane.
187 * @param $ the engine
188 * @param e the pane instance to draw
189 * @returns the pane's tree
190 */
191export const onRender = async (
192 $: Engine,
193 e: Readonly<RenderInput<"Pane">>,
194): Promise<RenderElement> => {
195 const kit = $.ui.resolve(e);
196 const calls = await read($, callsAtom);
197 const now = Math.max(await read($, nowAtom), await $.clock.now());
198 return paneTree(
199 kit,
200 {
201 calls,
202 agents: await read($, agentsAtom),
203 folds: await read($, foldsAtom),
204 view: await read($, viewAtom),
205 limits: await read($, limitsAtom),
206 usage: await read($, usageAtom),
207 now,
208 selected: await read($, selectedAtom),
209 columns: e.props.bodyColumns,
210 rows: e.props.scroll.bodyRows,
211 isFocused: e.props.isFocused,
212 },
213 actionsOf($),
214 );
215};
216hooks/slash-command.ts 166 lines1/**
2 * `/shell-watch` (`runners`, `agents`, `clear`, `stop`), the pane's height
3 * and re-opening, and the pane's closing by the person.
4 */
5import type {
6 CommandRunInput,
7 CommandRunResult,
8 EngineInterface,
9 Next,
10 OpEventResult,
11 PaneCloseInput,
12 UiOpenResult,
13} from "claude-code";
14import { atom, read, update } from "claude-code";
15
16import type { ShellAgents, ShellCall, ShellView } from "../types";
17import { finishedIds, liveOnly } from "./model/calls.ts";
18import { configOf } from "./model/config.ts";
19import { rowsWantedFor } from "./model/pane-items.ts";
20
21const NO_CALLS: readonly ShellCall[] = [];
22const callsAtom = atom(
23 { plugin: "agent-shell-watch", key: "calls" } as const,
24 NO_CALLS,
25);
26const NO_IDS: readonly string[] = [];
27// The ids `clear` forgot, so a backfill does not bring them back.
28const clearedAtom = atom(
29 { plugin: "agent-shell-watch", key: "cleared" } as const,
30 NO_IDS,
31);
32
33const configAtom = atom(
34 { plugin: "agent-shell-watch", key: "config" } as const,
35 configOf({}),
36);
37const NO_AGENTS: ShellAgents = {};
38const agentsAtom = atom(
39 { plugin: "agent-shell-watch", key: "agentInfo" } as const,
40 NO_AGENTS,
41);
42const NO_FOLDS: Readonly<Record<string, boolean>> = {};
43const foldsAtom = atom(
44 { plugin: "agent-shell-watch", key: "folds" } as const,
45 NO_FOLDS,
46);
47const viewAtom = atom(
48 { plugin: "agent-shell-watch", key: "view" } as const,
49 "agents" as ShellView,
50);
51const openAtom = atom(
52 { plugin: "agent-shell-watch", key: "isOpen" } as const,
53 false,
54);
55const selectedAtom = atom(
56 { plugin: "agent-shell-watch", key: "selected" } as const,
57 "",
58);
59
60/** The pane's id and the command's name. */
61export const PANE = "shell-watch";
62
63const VIEWS: Readonly<Partial<Record<string, ShellView>>> = {
64 runners: "runners",
65 agents: "agents",
66 groups: "agents",
67};
68
69// ponytail: the cleared ids list is capped; a transcript keeps 4096 entries.
70const CLEARED_MAX = 4096;
71
72const clearCalls = async ($: Readonly<EngineInterface>): Promise<void> => {
73 const calls = await read($, callsAtom);
74 await update($, clearedAtom, (ids) =>
75 [...ids, ...finishedIds(calls)].slice(-CLEARED_MAX),
76 );
77 await update($, callsAtom, liveOnly);
78};
79
80const rowsWantedNow = async ($: Readonly<EngineInterface>): Promise<number> =>
81 rowsWantedFor({
82 view: await read($, viewAtom),
83 calls: await read($, callsAtom),
84 agents: await read($, agentsAtom),
85 folds: await read($, foldsAtom),
86 now: await $.clock.now(),
87 });
88
89// An inline pane got its height at open ("each open sets it anew"): ask again.
90const reopen = async ($: Readonly<EngineInterface>): Promise<UiOpenResult> => {
91 const { columns } = await read($, configAtom);
92 return $.ui.open({
93 id: PANE,
94 title: PANE,
95 columns,
96 rows: await rowsWantedNow($),
97 focus: true,
98 });
99};
100
101const setView = async (
102 $: Readonly<EngineInterface>,
103 view: ShellView,
104): Promise<void> => {
105 await update($, viewAtom, () => view);
106 await $.store.set("view", view);
107};
108
109/**
110 * `command.run` for `/shell-watch`: opens the pane (`runners`, `agents` or
111 * `groups` pick its view, bare keeps it), `clear` forgets finished calls,
112 * `stop` closes the pane.
113 * @param $ the engine
114 * @param e the command
115 * @returns the command's answer
116 */
117export const onCommand = async (
118 $: Readonly<EngineInterface>,
119 e: Readonly<CommandRunInput>,
120): Promise<CommandRunResult> => {
121 const argument = e.args.trim().toLowerCase();
122 if (argument === "stop" || argument === "close") {
123 await $.store.set("paneOpen", false);
124 await update($, openAtom, () => false);
125 await $.ui.close({ id: PANE });
126 return { text: "closed" };
127 }
128 if (argument === "clear") {
129 await clearCalls($);
130 await update($, selectedAtom, () => "");
131 return { text: "finished calls cleared" };
132 }
133 const view = VIEWS[argument];
134 if (view !== undefined) {
135 await setView($, view);
136 }
137 const opened = await reopen($);
138 await update($, openAtom, () => true);
139 await $.store.set("paneOpen", true);
140 return {
141 text: opened.isPlaced
142 ? "keys on the pane · Esc → prompt · /shell-watch again refocuses"
143 : "the pane waits for room",
144 };
145};
146
147/**
148 * `ui.close`: when the person closes the pane it is no longer open.
149 * @param $ the engine
150 * @param e the close
151 * @param next the rest of the chain
152 * @returns what the chain answered
153 */
154export const onClose = async (
155 $: Readonly<EngineInterface>,
156 e: Readonly<PaneCloseInput>,
157 next: Next<"ui.close">,
158): Promise<OpEventResult<"ui.close">> => {
159 // An unload (a reload, the session's end) is no choice of the person's.
160 if (e.id === PANE && e.origin.kind !== "unload") {
161 await update($, openAtom, () => false);
162 await $.store.set("paneOpen", false);
163 }
164 return next(e);
165};
166