@henryqw/pi-subagent 25.1.0 → 26.0.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 +3 -3
- package/README.md +12 -6
- package/dist/direct-herdr.d.ts +2 -2
- package/dist/direct-herdr.js +25 -10
- package/dist/ephemeral.js +14 -20
- package/dist/git-process.js +2 -9
- package/dist/index.js +3 -4
- package/dist/launch-runtime.js +1 -5
- package/dist/mcp-role.d.ts +2 -1
- package/dist/mcp-role.js +15 -2
- package/dist/process.d.ts +1 -1
- package/dist/runner.js +8 -7
- package/dist/store.js +1 -1
- package/docs/orchestration.md +4 -2
- package/examples/roles/implementer.md +1 -1
- package/extensions/admission.ts +15 -2
- package/extensions/isolated.ts +3 -4
- package/extensions/subagent.ts +25 -12
- package/extensions/workflow.ts +2 -4
- package/package.json +7 -7
- package/skills/pi-subagent/SKILL.md +3 -3
package/CONTEXT.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
6
6
|
|
|
7
|
-
- **direct mode** runs
|
|
7
|
+
- **direct mode** runs authorized compact shared-checkout work in Herdr tabs in Main's workspace and returns a handle before completion;
|
|
8
8
|
- **isolated mode** runs durable checked graphs with Herdr worktrees, exact Git identities, guarded integration, and recovery; and
|
|
9
9
|
- the package root exposes the Role, executor, worktree, evidence, schema, state, and runtime mechanisms used by the extension.
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
13
13
|
- **Main**: the Pi session and checkout coordinating delegated work.
|
|
14
14
|
- **Role**: package or user Markdown defining responsibility, tools, trusted extension sources, Skills, optional MCP names, instructions, and an optional model-class default. Roles do not select isolation.
|
|
15
15
|
- **Model Class**: `fast`, `balanced`, `frontier`, or `fav`, resolved through `pi-task-models`.
|
|
16
|
-
- **Direct task**: one task using
|
|
16
|
+
- **Direct task**: one bounded task using its Role's declared resources in Main's shared checkout; authorized writes take effect immediately without checked integration.
|
|
17
17
|
- **Isolated request**: one durable ID, goal, and checked task graph.
|
|
18
18
|
- **Candidate**: exact clean committed task-worktree identity produced by an isolated changeset worker.
|
|
19
19
|
- **Readiness**: durable proof that one exact live-worker candidate passed its preliminary checks and is sealed for Main's explicit selection; it is not a separate user approval.
|
|
@@ -41,7 +41,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
41
41
|
|
|
42
42
|
### Direct evidence
|
|
43
43
|
|
|
44
|
-
- Direct mode admits
|
|
44
|
+
- Direct mode admits declared write-capable resources for explicitly authorized scope while preserving read-only Role restrictions. Potential-writer requests serialize tasks and retain Pi checkout admission through exact completion; uncertain outcomes retain admission for manual recovery. Potential-writer answers require an explicit structured `succeeded` outcome; failed, blocked, or malformed completion stops dispatch and retains admission. This is a worker report, not proof of validation, cleanliness, rollback, or checked integration. Direct changesets require isolated mode.
|
|
45
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.
|
|
46
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.
|
|
47
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.
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# `@henryqw/pi-subagent`
|
|
2
2
|
|
|
3
|
-
Delegate work to configured Pi Roles with [Herdr](https://herdr.dev/).
|
|
3
|
+
Delegate work to configured Pi Roles with [Herdr](https://herdr.dev/). Authorized direct tasks run in Herdr tabs in your current workspace; checked implementation runs in isolated Herdr worktrees. Both return a handle so Main can continue while they work.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
+
Requires Pi 1.0.0 or later. Development and automated checks use Pi 1.0.2. Older Pi releases are no longer supported.
|
|
10
|
+
|
|
9
11
|
```bash
|
|
10
12
|
pi install npm:@henryqw/pi-task-models
|
|
11
13
|
pi install npm:@henryqw/pi-subagent
|
|
@@ -29,7 +31,7 @@ Commands are for you; tools and the packaged skill are for Main. You do not need
|
|
|
29
31
|
| --- | --- | --- |
|
|
30
32
|
| `/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
33
|
| `/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
|
-
| `delegate_task` | tool | Start
|
|
34
|
+
| `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. |
|
|
33
35
|
| `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
36
|
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. |
|
|
35
37
|
| `subagent_stage` | tool | Stage or resolve an exact candidate, or reject/revise it; rejection of a staged candidate freezes that generation. |
|
|
@@ -51,7 +53,7 @@ Main chooses the mode from your request; you do not need to select one. Words li
|
|
|
51
53
|
|
|
52
54
|
| Mode | Request that triggers it | Checkout behavior |
|
|
53
55
|
| --- | --- | --- |
|
|
54
|
-
| `direct` |
|
|
56
|
+
| `direct` | Bounded research, review, or explicitly authorized shared-checkout writes and commits | Opens non-focused Herdr tabs in Main's current workspace. No worktree is created. |
|
|
55
57
|
| `isolated` | Implementation or a checked task graph | Runs changesets in owned Herdr worktrees; Main selects, validates, and promotes exact candidates. |
|
|
56
58
|
|
|
57
59
|
Keep trivial mechanically verifiable work in Main. Keep tightly coupled changes under one owner rather than splitting by file count.
|
|
@@ -77,7 +79,11 @@ Parallel: { mode: "direct", tasks: [{ role, name, task, ... }] }
|
|
|
77
79
|
Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
Direct tasks use the selected Role's declared tools, extensions, Skills, and MCP servers, including write-capable resources. Authorization comes from your request and Main's bounded task packet, not the Role's capabilities: name allowed paths/actions and exclusions, preserve unrelated edits and staged changes, and authorize commits or external actions explicitly. For example, a configured Git operator with `bash` can stage named pending files and commit them on the `fast` route. Read-only Roles retain their tool restrictions. Use isolated mode for a checked changeset.
|
|
83
|
+
|
|
84
|
+
Direct writes affect Main's checkout immediately. No clean-checkout prerequisite, automatic checks, rollback, or isolated integration guarantee applies. Requests containing potential writers (including any extensions or MCP servers) run their tasks serially and hold checkout admission after the initial handle returns; competing Pi writes fail until exact successful completion. Pure read-only parallel requests still run concurrently. A failure in a potential-writer request stops further dispatch; an uncertain worker may still be running. Chains replace each literal `{previous}` with the preceding successful answer and stop on failure. On failure, cancellation, or uncertain launch after tab creation, admission stays held: inspect the exact tabs/session files through `/subagent`, stop any remaining owned workers, inspect and preserve their changes, then restart Pi before new writes. Never blindly retry a commit or delete work to recover.
|
|
85
|
+
|
|
86
|
+
Potential-writer tasks must return one JSON completion object, such as `{"outcome":"succeeded","answer":"Scoped commit completed."}`. `outcome` must be `succeeded`, `failed`, or `blocked`, and `answer` must be non-empty. A normal final Pi turn is not enough: failure, blocked work, plain text, or malformed completion stops later dispatch and retains admission. Success is the worker's explicit report, not independent validation of its changes.
|
|
81
87
|
|
|
82
88
|
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.
|
|
83
89
|
|
|
@@ -191,7 +197,7 @@ Checkout admission keeps a script's writer ownership until the script and all it
|
|
|
191
197
|
|
|
192
198
|
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
199
|
|
|
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.
|
|
200
|
+
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. `oauth.clientRegistration` must be `dcr` or `cimd`; `cimd` requires Pi 1.0.1 or later; Pi 1.0.0 rejects it before child launch. It cannot use `clientId` or `clientName`, and any `callbackUrl` must use localhost or 127.0.0.1 with path `/callback`. Project MCP overrides remain ignored: a project cannot change the Role's global server allowlist or exposure. 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.
|
|
195
201
|
|
|
196
202
|
## API
|
|
197
203
|
|
|
@@ -218,4 +224,4 @@ Durable state is private under `config/pi-subagent/state/`. Isolated worker Pi s
|
|
|
218
224
|
|
|
219
225
|
## Limits and recovery
|
|
220
226
|
|
|
221
|
-
Role extensions and MCP servers are trusted executable code, not a sandbox. Select the smallest resource set. Direct
|
|
227
|
+
Role extensions and MCP servers are trusted executable code, not a sandbox. Select the smallest resource set. Direct tasks may use declared write-capable resources in Main's checkout only within the authorized scope. Checkout admission coordinates this Main's Pi calls, not other Pi sessions, trusted extension lifecycle code, or external processes. Concurrent changes can make a direct worker's read stale. Retained-work reports identify exact resources for deliberate recovery.
|
package/dist/direct-herdr.d.ts
CHANGED
|
@@ -12,9 +12,9 @@ export interface DirectHandle {
|
|
|
12
12
|
cancel(): Promise<void>;
|
|
13
13
|
}
|
|
14
14
|
export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
|
|
15
|
-
export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number): string;
|
|
15
|
+
export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number, requireSuccess?: boolean): string;
|
|
16
16
|
export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
|
|
17
17
|
export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile">;
|
|
18
18
|
export declare function createDirectHerdr(pi: Pick<ExtensionAPI, "exec">, cwd: string, idleMs: number): {
|
|
19
|
-
start(launch: ResolvedRoleLaunch, name: string, label: string, task: string, signal: AbortSignal, onTab: (tab: DirectTab) => void): Promise<DirectHandle>;
|
|
19
|
+
start(launch: ResolvedRoleLaunch, name: string, label: string, task: string, signal: AbortSignal, onTab: (tab: DirectTab) => void, requireSuccess?: boolean): Promise<DirectHandle>;
|
|
20
20
|
};
|
package/dist/direct-herdr.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createHerdrClient, hasHerdrErrorCode, herdrCommandFailure, startPiAgent
|
|
|
7
7
|
const OPERATION_MS = 30_000;
|
|
8
8
|
const SESSION_LIMIT = 16 * 1024 * 1024;
|
|
9
9
|
const ANSWER_LIMIT = 50 * 1024;
|
|
10
|
+
const DIRECT_OUTCOMES = ["succeeded", "failed", "blocked"];
|
|
10
11
|
const object = (value, label) => {
|
|
11
12
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12
13
|
throw new Error(`Malformed Herdr ${label}.`);
|
|
@@ -63,7 +64,7 @@ export function exactDirectTerminalTurn(jsonl, prompt) {
|
|
|
63
64
|
const reason = exactFinalTurn(jsonl, prompt)?.stopReason;
|
|
64
65
|
return reason === "stop" || reason === "error" || reason === "aborted";
|
|
65
66
|
}
|
|
66
|
-
export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
|
|
67
|
+
export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT, requireSuccess = false) {
|
|
67
68
|
const final = exactFinalTurn(jsonl, prompt);
|
|
68
69
|
if (!final || final.stopReason !== "stop" || !Array.isArray(final.content)) {
|
|
69
70
|
throw new Error("Pi did not persist an exact successful final answer for this prompt.");
|
|
@@ -76,7 +77,23 @@ export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
|
|
|
76
77
|
throw new Error("Pi persisted an empty final answer.");
|
|
77
78
|
if (Buffer.byteLength(text, "utf8") > maxBytes)
|
|
78
79
|
throw new Error(`Pi final answer exceeds the ${maxBytes}-byte workflow limit; read the private session file for recovery.`);
|
|
79
|
-
|
|
80
|
+
if (!requireSuccess)
|
|
81
|
+
return text;
|
|
82
|
+
let completion;
|
|
83
|
+
try {
|
|
84
|
+
completion = JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error("Pi potential-writer final answer must be a JSON completion object.", { cause: error });
|
|
88
|
+
}
|
|
89
|
+
if (!completion || typeof completion !== "object" || Array.isArray(completion)
|
|
90
|
+
|| !DIRECT_OUTCOMES.some((outcome) => outcome === completion.outcome)
|
|
91
|
+
|| typeof completion.answer !== "string" || !completion.answer.trim()) {
|
|
92
|
+
throw new Error("Pi potential-writer completion must have a valid outcome and a non-empty answer.");
|
|
93
|
+
}
|
|
94
|
+
if (completion.outcome !== "succeeded")
|
|
95
|
+
throw new Error(`Direct task reported ${completion.outcome}: ${completion.answer}`);
|
|
96
|
+
return completion.answer;
|
|
80
97
|
}
|
|
81
98
|
// Ignore a partial trailing JSONL entry while the child is writing it.
|
|
82
99
|
export function directSessionTokens(jsonl, prompt) {
|
|
@@ -132,7 +149,7 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
132
149
|
return field(agent.agent_status, "agent state");
|
|
133
150
|
};
|
|
134
151
|
return {
|
|
135
|
-
async start(launch, name, label, task, signal, onTab) {
|
|
152
|
+
async start(launch, name, label, task, signal, onTab, requireSuccess = false) {
|
|
136
153
|
const caller = result(await herdr.json(["pane", "current", "--current"], options(signal)), "pane_current");
|
|
137
154
|
const pane = object(caller.pane, "calling pane");
|
|
138
155
|
if (pane.pane_id !== callerPane || pane.workspace_id !== workspaceId)
|
|
@@ -170,7 +187,10 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
170
187
|
// A canceled start may have succeeded server-side; retain its tab but
|
|
171
188
|
// never submit a new prompt after cancellation.
|
|
172
189
|
signal.throwIfAborted();
|
|
173
|
-
const
|
|
190
|
+
const completionInstruction = requireSuccess
|
|
191
|
+
? `\n\nPotential-writer completion: Return exactly one JSON object without Markdown, {"outcome":"succeeded","answer":"summary"}. outcome must be ${DIRECT_OUTCOMES.join(", ")}. Use succeeded only when the entire authorized task, including requested checks and commits, completed successfully; a failed mutation, unresolved partial work, or missing authorization must be failed or blocked, even if your Pi turn ends normally. Describe changes, checks, commit identity, remaining risks, and checkout status in answer.`
|
|
192
|
+
: "";
|
|
193
|
+
const prompt = `${task}\n\nDirect shared-checkout boundary: use only your Role's resources for the explicitly authorized task and scope. Writes and commits affect Main's existing checkout immediately; preserve unrelated files and staged changes. Do not create or manage worktrees, change branches, push, or perform external mutations without explicit task authorization. There is no isolated integration, automatic validation, or rollback. If instructions conflict or authorization is missing, stop and report it. Report changes, commit identity if any, checks, remaining risks, and checkout status; never claim isolated guarantees.${completionInstruction}\n\nTurn identity: ${randomBytes(16).toString("hex")}`;
|
|
174
194
|
// Native prompt without --wait acknowledges submission, not completion of the turn.
|
|
175
195
|
const accepted = await herdr.json(["agent", "prompt", name, prompt], options(signal));
|
|
176
196
|
const state = inspect(accepted, "agent_prompted", name, paneId, tabId);
|
|
@@ -195,7 +215,7 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
195
215
|
const info = await stat(sessionFile);
|
|
196
216
|
if (info.size > SESSION_LIMIT)
|
|
197
217
|
throw new Error(`Pi session in tab ${tabId} exceeds 16 MiB; inspect ${sessionFile} for recovery.`);
|
|
198
|
-
return exactDirectAnswer(await readFile(sessionFile, "utf8"), prompt, maxBytes);
|
|
218
|
+
return exactDirectAnswer(await readFile(sessionFile, "utf8"), prompt, maxBytes, requireSuccess);
|
|
199
219
|
};
|
|
200
220
|
const maybeAnswer = async () => {
|
|
201
221
|
try {
|
|
@@ -243,11 +263,6 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
243
263
|
const waited = await herdr.exec(args, options(signal));
|
|
244
264
|
if (waited.code !== 0 || waited.killed) {
|
|
245
265
|
if (!waited.killed && hasHerdrErrorCode(waited, "timeout")) {
|
|
246
|
-
if (current === "idle") {
|
|
247
|
-
const answer = await maybeAnswer();
|
|
248
|
-
if (answer !== undefined)
|
|
249
|
-
return answer;
|
|
250
|
-
}
|
|
251
266
|
continue;
|
|
252
267
|
}
|
|
253
268
|
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.js
CHANGED
|
@@ -97,7 +97,7 @@ const stringList = (value, field, source) => {
|
|
|
97
97
|
}
|
|
98
98
|
return value.map((item) => item.trim());
|
|
99
99
|
};
|
|
100
|
-
// Pi
|
|
100
|
+
// Pi built-in extensions (`builtin:<name>`, see Pi's settings docs); Pi exports no list of them.
|
|
101
101
|
// `builtin:mcp` is excluded: it connects every server in mcp.json and would bypass the Role `mcps` allowlist.
|
|
102
102
|
const BUILTIN_EXTENSION_NAMES = ["codemode", "tool-search", "llama.cpp"];
|
|
103
103
|
const BUILTIN_EXTENSION_PREFIX = "builtin:";
|
|
@@ -374,10 +374,9 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
374
374
|
const roleName = parseRoleName(input.role);
|
|
375
375
|
if (input.modelClass === undefined)
|
|
376
376
|
throw new Error("Configured Role launch requires an explicit modelClass.");
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
377
|
+
const role = loadRoles().find((role) => role.name === roleName);
|
|
378
|
+
if (!role)
|
|
379
379
|
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
380
|
-
const role = matches[0];
|
|
381
380
|
if (role.mcps?.length)
|
|
382
381
|
loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
|
|
383
382
|
const resources = await resolveRolePackageResources(role, ctx);
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
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`
|
|
@@ -9,4 +9,5 @@ export declare function parseRoleMcpAllowlist(value: unknown): string[];
|
|
|
9
9
|
*/
|
|
10
10
|
export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[], options?: {
|
|
11
11
|
codemode: boolean;
|
|
12
|
+
piVersion?: string;
|
|
12
13
|
}): LoadedMcpConfig;
|
package/dist/mcp-role.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
export function parseRoleMcpAllowlist(value) {
|
|
4
5
|
if (typeof value !== "string")
|
|
5
6
|
throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
|
|
@@ -62,6 +63,7 @@ const HTTP_CHECKS = {
|
|
|
62
63
|
}, "an http URL on localhost, 127.0.0.1, or [::1] without query or fragment, on the \"oauth.callbackPort\" port"],
|
|
63
64
|
"oauth.scope": [isString, "a string"],
|
|
64
65
|
"oauth.clientName": [(value) => typeof value === "string" && value.trim() !== "", "a non-empty string"],
|
|
66
|
+
"oauth.clientRegistration": [(value) => value === "dcr" || value === "cimd", '"dcr" or "cimd"'],
|
|
65
67
|
"oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
|
|
66
68
|
auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
|
|
67
69
|
};
|
|
@@ -70,7 +72,7 @@ const EXPOSURE_CHECKS = {
|
|
|
70
72
|
toolExposure: [(value) => isRecord(value) && Object.values(value).every(isExposure), "an object mapping tool names to exposures"],
|
|
71
73
|
};
|
|
72
74
|
/** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
|
|
73
|
-
function validateRoleMcpServer(path, name, value, codemode) {
|
|
75
|
+
function validateRoleMcpServer(path, name, value, codemode, piVersion) {
|
|
74
76
|
const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
|
|
75
77
|
if (!SERVER_NAME.test(name))
|
|
76
78
|
fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
|
|
@@ -87,6 +89,17 @@ function validateRoleMcpServer(path, name, value, codemode) {
|
|
|
87
89
|
if (fieldValue !== undefined && !valid(fieldValue, value))
|
|
88
90
|
fail(`field "${field}" must be ${shape}.`);
|
|
89
91
|
}
|
|
92
|
+
if (http && isRecord(value.oauth) && value.oauth.clientRegistration === "cimd") {
|
|
93
|
+
if (piVersion === "1.0.0")
|
|
94
|
+
fail('field "oauth.clientRegistration" "cimd" requires Pi 1.0.1 or later; current Pi is 1.0.0.');
|
|
95
|
+
if (value.oauth.clientId !== undefined || value.oauth.clientName !== undefined) {
|
|
96
|
+
fail('field "oauth.clientRegistration" "cimd" cannot be combined with "oauth.clientId" or "oauth.clientName".');
|
|
97
|
+
}
|
|
98
|
+
const callback = parseUrl(value.oauth.callbackUrl);
|
|
99
|
+
if (callback && (callback.hostname === "[::1]" || callback.pathname !== "/callback")) {
|
|
100
|
+
fail('field "oauth.clientRegistration" "cimd" requires "oauth.callbackUrl" on localhost or 127.0.0.1 with path /callback.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
90
103
|
// The native transport sends the provider's token to `url`, so Pi only allows it over https or loopback.
|
|
91
104
|
if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
|
|
92
105
|
fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
|
|
@@ -133,7 +146,7 @@ export function loadRoleMcpConfig(agentDir, allowlist, options = { codemode: fal
|
|
|
133
146
|
if (clash)
|
|
134
147
|
throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
|
|
135
148
|
namespaces.set(mcpNamespace(name), name);
|
|
136
|
-
return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode), source: path, scope: "global" };
|
|
149
|
+
return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode, options.piVersion ?? VERSION), source: path, scope: "global" };
|
|
137
150
|
});
|
|
138
151
|
return { servers, autoEnableCodemode: false, errors: [] };
|
|
139
152
|
}
|
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/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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
`pi-subagent` owns both delegation paths behind `delegate_task`:
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
|
-
┌─ direct ───
|
|
6
|
+
┌─ direct ─── authorized shared-checkout Herdr tab
|
|
7
7
|
request + Role + route ──┤
|
|
8
8
|
└─ isolated ─ Herdr worktree, checked candidate, durable state
|
|
9
9
|
```
|
|
@@ -62,7 +62,9 @@ Each entry has:
|
|
|
62
62
|
|
|
63
63
|
An explicit model must support the route's thinking level. A launched child is never retried automatically after provider or process failure.
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Direct tasks use their Role's declared resources, including write tools, within explicit task authorization. Read-only Role restrictions, resource/trust validation, and recursive-delegation exclusions still apply. A direct `changeset` is rejected; use isolated mode for checked integration. Tasks in any potential-writer request run serially and reserve checkout admission until exact successful completion; pure read-only parallel tasks run concurrently. Results stay in request order. A chain substitutes only the preceding successful output and stops at its first failure.
|
|
66
|
+
|
|
67
|
+
Writes and commits immediately change the shared checkout; there is no automatic validation, rollback, or isolated promotion. A potential-writer final answer must be a JSON object with `outcome` (`succeeded`, `failed`, or `blocked`) and a non-empty `answer`; only explicit worker-reported success permits later dispatch or admission release, not merely a normally settled Pi turn. Preserve unrelated files and index entries, require explicit commit/external-action scope, and report checkout status. Uncertain launch after tab creation, failure, or cancellation retains admission and exact recovery identities. Inspect `/subagent`, stop remaining owned workers, inspect and preserve changes, then restart Pi before new writes. Admission covers this Main's Pi calls, not other sessions or external processes.
|
|
66
68
|
|
|
67
69
|
The tool returns a handle after the first exact Herdr tab is launched. The remaining tabs are launched asynchronously; their identities are recorded on the session branch. A session-owned observer awaits each exact agent's lifecycle and retrieves its final answer from its Pi session file, not its possibly truncated screen. Idle, blocked, unknown, malformed, or oversized answers fail with recoverable tab/session identity; no whole-run deadline is imposed. `/subagent` shows exact recorded tabs on this session branch, distinguishing locally observed work from unobserved records. A session switch stops observation and carries tab identity into the new branch.
|
|
68
70
|
|
|
@@ -20,6 +20,6 @@ Run focused checks required by the task. Before reporting, remove only task-crea
|
|
|
20
20
|
|
|
21
21
|
Access credentials or the network, create extra artifacts, or broaden scope only when the task requires it. Never invoke external LLM APIs, SDKs, agent harnesses, or model CLIs.
|
|
22
22
|
|
|
23
|
-
Follow the task's commit policy. Direct delegation
|
|
23
|
+
Follow the task's commit policy. Direct delegation may write or commit only within Main's explicitly authorized scope; otherwise leave changes uncommitted. Isolated delegation commits completed scoped changes. Do not create or manage another worktree. Leave the assigned checkout and branch intact. Never push or open a pull request without explicit authorization.
|
|
24
24
|
|
|
25
25
|
Report briefly: outcome, commit, checks, and remaining risks.
|
package/extensions/admission.ts
CHANGED
|
@@ -9,7 +9,7 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
9
9
|
]);
|
|
10
10
|
|
|
11
11
|
export function roleCanWrite(role: Role): boolean {
|
|
12
|
-
return role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool));
|
|
12
|
+
return role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool)) || role.extensions.length > 0 || Boolean(role.mcps?.length);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function roleIsReadOnlyScout(role: Role): boolean {
|
|
@@ -32,13 +32,15 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
32
32
|
* calls it makes through `ctx.executeTool()` (codemode scripts), so its nested
|
|
33
33
|
* writes pass while independent writers wait until it settles.
|
|
34
34
|
*/
|
|
35
|
-
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void {
|
|
35
|
+
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): (toolCallId: string) => () => void {
|
|
36
|
+
const heldCalls = new Set<string>();
|
|
36
37
|
const ownerByCheckout = new Map<string, string>();
|
|
37
38
|
const checkoutByRoot = new Map<string, string>();
|
|
38
39
|
const writersByRoot = new Map<string, Set<string>>();
|
|
39
40
|
/** Model-issued ancestor of each in-flight call; Pi names nested calls `<parent id>/<n>`. */
|
|
40
41
|
const rootByCall = new Map<string, string>();
|
|
41
42
|
const release = (toolCallId: string) => {
|
|
43
|
+
if (heldCalls.has(toolCallId)) return;
|
|
42
44
|
const root = rootByCall.get(toolCallId);
|
|
43
45
|
if (root === undefined) return;
|
|
44
46
|
rootByCall.delete(toolCallId);
|
|
@@ -85,5 +87,16 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
85
87
|
checkoutByRoot.clear();
|
|
86
88
|
writersByRoot.clear();
|
|
87
89
|
rootByCall.clear();
|
|
90
|
+
heldCalls.clear();
|
|
88
91
|
});
|
|
92
|
+
// A direct handle settles the tool call before the worker settles. Keep its
|
|
93
|
+
// admission until exact completion; uncertain workers require manual recovery.
|
|
94
|
+
return (toolCallId) => {
|
|
95
|
+
const root = rootByCall.get(toolCallId);
|
|
96
|
+
if (root === undefined || !writersByRoot.get(root)?.has(toolCallId)) {
|
|
97
|
+
throw new Error(`Direct call ${toolCallId} was not admitted as a checkout writer; delegate again with the current Role resources.`);
|
|
98
|
+
}
|
|
99
|
+
heldCalls.add(toolCallId);
|
|
100
|
+
return () => { heldCalls.delete(toolCallId); release(toolCallId); };
|
|
101
|
+
};
|
|
89
102
|
}
|
package/extensions/isolated.ts
CHANGED
|
@@ -23,12 +23,12 @@ import {
|
|
|
23
23
|
IntegrationActionParameters,
|
|
24
24
|
parseIntegrationAction,
|
|
25
25
|
parseStageRequest,
|
|
26
|
-
parseExecuteRequest,
|
|
27
26
|
parseIdOnly,
|
|
28
27
|
parseResumeRequest,
|
|
29
28
|
sameIdentity,
|
|
30
29
|
StatusOutputSchema,
|
|
31
30
|
type CheckBatchEvidence,
|
|
31
|
+
type ExecuteRequest,
|
|
32
32
|
type ModelClass,
|
|
33
33
|
type ReviewEvidence,
|
|
34
34
|
type RunState,
|
|
@@ -412,7 +412,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
export interface IsolatedSurface {
|
|
415
|
-
execute(
|
|
415
|
+
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
416
416
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
417
417
|
recover(cwd: string): Promise<string>;
|
|
418
418
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
@@ -767,10 +767,9 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
767
767
|
requireCurrent(current);
|
|
768
768
|
return getComponents().runner.drainFollowups(root, requestId, taskId);
|
|
769
769
|
},
|
|
770
|
-
async execute(
|
|
770
|
+
async execute(request, signal, ctx) {
|
|
771
771
|
latestCtx = ctx;
|
|
772
772
|
latestContext();
|
|
773
|
-
const request = parseExecuteRequest(params);
|
|
774
773
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
775
774
|
return await startInSession(request.id, root, signal, ctx,
|
|
776
775
|
(state) => state.status === "pending" && state.createdAt === state.updatedAt
|
package/extensions/subagent.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
|
|
5
|
-
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
|
|
5
|
+
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, resolveRolePackageResources, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
|
|
6
6
|
import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
|
|
7
7
|
import { registerCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
|
|
8
8
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
@@ -181,7 +181,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
181
181
|
return true;
|
|
182
182
|
}
|
|
183
183
|
};
|
|
184
|
-
registerCheckoutAdmission(pi, canWrite);
|
|
184
|
+
const holdAdmission = registerCheckoutAdmission(pi, canWrite);
|
|
185
185
|
const isolatedSurface = registerIsolatedExtension(pi, {
|
|
186
186
|
executor,
|
|
187
187
|
policy: initialPolicy,
|
|
@@ -452,10 +452,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
452
452
|
pi.registerTool({
|
|
453
453
|
name: "delegate_task",
|
|
454
454
|
label: "Subagent",
|
|
455
|
-
description: `Delegate
|
|
456
|
-
promptSnippet: "Delegate
|
|
455
|
+
description: `Delegate explicitly authorized direct work in the shared checkout or a durable checked isolated graph to Pi Roles. Roles: ${roleSummary()}.`,
|
|
456
|
+
promptSnippet: "Delegate authorized shared-checkout work or a checked isolated task graph",
|
|
457
457
|
promptGuidelines: [
|
|
458
|
-
"Keep trivial mechanically verifiable work in Main. Use mode direct
|
|
458
|
+
"Keep trivial mechanically verifiable work in Main. Use mode direct for bounded, explicitly authorized work with the Role's declared resources in the shared checkout, including writes or commits. Specify allowed scope and exclusions; preserve unrelated changes. Direct work has no isolated checks, rollback, or integration guarantees. Use mode isolated for checked changesets; explicit mode never falls back.",
|
|
459
459
|
"Direct requests use one compact role/name/task packet, tasks for independent packets, or chain with {previous}. Direct work returns a Herdr handle after launch; results arrive as one follow-up message.",
|
|
460
460
|
"Isolation uses typed tasks and dependencies. Keep tightly coupled changes with one owner; do not split by file count. Failures, ambiguity, limits, and conflicts retain work and never waive checks or identity guards.",
|
|
461
461
|
`For delegate_task, ${MODEL_CLASS_GUIDANCE} A direct model replaces only the selected route's model; its thinking level stays unchanged.`,
|
|
@@ -482,8 +482,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
482
482
|
for (const delegation of workflow.delegations) {
|
|
483
483
|
const role = roles.get(delegation.role);
|
|
484
484
|
if (!role) throw boundedError(new Error(`Unknown Subagent role: ${delegation.role}. Available roles: ${[...roles.keys()].join(", ") || "none"}.`));
|
|
485
|
-
if (roleCanWrite(role)) throw new Error(`Role ${role.name} exposes write-capable or unverified resources. Use mode isolated for this Role; direct is read-only.`);
|
|
486
485
|
}
|
|
486
|
+
const writes = workflow.delegations.some((delegation) => roleCanWrite(roles.get(delegation.role)!));
|
|
487
487
|
const policy = currentPolicy();
|
|
488
488
|
const herdr = createDirectHerdr(pi, ctx.cwd, policy.childIdleMs);
|
|
489
489
|
const entries = identifyWorkflowEntries(toolCallId, workflow);
|
|
@@ -500,17 +500,25 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
500
500
|
const settled = new Promise<void>((resolve) => { resolveSettled = resolve; });
|
|
501
501
|
// Register before Herdr can create a tab: session switches must await this
|
|
502
502
|
// launch, including its identity callback, before copying recovery records.
|
|
503
|
+
const releaseAdmission = writes ? holdAdmission(toolCallId) : () => {};
|
|
503
504
|
directTasks.set(taskId, { controller, settled, handles, tabs });
|
|
504
505
|
const launch = async (entry: WorkflowEntry, activeSignal: AbortSignal) => {
|
|
505
506
|
activeSignal.throwIfAborted();
|
|
506
507
|
const role = loadRoles().find((candidate) => candidate.name === entry.delegation.role);
|
|
507
|
-
if (!role
|
|
508
|
+
if (!role) throw new Error(`Role ${entry.delegation.role} disappeared.`);
|
|
509
|
+
if (!writes && roleCanWrite(role)) throw new Error(`Role ${role.name} became write-capable after admission; delegate again with its current resources.`);
|
|
508
510
|
const context = latestCtx ?? ctx;
|
|
509
|
-
const
|
|
511
|
+
const resources = await resolveRolePackageResources(role, context);
|
|
512
|
+
const effectiveRole = { ...role, extensions: resources.extensions };
|
|
513
|
+
const route = prepareRoleLaunch(pi, context, { role: effectiveRole, task: DELEGATE_TASK,
|
|
510
514
|
...(entry.delegation.modelClass === undefined ? {} : { modelClass: entry.delegation.modelClass }) });
|
|
511
515
|
const prepared = entry.delegation.model === undefined ? route : prepareRoleLaunch(pi, context, {
|
|
512
|
-
role, route: replaceRouteModel(context, entry.delegation.model, route),
|
|
516
|
+
role: effectiveRole, route: replaceRouteModel(context, entry.delegation.model, route),
|
|
513
517
|
});
|
|
518
|
+
prepared.args.push("--no-prompt-templates", "--no-themes",
|
|
519
|
+
...resources.skills.flatMap((path) => ["--skill", path]),
|
|
520
|
+
...resources.prompts.flatMap((path) => ["--prompt-template", path]),
|
|
521
|
+
...resources.themes.flatMap((path) => ["--theme", path]));
|
|
514
522
|
states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
515
523
|
startWidgetItem(entry.id, taskId, role.name, prepared.model.id, prepared.thinkingLevel, entry.delegation.name, ctx);
|
|
516
524
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
@@ -524,7 +532,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
524
532
|
pi.appendEntry(DIRECT_TAB_TYPE, record);
|
|
525
533
|
tabs.push(record);
|
|
526
534
|
tabByEntry.set(entry.id, record);
|
|
527
|
-
});
|
|
535
|
+
}, writes);
|
|
528
536
|
} catch (error) {
|
|
529
537
|
// A failed start can still be in flight. Keep its private prompt for recovery.
|
|
530
538
|
throw new Error(`${error instanceof Error ? error.message : String(error)}. Direct Role prompt retained at ${transient.launch.args[prepared.promptArgIndex + 1]} after uncertain start.`, { cause: error });
|
|
@@ -552,15 +560,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
552
560
|
finishWidgetItem(entries[0]!.id, "failure");
|
|
553
561
|
directTasks.delete(taskId);
|
|
554
562
|
resolveSettled();
|
|
563
|
+
if (!tabs.length) releaseAdmission();
|
|
555
564
|
throw boundedError(error);
|
|
556
565
|
} finally { signal?.removeEventListener("abort", abortLaunch); }
|
|
557
566
|
void (async () => {
|
|
558
567
|
try {
|
|
559
568
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
560
569
|
let active = 0;
|
|
570
|
+
let writerFailed = false;
|
|
561
571
|
const queue: Array<() => void> = [];
|
|
562
572
|
const permit = async () => {
|
|
563
|
-
if (active >= policy.maxSubagents) await new Promise<void>((resolve) => queue.push(resolve));
|
|
573
|
+
if (active >= (writes ? 1 : policy.maxSubagents)) await new Promise<void>((resolve) => queue.push(resolve));
|
|
564
574
|
active++;
|
|
565
575
|
return () => { active--; queue.shift()?.(); };
|
|
566
576
|
};
|
|
@@ -568,6 +578,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
568
578
|
const release = await permit();
|
|
569
579
|
try {
|
|
570
580
|
controller.signal.throwIfAborted();
|
|
581
|
+
if (writerFailed) throw new Error("Previous direct task did not complete exactly; inspect recorded tabs before new shared-checkout work.");
|
|
571
582
|
const handle = entry.index === 0 ? first : await launch(entry, controller.signal);
|
|
572
583
|
const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
|
|
573
584
|
try {
|
|
@@ -578,6 +589,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
578
589
|
finishWidgetItem(entry.id, "success");
|
|
579
590
|
return answer;
|
|
580
591
|
} catch (error) {
|
|
592
|
+
if (writes) writerFailed = true;
|
|
581
593
|
const { assistantOutput: _partial, ...base } = states.get(entry.id)!;
|
|
582
594
|
states.set(entry.id, { ...base, status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
|
|
583
595
|
finishWidgetItem(entry.id, "failure");
|
|
@@ -596,13 +608,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
596
608
|
status: "skipped",
|
|
597
609
|
});
|
|
598
610
|
}
|
|
611
|
+
if ([...states.values()].every(({ status }) => status === "succeeded")) releaseAdmission();
|
|
599
612
|
if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, workflow.mode, [...states.values()], tabs);
|
|
600
613
|
directTasks.delete(taskId);
|
|
601
614
|
resolveSettled();
|
|
602
615
|
}
|
|
603
616
|
})();
|
|
604
617
|
return {
|
|
605
|
-
content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message
|
|
618
|
+
content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message. Direct work shares this checkout; completion is an exact answer, not checked integration. ${writes ? "Potential writers run serially; competing Pi writes stay blocked until exact completion. On failure or cancellation, inspect owned tabs before restarting Pi; changes are not rolled back." : "Concurrent changes may make reads stale."}`) }],
|
|
606
619
|
details: { taskId, mode: workflow.mode, tabId: first.tabId, sessionFile: first.sessionFile,
|
|
607
620
|
entries: entries.map(({ id, index, delegation }) => ({ id, index, name: delegation.name, role: delegation.role })) },
|
|
608
621
|
};
|
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";
|
|
@@ -109,7 +107,7 @@ export function parseWorkflow(value: unknown): ParsedWorkflow {
|
|
|
109
107
|
const mode = workflowMode(value);
|
|
110
108
|
if (!Check(DirectWorkflowSchema, value)) {
|
|
111
109
|
const issue = Errors(DirectWorkflowSchema, value)[0];
|
|
112
|
-
throw new Error(`direct workflow must match the declared tool schema${issue ? ` at ${issue.instancePath || "/"}: ${issue.message}` : ""}. Direct mode
|
|
110
|
+
throw new Error(`direct workflow must match the declared tool schema${issue ? ` at ${issue.instancePath || "/"}: ${issue.message}` : ""}. Direct mode returns task answers without checked integration; use mode isolated for changesets.`);
|
|
113
111
|
}
|
|
114
112
|
if (!mode) throw new Error("direct workflow must select exactly one of single, tasks, or chain.");
|
|
115
113
|
const input = value as DirectInput;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.0",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"pack:check": "npm pack --dry-run"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": "
|
|
44
|
-
"@earendil-works/pi-coding-agent": "
|
|
45
|
-
"@earendil-works/pi-tui": "
|
|
43
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
45
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
46
46
|
"typebox": "^1.3.15"
|
|
47
47
|
},
|
|
48
48
|
"repository": {
|
|
@@ -74,9 +74,9 @@
|
|
|
74
74
|
"proper-lockfile": "^4.1.2"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@earendil-works/pi-ai": "1.0.
|
|
78
|
-
"@earendil-works/pi-coding-agent": "1.0.
|
|
79
|
-
"@earendil-works/pi-tui": "1.0.
|
|
77
|
+
"@earendil-works/pi-ai": "1.0.2",
|
|
78
|
+
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
79
|
+
"@earendil-works/pi-tui": "1.0.2",
|
|
80
80
|
"@types/proper-lockfile": "^4.1.4"
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -11,8 +11,8 @@ Record the requested outcome, allowed scope and exclusions, local delegation/che
|
|
|
11
11
|
|
|
12
12
|
Keep trivial, mechanically verifiable work in Main. Choose the user-requested mode when one is explicit. Otherwise:
|
|
13
13
|
|
|
14
|
-
- Use `mode: direct` for bounded
|
|
15
|
-
- Use `mode: isolated` for
|
|
14
|
+
- Use `mode: direct` for bounded research, analysis, review, or explicitly authorized shared-checkout writes and commits using the selected Role's resources.
|
|
15
|
+
- Use `mode: isolated` for independently checked changes, or an exploratory or discardable candidate. A single isolated task is valid.
|
|
16
16
|
- Keep one implementation owner for tightly coupled work. Size alone does not justify splitting it.
|
|
17
17
|
- Never silently fall back from isolated to direct. Scope growth requires a checkpoint and replan, not migration of dirty work.
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ After delegation acknowledges a handle or durable request, do other work or end
|
|
|
22
22
|
|
|
23
23
|
# Direct requests
|
|
24
24
|
|
|
25
|
-
Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or `chain` for dependent packets. Only
|
|
25
|
+
Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Requests with potential writers run serially and block competing Pi writes until exact completion. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. On failure/cancellation or an uncertain launched tab, inspect `/subagent` identities, stop remaining owned workers, preserve and inspect changes, then restart Pi before writing or retrying. A failed commit may already have happened; never replay it blindly. The tool returns a handle after launching Herdr tabs in the current workspace. Main receives the verified result as a follow-up after the workflow finishes, without interrupting an active turn. If observation stops, open `/subagent` to inspect exact recorded tabs and session files on this session branch; recorded does not prove still running.
|
|
26
26
|
|
|
27
27
|
# Isolated requests
|
|
28
28
|
|