pi-legion 5.0.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/CONTRIBUTING.md +33 -2
  3. package/README.md +10 -5
  4. package/agents/centurion.md +32 -7
  5. package/agents/scout.md +11 -0
  6. package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
  7. package/docs/contracts/centurion-declared-phases-v1.md +104 -0
  8. package/docs/contracts/declarative-phase-machine-v5.md +43 -0
  9. package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
  10. package/docs/contracts/finished-cache-eviction-v1.md +169 -0
  11. package/docs/contracts/galaxy-scratch-v1.md +78 -0
  12. package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
  13. package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
  14. package/docs/contracts/legion-page-floor-v1.md +129 -0
  15. package/docs/contracts/legion-session-continuity-v1.md +118 -0
  16. package/docs/contracts/mailbox-delivery-v1.md +162 -0
  17. package/docs/contracts/notice-claim-v1.md +206 -0
  18. package/docs/contracts/partial-failure-exit-v1.md +235 -0
  19. package/docs/contracts/proc-sweep-v1.md +107 -0
  20. package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
  21. package/docs/contracts/stall-visibility-v1.md +218 -0
  22. package/docs/contracts/subagent-renewal-v1.md +5 -3
  23. package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
  24. package/docs/contracts/viz-frame-alloc-v1.md +123 -0
  25. package/docs/contracts/wait-mail-preempt-v1.md +117 -0
  26. package/docs/contracts/wait-not-stop.md +1 -1
  27. package/docs/design.md +4 -1
  28. package/docs/inprocess-supervisor.md +9 -3
  29. package/docs/legion-v2-contract.md +122 -20
  30. package/docs/vexillatio-contract.md +5 -1
  31. package/index.ts +1903 -130
  32. package/package.json +5 -5
  33. package/skills/pi-legion/SKILL.md +2 -1
  34. package/src/agents/agents.ts +26 -1
  35. package/src/agents/settings.ts +67 -2
  36. package/src/api.ts +17 -0
  37. package/src/extension/child-guard.ts +2 -1
  38. package/src/extension/join.ts +54 -0
  39. package/src/extension/legion-commands.ts +15 -4
  40. package/src/extension/legion-doctor.ts +48 -6
  41. package/src/extension/legion-status.ts +44 -2
  42. package/src/extension/legion-tools.ts +1044 -109
  43. package/src/extension/notify.ts +31 -2
  44. package/src/extension/playbook.ts +6 -3
  45. package/src/extension/runtime.ts +882 -81
  46. package/src/extension/spawn-hints.ts +52 -12
  47. package/src/extension/summary.ts +7 -15
  48. package/src/extension/timing.ts +4 -15
  49. package/src/legion/db.ts +23 -1
  50. package/src/legion/events.ts +7 -0
  51. package/src/legion/interrupt.ts +98 -0
  52. package/src/legion/mail-release.ts +266 -0
  53. package/src/legion/mail.ts +456 -29
  54. package/src/legion/nodes.ts +31 -0
  55. package/src/legion/phase-conditions.ts +7 -1
  56. package/src/legion/phase-machine.ts +160 -8
  57. package/src/legion/state-machine.ts +34 -2
  58. package/src/runs/acceptance.ts +105 -6
  59. package/src/runs/args.ts +16 -0
  60. package/src/runs/child-scope.ts +323 -0
  61. package/src/runs/extension-inheritance.ts +145 -19
  62. package/src/runs/orchestrator.ts +613 -55
  63. package/src/runs/store.ts +149 -5
  64. package/src/shared/proc-sweep.ts +317 -0
  65. package/src/shared/progress.ts +11 -2
  66. package/src/shared/session-map.ts +29 -0
  67. package/src/shared/session.ts +308 -6
  68. package/src/shared/types.ts +34 -0
  69. package/src/supervisor/inprocess-supervisor.ts +158 -12
  70. package/src/supervisor/race-timeout.ts +55 -0
  71. package/src/supervisor/routing-supervisor.ts +55 -4
  72. package/src/supervisor/rpc-supervisor.ts +215 -29
  73. package/src/supervisor/types.ts +439 -2
  74. package/src/tui/status.ts +2 -1
  75. package/src/tui/viz/canvas.ts +358 -36
  76. package/src/tui/viz/palette.ts +18 -2
  77. package/src/tui/viz/panels.ts +507 -104
  78. package/src/tui/viz/rate.ts +120 -15
  79. package/src/tui/viz/view.ts +72 -4
@@ -90,19 +90,451 @@ export interface UsageSnapshot {
90
90
  costUsd: number;
91
91
  }
92
92
 
93
+ /**
94
+ * What a `steer` call actually achieved (#313).
95
+ *
96
+ * - `"queued"`: the text sits behind the child's in-flight work and is consumed
97
+ * at its next turn boundary — the model has not seen it yet.
98
+ * - `"accepted"`: the call started a turn with the text (the child was idle, so
99
+ * there was no queue to wait in). Still not proof the model read it.
100
+ * - `"intercepted"`: an input handler consumed the text before it was ever
101
+ * queued — the child never saw it.
102
+ *
103
+ * There is deliberately no `"delivered"`: no seam observes the model reading the
104
+ * text, so any such claim would be invented.
105
+ */
106
+ export type SteerDisposition = "queued" | "accepted" | "intercepted";
107
+
108
+ /**
109
+ * Normalize the SDK's disposition vocabulary into {@link SteerDisposition}
110
+ * (#313 D2). The only place this mapping happens.
111
+ *
112
+ * `"handled"` is the SDK's *interception* signal, never delivery:
113
+ * `_queueUserInput` returns it **before** `_queueSteer` runs
114
+ * (`dist/core/agent-session.js:1694-1697`), so an input handler swallowed the
115
+ * text and the child never saw it. The RPC client says the same from the other
116
+ * side: `dist/modes/rpc/rpc-client.js:133` — "If the disposition is 'handled',
117
+ * no run started for this prompt".
118
+ *
119
+ * Everything else — `"queued"`, `undefined`, a backend that returns junk, an
120
+ * older SDK that returns nothing — means the text went in. `route` only decides
121
+ * what "went in" is called, and only for a child that was idle: a steer has
122
+ * nowhere to go but the queue (`"queued"`), while a `route` of `"prompt"` means
123
+ * `prompt()` returned without throwing, i.e. the session accepted a turn with
124
+ * this text (`"accepted"`) — `"handled"` is the only "no turn started" signal
125
+ * a prompt can produce.
126
+ */
127
+ export function toSteerDisposition(
128
+ raw: unknown,
129
+ route: "steer" | "prompt",
130
+ ): SteerDisposition {
131
+ if (raw === "handled") return "intercepted";
132
+ return route === "prompt" ? "accepted" : "queued";
133
+ }
134
+
135
+ /**
136
+ * What an `abort` request actually achieved (#314 D1).
137
+ *
138
+ * Three honest states, not a boolean:
139
+ *
140
+ * - `"interrupted"`: a turn was running and the interrupt request went through.
141
+ * - `"no-turn"`: nothing was running, so nothing was done — the queue was not
142
+ * touched either, and `cleared` is all zeros.
143
+ * - `"timeout"`: the request went out but the child did not settle within the
144
+ * caller's bound. It kills nothing: no retire, no state rewrite.
145
+ *
146
+ * `cleared` is the count the preceding queue clear reported (`steering` /
147
+ * `followUp` message counts), not a claim about what the model read.
148
+ *
149
+ * `timeoutMs` is the bound this call applied — or, for `no-turn`, the bound it
150
+ * WOULD have applied: that status returns before either step arms a timer, so
151
+ * nothing was bounded at all (#325). The number itself is the caller's
152
+ * `timeoutMs` after the junk fallback and the timer-delay cap, or
153
+ * `DEFAULT_ABORT_TIMEOUT_MS` when the caller passed nothing. It travels in the
154
+ * outcome because the receipt has to name the wait it is talking about, and the
155
+ * tool surface never passes a bound of its own — a receipt reading a default of
156
+ * its own would report a number nobody used. It is reported for all three
157
+ * statuses (normalization happens before the `turnActive` check), so a reader
158
+ * never has to wonder whether the field is present, only whether a timer ran.
159
+ */
160
+ export type AbortOutcome = {
161
+ status: "interrupted" | "no-turn" | "timeout";
162
+ cleared: { steering: number; followUp: number };
163
+ /** The bound this call applied, or would have applied on `no-turn` (see above). */
164
+ timeoutMs: number;
165
+ };
166
+
167
+ /**
168
+ * Bound for one `abort` (#314 D2), applied by both backends.
169
+ *
170
+ * The tool-surface receipt does NOT read it any more: it formats the bound the
171
+ * backend reported in `AbortOutcome.timeoutMs` (#325), so this constant stays the
172
+ * single source of the default bound.
173
+ *
174
+ * It bounds the pre-abort queue clear AND the interrupt that follows it
175
+ * (#314 D4), so a child that answers neither can delay the caller by at most
176
+ * about twice this — never by the RPC client's own request timeout.
177
+ */
178
+ export const DEFAULT_ABORT_TIMEOUT_MS = 5_000;
179
+
180
+ /**
181
+ * Largest delay `setTimeout` honours: 2^31 − 1 ms (~24.8 days).
182
+ *
183
+ * Node does not saturate an out-of-range delay, it **collapses it to 1 ms**
184
+ * (`TimeoutOverflowWarning: … does not fit into a 32-bit signed integer`), so an
185
+ * over-large `timeoutMs` would reproduce exactly the fake instant `"timeout"`
186
+ * the abort normalization exists to prevent (#314 D2). Both backends therefore
187
+ * clamp their bound to this value instead of trusting the caller's number.
188
+ */
189
+ export const MAX_ABORT_TIMER_DELAY_MS = 2_147_483_647;
190
+
191
+ /**
192
+ * Count what a queue clear dropped (#314 D2): read `{steering, followUp}` array
193
+ * lengths off whatever the backend's `clearQueue` returned. Anything else — a
194
+ * throw or a clear that never answers is handled by the caller — counts 0.
195
+ *
196
+ * Both backends call this so the two never disagree about the same payload, and
197
+ * every call allocates, so no caller can mutate another's counts.
198
+ */
199
+ export function countClearedQueue(raw: unknown): {
200
+ steering: number;
201
+ followUp: number;
202
+ } {
203
+ const record =
204
+ typeof raw === "object" && raw !== null
205
+ ? (raw as { steering?: unknown; followUp?: unknown })
206
+ : undefined;
207
+ return {
208
+ steering: Array.isArray(record?.steering) ? record.steering.length : 0,
209
+ followUp: Array.isArray(record?.followUp) ? record.followUp.length : 0,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * A tool call a child is inside right now (#315).
215
+ *
216
+ * `startedAt` is the HOST clock reading taken when the child's own
217
+ * `tool_execution_start` event arrived. Elapsed time is always
218
+ * `Date.now() - startedAt`, i.e. one clock family measuring an age — never two
219
+ * writers' timestamps arbitrated against each other (display-liveness D3).
220
+ */
221
+ export interface ChildActivity {
222
+ /** Tool name exactly as the child's event stream reported it. */
223
+ readonly tool: string;
224
+ /** Host `Date.now()` when the start event arrived. */
225
+ readonly startedAt: number;
226
+ /**
227
+ * Tool call id, when the child's event carried one. Used for exactly one
228
+ * thing: matching the end event to the start it belongs to, so a parallel
229
+ * call finishing cannot clear a different call still running.
230
+ */
231
+ readonly toolCallId?: string;
232
+ /**
233
+ * The call that made this one, when the child's event stream says so
234
+ * (`codemode` → `bash` sets it). It is what tells a nested call apart from a
235
+ * parallel sibling: without it, stack depth alone cannot say whether the
236
+ * extra frames are *inside* the top call or merely *beside* it (#331).
237
+ */
238
+ readonly parentToolCallId?: string;
239
+ }
240
+
241
+ /**
242
+ * The in-flight call stack for one child (#331).
243
+ *
244
+ * Oldest first, **innermost last**: `tool_execution_start` pushes, the matching
245
+ * `tool_execution_end` removes only its own frame, `agent_settled` empties the
246
+ * whole stack. The display reads the top (last) entry as "the tool it is inside
247
+ * right now"; every other frame is context (nesting / parallel siblings).
248
+ */
249
+ export type InFlightToolStack = readonly ChildActivity[];
250
+
251
+ /**
252
+ * A single tool call running at or past this bound gets a display-only marker
253
+ * (#315).
254
+ *
255
+ * Five minutes is chosen against the observable alternative: a child that stops
256
+ * making progress is currently invisible until its `collect` deadline (900s by
257
+ * default), because liveness calls any child holding a non-terminal handle
258
+ * alive. This bound fires well before that deadline, and it costs nothing but a
259
+ * string — it decides nothing and kills nothing.
260
+ */
261
+ export const STALL_NOTICE_MS = 300_000;
262
+
263
+ /**
264
+ * `451_000ms` → `00:07:31`. Hours are not capped, so an all-day call reads
265
+ * `30:00:00` instead of silently wrapping to `06:00:00`.
266
+ *
267
+ * A non-finite input is floored to the zero clock. `Math.max(0, Math.floor(NaN/1000))`
268
+ * is still `NaN`, which would render the readout as `NaN:NaN:NaN` — a defect that
269
+ * looks like data instead of a missing fact. Both clocks on this path are
270
+ * `Date.now()` readings, so this is belt-and-braces, not a live case.
271
+ */
272
+ export function formatToolElapsed(elapsedMs: number): string {
273
+ const totalSecs = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0;
274
+ const pad = (value: number): string => String(value).padStart(2, "0");
275
+ const hours = pad(Math.floor(totalSecs / 3600));
276
+ const minutes = pad(Math.floor((totalSecs % 3600) / 60));
277
+ return `${hours}:${minutes}:${pad(totalSecs % 60)}`;
278
+ }
279
+
280
+ /**
281
+ * The one-line activity readout for a child that is inside tool call(s) (#315,
282
+ * #331): `working · bash 00:07:31`, plus context hints and a marker once the
283
+ * top call has run for {@link STALL_NOTICE_MS}.
284
+ *
285
+ * The `working` prefix comes from the activity fact itself, not from the
286
+ * persisted child record: a start event with no matching end IS a child inside
287
+ * a tool call, while the record's `state` is only rewritten at turn boundaries
288
+ * and can lag (or read `retired` from a stale snapshot).
289
+ *
290
+ * The stack is LIFO (#331): the **last** element is the innermost call — the one
291
+ * the elapsed clock and the tool name come from. The remaining frames are
292
+ * context, reported only when the events name their parent:
293
+ *
294
+ * - frames that the top names (directly or transitively) through
295
+ * `parentToolCallId` are nesting → ` (inside <outermost> ×<depth>)`;
296
+ * - frames that are NOT on that chain are parallel siblings → ` (+N more)`.
297
+ *
298
+ * With no `parentToolCallId` anywhere, a second frame cannot be told apart from
299
+ * a nested one, so it is reported as a sibling (` (+N more)`): that is the
300
+ * weaker, always-true claim. Inventing `inside` would be a false one.
301
+ *
302
+ * **Display only.** This builds a string. It does not decide `liveness()`, does
303
+ * not abort the call, and does not steer, retire or report anything — the
304
+ * marker names `abort` as the lever precisely because `steer` cannot reach a
305
+ * child that is already inside a tool call (#313).
306
+ *
307
+ * An empty stack renders the empty string: no fact, no line.
308
+ */
309
+ export function formatInFlightToolLine(
310
+ stack: InFlightToolStack,
311
+ now: number,
312
+ ): string {
313
+ const activity = stack[stack.length - 1];
314
+ if (activity === undefined) return "";
315
+ // Same non-finite trap as `formatToolElapsed`, one step earlier: `Math.max(0,
316
+ // NaN)` is `NaN`, and `NaN < STALL_NOTICE_MS` is false, so an unusable clock
317
+ // would skip the short path and claim “no progress for NaNm”. Both clocks are
318
+ // `Date.now()` readings (belt-and-braces, not a live case) — but the marker is
319
+ // a claim, so an unusable clock must render as zero, never as a verdict.
320
+ const raw = now - activity.startedAt;
321
+ const elapsed = Number.isFinite(raw) ? Math.max(0, raw) : 0;
322
+ const chain = nestingChain(stack);
323
+ const hints: string[] = [];
324
+ if (chain.length > 1) {
325
+ // The outermost frame of the chain is the one that encloses everything the
326
+ // line is about; `×N` is the nesting depth it took to get to the top.
327
+ hints.push(` (inside ${chain[0]!.tool} ×${chain.length})`);
328
+ }
329
+ const siblings = stack.length - chain.length;
330
+ if (siblings > 0) hints.push(` (+${siblings} more)`);
331
+ const line = `working · ${activity.tool} ${formatToolElapsed(elapsed)}${hints.join("")}`;
332
+ if (elapsed < STALL_NOTICE_MS) return line;
333
+ const minutes = Math.floor(elapsed / 60_000);
334
+ return `${line} (long-running tool — no progress for ${minutes}m; \`steer\` cannot interrupt it, \`abort\` can)`;
335
+ }
336
+
337
+ /**
338
+ * The nesting chain that ends at the stack top (#331), outermost first.
339
+ *
340
+ * A frame is an ancestor when the current frame names it through
341
+ * `parentToolCallId`. The walk only looks *backwards* (a parent always started
342
+ * earlier) and visits each index at most once, so cyclic junk in an event
343
+ * stream cannot loop here. No parent links → `[top]`. The fallback exists
344
+ * because ids are optional on both sides of the event stream.
345
+ */
346
+ function nestingChain(stack: InFlightToolStack): InFlightToolStack {
347
+ const chain: ChildActivity[] = [];
348
+ let index = stack.length - 1;
349
+ if (index < 0) return chain;
350
+ const seen = new Set<number>();
351
+ while (index >= 0 && !seen.has(index)) {
352
+ seen.add(index);
353
+ const current = stack[index]!;
354
+ chain.unshift(current);
355
+ const parentId = current.parentToolCallId;
356
+ if (parentId === undefined) break;
357
+ let parentIndex = -1;
358
+ for (let candidate = index - 1; candidate >= 0; candidate -= 1) {
359
+ if (stack[candidate]!.toolCallId === parentId) {
360
+ parentIndex = candidate;
361
+ break;
362
+ }
363
+ }
364
+ if (parentIndex < 0) break;
365
+ index = parentIndex;
366
+ }
367
+ return chain;
368
+ }
369
+
370
+ /**
371
+ * Fold one child lifecycle event into the in-flight-tool stack (#315, #331).
372
+ *
373
+ * Pure and total: an event that says nothing about tool boundaries returns
374
+ * `current` unchanged, so both backends can call this on every event without
375
+ * having to classify first.
376
+ *
377
+ * - `tool_execution_start` pushes a frame (a name-less start is ignored: no
378
+ * invented tool);
379
+ * - `tool_execution_end` removes **only** the frame it names, by `toolCallId` —
380
+ * never the whole stack. An inner `bash` ending inside `codemode` leaves the
381
+ * outer frame running, and a parallel sibling finishing cannot erase the one
382
+ * still in flight;
383
+ * - `agent_settled` empties the stack: a settled turn holds no tool, and an
384
+ * aborted tool may never send its `end` at all.
385
+ *
386
+ * An end whose id matches no frame falls back to the topmost frame without an
387
+ * id (ids are optional on both sides), and otherwise changes nothing: trusting
388
+ * an unknown id would let a stranger's end erase a live call.
389
+ */
390
+ export function nextInFlightTool(
391
+ current: InFlightToolStack,
392
+ event: {
393
+ readonly type?: unknown;
394
+ readonly toolName?: unknown;
395
+ readonly toolCallId?: unknown;
396
+ readonly parentToolCallId?: unknown;
397
+ },
398
+ now: number,
399
+ ): InFlightToolStack {
400
+ if (event.type === "agent_settled") return [];
401
+ if (event.type === "tool_execution_start") {
402
+ if (typeof event.toolName !== "string" || event.toolName === "") {
403
+ return current;
404
+ }
405
+ return [
406
+ ...current,
407
+ {
408
+ tool: event.toolName,
409
+ startedAt: now,
410
+ ...(typeof event.toolCallId === "string"
411
+ ? { toolCallId: event.toolCallId }
412
+ : {}),
413
+ ...(typeof event.parentToolCallId === "string"
414
+ ? { parentToolCallId: event.parentToolCallId }
415
+ : {}),
416
+ },
417
+ ];
418
+ }
419
+ if (event.type === "tool_execution_end") {
420
+ if (current.length === 0) return current;
421
+ const ended =
422
+ typeof event.toolCallId === "string" ? event.toolCallId : undefined;
423
+ // An end that cannot name its call is taken at face value: pop the top
424
+ // (innermost) frame, which is what a LIFO stream implies.
425
+ if (ended === undefined) return current.slice(0, -1);
426
+ for (let index = current.length - 1; index >= 0; index -= 1) {
427
+ if (current[index]!.toolCallId === ended) {
428
+ return [...current.slice(0, index), ...current.slice(index + 1)];
429
+ }
430
+ }
431
+ // No frame claims that id. The only frame it could still be is one that
432
+ // never got an id; an all-named stack is left untouched.
433
+ for (let index = current.length - 1; index >= 0; index -= 1) {
434
+ if (current[index]!.toolCallId === undefined) {
435
+ return [...current.slice(0, index), ...current.slice(index + 1)];
436
+ }
437
+ }
438
+ return current;
439
+ }
440
+ return current;
441
+ }
442
+
93
443
  /** The orchestrator's frozen backend seam (legion-v2-contract.md §3.1). */
94
444
  export interface LegionSupervisor {
95
445
  spawnChild(input: SpawnInput): Promise<ChildHandle>;
96
446
  prompt(name: string, text: string): Promise<void>;
97
- steer(name: string, text: string): Promise<void>;
447
+ /**
448
+ * Steer a running child (#313): the returned disposition says what happened
449
+ * to the text — `"queued"` behind its current tool batch, `"accepted"` as a
450
+ * turn this call started, or `"intercepted"` by an input handler (the child
451
+ * never saw it). Callers that build a receipt must report it.
452
+ */
453
+ steer(name: string, text: string): Promise<SteerDisposition>;
98
454
  followUp(name: string, text: string): Promise<void>;
99
- abort(name: string): Promise<void>;
455
+ /**
456
+ * Soft interrupt (#314): drop the child's queued steering/follow-up messages
457
+ * FIRST, then interrupt the running turn, and wait a bounded time for it.
458
+ *
459
+ * Frozen defaults (#314 D2): `clearQueued` is **true** and `timeoutMs` is
460
+ * `DEFAULT_ABORT_TIMEOUT_MS`. The bound covers the clear as well as the
461
+ * interrupt, and a timeout never throws (#314 D4) — it returns
462
+ * `status:"timeout"` and kills nothing.
463
+ */
464
+ abort(
465
+ name: string,
466
+ opts?: { clearQueued?: boolean; timeoutMs?: number },
467
+ ): Promise<AbortOutcome>;
100
468
  waitSettled(name: string, timeoutMs?: number, signal?: AbortSignal): Promise<SettleResult>;
101
469
  isAlive(name: string): boolean;
470
+ /**
471
+ * The tool call this child is inside right now (#315), or `undefined`.
472
+ *
473
+ * Optional on purpose. It is a display-only read added after this seam was
474
+ * frozen, and a supervisor that cannot answer it — a test double, a backend
475
+ * predating #315 — must keep satisfying the contract. Absence means "no
476
+ * activity fact", which renders no line at all: the same "no fact, no claim"
477
+ * rule every other status source follows. Callers read it as
478
+ * `supervisor.inFlightTool?.(name)`.
479
+ *
480
+ * A dead child never answers: holding no live handle, it can run no tool.
481
+ */
482
+ inFlightTool?(name: string): ChildActivity | undefined;
483
+ /**
484
+ * The whole in-flight stack (#331), innermost last, or `undefined`.
485
+ *
486
+ * Optional for the same reason as `inFlightTool`: a backend or test double
487
+ * that cannot answer renders no line. {@link inFlightToolStackOf} is the one
488
+ * place callers should read it, so a legacy double that only implements
489
+ * `inFlightTool` still renders its single frame.
490
+ *
491
+ * A dead child never answers.
492
+ */
493
+ inFlightToolStack?(name: string): InFlightToolStack | undefined;
494
+ /**
495
+ * What this backend can actually observe (#323).
496
+ *
497
+ * `promptDisposition: false` means `prompt()` cannot tell "the session
498
+ * accepted a turn" apart from "it did not throw": an input handler that
499
+ * swallows the prompt is invisible. That is exactly the in-process backend —
500
+ * the SDK's `AgentSession.prompt()` returns `void` — so its `accepted` is a
501
+ * weaker claim than RPC's, where the reply carries the disposition.
502
+ *
503
+ * Frozen rule (#323 D1): a caller must never invent a `queued`/`intercepted`
504
+ * distinction a backend cannot answer. The flag exists so that difference is
505
+ * nameable instead of silently flattened. A facade-level read must never be
506
+ * used to tell backends apart — the routing facade reports the intersection of
507
+ * every backend it can reach; to distinguish, read the concrete backend
508
+ * instance (`RoutingSupervisor.backendFor` resolves it, and `routing.rpc` /
509
+ * `routing.inprocess` are the two capabilities it reaches). Contract:
510
+ * `docs/legion-v2-contract.md` §3.1.
511
+ *
512
+ * Optional like `inFlightTool`: a supervisor that cannot answer must keep
513
+ * satisfying the seam, and absence means "no capability claimed".
514
+ */
515
+ capabilities?: { readonly promptDisposition: boolean };
102
516
  stats(name: string): Promise<UsageSnapshot | null>;
103
517
  retire(name: string, opts?: { graceful?: boolean }): Promise<void>;
104
518
  }
105
519
 
520
+ /**
521
+ * Read the display fact as a stack (#331), with a one-frame fallback.
522
+ *
523
+ * `inFlightToolStack` is the precise read. A supervisor that predates #331 (a
524
+ * test double, an older backend) only implements `inFlightTool`, so its single
525
+ * call is wrapped as a one-frame stack — same line as before, no invented
526
+ * context. Absent everywhere → `undefined`, which renders no line at all.
527
+ */
528
+ export function inFlightToolStackOf(
529
+ supervisor: Pick<LegionSupervisor, "inFlightTool" | "inFlightToolStack">,
530
+ name: string,
531
+ ): InFlightToolStack | undefined {
532
+ const stack = supervisor.inFlightToolStack?.(name);
533
+ if (stack !== undefined && stack.length > 0) return stack;
534
+ const top = supervisor.inFlightTool?.(name);
535
+ return top === undefined ? undefined : [top];
536
+ }
537
+
106
538
  /**
107
539
  * RpcClient's public constructor options. Kept as an alias so the adapter tracks
108
540
  * the installed Pi SDK without widening the supervisor contract.
@@ -145,6 +577,11 @@ export interface RpcClientLike {
145
577
  steer(message: string): Promise<unknown>;
146
578
  followUp(message: string): Promise<unknown>;
147
579
  abort(): Promise<void>;
580
+ /**
581
+ * Optional so existing fakes keep satisfying this seam: drop the queued
582
+ * steering/follow-up messages and answer their counts (#314 D2).
583
+ */
584
+ clearQueue?(): Promise<unknown>;
148
585
  getSessionStats(): Promise<unknown>;
149
586
  }
150
587
 
package/src/tui/status.ts CHANGED
@@ -678,7 +678,8 @@ export interface StatusUi {
678
678
  };
679
679
  }
680
680
 
681
- function isStaleUiError(error: unknown): boolean {
681
+ /** Exported (#377): the same test the board uses, reused by the extension entry points. */
682
+ export function isStaleUiError(error: unknown): boolean {
682
683
  return (
683
684
  error instanceof Error &&
684
685
  (error.message.includes("This extension ctx is stale") ||