SLOPSHOPPER

belay

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

newpanebandcommandtoastprocess
★ 2v0.3.0MITupdated 2026-10-24aschwa1012/belay/src/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · belay
│ ┃ belay ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No belay run is watched. /belay watch │ belay │ │ ┃ [run-id] ⏺ Read(src/auth.ts) │ belay: no run is watched. /belay watch │ │ ⎿ Read 6 lines │ [run-id] │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ⏺ Bash(bun test) │ belay │ │ ⎿ 3 pass, 1 fail │ usage: /belay watch [run-id] | /belay │ │ │ unwatch │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · belay
No belay run is watched. /belay watch [run-id]
README

belay

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.

Why, and what it does differently

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

  • Subagents, each with its own context window. Each subagent can set its own model, effort, tools allowlist, permissionMode, MCP servers, and a git worktree to work in.
  • Agent teams (experimental, off by default). Teammates are separate Claude Code instances with a shared task list, task dependencies and direct messaging.
  • Background sessions in agent view (research preview). Each one is its own Claude Code process run by a supervisor service, and can be stopped and respawned.
  • CLAUDE.md files and auto memory that carry across sessions, and per-subagent persistent memory.
  • Launch flags that limit what a session loads: --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:

  • Mixed substrates. Claude Code and Codex CLI workers can run in the same run today, under the same task model. The launch table is designed to take more agent CLIs, so open-source or local-model agents could be added as substrates. That is an aim; it is not built.
  • Workers are processes belay spawns and owns. Each worker is a separate OS process with its own context window, and belay is its parent; there is no supervisor service or daemon. You can kill, pause (park and resume) or restart each worker on its own. When a worker dies, belay detects it and reports it to the orchestrator. One worker crashing does not take the run down.
  • Leases record who owns which task. A lease names the one worker that owns a task. The worker's adapter keeps the lease alive while the worker lives, and the worker releases it with a structured result. Tasks have dependencies. A task counts as 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.
  • A verified launch surface. belay starts each worker with no personal settings, plugins, auto memory or parent-directory instruction files. It passes in a chosen tool list and the worktree's own instructions. Then it checks what the worker actually loaded at startup and fails the launch if that differs. Native Claude Code lets you configure these things; belay also checks that they took effect.
  • Shared memory you can check. One SQLite store that every agent in a run can read and write, persisting across runs, with a 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.
  • Operator control with receipts. You can pause and resume a whole run. A permission request is a row you approve or deny. Every intervention names an id and returns proof. Kills are by exact pid, with proof of the process's identity. There is no daemon, and reading never writes.
  • A durable record. Tasks, leases, checkpoints, verdicts and per-agent costs stay in a SQLite store after the run, so a later session can read what happened.

How it works

   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)
  • The orchestrator is a Claude session, not code. It creates tasks with machine-checkable acceptance criteria, spawns workers, waits, evaluates and decides.
  • belay is the substrate. Spawning, leases, a checkpoint after every worker turn, death detection, restart from the last checkpoint and running acceptance commands are deterministic code.
  • Workers are real processes belay spawns and owns. Each gets its own belay MCP adapter; its identity is in that adapter's command-line arguments.
  • You watch and intervene from the CLI, or from a band and side pane inside Claude Code.

Where this is going

Each direction below says what it stands on today, which is built, and what is planned, which is not.

Memory that lasts across sessions

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:

  • One store. belay's SQLite store is the only memory its agents have. Each worker's own Claude Code auto memory is switched off at launch (an unsupported evaluation key, native_memory, turns it back on; see INSTALL.md).
  • Hybrid search that says what it did. Search fuses keyword and semantic results by default. You can also ask for --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.
  • A compact view. 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.
  • Filters. By type, status, worker, task, run and time.
  • Row status. Every row is 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.
  • Provenance. Each row records the run, task and turn that wrote it, and whether it came from a model, from belay itself, from an import or from you. The task comes from the worker's lease, not from anything the model says.
  • Redaction. When a row is written, belay removes <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.
  • Memory is data, not instructions. One model's words become another model's input, so belay guards that path:
  • Every block of memory an agent reads is framed as data, between BEGIN MEMORY and END MEMORY.
  • Nothing a model wrote can start a line of its own inside that block.
  • A worker can read and write only its own run's rows and its project's rows.
  • A worker can change or delete only its own unverified rows.
  • A worker cannot choose a row's id, time or vector.
  • What a worker can write is bounded.
  • Read-only views. belay memory list, show, timeline and stats, belay usage and belay notices all read without writing.
  • Moving rows between stores. 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.

LeverWhat it does
rulesAdds a short block of memory rules (search the index first, cite ids, treat unverified rows as unverified) to the orchestrator's prompt.
captureRecords 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.
observationsWrites 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_writesNames 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.
notesOn once project is set (and redact is on); false turns notes off. See Notes.
on_embedder_failurerequired (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_ledgerRecords tokens and dollars per turn, for belay usage.
first_message_indexPuts 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).
bottleWhen a worker is replaced, builds its resume document from its own stream: its text verbatim, tool calls as one line each, within bottle_bytes.
wrapupWrites a short wrap-up per task and one for the run, shown once in belay notices.
knowledge_agentA read-only worker that answers the orchestrator's questions from verified memory and the repository, citing row ids.
redactOn 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.
alphaThe 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:

  • A model-written summariser. Summaries today are mechanical only.
  • An evaluation of notes on a real project build, with a held-out set and a native-memory baseline, to show whether they help.
  • A way to judge notes written by reviewers, and tickets for workers' control pipes.
  • Better search ranking, including choosing hybrid or semantic per query.
  • Deciding whether the first-message index defaults to on, once that evaluation is done.
What we have measured

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:

ArmAvoided the trap
No memory0 of 24
Placebo rows: same number, shape and size, about something else0 of 24
The right row in the first-message index24 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 (experimental, new in 0.3.0)

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

Source 12 files
hooks/register.tsx 11 lines
1import 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}
11
hooks/panel/index.tsx 474 lines
1// 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}
474
hooks/wake/index.ts 59 lines
1import 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}
59
hooks/panel/fetch.ts 202 lines
1// 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}
202
hooks/panel/held.ts 241 lines
1// 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}
241
hooks/panel/model.ts 420 lines
1// 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}
420
hooks/panel/view.tsx 374 lines
1// 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
374
hooks/wake/core.mjs 115 lines
1// 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}
115
hooks/panel/wrap.ts 126 lines
1// 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}
126
hooks/panel/base.tsx 101 lines
1// 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}
101
hooks/panel/table.tsx 205 lines
1// 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}
205
types/index.d.ts 175 lines
1// 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