@henryqw/pi-subagent 27.6.1 → 27.6.3

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/README.md CHANGED
@@ -139,6 +139,8 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
139
139
 
140
140
  Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. The menu groups rows under **Direct**, **Isolated**, and **Orphaned** headers. Each isolated row shows the request ID, the status, and a short goal summary. **Refresh** or a header reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
141
141
 
142
+ Each row starts with a status symbol: `●` active, `✓` completed, `⚠` needs attention, `✗` failed. In the terminal UI, `/subagent` reports (Roles, details, reviews, and results) show as colored transcript entries. The model does not get these entries. In RPC mode, they show as notifications. Without interactive UI, they show as plain text messages.
143
+
142
144
  Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
143
145
 
144
146
  Only shown resources can be removed; live jobs, uncertain ownership, or other Herdr panes can block removal. Agents and tools cannot trigger this action. See [Close and delete isolated worktrees](./docs/orchestration.md#close-and-delete-isolated-worktrees) for guards and partial cleanup.
package/dist/prune.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface PruneOwnership {
17
17
  lease?: string;
18
18
  }
19
19
  export interface PruneGuards {
20
- /** Ownership and lease evidence across the repository's existing checkouts. */
20
+ /** Ownership and lease evidence across the repository's existing checkouts, primary first. */
21
21
  ownership(checkouts: readonly string[]): Promise<PruneOwnership>;
22
22
  /** Every Herdr pane; rejects when Herdr cannot be inspected. */
23
23
  panes(): Promise<readonly {
package/dist/runner.d.ts CHANGED
@@ -339,8 +339,9 @@ export declare class IsolatedRunner {
339
339
  invalidIds: string[];
340
340
  leaseBusy: boolean;
341
341
  }>;
342
- /** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
343
- * mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
342
+ /** Prune evidence across checkouts, primary first, and orphaned requests whose parent checkout was removed.
343
+ * A non-terminal state owns every resource name its saved text mentions; an unreadable state owns every
344
+ * name its raw text mentions, or every name when unreadable. */
344
345
  pruneOwnership(checkouts: readonly string[]): Promise<PruneOwnership>;
345
346
  /** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
346
347
  reconcilePrunedAborts(checkouts: readonly string[], names: readonly string[]): Promise<string[]>;
package/dist/runner.js CHANGED
@@ -672,11 +672,17 @@ export class IsolatedRunner {
672
672
  }
673
673
  return { requests, invalidIds: inventory.invalidIds, leaseBusy };
674
674
  }
675
- /** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
676
- * mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
675
+ /** Prune evidence across checkouts, primary first, and orphaned requests whose parent checkout was removed.
676
+ * A non-terminal state owns every resource name its saved text mentions; an unreadable state owns every
677
+ * name its raw text mentions, or every name when unreadable. */
677
678
  async pruneOwnership(checkouts) {
678
679
  const owners = [];
679
680
  let lease;
681
+ const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
682
+ for (const { state } of orphans) {
683
+ if (!terminal(state))
684
+ owners.push({ label: `owned by orphaned ${state.status} request ${state.request.id}; close and delete it from /subagent`, text: JSON.stringify(state) });
685
+ }
680
686
  for (const checkout of checkouts) {
681
687
  const root = realpathSync.native(checkout);
682
688
  if (!lease && await this.store.hasProductiveRunLease(root))
@@ -410,7 +410,7 @@ Run `/subagent prune` to clean up leftover worker and integration checkouts unde
410
410
 
411
411
  - Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. The command does not fetch.
412
412
  - Its checkout has no tracked or untracked changes. No merge, rebase, cherry-pick, revert, or bisect is in progress.
413
- - No unfinished isolated request, in any checkout of this repository, records it. An unreadable state file blocks every item that it mentions.
413
+ - No unfinished isolated request, in any checkout of this repository, records it. An unreadable state file blocks every item that it mentions. An unfinished orphaned request, whose parent checkout was removed, also blocks its items. To remove them, open that request in `/subagent` and use **Close and delete worktrees (discard work)**.
414
414
  - No productive run holds a repository lease.
415
415
  - No Herdr pane works inside the checkout. If the command cannot inspect Herdr, it refuses every checkout.
416
416
 
@@ -39,7 +39,7 @@ import {
39
39
  } from "../dist/schema.js";
40
40
  import { FileRunStore } from "../dist/store.js";
41
41
  import { workerOutputForMain } from "./result-transport.ts";
42
- import type { IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
42
+ import type { CleanupReport, IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
43
43
  import type { DiscardPlan } from "../dist/discard.js";
44
44
 
45
45
  const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
@@ -521,8 +521,8 @@ export interface IsolatedSurface {
521
521
  recover(cwd: string): Promise<string>;
522
522
  prunePlan(cwd: string): Promise<PrunePlan>;
523
523
  discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
524
- discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
525
- prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
524
+ discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
525
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<CleanupReport>;
526
526
  inspect(root: string, requestId: string): Promise<readonly string[]>;
527
527
  canFollowup(root: string, requestId: string, taskId: string): boolean;
528
528
  enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
@@ -887,9 +887,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
887
887
  panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
888
888
  blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
889
889
  });
890
- return boundedReport([`Close and delete ${plan.requestId}:`,
890
+ const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
891
+ return { failed: count("failed"), skipped: count("skipped"), text: boundedReport([`Close and delete ${plan.requestId}:`,
891
892
  ...removals.map((item) => `${item.outcome}: ${item.key}: ${item.reason}`),
892
- ], "removal report lines omitted; reopen /subagent to inspect retained resources.");
893
+ ], "removal report lines omitted; reopen /subagent to inspect retained resources.") };
893
894
  },
894
895
  async prunePlan(cwd) {
895
896
  const root = await lookupRoot(cwd);
@@ -904,11 +905,11 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
904
905
  const reconciled = removed.length
905
906
  ? await getComponents().runner.reconcilePrunedAborts(checkouts, removed.map(({ item }) => basename(item.branch))) : [];
906
907
  const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
907
- return boundedReport([
908
+ return { failed: count("failed"), skipped: count("skipped"), text: boundedReport([
908
909
  `Pi Subagent prune in ${root}: ${removed.length} removed, ${count("skipped")} skipped, ${count("failed")} failed.`,
909
910
  ...removals.map(({ outcome, item, reason }) => `${outcome}: ${item.path ?? "(no checkout)"} · ${item.branch} · ${reason}`),
910
911
  ...reconciled,
911
- ], "prune report lines omitted.");
912
+ ], "prune report lines omitted.") };
912
913
  },
913
914
  async inspect(root, requestId) {
914
915
  const response = await getComponents().runner.status(requestId, root);
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
2
3
  import type { DiscardPlan } from "../dist/discard.js";
3
4
  import { compactCost } from "./result-transport.ts";
4
5
 
@@ -85,11 +86,11 @@ export interface SubagentCommandAdapter {
85
86
  /** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
86
87
  prunePlan(cwd: string): Promise<PrunePlan>;
87
88
  discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
88
- discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
89
+ discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
89
90
  /** Re-verify and remove reviewed prunable items; returns a bounded report. */
90
- prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
91
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<CleanupReport>;
91
92
  /** Read-only Role catalog report with routes and per-file load errors. */
92
- roles(ctx: ExtensionContext): string;
93
+ roles(ctx: ExtensionContext): RolesReport;
93
94
  inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
94
95
  canFollowup(root: string, requestId: string, taskId: string): boolean;
95
96
  enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
@@ -98,6 +99,96 @@ export interface SubagentCommandAdapter {
98
99
  epoch(): number;
99
100
  }
100
101
 
102
+ /** Bounded removal report with outcome counts, so retained resources never read as a normal completion. */
103
+ export interface CleanupReport { text: string; failed: number; skipped: number }
104
+ export interface RoleReport {
105
+ name: string;
106
+ source: string;
107
+ /** Role modelClass, or undefined for the configured default route. */
108
+ modelClass?: string;
109
+ route: { model: string; thinking: string } | { error: string };
110
+ tools: readonly string[];
111
+ extensions: readonly string[];
112
+ skills: readonly string[];
113
+ mcps: readonly string[];
114
+ }
115
+ export type RolesReport = { roles: readonly RoleReport[]; errors: readonly string[] } | { unavailable: string };
116
+
117
+ type Tone = "info" | "success" | "warning" | "error";
118
+ /** `code` shows the value as inline code: IDs, paths, branches, model references, and session files. */
119
+ type Row = { label: string; value: string; tone?: Tone; code?: boolean } | { text: string; tone?: Tone; code?: boolean };
120
+ interface Section { heading?: string; note?: string; tone?: Tone; rows: readonly Row[] }
121
+ /** One /subagent output; every delivery mode renders it as Markdown. */
122
+ export interface Report { tone: Tone; title: string; sections: readonly Section[] }
123
+ const REPORT_ENTRY = "pi-subagent-report";
124
+ const RECOVERY_MESSAGE = "pi-subagent-recovery";
125
+
126
+ const TONES: Record<Tone, { glyph: string; color: Parameters<Theme["fg"]>[0] }> = {
127
+ info: { glyph: "●", color: "accent" }, success: { glyph: "✓", color: "success" },
128
+ warning: { glyph: "⚠", color: "warning" }, error: { glyph: "✗", color: "error" },
129
+ };
130
+ const LEVEL = { info: "info", success: "info", warning: "warning", error: "error" } as const;
131
+ const field = (text: string) => clean(text, Number.MAX_SAFE_INTEGER);
132
+ /** Escapes inline and line-start Markdown. Brackets, parentheses, and `!` stay literal because Pi reads `\(` and `\[` as LaTeX. */
133
+ const escapeMarkdown = (text: string) => field(text).replace(/[\\`*_~<>&|$]/g, "\\$&").replace(/^(\s*)([#+-])/, "$1\\$2").replace(/^(\s*\d+)([.)])/, "$1\\$2");
134
+ const inlineCode = (text: string) => {
135
+ const value = field(text);
136
+ const fence = "`".repeat(Math.max(0, ...(value.match(/`+/g) ?? []).map((run) => run.length)) + 1);
137
+ const pad = /^`|`$/.test(value) ? " " : "";
138
+ return value ? `${fence}${pad}${value}${pad}${fence}` : "";
139
+ };
140
+ const glyphPrefix = (tone?: Tone) => tone ? `${TONES[tone].glyph} ` : "";
141
+
142
+ /** `### heading · note`, then one list item per row. */
143
+ function sectionMarkdown({ heading, note, tone, rows }: Section, bullet = "-"): string {
144
+ const value = (text: string, row: Row) => row.code ? `${glyphPrefix(row.tone)}${inlineCode(text)}` : escapeMarkdown(`${glyphPrefix(row.tone)}${text}`);
145
+ return [
146
+ ...(heading ? [`### ${glyphPrefix(tone)}${escapeMarkdown(heading)}${note ? ` · ${escapeMarkdown(note)}` : ""}`] : []),
147
+ ...rows.map((row) => `${bullet} ${"label" in row ? `**${escapeMarkdown(row.label)}:** ${value(row.value, row)}` : value(row.text, row)}`),
148
+ ].join("\n");
149
+ }
150
+ /** A headless section switches its bullet so Markdown does not merge it into the list above. */
151
+ function reportBody(sections: readonly Section[]): string {
152
+ let bullet = "*";
153
+ return sections.map((section) => sectionMarkdown(section, bullet = section.heading || bullet === "*" ? "-" : "*")).join("\n\n");
154
+ }
155
+ const titleText = ({ tone, title }: Report) => `${TONES[tone].glyph} ${field(title)}`;
156
+ /** Bold glyph and title, then the section body: the RPC and message form of the TUI entry. */
157
+ export function reportMarkdown(report: Report): string {
158
+ return [`**${TONES[report.tone].glyph} ${escapeMarkdown(report.title)}**`, ...report.sections.length ? [reportBody(report.sections)] : []].join("\n\n");
159
+ }
160
+ /** Pi's custom-message look: a tone-colored bold title over a Markdown body. */
161
+ function reportComponent(report: Report, theme: Theme, paddingX: number): Box {
162
+ const box = new Box(paddingX, 1, (text) => theme.bg("customMessageBg", text));
163
+ box.addChild(new Text(theme.bold(theme.fg(TONES[report.tone].color, titleText(report))), 0, 0));
164
+ if (report.sections.length) {
165
+ box.addChild(new Spacer(1));
166
+ box.addChild(new Markdown(clean(reportBody(report.sections), ROLES_REPORT_CODE_POINTS, true), 0, 0, getMarkdownTheme(),
167
+ { color: (text) => theme.fg("customMessageText", text) }));
168
+ }
169
+ return box;
170
+ }
171
+ /** Adapter text: the first line is the title unless one is given; other lines are rows. */
172
+ function textReport(tone: Tone, text: string, title?: string): Report {
173
+ const lines = text.split("\n").filter((line) => line.trim());
174
+ const head = title ?? lines.shift() ?? "";
175
+ return { tone, title: head, sections: lines.length ? [{ rows: lines.map((line) => ({ text: line })) }] : [] };
176
+ }
177
+ const moreSection = (more: string | undefined): Section[] => more ? [{ rows: [{ text: more, tone: "warning" }] }] : [];
178
+
179
+ function rolesReport(report: RolesReport): Report {
180
+ if ("unavailable" in report) return { tone: "error", title: `Role catalog unavailable: ${report.unavailable}`, sections: [] };
181
+ return { tone: "info", title: `Roles (${report.roles.length})`, sections: [
182
+ ...report.roles.map((role): Section => ({ heading: role.name, note: role.source, rows: [
183
+ { label: "model class", value: role.modelClass ?? "default" },
184
+ ..."error" in role.route ? [{ label: "route", value: `error: ${role.route.error}`, tone: "error" as const }]
185
+ : [{ label: "model", value: role.route.model, code: true }, { label: "thinking", value: role.route.thinking }],
186
+ ...(["tools", "skills", "extensions", "mcps"] as const).map((key) => ({ label: key, value: role[key].join(", ") || "none" })),
187
+ ] })),
188
+ ...(report.errors.length ? [{ heading: `Invalid Role files (${report.errors.length})`, tone: "error" as const, rows: report.errors.map((text) => ({ text })) }] : []),
189
+ ] };
190
+ }
191
+
101
192
  const clean = (text: string, max = 110, multiline = false): string => [...text.replace(multiline ? /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g : /[\u0000-\u001f\u007f-\u009f]/g, " ")].slice(0, max).join("");
102
193
  const ROLES_REPORT_CODE_POINTS = 16_000;
103
194
  const errorText = (error: unknown): string => clean(error instanceof Error ? error.message : String(error), 500);
@@ -110,13 +201,19 @@ const summary = (text: string, max = 60): string => {
110
201
  const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
111
202
  const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
112
203
  const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
204
+ // Recorded-only and unverified work is uncertain, not active.
205
+ const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
206
+ : /failed|failure|aborted/i.test(status) ? "error" : completed(status) ? "success" : "info";
207
+ /** Uncolored: the selector colors each whole option, and inner ANSI resets break its highlight. */
208
+ const glyph = (status: string) => TONES[statusTone(status)].glyph;
209
+ const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
113
210
 
114
211
  const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
115
212
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
116
213
 
117
214
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
118
215
  function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number, omitted = NOT_SHOWN,
119
- count: (item: T) => number = () => 1): { text: string; shown: T[] } {
216
+ count: (item: T) => number = () => 1): { text: string; shown: T[]; more?: string } {
120
217
  const lines = [header];
121
218
  const shown: T[] = [];
122
219
  let size = [...header].length;
@@ -130,41 +227,76 @@ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => str
130
227
  shown.push(item);
131
228
  }
132
229
  const hidden = items.slice(shown.length).reduce((sum, item) => sum + count(item), 0);
133
- if (hidden) lines.push(`+${hidden} more not shown; ${omitted}`);
134
- return { text: lines.join("\n"), shown };
230
+ const more = hidden ? `+${hidden} more not shown; ${omitted}` : undefined;
231
+ if (more) lines.push(more);
232
+ return { text: lines.join("\n"), shown, more };
135
233
  }
136
234
 
137
235
  /** Groups items by exact reason, prunable groups first, in lines of 10 IDs so a long list is cut at whole lines.
138
236
  * Only shown items may be removed. IDs are display-only; removal uses the reviewed items. */
139
- function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneItem[] } {
140
- if (!items.length) return { text: `No pi-subagent worktrees or branches in ${root}.`, shown: [] };
237
+ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: PruneItem[] } {
238
+ if (!items.length) return { report: { tone: "info", title: `No pi-subagent worktrees or branches in ${root}.`, sections: [] }, shown: [] };
141
239
  const groups = new Map<string, PruneItem[]>();
142
240
  for (const item of [...items.filter((item) => item.prunable), ...items.filter((item) => !item.prunable)]) {
143
- const title = item.prunable ? `✓ Prunable: ${item.reason}` : `✗ Blocked: ${item.path ? item.reason : `${item.reason}; no checkout`}`;
241
+ const title = item.prunable ? `Prunable: ${item.reason}` : `Blocked: ${item.path ? item.reason : `${item.reason}; no checkout`}`;
144
242
  if (groups.has(title)) groups.get(title)!.push(item);
145
243
  else groups.set(title, [item]);
146
244
  }
147
- const rows = [...groups].flatMap(([title, group]) => Array.from({ length: Math.ceil(group.length / 10) }, (_, index) => {
148
- const chunk = group.slice(index * 10, index * 10 + 10);
149
- const ids = chunk.map(({ branch }) => clean(branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8))).join(", ");
150
- return { items: chunk, text: `${index ? "" : `${clean(title, 600)} (${group.length}):\n`} ${ids}` };
151
- }));
245
+ const rows = [...groups].flatMap(([title, group]) => {
246
+ const section: Section = { heading: `${title} (${group.length})`, tone: group[0]!.prunable ? "success" : "error", rows: [] };
247
+ return Array.from({ length: Math.ceil(group.length / 10) }, (_, index) => {
248
+ const chunk = group.slice(index * 10, index * 10 + 10);
249
+ return { section, index, items: chunk, row: { text: chunk.map(({ branch }) => branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8)).join(", "), code: true } };
250
+ });
251
+ });
152
252
  const prunable = items.filter((item) => item.prunable).length;
153
- const reviewed = fitItems(`Subagent prune review in ${root}: ${prunable} prunable, ${items.length - prunable} blocked. IDs are the first 8 characters after pi-subagent/subagent-.`,
154
- rows, (row) => row.text, ROLES_REPORT_CODE_POINTS, NOT_SHOWN, (row) => row.items.length);
155
- return { text: reviewed.text, shown: reviewed.shown.flatMap((row) => row.items) };
253
+ const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
254
+ { label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
255
+ { label: "IDs", value: "first 8 characters after pi-subagent/subagent-" },
256
+ ] }] };
257
+ // Each line measures its exact Markdown, so the cap never cuts a shown item.
258
+ const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
259
+ ROLES_REPORT_CODE_POINTS, NOT_SHOWN, (row) => row.items.length);
260
+ const sections = [...new Set(reviewed.shown.map(({ section }) => section))]
261
+ .map((section) => ({ ...section, rows: reviewed.shown.filter((row) => row.section === section).map(({ row }) => row) }));
262
+ return { report: { ...head, sections: [...head.sections, ...sections, ...moreSection(reviewed.more)] }, shown: reviewed.shown.flatMap((row) => row.items) };
156
263
  }
157
264
 
158
- function discardReview(plan: DiscardPlan) {
159
- if (!plan.items.length) return { text: `No remaining owned resources for ${plan.requestId}.`, shown: [] };
160
- return fitItems(`Close and delete request ${plan.requestId}:`, plan.items, (item) => clean([
161
- `${item.key}: ${item.blockers.length ? `blocked: ${item.blockers.join("; ")}` : "can delete"}`,
162
- `path: ${item.path ?? "none"}; checkout: ${item.checkout ? "yes" : "no"}; branch: ${item.branch ?? "none"}; tip: ${item.tip ?? "none"}`,
163
- `commits ahead of recorded base: ${item.commits ?? "unknown"}; dirty: ${item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no"}`,
164
- `ignored entries (including all content inside them): ${item.ignored.length ? item.ignored.join(", ") : "none"}`,
165
- `Herdr resources: ${item.hosts.length ? item.hosts.join("; ") : "none"}`,
166
- ].join("\n"), Number.MAX_SAFE_INTEGER, true), ROLES_REPORT_CODE_POINTS,
167
- "they are not removed; reopen this request to review them.");
265
+ function discardReview(plan: DiscardPlan): { report: Report; shown: DiscardPlan["items"] } {
266
+ if (!plan.items.length) return { report: { tone: "info", title: `No remaining owned resources for ${plan.requestId}.`, sections: [] }, shown: [] };
267
+ const section = (item: DiscardPlan["items"][number]): Section => ({ heading: item.key, note: item.blockers.length ? "blocked" : "can delete",
268
+ tone: item.blockers.length ? "error" : "warning", rows: [
269
+ { label: "path", value: item.path ?? "none", code: item.path !== undefined },
270
+ { label: "checkout", value: item.checkout ? "yes" : "no" },
271
+ { label: "branch", value: item.branch ?? "none", code: item.branch !== undefined },
272
+ { label: "tip", value: item.tip ?? "none", code: item.tip !== undefined },
273
+ { label: "commits ahead of recorded base", value: `${item.commits ?? "unknown"}` },
274
+ { label: "dirty", value: item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no" },
275
+ { label: "ignored entries, deleted with all content inside them", value: item.ignored.join(", ") || "none", code: item.ignored.length > 0 },
276
+ { label: "Herdr resources", value: item.hosts.join("; ") || "none" },
277
+ { label: "blockers", value: item.blockers.join("; ") || "none", ...item.blockers.length ? { tone: "error" as const } : {} },
278
+ ] });
279
+ const head: Report = { tone: "warning", title: `Close and delete request ${plan.requestId}`, sections: [] };
280
+ const reviewed = fitItems(reportMarkdown(head), plan.items, (item) => `\n${sectionMarkdown(section(item))}`, ROLES_REPORT_CODE_POINTS,
281
+ "they are not removed; reopen this request to review them.");
282
+ return { report: { ...head, sections: [...reviewed.shown.map(section), ...moreSection(reviewed.more)] }, shown: reviewed.shown };
283
+ }
284
+
285
+ function directReport(task: DirectTask): Report {
286
+ const [first] = task.workers;
287
+ const missing = first ? first.workers - task.workers.length : 0;
288
+ const untabbed = task.workers.filter((worker) => !task.tabs.some((tab) => tab.display?.worker === worker.worker));
289
+ return { tone: statusTone(task.status), title: `Direct workflow ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}`, sections: [
290
+ { rows: [{ label: "ID", value: task.id, code: true }, ...(task.recovery ? [{ label: "recovery", value: clean(task.recovery, 4000), tone: "warning" as const }] : [])] },
291
+ ...task.tabs.map((tab): Section => ({ heading: tab.display ? workerLabel(tab.display) : tab.name, rows: [
292
+ ...([["task", tab.entryId], ["agent", tab.name], ["tab", tab.tabId], ["pane", tab.paneId], ["session", tab.sessionFile]] as const).map(([label, value]) => ({ label, value, code: true })),
293
+ ...(compactCost(tab.cost) === undefined ? [] : [{ label: "cost", value: compactCost(tab.cost)! }]),
294
+ ] })),
295
+ ...(untabbed.length || missing > 0 ? [{ rows: [
296
+ ...untabbed.map((worker) => ({ text: `${workerLabel(worker)} · no verified tab recorded` })),
297
+ ...(missing > 0 ? [{ text: `${missing} other worker(s) in D${first!.workflow} have no recorded tab.` }] : []),
298
+ ] }] : []),
299
+ ] };
168
300
  }
169
301
 
170
302
  type Choice<T> = { label: string; value: T };
@@ -178,15 +310,23 @@ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly C
178
310
  }
179
311
 
180
312
  export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
313
+ pi.registerEntryRenderer<Report>(REPORT_ENTRY, (entry, _options, theme) => entry.data && reportComponent(entry.data, theme, 1));
314
+ pi.registerMessageRenderer(RECOVERY_MESSAGE, (message, { outputPad }, theme) => reportComponent(textReport("info", typeof message.content === "string"
315
+ ? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n"), "Recovery report"), theme, outputPad));
316
+ /** The TUI gets a themed transcript entry that stays out of model context; other modes get the same capped Markdown. */
317
+ const deliver = (ctx: ExtensionContext, report: Report, customType = REPORT_ENTRY) => {
318
+ if (ctx.hasUI && ctx.mode === "tui") return pi.appendEntry(REPORT_ENTRY, report);
319
+ const text = clean(reportMarkdown(report), ROLES_REPORT_CODE_POINTS, true);
320
+ if (ctx.hasUI) ctx.ui.notify(text, LEVEL[report.tone]);
321
+ else pi.sendMessage({ customType, content: text, display: true });
322
+ };
181
323
  pi.registerCommand("subagent", {
182
324
  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",
183
325
  handler: async (args, ctx) => {
184
326
  const command = args.trim();
185
327
  if (command && !["prune", "recover", "roles"].includes(command)) throw new Error("Usage: /subagent [prune|recover|roles]");
186
328
  if (command === "roles") {
187
- const report = clean(adapter.roles(ctx), ROLES_REPORT_CODE_POINTS, true);
188
- if (ctx.hasUI) ctx.ui.notify(report, "info");
189
- else pi.sendMessage({ customType: "pi-subagent-roles", content: report, display: true });
329
+ deliver(ctx, rolesReport(adapter.roles(ctx)), "pi-subagent-roles");
190
330
  return;
191
331
  }
192
332
 
@@ -207,7 +347,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
207
347
  if (command === "recover") {
208
348
  const report = await adapter.recover(ctx.cwd);
209
349
  if (!current()) return;
210
- pi.sendMessage({ customType: "pi-subagent-recovery", content: report, display: true },
350
+ pi.sendMessage({ customType: RECOVERY_MESSAGE, content: report, display: true },
211
351
  { triggerTurn: true, deliverAs: "followUp" });
212
352
  return;
213
353
  }
@@ -219,13 +359,12 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
219
359
  if (command === "prune") {
220
360
  const plan = await adapter.prunePlan(ctx.cwd);
221
361
  const reviewed = pruneReview(plan);
222
- const review = clean(reviewed.text, ROLES_REPORT_CODE_POINTS, true);
223
362
  if (!ctx.hasUI) {
224
- pi.sendMessage({ customType: "pi-subagent-prune", content: `${review}\nReport only: removal requires interactive confirmation.`, display: true });
363
+ deliver(ctx, { ...reviewed.report, sections: [...reviewed.report.sections, { rows: [{ text: "Report only: removal requires interactive confirmation." }] }] }, "pi-subagent-prune");
225
364
  return;
226
365
  }
227
366
  if (!valid()) return;
228
- ctx.ui.notify(review, "info");
367
+ deliver(ctx, reviewed.report);
229
368
  const prunable = reviewed.shown.filter((item) => item.prunable);
230
369
  if (!prunable.length) return;
231
370
  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.`);
@@ -245,16 +384,20 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
245
384
  if (!selected.length) return;
246
385
  try {
247
386
  const report = await adapter.prune(ctx.cwd, selected, allowIgnored, current);
248
- if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
387
+ if (valid()) deliver(ctx, cleanupReport(report));
249
388
  } catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
250
389
  return;
251
390
  }
252
391
  if (!ctx.hasUI) {
253
392
  const inventory = await adapter.isolated(ctx.cwd, current);
254
- const report = fitItems("Isolated requests (report only):", inventory.requests,
255
- (request) => clean(`${request.orphaned ? "orphaned · " : ""}${isolatedLabel(request)}`, 500), ROLES_REPORT_CODE_POINTS,
393
+ const head: Report = { tone: "info", title: "Isolated requests (report only)", sections: [] };
394
+ const row = (request: IsolatedRequest): Row => ({ text: `${glyph(request.status)} ${request.orphaned ? "orphaned · " : ""}${isolatedLabel(request)}` });
395
+ const listed = fitItems(`${reportMarkdown(head)}\n`, inventory.requests, (request) => sectionMarkdown({ rows: [row(request)] }), ROLES_REPORT_CODE_POINTS,
256
396
  "open /subagent with interactive UI to inspect them.");
257
- pi.sendMessage({ customType: "pi-subagent-discard", content: `${report.text}\nUnreadable state files preserved: ${inventory.invalidIds.length}.\nRemoval requires interactive confirmation.`, display: true });
397
+ deliver(ctx, { ...head, sections: [
398
+ ...(listed.shown.length ? [{ rows: listed.shown.map(row) }] : []), ...moreSection(listed.more),
399
+ { rows: [{ text: `Unreadable state files preserved: ${inventory.invalidIds.length}.` }, { text: "Removal requires interactive confirmation." }] },
400
+ ] }, "pi-subagent-discard");
258
401
  return;
259
402
  }
260
403
  let history = false;
@@ -275,10 +418,10 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
275
418
  items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
276
419
  ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
277
420
  const requests = inventory?.requests ?? [];
278
- const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
421
+ const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
279
422
  const selections: Choice<Selection>[] = [
280
423
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
281
- ...group("Direct", direct.filter((task) => history || !completed(task.status)), (task) => ({ label: ` ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } })),
424
+ ...group("Direct", direct.filter((task) => history || !completed(task.status)), (task) => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } })),
282
425
  ...group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
283
426
  ...group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
284
427
  { label: "Refresh", value: { kind: "refresh" } },
@@ -293,14 +436,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
293
436
  if (selected.kind === "direct") {
294
437
  const { task } = selected;
295
438
  const label = clean(directLabel(task));
296
- ctx.ui.notify(`Direct workflow ${label} · ${clean(task.status)}${costSuffix(task.cost)} · ID ${JSON.stringify(task.id)}`, "info");
297
- if (task.recovery) ctx.ui.notify(clean(task.recovery, 4000), "warning");
298
- for (const tab of task.tabs) {
299
- ctx.ui.notify(`${tab.display ? `${clean(workerLabel(tab.display))} · ` : ""}task ${JSON.stringify(tab.entryId)} · agent ${JSON.stringify(tab.name)} · tab ${JSON.stringify(tab.tabId)} · pane ${JSON.stringify(tab.paneId)} · session ${JSON.stringify(tab.sessionFile)}${costSuffix(tab.cost)}`, "info");
300
- }
301
- const untabbed = task.workers.filter((worker) => !task.tabs.some((tab) => tab.display?.worker === worker.worker));
302
- for (const worker of untabbed) ctx.ui.notify(`${clean(workerLabel(worker))} · no verified tab recorded`, "info");
303
- if (task.workers[0] && task.workers.length < task.workers[0].workers) ctx.ui.notify(`${task.workers[0].workers - task.workers.length} other worker(s) in D${task.workers[0].workflow} have no recorded tab.`, "info");
439
+ deliver(ctx, directReport(task));
304
440
  const stoppable = task.tabs.filter((tab) => task.stoppable?.includes(tab.entryId));
305
441
  if (task.canClose || stoppable.length) {
306
442
  const action = await choose<{ kind: "close" | "back" } | { kind: "stop"; entryId: string }>(ctx, `Direct · ${label}`, [
@@ -312,13 +448,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
312
448
  if (action?.kind === "stop") {
313
449
  try {
314
450
  const report = await adapter.stopDirect(task, action.entryId, current);
315
- if (valid()) ctx.ui.notify(report, "info");
451
+ if (valid()) deliver(ctx, textReport("success", report));
316
452
  } catch (error) { if (valid()) ctx.ui.notify(`Worker stop failed: ${errorText(error)}. Inspect the exact tab/session before retrying.`, "error"); }
317
453
  }
318
454
  if (action?.kind === "close") {
319
455
  try {
320
456
  const report = await adapter.closeDirect(task, current);
321
- if (valid()) ctx.ui.notify(report, "info");
457
+ if (valid()) deliver(ctx, textReport("success", report));
322
458
  } catch (error) { if (valid()) ctx.ui.notify(`Admission retained: ${errorText(error)}. Inspect the exact tab/session; retry Close/cancel-and-release after resolving termination.`, "error"); }
323
459
  }
324
460
  }
@@ -343,7 +479,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
343
479
  const plan = await adapter.discardPlan(ctx.cwd, request);
344
480
  const reviewed = discardReview(plan);
345
481
  if (!valid()) return;
346
- ctx.ui.notify(reviewed.text, "warning");
482
+ deliver(ctx, reviewed.report);
347
483
  const items = reviewed.shown.filter((item) => !item.blockers.length);
348
484
  if (!items.length) continue;
349
485
  const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)",
@@ -351,7 +487,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
351
487
  if (!valid()) return;
352
488
  if (confirmed) {
353
489
  const report = await adapter.discard(ctx.cwd, { ...plan, items }, current);
354
- if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
490
+ if (valid()) deliver(ctx, cleanupReport(report));
355
491
  }
356
492
  } catch (error) { if (valid()) ctx.ui.notify(`Close and delete failed: ${errorText(error)}`, "error"); }
357
493
  break;
@@ -359,7 +495,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
359
495
  if (action === "inspect") {
360
496
  try {
361
497
  const tab = await adapter.inspectInTab(root, request.id, ctx, current);
362
- if (valid()) ctx.ui.notify(`Status inspection ${tab.ref} started in Herdr tab ${tab.tabId} · agent ${tab.name} · session ${tab.sessionFile}.`, "info");
498
+ if (valid()) deliver(ctx, { tone: "success", title: `Status inspection ${tab.ref} started`, sections: [{ rows: [
499
+ { label: "tab", value: tab.tabId, code: true }, { label: "agent", value: tab.name, code: true }, { label: "session", value: tab.sessionFile, code: true }] }] });
363
500
  }
364
501
  catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
365
502
  continue;
@@ -397,21 +397,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
397
397
  const message = (error: unknown) => error instanceof Error ? error.message : String(error);
398
398
  let catalog: ReturnType<typeof loadRoleCatalog>;
399
399
  reportConfig(ctx);
400
- try { catalog = loadRoleCatalog(); } catch (error) { return `Role catalog unavailable: ${message(error)}`; }
401
- const list = (values: readonly string[] | undefined) => values?.length ? values.join(", ") : "none";
402
- const lines = catalog.roles.flatMap((role) => {
403
- let route: string;
400
+ try { catalog = loadRoleCatalog(); } catch (error) { return { unavailable: message(error) }; }
401
+ const roles = catalog.roles.map((role) => {
402
+ let route: { model: string; thinking: string } | { error: string };
404
403
  try {
405
404
  const resolved = role.modelClass === undefined ? resolveConfiguredTaskRoute(ctx, DELEGATE_TASK) : resolveTaskRoute(ctx, role.modelClass);
406
- route = `${modelReference(resolved.model)} · thinking ${resolved.thinkingLevel}`;
407
- } catch (error) { route = `route error: ${message(error)}`; }
408
- return [
409
- `${role.name} · ${catalog.sources.get(role.name)} · ${role.modelClass ?? "default route"} → ${route}`,
410
- ` tools: ${list(role.tools)} · extensions: ${list(role.extensions)} · skills: ${list(role.skills)} · mcps: ${list(role.mcps)}`,
411
- ];
405
+ route = { model: modelReference(resolved.model), thinking: resolved.thinkingLevel };
406
+ } catch (error) { route = { error: message(error) }; }
407
+ return { name: role.name, source: catalog.sources.get(role.name) ?? "unknown", modelClass: role.modelClass, route,
408
+ tools: role.tools ?? [], extensions: role.extensions ?? [], skills: role.skills ?? [], mcps: role.mcps ?? [] };
412
409
  });
413
- if (catalog.errors.length) lines.push(`Invalid Role files (${catalog.errors.length}):`, ...catalog.errors.map((error) => `- ${error.message}`));
414
- return lines.join("\n") || "No Roles configured.";
410
+ return { roles, errors: catalog.errors.map((error) => error.message) };
415
411
  },
416
412
  canFollowup: (root, id, task) => isolatedSurface.canFollowup(root, id, task),
417
413
  enqueue: (root, id, task, text, current) => isolatedSurface.enqueue(root, id, task, text, current),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.6.1",
3
+ "version": "27.6.3",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",