pi-legion 3.0.1 → 3.1.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.
@@ -12,6 +12,7 @@ import {
12
12
  formatCompletionNotice,
13
13
  formatGroupedNotice,
14
14
  collectFailureInput,
15
+ type CompletionNotice,
15
16
  type SendMessageApi,
16
17
  } from "./notify.ts";
17
18
  import {
@@ -19,6 +20,7 @@ import {
19
20
  DEFAULT_JOIN_MODE,
20
21
  JoinCoordinator,
21
22
  type JoinConfig,
23
+ type JoinSchedulerHandle,
22
24
  } from "./join.ts";
23
25
  import {
24
26
  createStatusBoard,
@@ -32,7 +34,12 @@ import {
32
34
  } from "../tui/status.ts";
33
35
  import { progressFromSessionFile } from "../shared/progress.ts";
34
36
  import { isTransientConnectionFailure } from "../shared/session.ts";
35
- import { DEFAULTS, type AgentKind, type RetireOptions } from "../shared/types.ts";
37
+ import {
38
+ DEFAULTS,
39
+ type AgentKind,
40
+ type RetireOptions,
41
+ type SpawnMode,
42
+ } from "../shared/types.ts";
36
43
  import type {
37
44
  EventSubscriptionOptions,
38
45
  SupervisorEvent,
@@ -74,6 +81,12 @@ export interface TrackedJobInput {
74
81
  model?: string;
75
82
  thinking?: string | false;
76
83
  worktreeBranch?: string;
84
+ /**
85
+ * Arbitrated backend for this child (`Handle.spawnMode`, #209). Optional:
86
+ * a job tracked from a legacy record has no value and must render as
87
+ * unknown rather than a guessed `rpc`.
88
+ */
89
+ spawnMode?: SpawnMode;
77
90
  /** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
78
91
  nodeId?: string;
79
92
  parentId?: string | null;
@@ -144,12 +157,48 @@ export interface SessionRuntimeDeps {
144
157
  ) => () => void;
145
158
  /** Batching config; applied via setJoinConfig right after construction. */
146
159
  joinConfig?: JoinConfig;
160
+ /** Injectable timer for the MAX_PENDING_AGE_MS overflow (tests fire it manually); default = unref'd setTimeout. */
161
+ scheduleNoticeOverflow?: (ms: number, fn: () => void) => JoinSchedulerHandle;
147
162
  }
148
163
 
149
164
  const DEFAULT_REFRESH_MS = 500;
165
+ /**
166
+ * Hard cap on how long a completion notice may stay hidden behind a live
167
+ * parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
168
+ * could reach while a busy parent kept extending it. Past this the notice is
169
+ * force-sent even though the run is still going: reverting to the old
170
+ * "queued as a followUp" behaviour after 80s is better than hiding it forever.
171
+ */
172
+ export const MAX_PENDING_AGE_MS = 80_000;
150
173
  /** Fallback per-child timeout for wait(); index.ts passes the role value. */
151
174
  const DEFAULT_WAIT_TIMEOUT_MS = DEFAULTS.turnTimeoutMs;
152
175
 
176
+ /** Default timer for the MAX_PENDING_AGE_MS overflow (unref'd, like join's). */
177
+ function defaultNoticeOverflow(
178
+ ms: number,
179
+ fn: () => void,
180
+ ): JoinSchedulerHandle {
181
+ const id = setTimeout(fn, ms);
182
+ id.unref?.();
183
+ return { cancel: () => clearTimeout(id) };
184
+ }
185
+
186
+ /** A completion notice held because the parent agent run is still active (D-A). */
187
+ interface QueuedNotice {
188
+ notice: CompletionNotice;
189
+ triggerTurn: boolean;
190
+ enqueuedAt: number;
191
+ }
192
+
193
+ export interface NoticeQueueStats {
194
+ /** Notices currently held. */
195
+ pending: number;
196
+ /** now() - enqueuedAt of the oldest held notice; 0 when empty. */
197
+ oldestAgeMs: number;
198
+ /** Lifetime count of MAX_PENDING_AGE_MS overflows that forced a send (D-B observability). */
199
+ forcedFlushes: number;
200
+ }
201
+
153
202
  /** A wait() promise that must settle by the deadline. */
154
203
  class WaitTimeoutError extends Error {}
155
204
  const WAIT_INTERRUPTED = Symbol("wait interrupted");
@@ -181,8 +230,21 @@ export interface LiveChildStatusInput {
181
230
  usage?: { input: number; output: number; cost: number } | null;
182
231
  };
183
232
  acceptance?: { status: string; level?: string };
233
+ /**
234
+ * Backend the child was actually spawned on (#209). Absent for records written
235
+ * before run.json carried it, and for tree-only fallbacks — renders `unknown`,
236
+ * never a guessed `rpc`.
237
+ */
238
+ spawnMode?: string;
184
239
  /** RPC process is still running. */
185
240
  alive: boolean;
241
+ /**
242
+ * Run-dir ownership verdict (#209-3), from `owner.json`.
243
+ * `alive` is this process's in-memory child registry, which never contains
244
+ * a child launched by another session — so a foreign record must be judged
245
+ * by the owner marker instead. Only consulted when `alive` is false.
246
+ */
247
+ owner?: "live" | "dead" | "unknown";
186
248
  now?: number;
187
249
  }
188
250
 
@@ -245,6 +307,12 @@ function formatExecutionStatusLine(
245
307
  return `${label}: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`;
246
308
  }
247
309
 
310
+ /** One-line ownership verdict appended to a status block (#209-3). */
311
+ function ownerLine(owner: "live" | "dead" | "unknown"): string {
312
+ if (owner === "dead") return "owner: dead (pid gone)";
313
+ return `owner: ${owner}`;
314
+ }
315
+
248
316
  /**
249
317
  * Status text for one child (`/legion status <name>` and `subagent status`).
250
318
  *
@@ -257,7 +325,15 @@ function formatExecutionStatusLine(
257
325
  */
258
326
  export function renderLiveChildStatus(input: LiveChildStatusInput): string {
259
327
  const stale = persistedSnapshotIsStale(input);
260
- const state = stale ? "working" : input.state;
328
+ // #209-3: a persisted `working` snapshot whose run-dir owner pid is gone is
329
+ // dead, not working. `stale` (alive process + retired/failed snapshot) keeps
330
+ // owning the opposite direction, so the two never fight over one record.
331
+ const ownerDead = input.owner === "dead" && !input.alive;
332
+ const state = stale
333
+ ? "working"
334
+ : ownerDead && input.state === "working"
335
+ ? "failed"
336
+ : input.state;
261
337
  const execution = stale
262
338
  ? { status: "running", reason: "rpc child alive", usage: input.execution?.usage }
263
339
  : input.execution;
@@ -268,9 +344,11 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
268
344
  const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
269
345
  lines.push(formatAliveQuiet(age));
270
346
  }
347
+ // After the liveness lead: who owns the run dir, and whether that pid is gone.
348
+ if (input.owner !== undefined) lines.push(ownerLine(input.owner));
271
349
  // Quiet age first; path metadata after snapshots so old store lines stay below.
272
350
  const meta = [
273
- "supervisor=rpc",
351
+ `supervisor=${input.spawnMode ?? "unknown"}`,
274
352
  `session=${input.sessionFile}`,
275
353
  ];
276
354
  if (execution) {
@@ -356,6 +434,12 @@ export interface SessionRuntime {
356
434
  statusTui(): WidgetTui | undefined;
357
435
  /** Forward join config to the internal coordinator (settings wiring). */
358
436
  setJoinConfig(config: JoinConfig): void;
437
+ /** Parent agent run started (pi `agent_start`): hold completion notices until settle (D-A/D-C). */
438
+ notifyParentRunStart(): void;
439
+ /** Parent agent run settled (pi `agent_settled`): send every held notice now, then repaint (D-A/D-D). */
440
+ notifyParentSettled(): void;
441
+ /** Introspection seam for tests/doctor: deferred-notice queue state (D-B observability). */
442
+ noticeQueueStats(): NoticeQueueStats;
359
443
  /**
360
444
  * Explicit wait: suppress auto-notify on the targets via consumedByTool
361
445
  * and aggregate their results. A timeout restores auto-notify.
@@ -400,6 +484,92 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
400
484
  mode: DEFAULT_JOIN_MODE,
401
485
  flushMs: DEFAULT_FLUSH_MS,
402
486
  };
487
+
488
+ // Run-scoped completion outbox (D-A). The host only drains
489
+ // `agent.followUpQueue` inside a live run, so a `followUp` sent while the
490
+ // parent run is active sits invisible until the user types again. When a
491
+ // run is active we therefore hold the formatted notice here and burst it
492
+ // out on `agent_settled` (D-D). This is the ONLY deferral point: the join
493
+ // window above batches stragglers and never waits on a busy parent (D-I).
494
+ //
495
+ // D-H: the default is `false`, so a host (or a test) that never signals a
496
+ // run start keeps today's immediate-send behaviour byte for byte.
497
+ let parentRunActive = false;
498
+ const outbox: QueuedNotice[] = [];
499
+ let outboxTimer: JoinSchedulerHandle | undefined;
500
+ let forcedFlushes = 0;
501
+
502
+ const armOutboxTimer = (): void => {
503
+ if (outboxTimer || outbox.length === 0 || disposed) return;
504
+ const oldest = outbox[0]!;
505
+ const delay = Math.max(0, oldest.enqueuedAt + MAX_PENDING_AGE_MS - now());
506
+ // `live` proves this callback is the CURRENT arm. A cancelled-but-still-
507
+ // invoked timer (a zombie queued before the cancel landed) must be a strict
508
+ // no-op: otherwise it would clear the live timer's handle and force-send a
509
+ // notice that is nowhere near its cap.
510
+ //
511
+ // A boolean — not a closure over the handle — because an injected scheduler
512
+ // is free to invoke the callback synchronously, before the `const` binding
513
+ // is initialized (TDZ). `live` is assigned above the call, so it is always
514
+ // safe to read from inside the callback.
515
+ let live = true;
516
+ const handle: JoinSchedulerHandle = (deps.scheduleNoticeOverflow ?? defaultNoticeOverflow)(delay, () => {
517
+ if (!live) return;
518
+ live = false;
519
+ outboxTimer = undefined;
520
+ if (outbox.length === 0) return;
521
+ const next = outbox[0]!;
522
+ if (now() < next.enqueuedAt + MAX_PENDING_AGE_MS) {
523
+ // Fired while the oldest notice is still younger than the cap (a
524
+ // coarse timer or a backwards clock step). Never force-send early:
525
+ // re-arm for the remaining age instead.
526
+ armOutboxTimer();
527
+ return;
528
+ }
529
+ forcedFlushes += 1;
530
+ flushOutbox();
531
+ });
532
+ outboxTimer = {
533
+ cancel: () => {
534
+ live = false;
535
+ handle.cancel();
536
+ },
537
+ };
538
+ };
539
+
540
+ // FIFO, one message per notice: the grouped shape belongs to the join layer.
541
+ // N held notices = N host `_runAgentPrompt` calls; the common case is 1 per
542
+ // run, so the batch is not merged into a synthetic grouped notice.
543
+ const flushOutbox = (): void => {
544
+ outboxTimer?.cancel();
545
+ outboxTimer = undefined;
546
+ if (outbox.length === 0) return;
547
+ // One splice: anything enqueued by a re-entrant send belongs to the next
548
+ // batch, so a settle can never double-deliver what it already flushed.
549
+ const batch = outbox.splice(0);
550
+ for (const item of batch) {
551
+ // A rejected send is dropped, not requeued (same as before).
552
+ deliverCompletion(
553
+ { sendMessage: deps.sendMessage },
554
+ item.notice,
555
+ item.triggerTurn,
556
+ );
557
+ }
558
+ };
559
+
560
+ /** The single completion exit: send now, or hold until the run settles. */
561
+ const sendCompletionNotice = (
562
+ notice: CompletionNotice,
563
+ triggerTurn: boolean,
564
+ ): void => {
565
+ if (!parentRunActive) {
566
+ deliverCompletion({ sendMessage: deps.sendMessage }, notice, triggerTurn);
567
+ return;
568
+ }
569
+ outbox.push({ notice, triggerTurn, enqueuedAt: now() });
570
+ armOutboxTimer();
571
+ };
572
+
403
573
  const join = new JoinCoordinator({
404
574
  deliver: (batch) => {
405
575
  if (disposed) return;
@@ -415,8 +585,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
415
585
  batch.length === 1 &&
416
586
  (joinConfig.mode === "each" || join.allSettled(first.runId))
417
587
  ) {
418
- deliverCompletion(
419
- { sendMessage: deps.sendMessage },
588
+ sendCompletionNotice(
420
589
  formatCompletionNotice(first.input),
421
590
  first.triggerTurn,
422
591
  );
@@ -435,8 +604,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
435
604
  !batch.some((e) => e.name === job.name),
436
605
  )
437
606
  .map((job) => job.name);
438
- deliverCompletion(
439
- { sendMessage: deps.sendMessage },
607
+ sendCompletionNotice(
440
608
  formatGroupedNotice({
441
609
  runId: first.runId,
442
610
  entries: batch.map((e) => ({ ...e.input, status: e.status })),
@@ -472,7 +640,30 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
472
640
  for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
473
641
  for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
474
642
  }
475
- return [...tree.values()].map((node) => ({ ...node }));
643
+ const nodes = [...tree.values()].map((node) => ({ ...node }));
644
+ return mergeSpawnModes(nodes);
645
+ };
646
+
647
+ /**
648
+ * #209/D5: the arbitrated backend lives on the tracked job, never in
649
+ * legion.db (no column, no migration), so it is merged onto the matching
650
+ * snapshot. Unmatched jobs contribute nothing rather than a guess.
651
+ *
652
+ * Shared by `snapshotNodes()` and `treeNodes()`: the latter synthesizes
653
+ * nodes of its own (a job with identity but no DB row yet) and returns that
654
+ * array directly, so a merge that only ran in `snapshotNodes()` would drop
655
+ * the mode in the pre-DB-row window and in a plain session where
656
+ * `getTreeNodes()` yields `[]`.
657
+ */
658
+ const mergeSpawnModes = (
659
+ nodes: TreeNodeSnapshot[],
660
+ ): TreeNodeSnapshot[] => {
661
+ for (const job of jobs.values()) {
662
+ if (job.spawnMode === undefined) continue;
663
+ const match = storedNodeForJob(job, nodes);
664
+ if (match) match.spawnMode = job.spawnMode;
665
+ }
666
+ return nodes;
476
667
  };
477
668
 
478
669
  const storedNodeForJob = (
@@ -812,6 +1003,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
812
1003
  existing.model = input.model;
813
1004
  existing.thinking = input.thinking;
814
1005
  existing.worktreeBranch = input.worktreeBranch;
1006
+ // Only a known value overwrites: a re-follow without the handle must
1007
+ // not erase the mode an earlier launch established.
1008
+ if (input.spawnMode !== undefined) existing.spawnMode = input.spawnMode;
815
1009
  if (input.nodeId !== undefined || identity.nodeId !== undefined) existing.nodeId = input.nodeId ?? identity.nodeId;
816
1010
  if (input.parentId !== undefined || identity.parentId !== undefined) existing.parentId = input.parentId ?? identity.parentId;
817
1011
  if (input.depth !== undefined || identity.depth !== undefined) existing.depth = input.depth ?? identity.depth;
@@ -867,6 +1061,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
867
1061
  job.notified = false;
868
1062
  job.consumedByTool = false;
869
1063
  join.addPending(job.runId, job.name);
1064
+ // Repaint before the new watch takes over so the panel cannot
1065
+ // hold the old row's final second. watch() repaints too; this is
1066
+ // an idempotent second guarantee.
1067
+ refreshUi();
870
1068
  runtime.watch(name, opts);
871
1069
  };
872
1070
  try {
@@ -914,6 +1112,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
914
1112
  }
915
1113
  job.state = "awaiting";
916
1114
  job.settledAt = now();
1115
+ // Repaint immediately: the row flips to a terminal state here, and
1116
+ // waiting for the 500ms sampler leaves a phantom "working" row.
1117
+ refreshUi();
917
1118
  if (!job.consumedByTool && !job.notified) {
918
1119
  job.notified = true;
919
1120
  join.onTerminal({
@@ -959,6 +1160,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
959
1160
  }
960
1161
  job.state = "awaiting";
961
1162
  job.settledAt = now();
1163
+ refreshUi();
962
1164
  if (!job.consumedByTool && !job.notified) {
963
1165
  job.notified = true;
964
1166
  // Collect failures join the batch too, as a failed entry.
@@ -1143,7 +1345,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1143
1345
  if (index >= 0) result[index] = next;
1144
1346
  else result.push(next);
1145
1347
  }
1146
- return result;
1348
+ // The synthesized nodes above are built from job facts, not from a DB
1349
+ // row, so they carry no `spawnMode` of their own: run the same merge
1350
+ // the snapshot path uses before handing the array out (#209/D5).
1351
+ return mergeSpawnModes(result);
1147
1352
  },
1148
1353
 
1149
1354
  statusEntries() {
@@ -1157,6 +1362,25 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1157
1362
  join.setConfig(config);
1158
1363
  },
1159
1364
 
1365
+ notifyParentRunStart() {
1366
+ parentRunActive = true;
1367
+ },
1368
+
1369
+ notifyParentSettled() {
1370
+ parentRunActive = false;
1371
+ flushOutbox();
1372
+ refreshUi();
1373
+ },
1374
+
1375
+ noticeQueueStats(): NoticeQueueStats {
1376
+ const oldest = outbox[0];
1377
+ return {
1378
+ pending: outbox.length,
1379
+ oldestAgeMs: oldest ? Math.max(0, now() - oldest.enqueuedAt) : 0,
1380
+ forcedFlushes,
1381
+ };
1382
+ },
1383
+
1160
1384
  async wait(names, opts = {}) {
1161
1385
  return Promise.all(
1162
1386
  names.map((name) =>
@@ -1194,6 +1418,12 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1194
1418
  jobs.clear();
1195
1419
  finished.clear();
1196
1420
  join.dispose();
1421
+ // Shutdown drops held notices unsent: the child's session file survives,
1422
+ // so `collect` can still recover the result from disk. The `finished`
1423
+ // cache does NOT survive — it was cleared two statements up.
1424
+ outboxTimer?.cancel();
1425
+ outboxTimer = undefined;
1426
+ outbox.length = 0;
1197
1427
  if (timer) clearInterval(timer);
1198
1428
  timer = undefined;
1199
1429
  for (const unsubscribe of busUnsubscribers) unsubscribe();
@@ -1289,6 +1519,13 @@ function statusEntryFromNode(
1289
1519
  activity?.turnStartedAt,
1290
1520
  activity?.turnEndedAt,
1291
1521
  );
1522
+ // An in-process child has no tracked job for its whole first run: spawnChild()
1523
+ // awaits the whole agent run, so the job (and its sessionFile plumbing in
1524
+ // statusEntryFromJob) does not exist yet. The node row does carry the session
1525
+ // file, so parse it directly — otherwise the ledger only has a tokens_out row
1526
+ // after settle and the token rate reads 0 for the agent's entire lifetime.
1527
+ const live = node.sessionFile ? progressFromSessionFile(node.sessionFile) : {};
1528
+ const tokensOut = live.tokensOut ?? node.tokensOut;
1292
1529
  return {
1293
1530
  name: node.name,
1294
1531
  agent: node.role,
@@ -1300,8 +1537,9 @@ function statusEntryFromNode(
1300
1537
  ...(node.phase ? { phase: node.phase } : {}),
1301
1538
  ...(node.kind ? { kind: node.kind } : {}),
1302
1539
  ...(node.model ? { model: node.model } : {}),
1303
- ...(node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
1304
- ...(node.tokensOut ? { tokensOut: node.tokensOut } : {}),
1540
+ ...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
1541
+ ...(tokensOut ? { tokensOut } : {}),
1542
+ ...(live.generationMs ? { generationMs: live.generationMs } : {}),
1305
1543
  ...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
1306
1544
  ...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
1307
1545
  ...(activity?.turnEndedAt !== undefined ? { currentTurnEndedAt: activity.turnEndedAt } : {}),
@@ -3,12 +3,16 @@
3
3
  * so `/legion doctor` / `subagent action=diagnose` can show what just broke.
4
4
  */
5
5
 
6
+ import type { SpawnMode } from "../shared/types.ts";
7
+
6
8
  export interface SpawnFailureRecord {
7
9
  ts: number;
8
10
  name: string;
9
11
  message: string;
10
12
  stderr?: string;
11
13
  hadNoExtensions?: boolean;
14
+ /** When known, doctor / hints can avoid RPC-only advice for inprocess (#212). */
15
+ spawnMode?: SpawnMode;
12
16
  }
13
17
 
14
18
  const MAX_FAILURES = 8;
@@ -29,21 +33,35 @@ export function clearSpawnFailures(): void {
29
33
  }
30
34
 
31
35
  const MODEL_NOT_FOUND =
32
- /Model\s+"[^"]+"\s+not found|No models available|Use --list-models/i;
36
+ /Model\s+"[^"]+"\s+not found|No models available|Use --list-models|Unknown provider|inprocess model resolve failed/i;
33
37
 
34
38
  export function looksLikeMissingModel(stderr: string | undefined): boolean {
35
39
  return Boolean(stderr && MODEL_NOT_FOUND.test(stderr));
36
40
  }
37
41
 
42
+ /** Inprocess children have no argv; point at models.json / role extensions (#212). */
43
+ export const INPROCESS_MODEL_HINT =
44
+ "Hint: default spawnMode is `inprocess` — children resolve models via ModelRuntime, " +
45
+ "not a `--no-extensions` argv.\n" +
46
+ "Fix: declare the provider statically in `models.json` (agent dir / project), " +
47
+ "list the extension under the role's `extensions:` frontmatter, or set " +
48
+ "`inheritExtensions: true` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`. " +
49
+ "Run `/legion doctor` or `subagent action=diagnose`.";
50
+
38
51
  /**
39
- * When a child dies because its model/provider is invisible under
40
- * `--no-extensions`, point at the escape hatches and `/legion doctor`.
52
+ * When a child dies because its model/provider is invisible, point at the
53
+ * right escape hatch for the spawn backend (`rpc` argv vs `inprocess`).
41
54
  */
42
55
  export function spawnFailureHint(input: {
43
56
  stderr?: string;
44
57
  args?: readonly string[];
58
+ /** Prefer this when known; inprocess never gets argv / CHILD_EXTENSIONS advice (#212). */
59
+ spawnMode?: SpawnMode;
45
60
  }): string | undefined {
46
61
  if (!looksLikeMissingModel(input.stderr)) return undefined;
62
+ if (input.spawnMode === "inprocess") {
63
+ return INPROCESS_MODEL_HINT;
64
+ }
47
65
  const noExt = input.args?.includes("--no-extensions") ?? true;
48
66
  if (!noExt) {
49
67
  return (
@@ -67,13 +85,16 @@ export function withSpawnFailureHint(message: string, input: {
67
85
  stderr?: string;
68
86
  args?: readonly string[];
69
87
  name?: string;
88
+ spawnMode?: SpawnMode;
70
89
  }): string {
71
90
  const hint = spawnFailureHint(input);
72
91
  recordSpawnFailure({
73
92
  name: input.name ?? "?",
74
93
  message,
75
94
  stderr: input.stderr,
76
- hadNoExtensions: input.args?.includes("--no-extensions"),
95
+ hadNoExtensions:
96
+ input.spawnMode === "inprocess" ? false : input.args?.includes("--no-extensions"),
97
+ spawnMode: input.spawnMode,
77
98
  });
78
99
  return hint ? `${message}\n\n${hint}` : message;
79
100
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TUI renderer for the `subagent` / `vexillatio` tool results.
3
+ *
4
+ * Pi never renders a tool result's `details` on its own: `renderResult` is the
5
+ * only consumer, and a tool registered without one falls back to the plain
6
+ * `content` text. So the arbitrated `spawnMode` (#209) stays invisible until a
7
+ * renderer names it — that is all this file does.
8
+ *
9
+ * The summary line is derived from `details`, never from `content`: `content`
10
+ * is model-facing, `details` is TUI-facing (`docs/design.md:258`). Keeping the
11
+ * line out of `content` keeps it out of the LLM context.
12
+ *
13
+ * Renderers are called on every repaint, so nothing here may throw: the host
14
+ * swallows a throwing renderer and falls back to the default block, silently
15
+ * erasing the very fact this file exists to show. Every `details` read is
16
+ * therefore optional-chained and shape-checked, AND both entry points are
17
+ * wrapped in `try/catch`: a shape check is no defence against a getter or a
18
+ * Proxy trap that throws (host: `tool-execution.js` catch → fallback card).
19
+ */
20
+
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
23
+ import type { Theme } from "@earendil-works/pi-coding-agent";
24
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
25
+
26
+ /** How many `content` lines a collapsed card keeps before the expand hint. */
27
+ const COLLAPSED_LINES = 10;
28
+
29
+ const SPAWN_MODES = new Set(["rpc", "inprocess"]);
30
+
31
+ /**
32
+ * Binary control bytes that must never reach the terminal renderer.
33
+ *
34
+ * Same set as the host fallback (`shell.js:sanitizeBinaryOutput`, used by
35
+ * `render-utils.js:getTextOutput`): TAB, LF and CR are excluded on purpose —
36
+ * the carriage return is a line break here, see {@link resultLines}.
37
+ */
38
+ const BINARY_CONTROL = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g;
39
+
40
+ export interface SpawnRenderOptions {
41
+ expanded: boolean;
42
+ isPartial: boolean;
43
+ }
44
+
45
+ function isSpawnMode(value: unknown): value is "rpc" | "inprocess" {
46
+ return typeof value === "string" && SPAWN_MODES.has(value);
47
+ }
48
+
49
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
50
+ return typeof value === "object" && value !== null
51
+ ? (value as Record<string, unknown>)
52
+ : undefined;
53
+ }
54
+
55
+ /** One `name=mode` entry from a `spawns[]` array (launch-family details). */
56
+ function entryFromSpawn(value: unknown): string | undefined {
57
+ const spawn = asRecord(value);
58
+ if (!spawn) return undefined;
59
+ const name = spawn.name;
60
+ const mode = spawn.spawnMode;
61
+ if (typeof name !== "string" || !name) return undefined;
62
+ // An unknown/absent mode is skipped, never printed as a guess: a legacy
63
+ // handle must render as "no fact", not as `rpc`.
64
+ if (!isSpawnMode(mode)) return undefined;
65
+ return `${name}=${mode}`;
66
+ }
67
+
68
+ /** One `name=mode` entry from a `handles[]` object (vexillatio details). */
69
+ function entryFromHandle(value: unknown): string | undefined {
70
+ const handle = asRecord(value);
71
+ if (!handle) return undefined;
72
+ return entryFromSpawn(handle);
73
+ }
74
+
75
+ /**
76
+ * `spawn: scout-0=inprocess` or `spawn: worker-0=rpc, scout-1=inprocess`.
77
+ *
78
+ * Reads both documented detail shapes defensively:
79
+ * - launch family: `details.spawns[]` (`{ name, agent, spawnMode }`)
80
+ * - vexillatio: `details.handles[]` (`{ name, agent, sessionFile, spawnMode? }`)
81
+ *
82
+ * One entry per child, first occurrence wins: a result that carries the same
83
+ * handle in both arrays must not print `w=rpc, w=rpc`.
84
+ *
85
+ * Returns `undefined` when nothing survives — no details, wrong shape, or every
86
+ * entry's mode unknown. Unknown modes are omitted rather than defaulted.
87
+ *
88
+ * Never throws: a `details` object with a throwing getter (or a Proxy) would
89
+ * otherwise take the whole card down, because the host swallows renderer
90
+ * exceptions and paints its fallback instead.
91
+ */
92
+ export function spawnModeSummary(details: unknown): string | undefined {
93
+ try {
94
+ const record = asRecord(details);
95
+ if (!record) return undefined;
96
+ const entries: string[] = [];
97
+ const seen = new Set<string>();
98
+ const push = (entry: string | undefined): void => {
99
+ if (!entry || seen.has(entry)) return;
100
+ seen.add(entry);
101
+ entries.push(entry);
102
+ };
103
+ const spawns = record.spawns;
104
+ if (Array.isArray(spawns)) {
105
+ for (const spawn of spawns) push(entryFromSpawn(spawn));
106
+ }
107
+ // `handles` may be a `string[]` (launch-family) or an object array
108
+ // (vexillatio); string entries simply never match, so both shapes pass.
109
+ const handles = record.handles;
110
+ if (Array.isArray(handles)) {
111
+ for (const handle of handles) push(entryFromHandle(handle));
112
+ }
113
+ if (entries.length === 0) return undefined;
114
+ return `spawn: ${entries.join(", ")}`;
115
+ } catch {
116
+ // A hostile/throwing getter is not a rendering failure: no fact, no line.
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The model-facing text of a result, normalized the way it will be painted.
123
+ *
124
+ * Line counting has to happen on the text that actually renders, so
125
+ * normalizing runs BEFORE the split: a carriage return is how a progress
126
+ * stream redraws one line, not 20 rows — but `Text` treats `\r` as a line
127
+ * break, so leaving it in lets 20 segments paint 20 rows and bypass
128
+ * {@link COLLAPSED_LINES} with no hint. `\r` therefore becomes `\n` (so all
129
+ * 20 segments stay readable instead of being concatenated into one blob) and
130
+ * binary control bytes are dropped, matching the host fallback's intent
131
+ * (`render-utils.js:40` sanitizes binary output and removes raw `\r`).
132
+ */
133
+ function resultLines(result: AgentToolResult<unknown> | undefined): string[] {
134
+ const content = result?.content;
135
+ if (!Array.isArray(content)) return [];
136
+ const text = content
137
+ .flatMap((part) => {
138
+ if (asRecord(part)?.type !== "text") return [];
139
+ const value = asRecord(part)?.text;
140
+ return typeof value === "string" ? [value] : [];
141
+ })
142
+ .join("\n");
143
+ const normalized = stripTerminalSequences(text)
144
+ .replace(/\r\n/g, "\n")
145
+ .replace(/\r/g, "\n")
146
+ .replace(BINARY_CONTROL, "");
147
+ return normalized.split("\n");
148
+ }
149
+
150
+ /**
151
+ * Build the card: the spawn summary line (when known) followed by the result
152
+ * text, capped at {@link COLLAPSED_LINES} lines until `ctrl+o` expands it.
153
+ *
154
+ * Never throws by design; see the file comment.
155
+ */
156
+ export function renderSpawnResult(
157
+ result: AgentToolResult<unknown>,
158
+ opts: SpawnRenderOptions,
159
+ _theme: Theme,
160
+ ) {
161
+ const lines: string[] = [];
162
+ // Double insurance: `spawnModeSummary` is already total, but this call site
163
+ // must not depend on the callee's implementation staying that way (the host
164
+ // silences a throw and drops the card). The `details` READ is inside the
165
+ // guard too — a Proxy can throw on the property access itself.
166
+ let summary: string | undefined;
167
+ try {
168
+ summary = spawnModeSummary(result?.details);
169
+ } catch {
170
+ summary = undefined;
171
+ }
172
+ if (summary) lines.push(summary);
173
+ lines.push(...resultLines(result));
174
+ // `opts` is host-supplied; optional-chained so a partial/absent options
175
+ // object degrades to the collapsed view instead of throwing.
176
+ if (opts?.expanded || lines.length <= COLLAPSED_LINES) {
177
+ return new Text(lines.join("\n"), 0, 0);
178
+ }
179
+ const hidden = lines.length - COLLAPSED_LINES;
180
+ const visible = lines.slice(0, COLLAPSED_LINES);
181
+ visible.push(`... (${hidden} more lines, ctrl+o to expand)`);
182
+ return new Text(visible.join("\n"), 0, 0);
183
+ }