@hcflabs/pi-subagent 0.0.0-stage → 27.10.7
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/CONTEXT.md +77 -0
- package/LICENSE +22 -0
- package/README.md +282 -2
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +104 -0
- package/dist/direct-herdr.d.ts +71 -0
- package/dist/direct-herdr.js +527 -0
- package/dist/discard.d.ts +51 -0
- package/dist/discard.js +218 -0
- package/dist/display-text.d.ts +5 -0
- package/dist/display-text.js +9 -0
- package/dist/ephemeral.d.ts +79 -0
- package/dist/ephemeral.js +916 -0
- package/dist/git-process.d.ts +8 -0
- package/dist/git-process.js +25 -0
- package/dist/git-runtime.d.ts +115 -0
- package/dist/git-runtime.js +489 -0
- package/dist/herdr-runtime.d.ts +147 -0
- package/dist/herdr-runtime.js +1366 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +587 -0
- package/dist/integration-git.d.ts +63 -0
- package/dist/integration-git.js +504 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +178 -0
- package/dist/mcp-role.d.ts +12 -0
- package/dist/mcp-role.js +152 -0
- package/dist/process-lease.d.ts +5 -0
- package/dist/process-lease.js +43 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/prune.d.ts +78 -0
- package/dist/prune.js +205 -0
- package/dist/review-evidence.d.ts +17 -0
- package/dist/review-evidence.js +221 -0
- package/dist/runner.d.ts +431 -0
- package/dist/runner.js +3380 -0
- package/dist/schema.d.ts +2560 -0
- package/dist/schema.js +1365 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +394 -0
- package/dist/worktree.d.ts +65 -0
- package/dist/worktree.js +288 -0
- package/docs/orchestration-architecture.html +138 -0
- package/docs/orchestration-architecture.svg +135 -0
- package/docs/orchestration.md +577 -0
- package/docs/worker-lifecycle.html +97 -0
- package/docs/worker-lifecycle.svg +86 -0
- package/example.png +0 -0
- package/examples/roles/implementer.md +25 -0
- package/examples/roles/reviewer.md +25 -0
- package/examples/roles/scout.md +25 -0
- package/extensions/admission.ts +82 -0
- package/extensions/config.ts +19 -0
- package/extensions/git-read.ts +126 -0
- package/extensions/isolated.ts +945 -0
- package/extensions/model-class-policy.ts +1 -0
- package/extensions/result-transport.ts +236 -0
- package/extensions/role-mcp.ts +14 -0
- package/extensions/role-tools.ts +178 -0
- package/extensions/subagent-command.ts +699 -0
- package/extensions/subagent.ts +958 -0
- package/extensions/workflow.ts +336 -0
- package/package.json +84 -4
- package/prompts/review.md +20 -0
- package/skills/pi-subagent/SKILL.md +41 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { getMarkdownTheme, getSelectListTheme, keyHint, keyText, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Markdown, SelectList, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { DiscardPlan } from "../dist/discard.js";
|
|
4
|
+
import type { PruneItem } from "../dist/prune.js";
|
|
5
|
+
import { TERMINAL_RUN_STATUSES } from "../dist/schema.js";
|
|
6
|
+
import { compactCost } from "./result-transport.ts";
|
|
7
|
+
|
|
8
|
+
/** Saved before launch for user matching only; never ownership or mutation authority. */
|
|
9
|
+
export interface DirectDisplay {
|
|
10
|
+
/** Session-local workflow number. */
|
|
11
|
+
workflow: number;
|
|
12
|
+
/** One-based original workflow entry index. */
|
|
13
|
+
worker: number;
|
|
14
|
+
workers: number;
|
|
15
|
+
title: string;
|
|
16
|
+
role: string;
|
|
17
|
+
}
|
|
18
|
+
export const directRef = ({ workflow, worker }: DirectDisplay): string => `D${workflow}.${worker}`;
|
|
19
|
+
|
|
20
|
+
/** Read-only branch records; the adapter groups all tabs of a direct workflow. */
|
|
21
|
+
export interface DirectTask {
|
|
22
|
+
id: string;
|
|
23
|
+
/** Legacy label: saved allocation label, otherwise the exact task ID. */
|
|
24
|
+
name: string;
|
|
25
|
+
status: string;
|
|
26
|
+
canClose?: boolean;
|
|
27
|
+
/** Entry IDs of running workers in a locally owned all-read workflow; each can be stopped alone. */
|
|
28
|
+
stoppable?: readonly string[];
|
|
29
|
+
recovery?: string;
|
|
30
|
+
/** Request total cost from the delivered result; absent before delivery or when unmeasured. */
|
|
31
|
+
cost?: number;
|
|
32
|
+
/** Recorded workers by original index; empty for legacy records. */
|
|
33
|
+
workers: readonly DirectDisplay[];
|
|
34
|
+
tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string; display?: DirectDisplay; cost?: number }[];
|
|
35
|
+
}
|
|
36
|
+
const costSuffix = (cost: number | undefined) => compactCost(cost) === undefined ? "" : ` · ${compactCost(cost)}`;
|
|
37
|
+
|
|
38
|
+
const workerLabel = (display: DirectDisplay) => `${directRef(display)} · ${display.role} · ${display.title}`;
|
|
39
|
+
const directLabel = ({ name, workers: [first] }: DirectTask): string => !first ? name
|
|
40
|
+
: first.workers === 1 ? workerLabel(first) : `D${first.workflow} · ${first.workers} workers`;
|
|
41
|
+
|
|
42
|
+
export interface IsolatedTask {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
status: string;
|
|
46
|
+
kind: string;
|
|
47
|
+
}
|
|
48
|
+
export interface IsolatedRequest {
|
|
49
|
+
id: string;
|
|
50
|
+
/** Saved state address for a removed parent checkout. */
|
|
51
|
+
directory?: string;
|
|
52
|
+
root?: string;
|
|
53
|
+
orphaned?: boolean;
|
|
54
|
+
name: string;
|
|
55
|
+
status: string;
|
|
56
|
+
tasks: readonly IsolatedTask[];
|
|
57
|
+
}
|
|
58
|
+
export interface IsolatedInventory {
|
|
59
|
+
root: string;
|
|
60
|
+
/** A live productive run holds this root's lease; the lease does not name its request. */
|
|
61
|
+
leaseLive: boolean;
|
|
62
|
+
requests: readonly IsolatedRequest[];
|
|
63
|
+
invalidIds: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type { PruneItem };
|
|
67
|
+
export interface PrunePlan {
|
|
68
|
+
root: string;
|
|
69
|
+
items: readonly PruneItem[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Mutation adapters MUST check current() synchronously alongside their live ownership
|
|
73
|
+
* validation and queue operation. They must not await between those operations. */
|
|
74
|
+
export interface SubagentCommandAdapter {
|
|
75
|
+
direct(ctx: ExtensionContext): readonly DirectTask[];
|
|
76
|
+
closeDirect(task: DirectTask, current: () => boolean): Promise<string>;
|
|
77
|
+
stopDirect(task: DirectTask, entryId: string, current: () => boolean): Promise<string>;
|
|
78
|
+
isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
|
|
79
|
+
recover(cwd: string): Promise<string>;
|
|
80
|
+
/** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
|
|
81
|
+
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
82
|
+
discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
|
|
83
|
+
discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
|
|
84
|
+
/** Re-verify and remove reviewed prunable items, and with `force` reviewed forceable items; returns a bounded report. */
|
|
85
|
+
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, force: boolean, current: () => boolean): Promise<CleanupReport>;
|
|
86
|
+
/** Read-only Role catalog report with routes and per-file load errors. */
|
|
87
|
+
roles(ctx: ExtensionContext): RolesReport;
|
|
88
|
+
inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
|
|
89
|
+
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
90
|
+
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
91
|
+
drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
|
|
92
|
+
/** Session start/shutdown epoch; prevents same-session reload after an awaited dialog. */
|
|
93
|
+
epoch(): number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bounded removal report with outcome counts, so retained resources never read as a normal completion. */
|
|
97
|
+
export interface CleanupReport { text: string; failed: number; skipped: number }
|
|
98
|
+
export interface RoleReport {
|
|
99
|
+
name: string;
|
|
100
|
+
source: string;
|
|
101
|
+
/** Role modelClass, or undefined for the configured default route. */
|
|
102
|
+
modelClass?: string;
|
|
103
|
+
route: { model: string; thinking: string } | { error: string };
|
|
104
|
+
tools: readonly string[];
|
|
105
|
+
extensions: readonly string[];
|
|
106
|
+
skills: readonly string[];
|
|
107
|
+
mcps: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
export type RolesReport = { roles: readonly RoleReport[]; errors: readonly string[] } | { unavailable: string };
|
|
110
|
+
|
|
111
|
+
type Tone = "info" | "success" | "warning" | "error";
|
|
112
|
+
/** `code` shows the value as inline code: IDs, paths, branches, model references, and session files. */
|
|
113
|
+
type Row = { label: string; value: string; tone?: Tone; code?: boolean } | { text: string; tone?: Tone; code?: boolean };
|
|
114
|
+
interface Section { heading?: string; note?: string; tone?: Tone; rows: readonly Row[] }
|
|
115
|
+
/** One /subagent output; every delivery mode renders it as Markdown. */
|
|
116
|
+
export interface Report { tone: Tone; title: string; sections: readonly Section[] }
|
|
117
|
+
const REPORT_ENTRY = "pi-subagent-report";
|
|
118
|
+
const RECOVERY_MESSAGE = "pi-subagent-recovery";
|
|
119
|
+
|
|
120
|
+
const TONES: Record<Tone, { glyph: string; color: Parameters<Theme["fg"]>[0] }> = {
|
|
121
|
+
info: { glyph: "●", color: "accent" }, success: { glyph: "✓", color: "success" },
|
|
122
|
+
warning: { glyph: "⚠", color: "warning" }, error: { glyph: "✗", color: "error" },
|
|
123
|
+
};
|
|
124
|
+
const LEVEL = { info: "info", success: "info", warning: "warning", error: "error" } as const;
|
|
125
|
+
const field = (text: string) => clean(text, Number.MAX_SAFE_INTEGER);
|
|
126
|
+
/** Escapes inline and line-start Markdown. Brackets, parentheses, and `!` stay literal because Pi reads `\(` and `\[` as LaTeX. */
|
|
127
|
+
const escapeMarkdown = (text: string) => field(text).replace(/[\\`*_~<>&|$]/g, "\\$&").replace(/^(\s*)([#+-])/, "$1\\$2").replace(/^(\s*\d+)([.)])/, "$1\\$2");
|
|
128
|
+
const inlineCode = (text: string) => {
|
|
129
|
+
const value = field(text);
|
|
130
|
+
const fence = "`".repeat(Math.max(0, ...(value.match(/`+/g) ?? []).map((run) => run.length)) + 1);
|
|
131
|
+
const pad = /^`|`$/.test(value) ? " " : "";
|
|
132
|
+
return value ? `${fence}${pad}${value}${pad}${fence}` : "";
|
|
133
|
+
};
|
|
134
|
+
const glyphPrefix = (tone?: Tone) => tone ? `${TONES[tone].glyph} ` : "";
|
|
135
|
+
|
|
136
|
+
/** `### heading · note`, then one list item per row. */
|
|
137
|
+
function sectionMarkdown({ heading, note, tone, rows }: Section, bullet = "-"): string {
|
|
138
|
+
const value = (text: string, row: Row) => row.code ? `${glyphPrefix(row.tone)}${inlineCode(text)}` : escapeMarkdown(`${glyphPrefix(row.tone)}${text}`);
|
|
139
|
+
return [
|
|
140
|
+
...(heading ? [`### ${glyphPrefix(tone)}${escapeMarkdown(heading)}${note ? ` · ${escapeMarkdown(note)}` : ""}`] : []),
|
|
141
|
+
...rows.map((row) => `${bullet} ${"label" in row ? `**${escapeMarkdown(row.label)}:** ${value(row.value, row)}` : value(row.text, row)}`),
|
|
142
|
+
].join("\n");
|
|
143
|
+
}
|
|
144
|
+
/** A headless section switches its bullet so Markdown does not merge it into the list above. */
|
|
145
|
+
function reportBody(sections: readonly Section[]): string {
|
|
146
|
+
let bullet = "*";
|
|
147
|
+
return sections.map((section) => sectionMarkdown(section, bullet = section.heading || bullet === "*" ? "-" : "*")).join("\n\n");
|
|
148
|
+
}
|
|
149
|
+
const titleText = ({ tone, title }: Report) => `${TONES[tone].glyph} ${field(title)}`;
|
|
150
|
+
/** Bold glyph and title, then the section body: the RPC and message form of the TUI entry. */
|
|
151
|
+
export function reportMarkdown(report: Report): string {
|
|
152
|
+
return [`**${TONES[report.tone].glyph} ${escapeMarkdown(report.title)}**`, ...report.sections.length ? [reportBody(report.sections)] : []].join("\n\n");
|
|
153
|
+
}
|
|
154
|
+
/** Pi's custom-message look: a tone-colored bold title over a Markdown body. */
|
|
155
|
+
function reportComponent(report: Report, theme: Theme, paddingX: number): Box {
|
|
156
|
+
const box = new Box(paddingX, 1, (text) => theme.bg("customMessageBg", text));
|
|
157
|
+
box.addChild(new Text(theme.bold(theme.fg(TONES[report.tone].color, titleText(report))), 0, 0));
|
|
158
|
+
if (report.sections.length) {
|
|
159
|
+
box.addChild(new Spacer(1));
|
|
160
|
+
box.addChild(new Markdown(clean(reportBody(report.sections), ROLES_REPORT_CODE_POINTS, true), 0, 0, getMarkdownTheme(),
|
|
161
|
+
{ color: (text) => theme.fg("customMessageText", text) }));
|
|
162
|
+
}
|
|
163
|
+
return box;
|
|
164
|
+
}
|
|
165
|
+
/** Adapter text: the first line is the title unless one is given; other lines are rows. */
|
|
166
|
+
function textReport(tone: Tone, text: string, title?: string): Report {
|
|
167
|
+
const lines = text.split("\n").filter((line) => line.trim());
|
|
168
|
+
const head = title ?? lines.shift() ?? "";
|
|
169
|
+
return { tone, title: head, sections: lines.length ? [{ rows: lines.map((line) => ({ text: line })) }] : [] };
|
|
170
|
+
}
|
|
171
|
+
const moreSection = (more: string | undefined): Section[] => more ? [{ rows: [{ text: more, tone: "warning" }] }] : [];
|
|
172
|
+
|
|
173
|
+
function rolesReport(report: RolesReport): Report {
|
|
174
|
+
if ("unavailable" in report) return { tone: "error", title: `Role catalog unavailable: ${report.unavailable}`, sections: [] };
|
|
175
|
+
return { tone: "info", title: `Roles (${report.roles.length})`, sections: [
|
|
176
|
+
...report.roles.map((role): Section => ({ heading: role.name, note: role.source, rows: [
|
|
177
|
+
{ label: "model class", value: role.modelClass ?? "default" },
|
|
178
|
+
..."error" in role.route ? [{ label: "route", value: `error: ${role.route.error}`, tone: "error" as const }]
|
|
179
|
+
: [{ label: "model", value: role.route.model, code: true }, { label: "thinking", value: role.route.thinking }],
|
|
180
|
+
...(["tools", "skills", "extensions", "mcps"] as const).map((key) => ({ label: key, value: role[key].join(", ") || "none" })),
|
|
181
|
+
] })),
|
|
182
|
+
...(report.errors.length ? [{ heading: `Invalid Role files (${report.errors.length})`, tone: "error" as const, rows: report.errors.map((text) => ({ text })) }] : []),
|
|
183
|
+
] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
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("");
|
|
187
|
+
const ROLES_REPORT_CODE_POINTS = 16_000;
|
|
188
|
+
const errorText = (error: unknown): string => clean(error instanceof Error ? error.message : String(error), 500);
|
|
189
|
+
/** One-line summary of free text such as a goal; the ellipsis marks a cut. */
|
|
190
|
+
const summary = (text: string, max = 60): string => {
|
|
191
|
+
const chars = [...clean(text, Number.MAX_SAFE_INTEGER).replace(/\s+/g, " ").trim()];
|
|
192
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : chars.join("");
|
|
193
|
+
};
|
|
194
|
+
// Identity and status first so clipping cuts only the goal summary.
|
|
195
|
+
const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
|
|
196
|
+
const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
|
|
197
|
+
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
198
|
+
// Recorded-only and unverified work is uncertain, not active.
|
|
199
|
+
const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
|
|
200
|
+
: /failed|failure|aborted/i.test(status) ? "error" : completed(status) ? "success" : "info";
|
|
201
|
+
/** Uncolored: the selector colors each whole option, and inner ANSI resets break its highlight. */
|
|
202
|
+
const glyph = (status: string) => TONES[statusTone(status)].glyph;
|
|
203
|
+
const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
|
|
204
|
+
|
|
205
|
+
const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
|
|
206
|
+
const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").replace(/([0-9a-f]{8})[0-9a-f]{16}$/, "$1");
|
|
207
|
+
const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
|
|
208
|
+
|
|
209
|
+
/** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
|
|
210
|
+
function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number, omitted = NOT_SHOWN,
|
|
211
|
+
count: (item: T) => number = () => 1): { text: string; shown: T[]; more?: string } {
|
|
212
|
+
const lines = [header];
|
|
213
|
+
const shown: T[] = [];
|
|
214
|
+
let size = [...header].length;
|
|
215
|
+
const reserve = [...omitted].length + 32;
|
|
216
|
+
for (const item of items) {
|
|
217
|
+
const text = line(item);
|
|
218
|
+
const length = [...text].length + 1;
|
|
219
|
+
if (size + length + reserve > max) break;
|
|
220
|
+
lines.push(text);
|
|
221
|
+
size += length;
|
|
222
|
+
shown.push(item);
|
|
223
|
+
}
|
|
224
|
+
const hidden = items.slice(shown.length).reduce((sum, item) => sum + count(item), 0);
|
|
225
|
+
const more = hidden ? `+${hidden} more not shown; ${omitted}` : undefined;
|
|
226
|
+
if (more) lines.push(more);
|
|
227
|
+
return { text: lines.join("\n"), shown, more };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Groups items by exact reason, prunable groups first, in lines of 10 IDs so a long list is cut at whole lines.
|
|
231
|
+
* Only shown items may be removed. IDs are display-only; removal uses the reviewed items. */
|
|
232
|
+
function pruneReview({ root, items }: PrunePlan): { report: Report; shown: PruneItem[] } {
|
|
233
|
+
if (!items.length) return { report: { tone: "info", title: `No pi-subagent worktrees or branches in ${root}.`, sections: [] }, shown: [] };
|
|
234
|
+
const groups = new Map<string, PruneItem[]>();
|
|
235
|
+
for (const item of [...items.filter((item) => item.prunable), ...items.filter((item) => !item.prunable)]) {
|
|
236
|
+
const title = item.prunable ? `Prunable: ${item.reason}` : `Blocked: ${item.path ? item.reason : `${item.reason}; no checkout`}`;
|
|
237
|
+
if (groups.has(title)) groups.get(title)!.push(item);
|
|
238
|
+
else groups.set(title, [item]);
|
|
239
|
+
}
|
|
240
|
+
const rows = [...groups].flatMap(([title, group]) => {
|
|
241
|
+
const section: Section = { heading: `${title} (${group.length})`, tone: group[0]!.prunable ? "success" : "error", rows: [] };
|
|
242
|
+
return Array.from({ length: Math.ceil(group.length / 10) }, (_, index) => {
|
|
243
|
+
const chunk = group.slice(index * 10, index * 10 + 10);
|
|
244
|
+
return { section, index, items: chunk, row: { text: chunk.map(shortId).join(", "), code: true } };
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
const prunable = items.filter((item) => item.prunable).length;
|
|
248
|
+
const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
|
|
249
|
+
{ label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
|
|
250
|
+
{ label: "IDs", value: "readable name, if present, and first 8 suffix characters" },
|
|
251
|
+
] }] };
|
|
252
|
+
// Each line measures its exact Markdown, so the cap never cuts a shown item.
|
|
253
|
+
const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
|
|
254
|
+
ROLES_REPORT_CODE_POINTS, NOT_SHOWN, (row) => row.items.length);
|
|
255
|
+
const sections = [...new Set(reviewed.shown.map(({ section }) => section))]
|
|
256
|
+
.map((section) => ({ ...section, rows: reviewed.shown.filter((row) => row.section === section).map(({ row }) => row) }));
|
|
257
|
+
return { report: { ...head, sections: [...head.sections, ...sections, ...moreSection(reviewed.more)] }, shown: reviewed.shown.flatMap((row) => row.items) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function discardReview(plan: DiscardPlan): { report: Report; shown: DiscardPlan["items"] } {
|
|
261
|
+
if (!plan.items.length) return { report: { tone: "info", title: `No remaining owned resources for ${plan.requestId}.`, sections: [] }, shown: [] };
|
|
262
|
+
const section = (item: DiscardPlan["items"][number]): Section => ({ heading: item.key, note: item.blockers.length ? "blocked" : "can delete",
|
|
263
|
+
tone: item.blockers.length ? "error" : "warning", rows: [
|
|
264
|
+
{ label: "path", value: item.path ?? "none", code: item.path !== undefined },
|
|
265
|
+
{ label: "checkout", value: item.checkout ? "yes" : "no" },
|
|
266
|
+
{ label: "branch", value: item.branch ?? "none", code: item.branch !== undefined },
|
|
267
|
+
{ label: "tip", value: item.tip ?? "none", code: item.tip !== undefined },
|
|
268
|
+
{ label: "commits ahead of recorded base", value: `${item.commits ?? "unknown"}` },
|
|
269
|
+
{ label: "dirty", value: item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no" },
|
|
270
|
+
{ label: "ignored entries, deleted with all content inside them", value: item.ignored.join(", ") || "none", code: item.ignored.length > 0 },
|
|
271
|
+
{ label: "Herdr resources", value: item.hosts.join("; ") || "none" },
|
|
272
|
+
{ label: "blockers", value: item.blockers.join("; ") || "none", ...item.blockers.length ? { tone: "error" as const } : {} },
|
|
273
|
+
] });
|
|
274
|
+
const head: Report = { tone: "warning", title: `Close and delete request ${plan.requestId}`, sections: [] };
|
|
275
|
+
const reviewed = fitItems(reportMarkdown(head), plan.items, (item) => `\n${sectionMarkdown(section(item))}`, ROLES_REPORT_CODE_POINTS,
|
|
276
|
+
"they are not removed; reopen this request to review them.");
|
|
277
|
+
return { report: { ...head, sections: [...reviewed.shown.map(section), ...moreSection(reviewed.more)] }, shown: reviewed.shown };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function directReport(task: DirectTask): Report {
|
|
281
|
+
const [first] = task.workers;
|
|
282
|
+
const missing = first ? first.workers - task.workers.length : 0;
|
|
283
|
+
const untabbed = task.workers.filter((worker) => !task.tabs.some((tab) => tab.display?.worker === worker.worker));
|
|
284
|
+
return { tone: statusTone(task.status), title: `Direct workflow ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}`, sections: [
|
|
285
|
+
{ rows: [{ label: "ID", value: task.id, code: true }, ...(task.recovery ? [{ label: "recovery", value: clean(task.recovery, 4000), tone: "warning" as const }] : [])] },
|
|
286
|
+
...task.tabs.map((tab): Section => ({ heading: tab.display ? workerLabel(tab.display) : tab.name, rows: [
|
|
287
|
+
...([["task", tab.entryId], ["agent", tab.name], ["tab", tab.tabId], ["pane", tab.paneId], ["session", tab.sessionFile]] as const).map(([label, value]) => ({ label, value, code: true })),
|
|
288
|
+
...(compactCost(tab.cost) === undefined ? [] : [{ label: "cost", value: compactCost(tab.cost)! }]),
|
|
289
|
+
] })),
|
|
290
|
+
...(untabbed.length || missing > 0 ? [{ rows: [
|
|
291
|
+
...untabbed.map((worker) => ({ text: `${workerLabel(worker)} · no verified tab recorded` })),
|
|
292
|
+
...(missing > 0 ? [{ text: `${missing} other worker(s) in D${first!.workflow} have no recorded tab.` }] : []),
|
|
293
|
+
] }] : []),
|
|
294
|
+
] };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const WORKING_REPORT_CODE_POINTS = 4_000;
|
|
298
|
+
/** Current work from local and saved evidence only. Recorded-only direct work and isolated work without a live lease never count as working. */
|
|
299
|
+
export function workingReport(direct: readonly DirectTask[], inventory: IsolatedInventory | undefined): Report {
|
|
300
|
+
const working: Row[] = [];
|
|
301
|
+
const attention: Row[] = [];
|
|
302
|
+
const unverified: Row[] = [];
|
|
303
|
+
for (const task of direct) {
|
|
304
|
+
const text = `${directLabel(task)} · ${task.status}`;
|
|
305
|
+
// Read workflows list their running workers; write workflows stay observed locally until their workers are proved stopped.
|
|
306
|
+
if (task.status === "observed locally" && task.stoppable?.length !== 0) working.push({ text: task.stoppable ? `${directLabel(task)} · ${task.stoppable.length} running` : text });
|
|
307
|
+
else if (/retained|attention/.test(task.status)) attention.push({ text });
|
|
308
|
+
else if (completed(task.status)) continue;
|
|
309
|
+
else unverified.push({ text: task.status === "observed locally" ? `${text}; no running worker` : text });
|
|
310
|
+
}
|
|
311
|
+
for (const request of inventory?.requests ?? []) {
|
|
312
|
+
const active = !request.orphaned && ["pending", "running"].includes(request.status);
|
|
313
|
+
if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
|
|
314
|
+
else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
|
|
315
|
+
else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
|
|
316
|
+
else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
|
|
317
|
+
}
|
|
318
|
+
const sections: Section[] = [
|
|
319
|
+
working.length ? { heading: `Working (${working.length})`, tone: "info", rows: working } : { rows: [{ text: inventory ? "No subagents are working." : "No direct subagents are working. Isolated status is unavailable." }] },
|
|
320
|
+
{ heading: `Needs attention (${attention.length})`, tone: "warning", rows: attention },
|
|
321
|
+
{ heading: `Unverified (${unverified.length})`, note: "not proof of running or completion", tone: "warning", rows: unverified },
|
|
322
|
+
];
|
|
323
|
+
const head: Report = { tone: attention.length ? "warning" : "info", title: "Current subagent work", sections: [] };
|
|
324
|
+
const items = sections.flatMap((section) => section.rows.map((row, index) => ({ section, index, row })));
|
|
325
|
+
const fitted = fitItems(reportMarkdown(head), items, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
|
|
326
|
+
WORKING_REPORT_CODE_POINTS, "open /subagent with interactive UI to see them.");
|
|
327
|
+
return { ...head, sections: [...sections.flatMap((section) => {
|
|
328
|
+
const rows = fitted.shown.filter((item) => item.section === section).map(({ row }) => row);
|
|
329
|
+
return rows.length ? [{ ...section, rows }] : [];
|
|
330
|
+
}), ...moreSection(fitted.more)] };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type Choice<T> = { label: string; value: T };
|
|
334
|
+
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[], selectedIndex = 0): Promise<T | undefined> {
|
|
335
|
+
// Numeric suffixes only tell duplicate labels apart. Never parse display text into an identity.
|
|
336
|
+
const clipped = items.map((item) => clean(item.label, 145));
|
|
337
|
+
const labels = clipped.map((label, index) => clipped.indexOf(label) === clipped.lastIndexOf(label) ? label : `${label} [${index + 1}]`);
|
|
338
|
+
if (ctx.mode !== "tui") {
|
|
339
|
+
const selected = await ctx.ui.select(title, labels);
|
|
340
|
+
const index = labels.indexOf(selected ?? "");
|
|
341
|
+
return index < 0 ? undefined : items[index]!.value;
|
|
342
|
+
}
|
|
343
|
+
return ctx.ui.custom<T | undefined>((tui, theme, _keys, done) => {
|
|
344
|
+
const rows = labels.map((label, index) => ({ label, value: String(index) }));
|
|
345
|
+
const createList = () => {
|
|
346
|
+
// Reserve title, key hints, and the native scroll indicator. Very short terminals show only the list.
|
|
347
|
+
const height = tui.terminal.rows;
|
|
348
|
+
const list = new SelectList(rows, Math.max(1, height - (height >= 4 ? 3 : 1)), getSelectListTheme());
|
|
349
|
+
list.setSelectedIndex(selectedIndex);
|
|
350
|
+
list.onSelectionChange = ({ value }) => { selectedIndex = Number(value); };
|
|
351
|
+
list.onSelect = ({ value }) => done(items[Number(value)]!.value);
|
|
352
|
+
list.onCancel = () => done(undefined);
|
|
353
|
+
return list;
|
|
354
|
+
};
|
|
355
|
+
let list = createList();
|
|
356
|
+
return {
|
|
357
|
+
render(width) {
|
|
358
|
+
list = createList();
|
|
359
|
+
const lines = list.render(width);
|
|
360
|
+
if (tui.terminal.rows < 4) return lines.slice(0, tui.terminal.rows);
|
|
361
|
+
return [truncateToWidth(theme.fg("accent", title), width), ...lines,
|
|
362
|
+
truncateToWidth([`${keyText("tui.select.up")}/${keyText("tui.select.down")} scroll`,
|
|
363
|
+
keyHint("tui.select.confirm", "select"), keyHint("tui.select.cancel", "cancel")].join(" · "), width)];
|
|
364
|
+
},
|
|
365
|
+
handleInput(data) { list.handleInput(data); tui.requestRender(); },
|
|
366
|
+
invalidate() { list.invalidate(); },
|
|
367
|
+
};
|
|
368
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 } });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Multi-select on single-choice menus: each pick toggles one row until Continue. Returns chosen values in
|
|
372
|
+
* item order, or undefined on Cancel or a changed session. Only selectable rows are offered. */
|
|
373
|
+
async function chooseMany<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[], preselected: readonly T[],
|
|
374
|
+
valid: () => boolean): Promise<T[] | undefined> {
|
|
375
|
+
type Pick = { kind: "toggle"; value: T } | { kind: "all" } | { kind: "clear" } | { kind: "continue" } | { kind: "cancel" };
|
|
376
|
+
const chosen = new Set(preselected);
|
|
377
|
+
let selectedIndex = 0;
|
|
378
|
+
for (;;) {
|
|
379
|
+
const choices: Choice<Pick>[] = [
|
|
380
|
+
...items.map((item) => ({ label: `${chosen.has(item.value) ? "[x]" : "[ ]"} ${item.label}`, value: { kind: "toggle" as const, value: item.value } })),
|
|
381
|
+
{ label: "Select all", value: { kind: "all" } }, { label: "Clear", value: { kind: "clear" } },
|
|
382
|
+
{ label: `Continue (${chosen.size} selected)`, value: { kind: "continue" } }, { label: "Cancel", value: { kind: "cancel" } },
|
|
383
|
+
];
|
|
384
|
+
const picked = await choose(ctx, title, choices, selectedIndex);
|
|
385
|
+
if (!valid() || !picked || picked.kind === "cancel") return undefined;
|
|
386
|
+
selectedIndex = choices.findIndex(({ value }) => value === picked);
|
|
387
|
+
if (picked.kind === "continue") return items.map(({ value }) => value).filter((value) => chosen.has(value));
|
|
388
|
+
if (picked.kind === "all") for (const { value } of items) chosen.add(value);
|
|
389
|
+
else if (picked.kind === "clear") chosen.clear();
|
|
390
|
+
else if (!chosen.delete(picked.value)) chosen.add(picked.value);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The owner is named once, so clipping keeps the other blockers.
|
|
395
|
+
const pruneLabel = (item: PruneItem) => item.override === "discard"
|
|
396
|
+
? [`${shortId(item)} · delete whole request ${item.owner!.id} (${item.owner!.status})`, ...item.blockers.filter(({ kind }) => kind !== "owner").map(({ text }) => text)].join(" · ")
|
|
397
|
+
: `${shortId(item)} · ${item.prunable ? "" : "force · "}${item.reason}`;
|
|
398
|
+
|
|
399
|
+
export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
|
|
400
|
+
pi.registerEntryRenderer<Report>(REPORT_ENTRY, (entry, _options, theme) => entry.data && reportComponent(entry.data, theme, 1));
|
|
401
|
+
pi.registerMessageRenderer(RECOVERY_MESSAGE, (message, { outputPad }, theme) => reportComponent(textReport("info", typeof message.content === "string"
|
|
402
|
+
? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n"), "Recovery report"), theme, outputPad));
|
|
403
|
+
/** The TUI gets a themed transcript entry that stays out of model context; other modes get the same capped Markdown. */
|
|
404
|
+
const deliver = (ctx: ExtensionContext, report: Report, customType = REPORT_ENTRY) => {
|
|
405
|
+
if (ctx.hasUI && ctx.mode === "tui") return pi.appendEntry(REPORT_ENTRY, report);
|
|
406
|
+
const text = clean(reportMarkdown(report), ROLES_REPORT_CODE_POINTS, true);
|
|
407
|
+
if (ctx.hasUI) ctx.ui.notify(text, LEVEL[report.tone]);
|
|
408
|
+
else pi.sendMessage({ customType, content: text, display: true });
|
|
409
|
+
};
|
|
410
|
+
pi.registerCommand("subagent", {
|
|
411
|
+
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",
|
|
412
|
+
handler: async (args, ctx) => {
|
|
413
|
+
const command = args.trim();
|
|
414
|
+
if (command && !["prune", "recover", "roles"].includes(command)) throw new Error("Usage: /subagent [prune|recover|roles]");
|
|
415
|
+
if (command === "roles") {
|
|
416
|
+
deliver(ctx, rolesReport(adapter.roles(ctx)), "pi-subagent-roles");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const manager = ctx.sessionManager;
|
|
421
|
+
const session = manager.getSessionId();
|
|
422
|
+
const file = manager.getSessionFile();
|
|
423
|
+
const branch = manager.getBranch().map((entry) => entry.id);
|
|
424
|
+
// A branch may grow during a dialog, but navigating to a descendant that
|
|
425
|
+
// already existed when it opened must not retarget its pending actions.
|
|
426
|
+
const existing = new Set(manager.getEntries().map((entry) => entry.id));
|
|
427
|
+
const epoch = adapter.epoch();
|
|
428
|
+
const current = () => {
|
|
429
|
+
if (adapter.epoch() !== epoch || manager.getSessionId() !== session || manager.getSessionFile() !== file) return false;
|
|
430
|
+
const now = manager.getBranch();
|
|
431
|
+
return now.length >= branch.length && branch.every((id, index) => now[index]?.id === id)
|
|
432
|
+
&& now.slice(branch.length).every((entry) => !existing.has(entry.id));
|
|
433
|
+
};
|
|
434
|
+
if (command === "recover") {
|
|
435
|
+
const report = await adapter.recover(ctx.cwd);
|
|
436
|
+
if (!current()) return;
|
|
437
|
+
pi.sendMessage({ customType: RECOVERY_MESSAGE, content: report, display: true },
|
|
438
|
+
{ triggerTurn: true, deliverAs: "followUp" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const valid = () => {
|
|
442
|
+
if (current()) return true;
|
|
443
|
+
ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
|
|
444
|
+
return false;
|
|
445
|
+
};
|
|
446
|
+
/** Review each request's discard plan, then one confirmation covers every shown unblocked group.
|
|
447
|
+
* `whole` states that a request is deleted although only some of its resources were selected. Returns false when stale. */
|
|
448
|
+
const discardRequests = async (requests: readonly IsolatedRequest[], whole = false): Promise<boolean> => {
|
|
449
|
+
const plans: DiscardPlan[] = [];
|
|
450
|
+
for (const request of requests) {
|
|
451
|
+
try {
|
|
452
|
+
const plan = await adapter.discardPlan(ctx.cwd, request);
|
|
453
|
+
const reviewed = discardReview(plan);
|
|
454
|
+
if (!valid()) return false;
|
|
455
|
+
deliver(ctx, reviewed.report);
|
|
456
|
+
const items = reviewed.shown.filter((item) => !item.blockers.length);
|
|
457
|
+
if (items.length) plans.push({ ...plan, items });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (!valid()) return false;
|
|
460
|
+
ctx.ui.notify(`Close and delete failed for ${request.id}: ${errorText(error)}`, "error");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!plans.length) return true;
|
|
464
|
+
const groups = plans.reduce((sum, plan) => sum + plan.items.length, 0);
|
|
465
|
+
const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)", clean(`${whole
|
|
466
|
+
? `This closes and deletes each whole owning request (${plans.map(({ requestId }) => requestId).join(", ")}), not only the selected worktrees. ` : ""}`
|
|
467
|
+
+ `Stop the listed workers and delete the ${groups} unblocked resource group(s) shown above${plans.length > 1 ? ` across ${plans.length} requests` : ""}? Committed, uncommitted, and ignored content in the listed checkouts is permanently deleted. Their listed branches, Herdr tabs, and workspaces are removed. Items not shown or blocked are kept.`,
|
|
468
|
+
PRUNE_IGNORED_CONFIRM_CODE_POINTS));
|
|
469
|
+
if (!valid()) return false;
|
|
470
|
+
if (!confirmed) return true;
|
|
471
|
+
for (const plan of plans) {
|
|
472
|
+
try {
|
|
473
|
+
const report = await adapter.discard(ctx.cwd, plan, current);
|
|
474
|
+
if (!valid()) return false;
|
|
475
|
+
deliver(ctx, cleanupReport(report));
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (!valid()) return false;
|
|
478
|
+
ctx.ui.notify(`Close and delete failed for ${plan.requestId}: ${errorText(error)}`, "error");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return true;
|
|
482
|
+
};
|
|
483
|
+
if (command === "prune") {
|
|
484
|
+
const plan = await adapter.prunePlan(ctx.cwd);
|
|
485
|
+
const reviewed = pruneReview(plan);
|
|
486
|
+
if (!ctx.hasUI) {
|
|
487
|
+
deliver(ctx, { ...reviewed.report, sections: [...reviewed.report.sections, { rows: [{ text: "Report only: removal requires interactive confirmation." }] }] }, "pi-subagent-prune");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!valid()) return;
|
|
491
|
+
deliver(ctx, reviewed.report);
|
|
492
|
+
const options = reviewed.shown.filter((item) => item.prunable || item.override);
|
|
493
|
+
if (!options.length) return;
|
|
494
|
+
// Continue confirms prunable items; each is re-verified first. Blocked items need their own confirmation below.
|
|
495
|
+
const picked = await chooseMany(ctx, "Prune subagent worktrees · prunable items are selected", options.map((item) => ({ label: pruneLabel(item), value: item })),
|
|
496
|
+
options.filter((item) => item.prunable), valid);
|
|
497
|
+
if (!picked?.length) return;
|
|
498
|
+
const forceable = picked.filter((item) => item.override === "force");
|
|
499
|
+
const forceList = fitItems(`Force remove ${forceable.length} blocked item(s)? This permanently deletes their unique commits, tracked and untracked changes, in-progress Git operations, and initialized submodules, as listed. Each is re-verified first and kept if a new kind of blocker appears:`,
|
|
500
|
+
forceable, (item) => `${shortId(item)}: ${item.reason}`, PRUNE_IGNORED_CONFIRM_CODE_POINTS);
|
|
501
|
+
const force = forceList.shown.length > 0 && await ctx.ui.confirm("Force remove blocked worktrees",
|
|
502
|
+
clean(forceList.text.replace(`Force remove ${forceable.length} blocked`, `Force remove ${forceList.shown.length} blocked`), PRUNE_IGNORED_CONFIRM_CODE_POINTS, true));
|
|
503
|
+
if (!valid()) return;
|
|
504
|
+
if (forceable.length && !force) ctx.ui.notify(`Kept ${forceable.length} blocked item(s); force removal was not approved.`, "info");
|
|
505
|
+
const prunable = [...picked.filter((item) => item.prunable), ...force ? forceList.shown : []];
|
|
506
|
+
const ignored = prunable.filter((item) => item.ignored.length);
|
|
507
|
+
// The header uses the full count's width so the shown count always fits.
|
|
508
|
+
const ignoredList = fitItems(`Also remove ${ignored.length} checkout(s) with ignored files? This permanently deletes these ignored entries and everything inside them:`,
|
|
509
|
+
// Approval names every ignored entry; a checkout whose full list does not fit is left out of this run.
|
|
510
|
+
ignored, (item) => clean(`${item.path}: ${item.ignored.join(", ")}`, Number.MAX_SAFE_INTEGER), PRUNE_IGNORED_CONFIRM_CODE_POINTS);
|
|
511
|
+
const approvable = ignoredList.shown;
|
|
512
|
+
const allowIgnored = approvable.length > 0 && await ctx.ui.confirm("Delete ignored content",
|
|
513
|
+
clean(ignoredList.text.replace(`Also remove ${ignored.length} checkout(s)`, `Also remove ${approvable.length} checkout(s)`), PRUNE_IGNORED_CONFIRM_CODE_POINTS, true));
|
|
514
|
+
if (!valid()) return;
|
|
515
|
+
const kept = ignored.length - (allowIgnored ? approvable.length : 0);
|
|
516
|
+
if (kept) ctx.ui.notify(`Kept ${kept} checkout(s) with ignored content; their deletion was not approved.`, "info");
|
|
517
|
+
const selected = prunable.filter((item) => !item.ignored.length || (allowIgnored && approvable.includes(item)));
|
|
518
|
+
if (selected.length) {
|
|
519
|
+
try {
|
|
520
|
+
const report = await adapter.prune(ctx.cwd, selected, allowIgnored, force, current);
|
|
521
|
+
if (!valid()) return;
|
|
522
|
+
deliver(ctx, cleanupReport(report));
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (!valid()) return;
|
|
525
|
+
ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Discard owns request-owned items: it closes the request's own workers and panes, then removes its resources.
|
|
529
|
+
const owners = new Map(picked.flatMap((item) => item.override === "discard" ? [[`${item.owner!.directory}\0${item.owner!.id}`, item.owner!] as const] : []));
|
|
530
|
+
await discardRequests([...owners.values()].map((owner) => ({ ...owner, tasks: [] })), true);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!ctx.hasUI) {
|
|
534
|
+
const inventory = await adapter.isolated(ctx.cwd, current);
|
|
535
|
+
deliver(ctx, workingReport(adapter.direct(ctx), inventory));
|
|
536
|
+
const head: Report = { tone: "info", title: "Isolated requests (report only)", sections: [] };
|
|
537
|
+
const row = (request: IsolatedRequest): Row => ({ text: `${glyph(request.status)} ${request.orphaned ? "orphaned · " : ""}${isolatedLabel(request)}` });
|
|
538
|
+
const listed = fitItems(`${reportMarkdown(head)}\n`, inventory.requests, (request) => sectionMarkdown({ rows: [row(request)] }), ROLES_REPORT_CODE_POINTS,
|
|
539
|
+
"open /subagent with interactive UI to inspect them.");
|
|
540
|
+
deliver(ctx, { ...head, sections: [
|
|
541
|
+
...(listed.shown.length ? [{ rows: listed.shown.map(row) }] : []), ...moreSection(listed.more),
|
|
542
|
+
{ rows: [{ text: `Unreadable state files preserved: ${inventory.invalidIds.length}.` }, { text: "Removal requires interactive confirmation." }] },
|
|
543
|
+
] }, "pi-subagent-discard");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
let history = false;
|
|
547
|
+
// The working report shows on open and on each Refresh, not after other menu actions.
|
|
548
|
+
let report = true;
|
|
549
|
+
for (;;) {
|
|
550
|
+
if (!valid()) return;
|
|
551
|
+
// Direct recovery is independent of Git and isolated config/inventory failures.
|
|
552
|
+
const direct = adapter.direct(ctx);
|
|
553
|
+
let inventory: IsolatedInventory | undefined;
|
|
554
|
+
let unavailable: string | undefined;
|
|
555
|
+
try { inventory = await adapter.isolated(ctx.cwd, current); }
|
|
556
|
+
catch (error) { unavailable = errorText(error); }
|
|
557
|
+
if (!valid()) return;
|
|
558
|
+
if (report) deliver(ctx, workingReport(direct, inventory));
|
|
559
|
+
report = false;
|
|
560
|
+
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
561
|
+
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");
|
|
562
|
+
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
|
|
563
|
+
// A header row selects Refresh; rows sort by status priority within their group.
|
|
564
|
+
const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
|
|
565
|
+
items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
|
|
566
|
+
...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
|
|
567
|
+
const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
|
|
568
|
+
const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
|
|
569
|
+
// One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
|
|
570
|
+
const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
|
|
571
|
+
const groups = [
|
|
572
|
+
group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
|
|
573
|
+
group("Isolated", requests.filter((request) => !request.orphaned), isolatedChoice),
|
|
574
|
+
group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
|
|
575
|
+
].flat();
|
|
576
|
+
const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
|
|
577
|
+
const selections: Choice<Selection>[] = [
|
|
578
|
+
...groups,
|
|
579
|
+
...(rows.length ? [{ label: "Select multiple…", value: { kind: "many" as const } }] : []),
|
|
580
|
+
{ label: "Refresh", value: { kind: "refresh" } },
|
|
581
|
+
{ label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" } },
|
|
582
|
+
{ label: "Close", value: { kind: "close" } },
|
|
583
|
+
];
|
|
584
|
+
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");
|
|
585
|
+
const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
|
|
586
|
+
if (!valid() || !selected || selected.kind === "close") return;
|
|
587
|
+
if (selected.kind === "refresh") { report = true; continue; }
|
|
588
|
+
if (selected.kind === "history") { history = !history; continue; }
|
|
589
|
+
if (selected.kind === "many") {
|
|
590
|
+
const picked = await chooseMany(ctx, "Subagent · select work", rows.map(({ label, value }) => ({ label: label.trim(), value })), [], valid);
|
|
591
|
+
if (!picked?.length) { if (!valid()) return; continue; }
|
|
592
|
+
const isolated = picked.flatMap((item) => item.kind === "isolated" ? [item.request] : []);
|
|
593
|
+
const closable = picked.flatMap((item) => item.kind === "direct" && item.task.canClose ? [item.task] : []);
|
|
594
|
+
type Batch = "discard" | "close" | "back";
|
|
595
|
+
const action = await choose<Batch>(ctx, `Subagent · ${picked.length} selected`, [
|
|
596
|
+
...(isolated.length ? [{ label: `Close and delete worktrees (discard work) of ${isolated.length} isolated request(s)`, value: "discard" as const }] : []),
|
|
597
|
+
...(closable.length ? [{ label: `Close/cancel-and-release ALL owned workers in ${closable.length} direct workflow(s) (preserve files and commits)`, value: "close" as const }] : []),
|
|
598
|
+
{ label: "Back / refresh", value: "back" },
|
|
599
|
+
]);
|
|
600
|
+
if (!valid()) return;
|
|
601
|
+
if (!action || action === "back") continue;
|
|
602
|
+
const skipped = picked.length - (action === "discard" ? isolated.length : closable.length);
|
|
603
|
+
if (skipped) ctx.ui.notify(`Skipped ${skipped} selected item(s) that this action does not apply to.`, "info");
|
|
604
|
+
if (action === "discard") { if (!await discardRequests(isolated)) return; continue; }
|
|
605
|
+
for (const task of closable) {
|
|
606
|
+
try {
|
|
607
|
+
const report = await adapter.closeDirect(task, current);
|
|
608
|
+
if (!valid()) return;
|
|
609
|
+
deliver(ctx, textReport("success", report));
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (!valid()) return;
|
|
612
|
+
ctx.ui.notify(`Admission retained for ${clean(directLabel(task))}: ${errorText(error)}. Inspect the exact tab/session; retry Close/cancel-and-release after resolving termination.`, "error");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (selected.kind === "direct") {
|
|
618
|
+
const { task } = selected;
|
|
619
|
+
const label = clean(directLabel(task));
|
|
620
|
+
deliver(ctx, directReport(task));
|
|
621
|
+
const stoppable = task.tabs.filter((tab) => task.stoppable?.includes(tab.entryId));
|
|
622
|
+
if (task.canClose || stoppable.length) {
|
|
623
|
+
const action = await choose<{ kind: "close" | "back" } | { kind: "stop"; entryId: string }>(ctx, `Direct · ${label}`, [
|
|
624
|
+
...(task.canClose ? [{ label: "Close/cancel-and-release ALL owned workers in this workflow (preserve files and commits)", value: { kind: "close" as const } }] : []),
|
|
625
|
+
...stoppable.map((tab) => ({ label: `Stop this worker: ${tab.display ? workerLabel(tab.display) : tab.name} (other workers continue)`, value: { kind: "stop" as const, entryId: tab.entryId } })),
|
|
626
|
+
{ label: "Back / refresh", value: { kind: "back" as const } },
|
|
627
|
+
]);
|
|
628
|
+
if (!valid()) return;
|
|
629
|
+
if (action?.kind === "stop") {
|
|
630
|
+
try {
|
|
631
|
+
const report = await adapter.stopDirect(task, action.entryId, current);
|
|
632
|
+
if (valid()) deliver(ctx, textReport("success", report));
|
|
633
|
+
} catch (error) { if (valid()) ctx.ui.notify(`Worker stop failed: ${errorText(error)}. Inspect the exact tab/session before retrying.`, "error"); }
|
|
634
|
+
}
|
|
635
|
+
if (action?.kind === "close") {
|
|
636
|
+
try {
|
|
637
|
+
const report = await adapter.closeDirect(task, current);
|
|
638
|
+
if (valid()) deliver(ctx, textReport("success", report));
|
|
639
|
+
} 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"); }
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const { root, request } = selected;
|
|
645
|
+
for (;;) {
|
|
646
|
+
if (!valid()) return;
|
|
647
|
+
type Action = "inspect" | "send" | "edit" | "discard" | "back";
|
|
648
|
+
const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
649
|
+
const actions: Choice<Action>[] = [
|
|
650
|
+
...(!request.orphaned ? [{ label: "Inspect status and recovery", value: "inspect" as const }] : []),
|
|
651
|
+
{ label: "Close and delete worktrees (discard work)", value: "discard" },
|
|
652
|
+
...(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 }] : []),
|
|
653
|
+
{ label: "Back / refresh", value: "back" },
|
|
654
|
+
];
|
|
655
|
+
const action = await choose(ctx, `${request.orphaned ? "Orphaned" : "Isolated"} · ${request.id} · ${summary(request.name)}`, actions);
|
|
656
|
+
if (!valid() || !action) return;
|
|
657
|
+
if (action === "back") break;
|
|
658
|
+
if (action === "discard") {
|
|
659
|
+
if (!await discardRequests([request])) return;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
if (action === "inspect") {
|
|
663
|
+
try {
|
|
664
|
+
const tab = await adapter.inspectInTab(root, request.id, ctx, current);
|
|
665
|
+
if (valid()) deliver(ctx, { tone: "success", title: `Status inspection ${tab.ref} started`, sections: [{ rows: [
|
|
666
|
+
{ label: "tab", value: tab.tabId, code: true }, { label: "agent", value: tab.name, code: true }, { label: "session", value: tab.sessionFile, code: true }] }] });
|
|
667
|
+
}
|
|
668
|
+
catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const live = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
672
|
+
if (!live.length) { ctx.ui.notify("No locally active unsealed changeset remains. Refresh to see current status.", "warning"); continue; }
|
|
673
|
+
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 })));
|
|
674
|
+
if (!valid() || !task) return;
|
|
675
|
+
let prefill = "";
|
|
676
|
+
if (action === "edit") {
|
|
677
|
+
try {
|
|
678
|
+
const withdrawn = adapter.drain(root, request.id, task.id, current);
|
|
679
|
+
if (!withdrawn.length) { ctx.ui.notify("No pending instructions remain for this task; already-claimed instructions cannot be recalled.", "info"); continue; }
|
|
680
|
+
prefill = withdrawn.join("\n\n");
|
|
681
|
+
} catch (error) { if (valid()) ctx.ui.notify(`Cannot withdraw: ${errorText(error)}`, "error"); continue; }
|
|
682
|
+
}
|
|
683
|
+
for (;;) {
|
|
684
|
+
const text = await ctx.ui.editor(action === "edit" ? "Edit withdrawn instructions · submit ONE replacement; Cancel leaves originals withdrawn" : "Send follow-up instructions", prefill);
|
|
685
|
+
if (!valid() || text === undefined) return;
|
|
686
|
+
try {
|
|
687
|
+
ctx.ui.notify(adapter.enqueue(root, request.id, task.id, text, current), "info");
|
|
688
|
+
break;
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (!valid()) return;
|
|
691
|
+
ctx.ui.notify(`Not queued: ${errorText(error)}. Submitted text will reopen in this editor for editing or copying; Cancel does not queue it.`, "error");
|
|
692
|
+
prefill = text;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
}
|