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