@henryqw/pi-subagent 16.0.1 → 16.1.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.
- package/CONTEXT.md +2 -2
- package/README.md +14 -1
- package/dist/git-process.d.ts +8 -0
- package/dist/git-process.js +32 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +25 -3
- package/dist/mcp-role.d.ts +8 -0
- package/dist/mcp-role.js +45 -0
- package/dist/review-evidence.js +5 -13
- package/dist/worktree.d.ts +1 -5
- package/dist/worktree.js +9 -12
- package/docs/orchestration.md +3 -2
- package/examples/roles/implementer.md +1 -0
- package/examples/roles/reviewer.md +1 -0
- package/examples/roles/scout.md +1 -0
- package/extensions/role-mcp.ts +31 -0
- package/package.json +2 -1
package/CONTEXT.md
CHANGED
|
@@ -15,7 +15,7 @@ Provide validated built-in and user Roles, shared task-model Pi launch policy, g
|
|
|
15
15
|
- **Workflow**: generic orchestration of one or more Delegated Tasks; `delegate_task` owns its selected mode, while library callers compose executor runs in JavaScript.
|
|
16
16
|
- **Workflow Mode**: `delegate_task` tool policy selected per call for `single`, `parallel`, or `chain` execution; not a Role property or executor API.
|
|
17
17
|
- **Exact Review Evidence**: caller-requested private base-to-tip patch plus exact `{base, tip, patchPath}` identity prepared by `prepareExactReviewEvidence`.
|
|
18
|
-
- **Resource Policy**: Role ownership of base tools, extensions,
|
|
18
|
+
- **Resource Policy**: Role ownership of base tools, extensions, Skill names, and an exact MCP server allowlist, plus explicit caller additions of tools, extensions, and environment through `createRoleLaunch`.
|
|
19
19
|
- **Pi Launch**: reusable `{env,args}` policy for one Role, resolved model route, explicit caller resources, and project trust.
|
|
20
20
|
- **Ephemeral Executor**: mechanism that receives a prepared Pi Launch, runs one bounded Delegated Task in one no-session child process, and returns its result without discovering resources or composing a Workflow.
|
|
21
21
|
|
|
@@ -24,7 +24,7 @@ Provide validated built-in and user Roles, shared task-model Pi launch policy, g
|
|
|
24
24
|
- One Delegated Task creates one ephemeral child process and no saved session. Execution ends at the first hard budget: attempted turn 51 by default (`maxTurns` is a safe integer >= 1; default 50), attempted continuation after the optional token handoff, or `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)` (recognized Pi events renew; raw bytes do not; max always terminates). A terminal turn 50 succeeds; attempted continuation rejects with `turn_limit`, accumulated usage, and bounded output. Optional `maxTokens` is a safe integer >= 1 with an unlimited default. It is one global executor default applied independently to every `delegate_task` child; it is neither a shared pool nor a per-call or environment option. Token accounting sums each completed assistant response's `Usage.totalTokens` once, matching the executor's aggregate `Usage`. A terminal response crossing the limit succeeds. A continuing crossing turn and its tools finish, then exactly one extra response turn is permitted. Further continuation rejects with typed `token_limit`, aggregate usage, and bounded last assistant output. This permits a crossing turn plus the final response to overshoot; it is not an exact hard cap. Raw executor launches enforce the extra-turn window but do not guarantee tools are disabled.
|
|
25
25
|
- Every Role launch installs the shared tool policy. On a continuing token crossing, or the continuing penultimate `maxTurns` turn, the policy waits for `turn_end`, disables every tool, and steers one structured final report. This covers `delegate_task` and library Role launches made with the public launch API. With `maxTurns` set to 1, tools are disabled during `session_start`, and the sole provider turn is the response-only handoff. The fixed decision packet is the default, but exact task or Role output takes precedence and is returned alone. Terminal boundary responses get no handoff. Before the final boundary, the policy steers the fixed convergence warning once at each 80% threshold for completed turns, aggregate tokens when configured, and maximum runtime. It combines thresholds first due together and starts no timer or extra warning turn. Timeout, provider, or child-process failures can prevent a handoff. After direct Pi exits, inherited stdout/stderr drain until EOF unless an escaped descendant holds them past short inactivity or a one-second hard deadline. Configurable in `~/.pi/agent/config/pi-subagent/config.json` (`maxTurns` defaults to 50; `maxTokens` defaults to unlimited; `timeout.idleMinutes`/`maxMinutes` default to 10/30).
|
|
26
26
|
- Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/config.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout.
|
|
27
|
-
- Ambient child extensions and Skills stay disabled. Every Role requires `tools`, `extensions`, and `skills` YAML arrays, and every launch installs the Role tool policy. `tools: []` activates no base built-ins but does activate all tools from explicitly selected trusted extension bundles and explicit caller tool additions; `skills: []` selects no separately named Role Skills but trusted selected extension Skills still load; `extensions: []` selects no Role extension bundle. A Role/caller explicitly selected extension is a trusted atomic capability bundle: all tools it registers and all Skills supplied through its Pi package metadata or dynamic `resources_discover` load alongside separately named Role Skills. This intentionally includes the extension's executable lifecycle/prompt behavior; pi-subagent does not infer or externally narrow undocumented dependencies, and loading an extension is not sandboxing. Scope children by selecting fewer trusted extensions; finer granularity requires separate entry points/configuration or an upstream split. Explicit Role/caller tool names still verify against the final filtered registry, while parent-only recursive orchestration tools remain excluded.
|
|
27
|
+
- Ambient child extensions and Skills stay disabled. Every Role requires `tools`, `extensions`, and `skills` YAML arrays, and every launch installs the Role tool policy. Optional `mcps` defaults to an empty deny-all list. Non-empty `mcps` loads `pi-mcp-adapter` with an isolated in-memory config containing only those exact configured servers; every tool that those servers expose is active, unknown names fail before the first model turn, and direct adapter loading is rejected. `tools: []` activates no base built-ins but does activate all tools from explicitly selected trusted extension bundles and explicit caller tool additions; `skills: []` selects no separately named Role Skills but trusted selected extension Skills still load; `extensions: []` selects no Role extension bundle. A Role/caller explicitly selected extension is a trusted atomic capability bundle: all tools it registers and all Skills supplied through its Pi package metadata or dynamic `resources_discover` load alongside separately named Role Skills. This intentionally includes the extension's executable lifecycle/prompt behavior; pi-subagent does not infer or externally narrow undocumented dependencies, and loading an extension is not sandboxing. Scope children by selecting fewer trusted extensions; finer granularity requires separate entry points/configuration or an upstream split. Explicit Role/caller tool names still verify against the final filtered registry, while parent-only recursive orchestration tools remain excluded.
|
|
28
28
|
- Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation. Explicit Role/caller tool names verify against the final filtered child registry after explicit provider `session_start` handlers, and unavailable names fail before the first turn.
|
|
29
29
|
- Route precedence is explicit call-level `modelClass` > Role `modelClass` > configured Model Task assignment or declared default. pi-subagent's local `pi-subagent/delegateTask` declaration defaults to `fast`. A direct model replaces only the selected route model and must honor its exact thinking level. Library callers select a Role plus their own Model Task declaration.
|
|
30
30
|
- The selected profile resolves primary then fallback only before launch when a route, model, or thinking level is unavailable. A direct model never changes the route level and fails before launch if it cannot honor it. A missing local JSON config uses defaults quietly. Missing shared task-model config warns once per session because delegation needs a route. If neither route is usable, launch rejects with `Run /task-models`; a started child is never retried by this package.
|
package/README.md
CHANGED
|
@@ -15,12 +15,20 @@ pi install npm:@henryqw/pi-subagent
|
|
|
15
15
|
|
|
16
16
|
Run `/task-models` and configure the `fast` profile before delegating. Open `/task-models` again and verify that `fast` no longer says `not configured`.
|
|
17
17
|
|
|
18
|
+
Install `pi-mcp-adapter` when any Role declares an MCP server allowlist:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-mcp-adapter
|
|
22
|
+
```
|
|
23
|
+
|
|
18
24
|
## Works with
|
|
19
25
|
|
|
20
26
|
| Package | Relationship | Purpose |
|
|
21
27
|
| --- | --- | --- |
|
|
22
28
|
| [`@henryqw/pi-orchestrator`](https://pi.henry.wang/extensions/pi-orchestrator) | Consumer | Owns durable checked local implementation graphs. |
|
|
23
29
|
| [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Required | Supplies `fast`, `balanced`, `frontier`, and `fav` model routes. |
|
|
30
|
+
| [`@henryqw/pi-process`](https://pi.henry.wang/packages/pi-process) | Required | Runs bounded captured Git commands. |
|
|
31
|
+
| [`pi-mcp-adapter`](https://www.npmjs.com/package/pi-mcp-adapter) | Optional | Exposes only the MCP servers selected by a Role. |
|
|
24
32
|
|
|
25
33
|
Routes come from `~/.pi/agent/config/pi-task-models/config.json`. It stores explicit task overrides. Missing shared model config warns once because delegation needs a route.
|
|
26
34
|
|
|
@@ -101,7 +109,11 @@ Its ordinary review loop is optional. Use it only when the caller or repository
|
|
|
101
109
|
- Fix initial findings together. Validate repaired inputs once before focused re-review. Include the original findings and acceptance criteria, exact repaired-candidate evidence, and validation evidence.
|
|
102
110
|
- Only `PASS` completes the loop. Surface and block on re-review findings or empty output. Retry empty output only when explicit caller policy requires one. A second empty result blocks. Do not add another round.
|
|
103
111
|
|
|
104
|
-
A Role selects base tools, extensions, named Skills, instructions, and optional worktree isolation. Named Skills resolve from Main's effective Pi registry. Unavailable names warn and skip.
|
|
112
|
+
A Role selects base tools, extensions, named Skills, MCP servers, instructions, and optional worktree isolation. Named Skills resolve from Main's effective Pi registry. Unavailable names warn and skip.
|
|
113
|
+
|
|
114
|
+
Use `mcps` to allow configured MCP servers by exact name. An omitted or empty `mcps` list denies MCP access. Every tool exposed by an allowed server is available through `pi-mcp-adapter`.
|
|
115
|
+
|
|
116
|
+
The adapter receives an isolated in-memory config containing only those servers. Unknown names fail before the first model turn. Loading `pi-mcp-adapter` directly through `extensions` is rejected because it would bypass the allowlist.
|
|
105
117
|
|
|
106
118
|
Children disable ambient extension and Skill discovery. `tools: []` adds no base tools, but selected extension tools and caller tools still activate. `extensions: []` adds no Role extension bundle. `skills: []` adds no separately named Role Skills, but selected extension Skills still load.
|
|
107
119
|
|
|
@@ -156,6 +168,7 @@ Role Markdown files live beside the config file. They require frontmatter and a
|
|
|
156
168
|
| `isolation` | Optional; only `worktree`. |
|
|
157
169
|
| `extensions` | Required YAML array. Entries are absolute paths, `~/…`, `file://`, or `npm:`, `git:`, `github:`, `https?:`, or `ssh:` sources. |
|
|
158
170
|
| `skills` | Required YAML array of non-empty Skill names. |
|
|
171
|
+
| `mcps` | Optional YAML array of exact MCP server names. Omitted or `[]` denies MCP access. |
|
|
159
172
|
| body | Required Markdown system prompt after the frontmatter. |
|
|
160
173
|
|
|
161
174
|
A Role's `modelClass` is a default. A call-level class wins.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type GitResult = {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
};
|
|
6
|
+
export type GitRunner = (args: string[], cwd: string, signal?: AbortSignal) => Promise<GitResult>;
|
|
7
|
+
/** Run one ordinary Git command with bounded output; transport failures never throw. */
|
|
8
|
+
export declare const runGit: GitRunner;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawnBounded } from "@henryqw/pi-process";
|
|
2
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
3
|
+
const GIT_OUTPUT_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
4
|
+
const GIT_DIAGNOSTIC_LIMIT = 200;
|
|
5
|
+
/** Run one ordinary Git command with bounded output; transport failures never throw. */
|
|
6
|
+
export const runGit = async (args, cwd, signal) => {
|
|
7
|
+
try {
|
|
8
|
+
const result = await spawnBounded("git", ["--no-pager", ...args], {
|
|
9
|
+
cwd,
|
|
10
|
+
signal,
|
|
11
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
12
|
+
stdoutLimitBytes: GIT_OUTPUT_LIMIT_BYTES,
|
|
13
|
+
stderrLimitBytes: GIT_OUTPUT_LIMIT_BYTES,
|
|
14
|
+
});
|
|
15
|
+
if (result.killed) {
|
|
16
|
+
return {
|
|
17
|
+
code: -1,
|
|
18
|
+
stdout: "",
|
|
19
|
+
stderr: result.stderr.trim().slice(0, GIT_DIAGNOSTIC_LIMIT) || "git was killed",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return {
|
|
27
|
+
code: -1,
|
|
28
|
+
stdout: "",
|
|
29
|
+
stderr: reason.slice(0, GIT_DIAGNOSTIC_LIMIT) || "git execution failed",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type AvailableModel, type ModelTask, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
3
3
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.ts";
|
|
4
|
+
export { fingerprintRoleMcpConfig, parseRoleMcpAllowlist, roleMcpFlagValue, selectRoleMcpConfig, type RoleMcpConfig } from "./mcp-role.ts";
|
|
4
5
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutionBudget, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
|
|
5
6
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
|
|
6
7
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
|
|
7
8
|
export declare const PI_ORCHESTRATOR_PROCESS_LEASE = "PI_ORCHESTRATOR_PROCESS_LEASE";
|
|
9
|
+
export declare const ROLE_MCP_CONFIG_SHA256_FLAG = "pi-subagent-role-mcp-config-sha256";
|
|
10
|
+
export declare const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
8
11
|
export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
9
12
|
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "orchestrate_execute", "orchestrate_status", "orchestrate_resume", "orchestrate_abort"];
|
|
10
13
|
export declare const CHILD_EXCLUDED_TOOLS: string;
|
|
@@ -22,6 +25,7 @@ export interface Role {
|
|
|
22
25
|
isolation?: string;
|
|
23
26
|
extensions: string[];
|
|
24
27
|
skills: string[];
|
|
28
|
+
mcps?: string[];
|
|
25
29
|
systemPrompt: string;
|
|
26
30
|
}
|
|
27
31
|
export interface PiLaunch {
|
package/dist/index.js
CHANGED
|
@@ -6,13 +6,17 @@ import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
|
6
6
|
import { hasDisplayControlCharacters } from "./display-text.js";
|
|
7
7
|
import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
|
|
8
8
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
|
|
9
|
+
export { fingerprintRoleMcpConfig, parseRoleMcpAllowlist, roleMcpFlagValue, selectRoleMcpConfig } from "./mcp-role.js";
|
|
9
10
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
|
|
10
11
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, } from "./worktree.js";
|
|
11
12
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
|
|
12
13
|
const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
|
|
13
14
|
const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
|
|
15
|
+
const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", import.meta.url));
|
|
14
16
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
15
17
|
export const PI_ORCHESTRATOR_PROCESS_LEASE = "PI_ORCHESTRATOR_PROCESS_LEASE";
|
|
18
|
+
export const ROLE_MCP_CONFIG_SHA256_FLAG = "pi-subagent-role-mcp-config-sha256";
|
|
19
|
+
export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
16
20
|
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
17
21
|
export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
18
22
|
"delegate_task",
|
|
@@ -62,6 +66,15 @@ function validateExtension(extension, source) {
|
|
|
62
66
|
function extensionList(value, source) {
|
|
63
67
|
return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
|
|
64
68
|
}
|
|
69
|
+
function mcpList(value, source) {
|
|
70
|
+
const names = stringList(value ?? [], "mcps", source);
|
|
71
|
+
if (new Set(names).size !== names.length)
|
|
72
|
+
throw new Error(`${source}: mcps contains duplicate MCP server names.`);
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
function namesMcpAdapter(extension) {
|
|
76
|
+
return extension.toLowerCase().split(/[\\/:@]+/).some((component) => component === "pi-mcp-adapter" || component.startsWith("pi-mcp-adapter."));
|
|
77
|
+
}
|
|
65
78
|
function roleModelClass(value, source) {
|
|
66
79
|
if (value === undefined)
|
|
67
80
|
return;
|
|
@@ -94,6 +107,7 @@ function parseRoleFile(file, raw) {
|
|
|
94
107
|
isolation,
|
|
95
108
|
extensions: extensionList(frontmatter.extensions, file),
|
|
96
109
|
skills: stringList(frontmatter.skills, "skills", file),
|
|
110
|
+
mcps: mcpList(frontmatter.mcps, file),
|
|
97
111
|
systemPrompt: cleanText(parsed.body, "system prompt", file),
|
|
98
112
|
};
|
|
99
113
|
}
|
|
@@ -181,13 +195,19 @@ export function resolveRoleSkills(pi, role) {
|
|
|
181
195
|
export function createRoleLaunch(pi, ctx, input) {
|
|
182
196
|
const role = input.role;
|
|
183
197
|
const skills = resolveRoleSkills(pi, role);
|
|
198
|
+
const mcps = mcpList(role.mcps, `Role ${role.name}`);
|
|
184
199
|
const tools = [...new Set([...role.tools, ...(input.tools ?? [])].map((tool) => cleanText(tool, "tool", `Role ${role.name}`)))];
|
|
200
|
+
const selectedExtensions = [...role.extensions, ...(input.extensions ?? [])]
|
|
201
|
+
.map((extension) => validateExtension(extension, `Role ${role.name}`));
|
|
202
|
+
if (selectedExtensions.some(namesMcpAdapter)) {
|
|
203
|
+
throw new Error(`Role ${role.name} must select MCP servers with mcps instead of loading pi-mcp-adapter directly.`);
|
|
204
|
+
}
|
|
185
205
|
const extensions = [
|
|
186
|
-
...
|
|
187
|
-
...(input.extensions ?? []),
|
|
206
|
+
...selectedExtensions,
|
|
188
207
|
...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
|
|
208
|
+
...(mcps.length ? [ROLE_MCP_EXTENSION] : []),
|
|
189
209
|
ROLE_TOOLS_EXTENSION,
|
|
190
|
-
]
|
|
210
|
+
];
|
|
191
211
|
const env = Object.fromEntries(Object.entries(input.env ?? {}).map(([key, value]) => {
|
|
192
212
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
193
213
|
throw new Error(`Invalid launch environment name: ${key}`);
|
|
@@ -196,6 +216,8 @@ export function createRoleLaunch(pi, ctx, input) {
|
|
|
196
216
|
return [key, value];
|
|
197
217
|
}));
|
|
198
218
|
const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools", CHILD_EXCLUDED_TOOLS];
|
|
219
|
+
if (mcps.length)
|
|
220
|
+
args.push(`--${ROLE_MCP_POLICY_FLAG}`, JSON.stringify(mcps));
|
|
199
221
|
for (const extension of new Set(extensions))
|
|
200
222
|
args.push("--extension", extension);
|
|
201
223
|
for (const skill of skills.paths)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface RoleMcpConfig {
|
|
2
|
+
mcpServers: Record<string, unknown>;
|
|
3
|
+
settings?: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseRoleMcpAllowlist(value: unknown): string[];
|
|
6
|
+
export declare function roleMcpFlagValue(args: readonly string[], flag: string): string | undefined;
|
|
7
|
+
export declare function fingerprintRoleMcpConfig(config: RoleMcpConfig): string;
|
|
8
|
+
export declare function selectRoleMcpConfig(config: RoleMcpConfig, allowlist: readonly string[]): RoleMcpConfig;
|
package/dist/mcp-role.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function parseRoleMcpAllowlist(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = JSON.parse(value);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(parsed)
|
|
13
|
+
|| parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
14
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
15
|
+
}
|
|
16
|
+
const names = parsed.map((name) => name.trim());
|
|
17
|
+
if (new Set(names).size !== names.length)
|
|
18
|
+
throw new Error("The Role MCP policy flag contains duplicate MCP server names.");
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
export function roleMcpFlagValue(args, flag) {
|
|
22
|
+
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
23
|
+
if (indexes.length > 1)
|
|
24
|
+
throw new Error(`${flag} must appear at most once.`);
|
|
25
|
+
if (!indexes.length)
|
|
26
|
+
return;
|
|
27
|
+
const value = args[indexes[0] + 1];
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
throw new Error(`${flag} requires a value.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export function fingerprintRoleMcpConfig(config) {
|
|
33
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
export function selectRoleMcpConfig(config, allowlist) {
|
|
36
|
+
const missing = allowlist.filter((name) => !Object.hasOwn(config.mcpServers, name));
|
|
37
|
+
if (missing.length)
|
|
38
|
+
throw new Error(`Role MCP servers are not configured: ${missing.join(", ")}.`);
|
|
39
|
+
const mcpServers = Object.fromEntries(allowlist.map((name) => [name, config.mcpServers[name]]));
|
|
40
|
+
const { agentPluginPaths: _agentPluginPaths, hostConfigDiscovery: _hostConfigDiscovery, ...settings } = config.settings ?? {};
|
|
41
|
+
return {
|
|
42
|
+
mcpServers,
|
|
43
|
+
...(Object.keys(settings).length ? { settings } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/review-evidence.js
CHANGED
|
@@ -1,28 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { isAbsolute, join } from "node:path";
|
|
5
5
|
import { inspectIndexFlags } from "./worktree.js";
|
|
6
|
+
import { runGit as runCapturedGit } from "./git-process.js";
|
|
6
7
|
const GIT_TIMEOUT_MS = 30_000;
|
|
7
8
|
export const REVIEW_MAX_PATHS = 1_000;
|
|
8
9
|
export const REVIEW_MAX_PATCH_BYTES = 512 * 1024;
|
|
9
10
|
const STDERR_LIMIT = 200;
|
|
10
|
-
const git = (args, cwd, signal) => new Promise((resolve) => {
|
|
11
|
-
execFile("git", ["--no-pager", ...args], { cwd, signal, timeout: GIT_TIMEOUT_MS }, (error, stdout, stderr) => {
|
|
12
|
-
resolve({
|
|
13
|
-
code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
|
|
14
|
-
stdout: String(stdout),
|
|
15
|
-
stderr: String(stderr).slice(0, STDERR_LIMIT),
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
11
|
function failure(args, result) {
|
|
20
|
-
const detail = result.stderr.trim();
|
|
12
|
+
const detail = result.stderr.slice(0, STDERR_LIMIT).trim();
|
|
21
13
|
return new Error(`git ${args.join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`);
|
|
22
14
|
}
|
|
23
15
|
async function runGit(args, cwd, signal) {
|
|
24
16
|
signal?.throwIfAborted();
|
|
25
|
-
const result = await
|
|
17
|
+
const result = await runCapturedGit(args, cwd, signal);
|
|
26
18
|
signal?.throwIfAborted();
|
|
27
19
|
if (result.code !== 0)
|
|
28
20
|
throw failure(args, result);
|
|
@@ -126,7 +118,7 @@ async function assertCleanRegisteredWorktree(worktree, tip, signal) {
|
|
|
126
118
|
if (await runGit(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], worktree, signal)) {
|
|
127
119
|
throw new Error("Review evidence worktree is not clean.");
|
|
128
120
|
}
|
|
129
|
-
const flags = await inspectIndexFlags(worktree,
|
|
121
|
+
const flags = await inspectIndexFlags(worktree, runCapturedGit, signal);
|
|
130
122
|
if (flags.failure)
|
|
131
123
|
throw new Error(`Review evidence index inspection failed: ${flags.failure}`);
|
|
132
124
|
if (flags.hidden)
|
package/dist/worktree.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type GitRunner } from "./git-process.ts";
|
|
1
2
|
export interface WorktreeInfo {
|
|
2
3
|
path: string;
|
|
3
4
|
cwd: string;
|
|
@@ -23,11 +24,6 @@ export type WorktreePayload = {
|
|
|
23
24
|
commits?: number;
|
|
24
25
|
dirty?: boolean;
|
|
25
26
|
};
|
|
26
|
-
export type GitRunner = (args: string[], cwd: string, signal?: AbortSignal) => Promise<{
|
|
27
|
-
code: number;
|
|
28
|
-
stdout: string;
|
|
29
|
-
stderr: string;
|
|
30
|
-
}>;
|
|
31
27
|
export declare class WorktreeSetupError extends Error {
|
|
32
28
|
name: string;
|
|
33
29
|
readonly worktree: WorktreeInfo;
|
package/dist/worktree.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import { existsSync, lstatSync } from "node:fs";
|
|
4
3
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
5
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
-
|
|
5
|
+
import { runGit } from "./git-process.js";
|
|
7
6
|
const WORKTREES_DIRNAME = ".worktrees";
|
|
8
7
|
const BRANCH_NAMESPACE = "pi-subagent";
|
|
9
8
|
export class WorktreeSetupError extends Error {
|
|
@@ -14,16 +13,6 @@ export class WorktreeSetupError extends Error {
|
|
|
14
13
|
this.worktree = worktree;
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
|
-
/** Runs git, capturing output; never throws on non-zero exit or spawn failure. */
|
|
18
|
-
const runGit = (args, cwd, signal) => new Promise((resolve) => {
|
|
19
|
-
execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, signal }, (error, stdout, stderr) => {
|
|
20
|
-
resolve({
|
|
21
|
-
code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
|
|
22
|
-
stdout: String(stdout),
|
|
23
|
-
stderr: String(stderr),
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
16
|
const sanitizeShortId = (childId) => createHash("sha256").update(childId).digest("hex").slice(0, 24);
|
|
28
17
|
const stripGitLineEnd = (value) => value.replace(/\r?\n$/, "");
|
|
29
18
|
function hasRepositoryMarker(cwd) {
|
|
@@ -71,10 +60,15 @@ async function ensureLocalExclude(gitDir) {
|
|
|
71
60
|
*/
|
|
72
61
|
export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared) {
|
|
73
62
|
const root = await run(["rev-parse", "--show-toplevel"], cwd, signal);
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
if (root.code === -1)
|
|
65
|
+
throw new Error(`git rev-parse failed (${root.stderr.trim().slice(0, 200)})`);
|
|
74
66
|
if (root.code !== 0) {
|
|
75
67
|
signal?.throwIfAborted();
|
|
76
68
|
const repository = await run(["-c", "safe.directory=*", "rev-parse", "--show-toplevel"], cwd, signal);
|
|
77
69
|
signal?.throwIfAborted();
|
|
70
|
+
if (repository.code === -1)
|
|
71
|
+
throw new Error(`git rev-parse failed (${repository.stderr.trim().slice(0, 200)})`);
|
|
78
72
|
if (repository.code !== 0 && !hasRepositoryMarker(cwd) && !process.env.GIT_DIR && !process.env.GIT_WORK_TREE)
|
|
79
73
|
return undefined;
|
|
80
74
|
throw new Error(`git rev-parse failed (${root.stderr.trim().slice(0, 200)})`); // dubious ownership, timeout, …
|
|
@@ -87,6 +81,9 @@ export async function createChildWorktree(cwd, childId, run = runGit, signal, on
|
|
|
87
81
|
throw new Error(`git rev-parse --show-prefix failed (${prefix.stderr.trim().slice(0, 200)})`);
|
|
88
82
|
const relativeCwd = stripGitLineEnd(prefix.stdout);
|
|
89
83
|
const base = await run(["rev-parse", "HEAD"], repoRoot, signal);
|
|
84
|
+
signal?.throwIfAborted();
|
|
85
|
+
if (base.code === -1)
|
|
86
|
+
throw new Error(`git rev-parse HEAD failed (${base.stderr.trim().slice(0, 200)})`);
|
|
90
87
|
if (base.code !== 0) {
|
|
91
88
|
signal?.throwIfAborted();
|
|
92
89
|
const head = await run(["symbolic-ref", "--quiet", "HEAD"], repoRoot, signal);
|
package/docs/orchestration.md
CHANGED
|
@@ -103,13 +103,14 @@ A Role file requires:
|
|
|
103
103
|
- base tools: a YAML array; `tools: []` activates no base built-ins, while trusted selected extension tools still activate;
|
|
104
104
|
- explicit extension paths or package sources: a YAML array; `extensions: []` selects no Role extension bundle;
|
|
105
105
|
- additional effective Pi Skill names: a YAML array; `skills: []` selects no separately named Role Skills, while trusted selected extension Skills still load;
|
|
106
|
+
- optional exact MCP server names: `mcps: []` or an omitted field denies MCP access, while allowed servers expose all their tools through `pi-mcp-adapter`;
|
|
106
107
|
- system instructions;
|
|
107
108
|
- optional `modelClass` default (`fast`, `balanced`, `frontier`, or `fav`); and
|
|
108
109
|
- optional `isolation: worktree` for the tool layer.
|
|
109
110
|
|
|
110
111
|
Every launch installs the Role tool policy. At launch, a package caller may add `tools`, `extensions`, and `env`; caller tools are unioned into the Role base list and loaded extension tools activate in every case. Caller `env` adds to or overrides the active Pi process environment for the child.
|
|
111
112
|
|
|
112
|
-
Children start with ambient extension and Skill discovery disabled. Only explicit Role or caller extensions, resolved Skill paths, extension package resources, and required internal tool-policy or Codex adapters load. Loaded extension tools activate even when the Role base list is empty. Child-inappropriate parent tools are always excluded: `delegate_task`, `orchestrate_execute`, `orchestrate_status`, `orchestrate_resume`, `orchestrate_abort`, and `ask_question`. Explicit Role or caller tool names are verified against the final filtered registry after each provider extension completes `session_start`. Unavailable names fail before the first model turn and identify the missing names with provider guidance.
|
|
113
|
+
Children start with ambient extension and Skill discovery disabled. Only explicit Role or caller extensions, resolved Skill paths, extension package resources, and required internal tool-policy, MCP, or Codex adapters load. A non-empty `mcps` list requires an installed `pi-mcp-adapter`. The MCP adapter receives an isolated in-memory config containing only the named servers. Unknown server names fail before the first model turn, and direct adapter loading through `extensions` is rejected. Loaded extension tools activate even when the Role base list is empty. Child-inappropriate parent tools are always excluded: `delegate_task`, `orchestrate_execute`, `orchestrate_status`, `orchestrate_resume`, `orchestrate_abort`, and `ask_question`. Explicit Role or caller tool names are verified against the final filtered registry after each provider extension completes `session_start`. Unavailable names fail before the first model turn and identify the missing names with provider guidance.
|
|
113
114
|
|
|
114
115
|
Role Skill names resolve through Main's effective Pi Skill registry at launch. Missing names are returned in `ResolvedRoleLaunch.missingSkills`; `delegate_task` warns and skips them. Library callers must surface that warning themselves. Missing Skills do not block launch.
|
|
115
116
|
|
|
@@ -147,7 +148,7 @@ The package root exports the following mechanism-level APIs:
|
|
|
147
148
|
| `retained` | `path`, `branch`, `commits`, `dirty` | Work was preserved. Both measurements are known. |
|
|
148
149
|
| `recovery` | `path`, `branch`, `note`, optional `commits`, `dirty` | Recovery needs action. The note tells Main what to inspect. Present measurements completed; omitted values are unknown. |
|
|
149
150
|
|
|
150
|
-
A loaded `Role` contains `name`, `description`, required normalized `tools`, `extensions`, and `skills` arrays, optional `modelClass` and `isolation`, and `systemPrompt`. `resolveRoleLaunch` accepts `role`, a caller-owned `task` Model Task declaration, optional call-level `modelClass`, and optional caller `agentDir`, `extensions`, `tools`, and `env`. At extension load, callers invoke `registerModelTask(pi, task)` from `@henryqw/pi-task-models` once to expose that declaration in the shared control plane. Its result is a `PiLaunch` (`{ env, args }`) plus the selected `model`, `thinkingLevel`, and `missingSkills`.
|
|
151
|
+
A loaded `Role` contains `name`, `description`, required normalized `tools`, `extensions`, and `skills` arrays, a normalized `mcps` array, optional `modelClass` and `isolation`, and `systemPrompt`. `resolveRoleLaunch` accepts `role`, a caller-owned `task` Model Task declaration, optional call-level `modelClass`, and optional caller `agentDir`, `extensions`, `tools`, and `env`. At extension load, callers invoke `registerModelTask(pi, task)` from `@henryqw/pi-task-models` once to expose that declaration in the shared control plane. Its result is a `PiLaunch` (`{ env, args }`) plus the selected `model`, `thinkingLevel`, and `missingSkills`.
|
|
151
152
|
|
|
152
153
|
`createEphemeralSubagentExecutor` requires:
|
|
153
154
|
|
package/examples/roles/scout.md
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createMcpAdapter } from "pi-mcp-adapter";
|
|
4
|
+
import { loadMcpConfig } from "pi-mcp-adapter/config";
|
|
5
|
+
import {
|
|
6
|
+
fingerprintRoleMcpConfig,
|
|
7
|
+
parseRoleMcpAllowlist,
|
|
8
|
+
ROLE_MCP_CONFIG_SHA256_FLAG,
|
|
9
|
+
ROLE_MCP_POLICY_FLAG,
|
|
10
|
+
roleMcpFlagValue,
|
|
11
|
+
selectRoleMcpConfig,
|
|
12
|
+
} from "@henryqw/pi-subagent";
|
|
13
|
+
|
|
14
|
+
export default function roleMcp(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
16
|
+
description: "Internal Pi Subagent Role MCP policy",
|
|
17
|
+
type: "string",
|
|
18
|
+
});
|
|
19
|
+
pi.registerFlag(ROLE_MCP_CONFIG_SHA256_FLAG, {
|
|
20
|
+
description: "Internal Pi Subagent Role MCP config fingerprint",
|
|
21
|
+
type: "string",
|
|
22
|
+
});
|
|
23
|
+
const allowlist = parseRoleMcpAllowlist(roleMcpFlagValue(process.argv, `--${ROLE_MCP_POLICY_FLAG}`));
|
|
24
|
+
const config = selectRoleMcpConfig(loadMcpConfig(join(getAgentDir(), "mcp.json"), process.cwd()), allowlist);
|
|
25
|
+
const expectedFingerprint = roleMcpFlagValue(process.argv, `--${ROLE_MCP_CONFIG_SHA256_FLAG}`);
|
|
26
|
+
if (expectedFingerprint !== undefined
|
|
27
|
+
&& (typeof expectedFingerprint !== "string" || fingerprintRoleMcpConfig(config) !== expectedFingerprint)) {
|
|
28
|
+
throw new Error("Role MCP config fingerprint drifted before launch.");
|
|
29
|
+
}
|
|
30
|
+
createMcpAdapter({ config })(pi);
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "16.0
|
|
3
|
+
"version": "16.1.0",
|
|
4
4
|
"description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@henryqw/pi-config-store": "^1.0.0",
|
|
65
65
|
"@henryqw/pi-multi-codex": "^1.0.0",
|
|
66
|
+
"@henryqw/pi-process": "^0.1.0",
|
|
66
67
|
"@henryqw/pi-task-models": "^5.0.0"
|
|
67
68
|
}
|
|
68
69
|
}
|