@henryqw/pi-subagent 25.0.1 → 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
 
@@ -126,7 +128,9 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
126
128
 
127
129
  `dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
128
130
 
129
- `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
131
+ `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. Session replacement or shutdown suppresses stale delivery and logs the request ID and reason, without prompts or credentials. Synchronous delivery failures are reported without automatic replay; Pi reports asynchronous `send_message` failures through its native runtime. If delivery is missed, inspect `subagent_status`.
132
+
133
+ Completed text answers are saved independently of cleanup. Status and terminal follow-ups include a bounded accepted-answer preview and cleanup evidence. Ignored automatic artifacts can retain a checkout without violating the read-only contract; no ignored files are deleted to make cleanup pass. Tracked or untracked changes, changed checkout identity, or unproved inspection still require attention: the completed answer remains saved but is not accepted as dependency context or retried automatically. Retained text checkouts require deliberate manual inspection and cleanup; a retained checkout alone is not evidence that the Role edited source.
130
134
 
131
135
  Isolated workers can start while Main has staged, unstaged, or untracked changes: they use only Main's pinned committed branch, HEAD, and tree. Those local edits are neither copied into worker worktrees nor stashed, staged, reset, or merged automatically. Main must remain at that committed identity while workers run; branch or HEAD drift blocks new waves. The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
132
136
 
@@ -173,7 +177,7 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
173
177
  | --- | --- |
174
178
  | `name`, `description` | Required non-empty text without terminal control characters |
175
179
  | `modelClass` | Optional `fast`, `balanced`, `frontier`, or `fav` default |
176
- | `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) |
177
181
  | `extensions` | Required array of trusted absolute paths, supported package sources, or Pi built-in extensions such as `builtin:codemode` |
178
182
  | `skills` | Required array of effective Pi Skill names |
179
183
  | `mcps` | Optional exact server names from `~/.pi/agent/mcp.json` in Pi's native `mcpServers` format; omitted or `[]` denies MCP access |
@@ -181,7 +185,13 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
181
185
 
182
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`.
183
187
 
184
- 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.
185
195
 
186
196
  ## API
187
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/runner.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { createChildWorktree, finalizeChildWorktree } from "./worktree.js";
2
+ import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
3
+ import { runGit } from "./git-process.js";
3
4
  import { IntegrationGit } from "./integration-git.js";
4
5
  import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reviewEvidencePasses, sameIdentity, RUN_STATE_VERSION, } from "./schema.js";
5
6
  import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
@@ -526,7 +527,9 @@ export class IsolatedRunner {
526
527
  const { states: entries, invalidIds } = await this.store.list(root);
527
528
  return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
528
529
  id: state.request.id, name: state.request.goal,
529
- status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
530
+ status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset"
531
+ ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
532
+ : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
530
533
  || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
531
534
  || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
532
535
  ? `${state.status} · retained` : state.status,
@@ -2035,6 +2038,7 @@ export class IsolatedRunner {
2035
2038
  if (!attempt || attempt.status !== "running")
2036
2039
  throw new Error(`Text task ${task.taskId} has no running attempt.`);
2037
2040
  const prompt = buildTextTaskPrompt(state.request.goal, request, resolveTextTaskContexts(state, request));
2041
+ let checkoutFailure;
2038
2042
  const result = await this.callProductive(handle, scope, async (context) => {
2039
2043
  const wave = state.waves.at(-1);
2040
2044
  if (!wave?.taskIds.includes(task.taskId))
@@ -2071,12 +2075,19 @@ export class IsolatedRunner {
2071
2075
  prepare: async () => ({ launch, task: prompt, cwd: isolated.cwd }),
2072
2076
  });
2073
2077
  });
2078
+ // Read-only evidence excludes ignored artifacts; deletion safety still includes them.
2079
+ const inspection = await inspectWorktreeDirty(isolated.path, runGit, false);
2080
+ const head = await runGit(["rev-parse", "--verify", "HEAD"], isolated.path);
2081
+ const branch = await runGit(["symbolic-ref", "--quiet", "HEAD"], isolated.path);
2082
+ checkoutFailure = inspection.failure ? `read-only checkout inspection failed: ${inspection.failure}`
2083
+ : inspection.dirty ? "read-only contract violated: tracked or untracked changes remain"
2084
+ : head.code !== 0 || branch.code !== 0 ? "read-only checkout identity could not be proved"
2085
+ : head.stdout.trim() !== isolated.baseCommit || branch.stdout.trim() !== `refs/heads/${isolated.branch}`
2086
+ ? "read-only contract violated: checkout HEAD or branch changed" : undefined;
2074
2087
  }
2075
2088
  finally {
2076
- const cleanup = await finalizeChildWorktree(isolated);
2077
- if (cleanup.outcome !== "pruned") {
2078
- throw new Error(`Isolated text task changed its checkout; work was retained at ${cleanup.path} on ${cleanup.branch}.`);
2079
- }
2089
+ // Retention is evidence, not proof of source edits, and must not mask an answer or executor error.
2090
+ attempt.cleanup = await finalizeChildWorktree(isolated);
2080
2091
  }
2081
2092
  return result;
2082
2093
  });
@@ -2091,16 +2102,30 @@ export class IsolatedRunner {
2091
2102
  if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
2092
2103
  throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
2093
2104
  }
2094
- const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2095
- if (!sameIdentity(actualMain, state.main))
2096
- throw new Error("Main drifted during text task execution.");
2097
- const wave = state.waves.at(-1);
2098
- if (wave.base.head !== state.main.head) {
2099
- const generation = state.integration.generations.at(-1);
2100
- if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
2101
- throw new Error("Staged dependency snapshot was superseded during text task execution.");
2105
+ try {
2106
+ if (checkoutFailure)
2107
+ throw new Error(`Isolated text task ${checkoutFailure}; inspect ${attempt.cleanup.path} on ${attempt.cleanup.branch}.`);
2108
+ if (attempt.cleanup?.outcome === "recovery")
2109
+ throw new Error(`Isolated text task cleanup requires recovery: ${attempt.cleanup.note}`);
2110
+ const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2111
+ if (!sameIdentity(actualMain, state.main))
2112
+ throw new Error("Main drifted during text task execution.");
2113
+ const wave = state.waves.at(-1);
2114
+ if (wave.base.head !== state.main.head) {
2115
+ const generation = state.integration.generations.at(-1);
2116
+ if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
2117
+ throw new Error("Staged dependency snapshot was superseded during text task execution.");
2118
+ }
2119
+ await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2102
2120
  }
2103
- await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2121
+ }
2122
+ catch (error) {
2123
+ this.rethrowStopped(error);
2124
+ // Retain the completed answer without admitting it as dependency context.
2125
+ attempt.status = "completed";
2126
+ attempt.output = { text: output };
2127
+ this.attention(task, `Task dispatch was interrupted: ${errorText(error)}`);
2128
+ throw error;
2104
2129
  }
2105
2130
  attempt.status = "completed";
2106
2131
  attempt.failure = undefined;
package/dist/schema.d.ts CHANGED
@@ -877,6 +877,24 @@ declare const TextTaskAttemptSchema: Type.TObject<{
877
877
  output: Type.TOptional<Type.TObject<{
878
878
  text: Type.TString;
879
879
  }>>;
880
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
881
+ outcome: Type.TLiteral<"pruned">;
882
+ path: Type.TString;
883
+ branch: Type.TString;
884
+ }>, Type.TObject<{
885
+ outcome: Type.TLiteral<"retained">;
886
+ path: Type.TString;
887
+ branch: Type.TString;
888
+ commits: Type.TInteger;
889
+ dirty: Type.TBoolean;
890
+ }>, Type.TObject<{
891
+ outcome: Type.TLiteral<"recovery">;
892
+ path: Type.TString;
893
+ branch: Type.TString;
894
+ note: Type.TString;
895
+ commits: Type.TOptional<Type.TInteger>;
896
+ dirty: Type.TOptional<Type.TBoolean>;
897
+ }>]>>;
880
898
  }>;
881
899
  declare const TextTaskStateSchema: Type.TObject<{
882
900
  taskId: Type.TString;
@@ -889,6 +907,24 @@ declare const TextTaskStateSchema: Type.TObject<{
889
907
  output: Type.TOptional<Type.TObject<{
890
908
  text: Type.TString;
891
909
  }>>;
910
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
911
+ outcome: Type.TLiteral<"pruned">;
912
+ path: Type.TString;
913
+ branch: Type.TString;
914
+ }>, Type.TObject<{
915
+ outcome: Type.TLiteral<"retained">;
916
+ path: Type.TString;
917
+ branch: Type.TString;
918
+ commits: Type.TInteger;
919
+ dirty: Type.TBoolean;
920
+ }>, Type.TObject<{
921
+ outcome: Type.TLiteral<"recovery">;
922
+ path: Type.TString;
923
+ branch: Type.TString;
924
+ note: Type.TString;
925
+ commits: Type.TOptional<Type.TInteger>;
926
+ dirty: Type.TOptional<Type.TBoolean>;
927
+ }>]>>;
892
928
  }>>;
893
929
  failure: Type.TOptional<Type.TString>;
894
930
  }>;
@@ -903,6 +939,24 @@ declare const TaskStateSchema: Type.TUnion<[Type.TObject<{
903
939
  output: Type.TOptional<Type.TObject<{
904
940
  text: Type.TString;
905
941
  }>>;
942
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
943
+ outcome: Type.TLiteral<"pruned">;
944
+ path: Type.TString;
945
+ branch: Type.TString;
946
+ }>, Type.TObject<{
947
+ outcome: Type.TLiteral<"retained">;
948
+ path: Type.TString;
949
+ branch: Type.TString;
950
+ commits: Type.TInteger;
951
+ dirty: Type.TBoolean;
952
+ }>, Type.TObject<{
953
+ outcome: Type.TLiteral<"recovery">;
954
+ path: Type.TString;
955
+ branch: Type.TString;
956
+ note: Type.TString;
957
+ commits: Type.TOptional<Type.TInteger>;
958
+ dirty: Type.TOptional<Type.TBoolean>;
959
+ }>]>>;
906
960
  }>>;
907
961
  failure: Type.TOptional<Type.TString>;
908
962
  }>, Type.TObject<{
@@ -1717,6 +1771,24 @@ declare const RunStateSchema: Type.TObject<{
1717
1771
  output: Type.TOptional<Type.TObject<{
1718
1772
  text: Type.TString;
1719
1773
  }>>;
1774
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
1775
+ outcome: Type.TLiteral<"pruned">;
1776
+ path: Type.TString;
1777
+ branch: Type.TString;
1778
+ }>, Type.TObject<{
1779
+ outcome: Type.TLiteral<"retained">;
1780
+ path: Type.TString;
1781
+ branch: Type.TString;
1782
+ commits: Type.TInteger;
1783
+ dirty: Type.TBoolean;
1784
+ }>, Type.TObject<{
1785
+ outcome: Type.TLiteral<"recovery">;
1786
+ path: Type.TString;
1787
+ branch: Type.TString;
1788
+ note: Type.TString;
1789
+ commits: Type.TOptional<Type.TInteger>;
1790
+ dirty: Type.TOptional<Type.TBoolean>;
1791
+ }>]>>;
1720
1792
  }>>;
1721
1793
  failure: Type.TOptional<Type.TString>;
1722
1794
  }>, Type.TObject<{
@@ -2248,6 +2320,116 @@ declare const RunStateSchema: Type.TObject<{
2248
2320
  createdAt: Type.TInteger;
2249
2321
  updatedAt: Type.TInteger;
2250
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
+ }>;
2251
2433
  export declare function taskDependencies(task: TaskRequest): string[];
2252
2434
  export declare function validateGraph(tasks: readonly TaskRequest[]): void;
2253
2435
  export declare function parseExecuteRequest(value: unknown): ExecuteRequest;
package/dist/schema.js CHANGED
@@ -291,6 +291,17 @@ const TextTaskAttemptSchema = Type.Object({
291
291
  status: Type.Union([Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("superseded")]),
292
292
  failure: OptionalRuntimeTextSchema,
293
293
  output: Type.Optional(TextTaskOutputSchema),
294
+ cleanup: Type.Optional(Type.Union([
295
+ Type.Object({ outcome: Type.Literal("pruned"), path: RuntimeTextSchema, branch: RuntimeTextSchema }, { additionalProperties: false }),
296
+ Type.Object({
297
+ outcome: Type.Literal("retained"), path: RuntimeTextSchema, branch: RuntimeTextSchema,
298
+ commits: Type.Integer({ minimum: 0 }), dirty: Type.Boolean(),
299
+ }, { additionalProperties: false }),
300
+ Type.Object({
301
+ outcome: Type.Literal("recovery"), path: RuntimeTextSchema, branch: RuntimeTextSchema, note: RuntimeTextSchema,
302
+ commits: Type.Optional(Type.Integer({ minimum: 0 })), dirty: Type.Optional(Type.Boolean()),
303
+ }, { additionalProperties: false }),
304
+ ])),
294
305
  }, { additionalProperties: false });
295
306
  const TextTaskStateSchema = Type.Object({
296
307
  taskId: IdSchema,
@@ -416,6 +427,55 @@ const RunStateSchema = Type.Object({
416
427
  createdAt: TimestampSchema,
417
428
  updatedAt: TimestampSchema,
418
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
+ });
419
479
  function normalizeCheck(check, field) {
420
480
  return {
421
481
  command: normalizeText(check.command, `${field}.command`),
@@ -679,7 +739,7 @@ function validateTextTaskState(taskState) {
679
739
  ? "running"
680
740
  : "completed";
681
741
  if (taskState.status === "needs_attention"
682
- ? latest && !["failed", "superseded"].includes(latest.status)
742
+ ? latest && !["failed", "superseded", "completed"].includes(latest.status)
683
743
  : latest?.status !== expectedLatestStatus) {
684
744
  throw new Error(`Text task ${taskState.taskId} status ${taskState.status} has an incompatible latest attempt.`);
685
745
  }
@@ -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,
@@ -281,16 +283,24 @@ function publicState(state: RunState, preferredTaskId?: string) {
281
283
  status: state.status,
282
284
  accepted: state.accepted,
283
285
  main: state.main,
284
- tasks: state.tasks.map((task) => ({
285
- taskId: task.taskId, status: task.status,
286
- ...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
287
- number: attempt.number, superseded: attempt.superseded === true,
288
- workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
289
- termination: attempt.termination?.status,
290
- worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
291
- cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
292
- })) } : {}),
293
- })),
286
+ tasks: state.tasks.map((task) => {
287
+ const textAttempt = task.kind === "text" ? task.attempts.at(-1) : undefined;
288
+ return {
289
+ taskId: task.taskId, status: task.status,
290
+ ...(task.status === "completed" && textAttempt?.output ? { output: boundedPublicText(textAttempt.output.text) } : {}),
291
+ ...(textAttempt?.cleanup ? { cleanup: {
292
+ ...textAttempt.cleanup,
293
+ ...(textAttempt.cleanup.outcome === "recovery" ? { note: boundedPublicText(textAttempt.cleanup.note) } : {}),
294
+ } } : {}),
295
+ ...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
296
+ number: attempt.number, superseded: attempt.superseded === true,
297
+ workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
298
+ termination: attempt.termination?.status,
299
+ worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
300
+ cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
301
+ })) } : {}),
302
+ };
303
+ }),
294
304
  final: { status: state.final.status },
295
305
  integration: {
296
306
  ...(state.integration.refresh ? { refresh: state.integration.refresh } : {}),
@@ -499,9 +509,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
499
509
  const sessionId = ctx.sessionManager?.getSessionId();
500
510
  const key = `${root}\0${id}`;
501
511
  if (jobOwners.has(key)) throw new Error(`Pi Subagent request ${id} is already active in this session runtime.`);
502
- const canDeliver = () => !sessionClosed && sessionEpoch === epoch
503
- && ctx.sessionManager?.getSessionId() === sessionId
504
- && latestCtx?.sessionManager?.getSessionId() === sessionId;
512
+ const deliverySuppression = () => sessionClosed ? "session closed"
513
+ : sessionEpoch !== epoch ? "session generation changed"
514
+ : ctx.sessionManager?.getSessionId() !== sessionId ? "launching session changed"
515
+ : latestCtx?.sessionManager?.getSessionId() !== sessionId ? "current session changed"
516
+ : undefined;
517
+ const canDeliver = () => deliverySuppression() === undefined;
505
518
  const controller = new AbortController();
506
519
  activeJobs.add(controller);
507
520
  jobOwners.set(key, canDeliver);
@@ -517,6 +530,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
517
530
  if (signal?.aborted) abortBeforeAck();
518
531
  else signal?.addEventListener("abort", abortBeforeAck, { once: true });
519
532
  let latestState: RunState | undefined;
533
+ let durableAcknowledged = false;
520
534
  let accept!: (state: RunState) => void;
521
535
  let reject!: (error: unknown) => void;
522
536
  const durable = new Promise<RunState>((resolve, fail) => { accept = resolve; reject = fail; });
@@ -525,14 +539,19 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
525
539
  if (state.root !== root || state.request.id !== id) return;
526
540
  latestState = state;
527
541
  if (!acknowledged(state)) return;
542
+ durableAcknowledged = true;
528
543
  for (const candidate of state.integration.candidates) notified.add(`${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`);
529
544
  stateListeners.delete(listener);
530
545
  removeTurnAbort();
531
546
  accept(state);
532
547
  };
533
548
  stateListeners.add(listener);
534
- const deliver = (text: string, response?: RunResponse) => {
535
- if (!canDeliver()) return;
549
+ const deliver = (kind: "candidate" | "attention" | "terminal", text: string, response?: RunResponse) => {
550
+ const suppression = deliverySuppression();
551
+ if (suppression) {
552
+ console.warn(`Pi Subagent ${id}: ${kind} delivery suppressed (${suppression}); durable request retained.`);
553
+ return;
554
+ }
536
555
  const state = response?.state ?? latestState;
537
556
  try {
538
557
  const details = state ? {
@@ -547,13 +566,14 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
547
566
  details,
548
567
  }, { triggerTurn: true, deliverAs: "followUp" });
549
568
  } catch {
550
- console.error(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`);
569
+ console.error(`Pi Subagent ${id}: ${kind} delivery failed (sendMessage threw); use subagent_status to recover. No delivery replayed.`);
551
570
  if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
552
571
  }
553
572
  };
554
573
  candidateListener = (state) => {
555
- if (state.root !== root || state.request.id !== id || state.status !== "running" || !canDeliver()) return;
574
+ if (state.root !== root || state.request.id !== id) return;
556
575
  latestState = state;
576
+ if (state.status !== "running") return;
557
577
  for (const task of state.tasks) {
558
578
  if (task.status !== "needs_attention") {
559
579
  attentionNotified.delete(task.taskId);
@@ -561,13 +581,13 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
561
581
  }
562
582
  if (attentionNotified.has(task.taskId)) continue;
563
583
  attentionNotified.add(task.taskId);
564
- deliver(`Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
584
+ deliver("attention", `Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
565
585
  }
566
586
  for (const candidate of state.integration.candidates) {
567
587
  const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
568
588
  if (candidate.decision || notified.has(identity)) continue;
569
589
  notified.add(identity);
570
- deliver(`Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
590
+ deliver("candidate", `Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
571
591
  }
572
592
  };
573
593
  stateListeners.add(candidateListener);
@@ -576,21 +596,21 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
576
596
  finish();
577
597
  stateListeners.delete(listener);
578
598
  removeTurnAbort();
579
- if (!latestState || !acknowledged(latestState)) {
599
+ if (!durableAcknowledged) {
580
600
  reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
581
601
  return;
582
602
  }
583
- deliver(`${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
603
+ deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
584
604
  },
585
605
  (error: unknown) => {
586
606
  finish();
587
607
  stateListeners.delete(listener);
588
608
  removeTurnAbort();
589
- if (!latestState || !acknowledged(latestState)) {
609
+ if (!durableAcknowledged) {
590
610
  reject(error);
591
611
  return;
592
612
  }
593
- deliver(`Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
613
+ deliver("terminal", `Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
594
614
  },
595
615
  );
596
616
  const state = await durable;
@@ -598,16 +618,22 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
598
618
  return toolResult({ text: `Pi Subagent ${id}: durable request accepted; productive work continues. Use subagent_status to inspect progress.`, state }, ctx, workspaceRowsByRequest);
599
619
  };
600
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.
601
624
  pi.registerTool({
602
625
  name: "subagent_status",
603
626
  label: "Subagent status",
604
627
  description: "Read one durable isolated request without reconciling or changing resources.",
605
628
  parameters: IdOnlySchema,
606
629
  prepareArguments: parseIdOnly,
630
+ outputSchema: StatusOutputSchema,
607
631
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
608
632
  latestCtx = ctx;
609
633
  const root = await lookupRoot(ctx.cwd, signal);
610
- 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 };
611
637
  },
612
638
  });
613
639
  pi.registerTool({
@@ -616,6 +642,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
616
642
  description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
617
643
  parameters: ResumeRequestParameters,
618
644
  prepareArguments: parseResumeRequest,
645
+ exposure: "model-only",
619
646
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
620
647
  latestCtx = ctx;
621
648
  const root = await lookupRoot(ctx.cwd, signal);
@@ -631,6 +658,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
631
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.",
632
659
  parameters: StageRequestSchema,
633
660
  prepareArguments: parseStageRequest,
661
+ exposure: "model-only",
634
662
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
635
663
  latestCtx = ctx;
636
664
  const root = await lookupRoot(ctx.cwd, signal);
@@ -643,6 +671,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
643
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.",
644
672
  parameters: IntegrationActionParameters,
645
673
  prepareArguments: parseIntegrationAction,
674
+ exposure: "model-only",
646
675
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
647
676
  latestCtx = ctx;
648
677
  const root = await lookupRoot(ctx.cwd, signal);
@@ -661,6 +690,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
661
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.",
662
691
  parameters: IdOnlySchema,
663
692
  prepareArguments: parseIdOnly,
693
+ exposure: "model-only",
664
694
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
665
695
  latestCtx = ctx;
666
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.1",
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",