@henryqw/pi-subagent 27.0.0 → 27.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.d.ts CHANGED
@@ -121,6 +121,7 @@ export declare const ResumeRequestSchema: Type.TUnion<[Type.TObject<{
121
121
  id: Type.TString;
122
122
  action: Type.TLiteral<"retry">;
123
123
  taskId: Type.TString;
124
+ instruction: Type.TOptional<Type.TString>;
124
125
  }>, Type.TObject<{
125
126
  id: Type.TString;
126
127
  action: Type.TLiteral<"verify">;
@@ -133,6 +134,7 @@ export declare const ResumeRequestParameters: Type.TObject<{
133
134
  id: Type.TString;
134
135
  action: Type.TUnion<[Type.TLiteral<"retry">, Type.TLiteral<"verify">, Type.TLiteral<"finalize">]>;
135
136
  taskId: Type.TOptional<Type.TString>;
137
+ instruction: Type.TOptional<Type.TString>;
136
138
  }>;
137
139
  export type IdOnly = Static<typeof IdOnlySchema>;
138
140
  export type CheckCommand = Static<typeof CheckCommandSchema>;
@@ -2389,6 +2391,10 @@ export declare const StatusOutputSchema: Type.TObject<{
2389
2391
  scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
2390
2392
  taskId: Type.TOptional<Type.TString>;
2391
2393
  failure: Type.TOptional<Type.TString>;
2394
+ failedReview: Type.TOptional<Type.TObject<{
2395
+ verdict: Type.TString;
2396
+ verdictTruncated: Type.TBoolean;
2397
+ }>>;
2392
2398
  }>>;
2393
2399
  createdAt: Type.TInteger;
2394
2400
  updatedAt: Type.TInteger;
@@ -2421,6 +2427,7 @@ export declare const StatusOutputSchema: Type.TObject<{
2421
2427
  id: Type.TString;
2422
2428
  action: Type.TLiteral<"retry">;
2423
2429
  taskId: Type.TString;
2430
+ instruction: Type.TOptional<Type.TString>;
2424
2431
  }>, Type.TObject<{
2425
2432
  id: Type.TString;
2426
2433
  action: Type.TLiteral<"verify">;
package/dist/schema.js CHANGED
@@ -62,7 +62,7 @@ export const ExecuteRequestSchema = Type.Object({
62
62
  }, { additionalProperties: false });
63
63
  export const IdOnlySchema = Type.Object({ id: IdSchema }, { additionalProperties: false });
64
64
  export const ResumeRequestSchema = Type.Union([
65
- Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema }, { additionalProperties: false }),
65
+ Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema, instruction: Type.Optional(TextSchema) }, { 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
  ]);
@@ -75,6 +75,7 @@ export const ResumeRequestParameters = Type.Object({
75
75
  ResumeRequestSchema.anyOf[2].properties.action,
76
76
  ]),
77
77
  taskId: Type.Optional(IdSchema),
78
+ instruction: ResumeRequestSchema.anyOf[0].properties.instruction,
78
79
  }, { additionalProperties: false });
79
80
  const AllocationLifecycleFields = {
80
81
  generation: Type.Integer({ minimum: 1, maximum: 2 }),
@@ -466,6 +467,7 @@ export const StatusOutputSchema = Type.Object({
466
467
  scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
467
468
  taskId: Type.Optional(IdSchema),
468
469
  failure: Type.Optional(Type.String()),
470
+ failedReview: Type.Optional(Type.Object({ verdict: Type.String(), verdictTruncated: Type.Boolean() })),
469
471
  })),
470
472
  createdAt: TimestampSchema,
471
473
  updatedAt: TimestampSchema,
@@ -635,7 +637,10 @@ export function parseResumeRequest(value) {
635
637
  if (!Check(ResumeRequestSchema, value)) {
636
638
  throw schemaValidationError("subagent_resume request must match one strict action", Errors(ResumeRequestSchema, value));
637
639
  }
638
- return value;
640
+ const request = value;
641
+ if ("instruction" in request && request.instruction !== undefined)
642
+ requireExactAllocationText(request.instruction, "Retry instruction");
643
+ return request;
639
644
  }
640
645
  export function sameIdentity(left, right) {
641
646
  return left.branch === right.branch && left.head === right.head && left.index === right.index && left.tree === right.tree;
@@ -1080,11 +1085,11 @@ export function parseRunState(value) {
1080
1085
  }
1081
1086
  for (let promptIndex = 0; promptIndex < attempt.prompts.length; promptIndex += 1) {
1082
1087
  const prompt = attempt.prompts[promptIndex];
1083
- if ((prompt.kind === "followup") !== (prompt.instruction !== undefined)) {
1084
- throw new Error(`Follow-up prompt history for ${definition.id} has invalid instruction evidence.`);
1088
+ if (prompt.kind === "followup" ? prompt.instruction === undefined : prompt.kind === "initial" && prompt.instruction !== undefined) {
1089
+ throw new Error(`Prompt history for ${definition.id} has invalid instruction evidence.`);
1085
1090
  }
1086
1091
  if (prompt.instruction !== undefined) {
1087
- requireExactAllocationText(prompt.instruction, `Follow-up prompt instruction for ${definition.id}`);
1092
+ requireExactAllocationText(prompt.instruction, `Prompt instruction for ${definition.id}`);
1088
1093
  }
1089
1094
  if (promptIndex === 0)
1090
1095
  continue;
package/dist/store.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare class FileRunStore {
48
48
  states: RunStateHandle[];
49
49
  invalidIds: string[];
50
50
  }>;
51
+ /** Raw text of a state file, for evidence when it does not parse. */
52
+ readRaw(root: string, id: string): Promise<string>;
51
53
  private handle;
52
54
  }
53
55
  export {};
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
- import { mkdir, open, readdir, realpath } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
6
6
  import { check, lock } from "proper-lockfile";
@@ -272,8 +272,13 @@ export class FileRunStore {
272
272
  const entries = await this.stateEntries(root);
273
273
  const states = [];
274
274
  const invalidIds = [];
275
- for (const entry of entries.filter((item) => item.isFile())) {
275
+ for (const entry of entries) {
276
276
  const id = entry.name.slice(0, -5);
277
+ // A symlink, directory, or other non-regular entry is unreadable state, never absent state.
278
+ if (!entry.isFile()) {
279
+ invalidIds.push(id);
280
+ continue;
281
+ }
277
282
  try {
278
283
  states.push(await this.load(root, id));
279
284
  }
@@ -283,6 +288,13 @@ export class FileRunStore {
283
288
  }
284
289
  return { states, invalidIds };
285
290
  }
291
+ /** Raw text of a state file, for evidence when it does not parse. */
292
+ async readRaw(root, id) {
293
+ const path = this.statePath(root, id);
294
+ if (!(await lstat(path)).isFile())
295
+ throw new Error(`State entry ${id} is not a regular file.`);
296
+ return await readTextFileBounded(path, STATE_MAX_BYTES);
297
+ }
286
298
  handle(state, path) {
287
299
  return new RunStateHandle(state, path, async (contents) => {
288
300
  await this.assertSafeDestination(state.root, path);
@@ -24,6 +24,9 @@ export type WorktreePayload = {
24
24
  commits?: number;
25
25
  dirty?: boolean;
26
26
  };
27
+ export declare const WORKTREES_DIRNAME = ".worktrees";
28
+ /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
29
+ export declare const OWNED_BRANCH_PATTERN: RegExp;
27
30
  export declare class WorktreeSetupError extends Error {
28
31
  name: string;
29
32
  readonly worktree: WorktreeInfo;
package/dist/worktree.js CHANGED
@@ -3,8 +3,10 @@ import { existsSync, lstatSync } from "node:fs";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { runGit } from "./git-process.js";
6
- const WORKTREES_DIRNAME = ".worktrees";
6
+ export const WORKTREES_DIRNAME = ".worktrees";
7
7
  const BRANCH_NAMESPACE = "pi-subagent";
8
+ /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
9
+ export const OWNED_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
8
10
  export class WorktreeSetupError extends Error {
9
11
  name = "WorktreeSetupError";
10
12
  worktree;
@@ -112,7 +112,7 @@ The user's request can authorize the declared local graph, checks, review, and M
112
112
 
113
113
  `/subagent` opens a native picker for direct recovery and isolated inspection; locally owned active changesets may queue an optional same-worker revision or drain all still-pending instructions into the native editor and submit one replacement. Cancel after draining leaves them withdrawn; claimed instructions are unaffected. Pi's global Option+Up is unchanged. There is no guaranteed post-completion editing window; late follow-ups fail visibly. After readiness, use `subagent_stage revise` with an exact candidate, generation, current `expectedTip`, and instruction when a same-worker correction remains allowed. Material scope growth, missing authorization, user-owned conflicts, or consequential external actions still require a new decision. pi-subagent never pushes, publishes, deploys, or opens a pull request.
114
114
 
115
- `subagent_status` reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
115
+ `subagent_status` puts failed review verdicts from every task's latest attempt (also while siblings run), the final gate, and each generation into its model-visible text within one shared 32 KiB budget and counts omitted verdicts; its structured projection carries each verdict up to 32 KiB and sets `verdictTruncated` when it is cut; asynchronous follow-ups keep a short preview and point Main to `subagent_status`. It reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
116
116
 
117
117
  - `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
118
118
  - `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
@@ -129,7 +129,9 @@ State version 5 is stored privately under:
129
129
  <agent-dir>/config/pi-subagent/state/<repository-hash>/<request-id>.json
130
130
  ```
131
131
 
132
- 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 conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
132
+ 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. When a proved finished turn leaves tracked or untracked changes in a valid owned worktree, the runner sends the same worker one commit reminder per attempt, saved as a follow-up prompt record before delivery like any other prompt. It does not use a correction or a dispatch attempt. The runner never commits, stages, stashes, or discards; a commit from the reminder goes through the normal candidate path, and a worktree still dirty after it needs attention. `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. A `retry` that sends a same-worker correction may add an `instruction` (at most 32000 exact characters, no NUL), appended to the correction prompt as Main's instruction after the saved failure and saved in its prompt record before delivery; other actions reject it. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
133
+
134
+ `/subagent prune` reviews every `pi-subagent/subagent-<24 hex>` branch in the repository and its `.worktrees/` checkout. An item is removable only when its tip is an ancestor of Main's HEAD or the local `origin` default-branch ref (no fetch), the checkout is still on its own branch (a detached or switched owned checkout is found by path and blocks), has no tracked or untracked changes, no initialized submodules, and no in-progress Git operation, no non-terminal or unreadable state in any of the repository's checkouts records its name (a non-regular state entry counts as unreadable and may own any name), no productive lease is live, and no Herdr pane works inside the checkout (unavailable Herdr refuses). Removal needs interactive confirmation; checkouts with ignored files need a second confirmation that names each ignored entry in full, and a checkout whose list does not fit is left out; that approval covers each entry with all its contents, and a new or changed top-level ignored entry skips the checkout. Prunable items are listed first, and only items shown in full in both dialogs can be removed; an over-long list ends with an explicit `+N more not shown` line instead of a silent cut. The confirming session's ownership is rechecked immediately before each `git worktree remove` and branch deletion. Each item is re-verified immediately before `git worktree remove`, and its branch is deleted with a compare-and-swap on the reviewed tip. Aborted requests that recorded a removed resource are then reconciled by the guarded abort path, which proves absence; requests it cannot reconcile are reported. State files are never edited directly.
133
135
 
134
136
  Text attempt history records dispatches, not pre-dispatch inspection failures. Such failures retain task-level attention without adding an attempt or rewriting earlier execution diagnostics. A recorded interrupted dispatch still consumes an attempt. Verification requires the same passing preliminary checks, judgment, and ownership as candidate retention; readiness and the verified candidate are saved together.
135
137
 
@@ -1,3 +1,4 @@
1
+ import { basename } from "node:path";
1
2
  import type { JsonValue } from "@earendil-works/pi-ai";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { truncateToWidth } from "@earendil-works/pi-tui";
@@ -12,6 +13,7 @@ import {
12
13
  type DirectProcessRunner,
13
14
  } from "../dist/git-runtime.js";
14
15
  import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
16
+ import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type PruneGuards, type PruneItem } from "../dist/prune.js";
15
17
  import { runProcess as directRunProcess } from "../dist/process.js";
16
18
  import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
17
19
  import { IsolatedRunner, type OperationContext, type RunResponse } from "../dist/runner.js";
@@ -36,10 +38,12 @@ import {
36
38
  } from "../dist/schema.js";
37
39
  import { FileRunStore } from "../dist/store.js";
38
40
  import { workerOutputForMain } from "./result-transport.ts";
39
- import type { IsolatedInventory } from "./subagent-command.ts";
41
+ import type { IsolatedInventory, PrunePlan } from "./subagent-command.ts";
40
42
 
41
43
  const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
42
44
  const PUBLIC_EVIDENCE_MAX_BYTES = 512;
45
+ /** Tool results return failed review verdicts in full up to this bound; follow-ups keep the short preview. */
46
+ const PUBLIC_REVIEW_VERDICT_MAX_BYTES = 32 * 1024;
43
47
  const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
44
48
  const WIDGET_FIELD_MAX_CHARS = 32;
45
49
  const MAX_WORKSPACE_WIDGET_LINES = 6;
@@ -175,10 +179,25 @@ function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, row
175
179
  }
176
180
  }
177
181
 
178
- function boundedPublicText(value: string): string {
179
- if (Buffer.byteLength(value, "utf8") <= PUBLIC_EVIDENCE_MAX_BYTES) return value;
180
- let end = Math.min(value.length, PUBLIC_EVIDENCE_MAX_BYTES);
181
- while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > PUBLIC_EVIDENCE_MAX_BYTES) end -= 1;
182
+ /** Join report lines within a follow-up budget, naming how many were omitted. */
183
+ function boundedReport(lines: readonly string[], omitted: string): string {
184
+ const report: string[] = [];
185
+ let bytes = 0;
186
+ let skipped = 0;
187
+ for (const line of lines.flatMap((entry) => entry.split("\n"))) {
188
+ const size = Buffer.byteLength(`${line}\n`, "utf8");
189
+ if (bytes + size > 7_900) { skipped++; continue; }
190
+ report.push(line);
191
+ bytes += size;
192
+ }
193
+ if (skipped) report.push(`[${skipped} ${omitted}]`);
194
+ return report.join("\n");
195
+ }
196
+
197
+ function boundedPublicText(value: string, maxBytes = PUBLIC_EVIDENCE_MAX_BYTES): string {
198
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
199
+ let end = Math.min(value.length, maxBytes);
200
+ while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes) end -= 1;
182
201
  return `${value.slice(0, end)}\n[truncated]`;
183
202
  }
184
203
 
@@ -199,16 +218,18 @@ function publicFailedCheck(evidence: CheckBatchEvidence | undefined) {
199
218
  };
200
219
  }
201
220
 
202
- function publicFailedReview(evidence: ReviewEvidence | undefined) {
221
+ function publicFailedReview(evidence: ReviewEvidence | undefined, verdictMaxBytes: number) {
203
222
  if (!evidence || evidence.passed) return undefined;
223
+ const verdict = boundedPublicText(evidence.verdict, verdictMaxBytes);
204
224
  return {
205
225
  phase: evidence.phase,
206
- verdict: boundedPublicText(evidence.verdict),
226
+ verdict,
227
+ verdictTruncated: verdict !== evidence.verdict,
207
228
  identityChanged: !sameIdentity(evidence.tip, evidence.identityAfter),
208
229
  };
209
230
  }
210
231
 
211
- function publicTaskRecovery(task: RunState["tasks"][number]) {
232
+ function publicTaskRecovery(task: RunState["tasks"][number], verdictMaxBytes: number) {
212
233
  if (task.kind === "text") {
213
234
  const attempt = task.attempts.at(-1);
214
235
  return {
@@ -225,7 +246,7 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
225
246
 
226
247
  const attempt = task.attempts.at(-1);
227
248
  const failedCheck = publicFailedCheck(attempt?.preliminaryChecks);
228
- const failedReview = publicFailedReview(attempt?.preliminaryReview);
249
+ const failedReview = publicFailedReview(attempt?.preliminaryReview, verdictMaxBytes);
229
250
  const cleanup = attempt?.cleanup
230
251
  .filter((step) => step.status !== "completed")
231
252
  .map((step) => ({
@@ -254,14 +275,14 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
254
275
  };
255
276
  }
256
277
 
257
- function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
278
+ function publicNeedsAttention(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
258
279
  if (state.status !== "needs_attention") return undefined;
259
280
  const task = state.tasks.find((candidate) => candidate.status === "needs_attention" && candidate.taskId === preferredTaskId)
260
281
  ?? state.tasks.find((candidate) => candidate.status === "needs_attention");
261
- if (task) return publicTaskRecovery(task);
282
+ if (task) return publicTaskRecovery(task, verdictMaxBytes);
262
283
  if (state.final.failure || state.final.checks?.passed === false || state.final.review?.passed === false) {
263
284
  const failedCheck = publicFailedCheck(state.final.checks);
264
- const failedReview = publicFailedReview(state.final.review);
285
+ const failedReview = publicFailedReview(state.final.review, verdictMaxBytes);
265
286
  return {
266
287
  scope: "final" as const,
267
288
  ...(state.final.failure ? { failure: boundedPublicText(state.final.failure) } : {}),
@@ -275,8 +296,8 @@ function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
275
296
  };
276
297
  }
277
298
 
278
- function publicState(state: RunState, preferredTaskId?: string) {
279
- const needsAttention = publicNeedsAttention(state, preferredTaskId);
299
+ function publicState(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
300
+ const needsAttention = publicNeedsAttention(state, verdictMaxBytes, preferredTaskId);
280
301
  return {
281
302
  version: state.version,
282
303
  id: state.request.id,
@@ -313,7 +334,7 @@ function publicState(state: RunState, preferredTaskId?: string) {
313
334
  ...(combinedTip ? { combinedTip } : {}),
314
335
  ...(correction ? { correction } : {}),
315
336
  ...(checks ? { checked: checks.passed, failedCheck: publicFailedCheck(checks) } : {}),
316
- ...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review) } : {}),
337
+ ...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review, verdictMaxBytes) } : {}),
317
338
  ...(promotion ? { promotion: promotion.status, ...(promotion.mainAfter ? { mainAfter: promotion.mainAfter } : {}),
318
339
  ...(promotion.failure ? { promotionFailure: boundedPublicText(promotion.failure) } : {}) } : {}),
319
340
  ...(failure ? { failure: boundedPublicText(failure) } : {}),
@@ -329,6 +350,42 @@ function publicState(state: RunState, preferredTaskId?: string) {
329
350
  };
330
351
  }
331
352
 
353
+ /** Model-visible failed review verdicts from every task, the final gate, and each generation, within one shared budget.
354
+ * Pi shows the model `content`, not `structuredContent`; task reviews appear even while sibling tasks still run. */
355
+ function failedReviewText(state: RunState): string {
356
+ const reviews = [
357
+ ...state.tasks.flatMap((task) => {
358
+ const review = task.kind === "changeset" ? task.attempts.at(-1)?.preliminaryReview : undefined;
359
+ return review && !review.passed ? [{ label: `Task ${task.taskId}`, review }] : [];
360
+ }),
361
+ ...(state.final.review && !state.final.review.passed ? [{ label: "Final", review: state.final.review }] : []),
362
+ ...state.integration.generations.flatMap(({ number, review }) => review && !review.passed ? [{ label: `Integration generation ${number}`, review }] : []),
363
+ ].reduce<{ label: string; review: ReviewEvidence }[]>((merged, entry) => {
364
+ // Show an identical verdict once, but keep every scope that failed with it.
365
+ const same = merged.find(({ review }) => review.verdict === entry.review.verdict);
366
+ if (same) same.label = `${same.label}, ${entry.label}`;
367
+ else merged.push({ ...entry });
368
+ return merged;
369
+ }, []);
370
+ let budget = PUBLIC_REVIEW_VERDICT_MAX_BYTES;
371
+ let omitted = 0;
372
+ const parts: string[] = [];
373
+ for (const { label, review } of reviews) {
374
+ if (budget <= 0) { omitted++; continue; }
375
+ const verdict = boundedPublicText(review.verdict, budget);
376
+ budget -= Buffer.byteLength(verdict, "utf8");
377
+ parts.push(`\n\n${label}: failed ${review.phase} review verdict${verdict !== review.verdict ? " (truncated)" : ""}:\n${workerOutputForMain(verdict)}`);
378
+ }
379
+ if (omitted) parts.push(`\n\n${omitted} more failed review verdict(s) omitted from this text; structuredContent lists each one.`);
380
+ return parts.join("");
381
+ }
382
+
383
+ function hasTruncatedVerdict(state: ReturnType<typeof publicState>): boolean {
384
+ const attention = state.needsAttention;
385
+ return Boolean(attention && "failedReview" in attention && attention.failedReview?.verdictTruncated)
386
+ || state.integration.generations.some((generation) => generation.failedReview?.verdictTruncated);
387
+ }
388
+
332
389
  function inspectionNotices({ state, main, continuation }: RunResponse): string[] {
333
390
  const lines = [
334
391
  `Isolated request ${state.request.id}: ${state.status}; accepted: ${state.accepted}; final: ${state.final.status}; Main: ${main?.status ?? "not checked"}.`,
@@ -338,7 +395,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
338
395
  ? `Main inspection unavailable: ${boundedPublicText(main.failure)}; expected: ${JSON.stringify(main.expected)}`
339
396
  : `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
340
397
  if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
341
- const attention = publicNeedsAttention(state);
398
+ const attention = publicNeedsAttention(state, PUBLIC_EVIDENCE_MAX_BYTES);
342
399
  if (attention) lines.push(`Reported blockage: ${JSON.stringify({ scope: attention.scope, failure: attention.failure, failedCheck: "failedCheck" in attention ? attention.failedCheck : undefined, failedReview: "failedReview" in attention ? attention.failedReview : undefined })}`);
343
400
  if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
344
401
  for (const task of state.tasks) {
@@ -404,7 +461,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
404
461
  return {
405
462
  content: [{ type: "text" as const, text }],
406
463
  details: {
407
- state: publicState(response.state, preferredTaskId),
464
+ state: publicState(response.state, PUBLIC_REVIEW_VERDICT_MAX_BYTES, preferredTaskId),
408
465
  ...(response.main ? { main: response.main } : {}),
409
466
  ...(response.continuation ? { continuation: response.continuation } : {}),
410
467
  },
@@ -415,6 +472,8 @@ export interface IsolatedSurface {
415
472
  execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
416
473
  inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
417
474
  recover(cwd: string): Promise<string>;
475
+ prunePlan(cwd: string): Promise<PrunePlan>;
476
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
418
477
  inspect(root: string, requestId: string): Promise<readonly string[]>;
419
478
  canFollowup(root: string, requestId: string, taskId: string): boolean;
420
479
  enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
@@ -487,6 +546,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
487
546
  pi.on("model_select", (event, ctx) => { latestCtx = { ...ctx, model: event.model } as ExtensionContext; });
488
547
  pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; });
489
548
 
549
+ const pruneGuards = (root: string): PruneGuards => ({
550
+ ownership: async (checkouts) => await getComponents().runner.pruneOwnership(checkouts),
551
+ panes: async () => await listHerdrPanes(root),
552
+ });
490
553
  const requireCurrent = (current: () => boolean) => {
491
554
  if (!current() || sessionClosed) throw new Error("Session or branch changed; reopen /subagent.");
492
555
  };
@@ -551,14 +614,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
551
614
  }
552
615
  const state = response?.state ?? latestState;
553
616
  try {
554
- const details = state ? {
555
- state: publicState(state, response?.continuation && "taskId" in response.continuation
556
- ? response.continuation.taskId : undefined),
617
+ const preview = state ? publicState(state, PUBLIC_EVIDENCE_MAX_BYTES, response?.continuation && "taskId" in response.continuation
618
+ ? response.continuation.taskId : undefined) : undefined;
619
+ const details = preview ? {
620
+ state: preview,
557
621
  ...(response?.continuation ? { continuation: response.continuation } : {}),
558
622
  } : { id };
559
623
  pi.sendMessage({
560
624
  customType: "pi-subagent-isolated-result",
561
- content: text,
625
+ content: preview && hasTruncatedVerdict(preview)
626
+ ? `${text}\n\nReview verdicts here are previews; run subagent_status for the full verdict.`
627
+ : text,
562
628
  display: true,
563
629
  details,
564
630
  }, { triggerTurn: true, deliverAs: "followUp" });
@@ -597,7 +663,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
597
663
  reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
598
664
  return;
599
665
  }
600
- deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
666
+ deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state, PUBLIC_EVIDENCE_MAX_BYTES))}`, response);
601
667
  },
602
668
  (error: unknown) => {
603
669
  finish();
@@ -628,15 +694,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
628
694
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
629
695
  latestCtx = ctx;
630
696
  const root = await lookupRoot(ctx.cwd, signal);
631
- const result = toolResult(await getComponents().runner.status(params.id, root, signal), ctx, workspaceRowsByRequest);
697
+ const response = await getComponents().runner.status(params.id, root, signal);
698
+ const result = toolResult(response, ctx, workspaceRowsByRequest);
699
+ const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
632
700
  // The projection is plain JSON; its conditional fields are typed optional rather than absent.
633
- return { ...result, structuredContent: result.details as JsonValue };
701
+ return { ...result, content, structuredContent: result.details as JsonValue };
634
702
  },
635
703
  });
636
704
  pi.registerTool({
637
705
  name: "subagent_resume",
638
706
  label: "Subagent resume",
639
- description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
707
+ description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
640
708
  parameters: ResumeRequestParameters,
641
709
  prepareArguments: parseResumeRequest,
642
710
  exposure: "model-only",
@@ -748,17 +816,26 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
748
816
  leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
749
817
  : "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
750
818
  ];
751
- const report: string[] = [];
752
- let bytes = 0;
753
- let omitted = 0;
754
- for (const line of lines.flatMap((entry) => entry.split("\n"))) {
755
- const size = Buffer.byteLength(`${line}\n`, "utf8");
756
- if (bytes + size > 7_900) { omitted++; continue; }
757
- report.push(line);
758
- bytes += size;
759
- }
760
- if (omitted) report.push(`[${omitted} recovery report lines omitted; use subagent_status for exact request evidence.]`);
761
- return report.join("\n");
819
+ return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
820
+ },
821
+ async prunePlan(cwd) {
822
+ const root = await lookupRoot(cwd);
823
+ return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
824
+ },
825
+ async prune(cwd, items, allowIgnored, current) {
826
+ requireCurrent(current);
827
+ const root = await lookupRoot(cwd);
828
+ const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored,
829
+ { ...pruneGuards(root), current: () => current() && !sessionClosed });
830
+ const removed = removals.filter(({ outcome }) => outcome === "removed");
831
+ const reconciled = removed.length
832
+ ? await getComponents().runner.reconcilePrunedAborts(checkouts, removed.map(({ item }) => basename(item.branch))) : [];
833
+ const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
834
+ return boundedReport([
835
+ `Pi Subagent prune in ${root}: ${removed.length} removed, ${count("skipped")} skipped, ${count("failed")} failed.`,
836
+ ...removals.map(({ outcome, item, reason }) => `${outcome}: ${item.path ?? "(no checkout)"} · ${item.branch} · ${reason}`),
837
+ ...reconciled,
838
+ ], "prune report lines omitted.");
762
839
  },
763
840
  async inspect(root, requestId) {
764
841
  const response = await getComponents().runner.status(requestId, root);
@@ -1,4 +1,4 @@
1
- import { capEphemeralSubagentOutput } from "@henryqw/pi-subagent";
1
+ import { capEphemeralSubagentOutput, formatDuration } from "@henryqw/pi-subagent";
2
2
  import type { WorkflowEntry, WorkflowMode } from "./workflow.ts";
3
3
 
4
4
  const EVIDENCE_PREVIEW_CODE_POINTS = 256;
@@ -22,6 +22,9 @@ export type WorkflowTransportEntry = {
22
22
  role: WorkflowEntry["delegation"]["role"];
23
23
  model?: string;
24
24
  thinkingLevel?: string;
25
+ /** Measured session tokens; absent when usage was unavailable. */
26
+ tokens?: number;
27
+ elapsedMs?: number;
25
28
  status: WorkflowTransportStatus;
26
29
  assistantOutput?: string;
27
30
  failure?: string;
@@ -76,6 +79,21 @@ export function displaySummary(text: string): string {
76
79
  return Array.from(normalized).slice(0, 160).join("");
77
80
  }
78
81
 
82
+ export function compactTokens(tokens: number): string {
83
+ return `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
84
+ }
85
+
86
+ /** Launched workers carry a model; workers that never launched show no usage. */
87
+ function usageFor(entry: WorkflowTransportEntry): string {
88
+ if (entry.model === undefined) return "";
89
+ return [
90
+ entry.model,
91
+ ...(entry.thinkingLevel === undefined ? [] : [`thinking ${entry.thinkingLevel}`]),
92
+ entry.tokens === undefined ? "tokens unknown" : compactTokens(entry.tokens),
93
+ ...(entry.elapsedMs === undefined ? [] : [formatDuration(entry.elapsedMs)]),
94
+ ].map((part) => ` · ${part}`).join("");
95
+ }
96
+
79
97
  export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
80
98
 
81
99
  export const ENTRY_STATUS_PRESENTATION = {
@@ -120,7 +138,7 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
120
138
  ...positioned.map(({ entry, position }) => {
121
139
  const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[entry.status];
122
140
  const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
123
- return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
141
+ return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role}${usageFor(entry)} — ${summary}`;
124
142
  }),
125
143
  ...(evidence.length ? [
126
144
  "Results:",
@@ -146,6 +164,8 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
146
164
  ...(source === undefined ? {} : { summary: displaySummary(source) }),
147
165
  ...(entry.model === undefined ? {} : { model: entry.model }),
148
166
  ...(entry.thinkingLevel === undefined ? {} : { thinkingLevel: entry.thinkingLevel }),
167
+ ...(entry.tokens === undefined ? {} : { tokens: entry.tokens }),
168
+ ...(entry.elapsedMs === undefined ? {} : { elapsedMs: entry.elapsedMs }),
149
169
  };
150
170
  }),
151
171
  },