Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ba9930c
feat(tui): add paused state to hold the delivery queue
TheGreatAxios Oct 8, 2026
3c189a1
feat(tui): gate queue drain on the paused state
TheGreatAxios Oct 8, 2026
7656d2d
feat(tui): hold compaction continuation while paused
TheGreatAxios Oct 8, 2026
bfdde61
feat(tui): pause the queue on ctrl+c and resume on send
TheGreatAxios Oct 8, 2026
997dd6d
feat(tui): show a pause note instead of a restart message
TheGreatAxios Oct 8, 2026
23e5c0e
feat(tui): add a shell-level stop-workers affordance
TheGreatAxios Oct 8, 2026
89d9ad4
feat(tui): stop sub-agents on the second ctrl+c
TheGreatAxios Oct 8, 2026
f37f5dc
test(tui): pin idempotent stop-then-quit for sub-agents
TheGreatAxios Oct 8, 2026
1795644
feat(tui): wire the shell stop affordance to the runner
TheGreatAxios Oct 8, 2026
3ca734c
feat(tui): show live sub-agent count in the pause note
TheGreatAxios Oct 8, 2026
c4c0744
refactor(tui): drop unused shell param from stop affordance
TheGreatAxios Oct 8, 2026
e94e550
fix(tui): skip occupancy flush while the operator pause holds
TheGreatAxios Oct 9, 2026
8d14aa9
fix(tui): keep the held queue on stay-alive worker stop
TheGreatAxios Oct 9, 2026
ee4e06c
docs(tui): document ctrl+c as a pause that stays idle
TheGreatAxios Oct 9, 2026
99593da
fix(tui): skip occupancy begin while the operator pause holds
TheGreatAxios Oct 9, 2026
4e1a40b
docs(tui): document compaction hop as deferred while paused
TheGreatAxios Oct 9, 2026
f8e0b57
docs(tui): align Ctrl+C help and stop-affordance comment with the pause
TheGreatAxios Oct 9, 2026
e4aaf4d
style(tui): oxfmt multi-line import in runtime-bridge test
TheGreatAxios Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 29 additions & 29 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 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
`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
Expand Down Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions src/subagent/fleet-dry-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
9 changes: 9 additions & 0 deletions src/subagent/fleet-dry-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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();
}
Expand Down
22 changes: 22 additions & 0 deletions src/subagent/mailbox-mail-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/subagent/mailbox-mail-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
102 changes: 102 additions & 0 deletions src/tui/agent-ask-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -623,6 +625,106 @@ 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("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<void>((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 = {
Expand Down
56 changes: 56 additions & 0 deletions src/tui/delivery-queue-pause.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading