@henryqw/pi-subagent 22.2.3 → 23.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTEXT.md 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 assignment in a Herdr tab in Main's current workspace.
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 only Roles proven read-only from declared tools, extensions, and MCP resources. Direct changesets and write-capable Roles require isolated mode.
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
- ## Use
22
+ ## Works with
23
23
 
24
- ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
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
- 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.
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
+ ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
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 with known read-only tools and no extensions or MCP servers may run direct. Write-capable Roles and direct `changeset` tasks are rejected; use isolated mode for implementation. Parallel tasks run independently; chains replace each literal `{previous}` with the preceding successful answer and stop on failure.
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. Use `subagent_status` if delivery is missed or you need exact current state.
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
- The package root exports the Role loader and launch APIs, the FIFO ephemeral executor, child-worktree helpers, exact review and working-change evidence helpers, checked isolated schemas, runtimes, and runner types.
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. Read-only direct Roles cannot write through their declared tools. This is a capability check, not an OS sandbox; external processes and changes to Main's checkout can still make a concurrent read stale. Retained-work reports identify exact resources for deliberate recovery.
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.
@@ -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">;
@@ -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
- export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
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
- if (!userId || ancestor !== userId || !final || final.stopReason !== "stop" || !Array.isArray(final.content)) {
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) => {
@@ -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;
@@ -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 before = await this.inspectWorkspace(baseRoot, false, context);
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 this.inspectWorkspace(baseRoot, false, context);
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 this.inspectWorkspace(baseRoot, false, context);
128
+ const after = await inspectBase();
110
129
  if (!sameIdentity(after, input.attempt.waveBase)) {
111
130
  return {
112
131
  kind: "worktree",
@@ -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;
@@ -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: launch.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);
@@ -18,7 +18,7 @@ export interface LaunchRuntimeOptions {
18
18
  cwd: string;
19
19
  root: string;
20
20
  }, context: OperationContext): Promise<void>;
21
- inspectMain(input: {
21
+ inspectMainBase(input: {
22
22
  root: string;
23
23
  }, context: OperationContext): Promise<WorkspaceIdentity>;
24
24
  executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
@@ -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.inspectMain({ root }, context);
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: states.map(({ state }) => ({
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) => this.gitRuntime.inspectMain({ root }, 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.inspectMain({ root: state.root }, context));
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
- if (!sameIdentity(await this.gitRuntime.inspectMain({ root: baseRoot }, context), wave.base)) {
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.inspectMain({ root: state.root }, context));
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/dist/schema.js CHANGED
@@ -65,7 +65,7 @@ export const ResumeRequestSchema = Type.Union([
65
65
  Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema }, { additionalProperties: false }),
66
66
  Type.Object({ id: IdSchema, action: Type.Literal("verify"), taskId: IdSchema }, { additionalProperties: false }),
67
67
  Type.Object({ id: IdSchema, action: Type.Literal("finalize") }, { additionalProperties: false }),
68
- ]);
68
+ ], { type: "object" });
69
69
  const AllocationLifecycleFields = {
70
70
  generation: Type.Integer({ minimum: 1, maximum: 2 }),
71
71
  token: Type.String({ pattern: TOKEN_PATTERN }),
@@ -167,7 +167,7 @@ export const IntegrationActionSchema = Type.Union([
167
167
  taskId: Type.Optional(IdSchema),
168
168
  attempt: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })),
169
169
  }, { additionalProperties: false }),
170
- ]);
170
+ ], { type: "object" });
171
171
  export function parseIntegrationAction(value) {
172
172
  if (!Check(IntegrationActionSchema, value))
173
173
  throw schemaValidationError("subagent_integrate requires an exact generation and tip", Errors(IntegrationActionSchema, value));
@@ -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 with known read-only tools and no extensions or MCP servers may run direct. A direct `changeset` or write-capable Role is rejected; use isolated mode for implementation. Parallel reads run concurrently and report in request order. A chain substitutes only the preceding successful output and stops at its first failure.
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. Completion or attention is sent as a Pi follow-up to the launching session. `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.
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
 
@@ -7,7 +7,11 @@ const READ_ONLY_TOOLS = new Set([
7
7
  ]);
8
8
 
9
9
  export function roleCanWrite(role: Role): boolean {
10
- return Boolean(role.extensions.length || role.mcps?.length || role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool)));
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 {
@@ -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
- inspectMain: async (input, operation) => await git.inspectMain(input, operation),
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 = state.status === "aborted" ? "aborted" : workspaceStatus(taskState.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" ? "!" : status === "aborted" ? "■" : "◌"} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? status)}` : status}`]
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
- return rows.length ? rows : undefined;
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 updateWorkspaceWidget(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
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 allRows = [...rowsByRequest.values()].flat().sort((a, b) =>
149
- (a.startsWith("!") ? 0 : a.startsWith("◌") ? 1 : 2) - (b.startsWith("!") ? 0 : b.startsWith("◌") ? 1 : 2));
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
- updateWorkspaceWidget(ctx, state, rowsByRequest);
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
- return { root, ...await getComponents().runner.listRequests(root) };
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");
@@ -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: (cwd) => isolatedSurface.inventory(cwd),
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 || roleCanWrite(role)) throw new Error("Status inspection requires a configured read-only scout 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();
@@ -42,7 +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
- export const DelegateTaskSchema = Type.Union([DirectWorkflowSchema, ExecuteRequestSchema]);
45
+ export const DelegateTaskSchema = Type.Union([DirectWorkflowSchema, ExecuteRequestSchema], { type: "object" });
46
46
  export const WorkflowSchema = DirectWorkflowSchema;
47
47
 
48
48
  export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "22.2.3",
3
+ "version": "23.0.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",