From 5f1eaf95173d544b600f789acbb2b77210d913ad Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:11:57 -0700 Subject: [PATCH 01/10] Keep queued operator input across an interrupt Interrupting discarded every pending queue and steer item. The common gesture is to type a correction and then interrupt so it lands sooner, which meant the interrupt destroyed exactly the input the operator most wanted delivered. Pending items now survive the stop and hand over at the next drain boundary. --- src/tui-opentui/runtime-bridge.test.ts | 16 ++++++++++++++-- src/tui-opentui/session-queue.test.ts | 4 ++-- src/tui-opentui/session-queue.ts | 10 ++++++---- src/tui-opentui/shell.test.ts | 6 ++---- src/tui-opentui/shell.ts | 5 ++++- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index e9809da1d..688955117 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -139,7 +139,7 @@ describe("attachSessionBridge", () => { ) }) - test("Ctrl+C hits port.interrupt and clears pending", async () => { + test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -157,9 +157,21 @@ describe("attachSessionBridge", () => { h.pressKey("c", { ctrl: true }) await h.renderOnce() expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) - expect(badgeCount(shell.session)).toBe(0) + expect(badgeCount(shell.session)).toBe(2) expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") + + // The stopped run settles into an idle boundary, which is where the + // kept input is handed over rather than thrown away. + port.clear() + bridge.handle({ type: "reactor.done", data: {} }) + await h.renderOnce() + expect( + port.calls.flatMap((c) => + c.op === "deliver" ? [c.item.text] : [], + ), + ).toEqual(["b", "a"]) + expect(badgeCount(shell.session)).toBe(0) } finally { bridge.dispose() shell.dispose() diff --git a/src/tui-opentui/session-queue.test.ts b/src/tui-opentui/session-queue.test.ts index cf78e2654..4cff4262b 100644 --- a/src/tui-opentui/session-queue.test.ts +++ b/src/tui-opentui/session-queue.test.ts @@ -52,12 +52,12 @@ describe("session-queue", () => { expect(d3.item?.text).toBe("q1") }) - test("Ctrl+C interrupt clears pending + sets flash + idle", () => { + test("Ctrl+C interrupt keeps pending + sets flash + idle", () => { let s = createSessionQueue("busy") s = enqueue(s, "a") s = enqueueSteer(s, "b") s = interrupt(s) - expect(badgeCount(s)).toBe(0) + expect(drainOrder(s).map((i) => i.text)).toEqual(["b", "a"]) expect(s.interruptFlash).toBe(true) expect(s.run).toBe("idle") s = clearInterruptFlash(s) diff --git a/src/tui-opentui/session-queue.ts b/src/tui-opentui/session-queue.ts index b90f6a1c4..851e90392 100644 --- a/src/tui-opentui/session-queue.ts +++ b/src/tui-opentui/session-queue.ts @@ -93,15 +93,17 @@ export function enqueueSteer( } /** - * Hard interrupt: discard all pending queue + steer, clear flash flag set, - * force run to idle (caller re-sets busy when a new run starts). + * Hard interrupt: stop the run, keep everything the operator queued. Typing a + * correction and then interrupting so it lands sooner is the common shape of + * this gesture, so discarding the queue destroyed exactly the input the + * operator most wanted delivered. Pending items survive to the next drain + * boundary; only the run state and the flash change here. */ export function interrupt(state: SessionQueueState): SessionQueueState { return { + ...state, run: "idle", - items: [], interruptFlash: true, - nextId: state.nextId, } } diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index b94c74a0e..caf120139 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -448,7 +448,7 @@ describe("product skin: stream + queue + overlay", () => { ) }) - test("Ctrl+C interrupt clears pending + flash", async () => { + test("Ctrl+C interrupt keeps pending + sets flash", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -463,14 +463,12 @@ describe("product skin: stream + queue + overlay", () => { submitPrompt(shell, "steer") expect(shell.pendingQueue).toBe(2) interruptShell(shell) - expect(shell.pendingQueue).toBe(0) + expect(shell.pendingQueue).toBe(2) expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") await h.renderOnce() const row = noticeRow(h.captureCharFrame()) expect(row).toContain("interrupt") - // An empty queue is the default state, so it stays off the row. - expect(row).not.toContain("queue") } finally { shell.dispose() } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 458695141..ec5c5c0c0 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3171,7 +3171,10 @@ export function applyShellInterrupt(shell: AppShell): void { shell.prompt.value = "" appendStreamRow(shell, { role: "system", - text: `interrupt — discarded ${had} pending`, + text: + had > 0 + ? `interrupt — ${had} pending kept, delivers next turn` + : "interrupt", meta: "stop", }) paintChrome(shell) From a9e21e5510c25fdae6f2c8bd3b88f97284b8b77f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:18:07 -0700 Subject: [PATCH 02/10] Answer where the fleet is without interrupting the run Interrupting was the only way to ask whether a fleet was progressing, and it stopped the work to answer. /status reads the live session store and prints one row -- running lanes with their clocks, which of them have gone quiet, and the finished tally -- from the palette, which does not touch the run. --- src/subagent/fleet-report.test.ts | 143 ++++++++++++++++++ src/subagent/fleet-report.ts | 241 ++++++++++++++++++++++++++++++ src/subagent/index.ts | 9 ++ src/tui/commands/built-in.test.ts | 20 +++ src/tui/commands/built-in.ts | 12 ++ src/tui/commands/registry.ts | 6 + src/tui/runner.ts | 8 +- 7 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 src/subagent/fleet-report.test.ts create mode 100644 src/subagent/fleet-report.ts diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts new file mode 100644 index 000000000..629c832a5 --- /dev/null +++ b/src/subagent/fleet-report.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { + createFleetWatch, + fleetDigest, + observeFleet, + type FleetLane, +} from "./fleet-report.js"; + +const T0 = 1_000_000; + +function lane(overrides: Partial & { id: string }): FleetLane { + return { + description: overrides.id, + status: "running", + startedAt: T0, + lastActivityAt: T0, + currentToolName: null, + ...overrides, + }; +} + +describe("observeFleet", () => { + test("the first observation seeds without announcing an in-flight fleet", () => { + const { watch, updates } = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs" })], + T0, + ); + expect(updates).toEqual([]); + expect(watch.running).toBe(2); + }); + + test("a finished lane is reported with what it produced", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "api", + status: "done", + report: "## Summary\nRewired the reporter and added six tests.", + }), + ], + T0 + 1000, + ); + expect(updates[0]).toBe( + "fleet · api done — Rewired the reporter and added six tests.", + ); + }); + + test("the last lane finishing says so, which is the silence the operator hit", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs", status: "done" })], + T0, + ).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "api", status: "done", report: "done" }), lane({ id: "docs", status: "done" })], + T0 + 1000, + ); + expect(updates).toEqual([ + "fleet · api done — done", + "fleet · 2 done — nothing running", + ]); + }); + + test("a failure names what went wrong", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "build" })], T0).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "build", status: "failed", error: "typecheck exited 1" })], + T0 + 1000, + ); + expect(updates[0]).toContain("build failed — typecheck exited 1"); + }); + + test("a dispatch carries the load it was decided against", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "api" }), lane({ id: "docs" })], + T0 + 1000, + ); + expect(updates).toEqual(["fleet · dispatched docs (2 running)"]); + }); + + test("a quiet lane is announced once, not on every tick it stays quiet", () => { + const quiet = lane({ id: "api", lastActivityAt: T0 }); + const seeded = observeFleet(createFleetWatch(), [quiet], T0).watch; + const first = observeFleet(seeded, [quiet], T0 + 60_000); + expect(first.updates[0]).toContain("api stalled"); + const second = observeFleet(first.watch, [quiet], T0 + 90_000); + expect(second.updates).toEqual([]); + }); + + test("routine activity that changes nothing produces no update", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const busy = observeFleet( + seeded, + [lane({ id: "api", lastActivityAt: T0 + 4000, currentToolName: "grep" })], + T0 + 5000, + ); + expect(busy.updates).toEqual([]); + }); + + test("a dozen lanes landing at once collapse into one tally", () => { + const before = Array.from({ length: 12 }, (_, i) => lane({ id: `l${i}` })); + const seeded = observeFleet(createFleetWatch(), before, T0).watch; + const after = before.map((l, i) => + i < 9 + ? { ...l, status: "done" as const, report: "ok" } + : { ...l, status: "failed" as const, error: "boom" }, + ); + const { updates } = observeFleet(seeded, after, T0 + 1000); + expect(updates).toEqual([ + "fleet · 9 done, 3 failed", + "fleet · 9 done, 3 failed — nothing running", + ]); + }); +}); + +describe("fleetDigest", () => { + test("one row carries running lanes, their clocks, and the finished tally", () => { + const digest = fleetDigest( + [ + lane({ id: "api", startedAt: T0 - 80_000, lastActivityAt: T0 - 1000 }), + lane({ id: "docs", startedAt: T0 - 20_000, lastActivityAt: T0 - 120_000 }), + lane({ id: "web", status: "done" }), + lane({ id: "cli", status: "failed" }), + ], + T0, + ); + expect(digest).toBe("fleet · 2 running (api 1:20, docs 0:20 stalled) · 1 done · 1 failed"); + }); + + test("a fleet with nothing left running says so rather than going blank", () => { + expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( + "fleet · nothing running · 1 done", + ); + expect(fleetDigest([], T0)).toBe("fleet · no lanes dispatched"); + }); +}); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts new file mode 100644 index 000000000..82612ea00 --- /dev/null +++ b/src/subagent/fleet-report.ts @@ -0,0 +1,241 @@ +/** + * What the orchestrator says to the operator about the fleet, unprompted. + * + * Every lane completion, stall and failure already passes through the parent + * session, which then said nothing about any of it unless interrupted and + * asked. This module turns that stream into the small number of lines that + * change the operator's picture, and nothing else: a lane finished and what it + * produced, a lane stalled or failed, work dispatched, and the moment the + * fleet runs dry. + * + * Pure and stateless per call — the caller keeps the returned watch and hands + * it back on the next observation. No painting, no store access. + */ + +import { agentProgress, clockLabel, DEFAULT_STALL_MS } from "../tui-opentui/agent-progress.js"; +import type { SubAgentSessionStatus } from "./session-store.js"; + +/** The lane fields a report is written from. `SubAgentSession` satisfies it. */ +export type FleetLane = { + readonly id: string; + readonly description: string; + readonly status: SubAgentSessionStatus; + readonly startedAt: number; + readonly lastActivityAt: number; + readonly currentToolName: string | null; + readonly report?: string; + readonly error?: string; +}; + +type LaneMark = { + readonly status: SubAgentSessionStatus; + /** + * Sticky once set. A lane that flaps either side of the stall threshold + * would otherwise re-announce itself every time it went quiet, which is the + * wall of noise this module exists to avoid. + */ + readonly stallReported: boolean; +}; + +export type FleetWatch = { + readonly lanes: ReadonlyMap; + readonly running: number; + /** False until the first observation, so a resumed fleet is not re-announced. */ + readonly seeded: boolean; +}; + +export function createFleetWatch(): FleetWatch { + return { lanes: new Map(), running: 0, seeded: false }; +} + +/** + * Above this many changes in one observation the individual lines stop being + * readable and start being a scroll, so they collapse into one tally. Set by + * what a glance can take in, not by fleet size. + */ +const COALESCE_ABOVE = 3; + +/** Enough of an outcome to judge it; past this the operator opens the lane. */ +const OUTCOME_CHARS = 72; + +const PREFIX = "fleet"; + +/** + * A lane going quiet is the one change that produces no event, so it has to be + * looked for. Coarse on purpose: the stall threshold is tens of seconds, and + * the observation is a cheap diff either way. + */ +export const FLEET_STALL_POLL_MS = 5_000; + +/** Lanes named in a digest before it starts counting instead of listing. */ +const DIGEST_NAMED_LANES = 4; + +function firstLine(text: string | undefined): string { + if (text === undefined) return ""; + for (const raw of text.split("\n")) { + // A report that opens with "## Summary" says nothing an operator can act + // on; the first line of prose under it is the outcome they wanted. + if (/^\s*#/.test(raw)) continue; + const line = raw.replace(/^[>*\-\s]+/, "").trim(); + if (line.length > 0) return line; + } + return ""; +} + +function clip(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1).trimEnd()}…`; +} + +function outcome(lane: FleetLane): string { + const summary = firstLine(lane.report); + return summary.length > 0 ? clip(summary, OUTCOME_CHARS) : "no summary reported"; +} + +function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean { + // One definition of a stalled lane lives in `agentProgress`; asking it is + // what keeps this report and the agents panel from disagreeing on screen. + return agentProgress(lane, nowMs, stallMs)?.stalled === true; +} + +type Change = + | { readonly kind: "dispatched"; readonly line: string } + | { readonly kind: "done"; readonly line: string } + | { readonly kind: "failed"; readonly line: string } + | { readonly kind: "stalled"; readonly line: string }; + +export type FleetObservation = { + readonly watch: FleetWatch; + /** Ready-to-print lines, already coalesced. Usually empty. */ + readonly updates: readonly string[]; +}; + +export function observeFleet( + previous: FleetWatch, + lanes: readonly FleetLane[], + nowMs: number, + stallMs: number = DEFAULT_STALL_MS, +): FleetObservation { + const marks = new Map(); + const changes: Change[] = []; + let running = 0; + + for (const lane of lanes) { + if (lane.status === "running") running += 1; + const before = previous.lanes.get(lane.id); + const stalled = + lane.status === "running" && + (before?.stallReported === true || isStalled(lane, nowMs, stallMs)); + marks.set(lane.id, { status: lane.status, stallReported: stalled }); + + if (!previous.seeded) continue; + + if (before === undefined) { + if (lane.status === "running") { + changes.push({ kind: "dispatched", line: `dispatched ${lane.description}` }); + } + continue; + } + + if (before.status !== lane.status) { + if (lane.status === "done") { + changes.push({ kind: "done", line: `${lane.description} done — ${outcome(lane)}` }); + } else if (lane.status === "failed") { + changes.push({ + kind: "failed", + line: `${lane.description} failed — ${clip(firstLine(lane.error) || "no error reported", OUTCOME_CHARS)}`, + }); + } else if (lane.status === "cancelled") { + changes.push({ kind: "failed", line: `${lane.description} cancelled` }); + } + continue; + } + + if (lane.status === "running" && stalled && before.stallReported !== true) { + changes.push({ + kind: "stalled", + line: `${lane.description} stalled — quiet for ${clockLabel(nowMs - lane.lastActivityAt)}`, + }); + } + } + + const watch: FleetWatch = { lanes: marks, running, seeded: true }; + if (changes.length === 0) return { watch, updates: [] }; + + // A dispatch carries the load it was decided against, so an operator who + // would have scheduled differently can say so while it still matters. + const lines = + changes.length > COALESCE_ABOVE + ? [tally(changes)] + : changes.map((c) => + c.kind === "dispatched" ? `${c.line} (${running} running)` : c.line, + ); + + // The defect this report exists for: work finished, nothing left running, + // and no one said so. That transition is always worth its own line. + if (running === 0 && previous.running > 0) { + lines.push(`${idleSummary(lanes)} — nothing running`); + } + + return { watch, updates: lines.map((line) => `${PREFIX} · ${line}`) }; +} + +function tally(changes: readonly Change[]): string { + const count = (kind: Change["kind"]): number => + changes.filter((c) => c.kind === kind).length; + const parts: string[] = []; + const dispatched = count("dispatched"); + const done = count("done"); + const failed = count("failed"); + const stalled = count("stalled"); + if (done > 0) parts.push(`${done} done`); + if (failed > 0) parts.push(`${failed} failed`); + if (stalled > 0) parts.push(`${stalled} stalled`); + if (dispatched > 0) parts.push(`${dispatched} dispatched`); + return parts.join(", "); +} + +function idleSummary(lanes: readonly FleetLane[]): string { + const done = lanes.filter((l) => l.status === "done").length; + const failed = lanes.filter((l) => l.status === "failed" || l.status === "cancelled").length; + const parts = [`${done} done`]; + if (failed > 0) parts.push(`${failed} failed`); + return parts.join(", "); +} + +/** + * The answer to "where are we" on demand — the same picture the unprompted + * lines build up to, in one row, so asking never costs an interrupt. + */ +export function fleetDigest( + lanes: readonly FleetLane[], + nowMs: number, + stallMs: number = DEFAULT_STALL_MS, +): string { + if (lanes.length === 0) return `${PREFIX} · no lanes dispatched`; + const running = lanes.filter((l) => l.status === "running"); + const done = lanes.filter((l) => l.status === "done").length; + const failed = lanes.filter((l) => l.status === "failed").length; + const cancelled = lanes.filter((l) => l.status === "cancelled").length; + + const parts: string[] = []; + if (running.length === 0) { + parts.push("nothing running"); + } else { + const named = running + .slice(0, DIGEST_NAMED_LANES) + .map((lane) => { + const quiet = isStalled(lane, nowMs, stallMs) ? " stalled" : ""; + return `${lane.description} ${clockLabel(nowMs - lane.startedAt)}${quiet}`; + }) + .join(", "); + const extra = running.length - Math.min(running.length, DIGEST_NAMED_LANES); + parts.push( + `${running.length} running (${named}${extra > 0 ? `, +${extra} more` : ""})`, + ); + } + if (done > 0) parts.push(`${done} done`); + if (failed > 0) parts.push(`${failed} failed`); + if (cancelled > 0) parts.push(`${cancelled} cancelled`); + return `${PREFIX} · ${parts.join(" · ")}`; +} diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 7228d3507..6b05dacf7 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -7,6 +7,15 @@ export type { SubAgentSession, SubAgentSessionStore, SubAgentTranscriptEntry } from "./session-store.js"; export { createSubAgentSessionStore } from "./session-store.js"; +export { + createFleetWatch, + fleetDigest, + FLEET_STALL_POLL_MS, + observeFleet, + type FleetLane, + type FleetObservation, + type FleetWatch, +} from "./fleet-report.js"; export { DEFAULT_THRASH_CONFIG, EMPTY_THRASH_STATE, diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 7ac1228de..f95fc9c35 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -41,6 +41,26 @@ describe("removed commands", () => { }); }); +describe("/status command", () => { + it("answers from the live fleet without sending anything to the model", () => { + const ctx: CommandContext = { + signalClear: () => {}, + getFleetStatus: () => "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + }; + expect(getCommand("status")!.handler("", ctx)).toEqual({ + type: "message", + text: "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + }); + }); + + it("says so rather than throwing when no fleet source is wired", () => { + expect(getCommand("status")!.handler("", makeCtx())).toEqual({ + type: "message", + text: "Fleet status is not available in this session.", + }); + }); +}); + describe("removed approval command", () => { it("is not registered", () => { expect(getCommand("auto")).toBeUndefined(); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 723f61933..ebeb4f3fc 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -119,6 +119,18 @@ export function registerBuiltInCommands(): void { }, }); + registerCommand({ + name: "status", + description: "Show what the dispatched fleet is doing right now", + handler: (_args, ctx) => { + const status = ctx.getFleetStatus?.(); + if (status === undefined) { + return { type: "message", text: "Fleet status is not available in this session." }; + } + return { type: "message", text: status }; + }, + }); + registerCommand({ name: "changelog", description: "Show recent release notes (or full history)", diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index bf44e9c34..e73380c79 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -3,6 +3,12 @@ import type { CostSummary } from "../../cost/cost-summary.js"; export type CommandContext = { signalClear: () => void; getCostSummary?: () => CostSummary; + /** + * One-row answer to "where are we" on the dispatched fleet. Read live and + * answered locally, so asking never costs the operator an interrupt (and + * with it whatever they had queued). + */ + getFleetStatus?: () => string; // Start a workflow by name; returns a status message to surface to the user. startWorkflow?: (name: string) => string; /** Rename the active session (persisted as run.json task). */ diff --git a/src/tui/runner.ts b/src/tui/runner.ts index d5ff008ec..086f27958 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -117,7 +117,12 @@ import { detectLanguageServerAvailable } from "../agent/lsp-availability.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { resolveSessionMode, type SessionMode } from "../config/session-mode.js"; import { promptSessionModeIfUnset } from "./session-mode-prompt.js"; -import { createSubAgentSessionStore, taskToolDefinition, type SubAgentProvider } from "../subagent/index.js"; +import { + createSubAgentSessionStore, + fleetDigest, + taskToolDefinition, + type SubAgentProvider, +} from "../subagent/index.js"; import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/types/runtime"; import { createSessionOperationQueue } from "./session-operation-queue.js"; import { setAgentSourceUnlessClosed } from "./agent-source-sync.js"; @@ -1741,6 +1746,7 @@ export async function runTUI(initialConfig: Config): Promise { }); }, startWorkflow: (name) => workflowController.start(name), + getFleetStatus: () => fleetDigest(subAgentSessions.list(), Date.now()), renameSession: (name) => { const trimmed = name.trim(); if (trimmed.length === 0) return "Session name cannot be empty"; From 8a28739947e0d0a798a8ca1506db5bc79ab36172 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:18:26 -0700 Subject: [PATCH 03/10] Report fleet progress to the operator unprompted Every lane completion, stall and failure already passed through the parent session, which said nothing about any of it unless interrupted and asked -- an operator watched half a fleet finish with the remaining work undispatched and had to stop the run to find out. Lane changes now drive the report directly, so a completion lands with what it produced and a failure with what went wrong, at the moment it happens. The noise boundary is the whole design: only transitions are reported, a quiet lane is announced once rather than on every tick it stays quiet, more than three changes in one observation collapse into a single tally so a dozen lanes cost a line rather than a screen, and the fleet running dry gets its own line because that is the silence that started this. --- src/tui/runner.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 086f27958..10c9933bc 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -118,8 +118,11 @@ import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normal import { resolveSessionMode, type SessionMode } from "../config/session-mode.js"; import { promptSessionModeIfUnset } from "./session-mode-prompt.js"; import { + createFleetWatch, createSubAgentSessionStore, fleetDigest, + FLEET_STALL_POLL_MS, + observeFleet, taskToolDefinition, type SubAgentProvider, } from "../subagent/index.js"; @@ -2200,6 +2203,20 @@ export async function runTUI(initialConfig: Config): Promise { setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); + // The fleet reports itself. Store changes drive it, so a lane finishing or + // failing is on screen the moment it happens rather than at the next turn + // boundary; the timer covers the one change that produces no event at all, + // a lane going quiet. `observeFleet` decides what is worth saying. + let fleetWatch = createFleetWatch(); + const reportFleet = (): void => { + const observation = observeFleet(fleetWatch, subAgentSessions.list(), Date.now()); + fleetWatch = observation.watch; + for (const update of observation.updates) surfaceSystemNotice(host.shell, update); + }; + const unsubscribeFleetReport = subAgentSessions.subscribe(reportFleet); + const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); + if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); + // Same names the operator can already reach by typing them: skills the // session discovered at startup, agents from the live profile registry // (which trust changes can update mid-session, so read through the @@ -2300,6 +2317,8 @@ export async function runTUI(initialConfig: Config): Promise { surfaceSystemNotice(host.shell, notice); await host.waitUntilExit(); + clearInterval(fleetStallPoll); + unsubscribeFleetReport(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing // downstream delivers its terminal event once the app is gone. await cycleRecorder.dispose("exit"); From ac3bfcf22edb7e5a0b23cbbd7b136f7fd3027436 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:30:17 -0700 Subject: [PATCH 04/10] Hand kept input over at the interrupt itself A stop does not reliably produce an idle event to drain against, so a message the operator had queued sat in the badge with nothing left to release it. The interrupt delivers it directly; the host serialises the send behind the agent rebuild the same interrupt started. --- src/tui-opentui/runtime-bridge.test.ts | 13 +++---------- src/tui-opentui/runtime-bridge.ts | 5 +++++ src/tui-opentui/shell.ts | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index 688955117..e62d766c7 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -157,19 +157,12 @@ describe("attachSessionBridge", () => { h.pressKey("c", { ctrl: true }) await h.renderOnce() expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) - expect(badgeCount(shell.session)).toBe(2) expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") - - // The stopped run settles into an idle boundary, which is where the - // kept input is handed over rather than thrown away. - port.clear() - bridge.handle({ type: "reactor.done", data: {} }) - await h.renderOnce() + // Handed over, not thrown away — and handed over here rather than + // left waiting on an idle event the stop may never produce. expect( - port.calls.flatMap((c) => - c.op === "deliver" ? [c.item.text] : [], - ), + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), ).toEqual(["b", "a"]) expect(badgeCount(shell.session)).toBe(0) } finally { diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index e9762c46a..dac8b47bc 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -955,6 +955,11 @@ export function attachSessionBridge( bag.pendingEchoes.length = 0 applyShellInterrupt(shell) bag.port.interrupt() + // The stop settles the turn without necessarily producing an idle event to + // drain against, so anything the operator had queued would sit there + // forever. Hand it over here instead: the host serialises it behind the + // agent rebuild the interrupt just started. + drainAtBoundary(shell, bag) // Clearing the last prompt is what stops the quota loop from replaying a // turn the operator (or the watchdog) deliberately stopped. bag.lastSentMessage = "" diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index ec5c5c0c0..6abc0e3c2 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3173,7 +3173,7 @@ export function applyShellInterrupt(shell: AppShell): void { role: "system", text: had > 0 - ? `interrupt — ${had} pending kept, delivers next turn` + ? `interrupt — ${had} pending kept` : "interrupt", meta: "stop", }) From 0ac9f7e6f6f8c5353e74f31ef50502b0b7340219 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:30:25 -0700 Subject: [PATCH 05/10] Hold fleet updates to one row each and let bursts settle Running a real fleet showed both halves of the noise boundary leaking. A parallel dispatch arrives as one store change per lane, so six lanes printed six dispatch lines instead of the one decision they were; the report now settles briefly before observing, and six lanes read as "6 dispatched". Long worker summaries also wrapped, costing two rows an update, so a line is clipped to the width a transcript row has. --- src/subagent/fleet-report.test.ts | 17 +++++++++++++++++ src/subagent/fleet-report.ts | 20 ++++++++++++++++++-- src/subagent/index.ts | 1 + src/tui/runner.ts | 12 +++++++++++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 629c832a5..41f8e636f 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -104,6 +104,23 @@ describe("observeFleet", () => { expect(busy.updates).toEqual([]); }); + test("an update is one row — a long outcome is clipped, never wrapped", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "api", + status: "done", + report: "Rewired the reporter, added the digest, wired the poll, and updated every affected test in the suite.", + }), + ], + T0 + 1000, + ); + expect(updates[0]!.length).toBeLessThanOrEqual(76); + expect(updates[0]).toContain("…"); + }); + test("a dozen lanes landing at once collapse into one tally", () => { const before = Array.from({ length: 12 }, (_, i) => lane({ id: `l${i}` })); const seeded = observeFleet(createFleetWatch(), before, T0).watch; diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 82612ea00..1fc8185ee 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -56,7 +56,13 @@ export function createFleetWatch(): FleetWatch { const COALESCE_ABOVE = 3; /** Enough of an outcome to judge it; past this the operator opens the lane. */ -const OUTCOME_CHARS = 72; +const OUTCOME_CHARS = 56; + +/** + * One update is one row. A line that wraps doubles the cost of every update on + * screen, which is how a report meant to be glanced at turns into a scroll. + */ +const MAX_UPDATE_CHARS = 76; const PREFIX = "fleet"; @@ -67,6 +73,13 @@ const PREFIX = "fleet"; */ export const FLEET_STALL_POLL_MS = 5_000; +/** + * A parallel dispatch lands as one store change per lane, so observing each + * one on its own turns a single decision into a line per lane. Settling first + * is what lets the tally do its job. + */ +export const FLEET_REPORT_SETTLE_MS = 400; + /** Lanes named in a digest before it starts counting instead of listing. */ const DIGEST_NAMED_LANES = 4; @@ -177,7 +190,10 @@ export function observeFleet( lines.push(`${idleSummary(lanes)} — nothing running`); } - return { watch, updates: lines.map((line) => `${PREFIX} · ${line}`) }; + return { + watch, + updates: lines.map((line) => clip(`${PREFIX} · ${line}`, MAX_UPDATE_CHARS)), + }; } function tally(changes: readonly Change[]): string { diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 6b05dacf7..ca62b6817 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -10,6 +10,7 @@ export { createSubAgentSessionStore } from "./session-store.js"; export { createFleetWatch, fleetDigest, + FLEET_REPORT_SETTLE_MS, FLEET_STALL_POLL_MS, observeFleet, type FleetLane, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 10c9933bc..2158a383e 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -121,6 +121,7 @@ import { createFleetWatch, createSubAgentSessionStore, fleetDigest, + FLEET_REPORT_SETTLE_MS, FLEET_STALL_POLL_MS, observeFleet, taskToolDefinition, @@ -2213,7 +2214,15 @@ export async function runTUI(initialConfig: Config): Promise { fleetWatch = observation.watch; for (const update of observation.updates) surfaceSystemNotice(host.shell, update); }; - const unsubscribeFleetReport = subAgentSessions.subscribe(reportFleet); + let fleetSettle: ReturnType | null = null; + const unsubscribeFleetReport = subAgentSessions.subscribe(() => { + if (fleetSettle !== null) return; + fleetSettle = setTimeout(() => { + fleetSettle = null; + reportFleet(); + }, FLEET_REPORT_SETTLE_MS); + if (typeof fleetSettle.unref === "function") fleetSettle.unref(); + }); const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); @@ -2318,6 +2327,7 @@ export async function runTUI(initialConfig: Config): Promise { await host.waitUntilExit(); clearInterval(fleetStallPoll); + if (fleetSettle !== null) clearTimeout(fleetSettle); unsubscribeFleetReport(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing // downstream delivers its terminal event once the app is gone. From 3d6e6ba9c0603de45a62866973ac785c24086b03 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 21:20:35 -0700 Subject: [PATCH 06/10] Surface a not-delivered notice when a mid-rebuild agent drops a message The deliver path caught failures silently on the theory a dropped message was harmless. It wasn't: the shell had already popped the queue item and painted it as delivered, and a failed rebuild left currentAgent pointing at the closed agent, so deliver threw and the catch ate it. Extracted the guard into a small testable function that checks fatalBuildError up front (mirroring the send path) and routes any failure through the system notice. --- src/tui/deliver-agent-message.test.ts | 58 +++++++++++++++++++++++++++ src/tui/deliver-agent-message.ts | 26 ++++++++++++ src/tui/runner.ts | 14 ++++--- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/tui/deliver-agent-message.test.ts create mode 100644 src/tui/deliver-agent-message.ts diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts new file mode 100644 index 000000000..c34143bb7 --- /dev/null +++ b/src/tui/deliver-agent-message.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { deliverAgentMessage } from "./deliver-agent-message.js"; + +describe("deliverAgentMessage", () => { + test("surfaces a not-delivered notice instead of throwing when the agent is mid-rebuild", () => { + const notices: string[] = []; + const fatal = new Error("agent rebuild failed: provider unreachable"); + let delivered = false; + + deliverAgentMessage({ + getFatalBuildError: () => fatal, + deliverToLiveAgent: () => { + delivered = true; + }, + onDeliverFailure: (message) => notices.push(message), + }); + + // The rebuild failed, so currentAgent still points at the closed agent. + // Delivery must never be attempted against it, and the operator must see + // why their message did not go through. + expect(delivered).toBe(false); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("not delivered"); + expect(notices[0]).toContain("provider unreachable"); + }); + + test("surfaces a not-delivered notice when the live agent throws on delivery", () => { + const notices: string[] = []; + + deliverAgentMessage({ + getFatalBuildError: () => null, + deliverToLiveAgent: () => { + throw new Error("agent is closed"); + }, + onDeliverFailure: (message) => notices.push(message), + }); + + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("not delivered"); + expect(notices[0]).toContain("agent is closed"); + }); + + test("delivers normally and stays silent when the agent is healthy", () => { + const notices: string[] = []; + let delivered = false; + + deliverAgentMessage({ + getFatalBuildError: () => null, + deliverToLiveAgent: () => { + delivered = true; + }, + onDeliverFailure: (message) => notices.push(message), + }); + + expect(delivered).toBe(true); + expect(notices).toHaveLength(0); + }); +}); diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts new file mode 100644 index 000000000..c41e52675 --- /dev/null +++ b/src/tui/deliver-agent-message.ts @@ -0,0 +1,26 @@ +/** + * Guards a queued/steer deliver against a mid-rebuild agent. The shell paints + * the delivered row and pops the queue item before this runs, so a failure + * here must be surfaced — a swallowed error here means the transcript claims + * delivery for a message that never reached the agent. + */ +export type DeliverAgentMessageDeps = { + getFatalBuildError: () => Error | null; + deliverToLiveAgent: () => void; + onDeliverFailure: (message: string) => void; +}; + +export function deliverAgentMessage(deps: DeliverAgentMessageDeps): void { + const fatal = deps.getFatalBuildError(); + if (fatal !== null) { + deps.onDeliverFailure(`Message not delivered: ${fatal.message}`); + return; + } + try { + deps.deliverToLiveAgent(); + } catch (err) { + deps.onDeliverFailure( + `Message not delivered: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 2158a383e..330c64443 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -198,6 +198,7 @@ import { import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { createModelSummarizer } from "../session/summarizer.js"; import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; +import { deliverAgentMessage } from "./deliver-agent-message.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -1182,11 +1183,14 @@ export async function runTUI(initialConfig: Config): Promise { const sessionOps = createSessionOperationQueue(); const enqueueAgentDeliver = (deliverToLiveAgent: () => void): void => { void sessionOps.enqueue(async () => { - try { - deliverToLiveAgent(); - } catch { - // Agent may be mid-reload or closing; a dropped message is harmless. - } + // The shell already popped the queue item and painted it as delivered + // by the time this runs, so a failed rebuild must be surfaced here — + // otherwise the message silently never reaches the agent. + deliverAgentMessage({ + getFatalBuildError: () => fatalBuildError, + deliverToLiveAgent, + onDeliverFailure: systemNotice, + }); }); }; From cba5c44120c165485189c34e43df17b5b0d55e43 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 21:27:30 -0700 Subject: [PATCH 07/10] Widen FleetLane to carry the tool-clock field the rebased observability lands Rebasing onto #429's call-id-keyed tool clock left FleetLane's structural type one field short of SubAgentSession, so the fleet report and the agents panel were reading two different lane shapes. --- src/subagent/fleet-report.test.ts | 1 + src/subagent/fleet-report.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 41f8e636f..bcd122cc6 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -15,6 +15,7 @@ function lane(overrides: Partial & { id: string }): FleetLane { startedAt: T0, lastActivityAt: T0, currentToolName: null, + currentToolStartedAt: null, ...overrides, }; } diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 1fc8185ee..827ec4309 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -23,6 +23,7 @@ export type FleetLane = { readonly startedAt: number; readonly lastActivityAt: number; readonly currentToolName: string | null; + readonly currentToolStartedAt: number | null; readonly report?: string; readonly error?: string; }; From 03c5ec24de8abe0aa628f2dc486fbfa82d7c7ab7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 21:49:58 -0700 Subject: [PATCH 08/10] Document Ctrl+C's kept-queue behavior and the unprompted fleet channel docs/TUI.md described Ctrl+C as discarding the queue, which stopped being true once the interrupt started keeping pending input. It also had no mention of the fleet-report notice channel added alongside the agents panel, so the two surfaces read as unrelated when they draw from the same store and stall definition. Also suppress the fleet's coalesced tally line when the idle line already says the same thing verbatim. --- docs/TUI.md | 38 ++++++++++++++++++++++++++++++- src/subagent/fleet-report.test.ts | 5 +--- src/subagent/fleet-report.ts | 12 ++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 46ca2ae25..9ccb75179 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -263,6 +263,38 @@ the way down. Only once every other collapsible zone ahead of it in `COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the same last-resort floor every other optional zone shares. +### Unprompted fleet reports + +The agents panel is a standing picture of what is running right now; it says +nothing when a lane finishes, stalls, or fails unless the operator interrupts +to ask. `src/subagent/fleet-report.ts` closes that gap with its own channel: +a system-notice line, pushed into the transcript through the same +`surfaceSystemNotice` path as any other system row, the moment a lane +transition is worth saying. It does not touch the panel's rows or its +`laneState()` computation — it reads the same sub-agent session store the +panel reads, and calls the same `agentProgress()` stall definition +(`isStalled`) so the two surfaces never disagree about whether a lane is +stalled, only about *when* they say so: the panel shows it continuously, +the notice announces the transition once. + +Store changes drive it directly, so a lane finishing or failing lands the +moment it happens; a `FLEET_REPORT_SETTLE_MS` (400ms) timer covers the one +change that produces no event at all — a lane going quiet — and also lets a +parallel dispatch that lands as N store changes settle into one observation +instead of N lines. Past `COALESCE_ABOVE` (3) changes in one observation the +individual lines collapse into a single tally (`"9 done, 3 failed"`); below +that threshold each change gets its own line. The one case both the fleet +going idle and a coalesced tally would otherwise say the same thing — +all changes are terminal and the tally alone already says "N done, N +failed" — the idle line replaces the tally instead of repeating it with +"— nothing running" tacked on. + +Outcomes and errors are clipped to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the +same "one update is one row, never wrapped" rule the panel's rows follow. +`fleetDigest()` is the on-demand counterpart: the same picture in one line, +answering "where is the fleet" without an interrupt, for `/fleet` or an +operator question mid-run. + ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider @@ -446,7 +478,11 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this replaced an Ink-era yes/no exit-confirm modal with the same intent (an explicit second confirmation) without adding a modal (`handleCtrlC`, -`shell.ts`). +`shell.ts`). The interrupt keeps whatever is sitting in the queue rather than +discarding it — the operator typed those messages meaning them delivered, not +meaning "cancel this run and also throw away what I typed"; the transcript +row says so (`"interrupt — N pending kept"`) and the kept items drain into the +rebuilt agent at the next boundary (`applyShellInterrupt`, `shell.ts`). ## Overflows, scrolling, and key macros diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index bcd122cc6..3e3605ff3 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -131,10 +131,7 @@ describe("observeFleet", () => { : { ...l, status: "failed" as const, error: "boom" }, ); const { updates } = observeFleet(seeded, after, T0 + 1000); - expect(updates).toEqual([ - "fleet · 9 done, 3 failed", - "fleet · 9 done, 3 failed — nothing running", - ]); + expect(updates).toEqual(["fleet · 9 done, 3 failed — nothing running"]); }); }); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 827ec4309..51cdc2d95 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -186,9 +186,17 @@ export function observeFleet( ); // The defect this report exists for: work finished, nothing left running, - // and no one said so. That transition is always worth its own line. + // and no one said so. That transition is always worth its own line — unless + // the tally above already said the same thing, in which case a second line + // restating it verbatim (with "— nothing running" tacked on) is noise, not + // information. if (running === 0 && previous.running > 0) { - lines.push(`${idleSummary(lanes)} — nothing running`); + const idle = `${idleSummary(lanes)} — nothing running`; + if (lines.length === 1 && lines[0] === idleSummary(lanes)) { + lines[0] = idle; + } else { + lines.push(idle); + } } return { From 2ec02bcdfe89799749ed04d4be96bf5223a06049 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 23:02:55 -0700 Subject: [PATCH 09/10] Align interrupt and fleet docs with shipped behavior Ctrl+C JSDoc still said the path cleared pending items after the implementation started keeping them. The fleet-report section credited the 400ms settle timer with quiet detection and named /fleet; quiet is polled on the 5s stall interval, settle only coalesces burst store changes, and the command is /status. Kept-queue drain is at the interrupt itself, not a later boundary via applyShellInterrupt. --- docs/TUI.md | 17 ++++++++++------- src/tui-opentui/shell.ts | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 9ccb75179..b956aaf83 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -278,10 +278,11 @@ stalled, only about *when* they say so: the panel shows it continuously, the notice announces the transition once. Store changes drive it directly, so a lane finishing or failing lands the -moment it happens; a `FLEET_REPORT_SETTLE_MS` (400ms) timer covers the one -change that produces no event at all — a lane going quiet — and also lets a -parallel dispatch that lands as N store changes settle into one observation -instead of N lines. Past `COALESCE_ABOVE` (3) changes in one observation the +moment it happens. A `FLEET_REPORT_SETTLE_MS` (400ms) timer lets a parallel +dispatch that lands as N store changes settle into one observation instead +of N lines. Quiet detection is separate: `FLEET_STALL_POLL_MS` (5s) re-runs +observation so a lane that went quiet with no further store event is still +announced once. Past `COALESCE_ABOVE` (3) changes in one observation the individual lines collapse into a single tally (`"9 done, 3 failed"`); below that threshold each change gets its own line. The one case both the fleet going idle and a coalesced tally would otherwise say the same thing — @@ -292,7 +293,7 @@ failed" — the idle line replaces the tally instead of repeating it with Outcomes and errors are clipped to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the same "one update is one row, never wrapped" rule the panel's rows follow. `fleetDigest()` is the on-demand counterpart: the same picture in one line, -answering "where is the fleet" without an interrupt, for `/fleet` or an +answering "where is the fleet" without an interrupt, for `/status` or an operator question mid-run. ## How pop-ups should feel @@ -481,8 +482,10 @@ explicit second confirmation) without adding a modal (`handleCtrlC`, `shell.ts`). The interrupt keeps whatever is sitting in the queue rather than discarding it — the operator typed those messages meaning them delivered, not meaning "cancel this run and also throw away what I typed"; the transcript -row says so (`"interrupt — N pending kept"`) and the kept items drain into the -rebuilt agent at the next boundary (`applyShellInterrupt`, `shell.ts`). +row says so (`"interrupt — N pending kept"`). Kept items are handed over at +the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after +`port.interrupt()`), serialized behind the agent rebuild the stop starts — +a stop does not reliably produce an idle event to drain against later. ## Overflows, scrolling, and key macros diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 6abc0e3c2..daf267b60 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3180,7 +3180,7 @@ export function applyShellInterrupt(shell: AppShell): void { paintChrome(shell) } -/** Ctrl+C interrupt path: clear pending, flash, idle. */ +/** Ctrl+C interrupt path: keep pending, flash, idle. */ export function interruptShell(shell: AppShell): void { const hooks = getShellBridgeHooks(shell) if (hooks?.exclusive) { From 55dc413bfae0d5c487a75e8cc5b68c7453e80146 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 23:09:26 -0700 Subject: [PATCH 10/10] Align fleet runner comment with settle and stall timers Docs already split FLEET_REPORT_SETTLE_MS from FLEET_STALL_POLL_MS; the runner wiring comment still blamed quiet detection on the settle timer. Match the two-timer design so the next doc pass cannot re-poison from this call site. --- src/tui/runner.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 330c64443..bdc23c0be 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2210,8 +2210,9 @@ export async function runTUI(initialConfig: Config): Promise { // The fleet reports itself. Store changes drive it, so a lane finishing or // failing is on screen the moment it happens rather than at the next turn - // boundary; the timer covers the one change that produces no event at all, - // a lane going quiet. `observeFleet` decides what is worth saying. + // boundary. The settle timer coalesces a parallel burst into one observation; + // the stall poll re-runs so a lane that goes quiet with no further store + // event is still announced once. `observeFleet` decides what is worth saying. let fleetWatch = createFleetWatch(); const reportFleet = (): void => { const observation = observeFleet(fleetWatch, subAgentSessions.list(), Date.now());