@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 +2 -0
- package/dist/prune.d.ts +1 -1
- package/dist/runner.d.ts +3 -2
- package/dist/runner.js +8 -2
- package/docs/orchestration.md +1 -1
- package/extensions/isolated.ts +8 -7
- package/extensions/subagent-command.ts +191 -54
- package/extensions/subagent.ts +8 -12
- package/package.json +1 -1
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
|
|
343
|
-
*
|
|
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
|
|
676
|
-
*
|
|
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))
|
package/docs/orchestration.md
CHANGED
|
@@ -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
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -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<
|
|
525
|
-
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<
|
|
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
|
-
|
|
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
|
|
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<
|
|
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<
|
|
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):
|
|
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
|
-
|
|
134
|
-
|
|
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): {
|
|
140
|
-
if (!items.length) return {
|
|
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 ?
|
|
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]) =>
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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 {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/extensions/subagent.ts
CHANGED
|
@@ -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
|
|
401
|
-
const
|
|
402
|
-
|
|
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 =
|
|
407
|
-
} catch (error) { route =
|
|
408
|
-
return
|
|
409
|
-
|
|
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
|
-
|
|
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),
|