@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 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 load in the child with direct exposure. Omitted or empty `mcps` denies MCP access. Direct `pi-mcp-adapter` loading is rejected because it bypasses the allowlist.
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
  ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
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`. The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only; their tools load in the child with direct exposure and are declared 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.
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
- export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
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
- /** Read child MCP policy before Pi binds registered extension flag values. */
21
- export function roleMcpAllowlistFromArgv(args) {
22
- const flag = `--${ROLE_MCP_POLICY_FLAG}`;
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 parseRoleMcpAllowlist(args[indexes[0] + 1]);
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);
@@ -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 `--no-extensions` child has no codemode tool, so
6
- * every selected server is forced to direct exposure and codemode is never activated.
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[]): LoadedMcpConfig;
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 `--no-extensions` child has no codemode tool, so
91
- * every selected server is forced to direct exposure and codemode is never activated.
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`),
@@ -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, and their tools load in the child with direct exposure. Omitted or empty `mcps` denies MCP access. Loading `pi-mcp-adapter` directly is rejected because it bypasses the allowlist.
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
 
@@ -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 checkoutByCall = new Map<string, string>();
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 key = checkoutByCall.get(toolCallId);
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
- checkoutByCall.delete(toolCallId);
38
- if (ownerByCheckout.get(key) === toolCallId) ownerByCheckout.delete(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 !== event.toolCallId) {
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, event.toolCallId);
54
- checkoutByCall.set(event.toolCallId, key);
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
- checkoutByCall.clear();
85
+ checkoutByRoot.clear();
86
+ writersByRoot.clear();
87
+ rootByCall.clear();
61
88
  });
62
89
  }
@@ -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
- return toolResult(await getComponents().runner.status(params.id, root, signal), ctx, workspaceRowsByRequest);
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);
@@ -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
  }
@@ -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 = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
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)));
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "25.0.2",
3
+ "version": "25.1.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",