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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "5.0.1",
3
+ "version": "5.2.0",
4
4
  "description": "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,10 +87,10 @@
87
87
  }
88
88
  },
89
89
  "devDependencies": {
90
- "@earendil-works/pi-agent-core": "^1.0.0",
91
- "@earendil-works/pi-ai": "^1.0.0",
92
- "@earendil-works/pi-coding-agent": "^1.0.0",
93
- "@earendil-works/pi-tui": "^1.0.0",
90
+ "@earendil-works/pi-agent-core": "^1.4.0",
91
+ "@earendil-works/pi-ai": "^1.4.0",
92
+ "@earendil-works/pi-coding-agent": "^1.4.0",
93
+ "@earendil-works/pi-tui": "^1.4.0",
94
94
  "@types/node": "^22.0.0",
95
95
  "typebox": "^1.3.30",
96
96
  "typescript": "^5.6.0"
@@ -70,7 +70,8 @@ commands under the supervisor, without an LLM deciding whether they passed.
70
70
 
71
71
  A centurion owns a Git worktree for its subtree; squad members share that
72
72
  worktree, and a nested centurion gets a nested Git worktree. Each squad
73
- squashes its subtree into an MR for its parent. MRs move upward until the root
73
+ squashes its subtree onto a branch for its parent — the branch is the deliverable, and an
74
+ MR is added only when the repository has a remote. Deliveries move upward until the root
74
75
  reviews and merges into the default branch. Do not merge a child squad's work
75
76
  at the wrong level.
76
77
 
@@ -31,10 +31,12 @@ import {
31
31
  import { getAgentDir } from "./paths.ts";
32
32
  import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
33
33
  import {
34
+ canReachTerminal,
34
35
  type PhaseDefinition,
35
36
  type PhaseMachine,
36
37
  type PhaseTransitionRule,
37
38
  normalizePhaseMachine,
39
+ phaseStart,
38
40
  validatePhaseMachine,
39
41
  } from "../legion/phase-machine.ts";
40
42
  import type { PhaseCondition } from "../legion/phase-conditions.ts";
@@ -528,8 +530,21 @@ function parsePhaseBlock(text: string): PhaseMachine | undefined {
528
530
  * Validation happens HERE, at load, and every problem is reported at once. A
529
531
  * machine that only fails mid-run would surface after a team had already spent
530
532
  * tokens on a workflow that could never complete.
533
+ *
534
+ * Exported since #371: the SAME parser reads a declaration handed to the
535
+ * `subagent` tool at dispatch time, so the card layer and the inline layer can
536
+ * never disagree about what a legal machine is.
537
+ *
538
+ * `requireTerminal` adds the one question this parser does not ask for a card:
539
+ * can the DECLARED graph actually reach a terminal phase (#371 G4)? A card has
540
+ * never been checked for it and its rules are frozen, so the check is opt-in
541
+ * rather than folded into `validatePhaseMachine`.
531
542
  */
532
- function parsePhaseMachine(value: unknown, filePath: string): PhaseMachine | undefined {
543
+ export function parsePhaseMachine(
544
+ value: unknown,
545
+ filePath: string,
546
+ options: { requireTerminal?: boolean } = {},
547
+ ): PhaseMachine | undefined {
533
548
  if (value === undefined || value === null) return undefined;
534
549
  let raw: unknown = value;
535
550
  if (typeof value === "string") {
@@ -570,6 +585,16 @@ function parsePhaseMachine(value: unknown, filePath: string): PhaseMachine | und
570
585
  // on the machine as it will actually run.
571
586
  const normalized = normalizePhaseMachine(machine);
572
587
  const errors = validatePhaseMachine(normalized);
588
+ // #371 G4: a well-formed graph may still be a loop. Asked only when the graph
589
+ // is otherwise valid (a structural error already names the real problem), and
590
+ // asked of the DECLARED graph — `normalizePhaseMachine` appends `done` to every
591
+ // non-terminal phase, so the normalized machine answers "yes" by construction.
592
+ if (options.requireTerminal === true && errors.length === 0 && !canReachTerminal(machine)) {
593
+ errors.push({
594
+ path: "phases",
595
+ message: `phase machine cannot reach a terminal phase from '${phaseStart(machine)}'`,
596
+ });
597
+ }
573
598
  if (errors.length > 0) {
574
599
  const detail = errors.map((error) => ` - ${error.path}: ${error.message}`).join("\n");
575
600
  throw new Error(`Agent '${filePath}' has an invalid phase machine:\n${detail}`);
@@ -135,7 +135,16 @@ const LEGION_SETTING_FIELDS = [
135
135
  "orphanStaleMs",
136
136
  ] as const satisfies readonly (keyof LegionSettings)[];
137
137
 
138
- interface LegionExplicitFields {
138
+ /**
139
+ * Which `legion` values the operator actually wrote down (#358 review, major 1).
140
+ *
141
+ * `parseLegionSettings` FILLS every default so a consumer never has to invent a
142
+ * number, and that fill is indistinguishable from a decision once it has been
143
+ * spread into a plain object. Anything that treats "the setting is present" as
144
+ * "the operator chose it" therefore sees a choice that was never made — which is
145
+ * exactly how a card's own `timeoutMs` on a built-in phase came to be ignored.
146
+ */
147
+ export interface LegionExplicitFields {
139
148
  fields: Set<keyof LegionSettings>;
140
149
  phaseTimeouts: Set<(typeof LEGION_PHASES)[number]>;
141
150
  }
@@ -253,7 +262,16 @@ function parseLegionSettings(
253
262
  return out;
254
263
  }
255
264
 
256
- function legionExplicitFields(
265
+ /**
266
+ * Read the operator's own decisions out of a `legion` block.
267
+ *
268
+ * A settings object produced by {@link parseLegionSettings} / {@link mergeLegionSettings}
269
+ * carries them on a symbol; a hand-built object (a test, a caller) falls back to
270
+ * "an own key means explicit", which is the only thing that is observable there.
271
+ * Read this — never `phaseTimeouts?.[phase] !== undefined` — to answer "did a
272
+ * human set this clock?".
273
+ */
274
+ export function legionExplicitFields(
257
275
  settings: LegionSettings | undefined,
258
276
  ): LegionExplicitFields {
259
277
  if (!settings) return { fields: new Set(), phaseTimeouts: new Set() };
@@ -272,6 +290,53 @@ function legionExplicitFields(
272
290
  };
273
291
  }
274
292
 
293
+ /**
294
+ * Did the operator set this phase's advisory clock by hand (#358 review, major 1)?
295
+ *
296
+ * The answer consumers need is about the DECISION, not about the value being
297
+ * present: a filled-in default is present too. `phase` is free-form because a
298
+ * declared machine names its own phases.
299
+ */
300
+ export function isExplicitPhaseTimeout(
301
+ settings: LegionSettings | undefined,
302
+ phase: string,
303
+ ): boolean {
304
+ return (legionExplicitFields(settings).phaseTimeouts as ReadonlySet<string>).has(phase);
305
+ }
306
+
307
+ /**
308
+ * The `legion` settings a run actually uses, defaults filled (#358 review, major 1).
309
+ *
310
+ * The fill is what lets every consumer read a number, but it must not masquerade
311
+ * as an operator decision: `checkOwnPhaseTimeout` asks
312
+ * {@link legionExplicitFields} whether a phase clock was set by a human, and a
313
+ * built-in default that arrived by being spread in would answer yes. This is the
314
+ * seam that keeps the two apart — replacing it with a plain spread silently turns
315
+ * `settings > machine > default` into `settings(filled) > nothing`, i.e. a card's
316
+ * `timeoutMs` on a built-in phase name never wins.
317
+ */
318
+ export function legionSettingsForRun(
319
+ settings: SubagentsSettings | undefined,
320
+ ): LegionSettings {
321
+ const legion = settings?.legion;
322
+ const resolved: TrackedLegionSettings = {
323
+ ...DEFAULT_LEGION_SETTINGS,
324
+ ...(legion ?? {}),
325
+ phaseTimeouts: {
326
+ ...DEFAULT_LEGION_SETTINGS.phaseTimeouts,
327
+ ...(legion?.phaseTimeouts ?? {}),
328
+ },
329
+ };
330
+ const explicit = legionExplicitFields(legion);
331
+ Object.defineProperty(resolved, LEGION_EXPLICIT_FIELDS, {
332
+ value: {
333
+ fields: new Set(explicit.fields),
334
+ phaseTimeouts: new Set(explicit.phaseTimeouts),
335
+ } satisfies LegionExplicitFields,
336
+ });
337
+ return resolved;
338
+ }
339
+
275
340
  function mergeLegionSettings(
276
341
  user: LegionSettings | undefined,
277
342
  project: LegionSettings | undefined,
package/src/api.ts CHANGED
@@ -187,8 +187,24 @@ export {
187
187
  } from "./runs/worktree.ts";
188
188
 
189
189
  // ── parent session: completion notify + input-box status ────────────────────
190
+ //
191
+ // `SessionRuntime.consumeCollect*` answers with `ClaimedCollect`, not a bare
192
+ // snapshot (#320):
193
+ //
194
+ // ```ts
195
+ // const { snapshot, claimedNow } = await rt.consumeCollect("worker-0")!;
196
+ // ```
197
+ //
198
+ // `claimedNow` is `true` only when THIS call took the round's completion claim, so a
199
+ // caller that bails before handing the result to the agent (an aborted parent turn, a
200
+ // failed persist) rolls it back with `unclaimUndelivered(name, snapshot, claimedNow)`
201
+ // — a call that did not take the claim must leave the claim of the delivery that did
202
+ // alone. The flag travels with the return value on purpose: two collects can await the
203
+ // same job promise and therefore see the same snapshot object, so a per-snapshot record
204
+ // would let the second call overwrite the first one's answer.
190
205
  export { createSessionRuntime, shouldRecycleAfterCollect } from "./extension/runtime.ts";
191
206
  export type {
207
+ ClaimedCollect,
192
208
  CollectSnapshot,
193
209
  SessionRuntime,
194
210
  SessionRuntimeDeps,
@@ -261,6 +277,7 @@ export {
261
277
  extractVerdict,
262
278
  isLastTurnComplete,
263
279
  parseSessionFile,
280
+ parseSessionFileCached,
264
281
  parseSessionText,
265
282
  } from "./shared/session.ts";
266
283
  export {
@@ -34,7 +34,8 @@ export function childTaskAppendix(opts?: {
34
34
  const worktree = opts?.worktreeBranch
35
35
  ? [
36
36
  `- You are in an isolated git worktree on branch \`${opts.worktreeBranch}\`. Do not write the parent checkout.`,
37
- "- Commit on this branch and open a merge request / pull request into the repository default branch. Do not merge locally. Do not push to main or master. Put the MR/PR URL in your verdict reason.",
37
+ "- Commit on this branch. The branch is the deliverable: put its name and HEAD sha in your verdict reason. Do not merge locally; do not push to main or master.",
38
+ "- A merge request / pull request is a convenience, not a requirement: open one only when the repository has a remote and `gh`/`glab` is available, and then add its URL to the verdict. With no remote, write `no remote: branch <name> at <sha>`; never invent a URL, and a missing MR is not a failure.",
38
39
  ]
39
40
  : [];
40
41
  return [
@@ -51,6 +51,13 @@ function defaultJoinConfig(): JoinConfig {
51
51
  export interface JoinEntry {
52
52
  runId: string;
53
53
  name: string;
54
+ /**
55
+ * Reporting round of this child (D10). The runtime assigns it from a per-name
56
+ * monotonic counter in `track()`; the coordinator only carries it, because one
57
+ * round is one deliverable result — two unread rounds of the same child must
58
+ * both survive the buffer, which a `(runId, name)` key cannot express.
59
+ */
60
+ round: number;
54
61
  /** Raw collect result; the deliver sink feeds it to formatGroupedNotice. */
55
62
  input: CompletionInput;
56
63
  /** From the triggering watch() call, forwarded to the delivery options. */
@@ -127,6 +134,37 @@ export class JoinCoordinator {
127
134
  }
128
135
  }
129
136
 
137
+ /**
138
+ * D8: drop the already-TERMINAL entries of one member.
139
+ *
140
+ * `remove()` only clears `pending` (a member that has already finished lives in
141
+ * `buffered`, not in `pending`), so a released child left its buffered entry
142
+ * behind as an orphan that fired at window expiry (W3) — most visibly right
143
+ * after a `wait()` hit, which retires and releases the very child whose entry
144
+ * is sitting in the window. Semantics of `remove()` are otherwise unchanged.
145
+ */
146
+ dropBuffered(runId: string, name: string): void {
147
+ this.dropBufferedWhere(runId, (entry) => entry.name === name);
148
+ }
149
+
150
+ /**
151
+ * D10: drop the terminal entries of ONE ROUND of one member.
152
+ *
153
+ * This is what `release()` needs. `release()` acts on a single tracked job, and
154
+ * a job carries exactly one round, so the copy it may drop is the one that round
155
+ * produced. The name-wide `dropBuffered` above is the REVOKE path (D7): a parent
156
+ * asking to see the child again invalidates every copy of the name. Using the
157
+ * name-wide form from `release()` would silently swallow an EARLIER round's
158
+ * unread notice — the loss D10 exists to prevent (one round = one deliverable
159
+ * result).
160
+ */
161
+ dropBufferedRound(runId: string, name: string, round: number): void {
162
+ this.dropBufferedWhere(
163
+ runId,
164
+ (entry) => entry.name === name && entry.round === round,
165
+ );
166
+ }
167
+
130
168
  /** Terminal entry point (success and collect-failure alike). */
131
169
  onTerminal(entry: JoinEntry): void {
132
170
  if (this.disposed) return;
@@ -184,6 +222,22 @@ export class JoinCoordinator {
184
222
  this.groupOf.clear();
185
223
  }
186
224
 
225
+ /** One implementation for both drops: keep every entry the predicate rejects. */
226
+ private dropBufferedWhere(
227
+ runId: string,
228
+ matches: (entry: BufferedEntry) => boolean,
229
+ ): void {
230
+ if (this.disposed) return;
231
+ const group = this.groups.get(runId);
232
+ if (!group || group.buffered.length === 0) return;
233
+ const kept = group.buffered.filter((entry) => !matches(entry));
234
+ if (kept.length === group.buffered.length) return;
235
+ group.buffered = kept;
236
+ if (group.pending.size === 0 && group.buffered.length === 0) {
237
+ this.dropGroup(runId, group);
238
+ }
239
+ }
240
+
187
241
  private ensureGroup(runId: string): Group {
188
242
  let group = this.groups.get(runId);
189
243
  if (!group) {
@@ -727,6 +727,20 @@ export function registerLegionCommand(
727
727
  if (classicTree) query = "";
728
728
  const vizSnapshot = deps.runtime.vizSnapshot?.bind(deps.runtime);
729
729
  if (!query && !classicTree && vizSnapshot) {
730
+ // #347: mount the page on the main screen instead of as a full-screen
731
+ // overlay. pi-tui composites *every* row of a visible overlay through
732
+ // `compositeTuiLine` (three `Intl.Segmenter` grapheme walks, cached by
733
+ // whole line, which a live frame never repeats); for this page — 56–68%
734
+ // non-ASCII braille/box/block cells — that was 57–70% of the frame and
735
+ // ~183µs/row for the whole terminal height. A main-screen mount (the
736
+ // mount the classic `--tree` view below already uses) replaces
737
+ // the base instead of stacking on it, so the page costs one plain
738
+ // `render()` and nothing else: measured 2.5–2.7× on the same component
739
+ // and data, with the frame's bytes within 3% (`bench/perf-core/09`).
740
+ // Esc/q still return to the session view: the host restores the editor
741
+ // it swapped out. The detail modal opened from inside the page stays a
742
+ // 75% floating overlay (`openLiveOverlay` below).
743
+ // Contract: `docs/contracts/legion-main-screen-mount-v1.md`.
730
744
  await ctx.ui.custom(createLegionViewFactory({
731
745
  getData: () => vizSnapshot(),
732
746
  onStopNode: deps.stop ? (nodeId) => void confirmStopNode(ctx, nodeId) : undefined,
@@ -735,10 +749,7 @@ export function registerLegionCommand(
735
749
  openLiveOverlay(pi, ctx.ui, ctx.cwd, liveOverlay, nodeId, overlayStop(ctx.ui));
736
750
  }
737
751
  },
738
- }), {
739
- overlay: true,
740
- overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 },
741
- });
752
+ }), { overlay: false });
742
753
  return;
743
754
  }
744
755
  if (query) {
@@ -25,6 +25,10 @@ import {
25
25
  roleToolGateNextSteps,
26
26
  } from "../runs/role-tool-gates.ts";
27
27
  import { getAgentDir } from "../agents/paths.ts";
28
+ import {
29
+ hostExtensionDiscoveryState,
30
+ type HostExtensionDiscoveryState,
31
+ } from "../runs/extension-inheritance.ts";
28
32
  import {
29
33
  CHILD_MAX_OLD_SPACE_ENV,
30
34
  CHILD_RESERVE_ENV,
@@ -355,11 +359,19 @@ function sampleAgentConfig(agent?: AgentConfig): AgentConfig {
355
359
  }
356
360
 
357
361
 
358
- /** Fix text for inprocess model/provider gaps — never recommends PI_LEGION_CHILD_EXTENSIONS (#212). */
362
+ /**
363
+ * Fix text for inprocess model/provider gaps (#212, #329).
364
+ *
365
+ * #329: same two levers as `spawn-hints.ts` (#317/#328) — `models.json`, or the
366
+ * `PI_LEGION_CHILD_EXTENSIONS` path list. The dead switch (`inheritExtensions: true`,
367
+ * `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`) must not come back here: `true` is the default
368
+ * policy since #280, so it changes nothing, and when host discovery fails it still yields
369
+ * 0 extensions — the very state doctor is reporting on.
370
+ */
359
371
  export function inprocessModelFix(): string {
360
372
  return (
361
373
  "Declare the provider statically in models.json (agent dir / project), " +
362
- "or set role frontmatter extensions: […] / inheritExtensions: true."
374
+ `or pass the provider extension explicitly with ${CHILD_EXTENSIONS_ENV}=<path>.`
363
375
  );
364
376
  }
365
377
 
@@ -368,7 +380,7 @@ function rpcChildExtensionsFix(providerExtHome: readonly string[]): string | und
368
380
  if (providerExtHome.length) {
369
381
  return `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`;
370
382
  }
371
- return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or set role extensions: / inheritExtensions: true.`;
383
+ return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or add it under the role's extensions: frontmatter.`;
372
384
  }
373
385
 
374
386
  function previewChildArgv(agent: AgentConfig, env: NodeJS.ProcessEnv): string[] {
@@ -427,6 +439,29 @@ function extensionsForRole(agent: AgentConfig, env: NodeJS.ProcessEnv, _extras:
427
439
  return collectChildExtensions(agent, env);
428
440
  }
429
441
 
442
+ /**
443
+ * One-line answer to "is inheritance actually wired?" (#317).
444
+ *
445
+ * The role line above only says `(all host-discovered extensions)`, which reads
446
+ * identically whether discovery worked, failed, or never ran. This turns the
447
+ * three cases into text: `ok`, `failed <n>s ago` (with the age, because the
448
+ * backoff is 5 s and a stale stamp is a different diagnosis than a live one),
449
+ * and `never` (no probe in this process — the common shape for a doctor run
450
+ * before the first spawn).
451
+ */
452
+ function formatDiscoveryProbe(state: HostExtensionDiscoveryState, now = Date.now()): string {
453
+ switch (state.status) {
454
+ case "ok":
455
+ return "ok";
456
+ case "failed": {
457
+ const ageS = Math.max(0, Math.floor((now - (state.at ?? now)) / 1000));
458
+ return `failed ${ageS}s ago`;
459
+ }
460
+ default:
461
+ return "never";
462
+ }
463
+ }
464
+
430
465
  function scanOrphanTemps(storeRoot: string): { total: number; orphanDead: number; live: number; unknown: number; samples: string[] } {
431
466
  const runsRoot = path.join(storeRoot, "runs");
432
467
  let total = 0;
@@ -790,6 +825,12 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
790
825
  push("## Extensions");
791
826
  push(`- spawnMode (sample/default): ${spawnMode}`);
792
827
  push(`- inheritExtensions (env/role default): ${inheritGlobal ? "yes (loads all host extensions)" : "no"}`);
828
+ // #317: `(all host-discovered extensions)` alone cannot say whether inheritance
829
+ // is wired — failed discovery, an empty host and "never probed" all looked the
830
+ // same. The count comes from the last probe in THIS process; doctor never
831
+ // probes on its own, so `never` is honest and cheap.
832
+ const discovery = hostExtensionDiscoveryState();
833
+ push(`- discovered host extensions: ${discovery.count} (last probe: ${formatDiscoveryProbe(discovery)})`);
793
834
  if (isInprocess) {
794
835
  push("- child argv --no-extensions: n/a (inprocess has no child argv)");
795
836
  } else {
@@ -1035,15 +1076,16 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
1035
1076
  const modelIssue = issues.some((i) =>
1036
1077
  i.code === "CHILD_NO_MODELS" || i.code === "TEAM_MODELS_MISSING" || i.code === "RECENT_MODEL_SPAWN_FAIL",
1037
1078
  );
1079
+ // #329: never name the dead inheritance switch here (see `inprocessModelFix`).
1038
1080
  if (modelIssue && isInprocess) {
1039
1081
  steps.push(
1040
- "Inprocess: declare the provider in models.json, or set role extensions: […] / inheritExtensions: true — then restart pi (#212).",
1082
+ `Inprocess: declare the provider in models.json, or pass the provider extension with ${CHILD_EXTENSIONS_ENV}=<path> — then restart pi (#212).`,
1041
1083
  );
1042
1084
  } else if (modelIssue && providerExtHome.length) {
1043
1085
  steps.push(`export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")} # then restart pi`);
1044
- steps.push(`# or role frontmatter extensions: […] / inheritExtensions: true (more RAM)`);
1086
+ steps.push(`# or role frontmatter extensions: […] (role-scoped only)`);
1045
1087
  } else if (modelIssue) {
1046
- steps.push(`# set role frontmatter extensions: […] / inheritExtensions: true, or export ${CHILD_EXTENSIONS_ENV}=<paths>`);
1088
+ steps.push(`# set role frontmatter extensions: […], or export ${CHILD_EXTENSIONS_ENV}=<paths>`);
1047
1089
  }
1048
1090
  steps.push(...roleToolGateNextSteps(issues));
1049
1091
  if (issues.some((i) => i.code === "MEMORY_GATE_WOULD_REFUSE")) {
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { NODE_STATUSES, type LegionNode } from "../legion/nodes.ts";
10
+ import type { InFlightToolStack } from "../supervisor/types.ts";
11
+ import { formatInFlightToolLine } from "../supervisor/types.ts";
10
12
  import { formatTree } from "./legion-tools.ts";
11
13
  import {
12
14
  renderLiveChildStatus,
@@ -14,7 +16,39 @@ import {
14
16
  } from "./runtime.ts";
15
17
 
16
18
  /** Child snapshot the status tool already prints. `now` is supplied by the caller. */
17
- export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now">;
19
+ export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now"> & {
20
+ /**
21
+ * The tool calls in flight right now (#315, #331), innermost last. Read from
22
+ * the supervisor, never persisted: it describes this process's live child, so
23
+ * a rebuilt record (another session's child) has none and none is invented.
24
+ */
25
+ inFlightTools?: InFlightToolStack;
26
+ };
27
+
28
+ /**
29
+ * Put the live-activity line at the TOP of one rendered child block (#315).
30
+ *
31
+ * The fact is the whole stack (#331) so the line can name nesting and parallel
32
+ * siblings; an absent or empty stack renders the block unchanged.
33
+ *
34
+ * A lead line, not a footnote: `working · bash 00:07:31` is the answer to "what
35
+ * is it doing, and for how long", and the block below it already leads its own
36
+ * liveness story with `alive · quiet Ns`. Prepending needs no knowledge of the
37
+ * block's internal line order, so this keeps working if
38
+ * `renderLiveChildStatus` grows or reorders lines.
39
+ *
40
+ * `undefined` (or an empty stack) renders the block unchanged: no fact, no
41
+ * line. This is display only — it never decides liveness and never stops
42
+ * anything.
43
+ */
44
+ export function withInFlightToolLine(
45
+ block: string,
46
+ stack: InFlightToolStack | undefined,
47
+ now: number,
48
+ ): string {
49
+ if (stack === undefined || stack.length === 0) return block;
50
+ return `${formatInFlightToolLine(stack, now)}\n${block}`;
51
+ }
18
52
 
19
53
  export interface LegionStatusJob {
20
54
  name: string;
@@ -72,7 +106,15 @@ function liveJobForNode(node: LegionNode, jobs: readonly LegionStatusJob[]): Leg
72
106
  }
73
107
 
74
108
  function renderFacts(facts: LegionChildStatusFacts, now: number | undefined): string {
75
- return renderLiveChildStatus(withNow(facts, now));
109
+ // The in-flight stack is display-only and lives outside
110
+ // `renderLiveChildStatus`, so it is split off before the snapshot is rendered
111
+ // and prepended afterwards.
112
+ const { inFlightTools, ...snapshot } = facts;
113
+ return withInFlightToolLine(
114
+ renderLiveChildStatus(withNow(snapshot, now)),
115
+ inFlightTools,
116
+ now ?? Date.now(),
117
+ );
76
118
  }
77
119
 
78
120
  function renderNode(