SLOPSHOPPER

llm-fleet

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.

panebandspinnercommandtoastprompt
★ 1v0.4.16MITupdated 2026-10-20doricstack/llm-fleet/plugins/llm-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · llm-fleet
│ ┃ llm-fleet (llm-fleet) ✕ › fix the failing auth test and add an audit log call │ ┃ ▬● llm-fleet │ ┃ x: Close Esc or x closes this pane ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▾ ▎ llm-fleet ⏺ Update(src/auth.ts) │ ┃ Could not read the fleet (the read did not ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ llm-fleet 0.4.16 · Doric Stack LLC · MIT │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /llm-fleet │ ⎿ llm-fleet: llm-fleet pane opened (llm-fleet). │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── [ ≡ Close llm-fleet ] [ ♪ Speak: off ] The fleet read failed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── [ ≡ Close llm-fleet ] [ ♪ Speak: off ] The fleet read failed
Pane · llm-fleet (llm-fleet)
▬● llm-fleet x: Close Esc or x closes this pane ▾ ▎ llm-fleet Could not read the fleet (the read did not answer: th… llm-fleet 0.4.16 · Doric Stack LLC · MIT
README

llm-fleet

llm-fleet

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.

Install

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.

Upgrade

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.

First run

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

Using the pane

  • The figures at the top are controls. Press Working sessions, Waiting on you or Failed outcomes to narrow the pane to those rows; a line under the controls says what is shown, with Show all to widen it again.
  • A row that holds more than its two lines says so on its figures: press them to open the rest (path, provider, cleanup line, check list), and press again to close it.
  • A section heading folds it, and the keys section lists every binding the pane draws.
  • On the band above the prompt, the figures open the pane narrowed to their state, and the item the band leads with opens the pane where its row lives.
  • What a row offers is copied or opened from its title: a job's attach line, a worktree's cleanup line, a pull request's page, a skill report command.

Commands

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

Settings

Three options live in /config under the plugin, because they are needed before the configuration file can be read:

  • Python interpreter: the interpreter the readers run under, python3 by default.
  • Configuration file: a full path to your configuration when it is not in the default place.
  • Desktop notifications: on by default; adds a desktop notification to the in-session toast.

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.

What it reads

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.

What it sends, and what it runs

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.

The hooks, and what each decides

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.

HookWhat it does
session.startRegisters /llm-fleet, /speak, /worktrees and /keys, and reads this session's identity.
command.runAnswers those four commands and nothing else.
ui.renderDraws the pane, the band above the prompt and the key tip under it.
ui.closeRecords that the pane shut and, for Escape, marks the digest it showed as seen.
prompt.submitRecords 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.endStops the timers this session started.

The programs it runs, and why

Every program runs as your user, from the list below, with its arguments built by the plugin and never through a shell.

ProgramWhy
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.pyThe 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.
claudeThe session roster.
psThe machine's load, and which terminal a session runs in.
git and lsofA worktree's branch and state, and which process holds it, when the worktrees source is on.
ghPull requests, when that source is on.
Commands your configuration namesThe 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.

The files it writes

  • The starter configuration, once, from /llm-fleet setup, and never over a file already there.
  • Small records under its state directory ($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).
  • Speech clips in the system temporary folder while speech is on, each removed once played.
  • Your folds, focus and speech choice, in Claude Code's own plugin store.

The environment it reads

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.

Calls from Claude Code

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.

Privacy

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.

License

MIT.

Source 25 files
hooks/llm-fleet.ts 3909 lines
1/**
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 lines
1/**
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}
961
hooks/engine.ts 244 lines
1/**
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;
244
components/state.ts 575 lines
1/**
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};
575
components/options.ts 303 lines
1/**
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}
303
types/index.d.ts 76 lines
1/**
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}
76
components/palette.ts 159 lines
1/**
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}
159
components/pane.ts 2943 lines
1/**
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 lines
1/**
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}
115
components/stream.ts 78 lines
1/**
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}
78
components/surface.ts 170 lines
1/**
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}
170
components/band.ts 534 lines
1/**
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