Skip to content

Commit ec915f8

Browse files
committed
Merge TUI delivery queue trio and inline micro-module barrels
1 parent 63d2989 commit ec915f8

53 files changed

Lines changed: 473 additions & 466 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
8282
- Wires `ask_operator` to an operator-gate event resolved by a modal
8383
- Mounts the OpenTUI host via `mountRunnerHost` (`src/tui/runner/host.ts`), which mounts `mountProductHost` (`src/tui/product-host.ts`) over the shell (`src/tui/shell/`)
8484
- Bridges reactor events to the OpenTUI host via a plain `EventEmitter`
85-
- **Mid-run injection** — Shell `session-queue` items drain at the parent
85+
- **Mid-run injection** — Shell `delivery-queue` items drain at the parent
8686
`tool.boundary` through `SessionPort.deliver`. Production `routeQueuedDelivery`
8787
live-injects in-flight parent-boundary steers via `agentProxy.deliver`
8888
(`Agent.deliver`) into one captured agent identity — never re-reading
@@ -220,7 +220,7 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source (
220220

221221
Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it.
222222

223-
When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture.
223+
When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through delivery-queue's idle-send path without entering the user follow-up queue or composer `/feedback` capture.
224224

225225
When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.
226226

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ the directors guard on; the full set of reactor and stream event types is
463463
treat that as canonical rather than this section or any other doc's partial
464464
list.
465465

466-
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.
466+
Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/delivery-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.
467467

468468
### Lifecycle Hooks
469469

docs/TUI.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ row at a time down to its 3-row base — never the transcript
3434
(`COLLAPSE_ORDER` in `zones.ts`).
3535

3636
Horizontally, every surface sits inside one shared gutter
37-
(`resolveSideMargin`, `src/tui/geometry/margins.ts`) so the shell reads
37+
(`resolveSideMargin`, `src/tui/geometry/zones.ts`) so the shell reads
3838
as a single column of content rather than stacked panes.
3939
`resolveGeometry` always returns `layoutMode: "stack"` — full-width
4040
y-stack, no dual-column rail. Live workers paint in the agents strip
@@ -711,7 +711,7 @@ thing they were trying to deliver. It now reports `interrupt — N pending
711711
kept`: the run stops, the queue survives, and those messages are handed over
712712
at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not
713713
left waiting on an idle event the stop may never produce (`interrupt` in
714-
`session-queue.ts` no longer clears `items`).
714+
`delivery-queue.ts` no longer clears `items`).
715715

716716
**Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up
717717
(queued drain) leave running workers alone — they never call

src/tui/agent-ask-wake.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import { classifySubmission, createSubmitHandler } from "./runner/submit.js";
88
import {
99
createDeliveryGeneration,
1010
createLeftoverSend,
11+
createSessionOperationQueue,
1112
routeQueuedDelivery,
12-
} from "./queued-delivery.js";
13-
import { createSessionOperationQueue } from "./session-operation-queue.js";
13+
} from "./delivery-queue.js";
1414
import { ingestOperatorPrompt } from "./prompt-attachments.js";
1515
import {
1616
armFeedbackCapture,

src/tui/collapse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { describe, expect, test } from "bun:test";
77
import { defined } from "../../tests/helpers/defined.js";
88
import { toolCallRow } from "./diff";
9-
import { resolveSideMargin } from "./geometry/margins";
9+
import { resolveSideMargin } from "./geometry/zones";
1010
import { withTestRenderer } from "./harness";
1111
import { pushToolCall, pushToolResult } from "./tool-rows";
1212
import {
Lines changed: 254 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,257 @@
1+
/**
2+
* Delivery queue: mid-run queue / steer / interrupt state machine, the serial
3+
* operation chain that drains it, and the generation-gated delivery hops.
4+
*
5+
* One module: `session-queue.ts` (pure item state), `session-operation-queue.ts`
6+
* (serial promise chain), and `queued-delivery.ts` (kind routing + delivery
7+
* hops) were three slices of the same drain pipeline. No behavior change —
8+
* sections below are verbatim moves.
9+
*
10+
* Mid-run queue / steer / interrupt state machine (interaction contract §3).
11+
* Pure data — no paint, no OpenTUI. Shell + demo own delivery and UI flash.
12+
*
13+
* Product chords (CL-6290):
14+
* - Enter mid-run → kind "steer" (soft steer; drain at tool.boundary)
15+
* - Alt+Enter mid-run → kind "queue" (follow-up; drain only when run goes idle)
16+
* Internal "reinject" is a separate bridge/shell submit kind, not a QueueKind,
17+
* and no product chord wires it anymore — leave the path for tests/API only.
18+
*/
19+
20+
import type { PendingImageAttachment } from "./image-attachments.js";
21+
import type { AgentDeliveryResult } from "./deliver-agent-message.js";
22+
import type { ProductHostDeliver } from "./product-host.js";
23+
import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js";
24+
import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js";
25+
26+
export type QueueKind = "queue" | "steer";
27+
28+
export interface QueueItem {
29+
readonly id: string;
30+
readonly text: string;
31+
readonly kind: QueueKind;
32+
readonly enqueuedAt: number;
33+
/** Images attached to this message, delivered with it at the boundary. */
34+
readonly attachments?: readonly PendingImageAttachment[];
35+
}
36+
37+
export type RunState = "idle" | "busy";
38+
39+
export interface SessionQueueState {
40+
readonly run: RunState;
41+
readonly items: readonly QueueItem[];
42+
/** True after interrupt until consumer clears (status flash). */
43+
readonly interruptFlash: boolean;
44+
/** Monotonic id seed for queue items. */
45+
readonly nextId: number;
46+
}
47+
48+
export function createSessionQueue(run: RunState = "idle"): SessionQueueState {
49+
return {
50+
run,
51+
items: [],
52+
interruptFlash: false,
53+
nextId: 1,
54+
};
55+
}
56+
57+
/** Pending badge count (queue + steer share one pool for depth totals). */
58+
export function badgeCount(state: SessionQueueState): number {
59+
return state.items.length;
60+
}
61+
62+
/** Soft-steer pending count (Enter mid-run). */
63+
export function steerCount(state: SessionQueueState): number {
64+
return state.items.filter((i) => i.kind === "steer").length;
65+
}
66+
67+
/** Follow-up pending count (Alt+Enter mid-run). */
68+
export function queueCount(state: SessionQueueState): number {
69+
return state.items.filter((i) => i.kind === "queue").length;
70+
}
71+
72+
export function setRunState(
73+
state: SessionQueueState,
74+
run: RunState,
75+
): SessionQueueState {
76+
if (state.run === run) return state;
77+
return { ...state, run };
78+
}
79+
80+
/**
81+
* Enqueue a mid-run message. Empty / whitespace-only is a no-op.
82+
* When idle, still accepts into the queue bag for tests; product shell
83+
* may route idle Enter as immediate send instead of calling this.
84+
*/
85+
export function enqueue(
86+
state: SessionQueueState,
87+
text: string,
88+
kind: QueueKind = "queue",
89+
now = Date.now(),
90+
attachments?: readonly PendingImageAttachment[],
91+
): SessionQueueState {
92+
const t = text.trim();
93+
if (
94+
t.length === 0 &&
95+
(attachments === undefined || attachments.length === 0)
96+
) {
97+
return state;
98+
}
99+
const item: QueueItem = {
100+
id: `q${state.nextId}`,
101+
text: t,
102+
kind,
103+
enqueuedAt: now,
104+
...(attachments !== undefined && attachments.length > 0
105+
? { attachments }
106+
: {}),
107+
};
108+
return {
109+
...state,
110+
items: [...state.items, item],
111+
nextId: state.nextId + 1,
112+
interruptFlash: false,
113+
};
114+
}
115+
116+
/** Steer = priority enqueue (same badge pool). */
117+
export function enqueueSteer(
118+
state: SessionQueueState,
119+
text: string,
120+
now = Date.now(),
121+
attachments?: readonly PendingImageAttachment[],
122+
): SessionQueueState {
123+
return enqueue(state, text, "steer", now, attachments);
124+
}
125+
126+
/**
127+
* Hard interrupt: stop the run, keep everything the operator queued. Typing a
128+
* correction and then interrupting so it lands sooner is the common shape of
129+
* this gesture, so discarding the queue destroyed exactly the input the
130+
* operator most wanted delivered. Pending items survive to the next drain
131+
* boundary; only the run state and the flash change here.
132+
*/
133+
export function interrupt(state: SessionQueueState): SessionQueueState {
134+
return {
135+
...state,
136+
run: "idle",
137+
interruptFlash: true,
138+
};
139+
}
140+
141+
export function clearInterruptFlash(
142+
state: SessionQueueState,
143+
): SessionQueueState {
144+
if (!state.interruptFlash) return state;
145+
return { ...state, interruptFlash: false };
146+
}
147+
148+
/**
149+
* Retract the most recently enqueued item, queue or steer alike. Last-only:
150+
* an operator who wants an earlier item gone has no path here (see
151+
* `applyShellCancelLast` for why that is the shipped scope, not an oversight).
152+
*/
153+
export function cancelLast(state: SessionQueueState): {
154+
state: SessionQueueState;
155+
item: QueueItem | null;
156+
} {
157+
const item = state.items[state.items.length - 1] ?? null;
158+
if (item === null) return { state, item: null };
159+
return {
160+
state: { ...state, items: state.items.slice(0, -1) },
161+
item,
162+
};
163+
}
164+
165+
/**
166+
* Retract a specific item by id — the pending column's per-row drop, where the
167+
* operator picked exactly which held message to kill rather than the newest.
168+
*/
169+
export function cancelItem(
170+
state: SessionQueueState,
171+
id: string,
172+
): { state: SessionQueueState; item: QueueItem | null } {
173+
const index = state.items.findIndex((item) => item.id === id);
174+
const item = state.items[index] ?? null;
175+
if (item === null) return { state, item: null };
176+
return {
177+
state: {
178+
...state,
179+
items: [...state.items.slice(0, index), ...state.items.slice(index + 1)],
180+
},
181+
item,
182+
};
183+
}
184+
185+
/** Drain order: steers first (FIFO within class), then queue (FIFO). */
186+
export function drainOrder(state: SessionQueueState): readonly QueueItem[] {
187+
const steers = state.items.filter((i) => i.kind === "steer");
188+
const queues = state.items.filter((i) => i.kind === "queue");
189+
return [...steers, ...queues];
190+
}
191+
192+
/**
193+
* Pop next delivery item. When `kind` is set, only that class (FIFO within
194+
* class); otherwise full `drainOrder` (steer-first, then queue).
195+
*/
196+
export function drainOne(
197+
state: SessionQueueState,
198+
kind?: QueueKind,
199+
): { state: SessionQueueState; item: QueueItem | null } {
200+
const order =
201+
kind === undefined
202+
? drainOrder(state)
203+
: state.items.filter((i) => i.kind === kind);
204+
const item = order[0] ?? null;
205+
if (!item) return { state, item: null };
206+
return {
207+
state: {
208+
...state,
209+
items: state.items.filter((i) => i.id !== item.id),
210+
},
211+
item,
212+
};
213+
}
214+
215+
/** Drain every pending soft-steer; leave follow-ups untouched. */
216+
export function drainSteersOnly(state: SessionQueueState): {
217+
state: SessionQueueState;
218+
drained: readonly QueueItem[];
219+
} {
220+
const drained: QueueItem[] = [];
221+
let current = state;
222+
for (;;) {
223+
const next = drainOne(current, "steer");
224+
if (!next.item) break;
225+
drained.push(next.item);
226+
current = next.state;
227+
}
228+
return { state: current, drained };
229+
}
230+
231+
// Serial promise chain for session-scoped operations (reload, interrupt, deliver).
232+
// Each task runs after the previous one settles; failures do not block the tail.
233+
234+
export interface SessionOperationQueue {
235+
/** Enqueue an async operation; returns a promise for this operation's settlement. */
236+
enqueue: (op: () => Promise<void>) => Promise<void>;
237+
/** Await the tail of the queue (all prior operations finished or failed). */
238+
awaitTail: () => Promise<void>;
239+
}
240+
241+
export function createSessionOperationQueue(): SessionOperationQueue {
242+
let tail: Promise<void> = Promise.resolve();
243+
244+
const enqueue = (op: () => Promise<void>): Promise<void> => {
245+
tail = tail.then(op, op);
246+
return tail;
247+
};
248+
249+
return {
250+
enqueue,
251+
awaitTail: () => tail.catch(() => undefined),
252+
};
253+
}
254+
1255
/**
2256
* Kind routing for drained queue items, plus a generation token so a
3257
* /clear|/new rotation can drop in-flight delivers that belonged to the
@@ -7,11 +261,6 @@
7261
* parent tool.boundary. Leftover steers at idle, idle-with-fleet, or
8262
* post-interrupt share the send path (sendQueue, inFlight, token refresh).
9263
*/
10-
import type { PendingImageAttachment } from "./image-attachments.js";
11-
import type { AgentDeliveryResult } from "./deliver-agent-message.js";
12-
import type { ProductHostDeliver } from "./product-host.js";
13-
import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js";
14-
import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js";
15264

16265
export type DeliverySettle = (result: AgentDeliveryResult) => void;
17266
type MaybeAsyncDeliveryResult =

src/tui/gate-wire.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from "./shell/overlay-list.js";
2323
import { streamRowGutter } from "./stream.js";
2424
import { APPROVAL_UNAVAILABLE_MESSAGE } from "./gate-events.js";
25-
import { SESSION_IDENTITY_ABORT_REASON } from "./queued-delivery.js";
25+
import { SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue.js";
2626
import {
2727
approvalOutcomeFromSelection,
2828
operatorCancelResult,

src/tui/geometry/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export {
2020
zoneDeclaration,
2121
type ZoneDeclaration,
2222
type ZoneId,
23+
promptBoxCapRows,
24+
promptBoxRows,
25+
promptInputRows,
26+
promptIsScrolling,
2327
} from "./zones.js";
2428

2529
export {
@@ -33,7 +37,7 @@ export {
3337
resolveContentWidth,
3438
resolveSideMargin,
3539
resolveTopPadRows,
36-
} from "./margins.js";
40+
} from "./zones.js";
3741

3842
export {
3943
desiredHeights,

0 commit comments

Comments
 (0)