@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 +4 -2
- package/docs/orchestration.md +1 -1
- package/extensions/isolated.ts +8 -7
- package/extensions/subagent-command.ts +213 -66
- package/extensions/subagent.ts +8 -12
- package/package.json +1 -1
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
|
-
|
|
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
|
|
package/docs/orchestration.md
CHANGED
|
@@ -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
|
|
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
|
|
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,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
|
-
|
|
127
|
-
|
|
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): {
|
|
133
|
-
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: [] };
|
|
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 ?
|
|
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]) =>
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
166
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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))
|
|
268
|
-
...(
|
|
269
|
-
|
|
270
|
-
{ label:
|
|
271
|
-
{ label: "
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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") {
|
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),
|