Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
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) 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
Expand Down
13 changes: 11 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>` (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
Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
26 changes: 25 additions & 1 deletion src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,6 +22,24 @@ function lane(overrides: Partial<FleetLane> & { 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(
Expand Down
9 changes: 9 additions & 0 deletions src/subagent/fleet-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
fleetDigest,
FLEET_REPORT_SETTLE_MS,
FLEET_STALL_POLL_MS,
liveFleetCount,
observeFleet,
type FleetLane,
type FleetObservation,
Expand Down
11 changes: 11 additions & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import {
fleetDigest,
FLEET_REPORT_SETTLE_MS,
FLEET_STALL_POLL_MS,
liveFleetCount,
observeFleet,
taskToolDefinition,
} from "../subagent/index.js";
Expand Down Expand Up @@ -2598,7 +2599,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
for (const update of observation.updates) surfaceSystemNotice(host.shell, update);
};
let fleetSettle: ReturnType<typeof setTimeout> | 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;
Expand Down
Loading
Loading