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.
- package/CHANGELOG.md +858 -0
- package/CONTRIBUTING.md +33 -2
- package/README.md +10 -5
- package/agents/centurion.md +32 -7
- package/agents/scout.md +11 -0
- package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
- package/docs/contracts/centurion-declared-phases-v1.md +104 -0
- package/docs/contracts/declarative-phase-machine-v5.md +43 -0
- package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
- package/docs/contracts/finished-cache-eviction-v1.md +169 -0
- package/docs/contracts/galaxy-scratch-v1.md +78 -0
- package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
- package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
- package/docs/contracts/legion-page-floor-v1.md +129 -0
- package/docs/contracts/legion-session-continuity-v1.md +118 -0
- package/docs/contracts/mailbox-delivery-v1.md +162 -0
- package/docs/contracts/notice-claim-v1.md +206 -0
- package/docs/contracts/partial-failure-exit-v1.md +235 -0
- package/docs/contracts/proc-sweep-v1.md +107 -0
- package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
- package/docs/contracts/stall-visibility-v1.md +218 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/viz-frame-alloc-v1.md +123 -0
- package/docs/contracts/wait-mail-preempt-v1.md +117 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/legion-v2-contract.md +122 -20
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +1903 -130
- package/package.json +5 -5
- package/skills/pi-legion/SKILL.md +2 -1
- package/src/agents/agents.ts +26 -1
- package/src/agents/settings.ts +67 -2
- package/src/api.ts +17 -0
- package/src/extension/child-guard.ts +2 -1
- package/src/extension/join.ts +54 -0
- package/src/extension/legion-commands.ts +15 -4
- package/src/extension/legion-doctor.ts +48 -6
- package/src/extension/legion-status.ts +44 -2
- package/src/extension/legion-tools.ts +1044 -109
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +6 -3
- package/src/extension/runtime.ts +882 -81
- package/src/extension/spawn-hints.ts +52 -12
- package/src/extension/summary.ts +7 -15
- package/src/extension/timing.ts +4 -15
- package/src/legion/db.ts +23 -1
- package/src/legion/events.ts +7 -0
- package/src/legion/interrupt.ts +98 -0
- package/src/legion/mail-release.ts +266 -0
- package/src/legion/mail.ts +456 -29
- package/src/legion/nodes.ts +31 -0
- package/src/legion/phase-conditions.ts +7 -1
- package/src/legion/phase-machine.ts +160 -8
- package/src/legion/state-machine.ts +34 -2
- package/src/runs/acceptance.ts +105 -6
- package/src/runs/args.ts +16 -0
- package/src/runs/child-scope.ts +323 -0
- package/src/runs/extension-inheritance.ts +145 -19
- package/src/runs/orchestrator.ts +613 -55
- package/src/runs/store.ts +149 -5
- package/src/shared/proc-sweep.ts +317 -0
- package/src/shared/progress.ts +11 -2
- package/src/shared/session-map.ts +29 -0
- package/src/shared/session.ts +308 -6
- package/src/shared/types.ts +34 -0
- package/src/supervisor/inprocess-supervisor.ts +158 -12
- package/src/supervisor/race-timeout.ts +55 -0
- package/src/supervisor/routing-supervisor.ts +55 -4
- package/src/supervisor/rpc-supervisor.ts +215 -29
- package/src/supervisor/types.ts +439 -2
- package/src/tui/status.ts +2 -1
- package/src/tui/viz/canvas.ts +358 -36
- package/src/tui/viz/palette.ts +18 -2
- package/src/tui/viz/panels.ts +507 -104
- package/src/tui/viz/rate.ts +120 -15
- package/src/tui/viz/view.ts +72 -4
package/src/supervisor/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") ||
|