pi-legion 4.0.0 → 5.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 (43) hide show
  1. package/CHANGELOG.md +168 -1
  2. package/README.md +101 -44
  3. package/agents/centurion.md +20 -16
  4. package/agents/scout.md +3 -1
  5. package/agents/worker.md +5 -7
  6. package/docs/contracts/declarative-phase-machine-v5.md +128 -0
  7. package/docs/contracts/subagent-renewal-v1.md +260 -0
  8. package/docs/headless-child-contract.md +6 -3
  9. package/docs/inprocess-supervisor.md +70 -24
  10. package/docs/legion-v2-contract.md +9 -0
  11. package/docs/spawn-mode-visibility-v1.md +4 -1
  12. package/index.ts +598 -44
  13. package/package.json +2 -2
  14. package/src/agents/agents.ts +293 -36
  15. package/src/agents/overrides.ts +3 -1
  16. package/src/agents/settings.ts +6 -18
  17. package/src/extension/child-guard.ts +25 -1
  18. package/src/extension/legion-tools.ts +66 -21
  19. package/src/extension/notify.ts +136 -4
  20. package/src/extension/playbook.ts +2 -2
  21. package/src/extension/runtime.ts +56 -1
  22. package/src/extension/summary.ts +13 -4
  23. package/src/extension/timing.ts +8 -4
  24. package/src/legion/phase-conditions.ts +164 -0
  25. package/src/legion/phase-machine.ts +403 -0
  26. package/src/legion/state-machine.ts +219 -115
  27. package/src/runs/args.ts +32 -18
  28. package/src/runs/extension-inheritance.ts +220 -0
  29. package/src/runs/orchestrator.ts +137 -44
  30. package/src/runs/progress-signal.ts +137 -0
  31. package/src/runs/store-lookup.ts +270 -0
  32. package/src/shared/types.ts +88 -3
  33. package/src/supervisor/child-context.ts +19 -0
  34. package/src/supervisor/inprocess-supervisor.ts +29 -5
  35. package/src/supervisor/routing-supervisor.ts +2 -1
  36. package/src/supervisor/rpc-supervisor.ts +2 -2
  37. package/src/tui/tree-view.ts +56 -0
  38. package/src/tui/viz/palette.ts +0 -3
  39. package/agents/advisor.md +0 -110
  40. package/agents/designer.md +0 -87
  41. package/agents/planner.md +0 -53
  42. package/agents/prototype.md +0 -72
  43. package/agents/reviewer.md +0 -113
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "4.0.0",
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.",
3
+ "version": "5.0.0",
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",
7
7
  "repository": {
@@ -19,7 +19,6 @@ import {
19
19
  type AcceptanceCriterion,
20
20
  type OnBlockedPolicy,
21
21
  type SpawnMode,
22
- SPAWN_MODES,
23
22
  type SystemPromptMode,
24
23
  type ToolBudgetConfig,
25
24
  type TurnBudgetConfig,
@@ -31,16 +30,19 @@ import {
31
30
  } from "./frontmatter.ts";
32
31
  import { getAgentDir } from "./paths.ts";
33
32
  import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
33
+ import {
34
+ type PhaseDefinition,
35
+ type PhaseMachine,
36
+ type PhaseTransitionRule,
37
+ normalizePhaseMachine,
38
+ validatePhaseMachine,
39
+ } from "../legion/phase-machine.ts";
40
+ import type { PhaseCondition } from "../legion/phase-conditions.ts";
34
41
 
35
42
  export const BUILTIN_AGENT_NAMES = [
36
43
  "scout",
37
- "planner",
38
- "reviewer",
39
44
  "worker",
40
45
  "centurion",
41
- "advisor",
42
- "designer",
43
- "prototype",
44
46
  ] as const;
45
47
 
46
48
  /**
@@ -177,15 +179,6 @@ function int(value: unknown): number | undefined {
177
179
  return undefined;
178
180
  }
179
181
 
180
- function spawnModeOf(value: unknown, filePath: string): SpawnMode | undefined {
181
- if (value === undefined || value === null || value === "") return undefined;
182
- const raw = typeof value === "string" ? value.trim() : String(value);
183
- if ((SPAWN_MODES as readonly string[]).includes(raw)) return raw as SpawnMode;
184
- throw new Error(
185
- `Agent '${filePath}' has invalid spawnMode '${raw}'; expected one of: ${SPAWN_MODES.join(", ")}.`,
186
- );
187
- }
188
-
189
182
 
190
183
  /**
191
184
  * Accept `tools: read, bash`, `tools: [read, bash]`, and block-list spellings.
@@ -413,28 +406,281 @@ function parseCriteria(value: unknown): AcceptanceCriterion[] | undefined {
413
406
  return criteria.length > 0 ? criteria : undefined;
414
407
  }
415
408
 
416
- function parseBudget(value: unknown): ToolBudgetConfig | undefined {
417
- const raw = str(value);
418
- if (!raw) return undefined;
419
- try {
420
- const parsed = JSON.parse(raw) as Record<string, unknown>;
421
- const max = int(parsed.maxToolCalls);
422
- return max === undefined ? undefined : { maxToolCalls: max };
423
- } catch {
424
- return undefined;
409
+ /**
410
+ * Read an indented `phases:` block into the machine shape (#298).
411
+ *
412
+ * The shared frontmatter parser is flat — nested lists come back as raw text —
413
+ * so this walks the text itself. The grammar is deliberately tiny and matches
414
+ * only what a card can express:
415
+ *
416
+ * ```text
417
+ * - id: planning # a phase
418
+ * require: [contract_file] # inline condition list
419
+ * timeoutMs: 900000
420
+ * terminal: false
421
+ * next: # a list of transitions
422
+ * - to: implementing
423
+ * require: [contract_file]
424
+ * actorRole: centurion
425
+ * rework: true
426
+ * - to: failed
427
+ * ```
428
+ *
429
+ * Anything outside this shape is an error rather than a silent skip, because a
430
+ * half-read machine is worse than none: it would run with the transitions it
431
+ * happened to parse.
432
+ */
433
+ function parsePhaseBlock(text: string): PhaseMachine | undefined {
434
+ // `id` is filled from the `- id: ...` line; validatePhaseMachine rejects a
435
+ // machine where it is missing, so the assertion at the end is safe.
436
+ type PhaseDraft = Record<string, unknown> & { id: string; next?: Array<Record<string, unknown>> };
437
+ const phases: PhaseDraft[] = [];
438
+ let currentPhase: PhaseDraft | undefined;
439
+ let currentRule: Record<string, unknown> | undefined;
440
+ // Column where list items live. The first `- ` we see sets it, so the block may
441
+ // be indented by any amount relative to the document.
442
+ let itemIndent = -1;
443
+
444
+ const scalar = (value: string): unknown => {
445
+ const trimmed = value.trim();
446
+ if (trimmed === "true") return true;
447
+ if (trimmed === "false") return false;
448
+ if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
449
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
450
+ const inner = trimmed.slice(1, -1).trim();
451
+ if (!inner) return [];
452
+ return inner.split(",").map((part) => part.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
453
+ }
454
+ return trimmed.replace(/^["']|["']$/g, "");
455
+ };
456
+
457
+ for (const rawLine of text.split("\n")) {
458
+ if (!rawLine.trim()) continue;
459
+ const indent = rawLine.length - rawLine.trimStart().length;
460
+ const line = rawLine.trim();
461
+
462
+ if (line.startsWith("- ")) {
463
+ if (itemIndent === -1) itemIndent = indent;
464
+ const body = line.slice(2).trim();
465
+ const entry: Record<string, unknown> = {};
466
+ const colon = body.indexOf(":");
467
+ if (colon > 0) entry[body.slice(0, colon).trim()] = scalar(body.slice(colon + 1));
468
+ // A `- ` at the item column opens a phase; deeper opens a transition.
469
+ if (indent <= itemIndent) {
470
+ currentPhase = entry as PhaseDraft;
471
+ currentPhase.next = [];
472
+ phases.push(currentPhase);
473
+ currentRule = undefined;
474
+ } else if (currentPhase) {
475
+ currentRule = entry;
476
+ currentPhase.next!.push(entry);
477
+ }
478
+ continue;
479
+ }
480
+
481
+ const colon = line.indexOf(":");
482
+ if (colon <= 0) continue;
483
+ const key = line.slice(0, colon).trim();
484
+ const value = line.slice(colon + 1).trim();
485
+ const target = currentRule ?? currentPhase;
486
+ if (!target) continue;
487
+ if (value === "") {
488
+ // `next:` with nothing after it — the transitions follow as `- to:` lines.
489
+ if (key === "next") target.next = target.next ?? [];
490
+ continue;
491
+ }
492
+ target[key] = scalar(value);
493
+ }
494
+
495
+ if (phases.length === 0) return undefined;
496
+ // Convert the walked drafts into the declared shape explicitly instead of
497
+ // asserting it. `validatePhaseMachine` runs right after this and rejects
498
+ // anything malformed, but doing the coercion here means the compiler — not just
499
+ // the validator — has seen every field.
500
+ const converted: PhaseDefinition[] = phases.map((draft) => {
501
+ const rules: PhaseTransitionRule[] = (draft.next ?? []).map((rule) => {
502
+ const out: PhaseTransitionRule = { to: String(rule.to ?? "") };
503
+ if (Array.isArray(rule.require)) out.require = rule.require.map(String) as PhaseCondition[];
504
+ if (typeof rule.actorRole === "string") out.actorRole = rule.actorRole;
505
+ if (rule.rework === true) out.rework = true;
506
+ if (rule.notifyParent === true) out.notifyParent = true;
507
+ return out;
508
+ });
509
+ const phase: PhaseDefinition = { id: String(draft.id ?? ""), next: rules };
510
+ if (Array.isArray(draft.require)) phase.require = draft.require.map(String) as PhaseCondition[];
511
+ if (typeof draft.timeoutMs === "number") phase.timeoutMs = draft.timeoutMs;
512
+ if (draft.terminal === true) phase.terminal = true;
513
+ return phase;
514
+ });
515
+ const machine: PhaseMachine = { phases: converted };
516
+ if (typeof (phases as unknown as { start?: unknown }).start === "string") {
517
+ machine.start = String((phases as unknown as { start: string }).start);
425
518
  }
519
+ return machine;
426
520
  }
427
521
 
428
- function parseTurnBudget(value: unknown): TurnBudgetConfig | undefined {
429
- const raw = str(value);
430
- if (!raw) return undefined;
431
- try {
432
- const parsed = JSON.parse(raw) as Record<string, unknown>;
433
- const max = int(parsed.maxTurns);
434
- return max === undefined ? undefined : { maxTurns: max };
435
- } catch {
436
- return undefined;
522
+ /**
523
+ * Parse a declared phase machine from `phases:` (#298).
524
+ *
525
+ * Accepts the YAML shape directly (a list of phase objects), or the same thing
526
+ * as a JSON string, matching how `toolBudget` is written.
527
+ *
528
+ * Validation happens HERE, at load, and every problem is reported at once. A
529
+ * machine that only fails mid-run would surface after a team had already spent
530
+ * tokens on a workflow that could never complete.
531
+ */
532
+ function parsePhaseMachine(value: unknown, filePath: string): PhaseMachine | undefined {
533
+ if (value === undefined || value === null) return undefined;
534
+ let raw: unknown = value;
535
+ if (typeof value === "string") {
536
+ const text = value.trim();
537
+ if (!text) return undefined;
538
+ // The frontmatter parser is FLAT, so a nested `phases:` block arrives here
539
+ // as raw text — exactly the situation `parseAcceptance` already handles.
540
+ // Two spellings are therefore accepted: an indented YAML block (what the
541
+ // card looks like) and a one-line JSON string (easier to generate).
542
+ let parsedText: unknown;
543
+ let wasJson = true;
544
+ try {
545
+ parsedText = JSON.parse(text);
546
+ } catch {
547
+ wasJson = false;
548
+ parsedText = parsePhaseBlock(text);
549
+ }
550
+ if (!wasJson && parsedText === undefined) {
551
+ throw new Error(
552
+ `Agent '${filePath}' has invalid phases: could not read the block. ` +
553
+ `Expected an indented list of " - id: <name>" entries, or a JSON string.`,
554
+ );
555
+ }
556
+ raw = parsedText;
437
557
  }
558
+ // Accept both `[{...}]` and `{ phases: [...] }`.
559
+ const shaped = Array.isArray(raw)
560
+ ? ({ phases: raw } as unknown)
561
+ : raw;
562
+ if (shaped === null || typeof shaped !== "object") {
563
+ throw new Error(`Agent '${filePath}' has invalid phases: expected a list or an object with a 'phases' key.`);
564
+ }
565
+ const machine = shaped as PhaseMachine;
566
+ if (!Array.isArray(machine.phases)) {
567
+ throw new Error(`Agent '${filePath}' has invalid phases: 'phases' must be a list.`);
568
+ }
569
+ // Inject the universal terminals before validating, so reachability is judged
570
+ // on the machine as it will actually run.
571
+ const normalized = normalizePhaseMachine(machine);
572
+ const errors = validatePhaseMachine(normalized);
573
+ if (errors.length > 0) {
574
+ const detail = errors.map((error) => ` - ${error.path}: ${error.message}`).join("\n");
575
+ throw new Error(`Agent '${filePath}' has an invalid phase machine:\n${detail}`);
576
+ }
577
+ return normalized;
578
+ }
579
+
580
+ /**
581
+ * Parse a child budget (`toolBudget` / `turnBudget`) strictly.
582
+ *
583
+ * These used to swallow every error and return `undefined`, so a typo became
584
+ * "no limit" -- the same silent-acceptance failure the frontmatter field check
585
+ * exists to prevent. Worse, a NEGATIVE number was accepted: `-3` reached the
586
+ * child, `toolCalls > -3` was true immediately, and the first tool call was
587
+ * blocked while the author believed they had set a limit of 3.
588
+ *
589
+ * `0` is meaningful here ("no calls allowed", per `extension/budget.ts`), so it
590
+ * must survive rather than decay into "unset". Anything that is not a
591
+ * non-negative integer is an error naming the file and the field.
592
+ */
593
+ function parseBudgets(fm: AgentFrontmatter, filePath: string): {
594
+ tool?: ToolBudgetConfig;
595
+ turn?: TurnBudgetConfig;
596
+ } {
597
+ const read = (
598
+ field: "toolBudget" | "turnBudget",
599
+ key: "maxToolCalls" | "maxTurns",
600
+ ): number | undefined => {
601
+ const raw = str(fm[field]);
602
+ if (!raw) return undefined;
603
+ let parsed: unknown;
604
+ try {
605
+ parsed = JSON.parse(raw);
606
+ } catch {
607
+ throw new Error(
608
+ `Agent '${filePath}' has invalid ${field}: expected a JSON object like '{"${key}": 10}', got '${raw}'.`,
609
+ );
610
+ }
611
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
612
+ throw new Error(
613
+ `Agent '${filePath}' has invalid ${field}: expected a JSON object with a '${key}' key.`,
614
+ );
615
+ }
616
+ const value = (parsed as Record<string, unknown>)[key];
617
+ if (value === undefined) return undefined;
618
+ if (typeof value !== "number" || !Number.isInteger(value)) {
619
+ throw new Error(
620
+ `Agent '${filePath}' has invalid ${field}.${key}: expected a non-negative integer, got ${JSON.stringify(value)}.`,
621
+ );
622
+ }
623
+ if (value < 0) {
624
+ throw new Error(
625
+ `Agent '${filePath}' has invalid ${field}.${key}: ${value} is negative. ` +
626
+ `0 means "none allowed"; omit the field for "no limit".`,
627
+ );
628
+ }
629
+ return value;
630
+ };
631
+ const toolMax = read("toolBudget", "maxToolCalls");
632
+ const turnMax = read("turnBudget", "maxTurns");
633
+ return {
634
+ ...(toolMax !== undefined ? { tool: { maxToolCalls: toolMax } } : {}),
635
+ ...(turnMax !== undefined ? { turn: { maxTurns: turnMax } } : {}),
636
+ };
637
+ }
638
+
639
+ /**
640
+ * Every frontmatter key a role card may declare.
641
+ *
642
+ * Kept next to the parser on purpose: an unknown key used to be silently
643
+ * ignored, so a typo (`maxSubagentdepth`, `inheritSkill`) produced a card that
644
+ * looked accepted but did nothing. That is the worst failure mode for a config
645
+ * file — the author believes the setting is in effect. Cards are now validated
646
+ * against this list and an unknown key is a hard error.
647
+ *
648
+ * When adding a field, add it here AND parse it; a name in this list that no
649
+ * parser reads is just as misleading as the reverse.
650
+ */
651
+ const AGENT_FRONTMATTER_FIELDS: ReadonlySet<string> = new Set([
652
+ // identity
653
+ "name", "description", "kind", "model", "thinking", "alias", "aliases",
654
+ "preset", "team", "output", "fallbackModels",
655
+ // prompt / context
656
+ "systemPromptMode", "inheritProjectContext", "inheritSkills", "inheritExtensions",
657
+ "excludeExtensions",
658
+ "phases",
659
+ "skillPath", "skills", "defaultReads",
660
+ // tools
661
+ "tools", "extensions", "subagentOnlyExtensions",
662
+ // budgets / guards / lifecycle
663
+ "defaultProgress", "async", "timeoutMs", "toolTimeoutMs", "completionGuard",
664
+ "maxSubagentDepth", "allowNestedSubagents", "renewable", "maxRenewals",
665
+ "utility", "disabled", "worktree", "steer", "onBlocked",
666
+ "acceptance", "toolBudget", "turnBudget",
667
+ ]);
668
+
669
+ /**
670
+ * Throw when a card declares a key this parser does not understand.
671
+ *
672
+ * Names are compared case-sensitively: YAML keys are case-sensitive, and a
673
+ * near-miss (`MaxRenewals`) is exactly the typo this is meant to catch.
674
+ */
675
+ function assertKnownFrontmatter(frontmatter: Record<string, unknown>, filePath: string): void {
676
+ const unknown = Object.keys(frontmatter).filter((key) => !AGENT_FRONTMATTER_FIELDS.has(key));
677
+ if (unknown.length === 0) return;
678
+ const known = [...AGENT_FRONTMATTER_FIELDS].sort().join(", ");
679
+ throw new Error(
680
+ `Agent '${filePath}' has unknown frontmatter ${unknown.length === 1 ? "field" : "fields"}: ` +
681
+ `${unknown.map((key) => `'${key}'`).join(", ")}. ` +
682
+ `Known fields: ${known}.`,
683
+ );
438
684
  }
439
685
 
440
686
  /**
@@ -448,6 +694,7 @@ export function parseAgentDocument(
448
694
  ): AgentConfig | null {
449
695
  const { frontmatter, body } = parseFrontmatter(content);
450
696
  const fm = frontmatter as AgentFrontmatter;
697
+ assertKnownFrontmatter(fm, filePath);
451
698
 
452
699
  const name = str(fm.name);
453
700
  const description = str(fm.description);
@@ -543,6 +790,7 @@ function applyCapabilityFields(
543
790
  setIf(config, "skills", listOrFalse(fm.skills));
544
791
  setIf(config, "skillPath", list(fm.skillPath));
545
792
  setIf(config, "extensions", list(fm.extensions));
793
+ if (list(fm.excludeExtensions)) config.excludeExtensions = list(fm.excludeExtensions);
546
794
  setIf(config, "subagentOnlyExtensions", list(fm.subagentOnlyExtensions));
547
795
  setIf(config, "alias", list(fm.alias) ?? list(fm.aliases));
548
796
  setIf(config, "output", str(fm.output));
@@ -558,14 +806,23 @@ function applyBehaviorFields(config: AgentConfig, fm: AgentFrontmatter): void {
558
806
  setIf(config, "completionGuard", bool(fm.completionGuard));
559
807
  setIf(config, "maxSubagentDepth", int(fm.maxSubagentDepth));
560
808
  setIf(config, "allowNestedSubagents", bool(fm.allowNestedSubagents));
809
+ // D5/D6: parsed verbatim and left absent when undeclared — the defaults live
810
+ // with the consumers (DEFAULT_RENEWABLE / DEFAULT_MAX_RENEWALS), so this
811
+ // layer never has to guess whether the role was explicit. `int()` returns 0
812
+ // for `maxRenewals: 0`, and `setIf` only skips `undefined`, so the
813
+ // "renewal disabled" spelling survives instead of decaying to the default.
814
+ setIf(config, "renewable", bool(fm.renewable));
815
+ setIf(config, "maxRenewals", int(fm.maxRenewals));
561
816
  setIf(config, "utility", bool(fm.utility));
562
817
  setIf(config, "disabled", bool(fm.disabled));
563
818
  setIf(config, "worktree", bool(fm.worktree));
564
819
  setIf(config, "steer", bool(fm.steer));
565
- setIf(config, "spawnMode", spawnModeOf(fm.spawnMode, config.filePath));
566
820
  setIf(config, "acceptance", parseAcceptance(fm.acceptance, config.name));
567
- setIf(config, "toolBudget", parseBudget(fm.toolBudget));
568
- setIf(config, "turnBudget", parseTurnBudget(fm.turnBudget));
821
+ const phases = parsePhaseMachine(fm.phases, config.filePath);
822
+ if (phases) config.phases = phases;
823
+ const budgets = parseBudgets(fm, config.filePath);
824
+ setIf(config, "toolBudget", budgets.tool);
825
+ setIf(config, "turnBudget", budgets.turn);
569
826
  // #214 / contract §9: only centurion holds worktree isolation. Writers no
570
827
  // longer auto-default to worktree:true — members work in-place; parallel
571
828
  // isolated writers are N root-dispatched centurions. Explicit worktree:true
@@ -31,19 +31,21 @@ export const OVERRIDE_FIELDS = [
31
31
  "inheritProjectContext",
32
32
  "inheritSkills",
33
33
  "inheritExtensions",
34
+ "excludeExtensions",
34
35
  "defaultProgress",
35
36
  "async",
36
37
  "completionGuard",
37
38
  "allowNestedSubagents",
39
+ "renewable",
38
40
  "utility",
39
41
  "disabled",
40
42
  "worktree",
41
43
  "steer",
42
- "spawnMode",
43
44
  // numbers
44
45
  "timeoutMs",
45
46
  "toolTimeoutMs",
46
47
  "maxSubagentDepth",
48
+ "maxRenewals",
47
49
  // arrays (a `false` value is meaningful for skills/extensions)
48
50
  "tools",
49
51
  "skills",
@@ -18,7 +18,6 @@ import {
18
18
  type InProcessLimits,
19
19
  type RpcSettings,
20
20
  type SubagentsSettings,
21
- SPAWN_MODES,
22
21
  type TeamConfig,
23
22
  type TeamMember,
24
23
  } from "../shared/types.ts";
@@ -471,13 +470,12 @@ function overrideFieldExpected(field: string): string | undefined {
471
470
  if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return "a string";
472
471
  if (field === "thinking") return "a string or false";
473
472
  if (field === "kind") return `one of: ${AGENT_KINDS.join(", ")}`;
474
- if (field === "spawnMode") return `one of: ${SPAWN_MODES.join(", ")}`;
475
473
  if (field === "onBlocked") return "a valid onBlocked policy";
476
474
  if (field === "systemPromptMode") return "replace or append";
477
- if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return "a boolean";
478
- if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth"].includes(field)) return "a number";
475
+ if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer", "renewable"].includes(field)) return "a boolean";
476
+ if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth", "maxRenewals"].includes(field)) return "a number";
479
477
  if (field === "skills") return "an array of strings or false";
480
- if (["tools", "extensions", "subagentOnlyExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return "an array of strings";
478
+ if (["tools", "extensions", "subagentOnlyExtensions", "excludeExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return "an array of strings";
481
479
  return undefined;
482
480
  }
483
481
 
@@ -485,13 +483,12 @@ function isValidOverrideField(field: string, value: unknown): boolean {
485
483
  if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return typeof value === "string";
486
484
  if (field === "thinking") return typeof value === "string" || value === false;
487
485
  if (field === "kind") return typeof value === "string" && (AGENT_KINDS as readonly string[]).includes(value);
488
- if (field === "spawnMode") return typeof value === "string" && (SPAWN_MODES as readonly string[]).includes(value);
489
486
  if (field === "onBlocked") return typeof value === "string" && VALID_ON_BLOCKED_POLICIES.has(value);
490
487
  if (field === "systemPromptMode") return value === "replace" || value === "append";
491
- if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return typeof value === "boolean";
492
- if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth"].includes(field)) return typeof value === "number";
488
+ if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer", "renewable"].includes(field)) return typeof value === "boolean";
489
+ if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth", "maxRenewals"].includes(field)) return typeof value === "number";
493
490
  if (field === "skills") return value === false || isStringArray(value);
494
- if (["tools", "extensions", "subagentOnlyExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return isStringArray(value);
491
+ if (["tools", "extensions", "subagentOnlyExtensions", "excludeExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return isStringArray(value);
495
492
  return true;
496
493
  }
497
494
 
@@ -635,15 +632,6 @@ function parseAgentOverrides(
635
632
  `Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.utility'; expected a boolean.`,
636
633
  );
637
634
  }
638
- if (
639
- normalized.spawnMode !== undefined &&
640
- (typeof normalized.spawnMode !== "string" ||
641
- !(SPAWN_MODES as readonly string[]).includes(normalized.spawnMode))
642
- ) {
643
- throw new Error(
644
- `Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.spawnMode'; expected one of: ${SPAWN_MODES.join(", ")}.`,
645
- );
646
- }
647
635
  parsed[name] = normalized as AgentOverride;
648
636
  }
649
637
  return parsed;
@@ -17,6 +17,7 @@ import {
17
17
  budgetExceededReason,
18
18
  childBudgetFromEnv,
19
19
  wrapBashWithTimeout,
20
+ type ChildBudget,
20
21
  } from "./budget.ts";
21
22
  import { shellChunks } from "./playbook.ts";
22
23
 
@@ -349,9 +350,32 @@ export function effectiveChildGuardEnv(
349
350
  return childGuardEnvFromProcess(env);
350
351
  }
351
352
 
353
+ /**
354
+ * The budget this child session enforces (#295).
355
+ *
356
+ * Same precedence as {@link effectiveChildGuardEnv}: the async-local child
357
+ * context wins, `process.env` is the fallback for rpc children (whose process
358
+ * really owns those values).
359
+ *
360
+ * Why the context is needed at all: the parent computes budgets into
361
+ * `SpawnInput.env`. An rpc child receives that object as its environment, so
362
+ * `process.env` has it. An IN-PROCESS child shares the host's `process.env`
363
+ * instead — the object never becomes environment — so reading only the process
364
+ * gave the guard the parent's (absent) values and `toolBudget: 3` permitted 12
365
+ * tool calls.
366
+ */
367
+ export function effectiveChildBudget(
368
+ ctx: ChildLaunchContext | undefined = currentChildContext(),
369
+ env: NodeJS.ProcessEnv = process.env,
370
+ ): ChildBudget {
371
+ const fromContext = ctx?.budget;
372
+ if (fromContext !== undefined) return fromContext;
373
+ return childBudgetFromEnv(env);
374
+ }
375
+
352
376
  /** Child-only extension: intercept tools; do not register the parent tool. */
353
377
  export function registerChildGuard(pi: ExtensionAPI): void {
354
- const budget = childBudgetFromEnv();
378
+ const budget = effectiveChildBudget();
355
379
  let toolCalls = 0;
356
380
  let turns = 0;
357
381
  pi.on("turn_start", () => { turns += 1; });