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

|
|
41
41
|
|
|
42
|
+
`delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only: Pi's `codemode` scripts cannot call them, because delegation and guarded state transitions need Main's visible reasoning. `subagent_status` stays callable from scripts. In Main, one model-issued call at a time may write to a checkout through Pi tools; a `codemode` script owns the checkout from its first nested write until the script ends, so its own nested calls pass while an independent writer is blocked with a reason and retries after it settles. Reads never wait.
|
|
43
|
+
|
|
42
44
|
The coordinator is extension code running in Main's Pi process, not another agent. It persists the request, launches the Herdr subagent, checks its committed work, and reports saved results; Main chooses what to stage and promote. If a worker fails, the coordinator reports attention after the wave settles, not necessarily immediately while siblings work. If Main's Pi process stops, no follow-up is guaranteed: restart and run `/subagent recover` in the canonical repository to classify orphaned interruptions and send Main one recovery report. Main then uses `subagent_status` for exact evidence and deliberate next actions. A stale `running` state does not prove a worker is still active; interrupted or ambiguous actions are never replayed automatically.
|
|
43
45
|
|
|
44
46
|
In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` marks direct or isolated work, and `[S]` or `[I3]` identifies the Role initial (plus the isolated model class: `1` fast, `2` balanced, `3` frontier, `*` fav). Direct rows show the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Isolated rows show the recorded task state and any retained workspace; an aborted request with visible tasks has its own `request aborted` row and does not change their recorded statuses. A committed worker without a checked candidate can still show attention rather than readiness. Status glyphs use the active TUI theme, while the text still names the status without color. Herdr does not currently provide reliable live model, thinking, or token readings for those rows. Attention appears before ordinary work, with a `… N more` summary when direct rows overflow. A ready isolated candidate is **not promoted** to Main. Choose **Refresh** in `/subagent` to reload both the menu and isolated widget from saved state; it does not recover or change a request. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
|
|
@@ -75,7 +77,7 @@ Parallel: { mode: "direct", tasks: [{ role, name, task, ... }] }
|
|
|
75
77
|
Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
Only Roles whose declared base tools are known read-only may run direct. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
80
|
+
Only Roles whose declared base tools are known read-only may run direct. `codemode` counts as read-only because its scripts can only call the tools the Role already activates plus trusted extension and MCP tools. Configured extensions and MCP servers are trusted and may provide additional tools; use isolated mode when a task needs write-capable base tools or a checked changeset. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
|
|
79
81
|
|
|
80
82
|
The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. A recorded tab may no longer be running; locally observed work is labelled separately.
|
|
81
83
|
|
|
@@ -175,7 +177,7 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
|
|
|
175
177
|
| --- | --- |
|
|
176
178
|
| `name`, `description` | Required non-empty text without terminal control characters |
|
|
177
179
|
| `modelClass` | Optional `fast`, `balanced`, `frontier`, or `fav` default |
|
|
178
|
-
| `tools` | Required array of base tool names; `[]` selects none |
|
|
180
|
+
| `tools` | Required array of base tool names; `[]` selects none. Name `codemode` here to activate it (see below) |
|
|
179
181
|
| `extensions` | Required array of trusted absolute paths, supported package sources, or Pi built-in extensions such as `builtin:codemode` |
|
|
180
182
|
| `skills` | Required array of effective Pi Skill names |
|
|
181
183
|
| `mcps` | Optional exact server names from `~/.pi/agent/mcp.json` in Pi's native `mcpServers` format; omitted or `[]` denies MCP access |
|
|
@@ -183,7 +185,13 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
|
|
|
183
185
|
|
|
184
186
|
Roles describe responsibility and capabilities. They do not choose isolation; each request does. A same-named user Role overrides a built-in Role. The package ships `implementer`, `reviewer`, and `scout`.
|
|
185
187
|
|
|
186
|
-
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`.
|
|
188
|
+
Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. Extension tools registered with `codemode` or `deferred` exposure stay callable from scripts but are declared to the model only when `tools` names them.
|
|
189
|
+
|
|
190
|
+
Checkout admission keeps a script's writer ownership until the script and all its admitted nested writes finish, including writes still settling after cancellation. Independent writers stay blocked during that time; nested writes that have not passed admission before the script ends are rejected.
|
|
191
|
+
|
|
192
|
+
To let a Role run `codemode` scripts, list `builtin:codemode` in `extensions` and `codemode` in `tools`; both are required, because the built-in registers the tool inactive and the Role tool policy activates it. `codemode` alone in `tools` fails the launch as an unavailable tool. Pi's `codemode.mode` and `codemode.inlineBudget` settings apply in the child unchanged, so the Role never forces codemode-only operation, and MCP configuration never activates `codemode` in a child. Scripts can call only the Role's active tools plus `codemode`/`deferred` extension and MCP tools; inactive base tools such as `bash` stay unreachable.
|
|
193
|
+
|
|
194
|
+
The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only. In a Role that activates `codemode`, each selected server keeps its configured `exposure` and `toolExposure` (Pi's default `codemode` exposure makes tools callable from scripts without declaring them, `direct` declares them, `deferred` tools stay reachable from scripts, `hidden` tools stay unreachable), and an invalid exposure value fails the launch. In every other Role, selected servers are forced to `direct` exposure and `toolExposure` is ignored, because that child cannot reach undeclared tools. Declared tools appear to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
|
|
187
195
|
|
|
188
196
|
## API
|
|
189
197
|
|
package/dist/direct-herdr.js
CHANGED
|
@@ -243,11 +243,6 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
243
243
|
const waited = await herdr.exec(args, options(signal));
|
|
244
244
|
if (waited.code !== 0 || waited.killed) {
|
|
245
245
|
if (!waited.killed && hasHerdrErrorCode(waited, "timeout")) {
|
|
246
|
-
if (current === "idle") {
|
|
247
|
-
const answer = await maybeAnswer();
|
|
248
|
-
if (answer !== undefined)
|
|
249
|
-
return answer;
|
|
250
|
-
}
|
|
251
246
|
continue;
|
|
252
247
|
}
|
|
253
248
|
throw new Error(herdrCommandFailure(args, waited));
|
package/dist/ephemeral.js
CHANGED
|
@@ -429,9 +429,8 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
429
429
|
let spawnError;
|
|
430
430
|
let protocolError;
|
|
431
431
|
let aborted = false;
|
|
432
|
-
let
|
|
432
|
+
let limit;
|
|
433
433
|
let tokenBudget = "within";
|
|
434
|
-
const tokenLimited = () => tokenBudget === "limited";
|
|
435
434
|
let startedTurns = 0;
|
|
436
435
|
let lastEventAt = startedAt;
|
|
437
436
|
let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline ?? startedAt + timeoutPolicy.idleMs);
|
|
@@ -514,15 +513,12 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
514
513
|
: `Subagent timed out after ${formatDuration(timeoutPolicy.idleMs)} without a recognized Pi event.`;
|
|
515
514
|
reject(new EphemeralSubagentError("timeout", message, new Error(message), accumulatedUsage()));
|
|
516
515
|
}
|
|
517
|
-
else if (
|
|
518
|
-
const summary =
|
|
516
|
+
else if (limit) {
|
|
517
|
+
const summary = limit === "turn_limit"
|
|
518
|
+
? `Subagent reached its maximum turn limit of ${budget.maxTurns}.`
|
|
519
|
+
: `Subagent reached its maximum token limit of ${budget.maxTokens}.`;
|
|
519
520
|
const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
|
|
520
|
-
reject(new EphemeralSubagentError(
|
|
521
|
-
}
|
|
522
|
-
else if (tokenLimited()) {
|
|
523
|
-
const summary = `Subagent reached its maximum token limit of ${budget.maxTokens}.`;
|
|
524
|
-
const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
|
|
525
|
-
reject(new EphemeralSubagentError("token_limit", message, new Error(message), accumulatedUsage(), output));
|
|
521
|
+
reject(new EphemeralSubagentError(limit, message, new Error(message), accumulatedUsage(), output));
|
|
526
522
|
}
|
|
527
523
|
else if (protocolError) {
|
|
528
524
|
reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
|
|
@@ -585,7 +581,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
585
581
|
deadlineTimer.unref();
|
|
586
582
|
};
|
|
587
583
|
const observeEvent = () => {
|
|
588
|
-
if (callbackFailure || aborted || timedOutAfterMs !== undefined ||
|
|
584
|
+
if (callbackFailure || aborted || timedOutAfterMs !== undefined || limit || childExited)
|
|
589
585
|
return;
|
|
590
586
|
const now = Date.now();
|
|
591
587
|
if (now >= deadline) {
|
|
@@ -609,17 +605,15 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
609
605
|
case "final_turn":
|
|
610
606
|
if (event === "turn_start") {
|
|
611
607
|
if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
|
|
612
|
-
|
|
608
|
+
limit = "token_limit";
|
|
613
609
|
stop(true);
|
|
614
610
|
return true;
|
|
615
611
|
}
|
|
616
612
|
return false;
|
|
617
|
-
case "limited":
|
|
618
|
-
return true;
|
|
619
613
|
}
|
|
620
614
|
};
|
|
621
615
|
const processLine = (line) => {
|
|
622
|
-
if (
|
|
616
|
+
if (limit || !line.trim())
|
|
623
617
|
return;
|
|
624
618
|
let event;
|
|
625
619
|
try {
|
|
@@ -637,7 +631,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
637
631
|
if (record.type === "turn_start") {
|
|
638
632
|
if (++startedTurns > budget.maxTurns) {
|
|
639
633
|
if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
|
|
640
|
-
|
|
634
|
+
limit = "turn_limit";
|
|
641
635
|
stop(true);
|
|
642
636
|
return;
|
|
643
637
|
}
|
|
@@ -775,7 +769,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
775
769
|
};
|
|
776
770
|
onStdoutData = (data) => {
|
|
777
771
|
armPostExitIdleDeadline();
|
|
778
|
-
if (callbackFailure || protocolError ||
|
|
772
|
+
if (callbackFailure || protocolError || limit)
|
|
779
773
|
return;
|
|
780
774
|
let offset = 0;
|
|
781
775
|
while (offset < data.length) {
|
|
@@ -816,7 +810,7 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
816
810
|
invokeCallback("onActivity", input.onActivity, activity);
|
|
817
811
|
}
|
|
818
812
|
}
|
|
819
|
-
if (callbackFailure ||
|
|
813
|
+
if (callbackFailure || limit)
|
|
820
814
|
return;
|
|
821
815
|
lineParts = [];
|
|
822
816
|
lineBytes = 0;
|
|
@@ -856,13 +850,13 @@ async function runPi(prepared, input, budget, invocation) {
|
|
|
856
850
|
killTimer.unref();
|
|
857
851
|
}
|
|
858
852
|
const abort = () => {
|
|
859
|
-
if (timedOutAfterMs !== undefined ||
|
|
853
|
+
if (timedOutAfterMs !== undefined || limit || childExited)
|
|
860
854
|
return;
|
|
861
855
|
aborted = true;
|
|
862
856
|
stop();
|
|
863
857
|
};
|
|
864
858
|
function timeout(afterMs, reason) {
|
|
865
|
-
if (timedOutAfterMs !== undefined ||
|
|
859
|
+
if (timedOutAfterMs !== undefined || limit || childExited)
|
|
866
860
|
return;
|
|
867
861
|
if (reason === "maximum") {
|
|
868
862
|
if (!aborted) {
|
package/dist/git-process.js
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runProcess } from "./process.js";
|
|
2
2
|
const GIT_TIMEOUT_MS = 30_000;
|
|
3
|
-
const GIT_OUTPUT_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
4
3
|
const GIT_DIAGNOSTIC_LIMIT = 200;
|
|
5
4
|
/** Run one ordinary Git command with bounded output; transport failures never throw. */
|
|
6
5
|
export const runGit = async (args, cwd, signal) => {
|
|
7
6
|
try {
|
|
8
|
-
const result = await
|
|
9
|
-
cwd,
|
|
10
|
-
signal,
|
|
11
|
-
timeoutMs: GIT_TIMEOUT_MS,
|
|
12
|
-
stdoutLimitBytes: GIT_OUTPUT_LIMIT_BYTES,
|
|
13
|
-
stderrLimitBytes: GIT_OUTPUT_LIMIT_BYTES,
|
|
14
|
-
});
|
|
7
|
+
const result = await runProcess("git", ["--no-pager", ...args], { cwd, signal, timeoutMs: GIT_TIMEOUT_MS });
|
|
15
8
|
if (result.killed) {
|
|
16
9
|
return {
|
|
17
10
|
code: -1,
|
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. */
|
|
@@ -342,12 +374,11 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
342
374
|
const roleName = parseRoleName(input.role);
|
|
343
375
|
if (input.modelClass === undefined)
|
|
344
376
|
throw new Error("Configured Role launch requires an explicit modelClass.");
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
377
|
+
const role = loadRoles().find((role) => role.name === roleName);
|
|
378
|
+
if (!role)
|
|
347
379
|
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
348
|
-
const role = matches[0];
|
|
349
380
|
if (role.mcps?.length)
|
|
350
|
-
loadRoleMcpConfig(getAgentDir(), role.mcps);
|
|
381
|
+
loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
|
|
351
382
|
const resources = await resolveRolePackageResources(role, ctx);
|
|
352
383
|
const effectiveRole = { ...role, extensions: resources.extensions };
|
|
353
384
|
const namedSkills = resolveRoleSkills(pi, effectiveRole);
|
package/dist/launch-runtime.js
CHANGED
|
@@ -125,11 +125,7 @@ export class RoleLaunchRuntime {
|
|
|
125
125
|
}
|
|
126
126
|
async prepareLaunch(role, modelClass, context) {
|
|
127
127
|
abortIfNeeded(context.signal);
|
|
128
|
-
const
|
|
129
|
-
const prepared = await resolveConfiguredRoleLaunch(this.options.pi, this.options.context(), input);
|
|
130
|
-
if (prepared.missingSkills.length) {
|
|
131
|
-
throw new Error(`Role ${role} requires missing Skills: ${prepared.missingSkills.join(", ")}.`);
|
|
132
|
-
}
|
|
128
|
+
const prepared = await resolveConfiguredRoleLaunch(this.options.pi, this.options.context(), { role, modelClass });
|
|
133
129
|
return Object.freeze({
|
|
134
130
|
launch: Object.freeze({
|
|
135
131
|
role: prepared.role,
|
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/process.d.ts
CHANGED
package/dist/runner.js
CHANGED
|
@@ -131,12 +131,12 @@ class FollowupControl {
|
|
|
131
131
|
if (this.queue.length >= MAX_QUEUED_FOLLOWUPS)
|
|
132
132
|
throw new Error(`A task may queue at most ${MAX_QUEUED_FOLLOWUPS} follow-ups.`);
|
|
133
133
|
this.validateFollowup(instruction);
|
|
134
|
-
this.queue.push(
|
|
134
|
+
this.queue.push(instruction);
|
|
135
135
|
}
|
|
136
136
|
drainPending() {
|
|
137
137
|
if (this.sealed)
|
|
138
138
|
throw new Error("This task candidate is already sealed for integration.");
|
|
139
|
-
return this.queue.splice(0)
|
|
139
|
+
return this.queue.splice(0);
|
|
140
140
|
}
|
|
141
141
|
takeQueuedFollowup() {
|
|
142
142
|
if (this.sealed)
|
|
@@ -578,7 +578,7 @@ export class IsolatedRunner {
|
|
|
578
578
|
}
|
|
579
579
|
drainFollowups(root, requestId, taskId) {
|
|
580
580
|
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
581
|
-
if (!active ||
|
|
581
|
+
if (!active || active.task.status !== "working" || active.control.isSealed) {
|
|
582
582
|
throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
|
|
583
583
|
}
|
|
584
584
|
return active.control.drainPending();
|
|
@@ -589,7 +589,7 @@ export class IsolatedRunner {
|
|
|
589
589
|
throw new Error("Follow-up instruction must be non-empty exact text of at most 32000 characters.");
|
|
590
590
|
}
|
|
591
591
|
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
592
|
-
if (!active ||
|
|
592
|
+
if (!active || active.task.status !== "working" || active.control.isSealed) {
|
|
593
593
|
throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
|
|
594
594
|
}
|
|
595
595
|
if (latestAttempt(active.task).prompts.length + active.control.pendingFollowups >= MAX_WORKER_PROMPTS) {
|
|
@@ -1093,7 +1093,8 @@ export class IsolatedRunner {
|
|
|
1093
1093
|
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
1094
1094
|
const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
|
|
1095
1095
|
const state = handle.state;
|
|
1096
|
-
|
|
1096
|
+
// Loading state has already normalized optional request fields, including finalChecks.
|
|
1097
|
+
const request = state.request;
|
|
1097
1098
|
const generation = state.integration.generations.at(-1);
|
|
1098
1099
|
if (action.action === "release") {
|
|
1099
1100
|
const scope = new ProductiveScope(outerSignal);
|
|
@@ -2300,7 +2301,7 @@ export class IsolatedRunner {
|
|
|
2300
2301
|
const queuedFollowup = control.takeQueuedFollowupOrSeal();
|
|
2301
2302
|
if (queuedFollowup) {
|
|
2302
2303
|
kind = "followup";
|
|
2303
|
-
instruction = queuedFollowup
|
|
2304
|
+
instruction = queuedFollowup;
|
|
2304
2305
|
task.status = "working";
|
|
2305
2306
|
continue;
|
|
2306
2307
|
}
|
|
@@ -2336,7 +2337,7 @@ export class IsolatedRunner {
|
|
|
2336
2337
|
const queuedFollowup = control.takeQueuedFollowup();
|
|
2337
2338
|
if (queuedFollowup) {
|
|
2338
2339
|
kind = "followup";
|
|
2339
|
-
instruction = queuedFollowup
|
|
2340
|
+
instruction = queuedFollowup;
|
|
2340
2341
|
task.status = "working";
|
|
2341
2342
|
continue;
|
|
2342
2343
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -2320,6 +2320,116 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
2320
2320
|
createdAt: Type.TInteger;
|
|
2321
2321
|
updatedAt: Type.TInteger;
|
|
2322
2322
|
}>;
|
|
2323
|
+
/**
|
|
2324
|
+
* `subagent_status` structured output: the bounded public projection that Main reads, never raw
|
|
2325
|
+
* run state. Objects stay open so bounded evidence fields pass; the identities and statuses that
|
|
2326
|
+
* drive stage, integrate, resume, and release decisions are typed.
|
|
2327
|
+
*/
|
|
2328
|
+
export declare const StatusOutputSchema: Type.TObject<{
|
|
2329
|
+
state: Type.TObject<{
|
|
2330
|
+
version: Type.TLiteral<5>;
|
|
2331
|
+
id: Type.TString;
|
|
2332
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"needs_attention">, Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
2333
|
+
accepted: Type.TBoolean;
|
|
2334
|
+
main: Type.TObject<{
|
|
2335
|
+
branch: Type.TString;
|
|
2336
|
+
head: Type.TString;
|
|
2337
|
+
index: Type.TString;
|
|
2338
|
+
tree: Type.TString;
|
|
2339
|
+
}>;
|
|
2340
|
+
tasks: Type.TArray<Type.TObject<{
|
|
2341
|
+
taskId: Type.TString;
|
|
2342
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"allocating">, Type.TLiteral<"working">, Type.TLiteral<"ready_to_integrate">, Type.TLiteral<"integrating">, Type.TLiteral<"cleanup">, Type.TLiteral<"completed">, Type.TLiteral<"rejected">, Type.TLiteral<"needs_attention">, Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">, Type.TLiteral<"needs_attention">]>;
|
|
2343
|
+
}>>;
|
|
2344
|
+
final: Type.TObject<{
|
|
2345
|
+
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"interrupted">, Type.TLiteral<"passed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">]>;
|
|
2346
|
+
}>;
|
|
2347
|
+
integration: Type.TObject<{
|
|
2348
|
+
candidates: Type.TArray<Type.TObject<{
|
|
2349
|
+
taskId: Type.TString;
|
|
2350
|
+
attempt: Type.TInteger;
|
|
2351
|
+
base: Type.TObject<{
|
|
2352
|
+
branch: Type.TString;
|
|
2353
|
+
head: Type.TString;
|
|
2354
|
+
index: Type.TString;
|
|
2355
|
+
tree: Type.TString;
|
|
2356
|
+
}>;
|
|
2357
|
+
tip: Type.TObject<{
|
|
2358
|
+
branch: Type.TString;
|
|
2359
|
+
head: Type.TString;
|
|
2360
|
+
index: Type.TString;
|
|
2361
|
+
tree: Type.TString;
|
|
2362
|
+
}>;
|
|
2363
|
+
checked: Type.TBoolean;
|
|
2364
|
+
worker: Type.TUnion<[Type.TLiteral<"retained">, Type.TLiteral<"release_pending">, Type.TLiteral<"released">]>;
|
|
2365
|
+
decision: Type.TOptional<Type.TLiteral<"rejected">>;
|
|
2366
|
+
}>>;
|
|
2367
|
+
generations: Type.TArray<Type.TObject<{
|
|
2368
|
+
number: Type.TInteger;
|
|
2369
|
+
status: Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validating">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promoting">, Type.TLiteral<"promoted">, Type.TLiteral<"promotion_unknown">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"superseded">]>;
|
|
2370
|
+
integrationBase: Type.TObject<{
|
|
2371
|
+
branch: Type.TString;
|
|
2372
|
+
head: Type.TString;
|
|
2373
|
+
index: Type.TString;
|
|
2374
|
+
tree: Type.TString;
|
|
2375
|
+
}>;
|
|
2376
|
+
combinedTip: Type.TOptional<Type.TObject<{
|
|
2377
|
+
branch: Type.TString;
|
|
2378
|
+
head: Type.TString;
|
|
2379
|
+
index: Type.TString;
|
|
2380
|
+
tree: Type.TString;
|
|
2381
|
+
}>>;
|
|
2382
|
+
retainedWorktree: Type.TOptional<Type.TObject<{
|
|
2383
|
+
path: Type.TString;
|
|
2384
|
+
branch: Type.TString;
|
|
2385
|
+
}>>;
|
|
2386
|
+
}>>;
|
|
2387
|
+
}>;
|
|
2388
|
+
needsAttention: Type.TOptional<Type.TObject<{
|
|
2389
|
+
scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
|
|
2390
|
+
taskId: Type.TOptional<Type.TString>;
|
|
2391
|
+
failure: Type.TOptional<Type.TString>;
|
|
2392
|
+
}>>;
|
|
2393
|
+
createdAt: Type.TInteger;
|
|
2394
|
+
updatedAt: Type.TInteger;
|
|
2395
|
+
}>;
|
|
2396
|
+
main: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
2397
|
+
status: Type.TUnion<[Type.TLiteral<"current">, Type.TLiteral<"drifted">]>;
|
|
2398
|
+
expected: Type.TObject<{
|
|
2399
|
+
branch: Type.TString;
|
|
2400
|
+
head: Type.TString;
|
|
2401
|
+
index: Type.TString;
|
|
2402
|
+
tree: Type.TString;
|
|
2403
|
+
}>;
|
|
2404
|
+
actual: Type.TObject<{
|
|
2405
|
+
branch: Type.TString;
|
|
2406
|
+
head: Type.TString;
|
|
2407
|
+
index: Type.TString;
|
|
2408
|
+
tree: Type.TString;
|
|
2409
|
+
}>;
|
|
2410
|
+
}>, Type.TObject<{
|
|
2411
|
+
status: Type.TLiteral<"unavailable">;
|
|
2412
|
+
expected: Type.TObject<{
|
|
2413
|
+
branch: Type.TString;
|
|
2414
|
+
head: Type.TString;
|
|
2415
|
+
index: Type.TString;
|
|
2416
|
+
tree: Type.TString;
|
|
2417
|
+
}>;
|
|
2418
|
+
failure: Type.TString;
|
|
2419
|
+
}>]>>;
|
|
2420
|
+
continuation: Type.TOptional<Type.TUnion<[Type.TObject<{
|
|
2421
|
+
id: Type.TString;
|
|
2422
|
+
action: Type.TLiteral<"retry">;
|
|
2423
|
+
taskId: Type.TString;
|
|
2424
|
+
}>, Type.TObject<{
|
|
2425
|
+
id: Type.TString;
|
|
2426
|
+
action: Type.TLiteral<"verify">;
|
|
2427
|
+
taskId: Type.TString;
|
|
2428
|
+
}>, Type.TObject<{
|
|
2429
|
+
id: Type.TString;
|
|
2430
|
+
action: Type.TLiteral<"finalize">;
|
|
2431
|
+
}>]>>;
|
|
2432
|
+
}>;
|
|
2323
2433
|
export declare function taskDependencies(task: TaskRequest): string[];
|
|
2324
2434
|
export declare function validateGraph(tasks: readonly TaskRequest[]): void;
|
|
2325
2435
|
export declare function parseExecuteRequest(value: unknown): ExecuteRequest;
|
package/dist/schema.js
CHANGED
|
@@ -427,6 +427,55 @@ const RunStateSchema = Type.Object({
|
|
|
427
427
|
createdAt: TimestampSchema,
|
|
428
428
|
updatedAt: TimestampSchema,
|
|
429
429
|
}, { additionalProperties: false });
|
|
430
|
+
/**
|
|
431
|
+
* `subagent_status` structured output: the bounded public projection that Main reads, never raw
|
|
432
|
+
* run state. Objects stay open so bounded evidence fields pass; the identities and statuses that
|
|
433
|
+
* drive stage, integrate, resume, and release decisions are typed.
|
|
434
|
+
*/
|
|
435
|
+
export const StatusOutputSchema = Type.Object({
|
|
436
|
+
state: Type.Object({
|
|
437
|
+
version: RunStateSchema.properties.version,
|
|
438
|
+
id: IdSchema,
|
|
439
|
+
status: RunStateSchema.properties.status,
|
|
440
|
+
accepted: Type.Boolean(),
|
|
441
|
+
main: WorkspaceSchema,
|
|
442
|
+
tasks: Type.Array(Type.Object({
|
|
443
|
+
taskId: IdSchema,
|
|
444
|
+
status: Type.Union([...ChangesetTaskStateSchema.properties.status.anyOf, ...TextTaskStateSchema.properties.status.anyOf]),
|
|
445
|
+
})),
|
|
446
|
+
final: Type.Object({ status: FinalGateSchema.properties.status }),
|
|
447
|
+
integration: Type.Object({
|
|
448
|
+
candidates: Type.Array(Type.Object({
|
|
449
|
+
taskId: IdSchema,
|
|
450
|
+
attempt: IntegrationCandidateSchema.properties.attempt,
|
|
451
|
+
base: WorkspaceSchema,
|
|
452
|
+
tip: WorkspaceSchema,
|
|
453
|
+
checked: Type.Boolean(),
|
|
454
|
+
worker: IntegrationCandidateSchema.properties.worker,
|
|
455
|
+
decision: Type.Optional(IntegrationCandidateSchema.properties.decision),
|
|
456
|
+
})),
|
|
457
|
+
generations: Type.Array(Type.Object({
|
|
458
|
+
number: IntegrationGenerationSchema.properties.number,
|
|
459
|
+
status: IntegrationGenerationSchema.properties.status,
|
|
460
|
+
integrationBase: WorkspaceSchema,
|
|
461
|
+
combinedTip: Type.Optional(WorkspaceSchema),
|
|
462
|
+
retainedWorktree: Type.Optional(Type.Object({ path: Type.String(), branch: Type.String() })),
|
|
463
|
+
})),
|
|
464
|
+
}),
|
|
465
|
+
needsAttention: Type.Optional(Type.Object({
|
|
466
|
+
scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
|
|
467
|
+
taskId: Type.Optional(IdSchema),
|
|
468
|
+
failure: Type.Optional(Type.String()),
|
|
469
|
+
})),
|
|
470
|
+
createdAt: TimestampSchema,
|
|
471
|
+
updatedAt: TimestampSchema,
|
|
472
|
+
}),
|
|
473
|
+
main: Type.Optional(Type.Union([
|
|
474
|
+
Type.Object({ status: Type.Union([Type.Literal("current"), Type.Literal("drifted")]), expected: WorkspaceSchema, actual: WorkspaceSchema }),
|
|
475
|
+
Type.Object({ status: Type.Literal("unavailable"), expected: WorkspaceSchema, failure: Type.String() }),
|
|
476
|
+
])),
|
|
477
|
+
continuation: Type.Optional(ResumeRequestSchema),
|
|
478
|
+
});
|
|
430
479
|
function normalizeCheck(check, field) {
|
|
431
480
|
return {
|
|
432
481
|
command: normalizeText(check.command, `${field}.command`),
|
package/dist/store.js
CHANGED
|
@@ -61,7 +61,7 @@ async function canonicalPlannedPath(path) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function serialize(state, maxBytes = STATE_MAX_BYTES) {
|
|
64
|
-
const validated = parseRunState(
|
|
64
|
+
const validated = parseRunState(state);
|
|
65
65
|
const contents = `${JSON.stringify(validated, null, 2)}\n`;
|
|
66
66
|
if (Buffer.byteLength(contents, "utf8") > maxBytes) {
|
|
67
67
|
throw new Error(`pi-subagent state exceeds ${maxBytes} bytes.`);
|
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";
|
|
@@ -22,11 +23,12 @@ import {
|
|
|
22
23
|
IntegrationActionParameters,
|
|
23
24
|
parseIntegrationAction,
|
|
24
25
|
parseStageRequest,
|
|
25
|
-
parseExecuteRequest,
|
|
26
26
|
parseIdOnly,
|
|
27
27
|
parseResumeRequest,
|
|
28
28
|
sameIdentity,
|
|
29
|
+
StatusOutputSchema,
|
|
29
30
|
type CheckBatchEvidence,
|
|
31
|
+
type ExecuteRequest,
|
|
30
32
|
type ModelClass,
|
|
31
33
|
type ReviewEvidence,
|
|
32
34
|
type RunState,
|
|
@@ -410,7 +412,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
410
412
|
}
|
|
411
413
|
|
|
412
414
|
export interface IsolatedSurface {
|
|
413
|
-
execute(
|
|
415
|
+
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
414
416
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
415
417
|
recover(cwd: string): Promise<string>;
|
|
416
418
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
@@ -616,16 +618,22 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
616
618
|
return toolResult({ text: `Pi Subagent ${id}: durable request accepted; productive work continues. Use subagent_status to inspect progress.`, state }, ctx, workspaceRowsByRequest);
|
|
617
619
|
};
|
|
618
620
|
|
|
621
|
+
// Status stays callable from codemode scripts and hands them the same bounded public projection
|
|
622
|
+
// the model reads; every mutating tool below is model-only because its guarded transitions need
|
|
623
|
+
// Main's visible reasoning.
|
|
619
624
|
pi.registerTool({
|
|
620
625
|
name: "subagent_status",
|
|
621
626
|
label: "Subagent status",
|
|
622
627
|
description: "Read one durable isolated request without reconciling or changing resources.",
|
|
623
628
|
parameters: IdOnlySchema,
|
|
624
629
|
prepareArguments: parseIdOnly,
|
|
630
|
+
outputSchema: StatusOutputSchema,
|
|
625
631
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
626
632
|
latestCtx = ctx;
|
|
627
633
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
628
|
-
|
|
634
|
+
const result = toolResult(await getComponents().runner.status(params.id, root, signal), ctx, workspaceRowsByRequest);
|
|
635
|
+
// The projection is plain JSON; its conditional fields are typed optional rather than absent.
|
|
636
|
+
return { ...result, structuredContent: result.details as JsonValue };
|
|
629
637
|
},
|
|
630
638
|
});
|
|
631
639
|
pi.registerTool({
|
|
@@ -634,6 +642,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
634
642
|
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
|
|
635
643
|
parameters: ResumeRequestParameters,
|
|
636
644
|
prepareArguments: parseResumeRequest,
|
|
645
|
+
exposure: "model-only",
|
|
637
646
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
638
647
|
latestCtx = ctx;
|
|
639
648
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -649,6 +658,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
649
658
|
description: "Main stages/resolves an exact candidate, or rejects/revises one. Rejection of a staged candidate freezes the old generation; explicitly restage chosen candidates in a new generation. Never writes Main.",
|
|
650
659
|
parameters: StageRequestSchema,
|
|
651
660
|
prepareArguments: parseStageRequest,
|
|
661
|
+
exposure: "model-only",
|
|
652
662
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
653
663
|
latestCtx = ctx;
|
|
654
664
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -661,6 +671,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
661
671
|
description: "Main advances staged dependents, refreshes after clean Main drift, validates, corrects, promotes, reconciles an interrupted promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
|
|
662
672
|
parameters: IntegrationActionParameters,
|
|
663
673
|
prepareArguments: parseIntegrationAction,
|
|
674
|
+
exposure: "model-only",
|
|
664
675
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
665
676
|
latestCtx = ctx;
|
|
666
677
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -679,6 +690,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
679
690
|
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged no-candidate allocations; preserve dirty or committed work.",
|
|
680
691
|
parameters: IdOnlySchema,
|
|
681
692
|
prepareArguments: parseIdOnly,
|
|
693
|
+
exposure: "model-only",
|
|
682
694
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
683
695
|
latestCtx = ctx;
|
|
684
696
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
@@ -755,10 +767,9 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
755
767
|
requireCurrent(current);
|
|
756
768
|
return getComponents().runner.drainFollowups(root, requestId, taskId);
|
|
757
769
|
},
|
|
758
|
-
async execute(
|
|
770
|
+
async execute(request, signal, ctx) {
|
|
759
771
|
latestCtx = ctx;
|
|
760
772
|
latestContext();
|
|
761
|
-
const request = parseExecuteRequest(params);
|
|
762
773
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
763
774
|
return await startInSession(request.id, root, signal, ctx,
|
|
764
775
|
(state) => state.status === "pending" && state.createdAt === state.updatedAt
|
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);
|
package/extensions/workflow.ts
CHANGED
|
@@ -42,8 +42,7 @@ export const DirectWorkflowSchema = Type.Object({
|
|
|
42
42
|
chain: Type.Optional(Type.Array(DelegationSchema, { minItems: 1, maxItems: MAX_WORKFLOW_ENTRIES })),
|
|
43
43
|
}, { additionalProperties: false, description: "Direct mode: exactly one compact single, tasks, or chain workflow" });
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
// Keep the strict mode union for parsing; expose a plain object root to tool providers.
|
|
45
|
+
// Parse each mode with its strict schema; expose a plain object root to tool providers.
|
|
47
46
|
export const DelegateTaskParameters = Type.Object({
|
|
48
47
|
...DirectWorkflowSchema.properties,
|
|
49
48
|
mode: Type.Union([DirectWorkflowSchema.properties.mode, ExecuteRequestSchema.properties.mode]),
|
|
@@ -56,7 +55,6 @@ export const DelegateTaskParameters = Type.Object({
|
|
|
56
55
|
finalChecks: ExecuteRequestSchema.properties.finalChecks,
|
|
57
56
|
finalJudgment: ExecuteRequestSchema.properties.finalJudgment,
|
|
58
57
|
}, { additionalProperties: false });
|
|
59
|
-
export const WorkflowSchema = DirectWorkflowSchema;
|
|
60
58
|
|
|
61
59
|
export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
|
|
62
60
|
export type WorkflowMode = "single" | "parallel" | "chain";
|