diff --git a/CHANGELOG.md b/CHANGELOG.md index 41275867b..f46749686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### TUI + +- Idle-with-fleet is real: after Skywalker dispatches workers with `spawn_agent` + and ends its turn, the session holds itself open while they run. Enter starts + a new primary turn immediately instead of queueing a soft-steer behind a + parent tool that no longer exists; Alt+Enter follow-ups still wait for true + session-idle (parent idle and no live fleet lanes), delivering the moment the + last worker terminalizes. Ctrl+C stop policy is unchanged. + ## [0.3.2] - 2026-08-25 ### Agent diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d01d4ac8a..9b51e5ee8 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -187,10 +187,10 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r `ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true, drain timing is **parent-idle** vs **session-idle**: -- **Enter** soft-steers — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers. +- **Enter** soft-steers while the parent is busy — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers. - **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run. -A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent idle after dispatch so Enter is a turn while workers run) is not shipped. +Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. `src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks. @@ -382,7 +382,7 @@ the directors guard on; the full set of reactor and stream event types is treat that as canonical rather than this section or any other doc's partial list. -Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is not shipped. +Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups. ### Lifecycle Hooks diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 7ade6ef10..7286e14f2 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) or via stdin when the product agent runs under `corbits exec`. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `task()` is parent-busy, so Enter is a queued steer, not a new turn. **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. A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent goes idle after dispatch so Enter is a turn while workers run) is not shipped. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; 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 Skywalker 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` or an awaiting `wait_agents` / `task()` is parent-busy, so Enter is a queued steer, not a new turn. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, 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; 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). 9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` / `wait_agents` / `search_agents` (`task` remains a fused spawn-plus-wait wrapper). 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 070adf829..d9ea6ae2d 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -456,8 +456,17 @@ Two mid-run gestures, two delivery times (CL-6290): When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS` (3s), the notice row adds `waiting on ` (e.g. `waiting on run_shell`). Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is -unchanged. Idle-with-fleet is not shipped — Enter stays a queued steer until -the parent tool finishes, not a new turn while workers run. +unchanged. + +**Idle-with-fleet** is shipped. After a non-blocking `spawn_agent` dispatch +the parent turn settles while workers keep running; the runner emits `fleet` +events carrying the live-lane count and the bridge holds the run busy on it. +During the hold, Enter upgrades to a new primary turn sent immediately — +there is no parent tool left to steer — while Alt+Enter follow-ups keep +waiting for true session-idle. A steer still pending when the hold engages +delivers at once (the parent it was steering has stopped), and the last lane +terminalizing releases the hold, drains follow-ups, and returns the session +to idle. Interrupting (Ctrl+C) never discards a queued or steered message. It used to — the transcript literally said `interrupt — discarded N pending`, and an diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 2334d908d..7aaf985e4 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -150,7 +150,7 @@ export function buildGuidelines( : [ "", "Orchestration:", - "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then `wait_agents` for the reports you need. `task` remains the deprecated fused spawn+wait fallback for a single blocking worker.", + "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked. `task` remains the deprecated fused spawn+wait fallback for a single blocking worker.", "- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.", "- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.", "- If a worker comes back without finishing, change the brief rather than repeating it: narrow the scope, name the files, or state the done-when more sharply.", diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index c41ecda04..7ad96496a 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { createFleetWatch, fleetDigest, observeFleet, type FleetLane } from "./fleet-report.js"; +import { + createFleetWatch, + fleetDigest, + liveFleetCount, + observeFleet, + type FleetLane, +} from "./fleet-report.js"; const T0 = 1_000_000; @@ -16,6 +22,24 @@ function lane(overrides: Partial & { id: string }): FleetLane { }; } +describe("liveFleetCount", () => { + test("counts only running lanes — the idle-with-fleet hold reads the same definition", () => { + const lanes = [ + lane({ id: "a" }), + lane({ id: "b", status: "done", report: "## Summary\nDone." }), + lane({ id: "c", status: "failed", error: "boom" }), + lane({ id: "d", status: "cancelled" }), + lane({ id: "e" }), + ]; + expect(liveFleetCount(lanes)).toBe(2); + }); + + test("an empty or fully-terminal fleet counts zero", () => { + expect(liveFleetCount([])).toBe(0); + expect(liveFleetCount([lane({ id: "a", status: "done", report: "x" })])).toBe(0); + }); +}); + describe("observeFleet", () => { test("the first observation seeds without announcing an in-flight fleet", () => { const { watch, updates } = observeFleet( diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 392b076be..b2413a7a7 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -108,6 +108,15 @@ function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean { return agentProgress(lane, nowMs, stallMs)?.stalled === true; } +/** + * Lanes still running — the count the idle-with-fleet hold reads (CL-7057). + * One definition lives here so the bridge feed and any other liveness reader + * cannot drift from what the strip and digest call a running lane. + */ +export function liveFleetCount(lanes: readonly FleetLane[]): number { + return lanes.filter((lane) => lane.status === "running").length; +} + type Change = | { readonly kind: "dispatched"; readonly line: string } | { readonly kind: "done"; readonly line: string } diff --git a/src/subagent/index.ts b/src/subagent/index.ts index dba195f3c..816bb8c28 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -16,6 +16,7 @@ export { fleetDigest, FLEET_REPORT_SETTLE_MS, FLEET_STALL_POLL_MS, + liveFleetCount, observeFleet, type FleetLane, type FleetObservation, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 5360af726..008a494c5 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -149,6 +149,7 @@ import { fleetDigest, FLEET_REPORT_SETTLE_MS, FLEET_STALL_POLL_MS, + liveFleetCount, observeFleet, taskToolDefinition, } from "../subagent/index.js"; @@ -2598,7 +2599,17 @@ export async function runTUI(initialConfig: Config): Promise { for (const update of observation.updates) surfaceSystemNotice(host.shell, update); }; let fleetSettle: ReturnType | null = null; + // Live-lane count feeds the bridge's idle-with-fleet hold (CL-7057): the + // run stays busy after the parent turn settles until the last lane + // terminalizes. Store notifications fire per child event, not per status + // flip, so emit only when the count itself moves. + let lastLiveFleet = 0; const unsubscribeFleetReport = subAgentSessions.subscribe(() => { + const liveFleet = liveFleetCount(subAgentSessions.list()); + if (liveFleet !== lastLiveFleet) { + lastLiveFleet = liveFleet; + emitter.emit("event", { type: "fleet", running: liveFleet }); + } if (fleetSettle !== null) return; fleetSettle = setTimeout(() => { fleetSettle = null; diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 81abdd935..c77270e1b 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -881,6 +881,183 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { }); }); +describe("idle-with-fleet (CL-7057)", () => { + /** A tool-less turn settles on inference.done — the spawn_agent dispatch shape. */ + function settleToollessTurn(bridge: ReturnType): void { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.done", data: {} }); + } + + test("parent settles while fleet is live: run holds busy, follow-up keeps waiting", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.submit("dispatch workers", "immediate"); + bridge.handle({ type: "fleet", running: 2 }); + bridge.submit("follow up later", "queue"); + port.clear(); + settleToollessTurn(bridge); + // The parent turn settled but the fleet is live: the run stays + // busy and the follow-up does not drain at mere parent-idle. + expect(shell.session.run).toBe("busy"); + expect(badgeCount(shell.session)).toBe(1); + expect(port.calls).toEqual([]); + await h.renderOnce(); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Enter mid-hold starts a new primary turn instead of queueing a steer", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + // Hold state: live fleet + settled parent turn. + bridge.handle({ type: "fleet", running: 2 }); + settleToollessTurn(bridge); + expect(shell.session.run).toBe("busy"); + port.clear(); + + shell.prompt.value = "also update the docs"; + shell.prompt.submit(); + // A new turn, not a queued steer waiting on a tool that no longer + // exists. + expect(port.calls.some((c) => c.op === "enqueue")).toBe(false); + expect(port.calls.some((c) => c.op === "sendImmediate")).toBe(true); + expect(badgeCount(shell.session)).toBe(0); + expect(shell.session.run).toBe("busy"); + await h.renderOnce(); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Alt+Enter mid-hold queues the follow-up; last lane terminalizing drains it at session-idle", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.handle({ type: "fleet", running: 1 }); + settleToollessTurn(bridge); + expect(shell.session.run).toBe("busy"); + + bridge.submit("when it finishes, summarize", "queue"); + expect(port.calls.some((c) => c.op === "sendImmediate")).toBe(false); + expect(badgeCount(shell.session)).toBe(1); + port.clear(); + + // The last lane terminalizes: the hold releases, the run idles, + // and only now does the queued follow-up deliver. + bridge.handle({ type: "fleet", running: 0 }); + expect(shell.session.run).toBe("idle"); + expect(badgeCount(shell.session)).toBe(0); + const deliver = port.calls.find((c) => c.op === "deliver"); + expect(deliver).toEqual({ + op: "deliver", + item: expect.objectContaining({ text: "when it finishes, summarize", kind: "queue" }), + }); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a steer left pending at hold engagement delivers immediately; the hold stays on", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.submit("dispatch workers", "immediate"); + // Parent busy (turn in flight): this steer queues for the boundary. + bridge.submit("one more worker", "steer"); + bridge.handle({ type: "fleet", running: 1 }); + port.clear(); + settleToollessTurn(bridge); + // The parent the steer was addressing has stopped, so it delivers + // as its own turn right away instead of sitting out the hold — but + // the run itself stays held by the live fleet. + expect(shell.session.run).toBe("busy"); + expect(badgeCount(shell.session)).toBe(0); + const deliver = port.calls.find((c) => c.op === "deliver"); + expect(deliver).toEqual({ + op: "deliver", + item: expect.objectContaining({ text: "one more worker", kind: "steer" }), + }); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("fleet count reaching zero mid-turn does not idle a working parent", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.submit("dispatch workers", "immediate"); + bridge.handle({ type: "fleet", running: 1 }); + // The lone worker fails immediately while the parent is still + // streaming its reply: no release, no premature idle. + bridge.handle({ type: "fleet", running: 0 }); + expect(shell.session.run).toBe("busy"); + settleToollessTurn(bridge); + expect(shell.session.run).toBe("idle"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); + describe("syncAgentProgress", () => { function taskSession(over: Partial): TaskProgressSession { return { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 66cadcb21..6dd61305b 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -254,6 +254,7 @@ function isBridgeInbound(event: { type: string }): event is BridgeInboundEvent { case "tool_result": case "system": case "run": + case "fleet": case "tool.boundary": case "error": return true; @@ -332,6 +333,13 @@ interface BridgeBag { mapCtx: StreamMapContext; disposed: boolean; turn: TurnState; + /** + * Live fleet-lane count from the last `fleet` event (idle-with-fleet). + * While this is above zero a settled parent turn holds the run busy — + * Enter upgrades to a new primary turn, follow-ups keep waiting for true + * session-idle — and the hold releases when the count lands back at zero. + */ + liveFleet: number; /** Last prompt actually sent — replay source for the quota auto-retry. */ lastSentMessage: string; /** One auto-retry per rate-limit window. */ @@ -741,9 +749,48 @@ function drainSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { paintChrome(shell); } +/** + * Release the run to idle and drain everything queued — but only at true + * session-idle. A live fleet holds the run busy after the parent turn settles + * (idle-with-fleet): Enter upgrades to a new primary turn during the hold and + * follow-ups keep waiting; the fleet event landing at zero re-enters here to + * release the hold. + */ +function settleRunToIdle(shell: AppShell, bag: BridgeBag): void { + if (shell.session.run !== "busy") return; + bag.turnThinking = null; + shell.inFlightTool = null; + if (bag.liveFleet > 0) { + // Hold: the fleet is still live, so the run stays busy. Steers left + // pending deliver now — the parent they were steering has stopped, so + // each one just starts its own turn — while follow-ups keep waiting. + drainSteersAtBoundary(shell, bag); + return; + } + shell.session = setRunState(shell.session, "idle"); + // Full drain: soft steers first, then follow-ups (drainOrder). + drainAtBoundary(shell, bag); +} + function applyInbound(shell: AppShell, bag: BridgeBag, event: BridgeInboundEvent): void { if (bag.disposed) return; + // Fleet liveness owns no transcript row state, so it is handled before the + // open-row machinery — a lane terminalizing mid-parent-stream must not + // close the assistant row the parent's own deltas are growing. + if (event.type === "fleet") { + // Idle-with-fleet bookkeeping. A transition to zero while the parent is + // already idle releases the hold: that moment is true session-idle, so + // queued follow-ups drain now. While the parent is still working the + // count just updates — the ordinary turn settle does the draining. + bag.liveFleet = event.running; + if (event.running === 0 && !bag.turn.isProcessing) { + settleRunToIdle(shell, bag); + } + paintChrome(shell); + return; + } + if (event.type === "assistant.delta") { growOpenRow(shell, bag, "assistant", event.text); return; @@ -770,16 +817,13 @@ function applyInbound(shell: AppShell, bag: BridgeBag, event: BridgeInboundEvent if (event.type === "user" && consumeEcho(bag, event.text)) return; if (event.type === "run") { - if (event.state === "idle") { - bag.turnThinking = null; - shell.inFlightTool = null; + if (event.state === "busy") { + shell.session = setRunState(shell.session, "busy"); + paintChrome(shell); + return; } - shell.session = setRunState(shell.session, event.state); + settleRunToIdle(shell, bag); paintChrome(shell); - if (event.state === "idle") { - // Full drain: soft steers first, then follow-ups (drainOrder). - drainAtBoundary(shell, bag); - } return; } @@ -831,6 +875,7 @@ export function attachSessionBridge( mapCtx: createStreamMapContext(), disposed: false, turn: initialTurnState(now()), + liveFleet: 0, lastSentMessage: "", quotaFired: false, now, @@ -985,15 +1030,14 @@ export function attachSessionBridge( }; /** - * A settled turn hands the session back to the operator. A chat session's - * terminator is `connector.reply`, which maps to no `run` event, so without - * this the shell would stay busy — offering the stop key and holding queued - * prompts — for the rest of the session. + * A settled turn hands the session back to the operator — unless a live + * fleet holds it busy (idle-with-fleet, see `settleRunToIdle`). A chat + * session's terminator is `connector.reply`, which maps to no `run` event, + * so without this the shell would stay busy — offering the stop key and + * holding queued prompts — for the rest of the session. */ const settleRun = (): void => { - if (shell.session.run === "idle") return; - shell.session = setRunState(shell.session, "idle"); - drainAtBoundary(shell, bag); + settleRunToIdle(shell, bag); }; const handle = (event: BridgeInboundEvent | ReactorLikeEvent): void => { @@ -1024,6 +1068,8 @@ export function attachSessionBridge( kind: "queue" | "steer" | "immediate" | "reinject", attachments?: readonly PendingImageAttachment[], ): void => { + // A steer is a queued boundary delivery only while the parent turn is + // actually in flight; see `parentIdleWithFleet` below for the exception. if (bag.disposed) return; const t = text.trim(); const attached = attachments ?? []; @@ -1070,7 +1116,17 @@ export function attachSessionBridge( bag.turn = turnStateOnInterrupt(bag.turn, now()); } - if (kind === "immediate" || kind === "reinject" || shell.session.run === "idle") { + // Idle-with-fleet: the parent turn has settled while spawned workers are + // still live, so the run is only nominally busy. Plain Enter is a new + // primary turn right now — not a queued steer waiting on a parent tool + // that no longer exists. + const parentIdleWithFleet = kind === "steer" && bag.liveFleet > 0 && !bag.turn.isProcessing; + if ( + kind === "immediate" || + kind === "reinject" || + shell.session.run === "idle" || + parentIdleWithFleet + ) { appendStreamRow(shell, { role: "user", text: userRowText(t, attached), diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 689db1d83..0a9a40def 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -6024,8 +6024,10 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption const onEnter = (): void => { if (disposed || shell.overlayList) return; if (internals.get(shell)?.inputSuspended === true) return; - // Mid-run Enter soft-steers (deliver at next tool.boundary). Alt+Enter - // is follow-up (quiet wait until idle). Idle sends ignore "kind". + // Mid-run Enter soft-steers (deliver at next tool.boundary); the bridge + // upgrades it to an immediate new turn while the parent is idle with a + // live fleet (idle-with-fleet, CL-7057). Alt+Enter is follow-up (quiet + // wait until idle). Idle sends ignore "kind". submitPrompt(shell, "steer"); }; diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index f8e1173ef..c8c53e85f 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -35,6 +35,14 @@ export type BridgeInboundEvent = } | { readonly type: "system"; readonly text: string } | { readonly type: "run"; readonly state: RunState } + /** + * Live fleet-lane count. Emitted by the runner on every transition of + * the sub-agent store's running-lane count; drives idle-with-fleet — + * the run stays busy after the parent turn settles until this lands + * back at zero (true session-idle), and Enter mid-hold upgrades to a + * new turn rather than a queued steer. + */ + | { readonly type: "fleet"; readonly running: number } | { readonly type: "tool.boundary" } | { readonly type: "error"; readonly message: string } /**