@henryqw/pi-subagent 25.0.2 → 25.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 +5 -3
- package/README.md +12 -4
- package/dist/index.d.ts +10 -1
- package/dist/index.js +40 -8
- package/dist/mcp-role.d.ts +7 -3
- package/dist/mcp-role.js +25 -6
- package/dist/schema.d.ts +110 -0
- package/dist/schema.js +49 -0
- package/docs/orchestration.md +1 -1
- package/extensions/admission.ts +37 -10
- package/extensions/isolated.ts +13 -1
- package/extensions/role-mcp.ts +4 -2
- package/extensions/role-tools.ts +6 -16
- package/extensions/subagent.ts +2 -0
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -41,7 +41,8 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
41
41
|
|
|
42
42
|
### Direct evidence
|
|
43
43
|
|
|
44
|
-
- Direct mode admits Roles with known read-only base tools. Extensions and MCP servers are trusted without a read-only guarantee; direct changesets and Roles with write-capable base tools require isolated mode.
|
|
44
|
+
- Direct mode admits Roles with known read-only base tools. `codemode` is read-only: its scripts call only the Role's active tools plus trusted extension and MCP tools. Extensions and MCP servers are trusted without a read-only guarantee; direct changesets and Roles with write-capable base tools require isolated mode.
|
|
45
|
+
- Checkout admission in Main serializes write-capable Pi tool calls per checkout by their model-issued root call: a `codemode` script's nested writes share the script's ownership, independent writers wait, and reads never wait.
|
|
45
46
|
- Each direct worker has a recorded Herdr tab and Pi session identity. The first handle returns before completion; subsequent tab identities remain recoverable on the session branch.
|
|
46
47
|
- An exact settled worker with a bounded final Pi answer produces a follow-up to Main. Blocked, unknown, idle-stalled, truncated, or ambiguous outcomes retain actionable recovery identity. Session replacement never delivers to the wrong Main session.
|
|
47
48
|
|
|
@@ -56,13 +57,14 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
56
57
|
- Promotion requires exact unchanged clean Main and a passing combined generation. Durable promotion evidence precedes selected-worker termination and cleanup; recovery never rolls back a proven promotion.
|
|
57
58
|
- Rejected workers and superseded generations stay retained until Main explicitly releases their exact clean, proved-owned resources. At most two unreleased integration checkouts coexist.
|
|
58
59
|
- Failures, ambiguity, interruption, review findings, conflicts, drift, unproved termination, or cleanup failure enter `needs_attention` and preserve evidence.
|
|
59
|
-
- `subagent_status` is read-only. `subagent_resume` accepts only strict `retry`, `verify`, or `finalize` continuations. `subagent_abort` terminates only exact owned workers.
|
|
60
|
+
- `subagent_status` is read-only, callable from `codemode` scripts, and returns the bounded public projection as structured content, never raw run state. `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. `subagent_resume` accepts only strict `retry`, `verify`, or `finalize` continuations. `subagent_abort` terminates only exact owned workers.
|
|
60
61
|
- The extension never pushes, opens a pull request, publishes, deploys, stashes, resets, force-cleans, or deletes unproved recoverable work.
|
|
61
62
|
|
|
62
63
|
### Resource and launch policy
|
|
63
64
|
|
|
64
65
|
- Ambient child extensions and Skills stay disabled. Only Role/caller resources plus required internal adapters load.
|
|
65
|
-
- Every Role requires `tools`, `extensions`, and `skills` arrays. `extensions` may name Pi built-in extensions as `builtin:<name>`. `mcps` names servers from the global `~/.pi/agent/mcp.json`; their tools
|
|
66
|
+
- Every Role requires `tools`, `extensions`, and `skills` arrays. `extensions` may name Pi built-in extensions as `builtin:<name>`. A Role activates `codemode` only by naming it in `tools` with `builtin:codemode` loaded; MCP configuration never activates it, and Pi's codemode settings apply unchanged. `mcps` names servers from the global `~/.pi/agent/mcp.json`; their tools keep their configured exposure in a codemode Role and are forced to direct exposure otherwise. Omitted or empty `mcps` denies MCP access. Direct `pi-mcp-adapter` loading is rejected because it bypasses the allowlist.
|
|
67
|
+
- The Role tool policy declares the Role's `tools` and the extension tools Pi would declare on registration; extension tools with `codemode` or `deferred` exposure stay callable from scripts without being declared.
|
|
66
68
|
- Selected extensions are trusted executable bundles, not a sandbox. All tools and lifecycle behavior they register load together.
|
|
67
69
|
- Role Skill names resolve through Main's effective Pi registry. Missing Roles, Skills, tools, MCP servers, routes, models, or thinking levels fail before the first productive turn.
|
|
68
70
|
- Route precedence is call model class, then Role default, then registered Model Task assignment/default. A direct model replaces only the route model and must support its thinking level.
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Commands are for you; tools and the packaged skill are for Main. You do not need
|
|
|
30
30
|
| `/subagent` | command | Browse work, launch isolated status inspection in a separate Herdr tab with the `fast` model, or send/edit pending instructions. Requires interactive TUI or RPC; inspection also requires a Herdr-managed pane and a read-only `scout` Role. |
|
|
31
31
|
| `/subagent recover` | command | Classify orphaned isolated work in the current canonical repository and send Main one follow-up with request IDs, blockers, and reported next actions. No UI or Herdr pane required. |
|
|
32
32
|
| `delegate_task` | tool | Start read-only direct work or a durable isolated checked graph. |
|
|
33
|
-
| `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. |
|
|
33
|
+
| `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. Declares an output schema, so `codemode` scripts receive the same bounded public projection as structured content. |
|
|
34
34
|
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. |
|
|
35
35
|
| `subagent_stage` | tool | Stage or resolve an exact candidate, or reject/revise it; rejection of a staged candidate freezes that generation. |
|
|
36
36
|
| `subagent_integrate` | tool | Advance staged dependents, refresh after clean Main drift, validate, record one correction, promote, reconcile interrupted promotion, clean up proven promotion, or explicitly release rejected/superseded resources. |
|
|
@@ -39,6 +39,8 @@ Commands are for you; tools and the packaged skill are for Main. You do not need
|
|
|
39
39
|
|
|
40
40
|

|
|
41
41
|
|
|
42
|
+
`delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only: Pi's `codemode` scripts cannot call them, because delegation and guarded state transitions need Main's visible reasoning. `subagent_status` stays callable from scripts. In Main, one model-issued call at a time may write to a checkout through Pi tools; a `codemode` script owns the checkout from its first nested write until the script ends, so its own nested calls pass while an independent writer is blocked with a reason and retries after it settles. Reads never wait.
|
|
43
|
+
|
|
42
44
|
The coordinator is extension code running in Main's Pi process, not another agent. It persists the request, launches the Herdr subagent, checks its committed work, and reports saved results; Main chooses what to stage and promote. If a worker fails, the coordinator reports attention after the wave settles, not necessarily immediately while siblings work. If Main's Pi process stops, no follow-up is guaranteed: restart and run `/subagent recover` in the canonical repository to classify orphaned interruptions and send Main one recovery report. Main then uses `subagent_status` for exact evidence and deliberate next actions. A stale `running` state does not prove a worker is still active; interrupted or ambiguous actions are never replayed automatically.
|
|
43
45
|
|
|
44
46
|
In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` marks direct or isolated work, and `[S]` or `[I3]` identifies the Role initial (plus the isolated model class: `1` fast, `2` balanced, `3` frontier, `*` fav). Direct rows show the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Isolated rows show the recorded task state and any retained workspace; an aborted request with visible tasks has its own `request aborted` row and does not change their recorded statuses. A committed worker without a checked candidate can still show attention rather than readiness. Status glyphs use the active TUI theme, while the text still names the status without color. Herdr does not currently provide reliable live model, thinking, or token readings for those rows. Attention appears before ordinary work, with a `… N more` summary when direct rows overflow. A ready isolated candidate is **not promoted** to Main. Choose **Refresh** in `/subagent` to reload both the menu and isolated widget from saved state; it does not recover or change a request. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
|
|
@@ -75,7 +77,7 @@ Parallel: { mode: "direct", tasks: [{ role, name, task, ... }] }
|
|
|
75
77
|
Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
Only Roles whose declared base tools are known read-only may run direct. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
80
|
+
Only Roles whose declared base tools are known read-only may run direct. `codemode` counts as read-only because its scripts can only call the tools the Role already activates plus trusted extension and MCP tools. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
79
81
|
|
|
80
82
|
The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. A recorded tab may no longer be running; locally observed work is labelled separately.
|
|
81
83
|
|
|
@@ -175,7 +177,7 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
|
|
|
175
177
|
| --- | --- |
|
|
176
178
|
| `name`, `description` | Required non-empty text without terminal control characters |
|
|
177
179
|
| `modelClass` | Optional `fast`, `balanced`, `frontier`, or `fav` default |
|
|
178
|
-
| `tools` | Required array of base tool names; `[]` selects none |
|
|
180
|
+
| `tools` | Required array of base tool names; `[]` selects none. Name `codemode` here to activate it (see below) |
|
|
179
181
|
| `extensions` | Required array of trusted absolute paths, supported package sources, or Pi built-in extensions such as `builtin:codemode` |
|
|
180
182
|
| `skills` | Required array of effective Pi Skill names |
|
|
181
183
|
| `mcps` | Optional exact server names from `~/.pi/agent/mcp.json` in Pi's native `mcpServers` format; omitted or `[]` denies MCP access |
|
|
@@ -183,7 +185,13 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
|
|
|
183
185
|
|
|
184
186
|
Roles describe responsibility and capabilities. They do not choose isolation; each request does. A same-named user Role overrides a built-in Role. The package ships `implementer`, `reviewer`, and `scout`.
|
|
185
187
|
|
|
186
|
-
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`.
|
|
188
|
+
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. Extension tools registered with `codemode` or `deferred` exposure stay callable from scripts but are declared to the model only when `tools` names them.
|
|
189
|
+
|
|
190
|
+
Checkout admission keeps a script's writer ownership until the script and all its admitted nested writes finish, including writes still settling after cancellation. Independent writers stay blocked during that time; nested writes that have not passed admission before the script ends are rejected.
|
|
191
|
+
|
|
192
|
+
To let a Role run `codemode` scripts, list `builtin:codemode` in `extensions` and `codemode` in `tools`; both are required, because the built-in registers the tool inactive and the Role tool policy activates it. `codemode` alone in `tools` fails the launch as an unavailable tool. Pi's `codemode.mode` and `codemode.inlineBudget` settings apply in the child unchanged, so the Role never forces codemode-only operation, and MCP configuration never activates `codemode` in a child. Scripts can call only the Role's active tools plus `codemode`/`deferred` extension and MCP tools; inactive base tools such as `bash` stay unreachable.
|
|
193
|
+
|
|
194
|
+
The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only. In a Role that activates `codemode`, each selected server keeps its configured `exposure` and `toolExposure` (Pi's default `codemode` exposure makes tools callable from scripts without declaring them, `direct` declares them, `deferred` tools stay reachable from scripts, `hidden` tools stay unreachable), and an invalid exposure value fails the launch. In every other Role, selected servers are forced to `direct` exposure and `toolExposure` is ignored, because that child cannot reach undeclared tools. Declared tools appear to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
|
|
187
195
|
|
|
188
196
|
## API
|
|
189
197
|
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,18 @@ export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectW
|
|
|
7
7
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
|
|
8
8
|
export declare const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
9
9
|
export declare const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
10
|
+
export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
10
11
|
/** Read child MCP policy before Pi binds registered extension flag values. */
|
|
11
12
|
export declare function roleMcpAllowlistFromArgv(args: readonly string[]): string[];
|
|
12
|
-
|
|
13
|
+
/** Parse the Role tool policy flag value: JSON tool names. */
|
|
14
|
+
export declare function parseRoleToolPolicy(value: unknown): string[];
|
|
15
|
+
/** Read child tool policy before Pi binds registered extension flag values. */
|
|
16
|
+
export declare function roleToolPolicyFromArgv(args: readonly string[]): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a tool policy activates `codemode` in the child, so scripts can reach tools with
|
|
19
|
+
* `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
|
|
20
|
+
*/
|
|
21
|
+
export declare function roleActivatesCodemode(tools: readonly string[]): boolean;
|
|
13
22
|
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_abort"];
|
|
14
23
|
export declare const DELEGATE_TASK: {
|
|
15
24
|
readonly id: "pi-subagent/delegateTask";
|
package/dist/index.js
CHANGED
|
@@ -17,15 +17,46 @@ const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", im
|
|
|
17
17
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
18
18
|
export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
19
19
|
export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
21
|
+
const CODEMODE_TOOL = "codemode";
|
|
22
|
+
/** The value after a flag that must appear exactly once in a child's argv. */
|
|
23
|
+
function flagValueFromArgv(args, flag) {
|
|
23
24
|
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
24
25
|
if (indexes.length !== 1)
|
|
25
26
|
throw new Error(`${flag} must appear exactly once.`);
|
|
26
|
-
return
|
|
27
|
+
return args[indexes[0] + 1];
|
|
28
|
+
}
|
|
29
|
+
/** Read child MCP policy before Pi binds registered extension flag values. */
|
|
30
|
+
export function roleMcpAllowlistFromArgv(args) {
|
|
31
|
+
return parseRoleMcpAllowlist(flagValueFromArgv(args, `--${ROLE_MCP_POLICY_FLAG}`));
|
|
32
|
+
}
|
|
33
|
+
/** Parse the Role tool policy flag value: JSON tool names. */
|
|
34
|
+
export function parseRoleToolPolicy(value) {
|
|
35
|
+
if (typeof value !== "string")
|
|
36
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
45
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(parsed.map((name) => name.trim()))];
|
|
48
|
+
}
|
|
49
|
+
/** Read child tool policy before Pi binds registered extension flag values. */
|
|
50
|
+
export function roleToolPolicyFromArgv(args) {
|
|
51
|
+
return parseRoleToolPolicy(flagValueFromArgv(args, `--${ROLE_TOOL_POLICY_FLAG}`));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a tool policy activates `codemode` in the child, so scripts can reach tools with
|
|
55
|
+
* `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
|
|
56
|
+
*/
|
|
57
|
+
export function roleActivatesCodemode(tools) {
|
|
58
|
+
return tools.includes(CODEMODE_TOOL);
|
|
27
59
|
}
|
|
28
|
-
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
29
60
|
export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
30
61
|
"delegate_task",
|
|
31
62
|
"ask_question",
|
|
@@ -333,8 +364,9 @@ export function prepareRoleLaunch(pi, ctx, input) {
|
|
|
333
364
|
: prepareResolvedRoleLaunch(pi, ctx, input);
|
|
334
365
|
assertNoMissingRoleSkills(input.role, prepared);
|
|
335
366
|
// Validate the mcp.json the child reads: its environment inherits Main's, with launch overrides on top.
|
|
336
|
-
if (input.role.mcps?.length)
|
|
337
|
-
loadRoleMcpConfig(prepared.env.PI_CODING_AGENT_DIR ?? getAgentDir(), input.role.mcps);
|
|
367
|
+
if (input.role.mcps?.length) {
|
|
368
|
+
loadRoleMcpConfig(prepared.env.PI_CODING_AGENT_DIR ?? getAgentDir(), input.role.mcps, { codemode: roleActivatesCodemode(prepared.tools) });
|
|
369
|
+
}
|
|
338
370
|
return { ...prepared, role };
|
|
339
371
|
}
|
|
340
372
|
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
@@ -347,7 +379,7 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
347
379
|
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
348
380
|
const role = matches[0];
|
|
349
381
|
if (role.mcps?.length)
|
|
350
|
-
loadRoleMcpConfig(getAgentDir(), role.mcps);
|
|
382
|
+
loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
|
|
351
383
|
const resources = await resolveRolePackageResources(role, ctx);
|
|
352
384
|
const effectiveRole = { ...role, extensions: resources.extensions };
|
|
353
385
|
const namedSkills = resolveRoleSkills(pi, effectiveRole);
|
package/dist/mcp-role.d.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { LoadedMcpConfig } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
export declare function parseRoleMcpAllowlist(value: unknown): string[];
|
|
3
3
|
/**
|
|
4
4
|
* Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
|
|
5
|
-
* shape; the project `.pi/mcp.json` is ignored. A
|
|
6
|
-
*
|
|
5
|
+
* shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
|
|
6
|
+
* server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
|
|
7
|
+
* cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
|
|
8
|
+
* never activated by MCP configuration: the Role tool policy owns the active set.
|
|
7
9
|
*/
|
|
8
|
-
export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[]
|
|
10
|
+
export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[], options?: {
|
|
11
|
+
codemode: boolean;
|
|
12
|
+
}): LoadedMcpConfig;
|
package/dist/mcp-role.js
CHANGED
|
@@ -31,6 +31,11 @@ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
|
31
31
|
const isHttpsOrLoopback = (url) => url !== undefined && (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)));
|
|
32
32
|
/** Tool namespace `mcp__<server>` replaces `-` with `_`, so names differing only there collide. */
|
|
33
33
|
const mcpNamespace = (name) => name.replace(/-/g, "_");
|
|
34
|
+
// Pi's exposure names and the alias it still accepts (core/mcp-servers.ts); the native loader is bypassed, so resolve it here.
|
|
35
|
+
const MCP_EXPOSURES = ["codemode", "deferred", "direct", "hidden"];
|
|
36
|
+
const MCP_EXPOSURE_ALIASES = { "codemode-deferred": "codemode" };
|
|
37
|
+
const resolveExposure = (value) => MCP_EXPOSURE_ALIASES[value] ?? value;
|
|
38
|
+
const isExposure = (value) => typeof value === "string" && MCP_EXPOSURES.includes(resolveExposure(value));
|
|
34
39
|
/** Documented `mcpServers` fields (Pi's docs/mcp.md) and their shapes, by transport; dotted keys are nested. */
|
|
35
40
|
const COMMON_CHECKS = {
|
|
36
41
|
enabled: [(value) => typeof value === "boolean", "a boolean"],
|
|
@@ -60,8 +65,12 @@ const HTTP_CHECKS = {
|
|
|
60
65
|
"oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
|
|
61
66
|
auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
|
|
62
67
|
};
|
|
68
|
+
const EXPOSURE_CHECKS = {
|
|
69
|
+
exposure: [isExposure, `one of ${MCP_EXPOSURES.map((exposure) => `"${exposure}"`).join(", ")}`],
|
|
70
|
+
toolExposure: [(value) => isRecord(value) && Object.values(value).every(isExposure), "an object mapping tool names to exposures"],
|
|
71
|
+
};
|
|
63
72
|
/** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
|
|
64
|
-
function validateRoleMcpServer(path, name, value) {
|
|
73
|
+
function validateRoleMcpServer(path, name, value, codemode) {
|
|
65
74
|
const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
|
|
66
75
|
if (!SERVER_NAME.test(name))
|
|
67
76
|
fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
|
|
@@ -73,7 +82,7 @@ function validateRoleMcpServer(path, name, value) {
|
|
|
73
82
|
const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
|
|
74
83
|
if (stdio === http)
|
|
75
84
|
fail("needs either a \"command\" (stdio) or a \"url\" (http or streamable-http).");
|
|
76
|
-
for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS) })) {
|
|
85
|
+
for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS), ...(codemode ? EXPOSURE_CHECKS : {}) })) {
|
|
77
86
|
const fieldValue = field.split(".").reduce((parent, key) => (isRecord(parent) ? parent[key] : undefined), value);
|
|
78
87
|
if (fieldValue !== undefined && !valid(fieldValue, value))
|
|
79
88
|
fail(`field "${field}" must be ${shape}.`);
|
|
@@ -82,15 +91,25 @@ function validateRoleMcpServer(path, name, value) {
|
|
|
82
91
|
if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
|
|
83
92
|
fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
|
|
84
93
|
}
|
|
94
|
+
if (codemode) {
|
|
95
|
+
const { exposure, toolExposure } = value;
|
|
96
|
+
return {
|
|
97
|
+
...value,
|
|
98
|
+
...(typeof exposure === "string" ? { exposure: resolveExposure(exposure) } : {}),
|
|
99
|
+
...(isRecord(toolExposure) ? { toolExposure: Object.fromEntries(Object.entries(toolExposure).map(([tool, entry]) => [tool, resolveExposure(entry)])) } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
85
102
|
const { toolExposure: _toolExposure, ...config } = value;
|
|
86
103
|
return { ...config, exposure: "direct" };
|
|
87
104
|
}
|
|
88
105
|
/**
|
|
89
106
|
* Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
|
|
90
|
-
* shape; the project `.pi/mcp.json` is ignored. A
|
|
91
|
-
*
|
|
107
|
+
* shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
|
|
108
|
+
* server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
|
|
109
|
+
* cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
|
|
110
|
+
* never activated by MCP configuration: the Role tool policy owns the active set.
|
|
92
111
|
*/
|
|
93
|
-
export function loadRoleMcpConfig(agentDir, allowlist) {
|
|
112
|
+
export function loadRoleMcpConfig(agentDir, allowlist, options = { codemode: false }) {
|
|
94
113
|
const path = join(agentDir, "mcp.json");
|
|
95
114
|
let parsed = {};
|
|
96
115
|
try {
|
|
@@ -114,7 +133,7 @@ export function loadRoleMcpConfig(agentDir, allowlist) {
|
|
|
114
133
|
if (clash)
|
|
115
134
|
throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
|
|
116
135
|
namespaces.set(mcpNamespace(name), name);
|
|
117
|
-
return { name, config: validateRoleMcpServer(path, name, configured[name]), source: path, scope: "global" };
|
|
136
|
+
return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode), source: path, scope: "global" };
|
|
118
137
|
});
|
|
119
138
|
return { servers, autoEnableCodemode: false, errors: [] };
|
|
120
139
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -2320,6 +2320,116 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
2320
2320
|
createdAt: Type.TInteger;
|
|
2321
2321
|
updatedAt: Type.TInteger;
|
|
2322
2322
|
}>;
|
|
2323
|
+
/**
|
|
2324
|
+
* `subagent_status` structured output: the bounded public projection that Main reads, never raw
|
|
2325
|
+
* run state. Objects stay open so bounded evidence fields pass; the identities and statuses that
|
|
2326
|
+
* drive stage, integrate, resume, and release decisions are typed.
|
|
2327
|
+
*/
|
|
2328
|
+
export declare const StatusOutputSchema: Type.TObject<{
|
|
2329
|
+
state: Type.TObject<{
|
|
2330
|
+
version: Type.TLiteral<5>;
|
|
2331
|
+
id: Type.TString;
|
|
2332
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"needs_attention">, Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
2333
|
+
accepted: Type.TBoolean;
|
|
2334
|
+
main: Type.TObject<{
|
|
2335
|
+
branch: Type.TString;
|
|
2336
|
+
head: Type.TString;
|
|
2337
|
+
index: Type.TString;
|
|
2338
|
+
tree: Type.TString;
|
|
2339
|
+
}>;
|
|
2340
|
+
tasks: Type.TArray<Type.TObject<{
|
|
2341
|
+
taskId: Type.TString;
|
|
2342
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"allocating">, Type.TLiteral<"working">, Type.TLiteral<"ready_to_integrate">, Type.TLiteral<"integrating">, Type.TLiteral<"cleanup">, Type.TLiteral<"completed">, Type.TLiteral<"rejected">, Type.TLiteral<"needs_attention">, Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">, Type.TLiteral<"needs_attention">]>;
|
|
2343
|
+
}>>;
|
|
2344
|
+
final: Type.TObject<{
|
|
2345
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"interrupted">, Type.TLiteral<"passed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">]>;
|
|
2346
|
+
}>;
|
|
2347
|
+
integration: Type.TObject<{
|
|
2348
|
+
candidates: Type.TArray<Type.TObject<{
|
|
2349
|
+
taskId: Type.TString;
|
|
2350
|
+
attempt: Type.TInteger;
|
|
2351
|
+
base: Type.TObject<{
|
|
2352
|
+
branch: Type.TString;
|
|
2353
|
+
head: Type.TString;
|
|
2354
|
+
index: Type.TString;
|
|
2355
|
+
tree: Type.TString;
|
|
2356
|
+
}>;
|
|
2357
|
+
tip: Type.TObject<{
|
|
2358
|
+
branch: Type.TString;
|
|
2359
|
+
head: Type.TString;
|
|
2360
|
+
index: Type.TString;
|
|
2361
|
+
tree: Type.TString;
|
|
2362
|
+
}>;
|
|
2363
|
+
checked: Type.TBoolean;
|
|
2364
|
+
worker: Type.TUnion<[Type.TLiteral<"retained">, Type.TLiteral<"release_pending">, Type.TLiteral<"released">]>;
|
|
2365
|
+
decision: Type.TOptional<Type.TLiteral<"rejected">>;
|
|
2366
|
+
}>>;
|
|
2367
|
+
generations: Type.TArray<Type.TObject<{
|
|
2368
|
+
number: Type.TInteger;
|
|
2369
|
+
status: Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validating">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promoting">, Type.TLiteral<"promoted">, Type.TLiteral<"promotion_unknown">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"superseded">]>;
|
|
2370
|
+
integrationBase: Type.TObject<{
|
|
2371
|
+
branch: Type.TString;
|
|
2372
|
+
head: Type.TString;
|
|
2373
|
+
index: Type.TString;
|
|
2374
|
+
tree: Type.TString;
|
|
2375
|
+
}>;
|
|
2376
|
+
combinedTip: Type.TOptional<Type.TObject<{
|
|
2377
|
+
branch: Type.TString;
|
|
2378
|
+
head: Type.TString;
|
|
2379
|
+
index: Type.TString;
|
|
2380
|
+
tree: Type.TString;
|
|
2381
|
+
}>>;
|
|
2382
|
+
retainedWorktree: Type.TOptional<Type.TObject<{
|
|
2383
|
+
path: Type.TString;
|
|
2384
|
+
branch: Type.TString;
|
|
2385
|
+
}>>;
|
|
2386
|
+
}>>;
|
|
2387
|
+
}>;
|
|
2388
|
+
needsAttention: Type.TOptional<Type.TObject<{
|
|
2389
|
+
scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
|
|
2390
|
+
taskId: Type.TOptional<Type.TString>;
|
|
2391
|
+
failure: Type.TOptional<Type.TString>;
|
|
2392
|
+
}>>;
|
|
2393
|
+
createdAt: Type.TInteger;
|
|
2394
|
+
updatedAt: Type.TInteger;
|
|
2395
|
+
}>;
|
|
2396
|
+
main: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
2397
|
+
status: Type.TUnion<[Type.TLiteral<"current">, Type.TLiteral<"drifted">]>;
|
|
2398
|
+
expected: Type.TObject<{
|
|
2399
|
+
branch: Type.TString;
|
|
2400
|
+
head: Type.TString;
|
|
2401
|
+
index: Type.TString;
|
|
2402
|
+
tree: Type.TString;
|
|
2403
|
+
}>;
|
|
2404
|
+
actual: Type.TObject<{
|
|
2405
|
+
branch: Type.TString;
|
|
2406
|
+
head: Type.TString;
|
|
2407
|
+
index: Type.TString;
|
|
2408
|
+
tree: Type.TString;
|
|
2409
|
+
}>;
|
|
2410
|
+
}>, Type.TObject<{
|
|
2411
|
+
status: Type.TLiteral<"unavailable">;
|
|
2412
|
+
expected: Type.TObject<{
|
|
2413
|
+
branch: Type.TString;
|
|
2414
|
+
head: Type.TString;
|
|
2415
|
+
index: Type.TString;
|
|
2416
|
+
tree: Type.TString;
|
|
2417
|
+
}>;
|
|
2418
|
+
failure: Type.TString;
|
|
2419
|
+
}>]>>;
|
|
2420
|
+
continuation: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
2421
|
+
id: Type.TString;
|
|
2422
|
+
action: Type.TLiteral<"retry">;
|
|
2423
|
+
taskId: Type.TString;
|
|
2424
|
+
}>, Type.TObject<{
|
|
2425
|
+
id: Type.TString;
|
|
2426
|
+
action: Type.TLiteral<"verify">;
|
|
2427
|
+
taskId: Type.TString;
|
|
2428
|
+
}>, Type.TObject<{
|
|
2429
|
+
id: Type.TString;
|
|
2430
|
+
action: Type.TLiteral<"finalize">;
|
|
2431
|
+
}>]>>;
|
|
2432
|
+
}>;
|
|
2323
2433
|
export declare function taskDependencies(task: TaskRequest): string[];
|
|
2324
2434
|
export declare function validateGraph(tasks: readonly TaskRequest[]): void;
|
|
2325
2435
|
export declare function parseExecuteRequest(value: unknown): ExecuteRequest;
|
package/dist/schema.js
CHANGED
|
@@ -427,6 +427,55 @@ const RunStateSchema = Type.Object({
|
|
|
427
427
|
createdAt: TimestampSchema,
|
|
428
428
|
updatedAt: TimestampSchema,
|
|
429
429
|
}, { additionalProperties: false });
|
|
430
|
+
/**
|
|
431
|
+
* `subagent_status` structured output: the bounded public projection that Main reads, never raw
|
|
432
|
+
* run state. Objects stay open so bounded evidence fields pass; the identities and statuses that
|
|
433
|
+
* drive stage, integrate, resume, and release decisions are typed.
|
|
434
|
+
*/
|
|
435
|
+
export const StatusOutputSchema = Type.Object({
|
|
436
|
+
state: Type.Object({
|
|
437
|
+
version: RunStateSchema.properties.version,
|
|
438
|
+
id: IdSchema,
|
|
439
|
+
status: RunStateSchema.properties.status,
|
|
440
|
+
accepted: Type.Boolean(),
|
|
441
|
+
main: WorkspaceSchema,
|
|
442
|
+
tasks: Type.Array(Type.Object({
|
|
443
|
+
taskId: IdSchema,
|
|
444
|
+
status: Type.Union([...ChangesetTaskStateSchema.properties.status.anyOf, ...TextTaskStateSchema.properties.status.anyOf]),
|
|
445
|
+
})),
|
|
446
|
+
final: Type.Object({ status: FinalGateSchema.properties.status }),
|
|
447
|
+
integration: Type.Object({
|
|
448
|
+
candidates: Type.Array(Type.Object({
|
|
449
|
+
taskId: IdSchema,
|
|
450
|
+
attempt: IntegrationCandidateSchema.properties.attempt,
|
|
451
|
+
base: WorkspaceSchema,
|
|
452
|
+
tip: WorkspaceSchema,
|
|
453
|
+
checked: Type.Boolean(),
|
|
454
|
+
worker: IntegrationCandidateSchema.properties.worker,
|
|
455
|
+
decision: Type.Optional(IntegrationCandidateSchema.properties.decision),
|
|
456
|
+
})),
|
|
457
|
+
generations: Type.Array(Type.Object({
|
|
458
|
+
number: IntegrationGenerationSchema.properties.number,
|
|
459
|
+
status: IntegrationGenerationSchema.properties.status,
|
|
460
|
+
integrationBase: WorkspaceSchema,
|
|
461
|
+
combinedTip: Type.Optional(WorkspaceSchema),
|
|
462
|
+
retainedWorktree: Type.Optional(Type.Object({ path: Type.String(), branch: Type.String() })),
|
|
463
|
+
})),
|
|
464
|
+
}),
|
|
465
|
+
needsAttention: Type.Optional(Type.Object({
|
|
466
|
+
scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
|
|
467
|
+
taskId: Type.Optional(IdSchema),
|
|
468
|
+
failure: Type.Optional(Type.String()),
|
|
469
|
+
})),
|
|
470
|
+
createdAt: TimestampSchema,
|
|
471
|
+
updatedAt: TimestampSchema,
|
|
472
|
+
}),
|
|
473
|
+
main: Type.Optional(Type.Union([
|
|
474
|
+
Type.Object({ status: Type.Union([Type.Literal("current"), Type.Literal("drifted")]), expected: WorkspaceSchema, actual: WorkspaceSchema }),
|
|
475
|
+
Type.Object({ status: Type.Literal("unavailable"), expected: WorkspaceSchema, failure: Type.String() }),
|
|
476
|
+
])),
|
|
477
|
+
continuation: Type.Optional(ResumeRequestSchema),
|
|
478
|
+
});
|
|
430
479
|
function normalizeCheck(check, field) {
|
|
431
480
|
return {
|
|
432
481
|
command: normalizeText(check.command, `${field}.command`),
|
package/docs/orchestration.md
CHANGED
|
@@ -139,7 +139,7 @@ Every child launch:
|
|
|
139
139
|
5. excludes Main-only delegation/recovery tools and `ask_question`; and
|
|
140
140
|
6. prepends the delegated-subagent identity contract.
|
|
141
141
|
|
|
142
|
-
An empty `tools` list adds no base tools, though selected extension tools can still activate. Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider; `builtin:<name>` entries load Pi built-in extensions. `mcps` names servers from the global `~/.pi/agent/mcp.json` in Pi's native format
|
|
142
|
+
An empty `tools` list adds no base tools, though selected extension tools can still activate; extension tools with `codemode` or `deferred` exposure stay callable from scripts and are declared only when `tools` names them. Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider; `builtin:<name>` entries load Pi built-in extensions. A Role activates `codemode` by listing `builtin:codemode` in `extensions` and `codemode` in `tools`. `mcps` names servers from the global `~/.pi/agent/mcp.json` in Pi's native format; their tools keep their configured exposure in a codemode Role and load with direct exposure otherwise. Omitted or empty `mcps` denies MCP access. Loading `pi-mcp-adapter` directly is rejected because it bypasses the allowlist.
|
|
143
143
|
|
|
144
144
|
Isolated Herdr Role launches receive the same turn, token, and child-runtime budget metadata as ephemeral launches. Retained workers therefore keep one non-refilling session budget across follow-ups rather than receiving a new request budget.
|
|
145
145
|
|
package/extensions/admission.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { realpath } from "node:fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { Role } from "../dist/index.js";
|
|
4
4
|
|
|
5
|
+
// `codemode` mutates nothing itself: its sandbox reaches a checkout only through nested tool calls,
|
|
6
|
+
// which are admitted by their own names here and limited to the Role's active tools in children.
|
|
5
7
|
const READ_ONLY_TOOLS = new Set([
|
|
6
|
-
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status",
|
|
8
|
+
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status", "codemode",
|
|
7
9
|
]);
|
|
8
10
|
|
|
9
11
|
export function roleCanWrite(role: Role): boolean {
|
|
@@ -26,37 +28,62 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
26
28
|
/**
|
|
27
29
|
* Coordinate Pi-owned calls that can mutate one checkout. This is admission,
|
|
28
30
|
* not an OS sandbox: external processes and trusted extension lifecycle code
|
|
29
|
-
* remain outside interception.
|
|
31
|
+
* remain outside interception. A model-issued call owns the checkout for the
|
|
32
|
+
* calls it makes through `ctx.executeTool()` (codemode scripts), so its nested
|
|
33
|
+
* writes pass while independent writers wait until it settles.
|
|
30
34
|
*/
|
|
31
35
|
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void {
|
|
32
36
|
const ownerByCheckout = new Map<string, string>();
|
|
33
|
-
const
|
|
37
|
+
const checkoutByRoot = new Map<string, string>();
|
|
38
|
+
const writersByRoot = new Map<string, Set<string>>();
|
|
39
|
+
/** Model-issued ancestor of each in-flight call; Pi names nested calls `<parent id>/<n>`. */
|
|
40
|
+
const rootByCall = new Map<string, string>();
|
|
34
41
|
const release = (toolCallId: string) => {
|
|
35
|
-
const
|
|
42
|
+
const root = rootByCall.get(toolCallId);
|
|
43
|
+
if (root === undefined) return;
|
|
44
|
+
rootByCall.delete(toolCallId);
|
|
45
|
+
const writers = writersByRoot.get(root);
|
|
46
|
+
writers?.delete(toolCallId);
|
|
47
|
+
if (rootByCall.has(root) || writers?.size) return;
|
|
48
|
+
writersByRoot.delete(root);
|
|
49
|
+
const key = checkoutByRoot.get(root);
|
|
36
50
|
if (!key) return;
|
|
37
|
-
|
|
38
|
-
if (ownerByCheckout.get(key) ===
|
|
51
|
+
checkoutByRoot.delete(root);
|
|
52
|
+
if (ownerByCheckout.get(key) === root) ownerByCheckout.delete(key);
|
|
39
53
|
};
|
|
40
54
|
pi.on("tool_call", async (event, ctx) => {
|
|
55
|
+
const root = event.parentToolCallId === undefined
|
|
56
|
+
? event.toolCallId
|
|
57
|
+
: rootByCall.get(event.parentToolCallId) ?? event.parentToolCallId;
|
|
58
|
+
rootByCall.set(event.toolCallId, root);
|
|
41
59
|
const potentiallyWriting = event.toolName === "delegate_task"
|
|
42
60
|
? directCanWrite(event.input)
|
|
43
61
|
: !READ_ONLY_TOOLS.has(event.toolName);
|
|
44
62
|
if (!potentiallyWriting) return;
|
|
45
63
|
const key = await checkoutKey(pi, ctx);
|
|
64
|
+
// A codemode result can settle while an unawaited nested call is still resolving its checkout.
|
|
65
|
+
if (root !== event.toolCallId && !rootByCall.has(root)) {
|
|
66
|
+
return { block: true, reason: `Parent call ${root} already settled; ${event.toolCallId} is not admitted.` };
|
|
67
|
+
}
|
|
46
68
|
const owner = ownerByCheckout.get(key);
|
|
47
|
-
if (owner && owner !==
|
|
69
|
+
if (owner && owner !== root) {
|
|
48
70
|
return {
|
|
49
71
|
block: true,
|
|
50
72
|
reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
|
-
ownerByCheckout.set(key,
|
|
54
|
-
|
|
75
|
+
ownerByCheckout.set(key, root);
|
|
76
|
+
checkoutByRoot.set(root, key);
|
|
77
|
+
const writers = writersByRoot.get(root) ?? new Set<string>();
|
|
78
|
+
writers.add(event.toolCallId);
|
|
79
|
+
writersByRoot.set(root, writers);
|
|
55
80
|
});
|
|
56
81
|
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
57
82
|
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
58
83
|
pi.on("session_shutdown", () => {
|
|
59
84
|
ownerByCheckout.clear();
|
|
60
|
-
|
|
85
|
+
checkoutByRoot.clear();
|
|
86
|
+
writersByRoot.clear();
|
|
87
|
+
rootByCall.clear();
|
|
61
88
|
});
|
|
62
89
|
}
|
package/extensions/isolated.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
4
|
import type { EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
parseIdOnly,
|
|
27
28
|
parseResumeRequest,
|
|
28
29
|
sameIdentity,
|
|
30
|
+
StatusOutputSchema,
|
|
29
31
|
type CheckBatchEvidence,
|
|
30
32
|
type ModelClass,
|
|
31
33
|
type ReviewEvidence,
|
|
@@ -616,16 +618,22 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
616
618
|
return toolResult({ text: `Pi Subagent ${id}: durable request accepted; productive work continues. Use subagent_status to inspect progress.`, state }, ctx, workspaceRowsByRequest);
|
|
617
619
|
};
|
|
618
620
|
|
|
621
|
+
// Status stays callable from codemode scripts and hands them the same bounded public projection
|
|
622
|
+
// the model reads; every mutating tool below is model-only because its guarded transitions need
|
|
623
|
+
// Main's visible reasoning.
|
|
619
624
|
pi.registerTool({
|
|
620
625
|
name: "subagent_status",
|
|
621
626
|
label: "Subagent status",
|
|
622
627
|
description: "Read one durable isolated request without reconciling or changing resources.",
|
|
623
628
|
parameters: IdOnlySchema,
|
|
624
629
|
prepareArguments: parseIdOnly,
|
|
630
|
+
outputSchema: StatusOutputSchema,
|
|
625
631
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
626
632
|
latestCtx = ctx;
|
|
627
633
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
628
|
-
|
|
634
|
+
const result = toolResult(await getComponents().runner.status(params.id, root, signal), ctx, workspaceRowsByRequest);
|
|
635
|
+
// The projection is plain JSON; its conditional fields are typed optional rather than absent.
|
|
636
|
+
return { ...result, structuredContent: result.details as JsonValue };
|
|
629
637
|
},
|
|
630
638
|
});
|
|
631
639
|
pi.registerTool({
|
|
@@ -634,6 +642,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
634
642
|
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
|
|
635
643
|
parameters: ResumeRequestParameters,
|
|
636
644
|
prepareArguments: parseResumeRequest,
|
|
645
|
+
exposure: "model-only",
|
|
637
646
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
638
647
|
latestCtx = ctx;
|
|
639
648
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -649,6 +658,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
649
658
|
description: "Main stages/resolves an exact candidate, or rejects/revises one. Rejection of a staged candidate freezes the old generation; explicitly restage chosen candidates in a new generation. Never writes Main.",
|
|
650
659
|
parameters: StageRequestSchema,
|
|
651
660
|
prepareArguments: parseStageRequest,
|
|
661
|
+
exposure: "model-only",
|
|
652
662
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
653
663
|
latestCtx = ctx;
|
|
654
664
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -661,6 +671,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
661
671
|
description: "Main advances staged dependents, refreshes after clean Main drift, validates, corrects, promotes, reconciles an interrupted promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
|
|
662
672
|
parameters: IntegrationActionParameters,
|
|
663
673
|
prepareArguments: parseIntegrationAction,
|
|
674
|
+
exposure: "model-only",
|
|
664
675
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
665
676
|
latestCtx = ctx;
|
|
666
677
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -679,6 +690,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
679
690
|
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged no-candidate allocations; preserve dirty or committed work.",
|
|
680
691
|
parameters: IdOnlySchema,
|
|
681
692
|
prepareArguments: parseIdOnly,
|
|
693
|
+
exposure: "model-only",
|
|
682
694
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
683
695
|
latestCtx = ctx;
|
|
684
696
|
const root = await lookupRoot(ctx.cwd, signal);
|
package/extensions/role-mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createMcpExtension, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadRoleMcpConfig, roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG } from "@henryqw/pi-subagent";
|
|
2
|
+
import { loadRoleMcpConfig, roleActivatesCodemode, roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG, roleToolPolicyFromArgv } from "@henryqw/pi-subagent";
|
|
3
3
|
|
|
4
4
|
export default function roleMcp(pi: ExtensionAPI): void | Promise<void> {
|
|
5
5
|
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
@@ -7,6 +7,8 @@ export default function roleMcp(pi: ExtensionAPI): void | Promise<void> {
|
|
|
7
7
|
type: "string",
|
|
8
8
|
});
|
|
9
9
|
// Pi binds extension flag values after factories load. Throwing here exits the child non-zero.
|
|
10
|
-
const config = loadRoleMcpConfig(getAgentDir(), roleMcpAllowlistFromArgv(process.argv)
|
|
10
|
+
const config = loadRoleMcpConfig(getAgentDir(), roleMcpAllowlistFromArgv(process.argv), {
|
|
11
|
+
codemode: roleActivatesCodemode(roleToolPolicyFromArgv(process.argv)),
|
|
12
|
+
});
|
|
11
13
|
return createMcpExtension({ loadConfig: () => config })(pi);
|
|
12
14
|
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -4,12 +4,16 @@ import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-codin
|
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
6
|
EXECUTION_BUDGET_ENV,
|
|
7
|
+
parseRoleToolPolicy,
|
|
7
8
|
PI_SUBAGENT_PROCESS_LEASE,
|
|
8
9
|
ROLE_TOOL_POLICY_FLAG,
|
|
9
10
|
type EphemeralSubagentExecutionBudget,
|
|
10
11
|
} from "@henryqw/pi-subagent";
|
|
11
12
|
|
|
12
13
|
const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
|
|
14
|
+
// Pi does not declare these exposures on registration: `codemode`/`deferred` tools stay callable from
|
|
15
|
+
// scripts, `hidden` ones are unreachable. The Role policy declares them only when `tools` names them.
|
|
16
|
+
const undeclaredExposures: ReadonlySet<string> = new Set(["codemode", "deferred", "hidden"]);
|
|
13
17
|
const WARNING_RATIO = 0.8;
|
|
14
18
|
const WARNING_MESSAGE_TYPE = "pi-subagent-execution-budget";
|
|
15
19
|
const PROCESS_LEASE_ERROR = `${PI_SUBAGENT_PROCESS_LEASE} must be a nonempty NUL/newline-free absolute path naming a regular non-symlink file owned by the current uid with mode 0600.`;
|
|
@@ -33,20 +37,6 @@ function validateProcessLease(path: string): void {
|
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
function configuredTools(value: unknown): string[] {
|
|
37
|
-
if (typeof value !== "string") throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
38
|
-
let parsed: unknown;
|
|
39
|
-
try {
|
|
40
|
-
parsed = JSON.parse(value);
|
|
41
|
-
} catch {
|
|
42
|
-
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
43
|
-
}
|
|
44
|
-
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
45
|
-
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
46
|
-
}
|
|
47
|
-
return [...new Set(parsed.map((name) => name.trim()))];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
40
|
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
51
41
|
if (value === undefined) return;
|
|
52
42
|
let parsed: unknown;
|
|
@@ -107,11 +97,11 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
107
97
|
const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
|
|
108
98
|
let handoffSent = false;
|
|
109
99
|
pi.on("session_start", () => {
|
|
110
|
-
const selected =
|
|
100
|
+
const selected = parseRoleToolPolicy(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
111
101
|
const allTools = pi.getAllTools();
|
|
112
102
|
const registeredTools = new Set(allTools.map((tool) => tool.name));
|
|
113
103
|
const extensionTools = allTools
|
|
114
|
-
.filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source))
|
|
104
|
+
.filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source) && !undeclaredExposures.has(tool.exposure))
|
|
115
105
|
.map((tool) => tool.name);
|
|
116
106
|
pi.setActiveTools([...new Set([...selected, ...extensionTools])].filter((name) => !childExcludedTools.has(name)));
|
|
117
107
|
const activeTools = new Set(pi.getActiveTools().filter((name) => registeredTools.has(name) && !childExcludedTools.has(name)));
|
package/extensions/subagent.ts
CHANGED
|
@@ -461,6 +461,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
461
461
|
`For delegate_task, ${MODEL_CLASS_GUIDANCE} A direct model replaces only the selected route's model; its thinking level stays unchanged.`,
|
|
462
462
|
],
|
|
463
463
|
parameters: DelegateTaskParameters,
|
|
464
|
+
// Delegation needs Main's visible reasoning; codemode scripts cannot start it.
|
|
465
|
+
exposure: "model-only",
|
|
464
466
|
prepareArguments(args) {
|
|
465
467
|
try {
|
|
466
468
|
const parsed = parseDelegateTask(args);
|