pi-legion 0.16.0 → 2.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 (89) hide show
  1. package/CONTRIBUTING.md +58 -0
  2. package/README.md +313 -640
  3. package/agents/advisor.md +7 -5
  4. package/agents/centurion.md +53 -0
  5. package/agents/designer.md +33 -33
  6. package/agents/planner.md +1 -2
  7. package/agents/prototype.md +2 -2
  8. package/agents/reviewer.md +67 -31
  9. package/agents/scout.md +6 -2
  10. package/agents/worker.md +12 -3
  11. package/docs/contracts/esc-abort-v1.md +131 -0
  12. package/docs/contracts/esc-cascade-v2.md +100 -0
  13. package/docs/design.md +44 -25
  14. package/docs/legion-v2-contract.md +461 -0
  15. package/docs/legion-v2-delivery.md +62 -0
  16. package/docs/retire-hardening-contract.md +125 -0
  17. package/docs/subagents-teams.md +76 -0
  18. package/docs/tui-viewer-contract.md +276 -0
  19. package/docs/vexillatio-contract.md +162 -0
  20. package/index.ts +1462 -286
  21. package/package.json +12 -9
  22. package/skills/pi-legion/SKILL.md +74 -96
  23. package/src/agents/agents.ts +6 -11
  24. package/src/agents/overrides.ts +2 -1
  25. package/src/agents/paths.ts +1 -1
  26. package/src/agents/settings.ts +268 -20
  27. package/src/api.ts +47 -48
  28. package/src/{herdr → backends/legacy}/client.ts +14 -69
  29. package/src/{shared → backends/legacy}/cursor-chat.ts +2 -2
  30. package/src/{herdr → backends/legacy}/runner.ts +22 -9
  31. package/src/backends/legacy/types.ts +138 -0
  32. package/src/extension/auto-doctor.ts +144 -0
  33. package/src/extension/budget.ts +2 -2
  34. package/src/extension/child-guard.ts +214 -201
  35. package/src/extension/event-bus.ts +263 -0
  36. package/src/extension/legion-commands.ts +719 -0
  37. package/src/extension/legion-doctor.ts +888 -0
  38. package/src/extension/legion-stop.ts +150 -0
  39. package/src/extension/legion-tools.ts +816 -0
  40. package/src/extension/notify.ts +24 -19
  41. package/src/extension/playbook.ts +20 -89
  42. package/src/extension/recycle.ts +2 -7
  43. package/src/extension/runtime.ts +655 -105
  44. package/src/extension/scout-guard.ts +483 -0
  45. package/src/extension/slash.ts +89 -438
  46. package/src/extension/spawn-hints.ts +79 -0
  47. package/src/extension/summary.ts +17 -58
  48. package/src/legion/budget.ts +165 -0
  49. package/src/legion/db.ts +242 -0
  50. package/src/legion/events.ts +150 -0
  51. package/src/legion/mail.ts +412 -0
  52. package/src/legion/nodes.ts +461 -0
  53. package/src/legion/rebuild.ts +533 -0
  54. package/src/legion/state-machine.ts +313 -0
  55. package/src/legion/usage.ts +101 -0
  56. package/src/runs/acceptance.ts +82 -25
  57. package/src/runs/args.ts +51 -5
  58. package/src/runs/hygiene.ts +335 -0
  59. package/src/runs/orchestrator.ts +790 -1683
  60. package/src/runs/owner.ts +61 -0
  61. package/src/runs/provision.ts +135 -0
  62. package/src/runs/store.ts +44 -17
  63. package/src/runs/verify-queue.ts +195 -0
  64. package/src/shared/name.ts +29 -0
  65. package/src/shared/output-cap.ts +57 -0
  66. package/src/shared/progress.ts +57 -135
  67. package/src/shared/session.ts +243 -171
  68. package/src/shared/types.ts +107 -208
  69. package/src/supervisor/child-memory.ts +140 -0
  70. package/src/supervisor/proc-tree.ts +103 -0
  71. package/src/supervisor/rpc-supervisor.ts +1033 -0
  72. package/src/supervisor/types.ts +153 -0
  73. package/src/supervisor/ui-proxy.ts +226 -0
  74. package/src/tui/chat-items.ts +330 -0
  75. package/src/tui/live-overlay.ts +874 -0
  76. package/src/tui/status.ts +553 -27
  77. package/src/tui/tree-view.ts +808 -0
  78. package/src/tui/viz/canvas.ts +119 -0
  79. package/src/tui/viz/layout.ts +111 -0
  80. package/src/tui/viz/model.ts +123 -0
  81. package/src/tui/viz/palette.ts +188 -0
  82. package/src/tui/viz/panels.ts +530 -0
  83. package/src/tui/viz/railway.ts +313 -0
  84. package/src/tui/viz/rate.ts +211 -0
  85. package/src/tui/viz/view.ts +228 -0
  86. package/src/extension/parent-label.ts +0 -115
  87. package/src/profiles/classify.ts +0 -375
  88. package/src/profiles/profiles.ts +0 -730
  89. package/src/runs/layout.ts +0 -258
@@ -6,7 +6,7 @@
6
6
  * - Additive changes (new optional fields) are allowed.
7
7
  * - Breaking changes require coordinating all modules.
8
8
  *
9
- * Design authority: pi-legion design.md (docs/design.md) (33 measured findings F1-F33).
9
+ * Design authority: docs/legion-v2-contract.md.
10
10
  */
11
11
 
12
12
  // =============================================================================
@@ -17,7 +17,7 @@ export type AgentScope = "user" | "project" | "both";
17
17
 
18
18
  export type SystemPromptMode = "replace" | "append";
19
19
 
20
- /** herdr agent kinds we can launch. Mirrors `herdr agent start --kind`. */
20
+ /** Agent kinds parsed from role files; the RPC supervisor accepts only `pi`. */
21
21
  export const AGENT_KINDS = [
22
22
  "pi",
23
23
  "claude",
@@ -60,9 +60,6 @@ export type AgentKind = (typeof AGENT_KINDS)[number];
60
60
  */
61
61
  export type ModelOrigin = "explicit" | "inherited";
62
62
 
63
- /** Where a subagent's pane is placed (design §8.3). */
64
- export type Placement = "split-down" | "split-right" | "new-tab";
65
-
66
63
  /** What to do when an agent reports `blocked` (design §5.3). */
67
64
  export type OnBlockedPolicy = "forward" | "auto-approve" | "notify";
68
65
 
@@ -123,6 +120,12 @@ export interface AgentConfig {
123
120
  systemPromptMode: SystemPromptMode;
124
121
  inheritProjectContext: boolean;
125
122
  inheritSkills: boolean;
123
+ /**
124
+ * Load the host's discovered extensions (settings `packages`, project
125
+ * extensions) in the child. Default false: children get `--no-extensions`
126
+ * plus only explicit `--extension` paths (#43).
127
+ */
128
+ inheritExtensions?: boolean;
126
129
 
127
130
  // ── behavior ──
128
131
  output?: string;
@@ -135,15 +138,15 @@ export interface AgentConfig {
135
138
  completionGuard?: boolean;
136
139
  maxSubagentDepth?: number;
137
140
  allowNestedSubagents?: boolean;
141
+ utility?: boolean;
138
142
  toolBudget?: ToolBudgetConfig;
139
143
  turnBudget?: TurnBudgetConfig;
140
144
  disabled?: boolean;
141
145
  alias?: string[];
142
146
  extraFields?: Record<string, string>;
143
147
 
144
- // ── herdr-specific ──
148
+ // ── execution metadata ──
145
149
  kind: AgentKind;
146
- placement?: Placement;
147
150
  worktree?: boolean;
148
151
  steer?: boolean;
149
152
  onBlocked?: OnBlockedPolicy;
@@ -283,16 +286,12 @@ export interface NestedPathEntry {
283
286
  export const MAX_NESTED_PATH_ENTRIES = 4;
284
287
 
285
288
  export interface ChildRecord {
286
- /** herdr agent name, globally unique among live agents. */
289
+ /** Supervisor-local child name; unique among live children in this run. */
287
290
  name: string;
288
- /** Pane id, or null once recycled (F12: pane is not the durable carrier). */
289
- paneId: string | null;
290
- /** Owning tab when the child got its own tab (placement: "new-tab"). */
291
- tabId?: string;
292
291
  /** ★ resume credential — must persist. */
293
292
  sessionFile: string;
294
293
  sessionId?: string;
295
- /** Proves we created this pane (guards against killing others'). */
294
+ /** Stable per-run child identity; legacy records used a pane owner token. */
296
295
  ownerToken: string;
297
296
 
298
297
  // lifecycle
@@ -300,7 +299,7 @@ export interface ChildRecord {
300
299
  spawnedAt: string;
301
300
  retiredAt?: string;
302
301
 
303
- // outcome (snapshot taken BEFORE recycle — F27)
302
+ // outcome (snapshot taken before child retirement — F27)
304
303
  execution?: Execution;
305
304
  acceptance?: AcceptanceResult;
306
305
  /**
@@ -321,7 +320,7 @@ export interface ChildRecord {
321
320
  onBlocked?: OnBlockedPolicy;
322
321
  /** Snapshotted at launch; missing `{"ok":…}` on success becomes rejected. */
323
322
  completionGuard?: boolean;
324
- /** Isolated git worktree the child's pane used; left on disk after retire. */
323
+ /** Isolated git worktree used by the child; left on disk after retire. */
325
324
  worktreePath?: string;
326
325
  /** Branch created in that worktree; the child ships via MR, not the parent checkout. */
327
326
  worktreeBranch?: string;
@@ -335,23 +334,8 @@ export interface ChildRecord {
335
334
  /** Model actually started with (resolved + fallback). */
336
335
  model?: string;
337
336
  thinking?: string | false;
338
- /**
339
- * Models that were requested but could not be handed to the target CLI's
340
- * `--model` (`nativeModelFor` returns undefined for them), so the child runs
341
- * on the CLI's own default instead.
342
- *
343
- * Only reachable for an INHERITED model — an explicitly chosen one is
344
- * refused before launch (see `Orchestrator.launch`'s `modelOrigin`). This is
345
- * the intended case: a `kind: cursor` role with no model of its own
346
- * inherits the parent's pi-shaped model, which must not be forwarded.
347
- */
337
+ /** Models that were requested but cannot be represented by the RPC child. */
348
338
  modelDropped?: string[];
349
- /**
350
- * Exact text sent with `herdr agent prompt`. Non-pi collect reads the pane,
351
- * which still contains this echo; strip it before extracting a verdict so a
352
- * template `{"ok": true}` in the system prompt cannot attest success.
353
- */
354
- promptText?: string;
355
339
  }
356
340
 
357
341
  export interface RunRecord {
@@ -361,15 +345,10 @@ export interface RunRecord {
361
345
  cwd: string;
362
346
 
363
347
  herdr: {
364
- workspaceId?: string;
365
- tabId?: string;
366
- tabLabel?: string;
367
- /**
368
- * Parent Pi's herdr pane (`HERDR_PANE_ID`). Scopes type-tab labels and
369
- * control-action lookup so two parent sessions in the same repo/Space
370
- * cannot steer or recycle each other's children.
371
- */
372
- parentPaneId?: string;
348
+ /** Current transport marker under the historic run.json key. */
349
+ supervisor?: "rpc";
350
+ /** Reserved for M2 tree identity. */
351
+ node_id?: string;
373
352
  };
374
353
 
375
354
  /** Lineage: root → this node. */
@@ -385,159 +364,6 @@ export interface RunRecord {
385
364
  updatedAt: string;
386
365
  }
387
366
 
388
- // =============================================================================
389
- // herdr client (design §9) — thin, testable wrapper over the CLI
390
- // =============================================================================
391
-
392
- export interface HerdrError {
393
- code: string;
394
- message: string;
395
- details?: unknown;
396
- }
397
-
398
- export type HerdrResult<T> =
399
- | { ok: true; value: T }
400
- | { ok: false; error: HerdrError };
401
-
402
- export interface PaneInfo {
403
- pane_id: string;
404
- tab_id: string;
405
- workspace_id: string;
406
- agent_status?: string;
407
- cwd?: string | null;
408
- terminal_title_stripped?: string;
409
- }
410
-
411
- export interface AgentInfo {
412
- name?: string | null;
413
- pane_id: string;
414
- tab_id: string;
415
- workspace_id: string;
416
- agent: string | null;
417
- agent_status: string;
418
- cwd?: string | null;
419
- agent_session?: { kind: string; source: string; value: string } | null;
420
- state_labels?: Record<string, string>;
421
- tokens?: unknown;
422
- }
423
-
424
- export interface TabInfo {
425
- tab_id: string;
426
- workspace_id: string;
427
- label?: string | null;
428
- pane_count: number;
429
- }
430
-
431
- export interface AgentStartResult {
432
- name: string;
433
- paneId: string;
434
- argv: string[];
435
- /** Session path reported by herdr (F1) — absent for non-pi kinds (F7). */
436
- sessionPath?: string;
437
- agentStatus: string;
438
- }
439
-
440
- export interface ProcessInfo {
441
- foregroundProcesses: Array<{
442
- argv: string[];
443
- cmdline: string;
444
- pid: number;
445
- name: string;
446
- cwd?: string;
447
- }>;
448
- shellPid?: number;
449
- }
450
-
451
- /** Injectable runner so the client is unit-testable without a live herdr. */
452
- export type CommandRunner = (
453
- args: string[],
454
- opts?: { timeoutMs?: number; env?: Record<string, string | undefined> },
455
- ) => Promise<{ stdout: string; stderr: string; code: number }>;
456
-
457
- export interface HerdrClient {
458
- // ── panes ──
459
- paneSplit(opts: {
460
- target?: string;
461
- current?: boolean;
462
- direction: "right" | "down";
463
- cwd?: string;
464
- env?: Record<string, string>;
465
- focus?: boolean;
466
- }): Promise<HerdrResult<PaneInfo>>;
467
- paneClose(paneId: string): Promise<HerdrResult<void>>;
468
- paneRead(
469
- paneId: string,
470
- opts?: { source?: ReadSource; lines?: number },
471
- ): Promise<HerdrResult<string>>;
472
- paneList(): Promise<HerdrResult<PaneInfo[]>>;
473
- paneGet(paneId: string): Promise<HerdrResult<PaneInfo>>;
474
- paneProcessInfo(paneId: string): Promise<HerdrResult<ProcessInfo>>;
475
- paneReportMetadata(opts: {
476
- paneId: string;
477
- source: string;
478
- displayAgent?: string;
479
- title?: string;
480
- tokens?: Record<string, string>;
481
- /** `idle=waiting 2 subagents` — labels a herdr agent_status from this source. */
482
- stateLabel?: { status: string; text: string };
483
- /** Auto-expiry so a crashed reporter leaves no stale label. */
484
- ttlMs?: number;
485
- /** Drop all state-labels set by this source. */
486
- clearStateLabels?: boolean;
487
- }): Promise<HerdrResult<void>>;
488
-
489
- // ── tabs ──
490
- tabCreate(opts: {
491
- cwd?: string;
492
- label?: string;
493
- focus?: boolean;
494
- /** Environment for the tab's root pane process (lineage propagation). */
495
- env?: Record<string, string>;
496
- /** Pin the new tab to this Space (`herdr tab create --workspace`). */
497
- workspaceId?: string;
498
- }): Promise<HerdrResult<{ tab: TabInfo; rootPaneId: string }>>;
499
- tabClose(tabId: string): Promise<HerdrResult<void>>;
500
- tabRename(tabId: string, label: string): Promise<HerdrResult<void>>;
501
- tabList(workspaceId?: string): Promise<HerdrResult<TabInfo[]>>;
502
-
503
- // ── agents ──
504
- agentStart(opts: {
505
- name: string;
506
- kind: AgentKind;
507
- paneId: string;
508
- args?: string[];
509
- timeoutMs?: number;
510
- }): Promise<HerdrResult<AgentStartResult>>;
511
- agentPrompt(
512
- target: string,
513
- text: string,
514
- opts?: { wait?: boolean; timeoutMs?: number },
515
- ): Promise<HerdrResult<AgentInfo>>;
516
- agentGet(target: string): Promise<HerdrResult<AgentInfo>>;
517
- agentList(): Promise<HerdrResult<AgentInfo[]>>;
518
- agentSendKeys(target: string, ...keys: string[]): Promise<HerdrResult<void>>;
519
- agentWait(
520
- target: string,
521
- opts?: { until?: string[]; timeoutMs?: number },
522
- ): Promise<HerdrResult<void>>;
523
-
524
- // ── meta ──
525
- version(): Promise<HerdrResult<string>>;
526
- available(): Promise<boolean>;
527
-
528
- // ── integrations ──
529
- /** One integration's status, or null when herdr cannot report it. */
530
- integrationStatus(target: string): Promise<HerdrResult<string | null>>;
531
- /** Install an integration hook (idempotent on reinstall). */
532
- integrationInstall(target: string): Promise<HerdrResult<string>>;
533
- }
534
-
535
- export type ReadSource =
536
- | "visible"
537
- | "recent"
538
- | "recent-unwrapped"
539
- | "detection";
540
-
541
367
  // =============================================================================
542
368
  // Session parsing (design §10)
543
369
  // =============================================================================
@@ -566,10 +392,21 @@ export interface ParsedSession {
566
392
  /** True when the last user prompt has no assistant reply (F29 abort signal). */
567
393
  lastTurnMissing: boolean;
568
394
  tornLines: number;
395
+ /**
396
+ * Cumulative model-generation wall time in ms: each completed assistant
397
+ * message timestamp minus the preceding user or toolResult timestamp when
398
+ * both are known. Tool/idle gaps are excluded. Approximate — session JSONL
399
+ * records finished assistant messages, not mid-stream token boundaries.
400
+ */
401
+ generationMs: number;
569
402
  }
570
403
 
571
404
  export interface TurnRecord {
572
405
  userText: string;
406
+ /** Unix ms timestamp of the user message that opened this turn, when written. */
407
+ startedAt?: number;
408
+ /** Unix ms timestamp of the last assistant message in this turn, when written. */
409
+ endedAt?: number;
573
410
  assistants: Array<{
574
411
  stopReason: StopReason | null;
575
412
  /**
@@ -606,7 +443,6 @@ export interface TurnRecord {
606
443
  */
607
444
  export interface Handle {
608
445
  name: string;
609
- paneId: string | null;
610
446
  sessionFile: string;
611
447
  runId: string;
612
448
  agent: string;
@@ -637,15 +473,64 @@ export interface PresetConfig {
637
473
  thinking?: string | false;
638
474
  }
639
475
 
476
+ /** @deprecated Transitional herdr.* settings retained until v3. */
640
477
  export interface HerdrSettings {
641
- defaultPlacement?: Placement;
478
+ /** @deprecated Parsed for transition compatibility; ignored by RPC v2. */
479
+ defaultPlacement?: "split-down" | "split-right" | "new-tab";
480
+ /** @deprecated Legacy concurrency hint; ignored by RPC v2. */
642
481
  maxConcurrentAgents?: number;
482
+ /** @deprecated Legacy Herdr launch retry setting. */
643
483
  startRetries?: number;
484
+ /** @deprecated Legacy Herdr launch retry setting. */
644
485
  startRetryBackoffMs?: number;
486
+ /** @deprecated Legacy session retention setting. */
645
487
  sessionRetentionDays?: number;
488
+ /** @deprecated Legacy session retention setting. */
646
489
  sessionRetentionMaxBytesPerRun?: number;
647
490
  }
648
491
 
492
+ export interface LegionPhaseTimeouts {
493
+ planning?: number;
494
+ implementing?: number;
495
+ reviewing?: number;
496
+ verifying?: number;
497
+ }
498
+
499
+ /** Settings for the v2 legion tree, incrementally consumed by its milestones. */
500
+ export interface LegionSettings {
501
+ maxDepth?: number;
502
+ maxChildrenPerNode?: number;
503
+ maxActiveNodes?: number;
504
+ phaseTimeouts?: LegionPhaseTimeouts;
505
+ verifyCommandTimeoutMs?: number;
506
+ /** Post-create provisioning for child worktrees: "npm-install" or a shell command. */
507
+ worktreeBootstrap?: string;
508
+ worktreeBootstrapTimeoutMs?: number;
509
+ mailRatePer5Min?: number;
510
+ usagePollMs?: number;
511
+ orphanStaleMs?: number;
512
+ }
513
+
514
+ /** Defaults from the frozen v2 contract §11.4. */
515
+ export const DEFAULT_LEGION_SETTINGS = {
516
+ maxDepth: 4,
517
+ maxChildrenPerNode: 8,
518
+ maxActiveNodes: 30,
519
+ phaseTimeouts: {
520
+ planning: 900_000,
521
+ implementing: 3_600_000,
522
+ reviewing: 1_800_000,
523
+ verifying: 900_000,
524
+ },
525
+ verifyCommandTimeoutMs: 600_000,
526
+ // Provisioning is opt-in: no default mode, a generous install budget.
527
+ worktreeBootstrap: "",
528
+ worktreeBootstrapTimeoutMs: 600_000,
529
+ mailRatePer5Min: 10,
530
+ usagePollMs: 30_000,
531
+ orphanStaleMs: 90_000,
532
+ } as const satisfies Required<LegionSettings>;
533
+
649
534
  export type AgentOverride = Partial<AgentConfig> & { disabled?: boolean };
650
535
 
651
536
  export type TeamMember =
@@ -657,6 +542,22 @@ export interface TeamConfig {
657
542
  members: TeamMember[];
658
543
  }
659
544
 
545
+ /**
546
+ * Main-session scout guard (issue #17).
547
+ *
548
+ * After `threshold` consecutive read-like tool calls with no subagent or
549
+ * vexillatio dispatch, the parent session gets one follow-up reminder and
550
+ * then waits `cooldownMs`. Defaults: enabled, threshold 8, cooldown 120000.
551
+ * `/scout-guard` and `/no-scout-guard` override `enabled` for this session.
552
+ */
553
+ export interface ScoutGuardSettings {
554
+ enabled?: boolean;
555
+ /** Positive integer. Default 8. */
556
+ threshold?: number;
557
+ /** Integer >= 0. Default 120000. */
558
+ cooldownMs?: number;
559
+ }
560
+
660
561
  export interface SubagentsSettings {
661
562
  /**
662
563
  * Master switch (default true). `false` stops new launches, system-prompt
@@ -711,11 +612,15 @@ export interface SubagentsSettings {
711
612
  disableBuiltins?: boolean;
712
613
  disableThinking?: boolean;
713
614
  maxSubagentSpawnsPerSession?: number;
615
+ /** @deprecated Transitional herdr.* keys are parsed and ignored by RPC v2. */
714
616
  herdr?: HerdrSettings;
617
+ legion?: LegionSettings;
715
618
  /** Completion-notice merge mode. Default "smart". */
716
619
  joinMode?: "each" | "smart";
717
620
  /** smart-mode group flush window (ms). Default 10000. */
718
621
  joinFlushMs?: number;
622
+ /** Main-session recon reminder. Absent fields use scout-guard defaults. */
623
+ scoutGuard?: ScoutGuardSettings;
719
624
  }
720
625
 
721
626
  /**
@@ -766,6 +671,7 @@ export class SubagentError extends Error {
766
671
  export const ErrorCodes = {
767
672
  HERDR_UNAVAILABLE: "HERDR_UNAVAILABLE",
768
673
  DISABLED: "DISABLED",
674
+ /** @deprecated Legacy Herdr client error retained for API compatibility. */
769
675
  PANE_BUSY: "PANE_BUSY",
770
676
  START_FAILED: "START_FAILED",
771
677
  START_TIMEOUT: "START_TIMEOUT",
@@ -777,6 +683,7 @@ export const ErrorCodes = {
777
683
  NOT_FOUND: "NOT_FOUND",
778
684
  RETIRE_FAILED: "RETIRE_FAILED",
779
685
  INVALID_PARAMS: "INVALID_PARAMS",
686
+ INSUFFICIENT_MEMORY: "INSUFFICIENT_MEMORY",
780
687
  } as const;
781
688
 
782
689
  // =============================================================================
@@ -784,28 +691,20 @@ export const ErrorCodes = {
784
691
  // =============================================================================
785
692
 
786
693
  export const DEFAULTS = {
787
- /** F19/F20: agent_pane_busy race; retry with backoff. */
694
+ /** @deprecated Legacy Herdr start-retry settings retained by the archived backend. */
788
695
  startRetries: 40,
789
696
  startRetryBackoffMs: 150,
790
697
  /** F8: agent wait covers a turn; generous default. */
791
698
  turnTimeoutMs: 900_000,
792
699
  /** jsonl quiet window before declaring a turn settled. */
793
700
  settleQuietMs: 2_500,
701
+ /** @deprecated Legacy Herdr concurrency hint retained during migration. */
794
702
  maxConcurrentAgents: 6,
703
+ /** @deprecated Legacy Herdr session retention setting. */
795
704
  sessionRetentionDays: 7,
796
- /** F22: missing binary surfaces as a ~15s timeout. */
705
+ /** @deprecated Legacy Herdr probe settings; retained for the archived backend. */
797
706
  startTimeoutMs: 45_000,
798
707
  binaryProbeTimeoutMs: 3_000,
799
- /**
800
- * Default wall clock for ONE herdr CLI invocation (agent get, pane list,
801
- * tab create, …). Calls that need longer pass their own timeoutMs and are
802
- * NOT capped by this.
803
- *
804
- * Measured (hw 2026-10-09): a fork storm on the host wedged the herdr CLI
805
- * mid-invocation; with no cap, ONE stuck `agent get` froze the whole
806
- * collect/wait chain forever — the parent's `wait`/`collect` tool calls
807
- * never returned (55+ min, no toolResult). Every short control call now
808
- * fails fast with a timeout error the poll loops already tolerate.
809
- */
708
+ /** @deprecated Legacy Herdr command timeout retained for the archived backend. */
810
709
  commandTimeoutMs: 15_000,
811
710
  } as const;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Child process memory controls.
3
+ *
4
+ * A) V8 flags for child pi processes, delivered via NODE_OPTIONS:
5
+ * `--max-semi-space-size=1` (smaller young generation) and a soft
6
+ * `--max-old-space-size` cap. The host's original NODE_OPTIONS is carried in
7
+ * `PI_LEGION_HOST_NODE_OPTIONS` so the child restores it at extension load
8
+ * and the flags never leak into bash commands the child runs (tsc /
9
+ * npm test inheriting a low heap cap would crash).
10
+ *
11
+ * B) Memory gate: refuse a spawn when available memory (minus in-flight
12
+ * reservations) is below a floor, instead of letting the host OOM.
13
+ */
14
+
15
+ import { execFileSync } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+
19
+ export const CHILD_MAX_OLD_SPACE_ENV = "PI_LEGION_CHILD_MAX_OLD_SPACE_MB";
20
+ export const CHILD_SEMI_SPACE_ENV = "PI_LEGION_CHILD_SEMI_SPACE_MB";
21
+ export const HOST_NODE_OPTIONS_ENV = "PI_LEGION_HOST_NODE_OPTIONS";
22
+ export const MIN_FREE_MEM_ENV = "PI_LEGION_MIN_FREE_MEM_MB";
23
+ export const CHILD_RESERVE_ENV = "PI_LEGION_CHILD_RESERVE_MB";
24
+
25
+ /** Measured: an idle child is ~100 MB PSS, a long scout session peaks ~160 MB RSS. */
26
+ export const DEFAULT_CHILD_MAX_OLD_SPACE_MB = 1024;
27
+ export const DEFAULT_CHILD_SEMI_SPACE_MB = 1;
28
+ /** Floor of free memory that must remain after reserving the new child. */
29
+ export const DEFAULT_MIN_FREE_MEM_MB = 1024;
30
+ /** Per-child reservation for spawns that are still starting up. */
31
+ export const DEFAULT_CHILD_RESERVE_MB = 160;
32
+
33
+ function envMb(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
34
+ const raw = env[key]?.trim();
35
+ if (!raw) return fallback;
36
+ const n = Number(raw);
37
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
38
+ }
39
+
40
+ /** Child NODE_OPTIONS: host options + memory flags (each disabled by 0). */
41
+ export function childNodeOptions(env: NodeJS.ProcessEnv = process.env): string | undefined {
42
+ const host = env.NODE_OPTIONS?.trim() ?? "";
43
+ const flags: string[] = [];
44
+ 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);
46
+ if (semi > 0 && !/--max-semi-space-size/.test(host)) flags.push(`--max-semi-space-size=${semi}`);
47
+ if (old > 0 && !/--max-old-space-size/.test(host)) flags.push(`--max-old-space-size=${old}`);
48
+ const merged = [host, ...flags].filter(Boolean).join(" ");
49
+ return merged || undefined;
50
+ }
51
+
52
+ /** Env entries the supervisor adds to every child pi process. */
53
+ export function childMemoryEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string | undefined> {
54
+ const nodeOptions = childNodeOptions(env);
55
+ if (nodeOptions === (env.NODE_OPTIONS?.trim() || undefined)) return {};
56
+ return { NODE_OPTIONS: nodeOptions, [HOST_NODE_OPTIONS_ENV]: env.NODE_OPTIONS ?? "" };
57
+ }
58
+
59
+ /**
60
+ * Called once in the child at extension load: put the host's NODE_OPTIONS back
61
+ * so tool subprocesses (bash → tsc / npm test) do not inherit the child caps.
62
+ */
63
+ export function restoreHostNodeOptions(env: NodeJS.ProcessEnv = process.env): void {
64
+ if (!(HOST_NODE_OPTIONS_ENV in env)) return;
65
+ const host = env[HOST_NODE_OPTIONS_ENV] ?? "";
66
+ if (host) env.NODE_OPTIONS = host;
67
+ else delete env.NODE_OPTIONS;
68
+ delete env[HOST_NODE_OPTIONS_ENV];
69
+ }
70
+
71
+ // ── B) memory gate ────────────────────────────────────────────────────
72
+
73
+ /** Available memory in MB: cgroup limit, /proc/meminfo, macOS vm_stat, os.freemem. */
74
+ export function availableMemoryMb(): number {
75
+ const candidates: number[] = [];
76
+ try {
77
+ const m = /MemAvailable:\s+(\d+)\s+kB/.exec(fs.readFileSync("/proc/meminfo", "utf8"));
78
+ if (m) candidates.push(Number(m[1]) / 1024);
79
+ } catch { /* not linux */ }
80
+ try {
81
+ const max = fs.readFileSync("/sys/fs/cgroup/memory.max", "utf8").trim();
82
+ if (max !== "max") {
83
+ const cur = Number(fs.readFileSync("/sys/fs/cgroup/memory.current", "utf8").trim());
84
+ const limit = Number(max);
85
+ if (Number.isFinite(limit) && Number.isFinite(cur)) candidates.push((limit - cur) / (1024 * 1024));
86
+ }
87
+ } catch { /* no cgroup v2 limit */ }
88
+ if (candidates.length === 0 && process.platform === "darwin") {
89
+ try {
90
+ const out = execFileSync("vm_stat", { encoding: "utf8", timeout: 2_000 });
91
+ const page = Number(/page size of (\d+)/.exec(out)?.[1] ?? 4096);
92
+ const pages = (k: string) => Number(new RegExp(`Pages ${k}:\\s+(\\d+)`).exec(out)?.[1] ?? 0);
93
+ candidates.push(((pages("free") + pages("inactive") + pages("speculative")) * page) / (1024 * 1024));
94
+ } catch { /* fall through */ }
95
+ }
96
+ if (candidates.length === 0) candidates.push(os.freemem() / (1024 * 1024));
97
+ return Math.floor(Math.max(0, Math.min(...candidates)));
98
+ }
99
+
100
+ export interface MemoryGateConfig {
101
+ minFreeMb: number;
102
+ reserveMb: number;
103
+ }
104
+
105
+ export function resolveMemoryGateConfig(env: NodeJS.ProcessEnv = process.env): MemoryGateConfig {
106
+ return {
107
+ minFreeMb: envMb(env, MIN_FREE_MEM_ENV, DEFAULT_MIN_FREE_MEM_MB),
108
+ reserveMb: envMb(env, CHILD_RESERVE_ENV, DEFAULT_CHILD_RESERVE_MB),
109
+ };
110
+ }
111
+
112
+ export interface MemoryGateVerdict {
113
+ ok: boolean;
114
+ availableMb: number;
115
+ requiredMb: number;
116
+ message?: string;
117
+ }
118
+
119
+ /**
120
+ * Refuse when `available - reserve × (inFlight + 1) < minFree`. `inFlight`
121
+ * counts this supervisor's children still starting (their memory is not yet
122
+ * visible in MemAvailable), so a parallel burst cannot all pass one reading.
123
+ */
124
+ export function checkMemoryGate(input: { name: string; inFlight: number; config?: MemoryGateConfig; available?: () => number }): MemoryGateVerdict {
125
+ const config = input.config ?? resolveMemoryGateConfig();
126
+ if (config.minFreeMb <= 0) return { ok: true, availableMb: -1, requiredMb: 0 };
127
+ const availableMb = (input.available ?? availableMemoryMb)();
128
+ const requiredMb = config.minFreeMb + config.reserveMb * (input.inFlight + 1);
129
+ if (availableMb >= requiredMb) return { ok: true, availableMb, requiredMb };
130
+ return {
131
+ ok: false,
132
+ availableMb,
133
+ requiredMb,
134
+ message:
135
+ `insufficient memory to start subagent ${input.name}: ${availableMb} MB available, ` +
136
+ `${requiredMb} MB required (${MIN_FREE_MEM_ENV}=${config.minFreeMb} + ${config.reserveMb} MB per starting child × ${input.inFlight + 1}). ` +
137
+ `Wait for running subagents to finish (or stop some with /legion stop) before retrying; ` +
138
+ `set ${MIN_FREE_MEM_ENV}=0 to disable this gate.`,
139
+ };
140
+ }