@henryqw/pi-subagent 27.6.0 → 27.6.2

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
@@ -137,9 +137,11 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
137
137
 
138
138
  ### Inspect work
139
139
 
140
- Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. **Refresh** 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.
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
- Requests whose parent checkout was removed show as **Isolated · orphaned** 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.
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
+
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.
145
147
 
@@ -271,7 +271,7 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
271
271
 
272
272
  To select work by label, open `/subagent` without arguments. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout. This includes requests launched in other sessions. Select an isolated request. Then select **Inspect status and recovery**.
273
273
 
274
- The menu also shows **Isolated · orphaned** requests whose saved parent checkout no longer exists. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
274
+ The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
275
275
 
276
276
  This launches a Herdr tab without focus, with the configured `scout` Role on the configured `fast` route. The tab runs a read-access task with the resources of the Role. Its prompt contains bounded saved status, including retained paths, but not the raw launch environment or session transcripts. The analysis stays in that tab and does not queue a Main turn. The tab shows in the direct list with its own reference and name, such as `D9.1 · scout · Inspect <request ID>`. The extension records the tab and session file for recovery, even if the launch becomes uncertain.
277
277
 
@@ -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,18 +99,121 @@ 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);
195
+ /** One-line summary of free text such as a goal; the ellipsis marks a cut. */
196
+ const summary = (text: string, max = 60): string => {
197
+ const chars = [...clean(text, Number.MAX_SAFE_INTEGER).replace(/\s+/g, " ").trim()];
198
+ return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : chars.join("");
199
+ };
200
+ // Identity and status first so clipping cuts only the goal summary.
201
+ const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
104
202
  const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
105
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);
106
210
 
107
211
  const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
108
212
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
109
213
 
110
214
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
111
215
  function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number, omitted = NOT_SHOWN,
112
- count: (item: T) => number = () => 1): { text: string; shown: T[] } {
216
+ count: (item: T) => number = () => 1): { text: string; shown: T[]; more?: string } {
113
217
  const lines = [header];
114
218
  const shown: T[] = [];
115
219
  let size = [...header].length;
@@ -123,62 +227,106 @@ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => str
123
227
  shown.push(item);
124
228
  }
125
229
  const hidden = items.slice(shown.length).reduce((sum, item) => sum + count(item), 0);
126
- if (hidden) lines.push(`+${hidden} more not shown; ${omitted}`);
127
- 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 };
128
233
  }
129
234
 
130
235
  /** Groups items by exact reason, prunable groups first, in lines of 10 IDs so a long list is cut at whole lines.
131
236
  * Only shown items may be removed. IDs are display-only; removal uses the reviewed items. */
132
- function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneItem[] } {
133
- 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: [] };
134
239
  const groups = new Map<string, PruneItem[]>();
135
240
  for (const item of [...items.filter((item) => item.prunable), ...items.filter((item) => !item.prunable)]) {
136
- 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`}`;
137
242
  if (groups.has(title)) groups.get(title)!.push(item);
138
243
  else groups.set(title, [item]);
139
244
  }
140
- const rows = [...groups].flatMap(([title, group]) => Array.from({ length: Math.ceil(group.length / 10) }, (_, index) => {
141
- const chunk = group.slice(index * 10, index * 10 + 10);
142
- const ids = chunk.map(({ branch }) => clean(branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8))).join(", ");
143
- return { items: chunk, text: `${index ? "" : `${clean(title, 600)} (${group.length}):\n`} ${ids}` };
144
- }));
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
+ });
145
252
  const prunable = items.filter((item) => item.prunable).length;
146
- const reviewed = fitItems(`Subagent prune review in ${root}: ${prunable} prunable, ${items.length - prunable} blocked. IDs are the first 8 characters after pi-subagent/subagent-.`,
147
- rows, (row) => row.text, ROLES_REPORT_CODE_POINTS, NOT_SHOWN, (row) => row.items.length);
148
- 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) };
263
+ }
264
+
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 };
149
283
  }
150
284
 
151
- function discardReview(plan: DiscardPlan) {
152
- if (!plan.items.length) return { text: `No remaining owned resources for ${plan.requestId}.`, shown: [] };
153
- return fitItems(`Close and delete request ${plan.requestId}:`, plan.items, (item) => clean([
154
- `${item.key}: ${item.blockers.length ? `blocked: ${item.blockers.join("; ")}` : "can delete"}`,
155
- `path: ${item.path ?? "none"}; checkout: ${item.checkout ? "yes" : "no"}; branch: ${item.branch ?? "none"}; tip: ${item.tip ?? "none"}`,
156
- `commits ahead of recorded base: ${item.commits ?? "unknown"}; dirty: ${item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no"}`,
157
- `ignored entries (including all content inside them): ${item.ignored.length ? item.ignored.join(", ") : "none"}`,
158
- `Herdr resources: ${item.hosts.length ? item.hosts.join("; ") : "none"}`,
159
- ].join("\n"), Number.MAX_SAFE_INTEGER, true), ROLES_REPORT_CODE_POINTS,
160
- "they are not removed; reopen this request to review them.");
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
+ ] };
161
300
  }
162
301
 
163
302
  type Choice<T> = { label: string; value: T };
164
303
  async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
165
- // Numeric suffixes are UI-only. Never parse display text into an identity.
166
- const labels = items.map((item, index) => `${clean(item.label, 145)} [${index + 1}]`);
304
+ // Numeric suffixes only tell duplicate labels apart. Never parse display text into an identity.
305
+ const clipped = items.map((item) => clean(item.label, 145));
306
+ const labels = clipped.map((label, index) => clipped.indexOf(label) === clipped.lastIndexOf(label) ? label : `${label} [${index + 1}]`);
167
307
  const selected = await ctx.ui.select(title, labels);
168
308
  const index = labels.indexOf(selected ?? "");
169
309
  return index < 0 ? undefined : items[index]!.value;
170
310
  }
171
311
 
172
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
+ };
173
323
  pi.registerCommand("subagent", {
174
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",
175
325
  handler: async (args, ctx) => {
176
326
  const command = args.trim();
177
327
  if (command && !["prune", "recover", "roles"].includes(command)) throw new Error("Usage: /subagent [prune|recover|roles]");
178
328
  if (command === "roles") {
179
- const report = clean(adapter.roles(ctx), ROLES_REPORT_CODE_POINTS, true);
180
- if (ctx.hasUI) ctx.ui.notify(report, "info");
181
- else pi.sendMessage({ customType: "pi-subagent-roles", content: report, display: true });
329
+ deliver(ctx, rolesReport(adapter.roles(ctx)), "pi-subagent-roles");
182
330
  return;
183
331
  }
184
332
 
@@ -199,7 +347,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
199
347
  if (command === "recover") {
200
348
  const report = await adapter.recover(ctx.cwd);
201
349
  if (!current()) return;
202
- pi.sendMessage({ customType: "pi-subagent-recovery", content: report, display: true },
350
+ pi.sendMessage({ customType: RECOVERY_MESSAGE, content: report, display: true },
203
351
  { triggerTurn: true, deliverAs: "followUp" });
204
352
  return;
205
353
  }
@@ -211,13 +359,12 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
211
359
  if (command === "prune") {
212
360
  const plan = await adapter.prunePlan(ctx.cwd);
213
361
  const reviewed = pruneReview(plan);
214
- const review = clean(reviewed.text, ROLES_REPORT_CODE_POINTS, true);
215
362
  if (!ctx.hasUI) {
216
- 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");
217
364
  return;
218
365
  }
219
366
  if (!valid()) return;
220
- ctx.ui.notify(review, "info");
367
+ deliver(ctx, reviewed.report);
221
368
  const prunable = reviewed.shown.filter((item) => item.prunable);
222
369
  if (!prunable.length) return;
223
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.`);
@@ -237,16 +384,20 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
237
384
  if (!selected.length) return;
238
385
  try {
239
386
  const report = await adapter.prune(ctx.cwd, selected, allowIgnored, current);
240
- if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
387
+ if (valid()) deliver(ctx, cleanupReport(report));
241
388
  } catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
242
389
  return;
243
390
  }
244
391
  if (!ctx.hasUI) {
245
392
  const inventory = await adapter.isolated(ctx.cwd, current);
246
- const report = fitItems("Isolated requests (report only):", inventory.requests,
247
- (request) => clean(`${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, 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,
248
396
  "open /subagent with interactive UI to inspect them.");
249
- 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");
250
401
  return;
251
402
  }
252
403
  let history = false;
@@ -262,19 +413,21 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
262
413
  if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
263
414
  if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
264
415
  type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
416
+ // A header row selects Refresh; rows sort by status priority within their group.
417
+ const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
418
+ items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
419
+ ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
420
+ const requests = inventory?.requests ?? [];
421
+ const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
265
422
  const selections: Choice<Selection>[] = [
266
423
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
267
- ...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct" as const, task } })),
268
- ...(inventory?.requests ?? []).filter((request) => request.orphaned || history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
269
- { label: "Refresh", value: { kind: "refresh" as const } },
270
- { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
271
- { label: "Close", value: { kind: "close" as const } },
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 } })),
425
+ ...group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
426
+ ...group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
427
+ { label: "Refresh", value: { kind: "refresh" } },
428
+ { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" } },
429
+ { label: "Close", value: { kind: "close" } },
272
430
  ];
273
- const taskChoices = selections.splice(0, selections.length - 3);
274
- const rank = ({ value }: Choice<Selection>) => value.kind === "direct" ? priority(value.task.status)
275
- : value.kind === "isolated" ? priority(value.request.status) : 3;
276
- taskChoices.sort((a, b) => rank(a) - rank(b));
277
- selections.unshift(...taskChoices);
278
431
  if (!unavailable && !direct.length && !inventory?.requests.length && !inventory?.invalidIds.length) ctx.ui.notify("No direct tasks recorded on this session branch or isolated requests in this checkout. Ask Main to start delegation.", "info");
279
432
  const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
280
433
  if (!valid() || !selected || selected.kind === "close") return;
@@ -283,14 +436,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
283
436
  if (selected.kind === "direct") {
284
437
  const { task } = selected;
285
438
  const label = clean(directLabel(task));
286
- ctx.ui.notify(`Direct workflow ${label} · ${clean(task.status)}${costSuffix(task.cost)} · ID ${JSON.stringify(task.id)}`, "info");
287
- if (task.recovery) ctx.ui.notify(clean(task.recovery, 4000), "warning");
288
- for (const tab of task.tabs) {
289
- 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");
290
- }
291
- const untabbed = task.workers.filter((worker) => !task.tabs.some((tab) => tab.display?.worker === worker.worker));
292
- for (const worker of untabbed) ctx.ui.notify(`${clean(workerLabel(worker))} · no verified tab recorded`, "info");
293
- 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));
294
440
  const stoppable = task.tabs.filter((tab) => task.stoppable?.includes(tab.entryId));
295
441
  if (task.canClose || stoppable.length) {
296
442
  const action = await choose<{ kind: "close" | "back" } | { kind: "stop"; entryId: string }>(ctx, `Direct · ${label}`, [
@@ -302,13 +448,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
302
448
  if (action?.kind === "stop") {
303
449
  try {
304
450
  const report = await adapter.stopDirect(task, action.entryId, current);
305
- if (valid()) ctx.ui.notify(report, "info");
451
+ if (valid()) deliver(ctx, textReport("success", report));
306
452
  } catch (error) { if (valid()) ctx.ui.notify(`Worker stop failed: ${errorText(error)}. Inspect the exact tab/session before retrying.`, "error"); }
307
453
  }
308
454
  if (action?.kind === "close") {
309
455
  try {
310
456
  const report = await adapter.closeDirect(task, current);
311
- if (valid()) ctx.ui.notify(report, "info");
457
+ if (valid()) deliver(ctx, textReport("success", report));
312
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"); }
313
459
  }
314
460
  }
@@ -325,7 +471,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
325
471
  ...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
326
472
  { label: "Back / refresh", value: "back" },
327
473
  ];
328
- const action = await choose(ctx, `Isolated · ${clean(request.name)} · ${request.id}`, actions);
474
+ const action = await choose(ctx, `${request.orphaned ? "Orphaned" : "Isolated"} · ${request.id} · ${summary(request.name)}`, actions);
329
475
  if (!valid() || !action) return;
330
476
  if (action === "back") break;
331
477
  if (action === "discard") {
@@ -333,7 +479,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
333
479
  const plan = await adapter.discardPlan(ctx.cwd, request);
334
480
  const reviewed = discardReview(plan);
335
481
  if (!valid()) return;
336
- ctx.ui.notify(reviewed.text, "warning");
482
+ deliver(ctx, reviewed.report);
337
483
  const items = reviewed.shown.filter((item) => !item.blockers.length);
338
484
  if (!items.length) continue;
339
485
  const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)",
@@ -341,7 +487,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
341
487
  if (!valid()) return;
342
488
  if (confirmed) {
343
489
  const report = await adapter.discard(ctx.cwd, { ...plan, items }, current);
344
- if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
490
+ if (valid()) deliver(ctx, cleanupReport(report));
345
491
  }
346
492
  } catch (error) { if (valid()) ctx.ui.notify(`Close and delete failed: ${errorText(error)}`, "error"); }
347
493
  break;
@@ -349,14 +495,15 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
349
495
  if (action === "inspect") {
350
496
  try {
351
497
  const tab = await adapter.inspectInTab(root, request.id, ctx, current);
352
- 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 }] }] });
353
500
  }
354
501
  catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
355
502
  continue;
356
503
  }
357
504
  const live = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
358
505
  if (!live.length) { ctx.ui.notify("No locally active unsealed changeset remains. Refresh to see current status.", "warning"); continue; }
359
- const task = await choose(ctx, `Select task for ${action === "edit" ? "withdrawal and edit" : "follow-up"}`, live.map((item) => ({ label: `${item.name} · ${item.status} · ${item.id}`, value: item })));
506
+ const task = await choose(ctx, `Select task for ${action === "edit" ? "withdrawal and edit" : "follow-up"}`, live.map((item) => ({ label: `${item.id} · ${item.status} · ${summary(item.name)}`, value: item })));
360
507
  if (!valid() || !task) return;
361
508
  let prefill = "";
362
509
  if (action === "edit") {
@@ -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.0",
3
+ "version": "27.6.2",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",