@henryqw/pi-subagent 22.2.3 → 23.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 -2
- package/README.md +31 -13
- package/dist/direct-herdr.d.ts +1 -0
- package/dist/direct-herdr.js +10 -2
- package/dist/git-runtime.d.ts +4 -0
- package/dist/git-runtime.js +22 -3
- package/dist/herdr-runtime.d.ts +5 -0
- package/dist/herdr-runtime.js +101 -9
- package/dist/launch-runtime.d.ts +1 -1
- package/dist/launch-runtime.js +1 -1
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +20 -11
- package/docs/orchestration.md +4 -4
- package/extensions/admission.ts +5 -1
- package/extensions/isolated.ts +42 -15
- package/extensions/subagent-command.ts +2 -2
- package/extensions/subagent.ts +3 -3
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -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 read-only
|
|
16
|
+
- **Direct task**: one task using read-only base tools in a Herdr tab in Main's current workspace; trusted extensions and MCP servers are not read-only constrained.
|
|
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 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.
|
|
45
45
|
- 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
46
|
- 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
47
|
|
|
@@ -50,6 +50,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
50
50
|
- Changeset tasks durably record allocation intent before each external side effect. Unknown outcomes are retained and never guessed.
|
|
51
51
|
- One worker remains live across prompts, preliminary checks, follow-up/correction, readiness, Main's staging decisions, combined validation, and guarded promotion. Selected workers terminate after promotion; rejected workers terminate on explicit rejection.
|
|
52
52
|
- Optional same-worker follow-ups are admitted only while a changeset is actively working. After the final checked-evidence save, one synchronous queue-or-seal transition prevents admitted revisions from being lost; the runner never waits for routine input.
|
|
53
|
+
- Worker waves pin Main's committed branch/HEAD/tree, excluding staged, unstaged, and untracked changes; dirty Main permits checked candidate readiness, but branch/HEAD drift blocks dispatch and clean Main is required before staging, combined validation, or promotion.
|
|
53
54
|
- Preliminary checks gate readiness. Any changed candidate invalidates previous readiness and validation.
|
|
54
55
|
- Optional task judgment binds to the exact preliminary candidate. Main selects and orders candidates in a separate owned integration checkout, where final full-suite checks and optional final judgment bind to the clean combined tip before promotion.
|
|
55
56
|
- 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.
|
package/README.md
CHANGED
|
@@ -19,18 +19,19 @@ Install `pi-mcp-adapter` only when a Role declares an MCP server allowlist:
|
|
|
19
19
|
pi install npm:pi-mcp-adapter
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Works with
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
| Package | Relationship | Purpose |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Required | Provides the configured model routes for delegated Roles. |
|
|
27
|
+
| [`pi-mcp-adapter`](https://github.com/nicobailon/pi-mcp-adapter) | Required | Loads the MCP servers named in a Role's `mcps` allowlist, only for Roles that declare one. |
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
## Use
|
|
27
30
|
|
|
28
31
|
Ask Main to delegate a bounded task, such as “Have a scout trace sign-in without editing files.” Main uses `delegate_task`; you receive progress and a result or an actionable failure while Main remains available.
|
|
29
32
|
|
|
30
33
|
Commands are for you; tools and the packaged skill are for Main. You do not need to call agent tools or manage candidate identities yourself.
|
|
31
34
|
|
|
32
|
-
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; their 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. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
|
|
33
|
-
|
|
34
35
|
| Surface | Type | Purpose |
|
|
35
36
|
| --- | --- | --- |
|
|
36
37
|
| `/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. |
|
|
@@ -43,6 +44,12 @@ In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` m
|
|
|
43
44
|
| `subagent_abort` | tool | Abort an isolated request only when no retained candidates or integration worktrees remain; cannot discard them. |
|
|
44
45
|
| `pi-subagent` | skill | Guide Main through delegation, authorization, checks, integration, and recovery. |
|
|
45
46
|
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
46
53
|
### How Main routes delegation
|
|
47
54
|
|
|
48
55
|
Main chooses the mode from your request; you do not need to select one. Words like “direct” and “isolated” can signal your intent, but the work determines the route. Main declares the chosen mode in every `delegate_task` call, and the extension never falls back between modes.
|
|
@@ -75,7 +82,7 @@ Parallel: { mode: "direct", tasks: [{ role, name, task, ... }] }
|
|
|
75
82
|
Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
|
|
76
83
|
```
|
|
77
84
|
|
|
78
|
-
Only Roles
|
|
85
|
+
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.
|
|
79
86
|
|
|
80
87
|
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
88
|
|
|
@@ -126,11 +133,11 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
|
|
|
126
133
|
|
|
127
134
|
`dependsOn` controls scheduling. `contextFrom` may name completed text tasks and preserves the declared order. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
|
|
128
135
|
|
|
129
|
-
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready, and again when the wave finishes or needs attention
|
|
136
|
+
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
|
|
130
137
|
|
|
131
|
-
The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
|
|
138
|
+
Isolated workers can start while Main has staged, unstaged, or untracked changes: they use only Main's pinned committed branch, HEAD, and tree. Those local edits are neither copied into worker worktrees nor stashed, staged, reset, or merged automatically. Main must remain at that committed identity while workers run; branch or HEAD drift blocks new waves. The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
|
|
132
139
|
|
|
133
|
-
Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick an isolated request and **Inspect status and recovery** to launch a non-focused Herdr tab with a read-only `scout` Role on the configured `fast` route. Its prompt contains bounded saved status (including retained paths, but not raw launch environment or session transcripts); the analysis stays in that tab and does not queue a Main turn. The tab and session file are recorded for recovery even if launch becomes uncertain. The read-only `subagent_status` tool remains Main's authority for exact identities before any stage or recovery action. If Herdr or the read-only fast route is unavailable, inspection fails without changing the request; Main can use `subagent_status`. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources.
|
|
140
|
+
Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick an isolated request and **Inspect status and recovery** to launch a non-focused Herdr tab with a read-only `scout` Role on the configured `fast` route. Unlike ordinary direct delegation, status inspection rejects Roles with extensions or MCP servers because their tools are not verified read-only. Its prompt contains bounded saved status (including retained paths, but not raw launch environment or session transcripts); the analysis stays in that tab and does not queue a Main turn. The tab and session file are recorded for recovery even if launch becomes uncertain. The read-only `subagent_status` tool remains Main's authority for exact identities before any stage or recovery action. If Herdr or the read-only fast route is unavailable, inspection fails without changing the request; Main can use `subagent_status`. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources.
|
|
134
141
|
|
|
135
142
|
For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor; submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text appears in FIFO order separated by blank lines. Explicit submission queues **one replacement** at the tail. Cancel leaves the withdrawn instructions removed; already-claimed instructions, initial prompts, and automatic corrections cannot be recalled. A worker may seal during editing: failed submission reopens the full submitted text for deliberate editing or copying, never automatically requeues it. Combined text above the single-instruction limit is preserved in the editor but rejected until shortened. Cancelling before withdrawal makes no change. Neither action touches Main's editor or Pi's global Option+Up queue shortcut. No-UI sessions can run `/subagent recover` or use agent tools for individual inspection and recovery.
|
|
136
143
|
|
|
@@ -142,7 +149,7 @@ After a coordinator interruption, run `/subagent recover` once. It acquires the
|
|
|
142
149
|
|
|
143
150
|
The following details describe exact identity and retained-work requirements.
|
|
144
151
|
|
|
145
|
-
Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
|
|
152
|
+
A checked candidate stays retained if Main is dirty. Staging, combined validation, and promotion require Main to be clean at the pinned committed identity; clean your local edits deliberately before using those actions. No dirty work is replayed or merged automatically. Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
|
|
146
153
|
|
|
147
154
|
The extension never pushes, opens a pull request, publishes, deploys, force-cleans recoverable work, or silently falls back to Main.
|
|
148
155
|
|
|
@@ -183,7 +190,18 @@ Children disable ambient extension and Skill discovery. Only declared resources
|
|
|
183
190
|
|
|
184
191
|
## API
|
|
185
192
|
|
|
186
|
-
|
|
193
|
+
| Surface | Type | Purpose |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| `loadRoles`, `parseRoleName` | functions | Load and validate built-in and user Roles. |
|
|
196
|
+
| `resolveRoleSkills`, `resolveRoleLaunch`, `resolveConfiguredRoleLaunch` | functions | Resolve effective Pi resources and model routes. |
|
|
197
|
+
| `prepareRoleLaunch`, `finalizeRoleLaunch` | functions | Prepare Role prompts, arguments, and tool policy. |
|
|
198
|
+
| `createEphemeralSubagentExecutor` | function | Run bounded no-session Pi children through a FIFO pool. |
|
|
199
|
+
| `createChildWorktree`, `finalizeChildWorktree` | functions | Create and conservatively finalize child worktrees. |
|
|
200
|
+
| `prepareExactReviewEvidence`, `prepareWorkingChangeEvidence` | functions | Prepare private exact review or working-change evidence. |
|
|
201
|
+
| `ExecuteRequestSchema`, `StageRequestSchema`, `IntegrationActionSchema` | schemas | Validate checked request and integration inputs. |
|
|
202
|
+
| `IsolatedRunner`, `FileRunStore` | classes | Run checked work and persist durable request state. |
|
|
203
|
+
| `RoleLaunchRuntime`, `HerdrHostRuntime`, `CheckedGitRuntime` | classes | Supply launch, host, and Git behavior to the runner. |
|
|
204
|
+
| `ExecutionPolicySnapshot`, `RunState`, `CoordinatorRuntime` | types | Describe persisted policy, state, and runner integration. |
|
|
187
205
|
|
|
188
206
|
`createEphemeralSubagentExecutor` accepts global concurrency, turn/token, idle, and hard-runtime policy. Queued time consumes no child timeout. A run resolves resources only after receiving its permit and accepts abort, output, token, and activity callbacks. Output and diagnostics are bounded.
|
|
189
207
|
|
|
@@ -191,8 +209,8 @@ See [Orchestration and package-author API](./docs/orchestration.md) for the deta
|
|
|
191
209
|
|
|
192
210
|
## State and storage
|
|
193
211
|
|
|
194
|
-
Durable state is private under `config/pi-subagent/state/`. State v5 rejects v4 and older files rather than migrating them; retain the old state and recover its work manually.
|
|
212
|
+
Durable state is private under `config/pi-subagent/state/`. Isolated worker Pi sessions are precreated with mode `0600` under `config/pi-subagent/leases/` without changing the worker's umask. Reads stop at the 16 MiB limit even if the session grows; an oversized session requires inspection rather than being accepted as completion evidence. Sessions are removed only after exact worker-tab cleanup; retained sessions may contain prompts and answers, so do not share them. State v5 rejects v4 and older files rather than migrating them; retain the old state and recover its work manually.
|
|
195
213
|
|
|
196
214
|
## Limits and recovery
|
|
197
215
|
|
|
198
|
-
Role extensions and MCP servers are trusted executable code, not a sandbox. Select the smallest resource set.
|
|
216
|
+
Role extensions and MCP servers are trusted executable code, not a sandbox. Select the smallest resource set. Direct admission checks only declared base tools; extensions and MCP servers can perform writes, including to Main's checkout. 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
|
@@ -11,6 +11,7 @@ export interface DirectHandle {
|
|
|
11
11
|
usageTokens(): Promise<number | undefined>;
|
|
12
12
|
cancel(): Promise<void>;
|
|
13
13
|
}
|
|
14
|
+
export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
|
|
14
15
|
export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number): string;
|
|
15
16
|
export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
|
|
16
17
|
export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile">;
|
package/dist/direct-herdr.js
CHANGED
|
@@ -23,7 +23,7 @@ const result = (value, type) => {
|
|
|
23
23
|
throw new Error(`Herdr returned an unexpected ${type} response.`);
|
|
24
24
|
return response;
|
|
25
25
|
};
|
|
26
|
-
|
|
26
|
+
function exactFinalTurn(jsonl, prompt) {
|
|
27
27
|
const lines = jsonl.trimEnd().split("\n");
|
|
28
28
|
let userId;
|
|
29
29
|
let final;
|
|
@@ -57,7 +57,15 @@ export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
|
|
|
57
57
|
seen.add(ancestor);
|
|
58
58
|
ancestor = parents.get(ancestor);
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
return userId && ancestor === userId ? final : undefined;
|
|
61
|
+
}
|
|
62
|
+
export function exactDirectTerminalTurn(jsonl, prompt) {
|
|
63
|
+
const reason = exactFinalTurn(jsonl, prompt)?.stopReason;
|
|
64
|
+
return reason === "stop" || reason === "error" || reason === "aborted";
|
|
65
|
+
}
|
|
66
|
+
export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
|
|
67
|
+
const final = exactFinalTurn(jsonl, prompt);
|
|
68
|
+
if (!final || final.stopReason !== "stop" || !Array.isArray(final.content)) {
|
|
61
69
|
throw new Error("Pi did not persist an exact successful final answer for this prompt.");
|
|
62
70
|
}
|
|
63
71
|
const text = final.content.flatMap((part) => {
|
package/dist/git-runtime.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export declare class CheckedGitRuntime implements GitRuntime, TaskCandidateInspe
|
|
|
41
41
|
inspectMain(input: {
|
|
42
42
|
root: string;
|
|
43
43
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
44
|
+
/** Pin the checked-out committed tree without reading or changing Main's dirty index/worktree. */
|
|
45
|
+
inspectMainBase(input: {
|
|
46
|
+
root: string;
|
|
47
|
+
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
44
48
|
allocateWorktree(input: {
|
|
45
49
|
root: string;
|
|
46
50
|
baseRoot?: string;
|
package/dist/git-runtime.js
CHANGED
|
@@ -68,6 +68,22 @@ export class CheckedGitRuntime {
|
|
|
68
68
|
return await this.inspectWorkspace(input.root, false, context);
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
+
/** Pin the checked-out committed tree without reading or changing Main's dirty index/worktree. */
|
|
72
|
+
async inspectMainBase(input, context) {
|
|
73
|
+
const cwd = input.root;
|
|
74
|
+
const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
|
|
75
|
+
const head = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
|
|
76
|
+
const entries = await this.requireGit(["ls-tree", "-r", "-z", head], cwd, context);
|
|
77
|
+
if (entries.split("\0").some((entry) => entry.startsWith("160000 "))) {
|
|
78
|
+
throw new Error("Pi Subagent does not support Git repositories containing mode-160000 gitlinks.");
|
|
79
|
+
}
|
|
80
|
+
const tree = oid(await this.requireGit(["rev-parse", "--verify", `${head}^{tree}`], cwd, context), "HEAD tree");
|
|
81
|
+
if (branch !== oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference")
|
|
82
|
+
|| head !== oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD")) {
|
|
83
|
+
throw new Error("Git workspace changed during committed Main inspection.");
|
|
84
|
+
}
|
|
85
|
+
return { branch, head, index: tree, tree };
|
|
86
|
+
}
|
|
71
87
|
async allocateWorktree(input, context) {
|
|
72
88
|
return await this.serializeMainOperation(input.root, context.signal, async () => {
|
|
73
89
|
return await this.allocateWorktreeExclusive(input, context);
|
|
@@ -77,7 +93,10 @@ export class CheckedGitRuntime {
|
|
|
77
93
|
let prepared;
|
|
78
94
|
let createdWorktree;
|
|
79
95
|
const baseRoot = input.baseRoot ?? input.root;
|
|
80
|
-
const
|
|
96
|
+
const inspectBase = () => baseRoot === input.root
|
|
97
|
+
? this.inspectMainBase({ root: baseRoot }, context)
|
|
98
|
+
: this.inspectWorkspace(baseRoot, false, context);
|
|
99
|
+
const before = await inspectBase();
|
|
81
100
|
if (!sameIdentity(before, input.attempt.waveBase)) {
|
|
82
101
|
return { kind: "worktree", outcome: "absent", failure: "Recorded wave snapshot drifted before worktree allocation." };
|
|
83
102
|
}
|
|
@@ -88,7 +107,7 @@ export class CheckedGitRuntime {
|
|
|
88
107
|
throw new Error("pi-subagent prepared a worktree from a base other than the recorded wave base.");
|
|
89
108
|
}
|
|
90
109
|
await input.onPrepared(prepared);
|
|
91
|
-
const current = await
|
|
110
|
+
const current = await inspectBase();
|
|
92
111
|
if (!sameIdentity(current, input.attempt.waveBase)) {
|
|
93
112
|
throw new Error("Recorded wave snapshot drifted after worktree preparation and before git worktree add.");
|
|
94
113
|
}
|
|
@@ -106,7 +125,7 @@ export class CheckedGitRuntime {
|
|
|
106
125
|
possibleResources: [worktree.path, worktree.branch],
|
|
107
126
|
};
|
|
108
127
|
}
|
|
109
|
-
const after = await
|
|
128
|
+
const after = await inspectBase();
|
|
110
129
|
if (!sameIdentity(after, input.attempt.waveBase)) {
|
|
111
130
|
return {
|
|
112
131
|
kind: "worktree",
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -119,6 +119,11 @@ export declare class HerdrHostRuntime implements HostRuntime {
|
|
|
119
119
|
private createPrivateLease;
|
|
120
120
|
private removeLeaseArtifacts;
|
|
121
121
|
private assertPrivateLeaseDirectories;
|
|
122
|
+
private workerSessionFile;
|
|
123
|
+
private sessionFileExists;
|
|
124
|
+
private privateSessionFile;
|
|
125
|
+
private workerTurnFinished;
|
|
126
|
+
private removeWorkerSessionFile;
|
|
122
127
|
private assertLeasePath;
|
|
123
128
|
private assertPrivateLease;
|
|
124
129
|
private scanLease;
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isCleanCommitted, } from "./schema.js";
|
|
|
9
9
|
import { buildChangesetTaskPrompt, withTransientLaunch, } from "./runner.js";
|
|
10
10
|
import { runProcess as defaultRunProcess } from "./process.js";
|
|
11
11
|
import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
|
|
12
|
+
import { exactDirectTerminalTurn } from "./direct-herdr.js";
|
|
12
13
|
const MIN_HERDR_VERSION = [0, 9, 0];
|
|
13
14
|
const MIN_HERDR_PROTOCOL = 22;
|
|
14
15
|
const HERDR_OPERATION_CAP_MS = 30_000;
|
|
@@ -19,6 +20,7 @@ const STALLED_PROMPT_POLL_MS = 250;
|
|
|
19
20
|
const HOST_LAYOUT_POLL_MS = 50;
|
|
20
21
|
const HOST_LAYOUT_MAX_POLLS = 40;
|
|
21
22
|
const DIAGNOSTIC_LIMIT = 8 * 1024;
|
|
23
|
+
const SESSION_LIMIT = 16 * 1024 * 1024;
|
|
22
24
|
const LEASE_MODE = 0o600;
|
|
23
25
|
const DIRECTORY_MODE = 0o700;
|
|
24
26
|
const PROCESS_LEASE_ENV = "PI_SUBAGENT_PROCESS_LEASE";
|
|
@@ -478,7 +480,7 @@ export class HerdrHostRuntime {
|
|
|
478
480
|
try {
|
|
479
481
|
if (input.task.kind !== "changeset")
|
|
480
482
|
throw new Error("Herdr assignment requires a changeset task.");
|
|
481
|
-
text = buildChangesetTaskPrompt({
|
|
483
|
+
text = `${buildChangesetTaskPrompt({
|
|
482
484
|
goal: input.goal,
|
|
483
485
|
contexts: input.contexts,
|
|
484
486
|
task: input.task,
|
|
@@ -486,7 +488,9 @@ export class HerdrHostRuntime {
|
|
|
486
488
|
worktreeCwd: allocation.worktreeCwd,
|
|
487
489
|
...(input.failure ? { failure: input.failure } : {}),
|
|
488
490
|
...(input.instruction ? { instruction: input.instruction } : {}),
|
|
489
|
-
})
|
|
491
|
+
})}\n\nTurn identity: ${input.attempt.correlationToken}:${input.attempt.prompts.length}`;
|
|
492
|
+
if (Buffer.byteLength(text, "utf8") > 96 * 1024)
|
|
493
|
+
throw new Error("Worker assignment exceeds 98304 bytes.");
|
|
490
494
|
}
|
|
491
495
|
catch (error) {
|
|
492
496
|
return { outcome: "not_prompted", diagnostic: `Worker assignment was not submitted: ${safeText(error)}` };
|
|
@@ -512,7 +516,7 @@ export class HerdrHostRuntime {
|
|
|
512
516
|
const prompted = await this.herdr.exec(promptArgs, promptOptions);
|
|
513
517
|
if (prompted.code !== 0 || prompted.killed) {
|
|
514
518
|
if (!prompted.killed && !context.signal.aborted && hasHerdrErrorCode(prompted, "agent_prompt_stalled")) {
|
|
515
|
-
return await this.reconcileDeliveredPrompt(input, allocation, context);
|
|
519
|
+
return await this.reconcileDeliveredPrompt(input, allocation, context, text);
|
|
516
520
|
}
|
|
517
521
|
return {
|
|
518
522
|
outcome: prompted.killed || context.signal.aborted ? "interrupted" : "unknown",
|
|
@@ -527,12 +531,12 @@ export class HerdrHostRuntime {
|
|
|
527
531
|
return { outcome: "unknown", diagnostic: `Worker prompt response is malformed: ${safeText(error)}` };
|
|
528
532
|
}
|
|
529
533
|
if (settled.status === "working")
|
|
530
|
-
return await this.reconcileDeliveredPrompt(input, allocation, context);
|
|
534
|
+
return await this.reconcileDeliveredPrompt(input, allocation, context, text);
|
|
531
535
|
if (settled.status === "blocked")
|
|
532
536
|
return { outcome: "blocked", diagnostic: await this.diagnostic(allocation, context, "Worker settled as blocked.") };
|
|
533
537
|
if (!SETTLED_AGENT_STATES.has(settled.status))
|
|
534
538
|
return { outcome: "unknown", diagnostic: `Worker prompt did not return a settled state: ${settled.status}.` };
|
|
535
|
-
return await this.candidateResult(input, allocation, context);
|
|
539
|
+
return await this.candidateResult(input, allocation, context, text);
|
|
536
540
|
}
|
|
537
541
|
async terminateWorker(input, context) {
|
|
538
542
|
try {
|
|
@@ -788,10 +792,16 @@ export class HerdrHostRuntime {
|
|
|
788
792
|
if (Object.keys(launch.env).some((name) => name !== EXECUTION_BUDGET_ENV || !this.executionBudget)) {
|
|
789
793
|
throw new Error("Herdr agent launch must not receive caller Role environment variables.");
|
|
790
794
|
}
|
|
795
|
+
const args = launch.args.filter((arg) => arg !== "--no-session");
|
|
796
|
+
if (args.length !== launch.args.length - 1)
|
|
797
|
+
throw new Error("Role launch must contain exactly one --no-session option.");
|
|
798
|
+
const sessionFile = this.workerSessionFile(allocation.leasePath, allocation.token);
|
|
799
|
+
// Pi initializes an existing empty session in place, retaining its private mode.
|
|
800
|
+
await (await open(sessionFile, "wx", 0o600)).close();
|
|
791
801
|
const response = await startPiAgent(this.herdr, {
|
|
792
802
|
name: allocation.agentName,
|
|
793
803
|
pane: allocation.paneId,
|
|
794
|
-
args:
|
|
804
|
+
args: [...args, "--session", sessionFile],
|
|
795
805
|
options,
|
|
796
806
|
shouldRetry: () => false,
|
|
797
807
|
});
|
|
@@ -812,7 +822,7 @@ export class HerdrHostRuntime {
|
|
|
812
822
|
}
|
|
813
823
|
});
|
|
814
824
|
}
|
|
815
|
-
async reconcileDeliveredPrompt(input, allocation, context, promptMayBeInFlight = true) {
|
|
825
|
+
async reconcileDeliveredPrompt(input, allocation, context, prompt, promptMayBeInFlight = true) {
|
|
816
826
|
for (;;) {
|
|
817
827
|
let lifecycle;
|
|
818
828
|
try {
|
|
@@ -862,6 +872,16 @@ export class HerdrHostRuntime {
|
|
|
862
872
|
diagnostic: await this.diagnostic(allocation, context, "Delivered prompt settled with a candidate."),
|
|
863
873
|
};
|
|
864
874
|
}
|
|
875
|
+
if (SETTLED_AGENT_STATES.has(lifecycle.status)) {
|
|
876
|
+
try {
|
|
877
|
+
if (await this.workerTurnFinished(allocation, prompt)) {
|
|
878
|
+
return { outcome: "blocked", diagnostic: "Worker finished without an exact changed clean committed candidate." };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
return { outcome: "unknown", diagnostic: `Worker session evidence cannot be verified: ${error instanceof SyntaxError ? "malformed JSONL" : safeText(error)}` };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
865
885
|
if (!promptMayBeInFlight && SETTLED_AGENT_STATES.has(lifecycle.status)) {
|
|
866
886
|
return {
|
|
867
887
|
outcome: "blocked",
|
|
@@ -879,7 +899,7 @@ export class HerdrHostRuntime {
|
|
|
879
899
|
}
|
|
880
900
|
}
|
|
881
901
|
}
|
|
882
|
-
async candidateResult(input, allocation, context) {
|
|
902
|
+
async candidateResult(input, allocation, context, prompt) {
|
|
883
903
|
let inspection;
|
|
884
904
|
try {
|
|
885
905
|
inspection = await this.inspectInFlightCandidate({ root: allocation.worktreeCwd, task: input.task, attempt: input.attempt }, this.childContext(context, GIT_INSPECTION_CAP_MS));
|
|
@@ -888,7 +908,7 @@ export class HerdrHostRuntime {
|
|
|
888
908
|
return { outcome: "unknown", diagnostic: `Settled worker candidate inspection failed: ${safeText(error)}` };
|
|
889
909
|
}
|
|
890
910
|
if (!inspection.valid || !inspection.clean || !this.isExpectedCandidate(input, inspection.candidate)) {
|
|
891
|
-
return await this.reconcileDeliveredPrompt(input, allocation, context, false);
|
|
911
|
+
return await this.reconcileDeliveredPrompt(input, allocation, context, prompt, false);
|
|
892
912
|
}
|
|
893
913
|
return {
|
|
894
914
|
outcome: "candidate",
|
|
@@ -1021,6 +1041,7 @@ export class HerdrHostRuntime {
|
|
|
1021
1041
|
}
|
|
1022
1042
|
if (!directoryPresent)
|
|
1023
1043
|
return;
|
|
1044
|
+
await this.removeWorkerSessionFile(allocation.leasePath, token);
|
|
1024
1045
|
try {
|
|
1025
1046
|
await rmdir(dirname(allocation.leasePath));
|
|
1026
1047
|
}
|
|
@@ -1047,6 +1068,77 @@ export class HerdrHostRuntime {
|
|
|
1047
1068
|
}
|
|
1048
1069
|
return true;
|
|
1049
1070
|
}
|
|
1071
|
+
workerSessionFile(leasePath, token) {
|
|
1072
|
+
this.assertLeasePath(leasePath, token);
|
|
1073
|
+
return `${leasePath}.session.jsonl`;
|
|
1074
|
+
}
|
|
1075
|
+
async sessionFileExists(path) {
|
|
1076
|
+
try {
|
|
1077
|
+
await lstat(path);
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
if (error.code === "ENOENT")
|
|
1082
|
+
return false;
|
|
1083
|
+
throw error;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async privateSessionFile(path) {
|
|
1087
|
+
let file;
|
|
1088
|
+
try {
|
|
1089
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1090
|
+
}
|
|
1091
|
+
catch (error) {
|
|
1092
|
+
if (error.code === "ENOENT")
|
|
1093
|
+
return undefined;
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const info = await file.stat();
|
|
1098
|
+
const uid = process.getuid?.();
|
|
1099
|
+
if (!info.isFile() || (info.mode & 0o077) !== 0 || (uid !== undefined && info.uid !== uid)
|
|
1100
|
+
|| info.size > SESSION_LIMIT)
|
|
1101
|
+
throw new Error("Worker session file is not private, regular, or bounded.");
|
|
1102
|
+
const chunks = [];
|
|
1103
|
+
let bytes = 0;
|
|
1104
|
+
while (bytes <= SESSION_LIMIT) {
|
|
1105
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, SESSION_LIMIT + 1 - bytes));
|
|
1106
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
1107
|
+
if (!bytesRead)
|
|
1108
|
+
break;
|
|
1109
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
1110
|
+
bytes += bytesRead;
|
|
1111
|
+
}
|
|
1112
|
+
if (bytes > SESSION_LIMIT)
|
|
1113
|
+
throw new Error("Worker session file exceeds the 16 MiB limit.");
|
|
1114
|
+
return Buffer.concat(chunks, bytes).toString("utf8");
|
|
1115
|
+
}
|
|
1116
|
+
finally {
|
|
1117
|
+
await file.close();
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async workerTurnFinished(allocation, prompt) {
|
|
1121
|
+
const path = this.workerSessionFile(allocation.leasePath, allocation.token);
|
|
1122
|
+
if (!await this.sessionFileExists(path))
|
|
1123
|
+
return false;
|
|
1124
|
+
await this.assertPrivateLeaseDirectories(allocation.leasePath, false);
|
|
1125
|
+
const session = await this.privateSessionFile(path);
|
|
1126
|
+
if (session === undefined)
|
|
1127
|
+
return false;
|
|
1128
|
+
try {
|
|
1129
|
+
return exactDirectTerminalTurn(session, prompt);
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
if (error instanceof SyntaxError && !session.endsWith("\n"))
|
|
1133
|
+
return false; // Pi may still be writing the last JSONL entry.
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async removeWorkerSessionFile(leasePath, token) {
|
|
1138
|
+
const path = this.workerSessionFile(leasePath, token);
|
|
1139
|
+
if (await this.privateSessionFile(path) !== undefined)
|
|
1140
|
+
await unlink(path);
|
|
1141
|
+
}
|
|
1050
1142
|
assertLeasePath(path, token) {
|
|
1051
1143
|
const parent = resolve(this.leaseDirectory, token);
|
|
1052
1144
|
const candidate = resolve(path);
|
package/dist/launch-runtime.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface LaunchRuntimeOptions {
|
|
|
18
18
|
cwd: string;
|
|
19
19
|
root: string;
|
|
20
20
|
}, context: OperationContext): Promise<void>;
|
|
21
|
-
|
|
21
|
+
inspectMainBase(input: {
|
|
22
22
|
root: string;
|
|
23
23
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
24
24
|
executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
|
package/dist/launch-runtime.js
CHANGED
|
@@ -167,7 +167,7 @@ export class RoleLaunchRuntime {
|
|
|
167
167
|
if (input.request.tasks.some((task) => task.kind === "changeset")) {
|
|
168
168
|
await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
|
|
169
169
|
}
|
|
170
|
-
const main = await this.options.
|
|
170
|
+
const main = await this.options.inspectMainBase({ root }, context);
|
|
171
171
|
const required = new Map();
|
|
172
172
|
const addRequired = (role, modelClass) => {
|
|
173
173
|
const modelClasses = required.get(role) ?? new Set();
|
package/dist/runner.d.ts
CHANGED
|
@@ -201,6 +201,9 @@ export interface GitRuntime {
|
|
|
201
201
|
inspectMain(input: {
|
|
202
202
|
root: string;
|
|
203
203
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
204
|
+
inspectMainBase(input: {
|
|
205
|
+
root: string;
|
|
206
|
+
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
204
207
|
allocateWorktree(input: {
|
|
205
208
|
root: string;
|
|
206
209
|
baseRoot?: string;
|
|
@@ -302,6 +305,7 @@ export declare class IsolatedRunner {
|
|
|
302
305
|
}>;
|
|
303
306
|
}>;
|
|
304
307
|
invalidIds: string[];
|
|
308
|
+
states: RunState[];
|
|
305
309
|
}>;
|
|
306
310
|
/** Classify orphaned work under the same repository lease as productive execution.
|
|
307
311
|
* Status remains read-only; Main alone chooses any subsequent action. */
|
package/dist/runner.js
CHANGED
|
@@ -520,8 +520,8 @@ export class IsolatedRunner {
|
|
|
520
520
|
this.currentPolicy = currentPolicy;
|
|
521
521
|
}
|
|
522
522
|
async listRequests(root) {
|
|
523
|
-
const { states, invalidIds } = await this.store.list(root);
|
|
524
|
-
return { invalidIds, requests:
|
|
523
|
+
const { states: entries, invalidIds } = await this.store.list(root);
|
|
524
|
+
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
|
|
525
525
|
id: state.request.id, name: state.request.goal,
|
|
526
526
|
status: state.status === "completed" && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
527
527
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
@@ -1237,7 +1237,7 @@ export class IsolatedRunner {
|
|
|
1237
1237
|
&& stagedDependencySnapshot(state, task.taskId, action.expectedTip));
|
|
1238
1238
|
if (!ready.length)
|
|
1239
1239
|
throw new Error("No dependent task is ready on this staged snapshot.");
|
|
1240
|
-
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1240
|
+
await this.requireExactIntegration(handle, generation, receipts, scope, true);
|
|
1241
1241
|
return await this.run(handle, scope, undefined, action.expectedTip);
|
|
1242
1242
|
}
|
|
1243
1243
|
if (action.action === "cleanup") {
|
|
@@ -1559,7 +1559,7 @@ export class IsolatedRunner {
|
|
|
1559
1559
|
await this.saveProductive(handle);
|
|
1560
1560
|
}
|
|
1561
1561
|
}
|
|
1562
|
-
async requireExactIntegration(handle, generation, receipts, scope) {
|
|
1562
|
+
async requireExactIntegration(handle, generation, receipts, scope, allowDirtyMain = false) {
|
|
1563
1563
|
const root = handle.state.root;
|
|
1564
1564
|
const worktree = generation.worktree;
|
|
1565
1565
|
const tip = generation.correction
|
|
@@ -1577,7 +1577,8 @@ export class IsolatedRunner {
|
|
|
1577
1577
|
throw new Error("Selected worker has no owned worktree.");
|
|
1578
1578
|
await this.callProductive(handle, scope, (context) => this.integrationGit.inspectWorker(root, worker, stage.source, context.signal));
|
|
1579
1579
|
}
|
|
1580
|
-
const main = await this.callProductive(handle, scope, (context) =>
|
|
1580
|
+
const main = await this.callProductive(handle, scope, (context) => allowDirtyMain
|
|
1581
|
+
? this.gitRuntime.inspectMainBase({ root }, context) : this.gitRuntime.inspectMain({ root }, context));
|
|
1581
1582
|
if (!sameIdentity(main, generation.expectedMain))
|
|
1582
1583
|
throw new Error("Main changed or became dirty before promotion.");
|
|
1583
1584
|
}
|
|
@@ -1674,7 +1675,7 @@ export class IsolatedRunner {
|
|
|
1674
1675
|
}
|
|
1675
1676
|
let actualMain;
|
|
1676
1677
|
try {
|
|
1677
|
-
actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.
|
|
1678
|
+
actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
|
|
1678
1679
|
}
|
|
1679
1680
|
catch (error) {
|
|
1680
1681
|
this.rethrowStopped(error);
|
|
@@ -1694,7 +1695,7 @@ export class IsolatedRunner {
|
|
|
1694
1695
|
}
|
|
1695
1696
|
if (stagedSnapshot) {
|
|
1696
1697
|
const generation = state.integration.generations.at(-1);
|
|
1697
|
-
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope);
|
|
1698
|
+
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
|
|
1698
1699
|
}
|
|
1699
1700
|
const wave = {
|
|
1700
1701
|
number: state.waves.length + 1,
|
|
@@ -1953,14 +1954,22 @@ export class IsolatedRunner {
|
|
|
1953
1954
|
const baseRoot = wave.base.head === state.main.head ? state.root : state.integration.generations.at(-1)?.worktree?.path;
|
|
1954
1955
|
if (!baseRoot)
|
|
1955
1956
|
throw new Error("Staged dependency checkout is unavailable.");
|
|
1956
|
-
|
|
1957
|
+
const inspectBase = baseRoot === state.root ? this.gitRuntime.inspectMainBase.bind(this.gitRuntime) : this.gitRuntime.inspectMain.bind(this.gitRuntime);
|
|
1958
|
+
if (!sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
|
|
1957
1959
|
throw new Error("Staged dependency snapshot drifted before text launch.");
|
|
1958
1960
|
}
|
|
1959
|
-
const isolated = await createChildWorktree(baseRoot, `${state.request.id}-${task.taskId}-text-${attempt.number}`, undefined, context.signal)
|
|
1961
|
+
const isolated = await createChildWorktree(baseRoot, `${state.request.id}-${task.taskId}-text-${attempt.number}`, undefined, context.signal, async (worktree) => {
|
|
1962
|
+
if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
|
|
1963
|
+
throw new Error("Staged dependency snapshot drifted before text worktree creation.");
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1960
1966
|
if (!isolated)
|
|
1961
1967
|
throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
|
|
1962
1968
|
let result;
|
|
1963
1969
|
try {
|
|
1970
|
+
if (!sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
|
|
1971
|
+
throw new Error("Staged dependency snapshot drifted during text worktree creation.");
|
|
1972
|
+
}
|
|
1964
1973
|
const launchHandle = await this.coordinatorRuntime.acquireLaunch(request.role, request.modelClass, context);
|
|
1965
1974
|
if (launchHandle.launch.role !== request.role || launchHandle.launch.modelClass !== request.modelClass) {
|
|
1966
1975
|
await withTransientLaunch(launchHandle, async () => {
|
|
@@ -1994,7 +2003,7 @@ export class IsolatedRunner {
|
|
|
1994
2003
|
if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
|
|
1995
2004
|
throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
|
|
1996
2005
|
}
|
|
1997
|
-
const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.
|
|
2006
|
+
const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
|
|
1998
2007
|
if (!sameIdentity(actualMain, state.main))
|
|
1999
2008
|
throw new Error("Main drifted during text task execution.");
|
|
2000
2009
|
const wave = state.waves.at(-1);
|
|
@@ -2003,7 +2012,7 @@ export class IsolatedRunner {
|
|
|
2003
2012
|
if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
|
|
2004
2013
|
throw new Error("Staged dependency snapshot was superseded during text task execution.");
|
|
2005
2014
|
}
|
|
2006
|
-
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope);
|
|
2015
|
+
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
|
|
2007
2016
|
}
|
|
2008
2017
|
attempt.status = "completed";
|
|
2009
2018
|
attempt.failure = undefined;
|
package/docs/orchestration.md
CHANGED
|
@@ -62,7 +62,7 @@ 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
|
-
Only Roles
|
|
65
|
+
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 or write to Main's checkout. A direct `changeset` or Role with write-capable base tools is rejected; use isolated mode for implementation. Parallel tasks run concurrently and report in request order. A chain substitutes only the preceding successful output and stops at its first failure.
|
|
66
66
|
|
|
67
67
|
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
68
|
|
|
@@ -91,9 +91,9 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
|
|
|
91
91
|
- `contextFrom` may reference completed text tasks only and preserves declaration order.
|
|
92
92
|
- A graph containing changesets requires final checks. The repository root must have a `package.json` test script; admission prepends canonical `pnpm test` if absent from final checks. Combined validation rejects a changed root test script.
|
|
93
93
|
|
|
94
|
-
The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.** Main selects exact candidates via `subagent_stage` into an owned integration checkout in explicit order. Each stage is a committed merge of the candidate, not a mutation of Main. Conflicts retain the checkout for Main to resolve; `resolve` verifies the exact two-parent merge instead of replaying an uncertain merge. Main can explicitly run dependent tasks from an exact staged tip with `subagent_integrate advance` and stage their results afterward. Independent candidates may be staged without re-running workers.
|
|
94
|
+
Admission and worker-wave dispatch pin Main's exact committed branch, HEAD, and tree without inspecting or modifying its staged, unstaged, or untracked edits. Worker allocation verifies the same committed identity around worktree creation, including resumed waves; staged-dependent waves verify their clean integration snapshot. A dirty Main can therefore produce a checked retained candidate, but branch/HEAD drift stops new waves. The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.** Main selects exact candidates via `subagent_stage` into an owned integration checkout in explicit order. Each stage is a committed merge of the candidate, not a mutation of Main. Conflicts retain the checkout for Main to resolve; `resolve` verifies the exact two-parent merge instead of replaying an uncertain merge. Main can explicitly run dependent tasks from an exact staged tip with `subagent_integrate advance` and stage their results afterward. Independent candidates may be staged without re-running workers.
|
|
95
95
|
|
|
96
|
-
Once all selected work is staged, Main calls `subagent_integrate validate` on the exact clean combined tip. This runs every final check, including `pnpm test`, and optional final judgment in the integration checkout before any Main mutation. A failed check/review can be followed by one Main-authored committed correction in that checkout, recorded with `correct`, then another `validate`. `promote` requires exact passing combined evidence and unchanged Main and uses a guarded update to Main. Only proven promotion leads to selected-worker termination and verified cleanup. A changed candidate, integration tip, or Main identity invalidates prior evidence or blocks promotion; review accepts only exact `PASS`.
|
|
96
|
+
Once all selected work is staged, Main calls `subagent_integrate validate` on the exact clean combined tip. This runs every final check, including `pnpm test`, and optional final judgment in the integration checkout before any Main mutation. A failed check/review can be followed by one Main-authored committed correction in that checkout, recorded with `correct`, then another `validate`. Staging, combined validation, and `promote` require clean Main at the recorded committed identity; they stay blocked by dirty edits even when the checked candidate is retained, and no dirty work is replayed or merged automatically. `promote` requires exact passing combined evidence and unchanged Main and uses a guarded update to Main. Only proven promotion leads to selected-worker termination and verified cleanup. A changed candidate, integration tip, or Main identity invalidates prior evidence or blocks promotion; review accepts only exact `PASS`.
|
|
97
97
|
|
|
98
98
|
### Readiness and Main actions
|
|
99
99
|
|
|
@@ -118,7 +118,7 @@ State version 5 is stored privately under:
|
|
|
118
118
|
<agent-dir>/config/pi-subagent/state/<repository-hash>/<request-id>.json
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously.
|
|
121
|
+
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. `subagent_abort` performs exact worker termination and teardown.
|
|
122
122
|
|
|
123
123
|
Productive execution has no whole-run wall-clock deadline. Long productive work and later resumes remain valid. The request retains its original policy snapshot and correction count; a resume cannot refill them. Current configuration may tighten the correction allowance. Child idle/hard runtime, subprocess I/O, status inspection, termination, cleanup, and outer abort remain bounded independently.
|
|
124
124
|
|
package/extensions/admission.ts
CHANGED
|
@@ -7,7 +7,11 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
7
7
|
]);
|
|
8
8
|
|
|
9
9
|
export function roleCanWrite(role: Role): boolean {
|
|
10
|
-
return
|
|
10
|
+
return role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function roleIsReadOnlyScout(role: Role): boolean {
|
|
14
|
+
return !roleCanWrite(role) && role.extensions.length === 0 && !role.mcps?.length;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export interface CheckoutAdmission {
|
package/extensions/isolated.ts
CHANGED
|
@@ -76,7 +76,7 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
76
76
|
context,
|
|
77
77
|
resolveRoot,
|
|
78
78
|
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
79
|
-
|
|
79
|
+
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
80
80
|
executionBudget,
|
|
81
81
|
});
|
|
82
82
|
return {
|
|
@@ -117,10 +117,10 @@ export function workspaceWidgetLines(state: RunState): string[] | undefined {
|
|
|
117
117
|
const rows = state.tasks.flatMap((taskState) => {
|
|
118
118
|
const task = state.request.tasks.find((candidate) => candidate.id === taskState.taskId);
|
|
119
119
|
if (!task) return [];
|
|
120
|
-
const status =
|
|
120
|
+
const status = workspaceStatus(taskState.status);
|
|
121
121
|
if (taskState.kind === "text") {
|
|
122
122
|
return taskState.status === "running" || taskState.status === "needs_attention"
|
|
123
|
-
? [`${status === "attention" ? "!" :
|
|
123
|
+
? [`${status === "attention" ? "!" : "◌"} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? status)}` : status}`]
|
|
124
124
|
: [];
|
|
125
125
|
}
|
|
126
126
|
const attempt = taskState.attempts.at(-1);
|
|
@@ -136,17 +136,17 @@ export function workspaceWidgetLines(state: RunState): string[] | undefined {
|
|
|
136
136
|
: status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? "needs attention")}` : status;
|
|
137
137
|
return `${symbol} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${detail}${allocation ? ` · ${compactWidgetField(allocation.label)}` : ""}`;
|
|
138
138
|
});
|
|
139
|
-
|
|
139
|
+
if (!rows.length) return undefined;
|
|
140
|
+
return state.status === "aborted"
|
|
141
|
+
? [`■ I ${compactWidgetField(state.request.id)} · request aborted`, ...rows]
|
|
142
|
+
: rows;
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
function
|
|
143
|
-
const key = `${state.root}\0${state.request.id}`;
|
|
144
|
-
const rows = workspaceWidgetLines(state);
|
|
145
|
-
if (rows) rowsByRequest.set(key, rows);
|
|
146
|
-
else rowsByRequest.delete(key);
|
|
145
|
+
function renderWorkspaceWidget(ctx: ExtensionContext, rowsByRequest: Map<string, string[]>): void {
|
|
147
146
|
if (!ctx.hasUI) return;
|
|
148
|
-
const
|
|
149
|
-
|
|
147
|
+
const rowOrder = (row: string) => row.startsWith("!") ? 0 : row.startsWith("◌") ? 1
|
|
148
|
+
: row.endsWith(" · request aborted") ? 2 : 3;
|
|
149
|
+
const allRows = [...rowsByRequest.values()].flat().sort((a, b) => rowOrder(a) - rowOrder(b));
|
|
150
150
|
const shown = allRows.length > MAX_WORKSPACE_WIDGET_LINES
|
|
151
151
|
? [...allRows.slice(0, MAX_WORKSPACE_WIDGET_LINES - 1), `+${allRows.length - MAX_WORKSPACE_WIDGET_LINES + 1} more · /subagent`]
|
|
152
152
|
: allRows;
|
|
@@ -164,7 +164,11 @@ function updateWorkspaceWidget(ctx: ExtensionContext, state: RunState, rowsByReq
|
|
|
164
164
|
|
|
165
165
|
function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
|
|
166
166
|
try {
|
|
167
|
-
|
|
167
|
+
const key = `${state.root}\0${state.request.id}`;
|
|
168
|
+
const rows = workspaceWidgetLines(state);
|
|
169
|
+
if (rows) rowsByRequest.set(key, rows);
|
|
170
|
+
else rowsByRequest.delete(key);
|
|
171
|
+
renderWorkspaceWidget(ctx, rowsByRequest);
|
|
168
172
|
} catch (error) {
|
|
169
173
|
console.error("Pi Subagent workspace widget update failed.", error);
|
|
170
174
|
}
|
|
@@ -400,7 +404,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
400
404
|
|
|
401
405
|
export interface IsolatedSurface {
|
|
402
406
|
execute(params: unknown, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
403
|
-
inventory(cwd: string): Promise<IsolatedInventory>;
|
|
407
|
+
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
404
408
|
recover(cwd: string): Promise<string>;
|
|
405
409
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
406
410
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
@@ -425,6 +429,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
425
429
|
let latestCtx: ExtensionContext | undefined;
|
|
426
430
|
let components: IsolatedExtensionComponents | undefined;
|
|
427
431
|
const workspaceRowsByRequest = new Map<string, string[]>();
|
|
432
|
+
let savedRevision = 0;
|
|
428
433
|
const stateListeners = new Set<(state: RunState) => void>();
|
|
429
434
|
const activeJobs = new Set<AbortController>();
|
|
430
435
|
const jobOwners = new Map<string, () => boolean>();
|
|
@@ -442,6 +447,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
442
447
|
policy: options.policy,
|
|
443
448
|
currentPolicy: options.currentPolicy,
|
|
444
449
|
onStateSaved: (state) => {
|
|
450
|
+
savedRevision += 1;
|
|
445
451
|
for (const listener of stateListeners) listener(state);
|
|
446
452
|
const owner = jobOwners.get(`${state.root}\0${state.request.id}`);
|
|
447
453
|
if (!sessionClosed && (owner === undefined || owner())) {
|
|
@@ -501,6 +507,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
501
507
|
activeJobs.add(controller);
|
|
502
508
|
jobOwners.set(key, canDeliver);
|
|
503
509
|
const notified = new Set<string>();
|
|
510
|
+
const attentionNotified = new Set<string>();
|
|
504
511
|
let candidateListener: ((state: RunState) => void) | undefined;
|
|
505
512
|
const finish = () => {
|
|
506
513
|
activeJobs.delete(controller);
|
|
@@ -548,6 +555,15 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
548
555
|
candidateListener = (state) => {
|
|
549
556
|
if (state.root !== root || state.request.id !== id || state.status !== "running" || !canDeliver()) return;
|
|
550
557
|
latestState = state;
|
|
558
|
+
for (const task of state.tasks) {
|
|
559
|
+
if (task.status !== "needs_attention") {
|
|
560
|
+
attentionNotified.delete(task.taskId);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (attentionNotified.has(task.taskId)) continue;
|
|
564
|
+
attentionNotified.add(task.taskId);
|
|
565
|
+
deliver(`Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
|
|
566
|
+
}
|
|
551
567
|
for (const candidate of state.integration.candidates) {
|
|
552
568
|
const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
|
|
553
569
|
if (candidate.decision || notified.has(identity)) continue;
|
|
@@ -653,9 +669,20 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
653
669
|
},
|
|
654
670
|
});
|
|
655
671
|
return {
|
|
656
|
-
async inventory(cwd) {
|
|
672
|
+
async inventory(cwd, current) {
|
|
657
673
|
const root = await lookupRoot(cwd);
|
|
658
|
-
|
|
674
|
+
const revision = savedRevision;
|
|
675
|
+
const { requests, invalidIds, states } = await getComponents().runner.listRequests(root);
|
|
676
|
+
if (current?.() && !sessionClosed && revision === savedRevision) {
|
|
677
|
+
workspaceRowsByRequest.clear();
|
|
678
|
+
for (const state of states) {
|
|
679
|
+
const rows = workspaceWidgetLines(state);
|
|
680
|
+
if (rows) workspaceRowsByRequest.set(`${state.root}\0${state.request.id}`, rows);
|
|
681
|
+
}
|
|
682
|
+
try { renderWorkspaceWidget(latestContext(), workspaceRowsByRequest); }
|
|
683
|
+
catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
|
|
684
|
+
}
|
|
685
|
+
return { root, requests, invalidIds };
|
|
659
686
|
},
|
|
660
687
|
async recover(cwd) {
|
|
661
688
|
const root = await lookupRoot(cwd);
|
|
@@ -30,7 +30,7 @@ export interface IsolatedInventory {
|
|
|
30
30
|
* validation and queue operation. They must not await between those operations. */
|
|
31
31
|
export interface SubagentCommandAdapter {
|
|
32
32
|
direct(ctx: ExtensionContext): readonly DirectTask[];
|
|
33
|
-
isolated(cwd: string): Promise<IsolatedInventory>;
|
|
33
|
+
isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
|
|
34
34
|
recover(cwd: string): Promise<string>;
|
|
35
35
|
inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ tabId: string; name: string; sessionFile: string }>;
|
|
36
36
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
@@ -94,7 +94,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
94
94
|
const direct = adapter.direct(ctx);
|
|
95
95
|
let inventory: IsolatedInventory | undefined;
|
|
96
96
|
let unavailable: string | undefined;
|
|
97
|
-
try { inventory = await adapter.isolated(ctx.cwd); }
|
|
97
|
+
try { inventory = await adapter.isolated(ctx.cwd, current); }
|
|
98
98
|
catch (error) { unavailable = errorText(error); }
|
|
99
99
|
if (!valid()) return;
|
|
100
100
|
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
package/extensions/subagent.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@
|
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
|
|
5
5
|
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
|
|
6
6
|
import { DEFAULT_EXECUTION_POLICY, DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
|
|
7
|
-
import { createCheckoutAdmission, roleCanWrite } from "./admission.ts";
|
|
7
|
+
import { createCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
|
|
8
8
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
9
9
|
import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts";
|
|
10
10
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
@@ -210,7 +210,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
210
210
|
}
|
|
211
211
|
return [...grouped.values()];
|
|
212
212
|
},
|
|
213
|
-
isolated:
|
|
213
|
+
isolated: isolatedSurface.inventory,
|
|
214
214
|
recover: (cwd) => isolatedSurface.recover(cwd),
|
|
215
215
|
async inspectInTab(root, id, ctx, current) {
|
|
216
216
|
const notices = await isolatedSurface.inspect(root, id);
|
|
@@ -218,7 +218,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
218
218
|
const snapshot = notices.join("\n");
|
|
219
219
|
if (Buffer.byteLength(snapshot, "utf8") > 32_000) throw new Error("Status exceeds the 32 KiB inspection limit; use subagent_status for exact evidence.");
|
|
220
220
|
const role = loadRoles().find((candidate) => candidate.name === "scout");
|
|
221
|
-
if (!role ||
|
|
221
|
+
if (!role || !roleIsReadOnlyScout(role)) throw new Error("Status inspection requires a configured read-only scout Role without extensions or MCP servers.");
|
|
222
222
|
const prepared = prepareRoleLaunch(pi, ctx, { role, task: DELEGATE_TASK, modelClass: "fast" });
|
|
223
223
|
const herdr = createDirectHerdr(pi, ctx.cwd, currentPolicy().childIdleMs);
|
|
224
224
|
const controller = new AbortController();
|