// The SUPPLY-only cockpit boot/orchestration layer (ADR 0056, H5 / #148). // // Wires the supply cockpit together from injected capabilities, with NO direct dependency on the // browser, a socket implementation, or xterm.js — everything is passed in ({@link SupplyCockpitEnv}). // That is what lets the same page render identically embedded (console App View, ADR 0057) and // standalone (the shells differ only in the `host` element and the concrete capabilities), and what // makes the live-refresh + drill-in + resume-on-reconnect path unit-testable on Node. // // It mirrors the packaged `@nanobpm/agentic/cockpit` boot DISCIPLINE (a self-scheduling poll; a // persistent terminal region a refresh never wipes; drill-in that resumes-from-offset on every // reconnect) but renders the SUPPLY-only worker list ({@link ./supply-render.ts}) instead of the // demand×supply matrix. The genuinely reusable, correctness-critical parts — the relay client and the // resume-from-offset terminal session — are REUSED from the package ({@link RelayChannelClient}, // {@link TerminalSession}); only the supply projection the package does not provide is authored here. // // Responsibilities: // - a self-scheduling poll of the app's SUPPLY report that re-renders the worker list each pass (a // slow fetch can't overlap the next — mirrors the nano-workforce review-poller discipline); // - drill-into-a-worker: open a {@link RelayChannelClient} + {@link TerminalSession} for the selected // stream, mount its output into a PERSISTENT terminal region (so a list refresh never wipes it), // and re-attach on every reconnect so the terminal SURVIVES a cockpit reconnect via resume-from-offset. import { type DocumentLike, type ElementLike, RelayChannelClient, type Scheduler, type SocketFactory, TerminalSession, type TerminalSink, } from "@nanobpm/agentic/cockpit"; import { renderAgentHistory, renderAgentSessions } from "./agent-history-render.ts"; import { type AgentHistoryReport, type AgentInstanceListReport, agentHistoryView, agentSessionsView, } from "./agent-history-view.ts"; import type { CockpitRoute } from "./cockpit-route.ts"; import { renderSupply } from "./supply-render.ts"; import type { SupplyReport, SupplyView } from "./supply-view.ts"; import { supplyView } from "./supply-view.ts"; import { type RenderDerivedTranscriptOptions, renderDerivedTranscript } from "./transcript-derive.ts"; import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts"; import type { TranscriptListReport } from "./transcript-view.ts"; import { transcriptsView } from "./transcript-view.ts"; import { renderWorkerDetail } from "./worker-detail-render.ts"; import { workerDetailView } from "./worker-detail-view.ts"; /** Mounts a terminal into `host` and returns the sink relay output is written to. */ export type CreateTerminal = (host: ElementLike) => TerminalSink; /** The terminal region's playback mode: a LIVE relay stream vs a REPLAYED (static) stored transcript. */ export type TerminalMode = "live" | "replay"; /** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */ export type TimerHandle = unknown; export interface SupplyCockpitEnv { /** The element the cockpit renders into (standalone: `document.body`; embedded: the App View host). */ readonly host: ElementLike; /** The document the renderer creates elements from. */ readonly doc: DocumentLike; /** Fetches the latest SUPPLY report (e.g. over HTTP from the app's `/agentic/supply` endpoint). */ readonly fetchSupply: () => Promise; /** * Fetches the captured-session list (`GET /agentic/transcripts`) for the "past sessions" history. * Optional: when omitted the past-sessions panel is not rendered (live-only cockpit). */ readonly fetchTranscripts?: (instance?: string) => Promise; /** * Fetches a stored transcript's bytes for static replay. Production wiring hits the proxy-safe * query form `GET /agentic/transcripts?stream=&from=` (#744 — see `transcriptReadUrlFor` * in app/agentic/transcript-url.ts and its browser twin in pages/cockpit/mount.js): a * slash-bearing stream id in a PATH segment (`GET /agentic/transcripts/{stream}`) is split by * gateway proxies that decode %2F before routing, 404ing the read. * Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}. */ readonly fetchTranscript?: (stream: string, from?: number) => Promise; /** * Fetches the engine-native AgentInstance list (`GET /agentic/agent-instances`, served from * `@nanobpm/urban`'s EngineClient `searchAgentInstances`) for the SETTLED "agent history" panel * (issue #745/#747). Optional: when omitted the agent-history panel is not rendered. Must be * provided together with {@link fetchAgentHistory}. This is the CONSUMER read path — settled history * derives from engine truth keyed by agent-instance / process keys, NOT the slash-bearing relay * stream id (so the #744 gateway-proxy bug class is moot); the relay past-sessions panel above stays * the LIVE overlay only. */ readonly fetchAgentInstances?: (processInstanceKey?: string) => Promise; /** * Fetches one AgentInstance's durable conversation history (turns + per-turn metrics) from engine * `searchAgentInstanceHistory` (`GET /agentic/agent-instances/{agentInstanceKey}/history`), for the * historical transcript view. Required for the agent-history panel's drill-in to work; must be * provided together with {@link fetchAgentInstances}. */ readonly fetchAgentHistory?: (agentInstanceKey: string) => Promise; /** Opens a socket to the app relay channel (one per drill-in connection). */ readonly connectRelay: SocketFactory; /** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */ readonly createTerminal: CreateTerminal; /** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */ readonly schedule?: Scheduler; /** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must pair with {@link clearTimer}. */ readonly setTimer?: (run: () => void, ms: number) => TimerHandle; /** Cancels a poll timer. Default `clearTimeout`. Must pair with {@link setTimer}. */ readonly clearTimer?: (handle: TimerHandle) => void; /** Poll interval in ms. Default 2000. */ readonly refreshMs?: number; /** Bulk credit granted per terminal (re)subscribe. Default 1024. */ readonly credit?: number; /** A live worker idle longer than this (ms) grades `stale`. Default 15000. */ readonly staleAfterMs?: number; /** * Upper bound (ms) on a single "past sessions" transcripts fetch. Default 15000. `#refreshPast` is * single-flight, so a fetch that HANGS (never settles) would otherwise wedge the past panel forever; * this timeout guarantees the wait settles so the flag clears and the next poll retries. */ readonly pastFetchTimeoutMs?: number; /** Notified of a fetch/render/relay error (the poll keeps going). */ readonly onError?: (err: unknown) => void; /** * The seam the wave-2 escalation bridge attaches to: threaded into the STRUCTURED replay view's * {@link RenderDerivedTranscriptOptions.onPermissionResolve}, so a pending escalate-permission * prompt's Allow/Deny click reaches the bridge. Optional (default `undefined`): the structured view * still renders, its prompt buttons just have no handler. NOTE (runtime-layer truth): this is the * TYPED boot/replay path exercised by unit tests + the published package, NOT the operator's live * browser cockpit (`pages/cockpit/mount.js`, a hand-maintained drift twin) — surfacing the prompt * there is a separate follow-up. */ readonly onPermissionResolve?: RenderDerivedTranscriptOptions["onPermissionResolve"]; } /** The running supply cockpit; dispose to stop polling and tear down the terminal. */ export interface SupplyCockpitHandle { /** Run one fetch→render pass now (also the poll body). Resolves when rendered. */ refresh(): Promise; /** Start the self-scheduling poll loop (runs one pass immediately). */ start(): void; /** Stop the poll loop (leaves the last render in place). */ stop(): void; /** Drill into a worker's relay stream, opening a resumable LIVE terminal. */ drill(stream: string): void; /** Replay a captured past session's stored transcript statically into the terminal (no live worker). */ replay(stream: string): Promise; /** View one engine-native agent instance's settled conversation history in the agent-history panel. */ viewAgentHistory(agentInstanceKey: string): Promise; /** Open a worker's dedicated detail page. */ openWorker(instance: string): void; /** Return to the main worker list. */ back(): void; /** The current cockpit route. */ readonly currentRoute: CockpitRoute; /** The stream currently drilled into or replayed, if any. */ readonly currentStream: string | undefined; /** The agent instance whose settled history is currently shown in the agent-history panel, if any. */ readonly currentAgentInstanceKey: string | undefined; /** Whether the terminal is showing a LIVE stream or a REPLAYED transcript (undefined when idle). */ readonly currentMode: TerminalMode | undefined; /** Stop everything and release the terminal connection. */ dispose(): void; } const DEFAULT_REFRESH_MS = 2000; const DEFAULT_PAST_FETCH_TIMEOUT_MS = 15000; function isPosInt(value: number): boolean { return Number.isSafeInteger(value) && value > 0; } interface Drill { readonly stream: string; readonly client: RelayChannelClient; } class SupplyCockpit implements SupplyCockpitHandle { readonly #env: SupplyCockpitEnv; readonly #listRegion: ElementLike; readonly #pastRegion: ElementLike | undefined; // The engine-native SETTLED agent-history panel (issue #745): a list region (the AgentInstance runs) // and a detail region (a selected instance's ordered conversation turns + metrics). Present only when // the engine agent-history read endpoints are wired. Distinct from #pastRegion (the relay live overlay). readonly #agentRegion: ElementLike | undefined; readonly #agentDetailRegion: ElementLike | undefined; // A dedicated volatile region the STRUCTURED derived view (messages, rich tool/diff cards, permission // prompts) is mounted into on a replay — beside, and additive to, the byte-level terminal replay // (which is left untouched). Present only when the transcript read endpoints are wired. readonly #structuredRegion: ElementLike | undefined; readonly #terminalHost: ElementLike; readonly #terminalTitle: ElementLike; readonly #terminalNote: ElementLike; readonly #terminalPanel: ElementLike; readonly #refreshMs: number; readonly #pastFetchTimeoutMs: number; readonly #setTimer: (run: () => void, ms: number) => TimerHandle; readonly #clearTimer: (handle: TimerHandle) => void; readonly #timeouts = new Map>(); #nextTimerId = 0; #timer: TimerHandle | undefined; #running = false; #disposed = false; #drill: Drill | undefined; // The currently mounted terminal, tracked so switching streams (and dispose) tears down the prior // xterm instance instead of leaking it + its listeners. #terminal: TerminalSink | undefined; // The terminal region's current playback: a LIVE relay stream or a REPLAYED stored transcript, and // the stream it is showing — so the past-sessions list can highlight the active replay and the // panel title can distinguish live from replayed. #mode: TerminalMode | undefined; #shownStream: string | undefined; // Bumped by every drill()/replay()/dispose() that takes over the terminal region. replay() is async // (it awaits a transcript fetch); capturing this token before the await and re-checking it after lets // a slow replay drop its result when a newer drill/replay has since claimed the terminal — so a // late-resolving stale fetch can never clobber a newer selection (or leak the newer drill's client). #opToken = 0; // True while a #refreshPast() fetch is in flight, so the supply poll never stacks past-fetches and a // hung transcripts endpoint can't accumulate pending calls. #pastRefreshing = false; #pastRefreshPending = false; // Single-flight latch for the agent-history list refresh (mirrors #pastRefreshing), so the poll can // never stack engine agent-instance fetches against a slow/unresponsive read endpoint. #agentRefreshing = false; #agentRefreshPending = false; // The agent instance whose settled history is currently rendered in the detail region, if any. #shownAgentInstanceKey: string | undefined; #route: CockpitRoute = { kind: "main" }; #view: SupplyView | undefined; // Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't // reschedule after a stop→start race and leave two overlapping poll chains running. #generation = 0; constructor(env: SupplyCockpitEnv) { this.#env = env; this.#refreshMs = env.refreshMs ?? DEFAULT_REFRESH_MS; // refreshMs feeds setTimeout as a poll delay. A negative/NaN/fractional/unsafe value silently // collapses to a ~0ms delay, turning the poll into a hot loop that hammers the endpoint. Require // a positive safe integer up-front so a bad env option fails loudly instead. if (!isPosInt(this.#refreshMs)) { throw new RangeError(`SupplyCockpitEnv.refreshMs must be a positive safe integer, got ${this.#refreshMs}`); } this.#pastFetchTimeoutMs = env.pastFetchTimeoutMs ?? DEFAULT_PAST_FETCH_TIMEOUT_MS; if (!isPosInt(this.#pastFetchTimeoutMs)) { throw new RangeError( `SupplyCockpitEnv.pastFetchTimeoutMs must be a positive safe integer, got ${this.#pastFetchTimeoutMs}`, ); } // setTimer/clearTimer are a matched pair: a caller-supplied setTimer returns opaque handles the // default clearTimer (which only understands the internal numeric-handle Map) cannot cancel, // leaving an un-stoppable poll loop. Fail fast rather than silently accept one without the other. if ((env.setTimer === undefined) !== (env.clearTimer === undefined)) { throw new Error("SupplyCockpitEnv.setTimer and clearTimer must be provided together (or neither)"); } // fetchTranscripts (the past-sessions LIST source) and fetchTranscript (the per-session REPLAY source) // are a matched pair: the past panel is rendered whenever the list source is present, but its replay // buttons route through replay(), which no-ops without the replay source — so a list source without a // replay source surfaces buttons that silently do nothing, and a replay source without a list source // is an unreachable capability. Require both together (or neither) so a half-wired env fails loudly. if ((env.fetchTranscripts === undefined) !== (env.fetchTranscript === undefined)) { throw new Error("SupplyCockpitEnv.fetchTranscripts and fetchTranscript must be provided together (or neither)"); } // The engine agent-history LIST source and the per-instance HISTORY source are likewise a matched // pair: the panel renders whenever the list source is present, but its rows route through // viewAgentHistory(), which no-ops without the history source. Require both together (or neither). if ((env.fetchAgentInstances === undefined) !== (env.fetchAgentHistory === undefined)) { throw new Error("SupplyCockpitEnv.fetchAgentInstances and fetchAgentHistory must be provided together (or neither)"); } this.#setTimer = env.setTimer ?? ((run, ms) => { const id = this.#nextTimerId++; const timeout = setTimeout(() => { this.#timeouts.delete(id); run(); }, ms); // A pending default timer (poll delay or the past-fetch timeout below) must never keep the // process alive on its own — a no-op in the browser, where timer handles have no `unref`. timeout.unref?.(); this.#timeouts.set(id, timeout); return id; }); this.#clearTimer = env.clearTimer ?? ((handle) => { if (typeof handle !== "number") return; const timeout = this.#timeouts.get(handle); if (timeout !== undefined) { clearTimeout(timeout); this.#timeouts.delete(handle); } }); // Build the stable skeleton once: a volatile list region the poll re-renders, an optional volatile // "past sessions" region (rendered only when the transcript read endpoints are wired), and a // PERSISTENT terminal region a refresh never touches (so a drilled-in/replayed terminal survives). env.host.replaceChildren(); const shell = env.doc.createElement("div"); shell.className = "cockpit-shell"; this.#listRegion = env.doc.createElement("div"); this.#listRegion.className = "cockpit-supply-region"; // The past-sessions history list only exists when a transcript list source is injected. if (env.fetchTranscripts !== undefined) { this.#pastRegion = env.doc.createElement("div"); this.#pastRegion.className = "cockpit-past-region"; } // The engine-native agent-history panel: a list region + a detail region, present only when the // engine agent-history read endpoints are wired. if (env.fetchAgentInstances !== undefined) { this.#agentRegion = env.doc.createElement("div"); this.#agentRegion.className = "cockpit-agent-region"; this.#agentDetailRegion = env.doc.createElement("div"); this.#agentDetailRegion.className = "cockpit-agent-detail-region"; } this.#terminalPanel = env.doc.createElement("section"); this.#terminalPanel.className = "cockpit-terminal"; this.#terminalPanel.setAttribute("data-terminal-mode", "idle"); this.#terminalTitle = env.doc.createElement("h2"); this.#terminalTitle.className = "cockpit-panel-title"; this.#terminalTitle.textContent = "Worker terminal"; this.#terminalPanel.appendChild(this.#terminalTitle); this.#terminalHost = env.doc.createElement("div"); this.#terminalHost.className = "cockpit-terminal-host"; this.#terminalHost.setAttribute("data-terminal", "host"); this.#terminalPanel.appendChild(this.#terminalHost); // The STRUCTURED derived view is mounted here on a replay, additive beside the byte terminal above. // Only exists when the transcript read endpoints are wired (same pairing as the past-sessions list). if (env.fetchTranscripts !== undefined) { this.#structuredRegion = env.doc.createElement("div"); this.#structuredRegion.className = "cockpit-structured-region"; this.#structuredRegion.setAttribute("data-structured", "region"); this.#terminalPanel.appendChild(this.#structuredRegion); } // A status note under the terminal, shown while a LIVE drill has connected but no output has // arrived yet (a quiet job between frames): without it the panel is an indistinguishable blank // black rectangle, so the operator can't tell "connected, waiting" from "broken". Cleared the // instant the first frame is written, and on every mode change. this.#terminalNote = env.doc.createElement("p"); this.#terminalNote.className = "cockpit-terminal-note"; this.#terminalNote.setAttribute("data-terminal-note", "none"); this.#terminalPanel.appendChild(this.#terminalNote); // Order MUST match the browser twin (pages/cockpit/mount.js) and its tests: the terminal sits // directly beneath the supply list, since cockpit.css keys layout off DOM order (no grid areas). // supply list → terminal → past sessions → agent list → agent detail. shell.appendChild(this.#listRegion); shell.appendChild(this.#terminalPanel); if (this.#pastRegion !== undefined) shell.appendChild(this.#pastRegion); if (this.#agentRegion !== undefined) shell.appendChild(this.#agentRegion); if (this.#agentDetailRegion !== undefined) shell.appendChild(this.#agentDetailRegion); env.host.appendChild(shell); } get currentStream(): string | undefined { return this.#shownStream; } get currentAgentInstanceKey(): string | undefined { return this.#shownAgentInstanceKey; } get currentMode(): TerminalMode | undefined { return this.#mode; } get currentRoute(): CockpitRoute { return this.#route; } /** Reflect the terminal region's playback mode on the panel (title + `data-terminal-mode`). */ #setMode(mode: TerminalMode | undefined, stream: string | undefined): void { this.#mode = mode; this.#shownStream = stream; this.#terminalPanel.setAttribute("data-terminal-mode", mode ?? "idle"); if (mode === "live") this.#terminalTitle.textContent = "Worker terminal — live"; else if (mode === "replay") this.#terminalTitle.textContent = "Worker terminal — replay (past session)"; else this.#terminalTitle.textContent = "Worker terminal"; // Any mode change replaces what's behind the panel, so the prior "waiting for output" note is // stale — clear it. A live drill re-arms it (below) once its fresh terminal is mounted. this.#setNote(undefined); // The STRUCTURED derived view is only valid alongside a replay. Any non-replay mode (a live drill // or idle) must clear it, or a stale derived transcript / pending permission prompt would linger — // visible and CLICKABLE — over the live terminal, risking an operator action against the wrong // callId. A replay re-mounts it (in replay(), after this #setMode("replay", …)). if (mode !== "replay") this.#structuredRegion?.replaceChildren(); } /** Show (or clear) the terminal status note — the "connecting" / "waiting for output" affordance. */ #setNote(text: string | undefined, state: "connecting" | "waiting" = "waiting"): void { if (text === undefined) { this.#terminalNote.textContent = ""; this.#terminalNote.setAttribute("data-terminal-note", "none"); return; } this.#terminalNote.textContent = text; this.#terminalNote.setAttribute("data-terminal-note", state); } async refresh(): Promise { if (this.#disposed) return; let report: SupplyReport; try { report = await this.#env.fetchSupply(); } catch (err) { if (this.#disposed) return; this.#env.onError?.(err); return; } if (this.#disposed) return; try { this.#view = supplyView(report, { staleAfterMs: this.#env.staleAfterMs }); this.#renderRoute(); } catch (err) { this.#env.onError?.(err); } // Fire-and-forget: the "past sessions" refresh must never gate the supply poll's next tick. A // transcripts endpoint that hangs (not just rejects) would otherwise stall #refresh() forever and // wedge the live worker list. #refreshPast is single-flight, so a slow fetch can't pile up either. void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined); // Same fire-and-forget discipline for the engine agent-history list: a slow/hung read endpoint must // never gate the supply poll's next tick. #refreshAgentHistory is single-flight + bounded. The list // is engine-global (not route-filtered), so it takes no route instance — call it with no argument. void this.#refreshAgentHistory(); } #renderRoute(): void { const view = this.#view; if (view === undefined) return; if (this.#route.kind === "worker") { renderWorkerDetail(this.#listRegion, this.#env.doc, workerDetailView(view, this.#route.instance), { onBack: () => this.back(), onDrill: (stream) => this.drill(stream), }); return; } renderSupply(this.#listRegion, this.#env.doc, view, { onDrill: (stream) => this.drill(stream), onOpenWorker: (instance) => this.openWorker(instance), }); } /** Fetch + render the "past sessions" history list, when a transcript source is wired. Independent * of the supply fetch: a transcript-endpoint fault (or hang) never blocks the live worker list. */ async #refreshPast(instance?: string): Promise { const fetchTranscripts = this.#env.fetchTranscripts; if (fetchTranscripts === undefined || this.#pastRegion === undefined) return; // Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another // so the poll can't stack pending fetches against a slow/unresponsive transcripts endpoint. if (this.#pastRefreshing) { this.#pastRefreshPending = true; return; } this.#pastRefreshing = true; try { let report: TranscriptListReport; try { report = await this.#bounded(() => fetchTranscripts(instance), "transcripts"); } catch (err) { if (this.#disposed) return; this.#env.onError?.(err); return; } if (this.#disposed || this.#pastRegion === undefined) return; const currentInstance = this.#route.kind === "worker" ? this.#route.instance : undefined; if (currentInstance !== instance) { this.#pastRefreshPending = true; return; } try { renderTranscripts(this.#pastRegion, this.#env.doc, transcriptsView(report), { onReplay: (stream) => void this.replay(stream), ...(this.#mode === "replay" && this.#shownStream !== undefined ? { activeStream: this.#shownStream } : {}), ...(instance !== undefined ? { title: "Job history", emptyText: "No captured sessions for this worker yet." } : {}), }); } catch (err) { this.#env.onError?.(err); } } finally { this.#pastRefreshing = false; if (this.#pastRefreshPending && !this.#disposed) { this.#pastRefreshPending = false; void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined); } } } /** Fetch + render the engine-native SETTLED agent-history list, when the read endpoints are wired. * Single-flight + bounded (mirrors {@link #refreshPast}): an engine read fault/hang never blocks the * live worker list. The list is engine-global (settled AgentInstances), so it is not route-filtered. */ async #refreshAgentHistory(): Promise { const fetchAgentInstances = this.#env.fetchAgentInstances; if (fetchAgentInstances === undefined || this.#agentRegion === undefined) return; if (this.#agentRefreshing) { this.#agentRefreshPending = true; return; } this.#agentRefreshing = true; try { let report: AgentInstanceListReport; try { report = await this.#bounded(() => fetchAgentInstances(), "agent-instances"); } catch (err) { if (this.#disposed) return; this.#env.onError?.(err); return; } if (this.#disposed || this.#agentRegion === undefined) return; try { renderAgentSessions(this.#agentRegion, this.#env.doc, agentSessionsView(report), { onSelect: (agentInstanceKey) => void this.viewAgentHistory(agentInstanceKey), ...(this.#shownAgentInstanceKey !== undefined ? { activeInstanceKey: this.#shownAgentInstanceKey } : {}), }); } catch (err) { this.#env.onError?.(err); } } finally { this.#agentRefreshing = false; if (this.#agentRefreshPending && !this.#disposed) { this.#agentRefreshPending = false; void this.#refreshAgentHistory(); } } } /** * Fetch + render one engine-native agent instance's SETTLED conversation history (turns + per-turn / * instance metrics) into the detail region, keyed by `agentInstanceKey` — the CONSUMER read path * (issue #745/#747). Bounded so a hung engine read can't wedge the panel. Read-as-absence: an unknown * key renders an explicit empty history, never an error. */ async viewAgentHistory(agentInstanceKey: string): Promise { if (this.#disposed) return; const fetchAgentHistory = this.#env.fetchAgentHistory; if (fetchAgentHistory === undefined || this.#agentDetailRegion === undefined) return; let report: AgentHistoryReport; try { report = await this.#bounded(() => fetchAgentHistory(agentInstanceKey), "agent-history"); } catch (err) { if (this.#disposed) return; this.#env.onError?.(err); return; } if (this.#disposed || this.#agentDetailRegion === undefined) return; try { this.#shownAgentInstanceKey = agentInstanceKey; renderAgentHistory(this.#agentDetailRegion, this.#env.doc, agentHistoryView(report)); // Re-render the list so the just-selected run shows as active (best-effort). void this.#refreshAgentHistory(); } catch (err) { this.#env.onError?.(err); } } /** * Race an injected fetch against a timeout so the returned promise ALWAYS settles, even if the fetch * HANGS (never settles, not merely rejects). Both single-flight callers below — the past-sessions list * refresh and a past-session {@link replay} — depend on this: a hung list fetch would leave * `#pastRefreshing` stuck `true` forever (permanently disabling the past panel), and a hung replay fetch * would leave `replay()` pending forever with the terminal wedged out of live mode. Racing the fetch * against a timeout guarantees the wait settles (here, rejects), so the caller's `finally`/`catch` runs * and the next poll can retry. A hung fetch that resolves late is ignored (the `settled` latch drops it). */ #bounded(fetch: () => Promise, what: string): Promise { return new Promise((resolve, reject) => { let settled = false; const handle = this.#setTimer(() => { if (settled) return; settled = true; // Clear our own handle on the timeout arm too, symmetric with the fetch arms below: a // caller-supplied clearTimer may reclaim a handle a fired timer still holds, and clearing here // guards against a custom scheduler re-invoking the callback (the settled latch is belt-and-braces). this.#clearTimer(handle); reject(new Error(`${what} fetch timed out after ${this.#pastFetchTimeoutMs}ms`)); }, this.#pastFetchTimeoutMs); // Invoke the fetch inside try/catch so a SYNCHRONOUS throw (not a rejected promise) is handled on the // same arms as an async rejection: clear our timer and reject once. Without this, a sync throw escapes // the executor (rejecting the promise) but leaves the timeout handle scheduled — a leak that fires // (and, under a custom scheduler, could re-fire) long after the wait has already settled. let pending: Promise; try { pending = fetch(); } catch (err) { settled = true; this.#clearTimer(handle); reject(err); return; } pending.then( (report) => { if (settled) return; settled = true; this.#clearTimer(handle); resolve(report); }, (err) => { if (settled) return; settled = true; this.#clearTimer(handle); reject(err); }, ); }); } start(): void { if (this.#disposed || this.#running) return; this.#running = true; const generation = ++this.#generation; this.#tick(generation); } stop(): void { this.#running = false; // Invalidate any in-flight tick so its finally can't reschedule after this. this.#generation++; if (this.#timer !== undefined) { this.#clearTimer(this.#timer); this.#timer = undefined; } } #tick(generation: number): void { // Self-scheduling: schedule the NEXT pass only after this one settles, so a slow fetch can never // overlap its successor. The generation guard drops a stale tick whose start cycle was already // stopped (and possibly restarted), so a stop→start race never leaves two poll chains scheduling. void this.refresh().finally(() => { if (generation !== this.#generation || !this.#running || this.#disposed) return; this.#timer = this.#setTimer(() => this.#tick(generation), this.#refreshMs); }); } drill(stream: string): void { if (this.#disposed) return; if (this.#mode === "live" && this.#drill?.stream === stream) return; // Claim the terminal region: bump the op token so any in-flight replay (whose fetch has not yet // resolved) drops its result instead of overwriting this live drill once it lands. this.#opToken++; // Close and drop the prior drill up-front so a synchronous failure while building the new one // (createTerminal, an invalid TerminalSession credit, or connect throwing) can't leave #drill // pointing at an already-closed client. #drill is re-set only once the new client is fully wired. this.#drill?.client.close(); this.#drill = undefined; // Tear down the prior terminal before mounting a fresh one so repeated drills don't leak xterm // instances/listeners (replaceChildren only drops the DOM node, not the widget). this.#terminal?.dispose?.(); this.#terminal = undefined; try { // Fresh terminal for the newly selected worker. this.#terminalHost.replaceChildren(); const rawSink = this.#env.createTerminal(this.#terminalHost); this.#terminal = rawSink; // Wrap the sink so the FIRST byte of live output clears the "waiting for output" note. A drill // that connects to a quiet stream (a job between frames) otherwise shows a blank panel with no // signal it is working; the note stays until output flows, then vanishes on the first write. // Only `write` is proxied — TerminalSession never disposes the sink (teardown goes through // #terminal, which holds the raw sink), so the wrapper needs nothing else. let cleared = false; const sink: TerminalSink = { write: (chunk) => { if (!cleared) { cleared = true; this.#setNote(undefined); } rawSink.write(chunk); }, }; let session: TerminalSession | undefined; const client = new RelayChannelClient({ connect: this.#env.connectRelay, onRelay: (message) => { // Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until // then it honestly reads "connecting", so a socket that never opens/subscribes stops // masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's // resubscribe ack does not re-arm the note after real output has already flowed. if (!cleared && "op" in message && message.op === "subscribed") { this.#setNote("Waiting for live output…", "waiting"); } session?.handle(message); }, // Re-attach on EVERY (re)connect → resume-from-offset: the terminal survives a cockpit // reconnect without losing or double-writing output. onOpen: () => session?.attach(), schedule: this.#env.schedule, onError: (err) => this.#env.onError?.(err), }); session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message), credit: this.#env.credit, }); client.open(); this.#drill = { stream, client }; this.#setMode("live", stream); // Arm the note as "connecting" (after #setMode, which clears it) BEFORE the socket opens. It only // becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the // first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600). this.#setNote("Connecting…", "connecting"); } catch (err) { // Building the new terminal failed AFTER the prior drill + terminal were already torn down // above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale // "live"/"replay" indicator backing a terminal that no longer exists, and a partially-built // #terminal (createTerminal returned before a later step threw) would leak. Reset the region to // idle — symmetric with replay(), which clears mode up-front — before surfacing the error. this.#drill?.client.close(); this.#drill = undefined; this.#terminal?.dispose?.(); this.#terminal = undefined; this.#setMode(undefined, undefined); this.#env.onError?.(err); } } /** * Replay a captured PAST session's stored transcript into the terminal region — static playback of a * closed stream with NO live worker and NO relay connection. Tears down any live drill first, fetches * the transcript's bytes, and feeds them through the SAME resume-from-offset {@link TerminalSession} * renderer a live stream uses, so the exited agent's terminal renders faithfully. The panel is marked * `replay` so the operator plainly sees it is a past session, not a live one. */ async replay(stream: string): Promise { if (this.#disposed) return; const fetchTranscript = this.#env.fetchTranscript; if (fetchTranscript === undefined) return; // Claim the terminal region under a fresh op token, captured for the post-fetch re-check below. const token = ++this.#opToken; // Drop any live drill and the prior terminal before fetching so a replay never runs alongside a // live stream in the same region. this.#drill?.client.close(); this.#drill = undefined; this.#terminal?.dispose?.(); this.#terminal = undefined; this.#setMode(undefined, undefined); let data: TranscriptDataReport; try { // Bound the transcript fetch: a hung endpoint must not leave replay() pending forever with the // terminal wedged out of live mode. The wait always settles, so this catch runs and mode stays idle. data = await this.#bounded(() => fetchTranscript(stream), "transcript"); } catch (err) { // Mirror the success path's guard: a replay superseded by a newer op (or a disposed cockpit) must not // surface its late timeout/rejection — the terminal region no longer belongs to this stale replay. if (this.#disposed || token !== this.#opToken) return; this.#env.onError?.(err); return; } // A newer drill()/replay() (or dispose()) claimed the terminal while this fetch was outstanding — // drop this stale result rather than clobber the newer selection with an out-of-date replay. if (this.#disposed || token !== this.#opToken) return; try { this.#terminalHost.replaceChildren(); const sink = this.#env.createTerminal(this.#terminalHost); this.#terminal = sink; const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from }); replayTranscript(session, data); this.#setMode("replay", stream); // Additively mount the STRUCTURED derived view beside the byte replay above (the byte replay is // left untouched). The env's onPermissionResolve seam is threaded into the render so a pending // escalate-permission prompt's Allow/Deny click reaches whatever the bridge wires there. if (this.#structuredRegion !== undefined) { renderDerivedTranscript(this.#structuredRegion, this.#env.doc, data, { onPermissionResolve: this.#env.onPermissionResolve, }); } // Re-render the past list so the just-selected session shows as active (best-effort). void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined); } catch (err) { this.#env.onError?.(err); } } openWorker(instance: string): void { if (this.#disposed) return; this.#route = { kind: "worker", instance }; try { this.#renderRoute(); } catch (err) { this.#env.onError?.(err); } void this.#refreshPast(instance); } back(): void { if (this.#disposed) return; this.#route = { kind: "main" }; try { this.#renderRoute(); } catch (err) { this.#env.onError?.(err); } void this.#refreshPast(); } dispose(): void { if (this.#disposed) return; this.#disposed = true; this.#opToken++; this.stop(); this.#drill?.client.close(); this.#drill = undefined; this.#terminal?.dispose?.(); this.#terminal = undefined; this.#setMode(undefined, undefined); } } /** Boot the supply cockpit against an injected environment. Call {@link SupplyCockpitHandle.start} to poll. */ export function bootSupplyCockpit(env: SupplyCockpitEnv): SupplyCockpitHandle { return new SupplyCockpit(env); }