@henryqw/pi-subagent 16.0.2 → 17.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.
- package/CONTEXT.md +3 -3
- package/README.md +19 -2
- package/dist/ephemeral.d.ts +1 -0
- package/dist/ephemeral.js +14 -3
- package/dist/index.d.ts +32 -1
- package/dist/index.js +176 -11
- package/dist/mcp-role.d.ts +7 -0
- package/dist/mcp-role.js +41 -0
- package/docs/adr/001-composable-ephemeral-execution.md +4 -1
- package/docs/orchestration.md +11 -12
- 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 +20 -0
- package/extensions/subagent.ts +13 -25
- package/package.json +1 -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,8 +24,8 @@ 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.
|
|
28
|
-
- Role Skill names resolve through Main's effective Pi Skill registry
|
|
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
|
+
- Role Skill names resolve through Main's effective Pi Skill registry. `prepareRoleLaunch` and `resolveConfiguredRoleLaunch` reject a nonempty `missingSkills` list before returning a prepared launch, so `delegate_task` does not start an under-capable child. Callers that bypass preparation must reject missing Skills before launch. 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.
|
|
31
31
|
- User Role Markdown files and Subagent JSON config (`config/pi-subagent/config.json`) live only in the user `config/pi-subagent` directory; model routes live in shared `config/pi-task-models/config.json`. Package-shipped built-in Roles (`implementer`, `reviewer`, `scout`) resolve from the package's own `examples/roles/` Markdown through the same parser and intentionally leave `modelClass` unset; same-named user files override built-ins for `delegate_task` and public API callers. The `synthesizer` Markdown remains the only optional inert sample.
|
package/README.md
CHANGED
|
@@ -15,6 +15,12 @@ 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 |
|
|
@@ -22,6 +28,7 @@ Run `/task-models` and configure the `fast` profile before delegating. Open `/ta
|
|
|
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. |
|
|
24
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. |
|
|
25
32
|
|
|
26
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.
|
|
27
34
|
|
|
@@ -102,7 +109,11 @@ Its ordinary review loop is optional. Use it only when the caller or repository
|
|
|
102
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.
|
|
103
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.
|
|
104
111
|
|
|
105
|
-
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.
|
|
106
117
|
|
|
107
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.
|
|
108
119
|
|
|
@@ -157,12 +168,15 @@ Role Markdown files live beside the config file. They require frontmatter and a
|
|
|
157
168
|
| `isolation` | Optional; only `worktree`. |
|
|
158
169
|
| `extensions` | Required YAML array. Entries are absolute paths, `~/…`, `file://`, or `npm:`, `git:`, `github:`, `https?:`, or `ssh:` sources. |
|
|
159
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. |
|
|
160
172
|
| body | Required Markdown system prompt after the frontmatter. |
|
|
161
173
|
|
|
162
174
|
A Role's `modelClass` is a default. A call-level class wins.
|
|
163
175
|
|
|
164
176
|
An unreadable or invalid Role fails loading fast. Duplicate Role names are rejected. A same-named user file overrides a built-in Role.
|
|
165
177
|
|
|
178
|
+
A missing named Skill rejects `prepareRoleLaunch` and `resolveConfiguredRoleLaunch`. `delegate_task` returns a Role-specific workflow error and does not start a child.
|
|
179
|
+
|
|
166
180
|
The package always provides these built-in Roles. Their files leave `modelClass` unset, so they use the configured `pi-subagent/delegateTask` assignment or declared default unless a call overrides it:
|
|
167
181
|
|
|
168
182
|
| Role | Purpose | Isolation/use |
|
|
@@ -178,9 +192,12 @@ The package root includes these main exports:
|
|
|
178
192
|
| Surface | Type | Purpose |
|
|
179
193
|
| --- | --- | --- |
|
|
180
194
|
| `loadRoles` | function | Loads built-in and user Role definitions. |
|
|
195
|
+
| `RoleName` / `parseRoleName` | type/function | Normalizes arbitrary Role names and rejects empty or C0/C1 control-character values. |
|
|
181
196
|
| `resolveRoleSkills` | function | Resolves a Role's named Skills from Pi's effective registry. |
|
|
182
197
|
| `resolveRoleLaunch` | function | Resolves a Role, route, and launch resources. |
|
|
198
|
+
| `resolveConfiguredRoleLaunch` | function | Resolves a configured Role and its package resources with an explicit model class. Rejects missing Role Skills. |
|
|
183
199
|
| `createRoleLaunch` | function | Builds launch arguments from a resolved route. |
|
|
200
|
+
| `prepareRoleLaunch` / `finalizeRoleLaunch` | functions | Separates the stable Role prompt, exposes its immutable tool policy, and rejects missing Role Skills. |
|
|
184
201
|
| `createEphemeralSubagentExecutor` | function | Creates the bounded child-process executor. |
|
|
185
202
|
| Worktree helpers | functions | Create, inspect, finalize, and report child worktrees. |
|
|
186
203
|
| `prepareExactReviewEvidence` | function | Create a bounded private base-to-tip patch with exact Git identity for caller-owned review. |
|
|
@@ -189,7 +206,7 @@ The executor works only inside the active Pi process. It does not discover or st
|
|
|
189
206
|
|
|
190
207
|
`finalizeChildWorktree` returns the breaking `WorktreePayload` lifecycle union. `pruned` proves zero commits, a clean tree, and removed worktree and branch. `retained` contains measured `commits` and `dirty` values. `recovery` has an actionable `note` and only completed measurements. An omitted recovery measurement is unknown.
|
|
191
208
|
|
|
192
|
-
See the [public Role and executor API](./docs/orchestration.md#public-role-and-executor-api) for contracts and a `prepare` example. Pass `modelClass` to `resolveRoleLaunch` to override a Role default.
|
|
209
|
+
See the [public Role and executor API](./docs/orchestration.md#public-role-and-executor-api) for contracts and a `prepare` example. Pass `modelClass` to `resolveRoleLaunch` to override a Role default. `resolveConfiguredRoleLaunch` requires a model class and does not use Role or task defaults.
|
|
193
210
|
|
|
194
211
|
## Limits and recovery
|
|
195
212
|
|
package/dist/ephemeral.d.ts
CHANGED
package/dist/ephemeral.js
CHANGED
|
@@ -416,8 +416,13 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
416
416
|
let lineEventType;
|
|
417
417
|
let ignoreLine = false;
|
|
418
418
|
let output = "";
|
|
419
|
+
let outputTruncated = false;
|
|
419
420
|
const stderr = { prefix: "", totalBytes: 0 };
|
|
420
421
|
const partial = { prefix: "", totalBytes: 0 };
|
|
422
|
+
const updateOutput = (nextOutput) => {
|
|
423
|
+
output = nextOutput;
|
|
424
|
+
outputTruncated = partial.totalBytes > MAX_OUTPUT_BYTES;
|
|
425
|
+
};
|
|
421
426
|
let hasPartialText = false;
|
|
422
427
|
let stopReason;
|
|
423
428
|
let errorMessage;
|
|
@@ -532,6 +537,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
532
537
|
outcome,
|
|
533
538
|
exitCode,
|
|
534
539
|
output,
|
|
540
|
+
outputTruncated,
|
|
535
541
|
stderr: boundedText(stderr),
|
|
536
542
|
stopReason,
|
|
537
543
|
errorMessage,
|
|
@@ -657,14 +663,16 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
657
663
|
const update = record.assistantMessageEvent;
|
|
658
664
|
if (update && typeof update === "object" && !Array.isArray(update)) {
|
|
659
665
|
const assistantEvent = update;
|
|
660
|
-
if (assistantEvent.type === "text_start" && hasPartialText)
|
|
666
|
+
if (assistantEvent.type === "text_start" && hasPartialText) {
|
|
661
667
|
appendBounded(partial, "\n");
|
|
668
|
+
updateOutput(boundedText(partial));
|
|
669
|
+
}
|
|
662
670
|
if (assistantEvent.type === "text_start")
|
|
663
671
|
hasPartialText = true;
|
|
664
672
|
if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
665
673
|
hasPartialText = true;
|
|
666
674
|
appendBounded(partial, assistantEvent.delta);
|
|
667
|
-
|
|
675
|
+
updateOutput(boundedText(partial));
|
|
668
676
|
invokeCallback("onUpdate", input.onUpdate, output);
|
|
669
677
|
}
|
|
670
678
|
}
|
|
@@ -697,7 +705,10 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
697
705
|
return;
|
|
698
706
|
const text = assistantText(record.message);
|
|
699
707
|
if (text !== undefined) {
|
|
700
|
-
|
|
708
|
+
partial.prefix = "";
|
|
709
|
+
partial.totalBytes = 0;
|
|
710
|
+
appendBounded(partial, text);
|
|
711
|
+
updateOutput(capEphemeralSubagentOutput(text));
|
|
701
712
|
invokeCallback("onUpdate", input.onUpdate, output);
|
|
702
713
|
}
|
|
703
714
|
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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 { 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_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
8
10
|
export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
9
11
|
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "orchestrate_execute", "orchestrate_status", "orchestrate_resume", "orchestrate_abort"];
|
|
10
12
|
export declare const CHILD_EXCLUDED_TOOLS: string;
|
|
@@ -14,14 +16,16 @@ export declare const DELEGATE_TASK: {
|
|
|
14
16
|
readonly purpose: "Launch an isolated Pi subagent.";
|
|
15
17
|
readonly defaultProfile: "fast";
|
|
16
18
|
};
|
|
19
|
+
export type RoleName = string;
|
|
17
20
|
export interface Role {
|
|
18
|
-
name:
|
|
21
|
+
name: RoleName;
|
|
19
22
|
description: string;
|
|
20
23
|
modelClass?: ProfileName;
|
|
21
24
|
tools: string[];
|
|
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 {
|
|
@@ -33,6 +37,13 @@ export interface ResolvedRoleLaunch extends PiLaunch {
|
|
|
33
37
|
thinkingLevel: ThinkingLevel;
|
|
34
38
|
missingSkills: string[];
|
|
35
39
|
}
|
|
40
|
+
export interface PreparedRoleLaunch extends ResolvedRoleLaunch {
|
|
41
|
+
role: RoleName;
|
|
42
|
+
isolation?: "worktree";
|
|
43
|
+
tools: readonly string[];
|
|
44
|
+
systemPrompt: string;
|
|
45
|
+
promptArgIndex: number;
|
|
46
|
+
}
|
|
36
47
|
export interface CreateRoleLaunchInput {
|
|
37
48
|
role: Role;
|
|
38
49
|
route: ResolvedTaskRoute;
|
|
@@ -45,10 +56,16 @@ export interface ResolveRoleLaunchInput extends Omit<CreateRoleLaunchInput, "rou
|
|
|
45
56
|
modelClass?: ProfileName;
|
|
46
57
|
agentDir?: string;
|
|
47
58
|
}
|
|
59
|
+
export interface ResolveConfiguredRoleLaunchInput {
|
|
60
|
+
role: string;
|
|
61
|
+
modelClass: ProfileName;
|
|
62
|
+
}
|
|
48
63
|
export interface ResolvedRoleSkills {
|
|
49
64
|
paths: string[];
|
|
50
65
|
missing: string[];
|
|
51
66
|
}
|
|
67
|
+
/** Normalize one arbitrary Role name using the Role configuration contract. */
|
|
68
|
+
export declare function parseRoleName(value: unknown, source?: string): RoleName;
|
|
52
69
|
declare const BUILTIN_ROLE_NAMES: readonly ["implementer", "reviewer", "scout"];
|
|
53
70
|
export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number];
|
|
54
71
|
export declare function loadBuiltinRole(name: BuiltinRoleName): Role;
|
|
@@ -60,5 +77,19 @@ export declare function loadBuiltinRole(name: BuiltinRoleName): Role;
|
|
|
60
77
|
export declare function loadRoles(agentDir?: string): Role[];
|
|
61
78
|
export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string): ResolvedTaskRoute;
|
|
62
79
|
export declare function resolveRoleSkills(pi: Pick<ExtensionAPI, "getCommands">, role: Role): ResolvedRoleSkills;
|
|
80
|
+
/** Resolve all enabled package resources selected by one Role's extension sources. */
|
|
81
|
+
export declare function resolveRolePackageResources(role: Role, ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Promise<{
|
|
82
|
+
extensions: string[];
|
|
83
|
+
skills: string[];
|
|
84
|
+
prompts: string[];
|
|
85
|
+
themes: string[];
|
|
86
|
+
}>;
|
|
63
87
|
export declare function createRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: Pick<ExtensionContext, "isProjectTrusted">, input: CreateRoleLaunchInput): ResolvedRoleLaunch;
|
|
64
88
|
export declare function resolveRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): ResolvedRoleLaunch;
|
|
89
|
+
/** Prepare a resolved or resolvable Role launch while keeping its system prompt out of argv. */
|
|
90
|
+
export declare function prepareRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): PreparedRoleLaunch;
|
|
91
|
+
export declare function prepareRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: CreateRoleLaunchInput): PreparedRoleLaunch;
|
|
92
|
+
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
93
|
+
export declare function resolveConfiguredRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveConfiguredRoleLaunchInput): Promise<PreparedRoleLaunch>;
|
|
94
|
+
/** Restore the system prompt pair after a caller has prepared its launch argv. */
|
|
95
|
+
export declare function finalizeRoleLaunch(prepared: PreparedRoleLaunch): ResolvedRoleLaunch;
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { DefaultPackageManager, getAgentDir, parseFrontmatter, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
6
6
|
import { hasDisplayControlCharacters } from "./display-text.js";
|
|
7
|
+
import { selectRoleMcpConfig } from "./mcp-role.js";
|
|
7
8
|
import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
|
|
8
9
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
|
|
10
|
+
export { parseRoleMcpAllowlist, roleMcpFlagValue, selectRoleMcpConfig } from "./mcp-role.js";
|
|
9
11
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
|
|
10
12
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, } from "./worktree.js";
|
|
11
13
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
|
|
12
14
|
const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
|
|
13
15
|
const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
|
|
16
|
+
const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", import.meta.url));
|
|
14
17
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
15
18
|
export const PI_ORCHESTRATOR_PROCESS_LEASE = "PI_ORCHESTRATOR_PROCESS_LEASE";
|
|
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",
|
|
@@ -23,6 +27,7 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
|
23
27
|
"orchestrate_abort",
|
|
24
28
|
];
|
|
25
29
|
export const CHILD_EXCLUDED_TOOLS = CHILD_EXCLUDED_TOOL_NAMES.join(",");
|
|
30
|
+
const SYSTEM_PROMPT_FLAG = "--append-system-prompt";
|
|
26
31
|
const CHILD_IDENTITY_POLICY = "You are a delegated Pi Subagent, not Main. Execute the assigned Role and task directly. Main-only delegation rules do not apply. Recursive delegation is unavailable; do not seek or invoke delegation tools.";
|
|
27
32
|
export const DELEGATE_TASK = {
|
|
28
33
|
id: "pi-subagent/delegateTask",
|
|
@@ -42,6 +47,10 @@ const cleanDisplayText = (value, field, source) => {
|
|
|
42
47
|
}
|
|
43
48
|
return cleanText(value, field, source);
|
|
44
49
|
};
|
|
50
|
+
/** Normalize one arbitrary Role name using the Role configuration contract. */
|
|
51
|
+
export function parseRoleName(value, source = "Role") {
|
|
52
|
+
return cleanDisplayText(value, "name", source);
|
|
53
|
+
}
|
|
45
54
|
const stringList = (value, field, source) => {
|
|
46
55
|
if (value === undefined)
|
|
47
56
|
throw new Error(`${source}: ${field} is required.`);
|
|
@@ -62,6 +71,25 @@ function validateExtension(extension, source) {
|
|
|
62
71
|
function extensionList(value, source) {
|
|
63
72
|
return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
|
|
64
73
|
}
|
|
74
|
+
function mcpList(value, source) {
|
|
75
|
+
const names = stringList(value ?? [], "mcps", source);
|
|
76
|
+
if (new Set(names).size !== names.length)
|
|
77
|
+
throw new Error(`${source}: mcps contains duplicate MCP server names.`);
|
|
78
|
+
return names;
|
|
79
|
+
}
|
|
80
|
+
function roleToolPolicy(role, additionalTools = []) {
|
|
81
|
+
return [...new Set([...role.tools, ...additionalTools].map((tool) => cleanText(tool, "tool", `Role ${role.name}`)))];
|
|
82
|
+
}
|
|
83
|
+
function namesMcpAdapter(extension) {
|
|
84
|
+
return extension.toLowerCase().split(/[\\/:@]+/).some((component) => component === "pi-mcp-adapter" || component.startsWith("pi-mcp-adapter."));
|
|
85
|
+
}
|
|
86
|
+
const FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES = ["pi-orchestrator", "pi-mcp-adapter"];
|
|
87
|
+
function rejectForbiddenRolePackageSource(value, role) {
|
|
88
|
+
const components = value.toLowerCase().split(/[\\/:@]+/);
|
|
89
|
+
if (FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES.some((name) => components.some((component) => component === name || component.startsWith(`${name}.`)))) {
|
|
90
|
+
throw new Error(`Role ${role.name} extension explicitly names the forbidden ${FORBIDDEN_ROLE_PACKAGE_SOURCE_NAMES.join("/")} source: ${value}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
65
93
|
function roleModelClass(value, source) {
|
|
66
94
|
if (value === undefined)
|
|
67
95
|
return;
|
|
@@ -70,6 +98,14 @@ function roleModelClass(value, source) {
|
|
|
70
98
|
}
|
|
71
99
|
return value;
|
|
72
100
|
}
|
|
101
|
+
function roleIsolation(value, source) {
|
|
102
|
+
if (value === undefined)
|
|
103
|
+
return;
|
|
104
|
+
if (cleanText(value, "isolation", source) !== "worktree") {
|
|
105
|
+
throw new Error(`${source}: isolation must be "worktree".`);
|
|
106
|
+
}
|
|
107
|
+
return "worktree";
|
|
108
|
+
}
|
|
73
109
|
// Built-in Roles resolved from the package-shipped Markdown relative to this module.
|
|
74
110
|
const BUILTIN_ROLE_NAMES = ["implementer", "reviewer", "scout"];
|
|
75
111
|
/** Single-file Role parser shared by built-in and user roles. */
|
|
@@ -82,18 +118,17 @@ function parseRoleFile(file, raw) {
|
|
|
82
118
|
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
119
|
}
|
|
84
120
|
const frontmatter = parsed.frontmatter;
|
|
85
|
-
const isolation =
|
|
86
|
-
if (isolation !== undefined && isolation !== "worktree")
|
|
87
|
-
throw new Error(`${file}: isolation must be "worktree".`);
|
|
121
|
+
const isolation = roleIsolation(frontmatter.isolation, file);
|
|
88
122
|
const modelClass = roleModelClass(frontmatter.modelClass, file);
|
|
89
123
|
return {
|
|
90
|
-
name:
|
|
124
|
+
name: parseRoleName(frontmatter.name, file),
|
|
91
125
|
description: cleanDisplayText(frontmatter.description, "description", file),
|
|
92
126
|
...(modelClass === undefined ? {} : { modelClass }),
|
|
93
127
|
tools: stringList(frontmatter.tools, "tools", file),
|
|
94
128
|
isolation,
|
|
95
129
|
extensions: extensionList(frontmatter.extensions, file),
|
|
96
130
|
skills: stringList(frontmatter.skills, "skills", file),
|
|
131
|
+
mcps: mcpList(frontmatter.mcps, file),
|
|
97
132
|
systemPrompt: cleanText(parsed.body, "system prompt", file),
|
|
98
133
|
};
|
|
99
134
|
}
|
|
@@ -178,16 +213,50 @@ export function resolveRoleSkills(pi, role) {
|
|
|
178
213
|
}
|
|
179
214
|
return { paths, missing };
|
|
180
215
|
}
|
|
216
|
+
function packageManager(ctx) {
|
|
217
|
+
const agentDir = getAgentDir();
|
|
218
|
+
const settingsManager = SettingsManager.create(ctx.cwd, agentDir, { projectTrusted: ctx.isProjectTrusted() });
|
|
219
|
+
return new DefaultPackageManager({ cwd: ctx.cwd, agentDir, settingsManager });
|
|
220
|
+
}
|
|
221
|
+
/** Resolve all enabled package resources selected by one Role's extension sources. */
|
|
222
|
+
export async function resolveRolePackageResources(role, ctx) {
|
|
223
|
+
const sources = role.extensions;
|
|
224
|
+
for (const source of sources)
|
|
225
|
+
rejectForbiddenRolePackageSource(source, role);
|
|
226
|
+
if (!sources.length)
|
|
227
|
+
return { extensions: [], skills: [], prompts: [], themes: [] };
|
|
228
|
+
const resolved = await packageManager(ctx).resolveExtensionSources([...sources]);
|
|
229
|
+
const resourceGroups = [resolved.extensions, resolved.skills, resolved.prompts, resolved.themes]
|
|
230
|
+
.map((resources) => resources.filter((resource) => resource.enabled));
|
|
231
|
+
const resolvedSources = new Set(resourceGroups.flat().map((resource) => resource.metadata.source));
|
|
232
|
+
const missing = sources.filter((source) => !resolvedSources.has(source));
|
|
233
|
+
if (missing.length)
|
|
234
|
+
throw new Error(`Role extension sources resolved no resources: ${missing.join(", ")}.`);
|
|
235
|
+
return {
|
|
236
|
+
extensions: resourceGroups[0].map((resource) => resource.path),
|
|
237
|
+
skills: resourceGroups[1].map((resource) => resource.path),
|
|
238
|
+
prompts: resourceGroups[2].map((resource) => resource.path),
|
|
239
|
+
themes: resourceGroups[3].map((resource) => resource.path),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
181
242
|
export function createRoleLaunch(pi, ctx, input) {
|
|
243
|
+
return createRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role));
|
|
244
|
+
}
|
|
245
|
+
function createRoleLaunchFromSkills(ctx, input, skills) {
|
|
182
246
|
const role = input.role;
|
|
183
|
-
const
|
|
184
|
-
const tools =
|
|
247
|
+
const mcps = mcpList(role.mcps, `Role ${role.name}`);
|
|
248
|
+
const tools = roleToolPolicy(role, input.tools);
|
|
249
|
+
const selectedExtensions = [...role.extensions, ...(input.extensions ?? [])]
|
|
250
|
+
.map((extension) => validateExtension(extension, `Role ${role.name}`));
|
|
251
|
+
if (selectedExtensions.some(namesMcpAdapter)) {
|
|
252
|
+
throw new Error(`Role ${role.name} must select MCP servers with mcps instead of loading pi-mcp-adapter directly.`);
|
|
253
|
+
}
|
|
185
254
|
const extensions = [
|
|
186
|
-
...
|
|
187
|
-
...(input.extensions ?? []),
|
|
255
|
+
...selectedExtensions,
|
|
188
256
|
...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
|
|
257
|
+
...(mcps.length ? [ROLE_MCP_EXTENSION] : []),
|
|
189
258
|
ROLE_TOOLS_EXTENSION,
|
|
190
|
-
]
|
|
259
|
+
];
|
|
191
260
|
const env = Object.fromEntries(Object.entries(input.env ?? {}).map(([key, value]) => {
|
|
192
261
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
193
262
|
throw new Error(`Invalid launch environment name: ${key}`);
|
|
@@ -196,6 +265,8 @@ export function createRoleLaunch(pi, ctx, input) {
|
|
|
196
265
|
return [key, value];
|
|
197
266
|
}));
|
|
198
267
|
const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools", CHILD_EXCLUDED_TOOLS];
|
|
268
|
+
if (mcps.length)
|
|
269
|
+
args.push(`--${ROLE_MCP_POLICY_FLAG}`, JSON.stringify(mcps));
|
|
199
270
|
for (const extension of new Set(extensions))
|
|
200
271
|
args.push("--extension", extension);
|
|
201
272
|
for (const skill of skills.paths)
|
|
@@ -205,7 +276,7 @@ export function createRoleLaunch(pi, ctx, input) {
|
|
|
205
276
|
if (input.route.thinkingLevel)
|
|
206
277
|
args.push("--thinking", input.route.thinkingLevel);
|
|
207
278
|
args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
|
|
208
|
-
args.push(
|
|
279
|
+
args.push(SYSTEM_PROMPT_FLAG, `${CHILD_IDENTITY_POLICY}\n\n${cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`)}`);
|
|
209
280
|
return {
|
|
210
281
|
env,
|
|
211
282
|
args,
|
|
@@ -224,3 +295,97 @@ export function resolveRoleLaunch(pi, ctx, input) {
|
|
|
224
295
|
: resolveTaskRoute(ctx, selectedClass, agentDir),
|
|
225
296
|
});
|
|
226
297
|
}
|
|
298
|
+
function stripRoleSystemPrompt(rawArgs) {
|
|
299
|
+
const indexes = rawArgs.flatMap((arg, index) => arg === SYSTEM_PROMPT_FLAG ? [index] : []);
|
|
300
|
+
if (indexes.length !== 1)
|
|
301
|
+
throw new Error(`Role launch must contain exactly one ${SYSTEM_PROMPT_FLAG} pair.`);
|
|
302
|
+
const promptArgIndex = indexes[0];
|
|
303
|
+
const systemPrompt = rawArgs[promptArgIndex + 1];
|
|
304
|
+
if (typeof systemPrompt !== "string" || !systemPrompt.includes("\n") || !systemPrompt.trim() || systemPrompt.includes("\0")) {
|
|
305
|
+
throw new Error(`Role ${SYSTEM_PROMPT_FLAG} value must be the exact multiline Role prompt.`);
|
|
306
|
+
}
|
|
307
|
+
const args = [...rawArgs.slice(0, promptArgIndex), ...rawArgs.slice(promptArgIndex + 2)];
|
|
308
|
+
if (args.includes(SYSTEM_PROMPT_FLAG) || args.includes(systemPrompt)) {
|
|
309
|
+
throw new Error("Sanitized Role argv must contain no prompt flag or raw Role prompt.");
|
|
310
|
+
}
|
|
311
|
+
return { args, systemPrompt, promptArgIndex };
|
|
312
|
+
}
|
|
313
|
+
function prepareResolvedRoleLaunch(roleDefinition, launch, additionalTools = []) {
|
|
314
|
+
const role = parseRoleName(roleDefinition.name);
|
|
315
|
+
const isolation = roleIsolation(roleDefinition.isolation, `Role ${role}`);
|
|
316
|
+
const tools = Object.freeze(roleToolPolicy(roleDefinition, additionalTools));
|
|
317
|
+
const { args, systemPrompt, promptArgIndex } = stripRoleSystemPrompt(launch.args);
|
|
318
|
+
return { ...launch, args, role, isolation, tools, systemPrompt, promptArgIndex };
|
|
319
|
+
}
|
|
320
|
+
function assertNoMissingRoleSkills(role, launch) {
|
|
321
|
+
if (!launch.missingSkills.length)
|
|
322
|
+
return;
|
|
323
|
+
throw new Error(`Role ${parseRoleName(role.name)} requires missing Skills: ${launch.missingSkills.join(", ")}.`);
|
|
324
|
+
}
|
|
325
|
+
export function prepareRoleLaunch(pi, ctx, input) {
|
|
326
|
+
const launch = "route" in input
|
|
327
|
+
? createRoleLaunch(pi, ctx, input)
|
|
328
|
+
: resolveRoleLaunch(pi, ctx, input);
|
|
329
|
+
assertNoMissingRoleSkills(input.role, launch);
|
|
330
|
+
return prepareResolvedRoleLaunch(input.role, launch, input.tools);
|
|
331
|
+
}
|
|
332
|
+
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
333
|
+
export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
334
|
+
const roleName = parseRoleName(input.role);
|
|
335
|
+
if (input.modelClass === undefined)
|
|
336
|
+
throw new Error("Configured Role launch requires an explicit modelClass.");
|
|
337
|
+
const matches = loadRoles().filter((role) => role.name === roleName);
|
|
338
|
+
if (matches.length !== 1)
|
|
339
|
+
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
340
|
+
const role = matches[0];
|
|
341
|
+
if (role.mcps?.length) {
|
|
342
|
+
const { loadMcpConfig } = await import("pi-mcp-adapter/config");
|
|
343
|
+
selectRoleMcpConfig(loadMcpConfig(join(getAgentDir(), "mcp.json"), ctx.cwd), role.mcps);
|
|
344
|
+
}
|
|
345
|
+
const resources = await resolveRolePackageResources(role, ctx);
|
|
346
|
+
const effectiveRole = { ...role, extensions: resources.extensions };
|
|
347
|
+
const namedSkills = resolveRoleSkills(pi, effectiveRole);
|
|
348
|
+
const skills = {
|
|
349
|
+
...namedSkills,
|
|
350
|
+
paths: [...new Set([...namedSkills.paths, ...resources.skills])],
|
|
351
|
+
};
|
|
352
|
+
const launch = createRoleLaunchFromSkills(ctx, {
|
|
353
|
+
role: effectiveRole,
|
|
354
|
+
route: resolveTaskRoute(ctx, input.modelClass),
|
|
355
|
+
}, skills);
|
|
356
|
+
assertNoMissingRoleSkills(effectiveRole, launch);
|
|
357
|
+
const additions = [
|
|
358
|
+
"--no-prompt-templates",
|
|
359
|
+
"--no-themes",
|
|
360
|
+
...resources.prompts.flatMap((path) => ["--prompt-template", path]),
|
|
361
|
+
...resources.themes.flatMap((path) => ["--theme", path]),
|
|
362
|
+
];
|
|
363
|
+
const promptArgIndex = launch.args.indexOf(SYSTEM_PROMPT_FLAG);
|
|
364
|
+
if (promptArgIndex < 0)
|
|
365
|
+
throw new Error(`Resolved Role launch has no ${SYSTEM_PROMPT_FLAG}.`);
|
|
366
|
+
const args = [...launch.args];
|
|
367
|
+
args.splice(promptArgIndex, 0, ...additions);
|
|
368
|
+
return prepareResolvedRoleLaunch(effectiveRole, { ...launch, args });
|
|
369
|
+
}
|
|
370
|
+
/** Restore the system prompt pair after a caller has prepared its launch argv. */
|
|
371
|
+
export function finalizeRoleLaunch(prepared) {
|
|
372
|
+
if (prepared.args.includes(SYSTEM_PROMPT_FLAG)) {
|
|
373
|
+
throw new Error(`Prepared Role launch already contains ${SYSTEM_PROMPT_FLAG}.`);
|
|
374
|
+
}
|
|
375
|
+
if (!Number.isSafeInteger(prepared.promptArgIndex)
|
|
376
|
+
|| prepared.promptArgIndex < 0 || prepared.promptArgIndex > prepared.args.length) {
|
|
377
|
+
throw new Error("Prepared Role launch has an invalid system prompt insertion index.");
|
|
378
|
+
}
|
|
379
|
+
if (typeof prepared.systemPrompt !== "string" || !prepared.systemPrompt.trim() || prepared.systemPrompt.includes("\0")) {
|
|
380
|
+
throw new Error("Prepared Role launch has an invalid system prompt.");
|
|
381
|
+
}
|
|
382
|
+
const args = [...prepared.args];
|
|
383
|
+
args.splice(prepared.promptArgIndex, 0, SYSTEM_PROMPT_FLAG, prepared.systemPrompt);
|
|
384
|
+
return {
|
|
385
|
+
env: prepared.env,
|
|
386
|
+
args,
|
|
387
|
+
model: prepared.model,
|
|
388
|
+
thinkingLevel: prepared.thinkingLevel,
|
|
389
|
+
missingSkills: prepared.missingSkills,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
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 selectRoleMcpConfig(config: RoleMcpConfig, allowlist: readonly string[]): RoleMcpConfig;
|
package/dist/mcp-role.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function parseRoleMcpAllowlist(value) {
|
|
2
|
+
if (typeof value !== "string")
|
|
3
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
4
|
+
let parsed;
|
|
5
|
+
try {
|
|
6
|
+
parsed = JSON.parse(value);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
10
|
+
}
|
|
11
|
+
if (!Array.isArray(parsed)
|
|
12
|
+
|| parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
13
|
+
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
14
|
+
}
|
|
15
|
+
const names = parsed.map((name) => name.trim());
|
|
16
|
+
if (new Set(names).size !== names.length)
|
|
17
|
+
throw new Error("The Role MCP policy flag contains duplicate MCP server names.");
|
|
18
|
+
return names;
|
|
19
|
+
}
|
|
20
|
+
export function roleMcpFlagValue(args, flag) {
|
|
21
|
+
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
22
|
+
if (indexes.length > 1)
|
|
23
|
+
throw new Error(`${flag} must appear at most once.`);
|
|
24
|
+
if (!indexes.length)
|
|
25
|
+
return;
|
|
26
|
+
const value = args[indexes[0] + 1];
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
throw new Error(`${flag} requires a value.`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function selectRoleMcpConfig(config, allowlist) {
|
|
32
|
+
const missing = allowlist.filter((name) => !Object.hasOwn(config.mcpServers, name));
|
|
33
|
+
if (missing.length)
|
|
34
|
+
throw new Error(`Role MCP servers are not configured: ${missing.join(", ")}.`);
|
|
35
|
+
const mcpServers = Object.fromEntries(allowlist.map((name) => [name, config.mcpServers[name]]));
|
|
36
|
+
const { agentPluginPaths: _agentPluginPaths, hostConfigDiscovery: _hostConfigDiscovery, ...settings } = config.settings ?? {};
|
|
37
|
+
return {
|
|
38
|
+
mcpServers,
|
|
39
|
+
...(Object.keys(settings).length ? { settings } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -8,8 +8,11 @@ Generic callers compose workflows with JavaScript. Fan-out and fan-in use promis
|
|
|
8
8
|
|
|
9
9
|
Resource Policy is split at launch preparation:
|
|
10
10
|
|
|
11
|
-
-
|
|
11
|
+
- Pi Subagent alone parses and resolves effective Roles.
|
|
12
|
+
- Role owns base tools, extension capability bundles, Skill names, and an exact MCP server allowlist.
|
|
12
13
|
- Caller may add explicit tools, extensions, and environment through `createRoleLaunch`.
|
|
14
|
+
- `resolveConfiguredRoleLaunch` reloads the current Role, package resources, Skill registry, model route, and MCP policy for each launch.
|
|
15
|
+
- The prepared launch keeps the Role system prompt separate from prompt-free argv until the caller's final launch boundary.
|
|
13
16
|
- The executor receives the resulting Pi Launch and does not discover resources.
|
|
14
17
|
|
|
15
18
|
Built-in `implementer`, `reviewer`, and `scout` Roles ship as Markdown in `examples/roles/` and use the same parser as user Roles. For generic delegation, a same-named user Role explicitly overrides a built-in. The package does not install, copy, or write user configuration.
|
package/docs/orchestration.md
CHANGED
|
@@ -103,15 +103,16 @@ 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
|
-
Role Skill names resolve through Main's effective Pi Skill registry at launch. Missing names are returned in `ResolvedRoleLaunch.missingSkills
|
|
115
|
+
Role Skill names resolve through Main's effective Pi Skill registry at launch. Missing names are returned in `ResolvedRoleLaunch.missingSkills`. `prepareRoleLaunch` and `resolveConfiguredRoleLaunch` reject a nonempty list with `Role <name> requires missing Skills: ...` before returning a finalizable prepared launch. `delegate_task` reports that failure as a workflow error and does not start a child. Raw launch callers must reject `missingSkills` before launching.
|
|
115
116
|
|
|
116
117
|
### Role final-turn handoff
|
|
117
118
|
|
|
@@ -132,9 +133,12 @@ The package root exports the following mechanism-level APIs:
|
|
|
132
133
|
| API | Responsibility |
|
|
133
134
|
| --- | --- |
|
|
134
135
|
| `loadRoles(agentDir?)` | Validate and load package-shipped built-in and user Role Markdown. |
|
|
136
|
+
| `parseRoleName(value, source?)` / `RoleName` | Normalize one arbitrary Role name. Empty names and C0/C1 controls fail. |
|
|
135
137
|
| `resolveRoleSkills(pi, role)` | Resolve Role Skill names from Pi's effective registry. |
|
|
136
138
|
| `resolveRoleLaunch(pi, ctx, input)` | Resolve a caller-owned Model Task route, applying call-level then Role `modelClass` precedence, and produce `ResolvedRoleLaunch`. |
|
|
139
|
+
| `resolveConfiguredRoleLaunch(pi, ctx, { role, modelClass })` | Reload one configured Role and package resources with a required explicit Model Class; reject missing Role Skills. |
|
|
137
140
|
| `createRoleLaunch(pi, ctx, input)` | Produce the same launch from a caller-supplied resolved route. |
|
|
141
|
+
| `prepareRoleLaunch` / `finalizeRoleLaunch` | Separate the stable Role prompt from argv, expose its immutable tool policy, and reject unavailable Role Skills. |
|
|
138
142
|
| `createEphemeralSubagentExecutor(options)` | Queue and run one prepared no-session child per `run`. |
|
|
139
143
|
| `createChildWorktree` / `finalizeChildWorktree` | Optional caller-managed worktree lifecycle; `createChildWorktree` can prepare exact metadata before allocation. |
|
|
140
144
|
| `prepareExactReviewEvidence` | Validate Git identity and create a bounded private base-to-tip patch with exact `{base, tip, patchPath}` evidence. |
|
|
@@ -147,7 +151,7 @@ The package root exports the following mechanism-level APIs:
|
|
|
147
151
|
| `retained` | `path`, `branch`, `commits`, `dirty` | Work was preserved. Both measurements are known. |
|
|
148
152
|
| `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
153
|
|
|
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`.
|
|
154
|
+
`parseRoleName` returns a trimmed `RoleName`. It accepts arbitrary names, not only the built-in Role catalog. 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`. `resolveConfiguredRoleLaunch` accepts a Role name and required `modelClass`. It does not use Role or task defaults. Its named and package Skill paths load once in that order. 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`. A prepared launch exposes an immutable, ordered, de-duplicated `tools` policy. It includes explicit caller tool additions. `prepareRoleLaunch` and `resolveConfiguredRoleLaunch` reject missing Skills before returning a prepared launch; `finalizeRoleLaunch` restores that prompt after callers add variable state.
|
|
151
155
|
|
|
152
156
|
`createEphemeralSubagentExecutor` requires:
|
|
153
157
|
|
|
@@ -173,7 +177,8 @@ This JavaScript runs inside a Pi extension. `pi` is that extension's `ExtensionA
|
|
|
173
177
|
```js
|
|
174
178
|
import {
|
|
175
179
|
createEphemeralSubagentExecutor,
|
|
176
|
-
|
|
180
|
+
finalizeRoleLaunch,
|
|
181
|
+
prepareRoleLaunch,
|
|
177
182
|
} from "@henryqw/pi-subagent";
|
|
178
183
|
import { registerModelTask } from "@henryqw/pi-task-models";
|
|
179
184
|
|
|
@@ -222,7 +227,7 @@ export function createRunRole(pi) {
|
|
|
222
227
|
prepare: async () => {
|
|
223
228
|
// prepare runs only after this delegation owns a FIFO permit.
|
|
224
229
|
const ctx = latestContext();
|
|
225
|
-
const
|
|
230
|
+
const prepared = prepareRoleLaunch(pi, ctx, {
|
|
226
231
|
role,
|
|
227
232
|
task: MODEL_TASK,
|
|
228
233
|
modelClass,
|
|
@@ -230,13 +235,7 @@ export function createRunRole(pi) {
|
|
|
230
235
|
tools,
|
|
231
236
|
env,
|
|
232
237
|
});
|
|
233
|
-
|
|
234
|
-
ctx.ui.notify(
|
|
235
|
-
`Skipped unavailable Skills: ${launch.missingSkills.join(", ")}`,
|
|
236
|
-
"warning",
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
return { launch, task, cwd: cwd ?? ctx.cwd };
|
|
238
|
+
return { launch: finalizeRoleLaunch(prepared), task, cwd: cwd ?? ctx.cwd };
|
|
240
239
|
},
|
|
241
240
|
});
|
|
242
241
|
}
|
package/examples/roles/scout.md
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
parseRoleMcpAllowlist,
|
|
7
|
+
ROLE_MCP_POLICY_FLAG,
|
|
8
|
+
roleMcpFlagValue,
|
|
9
|
+
selectRoleMcpConfig,
|
|
10
|
+
} from "@henryqw/pi-subagent";
|
|
11
|
+
|
|
12
|
+
export default function roleMcp(pi: ExtensionAPI): void {
|
|
13
|
+
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
14
|
+
description: "Internal Pi Subagent Role MCP policy",
|
|
15
|
+
type: "string",
|
|
16
|
+
});
|
|
17
|
+
const allowlist = parseRoleMcpAllowlist(roleMcpFlagValue(process.argv, `--${ROLE_MCP_POLICY_FLAG}`));
|
|
18
|
+
const config = selectRoleMcpConfig(loadMcpConfig(join(getAgentDir(), "mcp.json"), process.cwd()), allowlist);
|
|
19
|
+
createMcpAdapter({ config })(pi);
|
|
20
|
+
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -16,13 +16,13 @@ import {
|
|
|
16
16
|
createChildWorktree,
|
|
17
17
|
createEphemeralSubagentExecutor,
|
|
18
18
|
DELEGATE_TASK,
|
|
19
|
-
createRoleLaunch,
|
|
20
19
|
DEFAULT_MAX_TURNS,
|
|
21
20
|
EphemeralSubagentError,
|
|
22
21
|
finalizeChildWorktree,
|
|
22
|
+
finalizeRoleLaunch,
|
|
23
23
|
formatDuration,
|
|
24
24
|
loadRoles,
|
|
25
|
-
|
|
25
|
+
prepareRoleLaunch,
|
|
26
26
|
WorktreeSetupError,
|
|
27
27
|
worktreeContextNote,
|
|
28
28
|
type EphemeralSubagentActivityEvent,
|
|
@@ -620,28 +620,19 @@ export default function subagentExtension(
|
|
|
620
620
|
|
|
621
621
|
// Resolve against the latest known session context after each FIFO permit.
|
|
622
622
|
const launchCtx = () => latestCtx ?? ctx;
|
|
623
|
-
const
|
|
623
|
+
const prepareLaunch = (role: Role, delegation: Delegation) => {
|
|
624
624
|
const context = launchCtx();
|
|
625
|
-
const launch =
|
|
625
|
+
const launch = prepareRoleLaunch(pi, context, {
|
|
626
626
|
role,
|
|
627
627
|
task: DELEGATE_TASK,
|
|
628
628
|
...(delegation.modelClass === undefined ? {} : { modelClass: delegation.modelClass }),
|
|
629
629
|
});
|
|
630
630
|
if (delegation.model === undefined) return launch;
|
|
631
|
-
return
|
|
631
|
+
return prepareRoleLaunch(pi, context, {
|
|
632
632
|
role,
|
|
633
633
|
route: replaceRouteModel(context, delegation.model, launch),
|
|
634
634
|
});
|
|
635
635
|
};
|
|
636
|
-
const notifyMissingSkills = (role: Role, launch: ReturnType<typeof resolveLaunch>) => {
|
|
637
|
-
if (launch.missingSkills.length) {
|
|
638
|
-
ctx.ui.notify(
|
|
639
|
-
`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
|
|
640
|
-
"warning",
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
};
|
|
644
|
-
|
|
645
636
|
const foregroundWorkflow: ParsedWorkflow = { ...workflow, background: false };
|
|
646
637
|
const entries = identifyWorkflowEntries(toolCallId, foregroundWorkflow);
|
|
647
638
|
const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
|
|
@@ -706,21 +697,18 @@ export default function subagentExtension(
|
|
|
706
697
|
// Route and effective Role resources resolve only after this entry's
|
|
707
698
|
// shared executor permit, before isolated state is created.
|
|
708
699
|
const role = reloadRole(entry.delegation.role);
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (role.isolation === "worktree") {
|
|
700
|
+
const preparedLaunch = prepareLaunch(role, entry.delegation);
|
|
701
|
+
model = modelReference(preparedLaunch.model);
|
|
702
|
+
thinkingLevel = preparedLaunch.thinkingLevel;
|
|
703
|
+
if (preparedLaunch.isolation === "worktree") {
|
|
714
704
|
worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
|
|
715
705
|
}
|
|
716
|
-
startWidgetItem(entry.id, entry.id, role
|
|
706
|
+
startWidgetItem(entry.id, entry.id, preparedLaunch.role, preparedLaunch.model.id, preparedLaunch.thinkingLevel, entry.delegation.name, ctx);
|
|
717
707
|
setState("running", "");
|
|
718
708
|
emitUpdate(emitToolUpdates);
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
cwd: worktree?.cwd ?? ctx.cwd,
|
|
723
|
-
};
|
|
709
|
+
const task = worktree ? `${entry.delegation.task}${worktreeContextNote(worktree)}` : entry.delegation.task;
|
|
710
|
+
const cwd = worktree?.cwd ?? ctx.cwd;
|
|
711
|
+
return { launch: finalizeRoleLaunch(preparedLaunch), task, cwd };
|
|
724
712
|
},
|
|
725
713
|
});
|
|
726
714
|
if (child.outcome === "failure") {
|