SLOPSHOPPER

runner-board

A pane that shows linear-runner batches: each batch's issues at their stage, with time and tokens, and how the batches follow from one another

newpaneguardcommandprocesstimer
v0.3.0MITupdated 2026-10-14jiahaoh/linear-runner/mods/runner-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · runner-board
│ ┃ Runner board ✕ › fix the failing auth test and add an audit log call │ ┃ [ Board ] [ Lineage ] [ Refresh ] │ ┃ 0 batches · 0 issues · read Oct 9, 4:53 ⏺ Read(src/auth.ts) │ ┃ AM EDT ⎿ Read 6 lines │ ┃ No batch here yet. Add one with ⏺ Update(src/auth.ts) │ ┃ /runner-board add <batch id>; a trailing * ⎿ Added 2 lines, removed 1 line │ ┃ adds several. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /runner-board │ ⎿ runner-board: Runner board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Runner board
[ Board ] [ Lineage ] [ Refresh ] 0 batches · 0 issues · read Oct 9, 4:53 AM EDT No batch here yet. Add one with /runner-board add <batch id>; a trailing * adds several.
README

linear-runner

A sequential controller for explicitly authorized Linear issue batches. Deterministic Python owns scheduling, gates, Linear synchronization, checks, the Git commit and publication. A model CLI (Codex CLI or Claude Code) is invoked only for three bounded phases: implementation, repair and an independent read-only review. No extra Python packages are required.

The engine has no project-specific prompts, owner, workspace or toolchain. Policy lives in the public registry/; everything private (hosts, workspaces, projects, batches) lives in a private configuration home outside this repository. Your own workflow and authorization rules belong in project or batch guidance files. One command, runner.py launch, runs a preflight (model-free except one tiny start check per model backend, reused while the backend is unchanged), starts a host supervisor and exits; no outer model session is needed to start, continue or recover a batch.

Requires Python 3.10+, Git, a POSIX host (tested on Linux), an authenticated Codex CLI (and, for Claude-backed pools, Claude Code with a claude.ai subscription: its login or a long-lived token, see "Claude authentication") and a Linear credential reachable through an environment variable or the Codex credential cache. CLI event parsing was established on Codex CLI 0.154.0 and Claude Code 2.1.281; probe changed versions before unattended use.

Layout

The code is one Python package, linear_runner/, with no dependencies outside the standard library. runner.py at the checkout root is the only entry point you run; it forwards to linear_runner/cli.py. Data the code reads (registry/, schema/, templates/) stays at the checkout root, which is also ${runner_root}.

PathContents
runner.pyEntry point for every command (validate-config, dry-run, run, launch, supervise, recover, status, wait, stop, clear-stop, watchdog, sync-linear-template, and the offline report and measure; --version); launch units and comment commands run it by path
render_samples.pyEntry point that writes (or --checks) docs/template-samples.md
CHANGELOG.mdOne entry per release, each with its project impact and canary tier (see "Releases")
interface-migrations.jsonMachine-readable migrations of the runner-project interface (see "Interface version")
linear_runner/version.pyThe current release (RELEASE), --version and the batch runner_version check
linear_runner/cli.pyThe command line: argument parsing and dispatch of every command
linear_runner/config.pyLayered loading, schema validation, ${variable} substitution, name resolution pinning, the configuration fingerprint and runner identity
linear_runner/engine/runner.pyThe per-issue state machine: gates, model phases, checks and validation, the controller commit, delivery, review, publication and terminal reporting
linear_runner/engine/delivery.pyCheck outcomes and the generic, config-driven delivery integrity step
linear_runner/engine/intake.pyWorker intake packets: compact schema 2 (default) and the full schema 1
linear_runner/backends/__init__.pyThe model-backend interface (SessionRequest, Backend) and the backend registry
linear_runner/backends/codex.pyEverything Codex-specific: the codex exec argv, its JSONL events, execution evidence and the host model catalog
linear_runner/backends/claude.pyEverything Claude Code-specific: the claude -p argv and isolation flags, its stream-json events, execution evidence and the probe-verified model list
linear_runner/linear/client.pyDirect HTTPS JSON-RPC client for the official Linear MCP endpoint; append-only comments with read-back
linear_runner/linear/updates.pyTemplate rendering, draft lint, hidden event markers and the exactly-once event ledger
linear_runner/linear/messages.pyBuilds each human-review comment from saved state
linear_runner/linear/template.pyThe Linear issue template "Runner issue contract" (runner.py sync-linear-template)
linear_runner/linear/attention.pyStop classification, needs-input mechanisms and the out-of-band notifier
linear_runner/supervision/launcher.pyLaunch preflight with identity-keyed reuse; systemd-user and foreground host backends
linear_runner/supervision/backend_start.pyThe backend_start.<backend> preflight step: start each selectable model backend once in the batch worktree
linear_runner/supervision/supervisor.pyThe generic supervisor a launched unit runs: scheduling, lifecycle read-back, checkpoints, reporting
linear_runner/supervision/recovery.pyNamed, recorded recovery commands and the hash-chained recovery log
linear_runner/supervision/watchdog.pyModel-free check for a vanished or stalled supervisor (runner.py watchdog, run by a launch-started timer)
linear_runner/supervision/wait.pyModel-free, read-only wait for a launched batch to stop (runner.py wait)
linear_runner/supervision/rules.pyOne-line decision rules written in issue descriptions
linear_runner/reporting/records.pyReads saved session, check and intake records from evidence roots and deduplicates copies
linear_runner/reporting/trajectory.pyDeterministic trajectory/usage/attempts/validation-audit/batch-comparison report (runner.py report, and terminal-trajectory.* at the terminal step)
linear_runner/reporting/measure.pyOffline context-cost measurement: intake components, prompt/tool-output bytes, per-call context growth (runner.py measure)
linear_runner/reporting/board.pyThe compact record of a batch for a progress board (runner.py status --board): model-free and read-only
linear_runner/reporting/report.pyStandalone terminal HTML/JSON report
linear_runner/reporting/render_samples.pyBuilds one sample comment per template for docs/template-samples.md
mods/runner-board/The progress board: a Claude Code mod that draws batches, their issues and how batches follow (see "Progress board"); .claude-plugin/marketplace.json at the root lists it for claude plugin install
templates/Human-review Linear comment templates and the worker/reviewer draft templates
registry/Public policy defaults; each file's notes explain its values
schema/JSON schemas for every registry file and configuration layer
examples/home/Placeholder private home: site, workspace, project and batch files
prompts/Generic worker guidance
testdata/Fictional runner records for the renderer and measurement tests, and the status --board example (testdata/board/) that the runner's tests keep current
tests/Offline tests, laid out like the package (tests/engine/, tests/backends/, ...); shared fixtures in tests/fixtures.py; tests/test_public_tree.py fails on private identifiers in any tracked file

Run the tests from the checkout root; they need no network, Linear, model CLI or systemd:

python3 -m unittest -v                          # everything
python3 -m unittest tests.engine.test_runner    # one module (or tests.engine.test_runner.EngineTests)
python3 render_samples.py --check               # docs/template-samples.md is current

Configuration layers

Layers are loaded and merged in this order; later layers never repeat earlier policy.

  1. Registry — registry/*.json in this repository.
  2. Private registry overrides — optional <home>/registry/<name>.json, deep-merged onto the matching public file (objects merge, lists and scalars replace).
  3. Site — <home>/site.json: host bindings.
  4. Workspace — <home>/workspaces/<slug>.json: one Linear workspace.
  5. Project — <home>/projects/<name>.json: one repository and Linear project.
  6. Batch — any file passed with --batch: one authorized queue.

The private home is --home, else $LINEAR_RUNNER_HOME, else ~/.config/linear-runner. Every file is validated offline against schema/; unknown keys, unknown registry files and bad references (for example a profile naming an unregistered model or an effort that model does not allow) are errors.

Registry fileHolds
models.jsonEffort IDs the CLIs accept; models policy may select, the backend that runs each and their allowed efforts
labels.jsonTask-kind labels and profile labels (exactly one of each per issue)
profiles.jsonProfile order, review floors, phase overrides, escalation target, routing version, the review_routing low-risk rule
pools.jsonOrdered {backend, model, effort} pools per task kind (or *), profile and phase (see "Model backends and pools")
phases.jsonPer-phase soft budgets and timeouts, the largest per-issue timeout a batch may set (max_timeout_seconds), the shared repair limit (≤ 2), the default and the largest per-check timeout (check_timeout_seconds, max_check_timeout_seconds), bounded_sessions thresholds
linear.jsonDefault workflow state names and whether a milestone is required
interface.jsonThe runner-project interface version (see "Interface version"); not a policy layer, so a private registry cannot override it
LayerFields
Siteexecutables (must include codex, and claude when a pool uses the Claude backend), variables, state_root, artifact_root, model_catalog, optional claude (auth: exactly one of oauth_token_file or oauth_token_env; see "Claude authentication"), optional launcher, optional attention
Workspaceslug (matches the file name), auth (exactly one of token_env or credentials_file, optional timeout_seconds; with credentials_file, optional refresh_command, auto_refresh (false), min_lifetime_minutes (0: off) and warn_lifetime_minutes (720), see "Running a batch"), assignee ("me" or an exact name/email; default "me"), optional states renames, optional attention
Projectoptional interface_version, workspace, linear_project (exact Linear project name), repo, artifact_owner, retention, optional backup_status, guidance_files, optional context_files, optional criterion_lint (see "Writing issues"), contract_file, intake_mode (compact default, or full), identity_files, check_environment, checks (each: name, kind, tier, inputs, cwd, command, optional allow_empty, timeout_seconds, last_issue, base_parity), optional delivery_checks, delivery_integrity
Batchoptional interface_version, id, project, issues (ordered allowlist), terminal_issue, branch, optional worktree (defaults to the project repo), guidance_files (appended after the project's), required_done, human_gates, supervision, context_controls, model_overrides, phase_overrides (per-issue phase timeouts, see "Lifecycle"), variables (values for site variables in this batch only, see below), runner_version (see "Releases"), purpose, follows, reason, from_issues (the batch's record for people, see below)

Supervisor, launcher and delivery-integrity fields:

Layer.fieldKeyDefaultMeaning
site launcherbackendsystemd-usersystemd-user or foreground (in-process; tests and debugging)
pythonthe interpreter running launchInterpreter for the supervisor unit; ${name} allowed
cpu_listnoneWrap the supervisor in taskset -c <list>
environment{}Extra --setenv values for the unit, for example PATH
unit_prefixlinear-runnerUnit name is <prefix>-<batch id>-<launch id>.service
startup_timeout_seconds30How long launch waits to confirm the supervisor started
backend_start_timeout_seconds180Timeout of each preflight backend start check (backend_start.<backend>)
stop_on_exittrueWrite the STOP marker when the supervisor exits (also via ExecStopPost)
batch supervisionstop_after[]Planned checkpoints: stop after these issues are accepted
on_blockstopPause the batch on an issue-level block; continue_independent opts in to deferring the issue and continuing with independent issues
report_issues[]Extra issues that receive the batch-finished and batch-paused comments
decision_ruleshonorignore disables rule blocks in issue descriptions
baseline_checksfalseRun the default-tier checks on the clean baseline during launch preflight
workspace attentionowner_mention""Optional text (for example @handle) put as its own paragraph under the first sentence of comments that need action
needs_input.mechanismmentionHow a stopped issue is marked: label, state or mention (see "Stops")
needs_input.labelNeeds inputLabel added on a stop and removed when a recovery runs (must exist in the team)
needs_input.stateBlockedWorkflow state for the state mechanism (must exist in the team)
site attentioncommand_prefixpython3 ${runner_root}/runner.pyHow commands in comments start; ${name} allowed
notifier.backendnonenone, command or linear-mention-only
notifier.command[]argv run once per stop and watchdog alert; message on stdin, {subject} replaced
notifier.timeout_seconds30Notifier command timeout
watchdog.stall_minutes120Watchdog alert after this long without recorded progress
watchdog.interval_minutes10How often the launch-started timer runs the watchdog
watchdog.paused_minutes60Remind once (comment and notifier) when a stop has waited this long without a recorded recovery; 0 turns it off
lint.max_chars / max_lines / max_first_sentence_chars1500 / 30 / 240Limits for worker and reviewer drafts
outbox.poll_seconds / settle_seconds15 / 3Outbox poll interval while the model runs; drafts younger than this are left for the next poll
project delivery_integritymanifestrequiredRenderer manifest, relative to the issue's delivery/ directory
revision_fieldrequiredManifest field that must equal the committed revision
required_checks[]Check names that must be in the validated evidence
file_hashes{}Manifest field → file (relative to the manifest) whose SHA-256 it must equal
true_fields[]{"file", "field"} pairs (relative to the manifest) that must be literally true

The batch state directory is <state_root>/<batch id>. Relative paths resolve against the file that names them. Paths, check arguments, check environment values and credential-file paths may use ${name}, where name is a site executables or variables entry or a built-in: home (the private home), runner_root (this checkout), batch (the batch id) and worktree (the batch worktree; not usable in repo/worktree themselves). An undefined name is an error. A batch's variables replace site variables of the same name for that batch only (~ is expanded as in the site), for example {"venv": "~/envs/other"} to run one batch's checks in another Python environment without changing site.json for every batch. Each name must already be a site variable; executables, the built-ins and new names are refused, so a misspelt name fails instead of doing nothing. The values are part of the fingerprinted configuration (recover repin-config adopts a change), and validate-config lists them under variable_overrides. Guidance files may also use ${name} for host paths; there only defined names are replaced and any other ${...} text stays literal. {run_dir} (no dollar sign) in a check argument is still replaced by that check's validation directory at run time. Commands are argv arrays; no shell is involved.

A batch file may say what it is for and where it comes from, in four optional keys that the operator writes with the batch: purpose (one sentence), follows (the id of the batch this one stems from, or null for the first batch of a line of work), reason (why it follows: who decided what, and from which review or finding) and from_issues (the issues whose result led to it; they need not be in the allowlist). They are a record for people, so that the reason for a batch is not only prose in its guidance, forgotten within days (W-443). The engine never reads them: they change no order, gate or prompt, and they are outside the configuration fingerprint, so correcting a sentence on a pinned batch needs no recover repin-config. validate-config prints them under lineage, and status --json under lineage as the batch file holds them now; a key the file lacks is absent there. When follows names no batch file of the home (neither <home>/batches/<id>.json nor a file there with that id), validate-config says so in one line under warnings and still passes.

Credentials are never stored: the workspace names an environment variable or a credential cache path, and linear_runner/linear/client.py re-reads it on each request. Do not put secrets in check_environment; it is recorded in manifests.

Interface version

A private home depends on the runner through five things: the project profile schema, the guidance slots (guidance_files, context_files, contract_file), the site.json keys, the task-kind, profile and model labels, and the batch file schema. registry/interface.json gives them one number, interface_version (currently 1, the shape of release 2.0.0). Each project profile and batch file declares the version it was written for:

{"interface_version": 1, "workspace": "...", "linear_project": "...", ...}

validate-config (and every command that loads the configuration) reads the declared versions before any schema check. An older version fails with each migration step between it and the current one, from interface-migrations.json (the release, what changed and what to do), ending "Then set interface_version to N". A newer version asks for a newer runner. An undeclared version is accepted and listed under interface.undeclared in the validate-config report, with a note to declare it. The declared versions are not part of the configuration fingerprint, so stamping a file never blocks a paused batch.

A release that changes one of the five incompatibly bumps interface_version, adds the migration entry and names it in its CHANGELOG project impact.

Name resolution and pinning

validate-config is fully offline: it creates no state and contacts neither Linear nor Codex, so the Linear project and assignee remain unresolved names in its report. dry-run selects the next issue as the supervisor does: it skips done and deferred issues, logs each issue that waits for an unfinished prerequisite ("Dry run: W-9 waits for W-8"), and writes no batch state (before 2.6.1 it stopped on a first issue that waits, W-351). It does keep the title and link of every allowlisted issue in <state dir>/titles.json, as each launch does, so that status --board can name an issue before the batch claims it. dry-run, run and launch resolve the exact project name and the assignee to IDs (no match or more than one exact match is an error) and write <state dir>/resolved-config.json with the resolved IDs, the effective configuration, the source layer of every value and the layer files used. Project names must match exactly within the authenticated workspace. Later runs reuse the pinned IDs without contacting Linear for resolution; a renamed project or assignee, or any change to the resolved configuration, is refused. A paused or stopped batch can adopt a changed configuration or runner commit explicitly with recover repin-config (see "Stop, recovery and continuation"). Use a new batch id for a new queue.

The pin binds once the batch has state. A batch that was only dry-run has resolved-config.json and no state.json: it has claimed nothing. When its batch file, its guidance or the runner changed since that dry-run, the next dry-run, run or launch resolves the names again, replaces the pin and says so in one line ("This batch has not run yet, so the configuration pinned at ... is replaced by the configuration files as they are now."). Before W-378 such a batch was stuck: dry-run refused the change, and recover repin-config refused a batch with no state to re-pin.

The configuration fingerprint in state.json and resolved-config.json covers the whole resolved configuration: guidance text, commands, environment, policy, IDs and the runner identity. The runner is identified by its release, Git commit and a clean/dirty flag, not by where it is checked out: paths under the runner checkout are normalized to ${runner_root} before hashing. Moving or re-cloning the runner at the same commit therefore resumes normally, while a different runner commit, uncommitted runner edits or any configuration change is refused. The dirty flag is a boolean, so further edits to an already dirty checkout are not distinguished; run batches from a clean commit (recover repin-config adopts a new commit for a paused or stopped batch). The site launcher block and the site and workspace attention blocks are the exceptions: they only choose how the supervisor process starts and how a person is told about progress and stops, so they are left out of the fingerprint and changing them never blocks resuming. The batch's purpose, follows, reason and from_issues are left out as well: they are a record for people. Each launch record (<state dir>/launches/<launch id>.json) stores the launcher settings actually used, and status shows those of the latest launch.

Releases

Releases follow semantic versioning and are Git tags v<version> on a release commit; CHANGELOG.md has one entry per release and linear_runner/version.py names the newest one.

python3 runner.py --version
# linear-runner 2.0.0 (commit <sha>, clean)
# linear-runner 2.0.0 with unreleased changes (commit <sha>, dirty; not the tagged release v2.0.0)

resolved-config.json records the same release, commit and dirty flag under runner, so every batch state names the runner it ran with. A batch may pin a release with "runner_version": "2.0.0": the launch preflight's config step then refuses unless the runner checkout is exactly the clean, tagged release (The batch pins runner_version 2.1.0, but this checkout is release 2.0.0 at commit ...). validate-config reports the same problem under runner_version without failing, and recover repin-config refuses with it. Without runner_version any checkout runs, as before.

Every CHANGELOG entry states its project impact (none, or the exact configuration change and the validate-config message that asks for it) and its canary tier: first-issue checkpoint for a change to preflight, reporting or documentation only, canary batch for a change to the engine, prompts, backends or models. Changes land under ## Unreleased as they are committed. To cut a release:

  1. Every commit since the last release passes the full suite (python3 -m unittest), including the public-tree leak test (tests/test_public_tree.py), and validate-config passes for the example batch.
  2. In one release commit, rename ## Unreleased to ## v<version> — <date> with both required lines (a test checks them), start a new empty ## Unreleased, and set RELEASE in linear_runner/version.py (a test checks it matches the newest entry).
  3. Tag the release commit v<version> (annotated) and push the commit and the tag.
  4. Before the next production batch, run the canary its tier
Source 1 files
hooks/register.tsx 1101 lines
1import type { Color, EngineInterface, PluginOptions, Register } from 'claude-code'
2
3const PANE = 'runner-board'
4const TITLE = 'Runner board'
5const POLL_MS = 10_000
6// The record `runner.py status --board` prints, as this board reads it.
7const SCHEMA = 'linear-runner.board/1'
8
9// Where the runner is: the plugin's options, with the defaults plugin.json gives them. A path that starts with `~/`
10// is under the user's home, a bare name is a program on the PATH, and another relative path is under the plugin's
11// own folder, which is `mods/runner-board` of a runner checkout: two folders up is the checkout's `runner.py`.
12const DEFAULTS = { python: 'python3', runner: '../../runner.py', home: '~/.config/linear-runner' }
13let config = DEFAULTS
14
15// A total as the runner's report counts it: exact, or a bound where an attempt reported nothing.
16type Count = { value: number | null; bound: string | null }
17// One issue of the record. `stage` is done, deferred, queued, or the step of the active issue.
18type Issue = {
19  id: string
20  title: string | null
21  url: string | null
22  stage: string
23  repairs: number
24  blocked: { worker: number; review: number }
25  summary: string | null
26  accepted_at: string | null
27  commit: string | null
28  files: string[]
29  elapsed_seconds: number | null
30  tokens: { input_tokens: Count; cached_input_tokens: Count; output_tokens: Count }
31  pending_invocations: number
32}
33// Why a batch exists and which batch it stems from: the batch file's own keys, or, while a batch file has none,
34// its entry in a file of `<runner home>/lineage/`.
35type Link = {
36  purpose?: string
37  follows?: string | null
38  reason?: string
39  from_issues?: string[]
40  note?: string
41  // Written but not yet reviewed by the person.
42  isDraft?: boolean
43}
44export type Board = {
45  schema: string
46  batch: {
47    id: string
48    phase: string | null
49    supervisor: { status: string | null; outcome: string | null }
50    first_launch_at: string | null
51    last_launch_at: string | null
52    stop_marker: string | null
53    pending_recovery: { id?: string; kind?: string } | null
54    error: string | null
55    lineage: Link
56  }
57  issues: Issue[]
58}
59type Batch = {
60  id: string
61  issues: string[]
62  board?: Board
63  problem?: string
64}
65type Detail = { label: string; text?: string; items?: string[] }
66type Kind = 'done' | 'running' | 'stopped' | 'deferred' | 'queued'
67// A status icon in Linear's manner: a ring to do, a pie that fills as the work moves on, a check when done.
68type Icon = {
69  shape: 'ring' | 'dashed' | 'pie' | 'check' | 'pause' | 'alert' | 'live'
70  hex: string
71  part?: number
72  isLive?: boolean
73}
74// `glyph` stands for the icon where no picture is drawn; `color` paints the stage's words.
75type Stage = { kind: Kind; glyph: string; icon: Icon; label: string; color: Color }
76type Look = { glyph: string; icon: Icon; color: Color; border: Color }
77
78let batches: Batch[] = []
79let isLoaded = false
80let isRefreshing = false
81let updatedAt: number | null = null
82let expanded: string | null = null
83// What the files of `<runner home>/lineage/` record, for the batches whose own file says nothing.
84let recorded: Record<string, Link> = {}
85let view: 'board' | 'lineage' = 'board'
86let lineageAs: 'graph' | 'list' = 'graph'
87// How large the graph is drawn, as a share of its own size.
88let zoom = 1
89// Batches the person opened or closed by hand; the others open unless their queue is complete.
90const toggled = new Map<string, boolean>()
91
92// A path without its `.` and `..` steps.
93function tidy(path: string) {
94  const steps: string[] = []
95  for (const step of path.split('/')) {
96    if (step === '..') steps.pop()
97    else if (step !== '.' && step !== '') steps.push(step)
98  }
99
100  return '/' + steps.join('/')
101}
102
103async function places($: EngineInterface) {
104  const home = (await $.env.get('HOME')) ?? ''
105  const at = (path: string) => {
106    if (path.startsWith('~/')) return home + path.slice(1)
107
108    return path.startsWith('/') || !path.includes('/') ? path : tidy($.plugin.root + '/' + path)
109  }
110  // As the runner finds its home: the one that is named, then $LINEAR_RUNNER_HOME, then the default.
111  const fromEnv = config.home === DEFAULTS.home ? await $.env.get('LINEAR_RUNNER_HOME') : undefined
112
113  return { python: at(config.python), runner: at(config.runner), runnerHome: at(fromEnv || config.home) }
114}
115
116const LAST = 'batches:last'
117
118async function storeKey($: EngineInterface) {
119  try {
120    return 'batches:' + (await $.session.id())
121  } catch {
122    return 'batches:default'
123  }
124}
125
126async function load($: EngineInterface, id: string): Promise<Batch> {
127  const { python, runner, runnerHome } = await places($)
128  const file = `${runnerHome}/batches/${id}.json`
129  let issues: string[] = []
130  try {
131    issues = (JSON.parse(await $.fs.read(file)) as { issues?: string[] }).issues ?? []
132  } catch {
133    return { id, issues, problem: `no batch file ${file}` }
134  }
135  try {
136    // The runner reads the batch's state directory and the records of its issues' runs; it writes nothing.
137    const ran = await $.process.run([python, runner, 'status', '--board', '--batch', file, '--home', runnerHome], {
138      timeoutMs: 60_000,
139    })
140    if (ran.exitCode !== 0) return { id, issues, problem: ran.stderr.trim().split('\n').pop() || 'status failed' }
141    const board = JSON.parse(ran.stdout) as Partial<Board>
142    if (board.schema !== SCHEMA || !board.batch || !Array.isArray(board.issues)) {
143      return { id, issues, problem: `the runner printed a record this board cannot read (${board.schema ?? 'no schema'}, not ${SCHEMA})` }
144    }
145
146    return { id, issues: board.issues.map(issue => issue.id), board: board as Board }
147  } catch (error) {
148    return { id, issues, problem: String(error) }
149  }
150}
151
152async function readLineage($: EngineInterface) {
153  const { runnerHome } = await places($)
154  const dir = `${runnerHome}/lineage`
155  const files = await $.fs.list(dir).catch(() => [])
156  const next: Record<string, Link> = {}
157  for (const file of files) {
158    if (!file.name.endsWith('.json')) continue
159    try {
160      const record = JSON.parse(await $.fs.read(`${dir}/${file.name}`)) as { status?: string; batches?: Record<string, Link> }
161      for (const [id, link] of Object.entries(record.batches ?? {})) next[id] = { ...link, isDraft: record.status === 'draft' }
162    } catch {
163      // A file that does not read leaves its batches without a recorded reason.
164    }
165  }
166  recorded = next
167}
168
169// A batch file that holds any of the four keys speaks for itself; the lineage file is read for the others.
170function linkOf(batch: Batch): Link | undefined {
171  const own = batch.board?.batch.lineage
172
173  return own && Object.keys(own).length ? own : recorded[batch.id]
174}
175
176const issueOf = (board: Board | undefined, id: string) => board?.issues.find(issue => issue.id === id)
177// The first launch, so that a batch keeps its place on the board when it is launched again.
178const startedAt = (batch: Batch) => batch.board?.batch.first_launch_at ?? '9'
179const isLive = (batch: Batch) => !batch.board || batch.board.batch.supervisor.status === 'running'
180
181// What of a batch the board draws: the record is all of it. A read that leaves it as it was calls for no new
182// drawing, and a new drawing of the graph is a flash on the desktop, which draws it afresh each time.
183const shown = (batch: Batch) => JSON.stringify([batch.issues, batch.problem, batch.board])
184
185// With `only`, the timer's read of the batches under way: it draws again only when one of them has changed.
186async function refresh($: EngineInterface, only?: (batch: Batch) => boolean) {
187  if (isRefreshing || (only && !batches.some(only))) return
188  isRefreshing = true
189  try {
190    const before = batches.map(shown).join()
191    const fresh = new Map<string, Batch>()
192    await Promise.all(
193      batches.filter(batch => !only || only(batch)).map(async batch => fresh.set(batch.id, await load($, batch.id))),
194    )
195    // A failed read keeps what the last good read showed.
196    batches = batches
197      .map(batch => {
198        const next = fresh.get(batch.id)
199        if (!next) return batch
200
201        return !next.board && batch.board ? { ...batch, problem: next.problem } : next
202      })
203      .sort((a, b) => startedAt(a).localeCompare(startedAt(b)))
204    if (!only) await readLineage($)
205    updatedAt = await $.clock.now()
206    if (!only || batches.map(shown).join() !== before) $.ui.invalidate('ui.render')
207  } finally {
208    isRefreshing = false
209  }
210}
211
212async function restore($: EngineInterface) {
213  if (isLoaded) return
214  isLoaded = true
215  // From here on the batches under way are read again every few seconds.
216  $.clock.every(POLL_MS, () => void refresh($, isLive))
217  // A session with no board of its own yet starts from the board saved last: a resumed session has a new id.
218  let saved = (await $.store.get(await storeKey($))) ?? (await $.store.get(LAST))
219  if (!Array.isArray(saved)) {
220    // Before the last board was kept under its own key, each session's was kept only under that session's.
221    const keys = (await $.store.keys().catch(() => [])).filter(key => key.startsWith('batches:'))
222    const boards = await Promise.all(keys.map(key => $.store.get(key)))
223    saved = boards.filter(Array.isArray).sort((a, b) => b.length - a.length)[0]
224  }
225  if (Array.isArray(saved)) {
226    // A name with no batch file behind it is not a batch: it leaves the board.
227    const ids = saved.filter((id): id is string => typeof id === 'string')
228    const { found } = await sift($, ids)
229    batches = found.map(id => ({ id, issues: [] }))
230    if (found.length !== ids.length) await save($)
231  }
232  await refresh($)
233}
234
235// Which of these names have a batch file.
236async function sift($: EngineInterface, ids: string[]) {
237  const { runnerHome } = await places($)
238  const has = await Promise.all(ids.map(id => $.fs.exists(`${runnerHome}/batches/${id}.json`).catch(() => false)))
239
240  return { found: ids.filter((_, index) => has[index]), missing: ids.filter((_, index) => !has[index]) }
241}
242
243async function save($: EngineInterface) {
244  const ids = batches.map(batch => batch.id)
245  await $.store.set(await storeKey($), ids)
246  await $.store.set(LAST, ids)
247}
248
249// A name ending in `*` stands for every batch file whose name starts with what is before it.
250async function resolve($: EngineInterface, names: string[]) {
251  const { runnerHome } = await places($)
252  const ids: string[] = []
253  for (const name of names) {
254    if (!name.endsWith('*')) {
255      ids.push(name.replace(/^.*\//, '').replace(/\.json$/, ''))
256      continue
257    }
258    const files = await $.fs.list(`${runnerHome}/batches`)
259    for (const file of files) {
260      if (file.name.startsWith(name.slice(0, -1)) && file.name.endsWith('.json')) ids.push(file.name.slice(0, -5))
261    }
262  }
263
264  return [...new Set(ids)]
265}
266
267async function add($: EngineInterface, names: string[]) {
268  await restore($)
269  const known = new Set(batches.map(batch => batch.id))
270  const { found, missing } = await sift($, await resolve($, names))
271  const added = found.filter(id => !known.has(id))
272  batches = [...batches, ...added.map(id => ({ id, issues: [] }))]
273  await save($)
274  await refresh($, batch => added.includes(batch.id))
275
276  return { added, missing }
277}
278
279// The batches a shell command launches: `runner.py launch ... --batch <file>`.
280// Only the command line counts: what a here-document feeds a program is text, not a launch.
281export function launched(command: string) {
282  const ids: string[] = []
283  const line = command.split(/<<-?\s*['"]?\w/)[0] ?? ''
284  for (const call of line.matchAll(/runner\.py\s+launch\b([^\n;&|]*)/g)) {
285    const batch = /--batch[ =]+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(call[1] ?? '')
286    const path = batch?.[1] ?? batch?.[2] ?? batch?.[3]
287    if (path) ids.push(path.replace(/^.*\//, '').replace(/\.json$/, ''))
288  }
289
290  return ids
291}
292
293// Linear's status colors.
294const HEX = {
295  todo: '#9ca3af',
296  started: '#f2c94c',
297  review: '#27ae60',
298  done: '#5e6ad2',
299  stopped: '#f2994a',
300  failed: '#eb5757',
301}
302const RING: Icon = { shape: 'ring', hex: HEX.todo }
303const CHECK: Icon = { shape: 'check', hex: HEX.done }
304
305// How far each step of a running issue fills its pie, and in which color.
306const STEPS: Record<string, { part: number; hex: string; color: Color }> = {
307  implement: { part: 0.25, hex: HEX.started, color: 'warning' },
308  validate: { part: 0.5, hex: HEX.started, color: 'warning' },
309  commit: { part: 0.5, hex: HEX.started, color: 'warning' },
310  delivery: { part: 0.6, hex: HEX.started, color: 'warning' },
311  repair: { part: 0.5, hex: HEX.stopped, color: 'warning' },
312  review: { part: 0.75, hex: HEX.review, color: 'success' },
313  publish: { part: 0.9, hex: HEX.review, color: 'success' },
314}
315const pieGlyph = (part: number) => (part < 0.34 ? '◔' : part < 0.67 ? '◑' : '◕')
316const pie = (step: string, isLive = false): Icon => {
317  const { part, hex } = STEPS[step] ?? { part: 0.25, hex: HEX.started }
318
319  return { shape: 'pie', hex, part, isLive }
320}
321const LEGEND: [Icon, string, string][] = [
322  [RING, '○', 'queued'],
323  [pie('implement'), '◔', 'implement'],
324  [pie('validate'), '◑', 'validate'],
325  [pie('review'), '◕', 'review'],
326  [CHECK, '✓', 'done'],
327  [{ shape: 'pause', hex: HEX.stopped }, '⏸', 'stopped'],
328  [{ shape: 'dashed', hex: HEX.todo }, '◌', 'deferred'],
329]
330
331// The 14 by 14 shapes of an icon.
332function shapesOf(icon: Icon) {
333  const { hex } = icon
334  const ring = (more = '') => `<circle cx="7" cy="7" r="6" fill="none" stroke="${hex}" stroke-width="1.5"${more}/>`
335  const bar = (x: number) => `<rect x="${x}" y="4.3" width="1.6" height="5.4" rx="0.5" fill="${hex}"/>`
336  const fade = (from: string, to: string) =>
337    `<animate attributeName="opacity" values="${from};${to}" dur="1.6s" repeatCount="indefinite"/>`
338  const shapes: Record<Icon['shape'], string> = {
339    ring: ring(),
340    dashed: ring(' stroke-dasharray="1.4 1.74" stroke-dashoffset="0.65"'),
341    pie:
342      ring() +
343      `<circle cx="7" cy="7" r="2" fill="none" stroke="${hex}" stroke-width="4" transform="rotate(-90 7 7)" ` +
344      `stroke-dasharray="${(12.566 * (icon.part ?? 0.5)).toFixed(2)} 100"/>`,
345    check:
346      `<circle cx="7" cy="7" r="7" fill="${hex}"/>` +
347      '<path d="M4 7.3 6.2 9.5 10.1 5" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
348    pause: ring() + bar(4.8) + bar(7.6),
349    alert:
350      `<circle cx="7" cy="7" r="7" fill="${hex}"/>` +
351      '<rect x="6.2" y="3.2" width="1.6" height="5" rx="0.8" fill="#fff"/><circle cx="7" cy="10.3" r="1" fill="#fff"/>',
352    live:
353      `<circle cx="7" cy="7" r="3" fill="${hex}"/>` +
354      `<circle cx="7" cy="7" r="3" fill="none" stroke="${hex}" stroke-width="1.5">` +
355      `<animate attributeName="r" values="3;6.5" dur="1.6s" repeatCount="indefinite"/>${fade('0.8', '0')}</circle>`,
356  }
357  const pulse = icon.isLive ? fade('1;0.35', '1') : ''
358
359  return `<g>${shapes[icon.shape]}${pulse}</g>`
360}
361
362const svg = (icon: Icon) =>
363  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">${shapesOf(icon)}</svg>`
364
365function stageOf(board: Board | undefined, id: string): Stage {
366  const issue = issueOf(board, id)
367  if (!board || !issue || issue.stage === 'queued') return { kind: 'queued', glyph: '○', icon: RING, label: 'queued', color: 'inactive' }
368  if (issue.stage === 'done') return { kind: 'done', glyph: '✓', icon: CHECK, label: 'done', color: HEX.done }
369  if (issue.stage === 'deferred') {
370    return { kind: 'deferred', glyph: '◌', icon: { shape: 'dashed', hex: HEX.todo }, label: 'deferred', color: 'inactive' }
371  }
372  // Any other stage is the step the active issue is at.
373  const step = issue.stage
374  const repairs = issue.repairs ? `, repair ${issue.repairs}` : ''
375  if (board.batch.supervisor.status === 'running') {
376    const icon = pie(step, true)
377
378    return {
379      kind: 'running',
380      glyph: pieGlyph(icon.part ?? 0.25),
381      icon,
382      label: (board.batch.phase ?? step) + repairs,
383      color: STEPS[step]?.color ?? 'warning',
384    }
385  }
386  const how = board.batch.supervisor.outcome ?? 'stopped'
387  const hasFailed = Boolean(board.batch.error)
388
389  return {
390    kind: 'stopped',
391    glyph: hasFailed ? '!' : '⏸',
392    icon: hasFailed ? { shape: 'alert', hex: HEX.failed } : { shape: 'pause', hex: HEX.stopped },
393    label: `${how} at ${step}${repairs}`,
394    color: hasFailed ? 'error' : 'warning',
395  }
396}
397
398function batchState(batch: Batch) {
399  const about = batch.board?.batch
400  if (!about) return batch.problem ? 'cannot read' : 'loading'
401  if (about.supervisor.status === 'running') return 'running'
402  if (about.phase === 'queue_complete') return 'complete'
403  if (about.phase === 'not started') return 'not launched'
404
405  return about.supervisor.outcome ?? about.phase ?? 'unknown'
406}
407
408function batchLook(batch: Batch, state: string): Look {
409  if (state === 'cannot read' || batch.board?.batch.error) {
410    return { glyph: '!', icon: { shape: 'alert', hex: HEX.failed }, color: 'error', border: HEX.failed }
411  }
412  if (state === 'running') return { glyph: '●', icon: { shape: 'live', hex: HEX.started }, color: 'warning', border: HEX.started }
413  if (state === 'complete') return { glyph: '✓', icon: CHECK, color: HEX.done, border: 'inactive' }
414  if (state === 'loading' || state === 'not launched') return { glyph: '○', icon: RING, color: 'inactive', border: 'inactive' }
415
416  return { glyph: '⏸', icon: { shape: 'pause', hex: HEX.stopped }, color: 'warning', border: HEX.stopped }
417}
418
419const cut = (text: string, length: number) => (text.length > length ? text.slice(0, Math.max(1, length - 1)) + '…' : text)
420
421function eastern(time: string | number, zone = false) {
422  try {
423    return new Date(time).toLocaleString('en-US', {
424      timeZone: 'America/New_York',
425      month: 'short',
426      day: 'numeric',
427      hour: 'numeric',
428      minute: '2-digit',
429      ...(zone ? { timeZoneName: 'short' as const } : {}),
430    })
431  } catch {
432    return String(time)
433  }
434}
435
436// Which batch each batch stems from, among the batches on the board.
437function family(all: Batch[]) {
438  const ids = new Set(all.map(batch => batch.id))
439  const parentOf = (batch: Batch) => {
440    const parent = linkOf(batch)?.follows
441
442    return parent && ids.has(parent) && parent !== batch.id ? parent : null
443  }
444  const childrenOf = (id: string | null) => all.filter(batch => parentOf(batch) === id)
445
446  return { parentOf, childrenOf }
447}
448
449const NODE = { w: 118, h: 34, col: 136, row: 66, pad: 14 }
450const TIP = { w: 322, line: 15, chars: 44 }
451type TipLine = { text: string; style?: 'b' | 'd' | 'i' }
452
453const esc = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
454
455function wrap(text: string, chars: number) {
456  const lines: string[] = []
457  let line = ''
458  for (const word of text.split(/\s+/).filter(Boolean)) {
459    let rest = word
460    while (rest.length > chars) {
461      if (line) lines.push(line)
462      lines.push(rest.slice(0, chars))
463      line = ''
464      rest = rest.slice(chars)
465    }
466    if (line && line.length + 1 + rest.length > chars) {
467      lines.push(line)
468      line = rest
469    } else {
470      line = line ? line + ' ' + rest : rest
471    }
472  }
473  if (line) lines.push(line)
474
475  return lines
476}
477
478// The lineage as one drawing: a node per batch under the batch it stems from, and a card that shows while the
479// pointer is over a node (what the batch did) or over a line (why the batch followed). The cards are drawn last
480// and shown by a rule each, since a drawing runs no script.
481function lineageGraph(all: Batch[]) {
482  const { parentOf, childrenOf } = family(all)
483  const bare = (batch: Batch) => batch.id.replace(/-\d{8}$/, '')
484  const first = all[0] ? bare(all[0]) : ''
485  const prefix = first.slice(0, first.indexOf('-') + 1)
486  const shared = all.length > 1 && prefix && all.every(batch => bare(batch).startsWith(prefix)) ? prefix : ''
487  const short = (batch: Batch) => cut(bare(batch).slice(shared.length), 11)
488
489  // A leaf takes the next column; a batch sits over the middle of the batches that stem from it.
490  const at = new Map<string, { x: number; y: number }>()
491  let leaves = 0
492  let depth = 0
493  const place = (batch: Batch, y: number): number => {
494    at.set(batch.id, { x: 0, y })
495    const below = childrenOf(batch.id)
496      .filter(one => !at.has(one.id))
497      .map(one => place(one, y + 1))
498    const x = below.length ? below.reduce((sum, one) => sum + one, 0) / below.length : leaves++
499    at.set(batch.id, { x, y })
500    depth = Math.max(depth, y)
501
502    return x
503  }
504  for (const batch of all) if (parentOf(batch) === null) place(batch, 0)
505  for (const batch of all) if (!at.has(batch.id)) place(batch, 0)
506
507  const width = Math.max(leaves * NODE.col + 2 * NODE.pad, TIP.w + 2 * NODE.pad)
508  const left = (width - leaves * NODE.col) / 2
509  const middle = (batch: Batch) => left + (at.get(batch.id)?.x ?? 0) * NODE.col + NODE.col / 2
510  const top = (batch: Batch) => NODE.pad + (at.get(batch.id)?.y ?? 0) * NODE.row
511  const base = 2 * NODE.pad + depth * NODE.row + NODE.h
512  let height = base
513
514  const lines: string[] = []
515  const nodes: string[] = []
516  const cards: string[] = []
517  const rules: string[] = []
518  // A card under what it explains, or over it where the drawing ends; the drawing grows if neither fits.
519  const card = (id: string, owner: string, x: number, above: number, below: number, text: TipLine[]) => {
520    const tall = text.length * TIP.line + 14
521    const cardLeft = Math.min(Math.max(x - TIP.w / 2, 4), width - TIP.w - 4)
522    const cardTop = below + 8 + tall <= base - 4 || above - 8 - tall < 4 ? below + 8 : above - 8 - tall
523    height = Math.max(height, cardTop + tall + 4)
524    const spans = text.map(
525      (line, index) => `<tspan x="10" y="${18 + index * TIP.line}"${line.style ? ` class="${line.style}"` : ''}>${esc(line.text)}</tspan>`,
526    )
527    cards.push(
528      `<g id="${id}" class="tip" transform="translate(${cardLeft.toFixed(1)},${cardTop.toFixed(1)})">` +
529        `<rect width="${TIP.w}" height="${tall}" rx="8"/><text>${spans.join('')}</text></g>`,
530    )
531    rules.push(`#${owner}:hover~#${id}`)
532  }
533
534  all.forEach((batch, index) => {
535    const link = linkOf(batch)
536    const state = batchState(batch)
537    const look = batchLook(batch, state)
538    const stages = batch.issues.map(issue => stageOf(batch.board, issue))
539    const done = stages.filter(stage => stage.kind === 'done').length
540    const x = middle(batch)
541    const y = top(batch)
542    const parent = all.find(one => one.id === parentOf(batch))
543
544    if (parent) {
545      const fromY = top(parent) + NODE.h
546      const half = (fromY + y) / 2
547      const path = `M${middle(parent).toFixed(1)},${fromY} C${middle(parent).toFixed(1)},${half} ${x.toFixed(1)},${half} ${x.toFixed(1)},${y}`
548      const kind = !link?.reason ? ' none' : link.note ? ' guess' : ''
549      lines.push(
550        `<g id="e${index}" class="edge"><path class="line${kind}" d="${path}"/><path class="hit" d="${path}"/>` +
551          `<circle class="dot" cx="${((middle(parent) + x) / 2).toFixed(1)}" cy="${half}" r="4"/></g>`,
552      )
553      card(`te${index}`, `e${index}`, (middle(parent) + x) / 2, half - 6, half + 6, [
554        { text: `Why ${short(batch)} followed ${short(parent)}`, style: 'b' },
555        ...wrap(link?.reason ?? 'No reason is recorded.', TIP.chars).map(text => ({ text })),
556        ...(link?.from_issues?.length ? wrap('from ' + link.from_issues.join(', '), TIP.chars).map(text => ({ text, style: 'd' as const })) : []),
557        ...(link?.note ? wrap(link.note, TIP.chars).map(text => ({ text, style: 'i' as const })) : []),
558      ])
559    }
560
561    nodes.push(
562      `<g id="n${index}" class="node${state === 'running' ? ' live' : ''}" transform="translate(${(x - NODE.w / 2).toFixed(1)},${y})">` +
563        `<rect width="${NODE.w}" height="${NODE.h}" rx="8"/><g transform="translate(9,10)">${shapesOf(look.icon)}</g>` +
564        `<text x="30" y="21.5" class="b">${esc(short(batch))}</text>` +
565        `<text x="${NODE.w - 9}" y="21.5" text-anchor="end" class="d">${done}/${batch.issues.length}</text></g>`,
566    )
567    const started = batch.board?.batch.first_launch_at
568    const listed = batch.issues.slice(0, 10).map((issue, at) => {
569      const title = issueOf(batch.board, issue)?.title
570      const shown = title ? '  ' + cut(title, TIP.chars - issue.length - 4) : ''
571
572      return { text: `${stages[at]?.glyph ?? '○'} ${issue}${shown}` }
573    })
574    card(`tn${index}`, `n${index}`, x, y, y + NODE.h, [
575      { text: `${bare(batch)}  ${state}  ${done}/${batch.issues.length}`, style: 'b' },
576      ...(started ? [{ text: eastern(started, true), style: 'd' as const }] : []),
577      ...wrap(link?.purpose ?? 'No purpose is recorded.', TIP.chars).map(text => ({ text })),
578      ...(!parent && link?.reason ? wrap('Started: ' + link.reason, TIP.chars).map(text => ({ text, style: 'i' as const })) : []),
579      { text: '' },
580      ...listed,
581      ...(batch.issues.length > 10 ? [{ text: `and ${batch.issues.length - 10} more`, style: 'd' as const }] : []),
582    ])
583  })
584
585  const style =
586    'text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;fill:#e8e8ea}' +
587    '.node rect{fill:#2b2d31;stroke:#5a5d66;stroke-width:1.2}.node.live rect{stroke:#f2c94c}' +
588    '.node:hover rect{stroke:#8f97ff;stroke-width:2}' +
589    '.line{fill:none;stroke:#8a8f98;stroke-width:1.5}.line.guess{stroke-dasharray:5 4}.line.none{stroke:#f2994a}' +
590    '.hit{fill:none;stroke:transparent;stroke-width:16;pointer-events:stroke}.dot{fill:#2b2d31;stroke:#8a8f98;stroke-width:1.5}' +
591    '.edge:hover .line,.edge:hover .dot{stroke:#8f97ff;stroke-width:2.5}' +
592    '.tip{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .12s}' +
593    '.tip rect{fill:#17181b;stroke:#8f97ff;stroke-width:1}.b{font-weight:700}.d{fill:#9aa0aa}.i{font-style:italic;fill:#c5c8d0}' +
594    (rules.length ? rules.join(',') + '{opacity:1;visibility:visible}' : '')
595  const source =
596    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
597    `<style>${style}</style>${lines.join('')}${nodes.join('')}${cards.join('')}</svg>`
598  const steps = all.filter(batch => parentOf(batch)).map(batch => `${parentOf(batch)} to ${batch.id}`)
599
600  return { source, width, height, alt: `Lineage of ${all.length} batches. ${steps.join('; ')}` }
601}
602
603function span(seconds: number) {
604  const minutes = Math.round(seconds / 60)
605  if (minutes < 1) return `${Math.round(seconds)}s`
606
607  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
608}
609
610function amount(count: number) {
611  if (count >= 1e9) return (count / 1e9).toFixed(2) + 'B'
612  if (count >= 1e6) return (count / 1e6).toFixed(1) + 'M'
613
614  return count >= 1e3 ? Math.round(count / 1e3) + 'K' : String(count)
615}
616
617// How the runner marks a total that is not exact: at least, or at most.
618const MARK: Record<string, string> = { exact: '', 'lower-bound': '≥', 'upper-bound': '≤' }
619const isKnown = (count: Count | undefined): count is Count & { value: number } => typeof count?.value === 'number'
620const tokens = (count: Count | undefined) => (isKnown(count) ? (MARK[count.bound ?? ''] ?? '≥') + amount(count.value) : 'unknown')
621
622// The short form at the end of an issue's row: its time, and input and output tokens together; a trailing
623// "+" marks an issue under way, whose figures cover the phases that have ended.
624function spent(issue: Issue | undefined, isUnderWay: boolean) {
625  const parts: string[] = []
626  if (typeof issue?.elapsed_seconds === 'number') parts.push(span(issue.elapsed_seconds))
627  const { input_tokens: input, output_tokens: output } = issue?.tokens ?? {}
628  if (isKnown(input) && isKnown(output)) {
629    // The sum is exact only when both parts are; a part that is a lower bound makes it one.
630    const bounds = [input.bound, output.bound]
631    const mark = bounds.every(bound => bound === 'exact') ? '' : bounds.includes('upper-bound') && !bounds.includes('lower-bound') ? '≤' : '≥'
632    parts.push(`${mark}${amount(input.value + output.value)} tok`)
633  }
634
635  return parts.join(' · ') + (isUnderWay && parts.length ? ' +' : '')
636}
637
638// What the runner's record holds of one issue.
639function details(board: Board | undefined, id: string) {
640  const lines: Detail[] = []
641  const issue = issueOf(board, id)
642  if (!issue) return [{ label: '', text: 'The runner has no record of this issue yet.' }]
643  const times = (n: number) => (n === 1 ? 'once' : `${n} times`)
644  const isDone = issue.stage === 'done'
645  const isWaiting = issue.stage === 'queued' || issue.stage === 'deferred'
646  if (isDone) {
647    const when = issue.accepted_at ? eastern(issue.accepted_at, true) : 'at an unrecorded time'
648    lines.push({ label: 'Accepted', text: `${when}${issue.commit ? `, commit ${issue.commit.slice(0, 9)}` : ''}` })
649  }
650  if (issue.summary) {
651    lines.push({ label: isDone ? 'Result' : 'Latest result', text: cut(issue.summary.replace(/\s+/g, ' '), 600) })
652  }
653  if (typeof issue.elapsed_seconds === 'number') {
654    const sofar = !isDone && !isWaiting ? ', phases ended so far' : ''
655    lines.push({ label: 'Time', text: `${span(issue.elapsed_seconds)} from first start to last finish${sofar}` })
656  }
657  if (isKnown(issue.tokens.input_tokens) || isKnown(issue.tokens.output_tokens)) {
658    const cached = isKnown(issue.tokens.cached_input_tokens) ? ` (${tokens(issue.tokens.cached_input_tokens)} of it read from cache)` : ''
659    lines.push({ label: 'Tokens', text: `${tokens(issue.tokens.input_tokens)} input${cached}, ${tokens(issue.tokens.output_tokens)} output` })
660  }
661  if (issue.blocked.review) lines.push({ label: 'Review', text: `blocked it ${times(issue.blocked.review)}` })
662  if (issue.blocked.worker) lines.push({ label: 'Worker', text: `blocked ${times(issue.blocked.worker)}` })
663  if (issue.files.length) {
664    const more = issue.files.length > 12 ? [`and ${issue.files.length - 12} more`] : []
665    lines.push({ label: 'Files for you', items: [...issue.files.slice(0, 12), ...more] })
666  }
667  if (issue.stage === 'queued') {
668    lines.push({ label: 'Not started', text: 'The batch has not claimed this issue' + (issue.url ? '; what it asks is in Linear, by the link above.' : '.') })
669  }
670  if (!lines.length) lines.push({ label: '', text: 'No more is recorded in the batch state.' })
671
672  return lines
673}
674
675const named = (options: PluginOptions, name: keyof typeof DEFAULTS) => {
676  const value = options[name]
677
678  return typeof value === 'string' && value.trim() ? value.trim() : DEFAULTS[name]
679}
680
681export const register: Register = (on, options) => {
682  config = { python: named(options, 'python'), runner: named(options, 'runner'), home: named(options, 'home') }
683
684  on('session.start', async ($, e, next) => {
685    await $.command.register({
686      name: 'runner-board',
687      description: 'Show runner batches in a pane: add <batch id or prefix*>, remove <id>, clear',
688    })
689    await restore($)
690    if (batches.length) void $.ui.open({ id: PANE, title: TITLE })
691
692    return next(e)
693  })
694
695  on('command.run', { command: 'runner-board' }, async ($, e) => {
696    const [verb, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
697    const said: string[] = []
698    await restore($)
699    if (verb === 'clear') {
700      batches = []
701      await save($)
702      said.push('Board cleared.')
703    } else if (verb === 'remove') {
704      const gone = new Set(await resolve($, rest))
705      batches = batches.filter(batch => !gone.has(batch.id))
706      await save($)
707      said.push(`Removed ${gone.size} batch(es).`)
708    } else if (verb) {
709      const { added, missing } = await add($, verb === 'add' ? rest : [verb, ...rest])
710      said.push(added.length ? `Added ${added.join(', ')}.` : 'Nothing new to add.')
711      if (missing.length) said.push(`No batch file for ${missing.join(', ')}.`)
712    } else {
713      void refresh($)
714    }
715    const opened = await $.ui.open({ id: PANE, title: TITLE })
716    if (!opened.isPlaced) said.push(`The pane is not shown: ${opened.reason}`)
717    $.ui.invalidate('ui.render')
718
719    return { text: said.join(' ') || 'Runner board opened.' }
720  })
721
722  // A batch this session launches joins its board; the call itself goes on untouched.
723  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
724    const ran = await next(e)
725    try {
726      // A launch that started prints its launch id; a command that only mentions one does not.
727      const hasStarted = ran.deny === undefined && ran.isError !== true && Boolean(ran.text?.includes('"launch_id"'))
728      const ids = hasStarted ? launched(e.command) : []
729      if (ids.length) void add($, ids).catch(() => undefined)
730    } catch {
731      // Watching must never cost the call its result.
732    }
733
734    return ran
735  }).catch(($, e, next) => next(e))
736
737  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
738    const { Box, Text, Button, Link } = $.ui.resolve(e)
739    const redraw = () => $.ui.invalidate('ui.render')
740    const columns = Math.max(40, e.props.bodyColumns)
741    const isDesktop = e.surface === 'desktop'
742    // The desktop app draws the icon; elsewhere its glyph stands for it. A live icon moves, so it is drawn in a frame.
743    const mark = (icon: Icon, glyph: string, color: Color, alt: string) =>
744      e.surface === 'desktop' ? (
745        $.ui.resolve(e).Svg({
746          source: svg(icon),
747          alt,
748          width: 14,
749          height: 14,
750          ...(icon.isLive || icon.shape === 'live' ? { isInteractive: true } : {}),
751        })
752      ) : (
753        <Text color={color}>{glyph}</Text>
754      )
755    const all = batches.flatMap(batch => batch.issues.map(issue => stageOf(batch.board, issue)))
756    const tally = (kind: Kind) => all.filter(stage => stage.kind === kind).length
757    const totals: [Kind, string, Color][] = [
758      ['done', 'done', HEX.done],
759      ['running', 'in progress', 'warning'],
760      ['stopped', 'stopped', 'warning'],
761      ['deferred', 'deferred', 'inactive'],
762      ['queued', 'queued', 'inactive'],
763    ]
764
765    const tab = (name: typeof view, label: string, hotkey: string) => (
766      <Button
767        key={'view-' + name}
768        label={label}
769        hotkey={hotkey}
770        variant={view === name ? 'primary' : 'secondary'}
771        onPress={() => {
772          view = name
773          redraw()
774        }}
775      />
776    )
777
778    // The lineage view: each batch under the batch it stems from, the reason written on the way down.
779    const { parentOf, childrenOf } = family(batches)
780    // A drawing past the size a surface takes gives way to the list.
781    const drawn = view === 'lineage' && lineageAs === 'graph' && batches.length ? lineageGraph(batches) : null
782    const graph =
783      drawn && drawn.source.length <= 131_072 && e.surface === 'desktop'
784        ? // Its size is given outright: a drawing in a frame is not sized by its own markup.
785          $.ui.resolve(e).Svg({
786            source: drawn.source,
787            alt: drawn.alt,
788            width: Math.round(drawn.width * zoom),
789            height: Math.round(drawn.height * zoom),
790            isInteractive: true,
791          })
792        : null
793    const shape = (name: typeof lineageAs, label: string) => (
794      <Button
795        key={'lineage-' + name}
796        label={label}
797        variant={lineageAs === name ? 'primary' : 'secondary'}
798        onPress={() => {
799          lineageAs = name
800          redraw()
801        }}
802      />
803    )
804    const node = (batch: Batch) => {
805      const state = batchState(batch)
806      const look = batchLook(batch, state)
807      const done = batch.issues.filter(issue => stageOf(batch.board, issue).kind === 'done').length
808      const started = batch.board?.batch.first_launch_at
809
810      return (
811        <Box flexDirection="column">
812          <Box flexDirection="row" alignItems="center" columnGap={1}>
813            {mark(look.icon, look.glyph, look.color, state)}
814            <Box flexGrow={1}>
815              <Button
816                key={'node-' + batch.id}
817                plain
818                onPress={() => {
819                  view = 'board'
820                  toggled.set(batch.id, true)
821                  redraw()
822                }}
823              >
824                <Text bold>{batch.id.replace(/-\d{8}$/, '')}</Text>
825                {state !== 'complete' && <Text color={look.color}>{'  ' + state}</Text>}
826                <Text dimColor>{'  ' + done + '/' + batch.issues.length}</Text>
827              </Button>
828            </Box>
829            {started && <Text dimColor>{eastern(started)}</Text>}
830          </Box>
831          <Box paddingLeft={3}>
832            <Text dimColor wrap="wrap">
833              {linkOf(batch)?.purpose ?? 'No purpose recorded.'}
834            </Text>
835          </Box>
836        </Box>
837      )
838    }
839    const why = (batch: Batch) => {
840      const link = linkOf(batch)
841      if (!link?.reason) return <Text color="warning">↓ No reason recorded for this batch.</Text>
842
843      return (
844        <Box flexDirection="column">
845          <Text wrap="wrap">
846            <Text color="suggestion" bold>
847              {parentOf(batch) ? '↓ ' : '● '}
848            </Text>
849            <Text italic>{link.reason}</Text>
850          </Text>
851          {link.from_issues?.length ? <Text dimColor>{'   from ' + link.from_issues.join(', ')}</Text> : null}
852          {link.note && (
853            <Text dimColor italic wrap="wrap">
854              {'   ' + link.note}
855            </Text>
856          )}
857        </Box>
858      )
859    }
860    // A batch with one successor continues straight down; where several stem from it, each gets its own branch.
861    const seen = new Set<string>()
862    const chain = (batch: Batch): JSX.Element[] => {
863      if (seen.has(batch.id)) return []
864      seen.add(batch.id)
865      const next = childrenOf(batch.id)
866      const [only] = next
867      if (next.length === 1 && only) return [node(batch), why(only), ...chain(only)]
868
869      return [
870        node(batch),
871        ...next.map(one => (
872          <Box
873            flexDirection="column"
874            marginLeft={1}
875            paddingLeft={1}
876            rowGap={isDesktop ? 1 : 0}
877            borderStyle="quote"
878            borderColor="inactive"
879            borderDimColor
880          >
881            {why(one)}
882            {chain(one)}
883          </Box>
884        )),
885      ]
886    }
887
888    return (
889      <Box flexDirection="column">
890        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
891          <Box flexDirection="row" columnGap={1}>
892            {tab('board', 'Board', 'b')}
893            {tab('lineage', 'Lineage', 'l')}
894          </Box>
895          <Button
896            key="refresh"
897            label="Refresh"
898            hotkey="r"
899            onPress={() => void refresh($)}
900          />
901        </Box>
902        <Text>
903          <Text bold>{batches.length} batches</Text>
904          <Text dimColor>{'  ·  ' + all.length + ' issues'}</Text>
905          {totals
906            .filter(([kind]) => tally(kind) > 0)
907            .map(([kind, word, color]) => (
908              <Text color={color}>{'  ·  ' + tally(kind) + ' ' + word}</Text>
909            ))}
910          {updatedAt !== null && <Text dimColor>{'  ·  read ' + eastern(updatedAt, true)}</Text>}
911        </Text>
912        {batches.length === 0 && (
913          <Text dimColor>No batch here yet. Add one with /runner-board add &lt;batch id&gt;; a trailing * adds several.</Text>
914        )}
915        {view === 'lineage' && (
916          <Box flexDirection="column" marginTop={1} rowGap={isDesktop ? 1 : 0}>
917            {batches.some(batch => linkOf(batch)?.isDraft) && (
918              <Text color="warning" wrap="wrap">
919                Draft: purposes and reasons from a lineage file that is not yet reviewed.
920              </Text>
921            )}
922            {isDesktop && (
923              <Box flexDirection="row" alignItems="center" columnGap={1}>
924                {shape('graph', 'Graph')}
925                {shape('list', 'List')}
926                {graph && (
927                  <Button
928                    key="zoom-out"
929                    label="−"
930                    onPress={() => {
931                      zoom = Math.max(0.6, Math.round((zoom - 0.2) * 10) / 10)
932                      redraw()
933                    }}
934                  />
935                )}
936                {graph && (
937                  <Button
938                    key="zoom-in"
939                    label="+"
940                    onPress={() => {
941                      zoom = Math.min(2.4, Math.round((zoom + 0.2) * 10) / 10)
942                      redraw()
943                    }}
944                  />
945                )}
946                {graph && <Text dimColor>{Math.round(zoom * 100)}% · hover a batch or a line</Text>}
947              </Box>
948            )}
949            {graph ?? childrenOf(null).flatMap(root => [why(root), ...chain(root)])}
950          </Box>
951        )}
952        <Box flexDirection="column" marginTop={1} rowGap={isDesktop ? 1 : 0} display={view === 'board' ? 'flex' : 'none'}>
953          {batches.map(batch => {
954            const about = batch.board?.batch
955            const state = batchState(batch)
956            const look = batchLook(batch, state)
957            const stages = batch.issues.map(issue => stageOf(batch.board, issue))
958            const done = stages.filter(stage => stage.kind === 'done').length
959            const isOpen = toggled.get(batch.id) ?? state !== 'complete'
960            const isQuiet = state === 'complete'
961            const name = batch.id.replace(/-\d{8}$/, '')
962            const started = about?.first_launch_at
963            const purpose = linkOf(batch)?.purpose
964            const flags = [about?.stop_marker ? 'STOP marker' : '', about?.pending_recovery ? 'recovery pending' : '']
965              .filter(Boolean)
966              .join(' · ')
967
968            return (
969              <Box
970                key={batch.id}
971                flexDirection="column"
972                paddingX={1}
973                borderStyle="round"
974                borderColor={look.border}
975                borderDimColor={isQuiet}
976                hover={{ borderColor: 'text', borderDimColor: false }}
977                {...(state === 'running' && isDesktop ? { backgroundColor: HEX.started + '14' } : {})}
978              >
979                <Box flexDirection="row" alignItems="center" columnGap={1}>
980                  {mark(look.icon, look.glyph, look.color, state)}
981                  <Box flexGrow={1}>
982                    <Button
983                      key={'batch-' + batch.id}
984                      plain
985                      onPress={() => {
986                        toggled.set(batch.id, !isOpen)
987                        redraw()
988                      }}
989                    >
990                      <Text bold={!isQuiet}>{name}</Text>
991                      {!isQuiet && (
992                        <Text color={look.color} bold>
993                          {'  ' + state}
994                        </Text>
995                      )}
996                      <Text dimColor>{'  ' + done + '/' + batch.issues.length}</Text>
997                      {isOpen && !isQuiet && flags && <Text color="warning">{'  ' + flags}</Text>}
998                      <Text dimColor>{isOpen ? '  ▾' : '  ▸'}</Text>
999                    </Button>
1000                  </Box>
1001                  {started && <Text dimColor>{eastern(started)}</Text>}
1002                </Box>
1003                {purpose && (
1004                  <Box paddingLeft={3}>
1005                    <Text dimColor wrap="wrap">
1006                      {purpose}
1007                    </Text>
1008                  </Box>
1009                )}
1010                {isOpen && batch.problem && <Text color="error">{cut(batch.problem, 300)}</Text>}
1011                {isOpen && about?.error && <Text color="error">{cut(String(about.error), 300)}</Text>}
1012                {isOpen && batch.issues.length > 0 && (
1013                  // A bar down the left with a dash to each issue: the batch's issues as a tree.
1014                  <Box
1015                    flexDirection="column"
1016                    marginLeft={isDesktop ? 1 : 0}
1017                    borderStyle="quote"
1018                    borderColor="inactive"
1019                    borderDimColor
1020                  >
1021                    {batch.issues.map((issue, index) => {
1022                      const stage = stages[index] ?? stageOf(batch.board, issue)
1023                      const held = issueOf(batch.board, issue)
1024                      const key = batch.id + '/' + issue
1025                      const isPlain = stage.kind === 'done' || stage.kind === 'queued'
1026                      const cost = spent(held, stage.kind === 'running')
1027                      const used = issue.length + (isPlain ? 0 : stage.label.length + 2) + cost.length + 12
1028                      const title = held?.title ? cut(held.title, Math.max(16, columns - used)) : ''
1029
1030                      return (
1031                        <Box key={key} flexDirection="column">
1032                          <Box flexDirection="row" alignItems="center" columnGap={1}>
1033                            <Text color="inactive">─</Text>
1034                            {mark(stage.icon, stage.glyph, stage.color, stage.label)}
1035                            <Box flexGrow={1}>
1036                              <Button
1037                                key={'issue-' + key}
1038                                plain
1039                                onPress={() => {
1040                                  expanded = expanded === key ? null : key
1041                                  redraw()
1042                                }}
1043                              >
1044                                <Text bold={!isPlain} dimColor={stage.kind === 'queued'}>
1045                                  {issue}
1046                                </Text>
1047                                {!isPlain && <Text color={stage.color}>{'  ' + stage.label}</Text>}
1048                                {title && <Text dimColor>{'  ' + title}</Text>}
1049                              </Button>
1050                            </Box>
1051                            {cost && <Text dimColor>{cost}</Text>}
1052                          </Box>
1053                          {expanded === key && (
1054                            <Box
1055                              flexDirection="column"
1056                              marginLeft={4}
1057                              marginBottom={1}
1058                              paddingLeft={1}
1059                              borderStyle="quote"
1060                              borderColor={stage.icon.hex}
1061                            >
1062                              {held?.url && <Link href={held.url} label={held.title ?? `${issue} in Linear`} />}
1063                              {details(batch.board, issue).map(line => (
1064                                <Box flexDirection="column">
1065                                  <Text wrap="wrap">
1066                                    {line.label && <Text bold>{line.label + '  '}</Text>}
1067                                    {line.text && <Text dimColor>{line.text}</Text>}
1068                                  </Text>
1069                                  {line.items?.map(item => (
1070                                    <Text dimColor wrap="wrap">
1071                                      {'  • ' + item}
1072                                    </Text>
1073                                  ))}
1074                                </Box>
1075                              ))}
1076                            </Box>
1077                          )}
1078                        </Box>
1079                      )
1080                    })}
1081                  </Box>
1082                )}
1083              </Box>
1084            )
1085          })}
1086        </Box>
1087        {all.length > 0 && (
1088          <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
1089            {LEGEND.map(([icon, glyph, word]) => (
1090              <Box flexDirection="row" alignItems="center" columnGap={1}>
1091                {mark(icon, glyph, icon.hex, word)}
1092                <Text dimColor>{word}</Text>
1093              </Box>
1094            ))}
1095          </Box>
1096        )}
1097      </Box>
1098    )
1099  })
1100}
1101