From ba9930c615420fc59213a3a247c442d46c397e1a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 7 Oct 2026 18:41:32 -0700 Subject: [PATCH 01/18] feat(tui): add paused state to hold the delivery queue --- src/tui/delivery-queue-pause.test.ts | 56 ++++++++++++++++++++++++++++ src/tui/delivery-queue.ts | 33 ++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/tui/delivery-queue-pause.test.ts diff --git a/src/tui/delivery-queue-pause.test.ts b/src/tui/delivery-queue-pause.test.ts new file mode 100644 index 000000000..567884352 --- /dev/null +++ b/src/tui/delivery-queue-pause.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { + createSessionQueue, + enqueue, + enqueueSteer, + interrupt, + isPaused, + pause, + resumeForSend, + badgeCount, +} from "./delivery-queue"; + +/** + * Phase 1 (CL-10149) pins the `paused` state and its pure helpers. The drain + * gate that consumes this flag ships in Phase 2; here we assert only the state + * transitions — pause never drops items and resumeForSend clears the flag + * without touching the queue. + */ +describe("delivery-queue pause", () => { + test("createSessionQueue defaults to not paused", () => { + const s = createSessionQueue("busy"); + expect(isPaused(s)).toBe(false); + }); + + test("pause after interrupt holds queue items and sets paused", () => { + let s = createSessionQueue("busy"); + s = enqueue(s, "follow-up"); + s = enqueueSteer(s, "steer"); + s = interrupt(s); + s = pause(s); + expect(isPaused(s)).toBe(true); + // NG2: pause keeps every queued item. + expect(badgeCount(s)).toBe(2); + expect(s.items.map((i) => i.text)).toEqual(["follow-up", "steer"]); + }); + + test("resumeForSend clears paused and keeps items intact", () => { + let s = createSessionQueue("busy"); + s = enqueue(s, "follow-up"); + s = pause(s); + expect(isPaused(s)).toBe(true); + const resumed = resumeForSend(s); + expect(isPaused(resumed)).toBe(false); + // No item is dropped by the resume. + expect(resumed.items.map((i) => i.text)).toEqual(["follow-up"]); + expect(badgeCount(resumed)).toBe(1); + }); + + test("enqueue while paused accepts items and does not auto-resume", () => { + let s = createSessionQueue("busy"); + s = pause(s); + s = enqueue(s, "queued-while-paused"); + expect(isPaused(s)).toBe(true); + expect(badgeCount(s)).toBe(1); + }); +}); diff --git a/src/tui/delivery-queue.ts b/src/tui/delivery-queue.ts index af90d345f..b1026421f 100644 --- a/src/tui/delivery-queue.ts +++ b/src/tui/delivery-queue.ts @@ -221,6 +221,13 @@ export interface SessionQueueState { readonly interruptFlash: boolean; /** Monotonic id seed for queue items. */ readonly nextId: number; + /** + * True after the operator pause gesture (first Ctrl+C). While paused the + * drain boundary holds the queue: follow-ups and compaction continuations + * stay PENDING and deliver only on an explicit new send (set by + * resumeForSend). The gate lives in the bridge (Phase 2), not here. + */ + readonly paused: boolean; } export function createSessionQueue(run: RunState = "idle"): SessionQueueState { @@ -229,6 +236,7 @@ export function createSessionQueue(run: RunState = "idle"): SessionQueueState { items: [], interruptFlash: false, nextId: 1, + paused: false, }; } @@ -323,6 +331,31 @@ export function clearInterruptFlash( return { ...state, interruptFlash: false }; } +/** + * Operator pause (first Ctrl+C): stop the run and hold the queue. Follow-ups + * and compaction continuations stay PENDING; nothing drains while paused. The + * items array is untouched — pause never drops queued work (NG2). + */ +export function pause(state: SessionQueueState): SessionQueueState { + if (state.paused) return state; + return { ...state, paused: true }; +} + +/** + * Clear the pause on an explicit new send. The held follow-ups and compaction + * continuations remain intact and become deliverable again; only the flag + * changes here, the bridge gates the next drain. + */ +export function resumeForSend(state: SessionQueueState): SessionQueueState { + if (!state.paused) return state; + return { ...state, paused: false }; +} + +/** True while the queue is held after an operator pause gesture. */ +export function isPaused(state: SessionQueueState): boolean { + return state.paused; +} + /** * Retract the most recently enqueued item, queue or steer alike. Last-only: * an operator who wants an earlier item gone has no path here (see From 3c189a1127d5fd03b5fdbd849ff1956729c750ad Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 7 Oct 2026 18:47:05 -0700 Subject: [PATCH 02/18] feat(tui): gate queue drain on the paused state --- src/tui/queued-delivery-hop.test.ts | 44 +++++++++++++++++++++++++++-- src/tui/runtime-bridge.ts | 9 ++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index a75f15d3e..5b90e85d7 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -11,6 +11,8 @@ import { badgeCount, createLiveSteerDeliver, createSessionOperationQueue, + pause, + resumeForSend, routeQueuedDelivery, type AgentDeliveryResult, type DeliverySettle, @@ -181,12 +183,48 @@ describe("queued delivery last hop", () => { }); }); - test("interrupt leftover steer last-hops to send", async () => { - await withBridge("busy", ({ bridge, sends, steers }) => { + test("interrupt leftover steer is held under pause until an explicit new send", async () => { + await withBridge("busy", ({ shell, bridge, sends, steers }) => { + // A steer the operator queued while the run was busy. bridge.submit("after stop", "steer"); + // Operator Ctrl+C pauses (Phase 4 sets the flag; the gate here honors an + // already-paused state) then stops and interrupts the run. + shell.session = pause(shell.session); + bridge.interrupt(); + // Paused: the leftover steer must NOT deliver to the rebuilt agent. + expect(sends).toEqual([]); + expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(1); + // An explicit new send clears the pause and the boundary drains the + // held steer onto the fresh turn. + shell.session = resumeForSend(shell.session); + bridge.submit("fresh prompt", "immediate"); + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual(["fresh prompt", "after stop"]); + expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(0); + }); + }); + + test("held follow-up delivers at the boundary only after an explicit new send", async () => { + await withBridge("busy", ({ shell, bridge, sends, steers }) => { + bridge.submit("held follow-up", "queue"); + expect(badgeCount(shell.session)).toBe(1); + // Operator Ctrl+C pauses and stops the run. + shell.session = pause(shell.session); bridge.interrupt(); - expect(sends).toEqual(["after stop"]); + // Paused: nothing drains at the interrupt/following boundaries. + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual([]); expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(1); + // Explicit new send clears the pause and starts a fresh turn. + shell.session = resumeForSend(shell.session); + bridge.submit("fresh prompt", "immediate"); + // Now the boundary delivers both the new send and the held follow-up. + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual(["fresh prompt", "held follow-up"]); + expect(badgeCount(shell.session)).toBe(0); }); }); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 179b18bfa..b36a3c8cd 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -14,6 +14,7 @@ import { enqueue, enqueueSteer, interrupt, + isPaused, setRunState, type QueueItem, type QueueKind, @@ -1364,6 +1365,10 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { } function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { + // Pause gate (CL-10149): while the operator holds the queue, no boundary may + // auto-drain into a rebuilt agent. Return early keeping every item pending; + // an explicit new send clears the flag and the next boundary delivers. + if (isPaused(shell.session)) return; for (;;) { const { state, item } = drainOne(shell.session); if (!item) break; @@ -1379,6 +1384,10 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { * kind filter. */ function drainSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { + // Pause gate (CL-10149): soft steers are also held while paused; the parent + // that accepted them has stopped, so they must wait for an explicit new send + // rather than auto-start on the rebuilt agent. + if (isPaused(shell.session)) return; for (;;) { const { state, item } = drainOne(shell.session, "steer"); if (!item) break; From 7656d2db8544a9a1cf87c8dc2dbb9a630cb4caed Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 7 Oct 2026 19:06:25 -0700 Subject: [PATCH 03/18] feat(tui): hold compaction continuation while paused --- src/tui/runner/exit.test.ts | 60 +++++++++++++++++++++++++++++++++++++ src/tui/runner/exit.ts | 11 ++++++- src/tui/runner/index.ts | 6 ++++ src/tui/runner/state.ts | 6 ++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index 4c5127699..ea4c4e4fc 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -45,6 +45,7 @@ import { COMPACTION_ABORTED_REASON, createCompactionLifecycle, } from "../../session/compaction-lifecycle.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../../agent/compaction.js"; import type { RunnerServices, RunnerState } from "./state.js"; import type { InboundMessage } from "@intx/types/runtime"; import { createCredentialRecoveryState } from "./credential-recovery.js"; @@ -255,6 +256,21 @@ function recordingAgent(sends: string[]): Agent { }; } +/** + * An agent whose stream replays a single COMPACTION_CONTINUATION_EVENT. Used + * to drive a continuation emission through the stream sink so the test can + * assert whether exit.ts defers it while paused and delivers it after resume. + * Deliveries are counted so a spy on the enqueue hop is unambiguous. + */ +function continuationAgent(): Agent { + return { + ...recordingAgent([]), + stream: async function* stream() { + yield { type: COMPACTION_CONTINUATION_EVENT, seq: 1, data: {} } as never; + }, + }; +} + function stubSendLifecycle(agent: Agent): { state: RunnerState; services: RunnerServices; @@ -1004,6 +1020,50 @@ describe("rebuild close helpers", () => { expect(order[0]).toBe("bump"); expect(order.indexOf("enqueue")).toBeGreaterThan(0); }); + + test("compaction continuation is deferred while paused and delivers after resume", async () => { + const directorHolder: RunnerServices["directorHolder"] = {}; + const agent = continuationAgent(); + const { state, services } = stubSendLifecycle(agent); + // The continuation event walks current() on its way through the sink; + // give the stub tracker a no-op so observeRecoveryAttempts is a no-op. + ( + services.providerFailureAttempts as { + current?: () => ProviderFailureAttempt; + } + ).current = () => undefined as never; + // Wire the hop exit.ts calls: the real assembly routes it through + // enqueueCompactionContinuation → liveAgent.deliver. Here we spy on the + // call itself so we can assert whether exit.ts invokes it while paused. + let hops = 0; + state.enqueueCompactionContinuation = () => { + hops += 1; + }; + // Simulated operator pause (first Ctrl+C) holds the queue. exit.ts + // consults this observer (wired to the shell session in index.ts). + let paused = true; + state.isPaused = () => paused; + wireRebuildServices(services, directorHolder, agent); + await createRunLifecycle(state, services); + + // Drain the initial stream emission while paused: the continuation must + // NOT hop onto the rebuilt agent, and stay consume-once-intact so a later + // emission can still deliver. + await state.streamPromise; + expect(hops).toBe(0); + + // Explicit new send clears the pause; rebuilding re-mounts the stream and + // the next boundary re-drives the continuation, which now hops. + paused = false; + const agent2 = continuationAgent(); + services.buildAgent = (async () => + agent2) as unknown as RunnerServices["buildAgent"]; + state.currentAgent = agent2; + defined(state.interrupt, "interrupt")(); + await services.sessionOps.awaitTail(); + await state.streamPromise; + expect(hops).toBeGreaterThan(0); + }); }); describe("observeRecoveryAttempts fan-out", () => { diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 48d45b37f..db6e516d9 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -379,7 +379,16 @@ export async function createRunLifecycle( // interrupt rebuild (generation bump + rebuild already queued) is // re-queued onto the replacement agent so consume-once cannot land on // the outgoing liveAgent. - if (continuationGate.shouldDeliver(event.seq)) { + // CL-10149 pause gate: while the operator holds the queue (first + // Ctrl+C), the continuation is NOT re-queued onto the rebuilt agent — + // that would be the visible auto-restart. It stays consume-once-intact + // and only fires after resume (an explicit new send clears the flag; + // the next boundary re-drives the emit). The observer is optional so + // tests build partial sessions without the shell mount. + if ( + state.isPaused?.() !== true && + continuationGate.shouldDeliver(event.seq) + ) { state.enqueueCompactionContinuation?.(() => liveAgent(state).deliver(buildCompactionContinuationMessage()), ); diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index e437ca568..332b8a158 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -41,6 +41,7 @@ import { createReconnectRecoveryPresenter } from "./reconnect-recovery.js"; import { applyStartupTheme } from "../theme-startup.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { isPaused } from "../delivery-queue.js"; export async function runTUI(initialConfig: Config): Promise { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -233,6 +234,11 @@ export async function runTUI(initialConfig: Config): Promise { }); state.host = host; services.hostHolder.instance = host; + // CL-10149: the pause flag lives on the shell session queue owned by the + // bridge. Mount a tiny observer the runner consults at the compaction + // continuation emit so it holds the continuation while paused without the + // runner reaching into the shell directly. + state.isPaused = () => isPaused(host.shell.session); state.presentCredentialRecovery = (pending) => { const opened = host.openCredentialRecovery({ alternatives: pending.alternatives, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 58e2d635b..f66fec5df 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -293,6 +293,12 @@ export interface RunnerState { onSettle?: (result: AgentDeliveryResult) => void, ) => void; enqueueCompactionContinuation?: (deliverToLiveAgent: () => void) => void; + // CL-10149: late-wired observer the runner's compaction-continuation emit + // consults. The pause flag physically lives on the shell session queue; the + // runner must not reach into the shell, so index.ts mounts a tiny callback + // reading it (isPaused(host.shell.session)) the way systemNotice is wired. + // Optional because tests build partial states without that mount. + isPaused?: () => boolean; // CL-8220: abort-aware compaction lifecycle, created by the TUI session // assembly (session.ts) and read by the interrupt/rotation paths (exit.ts). // Optional because tests build partial states without session assembly. From bfdde615f28d93a8af89a515ce4b807ec3c688fc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 7 Oct 2026 19:19:00 -0700 Subject: [PATCH 04/18] feat(tui): pause the queue on ctrl+c and resume on send --- src/tui/keybindings.test.ts | 6 +++++- src/tui/queued-delivery-hop.test.ts | 10 ++++------ src/tui/runner/wiring.stall-bound.test.ts | 4 ++++ src/tui/runtime-bridge.test.ts | 18 ++++++++++++++---- src/tui/runtime-bridge.ts | 14 +++++++++++--- src/tui/shell.test.ts | 13 +++++++------ src/tui/shell/keys.ts | 8 +++++++- src/tui/steer-worker-invariant.test.ts | 15 ++++++++++++--- 8 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 5efed9600..959dddc0b 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -17,6 +17,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; +import { isPaused } from "./delivery-queue.js"; import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"; import { SHELL_SHORTCUTS } from "./keybindings.js"; import { createHarness, type Harness } from "./harness.js"; @@ -574,6 +575,9 @@ const PROBES: Readonly< press(h, chords[0]); expect(interrupted).toBe(1); expect(exited).toBe(0); + // First Ctrl+C is the operator pause gesture: the session is paused so + // queued work stays held (CL-10149). + expect(isPaused(shell.session)).toBe(true); press(h, chords[0]); expect(exited).toBe(1); setShellRunState(shell, "idle"); @@ -594,7 +598,7 @@ const PROBES: Readonly< expect(notice?.text).not.toContain("discarded"); // Other probes in this group share one shell — leave both the queue // and the transcript as this probe found them. - shell.session = { ...shell.session, items: [] }; + shell.session = { ...shell.session, items: [], paused: false }; truncateStreamRows(shell, rowsBefore); setShellRunState(shell, "idle"); diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index 5b90e85d7..f94f352cc 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -12,7 +12,6 @@ import { createLiveSteerDeliver, createSessionOperationQueue, pause, - resumeForSend, routeQueuedDelivery, type AgentDeliveryResult, type DeliverySettle, @@ -195,9 +194,8 @@ describe("queued delivery last hop", () => { expect(sends).toEqual([]); expect(steers).toEqual([]); expect(badgeCount(shell.session)).toBe(1); - // An explicit new send clears the pause and the boundary drains the - // held steer onto the fresh turn. - shell.session = resumeForSend(shell.session); + // An explicit new send clears the pause (the submit path resumes the + // session) and the boundary drains the held steer onto the fresh turn. bridge.submit("fresh prompt", "immediate"); bridge.handle({ type: "run", state: "idle" }); expect(sends).toEqual(["fresh prompt", "after stop"]); @@ -218,8 +216,8 @@ describe("queued delivery last hop", () => { expect(sends).toEqual([]); expect(steers).toEqual([]); expect(badgeCount(shell.session)).toBe(1); - // Explicit new send clears the pause and starts a fresh turn. - shell.session = resumeForSend(shell.session); + // Explicit new send clears the pause (submit path resumes the session) + // and starts a fresh turn. bridge.submit("fresh prompt", "immediate"); // Now the boundary delivers both the new send and the held follow-up. bridge.handle({ type: "run", state: "idle" }); diff --git a/src/tui/runner/wiring.stall-bound.test.ts b/src/tui/runner/wiring.stall-bound.test.ts index 39e45a5e8..f5191ee26 100644 --- a/src/tui/runner/wiring.stall-bound.test.ts +++ b/src/tui/runner/wiring.stall-bound.test.ts @@ -5,6 +5,7 @@ import { type SessionBridge, type TurnMonitorOptions, } from "../runtime-bridge.js"; +import { isPaused } from "../delivery-queue.js"; import { withAppShell } from "../test-helpers.js"; import { ASK_DIRECTOR_WAKE_PREFIX, @@ -184,6 +185,9 @@ describe("stall-bound primary turn (CL-8016)", () => { expect(bridge.turnMarkers().map((marker) => marker.path)).toContain( "stall-abort:awaiting-first-token", ); + // Stall-abort is a non-operator path (CL-10149): it must NOT set the + // operator pause flag, so it keeps the old drain semantics. + expect(isPaused(bridge.shell.session)).toBe(false); // ... the hung inference was interrupted before any new deliver ... expect(wakeDeliveriesAfterInterrupt(port)).toBe(0); // ... the queued operator message reached the port ... diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 0991249a9..efe012104 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -27,7 +27,7 @@ import { withAppShell } from "./test-helpers"; import { operatorCancelResult, wireGates } from "./gate-wire.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; import { moveOverlaySelection } from "./shell/overlay-list.js"; -import { badgeCount, SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue"; +import { badgeCount, isPaused, SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue"; import { LIVE_ACTIVITY_WORDS } from "./chrome-state"; type RecordingPort = ReturnType; @@ -248,7 +248,7 @@ describe("attachSessionBridge", () => { }); }); - test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { + test("Ctrl+C hits port.interrupt, pauses (CL-10149) and holds the queue", async () => { await withBridge( { run: "busy", wireKeys: true }, async ({ h, shell, port, bridge }) => { @@ -256,13 +256,23 @@ describe("attachSessionBridge", () => { bridge.submit("b", "steer"); expect(badgeCount(shell.session)).toBe(2); port.clear(); + // Operator first-press Ctrl+C routes through the pause gesture. h.pressKey("c", { ctrl: true }); await h.renderOnce(); expect(port.calls.some((c) => c.op === "interrupt")).toBe(true); expect(shell.session.interruptFlash).toBe(true); expect(shell.session.run).toBe("idle"); - // Handed over, not thrown away — and handed over here rather than - // left waiting on an idle event the stop may never produce. + // First Ctrl+C is a PAUSE (CL-10149): the queue stays held, nothing + // auto-drains onto a rebuilt agent. Delivered only on an explicit send. + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(2); + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual([]); + // An explicit new send clears the pause and the boundary drains the + // held items onto the fresh turn. + bridge.submit("fresh", "immediate"); + bridge.handle({ type: "run", state: "idle" }); expect( port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), ).toEqual(["b", "a"]); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index b36a3c8cd..c1d793a4f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -15,6 +15,7 @@ import { enqueueSteer, interrupt, isPaused, + resumeForSend, setRunState, type QueueItem, type QueueKind, @@ -2033,6 +2034,11 @@ export function attachSessionBridge( shell.session.run === "idle" || parentIdleWithFleet ) { + // An explicit operator send releases the operator pause (CL-10149): clear + // the flag so the next drain boundary delivers the held follow-ups and + // compaction continuations onto this fresh turn. Queued work never + // auto-drains after a single Ctrl+C — only an explicit new send clears it. + shell.session = resumeForSend(shell.session); appendStreamRow(shell, { role: "user", text: userRowText(t, attached), @@ -2157,9 +2163,11 @@ export function attachSessionBridge( 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. + // drain against. On an operator PAUSE the drain gate (drainAtBoundary's + // isPaused check) keeps the held queue PENDING instead of handing it to a + // rebuilt agent — a later explicit new send clears the pause and the next + // boundary delivers it. Only non-operator drains (stall/expire aborts) + // fall through to the current handover behavior below. drainAtBoundary(shell, bag); // Clearing the last prompt is what stops the quota loop from replaying a // turn the operator (or the watchdog) deliberately stopped. diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index f68def77f..025b1c2d8 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; +import { isPaused } from "./delivery-queue"; import { IDLE_TRANSCRIPT_FLOOR } from "./geometry/index"; import { focusOwner, scrollLease } from "./focus/index"; import { withTestRenderer } from "./harness"; @@ -22,11 +23,8 @@ import { shellInternals, stickyMode, } from "./shell/internals"; -import { - applyShellCancelLast, - interruptShell, - submitPrompt, -} from "./shell/prompt"; +import { applyShellCancelLast, submitPrompt } from "./shell/prompt"; +import { handleCtrlC } from "./shell/keys"; import { transcriptRowLayout } from "./shell/transcript"; /** The transient notice row sits directly above the prompt box's top rule. */ @@ -537,8 +535,11 @@ describe("product skin: stream + queue + overlay", () => { shell.prompt.value = "b"; submitPrompt(shell, "steer"); expect(shell.pendingQueue).toBe(2); - interruptShell(shell); + // Operator first-press Ctrl+C (handleCtrlC) = pause gesture (CL-10149): + // the session pauses, holding the queue, and the run interrupts. + handleCtrlC(shell); expect(shell.pendingQueue).toBe(2); + expect(isPaused(shell.session)).toBe(true); expect(shell.session.interruptFlash).toBe(true); expect(shell.session.run).toBe("idle"); await h.renderOnce(); diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 64bd0a0b6..fef53ccc7 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -7,7 +7,7 @@ import { type KeyEvent, type MouseEvent, } from "@opentui/core"; -import { badgeCount } from "../delivery-queue.js"; +import { badgeCount, pause } from "../delivery-queue.js"; import { type AppShell, @@ -199,6 +199,12 @@ export function handleCtrlC( ctrlCArmedAt.set(shell, now); + // First Ctrl+C is the operator PAUSE gesture (CL-10149): hold the queue so + // queued follow-ups / compaction continuations do not auto-drain onto a + // rebuilt agent. Set before interruptShell so the exclusive bridge path + // already observes the paused flag; an explicit new send later clears it. + shell.session = pause(shell.session); + if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { interruptShell(shell); } diff --git a/src/tui/steer-worker-invariant.test.ts b/src/tui/steer-worker-invariant.test.ts index 2ff566872..ec106f381 100644 --- a/src/tui/steer-worker-invariant.test.ts +++ b/src/tui/steer-worker-invariant.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { createAppShell } from "./shell/index"; import { type Harness, withTestRenderer } from "./harness"; -import { badgeCount } from "./delivery-queue"; +import { badgeCount, isPaused } from "./delivery-queue"; interface BridgeFixture { readonly shell: ReturnType; @@ -157,15 +157,24 @@ describe("CL-6291 worker-alive invariants", () => { ); }); - test("Ctrl+C / doInterrupt still calls port.interrupt", async () => { + test("Ctrl+C / doInterrupt still calls port.interrupt and holds the queue paused", async () => { await withBridge({ wireKeys: true }, async ({ shell, port, bridge }, h) => { bridge.submit("kept", "steer"); expect(badgeCount(shell.session)).toBe(1); port.clear(); + // Operator first-press Ctrl+C pauses (CL-10149) instead of handing + // pending work to a rebuilt agent. h.pressKey("c", { ctrl: true }); await h.renderOnce(); expect(port.calls.some((c) => c.op === "interrupt")).toBe(true); - // Hard stop still hands pending over rather than discarding them. + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(1); + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual([]); + // Hard stop keeps pending held; only an explicit new send releases it. + bridge.submit("fresh", "immediate"); + bridge.handle({ type: "run", state: "idle" }); expect( port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), ).toEqual(["kept"]); From 997dd6d97b7124e6fa33832162dab7cafc59bb5a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 7 Oct 2026 19:23:24 -0700 Subject: [PATCH 05/18] feat(tui): show a pause note instead of a restart message --- src/tui/runtime-bridge.test.ts | 7 +++++++ src/tui/runtime-bridge.ts | 10 +++++++++- src/tui/shell/prompt.ts | 8 +++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index efe012104..e011ad491 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -932,6 +932,13 @@ describe("same-turn retry after inference.error", () => { const text = shell.streamLog.map((r) => r.text).join("\n"); expect(text).toContain("restart from here"); + // Reinject interrupts are pause-oriented (CL-10149): the stop note reuses + // the pause wording ("pending kept"/"stopped" + the arming flash) and + // never says "restart from your message" anymore. + expect(text).toMatch( + /(pending kept|stopped).*press ctrl\+c again to exit/, + ); + expect(text).not.toContain("restarting from your message"); expect(errorRows(shell)).toEqual([]); }); }); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index c1d793a4f..81abccc8d 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -15,6 +15,7 @@ import { enqueueSteer, interrupt, isPaused, + badgeCount, resumeForSend, setRunState, type QueueItem, @@ -2012,9 +2013,16 @@ export function attachSessionBridge( bag.mapCtx.errorRollbackArmed = false; bag.attemptRow = null; shell.session = interrupt(shell.session); + // Reuse the first-Ctrl+C pause wording ("pending kept"/"stopped") plus the + // arming-window flash so this reinject stop reads as a pause, not a + // restart: queued work delivers only on the operator's next explicit send, + // and a second Ctrl+C quits. appendStreamRow(shell, { role: "system", - text: "stop — restarting from your message", + text: + badgeCount(shell.session) > 0 + ? `${badgeCount(shell.session)} pending kept — press ctrl+c again to exit` + : "stopped — press ctrl+c again to exit", meta: "stop", }); bag.port.interrupt(); diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index f7f80d9bb..e6e8be951 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -364,9 +364,15 @@ export function submitPrompt( shell.session = interrupt(shell.session); shell.prompt.value = ""; clearPendingAttachments(shell); + // Reuse the pause wording ("pending kept"/"stopped") plus the arming-window + // flash: queued work only delivers on the operator's next explicit send, + // and a second Ctrl+C quits. appendStreamRow(shell, { role: "system", - text: "stop — restarting from your message", + text: + badgeCount(shell.session) > 0 + ? `${badgeCount(shell.session)} pending kept — press ctrl+c again to exit` + : "stopped — press ctrl+c again to exit", meta: "stop", }); appendStreamRow(shell, { From 23e5c0e87dd47de360d6068779f5ec8f4a42db8d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:16:58 -0700 Subject: [PATCH 06/18] feat(tui): add a shell-level stop-workers affordance --- src/tui/shell/internals.ts | 30 ++++++ src/tui/shell/keys.ts | 32 ++++++ src/tui/shell/stop-affordance.test.ts | 134 ++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/tui/shell/stop-affordance.test.ts diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index fcc906c96..4e286b472 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -97,6 +97,36 @@ export function getShellBridgeHooks( return shellBridgeHooks.get(shell); } +/** + * Second-press stop affordance for CL-10149 follow-up. The shell key layer + * stays service-free; the runner registers a live-worker-count getter and a + * stop-workers callback so `handleCtrlC` can distinguish "stop the active + * sub-agents, app keeps running" from a real quit without touching the service + * layer (see SOLUTION_SCOPE D1/D2). + */ +export interface ShellStopAffordance { + /** Live, faithful fleet count (liveFleetCount over session-store list). */ + liveWorkerCount: () => number; + /** Stop all active sub-agents; app stays running. Idempotent. */ + onStopWorkers: () => void | Promise; +} + +const shellStopAffordances = new WeakMap(); + +export function setShellStopAffordance( + shell: AppShell, + affordance: ShellStopAffordance | undefined, +): void { + if (affordance) shellStopAffordances.set(shell, affordance); + else shellStopAffordances.delete(shell); +} + +export function getShellStopAffordance( + shell: AppShell, +): ShellStopAffordance | undefined { + return shellStopAffordances.get(shell); +} + /** * What the focused overlay row is, and what choosing it costs. Painted in the * fixed description zone under every overlay list that opts in via `describe`. diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index fef53ccc7..3cd7ba902 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -15,6 +15,7 @@ import { effortCycleHandlers, isSlashPopupOpen, type PrimaryOverlayKind, + getShellStopAffordance, shellExitHandlers, shellInternals, } from "./internals.js"; @@ -162,6 +163,13 @@ function isDecisionGate(shell: AppShell): boolean { /** Window in which a second Ctrl+C is read as "yes, quit". */ export const CTRL_C_EXIT_WINDOW_MS = 2000; +/** + * Prefix of the count-aware 1st-press note (Phase 5) shown when sub-agents are + * live, inserted after the resolved live-worker count. Imported from keys.ts + * by callers that build the flash so the wording has exactly one home. + */ +export const N_SUBAGENT_RUNNING_NOTE_PREFIX = "sub-agent(s) running — "; + const ctrlCArmedAt = new WeakMap(); /** @@ -216,6 +224,30 @@ export function handleCtrlC( }); } +/** + * Pure read of the registered stop affordance scalars for the second-press + * branch (Phase 2). The shell stays service-free: it never constructs a + * worker-count itself, only mirrors what the runner registered. Defaults keep + * an unregistered shell on today's two-press contract (count 0, no-op stop) so + * the armed branch stays safe before Phase 4 wires the runner. + * + * Phase 1 establishes this seam only; it is NOT yet consulted by `handleCtrlC`. + * It is exported so Phase 1 consumes it honestly (pinned by the stop-affordance + * test) instead of leaving it dead until Phase 2 wires the second-press branch. + */ +export function readStopAffordance(shell: AppShell): { + count: number; + onStop: () => void | Promise; +} { + const affordance = getShellStopAffordance(shell); + return { + count: affordance?.liveWorkerCount() ?? 0, + // Expression-bodied no-op (not an empty block) so oxlint's + // no-empty-function rule stays satisfied; still a pure identity no-op. + onStop: affordance?.onStopWorkers ?? (() => undefined), + }; +} + /** * Wheel/trackpad scroll landing on the prompt scrolls the chat instead. * diff --git a/src/tui/shell/stop-affordance.test.ts b/src/tui/shell/stop-affordance.test.ts new file mode 100644 index 000000000..3261ef3da --- /dev/null +++ b/src/tui/shell/stop-affordance.test.ts @@ -0,0 +1,134 @@ +/** + * Pure registry tests for the Phase 1 CL-10149 follow-up stop affordance + * (src/tui/shell/internals.ts). These pin the WeakMap-backed + * set/get round-trip, the unset behavior, and per-shell isolation so the + * second-press stop seam has an observable contract before Phase 2 wires it + * into handleCtrlC. + */ +import { describe, expect, test } from "bun:test"; + +import { withTestRenderer, type Harness } from "../harness.js"; +import { createAppShell } from "./index.js"; +import { N_SUBAGENT_RUNNING_NOTE_PREFIX, readStopAffordance } from "./keys.js"; +import { + getShellStopAffordance, + setShellStopAffordance, + type ShellStopAffordance, +} from "./internals.js"; + +function makeAffordance(workerCount = 2): ShellStopAffordance { + let stopped = 0; + return { + liveWorkerCount: () => workerCount, + onStopWorkers: () => { + stopped += 1; + }, + }; +} + +describe("shell stop affordance registry", () => { + test("exports the shared count-aware note prefix", () => { + // Phase 5 builds the 1st-press note from this constant so the wording has + // one home; pinning it here keeps it consumed and quarantines a rename. + expect(N_SUBAGENT_RUNNING_NOTE_PREFIX).toBe("sub-agent(s) running — "); + }); + + test("set then get round-trips the affordance", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const affordance = makeAffordance(3); + setShellStopAffordance(shell, affordance); + + const got = getShellStopAffordance(shell); + expect(got).toBe(affordance); + expect(got?.liveWorkerCount()).toBe(3); + expect(got?.onStopWorkers).toBe(affordance.onStopWorkers); + } finally { + shell.dispose(); + } + }); + }); + + test("getting an unregistered shell returns undefined", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + expect(getShellStopAffordance(shell)).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("unsetting a registered affordance returns undefined afterwards", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + setShellStopAffordance(shell, makeAffordance()); + expect(getShellStopAffordance(shell)).toBeDefined(); + setShellStopAffordance(shell, undefined); + expect(getShellStopAffordance(shell)).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("readStopAffordance defaults an unregistered shell (count 0, no-op onStop)", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const { count, onStop } = readStopAffordance(shell); + // Unregistered shell stays on today's two-press contract. + expect(count).toBe(0); + // No-op must be safe to call and return undefined (not a Promise). + expect(onStop()).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("readStopAffordance mirrors a registered affordance's count and callback", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const affordance = makeAffordance(3); + setShellStopAffordance(shell, affordance); + const { count, onStop } = readStopAffordance(shell); + expect(count).toBe(3); + // The callback is the registered one, not the default no-op. + expect(onStop).toBe(affordance.onStopWorkers); + } finally { + shell.dispose(); + } + }); + }); + + test("two shells do not share affordances (WeakMap isolation)", async () => { + await withTestRenderer(async (ha: Harness) => { + await withTestRenderer(async (hb: Harness) => { + const a = createAppShell(ha.renderer, { wireKeys: false }); + const b = createAppShell(hb.renderer, { wireKeys: false }); + try { + const affordanceA = makeAffordance(4); + setShellStopAffordance(a, affordanceA); + + // b stays unregistered even though a was set. + expect(getShellStopAffordance(a)).toBe(affordanceA); + expect(getShellStopAffordance(b)).toBeUndefined(); + + // Setting a different affordance on b does not disturb a. + const affordanceB = makeAffordance(9); + setShellStopAffordance(b, affordanceB); + expect(getShellStopAffordance(a)).toBe(affordanceA); + expect(getShellStopAffordance(b)).toBe(affordanceB); + } finally { + a.dispose(); + b.dispose(); + } + }); + }); + }); +}); From 89d9ad439bcd05bf5b91f806fa1863b4d39e949e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:21:28 -0700 Subject: [PATCH 07/18] feat(tui): stop sub-agents on the second ctrl+c --- src/tui/prompt-slash-exit.test.ts | 76 +++++++++++++++++++++++++++++++ src/tui/shell/keys.ts | 35 ++++++++++++-- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 8729a162a..35bac634a 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -15,6 +15,7 @@ import { createAppShell } from "./shell/index"; import { isSlashPopupOpen, setShellExitHandler, + setShellStopAffordance, type AppShell, } from "./shell/internals"; import { CTRL_C_EXIT_WINDOW_MS, handleCtrlC } from "./shell/keys"; @@ -510,6 +511,81 @@ describe("Ctrl+C exit", () => { } }); }); + + describe("second-press stop state machine (three-press contract)", () => { + let stops = 0; + let exits = 0; + let count = 0; + let onStop: () => void = () => { + stops += 1; + }; + let onExit: () => void = () => { + exits += 1; + }; + + function wireWorkers(shell: AppShell, n: number): void { + count = n; + stops = 0; + exits = 0; + setShellStopAffordance(shell, { + liveWorkerCount: () => count, + onStopWorkers: () => onStop(), + }); + setShellExitHandler(shell, () => onExit()); + } + + test("no live workers: two presses quit, onStopWorkers never fires", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 0); + handleCtrlC(shell, 0); + expect(stops).toBe(0); + expect(exits).toBe(0); + handleCtrlC(shell, 1); + expect(exits).toBe(1); + expect(stops).toBe(0); + }); + }); + + test("live workers: 2nd press stops (no quit), 3rd press quits", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + expect(exits).toBe(0); + handleCtrlC(shell, 1); + expect(stops).toBe(1); + expect(exits).toBe(0); + handleCtrlC(shell, 2); + expect(stops).toBe(1); + expect(exits).toBe(1); + }); + }); + + test("window re-arm reads a fresh worker count", async () => { + await withShell(async ({ shell }) => { + // Two live workers on the 1st press arm. + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + // Fleet drains to zero between presses; outside the window the press + // re-arms rather than exiting, but the next in-window press must quit. + count = 0; + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 1); + expect(stops).toBe(0); + expect(exits).toBe(0); + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 2); + expect(stops).toBe(0); + expect(exits).toBe(1); + }); + }); + + test("single press pauses only: neither stops nor exits", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + expect(stops).toBe(0); + expect(exits).toBe(0); + }); + }); + }); }); function pendingImage( diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 3cd7ba902..8402ab8f3 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -170,7 +170,15 @@ export const CTRL_C_EXIT_WINDOW_MS = 2000; */ export const N_SUBAGENT_RUNNING_NOTE_PREFIX = "sub-agent(s) running — "; -const ctrlCArmedAt = new WeakMap(); +/** + * Per-shell Ctrl+C arming slot. `at` is the window anchor; `state` extends the + * base two-state (unarmed/armed) machine with a third reachable state, + * STOPPED, recorded on the press that stops live sub-agents so the next press + * quits instead of double-stopping (see handleCtrlC). + */ +type CtrlCArmed = { at: number; state: "armed" | "stopped" }; + +const ctrlCArmedAt = new WeakMap(); /** * Ctrl+C: interrupt / clear, and quit on a second press inside the window. @@ -184,10 +192,31 @@ export function handleCtrlC( options?: FlashOptions, ): void { const armedAt = ctrlCArmedAt.get(shell); - if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { + if (armedAt !== undefined && now - armedAt.at <= CTRL_C_EXIT_WINDOW_MS) { ctrlCArmedAt.delete(shell); const onExit = shellExitHandlers.get(shell); if (onExit !== undefined) { + // Three-press machine: a press that would previously exit (in-window) + // first asks whether live sub-agents exist to stop. An already-stopped + // shell, or one with no live workers, quits (two-press preserved); a + // shell with live workers is the STOP press -- stop the fleet, stay + // running, and quits only on the next press. + const { count, onStop } = readStopAffordance(shell); + const wasStopped = armedAt.state === "stopped"; + if (!wasStopped && count > 0) { + // Stop press: do NOT quit. Record STOPPED at press time so a second + // simultaneous press cannot double-fire an async stop before it resolves. + ctrlCArmedAt.set(shell, { at: now, state: "stopped" }); + setStatusFlash(shell, "press ctrl+c again to exit", { + ttlMs: CTRL_C_EXIT_WINDOW_MS, + ...(options?.schedule !== undefined + ? { schedule: options.schedule } + : {}), + }); + void onStop(); + return; + } + // Real quit (2nd press with no live workers, or 3rd press after stop). // Host teardown usually disposes; unlink here too so a stub/delayed // onExit cannot leave Corbits-created clipboard files behind. clearPendingAttachments(shell); @@ -205,7 +234,7 @@ export function handleCtrlC( if (!hasPromptText) return; } - ctrlCArmedAt.set(shell, now); + ctrlCArmedAt.set(shell, { at: now, state: "armed" }); // First Ctrl+C is the operator PAUSE gesture (CL-10149): hold the queue so // queued follow-ups / compaction continuations do not auto-drain onto a From f37f5dcb172fdfeb5ec726f50849a19cd13d0324 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:30:20 -0700 Subject: [PATCH 08/18] test(tui): pin idempotent stop-then-quit for sub-agents --- src/tui/runner/exit.ts | 6 +- src/tui/runner/wiring.stop-idempotent.test.ts | 99 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/tui/runner/wiring.stop-idempotent.test.ts diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index db6e516d9..6d69c3080 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -569,7 +569,11 @@ export async function createRunLifecycle( // aborts the reactor mid-inference (the send signal only rejects the send // promise); that close cascades: operationController.abort → child // parent-abort forwarding → child abort. Do not add cancelAll here — fleet cancelAll is - // reserved for /clear (newSession) and shutdown. + // reserved for /clear (newSession), shutdown, and the explicit 2nd-press + // stop-workers gesture (see wiring.cancelWorkersForStop). This interrupt must + // stay cancelAll-free; the 2nd-press stop is a distinct caller that snapshots + // live sessions and leaves tombstones, so a later quit-path stop finds + // nothing live and is a no-op (no double cancel). // Close it, drain the old stream, and rebuild a fresh agent so the next send // works. const interrupt = (): void => { diff --git a/src/tui/runner/wiring.stop-idempotent.test.ts b/src/tui/runner/wiring.stop-idempotent.test.ts new file mode 100644 index 000000000..7111dc6a9 --- /dev/null +++ b/src/tui/runner/wiring.stop-idempotent.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSubAgentSessionStore, + type SubAgentSessionStore, +} from "../../subagent/session-store.js"; +import { isLiveStrip } from "../../subagent/lifecycle.js"; +import { cancelWorkersForStop } from "./wiring.js"; + +// CL-10149 Phase 3 — idempotent stop-then-quit (three-press Ctrl+C). +// +// The 2nd-press stop and the 3rd-press quit both drive `cancelWorkersForStop` +// (the affordance's onStopWorkers in Phase 4 wires the stop; the quit path +// runs it again through shutdownRuntime). Because `cancelAll` snapshots live +// sessions and leaves tombstones, a stop followed by a quit must cancel twice +// total, with the second call finding nothing live — no double cancel. The +// single-press pause (the interrupt path) must never call cancelAll at all. +// These assertions pin that idempotency at the `cancelWorkersForStop` unit +// boundary, before the affordance is wired into production (Phase 4). + +interface StopHarness { + store: SubAgentSessionStore; + /** Live count seen at the start of every cancelAll call, in order. */ + cancelCalls: number[]; +} + +function liveStripCount(store: SubAgentSessionStore): number { + return store.list().filter((session) => isLiveStrip(session.lifecycle)) + .length; +} + +function harnessWithLiveWorkers(count: number): StopHarness { + const store = createSubAgentSessionStore(); + const cancelCalls: number[] = []; + const realCancelAll = store.cancelAll.bind(store); + store.cancelAll = async (reason?: string): Promise => { + cancelCalls.push(liveStripCount(store)); + return realCancelAll(reason); + }; + for (let i = 0; i < count; i++) { + const session = store.start({ + description: `worker-${i}`, + agentId: "w", + brief: "b", + }); + store.markRunning(session.id); + store.registerCancel(session.id, () => undefined); + } + return { store, cancelCalls }; +} + +async function stopOnce(h: StopHarness): Promise { + // The production stop/quit path shares these exact deps (wiring.ts:296-300); + // a fresh clear/bridge triple is fine per stop since neither affects the + // cancelAll snapshot the idempotency claim is about. + await cancelWorkersForStop({ + subAgentSessions: h.store, + fleetRecords: { clear: () => undefined }, + bridge: { clearQueuedDelivery: () => undefined }, + }); +} + +describe("cancelWorkersForStop idempotency (CL-10149 Phase 3)", () => { + test("stop then quit invokes cancelAll twice total; the second sees zero live sessions", async () => { + const h = harnessWithLiveWorkers(2); + expect(liveStripCount(h.store)).toBe(2); + + // 2nd press: stop the live workers (onStopWorkers → cancelWorkersForStop). + await stopOnce(h); + // 3rd press: quit (shutdownRuntime → cancelWorkersForStop again). All + // sessions are already cancelled, so this must be a no-op for cancel. + await stopOnce(h); + + // cancelAll ran exactly twice, not more. + expect(h.cancelCalls).toHaveLength(2); + expect(h.cancelCalls[0]).toBe(2); + // The second snapshot sees nothing live — already-shutdown sessions skipped. + expect(h.cancelCalls[1]).toBe(0); + expect(liveStripCount(h.store)).toBe(0); + }); + + test("single-press pause never calls cancelAll", async () => { + // The pause press must not drive the stop/quit path, so a worker that is + // only paused stays live and cancelAll is never invoked (guards the + // interrupt path, R4/R5). + const h = harnessWithLiveWorkers(1); + expect(liveStripCount(h.store)).toBe(1); + expect(h.cancelCalls).toHaveLength(0); + expect(liveStripCount(h.store)).toBe(1); + }); + + test("a lone stop on many live workers still cancels exactly once", async () => { + const h = harnessWithLiveWorkers(3); + await stopOnce(h); + expect(h.cancelCalls).toHaveLength(1); + expect(h.cancelCalls[0]).toBe(3); + expect(liveStripCount(h.store)).toBe(0); + }); +}); From 17956442d6d1dac03d3ae48f9c4d890ce7d52b36 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:36:33 -0700 Subject: [PATCH 09/18] feat(tui): wire the shell stop affordance to the runner --- src/tui/runner/wiring.stop-affordance.test.ts | 133 ++++++++++++++++++ src/tui/runner/wiring.ts | 47 +++++++ 2 files changed, 180 insertions(+) create mode 100644 src/tui/runner/wiring.stop-affordance.test.ts diff --git a/src/tui/runner/wiring.stop-affordance.test.ts b/src/tui/runner/wiring.stop-affordance.test.ts new file mode 100644 index 000000000..5a52f87d0 --- /dev/null +++ b/src/tui/runner/wiring.stop-affordance.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSubAgentSessionStore, + type SubAgentSessionStore, +} from "../../subagent/session-store.js"; +import { isLiveStrip } from "../../subagent/lifecycle.js"; +import { createAppShell } from "../shell/index.js"; +import { + getShellStopAffordance, + setShellExitHandler, + setShellStopAffordance, +} from "../shell/internals.js"; +import { handleCtrlC } from "../shell/keys.js"; +import { withTestRenderer } from "../harness.js"; +import { buildShellStopAffordance } from "./wiring.js"; + +// CL-10149 Phase 4 -- the integration half of Phase 3 deferred here. +// +// The runner wires the REAL stop affordance into the shell inside +// wirePostStartup via buildShellStopAffordance: the affordance's +// liveWorkerCount reports the underlying session store's live fleet and its +// onStopWorkers drives the production `cancelWorkersForStop` closure. This test +// proves that end-to-end against a harnessed store: a 2nd Ctrl+C press stops +// the live workers and the app STAYS RUNNING (no exit), and liveWorkerCount +// reports the harnessed store's live fleet before and after the stop. + +function liveStripCount(store: SubAgentSessionStore): number { + return store.list().filter((session) => isLiveStrip(session.lifecycle)) + .length; +} + +function harnessWithLiveWorkers(count: number): SubAgentSessionStore { + const store = createSubAgentSessionStore(); + for (let i = 0; i < count; i++) { + const session = store.start({ + description: `worker-${i}`, + agentId: "w", + brief: "b", + }); + store.markRunning(session.id); + store.registerCancel(session.id, () => undefined); + } + return store; +} + +describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { + test("stop press drives real cancelWorkersForStop; app stays running; count is the store's live fleet", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = harnessWithLiveWorkers(2); + const bridge = { clearQueuedDelivery: () => undefined }; + // The production dep shapes feed cancelWorkersForStop; fleetRecords is + // cleared after a stop so the idle-with-fleet gauge drops. + const fleetRecords = { clear: () => undefined }; + const affordance = buildShellStopAffordance( + { shell, bridge }, + { subAgentSessions: store, toolset: { fleetRecords } }, + ); + // The runner's wirePostStartup registers the affordance on the shell; + // mirror that so the key machine's readStopAffordance sees it. + setShellStopAffordance(shell, affordance); + try { + // The affordance is registered on the shell as production would. + expect(getShellStopAffordance(shell)).toBe(affordance); + // Before any press, liveWorkerCount reflects the harnessed store's fleet. + expect(affordance.liveWorkerCount()).toBe(2); + + // 1st press arms (pause); app neither stops workers nor exits. + handleCtrlC(shell, 0); + expect(affordance.liveWorkerCount()).toBe(2); + expect(exits).toBe(0); + expect(liveStripCount(store)).toBe(2); + + // 2nd press inside the window stops the live workers via the real + // cancelWorkersForStop closure -- app STAYS RUNNING (no exit). + handleCtrlC(shell, 1); + expect(liveStripCount(store)).toBe(0); + expect(exits).toBe(0); + + // liveWorkerCount now reports the drained store. + expect(affordance.liveWorkerCount()).toBe(0); + + // 3rd press quits (two-press preserved for an empty fleet). + handleCtrlC(shell, 2); + expect(exits).toBe(1); + expect(liveStripCount(store)).toBe(0); + } finally { + shell.dispose(); + } + }); + }); + + test("no live workers: a 2nd press quits, onStopWorkers never cancels", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = createSubAgentSessionStore(); + const affordance = buildShellStopAffordance( + { shell, bridge: { clearQueuedDelivery: () => undefined } }, + { subAgentSessions: store, toolset: { fleetRecords: undefined } }, + ); + setShellStopAffordance(shell, affordance); + try { + expect(affordance.liveWorkerCount()).toBe(0); + handleCtrlC(shell, 0); + handleCtrlC(shell, 1); + expect(exits).toBe(1); + // The stop affordance is still the real one; live count stays zero. + expect(getShellStopAffordance(shell)).toBe(affordance); + } finally { + shell.dispose(); + } + }); + }); +}); diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index f0b2705bd..ab670f276 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -53,7 +53,9 @@ import { setEffortCycleHandler, setMentionSuggestionSource, setPromptRecognitionSource, + setShellStopAffordance, type AppShell, + type ShellStopAffordance, } from "../shell/internals.js"; import { setPromptModelLabel, @@ -172,6 +174,37 @@ export async function cancelWorkersForStop( deps.bridge.clearQueuedDelivery(); } +/** + * Stop-teardown sources for the shell stop affordance (CL-10149 Phase 4). + * Live-worker count is the authoritative `liveFleetCount` over the session + * store (not the bridge-local mirror); the stop callback is the exact + * production `cancelWorkersForStop` closure, so a 2nd-press stop and the + * 3rd-press quit share the same teardown deps as `shutdownRuntime` + * (wiring.ts:295-301) and stay idempotent. Extracted so the Phase 4 wiring + * test can drive the real closure against a harnessed store without a full + * runner harness. + */ +export function buildShellStopAffordance( + host: { shell: AppShell; bridge: { clearQueuedDelivery: () => void } }, + deps: { + subAgentSessions: Pick< + RunnerServices["subAgentSessions"], + "list" | "cancelAll" | "teardown" + >; + toolset: { fleetRecords?: { clear: () => void } | undefined }; + }, +): ShellStopAffordance { + return { + liveWorkerCount: () => liveFleetCount(deps.subAgentSessions.list()), + onStopWorkers: () => + cancelWorkersForStop({ + subAgentSessions: deps.subAgentSessions, + fleetRecords: deps.toolset.fleetRecords, + bridge: host.bridge, + }), + }; +} + export function createFleetWakePublisher( sessions: RunnerServices["subAgentSessions"], emitter: RunnerServices["emitter"], @@ -306,6 +339,20 @@ export function wirePostStartup( services.crashGuard.setDisposeHost(() => shutdownRuntime()); setActiveDisposeHost(() => services.crashGuard.invokeDisposeHost()); + // CL-10149 three-press: register the shell stop affordance so the 2nd Ctrl+C + // (with live sub-agents) drives the same faithful stop-teardown closure the + // quit path uses — the runner owns the source of truth, the shell stays + // service-free (SOLUTION_SCOPE D1/D2). liveWorkerCount reports the live fleet + // from the session store; onStopWorkers stops the workers while the app stays + // running, and is idempotent so a later quit finds nothing live. + setShellStopAffordance( + hostOf(state).shell, + buildShellStopAffordance( + { shell: hostOf(state).shell, bridge: hostOf(state).bridge }, + services, + ), + ); + // Harness inference.error events omit providerId; stamp the live catalog id // onto the stream map so transcript copy can identify known-xAI short 429s. state.stampProvider.fn = (id) => From 3ca734c6bc35ae1f20f43fdcbaef31ff17843fc0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:40:45 -0700 Subject: [PATCH 10/18] feat(tui): show live sub-agent count in the pause note --- src/tui/prompt-slash-exit.test.ts | 32 ++++++++++++++++++++++++++++++- src/tui/shell/keys.ts | 13 +++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 35bac634a..dffa5f0b7 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -18,7 +18,11 @@ import { setShellStopAffordance, type AppShell, } from "./shell/internals"; -import { CTRL_C_EXIT_WINDOW_MS, handleCtrlC } from "./shell/keys"; +import { + CTRL_C_EXIT_WINDOW_MS, + handleCtrlC, + N_SUBAGENT_RUNNING_NOTE_PREFIX, +} from "./shell/keys"; import { addPendingAttachment, clearPendingAttachments } from "./shell/prompt"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; @@ -585,6 +589,32 @@ describe("Ctrl+C exit", () => { expect(exits).toBe(0); }); }); + + test("count-aware note: 1st press names live workers, 2nd press resets to plain exit", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 3); + handleCtrlC(shell, 0); + // 1st press: the flash reuses the count-aware prefix and shows the live + // count, and is not just the plain exit string. + expect(shell.statusFlash).toContain(N_SUBAGENT_RUNNING_NOTE_PREFIX); + expect(shell.statusFlash).toContain("3"); + expect(shell.statusFlash).not.toBe("press ctrl+c again to exit"); + // 2nd press stops (no quit); the flash becomes the decided stop string. + handleCtrlC(shell, 1); + expect(stops).toBe(1); + expect(exits).toBe(0); + expect(shell.statusFlash).toBe("press ctrl+c again to exit"); + }); + }); + + test("no-worker note: 1st press keeps the plain exit string", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 0); + handleCtrlC(shell, 0); + expect(shell.statusFlash).toBe("press ctrl+c again to exit"); + expect(shell.statusFlash).not.toContain(N_SUBAGENT_RUNNING_NOTE_PREFIX); + }); + }); }); }); diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 8402ab8f3..bb6c8c2bd 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -246,8 +246,17 @@ export function handleCtrlC( interruptShell(shell); } // The notice is exactly as true as the arming window is open, so it expires - // with it rather than waiting for some later flash to overwrite it. - setStatusFlash(shell, "press ctrl+c again to exit", { + // with it rather than waiting for some later flash to overwrite it. When + // live sub-agents exist the note is count-aware (Phase 5) so the operator + // knows the next press stops them rather than quitting; otherwise the plain + // two-press exit string is kept. Read fresh at press time so a fleet that + // drained before arming falls back to the plain string. + const { count } = readStopAffordance(shell); + const note = + count > 0 + ? `${count} ${N_SUBAGENT_RUNNING_NOTE_PREFIX}press ctrl+c to stop, again to exit` + : "press ctrl+c again to exit"; + setStatusFlash(shell, note, { ttlMs: CTRL_C_EXIT_WINDOW_MS, ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), }); From c4c074403c5749d546a128a6a4cc6e87f26e659e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 00:51:10 -0700 Subject: [PATCH 11/18] refactor(tui): drop unused shell param from stop affordance --- src/tui/runner/wiring.stop-affordance.test.ts | 4 ++-- src/tui/runner/wiring.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/tui/runner/wiring.stop-affordance.test.ts b/src/tui/runner/wiring.stop-affordance.test.ts index 5a52f87d0..07b3f8c3d 100644 --- a/src/tui/runner/wiring.stop-affordance.test.ts +++ b/src/tui/runner/wiring.stop-affordance.test.ts @@ -63,7 +63,7 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { // cleared after a stop so the idle-with-fleet gauge drops. const fleetRecords = { clear: () => undefined }; const affordance = buildShellStopAffordance( - { shell, bridge }, + { bridge }, { subAgentSessions: store, toolset: { fleetRecords } }, ); // The runner's wirePostStartup registers the affordance on the shell; @@ -114,7 +114,7 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { const store = createSubAgentSessionStore(); const affordance = buildShellStopAffordance( - { shell, bridge: { clearQueuedDelivery: () => undefined } }, + { bridge: { clearQueuedDelivery: () => undefined } }, { subAgentSessions: store, toolset: { fleetRecords: undefined } }, ); setShellStopAffordance(shell, affordance); diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index ab670f276..6d742a474 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -185,7 +185,7 @@ export async function cancelWorkersForStop( * runner harness. */ export function buildShellStopAffordance( - host: { shell: AppShell; bridge: { clearQueuedDelivery: () => void } }, + host: { bridge: { clearQueuedDelivery: () => void } }, deps: { subAgentSessions: Pick< RunnerServices["subAgentSessions"], @@ -347,10 +347,7 @@ export function wirePostStartup( // running, and is idempotent so a later quit finds nothing live. setShellStopAffordance( hostOf(state).shell, - buildShellStopAffordance( - { shell: hostOf(state).shell, bridge: hostOf(state).bridge }, - services, - ), + buildShellStopAffordance({ bridge: hostOf(state).bridge }, services), ); // Harness inference.error events omit providerId; stamp the live catalog id From e94e550ab6fa22bc15c000a149fc47474f32c003 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 20:38:00 -0700 Subject: [PATCH 12/18] fix(tui): skip occupancy flush while the operator pause holds Pause already holds the delivery queue. Occupancy and ask-wake still started a new primary after Ctrl+C. Stall and expire do not pause, so they still flush. --- src/tui/agent-ask-wake.test.ts | 46 ++++++++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 8 +++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/tui/agent-ask-wake.test.ts b/src/tui/agent-ask-wake.test.ts index ebcb654ca..9a7dbc5e0 100644 --- a/src/tui/agent-ask-wake.test.ts +++ b/src/tui/agent-ask-wake.test.ts @@ -11,8 +11,10 @@ import { createDeliveryGeneration, createLeftoverSend, createSessionOperationQueue, + isPaused, routeQueuedDelivery, } from "./delivery-queue.js"; +import { handleCtrlC } from "./shell/keys.js"; import { ingestOperatorPrompt } from "./prompt-attachments.js"; import { armFeedbackCapture, @@ -623,6 +625,50 @@ describe("agent ask wake delivery", () => { }); } + test("operator pause does not flush a stashed ask or mailbox occupancy", async () => { + await withAppShell( + async (shell) => { + const sends: string[] = []; + const bridge = attachSessionBridge( + shell, + createLiveSessionPort({ + send: (text) => { + sends.push(text); + }, + deliver: (text) => { + sends.push(text); + }, + interrupt: () => undefined, + }), + ); + try { + const order = trackMailOrder(bridge, true); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.text.delta", + data: { token: "ok" }, + }); + bridge.handle({ type: "agent-ask", asks: [wake("a1", "q1")] }); + expect(sends).toEqual([]); + + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + expect(sends).toEqual([]); + expect(order).toEqual([]); + expect(bridge.turn.isProcessing).toBe(false); + + bridge.handle({ type: "agent-ask", asks: [wake("a2", "q2")] }); + expect(sends).toEqual([]); + expect(order).toEqual([]); + expect(bridge.turn.isProcessing).toBe(false); + } finally { + bridge.dispose(); + } + }, + { shell: { run: "busy" } }, + ); + }); + test("a wake question with bracket lines does not spoof attachment-echo matching", async () => { await withWakeBridge((bridge, sends, shell) => { const ask = { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 81abccc8d..a772f4007 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -2093,6 +2093,7 @@ export function attachSessionBridge( }); }; const flushMailboxMail = (): boolean => { + if (isPaused(shell.session)) return false; if (bag.disposed || bag.turn.isProcessing) return false; try { if (bag.mailboxMailDriver?.() === true) return true; @@ -2104,6 +2105,7 @@ export function attachSessionBridge( bag.flushMailboxMail = flushMailboxMail; const flushPendingAskWake = (): void => { + if (isPaused(shell.session)) return; if (bag.disposed || bag.turn.isProcessing || bag.turn.blockedGateCount > 0) return; if (mailboxMailDriveClaimed(bag.mailboxMailDriver)) return; @@ -2133,6 +2135,7 @@ export function attachSessionBridge( bag.flushPendingAskWake = flushPendingAskWake; const flushOccupancyThenWake = (): void => { + if (isPaused(shell.session)) return; if (flushMailboxMail() || bag.turn.isProcessing) return; flushPendingAskWake(); }; @@ -2188,7 +2191,10 @@ export function attachSessionBridge( bag.askWakeTurnArmed = false; bag.turn = turnStateOnInterrupt(bag.turn, now()); paintPhase(); - flushOccupancyThenWake(); + // Operator pause holds occupancy/ask-wake: a stashed mailbox or ask must + // not start a new primary until an explicit send. Stall/expire do not + // set paused, so they still flush here. + if (!isPaused(shell.session)) flushOccupancyThenWake(); }; /** From 8d14aa9fe603646650eff978da01175eaa83c54e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 20:38:07 -0700 Subject: [PATCH 13/18] fix(tui): keep the held queue on stay-alive worker stop Stay-alive second Ctrl+C cancelled workers through the quit teardown, which wiped held follow-ups. Quit still wipes; stay-alive does not. --- src/tui/prompt-slash-exit.test.ts | 20 ++++ src/tui/runner/wiring.stop-affordance.test.ts | 95 ++++++++++++++++--- src/tui/runner/wiring.stop-idempotent.test.ts | 54 +++++++---- src/tui/runner/wiring.ts | 64 +++++++------ src/tui/shell/keys.ts | 16 +++- 5 files changed, 190 insertions(+), 59 deletions(-) diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index dffa5f0b7..3979b7cb1 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -581,6 +581,26 @@ describe("Ctrl+C exit", () => { }); }); + test("rejecting onStopWorkers stays STOPPED without quitting", async () => { + await withShell(async ({ shell }) => { + count = 1; + stops = 0; + exits = 0; + setShellStopAffordance(shell, { + liveWorkerCount: () => count, + onStopWorkers: () => Promise.reject(new Error("cancelAll failed")), + }); + setShellExitHandler(shell, () => onExit()); + handleCtrlC(shell, 0); + handleCtrlC(shell, 1); + await Promise.resolve(); + await Promise.resolve(); + expect(exits).toBe(0); + handleCtrlC(shell, 2); + expect(exits).toBe(1); + }); + }); + test("single press pauses only: neither stops nor exits", async () => { await withShell(async ({ shell }) => { wireWorkers(shell, 2); diff --git a/src/tui/runner/wiring.stop-affordance.test.ts b/src/tui/runner/wiring.stop-affordance.test.ts index 07b3f8c3d..16062c84e 100644 --- a/src/tui/runner/wiring.stop-affordance.test.ts +++ b/src/tui/runner/wiring.stop-affordance.test.ts @@ -13,14 +13,20 @@ import { } from "../shell/internals.js"; import { handleCtrlC } from "../shell/keys.js"; import { withTestRenderer } from "../harness.js"; -import { buildShellStopAffordance } from "./wiring.js"; +import { + badgeCount, + createSessionQueue, + enqueue, + isPaused, +} from "../delivery-queue.js"; +import { buildShellStopAffordance, cancelWorkersForStop } from "./wiring.js"; // CL-10149 Phase 4 -- the integration half of Phase 3 deferred here. // // The runner wires the REAL stop affordance into the shell inside // wirePostStartup via buildShellStopAffordance: the affordance's // liveWorkerCount reports the underlying session store's live fleet and its -// onStopWorkers drives the production `cancelWorkersForStop` closure. This test +// onStopWorkers drives the production `cancelLiveWorkers` closure. This test // proves that end-to-end against a harnessed store: a 2nd Ctrl+C press stops // the live workers and the app STAYS RUNNING (no exit), and liveWorkerCount // reports the harnessed store's live fleet before and after the stop. @@ -58,14 +64,11 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { }); const store = harnessWithLiveWorkers(2); - const bridge = { clearQueuedDelivery: () => undefined }; - // The production dep shapes feed cancelWorkersForStop; fleetRecords is - // cleared after a stop so the idle-with-fleet gauge drops. const fleetRecords = { clear: () => undefined }; - const affordance = buildShellStopAffordance( - { bridge }, - { subAgentSessions: store, toolset: { fleetRecords } }, - ); + const affordance = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords }, + }); // The runner's wirePostStartup registers the affordance on the shell; // mirror that so the key machine's readStopAffordance sees it. setShellStopAffordance(shell, affordance); @@ -82,7 +85,7 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { expect(liveStripCount(store)).toBe(2); // 2nd press inside the window stops the live workers via the real - // cancelWorkersForStop closure -- app STAYS RUNNING (no exit). + // cancelLiveWorkers closure -- app STAYS RUNNING (no exit). handleCtrlC(shell, 1); expect(liveStripCount(store)).toBe(0); expect(exits).toBe(0); @@ -113,10 +116,10 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { }); const store = createSubAgentSessionStore(); - const affordance = buildShellStopAffordance( - { bridge: { clearQueuedDelivery: () => undefined } }, - { subAgentSessions: store, toolset: { fleetRecords: undefined } }, - ); + const affordance = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords: undefined }, + }); setShellStopAffordance(shell, affordance); try { expect(affordance.liveWorkerCount()).toBe(0); @@ -130,4 +133,68 @@ describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { } }); }); + + test("stay-alive stop keeps a held follow-up paused; quit still wipes", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = harnessWithLiveWorkers(2); + let cleared = 0; + const bridge = { + clearQueuedDelivery: () => { + cleared += 1; + shell.session = createSessionQueue("idle"); + }, + }; + const fleetRecords = { clear: () => undefined }; + const inner = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords }, + }); + let stopDone: Promise = Promise.resolve(); + const affordance = { + liveWorkerCount: inner.liveWorkerCount, + onStopWorkers: () => { + stopDone = Promise.resolve(inner.onStopWorkers()); + return stopDone; + }, + }; + setShellStopAffordance(shell, affordance); + try { + shell.session = enqueue(shell.session, "follow up later"); + expect(badgeCount(shell.session)).toBe(1); + + handleCtrlC(shell, 0); + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(1); + expect(liveStripCount(store)).toBe(2); + + handleCtrlC(shell, 1); + await stopDone; + expect(exits).toBe(0); + expect(liveStripCount(store)).toBe(0); + expect(cleared).toBe(0); + expect(badgeCount(shell.session)).toBe(1); + expect(isPaused(shell.session)).toBe(true); + + await cancelWorkersForStop({ + subAgentSessions: store, + fleetRecords, + bridge, + }); + expect(cleared).toBe(1); + expect(badgeCount(shell.session)).toBe(0); + } finally { + shell.dispose(); + } + }); + }); }); diff --git a/src/tui/runner/wiring.stop-idempotent.test.ts b/src/tui/runner/wiring.stop-idempotent.test.ts index 7111dc6a9..83398744a 100644 --- a/src/tui/runner/wiring.stop-idempotent.test.ts +++ b/src/tui/runner/wiring.stop-idempotent.test.ts @@ -5,18 +5,24 @@ import { type SubAgentSessionStore, } from "../../subagent/session-store.js"; import { isLiveStrip } from "../../subagent/lifecycle.js"; -import { cancelWorkersForStop } from "./wiring.js"; +import { createAppShell } from "../shell/index.js"; +import { + setShellExitHandler, + setShellStopAffordance, +} from "../shell/internals.js"; +import { handleCtrlC } from "../shell/keys.js"; +import { withTestRenderer } from "../harness.js"; +import { buildShellStopAffordance, cancelWorkersForStop } from "./wiring.js"; // CL-10149 Phase 3 — idempotent stop-then-quit (three-press Ctrl+C). // -// The 2nd-press stop and the 3rd-press quit both drive `cancelWorkersForStop` -// (the affordance's onStopWorkers in Phase 4 wires the stop; the quit path -// runs it again through shutdownRuntime). Because `cancelAll` snapshots live -// sessions and leaves tombstones, a stop followed by a quit must cancel twice -// total, with the second call finding nothing live — no double cancel. The -// single-press pause (the interrupt path) must never call cancelAll at all. -// These assertions pin that idempotency at the `cancelWorkersForStop` unit -// boundary, before the affordance is wired into production (Phase 4). +// The 2nd-press stay-alive stop drives `cancelLiveWorkers`; the 3rd-press +// quit runs `cancelWorkersForStop` through shutdownRuntime. Both cancel +// via `cancelAll`, which snapshots live sessions and leaves tombstones, so +// a stop followed by a quit must cancel twice total, with the second call +// finding nothing live — no double cancel. The single-press pause (the +// interrupt path) must never call cancelAll at all. These assertions pin +// that idempotency at the cancel unit boundary. interface StopHarness { store: SubAgentSessionStore; @@ -80,13 +86,29 @@ describe("cancelWorkersForStop idempotency (CL-10149 Phase 3)", () => { }); test("single-press pause never calls cancelAll", async () => { - // The pause press must not drive the stop/quit path, so a worker that is - // only paused stays live and cancelAll is never invoked (guards the - // interrupt path, R4/R5). - const h = harnessWithLiveWorkers(1); - expect(liveStripCount(h.store)).toBe(1); - expect(h.cancelCalls).toHaveLength(0); - expect(liveStripCount(h.store)).toBe(1); + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + setShellExitHandler(shell, () => undefined); + const harn = harnessWithLiveWorkers(1); + setShellStopAffordance( + shell, + buildShellStopAffordance({ + subAgentSessions: harn.store, + toolset: { fleetRecords: { clear: () => undefined } }, + }), + ); + try { + handleCtrlC(shell, 0); + expect(harn.cancelCalls).toHaveLength(0); + expect(liveStripCount(harn.store)).toBe(1); + } finally { + shell.dispose(); + } + }); }); test("a lone stop on many live workers still cancels exactly once", async () => { diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 6d742a474..33a94be0f 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -157,50 +157,58 @@ export interface StopTeardownDeps { bridge: { clearQueuedDelivery: () => void }; } +/** + * Cancel live workers and drop fleet records, leaving tombstones so a later + * quit-path cancelAll is a no-op. Does not wipe the held delivery queue — + * stay-alive 2nd-press stop uses this so follow-ups stay pending. + */ +export async function cancelLiveWorkers( + deps: Omit, +): Promise { + await deps.subAgentSessions.cancelAll("Session closed"); + deps.subAgentSessions.teardown("Session closed"); + deps.fleetRecords?.clear(); +} + /** * Stop teardown (CL-8016): cancel the workers, wipe the sessions (leaving * tombstones so a late send_input names the teardown), drop the mailbox * lanes that pin them, and clear the bridge queue so no wake-turn bound * outlives the sessions it was owed to. Exported so the stall-bound * regression test drives this exact production path instead of re-wiring - * the three clears by hand. + * the three clears by hand. Quit / shutdown keep this wipe; stay-alive + * stop uses `cancelLiveWorkers` instead. */ export async function cancelWorkersForStop( deps: StopTeardownDeps, ): Promise { - await deps.subAgentSessions.cancelAll("Session closed"); - deps.subAgentSessions.teardown("Session closed"); - deps.fleetRecords?.clear(); + await cancelLiveWorkers(deps); deps.bridge.clearQueuedDelivery(); } /** * Stop-teardown sources for the shell stop affordance (CL-10149 Phase 4). * Live-worker count is the authoritative `liveFleetCount` over the session - * store (not the bridge-local mirror); the stop callback is the exact - * production `cancelWorkersForStop` closure, so a 2nd-press stop and the - * 3rd-press quit share the same teardown deps as `shutdownRuntime` - * (wiring.ts:295-301) and stay idempotent. Extracted so the Phase 4 wiring - * test can drive the real closure against a harnessed store without a full - * runner harness. + * store (not the bridge-local mirror); the stop callback cancels workers + * and fleet records without wiping the held queue, so a 2nd-press stay-alive + * stop keeps pending follow-ups. Quit still goes through + * `cancelWorkersForStop` (wiring.ts shutdown path) and wipes. Extracted so + * the Phase 4 wiring test can drive the real closure against a harnessed + * store without a full runner harness. */ -export function buildShellStopAffordance( - host: { bridge: { clearQueuedDelivery: () => void } }, - deps: { - subAgentSessions: Pick< - RunnerServices["subAgentSessions"], - "list" | "cancelAll" | "teardown" - >; - toolset: { fleetRecords?: { clear: () => void } | undefined }; - }, -): ShellStopAffordance { +export function buildShellStopAffordance(deps: { + subAgentSessions: Pick< + RunnerServices["subAgentSessions"], + "list" | "cancelAll" | "teardown" + >; + toolset: { fleetRecords?: { clear: () => void } | undefined }; +}): ShellStopAffordance { return { liveWorkerCount: () => liveFleetCount(deps.subAgentSessions.list()), onStopWorkers: () => - cancelWorkersForStop({ + cancelLiveWorkers({ subAgentSessions: deps.subAgentSessions, fleetRecords: deps.toolset.fleetRecords, - bridge: host.bridge, }), }; } @@ -340,14 +348,14 @@ export function wirePostStartup( setActiveDisposeHost(() => services.crashGuard.invokeDisposeHost()); // CL-10149 three-press: register the shell stop affordance so the 2nd Ctrl+C - // (with live sub-agents) drives the same faithful stop-teardown closure the - // quit path uses — the runner owns the source of truth, the shell stays - // service-free (SOLUTION_SCOPE D1/D2). liveWorkerCount reports the live fleet - // from the session store; onStopWorkers stops the workers while the app stays - // running, and is idempotent so a later quit finds nothing live. + // (with live sub-agents) stops workers while the app stays running. The + // runner owns the source of truth; the shell stays service-free. Stay-alive + // stop cancels workers without wiping the held queue; quit still wipes + // through cancelWorkersForStop. Tombstones keep a later quit-path cancelAll + // a no-op. setShellStopAffordance( hostOf(state).shell, - buildShellStopAffordance({ bridge: hostOf(state).bridge }, services), + buildShellStopAffordance(services), ); // Harness inference.error events omit providerId; stamp the live catalog id diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index bb6c8c2bd..7865acb4a 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -1,12 +1,14 @@ /** * Key routing: paste guard, kill-ring chords, the onKey dispatcher body, Ctrl+C arming. */ +import { getLogger } from "@intx/log"; import { ScrollBoxRenderable, type BaseRenderable, type KeyEvent, type MouseEvent, } from "@opentui/core"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { badgeCount, pause } from "../delivery-queue.js"; import { @@ -91,6 +93,14 @@ import { } from "../sent-message-history.js"; import { EXPAND_KEY } from "../stream.js"; +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +function logStopWorkersFailure(error: unknown): void { + tuiLogger.warn("stop workers failed: {error}", { + error: error instanceof Error ? error.message : String(error), + }); +} + // Human keystrokes land tens of milliseconds apart at the fastest; a paste // replayed onto stdin without bracketed-paste framing lands effectively all // at once. 15ms is an empirical guess at a gap comfortably under normal @@ -213,7 +223,11 @@ export function handleCtrlC( ? { schedule: options.schedule } : {}), }); - void onStop(); + try { + void Promise.resolve(onStop()).catch(logStopWorkersFailure); + } catch (error: unknown) { + logStopWorkersFailure(error); + } return; } // Real quit (2nd press with no live workers, or 3rd press after stop). From ee4e06cafce3ec0e6f08c6bbf0162a95bdcb8ae2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 20:38:12 -0700 Subject: [PATCH 14/18] docs(tui): document ctrl+c as a pause that stays idle --- docs/PRODUCT.md | 2 +- docs/TUI.md | 58 ++++++++++++++++++++++++------------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 6e484a1be..b7b9b14b7 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary dispatch turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary dispatch turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** pauses: stop the primary, hold the queue, stay idle until an explicit operator send. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` pause). 9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience diff --git a/docs/TUI.md b/docs/TUI.md index 2433509ea..7cc24db26 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -693,7 +693,9 @@ nearest the prompt, first selected — stay visible, and a guidance row naming its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`). The transcript only ever sees the item that actually delivers, as an ordinary user row — pending/delivery labels (`[will steer next]`, `[steering]`, -`[following up]`) are gone on purpose. +`[following up]`) are gone on purpose. After a first Ctrl+C pause the column +is the durable surface: held items stay pending and deliver only on the next +explicit operator send. While the column has items, `↑` at the prompt buffer's top edge selects the newest held item and `↑`/`↓` walk the rows; `↓` past the last row hands the @@ -736,26 +738,26 @@ as id/status/description only. The mail and that fleet-dry continuation are runtime-to-agent traffic — the fleet board owns worker status — so neither paints a transcript row, and neither rehydrates as one. -Interrupting (Ctrl+C) never discards a queued or steered message. It used to -— the transcript literally said `interrupt — discarded N pending`, and an -operator who queued an instruction and then lost patience destroyed the very -thing they were trying to deliver. It now reports `interrupt — N pending -kept`: the run stops, the queue survives, and those messages are handed over -at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not -left waiting on an idle event the stop may never produce (`interrupt` in -`delivery-queue.ts` no longer clears `items`). +Interrupting (Ctrl+C) never discards a queued or steered message. The first +press is a pause: it stops the primary, holds the queue, and reports +`N pending kept` (`interrupt` in `delivery-queue.ts` does not clear `items`). +Held items stay in the pending column and deliver only on the next explicit +operator send. Stall and expire aborts do not pause, so they still drain. +Occupancy and ask-wake do not start a new primary while paused. A compaction +continuation is dropped on operator pause (`abortCompaction`); it is not held +and does not auto-resume. **Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up (queued drain) leave running workers alone — they never call `runner.ts`'s `interrupt()`, so the parent's operation signal stays live and -spawned workers keep running. Ctrl+C is the explicit fleet teardown: -`doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts the shared -operation signal and routes cancellation through the fleet/session-store -teardown path, so in-flight fleet-agent dispatch reports back as cancelled by -the operator rather than being left to finish silently detached. `/clear` and +spawned workers keep running. The first Ctrl+C pauses the parent and does not +tear down the fleet. A second press while workers are live stops those workers +(`cancelLiveWorkers`); the app stays up and the held queue is kept. A third +press quits. With no live workers, the second press still quits. `/clear` and session exit still call `subAgentSessions.cancelAll` for an explicit session-wide cancel; that path is separate from interrupt and must stay off -the soft-steer / follow-up gestures. +the soft-steer / follow-up gestures. Quit still full-teardowns, including +wiping the delivery queue. Up/Down are caret motion first inside a multi-line buffer — except while the pending column is engaged, when ↑ at the buffer's top edge selects a held item @@ -813,21 +815,19 @@ The prompt repaints on every keystroke (`onFrame` in `src/tui/shell/index.ts` ca not on a debounce) — anything added to the prompt's paint path must stay cheap, because it runs at typing speed. -Ctrl+C interrupts a busy run, or clears idle prompt text and pending +Ctrl+C pauses a busy run (stop the primary, hold the queue, stay idle until +an explicit operator send), or clears idle prompt text and pending attachments. Clearing prompt text arms a 2-second quit window -(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second -Ctrl+C while the window is open quits — this -replaced an Ink-era yes/no exit-confirm modal with the same intent (an -explicit second confirmation) without adding a modal (`handleCtrlC`, -`src/tui/shell/prompt.ts`). See "Soft steer vs. follow-up" above for the two -mid-run gestures and what interrupting does to fleet-agent lanes. 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 -(`"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. +(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. With live +sub-agents, a second Ctrl+C in the window stops the workers and keeps the +app up (queue kept); a third quits. With no live workers, the second press +still quits. The extra press is the confirmation, with no modal +(`handleCtrlC`, `src/tui/shell/keys.ts`). See "Soft steer vs. follow-up" +above for the two mid-run gestures and what interrupting does to +fleet-agent lanes. The pause keeps whatever is sitting in the queue rather +than discarding it — the transcript row says so (`"N pending kept"`). Held +items deliver only on the next explicit send; occupancy and ask-wake do +not start a new primary while paused. ## Overflows, scrolling, and key macros From 99593da9121a11939fe34ffc1e093d25748dad90 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 20:58:22 -0700 Subject: [PATCH 15/18] fix(tui): skip occupancy begin while the operator pause holds --- src/subagent/fleet-dry-drive.test.ts | 25 +++++++++++ src/subagent/fleet-dry-drive.ts | 9 ++++ src/subagent/mailbox-mail-drive.test.ts | 22 ++++++++++ src/subagent/mailbox-mail-drive.ts | 7 ++++ src/tui/agent-ask-wake.test.ts | 56 +++++++++++++++++++++++++ src/tui/runner/wiring.ts | 1 + src/tui/runtime-bridge.ts | 4 ++ 7 files changed, 124 insertions(+) diff --git a/src/subagent/fleet-dry-drive.test.ts b/src/subagent/fleet-dry-drive.test.ts index a3bdd7402..76a4371a3 100644 --- a/src/subagent/fleet-dry-drive.test.ts +++ b/src/subagent/fleet-dry-drive.test.ts @@ -680,6 +680,31 @@ describe("driveOpenTasksAfterFleetDry", () => { ).toBe(false); }); + test("does not send if begin does not claim the parent turn", async () => { + const records = recordsOf({ + w1: { status: "done", report: "ok" }, + }); + const sends: string[] = []; + expect( + await driveOpenTasksAfterFleetDry({ + previousRunning: 1, + running: 0, + openTasks: [openTask], + parentProcessing: false, + isParentProcessing: () => false, + mailbox: peekMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: (prompt) => { + sends.push(prompt); + return ACCEPTED_DELIVERY; + }, + }), + ).toBe(false); + expect(sends).toEqual([]); + expect(records.get("w1")?.collected).not.toBe(true); + }); + test("deferred dry edge after parentProcessing still collects and sends", async () => { const records = recordsOf({ w1: { status: "done", report: "ok" }, diff --git a/src/subagent/fleet-dry-drive.ts b/src/subagent/fleet-dry-drive.ts index c24503ac2..3004910e7 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -543,6 +543,8 @@ export function driveOpenTasksAfterFleetDry(args: { running?: number | undefined; openTasks: readonly Task[]; parentProcessing: boolean; + /** Live check after awaited collect; occupancy must not start a turn already claimed. */ + isParentProcessing?: () => boolean; deferredDryEdge?: boolean; mailbox: FleetDryMailbox | undefined; lanes: readonly FleetDryLane[]; @@ -601,6 +603,13 @@ async function driveOpenTasksAfterFleetDrySpill( const reports = dedupeByAgentId([...digested, ...stubs]); prompt = buildFleetDryContinuationPrompt(tasks, reports); args.beginSystemContinuation(prompt); + // begin is a no-op while paused. Do not send; release the delivering + // claim so a later explicit send can still deliver. The dry latch + // unclaims when this drive returns false. + if (args.isParentProcessing?.() === false) { + releaseOccupancyDelivering(args.mailbox, takeIds); + return false; + } } catch { return fail(); } diff --git a/src/subagent/mailbox-mail-drive.test.ts b/src/subagent/mailbox-mail-drive.test.ts index d357c91b1..74e19bebd 100644 --- a/src/subagent/mailbox-mail-drive.test.ts +++ b/src/subagent/mailbox-mail-drive.test.ts @@ -578,6 +578,28 @@ describe("driveMailboxMail", () => { expect(records.get("w1")?.collected).not.toBe(true); }); + test("does not send if begin does not claim the parent turn", async () => { + const records = recordsOf({ + w1: { status: "done", report: "ok" }, + }); + const sends: string[] = []; + expect( + await driveMailboxMail({ + parentProcessing: false, + isParentProcessing: () => false, + mailbox: collectingMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: (prompt) => { + sends.push(prompt); + return ACCEPTED_DELIVERY; + }, + }), + ).toBe(false); + expect(sends).toEqual([]); + expect(records.get("w1")?.collected).not.toBe(true); + }); + test("not-delivered send leaves the wake retryable", async () => { const records = await driveMailNotDelivered(); expect( diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts index 7efa4cc49..e744dcfba 100644 --- a/src/subagent/mailbox-mail-drive.ts +++ b/src/subagent/mailbox-mail-drive.ts @@ -179,6 +179,13 @@ async function driveMailboxMailAfterCollect( await digestCollectedReports(reports, args.writeBlob), ); args.beginSystemContinuation(prompt); + // begin is a no-op while paused (and if disposed/empty). Do not send a + // primary, and release the delivering claim so a later explicit send can + // still deliver. The mailbox latch unclaims when this drive settles. + if (args.isParentProcessing?.() === false) { + releaseOccupancyDelivering(args.mailbox, ids); + return false; + } } catch { return fail(); } diff --git a/src/tui/agent-ask-wake.test.ts b/src/tui/agent-ask-wake.test.ts index 9a7dbc5e0..2cec3eccc 100644 --- a/src/tui/agent-ask-wake.test.ts +++ b/src/tui/agent-ask-wake.test.ts @@ -669,6 +669,62 @@ describe("agent ask wake delivery", () => { ); }); + test("paused beginSystemContinuation does not mark the turn processing", async () => { + await withWakeBridge((bridge, sends, shell) => { + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + bridge.beginSystemContinuation("mailbox occupancy"); + expect(bridge.turn.isProcessing).toBe(false); + expect(shell.session.run).not.toBe("busy"); + expect(sends).toEqual([]); + }); + }); + + test("in-flight mailbox collect after pause does not start a primary", async () => { + await withWakeBridge(async (bridge, sends, shell) => { + const order: string[] = []; + let release: () => void = () => undefined; + const hold = new Promise((resolve) => { + release = resolve; + }); + const driver = latchAsyncMailboxMail(bridge, order, hold); + bridge.setMailboxMailDriver(driver); + bridge.flushMailboxMail(); + expect(order).toEqual(["mail"]); + expect(driver.claimed()).toBe(true); + expect(bridge.turn.isProcessing).toBe(false); + + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + + release(); + await hold; + await Promise.resolve(); + await Promise.resolve(); + + expect(order).toEqual(["mail", "begin"]); + expect(bridge.turn.isProcessing).toBe(false); + expect(sends).toEqual([]); + expect(shell.session.run).not.toBe("busy"); + expect(isPaused(shell.session)).toBe(true); + expect(driver.claimed()).toBe(false); + }); + }); + + test("unpaused interrupt still begins a system continuation", async () => { + await withWakeBridge((bridge, sends, shell) => { + bridge.handle({ type: "inference.start", data: {} }); + expect(bridge.turn.isProcessing).toBe(true); + bridge.interrupt(); + expect(isPaused(shell.session)).toBe(false); + expect(bridge.turn.isProcessing).toBe(false); + bridge.beginSystemContinuation("mailbox occupancy"); + expect(bridge.turn.isProcessing).toBe(true); + expect(shell.session.run).toBe("busy"); + expect(sends).toEqual([]); + }); + }); + test("a wake question with bracket lines does not spoof attachment-echo matching", async () => { await withWakeBridge((bridge, sends, shell) => { const ask = { diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 33a94be0f..597b515a7 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -407,6 +407,7 @@ export function wirePostStartup( deferredDryEdge: true, openTasks: services.directorHolder.instance?.getTasks() ?? [], parentProcessing: false, + isParentProcessing: () => sessionBridge.turn.isProcessing, mailbox: services.toolset.fleetRecords, lanes: services.subAgentSessions.list(), ...(storage !== null diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index a772f4007..2c4a45f37 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -2482,6 +2482,10 @@ export function attachSessionBridge( }, beginSystemContinuation: (text) => { if (bag.disposed) return; + // Occupancy that already claimed a latch can still call begin after an + // awaited collect. Operator pause must not start a primary; stall/expire + // do not set paused, so they still begin. + if (isPaused(shell.session)) return; const t = text.trim(); if (t.length === 0) return; bag.lastSentMessage = t; From 4e1a40b86a4b2a18fceeece7128050cd0a2aeb73 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 8 Oct 2026 20:58:24 -0700 Subject: [PATCH 16/18] docs(tui): document compaction hop as deferred while paused --- docs/TUI.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 7cc24db26..a2a7a9497 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -744,8 +744,8 @@ press is a pause: it stops the primary, holds the queue, and reports Held items stay in the pending column and deliver only on the next explicit operator send. Stall and expire aborts do not pause, so they still drain. Occupancy and ask-wake do not start a new primary while paused. A compaction -continuation is dropped on operator pause (`abortCompaction`); it is not held -and does not auto-resume. +continuation hop is deferred while paused; it stays consume-once-intact and +delivers after resume (an explicit operator send), not dropped. **Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up (queued drain) leave running workers alone — they never call From f8e0b572ab2a036cb69cdeaaca04c5532973815c Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:28:57 -0700 Subject: [PATCH 17/18] docs(tui): align Ctrl+C help and stop-affordance comment with the pause --- src/tui/keybindings.ts | 2 +- src/tui/shell/keys.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 9a8f722d3..0a347888c 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -38,7 +38,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+C", description: - "interrupt the run, or clear the prompt and attachments when idle; press twice to exit", + "pause a busy run and hold the queue, or clear the prompt and attachments when idle; Ctrl+C again to exit — with live sub-agents, a 2nd press stops them, a 3rd quits", }, { keys: "Ctrl+G", diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 7865acb4a..33cfafe3a 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -277,15 +277,17 @@ export function handleCtrlC( } /** - * Pure read of the registered stop affordance scalars for the second-press - * branch (Phase 2). The shell stays service-free: it never constructs a + * Pure read of the registered stop affordance scalars backing the Ctrl+C + * three-press machine. This is the live source for both the stop-press branch + * and the count-aware note in `handleCtrlC`: an in-window press that finds + * live workers becomes a "stop the fleet, stay up" press (`state: "stopped"`), + * routing the quit off to a third press; the note's count also comes straight + * from here, so the operator sees that the next press stops the sub-agents + * rather than exiting. The shell stays service-free: it never constructs a * worker-count itself, only mirrors what the runner registered. Defaults keep - * an unregistered shell on today's two-press contract (count 0, no-op stop) so - * the armed branch stays safe before Phase 4 wires the runner. - * - * Phase 1 establishes this seam only; it is NOT yet consulted by `handleCtrlC`. - * It is exported so Phase 1 consumes it honestly (pinned by the stop-affordance - * test) instead of leaving it dead until Phase 2 wires the second-press branch. + * an unregistered shell on the plain two-press contract (count 0, no-op stop) + * so a runner that has not wired the affordance still quits on the second + * press. */ export function readStopAffordance(shell: AppShell): { count: number; From e4aaf4d2f329f9960bbd6808a94071c1d8d9e5d4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 10 Oct 2026 18:37:41 -0700 Subject: [PATCH 18/18] style(tui): oxfmt multi-line import in runtime-bridge test --- src/tui/runtime-bridge.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index e011ad491..2fb0f892b 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -27,7 +27,11 @@ import { withAppShell } from "./test-helpers"; import { operatorCancelResult, wireGates } from "./gate-wire.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; import { moveOverlaySelection } from "./shell/overlay-list.js"; -import { badgeCount, isPaused, SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue"; +import { + badgeCount, + isPaused, + SESSION_IDENTITY_ABORT_REASON, +} from "./delivery-queue"; import { LIVE_ACTIVITY_WORDS } from "./chrome-state"; type RecordingPort = ReturnType;