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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "2.0.3",
3
+ "version": "3.0.0",
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,15 +49,17 @@
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)}\"",
52
53
  "typecheck": "tsc --noEmit",
53
- "test": "env -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",
54
- "test:integration": "env -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",
55
- "test:live": "env -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",
56
- "test:all": "npm run typecheck && npm run test && npm run test:integration",
57
- "test:docker": "env -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'",
58
- "prepublishOnly": "npm run typecheck && npm run test && npm run test:integration",
54
+ "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
+ "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
+ "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",
58
+ "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",
59
60
  "mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
60
- "test:mock-llm": "env -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"
61
+ "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
+ "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"
61
63
  },
62
64
  "peerDependencies": {
63
65
  "@earendil-works/pi-agent-core": "*",
@@ -91,5 +93,10 @@
91
93
  "@types/node": "^22.0.0",
92
94
  "typebox": "^1.3.30",
93
95
  "typescript": "^5.6.0"
96
+ },
97
+ "packageManager": "pnpm@9.15.9",
98
+ "engines": {
99
+ "node": ">=22",
100
+ "pnpm": ">=9"
94
101
  }
95
102
  }
@@ -93,11 +93,16 @@ Steer, inspect, collect, resume, or retire children with the `subagent` tool:
93
93
  ```text
94
94
  subagent({ action: "status", name: "worker-0" })
95
95
  subagent({ action: "collect", name: "worker-0" })
96
+ subagent({ action: "wait", all: true })
97
+ subagent({ action: "wait", name: "worker-0" })
96
98
  subagent({ action: "steer", name: "worker-0", message: "Focus on the parser tests." })
97
99
  subagent({ action: "resume", name: "worker-0" })
98
100
  subagent({ action: "retire", name: "worker-0" })
99
101
  ```
100
102
 
103
+ `wait` always needs a target: pass `all: true` for every live child, or `name`
104
+ for one child (mutually exclusive). A bare `action: "wait"` is rejected.
105
+
101
106
  A person can read that same status without a tool call: `/legion status` for
102
107
  the tree, `/legion status <name>` for one child.
103
108
 
@@ -18,6 +18,8 @@ import {
18
18
  type AcceptanceConfig,
19
19
  type AcceptanceCriterion,
20
20
  type OnBlockedPolicy,
21
+ type SpawnMode,
22
+ SPAWN_MODES,
21
23
  type SystemPromptMode,
22
24
  type ToolBudgetConfig,
23
25
  type TurnBudgetConfig,
@@ -80,9 +82,12 @@ export function resetExtraAgentDirsDeprecationWarnForTests(): void {
80
82
  legacyExtraAgentDirsWarned = false;
81
83
  }
82
84
 
85
+ /** Split EXTRA_AGENT_DIRS: colon+semicolon (+comma) on POSIX; comma+semicolon on Windows (drive letters). */
86
+ const EXTRA_AGENT_DIRS_SEP = path.delimiter === ";" ? /[,;]/ : /[;:,]/;
87
+
83
88
  function splitExtraAgentDirEntries(raw: string | undefined): string[] {
84
89
  if (!raw) return [];
85
- return raw.split(path.delimiter).flatMap((entry) => {
90
+ return raw.split(EXTRA_AGENT_DIRS_SEP).flatMap((entry) => {
86
91
  const trimmed = entry.trim();
87
92
  return trimmed ? [path.resolve(trimmed)] : [];
88
93
  });
@@ -144,6 +149,12 @@ const VALID_ACCEPTANCE_LEVELS: ReadonlySet<string> = new Set([
144
149
  "verified",
145
150
  ]);
146
151
 
152
+ const VALID_ACCEPTANCE_ROLES: ReadonlySet<string> = new Set([
153
+ "read-only",
154
+ "writer",
155
+ "unknown",
156
+ ]);
157
+
147
158
  /** Raw frontmatter values are `unknown`: a YAML scalar or collection may appear. */
148
159
  type AgentFrontmatter = Record<string, unknown>;
149
160
 
@@ -165,6 +176,16 @@ function int(value: unknown): number | undefined {
165
176
  return undefined;
166
177
  }
167
178
 
179
+ function spawnModeOf(value: unknown, filePath: string): SpawnMode | undefined {
180
+ if (value === undefined || value === null || value === "") return undefined;
181
+ const raw = typeof value === "string" ? value.trim() : String(value);
182
+ if ((SPAWN_MODES as readonly string[]).includes(raw)) return raw as SpawnMode;
183
+ throw new Error(
184
+ `Agent '${filePath}' has invalid spawnMode '${raw}'; expected one of: ${SPAWN_MODES.join(", ")}.`,
185
+ );
186
+ }
187
+
188
+
168
189
  /**
169
190
  * Accept `tools: read, bash`, `tools: [read, bash]`, and block-list spellings.
170
191
  *
@@ -318,7 +339,7 @@ function parseScalar(raw: string): Scalar {
318
339
  return stripQuotes(value);
319
340
  }
320
341
 
321
- function parseAcceptance(value: unknown): AcceptanceConfig | undefined {
342
+ function parseAcceptance(value: unknown, agentName?: string): AcceptanceConfig | undefined {
322
343
  const raw = str(value);
323
344
  if (!raw) return undefined;
324
345
 
@@ -340,9 +361,15 @@ function parseAcceptance(value: unknown): AcceptanceConfig | undefined {
340
361
  if (!level || !VALID_ACCEPTANCE_LEVELS.has(level)) return undefined;
341
362
 
342
363
  const out: AcceptanceConfig = { level: level as AcceptanceConfig["level"] };
343
- const role = str(parsed.role);
344
- if (role === "read-only" || role === "writer" || role === "unknown") {
345
- out.role = role;
364
+ // #172: author-facing role strings are case-insensitive; keep hyphenated canonical forms.
365
+ const role = str(parsed.role)?.toLowerCase();
366
+ if (role && VALID_ACCEPTANCE_ROLES.has(role)) {
367
+ out.role = role as AcceptanceConfig["role"];
368
+ } else if (role) {
369
+ const who = agentName ? `agent "${agentName}"` : "agent";
370
+ console.error(
371
+ `[pi-legion] ${who}: unknown acceptance.role ${JSON.stringify(role)}; expected read-only | writer | unknown.`,
372
+ );
346
373
  }
347
374
  const criteria = parseCriteria(parsed.criteria);
348
375
  if (criteria) out.criteria = criteria;
@@ -528,7 +555,8 @@ function applyBehaviorFields(config: AgentConfig, fm: AgentFrontmatter): void {
528
555
  setIf(config, "disabled", bool(fm.disabled));
529
556
  setIf(config, "worktree", bool(fm.worktree));
530
557
  setIf(config, "steer", bool(fm.steer));
531
- setIf(config, "acceptance", parseAcceptance(fm.acceptance));
558
+ setIf(config, "spawnMode", spawnModeOf(fm.spawnMode, config.filePath));
559
+ setIf(config, "acceptance", parseAcceptance(fm.acceptance, config.name));
532
560
  setIf(config, "toolBudget", parseBudget(fm.toolBudget));
533
561
  setIf(config, "turnBudget", parseTurnBudget(fm.turnBudget));
534
562
  // Writers share a checkout with every other parent Pi in this repo.
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Extra prompt context injected by settings: `subagents.parentContext` and
3
- * `subagents.childContext`.
3
+ * `subagents.childContext`, plus optional per-team overrides on
4
+ * `subagents.teams.<name>.parentContext` / `childContext` (see
5
+ * `resolveTeamContext`).
4
6
  *
5
7
  * Both keys accept either inline markdown or a file reference in pi's own
6
8
  * `@path` form. This exists so machine-specific operating discipline (parallel
@@ -17,6 +19,7 @@
17
19
  import * as fs from "node:fs";
18
20
  import * as os from "node:os";
19
21
  import * as path from "node:path";
22
+ import type { SubagentsSettings } from "../shared/types.ts";
20
23
 
21
24
  export interface ResolvedContext {
22
25
  /** Final markdown text, or undefined when nothing is configured. */
@@ -116,3 +119,82 @@ export function parseContextSetting(
116
119
  const joined = parts.filter(Boolean).join("\n\n").trim();
117
120
  return joined || undefined;
118
121
  }
122
+
123
+ export type ContextKind = "parent" | "child";
124
+
125
+ const CONTEXT_FIELD: Record<ContextKind, "parentContext" | "childContext"> = {
126
+ parent: "parentContext",
127
+ child: "childContext",
128
+ };
129
+
130
+ /**
131
+ * Resolve parent or child context for the active team.
132
+ *
133
+ * Order (each side independent):
134
+ * 1. If `teams[activeTeam.name]` exists:
135
+ * - `false` → no inject (stop; no global fallthrough)
136
+ * - string / string[] → team value only (replace global; `@path` via baseDir)
137
+ * - omitted → continue
138
+ * 2. Global `subagents.parentContext` / `childContext` (already resolved at
139
+ * settings load, including convention-file fallthrough)
140
+ *
141
+ * Unknown team names and the reserved `default` team (no `teams.default`)
142
+ * skip step 1 and use global only — matching today's behaviour.
143
+ *
144
+ * `baseDir` anchors team-level `@path` when the team field still holds a raw
145
+ * `@path` / array (programmatic TeamConfig). Settings load resolves team
146
+ * `@path` against the declaring settings file directory.
147
+ */
148
+ export function resolveTeamContext(
149
+ kind: ContextKind,
150
+ settings: SubagentsSettings,
151
+ activeTeam: { name: string },
152
+ baseDir?: string,
153
+ ): string | undefined {
154
+ const field = CONTEXT_FIELD[kind];
155
+ const teams = settings.teams;
156
+ const teamConfig =
157
+ teams && Object.hasOwn(teams, activeTeam.name)
158
+ ? teams[activeTeam.name]
159
+ : undefined;
160
+
161
+ if (teamConfig) {
162
+ const teamValue = teamConfig[field];
163
+ if (teamValue === false) return undefined;
164
+ if (teamValue !== undefined) {
165
+ return resolveTeamContextValue(
166
+ teamValue,
167
+ `teams.${activeTeam.name}.${field}`,
168
+ baseDir,
169
+ );
170
+ }
171
+ }
172
+
173
+ const global = settings[field];
174
+ if (typeof global !== "string") return undefined;
175
+ const trimmed = global.trim();
176
+ return trimmed || undefined;
177
+ }
178
+
179
+ /**
180
+ * Resolve a team context field.
181
+ * Settings load pre-resolves `@path` / arrays to a final string (or keeps
182
+ * `false`). Plain strings are final markdown. Arrays still need `baseDir`
183
+ * (programmatic TeamConfig in tests).
184
+ */
185
+ function resolveTeamContextValue(
186
+ value: string | string[],
187
+ field: string,
188
+ baseDir: string | undefined,
189
+ ): string | undefined {
190
+ if (Array.isArray(value)) {
191
+ if (baseDir === undefined) {
192
+ throw new Error(
193
+ `Subagent settings '${field}' is an array; baseDir is required to resolve it.`,
194
+ );
195
+ }
196
+ return parseContextSetting(value, field, baseDir);
197
+ }
198
+ const trimmed = value.trim();
199
+ return trimmed || undefined;
200
+ }
@@ -40,6 +40,7 @@ export const OVERRIDE_FIELDS = [
40
40
  "disabled",
41
41
  "worktree",
42
42
  "steer",
43
+ "spawnMode",
43
44
  // numbers
44
45
  "timeoutMs",
45
46
  "toolTimeoutMs",
@@ -16,7 +16,10 @@ import {
16
16
  type ModelScopeConfig,
17
17
  type OnBlockedPolicy,
18
18
  type ScoutGuardSettings,
19
+ type InProcessLimits,
20
+ type RpcSettings,
19
21
  type SubagentsSettings,
22
+ SPAWN_MODES,
20
23
  type TeamConfig,
21
24
  type TeamMember,
22
25
  } from "../shared/types.ts";
@@ -481,6 +484,8 @@ export function parseSubagentSettings(
481
484
 
482
485
  readJoinFields(input, out, filePath);
483
486
  setIf(out, "scoutGuard", parseScoutGuard(input.scoutGuard, filePath));
487
+ setIf(out, "inprocess", parseInProcessLimits(input.inprocess, filePath));
488
+ setIf(out, "rpc", parseRpcSettings(input.rpc, filePath));
484
489
 
485
490
  // THE WHITELIST: every key read above must be assigned, or it is silently
486
491
  // discarded. `presets` is the newest key and the easiest to drop here.
@@ -537,6 +542,7 @@ function overrideFieldExpected(field: string): string | undefined {
537
542
  if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return "a string";
538
543
  if (field === "thinking") return "a string or false";
539
544
  if (field === "kind") return `one of: ${AGENT_KINDS.join(", ")}`;
545
+ if (field === "spawnMode") return `one of: ${SPAWN_MODES.join(", ")}`;
540
546
  if (field === "onBlocked") return "a valid onBlocked policy";
541
547
  if (field === "systemPromptMode") return "replace or append";
542
548
  if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return "a boolean";
@@ -550,6 +556,7 @@ function isValidOverrideField(field: string, value: unknown): boolean {
550
556
  if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return typeof value === "string";
551
557
  if (field === "thinking") return typeof value === "string" || value === false;
552
558
  if (field === "kind") return typeof value === "string" && (AGENT_KINDS as readonly string[]).includes(value);
559
+ if (field === "spawnMode") return typeof value === "string" && (SPAWN_MODES as readonly string[]).includes(value);
553
560
  if (field === "onBlocked") return typeof value === "string" && VALID_ON_BLOCKED_POLICIES.has(value);
554
561
  if (field === "systemPromptMode") return value === "replace" || value === "append";
555
562
  if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return typeof value === "boolean";
@@ -563,6 +570,60 @@ function isStringArray(value: unknown): value is string[] {
563
570
  return Array.isArray(value) && value.every((item) => typeof item === "string");
564
571
  }
565
572
 
573
+
574
+ function parseInProcessLimits(
575
+ value: unknown,
576
+ filePath: string,
577
+ ): InProcessLimits | undefined {
578
+ if (value === undefined) return undefined;
579
+ if (!isRecord(value)) {
580
+ throw new Error(
581
+ `Subagent settings in '${filePath}' have invalid 'inprocess'; expected an object.`,
582
+ );
583
+ }
584
+ const out: InProcessLimits = {};
585
+ const maxSpawnsPerSession = positiveInt(
586
+ value.maxSpawnsPerSession,
587
+ "inprocess.maxSpawnsPerSession",
588
+ filePath,
589
+ );
590
+ if (maxSpawnsPerSession !== undefined) out.maxSpawnsPerSession = maxSpawnsPerSession;
591
+ const maxDepth = nonNegativeInt(value.maxDepth, "inprocess.maxDepth", filePath);
592
+ if (maxDepth !== undefined) out.maxDepth = maxDepth;
593
+ const maxActive = positiveInt(value.maxActive, "inprocess.maxActive", filePath);
594
+ if (maxActive !== undefined) out.maxActive = maxActive;
595
+ const maxChildrenPerNode = nonNegativeInt(
596
+ value.maxChildrenPerNode,
597
+ "inprocess.maxChildrenPerNode",
598
+ filePath,
599
+ );
600
+ if (maxChildrenPerNode !== undefined) out.maxChildrenPerNode = maxChildrenPerNode;
601
+ const waitTimeoutMs = positiveInt(
602
+ value.waitTimeoutMs,
603
+ "inprocess.waitTimeoutMs",
604
+ filePath,
605
+ );
606
+ if (waitTimeoutMs !== undefined) out.waitTimeoutMs = waitTimeoutMs;
607
+ // Unknown keys are ignored (same as other settings blobs).
608
+ return out;
609
+ }
610
+
611
+ function parseRpcSettings(
612
+ value: unknown,
613
+ filePath: string,
614
+ ): RpcSettings | undefined {
615
+ if (value === undefined) return undefined;
616
+ if (!isRecord(value)) {
617
+ throw new Error(
618
+ `Subagent settings in '${filePath}' have invalid 'rpc'; expected an object.`,
619
+ );
620
+ }
621
+ const out: RpcSettings = {};
622
+ setIf(out, "approve", requiredBoolean(value.approve, "rpc.approve", filePath));
623
+ // Unknown keys ignored; v1 does not accept / emit --no-approve.
624
+ return out;
625
+ }
626
+
566
627
  /** Validate the types of the supported fields in one override object. */
567
628
  function parseAgentOverride(
568
629
  value: unknown,
@@ -621,6 +682,15 @@ function parseAgentOverrides(
621
682
  `Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.utility'; expected a boolean.`,
622
683
  );
623
684
  }
685
+ if (
686
+ normalized.spawnMode !== undefined &&
687
+ (typeof normalized.spawnMode !== "string" ||
688
+ !(SPAWN_MODES as readonly string[]).includes(normalized.spawnMode))
689
+ ) {
690
+ throw new Error(
691
+ `Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.spawnMode'; expected one of: ${SPAWN_MODES.join(", ")}.`,
692
+ );
693
+ }
624
694
  parsed[name] = normalized as AgentOverride;
625
695
  }
626
696
  return parsed;
@@ -645,6 +715,34 @@ function parseTeamMember(
645
715
  return { agent, ...parseAgentOverride(override, field, filePath) };
646
716
  }
647
717
 
718
+
719
+ /** Validate team parentContext/childContext; resolve `@path` against settings dir. */
720
+ function parseTeamContextField(
721
+ value: unknown,
722
+ field: string,
723
+ filePath: string,
724
+ bad: InvalidTeamSetting,
725
+ ): string | false | undefined {
726
+ if (value === undefined) return undefined;
727
+ if (value === false) return false;
728
+ try {
729
+ const resolved = parseContextSetting(value, field, path.dirname(filePath));
730
+ // Empty after resolve → treat as omitted (fall through at resolve time).
731
+ // Callers that want "disable" must use false.
732
+ return resolved;
733
+ } catch (error) {
734
+ // parseContextSetting already builds a clear message; rethrow as-is
735
+ // except for the shape error which we map to the team field wording.
736
+ if (
737
+ error instanceof Error &&
738
+ error.message.includes("expected a non-empty string")
739
+ ) {
740
+ bad(field, "expected a non-empty string, string array, or false");
741
+ }
742
+ throw error;
743
+ }
744
+ }
745
+
648
746
  /** Validate the named team map and its ordered member references. */
649
747
  function parseTeams(
650
748
  value: unknown,
@@ -670,10 +768,25 @@ function parseTeams(
670
768
  if (raw.description !== undefined && typeof raw.description !== "string") {
671
769
  bad(`${field}.description`, "expected a string");
672
770
  }
673
- out[name] = {
771
+ const team: TeamConfig = {
674
772
  ...(raw.description !== undefined ? { description: raw.description } : {}),
675
773
  members,
676
774
  };
775
+ const parentCtx = parseTeamContextField(
776
+ raw.parentContext,
777
+ `${field}.parentContext`,
778
+ filePath,
779
+ bad,
780
+ );
781
+ if (parentCtx !== undefined) team.parentContext = parentCtx;
782
+ const childCtx = parseTeamContextField(
783
+ raw.childContext,
784
+ `${field}.childContext`,
785
+ filePath,
786
+ bad,
787
+ );
788
+ if (childCtx !== undefined) team.childContext = childCtx;
789
+ out[name] = team;
677
790
  }
678
791
  return out;
679
792
  }
@@ -770,6 +883,14 @@ export function resolveSubagentSettings(
770
883
  out.scoutGuard = { ...(user.scoutGuard ?? {}), ...project.scoutGuard };
771
884
  }
772
885
 
886
+ if (project.inprocess) {
887
+ out.inprocess = { ...(user.inprocess ?? {}), ...project.inprocess };
888
+ }
889
+
890
+ if (project.rpc) {
891
+ out.rpc = { ...(user.rpc ?? {}), ...project.rpc };
892
+ }
893
+
773
894
  return out;
774
895
  }
775
896
 
package/src/api.ts CHANGED
@@ -130,7 +130,12 @@ export type {
130
130
  export { Orchestrator, preCreateSessionFile, effectiveMaxDepth } from "./runs/orchestrator.ts";
131
131
  export type { BudgetRefusedEvent, CollectResult, OrchestratorDeps } from "./runs/orchestrator.ts";
132
132
  export { RpcSupervisor } from "./supervisor/rpc-supervisor.ts";
133
+ export { InProcessSupervisor } from "./supervisor/inprocess-supervisor.ts";
134
+ export { RoutingSupervisor } from "./supervisor/routing-supervisor.ts";
135
+ export { resolveSpawnMode, DEFAULT_SPAWN_MODE, DEFAULT_INPROCESS_MAX_ACTIVE } from "./supervisor/spawn-mode.ts";
133
136
  export { InMemoryUIProxy, isExtensionUIRequest } from "./supervisor/ui-proxy.ts";
137
+ export { runInChildContext, currentChildContext } from "./supervisor/child-context.ts";
138
+ export type { ChildLaunchContext } from "./supervisor/child-context.ts";
134
139
  export type {
135
140
  ChildHandle,
136
141
  LegionSupervisor,
@@ -142,6 +147,22 @@ export type {
142
147
  SupervisorEvent,
143
148
  UsageSnapshot,
144
149
  } from "./supervisor/types.ts";
150
+ export {
151
+ buildInProcessSessionPlan,
152
+ } from "./supervisor/inprocess-supervisor.ts";
153
+ export type {
154
+ InProcessSession,
155
+ InProcessSessionFactory,
156
+ InProcessSessionFactoryInput,
157
+ InProcessSessionPlan,
158
+ InProcessSessionStats,
159
+ InProcessSupervisorOptions,
160
+ } from "./supervisor/inprocess-supervisor.ts";
161
+ export type {
162
+ RoutingSupervisorOptions,
163
+ SupervisorBackend,
164
+ } from "./supervisor/routing-supervisor.ts";
165
+ export type { InProcessLimits, SpawnMode } from "./shared/types.ts";
145
166
  export type {
146
167
  ExtensionUIRequest,
147
168
  ExtensionUIResponse,
@@ -216,7 +237,9 @@ export {
216
237
  canUseCachedCollect,
217
238
  formatAlreadyRecycled,
218
239
  } from "./extension/recycle.ts";
240
+ /** STATUS_FOOTER_KEY / formatFooterStatus are @deprecated (#192 footer drop); soft-land re-exports. */
219
241
  export {
242
+ activeEntries,
220
243
  applyStatus,
221
244
  createStatusBoard,
222
245
  formatBusyLabel,
@@ -1,12 +1,18 @@
1
1
  /**
2
- * Guards that run inside an RPC child Pi process.
2
+ * Guards that run inside a child Pi session.
3
3
  *
4
4
  * The child extension enforces budgets and read-only role protections. A
5
5
  * nested dispatch can only use the subagent tool; bash must never access the
6
6
  * tree ledger directly.
7
+ *
8
+ * Two transports exist (#197): RPC children are separate `pi --mode rpc`
9
+ * processes (identity arrives via env), in-process children are
10
+ * `createAgentSession` sessions inside the parent Node process (identity
11
+ * arrives via the AsyncLocalStorage child context).
7
12
  */
8
13
 
9
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { currentChildContext, type ChildLaunchContext } from "../supervisor/child-context.ts";
10
16
  import {
11
17
  budgetExceededReason,
12
18
  childBudgetFromEnv,
@@ -52,7 +58,8 @@ export function blockChildMessage(reason: string): string {
52
58
  }
53
59
 
54
60
  export function isReadOnlyRole(role: string | undefined): boolean {
55
- return role === "read-only";
61
+ // #172: tolerate case/spacing from env-supplied PI_SUBAGENT_ACCEPTANCE_ROLE.
62
+ return role?.trim().toLowerCase() === "read-only";
56
63
  }
57
64
 
58
65
  export interface ChildGuardEnv {
@@ -310,6 +317,38 @@ export function childGuardEnvFromProcess(env: NodeJS.ProcessEnv = process.env):
310
317
  : {};
311
318
  }
312
319
 
320
+ /**
321
+ * Read-only role for an in-process child, from the AsyncLocalStorage child
322
+ * context injected by the orchestrator.
323
+ *
324
+ * Why this is not `process.env`: in-process children share the parent's
325
+ * `process.env` — they do not get a fresh env of their own. Reading
326
+ * `PI_SUBAGENT_ACCEPTANCE_ROLE` from env in an in-process child therefore sees
327
+ * the *parent's* value (or nothing, when the parent is a root session). Either
328
+ * direction is wrong: a parent `writer` would make a read-only child's write
329
+ * bans silently disappear, and a parent read-only role would wrongly ban a
330
+ * `writer` child.
331
+ */
332
+ export function childGuardEnvFromContext(
333
+ ctx: ChildLaunchContext | undefined = currentChildContext(),
334
+ ): ChildGuardEnv {
335
+ return ctx?.acceptanceRole ? { acceptanceRole: ctx.acceptanceRole } : {};
336
+ }
337
+
338
+ /**
339
+ * Effective child-guard env: the in-process child context wins, env is the
340
+ * RPC fallback. `childGuardEnvFromProcess` stays authoritative for RPC
341
+ * children, whose process really does own the env values.
342
+ */
343
+ export function effectiveChildGuardEnv(
344
+ ctx: ChildLaunchContext | undefined = currentChildContext(),
345
+ env: NodeJS.ProcessEnv = process.env,
346
+ ): ChildGuardEnv {
347
+ const fromContext = childGuardEnvFromContext(ctx);
348
+ if (fromContext.acceptanceRole !== undefined) return fromContext;
349
+ return childGuardEnvFromProcess(env);
350
+ }
351
+
313
352
  /** Child-only extension: intercept tools; do not register the parent tool. */
314
353
  export function registerChildGuard(pi: ExtensionAPI): void {
315
354
  const budget = childBudgetFromEnv();
@@ -326,7 +365,7 @@ export function registerChildGuard(pi: ExtensionAPI): void {
326
365
  }
327
366
  if (event.toolName !== "bash") return;
328
367
  const command = typeof event.input.command === "string" ? event.input.command : "";
329
- const reason = forbiddenChildReason(command, childGuardEnvFromProcess());
368
+ const reason = forbiddenChildReason(command, effectiveChildGuardEnv());
330
369
  if (reason) return { block: true, reason: blockChildMessage(reason) };
331
370
  if (budget.toolTimeoutMs && budget.toolTimeoutMs > 0) {
332
371
  event.input.command = wrapBashWithTimeout(command, budget.toolTimeoutMs);
@@ -26,6 +26,7 @@ import {
26
26
  type ToggleCommandDeps,
27
27
  } from "./slash.ts";
28
28
  import { createSummaryCommand } from "./summary.ts";
29
+ import { createTimingCommand } from "./timing.ts";
29
30
  import { getAgentDir } from "../agents/paths.ts";
30
31
  import type {
31
32
  ExtensionAPI,
@@ -36,6 +37,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
36
37
  import {
37
38
  createLiveOverlay,
38
39
  createLiveOverlayFactory,
40
+ resolveModalOverlayOptions,
39
41
  type LiveOverlayBackfillCursor,
40
42
  type LiveOverlayBackfillResult,
41
43
  type LiveOverlayListener,
@@ -304,6 +306,8 @@ function liveNode(
304
306
  status: node.status,
305
307
  createdAt: node.createdAt,
306
308
  updatedAt: node.updatedAt,
309
+ parentId: node.parentId,
310
+ depth: node.depth,
307
311
  ...(node.turn === undefined ? {} : { turn: node.turn }),
308
312
  ...(node.turns === undefined ? {} : { turns: node.turns }),
309
313
  sessionFile: node.sessionFile,
@@ -425,6 +429,11 @@ function openLiveOverlay(
425
429
  nodeId: string,
426
430
  onStop?: (nodeId: string) => void,
427
431
  ): void {
432
+ // #193: the overlay must cover the floating frame only. A full-screen overlay
433
+ // would make pi-tui discard every host cell (its composite has no transparency
434
+ // option), so the area around the frame collapsed to the terminal's default
435
+ // background — the "black modal". Anchoring to the frame rect leaves the rest of
436
+ // the screen to the host, which paints the chat / /legion view there.
428
437
  const factory = createLiveOverlayFactory(controller, nodeId, {
429
438
  ...(onStop ? { onStop } : {}),
430
439
  cwd,
@@ -432,7 +441,9 @@ function openLiveOverlay(
432
441
  });
433
442
  void ui.custom(factory, {
434
443
  overlay: true,
435
- overlayOptions: { width: "100%" },
444
+ // Percentages (not absolute cells) so pi-tui re-derives the rect from the
445
+ // current terminal every frame; the frame survives resize.
446
+ overlayOptions: resolveModalOverlayOptions(),
436
447
  onHandle: (handle) => controller.getCurrent()?.attachOverlayHandle(handle),
437
448
  }).catch((error: unknown) => reportOverlayOpenError(controller, ui, error));
438
449
  }
@@ -558,6 +569,7 @@ export function registerLegionCommand(
558
569
  }
559
570
  : {}),
560
571
  summary: createSummaryCommand(pi),
572
+ timing: createTimingCommand(pi),
561
573
  };
562
574
  const helpText = [
563
575
  "Legion commands:",
@@ -567,7 +579,7 @@ export function registerLegionCommand(
567
579
  description: "Open the Legion view or manage teams, agents, subagent runs, and settings",
568
580
  getArgumentCompletions(prefix) {
569
581
  const trimmed = prefix.trimStart();
570
- if (/^(team|agents|on|off|summary)\s/i.test(trimmed)) {
582
+ if (/^(team|agents|on|off|summary|timing)\s/i.test(trimmed)) {
571
583
  const key = trimmed.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
572
584
  if (key === "on" || key === "off") return null;
573
585
  const command = subcommands[key];
@@ -595,7 +607,7 @@ export function registerLegionCommand(
595
607
  const nodes = deps.runtime.treeNodes();
596
608
  const verbs: { value: string; label: string }[] = [];
597
609
  if (!prefix.includes(" ")) {
598
- for (const command of ["team", "agents", "on", "off", "summary", "help"]) {
610
+ for (const command of ["team", "agents", "on", "off", "summary", "timing", "help"]) {
599
611
  if (command.startsWith(prefix)) {
600
612
  verbs.push({ value: command, label: command });
601
613
  }