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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

## [Unreleased]

### Fixed

- Dry-fleet transcript and `/status` report the outcome tally only
(`2 done, 1 failed`). They no longer claim `nothing running` when the
parent may still continue.
- The prompt-box lockup stays on while a fleet is live or a dry-fleet
continuation is pending, even if the parent turn has settled. The word
cycles through a closed live-activity set (`working`, `warping`,
`buzzing`, `grinding`, `thinking`, `doing`, `cooking`, `creating`,
`imagining`, `inventing`) instead of going blank.

## [0.3.19] - 2026-09-10

### Security
Expand Down
43 changes: 24 additions & 19 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,16 @@ and `@mention` tokens anywhere paint `UI.action`. Bare skill or agent words
(`implement`, `emil`, `brand review`) stay unstyled, as does a `/review`
that appears mid-prose.

While a turn is live the lockup slot swaps the wordmark for a semantic
activity word — never the raw tool, MCP server, or plugin identifier that is
actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`)
maps execution onto the closed set `ACTIVITY_STATES` exported from that
module (`thinking`, `planning`, `researching`, `building`, `working`,
`waiting`, `stalled`, `stopping`); that export is the source
of truth for what the slot can say, not this list. It is led by a single density cell
While a turn is live — or the session is still occupied by a live fleet
or a pending dry-fleet continuation — the lockup slot swaps the wordmark
for a semantic activity word — never the raw tool, MCP server, or plugin
identifier that is actually executing. Live occupation cycles
`LIVE_ACTIVITY_WORDS` (`working`, `warping`, `buzzing`, `grinding`,
`thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`)
on `LIVE_WORD_MS`; gated turns still read `waiting` or `stopping`.
`ACTIVITY_STATES` exported from the session chrome module is the source
of truth for what the slot can say, not this list. It is led by a single
density cell
(`rampPulse`, `src/tui/ramp.ts`). The cell, not the word,
is what says whether the session is healthy, and it carries four states:

Expand All @@ -120,16 +123,16 @@ printed identically, so the only way to tell them apart was to wait.
waiting on something outside itself — and are told apart by motion: `blocked`
holds perfectly still, which is the signal that the session is waiting on _you_.

While fleet agents are running, the slot reports the _fleet_, not the parent.
`resolveTurnLabel` and `resolveRampPhase` take a `FleetProgress` roll-up and
rank it above the parent's own stall clock: with live lanes the parent is
idle by design, so its silence says nothing about whether the session is
progressing, and reporting it was how a session with every lane wedged still
read as `working`. A fleet with no stalled lane reads `working`; one
stalled lane makes the whole indicator read `stalled`, which is the state that
should pull an operator's eye to the panel. A blocked gate and a stopping turn
still outrank the fleet. With zero running fleet agents the roll-up is empty and
every path through both functions behaves exactly as it does for a plain
While fleet agents are running, the slot stays live even when the parent
turn has settled (idle-with-fleet). `resolveTurnLabel` and
`resolveRampPhase` take a `FleetProgress` roll-up plus session occupancy:
with live lanes the parent is idle by design, so its silence says nothing
about whether the session is progressing, and blanking the lockup made a
busy fleet look hung. Occupied sessions keep cycling a live-activity
word; recovery stays silent rather than painting `stalled`. A blocked
gate and a stopping turn still outrank the fleet. With zero running fleet
agents and no pending continuation the roll-up is empty and every path
through both functions behaves exactly as it does for a plain
single-agent turn.

The stall phase is driven by the watchdog's own silence clock
Expand Down Expand Up @@ -255,8 +258,10 @@ Parent prose owns success narratives. Transcript fleet notices exist only for
attention live spawn_agent rows cannot keep: a lane **failed** or **cancelled**
while other work is still running, and **one** dry-fleet line when the last
lane finishes
(`N done · nothing running`; failed and cancelled counts appear only
when non-zero, e.g. `N done, M failed, K cancelled · nothing running`).
(`N done`; failed and cancelled counts appear only
when non-zero, e.g. `N done, M failed, K cancelled`).
The line does not claim the run is idle — the parent often continues.
The prompt-box lockup is what names that occupation, not this tally.
Per-lane `done — summary` walls and live `dispatched` re-announcements
are never printed. That dry-fleet line stays operator-facing. If tasks
are still todo/doing, the runtime re-enters the parent with collected
Expand Down
18 changes: 8 additions & 10 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("observeFleet", () => {
],
T0 + 1000,
);
expect(updates).toEqual(["2 done · nothing running"]);
expect(updates).toEqual(["2 done"]);
});

test("a failure names what went wrong while the fleet is still live", () => {
Expand Down Expand Up @@ -155,7 +155,7 @@ describe("observeFleet", () => {
: { ...l, status: "failed" as const, error: "boom" },
);
const { updates } = observeFleet(seeded, after, T0 + 1000);
expect(updates).toEqual(["9 done, 3 failed · nothing running"]);
expect(updates).toEqual(["9 done, 3 failed"]);
});

test("a cancelled-only dry fleet counts cancelled, not failed", () => {
Expand All @@ -172,7 +172,7 @@ describe("observeFleet", () => {
],
T0 + 1000,
);
expect(updates).toEqual(["0 done, 2 cancelled · nothing running"]);
expect(updates).toEqual(["0 done, 2 cancelled"]);
});

test("a mixed dry fleet names done, failed, and cancelled separately", () => {
Expand All @@ -190,9 +190,7 @@ describe("observeFleet", () => {
],
T0 + 1000,
);
expect(updates).toEqual([
"1 done, 1 failed, 1 cancelled · nothing running",
]);
expect(updates).toEqual(["1 done, 1 failed, 1 cancelled"]);
});

test("a burst of live cancels coalesces as cancelled, not failed", () => {
Expand Down Expand Up @@ -236,11 +234,11 @@ describe("fleetDigest", () => {
expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed");
});

test("a fleet with nothing left running says so rather than going blank", () => {
test("a dry fleet is the outcome tally, not an idle claim", () => {
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
"nothing running · 1 done",
"1 done",
);
expect(fleetDigest([], T0)).toBe("nothing running");
expect(fleetDigest([], T0)).toBe("");
});

test("cancelled lanes are named separately from failed", () => {
Expand All @@ -252,7 +250,7 @@ describe("fleetDigest", () => {
],
T0,
),
).toBe("nothing running · 1 failed · 1 cancelled");
).toBe("1 failed · 1 cancelled");
});
});

Expand Down
19 changes: 10 additions & 9 deletions src/subagent/fleet-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ type Change =
| { readonly kind: "cancelled"; readonly line: string }
| { readonly kind: "stalled"; readonly line: string };

export interface FleetReportOptions {
readonly stallMs?: number;
}

export interface FleetObservation {
readonly watch: FleetWatch;
/** Ready-to-print lines, already coalesced. Usually empty. */
Expand All @@ -195,8 +199,9 @@ export function observeFleet(
previous: FleetWatch,
lanes: readonly FleetLane[],
nowMs: number,
stallMs: number = DEFAULT_STALL_MS,
options: FleetReportOptions = {},
): FleetObservation {
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
const marks = new Map<string, LaneMark>();
const changes: Change[] = [];
let running = 0;
Expand Down Expand Up @@ -262,9 +267,7 @@ export function observeFleet(
if (wentDry) {
return {
watch,
updates: [
clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS),
],
updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)],
};
}

Expand Down Expand Up @@ -355,14 +358,12 @@ function idleSummary(lanes: readonly FleetLane[]): string {
export function fleetDigest(
lanes: readonly FleetLane[],
nowMs: number,
stallMs: number = DEFAULT_STALL_MS,
options: FleetReportOptions = {},
): string {
if (lanes.length === 0) return "nothing running";
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
const running = lanes.filter((l) => l.status === "running");
const parts: string[] = [];
if (running.length === 0) {
parts.push("nothing running");
} else {
if (running.length > 0) {
const named = running
.slice(0, DIGEST_NAMED_LANES)
.map((lane) => {
Expand Down
1 change: 1 addition & 0 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
pendingAskWakeText,
type FleetLane,
type FleetObservation,
type FleetReportOptions,
type FleetWatch,
type PendingAskWake,
} from "./fleet-report.js";
Expand Down
10 changes: 10 additions & 0 deletions src/tui/commands/built-in.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ describe("/status command", () => {
});
});

it("noops when the live fleet digest is empty so idle /status paints no blank row", () => {
const ctx: CommandContext = {
signalClear: () => undefined,
getFleetStatus: () => "",
};
expect(defined(getCommand("status"), "status").handler("", ctx)).toEqual({
type: "noop",
});
});

it("says so rather than throwing when no fleet source is wired", () => {
expect(
defined(getCommand("status"), "status").handler("", makeCtx()),
Expand Down
3 changes: 3 additions & 0 deletions src/tui/commands/built-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ export function registerBuiltInCommands(): void {
text: "Fleet status is not available in this session.",
};
}
if (status.length === 0) {
return { type: "noop" };
}
return { type: "message", text: status };
},
});
Expand Down
9 changes: 9 additions & 0 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { streamRowCount } from "./shell/transcript";
import { STEER_WAIT_NOTICE_MS } from "./notice-line";
import { withTestRenderer } from "./harness";
import { badgeCount } from "./session-queue";
import { LIVE_ACTIVITY_WORDS } from "./session-chrome";

describe("mapReactorLike", () => {
test("message.received → user", () => {
Expand Down Expand Up @@ -1377,6 +1378,12 @@ describe("idle-with-fleet (CL-7057)", () => {
// 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(shell.lockupPhase).not.toBeNull();
expect(
(LIVE_ACTIVITY_WORDS as readonly string[]).includes(
shell.lockupPhase ?? "",
),
).toBe(true);
expect(badgeCount(shell.session)).toBe(1);
expect(port.calls).toEqual([]);
await h.renderOnce();
Expand Down Expand Up @@ -1616,9 +1623,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
bridge.handle({ type: "fleet", running: 0 });
expect(drives).toBe(0);
expect(shell.session.run).toBe("busy");
expect(shell.lockupPhase).not.toBeNull();
settleToollessTurn(bridge);
expect(drives).toBe(1);
expect(shell.session.run).toBe("busy");
expect(shell.lockupPhase).not.toBeNull();
bridge.submit("when it finishes, summarize", "queue");
expect(badgeCount(shell.session)).toBe(1);
port.clear();
Expand Down
22 changes: 18 additions & 4 deletions src/tui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,11 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
}
}

function occupancyHold(bag: BridgeBag, runBusy: boolean): boolean {
if (bag.liveFleet > 0 || bag.awaitingContinuationInference) return true;
return runBusy && bag.pendingDryOpenDrive;
}

/**
* 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
Expand Down Expand Up @@ -1286,15 +1291,18 @@ export function attachSessionBridge(
status: turn.status,
currentToolName: turn.currentToolName,
streamingType: turn.streamingType,
nowMs,
sessionActive: occupancyHold(bag, shell.session.run === "busy"),
};
const fleet = fleetProgress(bag.agentSessions, nowMs);
const label = resolveTurnLabel(input, isStalled, fleet);
const sessionLive = label !== undefined;
if (label === undefined) {
// The bottom-left status slot rides the same re-entry as the landing
// mark, so it crossfades between phases without a timer of its own.
setLockupFrame(shell, {
nowMs,
animating: turn.isProcessing,
animating: false,
phase: null,
rampPhase: null,
stalledForMs: null,
Expand All @@ -1309,7 +1317,7 @@ export function attachSessionBridge(
const stalledFor = stalledForMs(nowMs, rampPhase === "stalled");
setLockupFrame(shell, {
nowMs,
animating: turn.isProcessing,
animating: sessionLive,
phase: label,
rampPhase,
stalledForMs: stalledFor,
Expand Down Expand Up @@ -1383,13 +1391,19 @@ export function attachSessionBridge(
if (onTurnBoundary(event) && bag.turn.activeToolCalls.length > 0) {
drainLiveSteersAtBoundary(shell, bag);
}
if (settled) settleRun();
if (settled) {
settleRun();
paintPhase();
}
return;
}
if (isBridgeInbound(event)) {
applyInbound(shell, bag, event);
}
if (settled) settleRun();
if (settled) {
settleRun();
paintPhase();
}
};

const recordLastSent = (
Expand Down
Loading
Loading