SLOPSHOPPER

agent-shell-watch

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…

newpaneguardcommandstatusprocess
v0.7.0MITupdated 2026-10-09apolenkov/agent-shell-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-shell-watch
│ ┃ shell-watch ✕ › fix the failing auth test and add an audit log call │ ┃ [ f fold ] [ r runners ] [ c clear ] [ q clo │ ┃ No Bash calls yet. ⏺ Read(src/auth.ts) │ ┃ /shell-watch → keys ⎿ 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 │ │ › /shell-watch │ ⎿ agent-shell-watch: keys on the pane · Esc → prompt · /shell-watc │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · shell-watch
[ f fold ] [ r runners ] [ c clear ] [ q close ] No Bash calls yet. /shell-watch → keys
README

<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%">

agent-shell-watch demo: a background Codex review ticking in the status line, the /shell-watch pane, and the runners view with verdicts (FAILED, WAITING), tokens and subscription limits

<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>

ci codeql release license: MIT Claude Code ≥ 2.1.287 OpenSSF Scorecard

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.

Why

  • A delegated agent run in a background shell is a black box: you cannot tell a long review from a hung one without opening its log.
  • Failures in background calls scroll past unnoticed until the model trips over them.
  • A delegated agent that hit its rate limit or stalled says so only in a log you have to go and read.

agent-shell-watch shows at a glance that the work is moving: time ticking, output fresh, nothing failed.

Features

  • 📟 Status line while a run is live, or one failed in the last 2 minutes: the most urgent call leads, the rest are counted.
  • 🪟 /shell-watch pane, grouped by who made the calls (main and each subagent), with every call's state, command and last output line.
  • 🏃 Runners recognised: codex, pi, devin, ocr, with their live output file and their guard verdict (DONE, RATE_LIMIT, STALLED, BUSY).
  • ⚠️ Quiet and hung: a running call without new output for quietMin / hangMin minutes is flagged.
  • ⏹️ Stop button for the running background call, through TaskStop.
  • ⌨️ Keyboard first: every button shows its key; the pane remembers across sessions whether you left it open.

Install

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.

Usage

Status line

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)

The pane

[ 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 / keyWhat it does
/shell-watchOpens the pane and gives it the keyboard (refocuses it if already open)
/shell-watch runnersShows only the external agent CLIs (Pi, Devin, Codex), flat: the verdict and who started each one
/shell-watch agentsBack to the calls grouped by agent (groups works too); a bare /shell-watch keeps the view you left
/shell-watch clearForgets finished calls
/shell-watch stopCloses the pane
1–9Folds or opens a group (on its header) or expands a row: full command, last 40 lines, stderr, output and watch files
fFolds every group, or opens them all when all are folded
rFlips between the agents view and the runners view
cClears finished calls
sStops the running background call (when there is one) with TaskStop
qCloses the pane
Tab / EnterWalks the buttons / presses one
EscHands the keys back to the prompt; the pane stays

The first line's [ 1 ▾ ] holds the focus.

Runners view

/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.

Glyphs

GlyphMeaning
◐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.

Configuration

Set in /config.

OptionDefaultMeaning
columns52Width asked for the docked pane
openOnStartfalseOpen the pane at start until you have opened or closed it
maxCalls50Calls kept, the oldest finished dropped first
quietMin5Minutes without new output before a running call is quiet
hangMin10Minutes without new output before a running call is hung
statusLinetrueShow the status line
scoperunnersrunners: 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 / timerWhat agent-shell-watch does
session.startRegisters /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.appendA <task-notification> row settles its background call (status, exit code)
tick, every 1 sAdvances elapsed time and redraws the status line
poll, every 2 sfs.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.openRebuilds missed calls from $.session.messages() (main loop and running agents)
ui.close, command.runOpens 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.

Privacy

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.

Development

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).

License

MIT. engine-types/claude-code.d.ts is © Anthropic PBC and not covered by the MIT license; see engine-types/NOTICE.md.

Source 23 files
hooks/register.tsx 293 lines
1/**
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};
293
hooks/limits.ts 294 lines
1/**
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};
294
hooks/model/backfill.ts 159 lines
1/**
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};
159
hooks/model/calls.ts 373 lines
1/**
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  );
373
hooks/model/config.ts 43 lines
1/**
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});
43
hooks/model/format.ts 277 lines
1/**
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};
277
hooks/model/groups.ts 188 lines
1/**
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  );
188
hooks/model/limits.ts 335 lines
1/**
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);
335
hooks/model/pane-items.ts 140 lines
1/**
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);
140
hooks/model/poll.ts 79 lines
1/**
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  );
79
hooks/pane.tsx 216 lines
1/**
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};
216
hooks/slash-command.ts 166 lines
1/**
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