@henryqw/pi-subagent 18.0.1 → 22.0.0
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 +76 -24
- package/README.md +116 -142
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +107 -0
- package/dist/direct-herdr.d.ts +17 -0
- package/dist/direct-herdr.js +219 -0
- package/dist/ephemeral.d.ts +2 -2
- package/dist/ephemeral.js +7 -7
- package/dist/git-runtime.d.ts +110 -0
- package/dist/git-runtime.js +472 -0
- package/dist/herdr-runtime.d.ts +137 -0
- package/dist/herdr-runtime.js +1228 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +20 -16
- package/dist/integration-git.d.ts +59 -0
- package/dist/integration-git.js +484 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +195 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/runner.d.ts +356 -0
- package/dist/runner.js +2710 -0
- package/dist/schema.d.ts +2234 -0
- package/dist/schema.js +1236 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +288 -0
- package/dist/working-evidence.d.ts +25 -0
- package/dist/working-evidence.js +261 -0
- package/docs/adr/001-composable-ephemeral-execution.md +3 -3
- package/docs/delegation-routing.html +89 -0
- package/docs/delegation-routing.svg +78 -0
- package/docs/orchestration-architecture.html +238 -0
- package/docs/orchestration-architecture.svg +189 -0
- package/docs/orchestration.md +122 -331
- package/example.png +0 -0
- package/examples/roles/implementer.md +2 -3
- package/extensions/admission.ts +66 -0
- package/extensions/config.ts +51 -37
- package/extensions/isolated.ts +638 -0
- package/extensions/result-transport.ts +13 -103
- package/extensions/role-tools.ts +11 -11
- package/extensions/subagent-command.ts +167 -0
- package/extensions/subagent.ts +264 -540
- package/extensions/task-name.ts +1 -2
- package/extensions/workflow.ts +60 -96
- package/package.json +14 -6
- package/skills/pi-subagent/SKILL.md +35 -0
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
addUsage,
|
|
4
|
-
capEphemeralSubagentOutput,
|
|
5
|
-
type EphemeralSubagentResult,
|
|
6
|
-
type WorktreePayload,
|
|
7
|
-
} from "@henryqw/pi-subagent";
|
|
1
|
+
import { capEphemeralSubagentOutput } from "@henryqw/pi-subagent";
|
|
8
2
|
import type { WorkflowEntry, WorkflowMode } from "./workflow.ts";
|
|
9
3
|
|
|
10
4
|
const EVIDENCE_PREVIEW_CODE_POINTS = 256;
|
|
@@ -18,17 +12,11 @@ type TransportEntryBase = {
|
|
|
18
12
|
role: WorkflowEntry["delegation"]["role"];
|
|
19
13
|
model?: string;
|
|
20
14
|
thinkingLevel?: string;
|
|
21
|
-
worktreePayload?: WorktreePayload;
|
|
22
|
-
usage?: Usage;
|
|
23
15
|
};
|
|
24
16
|
|
|
25
17
|
export type WorkflowTransportEntry =
|
|
26
18
|
| TransportEntryBase & { status: "pending" | "skipped"; assistantOutput?: never; failure?: never }
|
|
27
|
-
| TransportEntryBase & {
|
|
28
|
-
status: "running" | "succeeded";
|
|
29
|
-
assistantOutput: EphemeralSubagentResult["output"];
|
|
30
|
-
failure?: never;
|
|
31
|
-
}
|
|
19
|
+
| TransportEntryBase & { status: "running" | "succeeded"; assistantOutput: string; failure?: never }
|
|
32
20
|
| TransportEntryBase & { status: "failed" | "rejected"; assistantOutput?: never; failure: string };
|
|
33
21
|
|
|
34
22
|
export type WorkflowTransportEntryDetails = {
|
|
@@ -40,7 +28,6 @@ export type WorkflowTransportEntryDetails = {
|
|
|
40
28
|
summary?: string;
|
|
41
29
|
model?: string;
|
|
42
30
|
thinkingLevel?: string;
|
|
43
|
-
worktree?: WorktreePayload;
|
|
44
31
|
};
|
|
45
32
|
|
|
46
33
|
export type WorkflowTransportDetails = {
|
|
@@ -52,17 +39,14 @@ export type BackgroundWorkflowTransportDetails = WorkflowTransportDetails & {
|
|
|
52
39
|
taskId: string;
|
|
53
40
|
outcome: "completed" | "failed" | "aborted";
|
|
54
41
|
recovery?: true;
|
|
55
|
-
usage?: Usage;
|
|
56
42
|
};
|
|
57
43
|
|
|
58
44
|
export type WorkflowTransport = {
|
|
59
45
|
text: string;
|
|
60
46
|
details: WorkflowTransportDetails;
|
|
61
|
-
usage?: Usage;
|
|
62
47
|
failed: boolean;
|
|
63
48
|
};
|
|
64
49
|
|
|
65
|
-
type TransportKind = "result" | "update" | "background" | "abort";
|
|
66
50
|
type Evidence = { heading: string; preview: string; remainder: string };
|
|
67
51
|
|
|
68
52
|
function compareEntries(left: WorkflowTransportEntry, right: WorkflowTransportEntry): number {
|
|
@@ -80,25 +64,18 @@ function workflowTitle(mode: WorkflowMode): string {
|
|
|
80
64
|
return "Delegation chain";
|
|
81
65
|
}
|
|
82
66
|
|
|
83
|
-
function statusCounts(entries: readonly WorkflowTransportEntry[]
|
|
67
|
+
function statusCounts(entries: readonly WorkflowTransportEntry[]): string[] {
|
|
84
68
|
const count = (statuses: WorkflowTransportStatus[]) => entries.filter(({ status }) => statuses.includes(status)).length;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
69
|
+
return ([
|
|
70
|
+
[["failed", "rejected"], "failed"],
|
|
71
|
+
[["succeeded"], "completed"],
|
|
72
|
+
[["skipped"], "skipped"],
|
|
73
|
+
] as const).flatMap(([statuses, word]) => {
|
|
90
74
|
const total = count([...statuses]);
|
|
91
|
-
return total ? [`${total} ${
|
|
75
|
+
return total ? [`${total} ${word}`] : [];
|
|
92
76
|
});
|
|
93
77
|
}
|
|
94
78
|
|
|
95
|
-
function heading(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[], kind: TransportKind, failed: boolean): string {
|
|
96
|
-
const title = kind === "background" ? `Background ${workflowTitle(mode).toLowerCase()}` : workflowTitle(mode);
|
|
97
|
-
if (kind === "update") return [title, ...statusCounts(entries, kind)].join(" · ");
|
|
98
|
-
const outcome = kind === "abort" ? "stopped" : failed ? "failed" : "completed";
|
|
99
|
-
return [`${title} ${outcome}`, ...statusCounts(entries, kind)].join(" · ");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
79
|
export function displaySummary(text: string): string {
|
|
103
80
|
const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) ?? "";
|
|
104
81
|
const normalized = line.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().replace(/^[-*+]\s+/, "").split(/\s+/).join(" ");
|
|
@@ -136,37 +113,24 @@ function evidenceFor(entry: WorkflowTransportEntry, position: number, total: num
|
|
|
136
113
|
};
|
|
137
114
|
}
|
|
138
115
|
|
|
139
|
-
function
|
|
140
|
-
mode: WorkflowMode,
|
|
141
|
-
entries: readonly WorkflowTransportEntry[],
|
|
142
|
-
kind: TransportKind,
|
|
143
|
-
): WorkflowTransport {
|
|
116
|
+
export function formatWorkflowResult(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[]): WorkflowTransport {
|
|
144
117
|
const ordered = [...entries].sort(compareEntries);
|
|
145
|
-
if (
|
|
118
|
+
if (ordered.some(({ status }) => status === "pending" || status === "running")) {
|
|
146
119
|
throw new TypeError("Final workflow transport requires terminal entry states.");
|
|
147
120
|
}
|
|
148
121
|
const failed = ordered.some(({ status }) => status === "failed" || status === "rejected");
|
|
149
122
|
const positioned = ordered.map((entry, index) => ({ entry, position: index + 1 }));
|
|
150
|
-
const
|
|
151
|
-
const worktree = entry.worktreePayload;
|
|
152
|
-
return worktree === undefined || worktree.outcome === "pruned" ? [] : [{ entry, position, worktree }];
|
|
153
|
-
});
|
|
154
|
-
const evidence = kind === "update" ? [] : positioned.flatMap(({ entry, position }) => {
|
|
123
|
+
const evidence = positioned.flatMap(({ entry, position }) => {
|
|
155
124
|
const value = evidenceFor(entry, position, ordered.length);
|
|
156
125
|
return value ? [value] : [];
|
|
157
126
|
});
|
|
158
127
|
const lines = [
|
|
159
|
-
|
|
128
|
+
[`${workflowTitle(mode)} ${failed ? "failed" : "completed"}`, ...statusCounts(ordered)].join(" · "),
|
|
160
129
|
...positioned.map(({ entry, position }) => {
|
|
161
130
|
const { glyph, fallback } = presentWorkflowEntryStatus(entry.status);
|
|
162
131
|
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
163
132
|
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
164
133
|
}),
|
|
165
|
-
...(recoveries.length ? [
|
|
166
|
-
"Recovery:",
|
|
167
|
-
...recoveries.map(({ entry, position, worktree }) =>
|
|
168
|
-
`- [${position}/${ordered.length}] ${entry.name} · worktree ${JSON.stringify(worktree.path)} · branch ${JSON.stringify(worktree.branch)}`),
|
|
169
|
-
] : []),
|
|
170
134
|
...(evidence.length ? [
|
|
171
135
|
"Results:",
|
|
172
136
|
...evidence.flatMap(({ heading, preview }) => [heading, preview]),
|
|
@@ -176,8 +140,6 @@ function formatWorkflowTransport(
|
|
|
176
140
|
] : []),
|
|
177
141
|
] : []),
|
|
178
142
|
];
|
|
179
|
-
let usage: Usage | undefined;
|
|
180
|
-
for (const entry of ordered) if (entry.usage) usage = addUsage(usage, entry.usage);
|
|
181
143
|
return {
|
|
182
144
|
text: capEphemeralSubagentOutput(lines.join("\n")),
|
|
183
145
|
details: {
|
|
@@ -193,61 +155,9 @@ function formatWorkflowTransport(
|
|
|
193
155
|
...(source === undefined ? {} : { summary: displaySummary(source) }),
|
|
194
156
|
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
195
157
|
...(entry.thinkingLevel === undefined ? {} : { thinkingLevel: entry.thinkingLevel }),
|
|
196
|
-
...(entry.worktreePayload === undefined ? {} : { worktree: { ...entry.worktreePayload } }),
|
|
197
158
|
};
|
|
198
159
|
}),
|
|
199
160
|
},
|
|
200
|
-
...(usage === undefined ? {} : { usage }),
|
|
201
161
|
failed,
|
|
202
162
|
};
|
|
203
163
|
}
|
|
204
|
-
|
|
205
|
-
export function formatWorkflowResult(
|
|
206
|
-
mode: WorkflowMode,
|
|
207
|
-
entries: readonly WorkflowTransportEntry[],
|
|
208
|
-
): WorkflowTransport {
|
|
209
|
-
return formatWorkflowTransport(mode, entries, "result");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function formatWorkflowUpdate(
|
|
213
|
-
mode: WorkflowMode,
|
|
214
|
-
entries: readonly WorkflowTransportEntry[],
|
|
215
|
-
): WorkflowTransport {
|
|
216
|
-
return formatWorkflowTransport(mode, entries, "update");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function formatBackgroundWorkflowResult(
|
|
220
|
-
mode: WorkflowMode,
|
|
221
|
-
entries: readonly WorkflowTransportEntry[],
|
|
222
|
-
): WorkflowTransport {
|
|
223
|
-
return formatWorkflowTransport(mode, entries, "background");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export class WorkflowFailureError extends Error {
|
|
227
|
-
override name = "WorkflowFailureError";
|
|
228
|
-
readonly details: WorkflowTransportDetails;
|
|
229
|
-
readonly usage?: Usage;
|
|
230
|
-
readonly failed = true;
|
|
231
|
-
|
|
232
|
-
constructor(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[]) {
|
|
233
|
-
const transport = formatWorkflowResult(mode, entries);
|
|
234
|
-
if (!transport.failed) throw new TypeError("WorkflowFailureError requires a failed or rejected entry.");
|
|
235
|
-
super(transport.text);
|
|
236
|
-
this.details = transport.details;
|
|
237
|
-
this.usage = transport.usage;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export class WorkflowAbortedError extends Error {
|
|
242
|
-
override name = "AbortError";
|
|
243
|
-
readonly details: WorkflowTransportDetails;
|
|
244
|
-
readonly usage?: Usage;
|
|
245
|
-
readonly failed = true;
|
|
246
|
-
|
|
247
|
-
constructor(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[], cause: unknown) {
|
|
248
|
-
const transport = formatWorkflowTransport(mode, entries, "abort");
|
|
249
|
-
super(transport.text, { cause });
|
|
250
|
-
this.details = transport.details;
|
|
251
|
-
this.usage = transport.usage;
|
|
252
|
-
}
|
|
253
|
-
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-codin
|
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
6
|
EXECUTION_BUDGET_ENV,
|
|
7
|
-
|
|
7
|
+
PI_SUBAGENT_PROCESS_LEASE,
|
|
8
8
|
ROLE_TOOL_POLICY_FLAG,
|
|
9
9
|
type EphemeralSubagentExecutionBudget,
|
|
10
10
|
} from "@henryqw/pi-subagent";
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
|
|
13
13
|
const WARNING_RATIO = 0.8;
|
|
14
14
|
const WARNING_MESSAGE_TYPE = "pi-subagent-execution-budget";
|
|
15
|
-
const PROCESS_LEASE_ERROR = `${
|
|
15
|
+
const PROCESS_LEASE_ERROR = `${PI_SUBAGENT_PROCESS_LEASE} must be a nonempty NUL/newline-free absolute path naming a regular non-symlink file owned by the current uid with mode 0600.`;
|
|
16
16
|
const FINAL_HANDOFF_MESSAGE = {
|
|
17
17
|
customType: "pi-subagent-final-handoff",
|
|
18
18
|
content: "**Final handoff required.** Tools are disabled. If your assigned task or Role requires exact output, reply only with that output instead; it takes precedence over this decision packet. Otherwise, reply only with this decision packet:\n\n**Status:** completed | blocked | incomplete\n**Outcome:** one sentence describing what is now true\n**Evidence:** up to three concrete findings, changes, or checks; include an attempted approach only when it prevents Main from repeating failed work\n**Blocker:** none or the exact blocker\n**Risk:** none or one material risk\n**Suggested next:** none or one concrete action",
|
|
@@ -64,14 +64,14 @@ function executionBudget(value: string | undefined): EphemeralSubagentExecutionB
|
|
|
64
64
|
if (Object.keys(budget).some((key) => !["maxTurns", "maxMs", "startedAt", "maxTokens"].includes(key))
|
|
65
65
|
|| !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
|
|
66
66
|
|| !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
|
|
67
|
-
|| typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
|
|
67
|
+
|| (budget.maxMs !== null && (typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0))
|
|
68
68
|
|| !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0
|
|
69
69
|
|| !maxTokensValid) {
|
|
70
70
|
throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
71
71
|
}
|
|
72
72
|
return {
|
|
73
73
|
maxTurns: budget.maxTurns as number,
|
|
74
|
-
maxMs: budget.maxMs,
|
|
74
|
+
maxMs: budget.maxMs as number | null,
|
|
75
75
|
startedAt: budget.startedAt as number,
|
|
76
76
|
...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens as number }),
|
|
77
77
|
};
|
|
@@ -94,11 +94,11 @@ function messageTokens(message: unknown): number | undefined {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function joinBudgetParts(parts: string[]): string {
|
|
97
|
-
return `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
|
|
97
|
+
return parts.length === 1 ? parts[0]! : `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export default function roleTools(pi: ExtensionAPI): void {
|
|
101
|
-
const processLease = process.env[
|
|
101
|
+
const processLease = process.env[PI_SUBAGENT_PROCESS_LEASE];
|
|
102
102
|
if (processLease !== undefined) validateProcessLease(processLease);
|
|
103
103
|
pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
|
|
104
104
|
description: "Internal Pi Subagent Role tool policy",
|
|
@@ -133,7 +133,7 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
133
133
|
while : 2>/dev/null <&"$__pi_subagent_process_lease_fd" || : 2>/dev/null >&"$__pi_subagent_process_lease_fd"; do
|
|
134
134
|
__pi_subagent_process_lease_fd=$((__pi_subagent_process_lease_fd + 1))
|
|
135
135
|
done
|
|
136
|
-
eval 'exec '"$__pi_subagent_process_lease_fd"'>>"$${
|
|
136
|
+
eval 'exec '"$__pi_subagent_process_lease_fd"'>>"$${PI_SUBAGENT_PROCESS_LEASE}"' || exit $?
|
|
137
137
|
unset __pi_subagent_process_lease_fd
|
|
138
138
|
${event.input.command}`;
|
|
139
139
|
});
|
|
@@ -166,18 +166,18 @@ ${event.input.command}`;
|
|
|
166
166
|
const elapsedMs = Math.max(0, Date.now() - budget.startedAt);
|
|
167
167
|
const turnWarningDue = !turnWarningSent && completedTurns >= warningTurn;
|
|
168
168
|
const tokenWarningDue = warningTokens !== undefined && !tokenWarningSent && completedTokens >= warningTokens;
|
|
169
|
-
const runtimeWarningDue = !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
|
|
169
|
+
const runtimeWarningDue = budget.maxMs !== null && !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
|
|
170
170
|
if (!turnWarningDue && !tokenWarningDue && !runtimeWarningDue) return;
|
|
171
171
|
if (turnWarningDue) turnWarningSent = true;
|
|
172
172
|
if (tokenWarningDue) tokenWarningSent = true;
|
|
173
173
|
if (runtimeWarningDue) runtimeWarningSent = true;
|
|
174
174
|
const remainingTurns = Math.max(0, budget.maxTurns - completedTurns);
|
|
175
|
-
const
|
|
176
|
-
|
|
175
|
+
const runtimeRemaining = budget.maxMs === null ? []
|
|
176
|
+
: [`approximately ${Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000))} of ${budget.maxMs / 60_000} minutes`];
|
|
177
177
|
const parts = [
|
|
178
178
|
`${remainingTurns} of ${budget.maxTurns} turns`,
|
|
179
179
|
...(budget.maxTokens === undefined ? [] : [`${Math.max(0, budget.maxTokens - completedTokens)} of ${budget.maxTokens} tokens`]),
|
|
180
|
-
|
|
180
|
+
...runtimeRemaining,
|
|
181
181
|
];
|
|
182
182
|
pi.sendMessage({
|
|
183
183
|
customType: WARNING_MESSAGE_TYPE,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Read-only branch records; the adapter groups all tabs of a direct workflow. */
|
|
4
|
+
export interface DirectTask {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
status: string;
|
|
8
|
+
tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string }[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IsolatedTask {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
status: string;
|
|
15
|
+
kind: string;
|
|
16
|
+
}
|
|
17
|
+
export interface IsolatedRequest {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
status: string;
|
|
21
|
+
tasks: readonly IsolatedTask[];
|
|
22
|
+
}
|
|
23
|
+
export interface IsolatedInventory {
|
|
24
|
+
root: string;
|
|
25
|
+
requests: readonly IsolatedRequest[];
|
|
26
|
+
invalidIds: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mutation adapters MUST check current() synchronously alongside their live ownership
|
|
30
|
+
* validation and queue operation. They must not await between those operations. */
|
|
31
|
+
export interface SubagentCommandAdapter {
|
|
32
|
+
direct(ctx: ExtensionContext): readonly DirectTask[];
|
|
33
|
+
isolated(cwd: string): Promise<IsolatedInventory>;
|
|
34
|
+
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
35
|
+
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
36
|
+
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
37
|
+
drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
|
|
38
|
+
/** Session start/shutdown epoch; prevents same-session reload after an awaited dialog. */
|
|
39
|
+
epoch(): number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
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("");
|
|
43
|
+
const errorText = (error: unknown): string => clean(error instanceof Error ? error.message : String(error), 500);
|
|
44
|
+
const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
|
|
45
|
+
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
46
|
+
|
|
47
|
+
type Pick<T> = { label: string; value: T };
|
|
48
|
+
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Pick<T>[]): Promise<T | undefined> {
|
|
49
|
+
// Numeric suffixes are UI-only. Never parse display text into an identity.
|
|
50
|
+
const labels = items.map((item, index) => `${clean(item.label, 145)} [${index + 1}]`);
|
|
51
|
+
const selected = await ctx.ui.select(title, labels);
|
|
52
|
+
const index = labels.indexOf(selected ?? "");
|
|
53
|
+
return index < 0 ? undefined : items[index]!.value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
|
|
57
|
+
pi.registerCommand("subagent", {
|
|
58
|
+
description: "Inspect direct and isolated work; send or edit pending isolated instructions",
|
|
59
|
+
handler: async (args, ctx) => {
|
|
60
|
+
if (args.trim()) throw new Error("Usage: /subagent (no arguments)");
|
|
61
|
+
if (!ctx.hasUI) throw new Error("/subagent requires interactive UI (TUI or RPC); use the subagent agent tools in non-interactive mode.");
|
|
62
|
+
const manager = ctx.sessionManager;
|
|
63
|
+
const session = manager.getSessionId();
|
|
64
|
+
const file = manager.getSessionFile();
|
|
65
|
+
const branch = manager.getBranch().map((entry) => entry.id);
|
|
66
|
+
// A branch may grow during a dialog, but navigating to a descendant that
|
|
67
|
+
// already existed when it opened must not retarget its pending actions.
|
|
68
|
+
const existing = new Set(manager.getEntries().map((entry) => entry.id));
|
|
69
|
+
const epoch = adapter.epoch();
|
|
70
|
+
const current = () => {
|
|
71
|
+
if (adapter.epoch() !== epoch || manager.getSessionId() !== session || manager.getSessionFile() !== file) return false;
|
|
72
|
+
const now = manager.getBranch();
|
|
73
|
+
return now.length >= branch.length && branch.every((id, index) => now[index]?.id === id)
|
|
74
|
+
&& now.slice(branch.length).every((entry) => !existing.has(entry.id));
|
|
75
|
+
};
|
|
76
|
+
const valid = () => {
|
|
77
|
+
if (current()) return true;
|
|
78
|
+
ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
let history = false;
|
|
82
|
+
for (;;) {
|
|
83
|
+
if (!valid()) return;
|
|
84
|
+
// Direct recovery is independent of Git and isolated config/inventory failures.
|
|
85
|
+
const direct = adapter.direct(ctx);
|
|
86
|
+
let inventory: IsolatedInventory | undefined;
|
|
87
|
+
let unavailable: string | undefined;
|
|
88
|
+
try { inventory = await adapter.isolated(ctx.cwd); }
|
|
89
|
+
catch (error) { unavailable = errorText(error); }
|
|
90
|
+
if (!valid()) return;
|
|
91
|
+
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
92
|
+
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");
|
|
93
|
+
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
|
|
94
|
+
const selections: Pick<Selection>[] = [
|
|
95
|
+
...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${task.name} · ${task.status} · ${task.id}`, value: { kind: "direct" as const, task } })),
|
|
96
|
+
...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
|
|
97
|
+
{ label: "Refresh", value: { kind: "refresh" as const } },
|
|
98
|
+
{ label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
|
|
99
|
+
{ label: "Close", value: { kind: "close" as const } },
|
|
100
|
+
];
|
|
101
|
+
const taskChoices = selections.splice(0, selections.length - 3);
|
|
102
|
+
const rank = ({ value }: Pick<Selection>) => value.kind === "direct" ? priority(value.task.status)
|
|
103
|
+
: value.kind === "isolated" ? priority(value.request.status) : 3;
|
|
104
|
+
taskChoices.sort((a, b) => rank(a) - rank(b));
|
|
105
|
+
selections.unshift(...taskChoices);
|
|
106
|
+
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");
|
|
107
|
+
const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
|
|
108
|
+
if (!valid() || !selected || selected.kind === "close") return;
|
|
109
|
+
if (selected.kind === "refresh") continue;
|
|
110
|
+
if (selected.kind === "history") { history = !history; continue; }
|
|
111
|
+
if (selected.kind === "direct") {
|
|
112
|
+
ctx.ui.notify(`Direct workflow ${clean(selected.task.name)} (${JSON.stringify(selected.task.id)}) · ${clean(selected.task.status)}`, "info");
|
|
113
|
+
for (const tab of selected.task.tabs) {
|
|
114
|
+
ctx.ui.notify(`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)}`, "info");
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const { root, request } = selected;
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (!valid()) return;
|
|
121
|
+
type Action = "inspect" | "send" | "edit" | "back";
|
|
122
|
+
const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
123
|
+
const actions: Pick<Action>[] = [
|
|
124
|
+
{ label: "Inspect status and recovery", value: "inspect" },
|
|
125
|
+
...(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 }] : []),
|
|
126
|
+
{ label: "Back / refresh", value: "back" },
|
|
127
|
+
];
|
|
128
|
+
const action = await choose(ctx, `Isolated · ${clean(request.name)} · ${request.id}`, actions);
|
|
129
|
+
if (!valid() || !action) return;
|
|
130
|
+
if (action === "back") break;
|
|
131
|
+
if (action === "inspect") {
|
|
132
|
+
try {
|
|
133
|
+
const notices = await adapter.inspect(root, request.id);
|
|
134
|
+
if (valid()) for (const notice of notices) ctx.ui.notify(notice, "info");
|
|
135
|
+
}
|
|
136
|
+
catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const live = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
140
|
+
if (!live.length) { ctx.ui.notify("No locally active unsealed changeset remains. Refresh to see current status.", "warning"); continue; }
|
|
141
|
+
const task = await choose(ctx, `Select task for ${action === "edit" ? "withdrawal and edit" : "follow-up"}`, live.map((item) => ({ label: `${item.name} · ${item.status} · ${item.id}`, value: item })));
|
|
142
|
+
if (!valid() || !task) return;
|
|
143
|
+
let prefill = "";
|
|
144
|
+
if (action === "edit") {
|
|
145
|
+
try {
|
|
146
|
+
const withdrawn = adapter.drain(root, request.id, task.id, current);
|
|
147
|
+
if (!withdrawn.length) { ctx.ui.notify("No pending instructions remain for this task; already-claimed instructions cannot be recalled.", "info"); continue; }
|
|
148
|
+
prefill = withdrawn.join("\n\n");
|
|
149
|
+
} catch (error) { if (valid()) ctx.ui.notify(`Cannot withdraw: ${errorText(error)}`, "error"); continue; }
|
|
150
|
+
}
|
|
151
|
+
for (;;) {
|
|
152
|
+
const text = await ctx.ui.editor(action === "edit" ? "Edit withdrawn instructions · submit ONE replacement; Cancel leaves originals withdrawn" : "Send follow-up instructions", prefill);
|
|
153
|
+
if (!valid() || text === undefined) return;
|
|
154
|
+
try {
|
|
155
|
+
ctx.ui.notify(adapter.enqueue(root, request.id, task.id, text, current), "info");
|
|
156
|
+
break;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!valid()) return;
|
|
159
|
+
ctx.ui.notify(`Not queued: ${errorText(error)}. Submitted text will reopen in this editor for editing or copying; Cancel does not queue it.`, "error");
|
|
160
|
+
prefill = text;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|