pi-agent-replicate 0.0.0-stage → 1.4.1

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 (82) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +152 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +68 -2
  6. package/RELEASING.md +132 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +23 -0
  10. package/docs/adr/0003-installable-role-packs.md +29 -0
  11. package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
  12. package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
  13. package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
  14. package/docs/configuration.md +14 -0
  15. package/docs/development.md +27 -0
  16. package/docs/public-subagent-tree-api.md +58 -0
  17. package/docs/retained-roles.md +71 -0
  18. package/docs/settled-lifecycle-contract.md +71 -0
  19. package/docs/worktree-subagents.md +58 -0
  20. package/examples/role-pack/extension.ts +18 -0
  21. package/examples/role-pack/package.json +16 -0
  22. package/examples/role-pack/roles/example-reviewer.md +12 -0
  23. package/index.d.ts +116 -0
  24. package/index.js +1064 -0
  25. package/index.ts +155 -0
  26. package/package.json +70 -4
  27. package/pi-extension/subagents/activity.js +575 -0
  28. package/pi-extension/subagents/activity.ts +980 -0
  29. package/pi-extension/subagents/completion.js +184 -0
  30. package/pi-extension/subagents/completion.ts +229 -0
  31. package/pi-extension/subagents/delivery-drain.js +15 -0
  32. package/pi-extension/subagents/delivery-drain.ts +20 -0
  33. package/pi-extension/subagents/delivery-files.js +86 -0
  34. package/pi-extension/subagents/delivery-files.ts +105 -0
  35. package/pi-extension/subagents/delivery-log.js +51 -0
  36. package/pi-extension/subagents/delivery-log.ts +51 -0
  37. package/pi-extension/subagents/delivery-payload.ts +55 -0
  38. package/pi-extension/subagents/delivery.ts +219 -0
  39. package/pi-extension/subagents/execution-owner.js +30 -0
  40. package/pi-extension/subagents/execution-owner.ts +38 -0
  41. package/pi-extension/subagents/herdr.js +645 -0
  42. package/pi-extension/subagents/herdr.ts +927 -0
  43. package/pi-extension/subagents/index.ts +3400 -0
  44. package/pi-extension/subagents/launch.js +774 -0
  45. package/pi-extension/subagents/launch.ts +1124 -0
  46. package/pi-extension/subagents/lifecycle.js +477 -0
  47. package/pi-extension/subagents/lifecycle.ts +582 -0
  48. package/pi-extension/subagents/lineage.js +806 -0
  49. package/pi-extension/subagents/lineage.ts +841 -0
  50. package/pi-extension/subagents/replica-background.js +32 -0
  51. package/pi-extension/subagents/replica-background.ts +38 -0
  52. package/pi-extension/subagents/replica-branch.ts +45 -0
  53. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  54. package/pi-extension/subagents/replica-history.js +22 -0
  55. package/pi-extension/subagents/replica-history.ts +21 -0
  56. package/pi-extension/subagents/replica-work.js +326 -0
  57. package/pi-extension/subagents/replica-work.ts +336 -0
  58. package/pi-extension/subagents/replica-worker.js +74 -0
  59. package/pi-extension/subagents/replica-worker.ts +71 -0
  60. package/pi-extension/subagents/replication.ts +99 -0
  61. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  62. package/pi-extension/subagents/role-catalog.js +123 -0
  63. package/pi-extension/subagents/role-catalog.ts +24 -0
  64. package/pi-extension/subagents/runtime-routing.js +154 -0
  65. package/pi-extension/subagents/runtime-routing.ts +239 -0
  66. package/pi-extension/subagents/session-binding.js +346 -0
  67. package/pi-extension/subagents/session-binding.ts +339 -0
  68. package/pi-extension/subagents/session-work.js +219 -0
  69. package/pi-extension/subagents/session-work.ts +167 -0
  70. package/pi-extension/subagents/session.js +318 -0
  71. package/pi-extension/subagents/session.ts +448 -0
  72. package/pi-extension/subagents/settled-contract.js +47 -0
  73. package/pi-extension/subagents/settled-contract.ts +175 -0
  74. package/pi-extension/subagents/source-state.js +180 -0
  75. package/pi-extension/subagents/source-state.ts +184 -0
  76. package/pi-extension/subagents/status.ts +493 -0
  77. package/pi-extension/subagents/subagent-done.js +805 -0
  78. package/pi-extension/subagents/subagent-done.ts +878 -0
  79. package/pi-extension/subagents/terminal.js +111 -0
  80. package/pi-extension/subagents/terminal.ts +208 -0
  81. package/pi-extension/subagents/watchdog.js +122 -0
  82. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,878 @@
1
+ /**
2
+ * Extension loaded into sub-agents.
3
+ * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
4
+ * - Saves completion and closes replicas only after their session work drains
5
+ */
6
+ import {
7
+ stripFrontmatter,
8
+ type ExtensionAPI,
9
+ type BeforeAgentStartEvent,
10
+ type BeforeAgentStartEventResult,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { Box, Text } from "@earendil-works/pi-tui";
13
+ import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
14
+ import { SessionWork, workFacts, type SessionWorkSnapshot } from "./session-work.ts";
15
+ import { replicaStopDue } from "./watchdog.ts";
16
+ import { bindSessionRuntime, beginChildSessionMove, readSessionBinding, writeBindingFile } from "./session-binding.ts";
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+
19
+ const lifecycleKey = Symbol.for("pi-herdr-subagents.child-lifecycle");
20
+ const lifecycles: WeakSet<object> = (globalThis as any)[lifecycleKey] ??= new WeakSet<object>();
21
+
22
+ const lifecycleSessions: Set<string> = (globalThis as any)[Symbol.for("pi-herdr-subagents.child-lifecycle-sessions")] ??= new Set<string>();
23
+ /** Extension event facades differ, but one child process still owns one lifecycle. */
24
+ export function hasChildLifecycle(pi: ExtensionAPI): boolean {
25
+ return lifecycles.has(pi.events) || !!process.env.PI_SUBAGENT_SESSION && lifecycleSessions.has(process.env.PI_SUBAGENT_SESSION);
26
+ }
27
+ import { dirname, join } from "node:path";
28
+ import { createSubagentActivityRecorder } from "./activity.ts";
29
+
30
+ import { waitForDeliveryDrain } from "./delivery-drain.ts";
31
+ import { createDeliveryLog } from "./delivery-log.ts";
32
+ import {
33
+ classifySettledOutcome,
34
+ type AssistantStopReason,
35
+ type NewestAssistantEntry,
36
+ type SettledOutcomeKind,
37
+ } from "./settled-contract.ts";
38
+ import {
39
+ hasUndrainedDescendants,
40
+ isLineageNodeDrained,
41
+ lineageFromEnvironment,
42
+ reduceLineage,
43
+ type LineageState,
44
+ } from "./lineage.ts";
45
+ export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
46
+ return agentStarted;
47
+ }
48
+
49
+ /** Wait until this session's owned descendants have terminal delivery. */
50
+ export async function waitForDescendantDrain(options: {
51
+ lineage?: { rootDir: string; nodeId?: string; parentNodeId?: string };
52
+ delay?: (ms: number) => Promise<void>;
53
+ now?: () => number;
54
+ onWaitOpen?: (info: { waitedSince: number }) => void;
55
+ onWaitRelease?: (info: { waitedSince: number; waitedMs: number }) => void;
56
+ projectWidget?: (projection: unknown) => void;
57
+ log?: (event: string, fields?: Record<string, unknown>) => void;
58
+ } = {}): Promise<void> {
59
+ const lineage = options.lineage ?? lineageFromEnvironment();
60
+ if (!lineage) return;
61
+ const childId = lineage.nodeId ?? lineage.parentNodeId;
62
+ if (!childId) return;
63
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
64
+ const persistentLog = sessionFile
65
+ ? createDeliveryLog({ logPath: join(dirname(sessionFile), "subagent-delivery.log"), now: options.now })
66
+ : undefined;
67
+ const record = options.log ?? ((event: string, fields?: Record<string, unknown>) => persistentLog?.record(event, fields));
68
+ await waitForDeliveryDrain({
69
+ isDrained: () => !hasUndrainedDescendants(reduceLineage(lineage.rootDir), childId),
70
+ delay: options.delay,
71
+ now: options.now,
72
+ onWaitOpen: (info) => {
73
+ options.projectWidget?.({ state: "waiting-on-descendants", childId });
74
+ record("drain-wait-open", { childId, phase: "drain", waitedSince: info.waitedSince });
75
+ options.onWaitOpen?.(info);
76
+ },
77
+ onWaitRelease: (info) => {
78
+ record("drain-wait-release", { childId, phase: "drain", ...info });
79
+ options.onWaitRelease?.(info);
80
+ },
81
+ });
82
+ }
83
+
84
+ function hasPendingOwnedChildren(): boolean {
85
+ const contextPath = process.env.PI_SUBAGENT_TREE_CONTEXT;
86
+ if (!contextPath) return false;
87
+ let context: { treeDir?: string; ownerId?: string };
88
+ try { context = JSON.parse(readFileSync(contextPath, "utf8")); } catch { return false; }
89
+ if (!context.treeDir || !context.ownerId) return false;
90
+ const nodesDir = join(context.treeDir, "nodes");
91
+ return existsSync(nodesDir) && readdirSync(nodesDir).some((file) => {
92
+ if (!file.endsWith(".json")) return false;
93
+ try {
94
+ const node = JSON.parse(readFileSync(join(nodesDir, file), "utf8"));
95
+ return node.ownerId === context.ownerId && node.status !== "settled";
96
+ } catch { return true; }
97
+ });
98
+ }
99
+
100
+ async function waitForOwnedChildren(): Promise<void> {
101
+ while (hasPendingOwnedChildren()) {
102
+ await new Promise((resolve) => setTimeout(resolve, 100));
103
+ }
104
+ }
105
+
106
+
107
+ function settledChildIdFromPrompt(prompt: unknown): string | undefined {
108
+ if (typeof prompt !== "string" || !/^Subagent .+ settled\.$/m.test(prompt)) return undefined;
109
+ return prompt.match(/^Delivery: settled:([^:\s]+):/m)?.[1];
110
+ }
111
+
112
+
113
+ function settledChildIdFromMessage(message: unknown): string | undefined {
114
+ if (!message || typeof message !== "object") return undefined;
115
+ const value = message as {
116
+ customType?: unknown;
117
+ content?: unknown;
118
+ details?: { kind?: unknown; outcome?: unknown; childId?: unknown };
119
+ };
120
+ if (value.customType !== "subagent_result") return undefined;
121
+ if (
122
+ value.details?.kind === "settled" &&
123
+ (value.details.outcome === "clean" || value.details.outcome === "empty") &&
124
+ typeof value.details.childId === "string"
125
+ ) {
126
+ return value.details.childId;
127
+ }
128
+ return settledChildIdFromPrompt(value.content);
129
+ }
130
+ function hasOtherUndrainedDescendants(
131
+ state: LineageState,
132
+ ownerId: string,
133
+ settledChildId: string | undefined,
134
+ ): boolean {
135
+ return [...state.nodes.values()].some((child) =>
136
+ child.parentNodeId === ownerId &&
137
+ !isLineageNodeDrained(state, child.nodeId) &&
138
+ (child.nodeId !== settledChildId || child.autoExit !== true)
139
+ );
140
+ }
141
+ function textFromContent(content: unknown): string {
142
+ if (!Array.isArray(content)) return "";
143
+ return content
144
+ .filter(
145
+ (part): part is { type?: string; text?: string } =>
146
+ part != null && typeof part === "object",
147
+ )
148
+ .filter((part) => part.type === "text" && typeof part.text === "string")
149
+ .map((part) => part.text ?? "")
150
+ .join("");
151
+ }
152
+
153
+ /** Convert the latest assistant message into the frozen settled contract. */
154
+ export function newestAssistantEntry(
155
+ messages: any[] | undefined,
156
+ ): NewestAssistantEntry | null {
157
+ if (!messages || messages.length === 0) return null;
158
+ const latest = messages[messages.length - 1];
159
+ if (latest?.role === "user") return null;
160
+ for (let i = messages.length - 1; i >= 0; i--) {
161
+ const msg = messages[i];
162
+ if (msg?.role !== "assistant") continue;
163
+ const text =
164
+ typeof msg.text === "string" ? msg.text : textFromContent(msg.content);
165
+ const contentLength = text.length;
166
+ return {
167
+ id: typeof msg.id === "string" && msg.id ? msg.id : `assistant-${i}`,
168
+ text: text || null,
169
+ contentLength,
170
+ stopReason:
171
+ typeof msg.stopReason === "string"
172
+ ? (msg.stopReason as AssistantStopReason)
173
+ : undefined,
174
+ errorMessage:
175
+ typeof msg.errorMessage === "string" ? msg.errorMessage : undefined,
176
+ empty: contentLength === 0,
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Decide whether a settled response is allowed to close an auto-exit child. */
183
+ export function classifyAutoExitOutcome(
184
+ messages: any[] | undefined,
185
+ interruptRequested = false,
186
+ ): SettledOutcomeKind | null {
187
+ const assistant = newestAssistantEntry(messages);
188
+ if (!assistant) return null;
189
+ return classifySettledOutcome({ assistant, interruptRequested });
190
+ }
191
+
192
+ /**
193
+ * Compatibility predicate retained for callers that only have an agent_end
194
+ * snapshot. The decision is now intentionally made at agent_settled.
195
+ */
196
+ export function shouldAutoExitOnAgentEnd(
197
+ _userTookOver: boolean,
198
+ messages: any[] | undefined,
199
+ ): boolean {
200
+ const outcome = classifyAutoExitOutcome(messages);
201
+ return outcome === "clean" || outcome === "empty";
202
+ }
203
+
204
+ export interface SubagentErrorInfo {
205
+ errorMessage: string;
206
+ stopReason: "error";
207
+ }
208
+
209
+ /**
210
+ * If the last assistant message in the turn ended with `stopReason: "error"`
211
+ * (typically auto-retry exhausted on an overload / rate limit / server error),
212
+ * return its error info so the parent orchestrator can surface a clear
213
+ * failure instead of silently treating the run as completed.
214
+ *
215
+ * Returns `null` when the latest assistant turn completed normally or was
216
+ * aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
217
+ */
218
+ export function findLatestAssistantError(
219
+ messages: any[] | undefined,
220
+ ): SubagentErrorInfo | null {
221
+ if (!messages) return null;
222
+ for (let i = messages.length - 1; i >= 0; i--) {
223
+ const msg = messages[i];
224
+ if (msg?.role !== "assistant") continue;
225
+ if (msg.stopReason !== "error") return null;
226
+ const raw =
227
+ typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
228
+ return {
229
+ errorMessage:
230
+ raw ||
231
+ "Subagent agent loop ended with stopReason=error (no errorMessage field).",
232
+ stopReason: "error",
233
+ };
234
+ }
235
+ return null;
236
+ }
237
+
238
+ export function buildCompletionSidecar(
239
+ messages: any[] | undefined,
240
+ ):
241
+ | { type: "done" }
242
+ | { type: "error"; errorMessage: string; stopReason: "error" } {
243
+ const errorInfo = findLatestAssistantError(messages);
244
+ return errorInfo ? { type: "error", ...errorInfo } : { type: "done" };
245
+ }
246
+
247
+ export async function waitForPingAcknowledgement(
248
+ sessionFile: string,
249
+ pingId: string,
250
+ signal?: AbortSignal,
251
+ intervalMs = 50,
252
+ ): Promise<void> {
253
+ for (;;) {
254
+ if (signal?.aborted) throw new Error("Help request wait was cancelled");
255
+ try {
256
+ const current = JSON.parse(readFileSync(`${sessionFile}.exit`, "utf8")) as { type?: unknown; id?: unknown };
257
+ if (current.type !== "ping" || current.id !== pingId) return;
258
+ } catch (error) {
259
+ const code = (error as NodeJS.ErrnoException).code;
260
+ if (code === "ENOENT" || error instanceof SyntaxError) return;
261
+ throw error;
262
+ }
263
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
264
+ }
265
+ }
266
+
267
+ export function parseDeniedTools(rawValue: string | undefined): string[] {
268
+ return (rawValue ?? "")
269
+ .split(",")
270
+ .map((value) => value.trim())
271
+ .filter(Boolean);
272
+ }
273
+
274
+ /** Build one hidden, session-persistent initialization message for requested skills. */
275
+ export function buildSkillInitialization(
276
+ event: BeforeAgentStartEvent,
277
+ requestedSkills: string,
278
+ notify: (message: string, type?: "info" | "warning" | "error") => void,
279
+ ): BeforeAgentStartEventResult | undefined {
280
+ const names = [...new Set(
281
+ requestedSkills
282
+ .split(",")
283
+ .map((name) => name.trim())
284
+ .filter(Boolean),
285
+ )];
286
+ if (names.length === 0) return undefined;
287
+
288
+ const catalog = event.systemPromptOptions.skills ?? [];
289
+ const blocks: string[] = [];
290
+ for (const name of names) {
291
+ const skill = catalog.find((candidate) => candidate.name === name);
292
+ if (!skill) {
293
+ notify(`Unable to load requested skill "${name}": not found in Pi's resolved skill catalog.`, "warning");
294
+ continue;
295
+ }
296
+ try {
297
+ const body = stripFrontmatter(readFileSync(skill.filePath, "utf8")).trim();
298
+ blocks.push(
299
+ `<skill name="${skill.name}" location="${skill.filePath}">\n` +
300
+ `References are relative to ${skill.baseDir}.\n\n${body}\n</skill>`,
301
+ );
302
+ } catch (error) {
303
+ const reason = error instanceof Error ? error.message : String(error);
304
+ notify(`Unable to load requested skill "${name}": ${reason}`, "warning");
305
+ }
306
+ }
307
+
308
+ if (blocks.length === 0) return undefined;
309
+ return {
310
+ message: {
311
+ customType: "subagent_skill_initialization",
312
+ content: [{ type: "text", text: blocks.join("\n\n") }],
313
+ display: false,
314
+ },
315
+ };
316
+ }
317
+
318
+ /** Install child lifecycle only after this runtime opens its own persisted binding. */
319
+ export default function subagentLifecycle(pi: ExtensionAPI, initialContext?: ExtensionContext): void {
320
+ const start = (ctx: ExtensionContext) => {
321
+ const file = ctx.sessionManager.getSessionFile();
322
+ const binding = file ? readSessionBinding(file) : undefined;
323
+ // Startup checks inherit the child's environment, not ownership of its session.
324
+ if (!binding?.child || binding.sessionId !== ctx.sessionManager.getSessionId()) return;
325
+ installChildLifecycle(pi, ctx);
326
+ };
327
+ if (initialContext) start(initialContext);
328
+ else {
329
+ // This explicitly loaded extension is last in the replica argv. Keep this
330
+ // hook on its own facade even if the main extension owns the lifecycle.
331
+ pi.on("before_agent_start", (_event, ctx) => {
332
+ const file = ctx.sessionManager.getSessionFile();
333
+ const binding = file ? readSessionBinding(file) : undefined;
334
+ if (!binding?.child || binding.sessionId !== ctx.sessionManager.getSessionId()) return;
335
+ const environment = binding.child.environment;
336
+ if (environment.PI_SUBAGENT_REPLICATION !== "1") return;
337
+ const promptFile = environment.PI_SUBAGENT_REPLICATION_PROMPT_FILE;
338
+ if (!promptFile) throw new Error("Replica system prompt is missing.");
339
+ return { systemPrompt: readFileSync(promptFile, "utf8") };
340
+ });
341
+ pi.on("session_start", (_event, ctx) => start(ctx));
342
+ }
343
+ }
344
+
345
+ function installChildLifecycle(pi: ExtensionAPI, initialContext: ExtensionContext): void {
346
+ if (hasChildLifecycle(pi)) return;
347
+ lifecycles.add(pi.events);
348
+ const lifecycleSession = process.env.PI_SUBAGENT_SESSION;
349
+ if (lifecycleSession) lifecycleSessions.add(lifecycleSession);
350
+ const unsubscribers: Array<() => void> = [];
351
+ const on: ExtensionAPI["on"] = ((event: any, handler: any) => {
352
+ const unsubscribe = (pi.on as (event: any, handler: any) => unknown)(event, handler);
353
+ if (typeof unsubscribe === "function") unsubscribers.push(unsubscribe as () => void);
354
+ return unsubscribe;
355
+ }) as ExtensionAPI["on"];
356
+ let toolNames: string[] = [];
357
+ let denied: string[] = [];
358
+ let expanded = false;
359
+
360
+ // Read subagent identity from env vars (set by parent orchestrator)
361
+ const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
362
+ const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
363
+ const deniedToolsValue = process.env.PI_DENY_TOOLS;
364
+ const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
365
+ const inheritedBinding = process.env.PI_SUBAGENT_SESSION ? readSessionBinding(process.env.PI_SUBAGENT_SESSION) : undefined;
366
+ const replication = process.env.PI_SUBAGENT_REPLICATION === "1" || inheritedBinding?.child?.environment.PI_SUBAGENT_REPLICATION === "1";
367
+ const treeOwner = process.env.PI_SUBAGENT_TREE_OWNER === "1";
368
+ const savedWork = [...initialContext.sessionManager.getBranch()].reverse().find(
369
+ entry => entry.type === "custom" && entry.customType === "replica-session-work",
370
+ );
371
+ const work = new SessionWork(initialContext.sessionManager.getSessionId(),
372
+ savedWork?.type === "custom" ? savedWork.data as SessionWorkSnapshot : undefined);
373
+ let savedWorkText = JSON.stringify(work.snapshot());
374
+ let closureVersion = 0;
375
+ const persistWork = () => {
376
+ if (!replication || !runtimeActive) return;
377
+ const snapshot = work.snapshot(), text = JSON.stringify(snapshot);
378
+ if (text !== savedWorkText) { pi.appendEntry("replica-session-work", snapshot); savedWorkText = text; }
379
+ };
380
+ const recorder = createSubagentActivityRecorder({
381
+ runningChildId: process.env.PI_SUBAGENT_ID,
382
+ activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
383
+ settledEventsFile: process.env.PI_SUBAGENT_SETTLED_EVENTS_FILE,
384
+ });
385
+
386
+ function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
387
+ ctx.ui.setWidget(
388
+ "subagent-tools",
389
+ (_tui: any, theme: any) => {
390
+ const box = new Box(1, 0, (text: string) =>
391
+ theme.bg("toolSuccessBg", text),
392
+ );
393
+
394
+ const label = subagentAgent || subagentName;
395
+ const agentTag = label
396
+ ? theme.bold(theme.fg("accent", `[${label}]`))
397
+ : "";
398
+
399
+ if (expanded) {
400
+ // Expanded: full tool list + denied
401
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
402
+ const hint = theme.fg("muted", " (Ctrl+J to collapse)");
403
+
404
+ const toolList = toolNames
405
+ .map((name: string) => theme.fg("dim", name))
406
+ .join(theme.fg("muted", ", "));
407
+
408
+ let deniedLine = "";
409
+ if (denied.length > 0) {
410
+ const deniedList = denied
411
+ .map((name: string) => theme.fg("error", name))
412
+ .join(theme.fg("muted", ", "));
413
+ deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
414
+ }
415
+
416
+ const content = new Text(
417
+ `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
418
+ 0,
419
+ 0,
420
+ );
421
+ box.addChild(content);
422
+ } else {
423
+ // Collapsed: one-line summary
424
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
425
+ const deniedInfo =
426
+ denied.length > 0
427
+ ? theme.fg("dim", " · ") +
428
+ theme.fg("error", `${denied.length} denied`)
429
+ : "";
430
+ const hint = theme.fg("muted", " (Ctrl+J to expand)");
431
+
432
+ const content = new Text(
433
+ `${agentTag}${countInfo}${deniedInfo}${hint}`,
434
+ 0,
435
+ 0,
436
+ );
437
+ box.addChild(content);
438
+ }
439
+
440
+ return box;
441
+ },
442
+ { placement: "aboveEditor" },
443
+ );
444
+ }
445
+
446
+ let latestAgentEndMessages: any[] | undefined;
447
+ let latestTurnIndex: number | undefined;
448
+ let settledChildResultId: string | undefined;
449
+ let skillInitializationComplete = false;
450
+ // Resumed sessions replay the previous turn during startup. Do not publish
451
+ // that historical settlement as the new resume completion.
452
+ let resumeTurnStarted = process.env.PI_SUBAGENT_RESUME !== "1";
453
+ let resumeInputSeen = process.env.PI_SUBAGENT_RESUME !== "1";
454
+ const resumeBaselineAssistantIds = new Set<string>(
455
+ (() => {
456
+ if (process.env.PI_SUBAGENT_RESUME !== "1") return [];
457
+ try {
458
+ const value: unknown = JSON.parse(process.env.PI_SUBAGENT_RESUME_BASELINE_ASSISTANTS ?? "[]");
459
+ return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
460
+ } catch { return []; }
461
+ })(),
462
+ );
463
+ // Parent-side interrupt bookkeeping is intentionally separate. Until the
464
+ // parent wires an explicit interrupt request, an abort remains open and is
465
+ // classified as an unexpected abort for delivery purposes.
466
+ let interruptRequested = false;
467
+
468
+ let agentStarted = false;
469
+ let movePending = false;
470
+ let reloadPending = false;
471
+ let runtimeActive = true;
472
+ let intervened = false;
473
+ const mechanicalInterruptPath = process.env.PI_SUBAGENT_SESSION
474
+ ? `${process.env.PI_SUBAGENT_SESSION}.mechanical-interrupt`
475
+ : undefined;
476
+ // Show widget + status bar on session start
477
+ const start = (ctx: ExtensionContext) => {
478
+ bindSessionRuntime(pi, ctx);
479
+ skillInitializationComplete = ctx.sessionManager.getBranch().some(
480
+ (entry) => entry.type === "custom_message" && entry.customType === "subagent_skill_initialization",
481
+ );
482
+ recorder.sessionStart();
483
+ toolNames = pi.getAllTools().map((tool) => tool.name).sort();
484
+ denied = parseDeniedTools(deniedToolsValue);
485
+ const allow = process.env.PI_SUBAGENT_TOOLS?.split(",").filter(Boolean);
486
+ pi.setActiveTools(pi.getActiveTools().filter((name) => !denied.includes(name) && (!allow?.length || allow.includes(name))));
487
+ renderWidget(ctx, null);
488
+ };
489
+ on("session_start", (_event, ctx) => start(ctx));
490
+ if (initialContext) start(initialContext);
491
+
492
+ on("session_before_compact", (event) => {
493
+ idleSince = undefined;
494
+ recorder.compactionStart({
495
+ reason: event.reason,
496
+ willRetry: event.willRetry,
497
+ });
498
+ });
499
+
500
+ on("session_compact", (event) => {
501
+ recorder.compactionSucceeded({
502
+ reason: event.reason,
503
+ willRetry: event.willRetry,
504
+ });
505
+ });
506
+
507
+ on("session_compact_failed", (event) => {
508
+ recorder.compactionFailed({
509
+ reason: event.reason,
510
+ willRetry: event.willRetry,
511
+ aborted: event.aborted,
512
+ errorMessage: event.errorMessage,
513
+ });
514
+ });
515
+
516
+ on("input", () => {
517
+ closureVersion++;
518
+ idleSince = undefined;
519
+ resumeInputSeen = true;
520
+ if (shouldMarkUserTookOver(agentStarted)) intervened = true;
521
+ recorder.input();
522
+ });
523
+
524
+ on("before_agent_start", (event, ctx) => {
525
+ settledChildResultId =
526
+ settledChildIdFromPrompt(event.prompt) ?? settledChildResultId;
527
+ if (replication) { closureVersion++; clearStop(); interruptRequested = false; }
528
+ // Each run needs fresh agent_end evidence; never reuse a prior clean turn.
529
+ latestAgentEndMessages = undefined;
530
+ latestTurnIndex = undefined;
531
+ resumeTurnStarted = true;
532
+ recorder.beforeAgentStart();
533
+
534
+ if (replication) {
535
+ const promptFile = process.env.PI_SUBAGENT_REPLICATION_PROMPT_FILE;
536
+ if (!promptFile) throw new Error("Replica system prompt is missing.");
537
+ return { systemPrompt: readFileSync(promptFile, "utf8") };
538
+ }
539
+ if (skillInitializationComplete) return;
540
+ skillInitializationComplete = true;
541
+ const requestedSkills = process.env.PI_SUBAGENT_SKILLS;
542
+ if (requestedSkills === undefined) return;
543
+ return buildSkillInitialization(event, requestedSkills, (message, type) =>
544
+ ctx.ui.notify(message, type),
545
+ );
546
+ });
547
+
548
+ on("agent_start", () => {
549
+ agentStarted = true;
550
+ recorder.agentStart();
551
+ });
552
+ let exitRequested = false;
553
+ let stopTimer: ReturnType<typeof setInterval> | undefined;
554
+ let idleSince: number | undefined;
555
+ let stopPayload: Record<string, unknown> | undefined;
556
+ let checkingWork = false;
557
+ const refreshWork = async (): Promise<void> => {
558
+ const completed = await work.refreshProcesses();
559
+ if (!runtimeActive) return;
560
+ persistWork();
561
+ if (completed.length) {
562
+ closureVersion++;
563
+ clearStop();
564
+ latestAgentEndMessages = undefined;
565
+ pi.sendMessage({
566
+ customType: "replica-work-completion", display: false,
567
+ content: `Session-owned processes exited: ${completed.join(", ")}. No exit status was recorded here. Inspect their saved output before giving your final result.`,
568
+ details: { pids: completed },
569
+ }, { triggerTurn: true, deliverAs: "followUp" });
570
+ }
571
+ };
572
+ const stopFile = process.env.PI_SUBAGENT_SESSION
573
+ ? `${process.env.PI_SUBAGENT_SESSION}.replica-stop`
574
+ : undefined;
575
+ const clearStop = () => {
576
+ idleSince = undefined;
577
+ // A terminal failure survives follow-up turns needed to drain owned work.
578
+ if (stopPayload?.type === "error") return;
579
+ stopPayload = undefined;
580
+ if (stopFile) rmSync(stopFile, { force: true });
581
+ };
582
+ const rememberStop = (payload: Record<string, unknown>) => {
583
+ if (stopPayload?.type === "error") payload = stopPayload;
584
+ stopPayload = payload;
585
+ // The exit sidecar remains authoritative if this retry checkpoint cannot be saved.
586
+ if (stopFile) {
587
+ try { writeBindingFile(stopFile, { payload, stoppedAt: Date.now() }); } catch {}
588
+ }
589
+ };
590
+ const armStopTimer = (ctx: ExtensionContext) => {
591
+ if (!replication || stopTimer) return;
592
+ stopTimer = setInterval(async () => {
593
+ if (checkingWork || !runtimeActive || exitRequested || !resumeTurnStarted || !resumeInputSeen) return;
594
+ checkingWork = true;
595
+ try {
596
+ await refreshWork();
597
+ if (!runtimeActive) return;
598
+ const lineage = lineageFromEnvironment();
599
+ const descendants = !!lineage && hasUndrainedDescendants(reduceLineage(lineage.rootDir), lineage.nodeId);
600
+ const idle = ctx.isIdle();
601
+ const pendingMessages = ctx.hasPendingMessages();
602
+ if (!idle || pendingMessages || movePending || reloadPending || descendants || work.pending) { idleSince = undefined; return; }
603
+ if (!latestAgentEndMessages && !stopPayload) return;
604
+ idleSince ??= Date.now();
605
+ if (!replicaStopDue({ now: Date.now(), idleSince, idle, pendingMessages, moving: movePending, descendants })) return;
606
+ const payload = stopPayload ?? { type: "error", errorMessage: "Replica stopped without saved settlement evidence." };
607
+ rememberStop(payload);
608
+ void finishAfterDrain(ctx, payload);
609
+ } catch (error) { ctx.ui.notify(`Session work could not be checked: ${String(error)}`, "error"); }
610
+ finally { checkingWork = false; }
611
+ }, 1000);
612
+ stopTimer.unref();
613
+ };
614
+ const restoreStopTimer = (ctx: ExtensionContext) => {
615
+ if (replication && stopFile && existsSync(stopFile) && process.env.PI_SUBAGENT_RESUME !== "1") {
616
+ try {
617
+ const saved = JSON.parse(readFileSync(stopFile, "utf8"));
618
+ if (!["done", "error"].includes(saved.payload?.type) || !Number.isFinite(saved.stoppedAt)) throw new Error("Invalid stopped-state checkpoint");
619
+ stopPayload = saved.payload;
620
+ idleSince = saved.stoppedAt;
621
+ } catch {
622
+ stopPayload = { type: "error", errorMessage: "Replica stopped-state checkpoint could not be read." };
623
+ idleSince = Date.now();
624
+ }
625
+ }
626
+ armStopTimer(ctx);
627
+ };
628
+ on("session_start", (_event, ctx) => restoreStopTimer(ctx));
629
+ if (initialContext) restoreStopTimer(initialContext);
630
+ const finishAfterDrain = async (
631
+ ctx: ExtensionContext,
632
+ payload: Record<string, unknown>,
633
+ ): Promise<void> => {
634
+ if (exitRequested || replication && work.pending) return;
635
+ exitRequested = true;
636
+ const version = closureVersion;
637
+ try {
638
+ const lineage = lineageFromEnvironment();
639
+ if (lineage && hasUndrainedDescendants(reduceLineage(lineage.rootDir), lineage.nodeId)) await waitForDescendantDrain();
640
+ if (treeOwner) await waitForOwnedChildren();
641
+ if (replication) {
642
+ // Let queued completion input start its turn before the final ownership check.
643
+ await new Promise<void>(resolve => setImmediate(resolve));
644
+ await refreshWork();
645
+ if (version !== closureVersion || work.pending || movePending || !ctx.isIdle() || ctx.hasPendingMessages()) return;
646
+ }
647
+ if (!runtimeActive || reloadPending) return;
648
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
649
+ if (sessionFile) {
650
+ try {
651
+ writeBindingFile(`${sessionFile}.exit`, { ...payload, summary: payload.summary ?? newestAssistantEntry(latestAgentEndMessages)?.text });
652
+ } catch (error) {
653
+ if (replication) {
654
+ ctx.ui.notify(`Replica completion could not be saved: ${String(error)}`, "error");
655
+ return;
656
+ }
657
+ // Retained callers can still use the wrapper sentinel.
658
+ }
659
+ }
660
+ ctx.shutdown();
661
+ } finally { exitRequested = false; }
662
+ };
663
+
664
+ on("agent_end", (event, ctx) => {
665
+ // Boundary messages commit directly to history without message_start.
666
+ // This run consumed them before producing its final response.
667
+ if (replication) {
668
+ for (const entry of ctx.sessionManager.getBranch()) {
669
+ if (entry.type === "custom_message") work.message(entry);
670
+ }
671
+ persistWork();
672
+ }
673
+ armStopTimer(ctx);
674
+ latestAgentEndMessages = (event as any).messages as any[] | undefined;
675
+ const eventTurnIndex = (event as any).turnIndex;
676
+ if (typeof eventTurnIndex === "number") latestTurnIndex = eventTurnIndex;
677
+ const assistant = newestAssistantEntry(latestAgentEndMessages);
678
+ if (assistant?.stopReason === "aborted") {
679
+ const mechanical = Boolean(mechanicalInterruptPath && existsSync(mechanicalInterruptPath));
680
+ if (mechanicalInterruptPath) rmSync(mechanicalInterruptPath, { force: true });
681
+ interruptRequested = true;
682
+ if (!mechanical) intervened = true;
683
+ }
684
+ recorder.agentEndWaiting();
685
+ });
686
+
687
+
688
+ on("agent_settled", (_event, ctx) => {
689
+ // A queued self-reload owns the next continuation, not this acknowledgement.
690
+ if (reloadPending || !runtimeActive) return;
691
+ // Cancellation receipts are passive. They may be appended after agent_end
692
+ // and must release ownership without starting a turn after an explicit Stop.
693
+ if (replication) {
694
+ for (const entry of ctx.sessionManager.getBranch()) {
695
+ if (entry.type === "custom" && entry.customType === "web-search:cancelled" && (entry.data as { status?: unknown })?.status === "cancelled") work.toolResult(entry.data);
696
+ }
697
+ persistWork();
698
+ }
699
+ const outcome = classifyAutoExitOutcome(latestAgentEndMessages, interruptRequested);
700
+ const assistant = newestAssistantEntry(latestAgentEndMessages);
701
+ if (!outcome || !assistant) {
702
+ if (replication && resumeTurnStarted && !movePending) {
703
+ const payload = { type: "error", errorMessage: "Replica stopped without a final assistant response." };
704
+ rememberStop(payload);
705
+ return finishAfterDrain(ctx, stopPayload ?? payload);
706
+ }
707
+ return;
708
+ }
709
+ if (resumeBaselineAssistantIds.has(assistant.id)) {
710
+ // Pi replays the previous assistant turn while opening a resumed session.
711
+ // Its settlement is not the follow-up requested by the parent.
712
+ return;
713
+ }
714
+ if (!resumeTurnStarted || (process.env.PI_SUBAGENT_RESUME === "1" && !resumeInputSeen)) return;
715
+
716
+ if ((!replication && intervened) || movePending) return;
717
+
718
+
719
+ const observedSettledChildId = settledChildResultId;
720
+ settledChildResultId = undefined;
721
+ const terminalAutoExit =
722
+ autoExit && (replication || outcome === "clean" || outcome === "empty");
723
+ const lineage = lineageFromEnvironment();
724
+ const lineageState = lineage ? reduceLineage(lineage.rootDir) : undefined;
725
+ const anyDescendantPending =
726
+ terminalAutoExit &&
727
+ !!lineage &&
728
+ !!lineageState &&
729
+ hasUndrainedDescendants(lineageState, lineage.nodeId);
730
+ const descendantPending =
731
+ terminalAutoExit &&
732
+ !!lineage &&
733
+ !!lineageState &&
734
+ hasOtherUndrainedDescendants(
735
+ lineageState,
736
+ lineage.nodeId,
737
+ observedSettledChildId,
738
+ );
739
+ const waitsForChildResult = !treeOwner && descendantPending && (!replication || outcome === "clean");
740
+
741
+ recorder.agentSettled({
742
+ outcome,
743
+ assistantId: assistant.id,
744
+ stopReason: assistant.stopReason,
745
+ errorMessage: assistant.errorMessage,
746
+ empty: assistant.empty,
747
+ turnIndex: latestTurnIndex,
748
+ autoExit: terminalAutoExit && !waitsForChildResult,
749
+ publish:
750
+ !waitsForChildResult && (treeOwner || !anyDescendantPending),
751
+ });
752
+
753
+ if (!terminalAutoExit || waitsForChildResult) return;
754
+ const summary = assistant.text?.trim();
755
+ const payload = replication && stopPayload?.type === "error"
756
+ ? stopPayload
757
+ : replication && (outcome !== "clean" || !summary)
758
+ ? { type: "error", errorMessage: assistant.errorMessage ?? (outcome === "intentional-abort" || outcome === "unexpected-abort" ? "Replica was interrupted." : "Replica returned no required summary.") }
759
+ : { type: "done", ...(replication ? { summary: assistant.text } : {}) };
760
+ if (replication) rememberStop(payload);
761
+ // Pi checks the shutdown flag after awaiting its settled handlers.
762
+ return finishAfterDrain(ctx, payload);
763
+ });
764
+
765
+ on("turn_start", (event) => {
766
+ latestTurnIndex = (event as any).turnIndex;
767
+ recorder.turnStart(latestTurnIndex);
768
+ });
769
+
770
+ on("turn_end", (event) => {
771
+ recorder.turnEnd((event as any).turnIndex);
772
+ });
773
+
774
+ on("before_provider_request", () => {
775
+ recorder.beforeProviderRequest();
776
+ });
777
+
778
+ on("after_provider_response", () => {
779
+ recorder.afterProviderResponse();
780
+ });
781
+
782
+ on("message_start", (event) => {
783
+ if (replication && work.message((event as any).message)) {
784
+ closureVersion++;
785
+ clearStop();
786
+ latestAgentEndMessages = undefined;
787
+ persistWork();
788
+ }
789
+ settledChildResultId =
790
+ settledChildIdFromMessage((event as any).message) ?? settledChildResultId;
791
+ });
792
+
793
+ on("message_update", (event) => {
794
+ recorder.messageUpdate((event as any).assistantMessageEvent?.type);
795
+ });
796
+
797
+ on("tool_execution_start", (event) => {
798
+ if (replication) { closureVersion++; work.toolStarted(event.toolCallId, event.toolName); }
799
+ recorder.toolExecutionStart(
800
+ (event as any).toolCallId,
801
+ (event as any).toolName,
802
+ );
803
+ });
804
+
805
+ on("tool_call", (event, ctx) => {
806
+ if (event.toolName === "teleport" && ["jump", "back"].includes(String(event.input.action))) {
807
+ movePending = true;
808
+ const file = ctx.sessionManager.getSessionFile();
809
+ const binding = file && readSessionBinding(file);
810
+ if (binding) beginChildSessionMove(binding);
811
+ }
812
+ recorder.toolCall((event as any).toolCallId, (event as any).toolName);
813
+ });
814
+
815
+ on("tool_execution_update", (event) => {
816
+ recorder.toolExecutionUpdate(
817
+ (event as any).toolCallId,
818
+ (event as any).toolName,
819
+ );
820
+ });
821
+
822
+ on("tool_result", (event, ctx) => {
823
+ if (event.toolName === "pi_extension_dev_reload_self" && !event.isError && (event.details as { queued?: boolean } | undefined)?.queued) reloadPending = true;
824
+ if (event.toolName === "teleport" && event.isError) { movePending = false; bindSessionRuntime(pi, ctx); }
825
+ recorder.toolResult((event as any).toolCallId, (event as any).toolName);
826
+ });
827
+
828
+ on("tool_execution_end", async (event) => {
829
+ if (replication) {
830
+ // tool_result handlers can still transform receipts, including nested calls.
831
+ for (const fact of workFacts(event.result)) {
832
+ // Inspection is not a process launch or proof of job ownership.
833
+ const inspection = ["read", "search", "select"].includes(event.toolName);
834
+ if (!event.isError) work.toolResult(fact, inspection, event.toolName, event.toolCallId);
835
+ if (!inspection) await work.trackProcesses(fact);
836
+ }
837
+ work.toolEnded(event.toolCallId);
838
+ persistWork();
839
+ }
840
+ recorder.toolExecutionEnd(
841
+ (event as any).toolCallId,
842
+ (event as any).toolName,
843
+ );
844
+ });
845
+
846
+ on("message_end", (event, ctx) => {
847
+ const message = event.message as { customType?: string; details?: { status?: string } };
848
+ if (message.customType === "pi-reload-self" && message.details?.status === "blocked") reloadPending = false;
849
+ if (message.customType === "pi-agent-teleport-failure") {
850
+ movePending = false;
851
+ bindSessionRuntime(pi, ctx);
852
+ }
853
+ });
854
+ on("session_shutdown", (event) => {
855
+ runtimeActive = false;
856
+ if (stopTimer) clearInterval(stopTimer);
857
+ if (replication && event.reason === "quit" && !movePending) {
858
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
859
+ if (sessionFile && !existsSync(`${sessionFile}.exit`)) {
860
+ writeBindingFile(`${sessionFile}.exit`, { type: "error", errorMessage: "Replica closed without a saved completion." });
861
+ }
862
+ }
863
+ for (const unsubscribe of unsubscribers) unsubscribe();
864
+ lifecycles.delete(pi.events);
865
+ if (lifecycleSession) lifecycleSessions.delete(lifecycleSession);
866
+ recorder.sessionShutdown((event as any).reason);
867
+ });
868
+
869
+ // Toggle expand/collapse with Ctrl+J
870
+ pi.registerShortcut("ctrl+j", {
871
+ description: "Toggle subagent tools widget",
872
+ handler: (ctx) => {
873
+ expanded = !expanded;
874
+ renderWidget(ctx, null);
875
+ },
876
+ });
877
+
878
+ }