pi-legion 3.0.0 → 3.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "3.0.0",
3
+ "version": "3.0.3",
4
4
  "description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,14 +49,13 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "scripts": {
52
- "preinstall": "node -e \"const ua=process.env.npm_config_user_agent||'';if(!ua.includes('pnpm')){console.error('Use pnpm to install dependencies (see CONTRIBUTING.md).');process.exit(1)}\"",
53
52
  "typecheck": "tsc --noEmit",
54
53
  "test": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/unit/*.test.ts",
55
54
  "test:integration": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/integration/*.test.ts",
56
55
  "test:live": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/live/*.test.ts",
57
- "test:all": "pnpm run typecheck && pnpm run test && pnpm run test:integration",
56
+ "test:all": "node --run typecheck && node --run test && node --run test:integration",
58
57
  "test:docker": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH sh -c 'echo \"RPC mainline Docker checks only; legacy Herdr workspace/kind/notification scenarios are archived under test/legacy/docker and require pi-legion v0.16.x\" && bash test/docker/verify-1-child-guard.sh && bash test/docker/verify-2-task-appendix.sh && bash test/docker/verify-3-readonly.sh && bash test/docker/verify-4-acceptance.sh && bash test/docker/verify-5-blocked.sh && bash test/docker/verify-6-cache.sh && bash test/docker/verify-7-budget.sh && bash test/docker/verify-8-verify-command.sh && bash test/docker/verify-9-worktree.sh && bash test/docker/verify-10-fallback.sh && bash test/docker/verify-11-nested-alias.sh'",
59
- "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run test:integration",
58
+ "prepublishOnly": "node --run typecheck && node --run test && node --run test:integration",
60
59
  "mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
61
60
  "test:mock-llm": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/unit/mock-llm*.test.ts test/integration/mock-llm*.test.ts test/integration/railway-mock-e2e.test.ts",
62
61
  "test:tui-visual": "docker build -q -f test/docker/Dockerfile.tui-sandbox -t ghcr.io/zzjcool/pi-legion/tui-sandbox:latest . >/dev/null && docker run --rm -v $(pwd):/plugin -w /plugin ghcr.io/zzjcool/pi-legion/tui-sandbox:latest bash test/docker/verify-tui-visual.sh"
@@ -94,9 +93,7 @@
94
93
  "typebox": "^1.3.30",
95
94
  "typescript": "^5.6.0"
96
95
  },
97
- "packageManager": "pnpm@9.15.9",
98
96
  "engines": {
99
- "node": ">=22",
100
- "pnpm": ">=9"
97
+ "node": ">=22"
101
98
  }
102
99
  }
@@ -559,11 +559,11 @@ function applyBehaviorFields(config: AgentConfig, fm: AgentFrontmatter): void {
559
559
  setIf(config, "acceptance", parseAcceptance(fm.acceptance, config.name));
560
560
  setIf(config, "toolBudget", parseBudget(fm.toolBudget));
561
561
  setIf(config, "turnBudget", parseTurnBudget(fm.turnBudget));
562
- // Writers share a checkout with every other parent Pi in this repo.
563
- // Default them onto an isolated worktree so they ship via MR instead.
564
- if (config.worktree === undefined && config.acceptance?.role === "writer") {
565
- config.worktree = true;
566
- }
562
+ // #214 / contract §9: only centurion holds worktree isolation. Writers no
563
+ // longer auto-default to worktree:true — members work in-place; parallel
564
+ // isolated writers are N root-dispatched centurions. Explicit worktree:true
565
+ // on a non-centurion is still demoted at launch (warn once / force false).
566
+ // centurion.md pins worktree:true itself; no auto-default here.
567
567
  }
568
568
 
569
569
  /** Transitional blocked-policy frontmatter retained for approval handling. */
package/src/api.ts CHANGED
@@ -132,7 +132,12 @@ export type { BudgetRefusedEvent, CollectResult, OrchestratorDeps } from "./runs
132
132
  export { RpcSupervisor } from "./supervisor/rpc-supervisor.ts";
133
133
  export { InProcessSupervisor } from "./supervisor/inprocess-supervisor.ts";
134
134
  export { RoutingSupervisor } from "./supervisor/routing-supervisor.ts";
135
- export { resolveSpawnMode, DEFAULT_SPAWN_MODE, DEFAULT_INPROCESS_MAX_ACTIVE } from "./supervisor/spawn-mode.ts";
135
+ export {
136
+ resolveSpawnMode,
137
+ DEFAULT_SPAWN_MODE,
138
+ DEFAULT_INPROCESS_MAX_ACTIVE,
139
+ DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS,
140
+ } from "./supervisor/spawn-mode.ts";
136
141
  export { InMemoryUIProxy, isExtensionUIRequest } from "./supervisor/ui-proxy.ts";
137
142
  export { runInChildContext, currentChildContext } from "./supervisor/child-context.ts";
138
143
  export type { ChildLaunchContext } from "./supervisor/child-context.ts";
@@ -71,6 +71,7 @@ function readSettings(): unknown {
71
71
  export function buildAutoDoctorSnippet(kind: AutoDoctorKind, opts?: {
72
72
  cwd?: string;
73
73
  name?: string;
74
+ spawnMode?: "rpc" | "inprocess";
74
75
  }): string {
75
76
  const settings = readSettings();
76
77
  const report = runLegionDoctor({
@@ -80,6 +81,7 @@ export function buildAutoDoctorSnippet(kind: AutoDoctorKind, opts?: {
80
81
  probeModels: false,
81
82
  expectedModels: expectedModelsFromSettings(settings),
82
83
  sections: [...SECTIONS[kind]],
84
+ spawnMode: opts?.spawnMode,
83
85
  // Failures already recorded by the caller; doctor will pick them up.
84
86
  });
85
87
  let body = report.text.trim();
@@ -108,25 +110,41 @@ export function enrichFailureWithAutoDoctor(
108
110
  cwd?: string;
109
111
  /** When false, skip recording into the recent-failure ring. Default true. */
110
112
  record?: boolean;
113
+ /** Prefer inprocess hints when known (#212). */
114
+ spawnMode?: "rpc" | "inprocess";
111
115
  } = {},
112
116
  ): string {
113
117
  const kind = classifyFailure({ message, stderr: input.stderr, code: input.code });
118
+ // Infer rpc when argv shows --no-extensions and caller did not tag spawnMode (#212).
119
+ const spawnMode =
120
+ input.spawnMode ??
121
+ (input.args?.includes("--no-extensions") ? "rpc" as const : undefined);
114
122
  if (input.record !== false) {
115
123
  recordSpawnFailure({
116
124
  name: input.name ?? "?",
117
125
  message,
118
126
  stderr: input.stderr,
119
- hadNoExtensions: input.args?.includes("--no-extensions"),
127
+ hadNoExtensions:
128
+ spawnMode === "inprocess" ? false : input.args?.includes("--no-extensions"),
129
+ spawnMode,
120
130
  });
121
131
  }
122
132
  if (!kind) return message;
123
133
 
124
134
  const parts = [message];
125
- const hint = spawnFailureHint({ stderr: input.stderr ?? message, args: input.args });
135
+ const hint = spawnFailureHint({
136
+ stderr: input.stderr ?? message,
137
+ args: input.args,
138
+ spawnMode,
139
+ });
126
140
  if (hint) parts.push("", hint);
127
141
 
128
142
  try {
129
- parts.push("", buildAutoDoctorSnippet(kind, { cwd: input.cwd, name: input.name }));
143
+ parts.push("", buildAutoDoctorSnippet(kind, {
144
+ cwd: input.cwd,
145
+ name: input.name,
146
+ spawnMode,
147
+ }));
130
148
  } catch (error) {
131
149
  parts.push(
132
150
  "",
@@ -18,9 +18,12 @@
18
18
  * the whole buffer now (the common "all children done" path).
19
19
  * 3. Otherwise buffer it; the window starts at the FIRST onTerminal.
20
20
  *
21
- * Window expiry: if the parent is mid-turn (parentBusy) and we have not
22
- * already extended MAX_BUSY_EXTENSIONS times, open another flushMs window;
23
- * otherwise flush what is buffered. Pending members survive — a straggler
21
+ * Window expiry: flush what is buffered, once. The window exists only to
22
+ * collect stragglers from one batch — it is NEVER extended because the parent
23
+ * session happens to be busy. A turn-level busy signal was the wrong predictor
24
+ * (the parent is busy for the whole tool loop by definition) and it stacked a
25
+ * second delay on top of the runtime's run-scoped outbox, which is the single
26
+ * place a completion may be held. Pending members survive — a straggler
24
27
  * finishing later starts a fresh batch of its own.
25
28
  */
26
29
 
@@ -34,14 +37,10 @@ export type JoinMode = "each" | "smart";
34
37
 
35
38
  export const DEFAULT_JOIN_MODE: JoinMode = "smart";
36
39
  export const DEFAULT_FLUSH_MS = 10_000;
37
- /** Max times a busy parent extends a window (total ≤ (1+3)×flushMs). */
38
- export const MAX_BUSY_EXTENSIONS = 3;
39
40
 
40
41
  export interface JoinConfig {
41
42
  mode: JoinMode;
42
43
  flushMs: number;
43
- /** Reports whether the parent is mid-turn; absent means "never busy". */
44
- parentBusy?: () => boolean;
45
44
  }
46
45
 
47
46
  /** Default: smart mode with a ten-second window. */
@@ -76,7 +75,6 @@ interface Group {
76
75
  /** Names tracked but not yet terminal (blocked members are excluded). */
77
76
  pending: Set<string>;
78
77
  buffered: BufferedEntry[];
79
- extensions: number;
80
78
  timer?: JoinSchedulerHandle;
81
79
  }
82
80
 
@@ -162,7 +160,6 @@ export class JoinCoordinator {
162
160
  return;
163
161
  }
164
162
  if (!group.timer) {
165
- group.extensions = 0;
166
163
  group.timer = this.scheduleFn(this.config.flushMs, () => {
167
164
  this.onWindowExpired(entry.runId);
168
165
  });
@@ -190,7 +187,7 @@ export class JoinCoordinator {
190
187
  private ensureGroup(runId: string): Group {
191
188
  let group = this.groups.get(runId);
192
189
  if (!group) {
193
- group = { pending: new Set(), buffered: [], extensions: 0 };
190
+ group = { pending: new Set(), buffered: [] };
194
191
  this.groups.set(runId, group);
195
192
  }
196
193
  return group;
@@ -200,14 +197,6 @@ export class JoinCoordinator {
200
197
  const group = this.groups.get(runId);
201
198
  if (!group || this.disposed) return;
202
199
  group.timer = undefined;
203
- const busy = this.config.parentBusy?.() ?? false;
204
- if (busy && group.extensions < MAX_BUSY_EXTENSIONS) {
205
- group.extensions += 1;
206
- group.timer = this.scheduleFn(this.config.flushMs, () => {
207
- this.onWindowExpired(runId);
208
- });
209
- return;
210
- }
211
200
  this.flush(runId);
212
201
  }
213
202
 
@@ -53,8 +53,12 @@ import {
53
53
  } from "../runs/verify-queue.ts";
54
54
  import { ALLOW_NESTED_ENV } from "./budget.ts";
55
55
  import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "./legion-stop.ts";
56
- import type { AgentConfig } from "../shared/types.ts";
57
- import { recentSpawnFailures, type SpawnFailureRecord } from "./spawn-hints.ts";
56
+ import type { AgentConfig, SpawnMode } from "../shared/types.ts";
57
+ import { resolveSpawnMode } from "../supervisor/spawn-mode.ts";
58
+ import {
59
+ recentSpawnFailures,
60
+ type SpawnFailureRecord,
61
+ } from "./spawn-hints.ts";
58
62
  import {
59
63
  formatSessionErrorTitle,
60
64
  sessionErrorsLogPath,
@@ -179,6 +183,11 @@ export interface DoctorOptions {
179
183
  sections?: readonly DoctorSection[];
180
184
  /** Injected session-error summary (tests); defaults to reading storeRoot log. */
181
185
  sessionErrors?: SessionErrorSummary;
186
+ /**
187
+ * Override resolved spawn backend for diagnosis (#212).
188
+ * When omitted, uses `resolveSpawnMode(sampleAgent)` (default inprocess).
189
+ */
190
+ spawnMode?: SpawnMode;
182
191
  }
183
192
 
184
193
  function readJson(file: string): unknown {
@@ -345,6 +354,23 @@ function sampleAgentConfig(agent?: AgentConfig): AgentConfig {
345
354
  );
346
355
  }
347
356
 
357
+
358
+ /** Fix text for inprocess model/provider gaps — never recommends PI_LEGION_CHILD_EXTENSIONS (#212). */
359
+ export function inprocessModelFix(): string {
360
+ return (
361
+ "Declare the provider statically in models.json (agent dir / project), " +
362
+ "or set role frontmatter extensions: […] / inheritExtensions: true."
363
+ );
364
+ }
365
+
366
+ /** RPC-only fix that exports PI_LEGION_CHILD_EXTENSIONS when paths are known. */
367
+ function rpcChildExtensionsFix(providerExtHome: readonly string[]): string | undefined {
368
+ if (providerExtHome.length) {
369
+ return `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`;
370
+ }
371
+ return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or set role extensions: / inheritExtensions: true.`;
372
+ }
373
+
348
374
  function previewChildArgv(agent: AgentConfig, env: NodeJS.ProcessEnv): string[] {
349
375
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "legion-doctor-"));
350
376
  try {
@@ -506,9 +532,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
506
532
 
507
533
  const inheritGlobal = childInheritsExtensions({}, env);
508
534
  const extras = childExtraExtensions(env);
509
- const sample = sampleAgentConfig(options.sampleAgent);
510
- const argv = previewChildArgv(sample, env);
511
- const noExtensions = argv.includes("--no-extensions");
535
+ const sample = sampleAgentConfig(
536
+ options.spawnMode && options.sampleAgent
537
+ ? { ...options.sampleAgent, spawnMode: options.spawnMode }
538
+ : options.spawnMode
539
+ ? { ...sampleAgentConfig(), spawnMode: options.spawnMode }
540
+ : options.sampleAgent,
541
+ );
542
+ const spawnMode: SpawnMode = resolveSpawnMode(sample);
543
+ const isInprocess = spawnMode === "inprocess";
544
+ // Argv preview is RPC-shaped; only meaningful when default/sample backend is rpc (#212).
545
+ const argv = isInprocess ? [] : previewChildArgv(sample, env);
546
+ const noExtensions = isInprocess ? false : argv.includes("--no-extensions");
512
547
  const explicitExts = argv.flatMap((a, i) => (a === "--extension" ? [argv[i + 1]!] : []));
513
548
 
514
549
  const probe = options.probeModels !== false;
@@ -556,7 +591,10 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
556
591
  ].filter((ext, i, all) => all.indexOf(ext) === i);
557
592
  const providerExtHome = providerExtPaths.map((ext) => homeize(ext, home));
558
593
 
559
- if (!inheritGlobal && noExtensions && hostModels.length > 0 && childModels.length === 0) {
594
+ // CHILD_NO_MODELS / CHILD_FEWER_MODELS come from the RPC CLI probe
595
+ // (`pi --no-extensions --list-models`). Skip them when default backend is
596
+ // inprocess — that probe is a false lead for ModelRuntime children (#212).
597
+ if (!isInprocess && !inheritGlobal && noExtensions && hostModels.length > 0 && childModels.length === 0) {
560
598
  issues.push({
561
599
  severity: "error",
562
600
  code: "CHILD_NO_MODELS",
@@ -564,11 +602,9 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
564
602
  message:
565
603
  `Host has ${hostModels.length} model(s) but a child with --no-extensions sees none. ` +
566
604
  `Providers are registered by host packages that children no longer load (#43).`,
567
- fix: providerExtHome.length
568
- ? `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`
569
- : `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or set role extensions: / inheritExtensions: true.`,
605
+ fix: rpcChildExtensionsFix(providerExtHome),
570
606
  });
571
- } else if (!inheritGlobal && noExtensions && hostModels.length > childModels.length && childModels.length > 0) {
607
+ } else if (!isInprocess && !inheritGlobal && noExtensions && hostModels.length > childModels.length && childModels.length > 0) {
572
608
  issues.push({
573
609
  severity: "warning",
574
610
  code: "CHILD_FEWER_MODELS",
@@ -578,18 +614,19 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
578
614
  }
579
615
 
580
616
  const expected = options.expectedModels ?? [];
581
- const missing = inheritGlobal
617
+ // Inprocess shares host discovery after extension drain — compare roster to host.
618
+ const missing = inheritGlobal || isInprocess
582
619
  ? expected.filter((m) => !modelMatches(m, hostModels))
583
620
  : expected.filter((m) => !modelMatches(m, childModels));
584
- if (missing.length > 0 && (childModels.length === 0 || !inheritGlobal)) {
621
+ if (missing.length > 0 && (isInprocess || childModels.length === 0 || !inheritGlobal)) {
585
622
  issues.push({
586
623
  severity: "error",
587
624
  code: "TEAM_MODELS_MISSING",
588
625
  section: "models",
589
- message: `Team/roster model(s) invisible to children: ${missing.join(", ")}`,
590
- fix: providerExtHome.length
591
- ? `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`
592
- : undefined,
626
+ message: isInprocess
627
+ ? `Team/roster model(s) not visible on the host registry (inprocess resolves via ModelRuntime): ${missing.join(", ")}`
628
+ : `Team/roster model(s) invisible to children: ${missing.join(", ")}`,
629
+ fix: isInprocess ? inprocessModelFix() : rpcChildExtensionsFix(providerExtHome),
593
630
  });
594
631
  }
595
632
 
@@ -625,16 +662,30 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
625
662
  }
626
663
 
627
664
  const failures = options.failures ?? recentSpawnFailures();
628
- if (failures.some((f) => f.hadNoExtensions !== false && /not found|No models/i.test(f.stderr ?? f.message))) {
629
- issues.push({
630
- severity: "error",
631
- code: "RECENT_MODEL_SPAWN_FAIL",
632
- section: "spawn",
633
- message: "A recent child spawn failed with a model/provider error under --no-extensions.",
634
- fix: providerExtHome.length
635
- ? `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`
636
- : undefined,
637
- });
665
+ const modelFailRe = /not found|No models|Unknown provider|inprocess model resolve failed/i;
666
+ const recentModelFails = failures.filter((f) => modelFailRe.test(f.stderr ?? f.message));
667
+ if (recentModelFails.length > 0) {
668
+ // Default backend (or tagged failure) decides the fix — never recommend
669
+ // PI_LEGION_CHILD_EXTENSIONS for inprocess (#212).
670
+ const treatAsInprocess =
671
+ isInprocess || recentModelFails.every((f) => f.spawnMode === "inprocess");
672
+ if (treatAsInprocess) {
673
+ issues.push({
674
+ severity: "error",
675
+ code: "RECENT_MODEL_SPAWN_FAIL",
676
+ section: "spawn",
677
+ message: "A recent inprocess child spawn failed to resolve a model/provider.",
678
+ fix: inprocessModelFix(),
679
+ });
680
+ } else if (recentModelFails.some((f) => f.hadNoExtensions !== false)) {
681
+ issues.push({
682
+ severity: "error",
683
+ code: "RECENT_MODEL_SPAWN_FAIL",
684
+ section: "spawn",
685
+ message: "A recent child spawn failed with a model/provider error under --no-extensions.",
686
+ fix: rpcChildExtensionsFix(providerExtHome),
687
+ });
688
+ }
638
689
  }
639
690
 
640
691
  const gate = resolveMemoryGateConfig(env);
@@ -721,8 +772,14 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
721
772
  const warnN = issues.filter((i) => i.severity === "warning").length;
722
773
  push(`- status: ${errN ? "FAIL" : warnN ? "WARN" : "OK"} (${errN} error(s), ${warnN} warning(s))`);
723
774
  push(`- session errors: ${sessionErrors.total}${sessionErrors.logPath ? ` (log ${homeize(sessionErrors.logPath, home)})` : ""}`);
724
- push(`- child --no-extensions: ${noExtensions ? "yes" : "no"}; inherit: ${inheritGlobal ? "yes" : "no"}`);
725
- push(`- models host/child: ${hostModels.length}/${childModels.length}`);
775
+ push(`- spawnMode (sample/default): ${spawnMode}`);
776
+ if (isInprocess) {
777
+ push(`- child backend: inprocess (ModelRuntime; no --no-extensions argv); inherit: ${inheritGlobal ? "yes" : "no"}`);
778
+ push(`- models host (inprocess uses host registry after extension drain): ${hostModels.length}`);
779
+ } else {
780
+ push(`- child --no-extensions: ${noExtensions ? "yes" : "no"}; inherit: ${inheritGlobal ? "yes" : "no"}`);
781
+ push(`- models host/child: ${hostModels.length}/${childModels.length}`);
782
+ }
726
783
  push(`- live children: ${runtime.liveJobs?.length ?? "(unknown — pass runtime)"}`);
727
784
  push(`- free memory: ${availableMb} MB (gate min ${gate.minFreeMb})`);
728
785
  if (storeRoot) push(`- store: ${homeize(storeRoot, home)}`);
@@ -731,8 +788,13 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
731
788
 
732
789
  if (want(selected, "extensions")) {
733
790
  push("## Extensions");
791
+ push(`- spawnMode (sample/default): ${spawnMode}`);
734
792
  push(`- inheritExtensions (env/role default): ${inheritGlobal ? "yes (loads all host extensions)" : "no"}`);
735
- push(`- child argv includes --no-extensions: ${noExtensions ? "yes" : "no"}`);
793
+ if (isInprocess) {
794
+ push("- child argv --no-extensions: n/a (inprocess has no child argv)");
795
+ } else {
796
+ push(`- child argv includes --no-extensions: ${noExtensions ? "yes" : "no"}`);
797
+ }
736
798
  push(`- ${CHILD_INHERIT_EXTENSIONS_ENV}=${env[CHILD_INHERIT_EXTENSIONS_ENV] ?? "(unset)"}`);
737
799
  push(`- ${CHILD_EXTENSIONS_ENV}=${env[CHILD_EXTENSIONS_ENV] ?? "(unset)"}`);
738
800
  push(`- extra extension paths: ${extras.length ? extras.map((p) => homeize(p, home)).join(", ") : "(none)"}`);
@@ -763,10 +825,19 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
763
825
 
764
826
  if (want(selected, "models")) {
765
827
  push("## Models / providers");
828
+ push(`- spawnMode (sample/default): ${spawnMode}`);
766
829
  push(`- host (normal discovery): ${hostModels.length}`);
767
- push(`- child (--no-extensions): ${childModels.length}`);
768
- if (hostModels.length && childModels.length === 0) {
769
- push("- child sees NO models — providers come from host packages that `--no-extensions` skips.");
830
+ if (isInprocess) {
831
+ push("- child probe (`pi --no-extensions --list-models`): skipped as doctor signal for inprocess (#212)");
832
+ push(`- rpc-shaped probe count (informational only): ${childModels.length}`);
833
+ if (hostModels.length === 0) {
834
+ push("- host sees NO models — declare providers in models.json or load provider extensions for inprocess.");
835
+ }
836
+ } else {
837
+ push(`- child (--no-extensions): ${childModels.length}`);
838
+ if (hostModels.length && childModels.length === 0) {
839
+ push("- child sees NO models — providers come from host packages that `--no-extensions` skips.");
840
+ }
770
841
  }
771
842
  if (restoring.length) {
772
843
  push("- packages that restore models when passed via `--extension`:");
@@ -786,11 +857,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
786
857
 
787
858
  if (want(selected, "spawn")) {
788
859
  push("## Spawn");
789
- push(`### Sample child argv (${sample.name})`);
790
- push("```");
791
- push(argv.join(" "));
792
- push("```");
793
- push(`- explicit --extension count: ${explicitExts.length}`);
860
+ push(`- spawnMode (sample/default): ${spawnMode}`);
861
+ if (isInprocess) {
862
+ push(`### Sample child (${sample.name}) — inprocess`);
863
+ push("- no child argv (ModelRuntime + additionalExtensionPaths from role/env)");
864
+ push("- do not treat `pi --no-extensions --list-models` as the primary diagnosis for inprocess (#212)");
865
+ } else {
866
+ push(`### Sample child argv (${sample.name})`);
867
+ push("```");
868
+ push(argv.join(" "));
869
+ push("```");
870
+ push(`- explicit --extension count: ${explicitExts.length}`);
871
+ }
794
872
  push(`- nested spawn env ${ALLOW_NESTED_ENV}=${env[ALLOW_NESTED_ENV] ?? "(unset)"}`);
795
873
  push(`- runtime.allowNestedEnv: ${runtime.allowNestedEnv ?? "(unknown)"}`);
796
874
  push(`- roles with allowNestedSubagents: ${
@@ -954,9 +1032,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
954
1032
  push("");
955
1033
  push("## Next steps (actionable)");
956
1034
  const steps: string[] = [];
957
- if (providerExtHome.length && issues.some((i) => i.code === "CHILD_NO_MODELS" || i.code === "TEAM_MODELS_MISSING" || i.code === "RECENT_MODEL_SPAWN_FAIL")) {
1035
+ const modelIssue = issues.some((i) =>
1036
+ i.code === "CHILD_NO_MODELS" || i.code === "TEAM_MODELS_MISSING" || i.code === "RECENT_MODEL_SPAWN_FAIL",
1037
+ );
1038
+ if (modelIssue && isInprocess) {
1039
+ steps.push(
1040
+ "Inprocess: declare the provider in models.json, or set role extensions: […] / inheritExtensions: true — then restart pi (#212).",
1041
+ );
1042
+ } else if (modelIssue && providerExtHome.length) {
958
1043
  steps.push(`export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")} # then restart pi`);
959
1044
  steps.push(`# or role frontmatter extensions: […] / inheritExtensions: true (more RAM)`);
1045
+ } else if (modelIssue) {
1046
+ steps.push(`# set role frontmatter extensions: […] / inheritExtensions: true, or export ${CHILD_EXTENSIONS_ENV}=<paths>`);
960
1047
  }
961
1048
  steps.push(...roleToolGateNextSteps(issues));
962
1049
  if (issues.some((i) => i.code === "MEMORY_GATE_WOULD_REFUSE")) {
@@ -253,7 +253,13 @@ export class LegionPostman {
253
253
  this.eventBus?.emit(event);
254
254
  reported.add(node.id);
255
255
  this.sendRootMessage(
256
- `Legion orphan detected: ${node.id} (${node.role}, status=${node.status}) has no active supervisor and no heartbeat for ${Math.max(0, timestamp - node.updatedAt)}ms. Resume requires CEO approval.`,
256
+ // The notice can be delivered long after detection: it is queued through
257
+ // `subagent-notify` and the outbox flushes at `MAX_PENDING_AGE_MS` or at
258
+ // `agent_settled`. A stale `staleMs` alone therefore reads as "detected
259
+ // just now" when the value can be tens of minutes old, which makes a
260
+ // replayed notice look like a fresh detection. Stamp the detection time
261
+ // so a late delivery is self-evident.
262
+ `Legion orphan detected at ${new Date(timestamp).toISOString()}: ${node.id} (${node.role}, status=${node.status}) has no active supervisor and no heartbeat for ${Math.max(0, timestamp - node.updatedAt)}ms. Resume requires CEO approval.`,
257
263
  );
258
264
  }
259
265
  }
@@ -13,7 +13,7 @@ export const PARENT_PLAYBOOK = [
13
13
  "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
14
  "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
- "6. Isolation is YOUR call. Pass `worktree: true` when another parent may write this repo or the child must ship via MR; pass `worktree: false` to share the checkout. Omit it for the role default.",
16
+ "6. Isolation belongs to centurion. For N parallel isolated writers, dispatch N centurions (each owns one worktree); members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
18
18
  "Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. The `subagent` tool stays for the command chain (worker/centurion).",
19
19
  "Forbidden: direct bash access to legion.db, hand-launching child Pi processes, or using herdr pane/tab/agent commands for dispatch.",