belay's panel: a band above the prompt and a side pane that show a belay run's agents, notices and cost. Read-only.

Windows only. belay is built and tested on Windows 10/11 only. As it stands, it will not work on macOS or Linux: the way it proves which process to kill, its pipes and its console handling rely on Windows mechanisms. macOS and Linux support is planned, with no date.
belay is a local orchestration harness for AI coding agents. It runs Claude Code and codex sessions as worker processes on one machine, keeps every task, lease, checkpoint, review verdict and per-agent cost in one SQLite store, and lets one human watch and step in. The planner is a Claude session, headless or your own interactive one, that drives the workers through belay's MCP tools. The name is the climbing sense: belay does not do the climbing. It holds the rope, keeps the record and catches the fall.
belay is early. It is being built toward four things: memory that lasts across sessions, several orchestrators coordinating one body of work, workers that can be any model, including open-source and local ones, and autonomy: taking a queue of work and starting its own runs to work through it. None of the four is finished. 0.2.0 added the first large piece, a shared memory you can check. 0.3.0 adds notes, experimental and on by default once a project is named. Where this is going says what each direction stands on today and what is planned.
Status: 0.3.0, early, built and used by one developer. Interfaces, spec keys and the store layout may change. Upgrading from 0.2.0 changes some defaults; see the upgrade warning in CHANGELOG.md and INSTALL.md.
Claude Code already does a lot of this natively, and for most tasks the native features are simpler. belay is different, not better. Nothing has measured belay against native Claude Code, so we make no claim that it gives better results.
What Claude Code already has (from the Claude Code documentation, checked 2026-10-08; check the current docs, because they change often):
model, effort, tools allowlist, permissionMode, MCP servers, and a git worktree to work in.--tools, --strict-mcp-config, --setting-sources, --bare and --safe-mode.ANTHROPIC_BASE_URL, for routing requests through a gateway. Anthropic does not support routing Claude Code to non-Claude models.belay also sets a model and effort for each worker. That is a feature, not something only belay has.
What belay does differently:
done only when its checks pass, or, for a review task, when the reviewer's verdict holds. Fix rounds after a refuted review are capped. A lease records ownership; it does not limit what a worker can do.project namespace that outlives any one run. Search tells you which modes ran and whether an empty result means anything. Every row carries a status (unverified, verified or refuted) and records who wrote it; the writer and status are what the caller claimed, see the trust limit under Notes. Secrets are redacted before a row is stored, and what an agent reads from memory is marked as data, not instructions. Once you name a project, workers hand back notes for later workers to find. Capture, summaries and an index in each worker's first message are built too, but stay off unless you turn them on. In a controlled experiment, workers given the right memory row avoided mistakes that workers without it made every time; see What we have measured. You
| CLI (window and hand) Claude Code panel (read-only)
v v
+-------------------------------------------------------------+
| Orchestrator: a Claude session (headless, or your own one). |
| It plans, delegates and judges. belay never does. |
+----------------------------+--------------------------------+
| MCP
v
belay adapter (orchestrator role) --> Core API --> SQLite store
parent of every worker ^
| spawns | other processes reach the store
v | through the owner's named pipe
Claude Code worker -- belay adapter (worker role)
Claude Code worker -- belay adapter (worker role)
Codex worker -- belay adapter (worker role)
Each direction below says what it stands on today, which is built, and what is planned, which is not.
belay 0.2.0 built the parts of memory that can be built and checked without a model call. One experiment shows that a delivered memory row changes what a worker does. 0.3.0 adds notes, facts workers hand back for later workers, with a review and hold queue around them. Whether notes make workers better, and whether a note is found again in a large store, is not shown yet.
On by default:
native_memory, turns it back on; see INSTALL.md).--mode keyword or --mode semantic. Every result names the modes that ran (modes_run), the modes that failed and why (modes_failed), and absence_established. That last field is true only when keyword and semantic search both ran over every row in scope and nothing matched. If the embedder is not available, a search runs keyword alone and says so in a warning at the top of the result. An empty page is never presented as proof that nothing is stored.view: index returns ids, types, statuses and titles, with no text. An agent can scan many rows cheaply and open only the ones it needs. There is also a timeline around any row.unverified until the orchestrator or you mark it verified or refuted. A mark needs a receipt that names its evidence: a checkpoint, a task, another row, a commit or a test. A commit receipt is checked against the repository when belay knows the worktree. Workers have no tool to mark a row; a note becomes verified only through a review verdict that quotes its evidence (see Notes). In a run with notes on, the orchestrator has no tool to mark rows either; it disputes a note by reporting it.<private>...</private> spans. It replaces API keys, tokens, private keys, bearer headers, URL credentials and secret-looking values with [REDACTED:<kind>]. Rows copied into a project namespace are always redacted. This protects memory only: a worker's raw stream.jsonl log keeps what the worker saw. Redaction is pattern matching and will miss things; do not treat it as a guarantee.BEGIN MEMORY and END MEMORY.belay memory list, show, timeline and stats, belay usage and belay notices all read without writing.belay memory export writes live rows to a file; --all includes refuted, superseded and expired ones too. belay memory import writes only into namespaces you name. Every imported row arrives as an unverified row of type imported, so nothing in a file reaches an agent as a decision or a verified fact until you verify it in the new store.Off by default, behind levers in the run spec (or as defaults in belay.config.json's memory block, set with belay settings memory.<lever> <value>; a run spec's own block wins). With none of these turned on and no project named, a run produces the same events, rows and first messages as in 0.1.0. Naming a project turns on notes; see Notes for their levers.
| Lever | What it does |
|---|---|
rules | Adds a short block of memory rules (search the index first, cite ids, treat unverified rows as unverified) to the orchestrator's prompt. |
capture | Records each tool result's exit code, the files it touched and the first and last lines of its output, from the worker's event stream. No hooks. Redacted. |
observations | Writes one mechanical row per worker turn: files read and changed, commands and their exit codes. No model call. Needs capture. |
summaries | "mechanical" writes one summary per task from those rows, labelled as generated, not as the worker's words. Needs observations. |
project, project_writes | Names a project namespace that outlives the run; project_writes copies observations, summaries and verified rows into it. Naming a project also turns on notes. project_writes together with notes is refused; set notes: false to keep it. |
notes | On once project is set (and redact is on); false turns notes off. See Notes. |
on_embedder_failure | required (default: a failed embedder pauses the run), keyword (go on ranking by keywords only) or off (memory off for the run). The 0.2.0 values stop and continue are still accepted, as required and keyword. |
cost_ledger | Records tokens and dollars per turn, for belay usage. |
first_message_index | Puts a bounded index of lasting rows (summaries, decisions, verified rows) in a worker's first message, ranked by relevance to the task. index_bytes sets the size (default 1,200 bytes, at most 4,096). |
bottle | When a worker is replaced, builds its resume document from its own stream: its text verbatim, tool calls as one line each, within bottle_bytes. |
wrapup | Writes a short wrap-up per task and one for the run, shown once in belay notices. |
knowledge_agent | A read-only worker that answers the orchestrator's questions from verified memory and the repository, citing row ids. |
redact | On by default. Set to false to turn redaction off for one run; the row then records that it was off. Copies into a project namespace are redacted regardless. |
alpha | The weight of semantic results against keyword results in hybrid search, 0 to 1, default 0.3. |
A lever that needs another lever, an unknown key or a bad value is a usage error that names the key.
Turning levers on. Add a memory block to the run spec. This one turns on capture, mechanical observations and summaries, and a bounded index in each worker's first message:
{
"worktree": ".",
"prompt_file": "./prompt.md",
"memory": {
"project": "my-app",
"capture": true,
"observations": true,
"summaries": "mechanical",
"first_message_index": true,
"index_bytes": 1200
}
}
Levers cost something: observations add a few KB to the store per turn, and an injected index adds bytes to every first message. In the trap experiment below the index cost the same as no memory; in the first experiment memory cost about 18% more. Whether capture and summaries pay for themselves is not measured.
Commands:
belay memory search "lease expired" # hybrid by default; check modes_run and absence_established
belay memory search "lease expired" --mode keyword # keyword only; needs no embedder
belay memory search "lease expired" --index --status verified # ids and titles only
belay memory list --type observation --task <task-id> # filter rows
belay memory show <id> # one or more rows in full
belay memory timeline <id> --before 5 --after 5 # what was written around a row
belay memory verify <id> --status verified --receipt commit:3e1de17 # needs a receipt
belay memory stats # counts by type and status, keyword index state
belay memory export --out rows.jsonl # the newest run's live rows without vectors (name a run or --namespace for others); --all for every row; never overwrites a file
belay memory import rows.jsonl --namespace-map run/old=project/my-app # every namespace in the file must be mapped
belay usage --by task # per-turn tokens and dollars (needs cost_ledger)
belay memory held # held notes, by ground; --awaiting for those waiting on you
belay memory review # the archive review of the held queue (an Opus 5.5 child, capped at $2)
belay memory held settle <note-id> --archive --reason "<why>" # or --release, --reoffer: settle one held note yourself
belay memory report <note-id> --reason "<why>" # report a note wrong; it is held for the archive review
belay memory proposals # the scrubber's proposals and the held queue
Read commands start no process. From the CLI, a search with no running embedder answers from keyword search and names the semantic half that did not run. Only --mode semantic refuses outright. Inside a run with the default on_embedder_failure: required, a failed embedder pauses the run and a worker's search fails instead. belay memory verify, import, held settle and the other write commands take the store owner for the length of the command; memory review and memory scrub also start one read-only model child.
What is still planned:
The experiments on search and on whether memory helps were pre-registered: the cases, the measures and the bar for success were fixed before any result was seen. The two 0.3.0 runs at the end check that notes work and what they cost; they do not test whether notes help.
Search: hybrid against semantic only (measured on 0.2.0's search; 0.3.0 changed the keyword half to match any term). The corpus was 2,442 memory rows that belay's own memory pipeline built from recorded runs. The 673 queries were mostly written by model agents that had not seen the corpus, plus queries taken from real runs, queries aimed at one known row, and queries with no answer. Model judges graded 20,972 query-row pairs, and a second judge on a sample agreed closely (weighted kappa 0.86). On held-out queries, hybrid search beat semantic-only by a small margin: mean reciprocal rank +0.023, with a 95% interval of -0.004 to +0.053. That interval includes zero, so by our own rule the gain is not confirmed. Recall in the top ten rose by 0.038 (0.016 to 0.063). The gain is concentrated where keyword search should help: on queries for file paths, the score rose from 0.58 to 0.85. Plain-language queries were flat, and lookups of past decisions lost a little (0.735 to 0.696). We made hybrid the default because the overall difference is small either way, and it fixes the case where semantic search is weakest: exact paths, identifiers and error text.
Does memory help a worker? First attempt: no benefit, from a flawed experiment. We ran 54 small coding tasks with and without a memory index in the worker's first message, and with a control that carried the same bytes without the content. Every arm passed the same tasks, and memory cost about 18% more. The null came from the design: most of the facts memory could supply were also in the task text or in files the worker read anyway, and the index showed the wrong rows. We fixed both before trying again.
Second attempt: the right row prevents the mistake. We built eight tasks, each hiding a trap in a fact the code cannot reveal: an external system's limit or format, an approach tried and rejected before, a migration number already used in production, an operator's formatting preference. Sonnet 5.5 ran each task three times in each of three arms, 72 runs in all:
| Arm | Avoided the trap |
|---|---|
| No memory | 0 of 24 |
| Placebo rows: same number, shape and size, about something else | 0 of 24 |
| The right row in the first-message index | 24 of 24 |
Every case passed its validity checks, including a check that the right row really was in the index. The memory arm cost the same as no memory. The limits are real: we wrote the rows by hand, each stating the exact fact, and the store held eight rows. This shows that a delivered fact gets applied. It does not show that belay captures such a fact on its own or finds it among thousands of rows.
Third attempt: belay captures the fact itself. We then ran that experiment, with belay capturing the fact from a worker's session (an earlier capture pipeline built on task summaries, not 0.3.0 notes). With nothing else in the store, the captured fact was applied in 7 of 24 later sessions, against 0 of 24 with no memory: just short of our bar, so inconclusive. Among 1,752 other rows, it was applied in 1 of 24. Where the repository already showed the fact, memory added nothing. belay still makes no broader claim that memory helps.
0.3.0: a 40-task build with notes on. belay drove a 40-task build of a small command-line product on real Claude Code 2.1.293, on a pre-release 0.3.0 build (before the report-flow change described under Notes; that flow was then checked with its own real smoke run), with the 0.3.0 defaults and a project set. It ran one task per run, in sequence: each run had a coder, its reviewer and any fix rounds, and no two tasks' workers ran at the same time. Every task landed and every run ended normally. It cost $103.79 in all, $6.57 of it attempts voided by a bug in the test driver, none caused by belay. Workers wrote 101 notes; 72 were returned by memory search to a worker other than the one that wrote them, and 30 were verified, 29 by a task's reviewer and 1 by the archive review. The six run-close scrubs cost $0.012 together. There was no notes-off comparison, so this shows that notes work end to end, not that they help.
0.3.0: what notes cost. In one small paired run (8 pairs of Sonnet 5.5 sessions on a fresh empty store, notes off against notes on), notes added 2,014 bytes to each worker's first message and about 1,550 input tokens to its first request. Worker cost rose by $0.0187 per session on average (standard error $0.0071; the pairs ranged from -$0.016 to +$0.052, and 7 of 8 cost more). That is one small run, not a cost to expect in a project whose store holds many notes.
Notes are experimental. They are on by default once a project is named, and they work end to end, but they have been tested in one 40-task build, never with thousands of notes. Nothing here says they make workers better.
What a note is. At the end of a task a worker can hand back up to 10 notes: a one-line claim, the situation that would run into it ("when"), how it knows (observed, inferred or relayed) and, when observed, the evidence it saw. belay stores each note in the project's memory with the worker, task and run that wrote it, and the files the worker changed. Later workers find notes with memory search. A note close to an existing one is held until the worker says whether it is new, a repeat, a replacement or a refinement.
Project memory is notes. With notes on, a memory_store into the project by a worker or the orchestrator is written as a note, and every rule on this page applies to it; a store without the note's fields is refused. The orchestrator records its decisions and run outcomes in summary/<project> instead. Rows already in the project that are not notes are not shown to workers or the orchestrator; belay memory commands still show them. project_writes together with notes is refused.
Turning notes on. Name a project: `belay settings memory.proje
hooks/register.tsx 11 lines1import type { Register } from 'claude-code'
2import { registerPanel } from './panel/index'
3import { registerWake } from './wake/index'
4
5// The belay mod: the visual panel (src/mod/hooks/panel/, owned by the panel session) and the interactive
6// wake (src/mod/hooks/wake/, ADR 0037 amendment 5). The wake activates only for the orchestrator's session.
7export const register: Register = on => {
8 registerPanel(on)
9 registerWake(on)
10}
11hooks/panel/index.tsx 474 lines1// The panel: a band above the prompt and a side pane over a belay run. Read-only, with ONE exception below. It runs belay's own
2// read commands by argv, keeps what it read in `$.state`, and draws it. It never writes the store or a
3// stream, and it submits nothing to the model: its commands answer with a toast, never with text the
4// model would read.
5//
6// The one exception, at the operator's request (2026-10-15): the memory line's button runs `belay memory review --project <p> --start`,
7// which launches the Opus archive reviewer DETACHED and returns once the review's claim shows in the store. The review writes the store and
8// spends money. It is narrowly scoped: exactly that one command; reachable ONLY from the button's onPress (view.tsx Held -> act.review ->
9// reviewPress -> dispatchReview), never from a command, a hook, a notice, a tool call, a model or a message; and it needs two presses (the
10// first only asks). The launcher is short, so a hot reload of this panel (which ends what the panel spawned) cannot end the review: the
11// detached belay CLI is the parent of the Opus child (invariant 1), holds the review's claim in the store, and is read back through belay's
12// notices (review_running, review_ended), so a reloaded panel still shows it and offers no second dispatch.
13//
14// The engine follows `$` only into functions declared at the top of this file, so every helper that
15// takes `$` lives here; the pure parts (parsing, drawing) live in model.ts, fetch.ts and view.tsx.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, On, Timer } from 'claude-code'
19import type { BelayPanelSettled, BelayPanelSnap, BelayPanelUi, BelayPanelWatch } from '../../types/index'
20import { argvFor, buildSnapshot, fatalSnapshot, fwd, launcherLine, isWindowsPath, needsRun, parseRan, parseRunFile, projectRootOf, repoCliGuess, type Ran, type RunFileRead } from './fetch'
21import { CONFIRM_MS, normHeld, reasonProblem, reviewProject, SETTLE_ACTIONS, SETTLE_UNKNOWN, PENDING_STALE_MS, pruneSettled, settleArgs, settleKind, settleLine, settledActive, shortId, type SettleAction } from './held'
22import { DEFAULT_SETTINGS, DEFAULT_UI } from './model'
23import { Band, BandWaiting, Fallback, Pane, type Act, type Els } from './view'
24
25const PANE = 'belay'
26const watchA = atom({ plugin: 'belay', key: 'panelWatch' } as const, null)
27const snapA = atom({ plugin: 'belay', key: 'panelSnap' } as const, null)
28const uiA = atom({ plugin: 'belay', key: 'panelUi' } as const, DEFAULT_UI)
29const settleA = atom({ plugin: 'belay', key: 'panelSettle' } as const, null)
30const settledA = atom({ plugin: 'belay', key: 'panelSettled' } as const, {} as BelayPanelSettled)
31
32// A round starts this long after the previous round ENDS (a slow belay is never chased).
33const PANE_EVERY_MS = 4000
34const BAND_EVERY_MS = 5000
35const SETTINGS_EVERY_MS = 10000
36const COMMAND_TIMEOUT_MS = 8000 // the panel's patience under host load (reads of 3.5 to 4.8 s were seen), not a product budget
37// `belay transcript --tail N`: the band reads the last 10 entries (it shows the newest words among them), the pane the last 50. Null would read it all.
38const TRANSCRIPT_TAIL: { band: number | null; pane: number | null } = { band: 10, pane: 50 }
39const BACKOFF_FIRST_MS = 10000
40const BACKOFF_MAX_MS = 60000
41const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/
42const REVIEW_ARGS = ['memory', 'review', '--project'] // the one write the panel makes; the project follows, then START_FLAG
43const START_FLAG = '--start'
44const RUN_FLAG = '--run' // the review acts for the watched run, a live one included (the CLI never defaults to a live run)
45const READ_VERBS = ['agents', 'notices', 'transcript', 'settings'] // the only commands runJson runs ...
46const AWAITING_READ = ['memory', 'held', '--awaiting'] // ... and this one read, run only while a held note waits on the operator
47const START_TIMEOUT_MS = 40000 // the launcher waits up to 20 s for the claim
48const NO_CLI_WINDOWS = 'belay CLI not found: on Windows set belay_cli and node in belay-run.json, or load this plugin from <repo>/src/mod'
49
50type Host = EngineInterface
51type Resolved = { cli: string[] } | { error: string }
52
53// Local to this load of the module. A hot reload drops the timer with the module, and every entry
54// point calls ensureTicker, so the first event after a reload starts it again.
55let timer: Timer | null = null
56let inflight = false
57let lastFetchAt = 0
58let lastSettingsAt = 0
59let timeoutStreak = 0
60let backoffUntil = 0
61const starting = new Set<string>() // projects whose launcher is running now (up to START_TIMEOUT_MS); the store's claim is the authority past that
62
63// null: there is no run file. Anything else that goes wrong with an existing file is an error state.
64async function readRunFile($: Host): Promise<RunFileRead | null> {
65 const path = `${fwd($.plugin.root)}/belay-run.json`
66 let exists: boolean
67 try { exists = await $.fs.exists(path) } catch { return { error: 'belay-run.json could not be checked' } }
68 if (!exists) return null
69 try { return parseRunFile((await $.fs.read(path)) as string) } catch { return { error: 'belay-run.json could not be read' } }
70}
71
72// Bound by belay-run.json: exactly [node, belay_cli]. A watching session (no file): `node <repo>/src/cli/belay.cjs`
73// when the plugin sits at <repo>/src/mod, else `belay` on PATH, which on Windows cannot be started without
74// a shell and so is an error naming the fix.
75async function resolveCli($: Host, watch: BelayPanelWatch): Promise<Resolved> {
76 if (watch.fixed) return watch.cli && watch.node ? { cli: [fwd(watch.node), fwd(watch.cli)] } : { error: 'belay-run.json lacks belay_cli/node' }
77 const guess = repoCliGuess($.plugin.root)
78 try {
79 if (await $.fs.exists(guess)) return { cli: ['node', guess] }
80 } catch { /* fall through */ }
81 return isWindowsPath($.plugin.root) ? { error: NO_CLI_WINDOWS } : { cli: ['belay'] }
82}
83
84// Every command has a hard timeout. A rejection after the full wait (or one that says it timed out) is a
85// timeout, which the caller turns into a skipped round and a back-off; any other rejection is a command
86// that did not run.
87async function runJson($: Host, cli: string[], args: string[], dataDir: string | null, cwd: string | null = null): Promise<Ran> {
88 if (!READ_VERBS.includes(args[0] ?? '') && args.join(' ') !== AWAITING_READ.join(' ')) return { error: `belay ${args[0] ?? ''}: the read helper runs only ${READ_VERBS.join(', ')}` } // never a write, whatever called it
89 const t0 = await $.clock.now()
90 try {
91 const r = await $.process.run(argvFor(cli, args, dataDir), { timeoutMs: COMMAND_TIMEOUT_MS, ...(cwd ? { cwd } : {}) })
92 return parseRan(args, r)
93 } catch (err) {
94 const waited = (await $.clock.now()) - t0
95 const said = err instanceof Error ? err.message : String(err)
96 if (waited >= COMMAND_TIMEOUT_MS - 100 || /time/i.test(said)) return { error: `belay ${args.join(' ')} timed out (${COMMAND_TIMEOUT_MS / 1000} s)`, timedOut: true }
97 return parseRan(args, null)
98 }
99}
100
101const timedOut = (r: Ran | null): boolean => !!r && 'timedOut' in r
102
103// One round: agents, notices, then (headless runs only) the transcript, then settings. Sequential, so at
104// most one belay process at a time. The orchestrator's words are read only for a headless run.
105// A timeout skips the rest of the round: what was read stays, what was not read is kept from before.
106async function fetchSnapshot($: Host, watch: BelayPanelWatch, prev: BelayPanelSnap | null, o: { withSettings: boolean; now: number; open: boolean }): Promise<{ snap: BelayPanelSnap; timedOut: boolean }> {
107 if (watch.error) return { snap: fatalSnapshot(watch.error, null, o.now), timedOut: false }
108 const r = await resolveCli($, watch)
109 if ('error' in r) return { snap: fatalSnapshot(r.error, watch.runId, o.now), timedOut: false }
110 const cli = r.cli
111 const agents = await runJson($, cli, ['agents', ...(watch.runId ? [watch.runId] : []), ...(watch.orchDir ? ['--dir', watch.orchDir] : [])], watch.dataDir)
112 const seen = 'json' in agents ? (agents.json as { run_id?: string; mode?: string }) : null
113 const runId = watch.runId ?? seen?.run_id ?? prev?.runId ?? null
114 const runArg = runId ? [runId] : []
115 const cut = (a: Ran, n: Ran | null, t: Ran | null, s: Ran | null, w: Ran | null = null) => ({ snap: buildSnapshot({ watch, prev, now: o.now, agents: a, notices: n, transcript: t, settings: s, awaiting: w }), timedOut: true })
116 if (timedOut(agents)) return cut(agents, null, null, null)
117 const notices = await runJson($, cli, ['notices', ...runArg], watch.dataDir)
118 if (timedOut(notices)) return cut(agents, notices, null, null)
119 // The ids of the held notes that wait on the operator: one more read, only while the notices say some wait.
120 const awaiting = 'json' in notices && (normHeld(notices.json)?.awaiting ?? 0) > 0 ? await runJson($, cli, ['memory', 'held', '--awaiting'], watch.dataDir) : null
121 if (timedOut(awaiting)) return cut(agents, notices, null, null, awaiting)
122 const mode = watch.fixed ? 'interactive' : seen ? seen.mode : prev?.mode
123 const tail = o.open ? TRANSCRIPT_TAIL.pane : TRANSCRIPT_TAIL.band
124 const transcript = mode === 'headless' ? await runJson($, cli, ['transcript', ...runArg, ...(tail === null ? [] : ['--tail', String(tail)])], watch.dataDir) : null
125 if (timedOut(transcript)) return cut(agents, notices, transcript, null, awaiting)
126 const settings = o.withSettings ? await runJson($, cli, ['settings'], watch.dataDir, watch.fixed && watch.dataDir ? projectRootOf(watch.dataDir) : null) : null
127 return { snap: buildSnapshot({ watch, prev, now: o.now, agents, notices, transcript, settings, awaiting }), timedOut: timedOut(settings) }
128}
129
130// Open or close the docked pane. Only a person's action calls this (a command or a press), because an
131// unasked open waits undrawn below 144 columns. If the pane is not placed the panel is drawn inline.
132async function setOpen($: Host, open: boolean): Promise<void> {
133 const snap = await read($, snapA)
134 const ui = await read($, uiA)
135 let placed = false
136 if (open && (snap?.settings ?? DEFAULT_SETTINGS).pane === 'docked') placed = (await $.ui.open({ id: PANE, title: 'belay' })).isPlaced
137 if (!open && ui.placed) await $.ui.close({ id: PANE })
138 if (!open) await update($, settleA, () => null) // a pending settle does not survive the panel closing
139 // Opening the panel is the person seeing the notices (decision 3): the current ones are marked seen.
140 const seen = open ? [...new Set([...ui.seen, ...(snap?.notices ?? []).map(n => n.key)])].slice(-200) : ui.seen
141 await update($, uiA, u => ({ ...u, open, placed, seen }))
142 lastFetchAt = 0
143 $.ui.invalidate('ui.render')
144}
145
146async function tick($: Host): Promise<void> {
147 try {
148 const watch = await read($, watchA)
149 if (!watch) { timer?.cancel(); timer = null; return }
150 if (watch.error) return
151 const ui = await read($, uiA)
152 const now = await $.clock.now()
153 if (!inflight && now >= backoffUntil && now - lastFetchAt >= (ui.open ? PANE_EVERY_MS : BAND_EVERY_MS)) {
154 inflight = true
155 try {
156 const prev = await read($, snapA)
157 const settingsDue = !prev || now - lastSettingsAt >= SETTINGS_EVERY_MS
158 const { snap, timedOut: slow } = await fetchSnapshot($, watch, prev, { withSettings: settingsDue, now, open: ui.open })
159 // Back off while commands keep timing out: 10 s, doubling to 60 s; one success resets it.
160 timeoutStreak = slow ? timeoutStreak + 1 : 0
161 backoffUntil = slow ? (await $.clock.now()) + Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** (timeoutStreak - 1)) : 0
162 if (settingsDue) lastSettingsAt = now
163 await update($, snapA, () => snap)
164 // `/belay watch` with no id resolved the newest run: pin that id once.
165 if (!watch.runId && snap.runId) await update($, watchA, w => (w && !w.runId ? { ...w, runId: snap.runId } : w))
166 // While the panel is open, notices that arrive are seen as they arrive.
167 if (ui.open) await update($, uiA, u => ({ ...u, seen: [...new Set([...u.seen, ...snap.notices.map(n => n.key)])].slice(-200) }))
168 // A pending settle whose note left the list, or whose run changed, is cleared.
169 await settleCheck($, snap, watch)
170 // A note that has left the list closes.
171 if (ui.openNote) await update($, uiA, u => (u.openNote && !(snap.held?.awaitingNotes ?? []).some(n => n.id === u.openNote) ? { ...u, openNote: null } : u))
172 // A switch to above-prompt closes the docked pane. A switch to docked opens nothing from a timer:
173 // the panel stays inline until the person reopens it.
174 if (ui.open && ui.placed && snap.settings.pane === 'above-prompt') {
175 await $.ui.close({ id: PANE })
176 await update($, uiA, u => ({ ...u, placed: false }))
177 }
178 } finally {
179 inflight = false
180 lastFetchAt = await $.clock.now()
181 }
182 }
183 $.ui.invalidate('ui.render') // the cache countdown ticks locally between fetches
184 } catch {
185 /* a tick never throws; the next one tries again */
186 }
187}
188
189function ensureTicker($: Host): void {
190 if (timer) return
191 timer = $.clock.every(1000, () => { void tick($) })
192}
193
194const sameWatch = (a: BelayPanelWatch | null, b: BelayPanelWatch): boolean =>
195 !!a && a.runId === b.runId && a.dataDir === b.dataDir && a.cli === b.cli && a.node === b.node && a.orchDir === b.orchDir && a.fixed === b.fixed && a.error === b.error
196
197// A repeated session.start (a hot reload) with an unchanged watch keeps the snapshot and the seen notices.
198async function watchRun($: Host, next: BelayPanelWatch): Promise<void> {
199 const cur = await read($, watchA)
200 if (sameWatch(cur, next)) { if (!next.error) ensureTicker($); return }
201 await update($, watchA, () => next)
202 await update($, uiA, u => ({ ...DEFAULT_UI, open: u.open, placed: u.placed }))
203 if (await read($, settleA)) { await update($, settleA, () => null); $.ui.toast('belay: the watched run changed, so the settle was cleared.') }
204 if (next.error) {
205 const now = await $.clock.now()
206 await update($, snapA, () => fatalSnapshot(next.error as string, null, now))
207 return
208 }
209 await update($, snapA, () => null)
210 lastFetchAt = 0
211 lastSettingsAt = 0
212 timeoutStreak = 0
213 backoffUntil = 0
214 ensureTicker($)
215}
216
217// Run the launcher `belay memory review --project <p> --start`: it starts the review detached, waits for its claim, prints it and exits. The
218// panel toasts what it printed (the proof) or why it refused or failed. The review itself is read back through belay's notices.
219// Called from reviewPress below and from nowhere else.
220async function dispatchReview($: Host, project: string): Promise<void> {
221 const watch = await read($, watchA)
222 if (!watch || watch.error) { $.ui.toast('belay: no run is watched, so the review was not started.'); return }
223 const r = await resolveCli($, watch)
224 if ('error' in r) { $.ui.toast(`belay: the review was not started: ${r.error}`); return }
225 if (starting.has(project)) { $.ui.toast(`belay: an archive review of ${project} is being started.`); return }
226 starting.add(project)
227 const cwd = watch.fixed && watch.dataDir ? projectRootOf(watch.dataDir) : null
228 // First without --run: the CLI chooses the newest ended run with notes on. Only if it says the one run with notes on is live (E_NOT_FOUND with live_run) is it
229 // asked again, once, with the watched run named.
230 const attempts: string[][] = [[], ...(watch.runId ? [[RUN_FLAG, watch.runId]] : [])]
231 try {
232 let res = { exitCode: 1, stdout: '', stderr: '' }
233 for (const runArg of attempts) {
234 res = await $.process.run(argvFor(r.cli, [...REVIEW_ARGS, project, START_FLAG, ...runArg], watch.dataDir), { timeoutMs: START_TIMEOUT_MS, ...(cwd ? { cwd } : {}) })
235 if (!needsRun(res.exitCode, res.stdout, res.stderr, watch.runId)) break
236 }
237 $.ui.toast(launcherLine(res.exitCode, res.stdout, res.stderr, project))
238 } catch (err) {
239 $.ui.toast(`belay: the review launcher did not answer (${firstLine(err instanceof Error ? err.message : String(err)) || 'no reason given'}): whether it started is unknown; the memory line shows a running review`)
240 } finally { starting.delete(project) }
241}
242
243const firstLine = (s: string): string => (s.split('\n').map(l => l.trim()).find(l => l !== '') ?? '').slice(0, 200)
244
245// The review button. First press: ask (the memory line turns into a confirm). Second press inside CONFIRM_MS, for the same project:
246// dispatch. The confirm is cleared either way. Any other press clears it too (calm below), and a timeout ends it (the line checks the age).
247async function reviewPress($: Host): Promise<void> {
248 const ui = await read($, uiA)
249 const snap = await read($, snapA)
250 const project = reviewProject(snap?.held ?? null)
251 if (!project) return
252 const now = await $.clock.now()
253 if (ui.confirm && ui.confirm.project === project && now - ui.confirm.at <= CONFIRM_MS) {
254 await update($, uiA, u => ({ ...u, confirm: null }))
255 await dispatchReview($, project)
256 return
257 }
258 await update($, uiA, u => ({ ...u, confirm: { project, at: now } }))
259}
260
261// The settle of a held note that waits on the operator, inside the pane (the interactive orchestrator has no AskUserQuestion, so nothing here asks
262// a dialog). Four steps, each a press of a drawn element:
263// 1. a settle button of the open note (view.tsx SettleRow -> act.settle -> settleStart) starts the reason stage: an Input and a cancel button;
264// 2. Enter in the Input (act.reason -> reasonSubmit) checks the reason and moves to the confirm stage, which shows the exact reason;
265// 3. the confirm button (act.confirm -> settlePress) is the ONLY way to the write: `belay memory held settle <id> --<action> --reason <text>`;
266// 4. cancel, closing or opening a note, the note leaving the list or a change of run clears the pending state (the settleA atom).
267// The reason is one argv element. Steps 1 and 2 and cancel run no process.
268const settling = new Set<string>() // note ids whose settle is running
269let inlineFocused = false // the inline band has no focus prop: true only after a $.ui.focus on its Input was answered without a denial
270
271async function settleStart($: Host, action: SettleAction, id: string): Promise<void> {
272 if (!SETTLE_ACTIONS.includes(action)) { $.ui.toast(`belay: unknown action ${action}.`); return }
273 const snap = await read($, snapA)
274 const listed = (snap?.held?.awaitingNotes ?? []).find(n => n.id === id)
275 if (!listed) { $.ui.toast(`belay: note ${shortId(id)} no longer awaits you.`); return } // only a note belay named as waiting on the operator
276 const watch = await read($, watchA)
277 if (!watch || watch.error) { $.ui.toast('belay: no run is watched, so a note cannot be settled.'); return }
278 if (settling.has(id) || settledActive((await read($, settledA))[id], snap, id)) { $.ui.toast(`belay: note ${shortId(id)} was just settled; wait for the next read before acting on it again.`); return }
279 await update($, uiA, calm)
280 await update($, settleA, () => ({ id, action, stage: 'reason', reason: '', title: listed.title, run: { runId: watch.runId, dataDir: watch.dataDir, cli: watch.cli, node: watch.node } }))
281 void focusReason($, snap, id) // not awaited: the Input is drawn only after this press returns, and the ring can move to it only then
282}
283
284// The Input is `autoFocus`, which takes the ring when the site takes the keyboard. A pane Button's click or hotkey can take it too, but the panel does not rely
285// on that: a docked pane is asked for the keys (`ui.open` with `focus`, a request and not a grant), which puts the ring on the autoFocus Input. That is the
286// one focus call; a second `$.ui.focus` on the docked pane is denied as redundant ("the focus moved meanwhile"), so only the inline band, which has no pane
287// to open, gets `$.ui.focus`. A refusal toasts nothing: the dim hint line in the reason stage, drawn while the pane is not focused, is the signal.
288async function focusReason($: Host, snap: BelayPanelSnap | null, id: string): Promise<void> {
289 const ui = await read($, uiA)
290 inlineFocused = false
291 if (!ui.open) return
292 const inline = (snap?.settings ?? DEFAULT_SETTINGS).pane === 'above-prompt' || !ui.placed
293 try {
294 if (inline) { const r = await $.ui.focus({ requestId: 'above-prompt', key: `reason:${id}` }); inlineFocused = !r.deny; $.ui.invalidate('ui.render') }
295 else await $.ui.open({ id: PANE, title: 'belay', focus: true })
296 } catch { /* the hint line shows while the pane is not focused */ }
297}
298
299// Enter in the reason Input. A reason that cannot be sent is toasted and the Input stays; a good one moves to the confirm stage. Nothing is run.
300async function reasonSubmit($: Host, value: string): Promise<void> {
301 const p = await read($, settleA)
302 if (!p || p.stage !== 'reason') { $.ui.toast('belay: no reason is being asked for.'); return }
303 const reason = value.trim()
304 if (reason === '') { $.ui.toast('belay: type a reason first, or press cancel.'); return }
305 const problem = reasonProblem(reason)
306 if (problem) { $.ui.toast(problem); return }
307 await update($, settleA, s => (s && s.id === p.id && s.stage === 'reason' ? { ...s, stage: 'confirm', reason } : s))
308}
309
310async function settlePress($: Host, pressed: string): Promise<void> {
311 const p = await read($, settleA)
312 if (!p || p.stage !== 'confirm' || p.id !== pressed) { $.ui.toast('belay: no settle is waiting for a confirm.'); return }
313 const { action, id, reason } = p
314 if (settling.has(id)) { $.ui.toast(`belay: note ${shortId(id)} is being settled.`); return }
315 if (settledActive((await read($, settledA))[id], await read($, snapA), id)) { await update($, settleA, () => null); $.ui.toast(`belay: note ${shortId(id)} was just settled; it was not settled again.`); return }
316 settling.add(id)
317 try {
318 await update($, settleA, () => null) // consumed: a second press of the button finds nothing
319 // The reason stage can stay open for minutes: the note and the watched run are checked again before anything is written.
320 const watch = await read($, watchA)
321 if (!watch || watch.error) { $.ui.toast('belay: no run is watched, so the note was not settled.'); return }
322 if (watch.runId !== p.run.runId || watch.dataDir !== p.run.dataDir || watch.cli !== p.run.cli || watch.node !== p.run.node) { $.ui.toast('belay: the watched run changed since you started, so the note was not settled.'); return }
323 const now = await read($, snapA)
324 if (!(now?.held?.awaitingNotes ?? []).some(n => n.id === id)) { $.ui.toast(`belay: note ${shortId(id)} no longer awaits you, so it was not settled.`); return }
325 const r = await resolveCli($, watch)
326 if ('error' in r) { $.ui.toast(`belay: the note was not settled: ${r.error}`); return }
327 // Written BEFORE the process runs: a hot reload that drops this function mid-settle leaves the note refused, as an unknown outcome, not open to a second settle.
328 const t0 = await $.clock.now()
329 await update($, settledA, m => ({ ...m, [id]: { label: 'settling…', at: t0, pending: true as const } }))
330 const cwd = watch.fixed && watch.dataDir ? projectRootOf(watch.dataDir) : null
331 try {
332 const res = await $.process.run(argvFor(r.cli, settleArgs(action, id, reason), watch.dataDir), { timeoutMs: START_TIMEOUT_MS, ...(cwd ? { cwd } : {}) })
333 const line = settleLine(res.exitCode, res.stdout, res.stderr)
334 $.ui.toast(line)
335 await markSettled($, id, line)
336 } catch {
337 $.ui.toast(SETTLE_UNKNOWN) // the command was started and did not answer (a timeout included): it may have written
338 await markSettled($, id, SETTLE_UNKNOWN)
339 }
340 } finally { settling.delete(id) }
341}
342
343// The operator looked (belay memory held) after an unknown outcome. The row is not opened at once: it is marked re-checking, stamped now, so only a read started after
344// this decides whether the note is still there; and not while a settle of it is still running.
345async function recheckNote($: Host, id: string): Promise<void> {
346 if (settling.has(id)) { $.ui.toast(`belay: note ${shortId(id)} is being settled; wait for it to finish.`); return }
347 const at = await $.clock.now()
348 await update($, settledA, m => (m[id] && (m[id]!.pending !== true || at - m[id]!.at > PENDING_STALE_MS) ? { ...m, [id]: { label: 're-checking', at } } : m))
349 lastFetchAt = 0
350 $.ui.invalidate('ui.render')
351}
352
353// Whatever the settle's outcome (written, unknown, refused), the note stays hidden and refused until a read taken after it has caught up: the snapshot the
354// rechecks read is the one from before the write, and a second settle on it would write twice. The next tick reads at once.
355async function markSettled($: Host, id: string, line: string): Promise<void> {
356 const at = await $.clock.now() // stamped after the process returned; the API has only the epoch clock (no monotonic one), so this is compared with fetchedAt in the same clock
357 const k = settleKind(line)
358 await update($, settledA, m => pruneSettled({ ...m, [id]: { label: k.label, at, ...(k.kind === 'unknown' ? { unknown: true as const } : {}) } }, id))
359 lastFetchAt = 0
360 $.ui.invalidate('ui.render')
361}
362
363// A pending settle whose note has left the list, or whose run is no longer the watched one, is cleared, and the person is told.
364async function settleCheck($: Host, snap: BelayPanelSnap, watch: BelayPanelWatch): Promise<void> {
365 const p = await read($, settleA)
366 if (!p) return
367 const same = watch.runId === p.run.runId && watch.dataDir === p.run.dataDir && watch.cli === p.run.cli && watch.node === p.run.node
368 const listed = (snap.held?.awaitingNotes ?? []).some(n => n.id === p.id)
369 if (same && listed) return
370 await update($, settleA, () => null)
371 $.ui.toast(same ? `belay: note ${shortId(p.id)} no longer awaits you, so the settle was cleared.` : 'belay: the watched run changed, so the settle was cleared.')
372}
373
374// Every press that is not the review button cancels a pending confirm.
375const calm = (u: BelayPanelUi): BelayPanelUi => (u.confirm ? { ...u, confirm: null } : u)
376
377function acts($: Host): Act {
378 return {
379 select: id => { void update($, uiA, u => ({ ...calm(u), selected: u.selected === id ? null : id })) },
380 legend: () => { void update($, uiA, u => ({ ...calm(u), legend: !u.legend })) },
381 fold: () => { void update($, uiA, u => ({ ...calm(u), foldOpen: !u.foldOpen })) },
382 group: key => { void update($, uiA, u => ({ ...calm(u), groups: u.groups.includes(key) ? u.groups.filter(g => g !== key) : [...u.groups, key].slice(-50) })) },
383 seen: key => { void update($, uiA, u => (u.seen.includes(key) && !u.confirm ? u : { ...calm(u), seen: u.seen.includes(key) ? u.seen : [...u.seen, key].slice(-200) })) },
384 close: () => { void update($, settleA, () => null); void update($, uiA, calm).then(() => setOpen($, false)) },
385 toggle: () => { void update($, settleA, () => null); void update($, uiA, calm).then(() => read($, uiA)).then(u => setOpen($, !u.open)) },
386 review: () => { void reviewPress($) },
387 settle: (action, id) => { void settleStart($, action, id) },
388 reason: value => { void reasonSubmit($, value) },
389 cancel: () => { void update($, settleA, () => null) },
390 recheck: id => { void recheckNote($, id) },
391 commit: id => { void settlePress($, id) },
392 note: id => { void update($, settleA, () => null); void update($, uiA, u => ({ ...calm(u), openNote: u.openNote === id ? null : id })) }, // opening, switching or closing a note drops a pending settle
393 }
394}
395
396export function registerPanel(on: On): void {
397 on('session.start', async ($, e, next) => {
398 const rf = await readRunFile($)
399 if (rf && 'error' in rf) {
400 await watchRun($, { runId: null, dataDir: null, cli: null, node: null, error: rf.error, fixed: true })
401 return next(e) // the error state: no commands are registered and nothing is read
402 }
403 if (rf) await watchRun($, { runId: rf.file.runId, dataDir: rf.file.dataDir, cli: rf.file.cli, node: rf.file.node, orchDir: rf.file.orchDir, error: null, fixed: true })
404 await $.command.register({ name: 'orch', description: "Toggle belay's panel for the watched run" })
405 if (!rf) await $.command.register({ name: 'belay', description: 'Watch a belay run: watch [run-id] | unwatch', argumentHint: 'watch [run-id] | unwatch' })
406 const w = await read($, watchA)
407 if (w && !w.error) ensureTicker($)
408 return next(e)
409 })
410
411 on('command.run', { command: 'orch' }, async $ => {
412 const w = await read($, watchA)
413 if (!w || w.error) { $.ui.toast('belay: no run is watched. /belay watch [run-id]'); return {} }
414 ensureTicker($)
415 const ui = await read($, uiA)
416 await setOpen($, !ui.open)
417 return {}
418 })
419
420 on('command.run', { command: 'belay' }, async ($, e) => {
421 const [verb, id, ...extra] = e.args.trim().split(/\s+/)
422 if (verb === 'unwatch') {
423 await update($, watchA, () => null)
424 await update($, snapA, () => null)
425 timer?.cancel(); timer = null
426 if ((await read($, uiA)).placed) await $.ui.close({ id: PANE })
427 await update($, uiA, u => ({ ...u, open: false, placed: false }))
428 $.ui.toast('belay: no longer watching a run.')
429 } else if (verb === 'watch' && extra.length === 0 && (id === undefined || RUN_ID.test(id))) {
430 const cur = await read($, watchA)
431 if (cur?.fixed) $.ui.toast(`belay: this session is bound by belay-run.json to run ${cur.runId}; it cannot be changed.`)
432 else {
433 await watchRun($, { runId: id ?? null, dataDir: null, cli: null, node: null, error: null, fixed: false })
434 $.ui.toast(`belay: watching ${id ?? 'the newest run'}. /orch opens the panel.`)
435 }
436 } else $.ui.toast('usage: /belay watch [run-id] | /belay unwatch')
437 return {}
438 })
439
440 on('ui.close', async ($, e, next) => {
441 if (e.id === PANE && e.origin.kind === 'person') { await update($, settleA, () => null); await update($, uiA, u => ({ ...u, open: false, placed: false })) }
442 return next(e)
443 })
444
445 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
446 const E = $.ui.resolve(e) as unknown as Els
447 const { Box, Text } = E
448 const watch = await read($, watchA)
449 const snap = await read($, snapA)
450 const ui = await read($, uiA)
451 const now = await $.clock.now()
452 if (!watch) return <Box><Text dimColor>No belay run is watched. /belay watch [run-id]</Text></Box>
453 if (!snap) return <BandWaiting E={E} runId={watch.runId} errors={[]} cols={e.props.bodyColumns} />
454 const c = { E, snap, ui, now, cols: e.props.bodyColumns, act: acts($), pend: await read($, settleA), focused: e.props.isFocused, settled: await read($, settledA) }
455 return e.surface === 'terminal' ? <Pane {...c} /> : <Fallback {...c} />
456 })
457
458 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
459 const watch = await read($, watchA)
460 if (e.props.hasSurvey || !watch) return next(e)
461 const E = $.ui.resolve(e) as unknown as Els
462 const { Box } = E
463 const snap = await read($, snapA)
464 const ui = await read($, uiA)
465 const now = await $.clock.now()
466 if (!snap) return <BandWaiting E={E} runId={watch.runId} errors={[]} cols={e.props.bodyColumns} />
467 const c = { E, snap, ui, now, cols: e.props.bodyColumns, act: acts($), pend: await read($, settleA), focused: inlineFocused, settled: await read($, settledA) } // the band has no focus prop: the hint is for the docked pane
468 // Inline when the person chose above-prompt, or when the docked pane is not seated (a narrow terminal).
469 const inline = ui.open && (snap.settings.pane === 'above-prompt' || !ui.placed)
470 if (e.surface !== 'terminal') return inline ? <Fallback {...c} /> : <Band {...c} />
471 return inline ? <Box flexDirection="column"><Band {...c} /><Pane {...c} /></Box> : <Band {...c} />
472 })
473}
474hooks/wake/index.ts 59 lines1import type { Register } from 'claude-code'
2import { activeConfig, streamArgv, deliveredPath, errorsPath, parseLine, createDelivery, lineSplitter, decide } from './core.mjs'
3
4// ADR 0037 amendment 5 decision 3. Thin: it decides nothing. It starts `belay wake-stream` (a child the
5// engine kills on reload and at exit), and for each wake line records the id in a file outside this copy
6// (after the write has succeeded) and only then submits the text: at most once, never twice (core.mjs createDelivery). A session with no
7// belay-run.json naming the orchestrator role (a watching session) does nothing.
8export const registerWake = (on: Parameters<Register>[0]): void => {
9 // The matcher keeps this apart from the panel's own unmatched session.start hook (one per module is allowed).
10 on('session.start', { isInteractive: true }, async ($, e, next) => {
11 const started = await next(e)
12 let cfg
13 try { cfg = activeConfig(await $.fs.read(`${$.plugin.root}/belay-run.json`)) } catch { return started }
14 if (!cfg) return started
15 const file = deliveredPath(cfg)
16 let content = ''
17 try { content = await $.fs.read(file) } catch { content = '' }
18 const errFile = errorsPath(cfg)
19 let errors = ''
20 const delivery = createDelivery({
21 file, initial: content,
22 io: {
23 write: (f: string, c: string) => $.fs.write(f, c),
24 submit: (text: string) => $.prompt.submit({ text }),
25 log: (entry: unknown) => { errors += `${JSON.stringify(entry)}\n`; void Promise.resolve($.fs.write(errFile, errors)).catch(() => undefined) },
26 },
27 })
28 const delivered = delivery.delivered
29 let chain: Promise<void> = Promise.resolve()
30 let gotLine = false
31 const connect = async (): Promise<void> => {
32 const split = lineSplitter()
33 try {
34 for await (const chunk of $.process.spawn({ argv: streamArgv(cfg) })) {
35 if (chunk.stream !== 'stdout') continue
36 for (const line of split(chunk.text)) {
37 gotLine = true
38 const p = parseLine(line)
39 if (p.kind === 'wake' && decide(p, delivered) === 'submit') {
40 const { id, text } = p
41 chain = chain.then(() => delivery.deliver(id, text)).then(() => undefined, () => undefined)
42 }
43 }
44 }
45 } catch { /* the client is gone; the loop below decides */ }
46 }
47 let wait = 5000
48 const loop = async (): Promise<void> => {
49 gotLine = false
50 await connect()
51 await chain
52 wait = gotLine ? 5000 : Math.min(wait * 2, 60000)
53 $.clock.after(wait, () => { void loop() })
54 }
55 void loop()
56 return started
57 })
58}
59hooks/panel/fetch.ts 202 lines1// What the panel does with belay's answers, as pure functions. The commands themselves are run from
2// index.tsx: the engine follows `$` only inside the file that declares the hooks.
3
4import type { BelayAgent, BelayHeld, BelayReadIssue, BelayPanelSnap, BelayPanelWatch } from '../../types/index'
5import { countAwaiting, normAwaiting, normHeld } from './held'
6import { DEFAULT_SETTINGS, normAgents, normNotices, normTranscript, parseMemoryNotice, parseSettings, type AgentsRead } from './model'
7
8export type RunFile = { role: string; runId: string; dataDir: string; cli: string; node: string; orchDir: string | null }
9export type RunFileRead = { file: RunFile } | { error: string }
10export type Ran = { json: unknown } | { error: string; timedOut?: true }
11
12export const fwd = (p: string): string => p.split('\\').join('/')
13
14// Resolve `..` by hand: the module has no `path`.
15export function normalise(p: string): string {
16 const out: string[] = []
17 for (const part of fwd(p).split('/')) {
18 if (part === '..' && out.length > 1 && out[out.length - 1] !== '..') out.pop()
19 else if (part !== '.' && part !== '') out.push(part)
20 }
21 return (p.startsWith('/') ? '/' : '') + out.join('/')
22}
23
24const RUN_FILE_FIELDS: [keyof RunFile, string][] = [
25 ['role', 'role'], ['runId', 'run_id'], ['dataDir', 'data_dir'], ['cli', 'belay_cli'], ['node', 'node'],
26]
27
28// belay-run.json must carry role, run_id, data_dir, belay_cli and node. A file that does not parse, or
29// lacks one, is an error state, never a partial binding.
30export function parseRunFile(text: string): RunFileRead {
31 let j: Record<string, unknown>
32 try { j = JSON.parse(text) as Record<string, unknown> } catch { return { error: 'belay-run.json is not valid JSON' } }
33 if (j === null || typeof j !== 'object' || Array.isArray(j)) return { error: 'belay-run.json is not an object' }
34 const missing = RUN_FILE_FIELDS.filter(([, k]) => typeof j[k] !== 'string' || j[k] === '').map(([, k]) => k)
35 if (missing.length) return { error: `belay-run.json lacks ${missing.join('/')}` }
36 if (j.role !== 'orchestrator') return { error: 'belay-run.json role must be "orchestrator"' }
37 const s = (k: string): string => j[k] as string
38 return { file: { role: s('role'), runId: s('run_id'), dataDir: s('data_dir'), cli: s('belay_cli'), node: s('node'), orchDir: typeof j.orchestrator_dir === 'string' && j.orchestrator_dir !== '' ? j.orchestrator_dir : null } }
39}
40
41// A Windows path: there a bare `belay` is usually a .cmd shim, which cannot be started without a shell.
42export const isWindowsPath = (p: string): boolean => /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
43// Where the repo's CLI would be if this plugin sits at <repo>/src/mod.
44export const repoCliGuess = (pluginRoot: string): string => normalise(`${pluginRoot}/../../src/cli/belay.cjs`)
45
46// Where `belay settings` runs for a run bound by belay-run.json: the project root that holds data_dir, so it
47// reads the run's repo config. A .belay data dir sits in its project root; any other data_dir is the root.
48export function projectRootOf(dataDir: string): string {
49 const d = fwd(dataDir).replace(/\/+$/, '')
50 return /\/\.belay$/.test(d) ? d.slice(0, d.length - '/.belay'.length) || '/' : d
51}
52
53// What the review launcher printed (`--json` is always on the argv, so the real output is a JSON receipt or error), as one short toast line.
54// Falls back to the first line of plain text. Never raw JSON.
55export function launcherLine(exitCode: number, stdout: string, stderr: string, project: string): string {
56 const first = (s: string): string => (s.split('\n').map(l => l.trim()).find(l => l !== '') ?? '').slice(0, 160)
57 const asObj = (s: string): Record<string, unknown> | null => { try { const p: unknown = JSON.parse(s); return p !== null && typeof p === 'object' && !Array.isArray(p) ? (p as Record<string, unknown>) : null } catch { return null } }
58 // --json prints a receipt to stdout and an error to stderr (pretty-printed); a plain-text answer falls back to its first line.
59 const j = asObj(stdout)
60 const ej = asObj(stderr)
61 const err = j && j.error !== null && typeof j.error === 'object' ? (j.error as Record<string, unknown>) : ej && ej.error !== null && typeof ej.error === 'object' ? (ej.error as Record<string, unknown>) : null
62 // A review acting for a live run carries a note: `closing_note` (the close waits for the review), or `closing_warning` / `warning` from an older CLI (the close cuts it off).
63 const noteRaw = j && typeof j.closing_note === 'string' ? j.closing_note : null
64 const warnRaw = j && typeof j.closing_warning === 'string' ? j.closing_warning : j && typeof j.warning === 'string' ? j.warning : null
65 const clip = (x: string): string => x.replace(/\s+/g, ' ').slice(0, 200)
66 const waits = j && j.close_waits === true ? ` · this run's close waits for it` : ''
67 const warn = `${waits}${noteRaw ? ` · note: ${clip(noteRaw)}` : warnRaw ? ` · warning: ${clip(warnRaw)}` : ''}`
68 if (exitCode === 0 && j && j.started === true) return `belay: archive review of ${project} started: pid ${String(j.pid ?? '?')}, job ${String(j.job ?? '?')}${warn}`
69 if (exitCode === 0 && j && j.ended !== undefined) return `belay: archive review of ${project} ended at once: ${first(JSON.stringify(j.ended))}${warn}`
70 const plainErr = typeof j?.error === 'string' ? j.error : typeof ej?.error === 'string' ? ej.error : null // a usage error prints {"error": "<text>"}
71 const said = err ? `${typeof err.code === 'string' ? `${err.code}: ` : ''}${typeof err.message === 'string' ? err.message.slice(0, 160) : ''}` : plainErr !== null ? first(plainErr) : first(stdout.trim() ? stdout : stderr)
72 return exitCode === 0 ? `belay: ${said || 'archive review started'}` : `belay: the review was not started (exit ${exitCode})${said ? `: ${said}` : ''}`
73}
74
75// The review launcher refuses with E_NOT_FOUND and `live_run` (a run id) when the only run with notes on is still live and it was not named (cmd-memory-stages.cjs).
76// That field, never the words, tells the panel to say which run it means. null when the launcher said anything else.
77export function needsRun(exitCode: number, stdout: string, stderr: string, runId: string | null): boolean {
78 if (exitCode === 0 || !runId) return false
79 for (const text of [stdout, stderr]) {
80 try {
81 const e = (JSON.parse(text) as { error?: { code?: unknown; live_run?: unknown } }).error
82 if (e && typeof e === 'object' && e.code === 'E_NOT_FOUND' && e.live_run === runId) return true // only the run the panel watches; another live run's refusal names that run and is shown as it is
83 } catch { /* not JSON */ }
84 }
85 return false
86}
87
88export const argvFor = (cli: string[], args: string[], dataDir: string | null): string[] =>
89 [...cli, ...args, '--json', ...(dataDir ? ['--data', dataDir] : [])]
90
91export function parseRan(args: string[], r: { exitCode: number; stdout: string } | null): Ran {
92 const label = `belay ${args.join(' ')}`
93 if (!r) return { error: `${label} did not run` }
94 if (r.exitCode !== 0) return { error: `${label} exit ${r.exitCode}` }
95 try { return { json: JSON.parse(r.stdout) } } catch { return { error: `${label} exit 0, output is not JSON` } }
96}
97
98// A snapshot that only carries an error that stops every read: nothing is run, nothing falls back.
99export function fatalSnapshot(message: string, runId: string | null, now: number): BelayPanelSnap {
100 return {
101 fetchedAt: now, runId, mode: null, runState: null, stale: false, fatal: message, transcriptOk: false, transcriptAt: null,
102 startedAtMs: null, pausedAtMs: null, frozenAtMs: null, endedAtMs: null, tasks: null, maxFixRounds: null, embedder: null, agents: [], footer: null, notices: [], held: null, transcript: null,
103 settings: DEFAULT_SETTINGS, memoryNotice: null, errors: [], readIssues: [], goodAt: null,
104 }
105}
106
107export type Parts = {
108 watch: BelayPanelWatch
109 prev: BelayPanelSnap | null
110 now: number
111 agents: Ran
112 notices: Ran | null
113 transcript: Ran | null
114 settings: Ran | null
115 /** `belay memory held --awaiting`: read only while some held note waits on the operator. */
116 awaiting?: Ran | null
117}
118
119// The orchestrator's row in THIS successful agents read decides the run state; it is never carried over.
120function runStateOf(agents: BelayAgent[], stateRaw: string | null): BelayPanelSnap['runState'] {
121 const orch = agents.find(a => a.role === 'orchestrator')
122 if (!orch) return null
123 if (stateRaw === 'paused') return 'paused'
124 if (orch.state === 'done') return 'ended'
125 if (orch.state === 'dead') return 'dead'
126 if (orch.state === 'unknown') return null
127 return 'running'
128}
129
130// The held line plus the ids of the notes that wait on the operator: this round's read, else the last round's while some still wait.
131function heldWith(h: BelayHeld | null, aw: unknown, prev: BelayHeld | null, now: number): BelayHeld | null {
132 if (!h) return h
133 if (!(h.awaiting ?? 0)) { const { awaitingNotes: _gone, ...rest } = h; return rest }
134 // The list and its own total come from one read. A list carried over from an earlier round (this round's read failed) says so and keeps
135 // the total and the time of the read it came from; the heading's count is a different read and is never mixed into the list's.
136 if (aw !== undefined) return { ...h, awaitingNotes: normAwaiting(aw), awaitingList: { total: countAwaiting(aw), at: now, carried: false } }
137 const notes = prev?.awaitingNotes ?? h.awaitingNotes ?? []
138 const was = prev?.awaitingList
139 return { ...h, awaitingNotes: notes, ...(notes.length || was ? { awaitingList: { total: was?.total ?? notes.length, at: was?.at ?? now, carried: true } } : {}) }
140}
141
142export function buildSnapshot(p: Parts): BelayPanelSnap {
143 const { prev } = p
144 const errors: string[] = []
145 const readIssues: BelayReadIssue[] = [] // a read that timed out or failed: shown as the stale line, not as a raw error
146 const take = (r: Ran | null, verb: string): unknown => {
147 if (!r) return undefined
148 if ('error' in r) { readIssues.push({ verb, timedOut: 'timedOut' in r, text: 'timedOut' in r ? '' : r.error.split(/\s+/).filter(Boolean).join(' ').slice(0, 120) }); return undefined }
149 return r.json
150 }
151 const aj = take(p.agents, 'agents')
152 const ag: AgentsRead | null = aj !== undefined ? normAgents(aj) : null
153 const stale = ag === null
154 if (ag && ag.runId === null && p.watch.runId === null) errors.push('no belay run found (this session\'s directory has no belay store, or the store has no runs)')
155 const nj = take(p.notices, 'notices') // null: the round was cut short by a timeout, so the earlier notices stand
156 const notices = nj !== undefined ? normNotices(nj) : (prev?.notices ?? [])
157 // belay-run.json means this is the orchestrator's own window; otherwise the run's mode, null when unknown.
158 const mode = p.watch.fixed ? 'interactive' : ag ? ag.mode : (prev?.mode ?? null)
159 const tj = take(p.transcript, 'transcript')
160 let transcript = mode === 'headless' ? (prev?.transcript ?? null) : null
161 let transcriptOk = mode === 'headless' ? (prev?.transcriptOk ?? false) : false
162 let transcriptAt = mode === 'headless' ? (prev?.transcriptAt ?? null) : null
163 if (mode === 'headless' && p.transcript) {
164 transcriptOk = tj !== undefined
165 if (tj !== undefined) {
166 transcript = normTranscript(tj)
167 // A one-entry tail may hold no words (a tool call or a turn end): the last words seen stay, and their age shows.
168 const lastText = (xs: typeof transcript) => (xs ?? []).filter(e => e.kind === 'text').slice(-1)
169 if (!lastText(transcript).length) transcript = [...lastText(prev?.transcript ?? null), ...(transcript ?? [])]
170 transcriptAt = p.now
171 }
172 }
173 const aw = take(p.awaiting ?? null, 'awaiting list')
174 const sj = take(p.settings, 'settings')
175 const settings = sj !== undefined ? parseSettings(sj) : (prev?.settings ?? DEFAULT_SETTINGS)
176 // The settings read sees the config file only. A run answers for itself (its launch recorded the notice, or none: notes on, notes
177 // off on purpose, memory null), whatever the config says now; the config's notice is for a panel with no run to read.
178 const runRead = aj !== undefined && typeof (aj as { run_id?: unknown }).run_id === 'string'
179 // A failed agents read leaves the selected run as it was: its cached notice stands, null included.
180 const runSelected = aj === undefined && (p.watch.runId ?? prev?.runId ?? null) !== null
181 const memoryNotice = runRead ? parseMemoryNotice(aj) : runSelected ? (prev?.memoryNotice ?? null) : sj !== undefined ? parseMemoryNotice(sj) : (prev?.memoryNotice ?? null)
182 const orchRaw = aj !== undefined ? ((aj as { agents?: { role?: string; state?: string }[] }).agents ?? []).find(a => a.role === 'orchestrator')?.state ?? null : null
183 return {
184 // the cache countdown is anchored to when the agents were last read, not to a failed round
185 fetchedAt: ag ? p.now : (prev?.fetchedAt ?? p.now),
186 runId: ag?.runId ?? p.watch.runId ?? prev?.runId ?? null,
187 mode,
188 runState: ag ? runStateOf(ag.agents, orchRaw) : null,
189 stale, fatal: null, transcriptOk, transcriptAt,
190 startedAtMs: ag ? ag.startedAtMs : (prev?.startedAtMs ?? null),
191 frozenAtMs: ag && runStateOf(ag.agents, orchRaw) === 'dead' ? (prev?.runState === 'dead' && prev.frozenAtMs !== null ? prev.frozenAtMs : p.now) : null,
192 pausedAtMs: ag ? ag.pausedAtMs : (prev?.pausedAtMs ?? null),
193 endedAtMs: ag ? ag.endedAtMs : (prev?.endedAtMs ?? null),
194 tasks: ag ? ag.tasks : (prev?.tasks ?? null),
195 maxFixRounds: ag ? ag.maxFixRounds : (prev?.maxFixRounds ?? null),
196 embedder: ag ? ag.embedder : (prev?.embedder ?? null),
197 agents: ag ? ag.agents : (prev?.agents ?? []),
198 footer: ag ? ag.footer : (prev?.footer ?? null),
199 notices, held: heldWith(nj !== undefined ? normHeld(nj) : (prev?.held ?? null), aw, prev?.held ?? null, p.now), transcript, settings, memoryNotice, errors, readIssues, goodAt: readIssues.length ? (prev?.goodAt ?? null) : p.now,
200 }
201}
202hooks/panel/held.ts 241 lines1// The memory line's model: notes held for the archive reviewer and the state of its review, from belay's notices. Pure; no `$`.
2
3import type { BelayAwaitingNote, BelayHeld, BelayReview } from '../../types/index'
4import { arr, clean, dur, num, obj, str } from './model'
5import { clusters } from './wrap'
6
7// The estimated cost of one archive review, once the batch study has produced one. Null until then: the confirm says so.
8export const REVIEW_COST_ESTIMATE_USD: number | null = null
9// A first press waits this long for the second; after it, or after any other press, the confirm is gone.
10export const CONFIRM_MS = 8000
11
12// belay's `notes_held` notice (a standing count while any hold waits) and its `review_running` / `review_ended` notices (the store-level
13// claim of an archive review, which survives a reload of this panel), read defensively. Not notices of the panel's own list: they are the
14// memory line, with the review button. null when there is neither a count nor a review to show.
15export function normHeld(json: unknown): BelayHeld | null {
16 let count = 0
17 let unsupported = 0
18 let oldestAgeRuns = 0
19 let oldestAt: number | null = null
20 const byProject: Record<string, number> = {}
21 const byGround: Record<string, number> = {}
22 let awaiting = 0
23 const reviews: BelayReview[] = [], breakers: { runId: string; disputed: number }[] = []
24 for (const raw of arr(obj(json)?.events)) {
25 const e = obj(raw)
26 if (!e) continue
27 if (e.kind === 'notes_held') {
28 count = num(e.count) ?? 0
29 unsupported = Math.min(count, num(e.unsupported) ?? 0)
30 oldestAgeRuns = num(e.oldest_age_runs) ?? 0
31 oldestAt = num(e.oldest_at)
32 for (const [k, v] of Object.entries(obj(e.by_project) ?? {})) if (num(v) !== null && k !== '') byProject[clean(k, 64)] = v as number
33 for (const [k, v] of Object.entries(obj(e.by_ground) ?? {})) if (num(v) !== null && k !== '') byGround[clean(k, 40)] = v as number
34 awaiting = Math.min(count, num(e.awaiting_operator) ?? 0)
35 } else if (e.kind === 'run_disputed') {
36 if (str(e.disputed_run) && breakers.length < 3) breakers.push({ runId: clean(str(e.disputed_run)!, 64), disputed: num(e.disputed) ?? 0 })
37 } else if (e.kind === 'review_running' || e.kind === 'review_ended') {
38 const project = str(e.project)
39 if (!project) continue
40 const state: BelayReview['state'] = e.kind === 'review_running' ? (e.pid_alive === true ? 'running' : 'interrupted') : (e.ok === true ? 'ok' : 'failed')
41 reviews.push({ project: clean(project, 64), state, pid: num(e.pid), text: str(e.text) === null ? (str(e.code) === null ? null : clean(str(e.code)!, 40)) : clean(str(e.text)!, 240), ts: num(e.ts), job: str(e.job) })
42 }
43 }
44 if (count <= 0 && !reviews.length) return null
45 return { count: Math.max(0, count), unsupported, byProject, oldestAgeRuns, ...(oldestAt !== null && oldestAt > 0 ? { oldestAt } : {}), reviews, ...(Object.keys(byGround).length ? { byGround, awaiting } : {}), ...(breakers.length ? { breakers } : {}) }
46}
47
48// The review of a project that is in flight: the panel offers no button over it.
49export const runningReview = (h: BelayHeld, project: string | null): BelayReview | null =>
50 h.reviews.find(r => r.state === 'running' && (project === null || r.project === project)) ?? null
51
52// belay's summary of a finished review is `ran as <job>: {"verdicts":1,"applied":1,...}`. Words for it, or null when it is not that.
53export function reviewOutcome(text: string | null): string | null {
54 const m = text && /^ran as \S+: (\{.*\})$/.exec(text)
55 if (!m) return null
56 let c: Record<string, unknown>
57 try { c = JSON.parse(m[1]!) as Record<string, unknown> } catch { return null }
58 if (c === null || typeof c !== 'object') return null
59 const n = (k: string): number => (typeof c[k] === 'number' && (c[k] as number) > 0 ? (c[k] as number) : 0)
60 const parts = ['applied', 'refused', 'undecided', 'refused_stale'].filter(k => n(k) > 0).map(k => `${n(k)} ${k === 'refused_stale' ? 'stale' : k}`)
61 const v = typeof c.verdicts === 'number' ? `${c.verdicts} verdict${c.verdicts === 1 ? '' : 's'}` : null
62 return [v, parts.join(', ') || null].filter(Boolean).join(': ') || null
63}
64
65// The one line a review's state is shown as.
66// A running review is shown from a pid check; belay proves identity (pid and creation time) only where it takes the claim. An old claim says so.
67export const STALE_CLAIM_MS = 15 * 60 * 1000
68export function reviewLine(r: BelayReview, now: number | null = null): string {
69 if (r.state === 'running') return `archive review of ${r.project} running${r.pid ? ` · pid ${r.pid}` : ''}${now !== null && r.ts !== null && now - r.ts > STALE_CLAIM_MS ? ' · pid check only, may be stale: a new review checks the process' : ''}`
70 if (r.state === 'interrupted') return `archive review of ${r.project} cut off · its process is gone and it gave no answer; the next review takes over`
71 if (r.state === 'ok') {
72 const w = reviewOutcome(r.text)
73 if (w) return `archive review of ${r.project} done · ${w}${r.job ? ` · job ${r.job.length > 16 ? '…' + r.job.slice(-8) : r.job}` : ''}`
74 }
75 return `archive review of ${r.project} ${r.state === 'ok' ? 'ended ok' : 'FAILED'}${r.text ? ` · ${r.text}` : ''}`
76}
77
78// The project one press would review: the one with the most held notes, ties by name. null when belay named none.
79export function reviewProject(h: BelayHeld | null): string | null {
80 if (!h || h.count <= 0) return null
81 const names = Object.keys(h.byProject).sort((a, b) => h.byProject[b]! - h.byProject[a]! || (a < b ? -1 : 1))
82 return names[0] ?? null
83}
84
85const GROUND_LABEL: Record<string, string> = { reported: 'reported wrong', report_confirmed: 'reported wrong', code_contradicts: 'wrong', refuted_review: 'wrong', same_claim: 'duplicate', replaced_by_newer: 'superseded', unsupported: 'unsupported', none: 'other' }
86
87// How long the oldest held note has waited, as a suffix, when belay reported when it was held (`now` is the panel's clock).
88const waitedFor = (h: BelayHeld, now?: number): string => (now !== undefined && h.oldestAt !== undefined ? ` · oldest waited ${dur(Math.max(0, now - h.oldestAt))}` : '')
89const flagFor = (h: BelayHeld): string => (h.breakers ?? []).map(b => ` · run ${b.runId.slice(0, 8)} disputed ${b.disputed}: review`).join('') // the breaker flag, ADR 0047 decision 9
90
91export function heldLine(h: BelayHeld, now?: number): string {
92 if (h.count <= 0) return ''
93 const w = waitedFor(h, now) + flagFor(h)
94 if (h.byGround && Object.keys(h.byGround).length) {
95 const parts = new Map<string, number>()
96 for (const [k, n] of Object.entries(h.byGround)) parts.set(GROUND_LABEL[k] ?? k, (parts.get(GROUND_LABEL[k] ?? k) ?? 0) + n)
97 const a = h.awaiting ?? 0
98 return `${h.count} held: ${[...parts].map(([k, n]) => `${n} ${k}`).join(', ')}${a > 0 ? `; ${a} await${a === 1 ? 's' : ''} you` : ''}${w}`
99 }
100 const names = Object.keys(h.byProject).sort()
101 const warned = h.count - h.unsupported
102 if (h.unsupported > 0) return `${warned > 0 ? `${warned} held as wrong or duplicate · ` : ''}${h.unsupported} unsupported awaiting review · oldest ${h.oldestAgeRuns} run${h.oldestAgeRuns === 1 ? '' : 's'}${names.length ? ` · ${names.join(', ')}` : ''}${w}`
103 return `${h.count} note${h.count === 1 ? '' : 's'} held for review · oldest ${h.oldestAgeRuns} run${h.oldestAgeRuns === 1 ? '' : 's'}${names.length ? ` · ${names.join(', ')}` : ''}${w}`
104}
105
106// The MEMORY section's detail line. The heading already says how many are held and how many await the operator, so this keeps what
107// the heading does not: the grounds, the oldest wait and the breaker flag. (heldLine is the plain surfaces' one line, count and all.)
108export function heldDetail(h: BelayHeld, now?: number): string {
109 if (h.count <= 0) return ''
110 const w = waitedFor(h, now) + flagFor(h)
111 if (h.byGround && Object.keys(h.byGround).length) {
112 const parts = new Map<string, number>()
113 for (const [k, n] of Object.entries(h.byGround)) parts.set(GROUND_LABEL[k] ?? k, (parts.get(GROUND_LABEL[k] ?? k) ?? 0) + n)
114 return `${[...parts].map(([k, n]) => `${n} ${k}`).join(', ')}${w}`
115 }
116 const names = Object.keys(h.byProject).sort()
117 if (h.unsupported > 0) return `${h.count - h.unsupported > 0 ? `${h.count - h.unsupported} wrong or duplicate · ` : ''}${h.unsupported} unsupported · oldest ${h.oldestAgeRuns} run${h.oldestAgeRuns === 1 ? '' : 's'}${names.length ? ` · ${names.join(', ')}` : ''}${w}`
118 return `oldest ${h.oldestAgeRuns} run${h.oldestAgeRuns === 1 ? '' : 's'}${names.length ? ` · ${names.join(', ')}` : ''}${w}`
119}
120
121export function confirmLine(h: BelayHeld, project: string): string {
122 const cost = REVIEW_COST_ESTIMATE_USD === null ? 'cost: measured after first run' : `estimated cost $${REVIEW_COST_ESTIMATE_USD.toFixed(2)}`
123 return `${h.byProject[project] ?? h.count} note${(h.byProject[project] ?? h.count) === 1 ? '' : 's'} held in ${project} · dispatch the Opus archive reviewer? ${cost}`
124}
125
126// ---- the operator's settle of a note the archive review left undecided ------------------------------
127
128export type SettleAction = 'archive' | 'release' | 'reoffer'
129export const SETTLE_ACTIONS: readonly SettleAction[] = ['archive', 'release', 'reoffer']
130export const SETTLE_SHOWN = 5 // notes listed at once, one line each; the rest are counted
131export const REASON_CAP = 500 // the core's own cap on an undecided reason; the panel cuts nothing short of it
132// Cut to n whole grapheme clusters, ending in an ellipsis only when something was cut.
133const cutClusters = (s: string, n: number): string => { const g = clusters(s); return g.length > n ? g.slice(0, n - 1).join('') + '…' : s }
134
135// How many notes THIS read lists as waiting on the operator, uncapped: the panel shows the first few and counts the rest from this one read.
136export function countAwaiting(json: unknown): number {
137 let n = 0
138 for (const raw of arr(obj(json)?.entries)) { const e = obj(raw); if (e && str(e.note_id) && e.awaiting_operator === true) n += 1 }
139 return n
140}
141
142// `belay memory held --awaiting --json`: the notes waiting on the operator (a read). Ids and titles are belay's, still cleaned.
143export function normAwaiting(json: unknown): BelayAwaitingNote[] {
144 const out: BelayAwaitingNote[] = []
145 for (const raw of arr(obj(json)?.entries)) {
146 const e = obj(raw)
147 const id = e ? str(e.note_id) : null
148 if (!e || !id || e.awaiting_operator !== true || out.length >= SETTLE_SHOWN) continue
149 out.push({ id: clean(id, 64), project: clean(str(e.project) ?? '', 64), title: cutClusters(clean(str(e.title) ?? '', 100000), REASON_CAP), reason: cutClusters(clean(str(obj(e.undecided)?.reason) ?? '', 100000), REASON_CAP) })
150 }
151 return out
152}
153
154export const shortId = (id: string): string => id.slice(0, 8)
155
156// The argv tail of the settle write. The reason is ONE element, whatever it holds: belay's parser takes the element after --reason as the value.
157export const settleArgs = (action: SettleAction, id: string, reason: string): string[] => ['memory', 'held', 'settle', id, `--${action}`, '--reason', reason, '--if-awaiting']
158
159export const settleVerb = (a: SettleAction): string => a[0]!.toUpperCase() + a.slice(1)
160// The confirm shows the reason exactly as it is sent (a reason over REASON_MAX is refused first, so nothing is cut).
161// The confirm line names the note by its title and its short id (two notes can share a prefix) and shows the reason exactly as it is sent.
162export const settleConfirm = (a: SettleAction, id: string, reason: string, title = ''): string => `${settleVerb(a)} ${title ? `"${cutClusters(title, 40)}" ` : ''}(${shortId(id)})? reason: ${reason}`
163
164export const REASON_MAX = 160
165// belay strips its own global options out of the argv before any command sees it, so a reason that is exactly one of them never arrives.
166const GLOBAL_FLAGS = ['--json', '--fix', '--data', '--help', '-h']
167// null when the reason can be sent as it is; otherwise the toast that says why it is refused.
168export function reasonProblem(reason: string): string | null {
169 if (reason.length > REASON_MAX) return `belay: the reason is ${reason.length} characters; at most ${REASON_MAX}. Shorten it.`
170 if (GLOBAL_FLAGS.includes(reason)) return `belay: a reason cannot be exactly ${reason}: belay would read it as its own option. Add a word to it.`
171 return null
172}
173
174/** The error a belay command printed under --json: stdout, else stderr (the real CLI prints errors to stderr, pretty-printed). */
175export type CliError = { code: string | null; message: string; unknown: boolean }
176export function cliError(stdout: string, stderr: string): CliError | null {
177 for (const text of [stdout, stderr]) {
178 let j: Record<string, unknown> | null = null
179 try { j = obj(JSON.parse(text)) } catch { continue }
180 if (typeof j?.error === 'string' && j.error !== '') return { code: null, message: clean(j.error, 160), unknown: false } // a usage error: {"error": "<text>"}, exit 2
181 const e = obj(j?.error)
182 if (!e) continue
183 const d = obj(e.detail) ?? {}
184 const code = str(e.code)
185 return { code, message: clean(str(e.message) ?? '', 160), unknown: code === 'E_TIMEOUT' || code === 'E_OWNER_LOST' || d.may_have_committed === true || d.sent === true }
186 }
187 return null
188}
189
190// A note settled in this panel stays hidden (and refused) until a read taken after the settle shows what is left: the snapshot's own read time and,
191// while some note still awaits, the awaiting list's, must both be newer than the settle and not carried over from an earlier round.
192// An unknown outcome is stricter: reads copy the db and may predate a write still queued, so a later read that still lists the note proves nothing. Such a row
193// stays refused until a fresh read no longer lists the note, or the operator presses re-check on it.
194export function settledActive(entry: { at: number; unknown?: true; pending?: true } | undefined, snap: { fetchedAt: number; held?: { awaitingList?: { at: number; carried: boolean }; awaitingNotes?: { id: string }[] } | null } | null, id: string): boolean {
195 if (!entry) return false
196 if (entry.pending === true) return true // the process is running (or was, when a reload dropped it): only the outcome, or re-check once it is unknown, ends this
197 if (!snap || !(snap.fetchedAt > entry.at)) return true
198 const list = snap.held?.awaitingList
199 if (list && (list.carried || !(list.at > entry.at))) return true
200 return entry.unknown === true && (snap.held?.awaitingNotes ?? []).some(n => n.id === id)
201}
202
203// A pending entry older than the launcher's own bound (40 s plus a margin) belongs to a settle a reload dropped: it reads as an unknown outcome, with re-check.
204export const PENDING_STALE_MS = 45_000
205export function settleEntryView(entry: { label: string; at: number; unknown?: true; pending?: true }, now: number): { label: string; unknown: boolean; pending: boolean } {
206 if (entry.pending === true && now - entry.at > PENDING_STALE_MS) return { label: 'outcome unknown', unknown: true, pending: false }
207 return { label: entry.label, unknown: entry.unknown === true, pending: entry.pending === true }
208}
209
210// The settled map keeps at most `keep` entries, and drops only resolved ones (oldest first): an entry still pending or unknown is what keeps a note refused, so it
211// is never the one pruned, even if that leaves the map larger than `keep`. The entry just written is never dropped either.
212export function pruneSettled<T extends { pending?: true; unknown?: true }>(m: Record<string, T>, newest: string, keep = 20): Record<string, T> {
213 const out: Record<string, T> = { ...m }
214 let over = Object.keys(out).length - keep
215 for (const k of Object.keys(out)) {
216 if (over <= 0) break
217 if (k !== newest && out[k]!.pending !== true && out[k]!.unknown !== true) { delete out[k]; over -= 1 }
218 }
219 return out
220}
221
222// How a settle's toast reads as the row's own line.
223export function settleKind(line: string): { kind: 'ok' | 'unknown' | 'refused'; label: string } {
224 if (line.startsWith('belay: outcome unknown')) return { kind: 'unknown', label: 'outcome unknown' }
225 if (line.includes('was not settled')) return { kind: 'refused', label: 'not settled' }
226 return { kind: 'ok', label: `settled: ${line.split(': ')[2]?.split(' · ')[0] ?? 'done'}` }
227}
228
229export const SETTLE_UNKNOWN = 'belay: outcome unknown: re-read with belay memory held'
230
231// What `belay memory held settle --json` printed, as one toast line. Only a definite refusal says "not settled": a timeout, a lost owner,
232// anything that may have committed, or output that is not a receipt says the outcome is unknown (a write lost after it was sent is not re-run).
233export function settleLine(exitCode: number, stdout: string, stderr: string): string {
234 let j: Record<string, unknown> | null = null
235 try { j = obj(JSON.parse(stdout)) } catch { /* not a receipt */ }
236 if (exitCode === 0 && j && str(j.id) && str(j.outcome)) return `belay: ${clean(String(j.action ?? 'settled'), 20)} ${shortId(String(j.id))}: ${clean(String(j.outcome), 40)}${str(j.receipt) ? ` · receipt ${clean(String(j.receipt), 80)}` : ''}`
237 const err = cliError(stdout, stderr)
238 if (exitCode !== 0 && err && !err.unknown) return `belay: the note was not settled (exit ${exitCode}): ${err.code ? `${err.code}: ` : ''}${err.message}`
239 return `${SETTLE_UNKNOWN}${err?.code ? ` (${err.code})` : ` (exit ${exitCode})`}`
240}
241hooks/panel/model.ts 420 lines1// The panel's model: belay's JSON in, plain facts out. Pure; no `$`, no drawing. Everything is read
2// defensively. A field that is missing or the wrong type becomes null and its column hides or falls
3// back, and nothing here invents a value (belay invariant 4: every fact names what established it).
4
5import type {
6 BelayAgent, BelayCache, BelayDisplayState, BelayEntry, BelayNotice, BelayPanelFooter, BelayPanelSnap,
7 BelayPanelUi, BelaySettings, BelayMemoryNotice,
8} from '../../types/index'
9import { cells, clipCells } from './wrap'
10
11// `brk` marks the first segment of a part that may be dropped whole when the line is too wide (fitSegs).
12export type Seg = { t: string; c?: string; dim?: boolean; bold?: boolean; brk?: true }
13
14export const DEFAULT_SETTINGS: BelaySettings = {
15 pane: 'docked', marks: 'shapes', cache: 'both', context: 'both', band: 'two-lines', task: 'kind',
16}
17export const DEFAULT_UI: BelayPanelUi = { open: false, selected: null, legend: false, foldOpen: false, groups: [], placed: false, seen: [], confirm: null }
18
19export const num = (x: unknown): number | null => (typeof x === 'number' && Number.isFinite(x) ? x : null)
20export const str = (x: unknown): string | null => (typeof x === 'string' && x !== '' ? x : null)
21export const obj = (x: unknown): Record<string, unknown> | null =>
22 x !== null && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : null
23export const arr = (x: unknown): unknown[] => (Array.isArray(x) ? x : [])
24// Control characters and line breaks never reach a Text: one line, one row.
25export const clean = (s: string, cap = 200): string => {
26 const t = s.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
27 return t.length > cap ? t.slice(0, cap - 1) + '…' : t
28}
29
30// ---- settings -----------------------------------------------------------------------------------
31
32const ALLOWED: { [K in keyof BelaySettings]: readonly BelaySettings[K][] } = {
33 pane: ['docked', 'above-prompt'], marks: ['shapes', 'words', 'colour'], cache: ['countdown', 'bar', 'both'],
34 context: ['figure', 'bar', 'both'], band: ['one-line', 'two-lines', 'minimal'], task: ['kind', 'title', 'off'],
35}
36
37// `belay settings --json` is { found, path, parse_error, settings: [{ key, value, source, allowed }] }.
38// A value outside the allowed set is ignored and the default stands.
39export function parseSettings(json: unknown): BelaySettings {
40 const out: BelaySettings = { ...DEFAULT_SETTINGS }
41 for (const r of arr(obj(json)?.settings)) {
42 const o = obj(r)
43 const key = o && str(o.key)
44 if (!o || !key || !key.startsWith('mod.')) continue
45 const name = key.slice(4) as keyof BelaySettings
46 if (name in ALLOWED && (ALLOWED[name] as readonly unknown[]).includes(o.value)) (out as Record<string, unknown>)[name] = o.value
47 }
48 return out
49}
50
51// `belay settings --json` carries `memory_notice` (null once a project is set). A malformed one is no notice.
52export function parseMemoryNotice(json: unknown): BelayMemoryNotice | null {
53 const n = obj(obj(json)?.memory_notice)
54 const title = n && str(n.title), why = n && str(n.why), fix = n && str(n.fix)
55 if (!n || !title || !why || !fix) return null
56 return { title: clean(title, 80), why: clean(why, 240), fix: clean(fix, 400), configLine: clean(str(n.config_line) ?? '"memory": { "project": "<name>" }', 80), short: clean(str(n.short) ?? fix, 160) }
57}
58
59// The band's one-line warning and the plain-surface line for the notice (view.tsx draws exactly these).
60export function memoryTitleSegs(n: BelayMemoryNotice): Seg[] {
61 return [{ t: '▲ ', c: 'warning' }, { t: n.title, c: 'warning', bold: true }]
62}
63export function memoryFallbackSegs(n: BelayMemoryNotice): Seg[] {
64 return [{ t: `▲ ${n.title}. ${n.short}`, c: 'warning' }]
65}
66
67// ---- agents -------------------------------------------------------------------------------------
68
69const STATES: readonly BelayDisplayState[] = ['running', 'waiting', 'idle', 'stalled', 'permission', 'parked', 'dead', 'done', 'starting', 'stopping', 'unknown']
70// Without a valid `display_state` the mark is unknown: the row's own state is never mapped to a rung.
71
72function normCache(raw: unknown): BelayCache | null {
73 const c = obj(raw)
74 if (!c) return null
75 const s = c.state
76 return {
77 ttlS: num(c.ttl_s), ttlBy: str(c.ttl_established_by), lastRequestAtMs: num(c.last_request_at_ms),
78 ageMs: num(c.age_ms), leftMs: num(c.left_ms),
79 state: s === 'warm' || s === 'low' || s === 'cold' ? s : 'unknown',
80 }
81}
82
83function normAgent(raw: unknown): BelayAgent | null {
84 const a = obj(raw)
85 const id = a && str(a.id)
86 if (!a || !id) return null
87 const rawState = str(a.state)
88 const ds = str(a.display_state)
89 const fromDisplay = ds && (STATES as readonly string[]).includes(ds) ? (ds as BelayDisplayState) : null
90 const t = obj(a.task)
91 const task = t && str(t.id) ? { id: str(t.id) as string, title: str(t.title), titleSource: str(t.title_source), source: str(t.source) } : null
92 return {
93 id, role: str(a.role), substrate: str(a.substrate), model: str(a.model), effort: str(a.effort),
94 state: fromDisplay ?? 'unknown',
95 stateRaw: rawState,
96 stateBy: fromDisplay ? str(a.display_state_established_by) : null,
97 ctxTokens: num(a.context_tokens), ctxWindow: num(a.context_window), ctxPct: num(a.context_pct),
98 costUsd: num(a.cost_usd), costSource: str(a.cost_source), costBasis: str(a.cost_basis),
99 cache: normCache(a.cache), task,
100 launchMode: str(a.permission_mode) ?? str(a.launch_mode), version: str(a.claude_code_version) ?? str(a.version),
101 pid: num(a.pid),
102 }
103}
104
105export type AgentsRead = Pick<BelayPanelSnap, 'runId' | 'mode' | 'startedAtMs' | 'pausedAtMs' | 'endedAtMs' | 'tasks' | 'maxFixRounds' | 'embedder' | 'agents' | 'footer'>
106
107// Only belay's two words count; anything else is an unknown mode.
108const modeOf = (m: string | null): 'headless' | 'interactive' | null => (m === 'headless' || m === 'interactive' ? m : null)
109
110export function normAgents(json: unknown): AgentsRead {
111 const j = obj(json) ?? {}
112 const agents = arr(j.agents).map(normAgent).filter((a): a is BelayAgent => a !== null)
113 const f = obj(j.footer)
114 const footer: BelayPanelFooter | null = f ? {
115 agents: num(f.agents) ?? agents.length, totalCostUsd: num(f.total_cost_usd), notRecorded: num(f.cost_not_recorded) ?? 0,
116 asOfMs: num(f.as_of_ms), highestCtxPct: num(f.highest_context_pct),
117 } : null
118 const run = obj(j.run) ?? {}
119 const tc = obj(j.tasks) ?? obj(run.tasks)
120 const held = new Set(agents.filter(a => a.task).map(a => a.task!.id)).size
121 const tasks = tc ? { done: num(tc.done), total: num(tc.total), held } : held ? { done: null, total: null, held } : null
122 return {
123 runId: str(j.run_id), mode: modeOf(str(j.mode) ?? str(run.mode)),
124 startedAtMs: num(j.started_at_ms), pausedAtMs: num(j.paused_at_ms), endedAtMs: num(j.ended_at_ms),
125 tasks, maxFixRounds: num(j.max_fix_rounds),
126 embedder: str(j.embedder) ?? str(run.embedder), agents, footer,
127 }
128}
129
130// ---- notices ------------------------------------------------------------------------------------
131
132export const TONE: Record<BelayNotice['tone'], { glyph: string; color: string; rank: number }> = {
133 permission: { glyph: '▲', color: 'permission', rank: 0 },
134 dead: { glyph: '✕', color: 'error', rank: 1 },
135 refuted: { glyph: '✕', color: 'error', rank: 2 },
136 stalled: { glyph: '◌', color: 'warning', rank: 3 },
137 paused: { glyph: '‖', color: 'planMode', rank: 4 },
138 holds: { glyph: '✓', color: 'merged', rank: 5 },
139 stopped: { glyph: '✓', color: 'inactive', rank: 6 },
140 ended: { glyph: '✓', color: 'inactive', rank: 7 },
141 memory: { glyph: '◇', color: 'warning', rank: 8 },
142}
143
144export function normNotices(json: unknown): BelayNotice[] {
145 const out: BelayNotice[] = []
146 for (const raw of arr(obj(json)?.events)) {
147 const e = obj(raw)
148 const kind = e && str(e.kind)
149 if (!e || !kind) continue
150 const ts = num(e.ts)
151 const w = str(e.worker_id)
152 const mk = (tone: BelayNotice['tone'], id: string, text: string, ids: string, cmd: string | null, agentId: string | null = w, taskId: string | null = null): void => {
153 out.push({ key: `${kind}|${id}|${ts ?? ''}`, kind, tone, ts, agentId, taskId, text: clean(text), ids: clean(ids), cmd })
154 }
155 if (kind === 'permission_pending') {
156 const r = str(e.request_id) ?? '?'
157 mk('permission', r, `${r} · ${w ?? '?'} wants ${str(e.tool) ?? 'a tool'}`, r, `belay approve ${r} · belay deny ${r}`)
158 } else if (kind === 'worker_dead') {
159 if (w && /^archreview-/.test(w)) mk('stopped', w, `${w} cut off before it answered`, `exit ${e.exit_code ?? '?'}`, `belay worker rm ${w}`) // a review's death is its outcome, not an alert
160 else mk('dead', w ?? '?', `${w} dead · established by ${str(e.death_established_by) ?? 'not recorded'}`, `exit ${e.exit_code ?? '?'}`, `belay logs ${w}`)
161 } else if (kind === 'worker_stopped') {
162 mk('stopped', w ?? '?', `${w} stopped`, `exit ${e.exit_code ?? '?'}`, `belay logs ${w}`)
163 } else if (kind === 'worker_stalled') {
164 const age = num(e.last_event_age_ms)
165 mk('stalled', w ?? '?', `${w} stalled · no event for ${age === null ? '?' : dur(age, true)}`, `pid ${e.pid ?? '?'}`, `belay logs ${w}`)
166 } else if (kind === 'review_verdict') {
167 const t = str(e.task_id) ?? '?'
168 const holds = e.verdict === 'holds' || (e.passed === true && e.verdict == null)
169 const refuted = e.verdict === 'refuted' || (e.passed === false && e.executable !== false)
170 if (!holds && !refuted) continue
171 mk(holds ? 'holds' : 'refuted', t, `review ${holds ? 'holds' : 'refuted'}${str(e.judged_by) ? ` · ${str(e.judged_by)}` : ''}`, t.slice(0, 8), null, null, t)
172 } else if (kind === 'run_paused') {
173 mk('paused', str(e.run_id) ?? '?', `run paused${str(e.paused_by) ? ` by ${str(e.paused_by)}` : ''}`, str(e.run_id) ?? '', null, null)
174 } else if (kind === 'notes_unembedded') {
175 const n = num(e.count) ?? 0
176 if (n <= 0) continue
177 const names = Object.keys(obj(e.by_project) ?? {}).sort()
178 mk('memory', String(n), `${n} note${n === 1 ? '' : 's'} unembedded`, names.join(' '), `belay memory embed --project ${names.length === 1 ? names[0] : '<p>'}`, null)
179 } else if (kind === 'run_ended') {
180 mk('ended', str(e.run_id) ?? '?', `run ended · ${str(e.exit_reason) ?? 'reason not recorded'}`, str(e.run_id) ?? '', null, null)
181 }
182 }
183 return out
184}
185
186// A stopped worker is not one of decision 5's notices: it is listed in the pane, never in the band or the count.
187export const unseenNotices = (ns: BelayNotice[], ui: BelayPanelUi): BelayNotice[] =>
188 ns.filter(n => n.tone !== 'stopped' && !ui.seen.includes(n.key)).sort((a, b) => TONE[a.tone].rank - TONE[b.tone].rank || (b.ts ?? 0) - (a.ts ?? 0))
189
190// ---- transcript ---------------------------------------------------------------------------------
191
192export function normTranscript(json: unknown): BelayEntry[] {
193 const out: BelayEntry[] = []
194 for (const raw of arr(obj(json)?.entries)) {
195 const e = obj(raw)
196 const kind = e && str(e.kind)
197 if (!e || !kind) continue
198 let text = ''
199 if (kind === 'tool') {
200 const ids = Object.entries(obj(e.ids) ?? {}).map(([k, v]) => `${k}=${v}`).join(' ')
201 const res = e.result === null || e.result === undefined ? 'no result yet' : e.result === 'ok' ? 'ok' : (str(e.error_code) ?? 'error')
202 text = `${(str(e.tool) ?? 'tool (call outside the window)').replace(/^mcp__belay__/, '')}${ids ? ' ' + ids : ''} → ${res}`
203 } else if (kind === 'result') {
204 text = `turn end${num(e.total_cost_usd) !== null ? ` · $${(e.total_cost_usd as number).toFixed(2)}` : ''}`
205 } else text = str(e.text) ?? ''
206 const at = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN // belay gives an ISO string
207 if (text) out.push({ kind, text: clean(text, kind === 'text' ? 4000 : 400), ts: Number.isFinite(at) ? at : null })
208 }
209 return out
210}
211
212export const lastWords = (t: BelayEntry[] | null): BelayEntry | null => {
213 for (let i = (t?.length ?? 0) - 1; i >= 0; i -= 1) if (t![i]!.kind === 'text') return t![i]!
214 return null
215}
216
217// Two periods of the slowest poll (the band's 5 s): older than this, or after a failed read, the line is stale.
218export const TRANSCRIPT_STALE_MS = 10000
219
220// ---- figures ------------------------------------------------------------------------------------
221
222export function dur(ms: number, fine = false): string {
223 const s = Math.max(0, Math.round(ms / 1000))
224 if (s < 60) return `${s}s`
225 const m = Math.floor(s / 60)
226 if (m < 60) return fine && s % 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${m}m`
227 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
228}
229export const money = (x: number | null): string => (x === null ? '$ —' : `$${x.toFixed(2)}`)
230
231export function bar(frac: number, width: number): { on: string; off: string } {
232 const p = Math.max(0, Math.min(1, frac))
233 const n = Math.round(p * width)
234 return { on: '▰'.repeat(n), off: '▱'.repeat(width - n) }
235}
236
237// The cache's time left now: the fetched left_ms less the time since the fetch (a local tick between
238// fetches). null when belay did not give a left_ms; the state then falls back to belay's own word.
239export function cacheNow(c: BelayCache | null, fetchedAt: number, now: number): { leftMs: number | null; state: BelayCache['state'] } {
240 if (!c) return { leftMs: null, state: 'unknown' }
241 if (c.leftMs === null) return { leftMs: null, state: c.state }
242 const left = c.leftMs - Math.max(0, now - fetchedAt)
243 const low = Math.min(600000, (c.ttlS ?? 3600) * 1000 * 0.2)
244 return { leftMs: left, state: left <= 0 ? 'cold' : left < low ? 'low' : 'warm' }
245}
246const CACHE_COLOR = { warm: 'success', low: 'warning', cold: 'inactive', unknown: 'inactive' } as const
247
248export function cacheSegs(a: BelayAgent, s: BelaySettings, fetchedAt: number, now: number): Seg[] {
249 const { leftMs, state } = cacheNow(a.cache, fetchedAt, now)
250 const c = CACHE_COLOR[state]
251 if (state === 'unknown' || (leftMs === null && state !== 'cold')) return [{ t: '—', dim: true }]
252 const txt = state === 'cold' ? 'cold' : dur(leftMs as number)
253 const b = state === 'cold' || leftMs === null || !a.cache?.ttlS ? null : bar(leftMs / (a.cache.ttlS * 1000), 5)
254 const barSegs: Seg[] = b ? [{ t: b.on, c }, { t: b.off, dim: true }] : []
255 if (s.cache === 'countdown') return [{ t: txt, c }]
256 if (s.cache === 'bar') return barSegs.length ? barSegs : [{ t: txt, c }]
257 return barSegs.length ? [...barSegs, { t: ' ' }, { t: txt, c }] : [{ t: txt, c }]
258}
259
260// 18247 -> 18.2k, 1234567 -> 1.2M: the context in tokens, when belay knows the count but has no window to make a percentage of.
261export const tokensShort = (n: number): string => (n >= 999_950 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)))
262
263// The context column: the token count always, and the percent of the window when belay knows the window (`18.2k` or `18.2k · 2%`), so one rule reads the same for
264// every row. The bar, when the setting asks for it, is drawn only with a percent. `mode` is how much room the column has: `full` (the setting's own form), `mid`
265// (tokens and percent, no bar) or `tokens` (the narrow form: the percent and the bar are dropped first).
266export type CtxMode = 'full' | 'mid' | 'tokens'
267export function ctxSegs(a: BelayAgent, s: BelaySettings, mode: CtxMode = 'full'): Seg[] {
268 const tok: Seg | null = a.ctxTokens !== null ? { t: tokensShort(a.ctxTokens) } : null
269 const pct = a.ctxPct
270 if (pct === null) return [tok ?? { t: '—', dim: true }]
271 const fig: Seg = { t: `${Math.round(pct)}%` }
272 if (mode === 'tokens') return [tok ?? fig]
273 const c = pct > 75 ? 'warning' : 'suggestion'
274 const b = bar(pct / 100, 6)
275 const barSegs: Seg[] = [{ t: b.on, c }, { t: b.off, dim: true }]
276 const both: Seg[] = tok ? [tok, { t: ' · ', dim: true }, fig] : [fig]
277 if (mode === 'mid' || s.context === 'figure') return both
278 if (s.context === 'bar') return tok ? [...barSegs, { t: ' ' }, tok] : barSegs
279 return [...barSegs, { t: ' ' }, ...both]
280}
281
282// ---- marks --------------------------------------------------------------------------------------
283
284const GLYPH: Record<BelayDisplayState, string> = { running: '●', waiting: '◐', idle: '○', stalled: '◌', permission: '▲', parked: '‖', dead: '✕', done: '✓', starting: '●', stopping: '●', unknown: '?' }
285const WORD: Record<BelayDisplayState, string> = { running: 'run ', waiting: 'wait', idle: 'idle', stalled: 'stal', permission: 'perm', parked: 'park', dead: 'dead', done: 'done', starting: 'star', stopping: 'stop', unknown: '? ' }
286export const STATE_COLOR: Record<BelayDisplayState, string> = {
287 running: 'success', waiting: 'success', idle: 'inactive', stalled: 'warning', permission: 'permission', parked: 'inactive', dead: 'error', done: 'inactive', starting: 'inactive', stopping: 'inactive', unknown: 'inactive',
288}
289export const STATE_NAME: Record<BelayDisplayState, string> = {
290 running: 'running', waiting: 'waiting inside a call', idle: 'idle, no turn running', stalled: 'stalled', permission: 'waiting on a permission',
291 parked: 'parked, session kept', dead: 'dead', done: 'finished', starting: 'starting, no pid recorded yet', stopping: 'stopping', unknown: 'state unknown (belay gave no display_state)',
292}
293
294export function markSeg(state: BelayDisplayState, marks: BelaySettings['marks']): Seg {
295 const c = STATE_COLOR[state]
296 if (marks === 'colour') return { t: '●', c }
297 if (marks === 'words') return { t: `[${WORD[state]}]`, c }
298 return { t: GLYPH[state], c }
299}
300export const markWidth = (m: BelaySettings['marks']): number => (m === 'words' ? 6 : 1)
301
302// ---- run summary --------------------------------------------------------------------------------
303
304// In an interactive run the orchestrator is a person's own session: belay derives no state for it, and its words are in its own window.
305export const interactiveOrch = (a: BelayAgent, snap: BelayPanelSnap): boolean => a.role === 'orchestrator' && snap.mode === 'interactive' && a.state === 'unknown'
306export function interactiveMark(marks: BelaySettings['marks']): Seg {
307 if (marks === 'words') return { t: '[intv]', c: 'inactive' }
308 return { t: marks === 'colour' ? '●' : '◇', c: 'inactive' }
309}
310
311// A review worker (`archreview-…`) that died is a review that was cut, failed or abandoned: finished, with its outcome, not live. A dead coder or tester stays in the
312// live part because someone should look at it.
313export const isReviewWorker = (a: BelayAgent): boolean => /^archreview-/.test(a.id)
314export const isLive = (a: BelayAgent): boolean => a.state !== 'done' && !(a.state === 'dead' && isReviewWorker(a))
315export const isBusy = (a: BelayAgent): boolean => ['running', 'waiting', 'stalled', 'permission'].includes(a.state)
316
317// Elapsed run time: frozen at the end, at the pause while one is set, and where the orchestrator was first read dead.
318export function elapsedMs(snap: BelayPanelSnap, now: number): number | null {
319 if (snap.startedAtMs === null) return null
320 return (snap.endedAtMs ?? snap.pausedAtMs ?? snap.frozenAtMs ?? now) - snap.startedAtMs
321}
322
323// The dim line for a round whose reads did not all land: when the data was last all fresh, and which read timed out or failed.
324// null when every read of the latest round succeeded.
325export function staleLine(snap: BelayPanelSnap): string | null {
326 const bad = snap.readIssues ?? []
327 if (!bad.length) return null
328 const slow = bad.filter(i => i.timedOut).map(i => i.verb)
329 const failed = bad.filter(i => !i.timedOut).map(i => (i.text ? `${i.verb} failed: ${i.text}` : `${i.verb} failed`))
330 const what = [...(slow.length ? [`reads slow (${slow.join(', ')} timed out)`] : []), ...failed].join(' · ')
331 return snap.goodAt !== null ? `as of ${new Date(snap.goodAt).toTimeString().slice(0, 8)} · ${what}` : what
332}
333
334// The run line is parts: brand and id; state and elapsed; stale; agents; cost. A narrow width drops parts from the
335// END and never shrinks a kept one; the brand and id come first and stay whole while they fit.
336export function runBrief(snap: BelayPanelSnap, now: number): Seg[] {
337 const segs: Seg[] = [{ t: 'belay', c: 'claude', bold: true }, { t: ` ${snap.runId ?? '—'}` }]
338 if (snap.runState) {
339 const c = snap.runState === 'paused' ? 'planMode' : snap.runState === 'dead' ? 'error' : snap.runState === 'ended' ? 'inactive' : 'success'
340 segs.push({ t: ' ', brk: true }, { t: '●', c }, { t: ` ${snap.runState}` })
341 const el = elapsedMs(snap, now)
342 if (el !== null) segs.push({ t: ` ${dur(el)}` })
343 }
344 if (snap.stale) segs.push({ t: ' stale: last read failed', dim: true, brk: true })
345 return segs
346}
347
348export function runLine(snap: BelayPanelSnap, now: number): Seg[] {
349 const live = snap.agents.filter(isLive).length
350 const busy = snap.agents.filter(isBusy).length
351 const segs: Seg[] = [...runBrief(snap, now), { t: ' │ ', dim: true, brk: true }, { t: `${live} agents · ${busy} busy` }]
352 if (snap.footer) segs.push({ t: ' │ ', dim: true, brk: true }, snap.footer.totalCostUsd === null ? { t: 'no cost yet', dim: true } : { t: money(snap.footer.totalCostUsd) })
353 if (snap.footer?.asOfMs) segs.push({ t: ` @${new Date(snap.footer.asOfMs).toTimeString().slice(0, 5)}`, dim: true })
354 return segs
355}
356
357const width = (xs: Seg[]): number => xs.reduce((n, s) => n + cells(s.t), 0)
358
359// Fit a line to `room` cells: whole parts are kept while they fit and the rest are dropped from the end.
360// If the first part alone is too long, it is cut at its end with an ellipsis. Nothing is ever shrunk.
361export function fitSegs(segs: Seg[], room: number): Seg[] {
362 const parts: Seg[][] = []
363 for (const s of segs) {
364 if (s.brk || !parts.length) parts.push([s])
365 else parts[parts.length - 1]!.push(s)
366 }
367 const out: Seg[] = []
368 let used = 0
369 for (let i = 0; i < parts.length; i += 1) {
370 const w = width(parts[i]!)
371 if (used + w <= room) { out.push(...parts[i]!); used += w; continue }
372 if (i === 0) {
373 for (const s of parts[0]!) {
374 const left = room - used
375 if (left <= 0) break
376 const w = cells(s.t)
377 if (w <= left) { out.push(s); used += w } else { out.push({ ...s, t: clipCells(s.t, left) }); used = room; break }
378 }
379 }
380 break
381 }
382 return out
383}
384
385export const plain = (segs: Seg[]): string => segs.map(s => s.t).join('')
386
387// The band's second element: the newest unseen notice wins, else the orchestrator's latest line
388// (headless only, with its age, marked stale when the last read failed or is old), else a plain statement.
389// `cols` bounds the text so the band never wraps.
390export function bandMessage(snap: BelayPanelSnap, ui: BelayPanelUi, cols: number, now: number): Seg[] {
391 const top = unseenNotices(snap.notices, ui)[0]
392 if (top) {
393 const t = TONE[top.tone]
394 return [{ t: `${t.glyph} ${top.text}`, c: t.color }]
395 }
396 if (snap.mode === 'interactive') return [{ t: 'orchestrator is in its own window', dim: true }]
397 if (snap.mode !== 'headless') return [{ t: 'run mode unknown', dim: true }]
398 const last = lastWords(snap.transcript)
399 if (!last) return [{ t: 'no orchestrator line yet', dim: true }]
400 const stale = !snap.transcriptOk || snap.transcriptAt === null || now - snap.transcriptAt > TRANSCRIPT_STALE_MS
401 const age = last.ts !== null ? ` ${dur(now - last.ts)} ago` : ' (kept, age unknown)'
402 const room = Math.max(10, cols - 30)
403 return [
404 { t: `orch${age}${stale ? ' (stale)' : ''}: `, dim: true },
405 { t: clipCells(last.text, room), dim: stale },
406 ]
407}
408
409export function bandLines(snap: BelayPanelSnap, ui: BelayPanelUi, now: number, cols: number): Seg[][] {
410 const head = runLine(snap, now)
411 const un = unseenNotices(snap.notices, ui)
412 if (snap.settings.band === 'minimal') {
413 const t = un[0] ? TONE[un[0].tone] : null
414 return [[...runBrief(snap, now), ...(t ? [{ t: ` ${t.glyph}${un.length}`, c: t.color, brk: true as const }] : [])]]
415 }
416 const msg = bandMessage(snap, ui, cols, now)
417 if (snap.settings.band === 'one-line') return [[...head, { t: ' │ ', dim: true, brk: true }, ...msg]]
418 return [head, msg]
419}
420hooks/panel/view.tsx 374 lines1// The panel's drawing: the band, the pane and a plain fallback. Colours are theme keys only. Nothing
2// here fetches, writes or submits: a press only calls one of the `Act` handlers the caller passes.
3
4import type { BelayAwaitingNote, BelayEntry, BelayMemoryNotice, BelayNotice, BelayPanelSettle, BelayPanelSnap } from '../../types/index'
5import {
6 bandLines, cacheSegs, ctxSegs, isLive, markSeg, money, runLine, TONE, unseenNotices, bar,
7 memoryTitleSegs, memoryFallbackSegs, staleLine, type Seg,
8} from './model'
9import { CONFIRM_MS, confirmLine, heldDetail, heldLine, reviewLine, reviewProject, REASON_MAX, runningReview, SETTLE_ACTIONS, settleConfirm, settleEntryView, settledActive, shortId } from './held'
10import { cells, cmdLines, wordWrap, wrapHard } from './wrap'
11import { clip, INDENT, Line, Section, shortModel, tp, type Act, type Ctx, type Els } from './base'
12import { Table } from './table'
13
14export type { Act, Els }
15
16// ---- the band ----------------------------------------------------------------------------------
17
18export function Band({ E, snap, ui, now, cols, act }: Ctx) {
19 const { Box, Button } = E
20 if (snap.fatal) return <Line E={E} w={cols} k="fatal" segs={[{ t: 'belay ', c: 'claude', bold: true }, { t: snap.fatal, c: 'error' }]} />
21 const lines = bandLines(snap, ui, now, cols)
22 const err = snap.errors[0]
23 const stale = staleLine(snap)
24 // The toggle shrinks to a bare mark on a narrow band so the run line keeps its room.
25 const toggle = cols >= 60 ? (ui.open ? ' ▾ panel' : ' ▸ panel') : (ui.open ? ' ▾' : ' ▸')
26 return (
27 <Box flexDirection="column">
28 {snap.memoryNotice && <MemoryNotice E={E} snap={snap} cols={cols} />}
29 {lines.map((segs, i) => (
30 <Box key={`band${i}`}>
31 <Line E={E} segs={segs} k={`l${i}`} w={i === 0 ? cols - toggle.length : cols} />
32 {i === 0 && <Button key="open" label={toggle} plain dimColor onPress={act.toggle} />}
33 </Box>
34 ))}
35 {stale && wrapHard(stale, cols).map((t, i) => <Line E={E} w={cols} segs={[{ t, dim: true }]} k={`stale${i}`} />)}
36 {err && <Line E={E} w={cols} segs={[{ t: err, dim: true }]} k="err" />}
37 </Box>
38 )
39}
40
41export function BandWaiting({ E, runId, errors, cols = 80 }: { E: Els; runId: string | null; errors: string[]; cols?: number }) {
42 const { Box } = E
43 return (
44 <Box flexDirection="column">
45 <Line E={E} w={cols} segs={[{ t: 'belay', c: 'claude', bold: true }, { t: ` ${runId ?? 'latest run'} `, dim: false }, { t: 'reading…', dim: true }]} k="w" />
46 {errors[0] && <Line E={E} w={cols} segs={[{ t: errors[0], dim: true }]} k="we" />}
47 </Box>
48 )
49}
50
51// ---- the table ---------------------------------------------------------------------------------
52
53
54// ---- notices, orchestrator, legend -------------------------------------------------------------
55
56const hhmm = (ts: number | null): string => (ts === null ? '--:--' : new Date(ts).toTimeString().slice(0, 5))
57const ATTN = 'warning' // the one accent for what waits on the operator; a failure keeps `error`
58
59// The line under a notice. An id wider than the room beside the time moves out to the notice's own indent, whole.
60function IdLines({ E, ids, cols }: { E: Els; ids: string; cols: number }) {
61 const { Box, Text } = E
62 const pl = cells(ids) > cols - 8 ? 2 : 8
63 return <Box paddingLeft={pl} flexShrink={0} flexDirection="column">{wordWrap(ids, cols - pl).map((t, i) => <Text key={`i${i}`} dimColor wrap="truncate-end">{clip(t, cols - pl)}</Text>)}</Box>
64}
65
66// Rows are drawn at the section's inner width. One line each: the id goes beside the text when both fit, and is left out when the
67// text already names it; only an id that does not fit beside the text takes the line under it.
68function Notices({ E, snap, ui, act, cols }: Ctx) {
69 const { Box, Text, Button } = E
70 const ordered = [...snap.notices].sort((a: BelayNotice, b: BelayNotice) => (b.ts ?? 0) - (a.ts ?? 0))
71 const fresh = unseenNotices(ordered, ui).length
72 const inner = cols - INDENT
73 if (!ordered.length) return <Section E={E} k="sn" title="NOTICES" cols={cols} summary={[{ t: 'none', dim: true }]} />
74 return (
75 <Section E={E} k="sn" title="NOTICES" cols={cols} summary={fresh ? [{ t: `${fresh} new`, c: ATTN }] : []}>
76 {ordered.slice(0, 8).map(n => {
77 const t = TONE[n.tone]
78 const seen = ui.seen.includes(n.key)
79 const room = inner - 8
80 const fits = cells(n.text) <= room
81 const named = !n.ids || n.text.includes(n.ids)
82 const inline = fits && !named && cells(n.text) + 2 + cells(n.ids) <= room
83 return (
84 <Box key={`n-${n.key}`} flexDirection="column">
85 <Box>
86 <Box width={2} flexShrink={0}><Text color={t.color}>{t.glyph}</Text></Box>
87 <Box width={6} flexShrink={0}><Text dimColor>{hhmm(n.ts)}</Text></Box>
88 <Button key={`notice:${n.key}`} label={clip(n.text, room)} plain dimColor={seen} onPress={() => act.seen(n.key)} />
89 {inline ? <Text dimColor>{` ${n.ids}`}</Text> : null}
90 </Box>
91 {n.ids && !inline && !(fits && named) ? <IdLines E={E} ids={n.ids} cols={inner} /> : null}
92 {n.cmd ? <Box paddingLeft={2} flexShrink={0} flexDirection="column">{cmdLines(n.cmd, inner - 2).map((t, i) => <Text key={`c${i}`} dimColor wrap="truncate-end">{clip(t, inner - 2)}</Text>)}</Box> : null}
93 </Box>
94 )
95 })}
96 {ordered.length > 8 && <Line E={E} w={inner} k="nm" segs={[{ t: `and ${ordered.length - 8} older`, dim: true }]} />}
97 </Section>
98 )
99}
100
101type Item = { kind: 'text'; e: BelayEntry } | { kind: 'line'; e: BelayEntry } | { kind: 'tools'; key: string; calls: BelayEntry[] }
102
103// The orchestrator's last 8 text entries in full. Between (and after) them the tool calls collapse to one
104// dim line, "· N tool calls", that opens on a press. A wake, an operator line or a note stays its own dim line.
105export function orchestratorItems(entries: BelayEntry[]): Item[] {
106 const items: Item[] = []
107 let run: BelayEntry[] = []
108 let lastText = ''
109 const flush = () => { if (run.length) items.push({ kind: 'tools', key: lastText, calls: run }); run = [] }
110 for (const e of entries) {
111 if (e.kind === 'tool') run.push(e)
112 else if (e.kind === 'text') { flush(); items.push({ kind: 'text', e }); lastText = String(e.ts ?? e.text.slice(0, 24)) }
113 else if (e.kind === 'belay' || e.kind === 'operator' || e.kind === 'note') { flush(); items.push({ kind: 'line', e }) }
114 }
115 flush()
116 const texts = items.map((it, i) => (it.kind === 'text' ? i : -1)).filter(i => i >= 0)
117 const from = texts.length > 8 ? texts[texts.length - 8]! : 0
118 return items.slice(from)
119}
120
121// A note waiting on the operator, one line: a mark, its short id, the title and why the review could not decide. A press opens it (state
122// only, nothing runs): the whole title and the whole reason on their own lines, then archive / release / reoffer. Only an open note has buttons.
123function SettleRow({ E, cols, n, act, open, pend, focused, done }: { E: Els; cols: number; n: BelayAwaitingNote; act: Act; open: boolean; pend: BelayPanelSettle | null; focused: boolean; done: { label: string; unknown: boolean; pending: boolean } | null }) {
124 const { Box, Text, Button, Input } = E
125 const mine = open && pend && pend.id === n.id ? pend : null // this note's settle in progress: the reason Input, then the confirm
126 const room = Math.max(0, cols - 4 - 8) // the attention mark, the open mark and the id
127 const whole = (n.title ? ` "${n.title}"` : '') + (n.reason ? ` · ${n.reason}` : '')
128 const fits = cells(whole) <= room
129 // Whole when it fits; otherwise the title takes at most 40% of the room (none under 9 cells) and the reason the rest, clipped with an ellipsis.
130 const title = fits ? (n.title ? ` "${n.title}"` : '') : n.title && room >= 9 ? ` "${clip(n.title, Math.max(6, Math.floor(room * 0.4)))}"` : ''
131 const left = room - cells(title)
132 const reason = n.reason && (fits || left >= 8) ? ` · ${fits ? n.reason : clip(n.reason, left - 3)}` : ''
133 const press = () => act.note(n.id)
134 // The buttons wrap onto more lines when three do not fit.
135 const rows: string[][] = [[]]
136 let w = 0
137 for (const a of SETTLE_ACTIONS) {
138 const lw = cells(` ▸ ${a}`)
139 if (w > 0 && w + lw > cols) { rows.push([]); w = 0 }
140 rows[rows.length - 1]!.push(a)
141 w += lw
142 }
143 return (
144 <Box flexDirection="column" key={`aw-${n.id}`}>
145 <Box>
146 <Text color={ATTN}>▲ </Text>
147 <Button key={`note:${n.id}`} plain dimColor label={`${open ? '▾' : '▸'} ${shortId(n.id)}`} onPress={press} />
148 {title ? <Button key={`note-t:${n.id}`} plain label={title} onPress={press} /> : null}
149 {reason ? <Button key={`note-r:${n.id}`} plain dimColor label={reason} onPress={press} /> : null}
150 </Box>
151 {open && n.title ? wrapHard(`"${n.title}"`, Math.max(8, cols - 2)).map((t, i) => <Line E={E} w={cols} k={`awt-${n.id}-${i}`} segs={[{ t: ` ${t}` }]} />) : null}
152 {open && n.reason ? wrapHard(n.reason, Math.max(8, cols - 2)).map((t, i) => <Line E={E} w={cols} k={`awr-${n.id}-${i}`} segs={[{ t: ` ${t}`, dim: true }]} />) : null}
153 {mine && mine.stage === 'reason' ? (
154 <Box flexDirection="column" paddingLeft={2}>
155 <Input key={`reason:${n.id}`} label="reason: " placeholder={`why ${mine.action}? at most ${REASON_MAX} characters`} submitLabel="next" autoFocus onSubmit={v => act.reason(v)} />
156 {focused ? null : wrapHard('press ctrl+x tab to type the reason (Esc gives the keys back)', Math.max(8, cols - 2)).map((t, i) => <Line E={E} w={cols} k={`awh-${n.id}-${i}`} segs={[{ t, dim: true }]} />)}
157 <Button key={`cancel:${n.id}`} plain label="▸ cancel" onPress={act.cancel} />
158 </Box>
159 ) : null}
160 {mine && mine.stage === 'confirm' ? (
161 <Box flexDirection="column">
162 {wrapHard(settleConfirm(mine.action, n.id, mine.reason, n.title), Math.max(8, cols - 2)).map((t, i) => <Line E={E} w={cols} k={`awq-${n.id}-${i}`} segs={[{ t: ` ${t}`, c: ATTN }]} />)}
163 <Box>
164 <Button key={`cancel:${n.id}`} plain label=" ▸ cancel" onPress={act.cancel} />
165 <Button key={`commit:${n.id}`} plain label={` ▸ ${mine.action} it`} onPress={() => act.commit(n.id)} />
166 </Box>
167 </Box>
168 ) : null}
169 {open && done ? <Line E={E} w={cols} k={`awd-${n.id}`} segs={[{ t: ` ${done.label}`, dim: true }]} /> : null}
170 {open && done && done.unknown ? <Button key={`recheck:${n.id}`} plain label=" ▸ re-check (after belay memory held)" onPress={() => act.recheck(n.id)} /> : null}
171 {open && !mine && !done && rows.map((r, i) => <Box key={`awb-${n.id}-${i}`}>{r.map(a => <Button key={`${a}-${n.id}`} plain label={` ▸ ${a}`} onPress={() => act.settle(a, n.id)} />)}</Box>)}
172 </Box>
173 )
174}
175
176// The memory section. Its heading carries the summary (how many are held, how many await the operator); under it the review lines,
177// the detail line, the awaiting notes with their buttons and the one review button. The first press of the review button turns the
178// line into a confirm; the second press (inside CONFIRM_MS) dispatches; the timeout or any other press cancels. This Button is the ONLY
179// caller of act.review, and act.review is the only way to the dispatch (index.tsx).
180function Held({ E, snap, ui, now, act, cols, pend, focused, settled }: Ctx) {
181 const { Button } = E
182 const hd = snap.held
183 const off = snap.memoryNotice
184 if (!hd && !off) return null
185 const inner = cols - INDENT
186 const project = reviewProject(hd)
187 const busy = hd !== null && project !== null && runningReview(hd, project) !== null // a review of that project holds its claim: no second dispatch
188 const asking = !busy && !!ui.confirm && project !== null && ui.confirm.project === project && now - ui.confirm.at <= CONFIRM_MS
189 const awaiting = hd?.awaiting ?? 0
190 const notes = hd?.awaitingNotes ?? []
191 const openId = ui.openNote ?? null // one at a time; a note that leaves the list is closed by the fetch that drops it
192 const list = hd?.awaitingList
193 // The "+N more" count is the list's own: its total less the rows shown, from the one read. A list kept from an earlier round says when it was read instead.
194 const more = list && !list.carried ? Math.max(0, list.total - notes.length) : 0
195 const carriedAt = list?.carried && notes.length ? list.at : null
196 const summary: Seg[] = [
197 ...(off ? [{ t: 'off', c: ATTN } as Seg, ...(hd ? [{ t: ' · ', dim: true } as Seg] : [])] : []),
198 ...(hd ? [{ t: hd.count > 0 ? `${hd.count} held` : 'none held', dim: true } as Seg] : []),
199 ...(awaiting > 0 ? [{ t: ' · ', dim: true } as Seg, { t: `${awaiting} await${awaiting === 1 ? 's' : ''} you`, c: ATTN } as Seg] : []),
200 ]
201 return (
202 <Section E={E} k="sm" title="MEMORY" cols={cols} summary={summary}>
203 {off && <MemoryOff E={E} n={off} cols={inner} />}
204 {hd && hd.reviews.flatMap((r, i) => {
205 const bad = r.state === 'failed' // a review cut off (its claimant gone) is finished, so dim; only a failed one with a reason is red
206 return wrapHard(reviewLine(r, now), Math.max(8, inner - 2)).map((t, j) => <Line E={E} w={inner} k={`hr${i}-${j}`} segs={[{ t: j === 0 ? '◇ ' : ' ', c: bad ? 'error' : undefined, dim: !bad }, { t, c: bad ? 'error' : undefined }]} />)
207 })}
208 {hd && hd.count > 0 && wrapHard(asking && project ? confirmLine(hd, project) : heldDetail(hd, now), Math.max(8, inner)).map((t, i) => <Line E={E} w={inner} k={`hl${i}`} segs={[{ t, c: asking ? ATTN : undefined, dim: !asking }]} />)}
209 {notes.map(n => <SettleRow E={E} cols={inner} n={n} act={act} open={n.id === openId} pend={pend} focused={focused} done={settledActive(settled[n.id], snap, n.id) ? settleEntryView(settled[n.id]!, now) : null} />)}
210 {carriedAt !== null && <Line E={E} w={inner} k="awc" segs={[{ t: `list from ${hhmm(carriedAt)}`, dim: true }]} />}
211 {more > 0 && wrapHard(`+${more} more · belay memory held --awaiting`, inner).map((t, i) => <Line E={E} w={inner} k={`awm${i}`} segs={[{ t, dim: true }]} />)}
212 {hd && project !== null && !busy && <Button key="review" plain label={asking ? ' ▸ press again to dispatch · any other press cancels' : ` ▸ review the held notes${Object.keys(hd.byProject).length > 1 ? ` (${project} first)` : ''}`} onPress={act.review} />}
213 </Section>
214 )
215}
216
217function Orchestrator({ E, snap, ui, act, cols }: Ctx) {
218 const { Box, Text, Button } = E
219 const inner = cols - INDENT
220 const note = (t: string, k: string) => <Section E={E} k="so" title="ORCHESTRATOR" cols={cols}>{wrapHard(t, inner).map((l, i) => <Line E={E} w={inner} k={`${k}${i}`} segs={[{ t: l, dim: true }]} />)}</Section>
221 if (snap.mode === 'interactive') return note('Interactive: its words are already in its own window.', 'oi')
222 if (snap.mode !== 'headless') return note('run mode unknown', 'ou')
223 const entries = snap.transcript ?? []
224 if (!entries.length) return note(snap.transcript === null ? 'no transcript read yet' : 'no lines yet', 'oe')
225 return (
226 <Section E={E} k="so" title="ORCHESTRATOR" cols={cols}>
227 {orchestratorItems(entries).map((it, i) => {
228 if (it.kind === 'text' || it.kind === 'line') {
229 return (
230 <Box key={`o${i}`}>
231 <Box width={6} flexShrink={0}><Text dimColor>{hhmm(it.e.ts)}</Text></Box>
232 <Box flexGrow={1} flexShrink={1}><Text dimColor={it.kind === 'line'} wrap={it.kind === 'text' ? 'wrap' : 'truncate-end'}>{it.e.text}</Text></Box>
233 </Box>
234 )
235 }
236 const open = ui.groups.includes(it.key)
237 return (
238 <Box key={`o${i}`} flexDirection="column">
239 <Button key={`tools:${it.key}`} plain dimColor label={`${open ? '▾' : '·'} ${it.calls.length} tool call${it.calls.length === 1 ? '' : 's'}`} onPress={() => act.group(it.key)} />
240 {open && it.calls.map((c, j) => <Box key={`o${i}-${j}`} paddingLeft={2}><Text dimColor wrap="truncate-end">{c.text}</Text></Box>)}
241 </Box>
242 )
243 })}
244 </Section>
245 )
246}
247
248function Legend({ E, cols }: Ctx) {
249 const inner = cols - INDENT
250 const m: [string, string, string][] = [
251 ['success', '●', 'running'], ['success', '◐', 'waiting inside a call'], ['inactive', '○', 'idle, no turn running'], ['warning', '◌', 'stalled, no event for 5 min'],
252 ['permission', '▲', 'needs your approval'],
253 // Marks for these three confirmed by the operator, 2026-10-08.
254 ['inactive', '●', 'starting or stopping'], ['inactive', '?', 'state unknown'], ['inactive', '‖', 'parked, session kept'], ['error', '✕', 'dead'], ['inactive', '✓', 'finished'],
255 ]
256 const b = bar(0.6, 4)
257 const c: Seg[][] = [
258 [{ t: b.on, c: 'success' }, { t: b.off, dim: true }, { t: ' cache time left', dim: true }],
259 [{ t: b.on, c: 'suggestion' }, { t: b.off, dim: true }, { t: ' context used', dim: true }],
260 [{ t: '18.2k · 2%', dim: false }, { t: ' context: tokens, and the share of the window when known', dim: true }],
261 [{ t: 'amber', c: 'warning' }, { t: ' look soon', dim: true }], [{ t: 'red', c: 'error' }, { t: ' failed', dim: true }],
262 [{ t: 'purple', c: 'merged' }, { t: ' review holds', dim: true }],
263 ]
264 return (
265 <Section E={E} k="sl" title="LEGEND" cols={cols} summary={[{ t: '? closes', dim: true }]}>
266 {m.map(([col, g, t]) => <Line E={E} w={inner} k={`lg${g}${t}`} segs={[{ t: g, c: col }, { t: ` ${t}`, dim: true }]} />)}
267 {c.map((s, i) => <Line E={E} w={inner} k={`lc${i}`} segs={s} />)}
268 <Line E={E} w={inner} k="lz" segs={[{ t: '@hh:mm cost is as of the last turn end · → command to act', dim: true }]} />
269 </Section>
270 )
271}
272
273// The compact notice in the band, while no memory.project is set: a title and one short line with the fix (the full text is in the CLI).
274function MemoryNotice({ E, snap, cols }: { E: Els; snap: BelayPanelSnap; cols: number }) {
275 const n = snap.memoryNotice
276 if (!n) return null
277 const { Box } = E
278 const body = (t: string, k: string, dim = false) => wordWrap(t, cols - 2).map((l, i) => <Line E={E} w={cols} k={`${k}${i}`} segs={[{ t: ` ${l}`, dim }]} />)
279 return (
280 <Box flexDirection="column">
281 <Line E={E} w={cols} k="mnt" segs={memoryTitleSegs(n)} />
282 {body(n.short, 'mns', true)}
283 </Box>
284 )
285}
286
287// The same notice in the pane's memory section: dim, the title and the fix command, nothing more.
288function MemoryOff({ E, n, cols }: { E: Els; n: BelayMemoryNotice; cols: number }) {
289 return (
290 <>
291 {wrapHard(n.title, Math.max(8, cols - 2)).map((l, i) => <Line E={E} w={cols} k={`mot${i}`} segs={[{ t: i === 0 ? '▲ ' : ' ', c: ATTN }, { t: l, c: ATTN, bold: true }]} />)}
292 {wrapHard(n.short, cols).map((l, i) => <Line E={E} w={cols} k={`mos${i}`} segs={[{ t: l, dim: true }]} />)}
293 </>
294 )
295}
296
297// ---- the pane ----------------------------------------------------------------------------------
298
299export function Pane(c: Ctx) {
300 const { E, snap, ui, now, act, cols } = c
301 const { Box, Button } = E
302 const t = snap.tasks
303 const stale = staleLine(snap)
304 if (snap.fatal) return <Line E={E} w={cols} k="fatal" segs={[{ t: 'belay ', c: 'claude', bold: true }, { t: snap.fatal, c: 'error' }]} />
305 const tasksLine: Seg[] = []
306 if (t) {
307 tasksLine.push({ t: 'tasks ', dim: true })
308 if (t.total !== null) {
309 const b = bar(t.total ? (t.done ?? 0) / t.total : 0, 10)
310 tasksLine.push({ t: b.on, c: 'success' }, { t: b.off, dim: true }, { t: ` ${t.done ?? 0}/${t.total}` })
311 } else tasksLine.push({ t: `${t.held} held by agents` })
312 }
313 if (snap.maxFixRounds !== null) tasksLine.push({ t: ' fix rounds ', dim: true }, { t: `max ${snap.maxFixRounds}` }) // the rounds used are not recorded, so no used count
314 if (snap.embedder) tasksLine.push({ t: ' embedder ', dim: true }, { t: snap.embedder, c: snap.embedder === 'ok' ? 'success' : 'warning' })
315 const f = snap.footer
316 const costLine: Seg[] = f
317 ? (f.totalCostUsd === null && !f.asOfMs
318 ? [{ t: `no cost recorded yet${f.notRecorded ? ` · ${f.notRecorded} agent${f.notRecorded === 1 ? '' : 's'} unrecorded` : ''}`, dim: true }]
319 : [{ t: money(f.totalCostUsd) }, { t: ` as of ${f.asOfMs ? new Date(f.asOfMs).toTimeString().slice(0, 5) : 'unknown'} · ${f.notRecorded} agent${f.notRecorded === 1 ? '' : 's'} unrecorded · moves when a turn ends`, dim: true }])
320 : [{ t: 'cost not read', dim: true }]
321 const inner = { ...c, cols: cols - INDENT }
322 const live = snap.agents.filter(isLive).length
323 return (
324 <Box flexDirection="column">
325 <Box justifyContent="space-between">
326 <Line E={E} w={cols - 6} k="ph" segs={runLine(snap, now)} />
327 <Box>
328 <Button key="legend" label="?" plain dimColor onPress={act.legend} />
329 <Button key="close" label=" ✕" plain dimColor onPress={act.close} />
330 </Box>
331 </Box>
332 <Line E={E} w={cols} k="pr" segs={[{ t: '─'.repeat(cols), dim: true }]} />
333 {stale && wrapHard(stale, cols).map((t, i) => <Line E={E} w={cols} k={`pstale${i}`} segs={[{ t, dim: true }]} />)}
334 {tasksLine.length > 0 && <Line E={E} w={cols} k="pt" segs={tasksLine} />}
335 <Line E={E} w={cols} k="pc" segs={costLine} />
336 {ui.legend && <Legend {...c} />}
337 <Section E={E} k="sa" title="AGENTS" cols={cols} summary={snap.agents.length ? [{ t: `${live} live`, dim: true }] : []}>
338 {snap.agents.length ? <Table {...inner} /> : <Line E={E} w={cols - INDENT} k="pn" segs={[{ t: 'no agents read', dim: true }]} />}
339 </Section>
340 {(snap.held || snap.memoryNotice) && <Held {...c} />}
341 <Notices {...c} />
342 <Orchestrator {...c} />
343 <Box flexDirection="column" marginTop={1}>
344 {snap.errors.map((e, i) => <Line E={E} w={cols} k={`pe${i}`} segs={[{ t: e, dim: true }]} />)}
345 {wrapHard(snap.held && snap.held.count > 0 ? 'read-only except the review and settle buttons · /orch toggles' : 'read-only · /orch toggles', cols).map((t, i) => <Line E={E} w={cols} k={`pf${i}`} segs={[{ t, dim: true }]} />)}
346 </Box>
347 </Box>
348 )
349}
350
351// ---- other surfaces: the same facts as plain lines ---------------------------------------------
352
353export function Fallback(c: Ctx) {
354 const { E, snap, ui, now, cols } = c
355 const { Box } = E
356 const lines: Seg[][] = [runLine(snap, now)]
357 for (const a of snap.agents) {
358 lines.push([markSeg(a.state, snap.settings.marks), { t: ` ${a.id} ${shortModel(a.model)} · ` }, ...ctxSegs(a, snap.settings), { t: ' · ' },
359 ...cacheSegs(a, snap.settings, snap.fetchedAt, now), { t: ` · ${money(a.costUsd)}` }, { t: a.task ? ` · ${a.task.id}` : '', dim: true }])
360 }
361 if (snap.memoryNotice) lines.push(memoryFallbackSegs(snap.memoryNotice))
362 if (snap.held) { // plain text: the review button is the terminal pane's alone
363 for (const r of snap.held.reviews) lines.push([{ t: `◇ ${reviewLine(r)}`, c: 'warning' }])
364 if (snap.held.count > 0) lines.push([{ t: `◇ ${heldLine(snap.held, now)}`, c: 'warning' }])
365 }
366 for (const n of snap.notices) lines.push([{ t: `${TONE[n.tone].glyph} ${n.text}`, c: TONE[n.tone].color, dim: ui.seen.includes(n.key) }])
367 if (snap.mode === 'headless') for (const e of (snap.transcript ?? []).slice(-5)) lines.push([{ t: e.text, dim: e.kind !== 'text' }])
368 const stale = staleLine(snap)
369 if (stale) lines.push([{ t: stale, dim: true }])
370 for (const e of snap.errors) lines.push([{ t: e, dim: true }])
371 return <Box flexDirection="column">{lines.map((s, i) => <Line E={E} w={cols} segs={s} k={`f${i}`} />)}</Box>
372}
373
374hooks/wake/core.mjs 115 lines1// The wake mod's logic, kept free of the mod API so that Node tests import it (ADR 0037 amendment 5).
2// Plain ES module with no globals beyond the language's own (a mod's environment has no Node, so no Buffer).
3
4export const PLUGIN_NAME = 'belay'
5// Claude Code's frame around a submitted wake adds 174 B plus the plugin name; the whole must stay under
6// 400 B (wake2 VERDICTS, wake_text_215_kept), so the text is at most 226 B minus the name's length.
7export const FRAME_BUDGET = 226
8
9// UTF-8 length of one code point.
10const cpBytes = (cp) => (cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4)
11
12export function byteLength(s) {
13 let n = 0
14 for (const ch of String(s)) n += cpBytes(ch.codePointAt(0))
15 return n
16}
17
18export const maxTextBytes = (name = PLUGIN_NAME) => FRAME_BUDGET - byteLength(name)
19
20// Cuts `text` to at most `max` UTF-8 bytes, on a character boundary.
21export function capText(text, max = maxTextBytes()) {
22 let n = 0
23 let out = ''
24 for (const ch of String(text)) {
25 n += cpBytes(ch.codePointAt(0))
26 if (n > max) break
27 out += ch
28 }
29 return out
30}
31
32// belay-run.json -> the config the mod acts on, or null. A watching session has no such file, and a file
33// whose role is not the orchestrator's activates nothing.
34export function activeConfig(jsonText) {
35 let c
36 try { c = JSON.parse(jsonText) } catch { return null }
37 if (!c || c.role !== 'orchestrator') return null
38 for (const k of ['run_id', 'data_dir', 'belay_cli', 'node', 'orchestrator_dir', 'wake_nonce']) {
39 if (typeof c[k] !== 'string' || !c[k]) return null
40 }
41 return c
42}
43
44export const streamArgv = (c) => [c.node, c.belay_cli, 'wake-stream', '--run', c.run_id, '--nonce', c.wake_nonce, '--data', c.data_dir]
45export const deliveredPath = (c) => `${String(c.orchestrator_dir).split('\\').join('/')}/wakes-delivered.jsonl`
46
47// One line from wake-stream -> { kind: 'wake', id, text } | { kind: 'hb' } | { kind: 'ignore' }.
48export function parseLine(line) {
49 let o
50 try { o = JSON.parse(line) } catch { return { kind: 'ignore' } }
51 if (!o || typeof o !== 'object') return { kind: 'ignore' }
52 if (typeof o.id === 'string' && o.id && typeof o.text === 'string' && o.text) return { kind: 'wake', id: o.id, text: o.text }
53 if (typeof o.hb === 'number') return { kind: 'hb' }
54 return { kind: 'ignore' }
55}
56
57// wakes-delivered.jsonl -> the Set of ids it names. A torn or foreign line is skipped.
58export function parseDelivered(fileText) {
59 const ids = new Set()
60 for (const l of String(fileText || '').split('\n')) {
61 if (!l) continue
62 try { const o = JSON.parse(l); if (o && typeof o.id === 'string') ids.add(o.id) } catch { /* skip */ }
63 }
64 return ids
65}
66
67export const errorsPath = (c) => `${String(c.orchestrator_dir).split('\\').join('/')}/wake-errors.jsonl`
68export const deliveredLine = (id, at) => `${JSON.stringify({ id, at })}\n`
69
70// Splits a stream of chunks into whole lines; the unfinished tail waits for the next chunk.
71export function lineSplitter() {
72 let buf = ''
73 return (chunk) => {
74 buf += chunk
75 const out = []
76 let i
77 while ((i = buf.indexOf('\n')) >= 0) { out.push(buf.slice(0, i)); buf = buf.slice(i + 1) }
78 return out
79 }
80}
81
82// The decision for one parsed line: 'submit' (record, then submit), 'skip' (id already delivered), 'none'.
83export const decide = (parsed, delivered) => (parsed.kind !== 'wake' ? 'none' : delivered.has(parsed.id) ? 'skip' : 'submit')
84
85// Delivery, at most once (ADR 0037 amendment 5): the id is recorded (memory and file) ONLY after the file write
86// has succeeded, and the submit happens only after that, so a failed write submits nothing (the wake is retried
87// on a replay) and a crash between the record and the submit loses that wake but never duplicates one.
88// `io` = { write(file, content) -> Promise, submit(text) -> Promise, log(entry) }; `initial` is the file's text.
89export function createDelivery({ file, initial = '', io, now = () => Date.now(), cap = capText }) {
90 let content = String(initial || '')
91 const delivered = parseDelivered(content)
92 const inflight = new Set()
93 return {
94 delivered,
95 async deliver(id, text) {
96 if (delivered.has(id) || inflight.has(id)) return 'skip'
97 inflight.add(id)
98 try {
99 const next = content + deliveredLine(id, now())
100 try { await io.write(file, next) } catch (e) {
101 try { io.log({ at: now(), id, stage: 'write', error: String((e && e.message) || e) }) } catch { /* nowhere to log */ }
102 return 'write-failed'
103 }
104 content = next
105 delivered.add(id)
106 try { await io.submit(cap(text)) } catch (e) {
107 try { io.log({ at: now(), id, stage: 'submit', error: String((e && e.message) || e) }) } catch { /* nowhere to log */ }
108 return 'submit-failed'
109 }
110 return 'submitted'
111 } finally { inflight.delete(id) }
112 },
113 }
114}
115hooks/panel/wrap.ts 126 lines1// Display width and wrapping for the pane's long parts. Pure code, no Node and no Intl: the mod host has neither
2// guaranteed. Width is counted in terminal cells per grapheme cluster (a wide character or an emoji sequence is
3// two), not in codepoints.
4//
5// Wrapping breaks only at a space or at a part separator, so a word is never split; a word wider than the room
6// stays whole on its own line. What happens to such a line is the caller's: ids that an operator types are put
7// on a flush line with more room (see Detail, Notices and cmdLines in view.tsx); a long descriptive field that
8// still does not fit is cut at its end with an ellipsis by clipCells, and that is the only place text is cut.
9
10const inR = (c: number, r: [number, number][]): boolean => r.some(([a, b]) => c >= a && c <= b)
11const ZERO: [number, number][] = [[0x300, 0x36f], [0x200b, 0x200f], [0x2060, 0x2064], [0xfe00, 0xfe0f], [0x1f3fb, 0x1f3ff], [0xe0020, 0xe007f], [0x20d0, 0x20ff]]
12const WIDE: [number, number][] = [
13 [0x1100, 0x115f], [0x2329, 0x232a], [0x231a, 0x231b], [0x23e9, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
14 [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
15 [0x26f2, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e],
16 [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
17 [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe6f],
18 [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f64f],
19 [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb], [0x1f90c, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd],
20]
21const RI = (c: number): boolean => c >= 0x1f1e6 && c <= 0x1f1ff
22
23// Grapheme clusters, close enough for terminal widths: marks, joiners, variation selectors and skin tones join
24// the previous cluster, a character after a joiner joins it, and two regional indicators make one flag.
25export function clusters(s: string): string[] {
26 const out: string[] = []
27 let prevJoin = false
28 for (const ch of s) {
29 const c = ch.codePointAt(0)!
30 const last = out.length - 1
31 const riPair = RI(c) && last >= 0 && [...out[last]!].length === 1 && RI(out[last]!.codePointAt(0)!)
32 if (last >= 0 && (inR(c, ZERO) || prevJoin || riPair)) out[last] += ch
33 else out.push(ch)
34 prevJoin = c === 0x200d
35 }
36 return out
37}
38
39function clusterCells(g: string): number {
40 const cps = [...g].map(x => x.codePointAt(0)!)
41 if (cps.every(c => inR(c, ZERO))) return 0
42 if (cps.some(c => inR(c, WIDE) || RI(c)) || cps.includes(0xfe0f)) return 2
43 return 1
44}
45
46export const cells = (s: string): number => clusters(s).reduce((n, g) => n + clusterCells(g), 0)
47
48// Cut to n cells, ending in an ellipsis when something was cut. Never splits a cluster.
49export function clipCells(s: string, n: number): string {
50 if (cells(s) <= n) return s
51 let out = ''
52 let used = 0
53 for (const g of clusters(s)) {
54 const w = clusterCells(g)
55 if (used + w > n - 1) break
56 out += g
57 used += w
58 }
59 return n > 0 ? out + '…' : ''
60}
61
62// Break prose at spaces. A lone '·' token at a break is dropped rather than left to start or end a line.
63export function wordWrap(text: string, room: number): string[] {
64 const out: string[] = []
65 let cur = ''
66 for (const w of text.split(/\s+/).filter(Boolean)) {
67 if (cur === '') { cur = w; continue }
68 if (cells(cur) + 1 + cells(w) <= room) { cur += ` ${w}`; continue }
69 out.push(cur)
70 cur = w === '·' ? '' : w
71 }
72 if (cur !== '') out.push(cur)
73 return out.length ? out : ['']
74}
75
76// Like wordWrap, but a word wider than the room (a long path or id) is broken on whole grapheme clusters instead of left to be cut.
77export function wrapHard(text: string, room: number): string[] {
78 const r = Math.max(1, room)
79 const out: string[] = []
80 for (const l of wordWrap(text, r)) {
81 if (cells(l) <= r) { out.push(l); continue }
82 let cur = ''
83 let w = 0
84 for (const g of clusters(l)) {
85 const gw = clusterCells(g)
86 if (w + gw > r && cur !== '') { out.push(cur); cur = ''; w = 0 }
87 cur += g
88 w += gw
89 }
90 if (cur !== '') out.push(cur)
91 }
92 return out.length ? out : ['']
93}
94
95// Pack parts onto lines, joined by `sep`. A part that fits a line is never split; one that does not is
96// word-wrapped on its own lines. A part goes to the next line whole when it would overflow this one.
97export function wrapParts(parts: string[], sep: string, room: number): string[] {
98 const out: string[] = []
99 let cur = ''
100 for (const p of parts.filter(x => x !== '')) {
101 if (cur !== '' && cells(cur) + cells(sep) + cells(p) <= room) { cur += sep + p; continue }
102 if (cur !== '') { out.push(cur); cur = '' }
103 if (cells(p) <= room) cur = p
104 else { const w = wordWrap(p, room); out.push(...w.slice(0, -1)); cur = w[w.length - 1]! }
105 }
106 if (cur !== '') out.push(cur)
107 return out.length ? out : ['']
108}
109
110// The lines of a notice's command row: "→ cmd · cmd". Commands that fit are packed whole. If any command does
111// not fit its own line, splitting it would leave "belay approve" and an id on separate lines that read as two
112// commands, so instead the id they share is shown once, whole, on a line labelled `id`, and each command is
113// written with `<id>` in its place: the operator copies the id and types the command. Commands that share no
114// id are one per line and cut at their end if still too wide.
115export function cmdLines(cmd: string, room: number): string[] {
116 const cmds = cmd.split(/\s+·\s+/).filter(Boolean)
117 const inner = Math.max(8, room - 2)
118 const mark = (ls: string[]): string[] => ls.map((t, i) => (i === 0 ? `→ ${t}` : ` ${t}`))
119 if (cmds.every(c => cells(c) <= inner)) return mark(wrapParts(cmds, ' · ', inner))
120 const last = cmds.map(c => c.split(' ').pop()!)
121 if (cmds.every(c => c.includes(' ')) && last.every(l => l === last[0])) {
122 return mark([`id ${last[0]}`, ...cmds.map(c => `${c.slice(0, c.length - last[0]!.length)}<id>`)])
123 }
124 return mark(cmds.map(c => clipCells(c, inner)))
125}
126hooks/panel/base.tsx 101 lines1// Drawing primitives shared by the panel's views: the element and handler types, a line of segments, and the cell helpers.
2
3import type { Elements } from 'claude-code'
4import type { BelayPanelSettle, BelayPanelSettled, BelayPanelSnap, BelayPanelUi } from '../../types/index'
5import { fitSegs, type Seg } from './model'
6import { cells, clipCells } from './wrap'
7import type { SettleAction } from './held'
8
9export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>
10export type Act = {
11 select: (id: string) => void
12 legend: () => void
13 fold: () => void
14 group: (key: string) => void
15 seen: (key: string) => void
16 close: () => void
17 toggle: () => void
18 /** The memory line's one write action: the first press asks, the second dispatches (index.tsx). */
19 review: () => void
20 /** A held note that waits on the operator: ask for a reason, confirm, then settle it (index.tsx). */
21 /** A settle button of the open note: starts the reason stage. UI state only; it runs no process. */
22 settle: (action: SettleAction, id: string) => void
23 /** The reason Input's Enter: checks the reason and moves to the confirm stage. Runs no process. */
24 reason: (value: string) => void
25 /** Cancel the pending settle. */
26 cancel: () => void
27 /** The operator has checked an unknown outcome (belay memory held): the row may be acted on again. UI state only. */
28 recheck: (id: string) => void
29 /** The confirm button of the pending settle: the only way to the write (index.tsx). */
30 commit: (id: string) => void
31 /** Open an awaiting note's line (its reason and buttons), or close it when it is the open one. UI state only: runs no process. */
32 note: (id: string) => void
33}
34export type Ctx = { E: Els; snap: BelayPanelSnap; ui: BelayPanelUi; now: number; cols: number; act: Act; pend: BelayPanelSettle | null; focused: boolean; settled: BelayPanelSettled }
35
36export const tp = (s: Seg): Record<string, unknown> => ({
37 ...(s.c ? { color: s.c } : {}), ...(s.dim ? { dimColor: true } : {}), ...(s.bold ? { bold: true } : {}), wrap: 'truncate-end',
38})
39
40// A line of segments. With a width, whole parts drop from the end and nothing inside is shrunk, so the
41// sum of the kept texts never exceeds the room and the flex row has nothing to squeeze.
42export function Line({ E, segs, k, w }: { E: Els; segs: Seg[]; k: string; w?: number }) {
43 const { Box, Text } = E
44 const out = w === undefined ? segs : fitSegs(segs, Math.max(1, w))
45 return <Box key={k} flexShrink={0}>{out.map(s => <Text {...tp(s)}>{s.t}</Text>)}</Box>
46}
47
48// Cut a string to n cells (display width, not codepoints) with an ellipsis.
49export const clip = clipCells
50
51export const pad = (s: string, n: number): string => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
52
53// An agent name that does not fit is cut with an ellipsis, so the cut shows.
54export const padName = (s: string, n: number): string => (s.length > n ? s.slice(0, Math.ceil((n - 1) / 2)) + '…' + s.slice(s.length - Math.floor((n - 1) / 2)) : pad(s, n)) // a cut keeps the end, which tells archreview-… workers apart
55
56// 'claude-sonnet-5-5' -> 'sonnet'; anything else (codex) is shown as belay gave it.
57export function shortModel(m: string | null): string {
58 if (!m) return '—'
59 const f = /(?:claude-)?(opus|sonnet|haiku|fable)/i.exec(m)
60 return f ? f[1]!.toLowerCase() : m
61}
62
63// ---- sections ----------------------------------------------------------------------------------
64
65// Every block of the pane is a Section: one bold heading in caps, an optional dim summary beside it, one blank line above,
66// and its rows indented by one space. A caller draws the rows at `cols - 1`.
67export const INDENT = 1
68// Pack segments onto lines of at most `room` cells, never splitting one. A bare ' · ' joiner that would start a line is dropped.
69export function packSegs(segs: Seg[], room: number): Seg[][] {
70 const out: Seg[][] = [[]]
71 let w = 0
72 for (const s of segs) {
73 const sw = cells(s.t)
74 if (w > 0 && w + sw > room) {
75 const last = out[out.length - 1]!
76 if (last.length > 1 && last[last.length - 1]!.t.trim() === '·') last.pop() // a joiner left at the end of a line goes
77 out.push([])
78 w = 0
79 }
80 if (w === 0 && s.t.trim() === '·') continue
81 out[out.length - 1]!.push(s)
82 w += sw
83 }
84 return out.filter(l => l.length)
85}
86
87export function Section({ E, k, title, summary, cols, children }: { E: Els; k: string; title: string; summary?: Seg[]; cols: number; children?: unknown }) {
88 const { Box } = E
89 // The summary sits beside the heading when the whole of it fits; otherwise it wraps on its own lines under the heading. It is never dropped.
90 const sw = (summary ?? []).reduce((n, s) => n + cells(s.t), 0)
91 const beside = !summary || !summary.length || cells(title) + 2 + sw <= cols
92 const head: Seg[] = [{ t: title, bold: true }, ...(summary && summary.length && beside ? [{ t: ' ' } as Seg, ...summary] : [])]
93 const below = !beside && summary ? packSegs(summary, Math.max(1, cols - INDENT)) : []
94 return (
95 <Box key={k} flexDirection="column" marginTop={1}>
96 <Line E={E} w={cols} k={`${k}-h`} segs={head} />
97 {below.length || children ? <Box paddingLeft={INDENT} flexDirection="column">{below.map((l, i) => <Line E={E} w={cols - INDENT} k={`${k}-s${i}`} segs={l} />)}{children as never}</Box> : null}
98 </Box>
99 )
100}
101hooks/panel/table.tsx 205 lines1// The agent table: its columns, the task label, an opened agent's detail and the rows. Drawing only.
2
3import type { BelayAgent, BelayPanelSnap } from '../../types/index'
4import { cacheSegs, ctxSegs, type CtxMode, dur, fitSegs, interactiveMark, interactiveOrch, isLive, isReviewWorker, markSeg, markWidth, money, STATE_NAME, type Seg } from './model'
5import { cells, clipCells, wrapParts } from './wrap'
6import { clip, Line, pad, padName, shortModel, tp, type Act, type Ctx, type Els } from './base'
7
8type Cols = { mark: number; agent: number; id: number; model: boolean; task: number; ctx: number; ctxMode: CtxMode; cache: number; cost: number }
9
10// Every cell is followed by one column of space. When the pane is narrow whole columns drop, the
11// least needed first: task, cache, context, the model name, then the id is cut. Nothing is squeezed.
12function columns({ snap, cols }: Ctx): Cols {
13 const s = snap.settings
14 const longest = snap.agents.reduce((m, a) => Math.max(m, a.id.length), 0)
15 const c: Cols = {
16 mark: markWidth(s.marks), agent: 0, id: Math.max(6, Math.min(16, longest)), model: true, task: 0,
17 ctx: 6, ctxMode: 'tokens', cache: s.cache === 'both' ? 11 : 5, cost: 7,
18 }
19 const kind = s.task === 'kind'
20 // `kind` is the cheapest column to keep: its label (`work`, `rev`, `research`) is reserved before the cache and the
21 // context are dropped, and grows to take the review verdict and the ▲ id when there is room. Only the label is kept last.
22 const labelW = kind ? Math.max(4, ...snap.agents.map(a => cells(taskSegs(a, snap, 99)[0]?.t ?? ''))) : 0
23 const fullW = kind ? Math.max(labelW, ...snap.agents.map(a => cells(taskSegs(a, snap, 99).map(x => x.t).join('')))) : 0
24 if (kind) c.task = fullW
25 const total = (): number => (c.mark + 1) + (c.id + (c.model ? 8 : 0) + 1) + (c.ctx ? c.ctx + 1 : 0) + (c.cache ? c.cache + 1 : 0) + (c.task ? c.task + 1 : 0) + (c.cost + 1)
26 const cacheW = c.cache
27 if (total() > cols) c.cache = 0
28 if (kind && total() > cols) { // the verdict and the ▲ drop before the context does, and give the cache back if it then fits
29 c.task = labelW
30 c.cache = cacheW
31 if (total() > cols) c.cache = 0
32 }
33 if (total() > cols) c.ctx = 0
34 if (total() > cols) c.model = false
35 if (total() > cols) c.task = 0
36 if (total() > cols) c.id = Math.max(3, c.id - (total() - cols))
37 // The column starts as the tokens alone and takes the percent (and the bar) only from room the task column does not want, so the percent is what goes first.
38 // What the task column still wants to show whole: the kind label with its verdict and mark, or the title (up to 24 cells). The tokens give way to that, and the
39 // percent gives way first.
40 const titleW = !kind && s.task !== 'off' ? Math.min(24, Math.max(8, ...snap.agents.map(a => cells(taskSegs(a, snap, 99).map(x => x.t).join(''))))) : 0
41 const want = kind ? Math.max(0, fullW - c.task) : titleW
42 if (c.ctx > 0 && cols - total() - 1 < want) c.ctx = 0
43 if (c.ctx > 0) {
44 const wide = s.context === 'both' ? 20 : 13
45 for (const [mode, w] of [['full', wide], ['mid', 13]] as const) {
46 if (w > c.ctx && cols - total() - 1 - want >= w - c.ctx) { c.ctx = w; c.ctxMode = mode; break }
47 }
48 }
49 c.agent = c.id + (c.model ? 8 : 0)
50 const t = cols - total() - 1
51 if (kind) { if (c.task) c.task = Math.min(fullW, c.task + Math.max(0, t + 1)) }
52 else c.task = s.task === 'off' || t < 8 ? 0 : t
53 return c
54}
55
56// The kind of work an agent is on, from its role: a reviewer is `rev`, a researcher `research`, a worker with no
57// role `work`. Any other role names itself.
58function kindOf(a: BelayAgent): string {
59 const r = (a.role ?? '').toLowerCase()
60 if (!r || r === 'coder' || r === 'worker') return 'work'
61 if (r.startsWith('review')) return 'rev'
62 if (r.startsWith('research')) return 'research'
63 return r
64}
65
66// One readable label, never two cut fragments: the id and title when both fit, else the title alone (the id is in the
67// detail), else the id. The verdict and the permission mark are parts that drop whole from the end.
68// How a dead review worker ended: cut (its process was ended with a non-zero exit, as when closing the run does), or no answer (it died without one).
69const reviewOutcome = (a: BelayAgent, snap: BelayPanelSnap): string => {
70 const n = snap.notices.find(x => x.kind === 'worker_dead' && x.agentId === a.id)
71 return n && /^exit [1-9]/.test(n.ids) ? 'cut' : 'no answer'
72}
73
74function taskSegs(a: BelayAgent, snap: BelayPanelSnap, room: number): Seg[] {
75 if (interactiveOrch(a, snap)) return [{ t: clip('interactive', room), dim: true }]
76 if (a.state === 'dead' && isReviewWorker(a)) return [{ t: clip(reviewOutcome(a, snap), room), dim: true }]
77 if (a.role === 'orchestrator') return [{ t: clip(snap.settings.task === 'kind' ? 'orch' : 'orchestrator', room), dim: true }]
78 const perm = snap.notices.find(n => n.tone === 'permission' && n.agentId === a.id)
79 const v = a.task && snap.notices.find(n => n.taskId === a.task!.id && (n.tone === 'holds' || n.tone === 'refuted'))
80 const tail: Seg[] = []
81 if (v) tail.push({ t: ' → ', brk: true }, { t: v.tone === 'holds' ? 'holds' : 'refuted', c: v.tone === 'holds' ? 'merged' : 'error' })
82 if (perm) tail.push({ t: ` ▲ ${perm.ids}`, c: 'permission', brk: true })
83 if (snap.settings.task === 'kind') {
84 // The kind label is kept last: the permission mark drops first, then the verdict. A request id is about 30 cells, so
85 // the mark stands alone here; the id and the commands are in the permission notice.
86 const kt: Seg[] = tail.map(x => (x.c === 'permission' ? { ...x, t: ' ▲' } : x))
87 return fitSegs([a.task ? { t: clip(kindOf(a), room) } : { t: '—', dim: true }, ...kt], room)
88 }
89 const kept = fitSegs([{ t: '' }, ...tail], Math.max(1, room - 6)).slice(1)
90 const label = Math.max(1, room - cells(kept.map(x => x.t).join('')))
91 if (!a.task) return fitSegs([{ t: 'no task', dim: true }, ...kept], room)
92 const id = a.task.id.slice(0, 8)
93 const title = (a.task.title ?? '').trim()
94 const both = title ? `${id} ${title}` : id
95 if (cells(both) <= label) return [...(title ? [{ t: id, dim: true }, { t: ` ${title}` }] : [{ t: id, dim: true }]), ...kept]
96 return [title ? { t: clip(title, label) } : { t: clip(id, label), dim: true }, ...kept]
97}
98
99// An estimate made from the transcript at API rates is marked with ≈; any other basis, or none, is drawn as a plain figure.
100const ESTIMATE_BASIS = 'transcript, API rates'
101
102// An estimate shows as ≈$12.34; from $100 up the cents go (≈$123), so it still fits the cost column.
103const costText = (a: BelayAgent): string => (a.costUsd !== null && a.costBasis === ESTIMATE_BASIS ? (Number(a.costUsd.toFixed(2)) >= 100 ? `≈$${Math.round(a.costUsd)}` : `≈${money(a.costUsd)}`) : money(a.costUsd))
104
105const LABEL = 8
106
107// A detail row is a label and parts. The parts join with `sep` and wrap under the label instead of being cut.
108type DRow = { k: string; parts: string[]; sep: string; note?: Seg }
109
110function Detail({ E, a, snap, now, cols, last }: { E: Els; a: BelayAgent; snap: BelayPanelSnap; now: number; cols: number; last: boolean }) {
111 const { Box } = E
112 const rows: DRow[] = []
113 rows.push({ k: 'status', sep: ' · ', parts: [interactiveOrch(a, snap) ? 'interactive — its words are in its own window' : `${STATE_NAME[a.state]} — established by ${a.stateBy ?? 'not recorded'}`, a.pid !== null ? `pid ${a.pid}` : ''] })
114 const launch = [a.model, a.effort ? `effort ${a.effort}` : null, a.launchMode ? `mode ${a.launchMode}` : null, a.version ? `Claude Code ${a.version}` : null]
115 rows.push({ k: 'launch', sep: ' · ', parts: launch.filter(Boolean).length ? launch.filter(Boolean) as string[] : ['not recorded'] })
116 if (a.task) {
117 const src = a.task.title && a.task.titleSource ? (/spec/.test(a.task.titleSource) ? ' (from spec)' : ` (${a.task.titleSource})`) : ''
118 rows.push({ k: 'task', sep: ' ', parts: [`${a.task.id} ${a.task.title ?? ''}`.trim()], ...(src ? { note: { t: src, dim: true } } : {}) })
119 }
120 if (a.ctxTokens !== null) rows.push({ k: 'context', sep: ' ', parts: [`${a.ctxTokens.toLocaleString('en-US')} tokens${a.ctxWindow ? ` of ${a.ctxWindow.toLocaleString('en-US')}` : ' (window unknown)'}`] })
121 if (a.cache) {
122 const age = a.cache.ageMs !== null ? a.cache.ageMs + Math.max(0, now - snap.fetchedAt) : null
123 rows.push({ k: 'cache', sep: ' · ', parts: [`TTL ${a.cache.ttlS !== null ? dur(a.cache.ttlS * 1000) : 'unknown'}${a.cache.ttlBy ? `, read from ${a.cache.ttlBy}` : ''}`, age !== null ? `age ${dur(age, true)}` : ''] })
124 }
125 rows.push({ k: 'cost', sep: ' ', parts: [a.costUsd === null ? 'not yet recorded' : `${money(a.costUsd)}${a.costSource ? ` (${a.costSource})` : ''}`] })
126 if (a.state === 'dead' && isReviewWorker(a)) {
127 rows.unshift({ k: 'outcome', sep: ' · ', parts: [reviewOutcome(a, snap), a.stateBy ?? 'not recorded'] })
128 rows.push({ k: 'clear', sep: ' ', parts: [`belay worker rm ${a.id}`] })
129 }
130 rows.push({ k: 'logs', sep: ' ', parts: [`belay logs ${a.id}`] })
131 const room = Math.max(8, cols - 2 - LABEL)
132 const flush = cols - 2 // an id wider than the value room goes on its own line with the label's indent removed
133 return (
134 <Box flexDirection="column" paddingLeft={2} marginBottom={last ? 0 : 1}>
135 {rows.flatMap(r => {
136 const note = r.note ? cells(r.note.t) : 0
137 const lines = wrapParts(r.parts, r.sep, room).filter(t => t !== '')
138 // The note is counted: it shares the last line when it fits there, else it takes a line of its own.
139 const noteOwn = !!r.note && (lines.length === 0 || cells(lines[lines.length - 1]!) + note > (cells(lines[lines.length - 1]!) > room ? flush : room))
140 const out: { t: string; flush: boolean; note?: Seg }[] = lines.map((t, i) => ({ t, flush: cells(t) > room && cells(t) <= flush, ...(i === lines.length - 1 && r.note && !noteOwn ? { note: r.note } : {}) }))
141 if (!out.length) out.push({ t: '', flush: false })
142 if (out[0]!.flush) out.unshift({ t: '', flush: false }) // the label alone, its flush id on the next line
143 if (noteOwn) out.push({ t: '', flush: false, note: { ...r.note!, t: r.note!.t.trimStart() } })
144 return out.map((l, i) => {
145 const first = i === 0
146 const segs: Seg[] = l.flush ? [{ t: l.t }] : [{ t: pad(first ? r.k : '', LABEL), dim: true }, { t: l.t }]
147 if (l.note) segs.push(l.note)
148 return <Line E={E} k={`d-${a.id}-${r.k}-${i}`} segs={segs} w={cols - 2} />
149 })
150 })}
151 </Box>
152 )
153}
154
155function AgentRow(c: Ctx & { a: BelayAgent; c: Cols; last: boolean }) {
156 const { E, a, snap, ui, now, act } = c
157 const { Box, Text, Button } = E
158 const w = c.c
159 const cell = (key: string, width: number, segs: Seg[], end = false) => (
160 <Box key={key} width={width} flexShrink={0} marginRight={1} {...(end ? { justifyContent: 'flex-end' } : {})}>{segs.map(s => <Text {...tp(s)}>{s.t}</Text>)}</Box>
161 )
162 const io = interactiveOrch(a, snap)
163 const mk = io ? interactiveMark(snap.settings.marks) : markSeg(a.state, snap.settings.marks)
164 return (
165 <Box key={`row-${a.id}`} flexDirection="column">
166 <Box>
167 {cell('m', w.mark, [mk])}
168 <Box key="a" width={w.agent} flexShrink={0} marginRight={1}>
169 <Button key={`agent:${a.id}`} label={padName(a.id, w.id)} plain onPress={() => act.select(a.id)} />
170 {w.model ? <Text dimColor> {pad(shortModel(a.model), 7)}</Text> : null}
171 </Box>
172 {w.task > 0 && cell('t', w.task, taskSegs(a, snap, w.task))}
173 {w.ctx > 0 && cell('x', w.ctx, ctxSegs(a, snap.settings, w.ctxMode))}
174 {w.cache > 0 && cell('k', w.cache, cacheSegs(a, snap.settings, snap.fetchedAt, now))}
175 {cell('$', w.cost, [{ t: a.costUsd === null && io ? 'n/a' : costText(a), dim: a.costUsd === null }], true)}
176 </Box>
177 {ui.selected === a.id && <Detail E={E} a={a} snap={snap} now={now} cols={c.cols} last={c.last} />}
178 </Box>
179 )
180}
181
182export function Table(c: Ctx) {
183 const { E, snap, ui, act, cols } = c
184 const { Box, Button } = E
185 const w = columns(c)
186 const live = snap.agents.filter(isLive)
187 const fin = snap.agents.filter(a => !isLive(a))
188 // The last row on show has no blank line under its opened detail: the next section brings its own.
189 const lastRow = fin.length > 0 && !ui.foldOpen ? null : [...live, ...(ui.foldOpen ? fin : [])].at(-1)
190 return (
191 <Box flexDirection="column">
192 <Box key="th">
193 <Box width={w.mark} marginRight={1}>{null}</Box>
194 <Line E={E} w={cols - w.mark - 1} k="th1" segs={[{ t: pad('agent', w.agent + 1), dim: true }, ...(w.task > 0 ? [{ t: pad('task', w.task + 1), dim: true }] : []),
195 ...(w.ctx > 0 ? [{ t: pad('ctx', w.ctx + 1), dim: true }] : []), ...(w.cache > 0 ? [{ t: pad('cache', w.cache + 1), dim: true }] : []), { t: ' '.repeat(Math.max(0, w.cost - 4)) + 'cost', dim: true }]} />
196 </Box>
197 {live.map(a => <AgentRow {...c} a={a} c={w} last={a === lastRow} />)}
198 {fin.length > 0 && (
199 <Button key="fold" plain dimColor label={`${ui.foldOpen ? '▾' : '▸'} ${fin.length} finished`} onPress={act.fold} />
200 )}
201 {ui.foldOpen && fin.map(a => <AgentRow {...c} a={a} c={w} last={a === lastRow} />)}
202 </Box>
203 )
204}
205types/index.d.ts 175 lines1// belay's mod: the contract of the values it keeps in `$.state`, one block per feature under the
2// plugin's name. Self-contained (the engine reads this file alone). The panel's block is `panel*`;
3// the wake adds its own block beside it.
4
5export type BelayDisplayState = 'running' | 'waiting' | 'idle' | 'stalled' | 'permission' | 'parked' | 'dead' | 'done' | 'starting' | 'stopping' | 'unknown'
6
7export type BelayCache = {
8 ttlS: number | null
9 ttlBy: string | null
10 lastRequestAtMs: number | null
11 ageMs: number | null
12 leftMs: number | null
13 state: 'warm' | 'low' | 'cold' | 'unknown'
14}
15
16export type BelayAgent = {
17 id: string
18 role: string | null
19 substrate: string | null
20 model: string | null
21 effort: string | null
22 state: BelayDisplayState
23 stateRaw: string | null
24 stateBy: string | null
25 ctxTokens: number | null
26 ctxWindow: number | null
27 ctxPct: number | null
28 costUsd: number | null
29 costSource: string | null
30 /** belay's word for how an estimated cost was made ("transcript, API rates"); null on every row today. */
31 costBasis: string | null
32 cache: BelayCache | null
33 task: { id: string; title: string | null; titleSource: string | null; source: string | null } | null
34 launchMode: string | null
35 version: string | null
36 pid: number | null
37}
38
39export type BelayNotice = {
40 key: string
41 kind: string
42 tone: 'permission' | 'dead' | 'stalled' | 'holds' | 'refuted' | 'paused' | 'ended' | 'stopped' | 'memory'
43 ts: number | null
44 agentId: string | null
45 taskId: string | null
46 text: string
47 ids: string
48 cmd: string | null
49}
50
51export type BelayEntry = { kind: string; text: string; ts: number | null }
52
53export type BelaySettings = {
54 pane: 'docked' | 'above-prompt'
55 marks: 'shapes' | 'words' | 'colour'
56 cache: 'countdown' | 'bar' | 'both'
57 context: 'figure' | 'bar' | 'both'
58 task: 'kind' | 'title' | 'off'
59 band: 'one-line' | 'two-lines' | 'minimal'
60}
61
62export type BelayPanelFooter = {
63 agents: number
64 totalCostUsd: number | null
65 notRecorded: number
66 asOfMs: number | null
67 highestCtxPct: number | null
68}
69
70/** The newest archive review of a project, from belay's `review_running` / `review_ended` notices (the review's store-level claim). */
71export type BelayReview = { project: string; state: 'running' | 'interrupted' | 'ok' | 'failed'; pid: number | null; text: string | null; ts: number | null; job?: string | null }
72
73/** A held note that waits on the operator (an archive review left it undecided), from `belay memory held --awaiting`. */
74export type BelayAwaitingNote = { id: string; project: string; title: string; reason: string }
75
76/** Notes held for the on-demand Opus archive reviewer, from belay's `notes_held` notice, and the state of the project's review. */
77export type BelayHeld = { count: number; unsupported: number; byProject: Record<string, number>; oldestAgeRuns: number; oldestAt?: number; reviews: BelayReview[]; byGround?: Record<string, number>; awaiting?: number; awaitingNotes?: BelayAwaitingNote[]; awaitingList?: { total: number; at: number; carried: boolean }; breakers?: { runId: string; disputed: number }[] }
78
79/** `belay settings`' memory_notice: notes are on by default but no memory.project is set, so memory is off. null once a project is set. */
80export type BelayMemoryNotice = { title: string; why: string; fix: string; configLine: string; short: string }
81
82export type BelayPanelSnap = {
83 fetchedAt: number
84 runId: string | null
85 mode: string | null
86 /** From the orchestrator's row in the current successful agents read only; null when unknown or stale. */
87 runState: 'running' | 'paused' | 'ended' | 'dead' | null
88 /** The agents read of this round failed: the data above is from an earlier read. */
89 stale: boolean
90 /** A configuration error that stops every read (belay-run.json, no CLI). */
91 fatal: string | null
92 /** The last transcript read succeeded, and when (the snapshot's fetch time). */
93 transcriptOk: boolean
94 transcriptAt: number | null
95 startedAtMs: number | null
96 pausedAtMs: number | null
97 /** The first read at which the orchestrator was dead: the elapsed time stops there. */
98 frozenAtMs: number | null
99 endedAtMs: number | null
100 tasks: { done: number | null; total: number | null; held: number } | null
101 /** The run row records the limit, not the rounds used. */
102 maxFixRounds: number | null
103 embedder: string | null
104 agents: BelayAgent[]
105 footer: BelayPanelFooter | null
106 notices: BelayNotice[]
107 /** null: belay reported no `notes_held` notice (nothing held, or not read yet). */
108 held: BelayHeld | null
109 transcript: BelayEntry[] | null
110 settings: BelaySettings
111 memoryNotice: BelayMemoryNotice | null
112 /** One line per failing command: the command and its exit code. */
113 errors: string[]
114 /** The reads of the latest round that timed out or failed (their data is from an earlier round). Empty when every read succeeded. */
115 readIssues: BelayReadIssue[]
116 /** When the last round with no read issue ended; null if none has yet. */
117 goodAt: number | null
118}
119
120export type BelayReadIssue = { verb: string; timedOut: boolean; /** The failure in words, clipped; empty for a timeout. */ text: string }
121
122export type BelayPanelWatch = {
123 runId: string | null
124 dataDir: string | null
125 cli: string | null
126 node: string | null
127 /** The orchestrator's directory, from belay-run.json's orchestrator_dir: passed to `belay agents --dir` so it finds the launch marker. Absent when the file does not name it. */
128 orchDir?: string | null
129 /** belay-run.json exists but is unusable. Nothing is read and nothing falls back. */
130 error: string | null
131 /** Bound by belay-run.json: the CLI is exactly [node, cli]; a lack of either is an error, not a fallback. */
132 fixed: boolean
133}
134
135export type BelayPanelUi = {
136 open: boolean
137 selected: string | null
138 legend: boolean
139 foldOpen: boolean
140 /** Keys of the orchestrator's tool-call groups the person expanded. */
141 groups: string[]
142 /** A docked Pane is actually seated; the inline panel is drawn while it is not. */
143 placed: boolean
144 seen: string[]
145 /** The archive review button was pressed once: the project it would review and when. A second press inside CONFIRM_MS dispatches. */
146 confirm: { project: string; at: number } | null
147 /** The id of the awaiting note whose reason and buttons are shown; one at a time. Pure UI state: pressing runs nothing. */
148 openNote?: string | null
149}
150
151/** A settle in progress, drawn inside the pane (the session has no AskUserQuestion). `reason`: the Input is asked; `confirm`: the exact reason is shown. `run` is the watch it started under. */
152export type BelayPanelSettle = {
153 id: string
154 action: 'archive' | 'release' | 'reoffer'
155 stage: 'reason' | 'confirm'
156 reason: string
157 title: string
158 run: { runId: string | null; dataDir: string | null; cli: string | null; node: string | null }
159}
160
161/** Notes settled in this panel, by id: what the row says, and when. The row's buttons stay hidden, and a new settle on the id is refused, until a read taken after `at` has caught up. */
162export type BelayPanelSettled = Record<string, { label: string; at: number; unknown?: true; pending?: true }>
163
164declare module 'claude-code' {
165 interface PluginState {
166 belay: {
167 panelWatch: BelayPanelWatch | null
168 panelSnap: BelayPanelSnap | null
169 panelUi: BelayPanelUi
170 panelSettle: BelayPanelSettle | null
171 panelSettled: BelayPanelSettled
172 }
173 }
174}
175