pi-legion 2.0.4 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +315 -49
- package/package.json +24 -13
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +9 -2
- package/src/extension/legion-doctor.ts +54 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
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",
|
|
@@ -48,17 +48,6 @@
|
|
|
48
48
|
"CONTRIBUTING.md",
|
|
49
49
|
"LICENSE"
|
|
50
50
|
],
|
|
51
|
-
"scripts": {
|
|
52
|
-
"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",
|
|
59
|
-
"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
|
-
},
|
|
62
51
|
"peerDependencies": {
|
|
63
52
|
"@earendil-works/pi-agent-core": "*",
|
|
64
53
|
"@earendil-works/pi-ai": "*",
|
|
@@ -91,5 +80,27 @@
|
|
|
91
80
|
"@types/node": "^22.0.0",
|
|
92
81
|
"typebox": "^1.3.30",
|
|
93
82
|
"typescript": "^5.6.0"
|
|
83
|
+
},
|
|
84
|
+
"engines": {
|
|
85
|
+
"node": ">=22",
|
|
86
|
+
"pnpm": ">=9"
|
|
87
|
+
},
|
|
88
|
+
"devEngines": {
|
|
89
|
+
"packageManager": {
|
|
90
|
+
"name": "pnpm",
|
|
91
|
+
"version": ">=9",
|
|
92
|
+
"onFail": "error"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"scripts": {
|
|
96
|
+
"typecheck": "tsc --noEmit",
|
|
97
|
+
"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",
|
|
98
|
+
"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",
|
|
99
|
+
"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",
|
|
100
|
+
"test:all": "pnpm run typecheck && pnpm run test && pnpm run test:integration",
|
|
101
|
+
"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'",
|
|
102
|
+
"mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
|
|
103
|
+
"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",
|
|
104
|
+
"test:tui-visual": "docker build -q -f test/docker/Dockerfile.tui-sandbox -t ghcr.io/zzjcool/pi-legion/tui-sandbox:latest . >/dev/null && docker run --rm -v $(pwd):/plugin -w /plugin ghcr.io/zzjcool/pi-legion/tui-sandbox:latest bash test/docker/verify-tui-visual.sh"
|
|
94
105
|
}
|
|
95
|
-
}
|
|
106
|
+
}
|
|
@@ -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
|
|
package/src/agents/agents.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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, "
|
|
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.
|
package/src/agents/context.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/agents/overrides.ts
CHANGED
package/src/agents/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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);
|
|
@@ -37,6 +37,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
37
37
|
import {
|
|
38
38
|
createLiveOverlay,
|
|
39
39
|
createLiveOverlayFactory,
|
|
40
|
+
resolveModalOverlayOptions,
|
|
40
41
|
type LiveOverlayBackfillCursor,
|
|
41
42
|
type LiveOverlayBackfillResult,
|
|
42
43
|
type LiveOverlayListener,
|
|
@@ -428,6 +429,11 @@ function openLiveOverlay(
|
|
|
428
429
|
nodeId: string,
|
|
429
430
|
onStop?: (nodeId: string) => void,
|
|
430
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.
|
|
431
437
|
const factory = createLiveOverlayFactory(controller, nodeId, {
|
|
432
438
|
...(onStop ? { onStop } : {}),
|
|
433
439
|
cwd,
|
|
@@ -435,8 +441,9 @@ function openLiveOverlay(
|
|
|
435
441
|
});
|
|
436
442
|
void ui.custom(factory, {
|
|
437
443
|
overlay: true,
|
|
438
|
-
//
|
|
439
|
-
|
|
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(),
|
|
440
447
|
onHandle: (handle) => controller.getCurrent()?.attachOverlayHandle(handle),
|
|
441
448
|
}).catch((error: unknown) => reportOverlayOpenError(controller, ui, error));
|
|
442
449
|
}
|