Every Claude Code session, background job and open pull request on this machine in one live pane, with alerts when one fails or waits for you.

llm-fleet is a Claude Code plugin that keeps every session and background job on your machine in one live pane. You see which jobs are working, which wait for you and which failed, with each agent's model, step, context use and cost. A band above the prompt carries what needs you, and an alert reaches you when a job fails or starts waiting.
The machine's load, pull requests, worktrees, skill scans and your own sections can join the pane when you switch them on. Everything it reads comes from one configuration file, so nothing about your machine is written into the plugin.
Type this at the prompt of a Claude Code session:
/plugin install llm-fleet --marketplace doricstack/llm-fleet
Answer y to add the marketplace, then pick a scope. The one-step install needs Claude Code 2.1.295 or later. The readers are Python scripts, so you also need Python 3.11 or newer. On Windows the interpreter is python, not python3: set the plugin's Python option to python when the first /llm-fleet says the readers could not start.
Claude Code installs a version and keeps it: it does not follow the plugin's source, and a session that is already open keeps what it loaded when it started. After a new release, update the plugin and restart:
/plugin update llm-fleet
A session started before the update has no pane until it restarts. From a checkout of this repository, make host-update does the same for every Claude home on the machine and reads each one back to prove it took.
/llm-fleet setup
/llm-fleet doctor
/llm-fleet
/llm-fleet setup writes a starter configuration holding the Claude Code homes it finds on this machine and nothing else. It never overwrites a file that is already there. The default place is $XDG_CONFIG_HOME/llm-fleet/config.json, then %APPDATA%\llm-fleet\config.json on Windows, then ~/.config/llm-fleet/config.json.
/llm-fleet doctor tells you what the pane is missing and the setting that fixes each thing: the interpreter, where the configuration was read from, each home and source with its state, and the commands the readers start. /llm-fleet opens the pane, and running it again closes it.
/llm-fleet opens or closes the pane, and /llm-fleet close only closes it./llm-fleet unhide brings back the band items you dismissed./llm-fleet doctor and /llm-fleet setup are the two commands from the first run./worktrees opens the pane on its worktrees section with a fresh probe./keys opens the pane on its keys section, and /keys tips off turns off the key tip under the prompt./speak picks this session's voice or switches speech on or off in every running session, once you have configured a speech command. A session started later joins speech as it stands: on when it is on in any session, off when it is off everywhere. Off stops the line playing and every line waiting.Three options live in /config under the plugin, because they are needed before the configuration file can be read:
python3 by default.Everything else is in the configuration file. Each section can be shown or hidden (pane.sections.<name>.visible), folded at the start (collapsed), and capped at a row count (maxRows), and the order is the one you set. Sessions, background jobs, agent cards and this session's own cost, context and limits are on from the start. Pull requests, worktrees, trials, progress, skill scans and a recorded statusline payload are sources you switch on, and the doctor names the key each one needs. Speech is off until you set speech.enabled and a speech command. A key the schema does not know is refused by name rather than ignored. The full list of settings, with defaults, is in the configuration reference.
To follow an external system's off switch, set sources.progress.paused_when_exists or sources.trials.paused_when_exists to its marker path. Both are optional and default to null (never paused). An existing marker replaces that source's rows with one paused line and prevents data reads; removing it resumes reads on the next refresh. ~/ expands from HOME, relative paths resolve beside the configuration file, and absolute paths follow the running platform. A disabled source stays disabled.
The plugin reads the session roster through the claude command, transcripts and task lists under the homes you configure, and, when you switch it on, pull requests through the gh command. The outside commands the readers start (claude, gh, git, ps, lsof, the speech command and your own sections' commands) run with your Anthropic variables and the common provider API key variables removed from their environment, and the configuration can add to that list but never shorten it.
The pane draws in the terminal, the Claude desktop app and the VS Code extension, on the same render site each app provides.
Nothing about you or your sessions leaves your machine through the plugin. It holds no key and reads no credential to send anywhere. Every fact in the pane is read on this machine and drawn on it. A desktop notification ($.ui.notify) is shown by Claude Code on this machine and goes nowhere else, and the only messages it sends are $.session.send notes to the other Claude sessions on the same machine, each holding a job identifier and a short line such as a speech reply. It reads the conversation only through the transcripts and events the host hands it, never sends that text anywhere, and never changes it.
The plugin makes one network request of its own, on by default: once a day it reads each model's public rate card from the page its provider publishes (Anthropic's pricing page for Claude, and the DeepSeek, Kimi and MiMo pricing pages for theirs), so a session's cost is priced at the rate the host it ran on charges. Each is a plain HTTPS GET for a public page, sent with no credential, cookie, session text, model usage or identifier of yours, from a background readers/rates.py --refresh that the pane's read starts and never waits on. sources.rates.enabled: false turns it off, and the pane then prices from the starter table it ships with; sources.rates.cards names the pages, so you can point a model at the host you use or drop one.
Two more things reach the network, and only because you turn them on: the pull-request source asks GitHub through your own gh login, and a custom section runs whatever command you give it. The one web address in the pane is a pull request's page on github.com, opened only when you press its row.
Every hook either answers its own command or drawing and returns, or passes the event on unchanged with next(event). It registers no permission event and never decides whether a tool call may run; that stays with you and your settings. Its command.run hooks are matched to the four commands it registers, so no other command passes through it.
| Hook | What it does |
|---|---|
session.start | Registers /llm-fleet, /speak, /worktrees and /keys, and reads this session's identity. |
command.run | Answers those four commands and nothing else. |
ui.render | Draws the pane, the band above the prompt and the key tip under it. |
ui.close | Records that the pane shut and, for Escape, marks the digest it showed as seen. |
prompt.submit | Records this session as the one you are typing in, so alerts reach you here, and lands any write a paint left waiting. It hands the prompt on unchanged and never reads, edits or drops its text. |
session.end | Stops the timers this session started. |
Every program runs as your user, from the list below, with its arguments built by the plugin and never through a shell.
| Program | Why |
|---|---|
Your Python interpreter (the plugin option, python3 by default) on the plugin's own scripts: readers/snapshot.py, readers/alerts.py, readers/tab.py, readers/doctor.py, readers/setup.py, readers/governance.py, readers/pacer.py and readers/rates.py | The reading behind the pane, alerts and speech, the terminal tab's color, the two first-run commands, the governance and pacer switches, and the daily rate-card refresh, started detached after a read when a card is older than a day. The statusline scripts (readers/host.py, readers/session.py, readers/transcript.py) run only when your statusline starts them. |
tmux show-options -gqv allow-passthrough and tmux display-message -p #{client_tty} | Inside tmux only: whether the tab can be colored, and which terminal to color. |
claude | The session roster. |
ps | The machine's load, and which terminal a session runs in. |
git and lsof | A worktree's branch and state, and which process holds it, when the worktrees source is on. |
gh | Pull requests, when that source is on. |
| Commands your configuration names | The speech command, your custom sections, the governance switch, pacer (asked for its status, and given on or off when you press its switch), and the cleanup, skill report and process-list commands. None runs until you configure it. |
The outside commands run with your Anthropic variables and the common provider API key variables removed from their environment, and the configuration can add to that list but never shorten it.
/llm-fleet setup, and never over a file already there.$XDG_STATE_HOME/llm-fleet, %LOCALAPPDATA%\llm-fleet or ~/.local/state/llm-fleet): which alert was claimed, which reply was sent, whose turn the voice is, each terminal tab's color, and the rate cards last read with the time each was read (records/rate-cards.json).The hooks module reads terminal identity only: TERM_PROGRAM, TMUX, ITERM_SESSION_ID and LC_TERMINAL. The Python scripts read FLEET_CONFIG, HOME, USERPROFILE, CLAUDE_CONFIG_DIR, PATH, TMPDIR and the XDG and Windows folder variables to find your configuration, homes and commands, and hand the rest of the environment, minus the removed variables above, to the commands they start. None of it is sent anywhere.
Every mods API call it makes is part of Claude Code's own surface, $.ui.notify included; it adds no namespace and calls no method another plugin adds. The paint recolors the element tree it was handed by walking that tree's props and rewriting the color values in it (components/palette.ts); it reads nothing else and draws nothing new.
The plugin collects nothing about you and sends nothing to Doric Stack: everything it reads stays on your machine, as the sections above set out. Doric Stack's Privacy Policy covers the company's own site and support requests.
MIT.
hooks/llm-fleet.ts 3909 lines1/**
2 * llm-fleet: the pane, drawn by the engine.
3 *
4 * This is the entry the engine loads. It is deliberately thin: it registers one
5 * command, opens one pane, and hands the reading it already has to
6 * `plugins/llm-fleet/components/surface.ts`, which decides the tree. Everything the
7 * pane shows is therefore testable without the engine, and this file holds only
8 * what needs it: the plugin's own root, the resolved element table, the process
9 * runner, and the clock.
10 *
11 * A render hook paints: it is asked for a tree while the surface is drawing, so
12 * it must answer from what it already holds. The reading therefore happens off
13 * the paint, in a refresh this module owns, and the pane is asked to draw again
14 * once it lands. A hook that awaited the read command instead would be waiting
15 * inside a paint, and the surface would draw the pane empty while it waited.
16 *
17 * The engine gives a render hook its element constructors through `$.ui.resolve`,
18 * which is exactly the subset this repository declares in `plugins/llm-fleet/components/host.ts`.
19 * Nothing here writes JSX: the layout builds the tree as data, so the same code
20 * that a terminal draws is the code the review renderer measures.
21 *
22 * The band above the prompt is drawn here too: it holds the switch that opens
23 * and closes the pane, because a typed command waits behind a running turn and a
24 * press does not, and beside it the fleet's figures and what needs a person.
25 *
26 * Alerts are raised here too, from each fresh reading: a toast in the session
27 * the person typed in last, one native notification per machine, and the voice
28 * saying a waiting job aloud once per machine. Which session is focused and
29 * which one takes an event are decided by a helper over files every session
30 * shares, because each session runs its own copy of this plugin.
31 *
32 * Speech is one switch for the whole machine, kept in the store every session
33 * reads: the band's Speak button or `/speak on|off` in any session turns it on
34 * or off in all the sessions running, and a session that starts later joins it
35 * as it stands: on when it is on anywhere, off when it is off everywhere.
36 * Off is a hard stop: a session that hears it stops the clip it is playing and
37 * drops every line it was going to say. The voice is each session's own: `/speak male` here leaves the
38 * session beside it speaking as it did. A session says one clip at a time, and
39 * the helper holds the machine's voice for each clip, so two sessions never
40 * speak over each other.
41 *
42 * A press on a row copies what that row offers: a background job the line that
43 * opens it, a worktree its cleanup line, the skills status the report command.
44 * A waiting job takes a reply in a field under its question, sent to its own
45 * session once the job is asked again what it asks now, so a reply typed for an
46 * old question never lands on a new one.
47 *
48 * The terminal tab is tinted by the band's level where the terminal draws a
49 * tab color, and given its own color back when the band clears or the session
50 * ends. While a draft is typed the hint line under the prompt ends with a key
51 * tip. `/worktrees` and `/keys` open the pane on one section, and the folds a
52 * person leaves are kept for the next session.
53 *
54 * A `-p` run or an SDK host has nobody to show any of this to, so it starts no
55 * timer and reads nothing on its own.
56 *
57 * Not built here, and named so nothing pretends to have it: the switch advice,
58 * which was a separate plugin.
59 */
60
61import type { ReaderDocuments } from "../components/snapshot.ts";
62import type { Engine, Register } from "./engine.ts";
63import type { Governance, Job, Pacer } from "../components/state.ts";
64import type { PaneConfig } from "../components/options.ts";
65import type { FleetDigestSeen, FleetFold } from "../types/index.d.ts";
66import { GEOMETRY, paneConfigFrom, paneOptions, SECTIONS } from "../components/options.ts";
67import { recolor, THEME } from "../components/palette.ts";
68import { CLOSE, DETAILS_PREFIX, FOCUS_CLEAR, FOCUS_PREFIX, HIDE_PREFIX,
69 REPLY_PENDING_SECONDS, replyKey, replyText, SHOW_ALL_PREFIX, TOGGLE_ALL,
70 TOGGLE_PREFIX, unreadableKey, PACER_SWITCH,
71 type Focus, type PaneActions, type PaneOptions, type RepliesPaused }
72 from "../components/pane.ts";
73import { mask } from "../components/mask.ts";
74import { pendingSources, stepStream } from "../components/stream.ts";
75import { isReading, isRefusal, surfaceTree } from "../components/surface.ts";
76import { answeredSources, bandConfigFrom, bandCounts, bandItems, bandTree, hiddenLimitFrom,
77 keptHidden } from "../components/band.ts";
78import { bindPrimitives, drawsFields, mount, type HostElements } from "../components/mount.ts";
79import { snapshotFrom } from "../components/snapshot.ts";
80import { newEvents, type FleetEvent } from "../components/alerts.ts";
81import { sessionDocument, withSession } from "../components/session.ts";
82import { animate, BLINK_MS, motionMemory, SLIDE_MS, type Motion } from "../components/motion.ts";
83import type { Pane } from "../components/layout.ts";
84import { firstName, speakRequest, speakStatus, speechInfo, SPEAK_USAGE, voiceList, voiceOrder,
85 voiceReply, voiceSetting, type SpeechInfo, type VoiceSetting }
86 from "../components/speech.ts";
87import { afterSend, endNeeded, nextSend, passesThrough, serially, tabArgs, tabLevel, tabSender,
88 tabTerminal, TINT_DEFAULTS, tintFrom, type TabLevel, type TabSender, type TabTerminal,
89 type TabTint } from "../components/tab.ts";
90import { KEYS_USAGE, keysRequest, tipAt, tipsShown, withTip } from "../components/tips.ts";
91import { ENTRIES, entryArgv, entryEnv, LAUNCH, launchFrom, type Entry, type Launch }
92 from "../components/entries.ts";
93
94/** The pane's id, which is also the requestId its render hook is keyed by. */
95export const PANE = "fleet";
96/** Carried in the pane's title and its messages, so which plugin is loaded is visible. */
97export const MARK = "llm-fleet";
98/** Where the read command lives inside the plugin. */
99export const READER = ENTRIES.snapshot;
100/** How long a reading is drawn before another is asked for. */
101export const STALE_SECONDS = 20;
102/**
103 * How far ahead of this clock a reading or a claim may be dated and still count
104 * as current. Another session writes them between this session's clock read and
105 * its store read, so a few milliseconds ahead is the store being quick, not a
106 * clock that is wrong; an hour ahead is a clock that is wrong.
107 */
108export const AHEAD_SECONDS = 2;
109/**
110 * How often a session looks at the reading every session shares, until a
111 * reading says otherwise (`refresh.snapshotMs`). A look is one store read; the
112 * machine is read only by the one session that finds the shared reading old and
113 * claims the read.
114 */
115export const LOOK_MS = 3_000;
116/**
117 * How old a live governor run's counts may get while a pane shows them, until a
118 * reading says otherwise (`refresh.progressMs`).
119 */
120export const LIVE_MS = 1_000;
121/**
122 * How often an open pane is drawn with nothing new held, until a reading says
123 * otherwise (`refresh.clockMs`): ages and run times are counted from the paint's
124 * clock, and a pane drawn only when a reading landed showed them still and then
125 * leaping.
126 */
127export const CLOCK_MS = 1_000;
128/**
129 * How far apart the sections a new reading changed land on the pane, until a
130 * reading says otherwise (`refresh.streamStepMs`): the first lands at once and
131 * the rest follow it down the pane, rather than every one changing in one frame.
132 */
133export const STREAM_MS = 150;
134/**
135 * How long a claim to read for every session stands before another session may
136 * take it over: longer than a read may run, so only a claim whose session died
137 * mid-read is taken over.
138 */
139export const CLAIM_SECONDS = 35;
140/** Where the session reading the whole machine for every session says so. */
141export const CLAIM_KEY = "reading.claim";
142/** Where the session reading a live governor run's counts for every session says so. */
143export const PROGRESS_CLAIM_KEY = "progress.claim";
144/**
145 * The interval the pane is drawn at while something on it moves, and only then:
146 * about twelve frames a second, enough for a row to read as sliding.
147 */
148export const FRAME_MS = 80;
149/**
150 * How long after a paint a write it asked for is made: at the next tick, which
151 * the host runs as an event of its own rather than as part of the paint.
152 */
153export const AFTER_PAINT_MS = 1;
154/** The longest a session's end waits for the folds and the last look it is still writing. */
155export const END_WRITES_MS = 2_000;
156/** The most frames one movement may ask for, so frames stop even if no paint comes. */
157const FRAME_LIMIT = Math.ceil((BLINK_MS + SLIDE_MS) / FRAME_MS) + 2;
158/**
159 * Where the last reading is kept, in the plugin's own store.
160 *
161 * The store is the one place a plugin keeps a value between sessions, so this
162 * is what lets the pane open on the fleet as it last stood instead of on a
163 * sentence while the read command runs.
164 */
165export const READING_KEY = "last-reading";
166/**
167 * Where a live governor run's counts are kept, apart from the whole reading.
168 *
169 * A count read never writes the whole reading back. The store has no
170 * compare-and-set, so a count read that rewrote the whole reading could put an
171 * older fleet over a newer one another session had just written; kept apart,
172 * each key is written by one kind of read only, and the two are put together
173 * where they are drawn.
174 */
175export const COUNTS_KEY = "live-counts";
176/**
177 * Where the items a person dismissed from the band are kept, in the plugin's
178 * own store, so a dismissal holds in the next session as well.
179 */
180export const HIDDEN_KEY = "band-hidden";
181/**
182 * The session state holding which sections a person folded. The host keeps
183 * state under the plugin's own name and reads this reference from the source,
184 * so the name is spelled here once, as the literal the manifest carries.
185 */
186const FOLDED = { plugin: "llm-fleet", key: "folded" } as const;
187/**
188 * The session state holding which sections a person pressed to draw in full.
189 *
190 * A section past its ceiling holds rows back, and the press is what opens them
191 * for the rest of the session. Kept beside the fold for the same reason: a hot
192 * reload of the plugin is not a reason to draw the section short again.
193 */
194const SHOWN_ALL = { plugin: "llm-fleet", key: "shownAll" } as const;
195/**
196 * The session state holding the sections a person put away.
197 *
198 * Beside the folds, because it is the same kind of thing: a choice about the
199 * pane that lasts for this session and crosses a hot reload. It stays out of
200 * the resolved configuration for the same reason the folds do, so a reading
201 * that lands with a new order cannot walk over a press.
202 */
203const HIDDEN = { plugin: "llm-fleet", key: "hidden" } as const;
204/**
205 * Where the replies sent from a pane are kept, by job and the hash of the
206 * question answered, in the store every session reads: a job answered from one
207 * pane is not offered the field again in the pane beside it.
208 */
209export const REPLIED_KEY = "replied";
210/** How many replies are remembered, until a reading says otherwise (`pane.repliedLimit`). */
211export const REPLIED_LIMIT = 50;
212/** The longest reply sent, until a reading says otherwise (`pane.replyMaxChars`). */
213export const REPLY_MAX_CHARS = 4000;
214/** Where focus, claims and the voice are decided, inside the plugin. */
215export const ALERTS = ENTRIES.alerts;
216/**
217 * Where a voice picked before voices were each session's own was kept. A
218 * session with no voice of its own starts from it, and nothing writes it now.
219 */
220export const VOICE_KEY = "voice";
221/** Where a session keeps the voice it picked, under its own identifier. */
222/**
223 * Whether a reading already in hand was taken without this session's identifier.
224 *
225 * The reader prices a routed session from the status line's own file for the
226 * session it is told about, and prices the transcript when it is told none. The
227 * identifier arrives on a promise of its own, so a read can finish first and
228 * carry a figure that is not this session's money: that reading is read again
229 * once the identifier is here.
230 */
231export function needsSessionRead(hasReading: boolean, sessionId: string,
232 readWith: string): boolean {
233 return hasReading && sessionId !== "" && sessionId !== readWith;
234}
235
236export function sessionVoiceKey(sessionId: string): string {
237 return VOICE_KEY + ":" + sessionId;
238}
239/**
240 * When speech was switched on, in epoch milliseconds, for every session running
241 * then; `false`, absent, or anything else is off.
242 */
243export const SPEECH_ON_KEY = "speech.on";
244
245/**
246 * Whether the stored switch speaks.
247 *
248 * The switch is the machine's, so a session that starts after the press joins
249 * it: speech on anywhere is on here. Only a press writes the on value, the time
250 * it was made; `false`, nothing, or a value from before presses were stamped,
251 * plain `true`, is off.
252 */
253export function speechIsOn(value: unknown): boolean {
254 return typeof value === "number";
255}
256/** How often a session looks for the switch another session pressed. */
257export const SPEECH_POLL_MS = 1_500;
258/**
259 * Where the folds a person left are kept, in the plugin's own store, so the
260 * next session opens with them. The host's `fleet.folded` holds them for this
261 * session across a reload; this holds them across sessions.
262 */
263export const FOLDS_KEY = "folds";
264/** Where the person's own word on the key tips is kept: false is off, anything else is no word. */
265export const TIPS_KEY = "key-tips";
266/**
267 * The last look for the "since you left" digest: `{ at }`, the newest event a
268 * digest drew when its pane closed. It is kept twice.
269 *
270 * The first is the session's own, held by the host in its state: it outlives a hot reload,
271 * every load of the session shares it, and a write is made only at the version
272 * it read, so two loads closing at once never move it back. It is exact.
273 *
274 * The second, `SEEN_KEY`, is the look every session shares, in the store, so a new session
275 * does not show what was seen in another. The store has no compare-and-set, so
276 * two sessions closing in the same moment can leave the earlier of their looks
277 * there; then another session's next digest shows a few events again. A session
278 * always starts from the later of the two, so its own look is never moved back.
279 */
280export const SEEN_KEY = "digest-seen";
281const SEEN_HELD = { plugin: "llm-fleet", key: "digestSeen" } as const;
282/** Where the tab color is written, inside the plugin. */
283export const TAB_HELPER = ENTRIES.tab;
284/** What the read command takes to probe every worktree now rather than answer from its cache. */
285export const FRESH_WORKTREES = "--refresh-worktrees";
286/**
287 * What a whole read takes to start a refresh of the rate cards beside it when
288 * one is due. The read never waits on it: the next read prices from what it kept.
289 */
290export const FRESH_RATES = "--refresh-rates";
291/** What asks the read command for the configuration's parts alone, with no reader run. */
292export const SETTINGS_ONLY = "--settings";
293/** How long the settings may take: a load of the configuration, with no source read. */
294export const SETTINGS_MS = 10_000;
295
296/**
297 * How alerts are raised and how often the machine is looked at, as the reading
298 * carries it, and what stands in before one lands.
299 */
300type AlertConfig = { toast: boolean; notify: boolean; toastMs: number; snapshotMs: number;
301 synthesisMs: number; snapshotSeconds: number; progressMs: number;
302 copyToastMs: number; tabCommandMs: number; keyTipMs: number; tint: TabTint;
303 helperMs: number; worktreeHelperMs: number; clockMs: number;
304 streamStepMs: number };
305const ALERT_DEFAULTS: AlertConfig = { toast: true, notify: true, toastMs: 8000,
306 snapshotMs: LOOK_MS, synthesisMs: 30_000,
307 snapshotSeconds: STALE_SECONDS, progressMs: LIVE_MS,
308 copyToastMs: 6000, tabCommandMs: 5_000, keyTipMs: 30_000,
309 tint: TINT_DEFAULTS, helperMs: 25_000, worktreeHelperMs: 60_000,
310 clockMs: CLOCK_MS, streamStepMs: STREAM_MS };
311
312/** The alert settings a reading carries, each one the default when it is not a usable value. */
313export function alertConfigFrom(documents: unknown): AlertConfig {
314 const carried = (documents as { alerts?: Record<string, unknown> } | null)?.alerts;
315 if (typeof carried !== "object" || carried === null) {
316 return ALERT_DEFAULTS;
317 }
318 const flag = (key: "toast" | "notify") =>
319 typeof carried[key] === "boolean" ? carried[key] as boolean : ALERT_DEFAULTS[key];
320 const span = (key: "toastMs" | "snapshotMs" | "synthesisMs" | "snapshotSeconds"
321 | "progressMs" | "copyToastMs" | "tabCommandMs" | "keyTipMs"
322 | "helperMs" | "worktreeHelperMs" | "clockMs" | "streamStepMs") => {
323 const value = carried[key];
324 return typeof value === "number" && Number.isFinite(value) && value >= 1
325 ? value : ALERT_DEFAULTS[key];
326 };
327 // A reading written by a build from before the shared reading carries the
328 // interval that build read on, thirty seconds, and none of the intervals the
329 // shared reading is aged by: taken here, it would slow every session drawing
330 // it, and the panes would drift apart again.
331 const shared = typeof carried.progressMs === "number";
332 return { toast: flag("toast"), notify: flag("notify"), toastMs: span("toastMs"),
333 snapshotMs: shared ? span("snapshotMs") : ALERT_DEFAULTS.snapshotMs, synthesisMs: span("synthesisMs"),
334 snapshotSeconds: span("snapshotSeconds"), progressMs: span("progressMs"),
335 copyToastMs: span("copyToastMs"), tabCommandMs: span("tabCommandMs"),
336 keyTipMs: span("keyTipMs"), tint: tintFrom(carried.tabTint),
337 helperMs: span("helperMs"), worktreeHelperMs: span("worktreeHelperMs"),
338 clockMs: span("clockMs"), streamStepMs: span("streamStepMs") };
339}
340
341/**
342 * How many replies the pane remembers, as the reading carries it: a whole
343 * number of at least one, or the packaged default for anything else.
344 */
345export function repliedLimitFrom(documents: unknown): number {
346 return paneNumberFrom(documents, "repliedLimit", REPLIED_LIMIT);
347}
348
349/**
350 * One of the pane's whole-number settings as the reading carries it, or the
351 * packaged default for a reading without it or a value that is not one.
352 */
353export function paneNumberFrom(documents: unknown,
354 key: "repliedLimit" | "replyMaxChars" | "replyPendingSeconds",
355 fallback: number): number {
356 const carried = (documents as { pane?: Record<string, unknown> } | null)?.pane?.[key];
357 return typeof carried === "number" && Number.isInteger(carried) && carried >= 1
358 ? carried : fallback;
359}
360
361/**
362 * Which sections are folded, by name: the value held under `fleet.folded`, so
363 * its type is the one the contract declares to the engine.
364 */
365type Fold = FleetFold;
366
367/**
368 * What the pane draws from: the last reading, or the reason there is none.
369 *
370 * One of these per registration rather than one for the module: the engine
371 * loads a plugin once, so a registration is a session's view of the machine,
372 * and a reading is not state that outlives it.
373 */
374type Held = { documents: unknown; at: number; progressAt?: number } | { reason: string; at: number };
375type State = {
376 held: Held | null;
377 /** Whether a look at the store is out. */
378 looking: boolean;
379 /** Whether this session is reading the whole machine for every session. */
380 readingWhole: boolean;
381 /** Whether this session is reading a live run's counts for every session. */
382 counting: boolean;
383 hydrated: boolean;
384 /**
385 * Which sections are folded, by name, once anything has been pressed.
386 *
387 * Only what a person pressed, not what the pane draws: the configuration
388 * decides every section nobody touched, and a reading that lands while the
389 * pane is open brings its own configuration with it. Keeping the two apart is
390 * what lets a fresh reading's configuration take effect for the sections
391 * nobody has an opinion about, while the presses stand.
392 *
393 * It lives for this session and nowhere else, and the host holds it: a press
394 * folds a section for now, and a hot reload of the plugin keeps it.
395 */
396 pressed: Fold;
397 /** Whether the fold this session was left in has been asked for. */
398 restored: boolean;
399 /** Which sections a person pressed to draw in full, by name. */
400 shownAll: Fold;
401 /**
402 * The sections a person put away, by name.
403 *
404 * Kept apart from the folds: a fold changes how much of a section is read,
405 * while a section put away is not read here at all. Both are presses, so both
406 * live in this session's state and outrank the configuration.
407 */
408 putAway: Fold;
409 /** Whether the sections this session opened with put away have been asked for. */
410 putAwayRestored: boolean;
411 /**
412 * The state a press on a headline figure narrowed the pane to.
413 *
414 * It lives for this session and only while it is pressed: the figures at the
415 * top keep counting the whole machine, and the clear control beside them
416 * widens the pane again.
417 */
418 focus: Focus | null;
419 /** The rows a person pressed open, by the key the row carries. */
420 details: Record<string, true>;
421 /**
422 * The governance scope whose switch-off is waiting for its confirming press.
423 *
424 * Switching a scope on is one press; switching one off takes two, so this is
425 * what the pane is told while the first press stands.
426 */
427 confirmingGovernance: "governor" | "controller" | null;
428 /** Whether pacer's switch-off waits for the press that confirms it. */
429 confirmingPacer: boolean;
430 /** When that first press was made, in the clock's milliseconds. */
431 pacerArmedAt: number;
432 /** Whether the sections this session was left in full have been asked for. */
433 shownAllRestored: boolean;
434 /** The band items a person dismissed, by key. */
435 hidden: string[];
436 /** The replies sent from a pane on this machine, by `replyKey`, newest last. */
437 replied: string[];
438 /** The jobs this session is sending a reply to now, by their claim key. */
439 replying: Set<string>;
440 /** The pending replies cleared from this pane, by `replyKey` and the time each was recorded. */
441 cleared: string[];
442 /** Why the helper last refused a reply for the whole machine, until one goes or a press clears it. */
443 paused: RepliesPaused | null;
444 /** Whether the pane was open at the last answer or press, for when the engine gives none. */
445 open: boolean;
446 /** This session's identifier, so the band leaves out its own job; empty until known. */
447 sessionId: string;
448 /** The identifier the reading in hand was taken with, so a later one can tell it priced this session. */
449 sessionIdRead: string;
450 /** The model this session runs, for pricing a routed session; empty until the engine says. */
451 modelId?: string;
452 /** The event keys already raised, or null before the first fresh reading. */
453 seen: string[] | null;
454 /** How alerts are raised, from the last reading. */
455 alerts: AlertConfig;
456 /** Whether `alerts` came from the configuration rather than the packaged defaults. */
457 timesKnown: boolean;
458 /** Whether this registration may raise native notifications at all. */
459 notify: boolean;
460 /** What the helper says about speech here, once asked. */
461 speech: SpeechInfo | null;
462 /** The voice a person picked, once read from the store. */
463 voice: VoiceSetting | null;
464 /** The voice that spoke last, tried first next time. */
465 voiceName: string | null;
466 /**
467 * How many times a voice has been picked here, so a line that started before
468 * a pick and finished after it does not put its own voice over the pick.
469 */
470 voiceSelection: number;
471 /** The session the voice was read for, so one read before the identifier landed is read again. */
472 voiceFor: string;
473 /** Whether speech is on in every session, as the store last said; null before it has. */
474 speechOn: boolean | null;
475 /** The clip playing now, so speech switched off stops it mid-word. */
476 playing: AbortController | null;
477 /** The clips this session says, each one after the one before. */
478 voiceQueue: Promise<unknown>;
479 /** Moved on each time speech is heard off, so every line asked for before it is dropped. */
480 speechEpoch: number;
481 /** The voice picks, run one after another so two never interleave. */
482 voicePicks: Promise<unknown>;
483 /** The timer that follows the switch other sessions press. */
484 speechTimer: { cancel(): void } | null;
485 /** The timer that looks at the shared reading, drawn or not, so alerts are not late. */
486 timer: { cancel(): void } | null;
487 /** The interval that timer runs at, so a look that needs another one re-arms it. */
488 timerMs: number;
489 /** Whether the session has ended, so a look still out does not start the timer again. */
490 ended: boolean;
491 /** What this registration writes in a claim, so it knows its own claim from another's. */
492 token: string;
493 /** This session's figures as the engine last reported them, once it has. */
494 engineSession: Record<string, unknown> | null;
495 /** Whether the engine is being asked for them now. */
496 measuring: boolean;
497 /** Whether it has been asked at all, so a paint asks at most once on its own. */
498 measured: boolean;
499 /** What the last paint drew and what is moving since, kept across paints. */
500 motion: Motion;
501 /** The frames running while something moves, and how many each has asked for. */
502 frames: { cancel(): void } | null;
503 framed: number;
504 /**
505 * What the pane draws: the held reading's sections as far as they have
506 * landed, so a reading that changed several lands them one after another.
507 */
508 shown: Record<string, unknown> | null;
509 /** The held reading `shown` has fully landed, so a paint of it steps nothing. */
510 shownFrom: unknown;
511 /** When the counts the pane draws were read, so run times age from then. */
512 shownCountsAt: number | undefined;
513 /** The timer landing the sections still to land, and those sections in line. */
514 streamTimer: { cancel(): void } | null;
515 streamQueue: string[];
516 /** The timer drawing an open pane so its ages count, and the interval it runs at. */
517 clock: { cancel(): void } | null;
518 clockMs: number;
519 /**
520 * Whether a person is at the prompt. A `-p` run or an SDK host is not
521 * interactive, and starts no timer of its own.
522 */
523 interactive: boolean;
524 /** Whether `/worktrees` asked for every worktree probed now, on the next look. */
525 freshAsked: boolean;
526 /** The terminal the session runs in, once asked; null before, and where it could not be. */
527 terminal: TabTerminal | null;
528 /** The color this session gave up on, and the write in flight. */
529 tab: TabSender;
530 /** What names the tab this session draws in, once asked; null for a tab nothing names. */
531 tabId: string | null;
532 /** The person's own word on the key tips, from the store; undefined until read. */
533 tipsWord: unknown;
534 /** Whether `/keys tips` set the word in this session, which the startup read then leaves alone. */
535 tipsSet: boolean;
536 /** The turn the key tip is at, and the timer that moves it on while a draft is typed. */
537 tipTurn: number;
538 tipTimer: { cancel(): void } | null;
539 /** The interval that timer runs at, so a reading that changes it re-arms it. */
540 tipMs: number;
541 /** The tab's heartbeat, at a third of the staleness, and the interval it runs at. */
542 tabBeat: { cancel(): void } | null;
543 tabBeatMs: number;
544 /** The fold saves to the store, one after another, so the last press is the one kept. */
545 foldSaves: Promise<unknown>;
546 /** Whether the person has been told the folds could not be saved, so it is said once. */
547 foldToast: boolean;
548 /** Whether the folds the last session left have been read, or found absent. */
549 foldsLoaded: boolean;
550 /** Whether a press landed before they were, so the merge is saved once they are. */
551 foldsDirty: boolean;
552 /** The sections pressed before they were, saved over what the store holds once they are. */
553 foldsPending: Fold;
554 /** This load's last write of the held fold, so its writes land in the order made. */
555 foldWrites: Promise<unknown>;
556 /** The read of the folds a session opens with, which adds writes of its own when it lands. */
557 foldRestore: Promise<unknown>;
558 /**
559 * The person's last look, for the digest: undefined until the store answers,
560 * null when it holds none, and the newest event a closed pane's digest drew.
561 */
562 lastSeen: number | null | undefined;
563 /** Whether the last look has been asked of the store since the pane last closed. */
564 seenAsked: boolean;
565 /** The newest event the digest has drawn since the pane opened; null while it drew none. */
566 digestThrough: number | null;
567 /** This session's last write of its own look, so its writes land in the order made. */
568 seenWrite: Promise<unknown>;
569 /** The writes a paint asked for, made at the next tick rather than inside it. */
570 afterPaint: (() => void)[];
571 /** The tick those writes wait on; null while none waits. */
572 afterPaintTick: { cancel(): void } | null;
573};
574
575/**
576 * What the store holds under `READING_KEY`: the last reading, when it was taken,
577 * and when its governor counts were last read on their own, when they were.
578 */
579type Carried = { documents: unknown; at: number; progressAt?: number };
580
581/**
582 * Whether a value the store handed back is a reading.
583 *
584 * Anything can be under that key: a value from an older version of this plugin,
585 * one written while the process died, or something else entirely. A value that
586 * is not a reading is not drawn as one, and the test for that is the surface's
587 * own, so a stored value can never be a shape the pane would refuse to draw.
588 */
589function isCarried(value: unknown): value is Carried {
590 if (typeof value !== "object" || value === null || Array.isArray(value)) {
591 return false;
592 }
593 const candidate = value as { documents?: unknown; at?: unknown };
594 return typeof candidate.at === "number" && Number.isFinite(candidate.at)
595 && isReading(candidate.documents as ReaderDocuments | null);
596}
597
598/** What the store holds under `COUNTS_KEY`: a live run's counts and when they were read. */
599type Counts = { at: number; progress: Record<string, unknown> };
600
601/** Whether a value the store handed back is a count read that worked. */
602function isCounts(value: unknown): value is Counts {
603 if (typeof value !== "object" || value === null || Array.isArray(value)) {
604 return false;
605 }
606 const candidate = value as { at?: unknown; progress?: unknown };
607 return typeof candidate.at === "number" && Number.isFinite(candidate.at)
608 && typeof candidate.progress === "object" && candidate.progress !== null
609 && READ_STATES.has((candidate.progress as { state?: unknown }).state);
610}
611
612/** What a failed run printed, when what it printed is a refusal document. */
613function refusalFrom(stdout: string): ReaderDocuments | null {
614 try {
615 const printed: unknown = JSON.parse(stdout);
616 return isRefusal(printed as ReaderDocuments | null) ? printed as ReaderDocuments : null;
617 } catch {
618 return null;
619 }
620}
621
622/**
623 * Whether the host killed the run rather than the run failing.
624 *
625 * A run belongs to the work that started it, and the host is documented to kill
626 * a child when the signal it ran under aborts. Scrolling the pane is enough to
627 * supersede the request a read was started from, and what comes back is the
628 * host saying it stopped the command, not a command that answered. Read as a
629 * failure it draws "could not read the fleet" over a machine that was never
630 * asked, which is the pane inventing a fault.
631 */
632function cancelled(error: unknown): boolean {
633 if (typeof error !== "object" || error === null) {
634 return false;
635 }
636 if ((error as { name?: unknown }).name === "AbortError") {
637 return true;
638 }
639 return /\babort/i.test(error instanceof Error ? error.message : String(error));
640}
641
642/**
643 * How this load starts the plugin's scripts, from the options the host handed
644 * `register`. The options are fixed for a load, and a change to one reloads the
645 * module, which sets this again; so it is set once per load and read by every
646 * start, rather than threaded through each caller.
647 */
648let launch: Launch = LAUNCH;
649
650/** Start one of the plugin's scripts under the person's interpreter and configuration. */
651function runEntry($: Engine, entry: Entry, args: readonly string[], timeoutMs: number) {
652 const env = entryEnv(launch);
653 return $.process.run(entryArgv($.plugin.root, entry, args, launch),
654 env === undefined ? { timeoutMs } : { timeoutMs, env });
655}
656
657/** How long `/llm-fleet doctor` and `/llm-fleet setup` may take: a load and a look, no source read. */
658export const ASK_MS = 10_000;
659
660/** What a person is told when the configured interpreter could not run a script. */
661function interpreterAdvice(entry: string, detail: string): string {
662 return `fleet ${entry} could not run under ${launch.python} (${detail}). Set the plugin's `
663 + "Python interpreter option in /config to a Python 3.11 or newer on this machine.";
664}
665
666/**
667 * Run `/llm-fleet doctor` or `/llm-fleet setup` and answer with what the script printed.
668 *
669 * Both print lines meant for a person, so their output is the answer. A script
670 * that printed nothing is one the interpreter never ran, which is the one thing
671 * neither script can say for itself, so that answer names the option that sets
672 * the interpreter. A starter just written changes the configuration under the
673 * session, so its read times are asked for again before the next read.
674 */
675async function askEntry($: Engine, state: State, entry: "doctor" | "setup"): Promise<string> {
676 let run: Awaited<ReturnType<typeof runEntry>>;
677 try {
678 run = await runEntry($, entry, [], ASK_MS);
679 } catch (error) {
680 if (cancelled(error)) {
681 return `fleet ${entry} was stopped before it answered.`;
682 }
683 return interpreterAdvice(entry, error instanceof Error ? error.message : String(error));
684 }
685 const text = run.stdout.trim();
686 if (!text) {
687 const said = run.stderr.trim().split("\n")[0] ?? "";
688 return interpreterAdvice(entry, "exit " + run.exitCode + (said ? ": " + said : ""));
689 }
690 if (entry === "setup" && run.exitCode === 0) {
691 state.timesKnown = false;
692 return text + " The pane reads it on its next read of the machine.";
693 }
694 return text;
695}
696
697/**
698 * Reads once, without throwing: a read that failed is a state the pane names.
699 *
700 * Returns null for the one outcome that is not an answer of any kind, which is
701 * the host cancelling the run. Nothing is held and nothing is reported.
702 */
703async function readOnce($: Engine, timeoutMs: number, extra: string[] = []): Promise<Held | null> {
704 try {
705 const run = await runEntry($, "snapshot", ["--pretty", ...extra], timeoutMs);
706 const at = (await $.clock.now()) / 1000;
707 if (run.exitCode !== 0) {
708 // The reader answers a refused configuration on its standard output and
709 // exits nonzero: the refusal is a result, and the pane draws every error
710 // it holds. Only a nonzero exit with nothing usable to show is named by
711 // its code, and the exit is what says the run did not succeed.
712 const refusal = refusalFrom(run.stdout);
713 return refusal === null
714 ? { reason: "the read command exited " + run.exitCode, at }
715 : { documents: refusal, at };
716 }
717 const documents: unknown = JSON.parse(run.stdout);
718 if (documents === null) {
719 // Valid JSON, and not a reading: nothing a reader prints is null. Left as
720 // a document it would draw as a read still in flight, which is a failure
721 // reported as patience.
722 return { reason: "the read command printed no reading", at };
723 }
724 return { documents, at };
725 } catch (error) {
726 if (cancelled(error)) {
727 return null;
728 }
729 const message = error instanceof Error ? error.message : String(error);
730 // A failure carries the time it happened like any other answer: a pane that
731 // treated one as always stale would start another read on every paint.
732 return { reason: "the read command did not answer: " + message,
733 at: (await $.clock.now()) / 1000 };
734 }
735}
736
737/**
738 * The read times from the configuration alone, for a session that has held no reading.
739 *
740 * The times travel in the reading, so a session with none would give its first
741 * read the packaged time, and a configured source slower than that would end
742 * every read before the reading carrying the longer time could land. This
743 * answer runs no reader, so it comes back quickly. One that fails or is refused
744 * leaves the packaged times, and the next read asks again.
745 *
746 * False only when the host cancelled the run.
747 */
748async function learnTimes($: Engine, state: State): Promise<boolean> {
749 const answer = await readOnce($, SETTINGS_MS, [SETTINGS_ONLY]);
750 if (answer === null) {
751 return false;
752 }
753 const documents = "documents" in answer ? answer.documents : null;
754 // A reading another session kept may have landed while this was out, and
755 // brought the times with it: those stand, and this answer is not laid over them.
756 if (!state.timesKnown && typeof documents === "object" && documents !== null && !Array.isArray(documents)
757 && !("configErrors" in documents) && "alerts" in documents) {
758 state.alerts = alertConfigFrom(documents);
759 state.timesKnown = true;
760 }
761 return true;
762}
763
764/**
765 * Ask for a reading, and ask the pane to draw again once it lands.
766 *
767 * Never awaited by a hook: the answer arrives between paints, which is the only
768 * place a process may run.
769 */
770/**
771 * Ask the engine for this session's figures, off the paint, and draw again once
772 * they land. Never throws: an engine that cannot answer leaves the reading's
773 * own session document standing.
774 */
775function measure($: Engine, state: State): void {
776 if (state.measuring) {
777 return;
778 }
779 state.measuring = true;
780 state.measured = true;
781 void Promise.all([
782 $.session.usage(),
783 $.session.model().catch(() => null),
784 $.session.version().catch(() => null),
785 $.clock.now(),
786 ]).then(([usage, model, version, now]) => {
787 if (typeof model === "string" && model) {
788 state.modelId = model;
789 }
790 const document = sessionDocument(usage, model, version, state.sessionId, now);
791 if (document !== null) {
792 state.engineSession = document;
793 $.ui.invalidate("ui.render");
794 }
795 }).catch(() => undefined).finally(() => { state.measuring = false; });
796}
797
798function stopFrames(state: State): void {
799 state.frames?.cancel();
800 state.frames = null;
801 state.framed = 0;
802}
803
804/**
805 * The pane with what is moving on it marked, and frames asked for while it moves.
806 *
807 * Frames are an interval that asks for a paint, started when something begins
808 * to move and stopped by the first paint that finds nothing moving. The interval
809 * also stops itself after the longest movement there is, so a pane closed in the
810 * middle of a slide does not go on asking to be drawn.
811 */
812function moved($: Engine, state: State, pane: Pane, nowMs: number): Pane {
813 const result = animate(pane, state.motion, nowMs);
814 if (result.moving && state.frames === null) {
815 state.framed = 0;
816 state.frames = $.clock.every(FRAME_MS, () => {
817 state.framed += 1;
818 if (state.framed > FRAME_LIMIT) {
819 stopFrames(state);
820 return;
821 }
822 $.ui.invalidate("ui.render");
823 });
824 } else if (!result.moving && state.frames !== null) {
825 stopFrames(state);
826 }
827 return result.pane;
828}
829
830/**
831 * Land one more of the held reading's sections on what the pane draws, and
832 * date the counts when the step brought the governor's.
833 */
834function landStep(state: State, held: { documents: unknown; at: number; progressAt?: number },
835 only?: string): string[] {
836 const before = state.shown;
837 const step = stepStream(before, documentsOf(held), only);
838 state.shown = step.documents;
839 // The counts are as old as the read that brought them: a live run's counts
840 // carry their own time, and a whole reading's are as old as it is.
841 if (before === null || (step.documents !== null && before.progress !== step.documents.progress)) {
842 state.shownCountsAt = held.progressAt ?? held.at;
843 }
844 return step.pending;
845}
846
847/** A held reading's sections, or null for one that holds no object. */
848function documentsOf(held: { documents: unknown }): Record<string, unknown> | null {
849 return typeof held.documents === "object" && held.documents !== null
850 ? held.documents as Record<string, unknown> : null;
851}
852
853function stopStream(state: State): void {
854 state.streamTimer?.cancel();
855 state.streamTimer = null;
856}
857
858/**
859 * What the pane draws of the held reading: what changed lands a section at a
860 * time, in the order the pane stacks them.
861 *
862 * The first section that changed lands on the paint that finds it, so a lone
863 * change is drawn at once; the rest follow a step apart on a timer that stops
864 * once nothing is left to land. A whole reading every twenty seconds otherwise
865 * changed every section in one frame, which is the jerk a person sees.
866 */
867function streamed($: Engine, state: State,
868 held: { documents: unknown; at: number; progressAt?: number }): unknown {
869 // A paused source replaces its rows on this paint, without waiting behind
870 // another section's transition.
871 const target = documentsOf(held);
872 if (state.shown !== null && target !== null) {
873 for (const source of ["progress", "trials"]) {
874 if ((target[source] as { state?: unknown } | null)?.state === "paused") {
875 state.shown = { ...state.shown, [source]: target[source] };
876 if (source === "progress") {
877 state.shownCountsAt = held.progressAt ?? held.at;
878 }
879 }
880 }
881 }
882 // A paint of the reading already landed has nothing to step, and one while
883 // the timer runs leaves the stepping to it.
884 if (state.streamTimer !== null || state.shownFrom === held.documents) {
885 return state.shown;
886 }
887 // A first reading lands whole, and one that changed no section lands its
888 // settings at once: only the sections ripple.
889 const pending = landStep(state, held);
890 if (pending.length === 0) {
891 state.shownFrom = held.documents;
892 return state.shown;
893 }
894 // The sections land in the order they were found changed. One read again
895 // while the stream runs goes to the back of the line rather than the front:
896 // stepped by the pane's order each time, a section read faster than the
897 // stream steps took the first place on every step, and the ones after it
898 // never landed.
899 state.streamQueue = pending;
900 state.streamTimer = $.clock.every(state.alerts.streamStepMs, () => {
901 const now = state.held !== null && !("reason" in state.held) ? state.held : null;
902 const queued = state.streamQueue.shift();
903 if (now !== null && !state.ended && queued !== undefined) {
904 landStep(state, now, queued);
905 }
906 // What changed again while the line was landing is the next line.
907 if (now !== null && !state.ended && state.streamQueue.length === 0) {
908 state.streamQueue = pendingSources(state.shown, documentsOf(now));
909 }
910 if (now === null || state.ended || state.streamQueue.length === 0) {
911 state.shownFrom = now?.documents;
912 stopStream(state);
913 }
914 $.ui.invalidate("ui.render");
915 });
916 return state.shown;
917}
918
919/**
920 * Keep an open pane drawn once a tick so the ages on it count, and stop when it
921 * closes or the session ends.
922 */
923function keepTime($: Engine, state: State): void {
924 if (state.clock !== null && state.clockMs === state.alerts.clockMs) {
925 return;
926 }
927 state.clock?.cancel();
928 state.clockMs = state.alerts.clockMs;
929 try {
930 state.clock = $.clock.every(state.clockMs, () => {
931 if (!state.open || state.ended) {
932 state.clock?.cancel();
933 state.clock = null;
934 return;
935 }
936 $.ui.invalidate("ui.render");
937 });
938 } catch {
939 // No clock: the ages move when something else draws the pane.
940 state.clock = null;
941 }
942}
943
944/**
945 * Look at the machine, off the paint, and ask the pane to draw again when what
946 * it holds changed.
947 *
948 * Never awaited by a hook: the answer arrives between paints, which is the only
949 * place a process may run.
950 */
951function refresh($: Engine, state: State, from: "timer" | "paint" | "press" = "timer"): void {
952 // Every paint of a stale pane lands here while a look is out, so nothing
953 // before this line may ask for a frame: asking on every paint drew sixty
954 // frames a second until the read landed. A paint asks only because the whole
955 // reading is old, and while this session is reading it the read's landing is
956 // what draws the pane again: a look per paint would ask the store the same
957 // question on every frame of a slide.
958 if (state.looking || state.ended || (from === "paint" && state.readingWhole)) {
959 return;
960 }
961 state.looking = true;
962 void share($, state)
963 .catch(() => undefined)
964 .finally(() => { state.looking = false; });
965}
966
967/**
968 * One look, shared with every other session on the machine.
969 *
970 * Each session used to read the machine on its own timer and draw what it read,
971 * so two panes side by side showed two moments, up to an interval apart, and
972 * every session paid for a read. Now the store holds one reading for all of
973 * them: a session draws the newest one there, and only a session that finds it
974 * old reads the machine, under a claim the others respect, and writes what it
975 * read back for everyone. While a governor run is live its counts are read on
976 * their own every second, the same way, which is a read of one file, and kept
977 * under their own key.
978 */
979async function share($: Engine, state: State): Promise<void> {
980 const [stored, counts, replied] = await Promise.all([
981 $.store.get(READING_KEY).catch(() => undefined),
982 $.store.get(COUNTS_KEY).catch(() => undefined),
983 $.store.get(REPLIED_KEY).catch(() => undefined),
984 ]);
985 heardReplies($, state, replied);
986 if (isCarried(stored) && newer(stored, state.held)) {
987 adopt($, state, stored);
988 }
989 if (isCounts(counts)) {
990 fold($, state, counts);
991 }
992 // Read once the store has answered: a reading another session wrote during
993 // that round trip is dated after a clock read taken before it.
994 const now = (await $.clock.now()) / 1000;
995 try {
996 look($, state, now);
997 } finally {
998 // The look runs faster while an open pane shows a live run, and slows again
999 // once it does not, whichever way this look went.
1000 arm($, state);
1001 }
1002}
1003
1004/**
1005 * Start a read of what is old: the whole machine, a live run's counts while a
1006 * pane shows them, or both.
1007 *
1008 * Neither waits for the other. A whole read takes one to three seconds, and the
1009 * counts used to wait it out, so a pane streaming a run's counts every second
1010 * froze for that long every twenty seconds and then jumped.
1011 */
1012function look($: Engine, state: State, now: number): void {
1013 // A probe `/worktrees` asked for is a whole read already, whatever the age.
1014 readFresh($, state);
1015 const held = state.held;
1016 const interval = state.alerts.snapshotSeconds;
1017 const age = held === null ? Number.POSITIVE_INFINITY : now - held.at;
1018 // A reading dated well ahead of this clock is as unusable as an old one: every
1019 // age drawn from it is negative, and it never ages into being read again.
1020 if ((age >= interval || age < -AHEAD_SECONDS) && !state.readingWhole) {
1021 state.readingWhole = true;
1022 void underClaim($, state, CLAIM_KEY, now, () => readWhole($, state))
1023 .finally(() => {
1024 state.readingWhole = false;
1025 arm($, state);
1026 // A probe `/worktrees` asked for while this read was out starts now.
1027 readFresh($, state);
1028 });
1029 }
1030 // The counts are dated when the look decided to read them, and are due half
1031 // an interval early: dated when the read finished, a look one interval later
1032 // would find them a read's length short of due and skip, and the counts would
1033 // move every other interval.
1034 const every = state.alerts.progressMs / 1000;
1035 if (state.open && held !== null && "documents" in held && liveRuns(held.documents)
1036 && now - (held.progressAt ?? held.at) >= every / 2 && !state.counting) {
1037 state.counting = true;
1038 void underClaim($, state, PROGRESS_CLAIM_KEY, now, () => readProgress($, state, now))
1039 .finally(() => {
1040 state.counting = false;
1041 arm($, state);
1042 });
1043 }
1044}
1045
1046/**
1047 * Read the whole machine now, with every worktree probed rather than answered
1048 * from its cache, whatever age the shared reading is, once `/worktrees` asked.
1049 *
1050 * Started by a look, never by the command: a probe of every worktree takes
1051 * seconds per worktree, and a process the command starts is the command's own
1052 * work. In a live session a probe the command waited for ended at 15 seconds
1053 * with nothing printed, and under the host's test kit one it left running never
1054 * ran. A look is where every read of the machine starts.
1055 *
1056 * It takes no claim, since another session's read in flight may be answering
1057 * from the cache. A read of this session's own in flight is let finish first,
1058 * and this one starts as it lands, so two reads never write this session's
1059 * reading at once.
1060 */
1061function readFresh($: Engine, state: State): void {
1062 if (!state.freshAsked || state.readingWhole || state.ended) {
1063 return;
1064 }
1065 state.freshAsked = false;
1066 state.readingWhole = true;
1067 void readWhole($, state, true)
1068 .catch(() => undefined)
1069 .finally(() => {
1070 state.readingWhole = false;
1071 arm($, state);
1072 });
1073}
1074
1075/** Run a read under a claim for every session, and let the claim go after, whatever the read did. */
1076async function underClaim($: Engine, state: State, key: string, now: number,
1077 read: () => Promise<void>): Promise<void> {
1078 try {
1079 if (!(await claim($, state, key, now))) {
1080 return;
1081 }
1082 try {
1083 await read();
1084 } finally {
1085 await release($, state, key);
1086 }
1087 } catch {
1088 // A read that threw leaves what this session holds standing.
1089 }
1090}
1091
1092/** When a reading's newest part was read: the whole, or its governor counts since. */
1093function readAt(held: Held | Carried): number {
1094 return Math.max(held.at, "progressAt" in held && typeof held.progressAt === "number"
1095 ? held.progressAt : 0);
1096}
1097
1098/**
1099 * Whether the shared reading is a newer whole reading than this session holds.
1100 *
1101 * By when the whole was read: counts this session folded in since are kept apart
1102 * and say nothing about the rest. The same whole reading is newer only when a
1103 * session from before the counts were kept apart folded fresher counts into it.
1104 */
1105function newer(stored: Carried, held: Held | null): boolean {
1106 if (held === null) {
1107 return true;
1108 }
1109 if (stored.at !== held.at) {
1110 return stored.at > held.at;
1111 }
1112 return readAt(stored) > readAt(held);
1113}
1114
1115/**
1116 * A whole reading's documents with the counts this session already holds, when
1117 * those were read after the whole reading's own.
1118 *
1119 * A whole read reads the counts as it starts and lands up to a few seconds later,
1120 * so taking its counts over ones read meanwhile would step a run backwards.
1121 */
1122function withNewerCounts(before: Held | null, documents: unknown,
1123 progressAt: number): { documents: unknown; progressAt: number } {
1124 if ((documents as { progress?: { state?: unknown } } | null)?.progress?.state === "paused") {
1125 return { documents, progressAt };
1126 }
1127 if (before === null || !("documents" in before) || before.progressAt === undefined
1128 || before.progressAt <= progressAt) {
1129 return { documents, progressAt };
1130 }
1131 const progress = (before.documents as { progress?: unknown } | null)?.progress;
1132 return { documents: { ...(documents as Record<string, unknown>), progress },
1133 progressAt: before.progressAt };
1134}
1135
1136/** Fold counts another session read into what this session draws, when they are newer. */
1137function fold($: Engine, state: State, counts: Counts): void {
1138 const held = state.held;
1139 if (held === null || !("documents" in held) || counts.at <= (held.progressAt ?? held.at)) {
1140 return;
1141 }
1142 state.held = { documents: { ...(held.documents as Record<string, unknown>),
1143 progress: counts.progress },
1144 at: held.at, progressAt: counts.at };
1145 $.ui.invalidate("ui.render");
1146 // A run's failures are band items, so new counts can move the tab's level.
1147 void tint($, state);
1148}
1149
1150/**
1151 * Draw a reading another session took, as though this one had.
1152 *
1153 * Its news is raised here like a reading of this session's own, since this
1154 * session may never read the machine itself; what was already seen is not
1155 * raised again, and the first reading a session holds starts its record.
1156 */
1157function adopt($: Engine, state: State, stored: Carried): void {
1158 const fresh = state.held === null || !("documents" in state.held) || stored.at > state.held.at;
1159 const kept = withNewerCounts(state.held, stored.documents,
1160 typeof stored.progressAt === "number" ? stored.progressAt : stored.at);
1161 state.held = { documents: kept.documents, at: stored.at, progressAt: kept.progressAt };
1162 state.alerts = alertConfigFrom(stored.documents);
1163 state.timesKnown = true;
1164 limitHidden(state, stored.documents);
1165 void raise($, state, stored.documents as ReaderDocuments, readAt(stored));
1166 void tint($, state);
1167 // The session's own figures are asked for with each whole reading, so they
1168 // age with it rather than freezing at the first paint.
1169 if (fresh) {
1170 remeasure($, state);
1171 }
1172 $.ui.invalidate("ui.render");
1173}
1174
1175/** Ask the engine for this session's figures, without letting a fault stop a reading. */
1176function remeasure($: Engine, state: State): void {
1177 try {
1178 measure($, state);
1179 } catch {
1180 // An engine with no session figures: the reading's document stands.
1181 }
1182}
1183
1184/**
1185 * Read the whole machine, hold it, raise its news, and share it.
1186 *
1187 * `fresh` has the worktrees probed now rather than answered from their cache,
1188 * which is what `/worktrees` asks for.
1189 */
1190async function readWhole($: Engine, state: State, fresh = false): Promise<void> {
1191 remeasure($, state);
1192 if (!state.timesKnown && !(await learnTimes($, state))) {
1193 // Cancelled with the paint that started it, as the read below may be.
1194 $.ui.invalidate("ui.render");
1195 return;
1196 }
1197 // The read reads the counts as it starts, so its counts are dated then.
1198 const started = (await $.clock.now()) / 1000;
1199 // A probe of every worktree takes longer than a read from their cache.
1200 const asked = state.sessionId;components/snapshot.ts 961 lines1/**
2 * The snapshot, assembled from what the readers printed.
3 *
4 * The readers are a separate process, so what arrives here is parsed JSON and not
5 * a value this program built. Every field is therefore checked before it is used:
6 * a document that is missing, of the wrong shape, or naming a state this layer
7 * does not know becomes a source that failed, never a group that quietly reads as
8 * empty. That distinction is the whole reason this function exists rather than a
9 * cast.
10 */
11
12import type {
13 AgentFigures, FanEntry, Job, ModelSpend, RateAccount, JobReply, Lease, Load, LoadProcess, Progress, PullRequest, PullRequests, ReaderState, Roster, Run, SkillItem, Skills,
14 SessionFigures, Snapshot, SourceState, Trial, TrialBucket, TrialHistory, Trials, Worktree,
15 Worktrees, Change, Custom, CustomRow, CustomSection, CheckRun, DigestEvent, Events, SpendSeries,
16 TimelineSegment, Governance, Pacer, PacerRun, PacerSlot, PacerWaiter } from "./state.ts";
17
18export type ReaderDocuments = {
19 roster?: unknown;
20 trials?: unknown;
21 session?: unknown;
22 progress?: unknown;
23 prs?: unknown;
24 skills?: unknown;
25 worktrees?: unknown;
26 load?: unknown;
27 custom?: unknown;
28 events?: unknown;
29 governance?: unknown;
30 pacer?: unknown;
31};
32
33const READER_STATES = new Set<ReaderState>([
34 "disabled", "unconfigured", "unavailable", "ready", "empty", "failed", "paused",
35]);
36
37const WORKTREE_STATES = new Set<Worktree["state"]>([
38 "merged", "held", "active", "unknown", "preserved",
39]);
40
41// The states that say whether a branch is merged: active and preserved say it is
42// not, merged says it is. Held says only that a process has the folder open.
43const MERGE_CLAIMS = new Set<Worktree["state"]>(["merged", "active", "preserved"]);
44
45/**
46 * Whether a worktree's state rests on facts the row carries. Held needs a
47 * process seen holding it; merged, active and preserved need the merge, the
48 * archive and the holders all answered, the last with nothing holding it. A
49 * state without its facts is drawn as unknown, never as the claim.
50 */
51function believed(state: Worktree["state"], merged: boolean | null, archived: boolean | null,
52 held: boolean | null): boolean {
53 if (state === "held") {
54 return held === true;
55 }
56 if (MERGE_CLAIMS.has(state)) {
57 return merged !== null && archived !== null && held === false;
58 }
59 return true;
60}
61
62// A provider has a configuration state, not a reader state: whether a home was
63// read is a property of the roster, not of the home.
64const PROVIDER_STATES = new Set<SourceState>([
65 "disabled", "unconfigured", "unavailable", "ready",
66]);
67
68function isObject(value: unknown): value is Record<string, unknown> {
69 return typeof value === "object" && value !== null && !Array.isArray(value);
70}
71
72function text(value: unknown, fallback = ""): string {
73 return typeof value === "string" ? value : fallback;
74}
75
76function maybeText(value: unknown): string | null {
77 return typeof value === "string" ? value : null;
78}
79
80function count(value: unknown): number {
81 return typeof value === "number" && Number.isFinite(value) ? value : 0;
82}
83
84function maybeCount(value: unknown): number | null {
85 return typeof value === "number" && Number.isFinite(value) ? value : null;
86}
87
88function list(value: unknown): unknown[] {
89 return Array.isArray(value) ? value : [];
90}
91
92/**
93 * The moment the read finished, as the reading recorded it.
94 *
95 * The reader stamps the top level of what it prints, and that stamp is the
96 * reading's, not the paint's: a reading drawn a second after it was taken and
97 * one drawn on the next session's first frame are the same reading, and the age
98 * beside the counts has to say which. A reading with no usable clock of its own
99 * falls back to the moment the caller read it, which is what a document set
100 * that never carried one used to get.
101 */
102function readingClock(documents: ReaderDocuments, readAt: number): number {
103 const claimed = (documents as { generated_at?: unknown }).generated_at;
104 // A stamp later than the read that carries it is inconsistent: the reader
105 // stamps its own clock before it returns. Drawn as-is it would date the
106 // reading to the future and clamp to an age of nothing, so the recorded time
107 // wins, being the one that cannot be wrong.
108 return typeof claimed === "number" && Number.isFinite(claimed) && claimed <= readAt
109 ? claimed
110 : readAt;
111}
112
113/**
114 * The state a document claims.
115 *
116 * Absent means no read happened, so the source is empty; present but not an
117 * object, or naming a state this layer does not know, means the read went wrong
118 * and is reported as failed. Reading the second as empty would claim nothing is
119 * running when nothing could be read.
120 */
121function stateOf(document: unknown, fallback: ReaderState): ReaderState {
122 if (document === undefined || document === null) {
123 return fallback;
124 }
125 if (!isObject(document)) {
126 return "failed";
127 }
128 const claimed = document.state;
129 return typeof claimed === "string" && READER_STATES.has(claimed as ReaderState)
130 ? claimed as ReaderState
131 : "failed";
132}
133
134function errorsOf(document: unknown): { source: string; reason: string }[] {
135 if (!isObject(document)) {
136 return [];
137 }
138 return list(document.errors).filter(isObject).map((entry) => ({
139 source: text(entry.source, "unknown"),
140 reason: text(entry.reason, "no reason was given"),
141 }));
142}
143
144/** A count the reader published, kept only when it is one: whole and not negative. */
145function tokenCount(value: unknown): number | undefined {
146 return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
147}
148
149/**
150 * A card's figures, kept field by field, and only as the reader published them.
151 *
152 * Anything that is not the shape it should be is left out rather than repaired:
153 * a token total missing one of its four parts is no total, a step count whose
154 * done exceeds its total is no step count, and a cost that is not a finite,
155 * non-negative number is no cost. Each absence draws as the words for it.
156 */
157/**
158 * A model's rate account as the reader wrote it, or null for one not written whole.
159 *
160 * A card read is dated and a table rate is not; an account claiming either
161 * the other way is dropped rather than drawn as a date nobody read.
162 */
163function rateAccountOf(value: unknown): RateAccount | null {
164 if (!isObject(value)) {
165 return null;
166 }
167 const state = value.state;
168 if (state !== "live" && state !== "cached" && state !== "table") {
169 return null;
170 }
171 const checked = value.checkedAt;
172 const dated = typeof checked === "number" && Number.isFinite(checked) && checked >= 0;
173 if (state === "table" ? checked !== null : !dated) {
174 return null;
175 }
176 const account: RateAccount = { state, checkedAt: dated ? checked : null };
177 if (typeof value.reason === "string" && value.reason) {
178 account.reason = value.reason;
179 }
180 return account;
181}
182
183/**
184 * The per-model split as the reader wrote it, keeping only whole rows.
185 *
186 * A row with no turn count, or a cost that is neither a non-negative number
187 * nor null, is dropped rather than drawn: a null cost is a model no rate
188 * prices, and a missing one would read as free.
189 */
190function modelsOf(value: unknown): ModelSpend[] | undefined {
191 if (!Array.isArray(value)) {
192 return undefined;
193 }
194 const rows: ModelSpend[] = [];
195 for (const item of value) {
196 if (!isObject(item)) {
197 continue;
198 }
199 const turns = tokenCount(item.turns);
200 const cost = item.costUsd;
201 const priced = typeof cost === "number" && Number.isFinite(cost) && cost >= 0;
202 if (turns === undefined || (!priced && cost !== null)) {
203 continue;
204 }
205 const row: ModelSpend = {
206 model: typeof item.model === "string" && item.model ? item.model : null,
207 turns,
208 costUsd: priced ? cost : null,
209 };
210 const rates = rateAccountOf(item.rates);
211 if (rates !== null) {
212 row.rates = rates;
213 }
214 rows.push(row);
215 }
216 return rows;
217}
218
219function agentOf(value: unknown): AgentFigures | undefined {
220 if (!isObject(value)) {
221 return undefined;
222 }
223 const state = stateOf(value, "failed");
224 const figures: AgentFigures = { state };
225 if (typeof value.reason === "string") {
226 figures.reason = value.reason;
227 }
228 figures.title = maybeText(value.title);
229 figures.model = maybeText(value.model);
230 figures.effort = maybeText(value.effort);
231 const turns = tokenCount(value.turns);
232 if (turns !== undefined) {
233 figures.turns = turns;
234 }
235 const context = tokenCount(value.contextTokens);
236 if (context !== undefined) {
237 figures.contextTokens = context;
238 }
239 const window = tokenCount(value.contextWindow);
240 if (window !== undefined && window > 0) {
241 figures.contextWindow = window;
242 }
243 if (isObject(value.tokens)) {
244 const parts = ["input", "cacheWrite", "cacheRead", "output"].map(
245 (key) => tokenCount((value.tokens as Record<string, unknown>)[key]));
246 if (parts.every((part) => part !== undefined)) {
247 const [input, cacheWrite, cacheRead, output] = parts as number[];
248 figures.tokens = { input, cacheWrite, cacheRead, output };
249 }
250 }
251 const cost = value.costUsd;
252 if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0
253 && (value.costSource === "host" || value.costSource === "estimate"
254 || value.costSource === "floor")) {
255 figures.costUsd = cost;
256 figures.costSource = value.costSource;
257 }
258 const rates = rateAccountOf(value.rates);
259 if (rates !== null) {
260 figures.rates = rates;
261 }
262 const models = modelsOf(value.models);
263 if (models !== undefined) {
264 figures.models = models;
265 }
266 figures.lowerBound = value.lowerBound === true;
267 figures.firstActivity = maybeCount(value.firstActivity);
268 figures.lastActivity = maybeCount(value.lastActivity);
269 const steps = value.steps;
270 if (isObject(steps)) {
271 const done = tokenCount(steps.done);
272 const total = tokenCount(steps.total);
273 figures.steps = done !== undefined && total !== undefined && total > 0 && done <= total
274 ? { done, total, phase: maybeText(steps.phase) } : null;
275 } else {
276 figures.steps = null;
277 }
278 figures.timeline = timelineOf(value.timeline);
279 return figures;
280}
281
282/**
283 * An ISO 8601 time that states its zone, as epoch seconds, or null.
284 *
285 * The readers write UTC with a `Z`. A time with no zone is read by the runtime
286 * as this machine's local time, which is a guess at what the writer meant, so it
287 * is refused rather than placed.
288 */
289function isoSeconds(value: unknown): number | null {
290 if (typeof value !== "string" || !/(Z|[+-]\d\d:\d\d)$/.test(value)) {
291 return null;
292 }
293 const parsed = Date.parse(value);
294 return Number.isFinite(parsed) ? parsed / 1000 : null;
295}
296
297/**
298 * A session's working and idle stretches, or null when there is no timeline to
299 * draw: not read, not a list, or holding a stretch that is not two times in
300 * order with a state. One bad stretch refuses the whole, because a strip drawn
301 * with a hole in it says the session was doing nothing there.
302 */
303function timelineOf(value: unknown): TimelineSegment[] | null {
304 if (!Array.isArray(value)) {
305 return null;
306 }
307 const segments: TimelineSegment[] = [];
308 for (const entry of value) {
309 const start = isObject(entry) ? isoSeconds(entry.start) : null;
310 const end = isObject(entry) ? isoSeconds(entry.end) : null;
311 if (!isObject(entry) || start === null || end === null || end < start
312 || typeof entry.state !== "string" || !entry.state) {
313 return null;
314 }
315 segments.push({ start, end, state: entry.state });
316 }
317 return segments;
318}
319
320/**
321 * The roster's spend over its window, kept whole or not at all: a bucket that is
322 * not a figure, or a figure below nothing, is a series that cannot be drawn, and
323 * a line drawn with that bucket as a zero would say the hour cost nothing.
324 */
325function spendSeriesOf(value: unknown): SpendSeries | null {
326 if (!isObject(value) || !Array.isArray(value.buckets)) {
327 return null;
328 }
329 const minutes = value.bucket_minutes;
330 if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes <= 0) {
331 return null;
332 }
333 const buckets: number[] = [];
334 for (const bucket of value.buckets) {
335 if (typeof bucket !== "number" || !Number.isFinite(bucket) || bucket < 0) {
336 return null;
337 }
338 buckets.push(bucket);
339 }
340 const total = value.total;
341 if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
342 return null;
343 }
344 return { bucket_minutes: minutes, buckets, total, complete: value.complete === true,
345 sessions: wholeCount(value.sessions) };
346}
347
348/**
349 * A pull request's checks by name and state. An entry that is not a check is
350 * dropped; one with no name keeps its dot under the empty name, as the reader
351 * sends it, so the dots still add up to the checks it listed.
352 */
353function checkRunsOf(value: unknown[]): CheckRun[] {
354 return value.filter((entry): entry is Record<string, unknown> =>
355 isObject(entry) && typeof entry.name === "string" && typeof entry.state === "string")
356 .map((entry) => ({ name: entry.name as string, state: entry.state as string }));
357}
358
359/**
360 * The digest's events, newest first as the reader wrote them.
361 *
362 * An event whose time does not read, or whose words are not text, is dropped:
363 * the digest is a list of what happened, and an event placed at a guessed time
364 * would land on the wrong side of the last look. A ready document whose events
365 * are not a list is not a quiet stretch either, and reads as failed.
366 */
367function eventsFrom(document: unknown): Events | undefined {
368 if (document === undefined || document === null) {
369 return undefined;
370 }
371 let state = stateOf(document, "failed");
372 const body = isObject(document) ? document : {};
373 if (state === "ready" && !Array.isArray(body.events)) {
374 state = "failed";
375 }
376 const events: DigestEvent[] = [];
377 for (const entry of list(body.events)) {
378 const at = isObject(entry) ? isoSeconds(entry.at) : null;
379 if (!isObject(entry) || at === null || typeof entry.kind !== "string"
380 || typeof entry.title !== "string" || typeof entry.ref !== "string") {
381 continue;
382 }
383 events.push({ at, kind: entry.kind, title: entry.title, ref: entry.ref });
384 }
385 const sources: Record<string, string> = {};
386 if (isObject(body.sources)) {
387 for (const [name, value] of Object.entries(body.sources)) {
388 if (typeof value === "string") {
389 sources[name] = value;
390 }
391 }
392 }
393 return { state, events, sources, errors: errorsOf(document) };
394}
395
396/** A moment the job's state file recorded, kept only when it is a finite number. */
397function moment(value: unknown): number | undefined {
398 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
399}
400
401/**
402 * The subagent tasks a job's state file lists, each kept only with the two words
403 * that name it: an entry without a kind or a label is no task a row can name.
404 */
405function fanOf(value: unknown): FanEntry[] {
406 const entries: FanEntry[] = [];
407 for (const entry of list(value)) {
408 if (!isObject(entry) || typeof entry.kind !== "string" || typeof entry.label !== "string"
409 || !entry.kind || !entry.label) {
410 continue;
411 }
412 const startedAt = moment(entry.startedAt);
413 const doneAt = moment(entry.doneAt);
414 entries.push({ kind: entry.kind, label: entry.label,
415 ...(startedAt === undefined ? {} : { startedAt }),
416 ...(doneAt === undefined ? {} : { doneAt }) });
417 }
418 return entries;
419}
420
421/** Where a reply stands, kept only in the two states the helper writes and with a time. */
422function replyOf(value: unknown): JobReply | null {
423 if (isObject(value) && value.state === "unreadable") {
424 // A marker that is there but does not read still holds the question.
425 const id = typeof value.id === "string" && value.id.length > 0 && value.id.length <= 64 ? value.id : null;
426 return { state: "unreadable", at: null, id };
427 }
428 if (!isObject(value) || (value.state !== "pending" && value.state !== "sent")) {
429 return null;
430 }
431 const at = maybeCount(value.at);
432 return at === null ? null : { state: value.state, at };
433}
434
435function jobsOf(document: unknown): Job[] {
436 if (!isObject(document)) {
437 return [];
438 }
439 return list(document.jobs).filter(isObject).map((entry) => ({
440 id: text(entry.id),
441 name: text(entry.name, "unnamed"),
442 state: text(entry.state, "unknown"),
443 cwd: text(entry.cwd),
444 started_at: maybeCount(entry.started_at),
445 provider: text(entry.provider),
446 label: text(entry.label, text(entry.provider)),
447 session_id: maybeText(entry.session_id),
448 needs: maybeText(entry.needs),
449 needs_hash: maybeText(entry.needs_hash),
450 reply: replyOf(entry.reply),
451 suggested_reply: maybeText(entry.suggested_reply),
452 activity: entry.activity === "busy" || entry.activity === "idle" ? entry.activity : null,
453 detail: maybeText(entry.detail),
454 fan: fanOf(entry.fan),
455 since: maybeCount(entry.since),
456 attach_command: maybeText(entry.attach_command),
457 ...(entry.agent === undefined ? {} : { agent: agentOf(entry.agent) }),
458 }));
459}
460
461function rosterFrom(document: unknown): Roster {
462 const sources = isObject(document) && isObject(document.sources) ? document.sources : {};
463 const counts: Record<string, number> = {};
464 if (isObject(document) && isObject(document.interactive)) {
465 for (const [name, value] of Object.entries(document.interactive)) {
466 counts[name] = count(value);
467 }
468 }
469 const providers: Roster["providers"] = {};
470 if (isObject(document) && isObject(document.providers)) {
471 for (const [name, value] of Object.entries(document.providers)) {
472 const entry = isObject(value) ? value : {};
473 providers[name] = {
474 state: (typeof entry.state === "string" && PROVIDER_STATES.has(entry.state as SourceState)
475 ? entry.state as SourceState : "unavailable"),
476 reason: maybeText(entry.reason),
477 };
478 }
479 }
480 return {
481 state: stateOf(document, "empty"),
482 jobs: jobsOf(document),
483 interactive: counts,
484 errors: errorsOf(document),
485 providers,
486 sources: {
487 jobs: stateOf(sources, "empty"),
488 interactive: stateOf(sources, "empty"),
489 },
490 spend_series: isObject(document) ? spendSeriesOf(document.spend_series) : null,
491 };
492}
493
494function trialsFrom(document: unknown): Trials {
495 const running: Lease[] = list(isObject(document) ? document.running : [])
496 .filter(isObject)
497 .map((entry) => ({
498 repo: text(entry.repo),
499 branch: text(entry.branch),
500 tool: text(entry.tool),
501 expires_at: count(entry.expires_at),
502 session: text(entry.session),
503 }));
504 const recent: Trial[] = list(isObject(document) ? document.recent : [])
505 .filter(isObject)
506 .map((entry) => ({
507 key: text(entry.key),
508 repo: text(entry.repo),
509 branch: text(entry.branch),
510 change_state: text(entry.change_state),
511 at: count(entry.at),
512 is_green: entry.is_green === true,
513 passed: count(entry.passed),
514 failed: count(entry.failed),
515 errors: count(entry.errors),
516 skipped: count(entry.skipped),
517 }));
518 const changes: Change[] = list(isObject(document) ? document.changes : [])
519 .filter(isObject)
520 .map((entry) => ({
521 repo: text(entry.repo),
522 branch: text(entry.branch),
523 state: text(entry.state),
524 stage: maybeCount(entry.stage),
525 stages: count(entry.stages),
526 moved_at: maybeCount(entry.moved_at),
527 held_by: maybeText(entry.held_by),
528 }));
529 return { state: stateOf(document, "empty"), running, recent, changes,
530 history: historyFrom(isObject(document) ? document.history : null),
531 switch: switchOf(document),
532 errors: errorsOf(document) };
533}
534
535/**
536 * The trials window, when the reading carries one this pane can draw.
537 *
538 * Null for anything a strip cannot be drawn from: a missing block, one whose
539 * cells are not a list, or a bucket that is not two counts. A bucket read as
540 * zero would be an hour reported as quiet, which is the claim a strip makes
541 * most loudly, and this is the layer that refuses to make it on a guess.
542 */
543function historyFrom(value: unknown): TrialHistory | null {
544 if (!isObject(value) || !Array.isArray(value.cells)) {
545 return null;
546 }
547 const cells: TrialBucket[] = [];
548 for (const cell of value.cells) {
549 if (!isObject(cell) || !Number.isFinite(cell.outcomes) || !Number.isFinite(cell.failed)) {
550 return null;
551 }
552 cells.push({ outcomes: count(cell.outcomes), failed: count(cell.failed) });
553 }
554 return {
555 label: text(value.label) || "Recent trials",
556 hours: count(value.hours),
557 cells,
558 outcomes: count(value.outcomes),
559 failed: count(value.failed),
560 };
561}
562
563/** The machine switch a document reports, when it reports one. */
564function switchOf(document: unknown): "on" | "off" | null {
565 const value = isObject(document) ? document.switch : undefined;
566 return value === "on" || value === "off" ? value : null;
567}
568
569function progressFrom(document: unknown): Progress {
570 const runs: Run[] = list(isObject(document) ? document.runs : [])
571 .filter(isObject)
572 .map((entry) => ({
573 key: text(entry.key),
574 repo: text(entry.repo),
575 branch: maybeText(entry.branch),
576 label: text(entry.label),
577 display: text(entry.display, "unnamed run"),
578 done: count(entry.done),
579 total: count(entry.total),
580 failed: count(entry.failed),
581 first_failure: text(entry.first_failure),
582 elapsed: count(entry.elapsed),
583 eta_minutes: maybeCount(entry.eta_minutes),
584 phase: text(entry.phase),
585 kind: maybeText(entry.kind),
586 command_class: text(entry.command_class),
587 queue_position: maybeCount(entry.queue_position),
588 queue_total: maybeCount(entry.queue_total),
589 wait_total: maybeCount(entry.wait_total),
590 wait_left: maybeCount(entry.wait_left),
591 }));
592 return { state: stateOf(document, "empty"), runs, switch: switchOf(document),
593 errors: errorsOf(document) };
594}
595
596function pullRequestsFrom(document: unknown): PullRequests {
597 const prs: PullRequest[] = list(isObject(document) ? document.prs : [])
598 .filter(isObject)
599 .map((entry) => ({
600 repo: text(entry.repo),
601 number: maybeCount(entry.number),
602 title: text(entry.title),
603 branch: text(entry.branch),
604 head: text(entry.head),
605 is_draft: entry.is_draft === true,
606 checks: entry.checks === "pass" || entry.checks === "fail" || entry.checks === "pending"
607 ? entry.checks : "none",
608 ...(Array.isArray(entry.check_runs) ? { check_runs: checkRunsOf(entry.check_runs) } : {}),
609 check_runs_more: wholeCount(entry.check_runs_more) ?? 0,
610 opened_at: isoSeconds(entry.opened_at),
611 mine: entry.mine === true,
612 }));
613 return { state: stateOf(document, "empty"), prs, errors: errorsOf(document) };
614}
615
616function skillItems(value: unknown): SkillItem[] {
617 return list(value).filter(isObject).map((entry) => ({
618 name: text(entry.name, "unnamed"),
619 target: text(entry.target),
620 verdict: text(entry.verdict, "review"),
621 score: maybeCount(entry.score),
622 hash: text(entry.hash),
623 issue: text(entry.issue),
624 }));
625}
626
627function skillsFrom(document: unknown): Skills {
628 const source = isObject(document) ? document : {};
629 const running = isObject(source.running)
630 ? { done: count(source.running.done), total: count(source.running.total) }
631 : null;
632 const counts: Record<string, number> = {};
633 if (isObject(source.counts)) {
634 for (const [name, value] of Object.entries(source.counts)) {
635 counts[name] = count(value);
636 }
637 }
638 return {
639 state: stateOf(document, "empty"),
640 generated_at: maybeCount(source.generated_at),
641 counts,
642 items: skillItems(source.items),
643 new: skillItems(source.new),
644 // A running scan without a total has no denominator to report.
645 running: running && running.total > 0 ? running : null,
646 report_command: maybeText(source.report_command),
647 errors: errorsOf(document),
648 };
649}
650
651/**
652 * The figures and the words a session document may carry, by the name the pane
653 * draws them under.
654 *
655 * The two lists are the shape of the reader's document, and they are here rather
656 * than beside each field's use so that a field the reader stops sending stops
657 * being read: a map written out twice drifts, and the half that drifts draws a
658 * figure under a name nothing produces.
659 */
660const NUMBER_FIELDS: string[] = [
661 "costUsd", "providerCostUsd", "durationMs", "apiDurationMs", "linesAdded", "linesRemoved", "inputTokens",
662 "outputTokens", "remainingPercent", "windowSize", "cacheReadTokens", "cacheCreationTokens",
663 "fiveHourUsedPercent", "fiveHourResetsAt", "sevenDayUsedPercent", "weekResetsAt",
664 "spendUsedPercent", "spendWindowUsd", "spendWindowFiles", "spendWindowSkipped",
665];
666const TEXT_FIELDS: string[] = [
667 "sessionId", "sessionName", "cwd", "version", "model", "modelName", "outputStyle",
668 "costSource",
669];
670
671function worktreesFrom(document: unknown): Worktrees {
672 const source = isObject(document) ? document : {};
673 const worktrees: Worktree[] = list(source.worktrees).filter(isObject).map((entry) => {
674 const named = typeof entry.state === "string" && WORKTREE_STATES.has(entry.state as Worktree["state"])
675 ? entry.state as Worktree["state"]
676 : "unknown";
677 // Anything but a boolean is a fact nobody could tell.
678 const merged = typeof entry.merged === "boolean" ? entry.merged : null;
679 const archived = typeof entry.archived === "boolean" ? entry.archived : null;
680 const held = typeof entry.held === "boolean" ? entry.held : null;
681 const claimed = believed(named, merged, archived, held) ? named : "unknown";
682 return {
683 name: text(entry.name, "unnamed"),
684 path: text(entry.path),
685 project: maybeText(entry.project) ?? undefined,
686 project_root: maybeText(entry.project_root) ?? undefined,
687 branch: text(entry.branch),
688 state: claimed,
689 merged,
690 archived,
691 held,
692 error: maybeText(entry.error) ?? undefined,
693 cleanup_command: text(entry.cleanup_command),
694 cleanup_note: maybeText(entry.cleanup_note) ?? undefined,
695 change_state: maybeText(entry.change_state),
696 change_owner: maybeText(entry.change_owner),
697 change_updated_at: maybeCount(entry.change_updated_at),
698 checked_at: maybeCount(entry.checked_at) ?? undefined,
699 recheck_error: maybeText(entry.recheck_error) ?? undefined,
700 };
701 });
702 return {
703 state: stateOf(document, "empty"),
704 worktrees,
705 cleaned: maybeCount(source.cleaned),
706 errors: errorsOf(document),
707 };
708}
709
710/**
711 * The processes under load, each kept only with the four figures a row draws.
712 *
713 * A process missing its pid, its command or one of its measurements is dropped
714 * rather than drawn with a nought: a process at 0% CPU is a process this layer
715 * measured, and one the listing did not describe is not. A project is kept only
716 * as a non-empty name, with its root when the reader carried one.
717 */
718function loadFrom(document: unknown): Load {
719 const source = isObject(document) ? document : {};
720 const processes: LoadProcess[] = [];
721 for (const entry of list(source.processes)) {
722 if (!isObject(entry) || typeof entry.command !== "string" || !entry.command) {
723 continue;
724 }
725 const pid = wholeCount(entry.pid);
726 const cpu = maybeCount(entry.cpu_percent);
727 const memory = maybeCount(entry.memory_bytes);
728 const elapsed = maybeCount(entry.elapsed_seconds);
729 if (pid === null || pid <= 0 || cpu === null || cpu < 0 || memory === null
730 || memory < 0 || elapsed === null || elapsed < 0) {
731 continue;
732 }
733 const project = maybeText(entry.project);
734 const root = maybeText(entry.project_root);
735 processes.push({
736 pid,
737 command: entry.command,
738 cpu_percent: cpu,
739 memory_bytes: memory,
740 elapsed_seconds: elapsed,
741 ...(project ? { project } : {}),
742 ...(project && root ? { project_root: root } : {}),
743 });
744 }
745 return {
746 state: stateOf(document, "empty"),
747 sampled_at: maybeCount(source.sampled_at),
748 total: wholeCount(source.total) ?? 0,
749 processes,
750 errors: errorsOf(document),
751 };
752}
753
754/** A flag the reader published, kept only when it is a boolean. */
755function flag(value: unknown): boolean | null {
756 return typeof value === "boolean" ? value : null;
757}
758
759/**
760 * The governor and controller switch, read as untrusted input.
761 *
762 * A scope is kept only when the reader published a boolean for it, so a
763 * document that failed carries neither and the pane draws the error rather
764 * than the tool's own "both on" default.
765 */
766function governanceFrom(document: unknown): Governance {
767 const source = isObject(document) ? document : {};
768 return {
769 state: stateOf(document, "disabled"),
770 governor: flag(source.governor),
771 controller: flag(source.controller),
772 source: maybeText(source.source),
773 path: maybeText(source.path),
774 updated_at: maybeText(source.updated_at),
775 by: maybeText(source.by),
776 errors: errorsOf(document),
777 };
778}
779
780/** A number of seconds at or above nought, or null. */
781function seconds(value: unknown): number | null {
782 return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
783}
784
785function pacerSlot(value: unknown): PacerSlot | null {
786 if (!isObject(value) || typeof value.busy !== "boolean" || wholeCount(value.slot) === null) {
787 return null;
788 }
789 return { slot: wholeCount(value.slot) as number, busy: value.busy,
790 pid: wholeCount(value.pid), repo: maybeText(value.repo),
791 startedAt: seconds(value.startedAt) };
792}
793
794function pacerWaiter(value: unknown): PacerWaiter | null {
795 if (!isObject(value) || seconds(value.since) === null) {
796 return null;
797 }
798 return { pid: wholeCount(value.pid), repo: maybeText(value.repo),
799 since: seconds(value.since) as number };
800}
801
802function pacerRun(value: unknown): PacerRun | null {
803 if (!isObject(value) || seconds(value.at) === null || typeof value.exit !== "number"
804 || !Number.isInteger(value.exit)) {
805 return null;
806 }
807 return { at: seconds(value.at) as number, repo: maybeText(value.repo),
808 waitSeconds: seconds(value.waitSeconds), runSeconds: seconds(value.runSeconds),
809 exit: value.exit, slot: wholeCount(value.slot), mode: maybeText(value.mode),
810 reason: maybeText(value.reason) };
811}
812
813/** Kept entries of a list the reader sent, or null when it sent none. */
814function keptOrNull<T>(value: unknown, keep: (entry: unknown) => T | null): T[] | null {
815 if (!Array.isArray(value)) {
816 return null;
817 }
818 return value.map(keep).filter((entry): entry is T => entry !== null);
819}
820
821/**
822 * Pacer's run slots, read as untrusted input.
823 *
824 * Waiting callers and recent runs stay null when the reader sent no list: the
825 * first pacer does not record them, and drawing that as an empty list would
826 * say nobody waits when nobody counted.
827 */
828function pacerFrom(document: unknown): Pacer {
829 const source = isObject(document) ? document : {};
830 return {
831 state: stateOf(document, "disabled"),
832 enabled: flag(source.enabled),
833 switchSource: maybeText(source.switchSource),
834 reason: maybeText(source.reason),
835 version: wholeCount(source.version),
836 slotCount: wholeCount(source.slotCount),
837 workersPerRun: wholeCount(source.workersPerRun),
838 slots: list(source.slots).map(pacerSlot).filter((slot): slot is PacerSlot => slot !== null),
839 waiting: keptOrNull(source.waiting, pacerWaiter),
840 recent: keptOrNull(source.recent, pacerRun),
841 errors: errorsOf(document),
842 };
843}
844
845/**
846 * This session's figures, read as untrusted input like every other document.
847 *
848 * A figure the payload did not carry is left out rather than set to zero, and
849 * `absent` is what says which: the pane draws "not reported" from the absence
850 * and never from a nought, because a session reported as having spent nothing
851 * and one nobody measured are different claims.
852 */
853function sessionFrom(document: unknown): SessionFigures {
854 const source = isObject(document) ? document : {};
855 const numbers = NUMBER_FIELDS.map((name) => [name, maybeCount(source[name])] as const)
856 .filter((entry): entry is readonly [string, number] => entry[1] !== null);
857 const words = TEXT_FIELDS.map((name) => [name, maybeText(source[name])] as const)
858 .filter((entry): entry is readonly [string, string] => entry[1] !== null);
859 const parameters: Record<string, string> = {};
860 if (isObject(source.parameters)) {
861 for (const [key, value] of Object.entries(source.parameters)) {
862 if (typeof value === "string" || typeof value === "number") {
863 parameters[key] = String(value);
864 }
865 }
866 }
867 return {
868 state: stateOf(document, "empty"),
869 absent: list(source.absent).filter((name): name is string => typeof name === "string"),
870 ...Object.fromEntries(numbers),
871 ...Object.fromEntries(words),
872 parameters,
873 errors: errorsOf(document),
874 } as SessionFigures;
875}
876
877/**
878 * One snapshot from the six documents a read produced.
879 *
880 * The clock the ages are read against is the reading's own, from its stamp or
881 * from the moment the caller read it, rather than the moment of the paint: a
882 * pane can open on a reading the last session left.
883 */
884/** A whole, non-negative figure, or null: a bar is never drawn from anything else. */
885function wholeCount(value: unknown): number | null {
886 return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
887}
888
889function customRows(value: unknown): CustomRow[] {
890 const rows: CustomRow[] = [];
891 for (const entry of list(value)) {
892 if (!isObject(entry) || typeof entry.title !== "string" || !entry.title) {
893 continue;
894 }
895 rows.push({
896 title: entry.title,
897 state: maybeText(entry.state),
898 detail: maybeText(entry.detail),
899 slot: maybeText(entry.slot),
900 done: wholeCount(entry.done),
901 total: wholeCount(entry.total),
902 failed: wholeCount(entry.failed),
903 });
904 }
905 return rows;
906}
907
908/**
909 * One declared section. A section that arrives as something other than an
910 * object is one whose reading went wrong, so it keeps its place as failed.
911 */
912function customSection(key: string, value: unknown): CustomSection {
913 const body = isObject(value) ? value : {};
914 return {
915 state: isObject(value) ? stateOf(value, "failed") : "failed",
916 title: typeof body.title === "string" && body.title ? body.title : key,
917 legend: typeof body.legend === "string" && body.legend ? body.legend : null,
918 visible: body.visible !== false,
919 collapsed: body.collapsed === true,
920 hideWhenEmpty: body.hideWhenEmpty === true,
921 ...(typeof body.maxRows === "number" && Number.isInteger(body.maxRows) && body.maxRows >= 1
922 ? { maxRows: body.maxRows } : {}),
923 summary: maybeText(body.summary),
924 rows: customRows(body.rows),
925 printed: count(body.printed),
926 errors: errorsOf(value),
927 };
928}
929
930/**
931 * The sections a person added. A reading from before they existed carries no
932 * document, and that is a switched-off source rather than a failed one.
933 */
934function customFrom(document: unknown): Custom {
935 const sections: Record<string, CustomSection> = {};
936 const declared = isObject(document) && isObject(document.sections) ? document.sections : {};
937 for (const [key, value] of Object.entries(declared)) {
938 sections[key] = customSection(key, value);
939 }
940 return { state: stateOf(document, "disabled"), sections, errors: errorsOf(document) };
941}
942
943export function snapshotFrom(documents: ReaderDocuments, generatedAt: number): Snapshot {
944 return {
945 generated_at: readingClock(documents, generatedAt),
946 roster: rosterFrom(documents.roster),
947 trials: trialsFrom(documents.trials),
948 session: sessionFrom(documents.session),
949 progress: progressFrom(documents.progress),
950 prs: pullRequestsFrom(documents.prs),
951 skills: skillsFrom(documents.skills),
952 worktrees: worktreesFrom(documents.worktrees),
953 load: loadFrom(documents.load),
954 custom: customFrom(documents.custom),
955 governance: governanceFrom(documents.governance),
956 pacer: pacerFrom(documents.pacer),
957 ...(documents.events === undefined || documents.events === null ? {}
958 : { events: eventsFrom(documents.events) }),
959 };
960}
961hooks/engine.ts 244 lines1/**
2 * The engine contract this entry writes against, declared here rather than
3 * imported.
4 *
5 * The engine publishes a generated declaration of its whole API. Publishing a
6 * copy of it, or importing the file from this repository, is what the host
7 * binding rule refuses: a 622 KB generated file is not reviewable through an
8 * allowlist, and a copy drifts in silence. So this file declares the narrow
9 * subset the entry actually calls, and `scripts/host-drift.py` fails by name when
10 * the engine no longer carries one of them.
11 *
12 * Only what the pane entry uses is here. Adding a call means adding it to this
13 * file first, which is the point: the surface the repository depends on stays
14 * small enough to read.
15 */
16
17export type RenderedSurface = {
18 /** Whether the host found room to place the surface. */
19 isPlaced: boolean;
20};
21
22export type CommandRegistration = {
23 name: string;
24 description: string;
25 argumentHint?: string;
26 /**
27 * Runs the command while a turn streams, rather than queued behind it. A
28 * switch that waits for the model to finish is not a switch.
29 */
30 immediate?: true;
31};
32
33export type PaneRequest = {
34 id: string;
35 title?: string;
36 focus?: boolean;
37 closeOnEscape?: boolean;
38};
39
40export type ProcessResult = {
41 exitCode: number;
42 stdout: string;
43 stderr: string;
44};
45
46/** Whether a copy reached a clipboard, and why not when it did not. */
47export type CopyResult = { isCopied: true } | { isCopied: false; reason?: string };
48
49/** Whether a message reached the other session's queue, and why not when it did not. */
50export type SendResult = { isDelivered: true } | { isDelivered: false; reason: string };
51
52/** One value the host holds for this plugin, and the version it stands at. */
53export type StateRead = {
54 value: unknown;
55 version: number;
56};
57
58export type StateSetResult = {
59 /** False when an `ifVersion` write found a different version standing. */
60 isSet: boolean;
61 version: number;
62};
63
64/** A value is named by the plugin that owns it and the key it is kept under. */
65export type StateRef = {
66 plugin: string;
67 key: string;
68};
69
70export type Engine = {
71 command: {
72 register(options: CommandRegistration): Promise<unknown>;
73 };
74 ui: {
75 open(request: PaneRequest): Promise<RenderedSurface>;
76 close(request: { id: string }): Promise<unknown>;
77 panes(): Promise<{ id: string }[]>;
78 /** The element constructors a render hook draws with. */
79 resolve(event: unknown): unknown;
80 /**
81 * Re-runs the render hooks this plugin may draw again, so a pane whose
82 * reading changed is painted once more.
83 */
84 invalidate(event: string): void;
85 toast(message: string, options?: { timeoutMs?: number }): Promise<unknown>;
86 /** A native notification through the person's own terminal. */
87 notify(text: string, options?: { title?: string }): Promise<unknown>;
88 /**
89 * Puts a line on the clipboard of the surface a press came from, and says
90 * whether it took: a remote surface with no path yet answers why not.
91 */
92 copy(args: { text: string; surface?: string }): Promise<CopyResult>;
93 };
94 clock: {
95 now(): Promise<number>;
96 /** Calls `fn` every `ms` milliseconds until cancelled. */
97 every(ms: number, fn: () => void): { cancel(): void };
98 };
99 fs: {
100 /** A file's bytes, base64, which is how a synthesized line reaches the player. */
101 read(path: string, options: { as: "bytes" }): Promise<{ base64: string }>;
102 };
103 audio: {
104 /** Plays one clip from its bytes; resolves once played, and stops when `signal` aborts. */
105 play(clip: { base64: string; mime: string }, options?: { signal?: AbortSignal }): Promise<void>;
106 };
107 session: {
108 /** This session's identifier, which is how the band leaves out its own job. */
109 id(): Promise<string>;
110 /** What this session has cost, its context window and its rate-limit windows. */
111 usage(): Promise<unknown>;
112 /** The model this session runs. */
113 model(): Promise<string>;
114 /** The engine's own version. */
115 version(): Promise<{ version: string }>;
116 /**
117 * Sends one plain-text message to another session, by its full identifier.
118 * Delivered means queued at that session, never read: its turn is its own.
119 */
120 send(args: { to: { sessionId: string }; text: string }): Promise<SendResult>;
121 };
122 process: {
123 /** `env` is set over the host process's own environment, never in place of it. */
124 run(argv: string[], init?: { timeoutMs?: number; env?: Record<string, string> }): Promise<ProcessResult>;
125 };
126 /**
127 * The session's environment, read by literal name: which terminal draws the
128 * session decides whether its tab can be tinted.
129 */
130 env: {
131 get(name: string): Promise<string | undefined>;
132 };
133 plugin: {
134 /** This plugin's own directory, which is where its readers live. */
135 root: string;
136 };
137 store: {
138 get(key: string): Promise<unknown>;
139 set(key: string, value: unknown): Promise<unknown>;
140 };
141 /**
142 * The values the host holds for the session, kept by plugin and key.
143 *
144 * Where the store outlives the process, this outlives the registration: it is
145 * the host's, so a hot reload of this plugin rebuilds the module and finds it
146 * still standing. Writing is refused while a render hook draws, which is why
147 * a press writes from the closure the host ran rather than from the paint.
148 */
149 state: {
150 get(ref: StateRef): Promise<StateRead>;
151 set(ref: StateRef, value: unknown,
152 options?: { ifVersion?: number }): Promise<StateSetResult>;
153 };
154};
155
156/** What a render hook is handed: the surface it draws on, and the room it has. */
157export type RenderEvent = {
158 surface: string;
159 props: {
160 /** The columns the surface gives the pane's body, before the pane's own inset. */
161 bodyColumns: number;
162 };
163};
164
165/** What the band's render hook is handed: the room it has, and whether a survey holds it. */
166export type BandEvent = {
167 surface: string;
168 props: {
169 /** The cells the band's tree is laid out in. */
170 bodyColumns: number;
171 /** True while a survey holds the band; the band yields to it. */
172 hasSurvey: boolean;
173 };
174};
175
176export type StartupEvent = {
177 surface: string | null;
178 /**
179 * Whether a person is at the prompt: false for a `-p` run or the SDK, which
180 * have nobody to show a pane, a band or a tab color to.
181 */
182 isInteractive?: boolean;
183};
184
185/** What the hint line under the prompt is handed, and what a hook may add to it. */
186export type PromptHintEvent = {
187 surface: string;
188 props: {
189 /** True while the prompt holds typed text. */
190 isDraft: boolean;
191 /** True while a model turn runs. */
192 isWorking: boolean;
193 /** The line as the engine draws it. */
194 hint: string;
195 /** Text drawn dim after the line; absent as the engine hands it. */
196 tail?: string;
197 };
198};
199
200/** A prompt the person submitted: the sign of which session they are typing in. */
201export type PromptEvent = {
202 text: string;
203};
204
205export type CommandEvent = {
206 surface: string;
207 /** What followed the command's name, as typed; empty when nothing did. */
208 args: string;
209};
210
211
212export type Hook = (engine: Engine, event: never) => Promise<unknown> | unknown;
213
214/**
215 * The engine's own continuation for a hook.
216 *
217 * A hook that runs the engine's work itself returns what `next` returns. A hook
218 * that returns a value of its own instead is skipped by the engine, which is
219 * silent: the pane's command simply never appears.
220 */
221export type Next = (event: unknown) => Promise<unknown>;
222
223export type On = {
224 (event: "session.start",
225 handler: (engine: Engine, event: StartupEvent, next: Next) => Promise<unknown> | unknown): void;
226 (event: "command.run", match: { command: string },
227 handler: (engine: Engine, event: CommandEvent) => Promise<unknown> | unknown): void;
228 (event: "ui.render", match: { component: string; requestId: string },
229 handler: (engine: Engine, event: RenderEvent) => Promise<unknown> | unknown): void;
230 (event: "ui.render", match: { component: "AbovePrompt" },
231 handler: (engine: Engine, event: BandEvent, next: Next) => Promise<unknown> | unknown): void;
232 (event: "ui.render", match: { component: "PromptHint" },
233 handler: (engine: Engine, event: PromptHintEvent, next: Next) => Promise<unknown> | unknown): void;
234 (event: "prompt.submit",
235 handler: (engine: Engine, event: PromptEvent, next: Next) => Promise<unknown> | unknown): void;
236 (event: "session.end",
237 handler: (engine: Engine, event: unknown, next: Next) => Promise<unknown> | unknown): void;
238 (event: "ui.close",
239 handler: (engine: Engine, event: { id: string; origin: { kind: string } },
240 next: Next) => Promise<unknown> | unknown): void;
241};
242
243export type Register = (on: On, options?: { notify?: boolean }) => void;
244components/state.ts 575 lines1/**
2 * What the readers produce, as the pane reads it.
3 *
4 * These types describe the JSON the readers emit. They are declared here rather
5 * than generated from the Python side so the view layer has one readable
6 * contract, and a field name that drifts shows up as a type error in this
7 * repository instead of as a blank row on screen.
8 */
9
10export type SourceState = "disabled" | "unconfigured" | "unavailable" | "ready" | "paused";
11export type ReaderState = SourceState | "empty" | "failed";
12
13export type ReaderError = {
14 source: string;
15 reason: string;
16};
17
18export type FanEntry = {
19 kind: string;
20 label: string;
21 startedAt?: number;
22 doneAt?: number;
23};
24
25/**
26 * What one session's own records say it has spent, and where it stands.
27 *
28 * Read from the session's own transcript, the subagent transcripts beside it and
29 * the session's task list. A figure the records did not carry is absent rather
30 * than nought, and `lowerBound` says the totals come from a transcript too long
31 * to read whole, or from a subagent transcript nobody could read, so they are
32 * less than the session spent, never more. `reason` names which.
33 */
34/** What a model was priced from, and when its card was read, in epoch seconds. */
35export type RateAccount = {
36 state: "live" | "cached" | "table";
37 checkedAt: number | null;
38 reason?: string;
39};
40
41/**
42 * One model a session ran: its turns, and their cost at that model's own rate,
43 * or null when no rate prices it. `model` is null for turns no record named.
44 */
45export type ModelSpend = {
46 model: string | null;
47 turns: number;
48 costUsd: number | null;
49 rates?: RateAccount;
50};
51
52export type AgentFigures = {
53 state: ReaderState | "unavailable";
54 reason?: string;
55 title?: string | null;
56 model?: string | null;
57 effort?: string | null;
58 turns?: number;
59 contextTokens?: number;
60 contextWindow?: number;
61 tokens?: { input: number; cacheWrite: number; cacheRead: number; output: number };
62 costUsd?: number;
63 /**
64 * "host" is the cost the host recorded and that covers the whole session;
65 * "estimate" is usage at configured rates; "floor" is the larger of a host
66 * record that covers part of the session and the estimate.
67 */
68 costSource?: "host" | "estimate" | "floor";
69 /**
70 * Where the model's rates came from: its provider's card read within the
71 * allowed age, a card kept from an earlier read whose page failed since, or
72 * the shipped table. Absent when the read carried no account.
73 */
74 rates?: RateAccount;
75 /** Each model the session and its subagents ran, dearest first. */
76 models?: ModelSpend[];
77 lowerBound?: boolean;
78 /** The session's first turn, when the transcript was read from its start. */
79 firstActivity?: number | null;
80 lastActivity?: number | null;
81 /** The session's own task list: the only plan a step count may be drawn from. */
82 steps?: { done: number; total: number; phase: string | null } | null;
83 /** Its working and idle stretches over the window, oldest first; null when not read. */
84 timeline?: TimelineSegment[] | null;
85};
86
87export type Job = {
88 id: string;
89 name: string;
90 state: string;
91 cwd: string;
92 started_at: number | null;
93 provider: string;
94 label: string;
95 /** The full session identifier a reply goes to, when the registry gave one. */
96 session_id?: string | null;
97 /** What a waiting job asks, and the reply it suggests, already printable. */
98 needs?: string | null;
99 suggested_reply?: string | null;
100 /**
101 * The SHA-256 of the whole question, as the reader read it: `needs` is cut to
102 * fit a row, and a reply is remembered by, and sent only to, this question.
103 */
104 needs_hash?: string | null;
105 /**
106 * Where a reply to this question stands on this machine, as the helper's marker
107 * records it: on its way since `at`, or sent. None when no pane has sent one.
108 */
109 reply?: JobReply | null;
110 /** A working job mid-turn or between turns, when the registry says. */
111 activity?: "busy" | "idle" | null;
112 detail?: string | null;
113 fan?: FanEntry[];
114 /** When the job entered its state, as far as any read has seen. */
115 since?: number | null;
116 /**
117 * The line that opens the job in a terminal, quoted for a POSIX shell, as the
118 * reader built it from the provider's home; absent from older readings.
119 */
120 attach_command?: string | null;
121 /** The card's figures, when the agents source is on. */
122 agent?: AgentFigures;
123};
124
125export type JobReply = { state: "pending" | "sent"; at: number }
126 /** A record there that does not read; `id` names that very file when the reading says. */
127 | { state: "unreadable"; at: null; id: string | null };
128
129export type Roster = {
130 state: ReaderState;
131 jobs: Job[];
132 interactive: Record<string, number>;
133 errors: ReaderError[];
134 providers: Record<string, { state: SourceState; reason: string | null }>;
135 sources: { jobs: ReaderState; interactive: ReaderState };
136 /**
137 * What the roster's sessions spent per bucket of the window, oldest first.
138 * Null where no provider answered with the agent source on: a row of zeros
139 * from a roster nobody read would be a claim. Absent from older readings.
140 */
141 spend_series?: SpendSeries | null;
142};
143
144/**
145 * Spend over a window, one figure per bucket, as the reader summed it.
146 *
147 * `complete` is false when a figure the series should hold could be missing, so
148 * its total is a floor; `sessions` is how many cards it sums.
149 */
150export type SpendSeries = {
151 bucket_minutes: number;
152 buckets: number[];
153 total: number;
154 complete: boolean;
155 sessions: number | null;
156};
157
158/** One stretch of a session's recent work, in epoch seconds: working or idle. */
159export type TimelineSegment = { start: number; end: number; state: string };
160
161/** One check run on a pull request, as the reader states it: pass, fail or pending. */
162export type CheckRun = { name: string; state: string };
163
164/** Something that happened, for the "since you left" digest, in epoch seconds. */
165export type DigestEvent = { at: number; kind: string; title: string; ref: string };
166
167/**
168 * The events a whole reading holds, newest first, and the state each document
169 * they came from was read in, so a digest can say what it could not see.
170 */
171export type Events = {
172 state: ReaderState;
173 events: DigestEvent[];
174 sources: Record<string, string>;
175 errors: ReaderError[];
176};
177
178export type Lease = {
179 repo: string;
180 branch: string;
181 tool: string;
182 expires_at: number;
183 session: string;
184};
185
186export type Trial = {
187 key: string;
188 repo: string;
189 branch: string;
190 change_state: string;
191 at: number;
192 is_green: boolean;
193 passed: number;
194 failed: number;
195 errors: number;
196 skipped: number;
197};
198
199/** One hour of the trials window: how many landed in it and how many failed. */
200export type TrialBucket = {
201 outcomes: number;
202 failed: number;
203};
204
205/**
206 * The trials window as buckets, which is what the pane's strip is drawn from.
207 *
208 * It is null, rather than a run of empty buckets, whenever the store behind it
209 * was not read: a strip of track is a claim about twelve hours, and a source
210 * that was switched off has not measured any.
211 */
212export type TrialHistory = {
213 label: string;
214 hours: number;
215 cells: TrialBucket[];
216 outcomes: number;
217 failed: number;
218};
219
220/**
221 * This session's own figures, as the renderer last recorded them.
222 *
223 * Every field is optional but `absent` names each one the payload did not carry:
224 * a session whose context window nobody reported is not a session at 0% of one,
225 * and the only way the pane can tell those apart is if this layer keeps them
226 * apart rather than filling them in.
227 */
228export type SessionFigures = {
229 state: ReaderState;
230 absent: string[];
231 costUsd?: number;
232 /** A routed session's spend, priced by the status line from its transcript. */
233 providerCostUsd?: number;
234 /** Where the cost came from: the provider's own pricing, or the host's table. */
235 costSource?: "provider" | "host" | "estimate";
236 /** What every routed session spent inside the configured window, priced from the status line's files. */
237 spendWindowUsd?: number;
238 /** How many files contributed to the window spend. */
239 spendWindowFiles?: number;
240 /** How many files inside the window could not be read or priced. */
241 spendWindowSkipped?: number;
242 durationMs?: number;
243 apiDurationMs?: number;
244 linesAdded?: number;
245 linesRemoved?: number;
246 inputTokens?: number;
247 outputTokens?: number;
248 remainingPercent?: number;
249 windowSize?: number;
250 cacheReadTokens?: number;
251 cacheCreationTokens?: number;
252 fiveHourUsedPercent?: number;
253 fiveHourResetsAt?: number;
254 sevenDayUsedPercent?: number;
255 weekResetsAt?: number;
256 spendUsedPercent?: number;
257 sessionId?: string;
258 sessionName?: string;
259 cwd?: string;
260 version?: string;
261 model?: string;
262 modelName?: string;
263 outputStyle?: string;
264 parameters: Record<string, string>;
265 errors: ReaderError[];
266};
267
268/** One change still moving through the controller, and its place on the stage path. */
269export type Change = {
270 repo: string;
271 branch: string;
272 state: string;
273 /** Its place on the configured path, first stage 1; null for a state the path does not name. */
274 stage: number | null;
275 stages: number;
276 moved_at: number | null;
277 /** The tool holding a live lease on it, when one does. */
278 held_by: string | null;
279};
280
281export type Trials = {
282 state: ReaderState;
283 running: Lease[];
284 recent: Trial[];
285 history: TrialHistory | null;
286 /** Changes in flight; absent from a snapshot assembled before they were read. */
287 changes?: Change[];
288 /**
289 * The machine-wide on/off switch the reader found, or null when no switch file
290 * is configured or it could not be looked at.
291 */
292 switch?: "on" | "off" | null;
293 errors: ReaderError[];
294};
295
296export type Run = {
297 key: string;
298 repo: string;
299 branch: string | null;
300 label: string;
301 display: string;
302 done: number;
303 total: number;
304 failed: number;
305 first_failure: string;
306 elapsed: number;
307 eta_minutes: number | null;
308 phase: string;
309 /** "wait" while the governor holds the command, "run" once it runs; absent from older readings. */
310 kind?: string | null;
311 command_class?: string;
312 /** The command's place in the governor's queue, and the queue's length, when it said. */
313 queue_position?: number | null;
314 queue_total?: number | null;
315 /** The wait the governor set, and what is left of it, in seconds, while it holds the command. */
316 wait_total?: number | null;
317 wait_left?: number | null;
318};
319
320export type Progress = {
321 state: ReaderState;
322 runs: Run[];
323 /**
324 * The machine-wide on/off switch the reader found, or null when no switch file
325 * is configured or it could not be looked at.
326 */
327 switch?: "on" | "off" | null;
328 errors: ReaderError[];
329};
330
331export type PullRequest = {
332 repo: string;
333 /** Absent when the command did not report one: a row shows no number rather than a zero. */
334 number: number | null;
335 title: string;
336 branch: string;
337 head: string;
338 is_draft: boolean;
339 checks: "pass" | "fail" | "pending" | "none";
340 /** Each check by name, capped by the reader, failing ones kept first; absent from older readings. */
341 check_runs?: CheckRun[];
342 /** How many checks the cap left out of `check_runs`. */
343 check_runs_more?: number;
344 /** When the pull request was opened, in epoch seconds; null when not stated. */
345 opened_at?: number | null;
346 /**
347 * True when the person's own search found this repository rather than the
348 * configuration naming it. The owner is then the same on every such row, so
349 * the pane draws the project alone and keeps the width for the name that
350 * varies. Absent from a reading made before this was carried.
351 */
352 mine?: boolean;
353};
354
355export type PullRequests = {
356 state: ReaderState;
357 prs: PullRequest[];
358 errors: ReaderError[];
359};
360
361export type SkillItem = {
362 name: string;
363 /** The folder, home-relative. */
364 target: string;
365 verdict: string;
366 score: number | null;
367 hash: string;
368 /** The worst finding as one line, empty when there is none. */
369 issue: string;
370};
371
372export type Skills = {
373 state: ReaderState;
374 generated_at: number | null;
375 counts: Record<string, number>;
376 items: SkillItem[];
377 new: SkillItem[];
378 running: { done: number; total: number } | null;
379 /** The line that prints every finding, as configured; null when none is. */
380 report_command?: string | null;
381 errors: ReaderError[];
382};
383
384export type Worktree = {
385 name: string;
386 path: string;
387 /** The project this worktree belongs to, as git names it; absent when git could not say. */
388 project?: string;
389 /** That project's root, carried to the hover card; absent with `project`. */
390 project_root?: string;
391 branch: string;
392 state: "merged" | "held" | "active" | "unknown" | "preserved";
393 /**
394 * Whether the branch is in the base ref; null when that could not be told, as
395 * for a cut-off probe or a comparison git failed. An unknown is never drawn as
396 * merged or as not merged, and never offered for cleanup.
397 */
398 merged: boolean | null;
399 /** Whether an archive ref keeps the branch; null when the lookup failed. */
400 archived: boolean | null;
401 /** Whether a live process has the folder open; null when that could not be checked. */
402 held: boolean | null;
403 error?: string;
404 cleanup_command: string;
405 /** Why no cleanup command is offered, when the reader refused to build one. */
406 cleanup_note?: string;
407 change_state?: string | null;
408 change_owner?: string | null;
409 change_updated_at?: number | null;
410 /** When this row was last read successfully, kept across a failed recheck. */
411 checked_at?: number;
412 recheck_error?: string;
413};
414
415export type Worktrees = {
416 state: ReaderState;
417 worktrees: Worktree[];
418 /** Worktrees cleaned inside the retention window, absent when nothing could say. */
419 cleaned: number | null;
420 errors: ReaderError[];
421};
422
423/**
424 * One process under load, as the platform's process table reported it.
425 *
426 * `cpu_percent` is the platform's own reading for the moment it was sampled,
427 * never an average this layer computed. `project` is absent when nobody could
428 * read the process's working directory, which is a fact about the read rather
429 * than a process that belongs to no project.
430 */
431export type LoadProcess = {
432 pid: number;
433 command: string;
434 cpu_percent: number;
435 memory_bytes: number;
436 elapsed_seconds: number;
437 project?: string;
438 project_root?: string;
439};
440
441export type Load = {
442 state: ReaderState;
443 /** When the process table was sampled, in epoch seconds. */
444 sampled_at: number | null;
445 /** How many processes the machine reported, which is what the drawn ones were chosen from. */
446 total: number;
447 processes: LoadProcess[];
448 errors: ReaderError[];
449};
450
451/**
452 * The machine's governor and controller switch.
453 *
454 * The tool that owns the switch writes one small document, so a state this
455 * layer draws is one the reader read: a file that could not be read is failed
456 * and carries no scope at all, rather than the tool's own "both on" default.
457 */
458/** One of pacer's machine-wide run slots. */
459export type PacerSlot = {
460 slot: number;
461 /** The lock decides this, so a busy slot may name nobody. */
462 busy: boolean;
463 pid: number | null;
464 repo: string | null;
465 /** Epoch seconds the holder took the slot, when it said. */
466 startedAt: number | null;
467};
468
469/** A caller blocked on a slot. */
470export type PacerWaiter = { pid: number | null; repo: string | null; since: number };
471
472/** A run pacer recorded when it ended. */
473export type PacerRun = {
474 at: number;
475 repo: string | null;
476 waitSeconds: number | null;
477 runSeconds: number | null;
478 exit: number;
479 slot: number | null;
480 /** on, off, nested, refused, failopen or interrupted, as pacer wrote it. */
481 mode: string | null;
482 reason: string | null;
483};
484
485/** The machine's run slots, as pacer's own status reports them. */
486export type Pacer = {
487 state: ReaderState;
488 /** Whether pacer is on; absent when nothing was read. */
489 enabled: boolean | null;
490 /** Where the switch's answer came from: missing, file, corrupt or env. */
491 switchSource: string | null;
492 reason: string | null;
493 version: number | null;
494 slotCount: number | null;
495 workersPerRun: number | null;
496 slots: PacerSlot[];
497 /** Null when pacer did not report its waiters, which is not nobody waiting. */
498 waiting: PacerWaiter[] | null;
499 /** Null when pacer did not report its runs. */
500 recent: PacerRun[] | null;
501 errors: ReaderError[];
502};
503
504export type Governance = {
505 state: ReaderState;
506 /** Whether each scope is on, absent when the document was not a reading. */
507 governor: boolean | null;
508 controller: boolean | null;
509 /** Where the answer came from: the file, a legacy marker, or a missing file's default. */
510 source: string | null;
511 /** The switch file the configuration named. */
512 path: string | null;
513 /** When the switch last moved, as the writer stamped it. */
514 updated_at: string | null;
515 /** Who moved it, as the writer recorded. */
516 by: string | null;
517 errors: ReaderError[];
518};
519
520/** One row a custom section's command printed, checked by the reader. */
521export type CustomRow = {
522 title: string;
523 state: string | null;
524 detail: string | null;
525 slot: string | null;
526 /** A bar's parts, both present or both null: the reader never fills one in. */
527 done: number | null;
528 total: number | null;
529 failed: number | null;
530};
531
532/** A section a person added, with the settings it was declared with. */
533export type CustomSection = {
534 state: ReaderState;
535 title: string;
536 legend: string | null;
537 visible: boolean;
538 collapsed: boolean;
539 hideWhenEmpty: boolean;
540 /** The most rows drawn, as the section was declared with; absent from older readings. */
541 maxRows?: number;
542 summary: string | null;
543 rows: CustomRow[];
544 /** How many rows the command printed, which can be more than the rows carried. */
545 printed: number;
546 errors: ReaderError[];
547};
548
549export type Custom = {
550 state: ReaderState;
551 /** By the key the section was declared under, in the order it was declared. */
552 sections: Record<string, CustomSection>;
553 errors: ReaderError[];
554};
555
556/** Everything the pane reads, as one snapshot. */
557export type Snapshot = {
558 generated_at: number;
559 roster: Roster;
560 trials: Trials;
561 session: SessionFigures;
562 progress: Progress;
563 prs: PullRequests;
564 skills: Skills;
565 worktrees: Worktrees;
566 load: Load;
567 custom: Custom;
568 /** The machine's governor and controller switch; absent from a reading taken before it was read. */
569 governance?: Governance;
570 /** Pacer's run slots; absent from a reading taken before they were read. */
571 pacer?: Pacer;
572 /** The digest's events; absent from a reading taken before they were read, or a part of one. */
573 events?: Events;
574};
575components/options.ts 303 lines1/**
2 * The pane options this plugin draws with.
3 *
4 * These are the choices the host's render hook does not make for us: which
5 * sections exist, the order a reader meets them in, and the width below which
6 * the pane stops holding two columns. They live in their own module because two
7 * surfaces use them: the plugin's render hook, and the review tool that prints
8 * the same pane outside a terminal.
9 *
10 * The pane has no configuration file of its own to read, so a person's choices
11 * reach it one way: the read command resolves them and prints them with the
12 * reading, and `paneConfigFrom` reads that document. What is here is the
13 * packaged default, which is what a pane draws when the reading carries no
14 * usable configuration of its own.
15 */
16
17import { CUSTOM_PREFIX, DEFAULT_KEYS, type PaneGeometry, type PaneKeys, type PaneOptions,
18 type SectionChoice } from "./pane.ts";
19import { THEME, themeFrom, type Theme } from "./palette.ts";
20
21/** The sections the pane carries, in the order a reader meets them. */
22export const SECTIONS = [
23 "load", "prs", "sessions", "trials", "running", "session", "pacer", "worktrees", "skills",
24 "warnings", "governance", "keys",
25];
26
27/**
28 * The sections a pane starts folded when nothing else says otherwise.
29 *
30 * The schema declares these, and the read command resolves them, so this is the
31 * answer only for a reading that carries no configuration document: an older
32 * reading from before the document existed, or one whose section table could
33 * not be read. The values are the schema's own defaults at the revision this
34 * was written, which is why they are here rather than nowhere.
35 */
36const FOLDED_AT_START = new Set(["worktrees", "skills", "keys"]);
37/** The sections left out while they have nothing to show, under the same terms. */
38const HIDDEN_WHEN_EMPTY = new Set(["warnings", "load"]);
39
40export const NARROW_AT = 60;
41
42/**
43 * The sections a person added, as the custom document declares them: each one's
44 * settings travel with its reading, because the pane document holds only the
45 * sections this build ships.
46 */
47function customChoices(documents: Record<string, unknown>): Record<string, SectionChoice> {
48 const custom = documents.custom;
49 if (typeof custom !== "object" || custom === null || Array.isArray(custom)) {
50 return {};
51 }
52 const sections = (custom as { sections?: unknown }).sections;
53 if (typeof sections !== "object" || sections === null || Array.isArray(sections)) {
54 return {};
55 }
56 const choices: Record<string, SectionChoice> = {};
57 for (const [key, value] of Object.entries(sections as Record<string, unknown>)) {
58 const choice = choiceFrom(value);
59 if (choice !== null) {
60 choices[CUSTOM_PREFIX + key] = choice;
61 }
62 }
63 return choices;
64}
65
66/** The pane's own configuration as the reading carries it. */
67export type PaneConfig = {
68 order: string[];
69 sections: Record<string, SectionChoice>;
70 keys: PaneKeys;
71 geometry: PaneGeometry;
72 theme: Theme;
73};
74
75/**
76 * The packed defaults for the settings the schema declares with these defaults.
77 *
78 * They are here for a reading written before the settings travelled, which is
79 * the same reason the packaged keys are: a pane that received none of them draws
80 * what the schema ships rather than nothing.
81 */
82export const GEOMETRY: PaneGeometry = {
83 minimumColumns: 20,
84 horizontalReserve: 0,
85 progressCells: 16,
86 skillRows: 5,
87 fanLines: 4,
88};
89
90/** One positive integer the configuration declares, or the packaged default. */
91function geometryFrom(value: unknown, fallback: number, minimum: number): number {
92 if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
93 return fallback;
94 }
95 return value < minimum ? fallback : value;
96}
97
98function geometryOf(value: unknown): PaneGeometry {
99 if (typeof value !== "object" || value === null || Array.isArray(value)) {
100 return GEOMETRY;
101 }
102 const body = value as Record<string, unknown>;
103 return {
104 minimumColumns: geometryFrom(body.minimumColumns, GEOMETRY.minimumColumns, 1),
105 horizontalReserve: geometryFrom(body.horizontalReserve, GEOMETRY.horizontalReserve, 0),
106 progressCells: geometryFrom(body.progressCells, GEOMETRY.progressCells, 1),
107 skillRows: geometryFrom(body.skillRows, GEOMETRY.skillRows, 1),
108 fanLines: geometryFrom(body.fanLines, GEOMETRY.fanLines, 0),
109 };
110}
111
112/**
113 * One key binding, or the packaged default when the document does not name one.
114 *
115 * A binding the host can fire is one character, and the pane draws a hotkey on a
116 * control: a longer string is listed in the keys section as a shortcut nothing
117 * will ever match, which is worse than no list. A digit is refused as well,
118 * because the digits are the pane's own and already fold one section each: two
119 * controls bound to one key is one of them silently not working.
120 */
121function keyFrom(value: unknown, fallback: string): string {
122 if (typeof value !== "string" || value.length !== 1 || /\d/.test(value)) {
123 return fallback;
124 }
125 return value;
126}
127
128/**
129 * The keys a reading carries, or the packaged defaults.
130 *
131 * A key is one character the host will match, so anything else is not one this
132 * pane can honour and the default stands. The tips are the exception: an empty
133 * list is a person who turned the tips off, and drawing the packaged five over
134 * that would be the pane overruling a choice it was handed.
135 */
136function keysFrom(value: unknown): PaneKeys {
137 if (typeof value !== "object" || value === null || Array.isArray(value)) {
138 return DEFAULT_KEYS;
139 }
140 const body = value as Record<string, unknown>;
141 return {
142 tipsEnabled: body.tipsEnabled !== false,
143 // A list the document did not carry at all is not a person who turned the
144 // tips off: that is a reading from before the tips travelled, and the
145 // packaged five stand. An empty list is the different answer, and it is
146 // honoured, because that is the one somebody wrote.
147 tips: Array.isArray(body.tips)
148 ? body.tips.filter((tip) => typeof tip === "string")
149 : DEFAULT_KEYS.tips,
150 close: keyFrom(body.close, DEFAULT_KEYS.close),
151 collapse: keyFrom(body.collapse, DEFAULT_KEYS.collapse),
152 expand: keyFrom(body.expand, DEFAULT_KEYS.expand),
153 };
154}
155
156/** One section's configured choice, when the document holds a usable one. */
157function choiceFrom(value: unknown): SectionChoice | null {
158 if (typeof value !== "object" || value === null || Array.isArray(value)) {
159 return null;
160 }
161 const body = value as { title?: unknown; visible?: unknown; collapsed?: unknown;
162 legend?: unknown; hideWhenEmpty?: unknown; maxRows?: unknown };
163 // Both fields must be booleans. A document that says `"collapsed": "yes"` is
164 // not a document this pane understands, and half-reading it would draw a
165 // section folded on the strength of a string.
166 if (typeof body.visible !== "boolean" || typeof body.collapsed !== "boolean") {
167 return null;
168 }
169 // A legend is prose the pane draws, so a document that carries none, or
170 // carries something that is not a string, draws no legend rather than the
171 // word "[object Object]" under a heading.
172 const legend = typeof body.legend === "string" && body.legend.length > 0 ? body.legend : null;
173 // A section's title is the word on its heading, so a document that carries
174 // something that is not a word leaves the heading as this build names it,
175 // rather than printing whatever the document held there.
176 const title = typeof body.title === "string" && body.title.length > 0 ? body.title : null;
177 // Optional, so a reading from before the field existed keeps every section.
178 const hideWhenEmpty = body.hideWhenEmpty === true;
179 // A ceiling is a whole number of rows of at least one, or no ceiling at all.
180 const maxRows = typeof body.maxRows === "number" && Number.isInteger(body.maxRows)
181 && body.maxRows >= 1 ? body.maxRows : undefined;
182 return { title, visible: body.visible, collapsed: body.collapsed, legend, hideWhenEmpty,
183 ...(maxRows !== undefined ? { maxRows } : {}) };
184}
185
186/**
187 * The pane configuration a reading carries, or null when it carries none.
188 *
189 * The reading is untrusted input like any other: it crosses a process boundary
190 * and may have been written by an older version of this plugin. So the document
191 * is understood whole or refused whole, and the refusals are the ones that would
192 * otherwise draw a pane nobody asked for:
193 *
194 * - a state other than ready is a reading that did not resolve a configuration,
195 * so nothing in it is applied;
196 * - a section the order names whose choice is missing or is not two booleans is
197 * refused rather than skipped, because skipping it drops a section from the
198 * pane, and a section dropped for being unreadable looks exactly like one a
199 * person chose to hide;
200 * - an order is honoured as written, including an empty one, which is a person
201 * asking for no sections at all. An absent document is the different thing,
202 * and that is what falls back to the packaged default.
203 *
204 * A name the order carries that this build cannot draw is dropped, which is the
205 * one narrowing here: a section this build has no way to build is not one it can
206 * show, and refusing the whole document over a name from a newer build would
207 * lose every choice the person did make. A non-empty order whose every name is
208 * one this build cannot draw is the different case: nothing in it can be
209 * honoured, so the document is refused and the packaged default stands rather
210 * than an order that draws no sections at all.
211 */
212export function paneConfigFrom(documents: unknown): PaneConfig | null {
213 if (typeof documents !== "object" || documents === null || Array.isArray(documents)) {
214 return null;
215 }
216 const candidate = (documents as { pane?: unknown }).pane;
217 if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
218 return null;
219 }
220 const body = candidate as { state?: unknown; order?: unknown; sections?: unknown;
221 keys?: unknown; geometry?: unknown; theme?: unknown };
222 if (body.state !== "ready" || !Array.isArray(body.order)
223 || typeof body.sections !== "object" || body.sections === null
224 || Array.isArray(body.sections)) {
225 return null;
226 }
227 const table = body.sections as Record<string, unknown>;
228 const custom = customChoices(documents as Record<string, unknown>);
229 const order: string[] = [];
230 const sections: Record<string, SectionChoice> = {};
231 for (const name of body.order) {
232 if (typeof name !== "string" || order.includes(name)) {
233 continue;
234 }
235 if (name.startsWith(CUSTOM_PREFIX)) {
236 // The loader refused an order naming a section nobody declared, so a name
237 // here whose reading is missing is one whose command could not be read:
238 // it keeps its place and draws as not read, under its own key.
239 order.push(name);
240 sections[name] = custom[name] ?? { title: name.slice(CUSTOM_PREFIX.length), visible: true,
241 collapsed: false, legend: null, hideWhenEmpty: false };
242 continue;
243 }
244 if (!SECTIONS.includes(name)) {
245 continue;
246 }
247 const choice = choiceFrom(table[name]);
248 if (choice === null) {
249 return null;
250 }
251 order.push(name);
252 sections[name] = choice;
253 }
254 if (body.order.length && !order.length) {
255 return null;
256 }
257 // A section a person declared and did not place is drawn after the ones the
258 // order names, so declaring one is enough to see it.
259 for (const [name, choice] of Object.entries(custom)) {
260 if (!order.includes(name)) {
261 order.push(name);
262 sections[name] = choice;
263 }
264 }
265 return { order, sections, keys: keysFrom(body.keys), geometry: geometryOf(body.geometry),
266 theme: themeFrom(body.theme) };
267}
268
269/**
270 * The options a pane draws with, on the configuration its reading carried.
271 *
272 * An absent configuration decides nothing, so the packaged default stands for
273 * all eight sections. A configuration that is there decides everything: the
274 * order, which sections are drawn, and where each one starts.
275 */
276export function paneOptions(width: number, now: number,
277 resolved: PaneConfig | null = null): PaneOptions {
278 if (resolved === null) {
279 return {
280 width,
281 narrowAt: NARROW_AT,
282 now,
283 sectionOrder: SECTIONS,
284 sections: Object.fromEntries(SECTIONS.map((name) => [
285 name, { title: null, visible: true, collapsed: FOLDED_AT_START.has(name),
286 hideWhenEmpty: HIDDEN_WHEN_EMPTY.has(name) }])),
287 keys: DEFAULT_KEYS,
288 geometry: GEOMETRY,
289 theme: THEME,
290 };
291 }
292 return {
293 width,
294 narrowAt: NARROW_AT,
295 now,
296 sectionOrder: resolved.order,
297 sections: { ...resolved.sections },
298 keys: resolved.keys,
299 geometry: resolved.geometry,
300 theme: resolved.theme,
301 };
302}
303types/index.d.ts 76 lines1/**
2 * The llm-fleet plugin's contract with the engine: the values it keeps in `$.state`,
3 * and the keys it keeps in `$.store`.
4 *
5 * Written by hand and named as "types" in .claude-plugin/plugin.json. The
6 * engine's own declaration, which it scaffolds into .claude-plugin/types/ when
7 * it loads the plugin, is generated and never committed; this file only adds to
8 * it, by declaration merging, and imports nothing.
9 *
10 * `claude plugin validate` reads the `$.state` calls in the hooks module off its
11 * source and refuses a key this interface does not name, so a new key is
12 * declared here in the same change that first reads or writes it.
13 */
14
15/**
16 * Which sections a person folded in this session, by section name: true for
17 * folded, false for opened. Only what was pressed; the configuration decides
18 * every section nobody touched.
19 */
20export type FleetFold = Record<string, boolean>;
21
22/**
23 * The person's last look, for the "since you left" digest: the newest event a
24 * digest drew when its pane closed, in epoch seconds. It only moves forward.
25 */
26export type FleetDigestSeen = { at: number };
27
28/**
29 * Every key the plugin keeps in its `$.store`, and what each holds.
30 *
31 * The store is keyed by free text the host does not check, so this is where a
32 * key is written down: `tests/test_plugin_contract.py` refuses a key the entry
33 * names and this does not declare, and one declared here that nothing names.
34 */
35export interface FleetStore {
36 /** Which session reads the whole machine for every session, and since when. */
37 "reading.claim": { at: number; by: string };
38 /** Which session reads a live governor run's counts for every session. */
39 "progress.claim": { at: number; by: string };
40 /** The last reading, so a pane opens on the fleet as it last stood. */
41 "last-reading": { documents: unknown; at: number; progressAt?: number };
42 /** A live governor run's counts, read apart from the whole reading. */
43 "live-counts": unknown;
44 /** The band items a person dismissed, by key. */
45 "band-hidden": string[];
46 /** The replies sent from a pane, by reply key, newest last. */
47 "replied": string[];
48 /** The voice picked before each session kept its own. */
49 "voice": unknown;
50 /** Whether speech is on in every session; absent is off. */
51 "speech.on": boolean;
52 /** The folds a person left, for the next session. */
53 "folds": FleetFold;
54 /** The person's own word on the key tips: false is off. */
55 "key-tips": unknown;
56 /** The digest's last look every session shares: the latest a closed pane's digest drew, as near as a store without compare-and-set keeps it. */
57 "digest-seen": FleetDigestSeen;
58 /** The voice one session picked, under that session's identifier. */
59 [sessionVoice: `voice:${string}`]: unknown;
60}
61
62declare module "claude-code" {
63 interface PluginState {
64 "llm-fleet": {
65 /** Read by `restoreFold` on the first paint; written on every press. */
66 folded: FleetFold;
67 /** The sections a person pressed to draw in full; read by `restoreShownAll` on the first paint and written on its press. */
68 shownAll: FleetFold;
69 /** The sections a person put away; read by `restorePutAway` on the first paint and written on each picker press. */
70 hidden: FleetFold;
71 /** This session's own digest look: read on each opening, written at the version read when the pane closes. */
72 digestSeen: FleetDigestSeen;
73 };
74 }
75}
76components/palette.ts 159 lines1/**
2 * The four tones a state is drawn in, and what a person's theme draws each as.
3 *
4 * The pane decides every color in the four default tokens, and the decisions
5 * read those tokens back: a card's rule is the loudest tone inside it, and a
6 * count is drawn on a chip only in a state's tone. So the theme is not threaded
7 * into those decisions. It is one substitution over the finished tree, the last
8 * step before the surface draws it, which is what lets every place that colors a
9 * failure follow `theme.failure` without any of them knowing the setting exists.
10 *
11 * The substitution reaches a desktop drawing too. A vector carries its colors in
12 * its own markup, since no theme key reaches it, so its style is written again
13 * for the tokens the theme asks for.
14 */
15
16import type { Color, Node, Tree } from "./host.ts";
17import { restyle } from "./vector.ts";
18
19/** A tone, named by what it means rather than how it looks. */
20export type Tone = "failure" | "waiting" | "working" | "idle";
21
22/** The token the pane draws each tone in before any theme is applied. */
23export const TONE_TOKEN: Record<Tone, Color> = {
24 failure: "error",
25 waiting: "warning",
26 working: "success",
27 idle: "subtle",
28};
29
30/** The worktree states, each drawn in one tone. */
31export type WorktreeState = "active" | "held" | "unknown" | "merged" | "preserved";
32
33/** What a person's theme decides. */
34export type Theme = {
35 /** The token each tone is drawn as. */
36 tones: Record<Tone, Color>;
37 /** The tone each worktree state is drawn in. */
38 worktrees: Record<WorktreeState, Tone>;
39};
40
41/**
42 * The packaged theme: every tone drawn in its own token.
43 *
44 * Here for a reading written before the theme travelled, as the packaged keys
45 * and measurements are: a pane that received none draws what the schema ships.
46 */
47export const THEME: Theme = {
48 tones: { ...TONE_TOKEN },
49 worktrees: { active: "working", held: "waiting", unknown: "waiting",
50 merged: "idle", preserved: "idle" },
51};
52
53/** The tokens a tone may be drawn as: the ones that read as a text color. */
54const TOKENS = new Set<Color>(["text", "subtle", "inactive", "success", "error", "warning",
55 "merged", "claude", "suggestion", "planMode"]);
56
57const TONES: Tone[] = ["failure", "waiting", "working", "idle"];
58const STATES: WorktreeState[] = ["active", "held", "unknown", "merged", "preserved"];
59
60/**
61 * The theme a reading carries, entry by entry.
62 *
63 * The loader refused a document naming a token outside the list, so anything
64 * else here came from a reading this build did not write; that entry keeps the
65 * packaged choice and the rest of the theme still stands.
66 */
67export function themeFrom(value: unknown): Theme {
68 if (typeof value !== "object" || value === null || Array.isArray(value)) {
69 return THEME;
70 }
71 const body = value as Record<string, unknown>;
72 const tones = { ...THEME.tones };
73 for (const tone of TONES) {
74 const token = body[tone];
75 if (typeof token === "string" && TOKENS.has(token as Color)) {
76 tones[tone] = token as Color;
77 }
78 }
79 const worktrees = { ...THEME.worktrees };
80 const declared = body.worktrees;
81 if (typeof declared === "object" && declared !== null && !Array.isArray(declared)) {
82 for (const state of STATES) {
83 const tone = (declared as Record<string, unknown>)[state];
84 if (typeof tone === "string" && (TONES as string[]).includes(tone)) {
85 worktrees[state] = tone as Tone;
86 }
87 }
88 }
89 return { tones, worktrees };
90}
91
92/** The token a worktree state is drawn in before the substitution. */
93export function worktreeToken(theme: Theme, state: WorktreeState): Color {
94 return TONE_TOKEN[theme.worktrees[state]];
95}
96
97/** The substitution a theme makes, as a token-to-token table. */
98function swapOf(theme: Theme): Map<Color, Color> {
99 const swap = new Map<Color, Color>();
100 for (const tone of TONES) {
101 if (theme.tones[tone] !== TONE_TOKEN[tone]) {
102 swap.set(TONE_TOKEN[tone], theme.tones[tone]);
103 }
104 }
105 return swap;
106}
107
108function isNode(value: unknown): value is Node {
109 return typeof value === "object" && value !== null && !Array.isArray(value)
110 && typeof (value as { type?: unknown }).type === "string"
111 && Array.isArray((value as { children?: unknown }).children);
112}
113
114/**
115 * One props object with every color it holds swapped.
116 *
117 * A color is a prop named `color` or ending in `Color`; a nested object, the
118 * hover props, is walked the same way. A tree's props are plain data, so no
119 * object in them needs its kind read before it is walked. A function, the press
120 * a control carries, is kept as it is, and so is every other value.
121 */
122function swapProps(props: Record<string, unknown>, swap: Map<Color, Color>): Record<string, unknown> {
123 const out: Record<string, unknown> = {};
124 for (const [key, value] of Object.entries(props)) {
125 if ((key === "color" || key.endsWith("Color")) && typeof value === "string") {
126 out[key] = swap.get(value as Color) ?? value;
127 } else if (key === "children" && Array.isArray(value)) {
128 out[key] = value.map((child) => (isNode(child) ? swapNode(child, swap) : child));
129 } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
130 out[key] = swapProps(value as Record<string, unknown>, swap);
131 } else {
132 out[key] = value;
133 }
134 }
135 return out;
136}
137
138function swapNode(node: Node, swap: Map<Color, Color>): Node {
139 return {
140 ...node,
141 props: swapProps(node.props as Record<string, unknown>, swap) as Node["props"],
142 children: node.children.map((child) => (typeof child === "string" ? child : swapNode(child, swap))),
143 ...(node.vector
144 ? { vector: { ...node.vector, source: restyle(node.vector.source, (token) => swap.get(token) ?? token) } }
145 : {}),
146 };
147}
148
149/**
150 * The tree drawn in a person's theme.
151 *
152 * The packaged theme changes nothing, so the tree is handed back as it is
153 * rather than copied on every paint.
154 */
155export function recolor(tree: Tree, theme: Theme): Tree {
156 const swap = swapOf(theme);
157 return swap.size === 0 ? tree : swapNode(tree, swap);
158}
159components/pane.ts 2943 lines1/**
2 * The pane's view model: reader rows in, layout descriptions out.
3 *
4 * This is where the honesty rules of the design live, and it is a pure function
5 * so each of them can be tested without a terminal: a group that was never read
6 * is not an empty group, a stale row says how old it is, a count is never
7 * invented, and the same entity is never counted twice under two headings.
8 */
9
10import type { Control, Digest, Group, Pane, PickerItem, Row, RowLine, GroupState, Slot,
11 Spark } from "./layout.ts";
12import { ageText, countLabel, durationText, fit } from "./layout.ts";
13import { mask, MASK } from "./mask.ts";
14import { THEME, worktreeToken, type Theme } from "./palette.ts";
15import { money, spendWindowTile } from "./session.ts";
16import { FOOTER_TEXT } from "./version.ts";
17
18// The money rule lives with the session figures it prices; this module drew it
19// before the machine-wide spend tile needed the same rule in that module.
20export { money };
21import type {
22 AgentFigures, Governance, Job, Load, LoadProcess, Progress, PullRequest, PullRequests, Roster,
23 Run, SessionFigures, SkillItem, Skills, Snapshot, SpendSeries, Trial, Trials, Worktree,
24 Worktrees, Change, CustomRow, CustomSection, Pacer, PacerRun, PacerSlot } from "./state.ts";
25
26/** The prefix a section a person added is named by in the order. */
27export const CUSTOM_PREFIX = "custom:";
28
29/**
30 * The keys the pane draws with, and the tips it shows beside them.
31 *
32 * Every binding here is one the pane's own controls carry, which is what makes
33 * the keys section a description of this pane rather than a list of shortcuts a
34 * person has to hope apply here.
35 */
36export type PaneKeys = {
37 tipsEnabled: boolean;
38 tips: string[];
39 close: string;
40 collapse: string;
41 expand: string;
42};
43
44/**
45 * The packaged default, which is the schema's own, for a reading carrying none.
46 *
47 * It lives here rather than beside the schema defaults because the pane is what
48 * has to draw something when a reading arrives without keys, and a pane that
49 * throws on the one reading it cannot refuse would leave a blank panel.
50 */
51export const DEFAULT_KEYS: PaneKeys = {
52 tipsEnabled: true,
53 tips: [
54 "Ctrl+U clears the line before the cursor.",
55 "Ctrl+K clears the text after the cursor.",
56 "Option+Delete, or Ctrl+W, deletes the previous word.",
57 "Escape twice clears the draft; Ctrl+_ and Ctrl+Y undo and redo.",
58 "/diff reviews the changes in this session.",
59 ],
60 close: "x",
61 collapse: "c",
62 expand: "e",
63};
64
65/**
66 * The measurements the configuration declares, which decide what fits where.
67 *
68 * They are configuration rather than constants because each one is a choice a
69 * person made about their own terminal: how narrow the pane may get, how much of
70 * the width the surface keeps for itself, how long a bar is, and how many rows a
71 * scan may take. A pane that drew at its own numbers would be one whose declared
72 * settings are read, validated, and honoured by nobody.
73 */
74export type PaneGeometry = {
75 minimumColumns: number;
76 horizontalReserve: number;
77 progressCells: number;
78 skillRows: number;
79 /** How many of a working job's unfinished subagent tasks are listed before the rest are counted. */
80 fanLines: number;
81};
82
83/** The order the sections appear in, and what the person chose for each. */
84export type SectionChoice = {
85 /** What the section is called, when the configuration renamed it. */
86 title?: string | null;
87 visible: boolean;
88 collapsed: boolean;
89 legend?: string | null;
90 /** Leave the section out while it has nothing to show and nothing is wrong with it. */
91 hideWhenEmpty?: boolean;
92 /** The most rows the section draws; the rest are counted beside its heading. */
93 maxRows?: number;
94};
95
96/**
97 * Whether a section has nothing to show: no rows, and a state that says so
98 * rather than one that says something went wrong. A section whose source failed,
99 * is unavailable or has gone stale is never empty in this sense, because the
100 * failure is the thing to show.
101 */
102export function emptyGroup(group: Group): boolean {
103 return group.rows.length === 0 && !(group.notes?.length) && group.state !== "failed"
104 && group.state !== "unavailable" && group.state !== "stale";
105}
106
107/**
108 * What a press on a row asks of the entry, which is the one that holds the
109 * engine: put a line on the clipboard, say why a row has nothing to copy, or
110 * send a waiting job its reply. The view model only names the text and the job.
111 */
112export type PaneActions = {
113 /** Copy `text`; `what` names it in the toast that says so. */
114 copy: (text: string, what: string) => void;
115 /** Say why a press has nothing to act on. */
116 refuse: (why: string) => void;
117 /** Send a waiting job what was typed, or its suggestion when nothing was. */
118 reply: (job: Job, typed: string) => void;
119 /** Clear a reply that may not have been sent, so the question can take one again. */
120 resend?: (job: Job) => void;
121 /** Forget the sent replies that answer nothing any more, so replies are open again. */
122 clearAnswered?: () => void;
123 /** Clear a reply's record that does not read, so the question can take a reply. */
124 clearUnreadable?: (job: Job) => void;
125 /**
126 * Move one of the machine's governance scopes.
127 *
128 * A scope that is on is switched off only on a second press, so this is
129 * called once to arm that press and again to make it; the option that says
130 * which scope is armed is `confirmingGovernance`.
131 */
132 governance?: (scope: GovernanceScope) => void;
133 /**
134 * Switch pacer on or off. Switching it off is called once to arm the press
135 * and again to make it; `confirmingPacer` says it is armed.
136 */
137 pacer?: (wanted: boolean) => void;
138};
139
140/** The two scopes the machine's governance switch carries, which the pane can move. */
141export type GovernanceScope = "governor" | "controller";
142
143/**
144 * Why replies are paused on this machine, as the helper last refused one: too
145 * many held markers (`full`), cleared by a press, or a lock it will not use
146 * (`lock`), which names the lock and its fix and ends when a retry succeeds.
147 */
148export type RepliesPaused = { kind: "full" | "lock"; reason: string };
149
150export type PaneOptions = {
151 width: number;
152 narrowAt: number;
153 now: number;
154 /**
155 * When the governor's counts were read, on the same clock as `now`. A run's
156 * times are aged from it at each paint, so they count on between reads
157 * instead of freezing and then jumping. Absent, the reading's own figures
158 * stand.
159 */
160 countsAt?: number;
161 /** Section order and per-section choices, as configuration resolved them. */
162 sectionOrder: string[];
163 sections: Record<string, SectionChoice>;
164 /**
165 * The pane's own keys and the tips it shows, as configuration resolved them.
166 *
167 * Every binding here is one the pane's own controls carry, which is what makes
168 * the keys section a description of this pane rather than a list of shortcuts
169 * a person has to guess apply.
170 */
171 keys: PaneKeys;
172 /** The measurements the configuration declares, which decide what fits where. */
173 geometry: PaneGeometry;
174 /** The tones the configuration draws states in; the packaged theme when absent. */
175 theme?: Theme;
176 /**
177 * The fold a person pressed, by section, which outranks the configured one.
178 *
179 * A section's configured fold is where it starts, not where it stays: this is
180 * what a press changes, and it lives for the session rather than on disk.
181 */
182 folded?: Record<string, boolean>;
183 /**
184 * The sections a person pressed to draw in full, by section.
185 *
186 * A section's configured `maxRows` is where it starts, not where it stays: a
187 * press on its control raises the ceiling for this session, the same way a
188 * fold press outranks the configured fold.
189 */
190 shownAll?: Record<string, boolean>;
191 /**
192 * The sections a person put away, by section name.
193 *
194 * A section put away is not drawn among the cards, but it keeps its name in
195 * the picker, so the press that put it away brings it back. It lives beside
196 * the folds because it is a preference about the pane, not a filter on the
197 * rows a person is reading now.
198 */
199 putAway?: Record<string, boolean>;
200 /**
201 * What a press on one of the pane's controls does.
202 *
203 * The layout is pure and cannot press anything itself, so the pane hands each
204 * control the action it carries, and the entry is the one that knows what to
205 * do with it.
206 */
207 onPress?: (element: string) => void;
208 /** Session counts are read from the same roster; they are not a second source. */
209 titles?: Record<string, string>;
210 /** What a press on a row does, when the caller answers; a pane without it draws inert rows. */
211 actions?: PaneActions;
212 /** The reply keys already sent from a pane, newest last, so a replied job is not asked again. */
213 replied?: string[];
214 /**
215 * How long a reply recorded as on its way, and never recorded as sent, waits
216 * before its row says it may not have been sent (`pane.replyPendingSeconds`).
217 */
218 replyPendingSeconds?: number;
219 /**
220 * The pending replies this pane cleared, each as its reply key and the time it
221 * was recorded, so the row offers the field again before the next reading.
222 */
223 cleared?: string[];
224 /** Why replies are paused, when the helper last refused one for the whole machine. */
225 paused?: RepliesPaused | null;
226 /** How many items still on the band a person dismissed from it. */
227 hidden?: number;
228 /** Whether the surface draws a text field, so a reply is typed rather than copied. */
229 fields?: boolean;
230 /**
231 * When the person last looked, for the "since you left" digest: the newest
232 * event the digest drew when the pane last closed, in epoch seconds. Null is
233 * never, and the digest shows the newest events as a first look. Absent is a
234 * caller that has not found out yet, and no digest is drawn: drawing every
235 * event until the store answered would flash a first look at someone who has
236 * looked before.
237 */
238 lastSeen?: number | null;
239 /**
240 * The state a press on one of the headline figures narrowed the pane to.
241 *
242 * Absent or null is the whole fleet. A narrowed pane draws the same figures
243 * and the same headings, so the numbers still count everything; only the rows
244 * under them are the ones in that state, and a line says so.
245 */
246 focus?: Focus | null;
247 /** The rows a person pressed open, by the key the row carries. */
248 details?: Record<string, true>;
249 /**
250 * The scope whose switch-off is waiting for the press that confirms it.
251 *
252 * Switching a scope on takes one press; switching one off is a gate going
253 * away, so the row asks again and this is what says it is asking.
254 */
255 confirmingGovernance?: GovernanceScope | null;
256 /** Whether pacer's switch-off is waiting for the press that confirms it. */
257 confirmingPacer?: boolean;
258};
259
260/** The three states a headline figure narrows the pane to. */
261export type Focus = "working" | "waiting" | "failed";
262
263/** The address of a press on one of the headline figures. */
264export const FOCUS_PREFIX = "fleet.focus.";
265/** The address of the press that widens a narrowed pane again. */
266export const FOCUS_CLEAR = "fleet.focus.clear";
267/** The address of a press that opens the rest of a row. */
268export const DETAILS_PREFIX = "fleet.details.";
269/** The address of a press on one of the machine's governance switches, before the scope. */
270export const GOVERNANCE_PREFIX = "fleet.governance.";
271
272/**
273 * Whether a row is one the focused figure counted.
274 *
275 * The words are the states the rows carry: a job works or waits, a pull request
276 * and a trial pass or fail, and a run is running. A figure for working counts
277 * every live state, because a lapse of one word is not a different outcome.
278 */
279export function focusMatches(state: string | undefined, focus: Focus): boolean {
280 if (focus === "failed") {
281 return state === "failed";
282 }
283 if (focus === "waiting") {
284 // A job that waits on a person carries the word its provider uses for it.
285 return state === "waiting" || state === "blocked";
286 }
287 return state === "working" || state === "running" || state === "active";
288}
289
290/** The address a press carries back for a job's attach command. */
291export const JOB_PREFIX = "fleet.job.";
292/** The address of a waiting job's reply field. */
293export const REPLY_PREFIX = "fleet.reply.";
294/** The address of a waiting job's suggestion, pressed to copy it where no field is drawn. */
295export const SUGGEST_PREFIX = "fleet.suggest.";
296/** The address of a reply that may not have been sent, pressed to clear it and send again. */
297export const RESEND_PREFIX = "fleet.resend.";
298/** The address of the press that clears answered replies when replies are paused. */
299export const PRUNE_PREFIX = "fleet.prune.";
300/** The address of a reply whose record does not read, pressed to clear that record. */
301export const UNREADABLE_PREFIX = "fleet.unreadable.";
302/** How long a pending reply waits before it reads as maybe not sent, until a reading says. */
303export const REPLY_PENDING_SECONDS = 120;
304/** The address of a worktree's cleanup press. */
305export const WORKTREE_PREFIX = "fleet.worktree.";
306/** The address of the skills section's status line, pressed to copy the report command. */
307export const SKILL_REPORT = "fleet.skills.report";
308
309/**
310 * The key a reply is remembered by: this job, the session it runs in, and the
311 * hash of the whole question it was sent for. The time a job began waiting is
312 * not the question: a job answered and asking again between two readings never
313 * seemed to stop waiting, and its new question would read as answered. The
314 * session is: a job restarted into another session asking the same words is
315 * asking again, and the reply sent to the old session never reached it.
316 */
317export function replyKey(job: Job): string {
318 return job.provider + ":" + job.id + ":" + (job.session_id ?? "") + ":" + (job.needs_hash ?? "");
319}
320
321/** The pending reply a row draws, unless this pane already cleared that very one. */
322function pendingReply(job: Job, options: PaneOptions): { at: number } | null {
323 const reply = job.reply;
324 if (!reply || reply.state !== "pending") {
325 return null;
326 }
327 return options.cleared?.includes(replyKey(job) + "@" + reply.at) ? null : reply;
328}
329
330/** The key a pane keeps for an unreadable record it cleared: the question's and the file's. */
331export function unreadableKey(job: Job, id: string): string {
332 return replyKey(job) + "@unreadable:" + id;
333}
334
335/**
336 * Whether this pane cleared that very unreadable record. Another record for the
337 * same question that goes bad later is another file with another id, so it is
338 * drawn; a reading that names no file hides nothing on a clearing's word.
339 */
340function clearedUnreadable(id: string | null, job: Job, options: PaneOptions): boolean {
341 return id !== null && (options.cleared?.includes(unreadableKey(job, id)) ?? false);
342}
343
344/**
345 * What a reply sends: what was typed, exactly as typed, or the suggestion for a
346 * field left blank; null for neither. Spacing and line breaks are the person's
347 * words, so nothing is trimmed from a reply that has any.
348 */
349export function replyText(typed: string, suggested: string | null | undefined): string | null {
350 if (typed.trim() !== "") {
351 return typed;
352 }
353 const offered = suggested ?? "";
354 return offered.trim() === "" ? null : offered;
355}
356
357/** A pull request's page, or null for a repository that is not `owner/name` or a missing number. */
358export function prUrl(pull: { repo: string; number: number | null }): string | null {
359 return pull.number !== null && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(pull.repo)
360 ? "https://github.com/" + pull.repo + "/pull/" + pull.number : null;
361}
362
363/** The address a press carries back for a section's own fold control. */
364export const TOGGLE_PREFIX = "fleet.section.";
365/**
366 * The address a press carries back for a section's "show all rows" control.
367 *
368 * A section past its ceiling holds rows back, and hover is the only way to see
369 * them: a pointer is not on every surface, so the section that withholds rows
370 * carries a control, and pressing it raises that ceiling for the session.
371 */
372export const SHOW_ALL_PREFIX = "fleet.showall.";
373/** The address a press carries back for the picker's control over one section. */
374export const HIDE_PREFIX = "fleet.hide.";
375/** The address a press carries back for the control that folds every section. */
376export const TOGGLE_ALL = "fleet.all";
377/** The address a press carries back for the control that closes the pane. */
378export const CLOSE = "fleet.close";
379/** The two folds that act on every section at once. */
380export const ALL_CONTROLS = [
381 { key: TOGGLE_ALL + ".collapse", label: "Collapse all" },
382 { key: TOGGLE_ALL + ".expand", label: "Expand all" },
383];
384
385/**
386 * What each section is called when the configuration does not say.
387 *
388 * A fallback rather than the answer: the schema declares a title per section and
389 * the reading carries them, so this is what a reading written before they
390 * travelled draws instead of a section with no heading at all.
391 */
392const DEFAULT_TITLES: Record<string, string> = {
393 running: "Running",
394 sessions: "Background sessions",
395 session: "This session",
396 trials: "Trials",
397 prs: "Pull requests",
398 worktrees: "Worktrees",
399 skills: "Skills",
400 load: "Load",
401 governance: "Governance",
402 pacer: "Pacer",
403 warnings: "Warnings",
404 keys: "Keys",
405};
406
407const TERMINAL_STATES = new Set(["done", "failed", "cancelled", "error", "stopped"]);
408
409export function titleOf(name: string, options: PaneOptions): string {
410 return options.titles?.[name] ?? options.sections[name]?.title
411 ?? DEFAULT_TITLES[name] ?? name;
412}
413
414/** A reader's state, as a group state. */
415function groupState(state: string): GroupState {
416 switch (state) {
417 case "paused":
418 case "disabled":
419 case "empty":
420 case "failed":
421 case "unavailable":
422 return state;
423 case "unconfigured":
424 return "unavailable";
425 default:
426 return "ready";
427 }
428}
429
430/** The reading of a group that failed but still holds rows from an earlier read. */
431function partial(rows: Row[], state: string): GroupState {
432 if (state === "failed" && rows.length > 0) {
433 return "stale";
434 }
435 return groupState(state);
436}
437
438/**
439 * Every string that came from a session, masked before it is placed on a row.
440 *
441 * A session writes its own name, its own question and its own detail, and any of
442 * them can carry an account identifier or a sign-in. The pane is a display
443 * surface: it masks on the way in, so a row cannot show what it was never meant
444 * to show, and it says on the row that something was hidden rather than hiding it
445 * silently.
446 */
447function shown(value: string | null | undefined): { text: string; masked: boolean } {
448 const result = mask(value ?? "");
449 return { text: result.text, masked: result.rules.length > 0 };
450}
451
452export function waiting(job: Job): boolean {
453 return job.state === "blocked";
454}
455
456export function working(job: Job): boolean {
457 return job.state === "working";
458}
459
460export function active(job: Job): boolean {
461 return !waiting(job) && !working(job) && !TERMINAL_STATES.has(job.state);
462}
463
464/**
465 * What a press on a live job's title does: copy the line that opens it, or say
466 * why there is none. A reading from before the line travelled carries none, and
467 * the press says so rather than copying a guess at the provider's home.
468 */
469function attachPress(job: Job, title: string, options: PaneOptions): Control {
470 const key = JOB_PREFIX + job.provider + "." + job.id;
471 const command = job.attach_command;
472 return {
473 key,
474 label: title,
475 onPress: () => command
476 ? options.actions?.copy(command, "the command that opens " + title)
477 : options.actions?.refuse("no attach command for " + title
478 + ": this reading carries none; the next one will"),
479 };
480}
481
482/**
483 * The lines a waiting job draws under its question: the reply it suggests and
484 * a field to send one, or the word that it was answered from here.
485 *
486 * The field needs a surface that draws one and a session to send to. Without
487 * either, the suggestion is the control, and a press copies it for the person to
488 * paste where the job is open.
489 */
490function replyLines(job: Job, options: PaneOptions): { lines: RowLine[]; field?: Row["field"] } {
491 if (job.reply?.state === "unreadable" && !clearedUnreadable(job.reply.id, job, options)) {
492 // The record is there but does not read: it holds the question for every
493 // pane, this one too, so nothing is typed, and the press clears only it.
494 return { lines: [{
495 slots: [{ text: "this reply's record could not be read", color: "warning" }],
496 press: { key: UNREADABLE_PREFIX + job.provider + "." + job.id,
497 label: "Clear the record so a reply can be sent",
498 onPress: () => options.actions?.clearUnreadable?.(job) },
499 }] };
500 }
501 if (options.replied?.includes(replyKey(job)) || job.reply?.state === "sent") {
502 return { lines: [{ slots: [{ text: "replied; if it stays waiting, press the job to open it",
503 dim: true }] }] };
504 }
505 const pending = pendingReply(job, options);
506 if (pending !== null) {
507 // A reply recorded as on its way holds the question: no second one is typed.
508 // Past its time it may never have gone, and the person is told and offered
509 // to send again, which clears it first.
510 const waited = options.replyPendingSeconds ?? REPLY_PENDING_SECONDS;
511 // One dated ahead of now would never come of age by waiting; it is offered
512 // the press at once, as the helper's own stale release takes it.
513 const age = options.now - pending.at;
514 if (age >= 0 && age < waited) {
515 return { lines: [{ slots: [{ text: "a reply is on its way", dim: true }] }] };
516 }
517 return { lines: [{
518 slots: [{ text: "your reply may not have been sent", color: "warning" }],
519 press: { key: RESEND_PREFIX + job.provider + "." + job.id, label: "Send again",
520 onPress: () => options.actions?.resend?.(job) },
521 }] };
522 }
523 const lines: RowLine[] = [];
524 const paused = options.paused ?? null;
525 if (paused !== null) {
526 // The helper refused a reply for the whole machine: the row says why, masked.
527 // Too many held markers is cleared by a press and draws no field until then;
528 // an unsafe lock names its fix and keeps the field, since a retry once it is
529 // fixed is what ends the pause.
530 const why: RowLine = { slots: [{ text: "replies are paused: " + shown(paused.reason).text,
531 color: "warning" }] };
532 if (paused.kind === "full") {
533 return { lines: [{ ...why, press: { key: PRUNE_PREFIX + job.provider + "." + job.id,
534 label: "Clear answered replies",
535 onPress: () => options.actions?.clearAnswered?.() } }] };
536 }
537 lines.push(why);
538 }
539 const suggested = (job.suggested_reply ?? "").trim();
540 const canSend = options.fields === true && Boolean(job.session_id) && options.actions !== undefined;
541 if (suggested) {
542 const words: Slot[] = [{ text: "suggests:", dim: true }, { text: shown(suggested).text }];
543 lines.push(canSend ? { slots: words, italic: true } : {
544 slots: [...words, { text: " \u00b7 press to copy", dim: true }],
545 italic: true,
546 press: { key: SUGGEST_PREFIX + job.provider + "." + job.id,
547 label: "Copy the suggested reply",
548 onPress: () => options.actions?.copy(suggested, "the reply " + job.name + " suggests") },
549 });
550 }
551 if (!canSend) {
552 return { lines };
553 }
554 return {
555 lines,
556 field: {
557 key: REPLY_PREFIX + job.provider + "." + job.id,
558 label: "reply",
559 placeholder: suggested ? "Enter alone sends the suggested reply" : "type a reply",
560 submitLabel: "send",
561 onSubmit: (value: string) => options.actions?.reply(job, value),
562 },
563 };
564}
565
566/**
567 * The subagent tasks a working job has started and not finished, one line each,
568 * the rest counted. A job between turns lists none: what it started is not
569 * running while it waits for its next turn.
570 */
571function fanLines(job: Job, options: PaneOptions): RowLine[] {
572 if (job.activity === "idle") {
573 return [];
574 }
575 const open = (job.fan ?? []).filter((entry) => entry.startedAt !== undefined
576 && entry.doneAt === undefined);
577 const listed = Math.max(0, options.geometry.fanLines);
578 const lines: RowLine[] = open.slice(0, listed).map((entry) => {
579 // The state file dates a task in milliseconds, and marks one it started
580 // without a time with 0: that one is running, and its age is unknown.
581 const started = entry.startedAt as number;
582 const age = started > 0 ? [{ text: " \u00b7 " + durationText(Math.max(0, options.now - started / 1000)),
583 dim: true }] : [];
584 return { slots: [{ text: shown(entry.kind).text, color: "suggestion" },
585 { text: shown(entry.label).text }, ...age] };
586 });
587 if (open.length > listed) {
588 lines.push({ slots: [{ text: (open.length - listed) + " more running", dim: true }] });
589 }
590 return lines;
591}
592
593/** A live job's press, reply lines and subagent tasks, added to the row it draws as. */
594function withJobActions(row: Row, job: Job, options: PaneOptions): Row {
595 const lines: RowLine[] = [];
596 let field: Row["field"];
597 if (waiting(job)) {
598 const reply = replyLines(job, options);
599 lines.push(...reply.lines);
600 field = reply.field;
601 } else {
602 lines.push(...fanLines(job, options));
603 }
604 return { ...row, press: attachPress(job, row.title, options),
605 ...(lines.length ? { lines } : {}), ...(field ? { field } : {}) };
606}
607
608/**
609 * A finished job, drawn quiet under the live ones: what it was, who ran it,
610 * when it finished and what it left as its last word. It has no bar, since a
611 * finished job's context share says nothing about anything still to do.
612 */
613function finishedRow(job: Job, options: PaneOptions): Row {
614 const row = job.agent ? agentRow(job, job.agent, options) : jobRow(job, options);
615 const meta = row.meta.filter((slot) => !/^ \u00b7 (\d|waiting|mid-turn|between)/.test(slot.text));
616 const ended = job.since === null || job.since === undefined ? null : options.now - job.since;
617 if (ended !== null) {
618 meta.push({ text: " \u00b7 finished " + ageText(Math.max(0, ended)) });
619 }
620 return {
621 ...row,
622 key: "done:" + job.provider + ":" + job.id,
623 dim: true,
624 meta,
625 slot: [{ text: job.state, dim: true }],
626 figures: row.figures?.map((slot) => ({ ...slot, dim: true })),
627 bar: undefined,
628 };
629}
630
631function jobRow(job: Job, options: PaneOptions): Row {
632 const name = shown(job.name);
633 const question = shown(job.needs);
634 const detail = shown(job.detail);
635 const meta: Slot[] = [{ text: shown(job.label).text }];
636 if (job.session_id) {
637 meta.push({ text: " \u00b7 session " + job.session_id.slice(0, 8) });
638 }
639 const age = job.since === null || job.since === undefined
640 ? null
641 : options.now - job.since;
642 if (age !== null && age >= 60) {
643 // A job waiting for an answer has been waiting that long; anything else in a
644 // live state entered it that long ago.
645 meta.push({ text: " \u00b7 " + (waiting(job) ? "waiting " + durationText(age)
646 : ageText(age)) });
647 }
648 if (job.activity === "busy") {
649 meta.push({ text: " \u00b7 mid-turn" });
650 } else if (job.activity === "idle") {
651 meta.push({ text: " \u00b7 between turns" });
652 }
653 const slot: Slot[] = waiting(job)
654 ? [{ text: "Waiting for you", color: "warning" }]
655 : working(job)
656 ? [{ text: "Working", color: "success" }]
657 : active(job)
658 ? [{ text: job.state, color: "success" }]
659 : [{ text: job.state, dim: true }];
660 // What a person opens the row for: where it runs, under which provider, for
661 // how long, and the line that puts them in front of it. All of it was read
662 // already; none of it earns a line on a row at rest.
663 const opened: Slot[] = [];
664 if (job.cwd) {
665 opened.push({ text: "folder " + shown(job.cwd).text });
666 }
667 opened.push({ text: "provider " + shown(job.provider).text
668 + (job.session_id ? " \u00b7 session " + job.session_id : "") });
669 if (job.since !== null && job.since !== undefined) {
670 opened.push({ text: "in this state for " + durationText(options.now - job.since) });
671 }
672 if (job.attach_command) {
673 opened.push({ text: job.attach_command });
674 }
675 return {
676 // The row is named across readings, so a press that opens it survives a
677 // filter or a fresh reading rather than landing on another row's place.
678 key: "job:" + job.id,
679 title: name.text,
680 // The job's own state word, which is what the dot beside the name is for:
681 // the slot at the right says the same thing in words for a reader who
682 // wants them, and neither is a guess.
683 state: job.state,
684 meta,
685 slot: name.masked || question.masked
686 ? [...slot, { text: " \u00b7 " + MASK, dim: true }]
687 : slot,
688 detail: question.text || detail.text || undefined,
689 ...(opened.length ? { details: opened } : {}),
690 };
691}
692
693/** A model identifier the way a person says it: `claude-opus-5-5` as `opus 5.5`. */
694export function modelName(model: string): string {
695 return model.replace(/^claude-/, "").replace(/-\d{8}$/, "").replace(/-(\d+)-(\d+)$/, " $1.$2")
696 .replace(/-(\d+)$/, " $1");
697}
698
699/** Each effort word in a color of its own, always beside the word it repeats. */
700const EFFORT_COLORS: Record<string, Slot["color"]> = {
701 low: "subtle", medium: "text", high: "suggestion", xhigh: "planMode", max: "claude",
702};
703
704/**
705 * What an agent spent, in the words its source allows.
706 *
707 * The host's own figure is drawn as it is. An estimate at configured rates is
708 * drawn as one, and a total read from the end of a long transcript is drawn as
709 * the floor it is: the session spent at least that, and a bare figure would say
710 * it spent exactly that.
711 */
712export function spentText(agent: AgentFigures): string | null {
713 if (agent.costUsd === undefined) {
714 return null;
715 }
716 const figure = money(agent.costUsd);
717 if (agent.lowerBound || agent.costSource === "floor") {
718 return "at least " + figure.replace(/^~/, "");
719 }
720 return agent.costSource === "estimate" && !figure.startsWith("~") ? "~" + figure : figure;
721}
722
723/**
724 * What a card's cost was priced from: the provider's card read now, one kept
725 * from an earlier read with its age, or the shipped table. Null when the read
726 * carried no account, which says nothing rather than guess.
727 */
728export function ratesText(agent: AgentFigures, now: number): string | null {
729 const account = agent.rates;
730 if (!account) {
731 return null;
732 }
733 if (account.state === "live") {
734 return "live rates";
735 }
736 if (account.state === "cached" && account.checkedAt !== null) {
737 // The age in its largest whole unit: the card says how stale, not to the minute.
738 const age = Math.max(0, now - account.checkedAt);
739 const unit = age >= 86_400 ? Math.floor(age / 86_400) + "d"
740 : age >= 3_600 ? Math.floor(age / 3_600) + "h" : Math.max(1, Math.floor(age / 60)) + "m";
741 return "rates " + unit + " old";
742 }
743 return "table rates";
744}
745
746/**
747 * What each model a session ran cost, when it ran more than one: each at its
748 * own model's rate, dearest first, and a model no rate prices said to be so
749 * rather than drawn as nothing. Null for a session on one model, whose card
750 * figure already is that model's cost.
751 */
752export function modelSplitText(agent: AgentFigures): string | null {
753 const rows = agent.models ?? [];
754 if (rows.length < 2) {
755 return null;
756 }
757 const parts = rows.map((row) => {
758 const name = row.model ? modelName(row.model) : "unnamed model";
759 if (row.costUsd === null) {
760 return name + " not priced";
761 }
762 const figure = money(row.costUsd);
763 return name + " " + (figure.startsWith("~") ? figure : "~" + figure);
764 });
765 return "by model: " + parts.join(" · ");
766}
767
768/** Every token a session's turns moved: what it sent, wrote and read from the cache, and wrote back. */
769export function tokensOf(agent: AgentFigures): number | null {
770 const counts = agent.tokens;
771 if (!counts) {
772 return null;
773 }
774 return counts.input + counts.cacheWrite + counts.cacheRead + counts.output;
775}
776
777/**
778 * The share of its context window a session's last turn filled, when the
779 * window is known and the turn fits it.
780 *
781 * A turn larger than the configured window says the window is wrong for this
782 * session, not that it is full: drawn as a share it would read "100%" for a
783 * session with room to spare, so it is drawn in tokens instead.
784 */
785function contextShare(agent: AgentFigures): number | null {
786 if (agent.contextTokens === undefined || !agent.contextWindow
787 || agent.contextTokens > agent.contextWindow) {
788 return null;
789 }
790 return Math.max(0, (agent.contextTokens / agent.contextWindow) * 100);
791}
792
793/**
794 * How long a session has run: to now while it lives, to its last turn once it
795 * has ended.
796 *
797 * It starts at the earlier of the roster's start and the transcript's first
798 * turn. The roster dates the process, and a session resumed into a new process
799 * keeps its transcript: dated by the process alone, a session that has spent
800 * for hours reads as minutes old.
801 */
802function elapsedOf(job: Job, agent: AgentFigures | undefined, now: number): number | null {
803 const starts = [job.started_at, agent?.firstActivity].filter(
804 (value): value is number => typeof value === "number");
805 if (!starts.length) {
806 return null;
807 }
808 const end = TERMINAL_STATES.has(job.state) && agent?.lastActivity ? agent.lastActivity : now;
809 return Math.max(0, end - Math.min(...starts));
810}
811
812/**
813 * A background session as a card: what it is, what it runs on, what it has
814 * spent, and how far through its own plan it is.
815 *
816 * The step bar is drawn from the session's task list and from nothing else. A
817 * session that keeps no list has no denominator, so its bar is the share of the
818 * context window its last turn filled, labeled as that, because the two are
819 * different measurements and a bar must say which one it is.
820 */
821function agentRow(job: Job, agent: AgentFigures, options: PaneOptions): Row {
822 const base = jobRow(job, options);
823 if (agent.state !== "ready") {
824 const reason = agent.state === "empty" ? "No turns recorded yet."
825 : "Figures unreadable: " + (agent.reason ?? agent.state) + ".";
826 return { ...base, note: { text: reason, color: agent.state === "failed" ? "warning" : undefined,
827 dim: agent.state !== "failed" || undefined } };
828 }
829 const title = agent.title ? shown(agent.title) : null;
830 // Who runs it, under the title: the provider, the model and the effort.
831 const meta: Slot[] = [{ text: shown(job.label).text }];
832 if (agent.model) {
833 meta.push({ text: " \u00b7 " + modelName(agent.model) });
834 }
835 if (agent.effort) {
836 meta.push({ text: " \u00b7 " + agent.effort, color: EFFORT_COLORS[agent.effort] ?? "text" });
837 }
838 const steps = agent.steps;
839 const listed = steps !== null && steps !== undefined && steps.total > 0;
840 const share = contextShare(agent);
841 // What it measured, at the right of the bar's line. A line too long for its
842 // card is cut from the end, so the figures run from the one a person looks for
843 // first to the one the strip repeats. The context share is stated here only
844 // when the bar is the session's steps: otherwise the bar is the share and its
845 // label already says it.
846 const figures: Slot[] = [];
847 const add = (slot: Slot) => figures.push(
848 figures.length ? { ...slot, text: " \u00b7 " + slot.text } : slot);
849 const spent = spentText(agent);
850 add(spent === null ? { text: "cost not priced", dim: true } : { text: spent });
851 if (share !== null && listed) {
852 add({ text: share.toFixed(0) + "% context" });
853 } else if (share === null && agent.contextTokens !== undefined) {
854 add({ text: tokens(agent.contextTokens) + " in context" });
855 }
856 const moved = tokensOf(agent);
857 if (moved !== null) {
858 add({ text: (agent.lowerBound ? "at least " : "") + tokens(moved) + " tokens" });
859 }
860 const elapsed = elapsedOf(job, agent, options.now);
861 if (elapsed !== null) {
862 add({ text: durationOf(elapsed * 1000) });
863 }
864 // Last, so a narrow card cuts it first: what the cost was priced from.
865 const rates = ratesText(agent, options.now);
866 if (rates !== null) {
867 add({ text: rates, dim: true });
868 }
869 // A session that ran more than one model says what each cost, on a line of
870 // its own under the reply lines, so the card's one figure is not read as one
871 // model's spend.
872 const split = modelSplitText(agent);
873 const lines = split === null ? base.lines
874 : [...(base.lines ?? []), { slots: [{ text: split, dim: true }] }];
875 const question = shown(job.needs);
876 const phase = steps?.phase ? shown(steps.phase).text : "";
877 // The step in hand is the bar's own label when there is a list to count it in;
878 // a phase with no list behind it is said on a line of its own.
879 const detail = question.text || (!listed && phase ? "Now: " + phase : "") || base.detail;
880 const bar: Row["bar"] = listed && steps
881 ? { done: steps.done, total: steps.total,
882 label: { text: `${steps.done} / ${steps.total} steps` + (phase ? " \u00b7 " + phase : "") } }
883 : share !== null
884 ? { done: share, total: 100,
885 label: { text: share.toFixed(0) + "% of the context window", dim: true } }
886 : undefined;
887 return {
888 ...base,
889 title: title ? title.text : base.title,
890 meta,
891 figures,
892 lines,
893 slot: title?.masked && !base.slot.some((slot) => slot.text.includes(MASK))
894 ? [...base.slot, { text: " \u00b7 " + MASK, dim: true }] : base.slot,
895 detail: detail || undefined,
896 bar,
897 // Why the totals are a floor, in the reader's own words where it gave them:
898 // a transcript longer than the bound and a subagent transcript nobody could
899 // read are different reasons, and the sentence for one would name the wrong
900 // cause for the other.
901 note: agent.lowerBound
902 ? { text: agent.reason
903 ? "Totals are a floor: " + agent.reason + "."
904 : "Totals are from the end of a long transcript.", dim: true } : undefined,
905 ...timelineOf(job, agent, options),
906 };
907}
908
909/**
910 * The card's timeline: its stretches from the first to now while it lives, and
911 * from the first to the last once it has ended.
912 *
913 * The stretch after the last record is the row's own state where the row knows
914 * it: waiting on the person, mid-turn, or between turns. A job that says none of
915 * those leaves it unknown rather than idle, since the transcript writes nothing
916 * while a long tool call runs.
917 */
918function timelineOf(job: Job, agent: AgentFigures, options: PaneOptions): Pick<Row, "timeline"> {
919 const segments = agent.timeline;
920 if (!segments || !segments.length) {
921 return {};
922 }
923 const from = Math.min(...segments.map((segment) => segment.start));
924 const last = Math.max(...segments.map((segment) => segment.end));
925 if (TERMINAL_STATES.has(job.state)) {
926 return { timeline: { from, to: last, segments } };
927 }
928 const tail = waiting(job) ? "waiting" : job.activity === "busy" ? "working"
929 : job.activity === "idle" ? "idle" : undefined;
930 return { timeline: { from, to: Math.max(last, options.now), segments,
931 ...(tail ? { tail } : {}) } };
932}
933
934/**
935 * The strip a sessions heading carries: what the shown sessions spent, the
936 * tokens they moved, and the longest any of them has run.
937 *
938 * A sum is only as exact as its least exact part: one estimate makes the total
939 * an estimate, and one floor, or one session nobody could price, makes it a
940 * floor. Drawn as anything more certain, the strip would claim what no card does.
941 */
942export function agentStrip(jobs: Job[], now: number): Slot[] {
943 const agents = jobs.map((job) => job.agent).filter(
944 (agent): agent is AgentFigures => agent !== undefined && agent.state === "ready");
945 if (!agents.length) {
946 return [];
947 }
948 // A card that could not be read spent something nobody counted, so a sum
949 // without it is a floor. An empty one has no turns and spent nothing.
950 const unread = jobs.some((job) => job.agent !== undefined
951 && job.agent.state !== "ready" && job.agent.state !== "empty");
952 let cost = 0;
953 let priced = 0;
954 let floor = unread;
955 let estimated = false;
956 let moved = 0;
957 for (const agent of agents) {
958 if (agent.costUsd === undefined) {
959 floor = true;
960 } else {
961 cost += agent.costUsd;
962 priced += 1;
963 estimated = estimated || agent.costSource === "estimate";
964 }
965 floor = floor || agent.lowerBound === true || agent.costSource === "floor";
966 moved += tokensOf(agent) ?? 0;
967 }
968 const strip: Slot[] = [];
969 // With nothing priced there is no sum to state: "at least $0.00" is true and
970 // says nothing a reader can use.
971 if (priced) {
972 const figure = money(cost);
973 strip.push({ text: floor ? "at least " + figure.replace(/^~/, "")
974 : estimated && !figure.startsWith("~") ? "~" + figure : figure,
975 color: "text", bold: true });
976 }
977 const anyFloor = unread || agents.some((agent) => agent.lowerBound);
978 strip.push({ text: (strip.length ? " " : "") + (anyFloor ? "at least " : "")
979 + tokens(moved) + " tokens", dim: true });
980 let longest = 0;
981 for (const job of jobs) {
982 longest = Math.max(longest, elapsedOf(job, job.agent, now) ?? 0);
983 }
984 if (longest > 0) {
985 strip.push({ text: " " + durationOf(longest * 1000) + " longest", dim: true });
986 }
987 return strip;
988}
989
990/**
991 * A command the governor is holding: its place in the queue and how much of the
992 * wait it set has gone, drawn as a bar of that wait. Every figure is the
993 * governor's own; a wait it did not state draws no bar rather than a guess.
994 */
995function heldRow(run: Run, options: PaneOptions): Row {
996 const what = run.command_class ? run.command_class + " in " + run.repo : run.repo;
997 const meta: Slot[] = [{ text: "waiting" }];
998 if (run.queue_position !== null && run.queue_position !== undefined) {
999 meta.push({ text: " \u00b7 " + ordinal(run.queue_position)
1000 + (run.queue_total ? " of " + run.queue_total : "") + " in line" });
1001 }
1002 if (run.phase) {
1003 meta.push({ text: " \u00b7 " + run.phase + " gate", dim: true });
1004 }
1005 const total = run.wait_total ?? 0;
1006 const read = run.wait_left;
1007 const left = read === null || read === undefined ? read
1008 : Math.max(0, read - aged(options));
1009 const waited = left !== null && left !== undefined && total > 0
1010 ? Math.max(0, Math.min(total, total - left)) : null;
1011 return {
1012 title: shown(run.branch ? run.branch + " \u00b7 " + what : what).text,
1013 state: "waiting",
1014 meta,
1015 slot: [],
1016 bar: waited !== null
1017 ? { done: waited, total, cells: options.geometry.progressCells,
1018 label: { text: durationText(left as number) + " left", dim: true } }
1019 : undefined,
1020 };
1021}
1022
1023/**
1024 * How long ago the governor's counts were read, by this paint's clock: never
1025 * less than nothing, since counts dated ahead of the clock are the store being
1026 * quick, not time running backwards.
1027 */
1028function aged(options: PaneOptions): number {
1029 return options.countsAt === undefined ? 0 : Math.max(0, options.now - options.countsAt);
1030}
1031
1032/** 1st, 2nd, 3rd, 4th: a place in a queue as a person says it. */
1033function ordinal(place: number): string {
1034 const tens = place % 100;
1035 const suffix = tens >= 11 && tens <= 13 ? "th"
1036 : ["th", "st", "nd", "rd"][place % 10] ?? "th";
1037 return place + suffix;
1038}
1039
1040function runRow(run: Run, options: PaneOptions): Row {
1041 if (run.kind === "wait") {
1042 return heldRow(run, options);
1043 }
1044 const meta: Slot[] = [];
1045 if (run.branch) {
1046 meta.push({ text: run.branch });
1047 }
1048 meta.push({ text: (meta.length ? " \u00b7 " : "") + durationText(run.elapsed + aged(options))
1049 + " elapsed" });
1050 if (run.eta_minutes !== null) {
1051 meta.push({ text: " \u00b7 about " + run.eta_minutes + "m left", dim: true });
1052 }
1053 return {
1054 title: shown(run.label || run.display).text,
1055 meta,
1056 // The count label and the failure count are the layout's to write, so the
1057 // slot stays empty here and the bar carries the measurement.
1058 slot: [],
1059 bar: { done: run.done, total: run.total, failed: run.failed,
1060 cells: options.geometry.progressCells },
1061 note: run.first_failure ? { text: run.first_failure, color: "error" } : undefined,
1062 };
1063}
1064
1065function leaseRow(lease: { repo: string; branch: string; tool: string; expires_at: number },
1066 options: PaneOptions, retrying = false): Row {
1067 const left = lease.expires_at - options.now;
1068 return {
1069 title: shown(lease.branch).text,
1070 meta: [{ text: lease.repo }, { text: " \u00b7 " + lease.tool },
1071 { text: " \u00b7 lease " + (left > 0 ? "ends in " + durationText(left)
1072 : "expired " + ageText(-left)) }],
1073 state: left > 0 ? "running" : "expired",
1074 // The slot repeats what the dot says, so the two cannot disagree: a dot
1075 // that warned of an expired lease beside the word "running" would be the
1076 // row contradicting itself.
1077 // A branch tried again after a failure says so on the one row it keeps:
1078 // the failed trial is not drawn a second time beside the try that follows it.
1079 slot: [{ text: (left > 0 ? "running" : "expired") + (retrying ? ", last try failed" : ""),
1080 color: retrying ? "error" : left > 0 ? "text" : "warning" }],
1081 };
1082}
1083
1084/**
1085 * A change on its way through the controller: where it stands on the stage
1086 * path, drawn as a bar of the path, who holds it, and when it last moved. The
1087 * path is the configuration's, so the bar's whole is a declared length rather
1088 * than a guess at how much work is left.
1089 */
1090function changeRow(change: Change, options: PaneOptions, trial?: Trial): Row {
1091 const meta: Slot[] = [{ text: change.repo }];
1092 meta.push({ text: " \u00b7 " + change.state.toLowerCase().replace(/_/g, " ") });
1093 if (change.held_by) {
1094 meta.push({ text: " \u00b7 held by " + change.held_by });
1095 }
1096 if (change.moved_at !== null) {
1097 meta.push({ text: " \u00b7 moved " + ageText(Math.max(0, options.now - change.moved_at)),
1098 dim: true });
1099 }
1100 // The change's latest trial is its own verdict, so it sits on the change's
1101 // row rather than as a second row for the same branch further down. A trial
1102 // that reported no outcome has no verdict to state: it says so rather than
1103 // drawing a nought, which is the rule the bar beside it already follows.
1104 const bad = trial ? trial.failed + trial.errors : 0;
1105 const outcomes = trial ? trial.passed + bad : 0;
1106 return {
1107 title: shown(change.branch).text,
1108 state: change.held_by ? "running" : undefined,
1109 meta,
1110 slot: trial
1111 ? outcomes > 0
1112 ? [{ text: trial.passed + " passed", color: trial.is_green ? "success" : "error" },
1113 ...(bad ? [{ text: " \u00b7 " + bad + " failed", color: "error" as const }] : [])]
1114 : [countLabel(undefined)]
1115 : [],
1116 bar: change.stage !== null && change.stages > 0
1117 ? { done: change.stage, total: change.stages, cells: options.geometry.progressCells,
1118 label: { text: "stage " + change.stage + " of " + change.stages, dim: true } }
1119 : undefined,
1120 };
1121}
1122
1123function trialRow(trial: Trial, options: PaneOptions): Row {
1124 const counts = trial.passed + " passed";
1125 const bad = trial.failed + trial.errors;
1126 const outcomes = trial.passed + bad;
1127 return {
1128 key: "trial:" + trial.key,
1129 title: shown(trial.branch).text,
1130 state: trial.is_green ? "passed" : "failed",
1131 // The repository is the root above these rows; the row says what changed
1132 // since it mattered.
1133 meta: [{ text: ageText(options.now - trial.at) }],
1134 slot: outcomes > 0
1135 ? [{ text: counts, color: trial.is_green ? "success" : "error" },
1136 ...(trial.failed ? [{ text: " \u00b7 " + trial.failed + " failed", color: "error" as const }]
1137 : []),
1138 ...(trial.errors ? [{ text: " \u00b7 " + trial.errors + " errors", color: "error" as const }]
1139 : [])]
1140 : [countLabel(undefined)],
1141 // The share of the run that did not pass, drawn rather than written: the
1142 // figure a person reads at a glance is the one with the bar under it, and a
1143 // trial reporting eleven hundred passes and ten failures reads as a clean
1144 // run until the ten are a cell of a bar. The denominator is what the run
1145 // reported outcomes for, which is not what it selected: a trial that ran
1146 // nothing reports no bar rather than a full one.
1147 //
1148 // The counts are already in the slot beside the title, so the bar carries no
1149 // label of its own. Written twice they would be one row making two claims
1150 // that a reader has to check against each other.
1151 bar: outcomes > 0
1152 ? { done: trial.passed, total: outcomes, failed: bad, label: null,
1153 cells: options.geometry.progressCells }
1154 : undefined,
1155 // The counts in full, the repository the branch belongs to, and when the
1156 // trial ran: what a person checking a red row wants before opening the log.
1157 details: [
1158 { text: outcomes + " outcomes: " + trial.passed + " passed, " + trial.failed
1159 + " failed, " + trial.errors + " errors" },
1160 { text: "repository " + trial.repo + " · ran " + ageText(options.now - trial.at) + " ago" },
1161 ],
1162 };
1163}
1164
1165/** The longest check name a row states, so a long one leaves room for the dots. */
1166const CHECK_NAME_CELLS = 32;
1167
1168function pullRequestRow(pull: PullRequest): Row {
1169 const label = pull.checks === "pass" ? "checks passed"
1170 : pull.checks === "fail" ? "checks failed"
1171 : pull.checks === "pending" ? "checks pending"
1172 : "no checks";
1173 const color = pull.checks === "fail" ? "error"
1174 : pull.checks === "pass" ? "success"
1175 : pull.checks === "pending" ? "warning" : "subtle";
1176 // A failing check is named where the verdict would be, so what broke is read
1177 // without opening the page; a check the reader could not name is not guessed.
1178 const runs = pull.check_runs;
1179 const failing = (runs ?? []).filter((run) => run.state === "fail" && run.name);
1180 const named = failing.length
1181 ? fit(shown(failing[0]?.name).text, CHECK_NAME_CELLS)
1182 + (failing.length > 1 ? " and " + (failing.length - 1) + " more" : "") + " failed"
1183 : null;
1184 const slots: Slot[] = [named === null ? { text: label, color } : { text: named, color: "error" }];
1185 if (pull.is_draft) {
1186 slots.unshift({ text: "draft", dim: true });
1187 }
1188 const page = prUrl(pull);
1189 return {
1190 key: "pr:" + pull.repo + "#" + String(pull.number),
1191 title: (pull.number === null ? "" : "#" + pull.number + " ") + shown(pull.title).text,
1192 ...(page ? { link: page } : {}),
1193 state: pull.checks === "pass" ? "passed"
1194 : pull.checks === "fail" ? "failed"
1195 : pull.checks === "pending" ? "pending" : "unknown",
1196 // The project the pull request belongs to is the root above it, drawn
1197 // once for its rows rather than repeated on every one.
1198 meta: [...(pull.branch ? [{ text: pull.branch }] : [])],
1199 // The owner the row could not spend its width on is one hover away, so
1200 // nothing the repository states is lost.components/mask.ts 115 lines1/**
2 * Masking: the pane must not print what it was never meant to show.
3 *
4 * A session name, a branch name and a question are all written by whoever ran the
5 * session, and any of them can carry a merchant identifier, a location, a
6 * sign-in, or key material. The pane is a display surface, so it masks before it
7 * renders rather than hoping the text is clean.
8 *
9 * The rules are the shapes that identify an account or a credential. They are
10 * deliberately narrow: a masker that eats ordinary words makes itself useless, so
11 * every rule has a case in the suite proving it leaves normal text alone.
12 *
13 * This module is the masker the secret-shield mod will own. It lives here for now
14 * because the pane needs it before that mod is extracted; the mod keeps its own
15 * policy and thresholds, and this one only has to be honest about what it hides.
16 */
17
18export const MASK = "[masked]";
19
20export type Masked = {
21 text: string;
22 /** Which rules fired, in the order they are declared. */
23 rules: string[];
24};
25
26type Rule = {
27 name: string;
28 pattern: RegExp;
29 replacement: string | ((match: string, ...groups: string[]) => string);
30};
31
32/** A merchant, a location, or the organization they belong to. */
33const IDENTIFIERS: Rule[] = [
34 { name: "organization", pattern: /\borg_[A-Za-z0-9]+\b/gi, replacement: MASK },
35 { name: "location", pattern: /\bloc_[A-Za-z0-9]+\b/gi, replacement: MASK },
36 { name: "merchant identifier", pattern: /\bMID\b[\s:=#-]*\d{4,}/g, replacement: MASK },
37 { name: "account number", pattern: /\b(?:account|acct|card|pan)\b[\s:=#-]*\d{6,}/gi,
38 replacement: MASK },
39];
40
41/**
42 * A sign-in or key material.
43 *
44 * The separator is required for the word forms, because "the key is here" is a
45 * sentence and "key: abc" is a credential. The one form without a separator is a
46 * value that carries a digit, which prose almost never does, so "login failed"
47 * survives and "login y1avJp1mg" does not.
48 */
49const CREDENTIALS: Rule[] = [
50 { name: "sign-in", pattern: /\b(?:login|username|password|passwd|pwd|passcode)\b\s*[:=]\s*\S+/gi,
51 replacement: MASK },
52 // The value is captured on its own so a closing bracket, which is not part of
53 // the sign-in, survives the mask.
54 { name: "sign-in", pattern: /\b((?:login|password|passwd|pwd)\s+)(?=\S*\d)([A-Za-z0-9._@+-]{6,})/gi,
55 replacement: (_match, lead) => lead + MASK },
56 // The value must look like material: one unbroken run of eight or more, with a
57 // digit or a symbol in it. A colon after a word is not proof, so "high secret:
58 // looks like a key" keeps its sentence and "token: abc123def456" does not.
59 { name: "key material",
60 pattern: /\b(?:token|secret|api[_-]?key|access[_-]?key|secure[_-]?key|key)\b\s*[:=]\s*(\S+)/gi,
61 replacement: (match, value) => looksLikeMaterial(value) ? MASK : match },
62 { name: "key prefix", pattern: /\bsk-(?:proj-|ant-|live_)[A-Za-z0-9_-]{8,}/g, replacement: MASK },
63 { name: "github token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}/g, replacement: MASK },
64 { name: "aws key", pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: MASK },
65 { name: "private key",
66 pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
67 replacement: MASK },
68 { name: "credentialed url", pattern: /:\/\/[^\s/:]+:[^\s/@]+@/g,
69 replacement: (match, ...groups) => "://" + MASK + "@" },
70];
71
72/** Whether a value is a key rather than a word that followed a colon. */
73function looksLikeMaterial(value: string): boolean {
74 if (!/^[A-Za-z0-9+/_=.-]{8,}$/.test(value)) {
75 return false;
76 }
77 return /\d/.test(value) || /[+/_=-]/.test(value) || (/[a-z]/.test(value) && /[A-Z]/.test(value));
78}
79
80function applyRule(text: string, rule: Rule): string {
81 if (typeof rule.replacement === "string") {
82 return text.replace(rule.pattern, rule.replacement);
83 }
84 const replacement = rule.replacement;
85 return text.replace(rule.pattern,
86 (match: string, ...groups: string[]) => replacement(match, ...groups));
87}
88
89/**
90 * The text with every identifying shape replaced.
91 *
92 * `rules` names what fired rather than counting, so a caller can say what was
93 * hidden without repeating it.
94 */
95export function mask(value: unknown): Masked {
96 if (typeof value !== "string" || !value) {
97 return { text: "", rules: [] };
98 }
99 let text = value;
100 const rules: string[] = [];
101 for (const rule of [...IDENTIFIERS, ...CREDENTIALS]) {
102 const before = text;
103 text = applyRule(text, rule);
104 if (text !== before && !rules.includes(rule.name)) {
105 rules.push(rule.name);
106 }
107 }
108 return { text, rules };
109}
110
111/** Whether the text holds anything this masker would hide. */
112export function hides(value: unknown): boolean {
113 return mask(value).rules.length > 0;
114}
115components/stream.ts 78 lines1/**
2 * A new reading, landed on the pane one source at a time.
3 *
4 * The machine is read whole every interval, and drawn whole, every section that
5 * changed changed in one frame: the pane sat still for twenty seconds and then
6 * jumped. Here the drawn reading walks toward the read one, a source per step,
7 * in the order the pane draws its sections, so a reading arrives as a ripple
8 * down the pane rather than a jump. Nothing here waits or keeps time: the caller
9 * takes a step on its own clock and asks for a frame after each.
10 *
11 * What is not a section (the read times, the pane's own configuration) lands at
12 * once, because it decides how everything else is drawn. The first reading
13 * lands whole: there is nothing on the pane yet to ripple over.
14 */
15
16/** The sources the pane draws as sections, in the order it draws them. */
17export const STREAM_ORDER = ["session", "progress", "trials", "prs", "roster", "worktrees",
18 "skills", "custom"] as const;
19
20type Documents = Record<string, unknown>;
21
22const STREAMED: ReadonlySet<string> = new Set(STREAM_ORDER);
23
24/** Whether two readings of one source say the same thing. */
25function same(left: unknown, right: unknown): boolean {
26 return JSON.stringify(left) === JSON.stringify(right);
27}
28
29/** The sources whose drawn reading differs from the new one, in the pane's order. */
30export function pendingSources(shown: Documents | null, target: Documents | null): string[] {
31 if (shown === null || target === null) {
32 return [];
33 }
34 return STREAM_ORDER.filter((key) => (key in shown) !== (key in target)
35 || !same(shown[key], target[key]));
36}
37
38/**
39 * One step toward the new reading: everything that is not a section, and the
40 * first section that changed. `pending` is what is still to land after it.
41 */
42export function stepStream(shown: Documents | null,
43 target: Documents | null,
44 only?: string): { documents: Documents | null;
45 pending: string[] } {
46 if (target === null) {
47 return { documents: null, pending: [] };
48 }
49 if (shown === null) {
50 return { documents: target, pending: [] };
51 }
52 const next: Documents = {};
53 for (const [key, value] of Object.entries(target)) {
54 if (!STREAMED.has(key)) {
55 next[key] = value;
56 }
57 }
58 for (const key of STREAM_ORDER) {
59 if (key in shown) {
60 next[key] = shown[key];
61 }
62 }
63 // A stream that names its next section lands that one, so a section read
64 // again mid-stream waits behind the ones already in line.
65 const pending = pendingSources(shown, target);
66 const first = only === undefined ? pending[0]
67 : pending.includes(only) ? only : undefined;
68 const rest = pending.filter((key) => key !== first);
69 if (first !== undefined) {
70 if (first in target) {
71 next[first] = target[first];
72 } else {
73 delete next[first];
74 }
75 }
76 return { documents: next, pending: rest };
77}
78components/surface.ts 170 lines1/**
2 * The pane as a surface: reader documents in, a host tree out.
3 *
4 * This is the half of the engine entry that can be tested without the engine. The
5 * entry is a handful of lines of glue because everything it draws is decided
6 * here: which tree a set of documents becomes, what the surface shows while the
7 * first read is still running, and what it shows when that read was refused.
8 *
9 * Two rules it holds. A read that has not answered yet is not an empty fleet:
10 * saying "nothing is running" while a read is in flight is the claim this surface
11 * must not make, so it says it is reading. A refused configuration is not a fleet
12 * either, and it names every error the loader found rather than the first one.
13 */
14
15import type { Primitives } from "./host.ts";
16import { renderPane, type Group, type Pane } from "./layout.ts";
17import { mount } from "./mount.ts";
18import { recolor, THEME } from "./palette.ts";
19import { CLOSE, DEFAULT_KEYS, paneFrom, type PaneOptions } from "./pane.ts";
20import { paneOptions } from "./options.ts";
21import { snapshotFrom, type ReaderDocuments } from "./snapshot.ts";
22import { FOOTER_TEXT } from "./version.ts";
23
24export type SurfaceInput = {
25 /** What the read command printed, or nothing when it has not answered yet. */
26 documents: ReaderDocuments | null;
27 /** The columns the surface has for the pane. */
28 width: number;
29 /** The clock the ages are read against. */
30 now: number;
31 /**
32 * When this reading was taken, when that is not now.
33 *
34 * A pane can open on a reading the last session left, and the age beside the
35 * counts is that reading's. Falling back to the clock above would date it to
36 * the paint, which is a fleet labeled current that is hours old.
37 */
38 takenAt?: number;
39 /** Why there is no reading, when the read itself failed. */
40 reason?: string;
41 /** What a press on one of the pane's controls does, when the caller answers. */
42 onPress?: (element: string) => void;
43 /**
44 * The surface drawing the pane. A picture surface draws its sections as
45 * cards; the terminal, and a caller that names none, draws them as the runs
46 * of lines a terminal reads best.
47 */
48 surface?: string;
49 options?: PaneOptions;
50 /** What the caller changes on the pane before it is drawn, such as what is moving. */
51 transform?: (pane: Pane) => Pane;
52};
53
54/** A pane holding one group and one sentence, which is all a reader needs here. */
55function notice(state: Group["state"], text: string, input: SurfaceInput,
56 primitives: Primitives) {
57 const group: Group = {
58 name: "the fleet",
59 title: "llm-fleet",
60 summary: [],
61 rows: [],
62 state,
63 emptyText: text,
64 failure: text,
65 };
66 // The close control is drawn while the machine is read too: a pane that can
67 // only be closed once a reading lands is a pane the person waits on.
68 const close = input.options?.keys.close ?? DEFAULT_KEYS.close;
69 const pane: Pane = {
70 title: "llm-fleet",
71 counts: [],
72 updated: { text: "" },
73 controls: [{ key: CLOSE, label: "Close", hotkey: close, dim: true,
74 onPress: () => input.onPress?.(CLOSE) }],
75 hint: "Esc or " + close + " closes this pane",
76 footer: { text: FOOTER_TEXT, dim: true },
77 groups: [group],
78 width: input.width,
79 narrowAt: 60,
80 };
81 return mount(recolor(renderPane(pane, input.surface), input.options?.theme ?? THEME), primitives);
82}
83
84/** Whether the documents are the loader's refusal, rather than a reading. */
85export function isRefusal(documents: ReaderDocuments | null): boolean {
86 return documents !== null && typeof documents === "object" && !Array.isArray(documents)
87 && "configErrors" in documents;
88}
89
90/**
91 * The documents a reading is made of: the sources the reader answers with.
92 *
93 * A value carrying none of them is not a reading, whatever else it holds. The
94 * reader always prints every one, so an object naming no source came from
95 * somewhere else, and drawing it as a fleet would report zero of everything the
96 * machine never said.
97 */
98const READER_DOCUMENTS = ["roster", "trials", "progress", "prs", "skills", "worktrees"] as const;
99
100/**
101 * Whether the documents are a reading, rather than a refusal or nothing yet.
102 *
103 * At least one source has to be there and be something: a key present with
104 * nothing under it names no source either, and the assembly reads a missing
105 * document as a source that had nothing to report. Four keys set to null would
106 * otherwise draw a quiet machine that no reader ever described.
107 */
108export function isReading(documents: ReaderDocuments | null): boolean {
109 return documents !== null && typeof documents === "object" && !Array.isArray(documents)
110 && !("configErrors" in documents)
111 && READER_DOCUMENTS.some((name) => {
112 const source = (documents as Record<string, unknown>)[name];
113 return typeof source === "object" && source !== null;
114 });
115}
116
117/**
118 * Every error the loader named, as one sentence.
119 *
120 * The list is read as untrusted like the documents holding it: a refusal whose
121 * errors are not a list, or whose entries are not objects, is named as a refusal
122 * this pane could not read. Reading a field off whatever happens to be there
123 * throws inside a paint, and a throw there is a blank pane.
124 */
125function refusals(documents: ReaderDocuments): string {
126 const errors = (documents as { configErrors?: unknown }).configErrors;
127 if (!Array.isArray(errors) || errors.length === 0) {
128 return "no error this pane could read";
129 }
130 return errors.map((entry) => {
131 if (typeof entry !== "object" || entry === null) {
132 return "an error this pane could not read";
133 }
134 const { code, path } = entry as { code?: unknown; path?: unknown };
135 return (typeof code === "string" ? code : "an unnamed error")
136 + (typeof path === "string" ? " " + path : "");
137 }).join(", ");
138}
139
140export function surfaceTree(input: SurfaceInput, primitives: Primitives) {
141 if (input.documents === null) {
142 // A read still running and a read that failed are different states with
143 // different sentences, and neither is an empty fleet.
144 const pending = input.reason === undefined;
145 return notice(pending ? "empty" : "failed",
146 pending ? "Reading the machine..."
147 : "the fleet (the read did not answer: " + input.reason + ")",
148 input, primitives);
149 }
150 if (isRefusal(input.documents)) {
151 return notice("unavailable", "the configuration, which was refused: "
152 + refusals(input.documents), input, primitives);
153 }
154 if (!isReading(input.documents)) {
155 // Neither of the two shapes a read can answer with. That is a broken read,
156 // and calling it a refused configuration, or an empty fleet, would both be
157 // claims the machine did not make.
158 return notice("failed", "the fleet (the read did not print a reading)", input, primitives);
159 }
160 const snapshot = snapshotFrom(input.documents, input.takenAt ?? input.now);
161 const base = input.options ?? paneOptions(input.width, input.now);
162 // The caller's press handler is merged rather than expected in the options, so
163 // a caller that passes its own options still gets working controls.
164 const options = input.onPress ? { ...base, onPress: input.onPress } : base;
165 const pane = paneFrom(snapshot, options);
166 return mount(recolor(renderPane(input.transform ? input.transform(pane) : pane, input.surface),
167 options.theme ?? THEME),
168 primitives);
169}
170components/band.ts 534 lines1/**
2 * The band above the prompt: the switch for the pane, and what needs a person.
3 *
4 * The pane is opened by a press here rather than by a typed command, and that
5 * is the reason the band exists at all: a command typed while a turn runs waits
6 * for the turn to end, and a press on a band button runs at once. So the switch
7 * is always drawn, whatever the fleet holds.
8 *
9 * Beside it the band states the fleet in one line: how many sessions work and
10 * wait, how many outcomes failed, and the worst thing that needs a person, with
11 * the rest counted after it. Errors sort ahead of waits, so the first item is
12 * the worst. This session's own job is left out, because a session telling its
13 * own person that it is working is noise, and so is anything the person
14 * dismissed.
15 *
16 * Beside the fleet's switch sits the voice's. Speech is one switch for every
17 * session on the machine, so the word on it is what the store says, and a press
18 * in any session is heard in all of them. Beside the voice sits the machine's
19 * governance switch when the machine has one: it states both scopes in a word
20 * and moves them together, and it is not drawn at all for a person who does not
21 * run the tooling behind it. Each button carries an icon: a glyph a terminal
22 * draws in one cell where the surface is a terminal, and an emoji where the
23 * surface draws pictures.
24 */
25
26import type { Color, Node } from "./host.ts";
27import { chip } from "./cards.ts";
28import { box, fit, pressable, text } from "./layout.ts";
29import { mask } from "./mask.ts";
30import { active, failedOutcomes, waiting, working } from "./pane.ts";
31import type { Job, Snapshot } from "./state.ts";
32
33/** The address of the band's switch, which is what a press comes back on. */
34export const BAND_TOGGLE = "fleet.band.toggle";
35/** The address of the button that hides what the band is showing. */
36export const BAND_DISMISS = "fleet.band.dismiss";
37/** The address of the switch that turns speech on or off in every session. */
38export const BAND_SPEAK = "fleet.band.speak";
39/** The address of the machine's governance switch, when the machine has one. */
40export const BAND_GOVERNANCE = "fleet.band.governance";
41/** The address of pacer's switch, which takes the gear on a machine that runs pacer. */
42export const BAND_PACER = "fleet.band.pacer";
43/** The address of a press on one of the band's figures, before the state it names. */
44export const BAND_FOCUS = "fleet.band.focus.";
45/** The address of a press on the item the band leads with. */
46export const BAND_ITEM = "fleet.band.item.";
47
48/** The cell the rule above the buttons is drawn from, written as a code point. */
49const BAND_RULE = "─";
50
51/** The icon each switch carries, as a terminal draws it and as a picture surface does. */
52const ICONS = {
53 terminal: { fleet: "\u2261", speaking: "\u266A", quiet: "\u266A", governance: "\u2699" },
54 pictures: { fleet: "\u{1F4CB}", speaking: "\u{1F50A}", quiet: "\u{1F507}",
55 governance: "\u2699\ufe0f" },
56} as const;
57
58function icons(surface: string | undefined) {
59 return surface === undefined || surface === "terminal" ? ICONS.terminal : ICONS.pictures;
60}
61
62/**
63 * The fleet switch's word, which says which way a press goes, after its icon;
64 * `short` keeps the icon and the verb alone, for a band too narrow for the
65 * product's name.
66 */
67export function fleetLabel(open: boolean, surface?: string, short = false): string {
68 const word = short ? (open ? "Close" : "Open") : (open ? "Close llm-fleet" : "Open llm-fleet");
69 return icons(surface).fleet + " " + word;
70}
71
72/**
73 * The speech switch's word, which says how speech stands in every session,
74 * after its icon; `short` keeps the icon and the state alone, for a narrow band.
75 */
76export function speakLabel(speaking: boolean, surface?: string, short = false): string {
77 const set = icons(surface);
78 const word = short ? "" : "Speak: ";
79 return speaking ? set.speaking + " " + word + "on" : set.quiet + " " + word + "off";
80}
81
82/**
83 * The governance switch's word, after its icon: one word for two scopes, so the
84 * band stays one line. A scope the switch did not report is "not read" rather
85 * than folded into either answer.
86 */
87export function governanceLabel(governor: boolean | null, controller: boolean | null,
88 surface?: string, short = false): string {
89 const word = governor === true && controller === true ? "on"
90 : governor === false && controller === false ? "off"
91 : governor === null || controller === null ? "not read" : "mixed";
92 return icons(surface).governance + " " + (short ? "" : "Gov ") + word;
93}
94
95/**
96 * Pacer's switch word, after the gear: on, off, or not read when pacer did not
97 * say, and "off?" while a first press to switch it off waits for the second.
98 */
99export function pacerLabel(enabled: boolean | null, surface?: string, short = false,
100 confirming = false): string {
101 const word = enabled === true ? (confirming ? "off?" : "on")
102 : enabled === false ? "off" : "not read";
103 return icons(surface).governance + " " + (short ? "" : "Pacer ") + word;
104}
105
106/** One thing the band can lead with, under a key that names it across readings. */
107export type BandItem = {
108 key: string;
109 level: "error" | "warning";
110 text: string;
111};
112
113/** The figures the band states, or null when there is no reading to state them from. */
114export type BandCounts = { working: number; waiting: number; failed: number };
115
116/**
117 * Whether a job is this session's own.
118 *
119 * A job's id is the head of its session's identifier, and a job may carry the
120 * whole identifier too; either one matching is the same session.
121 */
122export function isOwnJob(job: Job, sessionId: string): boolean {
123 if (!sessionId) {
124 return false;
125 }
126 if (job.session_id && job.session_id === sessionId) {
127 return true;
128 }
129 return job.id !== "" && sessionId.startsWith(job.id);
130}
131
132/** What a value says once anything secret in it is masked. */
133function said(value: string | null | undefined): string {
134 return mask(value ?? "").text;
135}
136
137/** What the band shows and keeps, as the reading carries it. */
138export type BandConfig = {
139 /** How many dismissed keys are kept: the newest. */
140 hiddenLimit: number;
141 /** How long after a job blocked it still leads the band rather than waiting in the pane. */
142 waitingJobSeconds: number;
143};
144
145/** The schema's defaults, for a reading that carries no band document or an unusable value. */
146export const BAND_DEFAULTS: BandConfig = { hiddenLimit: 200, waitingJobSeconds: 86_400 };
147
148/** The band settings a reading carries, each one the default when it is not a whole number above nought. */
149export function bandConfigFrom(documents: unknown): BandConfig {
150 const carried = (documents as { band?: Record<string, unknown> } | null)?.band;
151 if (typeof carried !== "object" || carried === null) {
152 return BAND_DEFAULTS;
153 }
154 const whole = (key: keyof BandConfig): number => {
155 const value = carried[key];
156 return typeof value === "number" && Number.isInteger(value) && value >= 1
157 ? value : BAND_DEFAULTS[key];
158 };
159 return { hiddenLimit: whole("hiddenLimit"), waitingJobSeconds: whole("waitingJobSeconds") };
160}
161
162/**
163 * The dismissal limit a reading carries, or null when it carries none.
164 *
165 * Unlike `bandConfigFrom`, no default stands in: a reading without a limit says
166 * nothing about how many dismissals to keep, so it is no reason to cut them.
167 */
168export function hiddenLimitFrom(documents: unknown): number | null {
169 const carried = (documents as { band?: Record<string, unknown> } | null)?.band;
170 const value = typeof carried === "object" && carried !== null ? carried.hiddenLimit : undefined;
171 return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null;
172}
173
174/** The source each kind of band item comes from, by the head of its key. */
175const KEY_SOURCES: Record<string, string> = {
176 job: "roster", trial: "trials", run: "progress", pr: "prs", skill: "skills", pacer: "pacer",
177};
178
179/** The source a band key belongs to, or null for a key no source makes. */
180function keySource(key: string): string | null {
181 return KEY_SOURCES[key.slice(0, key.indexOf(":"))] ?? null;
182}
183
184/**
185 * The sources that answered this reading: read ready or empty, with no error.
186 *
187 * Only such a source can say an item has gone. A roster with one provider
188 * failing is ready with an error, and that provider's jobs are absent only
189 * because nobody could ask.
190 */
191export function answeredSources(snapshot: Snapshot): Set<string> {
192 const answered = new Set<string>();
193 for (const name of Object.values(KEY_SOURCES)) {
194 // A reading taken before a source existed carries no document for it, and
195 // such a source has not answered.
196 const document = snapshot[name as keyof Snapshot] as
197 { state?: string; errors?: unknown[] } | undefined;
198 if (document === undefined) {
199 continue;
200 }
201 if ((document.state === "ready" || document.state === "empty")
202 && (document.errors ?? []).length === 0) {
203 answered.add(name);
204 }
205 }
206 return answered;
207}
208
209/**
210 * The dismissed keys to keep once a dismissal is written.
211 *
212 * A key that names nothing the band could show now is dropped, since it hides
213 * nothing, but only when its source answered this reading: an item absent
214 * because its source failed is still dismissed when the source recovers. The
215 * keys just dismissed go last, each kept once, and only the newest `limit` are
216 * kept. Without `answered`, every source is taken to have answered.
217 */
218export function keptHidden(hidden: readonly string[], current: readonly string[],
219 dismissing: readonly string[], limit: number,
220 answered: ReadonlySet<string> | null = null): string[] {
221 const live = new Set(current);
222 const fresh = new Set(dismissing);
223 const unasked = (key: string): boolean => {
224 const source = keySource(key);
225 return answered !== null && source !== null && !answered.has(source);
226 };
227 const kept = hidden.filter((key) => (live.has(key) || unasked(key)) && !fresh.has(key));
228 return [...new Set([...kept, ...dismissing])].slice(-limit);
229}
230
231/** What a new skill finding says on the band, and whether it is an error or a wait. */
232function skillItem(skill: Snapshot["skills"]["new"][number]): BandItem {
233 const key = "skill:" + skill.target + ":" + skill.verdict + ":" + skill.hash;
234 if (skill.verdict === "flagged") {
235 return { key, level: "error",
236 text: "Skill flagged: " + said(skill.name) + ", " + said(skill.issue) };
237 }
238 if (skill.verdict === "failed") {
239 return { key, level: "warning", text: "Skill scan could not finish: " + said(skill.name) };
240 }
241 return { key, level: "warning",
242 text: "Skill to read: " + said(skill.name) + ", " + said(skill.issue) };
243}
244
245/** Change states after which a failed trial no longer needs anybody. */
246const SETTLED_CHANGES = new Set(["MERGED", "CLEANED"]);
247
248/**
249 * Everything in a reading that needs a person, worst first.
250 *
251 * A failed trial on a change still open, a run with failures, a pull request
252 * whose checks fail and a newly flagged skill are errors; a session waiting on
253 * its person, and a new skill finding to read or a scan that could not finish,
254 * are waits. The key of each carries what makes it the same item next time, so
255 * a dismissed item stays hidden until it changes: a session that waits a second
256 * time is a new item.
257 *
258 * A job that blocked longer ago than `waitingJobSeconds` is parked rather than
259 * waiting on its person now, so it stays in the pane and off the band; the age
260 * is read against `now`, or against the reading's own clock without one. A job
261 * no read has dated is kept, since nothing shows it to be parked.
262 */
263export function bandItems(snapshot: Snapshot, dismissed: readonly string[],
264 sessionId: string,
265 settings: BandConfig & { now?: number } = BAND_DEFAULTS): BandItem[] {
266 const hidden = new Set(dismissed);
267 const errors: BandItem[] = [];
268 const waits: BandItem[] = [];
269 const now = settings.now ?? snapshot.generated_at;
270 for (const trial of snapshot.trials.recent) {
271 if (!trial.is_green && !SETTLED_CHANGES.has(trial.change_state)) {
272 errors.push({ key: "trial:" + trial.key, level: "error",
273 text: "Trial failed on " + said(trial.repo) + " " + said(trial.branch) + ": "
274 + (trial.failed + trial.errors) + " failed" });
275 }
276 }
277 for (const run of snapshot.progress.runs) {
278 if (run.failed > 0) {
279 errors.push({ key: "run:" + run.key + ":" + run.failed, level: "error",
280 text: said(run.label || run.display) + ": " + run.failed + " failed" });
281 }
282 }
283 // A run pacer refused, or one that failed while pacer held its slot. A run
284 // made with pacing off failed on its own terms and is not pacer's to raise,
285 // and one the person stopped did not fail.
286 for (const run of snapshot.pacer?.recent ?? []) {
287 const refused = run.mode === "refused";
288 if (!refused && (run.exit === 0 || run.mode === "off" || run.mode === "interrupted")) {
289 continue;
290 }
291 const where = said(run.repo ?? "a repository");
292 errors.push({ key: "pacer:" + (run.repo ?? "") + ":" + run.at, level: "error",
293 text: refused
294 ? "Pacer refused a run in " + where + (run.reason ? ": " + said(run.reason) : "")
295 : "Run failed in " + where + ": exit " + run.exit });
296 }
297 for (const pull of snapshot.prs.prs) {
298 if (pull.checks === "fail") {
299 errors.push({ key: "pr:" + pull.repo + "#" + (pull.number ?? "") + ":" + pull.head,
300 level: "error",
301 text: "PR " + (pull.number === null ? "" : pull.number + " ")
302 + "checks failing: " + said(pull.title) });
303 }
304 }
305 for (const skill of snapshot.skills.new) {
306 const item = skillItem(skill);
307 (item.level === "error" ? errors : waits).push(item);
308 }
309 for (const job of snapshot.roster.jobs) {
310 const dated = job.since !== null && job.since !== undefined;
311 if (dated && now - (job.since as number) > settings.waitingJobSeconds) {
312 continue;
313 }
314 if (waiting(job) && !isOwnJob(job, sessionId)) {
315 const since = dated ? ":" + Math.trunc(job.since as number) : "";
316 waits.push({ key: "job:" + job.id + ":blocked" + since, level: "warning",
317 text: said(job.name) + " is waiting for you" });
318 }
319 }
320 return [...errors, ...waits].filter((item) => !hidden.has(item.key));
321}
322
323/**
324 * The figures the band states, counted the way the pane's head counts them,
325 * less this session's own job: the band leaves that job out of its items, and a
326 * figure that counted it would say "1 waiting" beside nothing that waits.
327 */
328export function bandCounts(snapshot: Snapshot, sessionId = ""): BandCounts | null {
329 if (snapshot.roster.state !== "ready") {
330 return null;
331 }
332 const jobs = snapshot.roster.jobs.filter((job) => !isOwnJob(job, sessionId));
333 return {
334 working: jobs.filter((job) => working(job) || active(job)).length,
335 waiting: jobs.filter(waiting).length,
336 failed: failedOutcomes(snapshot),
337 };
338}
339
340export type BandInput = {
341 /** What needs a person, worst first; empty when nothing does. */
342 items: BandItem[];
343 /** The figures, or null while there is no reading or the roster could not be read. */
344 counts: BandCounts | null;
345 /** Why there are no figures, when that is the case, such as a read in flight. */
346 note?: string;
347 /** Whether the pane is open, which is what the switch's word says. */
348 open: boolean;
349 /** The cells the band is laid out in. */
350 width: number;
351 /** The surface drawing the band, which picks the icons; a terminal when absent. */
352 surface?: string;
353 /** Whether speech is on in every session; the speech switch is drawn only with `onSpeak`. */
354 speaking?: boolean;
355 /**
356 * The machine's governance switch, drawn only when the machine has one: a
357 * reading whose source is unconfigured says nothing here and the button goes.
358 */
359 governance?: { governor: boolean | null; controller: boolean | null } | null;
360 onToggle: () => unknown;
361 onDismiss: () => void;
362 onSpeak?: () => unknown;
363 onGovernance?: () => unknown;
364 /**
365 * Pacer's switch, drawn only on a machine that runs pacer, where it takes
366 * the gear in place of the governance switch it replaces.
367 */
368 pacer?: { enabled: boolean | null; confirming?: boolean } | null;
369 onPacer?: () => unknown;
370 /**
371 * Open the pane, narrowed to one of the figures, or to the whole fleet.
372 *
373 * The band is on screen while the pane is shut, which is when a person reads
374 * a count and wants the rows behind it; without this the figure is a number
375 * they have to go and find.
376 */
377 /**
378 * The press that opens the pane, as this session's state to start it in.
379 *
380 * A figure names a state, and the pane opens narrowed to it. The item a band
381 * leads with names a row instead, so it hands its key over and the pane opens
382 * on the section that row lives in, the rest folded out of the way.
383 */
384 onOpen?: (focus?: "working" | "waiting" | "failed", key?: string) => unknown;
385};
386
387/** The cells a button adds around its label: "[ " before it and " ]" after. */
388const BUTTON_CHROME = 4;
389/** The cells between two parts of the band's line. */
390const PART_GAP = 2;
391
392/** One button, as the host draws a press: the address it comes back on, and its word. */
393function button(key: string, label: string, onPress: () => unknown, primary = false): Node {
394 return { type: "Button",
395 props: { key, label, onPress, ...(primary ? { variant: "primary" as const } : {}) },
396 children: [] };
397}
398
399/**
400 * The band's one line.
401 *
402 * The switch leads, so it sits in the same place whatever else the line holds.
403 * The figures follow on chips, a figure at zero left out, and then the worst
404 * item in its own color with the rest counted after it. Dismiss is offered only
405 * when there is something to dismiss. A line too long for the band is cut from
406 * the item's end, never from the switch or the figures.
407 */
408export function bandTree(input: BandInput): Node {
409 const toggle = fleetLabel(input.open, input.surface);
410 const parts: Node[] = [button(BAND_TOGGLE, toggle, input.onToggle, !input.open)];
411 // What the line takes as the terminal draws it: a button is its label in
412 // "[ " and " ]", a chip is its word with a cell of fill at each side, and
413 // every part after the first sits two cells from the one before.
414 let used = toggle.length + BUTTON_CHROME;
415 if (input.onSpeak !== undefined) {
416 const speaking = input.speaking === true;
417 const word = speakLabel(speaking, input.surface);
418 parts.push(button(BAND_SPEAK, word, input.onSpeak, speaking));
419 used += PART_GAP + word.length + BUTTON_CHROME;
420 }
421 // The machine's switch sits beside the voice's, and only while the machine
422 // has one: a person who does not run that tooling never sees the button.
423 // Pacer takes the gear where it runs; the governance switch it replaces is
424 // drawn only on a machine without pacer.
425 const pacing = input.pacer !== undefined && input.pacer !== null
426 && input.onPacer !== undefined;
427 const governing = !pacing && input.governance !== undefined && input.governance !== null
428 && input.onGovernance !== undefined;
429 const gear: { key: string; press: () => unknown; label: (short: boolean) => string } | null =
430 pacing ? { key: BAND_PACER, press: input.onPacer as () => unknown,
431 label: (short) => pacerLabel(input.pacer?.enabled ?? null, input.surface, short,
432 input.pacer?.confirming === true) }
433 : governing ? { key: BAND_GOVERNANCE, press: input.onGovernance as () => unknown,
434 label: (short) => governanceLabel(input.governance?.governor ?? null,
435 input.governance?.controller ?? null,
436 input.surface, short) }
437 : null;
438 let gearAt = -1;
439 if (gear !== null) {
440 const word = gear.label(false);
441 gearAt = parts.length;
442 parts.push(button(gear.key, word, gear.press));
443 used += PART_GAP + word.length + BUTTON_CHROME;
444 }
445 const figures: [number, string, Color, "working" | "waiting" | "failed"][] =
446 input.counts === null ? [] : [
447 [input.counts.working, "working", "success", "working"],
448 [input.counts.waiting, "waiting", "warning", "waiting"],
449 [input.counts.failed, "failed", "error", "failed"],
450 ];
451 for (const [count, word, color, focus] of figures) {
452 if (count > 0) {
453 const label = count + " " + word;
454 const drawn = chip(label, color);
455 // The figure is the way to the rows it counts: a press opens the pane
456 // already narrowed to them.
457 parts.push(input.onOpen === undefined ? drawn
458 : { type: "Button", props: { key: BAND_FOCUS + focus, label,
459 onPress: () => input.onOpen?.(focus) },
460 children: pressable([drawn]) });
461 used += PART_GAP + label.length + 2;
462 }
463 }
464 if (input.counts !== null && figures.every(([count]) => count === 0)) {
465 parts.push(text({ dimColor: true }, "Nothing running"));
466 used += PART_GAP + "Nothing running".length;
467 }
468 if (input.counts === null && input.note) {
469 parts.push(text({ dimColor: true }, input.note));
470 used += PART_GAP + input.note.length;
471 }
472 const dismiss = input.items.length > 0;
473 // A band too narrow for the whole line gives up the product's name first,
474 // then the speech switch's state word: the verb still says which way a press
475 // goes, and nothing a person needs to read is cut.
476 const dismissWidth = dismiss ? PART_GAP + "Dismiss".length + BUTTON_CHROME : 0;
477 let fixed = used + dismissWidth;
478 if (fixed > input.width) {
479 const short = fleetLabel(input.open, input.surface, true);
480 parts[0] = button(BAND_TOGGLE, short, input.onToggle, !input.open);
481 used -= toggle.length - short.length;
482 fixed = used + dismissWidth;
483 }
484 if (input.onSpeak !== undefined && fixed > input.width) {
485 const speaking = input.speaking === true;
486 const full = speakLabel(speaking, input.surface);
487 const short = speakLabel(speaking, input.surface, true);
488 parts[1] = button(BAND_SPEAK, short, input.onSpeak, speaking);
489 used -= full.length - short.length;
490 }
491 if (gear !== null && gearAt >= 0 && used + dismissWidth > input.width) {
492 const full = gear.label(false);
493 const short = gear.label(true);
494 parts[gearAt] = button(gear.key, short, gear.press);
495 used -= full.length - short.length;
496 }
497 const room = input.width - used - PART_GAP - dismissWidth;
498 const [first, ...others] = input.items;
499 if (first !== undefined && room > 8) {
500 const failing = others.filter((item) => item.level === "error").length;
501 const waits = others.length - failing;
502 const rest = [failing > 0 ? failing + " more failing" : "",
503 waits > 0 ? waits + " more waiting" : ""].filter(Boolean).join(", ");
504 const lead = fit(first.text, Math.max(4, room - (rest ? rest.length + 3 : 0)));
505 const words: Node[] = [
506 text({ color: first.level, bold: true, wrap: "truncate-end" }, lead),
507 ...(rest && lead.length + rest.length + 3 <= room
508 ? [text({ dimColor: true }, " · " + rest)] : []),
509 ];
510 // What the band leads with is the way into it: a press opens the pane,
511 // where the row it names is drawn with the rest of what it says.
512 parts.push(box({ flexDirection: "row", gap: 0, flexShrink: 1 },
513 input.onOpen === undefined ? words
514 : [{ type: "Button",
515 props: { key: BAND_ITEM + first.key, label: lead,
516 onPress: () => input.onOpen?.(undefined, first.key) },
517 children: pressable(words) }]));
518 }
519 const left = box({ flexDirection: "row", gap: 2, flexShrink: 1 }, parts);
520 const row = dismiss
521 ? box({ flexDirection: "row", justifyContent: "space-between", gap: 2 }, [
522 left,
523 button(BAND_DISMISS, "Dismiss", input.onDismiss),
524 ])
525 : box({ flexDirection: "row", gap: 0 }, [left]);
526 // A rule across the band sets it apart from whatever the session drew above it,
527 // so the buttons read as the fleet's own line. It is drawn dim: it separates
528 // without competing with the figures and the item beside it.
529 return box({ flexDirection: "column", gap: 0, marginBottom: 0 }, [
530 text({ dimColor: true }, BAND_RULE.repeat(Math.max(0, input.width))),
531 row,
532 ]);
533}
534