@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,945 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { capEphemeralSubagentOutput, utf8Prefix, type EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
6
|
+
import {
|
|
7
|
+
createCanonicalGitRootResolver,
|
|
8
|
+
createExactJudgmentExecutor,
|
|
9
|
+
} from "../dist/composition.js";
|
|
10
|
+
import {
|
|
11
|
+
CheckedGitRuntime,
|
|
12
|
+
type DirectProcessRunner,
|
|
13
|
+
} from "../dist/git-runtime.js";
|
|
14
|
+
import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
|
|
15
|
+
import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type PruneGuards, type PruneItem } from "../dist/prune.js";
|
|
16
|
+
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
17
|
+
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
18
|
+
import { IsolatedRunner, nextIntegrationAction, type ExecutePlan, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
19
|
+
import {
|
|
20
|
+
IdOnlySchema,
|
|
21
|
+
ResumeRequestParameters,
|
|
22
|
+
StageRequestSchema,
|
|
23
|
+
IntegrationActionParameters,
|
|
24
|
+
parseIntegrationAction,
|
|
25
|
+
parseStageRequest,
|
|
26
|
+
parseIdOnly,
|
|
27
|
+
formatCheckCommand,
|
|
28
|
+
parseResumeRequest,
|
|
29
|
+
sameIdentity,
|
|
30
|
+
StatusOutputSchema,
|
|
31
|
+
type CheckBatchEvidence,
|
|
32
|
+
type ExecuteRequest,
|
|
33
|
+
type ExecutionPolicySnapshot,
|
|
34
|
+
type ModelClass,
|
|
35
|
+
type ReviewEvidence,
|
|
36
|
+
type RunState,
|
|
37
|
+
type WorktreeAllocationIntent,
|
|
38
|
+
type WorkspaceAllocationIntent,
|
|
39
|
+
} from "../dist/schema.js";
|
|
40
|
+
import { FileRunStore } from "../dist/store.js";
|
|
41
|
+
import { workerOutputForMain } from "./result-transport.ts";
|
|
42
|
+
import type { CleanupReport, IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
|
|
43
|
+
import type { DiscardPlan } from "../dist/discard.js";
|
|
44
|
+
|
|
45
|
+
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
46
|
+
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
47
|
+
/** Tool results return failed review verdicts in full up to this bound; follow-ups keep the short preview. */
|
|
48
|
+
const PUBLIC_REVIEW_VERDICT_MAX_BYTES = 32 * 1024;
|
|
49
|
+
const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
|
|
50
|
+
const WIDGET_FIELD_MAX_CHARS = 32;
|
|
51
|
+
const MAX_WORKSPACE_WIDGET_LINES = 6;
|
|
52
|
+
|
|
53
|
+
export interface IsolatedExtensionComponents {
|
|
54
|
+
runner: IsolatedRunner;
|
|
55
|
+
resolveRoot: LaunchRuntimeOptions["resolveRoot"];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CreateIsolatedComponents = (options: {
|
|
59
|
+
pi: ExtensionAPI;
|
|
60
|
+
context(): ExtensionContext;
|
|
61
|
+
executor: EphemeralSubagentExecutor;
|
|
62
|
+
policy: ExecutionPolicySnapshot;
|
|
63
|
+
currentPolicy(): ExecutionPolicySnapshot;
|
|
64
|
+
onStateSaved(state: RunState): void;
|
|
65
|
+
}) => IsolatedExtensionComponents;
|
|
66
|
+
|
|
67
|
+
/** Construct the checked runtime while reusing the unified productive-assignment executor. */
|
|
68
|
+
export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
69
|
+
pi, context, executor, policy, currentPolicy, onStateSaved,
|
|
70
|
+
}) => {
|
|
71
|
+
const runProcess: DirectProcessRunner = async (command, args, options) => options.stdin === undefined
|
|
72
|
+
? await pi.exec(command, args, { cwd: options.cwd, signal: options.signal, timeout: options.timeoutMs })
|
|
73
|
+
: await directRunProcess(command, args, options);
|
|
74
|
+
const resolveRoot = createCanonicalGitRootResolver({ runProcess });
|
|
75
|
+
const git = new CheckedGitRuntime({ runProcess, executeReview: createExactJudgmentExecutor(executor) });
|
|
76
|
+
const executionBudget = () => ({
|
|
77
|
+
maxTurns: policy.maxTurns,
|
|
78
|
+
maxMs: null,
|
|
79
|
+
...(policy.maxTokens === undefined ? {} : { maxTokens: policy.maxTokens }),
|
|
80
|
+
});
|
|
81
|
+
const host = new HerdrHostRuntime({ inspectInFlightTaskCandidate: git.inspectInFlightTaskCandidate.bind(git), runProcess, executionBudget });
|
|
82
|
+
const coordinator = new RoleLaunchRuntime({
|
|
83
|
+
pi,
|
|
84
|
+
context,
|
|
85
|
+
resolveRoot,
|
|
86
|
+
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
87
|
+
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
resolveRoot,
|
|
91
|
+
runner: new IsolatedRunner(
|
|
92
|
+
coordinator,
|
|
93
|
+
host,
|
|
94
|
+
git,
|
|
95
|
+
new FileRunStore(undefined, onStateSaved),
|
|
96
|
+
executor,
|
|
97
|
+
policy,
|
|
98
|
+
currentPolicy,
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function compactWidgetField(value: string): string {
|
|
104
|
+
const characters = [...value];
|
|
105
|
+
return characters.length <= WIDGET_FIELD_MAX_CHARS
|
|
106
|
+
? value
|
|
107
|
+
: `${characters.slice(0, WIDGET_FIELD_MAX_CHARS - 1).join("")}~`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function workspaceBadge(role: string, modelClass: ModelClass): string {
|
|
111
|
+
const modelCode = { fast: "1", balanced: "2", frontier: "3", fav: "*" }[modelClass];
|
|
112
|
+
return `[${Array.from(role)[0]!.toUpperCase()}${modelCode}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function workspaceStatus(status: RunState["tasks"][number]["status"]): string {
|
|
116
|
+
switch (status) {
|
|
117
|
+
case "ready_to_integrate": return "ready";
|
|
118
|
+
case "needs_attention": return "attention";
|
|
119
|
+
default: return status;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function workspaceWidgetLines(state: RunState): string[] | undefined {
|
|
124
|
+
const rows = state.tasks.flatMap((taskState) => {
|
|
125
|
+
const task = state.request.tasks.find((candidate) => candidate.id === taskState.taskId);
|
|
126
|
+
if (!task) return [];
|
|
127
|
+
const status = workspaceStatus(taskState.status);
|
|
128
|
+
if (taskState.kind === "text") {
|
|
129
|
+
return taskState.status === "running" || taskState.status === "needs_attention"
|
|
130
|
+
? [`${status === "attention" ? "!" : "◌"} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? status)}` : status}`]
|
|
131
|
+
: [];
|
|
132
|
+
}
|
|
133
|
+
const attempt = taskState.attempts.at(-1);
|
|
134
|
+
const allocation = [...(attempt?.allocations ?? [])].reverse().find(
|
|
135
|
+
(candidate): candidate is WorkspaceAllocationIntent =>
|
|
136
|
+
candidate.kind === "workspace" && candidate.status !== "absent",
|
|
137
|
+
);
|
|
138
|
+
const workspaceCleanup = attempt?.cleanup.find((step) => step.kind === "workspace");
|
|
139
|
+
if (workspaceCleanup?.status === "completed" || (!allocation && !["allocating", "working", "attention"].includes(status))) return [];
|
|
140
|
+
const symbol = status === "attention" ? "!" : status === "ready" || status === "completed" ? "✓"
|
|
141
|
+
: ["working", "allocating", "integrating", "cleanup"].includes(status) ? "◌" : "■";
|
|
142
|
+
const detail = status === "ready" ? "candidate ready · Main not promoted"
|
|
143
|
+
: status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? "needs attention")}` : status;
|
|
144
|
+
return `${symbol} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${detail}${allocation ? ` · ${compactWidgetField(allocation.label)}` : ""}`;
|
|
145
|
+
});
|
|
146
|
+
if (!rows.length) return undefined;
|
|
147
|
+
return state.status === "aborted"
|
|
148
|
+
? [`■ I ${compactWidgetField(state.request.id)} · request aborted`, ...rows]
|
|
149
|
+
: rows;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderWorkspaceWidget(ctx: ExtensionContext, rowsByRequest: Map<string, string[]>): void {
|
|
153
|
+
if (!ctx.hasUI) return;
|
|
154
|
+
const rowOrder = (row: string) => row.startsWith("!") ? 0 : row.startsWith("◌") ? 1
|
|
155
|
+
: row.endsWith(" · request aborted") ? 2 : 3;
|
|
156
|
+
const allRows = [...rowsByRequest.values()].flat().sort((a, b) => rowOrder(a) - rowOrder(b));
|
|
157
|
+
const shown = allRows.length > MAX_WORKSPACE_WIDGET_LINES
|
|
158
|
+
? [...allRows.slice(0, MAX_WORKSPACE_WIDGET_LINES - 1), `+${allRows.length - MAX_WORKSPACE_WIDGET_LINES + 1} more · /subagent`]
|
|
159
|
+
: allRows;
|
|
160
|
+
ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, shown.length ? (_tui, theme) => ({
|
|
161
|
+
invalidate() {},
|
|
162
|
+
render(width) {
|
|
163
|
+
return shown.map((line) => {
|
|
164
|
+
const color = line.startsWith("!") ? "error" : line.startsWith("◌") ? "accent"
|
|
165
|
+
: line.startsWith("✓") ? "success" : line.startsWith("■") ? "warning" : undefined;
|
|
166
|
+
return truncateToWidth(color ? theme.fg(color, line[0]!) + line.slice(1) : line, width);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
}) : undefined);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
|
|
173
|
+
try {
|
|
174
|
+
const key = `${state.root}\0${state.request.id}`;
|
|
175
|
+
const rows = workspaceWidgetLines(state);
|
|
176
|
+
if (rows) rowsByRequest.set(key, rows);
|
|
177
|
+
else rowsByRequest.delete(key);
|
|
178
|
+
renderWorkspaceWidget(ctx, rowsByRequest);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error("Pi Subagent workspace widget update failed.", error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Join report lines within a follow-up budget, naming how many were omitted. */
|
|
185
|
+
function boundedReport(lines: readonly string[], omitted: string): string {
|
|
186
|
+
const report: string[] = [];
|
|
187
|
+
let bytes = 0;
|
|
188
|
+
let skipped = 0;
|
|
189
|
+
for (const line of lines.flatMap((entry) => entry.split("\n"))) {
|
|
190
|
+
const size = Buffer.byteLength(`${line}\n`, "utf8");
|
|
191
|
+
if (bytes + size > 7_900) { skipped++; continue; }
|
|
192
|
+
report.push(line);
|
|
193
|
+
bytes += size;
|
|
194
|
+
}
|
|
195
|
+
if (skipped) report.push(`[${skipped} ${omitted}]`);
|
|
196
|
+
return report.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function boundedPublicText(value: string, maxBytes = PUBLIC_EVIDENCE_MAX_BYTES): string {
|
|
200
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
201
|
+
return `${utf8Prefix(value, maxBytes)}\n[truncated]`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function publicFailedCheck(evidence: CheckBatchEvidence | undefined) {
|
|
205
|
+
if (!evidence || evidence.passed) return undefined;
|
|
206
|
+
const result = evidence.results.find((candidate) => candidate.code !== 0 || candidate.killed)
|
|
207
|
+
?? evidence.results.at(-1);
|
|
208
|
+
return {
|
|
209
|
+
phase: evidence.phase,
|
|
210
|
+
identityChanged: !sameIdentity(evidence.candidate, evidence.identityAfter),
|
|
211
|
+
...(result ? {
|
|
212
|
+
command: boundedPublicText(formatCheckCommand(result)),
|
|
213
|
+
code: result.code,
|
|
214
|
+
killed: result.killed,
|
|
215
|
+
...(result.stdout ? { stdout: boundedPublicText(result.stdout) } : {}),
|
|
216
|
+
...(result.stderr ? { stderr: boundedPublicText(result.stderr) } : {}),
|
|
217
|
+
} : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function publicFailedReview(evidence: ReviewEvidence | undefined, verdictMaxBytes: number) {
|
|
222
|
+
if (!evidence || evidence.passed) return undefined;
|
|
223
|
+
const verdict = boundedPublicText(evidence.verdict, verdictMaxBytes);
|
|
224
|
+
return {
|
|
225
|
+
phase: evidence.phase,
|
|
226
|
+
verdict,
|
|
227
|
+
verdictTruncated: verdict !== evidence.verdict,
|
|
228
|
+
identityChanged: !sameIdentity(evidence.tip, evidence.identityAfter),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function publicTaskRecovery(task: RunState["tasks"][number], verdictMaxBytes: number) {
|
|
233
|
+
if (task.kind === "text") {
|
|
234
|
+
const attempt = task.attempts.at(-1);
|
|
235
|
+
return {
|
|
236
|
+
scope: "task" as const,
|
|
237
|
+
taskId: task.taskId,
|
|
238
|
+
...(task.failure ? { failure: boundedPublicText(task.failure) } : {}),
|
|
239
|
+
...(attempt ? { attempt: {
|
|
240
|
+
number: attempt.number,
|
|
241
|
+
status: attempt.status,
|
|
242
|
+
...(attempt.failure ? { failure: boundedPublicText(attempt.failure) } : {}),
|
|
243
|
+
} } : {}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const attempt = task.attempts.at(-1);
|
|
248
|
+
const failedCheck = publicFailedCheck(attempt?.preliminaryChecks);
|
|
249
|
+
const failedReview = publicFailedReview(attempt?.preliminaryReview, verdictMaxBytes);
|
|
250
|
+
const cleanup = attempt?.cleanup
|
|
251
|
+
.filter((step) => step.status !== "completed")
|
|
252
|
+
.map((step) => ({
|
|
253
|
+
kind: step.kind,
|
|
254
|
+
status: step.status,
|
|
255
|
+
...(step.failure ? { failure: boundedPublicText(step.failure) } : {}),
|
|
256
|
+
}));
|
|
257
|
+
const worktreeCleanupPending = attempt?.cleanup.find((step) => step.kind === "worktree")?.status !== "completed";
|
|
258
|
+
const worktree = worktreeCleanupPending
|
|
259
|
+
? [...(attempt?.allocations ?? [])].reverse().find(
|
|
260
|
+
(intent): intent is WorktreeAllocationIntent => intent.kind === "worktree" && intent.status === "owned" && Boolean(intent.worktree),
|
|
261
|
+
)?.worktree
|
|
262
|
+
: undefined;
|
|
263
|
+
return {
|
|
264
|
+
scope: "task" as const,
|
|
265
|
+
taskId: task.taskId,
|
|
266
|
+
...(task.failure ? { failure: boundedPublicText(task.failure) } : {}),
|
|
267
|
+
...(worktree ? { retainedWorktree: {
|
|
268
|
+
path: boundedPublicText(worktree.path),
|
|
269
|
+
cwd: boundedPublicText(worktree.cwd),
|
|
270
|
+
branch: boundedPublicText(worktree.branch),
|
|
271
|
+
} } : {}),
|
|
272
|
+
...(failedCheck ? { failedCheck } : {}),
|
|
273
|
+
...(failedReview ? { failedReview } : {}),
|
|
274
|
+
...(cleanup?.length ? { cleanup } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function publicNeedsAttention(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
279
|
+
if (state.status !== "needs_attention") return undefined;
|
|
280
|
+
const task = state.tasks.find((candidate) => candidate.status === "needs_attention" && candidate.taskId === preferredTaskId)
|
|
281
|
+
?? state.tasks.find((candidate) => candidate.status === "needs_attention");
|
|
282
|
+
if (task) return publicTaskRecovery(task, verdictMaxBytes);
|
|
283
|
+
if (state.final.failure || state.final.checks?.passed === false || state.final.review?.passed === false) {
|
|
284
|
+
const failedCheck = publicFailedCheck(state.final.checks);
|
|
285
|
+
const failedReview = publicFailedReview(state.final.review, verdictMaxBytes);
|
|
286
|
+
return {
|
|
287
|
+
scope: "final" as const,
|
|
288
|
+
...(state.final.failure ? { failure: boundedPublicText(state.final.failure) } : {}),
|
|
289
|
+
...(failedCheck ? { failedCheck } : {}),
|
|
290
|
+
...(failedReview ? { failedReview } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const nextAction = nextIntegrationAction(state);
|
|
294
|
+
return {
|
|
295
|
+
scope: "request" as const,
|
|
296
|
+
...(nextAction ? { nextAction } : { failure: "Request needs attention without a task or final failure." }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function publicState(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
301
|
+
const needsAttention = publicNeedsAttention(state, verdictMaxBytes, preferredTaskId);
|
|
302
|
+
return {
|
|
303
|
+
version: state.version,
|
|
304
|
+
id: state.request.id,
|
|
305
|
+
status: state.status,
|
|
306
|
+
accepted: state.accepted,
|
|
307
|
+
main: state.main,
|
|
308
|
+
tasks: state.tasks.map((task) => {
|
|
309
|
+
const textAttempt = task.kind === "text" ? task.attempts.at(-1) : undefined;
|
|
310
|
+
return {
|
|
311
|
+
taskId: task.taskId, status: task.status,
|
|
312
|
+
...(task.status === "completed" && textAttempt?.output ? { output: workerOutputForMain(boundedPublicText(textAttempt.output.text)) } : {}),
|
|
313
|
+
...(textAttempt?.cleanup ? { cleanup: {
|
|
314
|
+
...textAttempt.cleanup,
|
|
315
|
+
...(textAttempt.cleanup.outcome === "recovery" ? { note: boundedPublicText(textAttempt.cleanup.note) } : {}),
|
|
316
|
+
} } : {}),
|
|
317
|
+
...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
|
|
318
|
+
number: attempt.number, superseded: attempt.superseded === true,
|
|
319
|
+
workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
|
|
320
|
+
termination: attempt.termination?.status,
|
|
321
|
+
worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
|
|
322
|
+
cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
|
|
323
|
+
})) } : {}),
|
|
324
|
+
};
|
|
325
|
+
}),
|
|
326
|
+
final: { status: state.final.status },
|
|
327
|
+
integration: {
|
|
328
|
+
...(state.integration.refresh ? { refresh: state.integration.refresh } : {}),
|
|
329
|
+
candidates: state.integration.candidates.map(({ taskId, attempt, tip, base, checks, decision, worker }) => ({ taskId, attempt, tip, base, checked: checks.passed, worker, ...(decision ? { decision } : {}) })),
|
|
330
|
+
generations: state.integration.generations.map(({ number, status, worktree, integrationBase, combinedTip, correction, checks, review, promotion, supersededPromotion, failure, stages, cleanup, supersededFrom }) => ({
|
|
331
|
+
number, status, integrationBase, ...(supersededFrom ? { supersededFrom } : {}),
|
|
332
|
+
...(supersededPromotion ? { supersededPromotion: { status: supersededPromotion.status,
|
|
333
|
+
failure: boundedPublicText(supersededPromotion.failure ?? "") } } : {}),
|
|
334
|
+
...(cleanup ? { cleanup } : {}),
|
|
335
|
+
...(combinedTip ? { combinedTip } : {}),
|
|
336
|
+
...(correction ? { correction } : {}),
|
|
337
|
+
...(checks ? { checked: checks.passed, failedCheck: publicFailedCheck(checks) } : {}),
|
|
338
|
+
...(checks?.results.some((result) => result.reusedFrom) ? { reusedChecks: checks.results.flatMap((result) =>
|
|
339
|
+
result.reusedFrom ? [{ command: boundedPublicText(formatCheckCommand(result)), reusedFrom: result.reusedFrom }] : []) } : {}),
|
|
340
|
+
...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review, verdictMaxBytes) } : {}),
|
|
341
|
+
...(promotion ? { promotion: promotion.status, ...(promotion.mainAfter ? { mainAfter: promotion.mainAfter } : {}),
|
|
342
|
+
...(promotion.failure ? { promotionFailure: boundedPublicText(promotion.failure) } : {}) } : {}),
|
|
343
|
+
...(failure ? { failure: boundedPublicText(failure) } : {}),
|
|
344
|
+
...(worktree ? { retainedWorktree: { path: worktree.path, branch: worktree.branch } } : {}),
|
|
345
|
+
stages: stages.map(({ taskId, attempt, source, onto, status, tip, failure }) => ({
|
|
346
|
+
taskId, attempt, source, onto, status, ...(tip ? { tip } : {}), ...(failure ? { failure: boundedPublicText(failure) } : {}),
|
|
347
|
+
})),
|
|
348
|
+
})),
|
|
349
|
+
},
|
|
350
|
+
...(needsAttention ? { needsAttention } : {}),
|
|
351
|
+
createdAt: state.createdAt,
|
|
352
|
+
updatedAt: state.updatedAt,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Model-visible failed review verdicts from every task, the final gate, and each generation, within one shared budget.
|
|
357
|
+
* Pi shows the model `content`, not `structuredContent`; task reviews appear even while sibling tasks still run. */
|
|
358
|
+
function failedReviewText(state: RunState): string {
|
|
359
|
+
const reviews = [
|
|
360
|
+
...state.tasks.flatMap((task) => {
|
|
361
|
+
const review = task.kind === "changeset" ? task.attempts.at(-1)?.preliminaryReview : undefined;
|
|
362
|
+
return review && !review.passed ? [{ label: `Task ${task.taskId}`, review }] : [];
|
|
363
|
+
}),
|
|
364
|
+
...(state.final.review && !state.final.review.passed ? [{ label: "Final", review: state.final.review }] : []),
|
|
365
|
+
...state.integration.generations.flatMap(({ number, review }) => review && !review.passed ? [{ label: `Integration generation ${number}`, review }] : []),
|
|
366
|
+
].reduce<{ label: string; review: ReviewEvidence }[]>((merged, entry) => {
|
|
367
|
+
// Show an identical verdict once, but keep every scope that failed with it.
|
|
368
|
+
const same = merged.find(({ review }) => review.verdict === entry.review.verdict);
|
|
369
|
+
if (same) same.label = `${same.label}, ${entry.label}`;
|
|
370
|
+
else merged.push({ ...entry });
|
|
371
|
+
return merged;
|
|
372
|
+
}, []);
|
|
373
|
+
let budget = PUBLIC_REVIEW_VERDICT_MAX_BYTES;
|
|
374
|
+
let omitted = 0;
|
|
375
|
+
const parts: string[] = [];
|
|
376
|
+
for (const { label, review } of reviews) {
|
|
377
|
+
if (budget <= 0) { omitted++; continue; }
|
|
378
|
+
const verdict = boundedPublicText(review.verdict, budget);
|
|
379
|
+
budget -= Buffer.byteLength(verdict, "utf8");
|
|
380
|
+
parts.push(`\n\n${label}: failed ${review.phase} review verdict${verdict !== review.verdict ? " (truncated)" : ""}:\n${workerOutputForMain(verdict)}`);
|
|
381
|
+
}
|
|
382
|
+
if (omitted) parts.push(`\n\n${omitted} more failed review verdict(s) omitted from this text; structuredContent lists each one.`);
|
|
383
|
+
return parts.join("");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function hasTruncatedVerdict(state: ReturnType<typeof publicState>): boolean {
|
|
387
|
+
const attention = state.needsAttention;
|
|
388
|
+
return Boolean(attention && "failedReview" in attention && attention.failedReview?.verdictTruncated)
|
|
389
|
+
|| state.integration.generations.some((generation) => generation.failedReview?.verdictTruncated);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function inspectionNotices({ state, main, continuation }: RunResponse): string[] {
|
|
393
|
+
const lines = [
|
|
394
|
+
`Isolated request ${state.request.id}: ${state.status}; accepted: ${state.accepted}; final: ${state.final.status}; Main: ${main?.status ?? "not checked"}.`,
|
|
395
|
+
...(continuation ? [`Reported continuation: ${JSON.stringify(continuation)}`] : []),
|
|
396
|
+
];
|
|
397
|
+
if (main) lines.push(main.status === "unavailable"
|
|
398
|
+
? `Main inspection unavailable: ${boundedPublicText(main.failure)}; expected: ${JSON.stringify(main.expected)}`
|
|
399
|
+
: `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
|
|
400
|
+
if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
|
|
401
|
+
const attention = publicNeedsAttention(state, PUBLIC_EVIDENCE_MAX_BYTES);
|
|
402
|
+
if (attention && "nextAction" in attention) lines.push(`Next integration action: ${attention.nextAction}`);
|
|
403
|
+
else if (attention) lines.push(`Reported blockage: ${JSON.stringify({ scope: attention.scope, failure: attention.failure, failedCheck: "failedCheck" in attention ? attention.failedCheck : undefined, failedReview: "failedReview" in attention ? attention.failedReview : undefined })}`);
|
|
404
|
+
if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
|
|
405
|
+
for (const task of state.tasks) {
|
|
406
|
+
lines.push(`Task ${task.taskId}: ${task.status}${task.failure ? `; blockage: ${boundedPublicText(task.failure)}` : ""}.`);
|
|
407
|
+
if (task.kind !== "changeset") continue;
|
|
408
|
+
for (const attempt of task.attempts) {
|
|
409
|
+
const label = `Task ${task.taskId} attempt ${attempt.number}`;
|
|
410
|
+
if (attempt.termination) lines.push(`${label} worker termination: ${JSON.stringify({ status: attempt.termination.status, workerId: attempt.termination.workerId, failure: attempt.termination.failure ? boundedPublicText(attempt.termination.failure) : undefined })}`);
|
|
411
|
+
for (const allocation of attempt.allocations) {
|
|
412
|
+
if (allocation.status === "absent") continue;
|
|
413
|
+
const cleanupKind = allocation.kind === "agent" ? "worker_tab" : allocation.kind;
|
|
414
|
+
const cleaned = (allocation.kind !== "agent" || attempt.termination?.status === "terminated")
|
|
415
|
+
&& attempt.cleanup.find((step) => step.kind === cleanupKind)?.status === "completed";
|
|
416
|
+
const branchPending = allocation.kind === "worktree"
|
|
417
|
+
&& attempt.cleanup.find((step) => step.kind === "branch")?.status !== "completed";
|
|
418
|
+
// A worktree can be gone while its branch is still retained. Keep its saved
|
|
419
|
+
// identity available for recovery, but never present the checkout as live.
|
|
420
|
+
if (allocation.status === "owned" && cleaned && !branchPending) continue;
|
|
421
|
+
const displayStatus = cleaned
|
|
422
|
+
? `${allocation.status}; ${branchPending ? "historical checkout; branch cleanup pending" : "cleanup completed (saved allocation)"}`
|
|
423
|
+
: allocation.kind === "agent" && attempt.termination?.status === "terminated"
|
|
424
|
+
? `${allocation.status}; worker terminated; tab cleanup pending`
|
|
425
|
+
: allocation.status;
|
|
426
|
+
const resource = allocation.kind === "worktree"
|
|
427
|
+
? { worktree: allocation.worktree && { path: allocation.worktree.path, cwd: allocation.worktree.cwd, branch: allocation.worktree.branch, repoRoot: allocation.worktree.repoRoot } }
|
|
428
|
+
: allocation.kind === "workspace" ? { label: allocation.label, workspaceId: allocation.workspaceId, worktreeCwd: allocation.worktreeCwd, mainRoot: allocation.mainRoot, herdrRepoRoot: allocation.herdrRepoRoot, repoKey: allocation.repoKey, rootTabId: allocation.rootTabId, rootPaneId: allocation.rootPaneId }
|
|
429
|
+
: allocation.kind === "worker_tab" ? { label: allocation.label, workspaceId: allocation.workspaceId, workspaceRootTabId: allocation.workspaceRootTabId, workspaceRootPaneId: allocation.workspaceRootPaneId, tabId: allocation.tabId, paneId: allocation.paneId, leasePath: allocation.leasePath, worktreeCwd: allocation.worktreeCwd }
|
|
430
|
+
: { agentName: allocation.agentName, workspaceId: allocation.workspaceId, tabId: allocation.tabId, paneId: allocation.paneId, leasePath: allocation.leasePath, worktreeCwd: allocation.worktreeCwd };
|
|
431
|
+
lines.push(`${label} ${allocation.kind} (${displayStatus}): ${JSON.stringify({ ...resource, possibleResources: allocation.possibleResources, failure: allocation.failure ? boundedPublicText(allocation.failure) : undefined })}`);
|
|
432
|
+
}
|
|
433
|
+
const cleanup = attempt.cleanup.filter((step) => step.status !== "completed");
|
|
434
|
+
if (cleanup.length) lines.push(`${label} cleanup pending: ${JSON.stringify(cleanup.map((step) => ({ kind: step.kind, status: step.status, failure: step.failure ? boundedPublicText(step.failure) : undefined })))}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const candidate of state.integration.candidates) lines.push(`Candidate ${candidate.taskId} attempt ${candidate.attempt}: ${candidate.worker}${candidate.decision ? `; ${candidate.decision}` : ""}; tip: ${JSON.stringify({ branch: candidate.tip.branch, head: candidate.tip.head })}.`);
|
|
438
|
+
for (const generation of state.integration.generations) {
|
|
439
|
+
const checkoutCleaned = generation.cleanup?.find((step) => step.kind === "worktree")?.status === "completed";
|
|
440
|
+
const branchCleaned = generation.cleanup?.find((step) => step.kind === "branch")?.status === "completed";
|
|
441
|
+
const retainedPath = generation.worktree && !(checkoutCleaned && branchCleaned);
|
|
442
|
+
if (!retainedPath && !generation.failure && !/conflict|failed|unknown/.test(generation.status)
|
|
443
|
+
&& !generation.cleanup?.some((step) => step.status !== "completed")
|
|
444
|
+
&& !generation.stages.some((stage) => stage.status === "conflict" || stage.failure)
|
|
445
|
+
&& !generation.promotion?.failure && !generation.supersededPromotion?.failure
|
|
446
|
+
&& generation !== state.integration.generations.at(-1)) continue;
|
|
447
|
+
const stagedTip = [...generation.stages].reverse().find((stage) => stage.tip)?.tip ?? generation.integrationBase;
|
|
448
|
+
lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.correction ? `; correction tip: ${JSON.stringify(generation.correction.to)}` : ""}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
|
|
449
|
+
for (const stage of generation.stages.filter((item) => item.status === "conflict" || item.failure)) lines.push(`Integration generation ${generation.number} stage ${stage.taskId} attempt ${stage.attempt}: ${stage.status}${stage.failure ? `; blockage: ${boundedPublicText(stage.failure)}` : ""}.`);
|
|
450
|
+
for (const promotion of [generation.promotion, generation.supersededPromotion]) if (promotion?.failure) lines.push(`Integration generation ${generation.number} promotion ${promotion.status}: ${boundedPublicText(promotion.failure)}`);
|
|
451
|
+
if (generation.cleanup?.some((step) => step.status !== "completed")) lines.push(`Integration generation ${generation.number} cleanup pending: ${JSON.stringify(generation.cleanup.filter((step) => step.status !== "completed").map((step) => ({ kind: step.kind, status: step.status, failure: step.failure ? boundedPublicText(step.failure) : undefined })))}`);
|
|
452
|
+
}
|
|
453
|
+
return lines;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest: Map<string, string[]>) {
|
|
457
|
+
updateWorkspaceWidgetSafely(ctx, response.state, rowsByRequest);
|
|
458
|
+
const preferredTaskId = response.continuation && "taskId" in response.continuation
|
|
459
|
+
? response.continuation.taskId
|
|
460
|
+
: undefined;
|
|
461
|
+
const workspaces = workspaceWidgetLines(response.state);
|
|
462
|
+
const text = ctx.hasUI === false && workspaces
|
|
463
|
+
? `${response.text}\n\nActive workspaces:\n${workspaces.join("\n")}`
|
|
464
|
+
: response.text;
|
|
465
|
+
return {
|
|
466
|
+
content: [{ type: "text" as const, text }],
|
|
467
|
+
details: {
|
|
468
|
+
state: publicState(response.state, PUBLIC_REVIEW_VERDICT_MAX_BYTES, preferredTaskId),
|
|
469
|
+
...(response.main ? { main: response.main } : {}),
|
|
470
|
+
...(response.continuation ? { continuation: response.continuation } : {}),
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Bounded dry-run plan; a route missing from the coordinator report shows as unresolved. */
|
|
476
|
+
export function formatExecutePlan({ request, root, main, routes }: ExecutePlan, hostChecked: boolean): { content: [{ type: "text"; text: string }]; details: unknown } {
|
|
477
|
+
const route = (role: string, modelClass: ModelClass) => {
|
|
478
|
+
const found = routes.find((candidate) => candidate.role === role && candidate.modelClass === modelClass);
|
|
479
|
+
return found ? { model: found.model, thinkingLevel: found.thinkingLevel } : undefined;
|
|
480
|
+
};
|
|
481
|
+
const label = (role: string, modelClass: ModelClass) => {
|
|
482
|
+
const resolved = route(role, modelClass);
|
|
483
|
+
return `${role} · ${modelClass} → ${resolved ? `${resolved.model} · thinking ${resolved.thinkingLevel}` : "route not reported"}`;
|
|
484
|
+
};
|
|
485
|
+
const changesets = request.tasks.some((task) => task.kind === "changeset");
|
|
486
|
+
const notChecked = [
|
|
487
|
+
...(changesets && !hostChecked ? ["Herdr host preflight (client and server versions, API schema, lsof, current workspace identity): it calls Herdr"] : []),
|
|
488
|
+
"request ID uniqueness and saved-state admission: they need the state lock",
|
|
489
|
+
];
|
|
490
|
+
const lines = [
|
|
491
|
+
`Dry run passed: isolated request ${request.id} in ${root} at Main ${main.head.slice(0, 12)} (${main.branch}). No state, worktree, Herdr tab, lease, or follow-up was created.`,
|
|
492
|
+
...request.tasks.map((task, index) => [
|
|
493
|
+
`${index + 1}. ${task.id} · ${task.kind} · ${label(task.role, task.modelClass)}`,
|
|
494
|
+
` depends on: ${task.dependsOn.join(", ") || "none"} · context from: ${task.contextFrom.join(", ") || "none"}`,
|
|
495
|
+
...(task.kind === "changeset" ? [` checks: ${task.checks.map(formatCheckCommand).join("; ")}`,
|
|
496
|
+
...(task.judgment ? [` judgment: ${label(task.judgment.role, task.judgment.modelClass)}`] : [])] : []),
|
|
497
|
+
].join("\n")),
|
|
498
|
+
`Final checks: ${request.finalChecks.map(formatCheckCommand).join("; ") || "none"}`,
|
|
499
|
+
...(request.finalJudgment ? [`Final judgment: ${label(request.finalJudgment.role, request.finalJudgment.modelClass)}`] : []),
|
|
500
|
+
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
501
|
+
];
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text", text: capEphemeralSubagentOutput(lines.join("\n")) }],
|
|
504
|
+
details: {
|
|
505
|
+
dryRun: true, mode: "isolated", id: request.id, root, main,
|
|
506
|
+
tasks: request.tasks.map((task) => ({ id: task.id, kind: task.kind, role: task.role, modelClass: task.modelClass,
|
|
507
|
+
...route(task.role, task.modelClass), dependsOn: task.dependsOn, contextFrom: task.contextFrom,
|
|
508
|
+
...(task.kind === "changeset" ? { checks: task.checks, ...(task.judgment ? { judgment: { ...task.judgment, ...route(task.judgment.role, task.judgment.modelClass) } } : {}) } : {}) })),
|
|
509
|
+
finalChecks: request.finalChecks,
|
|
510
|
+
...(request.finalJudgment ? { finalJudgment: { ...request.finalJudgment, ...route(request.finalJudgment.role, request.finalJudgment.modelClass) } } : {}),
|
|
511
|
+
notChecked,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface IsolatedSurface {
|
|
517
|
+
/** Dry run of execute: validates and resolves without durable state or Herdr calls. */
|
|
518
|
+
plan(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof formatExecutePlan>>;
|
|
519
|
+
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
520
|
+
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
521
|
+
recover(cwd: string): Promise<string>;
|
|
522
|
+
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
523
|
+
discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
|
|
524
|
+
discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
|
|
525
|
+
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, force: boolean, current: () => boolean): Promise<CleanupReport>;
|
|
526
|
+
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
527
|
+
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
528
|
+
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
529
|
+
drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface RegisterIsolatedOptions {
|
|
533
|
+
executor: EphemeralSubagentExecutor;
|
|
534
|
+
policy: ExecutionPolicySnapshot;
|
|
535
|
+
currentPolicy(): ExecutionPolicySnapshot;
|
|
536
|
+
/** Report new config issues, and throw when the config now blocks delegation, before each deferred Role launch. */
|
|
537
|
+
beforeRoleLaunch?(ctx: ExtensionContext): void;
|
|
538
|
+
componentsFactory?: CreateIsolatedComponents;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Register lifecycle surfaces; delegate_task remains the only start tool. */
|
|
542
|
+
export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIsolatedOptions): IsolatedSurface {
|
|
543
|
+
const componentsFactory = options.componentsFactory ?? createIsolatedComponents;
|
|
544
|
+
let latestCtx: ExtensionContext | undefined;
|
|
545
|
+
let components: IsolatedExtensionComponents | undefined;
|
|
546
|
+
const workspaceRowsByRequest = new Map<string, string[]>();
|
|
547
|
+
let savedRevision = 0;
|
|
548
|
+
const stateListeners = new Set<(state: RunState) => void>();
|
|
549
|
+
const activeJobs = new Set<AbortController>();
|
|
550
|
+
const jobOwners = new Map<string, () => boolean>();
|
|
551
|
+
let sessionEpoch = 0;
|
|
552
|
+
let sessionClosed = false;
|
|
553
|
+
|
|
554
|
+
const latestContext = (): ExtensionContext => {
|
|
555
|
+
if (!latestCtx) throw new Error("Pi Subagent cannot resolve a Role before session context exists.");
|
|
556
|
+
return latestCtx;
|
|
557
|
+
};
|
|
558
|
+
const getComponents = () => components ??= componentsFactory({
|
|
559
|
+
pi,
|
|
560
|
+
// The launch runtime reads this context only to resolve a Role launch.
|
|
561
|
+
context: () => {
|
|
562
|
+
const ctx = latestContext();
|
|
563
|
+
options.beforeRoleLaunch?.(ctx);
|
|
564
|
+
return ctx;
|
|
565
|
+
},
|
|
566
|
+
executor: options.executor,
|
|
567
|
+
policy: options.policy,
|
|
568
|
+
currentPolicy: options.currentPolicy,
|
|
569
|
+
onStateSaved: (state) => {
|
|
570
|
+
savedRevision += 1;
|
|
571
|
+
for (const listener of stateListeners) listener(state);
|
|
572
|
+
const owner = jobOwners.get(`${state.root}\0${state.request.id}`);
|
|
573
|
+
if (!sessionClosed && (owner === undefined || owner())) {
|
|
574
|
+
updateWorkspaceWidgetSafely(latestContext(), state, workspaceRowsByRequest);
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
const lookupRoot = async (cwd: string, signal?: AbortSignal): Promise<string> => {
|
|
579
|
+
const startedAt = Date.now();
|
|
580
|
+
const context: OperationContext = {
|
|
581
|
+
signal: signal ?? new AbortController().signal,
|
|
582
|
+
timeoutMs: LOOKUP_ROOT_TIMEOUT_MS,
|
|
583
|
+
deadline: startedAt + LOOKUP_ROOT_TIMEOUT_MS,
|
|
584
|
+
};
|
|
585
|
+
return await getComponents().resolveRoot(cwd, context);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
pi.on("session_start", (_event, ctx) => {
|
|
589
|
+
for (const controller of activeJobs) controller.abort();
|
|
590
|
+
sessionEpoch += 1;
|
|
591
|
+
sessionClosed = false;
|
|
592
|
+
latestCtx = ctx;
|
|
593
|
+
workspaceRowsByRequest.clear();
|
|
594
|
+
if (ctx.hasUI) ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, undefined);
|
|
595
|
+
});
|
|
596
|
+
pi.on("session_shutdown", () => {
|
|
597
|
+
for (const controller of activeJobs) controller.abort();
|
|
598
|
+
sessionEpoch += 1;
|
|
599
|
+
sessionClosed = true;
|
|
600
|
+
workspaceRowsByRequest.clear();
|
|
601
|
+
});
|
|
602
|
+
pi.on("model_select", (event, ctx) => { latestCtx = { ...ctx, model: event.model } as ExtensionContext; });
|
|
603
|
+
pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; });
|
|
604
|
+
|
|
605
|
+
const pruneGuards = (root: string): PruneGuards => ({
|
|
606
|
+
ownership: async (checkouts) => await getComponents().runner.pruneOwnership(checkouts),
|
|
607
|
+
panes: async () => await listHerdrPanes(root),
|
|
608
|
+
});
|
|
609
|
+
const requireCurrent = (current: () => boolean) => {
|
|
610
|
+
if (!current() || sessionClosed) throw new Error("Session or branch changed; reopen /subagent.");
|
|
611
|
+
};
|
|
612
|
+
// FileRunStore emits the initial state only after fsync. A resumed run emits its
|
|
613
|
+
// recovery record after saving it; validation acknowledges its saved exact intent.
|
|
614
|
+
// Neither the tool call nor its abort signal
|
|
615
|
+
// owns productive work after that durable boundary.
|
|
616
|
+
const startInSession = async (
|
|
617
|
+
id: string,
|
|
618
|
+
root: string,
|
|
619
|
+
signal: AbortSignal | undefined,
|
|
620
|
+
ctx: ExtensionContext,
|
|
621
|
+
acknowledged: (state: RunState) => boolean,
|
|
622
|
+
start: (signal: AbortSignal) => Promise<RunResponse>,
|
|
623
|
+
): Promise<ReturnType<typeof toolResult>> => {
|
|
624
|
+
const epoch = sessionEpoch;
|
|
625
|
+
const sessionId = ctx.sessionManager?.getSessionId();
|
|
626
|
+
const key = `${root}\0${id}`;
|
|
627
|
+
if (jobOwners.has(key)) throw new Error(`Pi Subagent request ${id} is already active in this session runtime.`);
|
|
628
|
+
const deliverySuppression = () => sessionClosed ? "session closed"
|
|
629
|
+
: sessionEpoch !== epoch ? "session generation changed"
|
|
630
|
+
: ctx.sessionManager?.getSessionId() !== sessionId ? "launching session changed"
|
|
631
|
+
: latestCtx?.sessionManager?.getSessionId() !== sessionId ? "current session changed"
|
|
632
|
+
: undefined;
|
|
633
|
+
const canDeliver = () => deliverySuppression() === undefined;
|
|
634
|
+
const controller = new AbortController();
|
|
635
|
+
activeJobs.add(controller);
|
|
636
|
+
jobOwners.set(key, canDeliver);
|
|
637
|
+
const notified = new Set<string>();
|
|
638
|
+
const attentionNotified = new Set<string>();
|
|
639
|
+
let candidateListener: ((state: RunState) => void) | undefined;
|
|
640
|
+
const finish = () => {
|
|
641
|
+
activeJobs.delete(controller);
|
|
642
|
+
if (candidateListener) stateListeners.delete(candidateListener);
|
|
643
|
+
if (jobOwners.get(key) === canDeliver) jobOwners.delete(key);
|
|
644
|
+
};
|
|
645
|
+
const abortBeforeAck = () => controller.abort(signal?.reason);
|
|
646
|
+
if (signal?.aborted) abortBeforeAck();
|
|
647
|
+
else signal?.addEventListener("abort", abortBeforeAck, { once: true });
|
|
648
|
+
let latestState: RunState | undefined;
|
|
649
|
+
let durableAcknowledged = false;
|
|
650
|
+
let accept!: (state: RunState) => void;
|
|
651
|
+
let reject!: (error: unknown) => void;
|
|
652
|
+
const durable = new Promise<RunState>((resolve, fail) => { accept = resolve; reject = fail; });
|
|
653
|
+
const removeTurnAbort = () => signal?.removeEventListener("abort", abortBeforeAck);
|
|
654
|
+
const listener = (state: RunState) => {
|
|
655
|
+
if (state.root !== root || state.request.id !== id) return;
|
|
656
|
+
latestState = state;
|
|
657
|
+
if (!acknowledged(state)) return;
|
|
658
|
+
durableAcknowledged = true;
|
|
659
|
+
for (const candidate of state.integration.candidates) notified.add(`${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`);
|
|
660
|
+
stateListeners.delete(listener);
|
|
661
|
+
removeTurnAbort();
|
|
662
|
+
accept(state);
|
|
663
|
+
};
|
|
664
|
+
stateListeners.add(listener);
|
|
665
|
+
const deliver = (kind: "candidate" | "attention" | "terminal", text: string, response?: RunResponse) => {
|
|
666
|
+
const suppression = deliverySuppression();
|
|
667
|
+
if (suppression) {
|
|
668
|
+
console.warn(`Pi Subagent ${id}: ${kind} delivery suppressed (${suppression}); durable request retained.`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const state = response?.state ?? latestState;
|
|
672
|
+
try {
|
|
673
|
+
const preview = state ? publicState(state, PUBLIC_EVIDENCE_MAX_BYTES, response?.continuation && "taskId" in response.continuation
|
|
674
|
+
? response.continuation.taskId : undefined) : undefined;
|
|
675
|
+
const details = preview ? {
|
|
676
|
+
state: preview,
|
|
677
|
+
...(response?.continuation ? { continuation: response.continuation } : {}),
|
|
678
|
+
} : { id };
|
|
679
|
+
pi.sendMessage({
|
|
680
|
+
customType: "pi-subagent-isolated-result",
|
|
681
|
+
content: preview && hasTruncatedVerdict(preview)
|
|
682
|
+
? `${text}\n\nReview verdicts here are previews; run subagent_status for the full verdict.`
|
|
683
|
+
: text,
|
|
684
|
+
display: true,
|
|
685
|
+
details,
|
|
686
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
687
|
+
} catch {
|
|
688
|
+
console.error(`Pi Subagent ${id}: ${kind} delivery failed (sendMessage threw); use subagent_status to recover. No delivery replayed.`);
|
|
689
|
+
if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
candidateListener = (state) => {
|
|
693
|
+
if (state.root !== root || state.request.id !== id) return;
|
|
694
|
+
latestState = state;
|
|
695
|
+
if (state.status !== "running") return;
|
|
696
|
+
for (const task of state.tasks) {
|
|
697
|
+
if (task.status !== "needs_attention") {
|
|
698
|
+
attentionNotified.delete(task.taskId);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (attentionNotified.has(task.taskId)) continue;
|
|
702
|
+
attentionNotified.add(task.taskId);
|
|
703
|
+
deliver("attention", `Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
|
|
704
|
+
}
|
|
705
|
+
for (const candidate of state.integration.candidates) {
|
|
706
|
+
const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
|
|
707
|
+
if (candidate.decision || notified.has(identity)) continue;
|
|
708
|
+
notified.add(identity);
|
|
709
|
+
deliver("candidate", `Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
stateListeners.add(candidateListener);
|
|
713
|
+
void Promise.resolve().then(() => start(controller.signal)).then(
|
|
714
|
+
(response) => {
|
|
715
|
+
finish();
|
|
716
|
+
stateListeners.delete(listener);
|
|
717
|
+
removeTurnAbort();
|
|
718
|
+
if (!durableAcknowledged) {
|
|
719
|
+
reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state, PUBLIC_EVIDENCE_MAX_BYTES))}`, response);
|
|
723
|
+
},
|
|
724
|
+
(error: unknown) => {
|
|
725
|
+
finish();
|
|
726
|
+
stateListeners.delete(listener);
|
|
727
|
+
removeTurnAbort();
|
|
728
|
+
if (!durableAcknowledged) {
|
|
729
|
+
reject(error);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
deliver("terminal", `Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request before choosing an explicit recovery action.`);
|
|
733
|
+
},
|
|
734
|
+
);
|
|
735
|
+
const state = await durable;
|
|
736
|
+
if (!canDeliver()) throw new Error(`Pi Subagent ${id} was saved, but its launching session changed before acknowledgement. Use subagent_status to inspect the durable request.`);
|
|
737
|
+
return toolResult({ text: `Pi Subagent ${id}: durable request accepted; productive work continues. Use subagent_status to inspect progress.`, state }, ctx, workspaceRowsByRequest);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// Status stays callable from codemode scripts and hands them the same bounded public projection
|
|
741
|
+
// the model reads; every mutating tool below is model-only because its guarded transitions need
|
|
742
|
+
// Main's visible reasoning.
|
|
743
|
+
pi.registerTool({
|
|
744
|
+
name: "subagent_status",
|
|
745
|
+
label: "Subagent status",
|
|
746
|
+
description: "Read one durable isolated request without reconciling or changing resources.",
|
|
747
|
+
parameters: IdOnlySchema,
|
|
748
|
+
prepareArguments: parseIdOnly,
|
|
749
|
+
outputSchema: StatusOutputSchema,
|
|
750
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
751
|
+
latestCtx = ctx;
|
|
752
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
753
|
+
const response = await getComponents().runner.status(params.id, root, signal);
|
|
754
|
+
const result = toolResult(response, ctx, workspaceRowsByRequest);
|
|
755
|
+
const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
|
|
756
|
+
// The projection is plain JSON; its conditional fields are typed optional rather than absent.
|
|
757
|
+
return { ...result, content, structuredContent: result.details as JsonValue };
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
pi.registerTool({
|
|
761
|
+
name: "subagent_resume",
|
|
762
|
+
label: "Subagent resume",
|
|
763
|
+
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
|
|
764
|
+
parameters: ResumeRequestParameters,
|
|
765
|
+
prepareArguments: parseResumeRequest,
|
|
766
|
+
exposure: "model-only",
|
|
767
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
768
|
+
latestCtx = ctx;
|
|
769
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
770
|
+
return await startInSession(params.id, root, signal, ctx,
|
|
771
|
+
(state) => state.recovery?.kind === "resume" && state.recovery.action === params.action
|
|
772
|
+
&& (!("taskId" in params) || state.recovery.taskId === params.taskId),
|
|
773
|
+
(runSignal) => getComponents().runner.resume(params, root, runSignal));
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
pi.registerTool({
|
|
777
|
+
name: "subagent_stage",
|
|
778
|
+
label: "Subagent stage",
|
|
779
|
+
description: "Main stages/resolves an exact candidate, or rejects/revises one. Rejection of a staged candidate freezes the old generation; explicitly restage chosen candidates in a new generation. Never writes Main.",
|
|
780
|
+
parameters: StageRequestSchema,
|
|
781
|
+
prepareArguments: parseStageRequest,
|
|
782
|
+
exposure: "model-only",
|
|
783
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
784
|
+
latestCtx = ctx;
|
|
785
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
786
|
+
return toolResult(await getComponents().runner.stage(params, root, signal), ctx, workspaceRowsByRequest);
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
pi.registerTool({
|
|
790
|
+
name: "subagent_integrate",
|
|
791
|
+
label: "Subagent integrate",
|
|
792
|
+
description: "Main advances staged dependents, refreshes after clean Main drift or evidence-less validation failure, validates in the background, corrects, promotes, reconciles interrupted validation/promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
|
|
793
|
+
parameters: IntegrationActionParameters,
|
|
794
|
+
prepareArguments: parseIntegrationAction,
|
|
795
|
+
exposure: "model-only",
|
|
796
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
797
|
+
latestCtx = ctx;
|
|
798
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
799
|
+
if (params.action === "validate") {
|
|
800
|
+
return await startInSession(params.id, root, signal, ctx,
|
|
801
|
+
(state) => {
|
|
802
|
+
const generation = state.integration.generations.at(-1);
|
|
803
|
+
return generation?.number === params.generation && generation.status === "validating"
|
|
804
|
+
&& sameIdentity(generation.combinedTip!, params.expectedTip);
|
|
805
|
+
},
|
|
806
|
+
(runSignal) => getComponents().runner.integrate(params, root, runSignal));
|
|
807
|
+
}
|
|
808
|
+
if (params.action === "advance") {
|
|
809
|
+
return await startInSession(params.id, root, signal, ctx,
|
|
810
|
+
(state) => state.status === "running" && state.waves.at(-1)?.status === "dispatching"
|
|
811
|
+
&& sameIdentity(state.waves.at(-1)!.base, params.expectedTip),
|
|
812
|
+
(runSignal) => getComponents().runner.integrate(params, root, runSignal));
|
|
813
|
+
}
|
|
814
|
+
return toolResult(await getComponents().runner.integrate(params, root, signal), ctx, workspaceRowsByRequest);
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
pi.registerTool({
|
|
818
|
+
name: "subagent_abort",
|
|
819
|
+
label: "Subagent abort",
|
|
820
|
+
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
|
|
821
|
+
parameters: IdOnlySchema,
|
|
822
|
+
prepareArguments: parseIdOnly,
|
|
823
|
+
exposure: "model-only",
|
|
824
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
825
|
+
latestCtx = ctx;
|
|
826
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
827
|
+
return toolResult(await getComponents().runner.abort(params.id, root, signal), ctx, workspaceRowsByRequest);
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
return {
|
|
831
|
+
async inventory(cwd, current) {
|
|
832
|
+
const root = await lookupRoot(cwd);
|
|
833
|
+
const revision = savedRevision;
|
|
834
|
+
const { requests, invalidIds, states } = await getComponents().runner.listRequests(root);
|
|
835
|
+
if (current?.() && !sessionClosed && revision === savedRevision) {
|
|
836
|
+
workspaceRowsByRequest.clear();
|
|
837
|
+
for (const state of states) {
|
|
838
|
+
const rows = workspaceWidgetLines(state);
|
|
839
|
+
if (rows) workspaceRowsByRequest.set(`${state.root}\0${state.request.id}`, rows);
|
|
840
|
+
}
|
|
841
|
+
try { renderWorkspaceWidget(latestContext(), workspaceRowsByRequest); }
|
|
842
|
+
catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
|
|
843
|
+
}
|
|
844
|
+
const orphans = await getComponents().runner.listOrphanRequests(root);
|
|
845
|
+
const leaseLive = await getComponents().runner.hasProductiveRunLease(root);
|
|
846
|
+
return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
|
|
847
|
+
},
|
|
848
|
+
async recover(cwd) {
|
|
849
|
+
const root = await lookupRoot(cwd);
|
|
850
|
+
const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
|
|
851
|
+
const lines = [
|
|
852
|
+
`Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
|
|
853
|
+
...(requests.length ? requests.map((response) => {
|
|
854
|
+
const state = response.state;
|
|
855
|
+
const blockers = state.tasks.flatMap((task) => [
|
|
856
|
+
...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
|
|
857
|
+
...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
|
|
858
|
+
? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
|
|
859
|
+
]);
|
|
860
|
+
const generation = state.integration.generations.at(-1);
|
|
861
|
+
const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
|
|
862
|
+
return [
|
|
863
|
+
`${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
|
|
864
|
+
...blockers.map((blocker) => ` Blocker: ${blocker}`),
|
|
865
|
+
...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
|
|
866
|
+
...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
|
|
867
|
+
...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
|
|
868
|
+
...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
|
|
869
|
+
...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
|
|
870
|
+
...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
|
|
871
|
+
].join("\n");
|
|
872
|
+
}) : ["No unfinished or retained isolated requests."]),
|
|
873
|
+
...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
|
|
874
|
+
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
875
|
+
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
876
|
+
];
|
|
877
|
+
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
878
|
+
},
|
|
879
|
+
async discardPlan(cwd, request) {
|
|
880
|
+
return await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
|
|
881
|
+
panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
|
|
882
|
+
blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
|
|
883
|
+
});
|
|
884
|
+
},
|
|
885
|
+
async discard(cwd, plan, current) {
|
|
886
|
+
requireCurrent(current);
|
|
887
|
+
const removals = await getComponents().runner.discard(cwd, plan, {
|
|
888
|
+
panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
|
|
889
|
+
blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
|
|
890
|
+
});
|
|
891
|
+
const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
|
|
892
|
+
return { failed: count("failed"), skipped: count("skipped"), text: boundedReport([`Close and delete ${plan.requestId}:`,
|
|
893
|
+
...removals.map((item) => `${item.outcome}: ${item.key}: ${item.reason}`),
|
|
894
|
+
], "removal report lines omitted; reopen /subagent to inspect retained resources.") };
|
|
895
|
+
},
|
|
896
|
+
async prunePlan(cwd) {
|
|
897
|
+
const root = await lookupRoot(cwd);
|
|
898
|
+
return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
|
|
899
|
+
},
|
|
900
|
+
async prune(cwd, items, allowIgnored, force, current) {
|
|
901
|
+
requireCurrent(current);
|
|
902
|
+
const root = await lookupRoot(cwd);
|
|
903
|
+
const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored, force,
|
|
904
|
+
{ ...pruneGuards(root), current: () => current() && !sessionClosed });
|
|
905
|
+
const removed = removals.filter(({ outcome }) => outcome === "removed");
|
|
906
|
+
const reconciled = removed.length
|
|
907
|
+
? await getComponents().runner.reconcilePrunedAborts(checkouts, removed.map(({ item }) => basename(item.branch))) : [];
|
|
908
|
+
const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
|
|
909
|
+
return { failed: count("failed"), skipped: count("skipped"), text: boundedReport([
|
|
910
|
+
`Pi Subagent prune in ${root}: ${removed.length} removed, ${count("skipped")} skipped, ${count("failed")} failed.`,
|
|
911
|
+
...removals.map(({ outcome, item, reason }) => `${outcome}: ${item.path ?? "(no checkout)"} · ${item.branch} · ${reason}`),
|
|
912
|
+
...reconciled,
|
|
913
|
+
], "prune report lines omitted.") };
|
|
914
|
+
},
|
|
915
|
+
async inspect(root, requestId) {
|
|
916
|
+
const response = await getComponents().runner.status(requestId, root);
|
|
917
|
+
return inspectionNotices(response);
|
|
918
|
+
},
|
|
919
|
+
canFollowup: (root, requestId, taskId) => !sessionClosed && (components?.runner.canFollowup(root, requestId, taskId) ?? false),
|
|
920
|
+
enqueue(root, requestId, taskId, text, current) {
|
|
921
|
+
requireCurrent(current);
|
|
922
|
+
return getComponents().runner.queueFollowup(root, requestId, taskId, text);
|
|
923
|
+
},
|
|
924
|
+
drain(root, requestId, taskId, current) {
|
|
925
|
+
requireCurrent(current);
|
|
926
|
+
return getComponents().runner.drainFollowups(root, requestId, taskId);
|
|
927
|
+
},
|
|
928
|
+
async plan(request, signal, ctx) {
|
|
929
|
+
latestCtx = ctx;
|
|
930
|
+
latestContext();
|
|
931
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
932
|
+
if (jobOwners.has(`${root}\0${request.id}`)) throw new Error(`Pi Subagent request ${request.id} is already active in this session runtime.`);
|
|
933
|
+
return formatExecutePlan(await getComponents().runner.plan(request, ctx.cwd, signal), false);
|
|
934
|
+
},
|
|
935
|
+
async execute(request, signal, ctx) {
|
|
936
|
+
latestCtx = ctx;
|
|
937
|
+
latestContext();
|
|
938
|
+
const root = await lookupRoot(ctx.cwd, signal);
|
|
939
|
+
return await startInSession(request.id, root, signal, ctx,
|
|
940
|
+
(state) => state.status === "pending" && state.createdAt === state.updatedAt
|
|
941
|
+
&& state.tasks.every((task) => task.status === "pending" && task.attempts.length === 0),
|
|
942
|
+
(runSignal) => getComponents().runner.execute(request, ctx.cwd, runSignal));
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
}
|