pi-legion 2.0.3 → 3.0.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 (52) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +396 -62
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +15 -3
  23. package/src/extension/legion-doctor.ts +55 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/extension/timing.ts +391 -0
  30. package/src/runs/args.ts +8 -0
  31. package/src/runs/orchestrator.ts +125 -16
  32. package/src/shared/output-cap.ts +2 -2
  33. package/src/shared/session.ts +2 -1
  34. package/src/shared/timing-breakdown.ts +23 -0
  35. package/src/shared/types.ts +74 -0
  36. package/src/supervisor/child-context.ts +90 -0
  37. package/src/supervisor/child-memory.ts +27 -3
  38. package/src/supervisor/inprocess-depth.ts +71 -0
  39. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  40. package/src/supervisor/routing-supervisor.ts +179 -0
  41. package/src/supervisor/rpc-supervisor.ts +14 -0
  42. package/src/supervisor/spawn-mode.ts +35 -0
  43. package/src/supervisor/types.ts +13 -0
  44. package/src/supervisor/ui-proxy.ts +78 -0
  45. package/src/tui/live-overlay.ts +297 -24
  46. package/src/tui/status.ts +71 -35
  47. package/src/tui/viz/layout.ts +50 -6
  48. package/src/tui/viz/model.ts +6 -0
  49. package/src/tui/viz/panels.ts +164 -12
  50. package/src/tui/viz/railway.ts +154 -21
  51. package/src/tui/viz/rate.ts +36 -14
  52. package/src/tui/viz/view.ts +69 -10
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared wall/gen/other math for `/legion timing` (#150) and the TUI timing panel (#151).
3
+ * Zero schema — callers supply wall_ms and generation_ms (null = no turn data → other —).
4
+ */
5
+ export interface TimingBreakdown {
6
+ wall_ms: number;
7
+ generation_ms: number | null;
8
+ /** max(0, wall − gen); null when generation_ms is null. Coarse bucket, not wait/tool. */
9
+ other_ms: number | null;
10
+ }
11
+
12
+ /** Pure breakdown: other = max(0, wall − gen); gen absent → other —. */
13
+ export function breakdown(
14
+ wall_ms: number,
15
+ generation_ms: number | null,
16
+ ): TimingBreakdown {
17
+ return {
18
+ wall_ms,
19
+ generation_ms,
20
+ other_ms:
21
+ generation_ms === null ? null : Math.max(0, wall_ms - generation_ms),
22
+ };
23
+ }
@@ -46,6 +46,11 @@ export const AGENT_KINDS = [
46
46
 
47
47
  export type AgentKind = (typeof AGENT_KINDS)[number];
48
48
 
49
+ /** How Legion launches a child session: omit → `inprocess` (with automatic rpc fallback when the agent needs a worktree or `kind !== "pi"`). */
50
+ export const SPAWN_MODES = ["rpc", "inprocess"] as const;
51
+ export type SpawnMode = (typeof SPAWN_MODES)[number];
52
+
53
+
49
54
  /**
50
55
  * How the model a child starts with was chosen.
51
56
  *
@@ -149,6 +154,15 @@ export interface AgentConfig {
149
154
  kind: AgentKind;
150
155
  worktree?: boolean;
151
156
  steer?: boolean;
157
+ /**
158
+ * Launch backend for this role. Omit → `inprocess` (createAgentSession in
159
+ * the parent Node process), with automatic rpc fallback when the resolved
160
+ * spawn needs a worktree (`worktree: true`, the writer/centurion default)
161
+ * or `kind !== "pi"`. An explicit `inprocess` + `worktree: true` is still
162
+ * refused with `INVALID_PARAMS` — the fallback only applies to the implicit
163
+ * default, never to an explicit request.
164
+ */
165
+ spawnMode?: SpawnMode;
152
166
  onBlocked?: OnBlockedPolicy;
153
167
 
154
168
  // ── provenance ──
@@ -252,6 +266,12 @@ export interface Execution {
252
266
  */
253
267
  export interface RetireOptions {
254
268
  cause?: "session-shutdown";
269
+ /**
270
+ * When false, skip graceful stdin-close and escalate via retire hardening
271
+ * (SIGTERM → SIGKILL + descendant sweep). Default / omitted = graceful.
272
+ * Used by `subagent({ action: "stop", force: true })` (#179).
273
+ */
274
+ graceful?: boolean;
255
275
  }
256
276
 
257
277
  export interface Usage {
@@ -548,6 +568,19 @@ export type TeamMember =
548
568
  export interface TeamConfig {
549
569
  description?: string;
550
570
  members: TeamMember[];
571
+ /**
572
+ * Same forms as `subagents.parentContext` (inline / `@path` / array).
573
+ * Replaces the global parent context for this team; `false` disables
574
+ * parent injection without falling through to global/convention.
575
+ * Omitted → fall back to global `subagents.parentContext` (or convention).
576
+ */
577
+ parentContext?: string | string[] | false;
578
+ /**
579
+ * Same forms as `subagents.childContext`. Replaces the global child
580
+ * context for this team; `false` disables child injection (no fallthrough).
581
+ * Omitted → fall back to global `subagents.childContext` (or convention).
582
+ */
583
+ childContext?: string | string[] | false;
551
584
  }
552
585
 
553
586
  /**
@@ -566,6 +599,37 @@ export interface ScoutGuardSettings {
566
599
  cooldownMs?: number;
567
600
  }
568
601
 
602
+
603
+ /**
604
+ * Concurrency / depth / wait limits for the in-process supervisor (#197).
605
+ * Keys are independent of RPC / legion.* budgets — never fall back to those.
606
+ *
607
+ * Defaults mirror `legion.*` when the whole `inprocess` block is absent:
608
+ * `maxDepth: 4` / `maxChildrenPerNode: 8` / `maxActive: 30`. With an explicit
609
+ * block that omits `maxActive`, `maxActive` is 8. `maxSpawnsPerSession` and
610
+ * `waitTimeoutMs` have no default (unlimited).
611
+ */
612
+ export interface InProcessLimits {
613
+ maxSpawnsPerSession?: number;
614
+ maxDepth?: number;
615
+ maxActive?: number;
616
+ maxChildrenPerNode?: number;
617
+ waitTimeoutMs?: number;
618
+ }
619
+
620
+ /**
621
+ * RPC-child spawn knobs (#193). Opt-in only — default stays no `--approve`.
622
+ * v1 does not emit `--no-approve`.
623
+ */
624
+ export interface RpcSettings {
625
+ /**
626
+ * When true, child `pi --mode rpc` argv includes `--approve` so project
627
+ * `.pi/settings.json` / project resources / extension discovery can load
628
+ * without a prior `/trust` decision. Default false (omit the flag).
629
+ */
630
+ approve?: boolean;
631
+ }
632
+
569
633
  export interface SubagentsSettings {
570
634
  /**
571
635
  * Master switch (default true). `false` stops new launches, system-prompt
@@ -629,6 +693,16 @@ export interface SubagentsSettings {
629
693
  joinFlushMs?: number;
630
694
  /** Main-session recon reminder. Absent fields use scout-guard defaults. */
631
695
  scoutGuard?: ScoutGuardSettings;
696
+ /**
697
+ * Limits for `spawnMode: "inprocess"` children only (#197).
698
+ * RPC path never reads these keys.
699
+ */
700
+ inprocess?: InProcessLimits;
701
+ /**
702
+ * RPC-child spawn options (#193). Default omits `--approve`; set
703
+ * `rpc.approve: true` to append it. Not an inherit-parent-trust channel.
704
+ */
705
+ rpc?: RpcSettings;
632
706
  }
633
707
 
634
708
  /**
@@ -0,0 +1,90 @@
1
+ /**
2
+ * In-process child identity channel (#197 / spawn-default-inprocess-v3 D5).
3
+ *
4
+ * An in-process child session shares the host's `process.env`: it sees the
5
+ * *parent's* values, not an empty environment. So env cannot carry lineage
6
+ * identity for it — `PI_SUBAGENT_CHILD` is empty at the root and the child
7
+ * would be mistaken for another root. Identity therefore travels through an
8
+ * AsyncLocalStorage scope instead.
9
+ *
10
+ * Why the store lives on `globalThis` under `Symbol.for(...)`:
11
+ * `jiti` may re-evaluate this module with `moduleCache: false` (the extension
12
+ * host does exactly that for a child's `--extension` load). A module-level
13
+ * `const store = new AsyncLocalStorage()` would be a *different* instance on
14
+ * every re-evaluation, so a value written by one module instance would be
15
+ * invisible to the other — the store would look empty and identity would be
16
+ * silently lost. `Symbol.for` is registry-backed and `globalThis` survives
17
+ * module re-evaluation (measured: `childMod.store === shared` after jiti
18
+ * reload). Do not replace this with a module-level constant.
19
+ *
20
+ * Security property: AsyncLocalStorage does NOT cross a `spawn()` boundary, so
21
+ * a real subprocess (`bash npm test`, a nested `pi` CLI, …) can never inherit
22
+ * this identity. The env channel lacked exactly that isolation (see the
23
+ * healthy-ghost-nodes incident). Never re-introduce an env fallback here.
24
+ */
25
+
26
+ import { AsyncLocalStorage } from "node:async_hooks";
27
+ import type { NestedPathEntry, SpawnMode } from "../shared/types.ts";
28
+
29
+ /** Lineage identity of one in-process child session. Never routed via process.env. */
30
+ export interface ChildLaunchContext {
31
+ /** Legion node id of this child session (undefined for a root-level in-process child). */
32
+ nodeId?: string;
33
+ /** Parent legion node id. */
34
+ parentNodeId?: string;
35
+ /** Absolute path to legion.db. */
36
+ dbPath?: string;
37
+ /**
38
+ * The caller's nested path segments, so the child's orchestrator can continue it.
39
+ * Same shape as the RPC lineage path (`NestedPathEntry`), not bare strings.
40
+ */
41
+ parentPath?: NestedPathEntry[];
42
+ /** The role's `acceptance.role` (read-only / writer / …). */
43
+ acceptanceRole?: string;
44
+ /** Whether this child may spawn children of its own (maps to allowNestedSubagents). */
45
+ allowNested: boolean;
46
+ /** Utility hard leaf. */
47
+ utility: boolean;
48
+ /** Spawn backend of this child, so the child's doctor/diagnostics can self-report. */
49
+ spawnMode: SpawnMode;
50
+ }
51
+
52
+ const STORE_KEY = Symbol.for("pi-legion.child-context.als");
53
+
54
+ /** Symbol-indexed view of globalThis so the store survives module re-evaluation. */
55
+ type StoreHolder = {
56
+ [key: symbol]: AsyncLocalStorage<ChildLaunchContext> | undefined;
57
+ };
58
+
59
+ // SAFETY: `globalThis` is an object, so a `Symbol.for`-keyed property on it is
60
+ // readable; the only invariant TypeScript cannot check is that whoever wrote
61
+ // that slot first stored an AsyncLocalStorage under this exact symbol key. Both
62
+ // sides of that contract live in this file, so the slot type is authoritative.
63
+ const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??=
64
+ new AsyncLocalStorage<ChildLaunchContext>());
65
+
66
+ /** Run `fn` under `ctx`; everything awaited inside (tool execution included) inherits it. */
67
+ export function runInChildContext<T>(
68
+ ctx: ChildLaunchContext,
69
+ fn: () => Promise<T>,
70
+ ): Promise<T> {
71
+ return store.run(ctx, fn);
72
+ }
73
+
74
+ /** Identity of the current child session; undefined in the root process/session. */
75
+ export function currentChildContext(): ChildLaunchContext | undefined {
76
+ return store.getStore();
77
+ }
78
+
79
+ /**
80
+ * Run `fn` outside any child identity (REV2-7).
81
+ *
82
+ * Parent-side callbacks are reached synchronously from inside a child's await
83
+ * chain — an `onEvent` listener is the case in point. Letting such a callback
84
+ * observe the child's identity makes host code believe it *is* the child (and
85
+ * inherit the child's spawn permissions). Anything leaving the child's own
86
+ * execution path must step out of the scope first.
87
+ */
88
+ export function runOutsideChildContext<T>(fn: () => T): T {
89
+ return store.exit(fn);
90
+ }
@@ -25,16 +25,35 @@ export const CHILD_RESERVE_ENV = "PI_LEGION_CHILD_RESERVE_MB";
25
25
  /** Measured: an idle child is ~100 MB PSS, a long scout session peaks ~160 MB RSS. */
26
26
  export const DEFAULT_CHILD_MAX_OLD_SPACE_MB = 1024;
27
27
  export const DEFAULT_CHILD_SEMI_SPACE_MB = 1;
28
+ /**
29
+ * Empirically: old-space below ~8 MB fails to start on Node v20/v24 (rc 133/134/139).
30
+ * Clamp positive values up to this floor (#175); `0` remains the disable sentinel.
31
+ */
32
+ export const MIN_CHILD_MAX_OLD_SPACE_MB = 16;
28
33
  /** Floor of free memory that must remain after reserving the new child. */
29
34
  export const DEFAULT_MIN_FREE_MEM_MB = 1024;
30
35
  /** Per-child reservation for spawns that are still starting up. */
31
36
  export const DEFAULT_CHILD_RESERVE_MB = 160;
32
37
 
33
- function envMb(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
38
+ function envMb(
39
+ env: NodeJS.ProcessEnv,
40
+ key: string,
41
+ fallback: number,
42
+ minPositive?: number,
43
+ ): number {
34
44
  const raw = env[key]?.trim();
35
45
  if (!raw) return fallback;
36
46
  const n = Number(raw);
37
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
47
+ if (!Number.isFinite(n) || n < 0) return fallback;
48
+ if (n === 0) return 0; // 0 = disabled
49
+ const floored = Math.floor(n);
50
+ if (minPositive !== undefined && floored < minPositive) {
51
+ console.error(
52
+ `[pi-legion] ${key}=${raw} is below the usable minimum ${minPositive} MB; using ${minPositive}.`,
53
+ );
54
+ return minPositive;
55
+ }
56
+ return floored;
38
57
  }
39
58
 
40
59
  /** Child NODE_OPTIONS: host options + memory flags (each disabled by 0). */
@@ -42,7 +61,12 @@ export function childNodeOptions(env: NodeJS.ProcessEnv = process.env): string |
42
61
  const host = env.NODE_OPTIONS?.trim() ?? "";
43
62
  const flags: string[] = [];
44
63
  const semi = envMb(env, CHILD_SEMI_SPACE_ENV, DEFAULT_CHILD_SEMI_SPACE_MB);
45
- const old = envMb(env, CHILD_MAX_OLD_SPACE_ENV, DEFAULT_CHILD_MAX_OLD_SPACE_MB);
64
+ const old = envMb(
65
+ env,
66
+ CHILD_MAX_OLD_SPACE_ENV,
67
+ DEFAULT_CHILD_MAX_OLD_SPACE_MB,
68
+ MIN_CHILD_MAX_OLD_SPACE_MB,
69
+ );
46
70
  if (semi > 0 && !/--max-semi-space-size/.test(host)) flags.push(`--max-semi-space-size=${semi}`);
47
71
  if (old > 0 && !/--max-old-space-size/.test(host)) flags.push(`--max-old-space-size=${old}`);
48
72
  const merged = [host, ...flags].filter(Boolean).join(" ");
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Process-local nesting depth for in-process children (#197).
3
+ * Independent of RPC / PI_SUBAGENT_* depth env.
4
+ *
5
+ * The store lives on `globalThis` under `Symbol.for(...)` rather than in a
6
+ * module-level `const store = new AsyncLocalStorage()`: jiti may re-evaluate
7
+ * this module with `moduleCache: false`, and each re-evaluation would create a
8
+ * fresh, empty store — the depth would silently reset to 0 in a re-loaded
9
+ * module instance and the nesting limit would be bypassed. `globalThis` +
10
+ * `Symbol.for` keeps one instance across module re-evaluation (measured:
11
+ * `childMod.store === shared` after a jiti reload).
12
+ *
13
+ * Depth is also *not* inherited by real subprocesses: AsyncLocalStorage does
14
+ * not cross `spawn()`. Only in-process children (same JS process) accumulate.
15
+ */
16
+
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+
19
+ const STORE_KEY = Symbol.for("pi-legion.inprocess-depth.als");
20
+
21
+ /** Symbol-indexed view of globalThis so the store survives module re-evaluation. */
22
+ type StoreHolder = {
23
+ [key: symbol]: AsyncLocalStorage<number> | undefined;
24
+ };
25
+
26
+ // SAFETY: `globalThis` is an object, so a `Symbol.for`-keyed property on it is
27
+ // readable; the invariant TypeScript cannot check is that the slot holds a
28
+ // number-depth AsyncLocalStorage. Both writer and reader of that slot are in
29
+ // this file, so the declared slot type is authoritative.
30
+ const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??=
31
+ new AsyncLocalStorage<number>());
32
+
33
+ /** Current in-process nesting depth (0 at the host session). */
34
+ export function inProcessDepth(): number {
35
+ return store.getStore() ?? 0;
36
+ }
37
+
38
+ /** Run `fn` at depth = current + 1. */
39
+ export function runAtInProcessChildDepth<T>(fn: () => T): T {
40
+ return store.run(inProcessDepth() + 1, fn);
41
+ }
42
+
43
+ /** Run async `fn` at depth = current + 1. */
44
+ export async function runAtInProcessChildDepthAsync<T>(
45
+ fn: () => Promise<T>,
46
+ ): Promise<T> {
47
+ return store.run(inProcessDepth() + 1, fn);
48
+ }
49
+
50
+ /**
51
+ * Run async `fn` at an absolute depth (REV2-1).
52
+ *
53
+ * `runAtInProcessChildDepthAsync` computes `current + 1`, which is right only
54
+ * while the caller is itself nested. The Orchestrator drives every turn after
55
+ * the first from the **root** chain (`orchestrator.ts` `prompt`/`followUp`), so
56
+ * there `inProcessDepth()` is 0 and `current + 1` would always be 1 — the
57
+ * recorded nesting would collapse and `maxDepth` could never fire, allowing
58
+ * unbounded recursion across generations. Dispatching with the child's own
59
+ * recorded depth (its `spawnDepth`) restores the real nesting level.
60
+ */
61
+ export async function runAtAbsoluteDepth<T>(
62
+ depth: number,
63
+ fn: () => Promise<T>,
64
+ ): Promise<T> {
65
+ return store.run(depth, fn);
66
+ }
67
+
68
+ /** @internal */
69
+ export function resetInProcessDepthForTests(): void {
70
+ // AsyncLocalStorage has no global clear; tests should avoid leaking across awaits.
71
+ }