@henryqw/pi-subagent 27.0.0 → 27.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  },
@@ -49,6 +49,22 @@ export interface IsolatedInventory {
49
49
  invalidIds: readonly string[];
50
50
  }
51
51
 
52
+ /** One pi-subagent branch and its checkout as reviewed by /subagent prune. */
53
+ export interface PruneItem {
54
+ branch: string;
55
+ tip: string;
56
+ path?: string;
57
+ prunable: boolean;
58
+ /** Why the item is prunable, or every exact blocker. */
59
+ reason: string;
60
+ /** Ignored entries that removing the checkout deletes. */
61
+ ignored: readonly string[];
62
+ }
63
+ export interface PrunePlan {
64
+ root: string;
65
+ items: readonly PruneItem[];
66
+ }
67
+
52
68
  /** Mutation adapters MUST check current() synchronously alongside their live ownership
53
69
  * validation and queue operation. They must not await between those operations. */
54
70
  export interface SubagentCommandAdapter {
@@ -57,6 +73,10 @@ export interface SubagentCommandAdapter {
57
73
  stopDirect(task: DirectTask, entryId: string, current: () => boolean): Promise<string>;
58
74
  isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
59
75
  recover(cwd: string): Promise<string>;
76
+ /** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
77
+ prunePlan(cwd: string): Promise<PrunePlan>;
78
+ /** Re-verify and remove reviewed prunable items; returns a bounded report. */
79
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
60
80
  /** Read-only Role catalog report with routes and per-file load errors. */
61
81
  roles(ctx: ExtensionContext): string;
62
82
  inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
@@ -73,6 +93,39 @@ const errorText = (error: unknown): string => clean(error instanceof Error ? err
73
93
  const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
74
94
  const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
75
95
 
96
+ const ignoredSummary = ({ ignored }: PruneItem) => !ignored.length ? "none"
97
+ : `${ignored.length} (${ignored.slice(0, 5).join(", ")}${ignored.length > 5 ? `, +${ignored.length - 5} more` : ""})`;
98
+ const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
99
+ const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
100
+
101
+ /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
102
+ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number): { text: string; shown: T[] } {
103
+ const lines = [header];
104
+ const shown: T[] = [];
105
+ let size = [...header].length;
106
+ const reserve = [...NOT_SHOWN].length + 32;
107
+ for (const item of items) {
108
+ const text = line(item);
109
+ const length = [...text].length + 1;
110
+ if (size + length + reserve > max) break;
111
+ lines.push(text);
112
+ size += length;
113
+ shown.push(item);
114
+ }
115
+ if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${NOT_SHOWN}`);
116
+ return { text: lines.join("\n"), shown };
117
+ }
118
+
119
+ /** Prunable items come first so a long blocked list cannot hide them; only shown items may be removed. */
120
+ function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneItem[] } {
121
+ if (!items.length) return { text: `No pi-subagent worktrees or branches in ${root}.`, shown: [] };
122
+ const prunable = items.filter((item) => item.prunable).length;
123
+ return fitItems(`Subagent prune review in ${root}: ${prunable} prunable, ${items.length - prunable} blocked.`,
124
+ [...items.filter((item) => item.prunable), ...items.filter((item) => !item.prunable)],
125
+ (item) => clean(`${item.prunable ? "✓ prunable" : "✗ blocked"} · ${item.path ?? "(no checkout)"} · ${item.branch} · ${item.tip.slice(0, 12)} · ${item.reason} · ignored: ${ignoredSummary(item)}`, 600),
126
+ ROLES_REPORT_CODE_POINTS);
127
+ }
128
+
76
129
  type Choice<T> = { label: string; value: T };
77
130
  async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
78
131
  // Numeric suffixes are UI-only. Never parse display text into an identity.
@@ -84,10 +137,10 @@ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly C
84
137
 
85
138
  export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
86
139
  pi.registerCommand("subagent", {
87
- description: "Inspect direct and isolated work, list Roles with /subagent roles, or recover orphaned isolated requests with /subagent recover",
140
+ description: "Inspect direct and isolated work, list Roles with /subagent roles, recover orphaned isolated requests with /subagent recover, or remove merged leftover worktrees with /subagent prune",
88
141
  handler: async (args, ctx) => {
89
142
  const command = args.trim();
90
- if (command && command !== "recover" && command !== "roles") throw new Error("Usage: /subagent [recover|roles]");
143
+ if (command && !["prune", "recover", "roles"].includes(command)) throw new Error("Usage: /subagent [prune|recover|roles]");
91
144
  if (command === "roles") {
92
145
  const report = clean(adapter.roles(ctx), ROLES_REPORT_CODE_POINTS, true);
93
146
  if (ctx.hasUI) ctx.ui.notify(report, "info");
@@ -121,6 +174,39 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
121
174
  ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
122
175
  return false;
123
176
  };
177
+ if (command === "prune") {
178
+ const plan = await adapter.prunePlan(ctx.cwd);
179
+ const reviewed = pruneReview(plan);
180
+ const review = clean(reviewed.text, ROLES_REPORT_CODE_POINTS, true);
181
+ if (!ctx.hasUI) {
182
+ pi.sendMessage({ customType: "pi-subagent-prune", content: `${review}\nReport only: removal requires interactive confirmation.`, display: true });
183
+ return;
184
+ }
185
+ if (!valid()) return;
186
+ ctx.ui.notify(review, "info");
187
+ const prunable = reviewed.shown.filter((item) => item.prunable);
188
+ if (!prunable.length) return;
189
+ const confirmed = await ctx.ui.confirm("Prune subagent worktrees", `Remove all ${prunable.length} prunable item(s) listed above? Each is re-verified first; unique commits, tracked or untracked changes, and unlisted items are never removed.`);
190
+ if (!valid() || !confirmed) return;
191
+ const ignored = prunable.filter((item) => item.ignored.length);
192
+ // The header uses the full count's width so the shown count always fits.
193
+ const ignoredList = fitItems(`Also remove ${ignored.length} checkout(s) with ignored files? This permanently deletes these ignored entries and everything inside them:`,
194
+ // Approval names every ignored entry; a checkout whose full list does not fit is left out of this run.
195
+ ignored, (item) => clean(`${item.path}: ${item.ignored.join(", ")}`, Number.MAX_SAFE_INTEGER), PRUNE_IGNORED_CONFIRM_CODE_POINTS);
196
+ const approvable = ignoredList.shown;
197
+ const allowIgnored = approvable.length > 0 && await ctx.ui.confirm("Delete ignored content",
198
+ clean(ignoredList.text.replace(`Also remove ${ignored.length} checkout(s)`, `Also remove ${approvable.length} checkout(s)`), PRUNE_IGNORED_CONFIRM_CODE_POINTS, true));
199
+ if (!valid()) return;
200
+ const kept = ignored.length - (allowIgnored ? approvable.length : 0);
201
+ if (kept) ctx.ui.notify(`Kept ${kept} checkout(s) with ignored content; their deletion was not approved.`, "info");
202
+ const selected = prunable.filter((item) => !item.ignored.length || (allowIgnored && approvable.includes(item)));
203
+ if (!selected.length) return;
204
+ try {
205
+ const report = await adapter.prune(ctx.cwd, selected, allowIgnored, current);
206
+ if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
207
+ } catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
208
+ return;
209
+ }
124
210
  let history = false;
125
211
  for (;;) {
126
212
  if (!valid()) return;
@@ -8,7 +8,7 @@ import { registerCheckoutAdmission } from "./admission.ts";
8
8
  import { registerIsolatedExtension } from "./isolated.ts";
9
9
  import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
10
10
  import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
11
- import { ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
11
+ import { compactTokens, ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
12
12
  import { DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, writesCheckout, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
13
13
  import { assertSettledDirectSession, createDirectHerdr, DirectAllocationError, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity } from "../dist/direct-herdr.js";
14
14
  import { materializeTransientLaunch } from "../dist/launch-runtime.js";
@@ -55,7 +55,7 @@ function statusLabel(status: WidgetStatus): string {
55
55
  }
56
56
 
57
57
  function tokenLabel(tokens: number | undefined): string {
58
- return tokens === undefined ? "— tok" : `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
58
+ return tokens === undefined ? "— tok" : compactTokens(tokens);
59
59
  }
60
60
 
61
61
  function activityLabel(activity: DirectToolActivity | undefined): string | undefined {
@@ -304,6 +304,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
304
304
  },
305
305
  isolated: isolatedSurface.inventory,
306
306
  recover: (cwd) => isolatedSurface.recover(cwd),
307
+ prunePlan: (cwd) => isolatedSurface.prunePlan(cwd),
308
+ prune: (cwd, items, allowIgnored, current) => isolatedSurface.prune(cwd, items, allowIgnored, current),
307
309
  async inspectInTab(root, id, ctx, current) {
308
310
  const notices = await isolatedSurface.inspect(root, id);
309
311
  if (!current()) throw new Error("Session or branch changed before status inspection.");
@@ -654,6 +656,11 @@ export default function subagentExtension(pi: ExtensionAPI): void {
654
656
  id, index, role: delegation.role, status: "pending",
655
657
  name: delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`,
656
658
  }]));
659
+ const startedAt = new Map<string, number>();
660
+ const measured = (id: string, tokens: number | undefined) => ({
661
+ ...(tokens === undefined ? {} : { tokens }),
662
+ ...(startedAt.has(id) ? { elapsedMs: Date.now() - startedAt.get(id)! } : {}),
663
+ });
657
664
  const taskId = `direct-${++directSequence}-${Date.now().toString(36)}`;
658
665
  const workflowNumber = nextWorkflow(ctx);
659
666
  const ref = `D${workflowNumber}`;
@@ -717,6 +724,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
717
724
  const prepared = await prepareDirectLaunch(role, latestCtx ?? ctx, {
718
725
  ...(modelClass === undefined ? {} : { modelClass }), ...(model === undefined ? {} : { model }) });
719
726
  states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
727
+ startedAt.set(entry.id, Date.now());
720
728
  const display = displayOf(entry);
721
729
  startWidgetItem(entry.id, taskId, display, prepared.model.id, prepared.thinkingLevel, ctx);
722
730
  const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
@@ -795,24 +803,26 @@ export default function subagentExtension(pi: ExtensionAPI): void {
795
803
  if (writerFailed) throw new Error("Previous direct task did not complete exactly; inspect recorded tabs before new shared-checkout work.");
796
804
  handle = entry.index === 0 ? first : await launch(entry, entrySignal(entry));
797
805
  const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
806
+ let tokens: number | undefined;
798
807
  try {
799
- const tokens = await handle.usageTokens();
808
+ tokens = await handle.usageTokens();
800
809
  const item = widgetItems.get(entry.id);
801
810
  if (item) { item.tokens = tokens; item.activity = handle.activity; }
802
811
  } catch { /* Optional telemetry. */ }
803
- states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer });
812
+ states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer, ...measured(entry.id, tokens) });
804
813
  finishWidgetItem(entry.id, "success");
805
814
  return answer;
806
815
  } catch (error) {
807
816
  if (writes) writerFailed = true;
808
817
  const { assistantOutput: _partial, ...base } = states.get(entry.id)!;
809
818
  const partialOutput = await handle?.partialText(PARTIAL_OUTPUT_CHARS).catch(() => undefined);
819
+ const tokens = await handle?.usageTokens().catch(() => undefined);
810
820
  const message = (value: unknown) => value instanceof Error ? value.message : String(value);
811
821
  const stopped = await stopProofs.get(entry.id)?.then(() => STOPPED_BY_USER,
812
822
  (stopError: unknown) => `${STOPPED_BY_USER}; termination unproved: ${message(stopError)}`);
813
823
  const tab = tabByEntry.get(entry.id);
814
824
  const recover = tab && stopped !== STOPPED_BY_USER ? `recover from Herdr tab ${tab.tabId}, agent ${tab.name}, session ${tab.sessionFile}: ` : "";
815
- states.set(entry.id, { ...base, status: "rejected", ...(partialOutput ? { partialOutput } : {}), failure: `${recover}${capOutput(stopped ?? message(error))}` });
825
+ states.set(entry.id, { ...base, status: "rejected", ...(partialOutput ? { partialOutput } : {}), ...measured(entry.id, tokens), failure: `${recover}${capOutput(stopped ?? message(error))}` });
816
826
  finishWidgetItem(entry.id, "failure");
817
827
  throw error;
818
828
  } finally { release(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.0.0",
3
+ "version": "27.1.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,10 +32,10 @@ To build on a finished direct worker, add `continueFrom` with its reference (suc
32
32
 
33
33
  Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
34
34
 
35
- Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
35
+ Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
36
36
 
37
37
  A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip`: it acknowledges saved intent, runs full root checks and optional final judgment in the session-owned background, then sends a follow-up. Acknowledgement is not passing evidence; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
38
38
 
39
- After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
39
+ After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
40
40
 
41
41
  All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.