import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; import { join } from "node:path"; import { emitKeypressEvents } from "node:readline"; import { findProject, loadConfig, resolveCaps } from "./config.ts"; import { statusSnapshotFromStore, type StatusSnapshot } from "./daemon.ts"; import { classifyDaemonProjectHealth, codeGraphFromHealthz, fleetLayers, probeTelegramHealth, workerPhasesFromHealthz, } from "./fleet.ts"; import type { FleetLayers, TelegramHealth } from "./status-render.ts"; import { probeCodeGraph, DEFAULT_DEPS, type CodeGraphHealth } from "./graph-health.ts"; import { healthCheck, livingDaemon } from "./lifecycle.ts"; import { httpAuthHeader, missingHttpTokenMessage } from "./http-token.ts"; import type { WorkerPausePhase } from "./worker.ts"; import type { WorkerPauseView } from "./fleet.ts"; import { dbPath, openStore } from "./store.ts"; import { formatTranscriptLine } from "./transcript.ts"; import { makeTracker } from "./tracker/github.ts"; import { planUsageBadge, readPlanUsage, sharedUsageSource } from "./usage.ts"; import type { AdmissionHoldReason, Caps, PrVerification, ProjectConfig, ReadyIssue, RunRecord, RunState, Store, Tracker, } from "./types.ts"; const REFRESH_MS = 1_000; /** How long an armed pause confirmation stays valid (#997). */ const PAUSE_CONFIRM_MS = 8_000; const HEALTH_REFRESH_MS = 10_000; /** Base cadence for the tracker label read, and the ceiling the idle backoff * in {@link nextLabelDelayMs} doubles toward. Separate from the health gate * above because only this one leaves the machine: daemon state and the plan * allowance are local reads and stay fresh at the base cadence regardless. */ const LABEL_REFRESH_MS = 10_000; const LABEL_REFRESH_MAX_MS = 60_000; /** Cadence for re-verifying pushed rows against GitHub — deliberately slower * than the board's health/label probe: it is one `gh` call per pushed row, and * pull-request state does not move at 1 Hz. #173. */ const PR_REFRESH_MS = 60_000; const MERGED_HISTORY_MS = 24 * 60 * 60_000; const TRANSCRIPT_BYTES = 64 * 1024; const MIN_WIDTH = 50; const MIN_HEIGHT = 20; const MIN_COLUMN_WIDTH = 22; const CSI = "\x1b["; const RESET = `${CSI}0m`; const BOLD = `${CSI}1m`; const DIM = `${CSI}2m`; const REVERSE = `${CSI}7m`; const CYAN = `${CSI}36m`; const GREEN = `${CSI}32m`; const YELLOW = `${CSI}33m`; const RED = `${CSI}31m`; const MAGENTA = `${CSI}35m`; /** * The board's lanes, in operator reading order. * * Each entry used to carry the `RunState[]` that filled it, which is the defect * #109 reports: a lane filled from the newest run row answers "what happened * last" under a heading an operator reads as "what needs action". Membership is * now decided once, by {@link laneOf}, from state that is true right now. */ const COLUMN_DEFS = [ { key: "queue", title: "QUEUE" }, { key: "claimed", title: "CLAIMED" }, { key: "running", title: "RUNNING" }, { key: "green", title: "GREEN" }, { key: "blocked", title: "BLOCKED" }, { key: "failed", title: "FAILED" }, { key: "orphaned", title: "ORPHANED" }, { key: "merged", title: "MERGED" }, { key: "settled", title: "SETTLED" }, { key: "parked", title: "PARKED" }, ] as const satisfies readonly { key: string; title: string }[]; export type BoardLane = (typeof COLUMN_DEFS)[number]["key"]; /** Run states that mean a worker process or an open pull request exists *now*, * and the lane each is drawn in. Being in this table is why the store outranks * every label in {@link laneOf}: no label is more current than a live run. */ const LIVE_LANES: Partial> = { claimed: "claimed", running: "running", "pushed-pending": "green", "pushed-green": "green", }; /** Terminal run states that park an issue nobody has acted on: a human cleared * the state labels but the issue is still open, so its last run is the story * that has to stay visible — #173. Differs from {@link LIVE_LANES} in that * these are rows, never current work, and from MERGED in that they are not a * happy resolution. */ const PARKED_STATES = new Set(["blocked", "failed", "killed", "stopped", "orphaned"]); /** Whether a terminal blocked/failed run is wearing its own state label. When * the label is absent the run row is the only record of what happened, so the * card and detail header say "last run: " rather than presenting it as * current — #173, the same distinction that parks the lane. */ function isLastRun(run: RunRecord, labels: BoardLabels): boolean { if (run.state === "blocked") return !labels.blocked.has(run.issue); if (run.state === "failed") return !labels.failed.has(run.issue); return false; } type DaemonBoardState = "stopped" | "ok" | "unreachable" | "unresponsive" | "other-project"; /** * A card the board can only describe from tracker state, because the store has * no run row for the issue: it was placed by a label it currently carries, or * by an admission hold recorded in the latest tick. */ interface IssueCard { kind: "issue"; issue: number; /** Why it is in this lane: the hold keeping it out of dispatch, or the label. */ reason: string; /** Where that fact came from, in one short line. */ note: string; } interface RunCard { kind: "run"; run: RunRecord; } type BoardCard = IssueCard | RunCard; export interface BoardHealth { layers: FleetLayers; telegram: TelegramHealth; daemon: DaemonBoardState; codeGraph?: CodeGraphHealth; } interface BoardHealthProbe { health: BoardHealth; pausedPhases: ReadonlyMap; } /** * Which issues currently carry each label the board reasons about, read from * the tracker rather than inferred from run rows. * * The store cannot answer this. It only knows what it did itself, so a label a * human added or removed, and an issue closed out from under a run row, are * both invisible to it — which is how eleven historical terminal runs were * drawn as failed work on 2026-08-08 (#109). */ export interface BoardLabels { /** Open issues carrying `ProjectConfig.queueLabel` — what dispatches next. */ queued: ReadonlySet; inProgress: ReadonlySet; blocked: ReadonlySet; failed: ReadonlySet; /** * Issues the tracker reports CLOSED, derived only from a *successful* label * snapshot on the same 10s cadence as the other sets. This is the one fact * that separates SETTLED (finished work, issue closed) from PARKED (a human * still has to look): absence from this set means open **or** the probe could * not tell, and either way the run parks rather than falsely asserting * closure. On a failed read the last good set survives behind {@link error}. */ closed: ReadonlySet; /** Epoch ms of the last read that succeeded; 0 when none has. */ readAt: number; /** Message from the most recent failed read. The sets above are then the last * good ones: blanking QUEUE and FAILED is a louder lie than showing them * stale, so the failure is reported in the header instead. */ error?: string; } /** The sets before the first read lands, and the fallback when a read has never * succeeded. `readAt: 0` is what {@link labelFact} reads as "unread". */ const UNREAD_LABELS: BoardLabels = { queued: new Set(), inProgress: new Set(), blocked: new Set(), failed: new Set(), closed: new Set(), readAt: 0, }; export interface BoardSnapshot { project: ProjectConfig; status: StatusSnapshot; health: BoardHealth; pausedPhases: ReadonlyMap; labels: BoardLabels; /** Live re-verification of pushed rows (#173): run id → what the tracker says * the PR looks like now. Rendered as a `now:` suffix on the card. */ pr: ReadonlyMap; runs: RunRecord[]; /** What this board itself has spent on the tracker in the last minute, and * how much of that was free 304s (#210). Absent in one-shot contexts like * `board --json`, which have no rate to report. */ trackerCost?: { calls: number; free: number }; now: number; } /** The card state a {@link PrVerification.status} reads as in the UI. */ function prLabel(status: PrVerification["status"]): string { return status === "green" ? "green" : status === "failed" ? "red" : "pending"; } export interface BoardCursor { column: number; card: number; detail: boolean; transcriptOffset: number; selection?: string; selectionIssue?: number; } interface KeyInput { name?: string; sequence?: string; ctrl?: boolean; } function ansiColor(key: BoardLane): string { switch (key) { case "running": return CYAN; case "green": case "merged": return GREEN; case "claimed": case "blocked": case "orphaned": case "parked": return YELLOW; case "failed": return RED; case "settled": return DIM; default: return MAGENTA; } } function clip(value: string, width: number): string { if (width < 1) return ""; if (value.length <= width) return value.padEnd(width); if (width === 1) return value.slice(0, 1); return `${value.slice(0, width - 1)}…`; } function styledCell(value: string, width: number, style = ""): string { const clipped = clip(value, width); return style === "" ? clipped : `${style}${clipped}${RESET}`; } function wrapDelimited(value: string, width: number, delimiter: string): string[] { const lines: string[] = []; let current = ""; for (const segment of value.split(delimiter)) { const candidate = current === "" ? segment : `${current}${delimiter}${segment}`; if (candidate.length <= width) { current = candidate; continue; } if (current !== "") lines.push(current); const wrapped = wrap(segment, width); lines.push(...wrapped.slice(0, -1)); current = wrapped.at(-1) ?? ""; } if (current !== "") lines.push(current); return lines.length === 0 ? [""] : lines; } function humanDuration(ms: number): string { const minutes = Math.max(0, Math.floor(ms / 60_000)); if (minutes < 1) return "<1m"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; return `${Math.floor(hours / 24)}d ${hours % 24}h`; } /** * The board's single lane rule. * * `omp-conductor board` answers one operator question — "what needs action, and * what will dispatch next?" — so every lane has to be a statement about state * *now*. What an issue's newest run row last did is a different question, and * answering it under an operational heading is the 2026-08-08 dogfood failure * in #109: the board read `FAILED 11` on conductor 0.3.19 while the tracker * carried four `agent:failed` issues (#82, #140, #297, #307). The other seven * were two requeued issues also drawn in QUEUE (#43, #321), a deliberately * protected dirty orphan (#319), two closed issues (#135, #310) and two open * parent issues a human had already cleared (#81, #86) — every one of them a * historical terminal run, not one of them failed work. * * so: the store decides the live lanes, because a running worker is the most * current fact there is; the tracker's current labels decide every stopped * lane; each issue matches exactly once, first branch wins; and last-run * history is answered in SETTLED (issue closed) and PARKED (issue still open, * labels cleared), under their own names. */ function laneOf( snapshot: BoardSnapshot, issue: number, run: RunRecord | undefined, queued: ReadonlySet, ): BoardLane | undefined { const live = run === undefined ? undefined : LIVE_LANES[run.state]; if (live !== undefined) return live; const { labels } = snapshot; // State labels outrank the queue label, because that is the order // `routing.isEligible` reads them in: it requires the queue label AND the // absence of all three state labels, so an issue carrying both will never be // dispatched no matter how queued it looks. Drawing it under QUEUE — "waiting // for dispatch" — is the same class of lie as #109 itself, just inverted, and // it is not hypothetical: veltro#319 on 2026-08-09 carried `ready-for-agent` // and `agent:in-progress` together, so a queue-first rule puts the protected // dirty orphan back in the queue lane this issue moved it out of. if (labels.failed.has(issue)) return "failed"; if (labels.blocked.has(issue)) return "blocked"; // The tracker says a worker owns this issue and the store says none is // running. Nothing will touch it until a human does, and its worktree is kept // because it may hold uncommitted work: that is a protected orphan, and // calling it a failure is what put #319 under FAILED. if (labels.inProgress.has(issue)) return "orphaned"; // What is left carrying the queue label really is next up, held or not. // Requeued work is queued work: #43 and #321 carried `ready-for-agent` and a // terminal newest run at the same time, and with no state label on them the // label is the whole of what happens next. if (queued.has(issue)) return "queue"; if (run?.state === "merged") return "merged"; if (run === undefined) return undefined; // SETTLED vs PARKED, decided by the one fact the board can prove: whether the // tracker confirmed the issue closed. Absence from `closed` means open OR the // probe could not tell, and either way the run parks — a failed label read // must render the issue as "a human has to look", never as finished work // (#218). PARKED is deliberately not subject to MERGED_HISTORY_MS: parked work // does not stop being the issue's last word after 24 hours. if (PARKED_STATES.has(run.state) && !labels.closed.has(issue)) return "parked"; // What is left is a confirmed-closed issue with a terminal run row. Recent // rows stay readable under SETTLED for the same 24 hours MERGED uses; older // ones are not news. Named for what it means — the run settled and the issue // is closed — not for the vague archive HISTORY read as, which is how one lane // came to look like two different things (#218). return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "settled" : undefined; } /** Description lines for an issue the store has no run row for. Only the lanes * a label or a hold can place on their own reach this: queue, failed, blocked * and orphaned. */ function issueCard( snapshot: BoardSnapshot, issue: number, lane: BoardLane, holds: ReadonlyMap, ): IssueCard { const hold = holds.get(issue); if (lane === "queue") { return hold === undefined ? { kind: "issue", issue, reason: "queued", note: "waiting for dispatch" } : { kind: "issue", issue, reason: hold.replaceAll("-", " "), note: "held in latest tick" }; } const { blocked, failed, inProgress } = snapshot.project.stateLabels; const label = lane === "failed" ? failed : lane === "blocked" ? blocked : inProgress; return { kind: "issue", issue, reason: label, note: "no run recorded" }; } /** The whole board, partitioned. Rebuilt per call rather than cached: it is * tens of issues wide and redraws once a second, so a cache would buy nothing * and would go stale the moment a caller mutated a snapshot in place. */ function laneCards(snapshot: BoardSnapshot): Map { const lanes = new Map(); for (const column of COLUMN_DEFS) lanes.set(column.key, []); // `recentRuns` already yields the newest attempt per issue; indexing it keeps // one card per issue even if a caller hands over a wider run list. const newest = new Map(); for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run); // Holds name issues the latest dispatch pass looked at and kept back, so they // are queue members by definition. They join the label set rather than // replacing it: the label read is authoritative and uncapped, while the hold // sample is bounded to five per reason but survives a tracker read that just // failed. const queued = new Set(snapshot.labels.queued); const holds = new Map(); for (const hold of snapshot.status.dispatch?.holds ?? []) { for (const issue of hold.issues) { queued.add(issue); if (!holds.has(issue)) holds.set(issue, hold.reason); } } const place = (issue: number, run: RunRecord | undefined): void => { const lane = laneOf(snapshot, issue, run, queued); if (lane === undefined) return; lanes.get(lane)!.push(run === undefined ? issueCard(snapshot, issue, lane, holds) : { kind: "run", run }); }; for (const [issue, run] of newest) place(issue, run); // Issues the tracker knows about that this store never ran, or whose run rows // have aged out. FAILED has to count them: the operator is asking how many // issues are failed, not how many failures this database still remembers. const labelled = [...queued, ...snapshot.labels.failed, ...snapshot.labels.blocked, ...snapshot.labels.inProgress]; for (const issue of [...new Set(labelled)].sort((a, b) => a - b)) { if (!newest.has(issue)) place(issue, undefined); } return lanes; } function cardsFor(snapshot: BoardSnapshot, key: BoardLane): BoardCard[] { return laneCards(snapshot).get(key) ?? []; } /** Issue numbers per lane, in render order. Exported so the lane rule can be * asserted directly — including that no issue is in two lanes, the defect this * model exists to make impossible. */ export function boardLanes(snapshot: BoardSnapshot): Map { const lanes = new Map(); for (const [lane, cards] of laneCards(snapshot)) lanes.set(lane, cards.map(cardIssue)); return lanes; } /** * The four lifecycle label sets of one open-issue snapshot. * * Label compare is case-insensitive: GitHub label names are unique * case-insensitively, and the server-side filter this replaces matched them * that way. An issue carrying more than one of the four lands in each matching * set, exactly as the four separate label queries did — `laneOf` already * resolves that precedence. */ export function boardLabelSets( issues: readonly ReadyIssue[], project: ProjectConfig, ): { queued: Set; inProgress: Set; blocked: Set; failed: Set } { const queued = new Set(); const inProgress = new Set(); const blocked = new Set(); const failed = new Set(); const names: Record<"queued" | "inProgress" | "blocked" | "failed", string> = { queued: project.queueLabel.toLowerCase(), inProgress: project.stateLabels.inProgress.toLowerCase(), blocked: project.stateLabels.blocked.toLowerCase(), failed: project.stateLabels.failed.toLowerCase(), }; for (const issue of issues) { for (const label of issue.labels) { const lower = label.toLowerCase(); if (lower === names.queued) queued.add(issue.number); else if (lower === names.inProgress) inProgress.add(issue.number); else if (lower === names.blocked) blocked.add(issue.number); else if (lower === names.failed) failed.add(issue.number); } } return { queued, inProgress, blocked, failed }; } function sameIssueSet(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false; for (const issue of a) if (!b.has(issue)) return false; return true; } /** * Whether two label reads say the same thing. * * Read outcome counts as well as membership, but only whether a read *failed* * — never the message. GitHub's failures carry a fresh request id every time * (`... request ID E05A:12929D:...`), so comparing text would score two * identical failures as a change and pin the fast cadence on a tracker that is * broken, which is exactly backwards. */ export function boardLabelsEqual(a: BoardLabels, b: BoardLabels): boolean { return ( sameIssueSet(a.queued, b.queued) && sameIssueSet(a.inProgress, b.inProgress) && sameIssueSet(a.blocked, b.blocked) && sameIssueSet(a.failed, b.failed) && sameIssueSet(a.closed, b.closed) && (a.error === undefined) === (b.error === undefined) ); } /** * How long to wait before the next tracker label read. * * #203 made that read a single conditional request that answers 304 while * nothing changes, so an idle board no longer spends rate-limit budget — but a * request nobody needs is still a request, and a board left open in a pane all * day makes thousands of them. Each pass that finds nothing changed doubles the * wait up to {@link LABEL_REFRESH_MAX_MS}; any change, and any operator * keypress, drops it straight back to {@link LABEL_REFRESH_MS}. So the board is * never more than a minute behind, and is back to base cadence the moment * either the work or the operator moves. */ export function nextLabelDelayMs(current: number, unchanged: boolean): number { return unchanged ? Math.min(current * 2, LABEL_REFRESH_MAX_MS) : LABEL_REFRESH_MS; } function cardIssue(card: BoardCard): number { return card.kind === "issue" ? card.issue : card.run.issue; } function cardKey(card: BoardCard): string { return card.kind === "issue" ? `issue:${card.issue}` : `run:${card.run.id}`; } function focusedCard(snapshot: BoardSnapshot, cursor: BoardCursor): BoardCard | undefined { const column = COLUMN_DEFS[cursor.column]; if (column === undefined) return undefined; return cardsFor(snapshot, column.key)[cursor.card]; } function selectCard(snapshot: BoardSnapshot, cursor: BoardCursor, requested: number): void { const column = COLUMN_DEFS[cursor.column]!; const cards = cardsFor(snapshot, column.key); cursor.card = Math.max(0, Math.min(Math.max(0, cards.length - 1), requested)); const card = cards[cursor.card]; if (card === undefined) { delete cursor.selection; delete cursor.selectionIssue; } else { cursor.selection = cardKey(card); cursor.selectionIssue = cardIssue(card); } } function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void { cursor.column = Math.max(0, Math.min(COLUMN_DEFS.length - 1, cursor.column)); const hadSelection = cursor.selection !== undefined || cursor.selectionIssue !== undefined; let foundColumn = -1; let foundCard = -1; if (cursor.selection !== undefined) { for (const [columnIndex, column] of COLUMN_DEFS.entries()) { const cardIndex = cardsFor(snapshot, column.key).findIndex((card) => cardKey(card) === cursor.selection); if (cardIndex < 0) continue; foundColumn = columnIndex; foundCard = cardIndex; break; } } if (foundColumn < 0 && cursor.selectionIssue !== undefined) { for (const [columnIndex, column] of COLUMN_DEFS.entries()) { const cardIndex = cardsFor(snapshot, column.key).findIndex((card) => cardIssue(card) === cursor.selectionIssue); if (cardIndex < 0) continue; foundColumn = columnIndex; foundCard = cardIndex; break; } } if (foundColumn >= 0) { cursor.column = foundColumn; selectCard(snapshot, cursor, foundCard); } else { if (hadSelection) cursor.detail = false; selectCard(snapshot, cursor, cursor.card); } cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset); } function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] { const endedAt = run.endedAt ?? snapshot.now; const duration = humanDuration(endedAt - run.startedAt); const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue)?.phase : undefined; const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : ""; const lines = [ // The class, when the sweep has attached one and nothing has recovered it // yet (#132): a row state says "failed", which four completed issues on this // fleet also said. The class says which of the two this is. run.failureClass === undefined || run.recoveredAt !== undefined ? `${pausePrefix}#${run.issue} · ${run.repo}` : `${pausePrefix}#${run.issue} · ${run.repo} [${run.failureClass}]`, `attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`, `$${run.spendUsd.toFixed(2)} · ${duration}`, ]; // Exactly one fourth line, so a card stays within the four content lines that // renderColumn's slot arithmetic (`slots = floor((height - 2) / 5)`) allocates // for it. Every fact a card can carry folds into this single priority: // an unsalvaged tree outranks even a live verdict (#118) — the error describes // a run that is over, the tree is work still at risk — then the live probe for // a pushed row, then the word the lane name cannot carry, then the run's own // evidence. // // `isLastRun` is deliberately not a card line: it belongs in the detail header // (renderDetail's `stateShown`), where a full width can spell out `last run: // blocked`. Putting it here too would blow the budget and repeat the #218 // conflation on the two-line boundary. if (run.salvageError !== undefined && run.salvageAckAt === undefined) lines.push("UNSALVAGED WIP"); else { // #173: a live re-verification of a pushed row, when the 60s probe has one. // Guarded on the pushed states: a stale entry must not follow a row that // settled into another lane and keep claiming "now: green" about a PR that // already merged or failed. const live = run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined; if (live !== undefined) { const reason = live.reason.split(/\s+/, 1)[0] ?? ""; lines.push( live.status === "failed" ? `now: red — ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`, ); } else if (lane === "settled") lines.push("issue closed"); else if (lane === "parked") lines.push("needs a label"); else if (run.state === "pushed-pending") lines.push("checks pending"); else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`); else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " ")); else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, "")); else lines.push(run.branch); } return lines; } function renderColumn( snapshot: BoardSnapshot, key: BoardLane, title: string, selectedColumn: boolean, selectedCard: number, width: number, height: number, ): string[] { const cards = cardsFor(snapshot, key); const color = ansiColor(key); const slots = Math.max(1, Math.floor(Math.max(0, height - 2) / 5)); const start = selectedColumn ? Math.max(0, Math.min(Math.max(0, cards.length - slots), selectedCard - Math.floor(slots / 2))) : 0; const shown = cards.slice(start, start + slots); const lines = [ styledCell(` ${title} ${cards.length}`, width, `${BOLD}${color}`), styledCell(start === 0 ? "─".repeat(width) : ` ↑ ${start} earlier`, width, DIM), ]; if (cards.length === 0) { lines.push(styledCell(" (empty)", width, DIM)); } else { for (const [offset, card] of shown.entries()) { const raw = card.kind === "run" ? runCardLines(card.run, snapshot, key) : [`#${card.issue}`, card.reason, card.note]; while (raw.length < 4) raw.push(""); const selected = selectedColumn && start + offset === selectedCard; for (const [lineIndex, value] of raw.entries()) { const prefix = lineIndex === 0 ? (selected ? "> " : " ") : " "; lines.push(styledCell(`${prefix}${value}`, width, selected ? REVERSE : "")); } lines.push(" ".repeat(width)); } } const hiddenAfter = Math.max(0, cards.length - start - shown.length); if (hiddenAfter > 0) lines[lines.length - 1] = styledCell(` ↓ ${hiddenAfter} more`, width, DIM); while (lines.length < height) lines.push(" ".repeat(width)); return lines.slice(0, height); } function healthLine(snapshot: BoardSnapshot): string { const { layers, daemon } = snapshot.health; const dispatch = daemon === "other-project" ? "other-project" : layers.dispatch; let nextTick = ""; if (layers.nextTickAt !== undefined) { const parsed = Date.parse(layers.nextTickAt); nextTick = ` next ${Number.isNaN(parsed) ? layers.nextTickAt : new Date(parsed).toISOString().slice(11, 19)}`; } return [ `dispatch ${dispatch}`, `ticks ${layers.ticks}${nextTick}`, `pane ${layers.pane}`, `recovery ${layers.recovery}`, `herdr ${layers.herdr}`, `daemon ${daemon}`, ].join(" · "); } /** * Sliding one-minute count of the board's own tracker requests, pruning what * has aged out of the window. * * #210 asks for the board's request cost to be visible to a human: a board * left open in a pane spent 95% of the fleet's GitHub budget for three and a * half hours and nothing on screen said so. Mutates the two windows in place — * they are the caller's ring buffers, kept small by this prune. */ export function trackerCostWindow( calls: number[], free: number[], now: number, windowMs = 60_000, ): { calls: number; free: number } { const cutoff = now - windowMs; while (calls.length > 0 && calls[0]! < cutoff) calls.shift(); while (free.length > 0 && free[0]! < cutoff) free.shift(); return { calls: calls.length, free: free.length }; } /** How current the label sets behind the lanes are, and what this board is * spending to keep them current. A lane rule is only as authoritative as its * last successful read, so a stale one says so instead of letting an empty * QUEUE read as an empty queue. */ function labelFact(snapshot: BoardSnapshot): string { const { readAt, error } = snapshot.labels; const cost = snapshot.trackerCost === undefined ? "" : ` · ${snapshot.trackerCost.calls}/min${snapshot.trackerCost.free === 0 ? "" : ` (${snapshot.trackerCost.free} free)`}`; if (error === undefined) return `tracker ok${cost}`; const detail = error.replace(/\s+/g, " ").slice(0, 80); if (readAt === 0) return `tracker unread${cost} — ${detail}`; return `tracker stale ${humanDuration(snapshot.now - readAt)}${cost} — ${detail}`; } function integrationLine(snapshot: BoardSnapshot): string { const { telegram, codeGraph } = snapshot.health; const graph = codeGraph?.configured ? `${codeGraph.status} ${codeGraph.repos.filter((repo) => repo.index === "present").length}/${codeGraph.repos.length} indexed` : "off"; const telegramDetail = telegram.detail?.replace(/\s+/g, " ").slice(0, 80); return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`} · ${labelFact(snapshot)}`; } function admissionLine(snapshot: BoardSnapshot): string { const dispatch = snapshot.status.dispatch; const cap = snapshot.status.caps.dailySpendUsd; const queue = dispatch === undefined ? "dispatch not recorded" : dispatch.paused === true ? `held pass — nothing admitted${(dispatch.settled ?? 0) > 0 ? ` · settled ${dispatch.settled}` : ""}` : `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`; const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", "); const holds = holdText === undefined || holdText === "" ? "none" : holdText; return ( `${queue} | holds ${holds} | workers ${snapshot.status.liveWorkers}/${snapshot.status.caps.maxConcurrentWorkers}` + ` | spend $${snapshot.status.spendTodayUsd.toFixed(2)}${cap === null ? "" : `/$${cap.toFixed(2)}`}` + // Both economic controls on one row, separately. #110's whole complaint was // that a subscription fleet could only see a dollar meter that cannot // represent its real ceiling. ` | ${planUsageBadge(snapshot.status.planUsage)}` ); } function header(snapshot: BoardSnapshot, width: number): string[] { const at = new Date(snapshot.now).toISOString().slice(11, 19); const degradedStyle = snapshot.status.dispatch?.degraded === true ? `${BOLD}${RED}` : DIM; return [ styledCell(` CONDUCTOR ${snapshot.project.name.toUpperCase()} ${" ".repeat(Math.max(1, width - snapshot.project.name.length - 22))}${at} `, width, `${BOLD}${REVERSE}`), ...wrapDelimited(healthLine(snapshot), width, " · ").map((line) => styledCell(line, width)), ...wrapDelimited(integrationLine(snapshot), width, " · ").map((line) => styledCell(line, width)), ...wrapDelimited(admissionLine(snapshot), width, " | ").map((line) => styledCell(line, width, degradedStyle)), ]; } function renderBoardColumns(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] { const visible = Math.max(1, Math.min(COLUMN_DEFS.length, Math.floor((width + 1) / (MIN_COLUMN_WIDTH + 1)))); const start = Math.max(0, Math.min(COLUMN_DEFS.length - visible, cursor.column - Math.floor(visible / 2))); const shown = COLUMN_DEFS.slice(start, start + visible); const columnWidth = Math.floor((width - (shown.length - 1)) / shown.length); const columns = shown.map((column, offset) => renderColumn( snapshot, column.key, column.title, start + offset === cursor.column, cursor.card, columnWidth, height, ), ); return Array.from({ length: height }, (_, row) => columns.map((column) => column[row]).join(" ")); } function readTranscript(run: RunRecord): string[] { if (run.sessionFile === undefined) return [`No transcript yet (state: ${run.state}).`]; // #172: a row name that never materialised is "not yet", not a read error — // rendering it as `Transcript unavailable` would blame the file for existing. if (!existsSync(run.sessionFile)) return [`No transcript yet (state: ${run.state}).`]; let fd: number | undefined; try { const size = statSync(run.sessionFile).size; const start = Math.max(0, size - TRANSCRIPT_BYTES); const buffer = Buffer.allocUnsafe(size - start); fd = openSync(run.sessionFile, "r"); const bytes = readSync(fd, buffer, 0, buffer.length, start); const raw = buffer.subarray(0, bytes).toString("utf8"); const source = start === 0 ? raw : raw.slice(Math.max(0, raw.indexOf("\n") + 1)); return source .split("\n") .flatMap((line) => formatTranscriptLine(line)?.split("\n") ?? []); } catch (err) { return [`Transcript unavailable: ${err instanceof Error ? err.message : String(err)}`]; } finally { if (fd !== undefined) closeSync(fd); } } function wrap(value: string, width: number): string[] { const words = value.replace(/\s+/g, " ").trim().split(" "); const lines: string[] = []; let line = ""; for (const word of words) { if (line === "") { line = word; } else if (line.length + word.length + 1 <= width) { line += ` ${word}`; } else { lines.push(line); line = word; } } if (line !== "") lines.push(line); return lines.length === 0 ? [""] : lines; } function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] { const card = focusedCard(snapshot, cursor); if (card === undefined) return [styledCell("No card selected.", width, DIM)]; if (card.kind === "issue") { return [ styledCell(` ISSUE #${card.issue} `, width, `${BOLD}${REVERSE}`), styledCell(`${card.reason} — ${card.note}`, width), "", styledCell("The store has no run row for this issue, so there is no transcript to inspect.", width, DIM), ]; } const run = card.run; // #173: a blocked/failed run whose label is gone is the reason this card is // parked; the header says so rather than presenting the state as current. const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state; const pausePhase = snapshot.pausedPhases.get(run.issue)?.phase; const metadata = [ styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`), ...(pausePhase === undefined ? [] : [ styledCell( pausePhase === "paused" ? "worker paused · wall clock frozen" : "worker pausing · draining to harness idle", width, YELLOW, ), ]), styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width), styledCell(`branch ${run.branch}`, width, DIM), styledCell(`worktree ${run.worktree || "removed"}`, width, DIM), styledCell("─".repeat(width), width, DIM), ]; const bodyHeight = Math.max(1, height - metadata.length); const wrapped = readTranscript(run).flatMap((line) => wrap(line, width)); const end = Math.max(0, wrapped.length - cursor.transcriptOffset); const start = Math.max(0, end - bodyHeight); const body = wrapped.slice(start, end).map((line) => styledCell(line, width, line.startsWith("tool:") ? CYAN : "")); while (body.length < bodyHeight) body.unshift(" ".repeat(width)); return [...metadata, ...body]; } function renderHelp(width: number, height: number): string[] { const lines = [ "FLEET BOARD KEYS", "", "←/→ or h/l select column", "↑/↓ or k/j select card", "Enter inspect/follow transcript", "u unblock selected blocked, failed or orphaned issue", "space pause/resume selected worker (asks for a confirming press)", "i open selected issue", "p open selected pull request", "r refresh health now", "? close help", "Esc back, then quit", "q / Ctrl-C back, then quit", "", "PARKED = issue open, no label — nothing dispatches it until you label it", ]; const top = Math.max(0, Math.floor((height - lines.length) / 2)); return [ ...Array.from({ length: top }, () => " ".repeat(width)), ...lines.map((line, index) => styledCell(line, width, index === 0 ? `${BOLD}${CYAN}` : "")), ].slice(0, height); } export function stripAnsi(value: string): string { return value.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); } export function renderBoard( snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number, notice = "", help = false, ): string { normalizeCursor(snapshot, cursor); if (width < MIN_WIDTH || height < MIN_HEIGHT) { return [ styledCell("omp-conductor board", width, `${BOLD}${REVERSE}`), styledCell(`Terminal too small: ${width}x${height}; need at least ${MIN_WIDTH}x${MIN_HEIGHT}.`, width), ].join("\n"); } const top = header(snapshot, width); const footerHeight = 2; const bodyHeight = height - top.length - footerHeight; const body = help ? renderHelp(width, bodyHeight) : cursor.detail ? renderDetail(snapshot, cursor, width, bodyHeight) : renderBoardColumns(snapshot, cursor, width, bodyHeight); while (body.length < bodyHeight) body.push(" ".repeat(width)); const footer = cursor.detail ? "↑/↓ scroll Esc back u unblock i issue p PR r refresh ? help q quit" : "←/→ column ↑/↓ card Enter inspect u unblock i issue p PR r refresh ? help q quit"; return [ ...top, ...body.slice(0, bodyHeight), styledCell(notice === "" ? " " : ` ${notice}`, width, notice.toLowerCase().includes("failed") ? RED : DIM), styledCell(` ${footer}`, width, REVERSE), ].join("\n"); } async function probeBoardHealth(project: ProjectConfig): Promise { const layers = fleetLayers(project.name); const record = livingDaemon(); // Same gate as status (#379): never health-check a record pinned to another // project, and never invent a second membership convention beside // classifyDaemonProjectHealth. const wrongRecord = record?.project !== undefined && record.project !== project.name; const [telegram, health] = await Promise.all([ probeTelegramHealth(project.name), record === undefined || wrongRecord ? undefined : healthCheck(record.port), ]); const classified = classifyDaemonProjectHealth(record, health, project.name); const daemon: DaemonBoardState = classified.kind; const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined; const store = openStore(dbPath()); let codeGraph: CodeGraphHealth; try { codeGraph = cachedGraph ?? (await probeCodeGraph(project, { ...DEFAULT_DEPS, // The runtime half of the code-graph finding (#726): grounded in // dispatched runs' own observations, never in the daemon's process. graphToolsObservations: () => store.graphToolsObservationCounts(project.name), })); } finally { store.close(); } return { health: { layers, telegram, daemon, codeGraph, }, pausedPhases: daemon === "ok" ? workerPhasesFromHealthz(health?.body, project.name) : new Map(), }; } /** * The label sets behind every stopped lane. * * Reading them is the whole of the #109 fix: the store only knows what it did * itself, so a label a human added or removed — and an issue closed out from * under a run row — are invisible to it, and a board built from run rows alone * reports last-run history as current work. * * One open-issue snapshot per pass, never one list call per label (#203): the * four lifecycle sets are derived in memory from it by {@link boardLabelSets}, * and SETTLED closure is read off the same snapshot instead of a per-candidate * `issueState` fan-out. The single read rides the conditional-request cache, so * an idle board costs no primary rate-limit budget at all. The cadence stays * the ten-second health refresh, never the one-second redraw path. * * Never throws. A failed read keeps the previous sets on screen and records * why, because blanking QUEUE and FAILED is a louder lie than showing them * stale — {@link labelFact} puts the staleness in the header. */ export async function probeBoardLabels( project: ProjectConfig, store: Store, previous?: BoardLabels, probeTracker?: Tracker, ): Promise { try { const tracker = probeTracker ?? makeTracker(project); const open = await tracker.listOpenIssues(); const { queued, inProgress, blocked, failed } = boardLabelSets(open, project); // PARKED candidates: the newest run per issue, terminal, non-merged, and // wearing no state or queue label. Exactly the rows {@link laneOf} would // otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit // and cached: a failed read keeps the last set. const labelled = new Set([...queued, ...inProgress, ...blocked, ...failed]); const newestRun = new Map(); for (const run of store.recentRuns(project.name, Date.now() - MERGED_HISTORY_MS)) { if (!newestRun.has(run.issue)) newestRun.set(run.issue, run); } const parkedCandidates = [...newestRun.values()].filter( (run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue), ); // The snapshot IS the set of open issues, so closure needs no per-issue // lookup: a candidate whose issue does NOT appear in it is closed, *because // this read succeeded*. That last clause is the whole distinction on the // failed path — `closed` is only ever derived here, inside the try, so an // unreadable probe survives as the previous set (or empty) and an issue the // board could not confirm closed keeps parking rather than falsely reading // as SETTLED (#218). Absence from `closed` is therefore open OR unknown, // and both mean PARKED. const openNumbers = new Set(open.map((issue) => issue.number)); const closed = new Set( parkedCandidates.map((run) => run.issue).filter((n) => !openNumbers.has(n)), ); return { queued, inProgress, blocked, failed, closed, readAt: Date.now() }; } catch (err) { return { ...(previous ?? UNREAD_LABELS), error: err instanceof Error ? err.message : String(err), }; } } function issueUrl(project: ProjectConfig, issue: number): string { return `https://github.com/${project.tracker.repo}/issues/${issue}`; } async function openUrl(url: string): Promise { const command = process.platform === "darwin" ? "open" : "xdg-open"; try { return (await Bun.spawn([command, url], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).exited) === 0; } catch { return false; } } export function summarizeUnblockOutput(issue: number, output: string): string { const lines = output .split("\n") .map((line) => line.trim()) .filter((line) => line !== ""); return lines.length === 0 ? `#${issue}: unblock completed` : `#${issue}: ${lines.at(-1)}`; } async function unblock(project: ProjectConfig, issue: number): Promise { const child = Bun.spawn( [process.execPath, join(import.meta.dir, "cli.ts"), "unblock", String(issue), "--project", project.name], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, ); const [code, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); if (code !== 0) return (stderr || stdout).trim().replace(/\s+/g, " ") || `#${issue}: unblock failed`; return summarizeUnblockOutput(issue, stdout); } /** * What one spacebar press on a RUNNING card does (#997): the first press only * arms the toggle and says so; the confirming second press on the same card, * within {@link PAUSE_CONFIRM_MS}, performs it. Pure, so the gate itself is * provable: an unconfirmed press must change nothing but the armed state — * one stray spacebar parked a live worker for 14 minutes on 2026-08-23. */ export function pauseKeyDecision( pending: { issue: number; at: number } | undefined, issue: number, phase: WorkerPausePhase | undefined, now: number, ): | { kind: "confirm"; pending: undefined } | { kind: "arm"; pending: { issue: number; at: number }; notice: string } { if (pending?.issue === issue && now - pending.at <= PAUSE_CONFIRM_MS) { return { kind: "confirm", pending: undefined }; } return { kind: "arm", pending: { issue, at: now }, notice: `press space again to confirm ${phase === "paused" ? "resume" : "pause"} of #${issue}`, }; } async function toggleWorkerPause( project: ProjectConfig, issue: number, phase: WorkerPausePhase | undefined, ): Promise { if (phase === "pausing") return "still pausing — wait"; const daemon = livingDaemon(); if (daemon === undefined) return "daemon is not running"; if (daemon.project !== undefined && daemon.project !== project.name) { return `daemon serves project "${daemon.project}", not requested project "${project.name}"`; } const action = phase === "paused" ? "resume" : "pause"; // Run control is a mutating daemon route and now needs the bearer token // (Phase 4). The board reads it; it never mints one, because a token this // process invented would authenticate against nothing. const auth = httpAuthHeader(); if (auth === undefined) return missingHttpTokenMessage(); try { const response = await fetch( `http://127.0.0.1:${daemon.port}/runs/${issue}/${action}`, { method: "PUT", headers: { "content-type": "application/json", ...auth }, body: JSON.stringify({ project: project.name, source: "board" }), }, ); const payload = (await response.json()) as { error?: unknown; phase?: unknown }; if (!response.ok) { return typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`; } return typeof payload.phase === "string" ? `#${issue} worker ${payload.phase}` : "daemon returned an invalid worker-control response"; } catch (err) { return err instanceof Error ? err.message : String(err); } } function enqueue(queue: KeyInput[], key: KeyInput, wake: (() => void) | undefined): void { queue.push(key); wake?.(); } async function waitForInput(queue: KeyInput[], setWake: (wake?: () => void) => void): Promise { if (queue.length > 0) return queue.shift()!; await new Promise((resolve) => { const timer = setTimeout(resolve, REFRESH_MS); setWake(() => { clearTimeout(timer); resolve(); }); }); setWake(undefined); return queue.shift() ?? { name: "refresh" }; } /** * One-shot snapshot for the headless board (#200): the same setup and one round * of probes `runBoard` performs on entry, with the per-row PR re-verification * probe deliberately skipped — a one-shot is a point-in-time read, so there is * no live verdict map to keep warm. */ export async function boardSnapshotOnce(projectName?: string): Promise { const cfg = loadConfig(); const project = findProject(cfg, projectName); const caps = resolveCaps(project, cfg.defaults); const store: Store = openStore(dbPath()); try { const now = Date.now(); const [healthProbe, labels, planUsage] = await Promise.all([ probeBoardHealth(project), probeBoardLabels(project, store), readPlanUsage(caps.planUsage, sharedUsageSource()), ]); return { project, status: statusSnapshotFromStore(project, caps, store, planUsage), health: healthProbe.health, pausedPhases: healthProbe.pausedPhases, labels, pr: new Map(), runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS), now, }; } finally { store.close(); } } interface BoardJsonRun { issue: number; state: string; attempt?: number; prUrl?: string; } /** * The headless board's JSON view (#200): one entry per issue per lane, fields * read from the lane's newest run row. Issues the store has never run (queue * members placed by label or hold) carry `"state": "queued"` instead. Every * COLUMN_DEFS lane key is emitted, empty ones included, so consumers never * key-check; keys and entries keep the board's stable render order. */ export function boardJson(snapshot: BoardSnapshot): string { // `recentRuns` yields the newest attempt per issue first; indexing keeps one // entry per issue even when a caller hands over a wider run list. const newest = new Map(); for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run); const lanesByKey = boardLanes(snapshot); const lanes: Record = {}; for (const { key } of COLUMN_DEFS) { lanes[key] = (lanesByKey.get(key) ?? []).map((issue) => { const run = newest.get(issue); if (run === undefined) { // Label- or hold-placed issues the store never ran. The queue lane's // state is its own word ("queued"); the other label-placed lanes keep // their key as the state, which is exactly what they are. return { issue, state: key === "queue" ? "queued" : key }; } const entry: BoardJsonRun = { issue, state: run.state, attempt: run.attempt }; if (run.prUrl !== undefined) entry.prUrl = run.prUrl; return entry; }); } return JSON.stringify( { project: snapshot.project.name, generatedAt: new Date(snapshot.now).toISOString(), lanes }, null, 2, ); } export interface BoardConfigState { project: ProjectConfig; caps: Caps; /** The last refresh could not read the config; `project`/`caps` are the last * good values and the board renders the failure instead of the values. */ error?: string; /** Whether the last successful refresh resolved different values. */ changed: boolean; } /** * Re-resolve the board's project and caps from the config, once per refresh * cycle (#370). The board renders `project` and `caps` on every repaint, so * this read — never the render path — is what keeps a cap changed while the * board is open from going stale. A config that cannot be read returns the * previous values with the failure, exactly how the daemon holds boot values * across a failed hot reload instead of wedging its tick. */ export function reloadBoardConfig( projectName: string | undefined, previous?: { project: ProjectConfig; caps: Caps }, ): BoardConfigState { try { const cfg = loadConfig(); const project = findProject(cfg, projectName); const caps = resolveCaps(project, cfg.defaults); const unchanged = previous !== undefined && JSON.stringify(project) === JSON.stringify(previous.project) && JSON.stringify(caps) === JSON.stringify(previous.caps); return { project, caps, changed: !unchanged }; } catch (err) { if (previous === undefined) throw err; return { project: previous.project, caps: previous.caps, changed: false, error: err instanceof Error ? err.message : String(err), }; } } export async function runBoard(projectName?: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error("board needs an interactive terminal (TTY)"); } // The config is read at boot (an unreadable one still fails the board), then // re-read on the health cadence below so the board tracks what the daemon // hot-reloads per tick. let { project, caps, error: configError } = reloadBoardConfig(projectName); const store: Store = openStore(dbPath()); const cursor: BoardCursor = { column: 2, card: 0, detail: false, transcriptOffset: 0 }; const queue: KeyInput[] = []; let wake: (() => void) | undefined; let stopping = false; let help = false; let notice = ""; // One tracker for the whole board, bound to its own call counters (#210): // every request this UI makes is counted where a human can see it, instead // of only being visible to an external shim on `gh`. const trackerCalls: number[] = []; const trackerFree: number[] = []; const boardTracker = makeTracker(project, undefined, { onCall: () => trackerCalls.push(Date.now()), onNotModified: () => trackerFree.push(Date.now()), }); let [healthProbe, labels, planUsage] = await Promise.all([ probeBoardHealth(project), probeBoardLabels(project, store, undefined, boardTracker), readPlanUsage(caps.planUsage, sharedUsageSource()), ]); let health = healthProbe.health; let pausedPhases = healthProbe.pausedPhases; // An armed-but-unconfirmed pause toggle (#997): issue and arm time. let pendingPause: { issue: number; at: number } | undefined; let healthAt = Date.now(); let healthRefresh: Promise | undefined; // The tracker read is gated separately from health and the plan allowance: // those two are local reads that cost no API traffic, so they stay on the // base cadence while this one backs off across unchanged passes. let labelAt = Date.now(); let labelDelay = LABEL_REFRESH_MS; let labelRefresh: Promise | undefined; // Live re-verification of pushed rows (#173): cached per run id, refreshed on // the slow PR cadence so a `gh` call per pushed row does not ride the 1 Hz // repaint or the 10 s health gate. const prProbe = new Map(); let prProbeAt = 0; let prProbeRun: Promise | undefined; emitKeypressEvents(process.stdin); const onKey = (_text: string, key: KeyInput): void => enqueue(queue, key, wake); const onResize = (): void => enqueue(queue, { name: "resize" }, wake); const onStop = (): void => { stopping = true; wake?.(); }; process.stdin.on("keypress", onKey); process.stdout.on("resize", onResize); process.on("SIGINT", onStop); process.on("SIGTERM", onStop); process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write(`${CSI}?1049h${CSI}?25l${CSI}2J`); try { while (!stopping) { const now = Date.now(); if (now - healthAt >= HEALTH_REFRESH_MS && healthRefresh === undefined) { healthAt = now; healthRefresh = (async () => { // #370: the config is re-read on the health cadence, not the 1 Hz // repaint — the daemon hot-reloads per tick, so a cap changed while // the board is open must land here too. A config that cannot be read // keeps the last good project/caps and marks the board stale instead // of tearing it down: the daemon's own boot-values rule. const reloaded = reloadBoardConfig(projectName, { project, caps }); // On the health cadence, not the 1s repaint: the provider read is a // subprocess, and an allowance does not move at 1 Hz. const [nextProbe, nextPlanUsage] = await Promise.all([ probeBoardHealth(reloaded.project), readPlanUsage(reloaded.caps.planUsage, sharedUsageSource()), ]); project = reloaded.project; caps = reloaded.caps; configError = reloaded.error; health = nextProbe.health; pausedPhases = nextProbe.pausedPhases; planUsage = nextPlanUsage; if (reloaded.error === undefined && reloaded.changed) { notice = "config reloaded - changes applied"; } enqueue(queue, { name: "refresh" }, wake); })() .catch((err: unknown) => { notice = `health refresh failed: ${err instanceof Error ? err.message : String(err)}`; }) .finally(() => { healthRefresh = undefined; }); } if (now - labelAt >= labelDelay && labelRefresh === undefined) { labelAt = now; labelRefresh = probeBoardLabels(project, store, labels, boardTracker) .then((nextLabels) => { labelDelay = nextLabelDelayMs(labelDelay, boardLabelsEqual(labels, nextLabels)); labels = nextLabels; enqueue(queue, { name: "refresh" }, wake); }) .catch((err: unknown) => { notice = `label refresh failed: ${err instanceof Error ? err.message : String(err)}`; }) .finally(() => { labelRefresh = undefined; }); } // #173: re-verify pushed rows against the tracker on their own slow // cadence, so red-shown-as-green and a moved head surface without paying // for the read every second. A failed probe leaves the last verdicts in // place (blanking a card's `now:` is a quieter lie than nothing). if (now - prProbeAt >= PR_REFRESH_MS && prProbeRun === undefined) { prProbeAt = now; const rows = store.recentRuns(project.name, now - MERGED_HISTORY_MS).filter( (run) => (run.state === "pushed-green" || run.state === "pushed-pending") && run.prUrl !== undefined && run.headSha !== undefined, ); prProbeRun = (async () => { await Promise.all( rows.map(async (run) => { const verified = await boardTracker.verifyPr(run.prUrl!, run.headSha!); if (verified !== undefined) prProbe.set(run.id, { status: verified.status, reason: verified.reason }); }), ); })() .catch((err: unknown) => { notice = `PR probe failed: ${err instanceof Error ? err.message : String(err)}`; }) .finally(() => { prProbeRun = undefined; }); } const snapshot: BoardSnapshot = { project, status: statusSnapshotFromStore(project, caps, store, planUsage), health, pausedPhases, labels, pr: prProbe, runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS), trackerCost: trackerCostWindow(trackerCalls, trackerFree, now), now, }; normalizeCursor(snapshot, cursor); // An unreadable config pins the footer to the last-good warning for as // long as the config stays broken, instead of clearing after the one // frame a transient notice gets (#370); the cap on screen is last good // and says so. Transient notices get their slot back on a good read. const frameNotice = configError === undefined ? notice : `config unreadable — ${configError} — showing last good values`; process.stdout.write(`${CSI}H${renderBoard(snapshot, cursor, process.stdout.columns, process.stdout.rows, frameNotice, help)}${CSI}J`); notice = ""; const key = await waitForInput(queue, (next) => { wake = next; }); const name = key.name ?? key.sequence; if (name === "refresh" || name === "resize") continue; // #997: an armed pause confirmation survives only until the next // keypress — anything but the confirming space disarms it. if (name !== "space") pendingPause = undefined; // Past this point the key came from a person, not the refresh timer: // someone watching the board gets the base cadence back immediately. labelDelay = LABEL_REFRESH_MS; if (name === "?" || key.sequence === "?") { help = !help; continue; } if (help) { if (name === "escape" || name === "q") help = false; continue; } if (name === "q" || (key.ctrl === true && name === "c")) { if (cursor.detail) cursor.detail = false; else stopping = true; continue; } if (name === "escape") { if (cursor.detail) cursor.detail = false; else stopping = true; continue; } if (name === "r") { healthAt = 0; labelAt = 0; notice = "refresh requested"; continue; } if (cursor.detail && (name === "up" || name === "k")) { cursor.transcriptOffset += 1; continue; } if (cursor.detail && (name === "down" || name === "j")) { cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset - 1); continue; } if (!cursor.detail && (name === "left" || name === "h")) { cursor.column = Math.max(0, cursor.column - 1); delete cursor.selection; selectCard(snapshot, cursor, 0); continue; } if (!cursor.detail && (name === "right" || name === "l")) { cursor.column = Math.min(COLUMN_DEFS.length - 1, cursor.column + 1); delete cursor.selection; selectCard(snapshot, cursor, 0); continue; } if (!cursor.detail && (name === "up" || name === "k")) { selectCard(snapshot, cursor, cursor.card - 1); continue; } if (!cursor.detail && (name === "down" || name === "j")) { selectCard(snapshot, cursor, cursor.card + 1); continue; } const card = focusedCard(snapshot, cursor); if (name === "return" || name === "enter") { if (card === undefined) notice = "no card selected"; else { cursor.detail = !cursor.detail; cursor.transcriptOffset = 0; } continue; } if (name === "i" && card !== undefined) { const url = issueUrl(project, cardIssue(card)); notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`; continue; } if (name === "p") { const url = card?.kind === "run" ? card.run.prUrl : undefined; if (url === undefined) notice = "selected run has no pull request"; else notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`; continue; } if (name === "space") { const lane = COLUMN_DEFS[cursor.column]?.key; if (card === undefined || card.kind !== "run" || lane !== "running") { pendingPause = undefined; notice = "pause/resume is available for RUNNING cards"; } else { const issue = cardIssue(card); const phase = pausedPhases.get(issue)?.phase; const decision = pauseKeyDecision(pendingPause, issue, phase, Date.now()); pendingPause = decision.pending; if (decision.kind === "confirm") { notice = await toggleWorkerPause(project, issue, phase); healthAt = 0; } else { notice = decision.notice; } } continue; } if (name === "u") { // Lane, not run state. `unblock` clears the blocked and failed labels // unconditionally and the in-progress label once the newest run row is // terminal (#18), so it is meaningful for exactly the three lanes those // labels define — including an issue with no run row at all, which the // old newest-run check could not reach. // // On an ORPHANED card backed by an `orphaned` row this is the release // path: that row is terminal, so the in-progress label finally comes // off. On an ORPHANED card with no run row it is not, and `unblock` // says so itself rather than being talked out of it here — a missing // row cannot be told apart from a claim that raced the store write, so // it leaves the label in place by design and prints that line. Being // wrong the other way is two workers on one issue; the exit for a card // stuck like that is escalation, not a wider gate here. Both outcomes // are worth offering; neither is promised. const lane = COLUMN_DEFS[cursor.column]?.key; if (card === undefined || (lane !== "blocked" && lane !== "failed" && lane !== "orphaned")) { notice = "unblock is available for blocked, failed or orphaned issues"; } else { notice = await unblock(project, cardIssue(card)); // An unblock just changed labels: re-read both gates now. healthAt = 0; labelAt = 0; } } } } finally { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off("keypress", onKey); process.stdout.off("resize", onResize); process.off("SIGINT", onStop); process.off("SIGTERM", onStop); process.stdout.write(`${CSI}?25h${CSI}?1049l`); store.close(); } }