@henryqw/pi-subagent 18.0.0 → 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.
Files changed (47) hide show
  1. package/CONTEXT.md +76 -24
  2. package/README.md +116 -142
  3. package/dist/composition.d.ts +11 -0
  4. package/dist/composition.js +107 -0
  5. package/dist/direct-herdr.d.ts +17 -0
  6. package/dist/direct-herdr.js +219 -0
  7. package/dist/ephemeral.d.ts +2 -2
  8. package/dist/ephemeral.js +7 -7
  9. package/dist/git-runtime.d.ts +110 -0
  10. package/dist/git-runtime.js +472 -0
  11. package/dist/herdr-runtime.d.ts +137 -0
  12. package/dist/herdr-runtime.js +1228 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +20 -16
  15. package/dist/integration-git.d.ts +59 -0
  16. package/dist/integration-git.js +484 -0
  17. package/dist/launch-runtime.d.ts +54 -0
  18. package/dist/launch-runtime.js +195 -0
  19. package/dist/process.d.ts +9 -0
  20. package/dist/process.js +11 -0
  21. package/dist/runner.d.ts +356 -0
  22. package/dist/runner.js +2710 -0
  23. package/dist/schema.d.ts +2234 -0
  24. package/dist/schema.js +1236 -0
  25. package/dist/store.d.ts +50 -0
  26. package/dist/store.js +288 -0
  27. package/dist/working-evidence.d.ts +25 -0
  28. package/dist/working-evidence.js +261 -0
  29. package/docs/adr/001-composable-ephemeral-execution.md +3 -3
  30. package/docs/delegation-routing.html +89 -0
  31. package/docs/delegation-routing.svg +78 -0
  32. package/docs/orchestration-architecture.html +238 -0
  33. package/docs/orchestration-architecture.svg +189 -0
  34. package/docs/orchestration.md +122 -331
  35. package/example.png +0 -0
  36. package/examples/roles/implementer.md +2 -3
  37. package/extensions/admission.ts +66 -0
  38. package/extensions/config.ts +51 -37
  39. package/extensions/isolated.ts +638 -0
  40. package/extensions/result-transport.ts +13 -103
  41. package/extensions/role-tools.ts +11 -11
  42. package/extensions/subagent-command.ts +167 -0
  43. package/extensions/subagent.ts +264 -540
  44. package/extensions/task-name.ts +1 -2
  45. package/extensions/workflow.ts +60 -96
  46. package/package.json +19 -11
  47. package/skills/pi-subagent/SKILL.md +35 -0
@@ -0,0 +1,638 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { EphemeralSubagentExecutor } from "../src/ephemeral.ts";
3
+ import { ROLE_TOOL_POLICY_FLAG } from "../src/index.ts";
4
+ import type { EffectiveExecutionPolicy } from "./config.ts";
5
+ import {
6
+ createCanonicalGitRootResolver,
7
+ createExactJudgmentExecutor,
8
+ } from "../src/composition.ts";
9
+ import {
10
+ CheckedGitRuntime,
11
+ type DirectProcessRunner,
12
+ } from "../src/git-runtime.ts";
13
+ import { HerdrHostRuntime } from "../src/herdr-runtime.ts";
14
+ import { runProcess as directRunProcess } from "../src/process.ts";
15
+ import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../src/launch-runtime.ts";
16
+ import { IsolatedRunner, type OperationContext, type RunResponse } from "../src/runner.ts";
17
+ import {
18
+ IdOnlySchema,
19
+ ResumeRequestSchema,
20
+ StageRequestSchema,
21
+ IntegrationActionSchema,
22
+ parseIntegrationAction,
23
+ parseStageRequest,
24
+ parseExecuteRequest,
25
+ parseIdOnly,
26
+ parseResumeRequest,
27
+ sameIdentity,
28
+ type CheckBatchEvidence,
29
+ type ModelClass,
30
+ type ReviewEvidence,
31
+ type RunState,
32
+ type WorktreeAllocationIntent,
33
+ type WorkspaceAllocationIntent,
34
+ } from "../src/schema.ts";
35
+ import { FileRunStore } from "../src/store.ts";
36
+ import type { IsolatedInventory } from "./subagent-command.ts";
37
+
38
+ const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
39
+ const PUBLIC_EVIDENCE_MAX_BYTES = 512;
40
+ const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
41
+ const WIDGET_FIELD_MAX_CHARS = 32;
42
+
43
+ export interface IsolatedExtensionComponents {
44
+ runner: IsolatedRunner;
45
+ resolveRoot: LaunchRuntimeOptions["resolveRoot"];
46
+ }
47
+
48
+ export type CreateIsolatedComponents = (options: {
49
+ pi: ExtensionAPI;
50
+ context(): ExtensionContext;
51
+ executor: EphemeralSubagentExecutor;
52
+ policy: EffectiveExecutionPolicy;
53
+ currentPolicy(): EffectiveExecutionPolicy;
54
+ onStateSaved(state: RunState): void;
55
+ }) => IsolatedExtensionComponents;
56
+
57
+ /** Construct the checked runtime while reusing the unified productive-assignment executor. */
58
+ export const createIsolatedComponents: CreateIsolatedComponents = ({
59
+ pi, context, executor, policy, currentPolicy, onStateSaved,
60
+ }) => {
61
+ const runProcess: DirectProcessRunner = async (command, args, options) => options.stdin === undefined
62
+ ? await pi.exec(command, args, { cwd: options.cwd, signal: options.signal, timeout: options.timeoutMs })
63
+ : await directRunProcess(command, args, options);
64
+ const resolveRoot = createCanonicalGitRootResolver({ runProcess });
65
+ const git = new CheckedGitRuntime({ runProcess, executeReview: createExactJudgmentExecutor(executor) });
66
+ const host = new HerdrHostRuntime({ inspectInFlightTaskCandidate: git.inspectInFlightTaskCandidate.bind(git), runProcess });
67
+ const coordinator = new RoleLaunchRuntime({
68
+ pi,
69
+ context,
70
+ resolveRoot,
71
+ preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
72
+ inspectMain: async (input, operation) => await git.inspectMain(input, operation),
73
+ executionBudget: () => ({
74
+ maxTurns: policy.maxTurns,
75
+ maxMs: null,
76
+ ...(policy.maxTokens === undefined ? {} : { maxTokens: policy.maxTokens }),
77
+ }),
78
+ });
79
+ return {
80
+ resolveRoot,
81
+ runner: new IsolatedRunner(
82
+ coordinator,
83
+ host,
84
+ git,
85
+ new FileRunStore(undefined, onStateSaved),
86
+ executor,
87
+ policy,
88
+ currentPolicy,
89
+ ),
90
+ };
91
+ };
92
+
93
+ function compactWidgetField(value: string): string {
94
+ const characters = [...value];
95
+ return characters.length <= WIDGET_FIELD_MAX_CHARS
96
+ ? value
97
+ : `${characters.slice(0, WIDGET_FIELD_MAX_CHARS - 1).join("")}~`;
98
+ }
99
+
100
+ function workspaceBadge(role: string, modelClass: ModelClass): string {
101
+ const modelCode = { fast: "1", balanced: "2", frontier: "3", fav: "*" }[modelClass];
102
+ return `[${Array.from(role)[0]!.toUpperCase()}${modelCode}]`;
103
+ }
104
+
105
+ function workspaceStatus(status: RunState["tasks"][number]["status"]): string {
106
+ switch (status) {
107
+ case "ready_to_integrate": return "ready";
108
+ case "needs_attention": return "attention";
109
+ default: return status;
110
+ }
111
+ }
112
+
113
+ export function workspaceWidgetLines(state: RunState): string[] | undefined {
114
+ const rows = state.tasks.flatMap((taskState) => {
115
+ if (taskState.kind !== "changeset") return [];
116
+ const attempt = taskState.attempts.at(-1);
117
+ const allocation = [...(attempt?.allocations ?? [])].reverse().find(
118
+ (candidate): candidate is WorkspaceAllocationIntent =>
119
+ candidate.kind === "workspace" && candidate.status !== "absent",
120
+ );
121
+ const workspaceCleanup = attempt?.cleanup.find((step) => step.kind === "workspace");
122
+ if (!allocation || workspaceCleanup?.status === "completed") return [];
123
+ const task = state.request.tasks.find((candidate) => candidate.id === taskState.taskId);
124
+ if (!task || task.kind !== "changeset") return [];
125
+ const status = state.status === "aborted" ? "aborted" : workspaceStatus(taskState.status);
126
+ return `${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(allocation.label)} · ${status} · ${compactWidgetField(task.id)}`;
127
+ });
128
+ return rows.length ? rows : undefined;
129
+ }
130
+
131
+ function updateWorkspaceWidget(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
132
+ const key = `${state.root}\0${state.request.id}`;
133
+ const rows = workspaceWidgetLines(state);
134
+ if (rows) rowsByRequest.set(key, rows);
135
+ else rowsByRequest.delete(key);
136
+ if (!ctx.hasUI) return;
137
+ const allRows = [...rowsByRequest.values()].flat();
138
+ ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, allRows.length ? allRows : undefined);
139
+ }
140
+
141
+ function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
142
+ try {
143
+ updateWorkspaceWidget(ctx, state, rowsByRequest);
144
+ } catch (error) {
145
+ console.error("Pi Subagent workspace widget update failed.", error);
146
+ }
147
+ }
148
+
149
+ function boundedPublicText(value: string): string {
150
+ if (Buffer.byteLength(value, "utf8") <= PUBLIC_EVIDENCE_MAX_BYTES) return value;
151
+ let end = Math.min(value.length, PUBLIC_EVIDENCE_MAX_BYTES);
152
+ while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > PUBLIC_EVIDENCE_MAX_BYTES) end -= 1;
153
+ return `${value.slice(0, end)}\n[truncated]`;
154
+ }
155
+
156
+ function publicFailedCheck(evidence: CheckBatchEvidence | undefined) {
157
+ if (!evidence || evidence.passed) return undefined;
158
+ const result = evidence.results.find((candidate) => candidate.code !== 0 || candidate.killed)
159
+ ?? evidence.results.at(-1);
160
+ return {
161
+ phase: evidence.phase,
162
+ identityChanged: !sameIdentity(evidence.candidate, evidence.identityAfter),
163
+ ...(result ? {
164
+ command: boundedPublicText([result.command, ...result.args.map((arg) => JSON.stringify(arg))].join(" ")),
165
+ code: result.code,
166
+ killed: result.killed,
167
+ ...(result.stdout ? { stdout: boundedPublicText(result.stdout) } : {}),
168
+ ...(result.stderr ? { stderr: boundedPublicText(result.stderr) } : {}),
169
+ } : {}),
170
+ };
171
+ }
172
+
173
+ function publicFailedReview(evidence: ReviewEvidence | undefined) {
174
+ if (!evidence || evidence.passed) return undefined;
175
+ return {
176
+ phase: evidence.phase,
177
+ verdict: boundedPublicText(evidence.verdict),
178
+ identityChanged: !sameIdentity(evidence.tip, evidence.identityAfter),
179
+ };
180
+ }
181
+
182
+ function publicTaskRecovery(task: RunState["tasks"][number]) {
183
+ if (task.kind === "text") {
184
+ const attempt = task.attempts.at(-1);
185
+ return {
186
+ scope: "task" as const,
187
+ taskId: task.taskId,
188
+ ...(task.failure ? { failure: boundedPublicText(task.failure) } : {}),
189
+ ...(attempt ? { attempt: {
190
+ number: attempt.number,
191
+ status: attempt.status,
192
+ ...(attempt.failure ? { failure: boundedPublicText(attempt.failure) } : {}),
193
+ } } : {}),
194
+ };
195
+ }
196
+
197
+ const attempt = task.attempts.at(-1);
198
+ const failedCheck = publicFailedCheck(attempt?.preliminaryChecks);
199
+ const failedReview = publicFailedReview(attempt?.preliminaryReview);
200
+ const cleanup = attempt?.cleanup
201
+ .filter((step) => step.status !== "completed")
202
+ .map((step) => ({
203
+ kind: step.kind,
204
+ status: step.status,
205
+ ...(step.failure ? { failure: boundedPublicText(step.failure) } : {}),
206
+ }));
207
+ const worktreeCleanupPending = attempt?.cleanup.find((step) => step.kind === "worktree")?.status !== "completed";
208
+ const worktree = worktreeCleanupPending
209
+ ? [...(attempt?.allocations ?? [])].reverse().find(
210
+ (intent): intent is WorktreeAllocationIntent => intent.kind === "worktree" && intent.status === "owned" && Boolean(intent.worktree),
211
+ )?.worktree
212
+ : undefined;
213
+ return {
214
+ scope: "task" as const,
215
+ taskId: task.taskId,
216
+ ...(task.failure ? { failure: boundedPublicText(task.failure) } : {}),
217
+ ...(worktree ? { retainedWorktree: {
218
+ path: boundedPublicText(worktree.path),
219
+ cwd: boundedPublicText(worktree.cwd),
220
+ branch: boundedPublicText(worktree.branch),
221
+ } } : {}),
222
+ ...(failedCheck ? { failedCheck } : {}),
223
+ ...(failedReview ? { failedReview } : {}),
224
+ ...(cleanup?.length ? { cleanup } : {}),
225
+ };
226
+ }
227
+
228
+ function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
229
+ if (state.status !== "needs_attention") return undefined;
230
+ const task = state.tasks.find((candidate) => candidate.status === "needs_attention" && candidate.taskId === preferredTaskId)
231
+ ?? state.tasks.find((candidate) => candidate.status === "needs_attention");
232
+ if (task) return publicTaskRecovery(task);
233
+ if (state.final.failure || state.final.checks?.passed === false || state.final.review?.passed === false) {
234
+ const failedCheck = publicFailedCheck(state.final.checks);
235
+ const failedReview = publicFailedReview(state.final.review);
236
+ return {
237
+ scope: "final" as const,
238
+ ...(state.final.failure ? { failure: boundedPublicText(state.final.failure) } : {}),
239
+ ...(failedCheck ? { failedCheck } : {}),
240
+ ...(failedReview ? { failedReview } : {}),
241
+ };
242
+ }
243
+ return {
244
+ scope: "request" as const,
245
+ failure: "Request needs attention without a task or final failure.",
246
+ };
247
+ }
248
+
249
+ function publicState(state: RunState, preferredTaskId?: string) {
250
+ const needsAttention = publicNeedsAttention(state, preferredTaskId);
251
+ return {
252
+ version: state.version,
253
+ id: state.request.id,
254
+ status: state.status,
255
+ accepted: state.accepted,
256
+ main: state.main,
257
+ tasks: state.tasks.map((task) => ({
258
+ taskId: task.taskId, status: task.status,
259
+ ...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
260
+ number: attempt.number, superseded: attempt.superseded === true,
261
+ workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
262
+ termination: attempt.termination?.status,
263
+ worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
264
+ cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
265
+ })) } : {}),
266
+ })),
267
+ final: { status: state.final.status },
268
+ integration: {
269
+ ...(state.integration.refresh ? { refresh: state.integration.refresh } : {}),
270
+ candidates: state.integration.candidates.map(({ taskId, attempt, tip, base, checks, decision, worker }) => ({ taskId, attempt, tip, base, checked: checks.passed, worker, ...(decision ? { decision } : {}) })),
271
+ generations: state.integration.generations.map(({ number, status, worktree, integrationBase, combinedTip, correction, checks, review, promotion, supersededPromotion, failure, stages, cleanup, supersededFrom }) => ({
272
+ number, status, integrationBase, ...(supersededFrom ? { supersededFrom } : {}),
273
+ ...(supersededPromotion ? { supersededPromotion: { status: supersededPromotion.status,
274
+ failure: boundedPublicText(supersededPromotion.failure ?? "") } } : {}),
275
+ ...(cleanup ? { cleanup } : {}),
276
+ ...(combinedTip ? { combinedTip } : {}),
277
+ ...(correction ? { correction } : {}),
278
+ ...(checks ? { checked: checks.passed, failedCheck: publicFailedCheck(checks) } : {}),
279
+ ...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review) } : {}),
280
+ ...(promotion ? { promotion: promotion.status, ...(promotion.mainAfter ? { mainAfter: promotion.mainAfter } : {}),
281
+ ...(promotion.failure ? { promotionFailure: boundedPublicText(promotion.failure) } : {}) } : {}),
282
+ ...(failure ? { failure: boundedPublicText(failure) } : {}),
283
+ ...(worktree ? { retainedWorktree: { path: worktree.path, branch: worktree.branch } } : {}),
284
+ stages: stages.map(({ taskId, attempt, source, onto, status, tip, failure }) => ({
285
+ taskId, attempt, source, onto, status, ...(tip ? { tip } : {}), ...(failure ? { failure: boundedPublicText(failure) } : {}),
286
+ })),
287
+ })),
288
+ },
289
+ ...(needsAttention ? { needsAttention } : {}),
290
+ createdAt: state.createdAt,
291
+ updatedAt: state.updatedAt,
292
+ };
293
+ }
294
+
295
+ function inspectionNotices({ state, main, continuation }: RunResponse): string[] {
296
+ const lines = [
297
+ `Isolated request ${state.request.id}: ${state.status}; accepted: ${state.accepted}; final: ${state.final.status}; Main: ${main?.status ?? "not checked"}.`,
298
+ ...(continuation ? [`Reported continuation: ${JSON.stringify(continuation)}`] : []),
299
+ ];
300
+ if (main) lines.push(main.status === "unavailable"
301
+ ? `Main inspection unavailable: ${boundedPublicText(main.failure)}; expected: ${JSON.stringify(main.expected)}`
302
+ : `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
303
+ if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
304
+ const attention = publicNeedsAttention(state);
305
+ 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 })}`);
306
+ if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
307
+ for (const task of state.tasks) {
308
+ lines.push(`Task ${task.taskId}: ${task.status}${task.failure ? `; blockage: ${boundedPublicText(task.failure)}` : ""}.`);
309
+ if (task.kind !== "changeset") continue;
310
+ for (const attempt of task.attempts) {
311
+ const label = `Task ${task.taskId} attempt ${attempt.number}`;
312
+ 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 })}`);
313
+ for (const allocation of attempt.allocations) {
314
+ if (allocation.status === "absent") continue;
315
+ const cleanupKind = allocation.kind === "agent" ? "worker_tab" : allocation.kind;
316
+ const cleaned = (allocation.kind !== "agent" || attempt.termination?.status === "terminated")
317
+ && attempt.cleanup.find((step) => step.kind === cleanupKind)?.status === "completed";
318
+ const branchPending = allocation.kind === "worktree"
319
+ && attempt.cleanup.find((step) => step.kind === "branch")?.status !== "completed";
320
+ // A worktree can be gone while its branch is still retained. Keep its saved
321
+ // identity available for recovery, but never present the checkout as live.
322
+ if (allocation.status === "owned" && cleaned && !branchPending) continue;
323
+ const displayStatus = cleaned
324
+ ? `${allocation.status}; ${branchPending ? "historical checkout; branch cleanup pending" : "cleanup completed (saved allocation)"}`
325
+ : allocation.kind === "agent" && attempt.termination?.status === "terminated"
326
+ ? `${allocation.status}; worker terminated; tab cleanup pending`
327
+ : allocation.status;
328
+ const resource = allocation.kind === "worktree"
329
+ ? { worktree: allocation.worktree && { path: allocation.worktree.path, cwd: allocation.worktree.cwd, branch: allocation.worktree.branch, repoRoot: allocation.worktree.repoRoot } }
330
+ : 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 }
331
+ : 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 }
332
+ : { agentName: allocation.agentName, workspaceId: allocation.workspaceId, tabId: allocation.tabId, paneId: allocation.paneId, leasePath: allocation.leasePath, worktreeCwd: allocation.worktreeCwd };
333
+ lines.push(`${label} ${allocation.kind} (${displayStatus}): ${JSON.stringify({ ...resource, possibleResources: allocation.possibleResources, failure: allocation.failure ? boundedPublicText(allocation.failure) : undefined })}`);
334
+ }
335
+ const cleanup = attempt.cleanup.filter((step) => step.status !== "completed");
336
+ 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 })))}`);
337
+ }
338
+ }
339
+ 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 })}.`);
340
+ for (const generation of state.integration.generations) {
341
+ const checkoutCleaned = generation.cleanup?.find((step) => step.kind === "worktree")?.status === "completed";
342
+ const branchCleaned = generation.cleanup?.find((step) => step.kind === "branch")?.status === "completed";
343
+ const retainedPath = generation.worktree && !(checkoutCleaned && branchCleaned);
344
+ if (!retainedPath && !generation.failure && !/conflict|failed|unknown/.test(generation.status)
345
+ && !generation.cleanup?.some((step) => step.status !== "completed")
346
+ && !generation.stages.some((stage) => stage.status === "conflict" || stage.failure)
347
+ && !generation.promotion?.failure && !generation.supersededPromotion?.failure
348
+ && generation !== state.integration.generations.at(-1)) continue;
349
+ const stagedTip = [...generation.stages].reverse().find((stage) => stage.tip)?.tip ?? generation.integrationBase;
350
+ 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.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
351
+ 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)}` : ""}.`);
352
+ for (const promotion of [generation.promotion, generation.supersededPromotion]) if (promotion?.failure) lines.push(`Integration generation ${generation.number} promotion ${promotion.status}: ${boundedPublicText(promotion.failure)}`);
353
+ 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 })))}`);
354
+ }
355
+ return lines;
356
+ }
357
+
358
+ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest: Map<string, string[]>) {
359
+ updateWorkspaceWidgetSafely(ctx, response.state, rowsByRequest);
360
+ const preferredTaskId = response.continuation && "taskId" in response.continuation
361
+ ? response.continuation.taskId
362
+ : undefined;
363
+ const workspaces = workspaceWidgetLines(response.state);
364
+ const text = ctx.hasUI === false && workspaces
365
+ ? `${response.text}\n\nActive workspaces:\n${workspaces.join("\n")}`
366
+ : response.text;
367
+ return {
368
+ content: [{ type: "text" as const, text }],
369
+ details: {
370
+ state: publicState(response.state, preferredTaskId),
371
+ ...(response.main ? { main: response.main } : {}),
372
+ ...(response.continuation ? { continuation: response.continuation } : {}),
373
+ },
374
+ };
375
+ }
376
+
377
+ export interface IsolatedSurface {
378
+ execute(params: unknown, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
379
+ inventory(cwd: string): Promise<IsolatedInventory>;
380
+ inspect(root: string, requestId: string): Promise<readonly string[]>;
381
+ canFollowup(root: string, requestId: string, taskId: string): boolean;
382
+ enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
383
+ drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
384
+ }
385
+
386
+ export interface RegisterIsolatedOptions {
387
+ executor: EphemeralSubagentExecutor;
388
+ policy: EffectiveExecutionPolicy;
389
+ currentPolicy(): EffectiveExecutionPolicy;
390
+ componentsFactory?: CreateIsolatedComponents;
391
+ }
392
+
393
+ /** Register lifecycle surfaces; delegate_task remains the only start tool. */
394
+ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIsolatedOptions): IsolatedSurface {
395
+ if (process.argv.includes(`--${ROLE_TOOL_POLICY_FLAG}`)) {
396
+ const denied = (): never => { throw new Error("Child Roles cannot access isolated delegation."); };
397
+ return { execute: async () => denied(), inventory: async () => denied(), inspect: async () => denied(), canFollowup: denied, enqueue: denied, drain: denied };
398
+ }
399
+ const componentsFactory = options.componentsFactory ?? createIsolatedComponents;
400
+ let latestCtx: ExtensionContext | undefined;
401
+ let components: IsolatedExtensionComponents | undefined;
402
+ const workspaceRowsByRequest = new Map<string, string[]>();
403
+ const stateListeners = new Set<(state: RunState) => void>();
404
+ const activeJobs = new Set<AbortController>();
405
+ const jobOwners = new Map<string, () => boolean>();
406
+ let sessionEpoch = 0;
407
+ let sessionClosed = false;
408
+
409
+ const latestContext = (): ExtensionContext => {
410
+ if (!latestCtx) throw new Error("Pi Subagent cannot resolve a Role before session context exists.");
411
+ return latestCtx;
412
+ };
413
+ const getComponents = () => components ??= componentsFactory({
414
+ pi,
415
+ context: latestContext,
416
+ executor: options.executor,
417
+ policy: options.policy,
418
+ currentPolicy: options.currentPolicy,
419
+ onStateSaved: (state) => {
420
+ for (const listener of stateListeners) listener(state);
421
+ const owner = jobOwners.get(`${state.root}\0${state.request.id}`);
422
+ if (!sessionClosed && (owner === undefined || owner())) {
423
+ updateWorkspaceWidgetSafely(latestContext(), state, workspaceRowsByRequest);
424
+ }
425
+ },
426
+ });
427
+ const lookupRoot = async (cwd: string, signal?: AbortSignal): Promise<string> => {
428
+ const startedAt = Date.now();
429
+ const context: OperationContext = {
430
+ signal: signal ?? new AbortController().signal,
431
+ timeoutMs: LOOKUP_ROOT_TIMEOUT_MS,
432
+ deadline: startedAt + LOOKUP_ROOT_TIMEOUT_MS,
433
+ };
434
+ return await getComponents().resolveRoot(cwd, context);
435
+ };
436
+
437
+ pi.on("session_start", (_event, ctx) => {
438
+ for (const controller of activeJobs) controller.abort();
439
+ sessionEpoch += 1;
440
+ sessionClosed = false;
441
+ latestCtx = ctx;
442
+ workspaceRowsByRequest.clear();
443
+ if (ctx.hasUI) ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, undefined);
444
+ });
445
+ pi.on("session_shutdown", () => {
446
+ for (const controller of activeJobs) controller.abort();
447
+ sessionEpoch += 1;
448
+ sessionClosed = true;
449
+ workspaceRowsByRequest.clear();
450
+ });
451
+ pi.on("model_select", (event, ctx) => { latestCtx = { ...ctx, model: event.model } as ExtensionContext; });
452
+ pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; });
453
+
454
+ const requireCurrent = (current: () => boolean) => {
455
+ if (!current() || sessionClosed) throw new Error("Session or branch changed; reopen /subagent.");
456
+ };
457
+ // FileRunStore emits the initial state only after fsync. A resumed run emits its
458
+ // recovery record after saving it. Neither the tool call nor its abort signal
459
+ // owns productive work after that durable boundary.
460
+ const startInSession = async (
461
+ id: string,
462
+ root: string,
463
+ signal: AbortSignal | undefined,
464
+ ctx: ExtensionContext,
465
+ acknowledged: (state: RunState) => boolean,
466
+ start: (signal: AbortSignal) => Promise<RunResponse>,
467
+ ): Promise<ReturnType<typeof toolResult>> => {
468
+ const epoch = sessionEpoch;
469
+ const sessionId = ctx.sessionManager?.getSessionId();
470
+ const key = `${root}\0${id}`;
471
+ if (jobOwners.has(key)) throw new Error(`Pi Subagent request ${id} is already active in this session runtime.`);
472
+ const canDeliver = () => !sessionClosed && sessionEpoch === epoch
473
+ && ctx.sessionManager?.getSessionId() === sessionId
474
+ && latestCtx?.sessionManager?.getSessionId() === sessionId;
475
+ const controller = new AbortController();
476
+ activeJobs.add(controller);
477
+ jobOwners.set(key, canDeliver);
478
+ const finish = () => {
479
+ activeJobs.delete(controller);
480
+ if (jobOwners.get(key) === canDeliver) jobOwners.delete(key);
481
+ };
482
+ const abortBeforeAck = () => controller.abort(signal?.reason);
483
+ if (signal?.aborted) abortBeforeAck();
484
+ else signal?.addEventListener("abort", abortBeforeAck, { once: true });
485
+ let latestState: RunState | undefined;
486
+ let accept!: (state: RunState) => void;
487
+ let reject!: (error: unknown) => void;
488
+ const durable = new Promise<RunState>((resolve, fail) => { accept = resolve; reject = fail; });
489
+ const removeTurnAbort = () => signal?.removeEventListener("abort", abortBeforeAck);
490
+ const listener = (state: RunState) => {
491
+ if (state.root !== root || state.request.id !== id) return;
492
+ latestState = state;
493
+ if (!acknowledged(state)) return;
494
+ stateListeners.delete(listener);
495
+ removeTurnAbort();
496
+ accept(state);
497
+ };
498
+ stateListeners.add(listener);
499
+ const deliver = (text: string, response?: RunResponse) => {
500
+ if (!canDeliver()) return;
501
+ const state = response?.state ?? latestState;
502
+ try {
503
+ const details = state ? {
504
+ state: publicState(state, response?.continuation && "taskId" in response.continuation
505
+ ? response.continuation.taskId : undefined),
506
+ ...(response?.continuation ? { continuation: response.continuation } : {}),
507
+ } : { id };
508
+ pi.sendMessage({
509
+ customType: "pi-subagent-isolated-result",
510
+ content: text,
511
+ display: true,
512
+ details,
513
+ }, { triggerTurn: true, deliverAs: "followUp" });
514
+ } catch {
515
+ console.error(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`);
516
+ if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
517
+ }
518
+ };
519
+ void Promise.resolve().then(() => start(controller.signal)).then(
520
+ (response) => {
521
+ finish();
522
+ stateListeners.delete(listener);
523
+ removeTurnAbort();
524
+ if (!latestState || !acknowledged(latestState)) {
525
+ reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
526
+ return;
527
+ }
528
+ deliver(`${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
529
+ },
530
+ (error: unknown) => {
531
+ finish();
532
+ stateListeners.delete(listener);
533
+ removeTurnAbort();
534
+ if (!latestState || !acknowledged(latestState)) {
535
+ reject(error);
536
+ return;
537
+ }
538
+ deliver(`Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
539
+ },
540
+ );
541
+ const state = await durable;
542
+ 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.`);
543
+ return toolResult({ text: `Pi Subagent ${id}: durable request accepted; productive work continues. Use subagent_status to inspect progress.`, state }, ctx, workspaceRowsByRequest);
544
+ };
545
+
546
+ pi.registerTool({
547
+ name: "subagent_status",
548
+ label: "Subagent status",
549
+ description: "Read one durable isolated request without reconciling or changing resources.",
550
+ parameters: IdOnlySchema,
551
+ prepareArguments: parseIdOnly,
552
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
553
+ latestCtx = ctx;
554
+ const root = await lookupRoot(ctx.cwd, signal);
555
+ return toolResult(await getComponents().runner.status(params.id, root, signal), ctx, workspaceRowsByRequest);
556
+ },
557
+ });
558
+ pi.registerTool({
559
+ name: "subagent_resume",
560
+ label: "Subagent resume",
561
+ description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
562
+ parameters: ResumeRequestSchema,
563
+ prepareArguments: parseResumeRequest,
564
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
565
+ latestCtx = ctx;
566
+ const root = await lookupRoot(ctx.cwd, signal);
567
+ return await startInSession(params.id, root, signal, ctx,
568
+ (state) => state.recovery?.kind === "resume" && state.recovery.action === params.action
569
+ && (!("taskId" in params) || state.recovery.taskId === params.taskId),
570
+ (runSignal) => getComponents().runner.resume(params, root, runSignal));
571
+ },
572
+ });
573
+ pi.registerTool({
574
+ name: "subagent_stage",
575
+ label: "Subagent stage",
576
+ 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.",
577
+ parameters: StageRequestSchema,
578
+ prepareArguments: parseStageRequest,
579
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
580
+ latestCtx = ctx;
581
+ const root = await lookupRoot(ctx.cwd, signal);
582
+ return toolResult(await getComponents().runner.stage(params, root, signal), ctx, workspaceRowsByRequest);
583
+ },
584
+ });
585
+ pi.registerTool({
586
+ name: "subagent_integrate",
587
+ label: "Subagent integrate",
588
+ description: "Main advances staged dependents, refreshes after clean Main drift, validates, corrects, promotes, reconciles an interrupted promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
589
+ parameters: IntegrationActionSchema,
590
+ prepareArguments: parseIntegrationAction,
591
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
592
+ latestCtx = ctx;
593
+ const root = await lookupRoot(ctx.cwd, signal);
594
+ return toolResult(await getComponents().runner.integrate(params, root, signal), ctx, workspaceRowsByRequest);
595
+ },
596
+ });
597
+ pi.registerTool({
598
+ name: "subagent_abort",
599
+ label: "Subagent abort",
600
+ description: "Abort one unfinished isolated request; retained candidates and integration checkouts must be explicitly rejected and released first. Terminate only exact owned workers.",
601
+ parameters: IdOnlySchema,
602
+ prepareArguments: parseIdOnly,
603
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
604
+ latestCtx = ctx;
605
+ const root = await lookupRoot(ctx.cwd, signal);
606
+ return toolResult(await getComponents().runner.abort(params.id, root, signal), ctx, workspaceRowsByRequest);
607
+ },
608
+ });
609
+ return {
610
+ async inventory(cwd) {
611
+ const root = await lookupRoot(cwd);
612
+ return { root, ...await getComponents().runner.listRequests(root) };
613
+ },
614
+ async inspect(root, requestId) {
615
+ const response = await getComponents().runner.status(requestId, root);
616
+ return inspectionNotices(response);
617
+ },
618
+ canFollowup: (root, requestId, taskId) => !sessionClosed && (components?.runner.canFollowup(root, requestId, taskId) ?? false),
619
+ enqueue(root, requestId, taskId, text, current) {
620
+ requireCurrent(current);
621
+ return getComponents().runner.queueFollowup(root, requestId, taskId, text);
622
+ },
623
+ drain(root, requestId, taskId, current) {
624
+ requireCurrent(current);
625
+ return getComponents().runner.drainFollowups(root, requestId, taskId);
626
+ },
627
+ async execute(params, signal, ctx) {
628
+ latestCtx = ctx;
629
+ latestContext();
630
+ const request = parseExecuteRequest(params);
631
+ const root = await lookupRoot(ctx.cwd, signal);
632
+ return await startInSession(request.id, root, signal, ctx,
633
+ (state) => state.status === "pending" && state.createdAt === state.updatedAt
634
+ && state.tasks.every((task) => task.status === "pending" && task.attempts.length === 0),
635
+ (runSignal) => getComponents().runner.execute(request, ctx.cwd, runSignal));
636
+ },
637
+ };
638
+ }