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

|
|
41
41
|
|
|
42
|
+
`delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only: Pi's `codemode` scripts cannot call them, because delegation and guarded state transitions need Main's visible reasoning. `subagent_status` stays callable from scripts. In Main, one model-issued call at a time may write to a checkout through Pi tools; a `codemode` script owns the checkout from its first nested write until the script ends, so its own nested calls pass while an independent writer is blocked with a reason and retries after it settles. Reads never wait.
|
|
43
|
+
|
|
42
44
|
The coordinator is extension code running in Main's Pi process, not another agent. It persists the request, launches the Herdr subagent, checks its committed work, and reports saved results; Main chooses what to stage and promote. If a worker fails, the coordinator reports attention after the wave settles, not necessarily immediately while siblings work. If Main's Pi process stops, no follow-up is guaranteed: restart and run `/subagent recover` in the canonical repository to classify orphaned interruptions and send Main one recovery report. Main then uses `subagent_status` for exact evidence and deliberate next actions. A stale `running` state does not prove a worker is still active; interrupted or ambiguous actions are never replayed automatically.
|
|
43
45
|
|
|
44
46
|
In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` marks direct or isolated work, and `[S]` or `[I3]` identifies the Role initial (plus the isolated model class: `1` fast, `2` balanced, `3` frontier, `*` fav). Direct rows show the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Isolated rows show the recorded task state and any retained workspace; an aborted request with visible tasks has its own `request aborted` row and does not change their recorded statuses. A committed worker without a checked candidate can still show attention rather than readiness. Status glyphs use the active TUI theme, while the text still names the status without color. Herdr does not currently provide reliable live model, thinking, or token readings for those rows. Attention appears before ordinary work, with a `… N more` summary when direct rows overflow. A ready isolated candidate is **not promoted** to Main. Choose **Refresh** in `/subagent` to reload both the menu and isolated widget from saved state; it does not recover or change a request. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
|
|
@@ -75,7 +77,7 @@ Parallel: { mode: "direct", tasks: [{ role, name, task, ... }] }
|
|
|
75
77
|
Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
Only Roles whose declared base tools are known read-only may run direct. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
80
|
+
Only Roles whose declared base tools are known read-only may run direct. `codemode` counts as read-only because its scripts can only call the tools the Role already activates plus trusted extension and MCP tools. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
79
81
|
|
|
80
82
|
The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. A recorded tab may no longer be running; locally observed work is labelled separately.
|
|
81
83
|
|
|
@@ -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`.
|
|
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
|
-
|
|
13
|
+
/** Parse the Role tool policy flag value: JSON tool names. */
|
|
14
|
+
export declare function parseRoleToolPolicy(value: unknown): string[];
|
|
15
|
+
/** Read child tool policy before Pi binds registered extension flag values. */
|
|
16
|
+
export declare function roleToolPolicyFromArgv(args: readonly string[]): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a tool policy activates `codemode` in the child, so scripts can reach tools with
|
|
19
|
+
* `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
|
|
20
|
+
*/
|
|
21
|
+
export declare function roleActivatesCodemode(tools: readonly string[]): boolean;
|
|
13
22
|
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_abort"];
|
|
14
23
|
export declare const DELEGATE_TASK: {
|
|
15
24
|
readonly id: "pi-subagent/delegateTask";
|
package/dist/index.js
CHANGED
|
@@ -17,15 +17,46 @@ const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", im
|
|
|
17
17
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
18
18
|
export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
19
19
|
export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
21
|
+
const CODEMODE_TOOL = "codemode";
|
|
22
|
+
/** The value after a flag that must appear exactly once in a child's argv. */
|
|
23
|
+
function flagValueFromArgv(args, flag) {
|
|
23
24
|
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
24
25
|
if (indexes.length !== 1)
|
|
25
26
|
throw new Error(`${flag} must appear exactly once.`);
|
|
26
|
-
return
|
|
27
|
+
return args[indexes[0] + 1];
|
|
28
|
+
}
|
|
29
|
+
/** Read child MCP policy before Pi binds registered extension flag values. */
|
|
30
|
+
export function roleMcpAllowlistFromArgv(args) {
|
|
31
|
+
return parseRoleMcpAllowlist(flagValueFromArgv(args, `--${ROLE_MCP_POLICY_FLAG}`));
|
|
32
|
+
}
|
|
33
|
+
/** Parse the Role tool policy flag value: JSON tool names. */
|
|
34
|
+
export function parseRoleToolPolicy(value) {
|
|
35
|
+
if (typeof value !== "string")
|
|
36
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
45
|
+
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(parsed.map((name) => name.trim()))];
|
|
48
|
+
}
|
|
49
|
+
/** Read child tool policy before Pi binds registered extension flag values. */
|
|
50
|
+
export function roleToolPolicyFromArgv(args) {
|
|
51
|
+
return parseRoleToolPolicy(flagValueFromArgv(args, `--${ROLE_TOOL_POLICY_FLAG}`));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a tool policy activates `codemode` in the child, so scripts can reach tools with
|
|
55
|
+
* `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
|
|
56
|
+
*/
|
|
57
|
+
export function roleActivatesCodemode(tools) {
|
|
58
|
+
return tools.includes(CODEMODE_TOOL);
|
|
27
59
|
}
|
|
28
|
-
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
29
60
|
export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
30
61
|
"delegate_task",
|
|
31
62
|
"ask_question",
|
|
@@ -333,8 +364,9 @@ export function prepareRoleLaunch(pi, ctx, input) {
|
|
|
333
364
|
: prepareResolvedRoleLaunch(pi, ctx, input);
|
|
334
365
|
assertNoMissingRoleSkills(input.role, prepared);
|
|
335
366
|
// Validate the mcp.json the child reads: its environment inherits Main's, with launch overrides on top.
|
|
336
|
-
if (input.role.mcps?.length)
|
|
337
|
-
loadRoleMcpConfig(prepared.env.PI_CODING_AGENT_DIR ?? getAgentDir(), input.role.mcps);
|
|
367
|
+
if (input.role.mcps?.length) {
|
|
368
|
+
loadRoleMcpConfig(prepared.env.PI_CODING_AGENT_DIR ?? getAgentDir(), input.role.mcps, { codemode: roleActivatesCodemode(prepared.tools) });
|
|
369
|
+
}
|
|
338
370
|
return { ...prepared, role };
|
|
339
371
|
}
|
|
340
372
|
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
@@ -347,7 +379,7 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
347
379
|
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
348
380
|
const role = matches[0];
|
|
349
381
|
if (role.mcps?.length)
|
|
350
|
-
loadRoleMcpConfig(getAgentDir(), role.mcps);
|
|
382
|
+
loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
|
|
351
383
|
const resources = await resolveRolePackageResources(role, ctx);
|
|
352
384
|
const effectiveRole = { ...role, extensions: resources.extensions };
|
|
353
385
|
const namedSkills = resolveRoleSkills(pi, effectiveRole);
|
package/dist/mcp-role.d.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { LoadedMcpConfig } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
export declare function parseRoleMcpAllowlist(value: unknown): string[];
|
|
3
3
|
/**
|
|
4
4
|
* Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
|
|
5
|
-
* shape; the project `.pi/mcp.json` is ignored. A
|
|
6
|
-
*
|
|
5
|
+
* shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
|
|
6
|
+
* server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
|
|
7
|
+
* cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
|
|
8
|
+
* never activated by MCP configuration: the Role tool policy owns the active set.
|
|
7
9
|
*/
|
|
8
|
-
export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[]
|
|
10
|
+
export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[], options?: {
|
|
11
|
+
codemode: boolean;
|
|
12
|
+
}): LoadedMcpConfig;
|
package/dist/mcp-role.js
CHANGED
|
@@ -31,6 +31,11 @@ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
|
31
31
|
const isHttpsOrLoopback = (url) => url !== undefined && (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)));
|
|
32
32
|
/** Tool namespace `mcp__<server>` replaces `-` with `_`, so names differing only there collide. */
|
|
33
33
|
const mcpNamespace = (name) => name.replace(/-/g, "_");
|
|
34
|
+
// Pi's exposure names and the alias it still accepts (core/mcp-servers.ts); the native loader is bypassed, so resolve it here.
|
|
35
|
+
const MCP_EXPOSURES = ["codemode", "deferred", "direct", "hidden"];
|
|
36
|
+
const MCP_EXPOSURE_ALIASES = { "codemode-deferred": "codemode" };
|
|
37
|
+
const resolveExposure = (value) => MCP_EXPOSURE_ALIASES[value] ?? value;
|
|
38
|
+
const isExposure = (value) => typeof value === "string" && MCP_EXPOSURES.includes(resolveExposure(value));
|
|
34
39
|
/** Documented `mcpServers` fields (Pi's docs/mcp.md) and their shapes, by transport; dotted keys are nested. */
|
|
35
40
|
const COMMON_CHECKS = {
|
|
36
41
|
enabled: [(value) => typeof value === "boolean", "a boolean"],
|
|
@@ -60,8 +65,12 @@ const HTTP_CHECKS = {
|
|
|
60
65
|
"oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
|
|
61
66
|
auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
|
|
62
67
|
};
|
|
68
|
+
const EXPOSURE_CHECKS = {
|
|
69
|
+
exposure: [isExposure, `one of ${MCP_EXPOSURES.map((exposure) => `"${exposure}"`).join(", ")}`],
|
|
70
|
+
toolExposure: [(value) => isRecord(value) && Object.values(value).every(isExposure), "an object mapping tool names to exposures"],
|
|
71
|
+
};
|
|
63
72
|
/** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
|
|
64
|
-
function validateRoleMcpServer(path, name, value) {
|
|
73
|
+
function validateRoleMcpServer(path, name, value, codemode) {
|
|
65
74
|
const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
|
|
66
75
|
if (!SERVER_NAME.test(name))
|
|
67
76
|
fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
|
|
@@ -73,7 +82,7 @@ function validateRoleMcpServer(path, name, value) {
|
|
|
73
82
|
const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
|
|
74
83
|
if (stdio === http)
|
|
75
84
|
fail("needs either a \"command\" (stdio) or a \"url\" (http or streamable-http).");
|
|
76
|
-
for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS) })) {
|
|
85
|
+
for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS), ...(codemode ? EXPOSURE_CHECKS : {}) })) {
|
|
77
86
|
const fieldValue = field.split(".").reduce((parent, key) => (isRecord(parent) ? parent[key] : undefined), value);
|
|
78
87
|
if (fieldValue !== undefined && !valid(fieldValue, value))
|
|
79
88
|
fail(`field "${field}" must be ${shape}.`);
|
|
@@ -82,15 +91,25 @@ function validateRoleMcpServer(path, name, value) {
|
|
|
82
91
|
if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
|
|
83
92
|
fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
|
|
84
93
|
}
|
|
94
|
+
if (codemode) {
|
|
95
|
+
const { exposure, toolExposure } = value;
|
|
96
|
+
return {
|
|
97
|
+
...value,
|
|
98
|
+
...(typeof exposure === "string" ? { exposure: resolveExposure(exposure) } : {}),
|
|
99
|
+
...(isRecord(toolExposure) ? { toolExposure: Object.fromEntries(Object.entries(toolExposure).map(([tool, entry]) => [tool, resolveExposure(entry)])) } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
85
102
|
const { toolExposure: _toolExposure, ...config } = value;
|
|
86
103
|
return { ...config, exposure: "direct" };
|
|
87
104
|
}
|
|
88
105
|
/**
|
|
89
106
|
* Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
|
|
90
|
-
* shape; the project `.pi/mcp.json` is ignored. A
|
|
91
|
-
*
|
|
107
|
+
* shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
|
|
108
|
+
* server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
|
|
109
|
+
* cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
|
|
110
|
+
* never activated by MCP configuration: the Role tool policy owns the active set.
|
|
92
111
|
*/
|
|
93
|
-
export function loadRoleMcpConfig(agentDir, allowlist) {
|
|
112
|
+
export function loadRoleMcpConfig(agentDir, allowlist, options = { codemode: false }) {
|
|
94
113
|
const path = join(agentDir, "mcp.json");
|
|
95
114
|
let parsed = {};
|
|
96
115
|
try {
|
|
@@ -114,7 +133,7 @@ export function loadRoleMcpConfig(agentDir, allowlist) {
|
|
|
114
133
|
if (clash)
|
|
115
134
|
throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
|
|
116
135
|
namespaces.set(mcpNamespace(name), name);
|
|
117
|
-
return { name, config: validateRoleMcpServer(path, name, configured[name]), source: path, scope: "global" };
|
|
136
|
+
return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode), source: path, scope: "global" };
|
|
118
137
|
});
|
|
119
138
|
return { servers, autoEnableCodemode: false, errors: [] };
|
|
120
139
|
}
|
package/dist/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"
|
|
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
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const
|
|
2100
|
-
if (!
|
|
2101
|
-
throw new Error("
|
|
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
|
-
|
|
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
|
}
|
package/docs/orchestration.md
CHANGED
|
@@ -139,7 +139,7 @@ Every child launch:
|
|
|
139
139
|
5. excludes Main-only delegation/recovery tools and `ask_question`; and
|
|
140
140
|
6. prepends the delegated-subagent identity contract.
|
|
141
141
|
|
|
142
|
-
An empty `tools` list adds no base tools, though selected extension tools can still activate. Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider; `builtin:<name>` entries load Pi built-in extensions. `mcps` names servers from the global `~/.pi/agent/mcp.json` in Pi's native format
|
|
142
|
+
An empty `tools` list adds no base tools, though selected extension tools can still activate; extension tools with `codemode` or `deferred` exposure stay callable from scripts and are declared only when `tools` names them. Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider; `builtin:<name>` entries load Pi built-in extensions. A Role activates `codemode` by listing `builtin:codemode` in `extensions` and `codemode` in `tools`. `mcps` names servers from the global `~/.pi/agent/mcp.json` in Pi's native format; their tools keep their configured exposure in a codemode Role and load with direct exposure otherwise. Omitted or empty `mcps` denies MCP access. Loading `pi-mcp-adapter` directly is rejected because it bypasses the allowlist.
|
|
143
143
|
|
|
144
144
|
Isolated Herdr Role launches receive the same turn, token, and child-runtime budget metadata as ephemeral launches. Retained workers therefore keep one non-refilling session budget across follow-ups rather than receiving a new request budget.
|
|
145
145
|
|
package/extensions/admission.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { realpath } from "node:fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { Role } from "../dist/index.js";
|
|
4
4
|
|
|
5
|
+
// `codemode` mutates nothing itself: its sandbox reaches a checkout only through nested tool calls,
|
|
6
|
+
// which are admitted by their own names here and limited to the Role's active tools in children.
|
|
5
7
|
const READ_ONLY_TOOLS = new Set([
|
|
6
|
-
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status",
|
|
8
|
+
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status", "codemode",
|
|
7
9
|
]);
|
|
8
10
|
|
|
9
11
|
export function roleCanWrite(role: Role): boolean {
|
|
@@ -26,37 +28,62 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
26
28
|
/**
|
|
27
29
|
* Coordinate Pi-owned calls that can mutate one checkout. This is admission,
|
|
28
30
|
* not an OS sandbox: external processes and trusted extension lifecycle code
|
|
29
|
-
* remain outside interception.
|
|
31
|
+
* remain outside interception. A model-issued call owns the checkout for the
|
|
32
|
+
* calls it makes through `ctx.executeTool()` (codemode scripts), so its nested
|
|
33
|
+
* writes pass while independent writers wait until it settles.
|
|
30
34
|
*/
|
|
31
35
|
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void {
|
|
32
36
|
const ownerByCheckout = new Map<string, string>();
|
|
33
|
-
const
|
|
37
|
+
const checkoutByRoot = new Map<string, string>();
|
|
38
|
+
const writersByRoot = new Map<string, Set<string>>();
|
|
39
|
+
/** Model-issued ancestor of each in-flight call; Pi names nested calls `<parent id>/<n>`. */
|
|
40
|
+
const rootByCall = new Map<string, string>();
|
|
34
41
|
const release = (toolCallId: string) => {
|
|
35
|
-
const
|
|
42
|
+
const root = rootByCall.get(toolCallId);
|
|
43
|
+
if (root === undefined) return;
|
|
44
|
+
rootByCall.delete(toolCallId);
|
|
45
|
+
const writers = writersByRoot.get(root);
|
|
46
|
+
writers?.delete(toolCallId);
|
|
47
|
+
if (rootByCall.has(root) || writers?.size) return;
|
|
48
|
+
writersByRoot.delete(root);
|
|
49
|
+
const key = checkoutByRoot.get(root);
|
|
36
50
|
if (!key) return;
|
|
37
|
-
|
|
38
|
-
if (ownerByCheckout.get(key) ===
|
|
51
|
+
checkoutByRoot.delete(root);
|
|
52
|
+
if (ownerByCheckout.get(key) === root) ownerByCheckout.delete(key);
|
|
39
53
|
};
|
|
40
54
|
pi.on("tool_call", async (event, ctx) => {
|
|
55
|
+
const root = event.parentToolCallId === undefined
|
|
56
|
+
? event.toolCallId
|
|
57
|
+
: rootByCall.get(event.parentToolCallId) ?? event.parentToolCallId;
|
|
58
|
+
rootByCall.set(event.toolCallId, root);
|
|
41
59
|
const potentiallyWriting = event.toolName === "delegate_task"
|
|
42
60
|
? directCanWrite(event.input)
|
|
43
61
|
: !READ_ONLY_TOOLS.has(event.toolName);
|
|
44
62
|
if (!potentiallyWriting) return;
|
|
45
63
|
const key = await checkoutKey(pi, ctx);
|
|
64
|
+
// A codemode result can settle while an unawaited nested call is still resolving its checkout.
|
|
65
|
+
if (root !== event.toolCallId && !rootByCall.has(root)) {
|
|
66
|
+
return { block: true, reason: `Parent call ${root} already settled; ${event.toolCallId} is not admitted.` };
|
|
67
|
+
}
|
|
46
68
|
const owner = ownerByCheckout.get(key);
|
|
47
|
-
if (owner && owner !==
|
|
69
|
+
if (owner && owner !== root) {
|
|
48
70
|
return {
|
|
49
71
|
block: true,
|
|
50
72
|
reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
|
-
ownerByCheckout.set(key,
|
|
54
|
-
|
|
75
|
+
ownerByCheckout.set(key, root);
|
|
76
|
+
checkoutByRoot.set(root, key);
|
|
77
|
+
const writers = writersByRoot.get(root) ?? new Set<string>();
|
|
78
|
+
writers.add(event.toolCallId);
|
|
79
|
+
writersByRoot.set(root, writers);
|
|
55
80
|
});
|
|
56
81
|
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
57
82
|
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
58
83
|
pi.on("session_shutdown", () => {
|
|
59
84
|
ownerByCheckout.clear();
|
|
60
|
-
|
|
85
|
+
checkoutByRoot.clear();
|
|
86
|
+
writersByRoot.clear();
|
|
87
|
+
rootByCall.clear();
|
|
61
88
|
});
|
|
62
89
|
}
|
package/extensions/isolated.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
4
|
import type { EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
parseIdOnly,
|
|
27
28
|
parseResumeRequest,
|
|
28
29
|
sameIdentity,
|
|
30
|
+
StatusOutputSchema,
|
|
29
31
|
type CheckBatchEvidence,
|
|
30
32
|
type ModelClass,
|
|
31
33
|
type ReviewEvidence,
|
|
@@ -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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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);
|
package/extensions/role-mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createMcpExtension, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadRoleMcpConfig, roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG } from "@henryqw/pi-subagent";
|
|
2
|
+
import { loadRoleMcpConfig, roleActivatesCodemode, roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG, roleToolPolicyFromArgv } from "@henryqw/pi-subagent";
|
|
3
3
|
|
|
4
4
|
export default function roleMcp(pi: ExtensionAPI): void | Promise<void> {
|
|
5
5
|
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
@@ -7,6 +7,8 @@ export default function roleMcp(pi: ExtensionAPI): void | Promise<void> {
|
|
|
7
7
|
type: "string",
|
|
8
8
|
});
|
|
9
9
|
// Pi binds extension flag values after factories load. Throwing here exits the child non-zero.
|
|
10
|
-
const config = loadRoleMcpConfig(getAgentDir(), roleMcpAllowlistFromArgv(process.argv)
|
|
10
|
+
const config = loadRoleMcpConfig(getAgentDir(), roleMcpAllowlistFromArgv(process.argv), {
|
|
11
|
+
codemode: roleActivatesCodemode(roleToolPolicyFromArgv(process.argv)),
|
|
12
|
+
});
|
|
11
13
|
return createMcpExtension({ loadConfig: () => config })(pi);
|
|
12
14
|
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -4,12 +4,16 @@ import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-codin
|
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
6
|
EXECUTION_BUDGET_ENV,
|
|
7
|
+
parseRoleToolPolicy,
|
|
7
8
|
PI_SUBAGENT_PROCESS_LEASE,
|
|
8
9
|
ROLE_TOOL_POLICY_FLAG,
|
|
9
10
|
type EphemeralSubagentExecutionBudget,
|
|
10
11
|
} from "@henryqw/pi-subagent";
|
|
11
12
|
|
|
12
13
|
const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
|
|
14
|
+
// Pi does not declare these exposures on registration: `codemode`/`deferred` tools stay callable from
|
|
15
|
+
// scripts, `hidden` ones are unreachable. The Role policy declares them only when `tools` names them.
|
|
16
|
+
const undeclaredExposures: ReadonlySet<string> = new Set(["codemode", "deferred", "hidden"]);
|
|
13
17
|
const WARNING_RATIO = 0.8;
|
|
14
18
|
const WARNING_MESSAGE_TYPE = "pi-subagent-execution-budget";
|
|
15
19
|
const PROCESS_LEASE_ERROR = `${PI_SUBAGENT_PROCESS_LEASE} must be a nonempty NUL/newline-free absolute path naming a regular non-symlink file owned by the current uid with mode 0600.`;
|
|
@@ -33,20 +37,6 @@ function validateProcessLease(path: string): void {
|
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
function configuredTools(value: unknown): string[] {
|
|
37
|
-
if (typeof value !== "string") throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
38
|
-
let parsed: unknown;
|
|
39
|
-
try {
|
|
40
|
-
parsed = JSON.parse(value);
|
|
41
|
-
} catch {
|
|
42
|
-
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
43
|
-
}
|
|
44
|
-
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
|
|
45
|
-
throw new Error(`${ROLE_TOOL_POLICY_FLAG} must be JSON tool names.`);
|
|
46
|
-
}
|
|
47
|
-
return [...new Set(parsed.map((name) => name.trim()))];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
40
|
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
51
41
|
if (value === undefined) return;
|
|
52
42
|
let parsed: unknown;
|
|
@@ -107,11 +97,11 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
107
97
|
const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
|
|
108
98
|
let handoffSent = false;
|
|
109
99
|
pi.on("session_start", () => {
|
|
110
|
-
const selected =
|
|
100
|
+
const selected = parseRoleToolPolicy(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
111
101
|
const allTools = pi.getAllTools();
|
|
112
102
|
const registeredTools = new Set(allTools.map((tool) => tool.name));
|
|
113
103
|
const extensionTools = allTools
|
|
114
|
-
.filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source))
|
|
104
|
+
.filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source) && !undeclaredExposures.has(tool.exposure))
|
|
115
105
|
.map((tool) => tool.name);
|
|
116
106
|
pi.setActiveTools([...new Set([...selected, ...extensionTools])].filter((name) => !childExcludedTools.has(name)));
|
|
117
107
|
const activeTools = new Set(pi.getActiveTools().filter((name) => registeredTools.has(name) && !childExcludedTools.has(name)));
|
package/extensions/subagent.ts
CHANGED
|
@@ -461,6 +461,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
461
461
|
`For delegate_task, ${MODEL_CLASS_GUIDANCE} A direct model replaces only the selected route's model; its thinking level stays unchanged.`,
|
|
462
462
|
],
|
|
463
463
|
parameters: DelegateTaskParameters,
|
|
464
|
+
// Delegation needs Main's visible reasoning; codemode scripts cannot start it.
|
|
465
|
+
exposure: "model-only",
|
|
464
466
|
prepareArguments(args) {
|
|
465
467
|
try {
|
|
466
468
|
const parsed = parseDelegateTask(args);
|