pi-agent-replicate 0.0.0-stage → 1.4.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 (79) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +147 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +239 -2
  6. package/RELEASING.md +130 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +20 -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/public-subagent-tree-api.md +58 -0
  15. package/docs/settled-lifecycle-contract.md +71 -0
  16. package/docs/worktree-subagents.md +58 -0
  17. package/examples/role-pack/extension.ts +18 -0
  18. package/examples/role-pack/package.json +16 -0
  19. package/examples/role-pack/roles/example-reviewer.md +12 -0
  20. package/index.d.ts +116 -0
  21. package/index.js +1064 -0
  22. package/index.ts +155 -0
  23. package/package.json +70 -4
  24. package/pi-extension/subagents/activity.js +575 -0
  25. package/pi-extension/subagents/activity.ts +980 -0
  26. package/pi-extension/subagents/completion.js +184 -0
  27. package/pi-extension/subagents/completion.ts +229 -0
  28. package/pi-extension/subagents/delivery-drain.js +15 -0
  29. package/pi-extension/subagents/delivery-drain.ts +20 -0
  30. package/pi-extension/subagents/delivery-files.js +86 -0
  31. package/pi-extension/subagents/delivery-files.ts +105 -0
  32. package/pi-extension/subagents/delivery-log.js +51 -0
  33. package/pi-extension/subagents/delivery-log.ts +51 -0
  34. package/pi-extension/subagents/delivery-payload.ts +55 -0
  35. package/pi-extension/subagents/delivery.ts +219 -0
  36. package/pi-extension/subagents/execution-owner.js +30 -0
  37. package/pi-extension/subagents/execution-owner.ts +38 -0
  38. package/pi-extension/subagents/herdr.js +645 -0
  39. package/pi-extension/subagents/herdr.ts +927 -0
  40. package/pi-extension/subagents/index.ts +3400 -0
  41. package/pi-extension/subagents/launch.js +774 -0
  42. package/pi-extension/subagents/launch.ts +1124 -0
  43. package/pi-extension/subagents/lifecycle.js +477 -0
  44. package/pi-extension/subagents/lifecycle.ts +582 -0
  45. package/pi-extension/subagents/lineage.js +806 -0
  46. package/pi-extension/subagents/lineage.ts +841 -0
  47. package/pi-extension/subagents/replica-background.js +32 -0
  48. package/pi-extension/subagents/replica-background.ts +38 -0
  49. package/pi-extension/subagents/replica-branch.ts +45 -0
  50. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  51. package/pi-extension/subagents/replica-history.js +22 -0
  52. package/pi-extension/subagents/replica-history.ts +21 -0
  53. package/pi-extension/subagents/replica-work.js +326 -0
  54. package/pi-extension/subagents/replica-work.ts +336 -0
  55. package/pi-extension/subagents/replica-worker.js +74 -0
  56. package/pi-extension/subagents/replica-worker.ts +71 -0
  57. package/pi-extension/subagents/replication.ts +99 -0
  58. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  59. package/pi-extension/subagents/role-catalog.js +123 -0
  60. package/pi-extension/subagents/role-catalog.ts +24 -0
  61. package/pi-extension/subagents/runtime-routing.js +154 -0
  62. package/pi-extension/subagents/runtime-routing.ts +239 -0
  63. package/pi-extension/subagents/session-binding.js +346 -0
  64. package/pi-extension/subagents/session-binding.ts +339 -0
  65. package/pi-extension/subagents/session-work.js +219 -0
  66. package/pi-extension/subagents/session-work.ts +167 -0
  67. package/pi-extension/subagents/session.js +318 -0
  68. package/pi-extension/subagents/session.ts +448 -0
  69. package/pi-extension/subagents/settled-contract.js +47 -0
  70. package/pi-extension/subagents/settled-contract.ts +175 -0
  71. package/pi-extension/subagents/source-state.js +180 -0
  72. package/pi-extension/subagents/source-state.ts +184 -0
  73. package/pi-extension/subagents/status.ts +493 -0
  74. package/pi-extension/subagents/subagent-done.js +805 -0
  75. package/pi-extension/subagents/subagent-done.ts +878 -0
  76. package/pi-extension/subagents/terminal.js +111 -0
  77. package/pi-extension/subagents/terminal.ts +208 -0
  78. package/pi-extension/subagents/watchdog.js +122 -0
  79. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,3400 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { keyHint } from "@earendil-works/pi-coding-agent";
7
+ import { Type, type Static } from "@sinclair/typebox";
8
+ import {
9
+ Box,
10
+ Text,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import { dirname, join } from "node:path";
15
+ import {
16
+ readFileSync,
17
+ writeFileSync,
18
+ existsSync,
19
+ rmSync,
20
+
21
+ readdirSync,
22
+ } from "node:fs";
23
+ import {
24
+ isTerminalAvailable,
25
+ closePane,
26
+ rememberPaneTab,
27
+ interruptPane,
28
+ readPaneAsync,
29
+ inspectPane,
30
+ getPaneProcessInfo,
31
+ waitForPaneAbsence,
32
+ waitForProcessesExit,
33
+ } from "./terminal.ts";
34
+ import { acknowledgeExitSidecar, acknowledgePingSidecar, waitForCompletion } from "./completion.ts";
35
+ import { materializeResultFile } from "./delivery-files.ts";
36
+ import { frameDeliveryPayload, type DeliveryStatus } from "./delivery-payload.ts";
37
+ import subagentLifecycle, { hasChildLifecycle } from "./subagent-done.ts";
38
+ import { registerReplicateTool, replicationName, replicationAssignment, replicationExtensionArgs, replicationContextMessages } from "./replication.ts";
39
+ import { readReplicaDirectoryMode, registerReplicaDirectoryCommand } from "./replica-directory-settings.ts";
40
+ import { snapshotSource } from "./source-state.ts";
41
+ import { linkedOutputDetails, readExecutionOwner, trackExecutionOwners, type ExecutionOwner } from "./execution-owner.ts";
42
+ import { runReplicaBackground } from "./replica-background.ts";
43
+ import { reserveReplicaBranch } from "./replica-branch.ts";
44
+ import { getHerdrCurrentPaneTitle, readHerdrScreenAsync } from "./herdr.ts";
45
+ import { replicaWorkFile, createReplicaWork, createSharedReplicaWork, readReplicaWork, saveReplicaRuntime, failReplicaLaunch, replicaLaunchFailureReported, captureReplicaWorkLocation, type ReplicaCompletion } from "./replica-work.ts";
46
+ import { bindSessionRuntime, beginChildSessionMove, readSessionBinding, childSessionLocation, parentSessionLocation, sessionBindingStore, sessionOwnsPane, sessionLocationAlive, sessionServerAlive, launchOwnsPane, type LaunchPaneOwnership } from "./session-binding.ts";
47
+
48
+ import { waitForDeliveryDrain } from "./delivery-drain.ts";
49
+ import { createDeliveryLog } from "./delivery-log.ts";
50
+
51
+ import {
52
+ classifyStaleness,
53
+ consumeRecoveryAttempt,
54
+ resolveQuietThreshold,
55
+ shouldStartStaleRecovery,
56
+ type RecoveryCause,
57
+ } from "./watchdog.ts";
58
+ import {
59
+ resolveRuntimePlan,
60
+ wrapPiModelRegistry,
61
+ type ResolvedRuntimePlan,
62
+ type ThinkingLevel,
63
+ } from "./runtime-routing.ts";
64
+ import {
65
+ discoverAgentCatalog,
66
+ resolveDenyTools,
67
+ type AgentDefinition,
68
+ type ListedAgentDefinition,
69
+ } from "./role-catalog.ts";
70
+ type AgentDefaults = AgentDefinition;
71
+ type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
72
+ function getAgentConfigDir(): string { return process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? "", ".pi", "agent"); }
73
+
74
+ import {
75
+ findLastAssistantMessage,
76
+ findObservedSessionRuntime,
77
+ getNewEntries,
78
+ readEntriesAfterBaseline,
79
+ findNewestAppendedAssistant,
80
+
81
+ captureSessionBaseline,
82
+ } from "./session.ts";
83
+ import {
84
+ type SubagentStatusState,
85
+ formatElapsedDuration,
86
+ loadStatusConfig,
87
+ } from "./status.ts";
88
+ import {
89
+ readSubagentActivityFile,
90
+ readSubagentSettledEventsFile,
91
+ orderSettledActivityEvents,
92
+ type ActivityReadResult,
93
+ type SettledActivityEvent,
94
+ type SubagentActivityState,
95
+ } from "./activity.ts";
96
+ import {
97
+ createLifecycle,
98
+ markCompleted,
99
+ markCompletionDetected,
100
+ consumeInterruptBoundary,
101
+ markDelivery,
102
+ markFailed,
103
+ markInterruptRequested,
104
+ markProcessRunning,
105
+ observeActivity,
106
+ observePaneInspection,
107
+ projectLifecycle,
108
+ type LifecycleProjection,
109
+ type SubagentLifecycle,
110
+ type PaneInspection,
111
+ } from "./lifecycle.ts";
112
+ import {
113
+ createResultDeliveryQueue,
114
+ enqueueResultDelivery,
115
+ type ResultDeliveryQueue,
116
+ } from "./result-delivery-queue.ts";
117
+ import {
118
+ type NewestAssistantEntry,
119
+ type SettledDeliveryIdentity,
120
+ type SessionBaselineCursor,
121
+ } from "./settled-contract.ts";
122
+ import {
123
+ appendLineageEvent,
124
+ appendLineageInbox,
125
+ acknowledgeReportedLaunchFailure,
126
+ claimLineageInboxMaterialization,
127
+ completeLineageInboxMaterialization,
128
+ releaseLineageInboxMaterialization,
129
+ hasLineageEvent,
130
+ discoverLineageRoots,
131
+ hasUndrainedDescendants,
132
+ latestDescendantDeliveryAt,
133
+ lineageIntegrityError,
134
+ pendingLineageInboxes,
135
+ reduceLineage,
136
+ type LineageRegistration,
137
+ type LineageNodeState,
138
+ } from "./lineage.ts";
139
+ import {
140
+ captureWorktreeHandoff,
141
+ launchPiSubagent,
142
+ persistWorktreeResult,
143
+ runSubagentScript,
144
+ writeWorktreeManifest,
145
+ buildSubagentToolAllowlist,
146
+ type WorktreeHandoff,
147
+ type WorktreeLaunch,
148
+ } from "./launch.ts";
149
+
150
+
151
+ // Survive /reload: replace presentation timers while keeping active completion
152
+ // watchers and their registry alive. Old module closures continue watching the
153
+ // children; the reloaded module adopts the shared registry for status/interrupts.
154
+ const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
155
+ const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
156
+ const RUNTIME_KEY = Symbol.for("pi-subagents/runtime");
157
+
158
+
159
+
160
+
161
+
162
+ {
163
+ const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
164
+ if (prevInterval) {
165
+ clearInterval(prevInterval);
166
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
167
+ }
168
+ const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
169
+ if (prevStatusInterval) {
170
+ clearInterval(prevStatusInterval);
171
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
172
+ }
173
+ }
174
+
175
+
176
+
177
+
178
+ const SubagentParams = Type.Object({
179
+ name: Type.String({
180
+ description:
181
+ "Short stable label for the subagent; for a new coordinated group use <task>-<role>[-n] (shown in the widget and pane title)",
182
+ }),
183
+ task: Type.String({ description: "Task/prompt for the sub-agent" }),
184
+ agent: Type.Optional(
185
+ Type.String({
186
+ description:
187
+ "Agent name to load defaults from. Discovery precedence is project .pi/agents, global ~/.pi/agent/agents, then installed role packs.",
188
+ }),
189
+ ),
190
+ systemPrompt: Type.Optional(
191
+ Type.String({
192
+ description:
193
+ "Role/system-prompt text for a bare spawn. Named agents keep their definition body.",
194
+ }),
195
+ ),
196
+ skills: Type.Optional(
197
+ Type.String({
198
+ description: "Comma-separated skills (overrides agent default)",
199
+ }),
200
+ ),
201
+ tools: Type.Optional(
202
+ Type.String({
203
+ description: "Comma-separated tools (overrides agent default)",
204
+ }),
205
+ ),
206
+ cwd: Type.Optional(
207
+ Type.String({
208
+ description:
209
+ "Working directory for the sub-agent. Without worktree, the agent starts in this folder. With worktree, this selects the source Git repository and the agent starts at the created worktree root.",
210
+ }),
211
+ ),
212
+ worktree: Type.Optional(
213
+ Type.Object({
214
+ branch: Type.String({
215
+ minLength: 1,
216
+ description:
217
+ "New branch name for an isolated Herdr-managed Git worktree",
218
+ }),
219
+ base: Type.Optional(
220
+ Type.String({
221
+ description:
222
+ "Git revision to branch from. Defaults to the source checkout's committed HEAD.",
223
+ }),
224
+ ),
225
+ }),
226
+ ),
227
+ fork: Type.Optional(
228
+ Type.Boolean({
229
+ description:
230
+ "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.",
231
+ }),
232
+ ),
233
+ interactive: Type.Optional(
234
+ Type.Boolean({
235
+ description:
236
+ "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).",
237
+ }),
238
+ ),
239
+ });
240
+
241
+
242
+
243
+
244
+ function discoverAgentDefinitions(
245
+ pi?: Pick<ExtensionAPI, "events">,
246
+ ): ListedAgentDefinition[] {
247
+ return discoverAgentCatalog(pi).agents;
248
+ }
249
+
250
+
251
+
252
+
253
+
254
+
255
+
256
+ function resolveEffectiveSessionMode(
257
+ params: Static<typeof SubagentParams>,
258
+ agentDefs: AgentDefaults | null,
259
+ ): SubagentSessionMode {
260
+ if (params.fork) return "fork";
261
+ return agentDefs?.sessionMode ?? "standalone";
262
+ }
263
+
264
+ function resolveLaunchBehavior(
265
+ params: Static<typeof SubagentParams>,
266
+ agentDefs: AgentDefaults | null,
267
+ ): {
268
+ sessionMode: SubagentSessionMode;
269
+ seededSessionMode: "lineage-only" | "fork" | null;
270
+ inheritsConversationContext: boolean;
271
+ taskDelivery: "direct" | "artifact";
272
+ } {
273
+ const sessionMode = resolveEffectiveSessionMode(params, agentDefs);
274
+ const inheritsConversationContext = sessionMode === "fork";
275
+ return {
276
+ sessionMode,
277
+ seededSessionMode: sessionMode === "standalone" ? null : sessionMode,
278
+ inheritsConversationContext,
279
+ taskDelivery: inheritsConversationContext ? "direct" : "artifact",
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Decide whether a subagent is interactive (user-driven, long-running).
285
+ *
286
+ * Resolution order:
287
+ * 1. Explicit `interactive` tool parameter wins.
288
+ * 2. Explicit `interactive` frontmatter field on the agent.
289
+ * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are
290
+ * autonomous (scout, worker, reviewer) and the parent session should be
291
+ * woken on stall/recovery transitions. Agents that don't auto-exit are
292
+ * driven by the user in their own pane and stall pings are noise.
293
+ *
294
+ * An internal bare launch has no role defaults and runs autonomously
295
+ * unless the caller explicitly sets `interactive: true`.
296
+ */
297
+ function resolveEffectiveAutoExit(
298
+ params: Static<typeof SubagentParams>,
299
+ agentDefs: AgentDefaults | null,
300
+ ): boolean {
301
+ // Named agents preserve their declared behavior. Internal bare calls are
302
+ // autonomous by default, including full-context forks: `fork` controls
303
+ // context inheritance, not whether the child should remain open.
304
+ // Interactive callers opt out explicitly with `interactive: true`.
305
+ if (agentDefs) return agentDefs.autoExit ?? false;
306
+ return params.interactive !== true;
307
+ }
308
+
309
+ function resolveEffectiveInteractive(
310
+ params: Static<typeof SubagentParams>,
311
+ agentDefs: AgentDefaults | null,
312
+ ): boolean {
313
+ if (params.interactive != null) return params.interactive;
314
+ if (agentDefs?.interactive != null) return agentDefs.interactive;
315
+ return !resolveEffectiveAutoExit(params, agentDefs);
316
+ }
317
+
318
+ function loadAgentDefaults(
319
+ agentName: string,
320
+ pi?: Pick<ExtensionAPI, "events">,
321
+ ): ListedAgentDefinition | null {
322
+ return (
323
+ discoverAgentCatalog(pi).agents.find((agent) => agent.name === agentName) ??
324
+ null
325
+ );
326
+ }
327
+
328
+
329
+ /** Return whether an agent must be prevented from launching its own role. */
330
+ function isSelfSpawnBlocked(
331
+ targetAgent: string | undefined,
332
+ currentAgent: string | undefined,
333
+ currentAgentDefaults: AgentDefaults | null,
334
+ ): boolean {
335
+ return Boolean(
336
+ targetAgent &&
337
+ currentAgent &&
338
+ targetAgent === currentAgent &&
339
+ currentAgentDefaults?.allowSelfSpawn !== true,
340
+ );
341
+ }
342
+
343
+ function formatElapsed(seconds: number): string {
344
+ if (seconds < 60) return `${seconds}s`;
345
+ const m = Math.floor(seconds / 60);
346
+ const s = seconds % 60;
347
+ return `${m}m ${s}s`;
348
+ }
349
+
350
+
351
+
352
+ function shouldRetainSubagentSurface(
353
+ running: Pick<RunningSubagent, "worktree"> | { worktree?: unknown },
354
+ ): boolean {
355
+ return !!running.worktree;
356
+ }
357
+
358
+ function finalizeSubagentSurface(
359
+ running: RunningSubagent,
360
+ state: "ready_for_review" | "failed" | "needs_help",
361
+ ignoreCloseError = false,
362
+ closeOrdinaryPane = true,
363
+ ): WorktreeHandoff | undefined {
364
+ if (running.worktree) {
365
+ let handoff = captureWorktreeHandoff(running.worktree);
366
+ try {
367
+ persistWorktreeResult(running.worktree, state, handoff);
368
+ } catch (error: any) {
369
+ handoff = {
370
+ ...handoff,
371
+ gitError: [
372
+ handoff.gitError,
373
+ `Manifest update failed: ${error?.message ?? String(error)}`,
374
+ ]
375
+ .filter(Boolean)
376
+ .join("; "),
377
+ };
378
+ }
379
+ return handoff;
380
+ }
381
+
382
+ if (!closeOrdinaryPane || running.lineage?.launchKind === "worktree" || !childPaneOwned(running)) return undefined;
383
+ try {
384
+ closePane(running.surface);
385
+ } catch (error) {
386
+ if (!ignoreCloseError) throw error;
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ const statusConfig = loadStatusConfig();
392
+
393
+ const quietThresholdMs = resolveQuietThreshold(
394
+ process.env.PI_SUBAGENT_QUIET_THRESHOLD_MS,
395
+ statusConfig.quietThresholdMs,
396
+ );
397
+
398
+
399
+ function recoveryLogPath(running: Pick<RunningSubagent, "lineage" | "sessionFile">): string {
400
+ return join(running.lineage?.rootDir ?? dirname(running.sessionFile), "subagent-delivery.log");
401
+ }
402
+
403
+ function recoveryDeliveryLog(running: Pick<RunningSubagent, "lineage" | "sessionFile">) {
404
+ return createDeliveryLog({ logPath: recoveryLogPath(running) });
405
+ }
406
+
407
+ const RESULT_CONTINUATION_PROMPT =
408
+ "Parent action: Continue the parent task using this result; do not return an empty response.";
409
+
410
+ function boundResultPresentation(body: string, sessionRef: string): string {
411
+ return body + sessionRef;
412
+ }
413
+
414
+ function formatSessionReference(sessionFile?: string): string {
415
+ return sessionFile
416
+ ? `\n\nSession: ${sessionFile}\nResume: pi --session ${sessionFile}`
417
+ : "";
418
+ }
419
+
420
+ function resolveUnexpectedErrorPresentation(
421
+ prefix: string,
422
+ error: unknown,
423
+ sessionFile?: string,
424
+ ): string {
425
+ const message = error instanceof Error ? error.message : String(error);
426
+ return boundResultPresentation(
427
+ `${prefix}: ${message}`,
428
+ formatSessionReference(sessionFile),
429
+ );
430
+ }
431
+
432
+ function sendSubagentResult(
433
+ api: Pick<ExtensionAPI, "sendMessage">,
434
+ content: string,
435
+ details: Record<string, unknown>,
436
+ ): void {
437
+ const childId = typeof details.childId === "string" ? details.childId : undefined;
438
+ const running = childId ? runningSubagents.get(childId) : undefined;
439
+ // A replay must keep its saved payload, file and owner together.
440
+ const savedDelivery = Object.hasOwn(details, "executionOwner");
441
+ const executionOwner = savedDelivery
442
+ ? readExecutionOwner(details.executionOwner)
443
+ : running?.executionOwner;
444
+ const resultContent = (savedDelivery ? undefined : running?.deliveryPayload) ??
445
+ (typeof details.resultContent === "string"
446
+ ? details.resultContent
447
+ : boundResultPresentation(content, ""));
448
+ if (details.replication && runtime.latestCtx) {
449
+ details = { ...details, receiverSessionId: runtime.latestCtx.sessionManager.getSessionId() };
450
+ }
451
+ const promptContent = details.replication ? resultContent : boundResultPresentation(
452
+ `${resultContent}\n\n${RESULT_CONTINUATION_PROMPT}`,
453
+ "",
454
+ );
455
+ api.sendMessage(
456
+ {
457
+ customType: "subagent_result",
458
+ content: promptContent,
459
+ display: true,
460
+ details: {
461
+ ...details,
462
+ ...linkedOutputDetails(executionOwner),
463
+ resultContent,
464
+ ...(!savedDelivery && running?.resultPath ? { resultPath: running.resultPath } : {}),
465
+ },
466
+ },
467
+ { triggerTurn: true, deliverAs: "steer" },
468
+ );
469
+ }
470
+
471
+ function formatWorktreeHandoff(worktree: WorktreeHandoff): string {
472
+ const state = worktree.gitError
473
+ ? "inspection unknown"
474
+ : worktree.conflicted
475
+ ? "conflicted"
476
+ : worktree.clean
477
+ ? "clean"
478
+ : "dirty";
479
+ const ahead =
480
+ worktree.commitsAhead == null
481
+ ? "commits ahead unknown"
482
+ : `${worktree.commitsAhead} commit${worktree.commitsAhead === 1 ? "" : "s"} ahead`;
483
+ const lines = [
484
+ "Worktree result retained for review:",
485
+ `Worktree: ${worktree.path}`,
486
+ `Workspace: ${worktree.workspaceId}`,
487
+ `Branch: ${worktree.branch}`,
488
+ `Base/head: ${worktree.baseSha} -> ${worktree.headSha ?? "unknown"}`,
489
+ `State: ${state} · ${ahead}`,
490
+ ];
491
+ if (worktree.changedFiles?.length)
492
+ lines.push(`Changed: ${worktree.changedFiles.join(", ")}`);
493
+ if (worktree.untrackedFiles?.length)
494
+ lines.push(`Untracked: ${worktree.untrackedFiles.join(", ")}`);
495
+ if (worktree.gitError)
496
+ lines.push(`Git inspection warning: ${worktree.gitError}`);
497
+ lines.push(
498
+ "After review and preservation, remove the workspace with:",
499
+ ` herdr worktree remove --workspace ${worktree.workspaceId}`,
500
+ );
501
+ return lines.join("\n");
502
+ }
503
+
504
+ function resolveResultPresentation(
505
+ result: Pick<
506
+ SubagentResult,
507
+ | "exitCode"
508
+ | "elapsed"
509
+ | "summary"
510
+ | "sessionFile"
511
+ | "errorMessage"
512
+ | "fallbackAttempts"
513
+ | "worktree"
514
+ | "resultPath"
515
+ >,
516
+ name: string,
517
+ runtimeMismatch?: string,
518
+ ): string {
519
+ const resultRef = result.resultPath ? `\n\nResult file: ${result.resultPath}` : "";
520
+ const sessionRef = resultRef + formatSessionReference(result.sessionFile);
521
+ let body: string;
522
+
523
+ if (result.errorMessage) {
524
+ // Auto-retry exhausted or other agent-loop error. The subagent did not
525
+ // produce a usable result — surface the underlying provider/network
526
+ // failure so the orchestrator can decide whether to retry, resume, or
527
+ // change approach instead of silently treating the run as completed.
528
+ body =
529
+ `Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` +
530
+ `(provider/agent error — auto-retry exhausted).\n\n` +
531
+ `Error: ${result.errorMessage}\n\n` +
532
+ `The subagent did not produce a result. You can retry by spawning a new ` +
533
+ `subagent or resume the session with subagent_resume.`;
534
+ } else {
535
+ body =
536
+ result.exitCode === 0
537
+ ? `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`
538
+ : `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}`;
539
+ }
540
+
541
+ if (result.fallbackAttempts && result.fallbackAttempts.length > 1) {
542
+ body += `\n\nModels attempted: ${result.fallbackAttempts.join(", ")}`;
543
+ }
544
+ if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
545
+ const runtimeWarning = runtimeMismatch
546
+ ? `\n\nRuntime warning: ${runtimeMismatch}`
547
+ : "";
548
+ return boundResultPresentation(body, sessionRef + runtimeWarning);
549
+ }
550
+
551
+ /**
552
+ * Result from running a single subagent.
553
+ */
554
+ interface SubagentResult {
555
+ name: string;
556
+ task: string;
557
+ summary: string;
558
+ sessionFile?: string;
559
+
560
+ resultPath?: string;
561
+
562
+ supervisionEpoch?: number;
563
+ recoveryFailed?: boolean;
564
+
565
+ /** The user closed the owned Herdr tab, so recovery is forbidden. */
566
+ cancelledByUser?: boolean;
567
+
568
+ closeWithAnswer?: boolean;
569
+ exitCode: number;
570
+ elapsed: number;
571
+ error?: string;
572
+ /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
573
+ errorMessage?: string;
574
+ /** Ordered models launched for this run, including failed fallback attempts. */
575
+ fallbackAttempts?: string[];
576
+ ping?: { name: string; message: string };
577
+ worktree?: WorktreeHandoff;
578
+ /** Confirmed manual or external Herdr pane closure. */
579
+ paneDisappeared?: true;
580
+ }
581
+
582
+ interface PendingTerminalDelivery {
583
+ /** Its exact native tool error already reached the parent session. */
584
+ alreadyReported?: boolean;
585
+ finalAssistant?: SettledDeliveryIdentity;
586
+ resultContent?: string;
587
+
588
+ status?: DeliveryStatus;
589
+ queued: boolean;
590
+ finalize: () => void | Promise<void>;
591
+ }
592
+
593
+ /**
594
+ * State for a launched (but not yet completed) subagent.
595
+ */
596
+ interface RunningSubagent {
597
+ executionOwner?: ExecutionOwner;
598
+ /** Current-session replica, not a named-role launch. */
599
+ replication?: boolean;
600
+ replicaWorkFile?: string;
601
+ launchPane?: LaunchPaneOwnership;
602
+ launchError?: string;
603
+ /** Settled task waiting for its exact parent before integration. */
604
+ pendingReplicaResult?: ReplicaCompletion;
605
+ /** In-flight background work must not be resubmitted by status ticks or reload. */
606
+ replicaFinishing?: boolean;
607
+ replicaRetryAt?: number;
608
+ replicaCleaning?: boolean;
609
+ lineage?: LineageRegistration;
610
+ id: string;
611
+ name: string;
612
+ task: string;
613
+ agent?: string;
614
+ surface: string;
615
+ startTime: number;
616
+ tabId?: string;
617
+ sessionFile: string;
618
+ launchScriptFile?: string;
619
+ activityFile?: string;
620
+ settledEventsFile?: string;
621
+ activity?: SubagentActivityState;
622
+ sessionBaseline?: SessionBaselineCursor;
623
+ activityRead?: {
624
+ ok: boolean;
625
+ reason?: "missing" | "invalid" | "wrong-id";
626
+ error?: string;
627
+ };
628
+ abortController?: AbortController;
629
+ /**
630
+ * Optional legacy status snapshot retained only for hydrating pre-lifecycle
631
+ * runtime entries after /reload. Live observation uses `lifecycle` only.
632
+ */
633
+ statusState?: SubagentStatusState;
634
+ lifecycle: SubagentLifecycle;
635
+ /** Last projected kind used to detect stalled/recovered transitions. */
636
+ lastProjectedKind?: LifecycleProjection["kind"];
637
+ /** User-driven sessions are observed but never killed or resumed by the watchdog. */
638
+ interactive: boolean;
639
+ /** Parent-resolved model/thinking selection and provenance. */
640
+ runtimePlan: ResolvedRuntimePlan | undefined;
641
+ /** Pending terminal parent enqueue; retained until sendMessage succeeds. */
642
+ pendingTerminalDelivery?: PendingTerminalDelivery;
643
+
644
+ /** Latest attributable durable delivery failure shown in the widget. */
645
+ deliveryFailure?: { deliveryId: string; error: string };
646
+
647
+ resultPath?: string;
648
+ deliveryPayload?: string;
649
+
650
+ supervisionEpoch?: number;
651
+ recoveryAttempt?: Promise<void>;
652
+
653
+ ignorePriorSentinelEpoch?: number;
654
+ recoveryState?: { cause: RecoveryCause; attempt: number; label: string };
655
+ recoveryFailureReason?: string;
656
+ /** The recovery budget is spent; the watchdog must not recover this child again. */
657
+ recoveryExhausted?: boolean;
658
+ cleanupPending?: boolean;
659
+ cleanupAttempts?: number;
660
+ cleanupRetryAt?: number;
661
+ /** Delivered and finished: the row leaves the widget while the pane is cleaned up. */
662
+ retired?: boolean;
663
+ cancellationAttempt?: Promise<boolean>;
664
+ worktree?: WorktreeLaunch;
665
+
666
+ recoveryWorkspace?: { id: string; cwd: string };
667
+ }
668
+
669
+ /**
670
+ * One result this process already queued for the parent but has not seen the
671
+ * parent session persist yet. The durable claim stays ours while the copy is
672
+ * only queued, so no observer can queue a second copy of the same result.
673
+ */
674
+ interface InflightDelivery {
675
+ rootDir: string;
676
+ nodeId: string;
677
+ token: string;
678
+ }
679
+
680
+ interface SubagentRuntime {
681
+ resolveExecutionOwner?: (id: string) => ExecutionOwner | undefined;
682
+ runningSubagents: Map<string, RunningSubagent>;
683
+ resultDeliveryQueue: ResultDeliveryQueue;
684
+ inflightDeliveries: Map<string, InflightDelivery>;
685
+ pi?: ExtensionAPI;
686
+ latestCtx?: ExtensionContext;
687
+ /** Do not queue into a source session after Teleport takes its snapshot. */
688
+ handoffPending?: boolean;
689
+ }
690
+
691
+ function createSubagentRuntime(): SubagentRuntime {
692
+ return {
693
+ runningSubagents: new Map<string, RunningSubagent>(),
694
+ resultDeliveryQueue: createResultDeliveryQueue(),
695
+ inflightDeliveries: new Map<string, InflightDelivery>(),
696
+ };
697
+ }
698
+
699
+ /** Runtime state preserved across /reload. */
700
+ const runtime: SubagentRuntime =
701
+ (globalThis as any)[RUNTIME_KEY] ??
702
+ ((globalThis as any)[RUNTIME_KEY] = createSubagentRuntime());
703
+ if (!runtime.resultDeliveryQueue) {
704
+ runtime.resultDeliveryQueue = createResultDeliveryQueue();
705
+ }
706
+ if (!runtime.inflightDeliveries) {
707
+ runtime.inflightDeliveries = new Map<string, InflightDelivery>();
708
+ }
709
+ const runningSubagents = runtime.runningSubagents;
710
+
711
+
712
+ function recordDeliveryFailure(
713
+ running: RunningSubagent,
714
+ deliveryId: string,
715
+ error: unknown,
716
+ phase: string,
717
+ ): void {
718
+ const message = error instanceof Error ? error.message : String(error);
719
+ createDeliveryLog({ logPath: join(dirname(running.sessionFile), "subagent-delivery.log") }).record(
720
+ "delivery-failed",
721
+ {
722
+ childId: running.id,
723
+ deliveryId,
724
+ phase,
725
+ error: message,
726
+ },
727
+ );
728
+ running.deliveryFailure = { deliveryId, error: message };
729
+ updateWidget();
730
+ }
731
+
732
+
733
+ /**
734
+ * Publish the durable acknowledgement for one restored inbox and drop its
735
+ * in-process record. A copy that was queued for the parent also waits until the
736
+ * parent session holds it; until then the claim stays ours, because the copy is
737
+ * only queued inside Pi and nobody may queue a second one for the same result.
738
+ */
739
+ function acknowledgeRestoredInboxDelivery(input: {
740
+ rootDir: string;
741
+ nodeId: string;
742
+ deliveryId: string;
743
+ token: string;
744
+ sessionFile: string;
745
+ /** Set for a sent copy: the acknowledgement waits for the parent record. */
746
+ requiresParentRecord?: boolean;
747
+ settledActivitySequence?: number;
748
+ }): void {
749
+ if (input.requiresParentRecord && !sessionContainsDelivery(input.sessionFile, input.deliveryId)) return;
750
+ if (!completeLineageInboxMaterialization(input.rootDir, input.deliveryId, input.nodeId, input.token)) return;
751
+ runtime.inflightDeliveries.delete(input.deliveryId);
752
+ if (input.settledActivitySequence !== undefined) {
753
+ appendLineageEvent(
754
+ input.rootDir,
755
+ `settled-delivered:${input.nodeId}:${input.settledActivitySequence}`,
756
+ "settled_delivered",
757
+ input.nodeId,
758
+ { resultId: input.deliveryId, activitySequence: input.settledActivitySequence },
759
+ );
760
+ }
761
+ }
762
+
763
+ /** Materialize inbox records left by a crash into this exact session once. */
764
+ async function restorePendingLineageInboxes(ctx: ExtensionContext): Promise<void> {
765
+ if (runtime.handoffPending) return;
766
+ const manager = ctx.sessionManager;
767
+ if (
768
+ !manager ||
769
+ typeof manager.getSessionId !== "function" ||
770
+ typeof manager.getSessionFile !== "function" ||
771
+ typeof manager.getSessionDir !== "function"
772
+ ) return;
773
+ const sessionId = manager.getSessionId();
774
+ const sessionFile = manager.getSessionFile();
775
+ if (!sessionFile) return;
776
+ if (!runtime.pi) return;
777
+ const roots = discoverLineageRoots(manager.getSessionDir(), sessionFile);
778
+ for (const rootDir of roots) {
779
+ for (const inbox of pendingLineageInboxes(rootDir, sessionId, sessionFile)) {
780
+ const resultContent = inbox.payload.resultContent;
781
+ if (typeof resultContent !== "string") continue;
782
+ const kind = inbox.payload.kind ?? "terminal";
783
+ const activitySequence = typeof inbox.payload.activitySequence === "number"
784
+ ? inbox.payload.activitySequence
785
+ : undefined;
786
+
787
+ // This process already queued this exact result. The parent has not
788
+ // persisted it yet, so just finish the record when it does. Sending
789
+ // again here is what delivered one result many times.
790
+ const inflight = runtime.inflightDeliveries.get(inbox.deliveryId);
791
+ if (inflight) {
792
+ acknowledgeRestoredInboxDelivery({
793
+ rootDir,
794
+ nodeId: inbox.nodeId,
795
+ deliveryId: inbox.deliveryId,
796
+ token: inflight.token,
797
+ sessionFile,
798
+ requiresParentRecord: true,
799
+ settledActivitySequence: activitySequence,
800
+ });
801
+ continue;
802
+ }
803
+
804
+ const claim = claimLineageInboxMaterialization({
805
+ rootDir,
806
+ deliveryId: inbox.deliveryId,
807
+ nodeId: inbox.nodeId,
808
+ hasExactSessionEvidence: () => sessionContainsDelivery(sessionFile, inbox.deliveryId),
809
+ });
810
+ // Another observer owns this delivery, or it is already acknowledged.
811
+ if (claim.status !== "acquired") continue;
812
+
813
+ // Intermediate settled turns are acknowledged without being sent.
814
+ if (kind === "settled") {
815
+ acknowledgeRestoredInboxDelivery({
816
+ rootDir,
817
+ nodeId: inbox.nodeId,
818
+ deliveryId: inbox.deliveryId,
819
+ token: claim.token,
820
+ sessionFile,
821
+ settledActivitySequence: activitySequence,
822
+ });
823
+ continue;
824
+ }
825
+
826
+ const childSessionFile = childSessionLocation(rootDir, inbox.nodeId)?.sessionFile ?? reduceLineage(rootDir).nodes.get(inbox.nodeId)?.sessionFile ?? inbox.sessionFile;
827
+ try {
828
+ sendSubagentResult(runtime.pi, resultContent, {
829
+ ...inbox.payload,
830
+ executionOwner: readExecutionOwner(inbox.payload.executionOwner),
831
+ kind,
832
+ childId: inbox.nodeId,
833
+ sessionFile: childSessionFile,
834
+ deliveryId: inbox.deliveryId,
835
+ resultContent,
836
+ });
837
+ } catch {
838
+ // Nothing was queued, so the claim stays retryable.
839
+ releaseLineageInboxMaterialization({
840
+ rootDir,
841
+ deliveryId: inbox.deliveryId,
842
+ nodeId: inbox.nodeId,
843
+ token: claim.token,
844
+ hasExactSessionEvidence: () => sessionContainsDelivery(sessionFile, inbox.deliveryId),
845
+ });
846
+ continue;
847
+ }
848
+ runtime.inflightDeliveries.set(inbox.deliveryId, { rootDir, nodeId: inbox.nodeId, token: claim.token });
849
+ acknowledgeRestoredInboxDelivery({
850
+ rootDir,
851
+ nodeId: inbox.nodeId,
852
+ deliveryId: inbox.deliveryId,
853
+ token: claim.token,
854
+ sessionFile,
855
+ requiresParentRecord: true,
856
+ settledActivitySequence: activitySequence,
857
+ });
858
+ }
859
+ }
860
+ }
861
+ /** Save task facts and integration outcome before delivery and owned-resource cleanup. */
862
+ function replicaRecordFor(running: RunningSubagent): string | undefined {
863
+ return running.replicaWorkFile ?? readSessionBinding(running.sessionFile)?.child?.environment.PI_SUBAGENT_REPLICA_WORK_FILE;
864
+ }
865
+ function finishReplicationResult(running: RunningSubagent, result: SubagentResult): void {
866
+ if (running.pendingTerminalDelivery || running.replicaFinishing || (running.replicaRetryAt ?? 0) > Date.now()) return;
867
+ const failed = !!running.launchError || result.exitCode !== 0 || !!result.errorMessage || !!result.error || !result.summary?.trim();
868
+ const completion: ReplicaCompletion = {
869
+ name: running.name, task: running.task, summary: result.summary ?? "", exitCode: failed ? 1 : 0, elapsed: result.elapsed,
870
+ ...(failed ? { errorMessage: running.launchError ?? result.errorMessage ?? result.error ?? "Replica failed." } : {}),
871
+ };
872
+ running.pendingReplicaResult = completion;
873
+ running.replicaFinishing = true;
874
+ void completeReplicaInBackground(running, completion).catch((error) => {
875
+ recordDeliveryFailure(running, `terminal:${running.id}`, error, "replica-completion");
876
+ running.replicaRetryAt = Date.now() + 2_000;
877
+ }).finally(() => { running.replicaFinishing = false; });
878
+ }
879
+ /** Retire resource work without sending a second result over an already saved tool error. */
880
+ function acknowledgeFailedLaunch(running: RunningSubagent, error: string): void {
881
+ queueTerminalDelivery(running, undefined, () => {}, error, "error", true);
882
+ }
883
+
884
+ /** An ambiguous dispatch error cannot settle a process that actually started. */
885
+ function launchFailureProcessActive(running: RunningSubagent): boolean {
886
+ const location = running.lineage && childSessionLocation(running.lineage.rootDir, running.id);
887
+ if (location && sessionLocationAlive(location)) return true;
888
+ if (!running.surface) return false;
889
+ try {
890
+ const info = getPaneProcessInfo(running.surface);
891
+ const owned = location ? sessionOwnsPane(location, running.surface, info.shellPid)
892
+ : running.launchPane ? launchOwnsPane(running.launchPane, running.surface, info.shellPid) : true;
893
+ return !owned || info.pids.some(pid => pid !== info.shellPid);
894
+ } catch (error) {
895
+ return !/pane_not_found|pane.*not found/i.test(String(error));
896
+ } // Unknown failures still leave pane/process loss to the ordinary observer.
897
+ }
898
+ async function completeReplicaInBackground(running: RunningSubagent, completion: ReplicaCompletion): Promise<void> {
899
+ const recordFile = replicaRecordFor(running);
900
+ if (!recordFile) throw new Error("Replica has no durable work record.");
901
+ const location = running.lineage && childSessionLocation(running.lineage.rootDir, running.id);
902
+ const ctx = exactParentSessionActive(running) ? runtime.latestCtx : undefined;
903
+ const sessionFile = ctx?.sessionManager.getSessionFile();
904
+ const parent = sessionFile ? parentSessionLocation(sessionBindingStore(sessionFile), ctx!.sessionManager.getSessionId()) : undefined;
905
+ const { work } = await runReplicaBackground({
906
+ operation: "complete", file: recordFile, cwd: location?.cwd ?? running.worktree?.path ?? readReplicaWork(recordFile).sharedCwd!,
907
+ sessionFile: running.sessionFile, completion, parent,
908
+ });
909
+ if (!work?.completion) throw new Error("Replica worker returned no saved completion.");
910
+ running.pendingReplicaResult = work.completion;
911
+ if (work.integration.status === "pending" || !exactParentSessionActive(running)) return;
912
+ const saved = work.completion;
913
+ if (work.launchError && ctx && replicaLaunchFailureReported(work, ctx.sessionManager.getBranch(), running.executionOwner)) {
914
+ running.pendingReplicaResult = undefined;
915
+ acknowledgeFailedLaunch(running, work.launchError);
916
+ return;
917
+ }
918
+ const failed = saved.exitCode !== 0 || !!saved.error || !!saved.errorMessage || !saved.summary.trim();
919
+ const history = saved.historyPath
920
+ ? `Textual answer history: ${saved.historyPath}`
921
+ : `Textual answer history unavailable: ${saved.historyError ?? "No saved history."}`;
922
+ let summary = failed ? [`Replica failed: ${saved.errorMessage ?? saved.error ?? "Replica failed."}`, saved.summary.trim(), history].filter(Boolean).join("\n\n") : saved.summary;
923
+ const report = work.mode === "shared" ? (failed ? "The copy worked in the shared directory; partial changes may already be present." : "")
924
+ : work.integration.status === "applied" ? ""
925
+ : work.integration.status === "conflict"
926
+ ? "The copy finished, but its changes conflict with your current work and were not included."
927
+ : work.integration.status === "not-attempted"
928
+ ? "The copy's unfinished work was not included in your work."
929
+ : "The copy finished, but its changes could not be safely included in your work.";
930
+ if (report) summary += `\n\n${report}`;
931
+ running.pendingReplicaResult = undefined;
932
+ queueTerminalDelivery(running, terminalAssistantIdentityFor(running), () => {
933
+ if (runtime.pi) sendSubagentResult(runtime.pi, summary, {
934
+ name: running.name, task: running.task, childId: running.id, sessionFile: work.sessionCheckpoint ?? running.sessionFile,
935
+ deliveryId: `terminal:${running.id}`, resultContent: summary,
936
+ exitCode: saved.exitCode, elapsed: saved.elapsed, replication: true,
937
+ ...(failed ? { errorMessage: saved.errorMessage, historyPath: saved.historyPath, historyError: saved.historyError } : {}),
938
+ worktreePath: work.worktree?.path, checkpoint: work.checkpoint?.commit,
939
+ integration: work.integration,
940
+ });
941
+ }, summary, failed ? "error" : "completed");
942
+ }
943
+ /** Rebuild direct-child watchers from durable launch metadata after a process restart. */
944
+ function startRecoveredWatcher(running: RunningSubagent): void {
945
+ const controller = new AbortController();
946
+ running.abortController = controller;
947
+ void watchSubagent(running, controller.signal).then((result) => {
948
+ if (result.supervisionEpoch !== (running.supervisionEpoch ?? 0)) return;
949
+ if (running.replication) { finishReplicationResult(running, result); return; }
950
+ if (result.ping) {
951
+ queueTerminalDelivery(running, undefined, () => {
952
+ if (runtime.pi) runtime.pi.sendMessage({ customType: "subagent_ping", content: result.ping?.message ?? "Recovered subagent needs help.", display: true, details: { ...linkedOutputDetails(running.executionOwner), name: running.name, childId: running.id, sessionFile: running.sessionFile } }, { triggerTurn: true, deliverAs: "steer" });
953
+ }, result.ping.message);
954
+ return;
955
+ }
956
+ const summary = result.summary ?? result.errorMessage ?? "Recovered subagent completed.";
957
+ const presentation = resolveResultPresentation({ ...result, summary, sessionFile: running.sessionFile }, running.name, running.runtimePlan?.runtimeMismatch);
958
+ queueTerminalDelivery(running, terminalAssistantIdentityFor(running), () => {
959
+ if (runtime.pi) sendSubagentResult(runtime.pi, presentation, { name: running.name, task: running.task, childId: running.id, ...(result.closeWithAnswer ? {} : { exitCode: result.exitCode }), sessionFile: running.sessionFile, deliveryId: `terminal:${running.id}`, resultContent: summary });
960
+ }, summary, result.cancelledByUser ? "cancelled" : result.recoveryFailed ? "recovery-failed" : result.closeWithAnswer ? "closed" : undefined);
961
+ }).catch((error) => {
962
+ recordDeliveryFailure(running, `terminal:${running.id}`, error, "recovered-watcher");
963
+ });
964
+ }
965
+
966
+ /** Rebuild direct-child watchers from durable launch metadata after a process restart. */
967
+ async function startRestoredSubagentWatcher(running: RunningSubagent): Promise<void> {
968
+ startRecoveredWatcher(running);
969
+ }
970
+
971
+ function restoreLineageRuntime(ctx: ExtensionContext): void {
972
+ const manager = ctx.sessionManager;
973
+ if (!manager || typeof manager.getSessionId !== "function" || typeof manager.getSessionFile !== "function" || typeof manager.getSessionDir !== "function") return;
974
+ const sessionId = manager.getSessionId();
975
+ const sessionFile = manager.getSessionFile();
976
+ if (!sessionFile) return;
977
+ const roots = discoverLineageRoots(manager.getSessionDir(), sessionFile);
978
+ try {
979
+ for (const run of readdirSync(join(ctx.cwd, ".pi", "plans"), { withFileTypes: true })) {
980
+ if (!run.isDirectory()) continue;
981
+ for (const lineage of readdirSync(join(ctx.cwd, ".pi", "plans", run.name, "lineage"), { withFileTypes: true })) {
982
+ if (lineage.isDirectory()) roots.push(join(ctx.cwd, ".pi", "plans", run.name, "lineage", lineage.name));
983
+ }
984
+ }
985
+ } catch {}
986
+ for (const rootDir of new Set(roots)) {
987
+ const state = reduceLineage(rootDir);
988
+ const integrity = lineageIntegrityError(state);
989
+ if (integrity) throw new Error(integrity);
990
+ for (const node of state.nodes.values()) {
991
+ const cancellationPending = !!node.cancellation?.intent && !node.terminal;
992
+ if (node.parentSessionId !== sessionId) continue;
993
+ const earlyWork = node.replication && node.replicaWorkFile ? readReplicaWork(node.replicaWorkFile) : undefined;
994
+ const failedLaunch = !!earlyWork?.launchError;
995
+ if (!failedLaunch && (!node.sessionFile || !node.surface)) continue;
996
+ if (!failedLaunch && !cancellationPending && (!node.activityFile || !node.settledEventsFile)) continue;
997
+ const recoveredSession = node.sessionFile ?? earlyWork?.sessionFile ?? "";
998
+ const recoveredWorktree = node.worktree ?? earlyWork?.worktree;
999
+ const drained = node.terminal && node.terminalDelivered;
1000
+ if (drained && !node.cleanupPending) continue;
1001
+ if (runningSubagents.has(node.nodeId)) continue;
1002
+ let baseline: SessionBaselineCursor;
1003
+ try { baseline = captureSessionBaseline(recoveredSession); } catch { baseline = { sessionFile: recoveredSession, entryCount: 0, leafId: null, assistantEntryIds: [] }; }
1004
+ const lineage: LineageRegistration = {
1005
+ rootDir,
1006
+ rootId: state.rootId,
1007
+ nodeId: node.nodeId,
1008
+ ...(node.parentNodeId ? { parentNodeId: node.parentNodeId } : {}),
1009
+ ...(node.parentSessionId ? { parentSessionId: node.parentSessionId } : {}),
1010
+ ...(node.parentSessionFile ? { parentSessionFile: node.parentSessionFile } : {}),
1011
+ launchKind: (node.launchKind as LineageRegistration["launchKind"]) ?? "fresh",
1012
+ };
1013
+ const running: RunningSubagent = {
1014
+ lineage,
1015
+ executionOwner: node.executionOwner,
1016
+ id: node.nodeId,
1017
+ name: node.name ?? node.nodeId,
1018
+ task: node.task ?? "recovered subagent",
1019
+ agent: node.agent,
1020
+ surface: node.surface ?? recoveredWorktree?.paneId ?? "",
1021
+ startTime: node.startTime ?? Date.now(),
1022
+ ...(node.tabId ? { tabId: node.tabId } : {}),
1023
+ sessionFile: recoveredSession,
1024
+ activityFile: node.activityFile,
1025
+ settledEventsFile: node.settledEventsFile,
1026
+ sessionBaseline: baseline,
1027
+ interactive: node.interactive ?? false,
1028
+ runtimePlan: undefined,
1029
+ ...(node.workspaceId && node.cwd
1030
+ ? { recoveryWorkspace: { id: node.workspaceId, cwd: node.cwd } }
1031
+ : {}),
1032
+ ...(recoveredWorktree ? { worktree: recoveredWorktree } : {}),
1033
+ ...(node.replication ? { replication: true, replicaWorkFile: node.replicaWorkFile } : {}),
1034
+ lifecycle: createLifecycle(node.startTime ?? Date.now()),
1035
+ cleanupPending: !!node.cleanupPending,
1036
+ // A drained node is finished: its result was delivered in an earlier
1037
+ // process, so it must not take a widget row while its pane is closed.
1038
+ ...(drained ? { retired: true } : {}),
1039
+ };
1040
+ runningSubagents.set(running.id, running);
1041
+ refreshChildLocation(running);
1042
+ if (running.lineage) running.lineage.parentSessionFile = sessionFile;
1043
+ if (running.tabId) rememberPaneTab(running.surface, running.tabId);
1044
+ const recordFile = running.replication && replicaRecordFor(running);
1045
+ const savedWork = earlyWork ?? (recordFile ? readReplicaWork(recordFile) : undefined);
1046
+ running.launchPane = savedWork?.launchPane;
1047
+ running.launchError = savedWork?.launchError;
1048
+ const savedReplica = savedWork?.completion;
1049
+ if (savedWork?.launchError && !savedReplica && !drained && !launchFailureProcessActive(running)) {
1050
+ const reported = replicaLaunchFailureReported(savedWork, manager.getBranch(), node.executionOwner);
1051
+ if (reported) {
1052
+ acknowledgeFailedLaunch(running, savedWork.launchError);
1053
+ continue;
1054
+ } else if (!running.worktree && savedWork.mode !== "shared") {
1055
+ const summary = `Replica failed during launch: ${savedWork.launchError}`;
1056
+ queueTerminalDelivery(running, undefined, () => {
1057
+ if (runtime.pi) sendSubagentResult(runtime.pi, summary, {
1058
+ name: running.name, childId: running.id, replication: true, exitCode: 1,
1059
+ errorMessage: savedWork.launchError, resultContent: summary,
1060
+ deliveryId: `terminal:${running.id}`, integration: { status: "not-attempted" },
1061
+ });
1062
+ }, summary, "error");
1063
+ continue;
1064
+ } else {
1065
+ finishReplicationResult(running, { name: running.name, task: running.task, summary: "", exitCode: 1,
1066
+ elapsed: Math.floor((Date.now() - running.startTime) / 1000), errorMessage: savedWork.launchError });
1067
+ continue;
1068
+ }
1069
+ }
1070
+ if (savedReplica && !drained) {
1071
+ finishReplicationResult(running, savedReplica);
1072
+ } else if (cancellationPending) {
1073
+ void attemptPendingCancellation(running);
1074
+ } else if (node.terminal && !node.terminalDelivered) {
1075
+ queueTerminalDelivery(running, undefined, () => {
1076
+ if (runtime.pi && runtime.latestCtx?.sessionManager.getSessionId() === sessionId && runtime.latestCtx.sessionManager.getSessionFile() === sessionFile) {
1077
+ sendSubagentResult(runtime.pi, node.terminal?.resultContent ?? "Recovered subagent terminal result.", { name: running.name, childId: running.id, sessionFile: running.sessionFile, resultContent: node.terminal?.resultContent ?? "Recovered subagent terminal result." });
1078
+ }
1079
+ }, node.terminal.resultContent);
1080
+ } else if (!drained) {
1081
+ void startRestoredSubagentWatcher(running).catch((error) => {
1082
+ recordDeliveryFailure(running, `recovery:${running.id}`, error, "startup-recovery");
1083
+ });
1084
+ } else {
1085
+ // Cleanup-pending rows are retried after restart without redelivering.
1086
+ if (tryCleanupSubagentSurface(running)) runningSubagents.delete(running.id);
1087
+ }
1088
+ }
1089
+ }
1090
+ if (runningSubagents.size > 0 && runtime.pi) {
1091
+ startWidgetRefresh();
1092
+ startStatusRefresh(runtime.pi);
1093
+ updateWidget();
1094
+ }
1095
+ }
1096
+
1097
+ export function shouldPreserveSubagentsOnShutdown(reason: unknown): boolean {
1098
+ // A deliberate quit (and an unknown shutdown reason) must not turn durable
1099
+ // descendants into cancellations. Explicit cancellation owns its own gate.
1100
+ return (
1101
+ reason == null ||
1102
+ reason === "reload" ||
1103
+ reason === "new" ||
1104
+ reason === "resume" ||
1105
+ reason === "fork" ||
1106
+ reason === "quit"
1107
+ );
1108
+ }
1109
+
1110
+ export function cleanupSubagentsForShutdown(
1111
+ reason: unknown,
1112
+ agents: Map<string, Pick<RunningSubagent, "abortController" | "lifecycle">>,
1113
+ ): void {
1114
+ if (shouldPreserveSubagentsOnShutdown(reason)) return;
1115
+
1116
+ for (const agent of agents.values()) {
1117
+ if (agent.lifecycle) {
1118
+ agent.lifecycle = markDelivery(agent.lifecycle, "suppressed");
1119
+ }
1120
+ agent.abortController?.abort();
1121
+ }
1122
+ agents.clear();
1123
+ }
1124
+
1125
+ export function shouldDeliverSubagentCompletion(
1126
+ running: Pick<RunningSubagent, "lifecycle">,
1127
+ ): boolean {
1128
+ // Authoritative gate: only pending deliveries may be sent.
1129
+ // Missing lifecycle (pre-migration fixtures) defaults to pending/true.
1130
+ return (running.lifecycle?.delivery ?? "pending") === "pending";
1131
+ }
1132
+
1133
+ export function selectCompletionApi<T>(previous: T, current: T | undefined): T {
1134
+ return current ?? previous;
1135
+ }
1136
+
1137
+ // ── Widget management ──
1138
+
1139
+ // Native tool errors become durable before agent_end; reconcile failed launch nodes then.
1140
+ let launchFailurePending = false;
1141
+
1142
+ /** Interval timer for widget re-renders. */
1143
+ let widgetInterval: ReturnType<typeof setInterval> | null = null;
1144
+
1145
+ /** Interval timer for status transition checks. */
1146
+ let statusInterval: ReturnType<typeof setInterval> | null = null;
1147
+
1148
+ function formatElapsedMMSS(startTime: number, endTime = Date.now()): string {
1149
+ const seconds = Math.floor((endTime - startTime) / 1000);
1150
+ const m = Math.floor(seconds / 60);
1151
+ const s = seconds % 60;
1152
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1153
+ }
1154
+
1155
+ const ACTIVE_ACCENT = "\x1b[38;2;77;163;255m";
1156
+ const OPEN_ACCENT = "\x1b[38;2;214;158;46m";
1157
+ const RST = "\x1b[0m";
1158
+
1159
+ /**
1160
+ * Build a bordered content line: │left right│
1161
+ * Left content is truncated if needed, right is preserved, padded to fill width.
1162
+ */
1163
+ function borderLine(
1164
+ left: string,
1165
+ right: string,
1166
+ width: number,
1167
+ accent = ACTIVE_ACCENT,
1168
+ ): string {
1169
+ if (width <= 0) return "";
1170
+ if (width === 1) return `${accent}│${RST}`;
1171
+
1172
+ // width = total visible chars for the whole line including │ and │
1173
+ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
1174
+ const rightVis = visibleWidth(right);
1175
+
1176
+ // If the status chunk alone is too wide, prefer preserving it in compact form
1177
+ // rather than overflowing the terminal.
1178
+ if (rightVis >= contentWidth) {
1179
+ const truncRight = truncateToWidth(right, contentWidth);
1180
+ const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
1181
+ return `${accent}│${RST}${truncRight}${" ".repeat(rightPad)}${accent}│${RST}`;
1182
+ }
1183
+
1184
+ const maxLeft = Math.max(0, contentWidth - rightVis);
1185
+ const truncLeft = truncateToWidth(left, maxLeft);
1186
+ const leftVis = visibleWidth(truncLeft);
1187
+ const pad = Math.max(0, contentWidth - leftVis - rightVis);
1188
+ return `${accent}│${RST}${truncLeft}${" ".repeat(pad)}${right}${accent}│${RST}`;
1189
+ }
1190
+
1191
+ /**
1192
+ * Build the bordered top line: ╭─ Title ──── info ─╮
1193
+ * All chars are accounted for within `width`.
1194
+ */
1195
+ function borderTop(
1196
+ title: string,
1197
+ info: string,
1198
+ width: number,
1199
+ accent = ACTIVE_ACCENT,
1200
+ ): string {
1201
+ if (width <= 0) return "";
1202
+ if (width === 1) return `${accent}╭${RST}`;
1203
+
1204
+ // ╭─ Title ───...─── info ─╮
1205
+ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
1206
+ const inner = Math.max(0, width - 2); // inside ╭ and ╮
1207
+ const titlePart = `─ ${title} `;
1208
+ const infoPart = ` ${info} ─`;
1209
+ const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
1210
+ const fill = "─".repeat(fillLen);
1211
+ const content = `${titlePart}${fill}${infoPart}`
1212
+ .slice(0, inner)
1213
+ .padEnd(inner, "─");
1214
+ return `${accent}╭${content}╮${RST}`;
1215
+ }
1216
+
1217
+ /**
1218
+ * Build the bordered bottom line: ╰──────────────────╯
1219
+ */
1220
+ function borderBottom(width: number, accent = ACTIVE_ACCENT): string {
1221
+ if (width <= 0) return "";
1222
+ if (width === 1) return `${accent}╰${RST}`;
1223
+
1224
+ const inner = Math.max(0, width - 2);
1225
+ return `${accent}╰${"─".repeat(inner)}╯${RST}`;
1226
+ }
1227
+
1228
+ function formatLifecycleWidgetLabel(
1229
+ projection: ReturnType<typeof projectLifecycle>,
1230
+ now: number,
1231
+ ): string {
1232
+ const duration =
1233
+ projection.stateDurationSince == null
1234
+ ? ""
1235
+ : ` ${formatElapsedDuration(now - projection.stateDurationSince)}`;
1236
+ if (projection.kind === "active")
1237
+ return projection.label
1238
+ ? ` active · ${projection.label}${duration} `
1239
+ : ` active${duration} `;
1240
+ if (projection.kind === "blocked") return ` blocked${duration} `;
1241
+ if (projection.kind === "running") return " running… ";
1242
+ if (projection.kind === "waiting") return ` waiting${duration} `;
1243
+ if (projection.kind === "interrupted") return ` interrupted${duration} `;
1244
+ if (projection.kind === "stalled") return ` stalled${duration} `;
1245
+ // completed/failed exist as lifecycle projections for delivery bookkeeping,
1246
+ // but the row is removed immediately after result delivery — so the only
1247
+ // visible terminal handoff label is finalizing.
1248
+ if (
1249
+ projection.kind === "finalizing" ||
1250
+ projection.kind === "completed" ||
1251
+ projection.kind === "failed"
1252
+ ) {
1253
+ return " finalizing… ";
1254
+ }
1255
+ return " starting… ";
1256
+ }
1257
+
1258
+ function renderSubagentWidgetLines(
1259
+ agents: RunningSubagent[],
1260
+ width: number,
1261
+ ): string[] {
1262
+ const now = Date.now();
1263
+ // A retired child is delivered and finished. Its row is gone even while the
1264
+ // pane is still being closed.
1265
+ const rendered = agents
1266
+ .filter((agent) => !agent.retired)
1267
+ .map((agent) => ({
1268
+ agent,
1269
+ projection: projectLifecycle(ensureLifecycle(agent), now),
1270
+ }));
1271
+ if (rendered.length === 0) return [];
1272
+ const activeCount = rendered.filter(
1273
+ ({ projection }) =>
1274
+ projection.kind === "active" ||
1275
+ projection.kind === "starting" ||
1276
+ projection.kind === "running" ||
1277
+ projection.kind === "blocked",
1278
+ ).length;
1279
+ const openCount = rendered.length - activeCount;
1280
+ const info =
1281
+ activeCount > 0
1282
+ ? openCount > 0
1283
+ ? `${activeCount} active · ${openCount} open`
1284
+ : `${activeCount} active`
1285
+ : `${openCount} open`;
1286
+ const accent = activeCount > 0 ? ACTIVE_ACCENT : OPEN_ACCENT;
1287
+
1288
+ const lines: string[] = [borderTop(rendered.every(({ agent }) => agent.replication) ? "Replicas" : "Subagents", info, width, accent)];
1289
+
1290
+ for (const { agent, projection } of rendered) {
1291
+ const elapsed = formatElapsedMMSS(
1292
+ agent.startTime,
1293
+ projection.runtimeEndedAt ?? now,
1294
+ );
1295
+ const agentTag = agent.agent ? ` (${agent.agent})` : "";
1296
+ const left = ` ${elapsed} ${agent.name}${agentTag} `;
1297
+ const runtimeTag = agent.runtimePlan
1298
+ ? `${agent.runtimePlan.modelId}|${agent.runtimePlan.thinking} · `
1299
+ : "";
1300
+ const lifecycleLabel = agent.cleanupPending
1301
+ ? "cleanup pending"
1302
+ : agent.recoveryState
1303
+ ? agent.recoveryState.label
1304
+ : agent.deliveryFailure
1305
+ ? `delivery-failed ${agent.deliveryFailure.deliveryId}`
1306
+ : formatLifecycleWidgetLabel(projection, now).trim();
1307
+ const right = statusConfig.enabled
1308
+ ? ` ${runtimeTag}${lifecycleLabel} `
1309
+ : ` ${runtimeTag}${agent.cleanupPending ? "cleanup pending" : "starting…"} `;
1310
+
1311
+ lines.push(borderLine(left, right, width, accent));
1312
+ }
1313
+
1314
+ lines.push(borderBottom(width, accent));
1315
+ return lines;
1316
+ }
1317
+
1318
+ function updateWidget() {
1319
+ const latestCtx = runtime.latestCtx;
1320
+ if (!latestCtx?.hasUI) return;
1321
+
1322
+ const visible = Array.from(runningSubagents.values()).filter((agent) => !agent.retired);
1323
+ if (visible.length === 0) {
1324
+ latestCtx.ui.setWidget("subagent-status", undefined);
1325
+ if (widgetInterval) {
1326
+ clearInterval(widgetInterval);
1327
+ widgetInterval = null;
1328
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
1329
+ }
1330
+ return;
1331
+ }
1332
+
1333
+ latestCtx.ui.setWidget(
1334
+ "subagent-status",
1335
+ (_tui: any, _theme: any) => {
1336
+ return {
1337
+ invalidate() {},
1338
+ render(width: number) {
1339
+ return renderSubagentWidgetLines(visible, width);
1340
+ },
1341
+ };
1342
+ },
1343
+ { placement: "aboveEditor" },
1344
+ );
1345
+ }
1346
+
1347
+ /**
1348
+ * Build the positional prompt args for a Pi CLI subagent launch.
1349
+ *
1350
+ * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage()
1351
+ * concatenates @file content with messages[0] into one initial prompt. That breaks
1352
+ * /skill: expansion because the message no longer starts with "/skill:". Only
1353
+ * messages[1..] are sent as separate follow-up prompts where /skill: is recognized.
1354
+ *
1355
+ * When there are skill prompts AND artifact-backed delivery, we prepend an empty
1356
+ * first positional message so that /skill: args land in messages[1..] and arrive
1357
+ * as standalone prompts in the child session.
1358
+ */
1359
+ function buildPiPromptArgs(params: {
1360
+ effectiveSkills?: string;
1361
+ taskDelivery: "direct" | "artifact";
1362
+ taskArg: string;
1363
+ }): string[] {
1364
+ const skillPrompts = (params.effectiveSkills ?? "")
1365
+ .split(",")
1366
+ .map((s) => s.trim())
1367
+ .filter(Boolean)
1368
+ .map((skill) => `/skill:${skill}`);
1369
+
1370
+ const needsSeparator =
1371
+ params.taskDelivery === "artifact" && skillPrompts.length > 0;
1372
+
1373
+ return [...(needsSeparator ? [""] : []), ...skillPrompts, params.taskArg];
1374
+ }
1375
+
1376
+ function ensureLifecycle(running: RunningSubagent): SubagentLifecycle {
1377
+ if (running.lifecycle) return running.lifecycle;
1378
+ let lifecycle = createLifecycle(running.startTime);
1379
+ const state = running.statusState;
1380
+ if (
1381
+ state?.activityLabel === "interrupted" &&
1382
+ state.localOverrideAtMs != null
1383
+ ) {
1384
+ lifecycle = markInterruptRequested(lifecycle, state.localOverrideAtMs);
1385
+ } else if (state?.phase === "done") {
1386
+ // Legacy activity "done" means the turn ended, not that completion
1387
+ // evidence was recorded. Hydrate as Herdr-style waiting and let the
1388
+ // preserved watcher consume sidecar/sentinel evidence.
1389
+ const observedAt = state.lastActivityAtMs ?? running.startTime;
1390
+ lifecycle = observePaneInspection(
1391
+ lifecycle,
1392
+ { kind: "present", observedAt, agentStatus: "done" },
1393
+ observedAt,
1394
+ );
1395
+ } else if (
1396
+ state?.phase === "active" ||
1397
+ state?.phase === "waiting" ||
1398
+ state?.phase === "starting"
1399
+ ) {
1400
+ lifecycle = observeActivity(
1401
+ lifecycle,
1402
+ {
1403
+ ok: true,
1404
+ activity: {
1405
+ version: 1,
1406
+ runningChildId: running.id,
1407
+ createdAt: running.startTime,
1408
+ updatedAt: state.lastActivityAtMs ?? running.startTime,
1409
+ sequence: state.lastActivitySequence ?? 0,
1410
+ latestEvent:
1411
+ state.latestEvent === "agent_end" ? "agent_end" : "agent_start",
1412
+ phase: state.phase,
1413
+ agentActive: state.phase === "active",
1414
+ turnActive: state.phase === "active",
1415
+ providerActive: false,
1416
+ toolActive: state.activeScope === "tool",
1417
+ ...(state.activeScope
1418
+ ? { activeScope: state.activeScope as any }
1419
+ : {}),
1420
+ ...(state.activeSinceMs == null
1421
+ ? {}
1422
+ : { activeSince: state.activeSinceMs }),
1423
+ ...(state.waitingSinceMs == null
1424
+ ? {}
1425
+ : { waitingSince: state.waitingSinceMs }),
1426
+ ...(state.activityLabel && state.activeScope === "tool"
1427
+ ? { toolName: state.activityLabel }
1428
+ : {}),
1429
+ },
1430
+ },
1431
+ state.lastActivityAtMs ?? running.startTime,
1432
+ );
1433
+ } else if (running.startTime) {
1434
+ // Pre-lifecycle Pi agents without a known phase still get a running process.
1435
+ lifecycle = markProcessRunning(lifecycle, running.startTime);
1436
+ }
1437
+ running.lifecycle = lifecycle;
1438
+ return lifecycle;
1439
+ }
1440
+
1441
+ function settledAssistantFor(
1442
+ running: RunningSubagent,
1443
+ ): NewestAssistantEntry | null {
1444
+ if (!running.sessionBaseline) return null;
1445
+ try {
1446
+ const read = readEntriesAfterBaseline(
1447
+ running.sessionFile,
1448
+ running.sessionBaseline,
1449
+ );
1450
+ return findNewestAppendedAssistant(read.entries);
1451
+ } catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+
1456
+ /** Match one session assistant to the durable evidence for a settled boundary. */
1457
+ function matchesSettledAssistant(
1458
+ assistant: NewestAssistantEntry,
1459
+ event: SettledActivityEvent,
1460
+ ): boolean {
1461
+ if (event.stopReason != null && assistant.stopReason !== event.stopReason) {
1462
+ return false;
1463
+ }
1464
+ if (event.errorMessage != null && assistant.errorMessage !== event.errorMessage) {
1465
+ return false;
1466
+ }
1467
+ if (event.empty != null && assistant.empty !== event.empty) {
1468
+ return false;
1469
+ }
1470
+ switch (event.outcome) {
1471
+ case "clean": {
1472
+ return (
1473
+ !assistant.empty &&
1474
+ !!assistant.text?.trim() &&
1475
+ assistant.stopReason !== "toolUse" &&
1476
+ assistant.stopReason !== "error" &&
1477
+ assistant.stopReason !== "aborted"
1478
+ );
1479
+ }
1480
+ case "empty": {
1481
+ return assistant.empty;
1482
+ }
1483
+ case "error": {
1484
+ return assistant.stopReason === "error" || assistant.errorMessage != null;
1485
+ }
1486
+ case "intentional-abort":
1487
+ case "unexpected-abort": {
1488
+ return assistant.stopReason === "aborted";
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * Correlate ordered settled boundaries with session assistants.
1495
+ *
1496
+ * Durable event ids are preferred, but older/fallback children can record an
1497
+ * id that is not the JSONL entry id. In that case evidence and session order
1498
+ * select the next matching final assistant, skipping tool-use control entries.
1499
+ */
1500
+ function correlateSettledAssistants(
1501
+ events: readonly SettledActivityEvent[],
1502
+ assistantPool: readonly NewestAssistantEntry[],
1503
+ ): Map<number, NewestAssistantEntry> {
1504
+ const matches = new Map<number, NewestAssistantEntry>();
1505
+ const usedAssistantIds = new Set<string>();
1506
+ let sessionCursor = -1;
1507
+
1508
+ for (const event of events) {
1509
+ let assistant: NewestAssistantEntry | null = null;
1510
+ let assistantIndex = -1;
1511
+ if (event.assistantId) {
1512
+ assistantIndex = assistantPool.findIndex(
1513
+ (candidate, index) =>
1514
+ index > sessionCursor &&
1515
+ candidate.id === event.assistantId &&
1516
+ !usedAssistantIds.has(candidate.id) &&
1517
+ matchesSettledAssistant(candidate, event),
1518
+ );
1519
+ if (assistantIndex >= 0) assistant = assistantPool[assistantIndex];
1520
+ }
1521
+ if (!assistant) {
1522
+ assistantIndex = assistantPool.findIndex(
1523
+ (candidate, index) =>
1524
+ index > sessionCursor &&
1525
+ !usedAssistantIds.has(candidate.id) &&
1526
+ matchesSettledAssistant(candidate, event),
1527
+ );
1528
+ if (assistantIndex >= 0) assistant = assistantPool[assistantIndex];
1529
+ }
1530
+ if (!assistant) continue;
1531
+ usedAssistantIds.add(assistant.id);
1532
+ sessionCursor = assistantIndex;
1533
+ matches.set(event.sequence, assistant);
1534
+ }
1535
+ return matches;
1536
+ }
1537
+
1538
+ function terminalAssistantIdentityFor(
1539
+ running: RunningSubagent,
1540
+ ): SettledDeliveryIdentity | undefined {
1541
+ const finalAssistant = settledAssistantFor(running);
1542
+ if (!finalAssistant) return undefined;
1543
+
1544
+ let assistantEntryId = finalAssistant.id;
1545
+ // An explicit subagent_done writes a tool-call assistant entry before the
1546
+ // session shuts down. That control entry is not the response represented by
1547
+ // the latest settled boundary, so correlate terminal cleanup with the
1548
+ // durable settled identity instead of redelivering its text.
1549
+ if (finalAssistant.stopReason === "toolUse" && running.settledEventsFile) {
1550
+ const events = orderSettledActivityEvents(readSubagentSettledEventsFile(
1551
+ running.settledEventsFile,
1552
+ running.id,
1553
+ ));
1554
+ const assistantPool = settledAssistants(running);
1555
+ const correlated = correlateSettledAssistants(events, assistantPool);
1556
+ let latestSettledAssistant: NewestAssistantEntry | null = null;
1557
+ for (const event of events) {
1558
+ const assistant = correlated.get(event.sequence);
1559
+ if (assistant) latestSettledAssistant = assistant;
1560
+ }
1561
+ if (
1562
+ latestSettledAssistant &&
1563
+ latestSettledAssistant.id !== finalAssistant.id
1564
+ ) {
1565
+ assistantEntryId = latestSettledAssistant.id;
1566
+ }
1567
+ }
1568
+
1569
+ return {
1570
+ childId: running.id,
1571
+ sessionFile: running.sessionFile,
1572
+ assistantEntryId,
1573
+ };
1574
+ }
1575
+
1576
+ function settledAssistants(running: RunningSubagent): NewestAssistantEntry[] {
1577
+ if (!running.sessionBaseline) return [];
1578
+ try {
1579
+ return readEntriesAfterBaseline(running.sessionFile, running.sessionBaseline).entries
1580
+ .map((entry) => findNewestAppendedAssistant([entry]))
1581
+ .filter((assistant): assistant is NewestAssistantEntry => assistant != null);
1582
+ } catch {
1583
+ return [];
1584
+ }
1585
+ }
1586
+
1587
+ /** Follow a child whose new Pi incarnation registered before the source pane closed. */
1588
+ function refreshChildLocation(running: RunningSubagent): void {
1589
+ if (!running.lineage) return;
1590
+ const location = childSessionLocation(running.lineage.rootDir, running.id);
1591
+ if (!location) return;
1592
+ running.sessionFile = location.sessionFile;
1593
+ if (running.sessionBaseline) running.sessionBaseline.sessionFile = location.sessionFile;
1594
+ if (location.paneId) running.surface = location.paneId;
1595
+ if (location.tabId) { running.tabId = location.tabId; rememberPaneTab(running.surface, location.tabId); }
1596
+ if (location.workspaceId) running.recoveryWorkspace = { id: location.workspaceId, cwd: location.cwd };
1597
+ }
1598
+
1599
+ function childMovePending(running: RunningSubagent): boolean {
1600
+ const location = running.lineage && childSessionLocation(running.lineage.rootDir, running.id);
1601
+ return Boolean(location?.moveDeadline && location.moveDeadline > Date.now());
1602
+ }
1603
+ /** Never operate on a pane ID reused by a restarted Herdr server. */
1604
+ function childPaneOwned(running: RunningSubagent): boolean {
1605
+ const location = running.lineage && childSessionLocation(running.lineage.rootDir, running.id);
1606
+ try {
1607
+ if (!location) {
1608
+ const owner = running.launchPane;
1609
+ return owner ? launchOwnsPane(owner, running.surface, getPaneProcessInfo(running.surface).shellPid) : true;
1610
+ }
1611
+ return sessionOwnsPane(location, running.surface, getPaneProcessInfo(running.surface).shellPid);
1612
+ }
1613
+ catch { return false; }
1614
+ }
1615
+ function exactParentSessionActive(running: RunningSubagent): boolean {
1616
+ const lineage = running.lineage;
1617
+ if (!lineage) return true;
1618
+ if (!lineage.parentSessionId || !lineage.parentSessionFile) return false;
1619
+ const ctx = runtime.latestCtx;
1620
+ if (!ctx || runtime.handoffPending) return false;
1621
+ try {
1622
+ if (ctx.sessionManager.getSessionId() !== lineage.parentSessionId) return false;
1623
+ const file = ctx.sessionManager.getSessionFile();
1624
+ if (!file) return false;
1625
+ const location = parentSessionLocation(sessionBindingStore(file), lineage.parentSessionId);
1626
+ if (location && (location.pid !== process.pid || !sessionLocationAlive(location))) return false;
1627
+ lineage.parentSessionFile = file;
1628
+ return true;
1629
+ }
1630
+ catch { return false; }
1631
+ }
1632
+
1633
+ function sessionContainsDelivery(sessionFile: string, deliveryId: string): boolean {
1634
+ try {
1635
+ return readFileSync(sessionFile, "utf8").split("\n").some((line) => {
1636
+ try {
1637
+ const entry = JSON.parse(line);
1638
+ return entry.type === "custom_message" && entry.customType === "subagent_result" && entry.details?.deliveryId === deliveryId;
1639
+ } catch { return false; }
1640
+ });
1641
+ } catch { return false; }
1642
+ }
1643
+
1644
+ /** Wait for durable descendant delivery before releasing a terminal result. */
1645
+ async function waitForDescendantDrain(
1646
+ running: Pick<RunningSubagent, "lineage" | "id">,
1647
+ options: {
1648
+ delay?: (ms: number) => Promise<void>;
1649
+ now?: () => number;
1650
+ onWaitOpen?: (info: { waitedSince: number }) => void;
1651
+ onWaitRelease?: (info: { waitedSince: number; waitedMs: number }) => void;
1652
+ projectWidget?: (projection: unknown) => void;
1653
+ log?: (event: string, fields?: Record<string, unknown>) => void;
1654
+ } = {},
1655
+ ): Promise<void> {
1656
+ if (!running.lineage) return;
1657
+ const childId = running.id;
1658
+ const record = options.log ?? ((event: string, fields?: Record<string, unknown>) => {
1659
+ const sessionFile = (running as RunningSubagent).sessionFile;
1660
+ if (sessionFile) createDeliveryLog({ logPath: join(dirname(sessionFile), "subagent-delivery.log"), now: options.now }).record(event, fields);
1661
+ });
1662
+ await waitForDeliveryDrain({
1663
+ isDrained: () => !hasUndrainedDescendants(reduceLineage(running.lineage!.rootDir), childId),
1664
+ delay: options.delay,
1665
+ now: options.now,
1666
+ onWaitOpen: (info) => {
1667
+ options.projectWidget?.({ state: "waiting-on-descendants", childId });
1668
+ record("drain-wait-open", { childId, phase: "drain", waitedSince: info.waitedSince });
1669
+ options.onWaitOpen?.(info);
1670
+ },
1671
+ onWaitRelease: (info) => {
1672
+ record("drain-wait-release", { childId, phase: "drain", ...info });
1673
+ options.onWaitRelease?.(info);
1674
+ },
1675
+ });
1676
+ }
1677
+
1678
+ function deliveryStatusFor(
1679
+ running: RunningSubagent,
1680
+ answer: string | undefined,
1681
+ explicit?: DeliveryStatus,
1682
+ ): DeliveryStatus {
1683
+ if (explicit) return explicit;
1684
+ if (!answer) return "empty";
1685
+ if (running.lifecycle.process.kind === "failed") {
1686
+ return /cancel/i.test(running.lifecycle.process.error) ? "cancelled" : "error";
1687
+ }
1688
+ if (running.lifecycle.process.kind === "completed") return "completed";
1689
+ return "closed";
1690
+ }
1691
+
1692
+ function prepareDurableDelivery(
1693
+ running: RunningSubagent,
1694
+ deliveryId: string,
1695
+ answer: string | undefined,
1696
+ status?: DeliveryStatus,
1697
+ ): { resultPath: string; payload: string; status: DeliveryStatus } {
1698
+ const resolvedStatus = deliveryStatusFor(running, answer, status);
1699
+ const replicaFile = running.replication && replicaRecordFor(running);
1700
+ const savedSession = replicaFile ? readReplicaWork(replicaFile).sessionCheckpoint : undefined;
1701
+ const handle = materializeResultFile({
1702
+ sessionsDir: savedSession || running.sessionFile ? dirname(savedSession ?? running.sessionFile) : join(replicaFile + ".artifacts", "sessions"),
1703
+ childSessionId: running.id,
1704
+ deliveryId,
1705
+ status: resolvedStatus,
1706
+ agentName: running.name,
1707
+ answer,
1708
+ now: Date.now(),
1709
+ });
1710
+ const payload = frameDeliveryPayload({
1711
+ ...(running.replication ? { subject: "Replica" as const } : {}),
1712
+ status: resolvedStatus,
1713
+ agentName: running.name,
1714
+ childSessionId: running.id,
1715
+ childSessionFile: savedSession ?? running.sessionFile,
1716
+ answer,
1717
+ resultPath: handle.path,
1718
+ deliveryId,
1719
+ ...(running.lifecycle.process.kind === "failed" && running.lifecycle.process.exitCode !== undefined
1720
+ ? { exitCode: running.lifecycle.process.exitCode }
1721
+ : {}),
1722
+ });
1723
+ return { resultPath: handle.path, payload, status: resolvedStatus };
1724
+ }
1725
+
1726
+ function observeSettledInterruptBoundary(running: RunningSubagent): void {
1727
+ if (!running.settledEventsFile || running.lifecycle.turn.kind !== "interrupted") return;
1728
+ const events = orderSettledActivityEvents(readSubagentSettledEventsFile(
1729
+ running.settledEventsFile,
1730
+ running.id,
1731
+ ));
1732
+ const correlated = correlateSettledAssistants(events, settledAssistants(running));
1733
+ const previousActivitySequence = running.lifecycle.turn.previousActivitySequence;
1734
+ for (const event of events) {
1735
+ const assistant = correlated.get(event.sequence);
1736
+ if (!assistant || assistant.stopReason !== "aborted") continue;
1737
+ if (
1738
+ previousActivitySequence == null ||
1739
+ event.sequence > previousActivitySequence
1740
+ ) {
1741
+ running.lifecycle = consumeInterruptBoundary(running.lifecycle, event.sequence);
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ function tryCleanupSubagentSurface(running: RunningSubagent): boolean {
1747
+ if (running.worktree && !running.replication) return true;
1748
+ try {
1749
+ const workFile = running.replication && replicaRecordFor(running);
1750
+ if (workFile) {
1751
+ if (!runtime.latestCtx || running.replicaCleaning) return false;
1752
+ running.replicaCleaning = true;
1753
+ void runReplicaBackground({
1754
+ operation: "cleanup", file: workFile, parentCwd: runtime.latestCtx.cwd, surface: running.surface,
1755
+ ...(running.lineage ? { child: { rootDir: running.lineage.rootDir, nodeId: running.id } } : {}),
1756
+ }).then(({ cleaned }) => {
1757
+ if (!cleaned) return;
1758
+ if (running.lineage) {
1759
+ const ackId = `cleanup-done:${running.id}`;
1760
+ if (!appendLineageEvent(running.lineage.rootDir, ackId, "cleanup_done", running.id) && !hasLineageEvent(running.lineage.rootDir, ackId, "cleanup_done")) throw new Error("Unable to publish cleanup acknowledgement");
1761
+ }
1762
+ running.cleanupPending = false;
1763
+ runningSubagents.delete(running.id);
1764
+ updateWidget();
1765
+ }).catch((error) => {
1766
+ if (running.lineage) appendLineageEvent(running.lineage.rootDir, `cleanup-pending:${running.id}`, "cleanup_pending", running.id, { error: String(error) });
1767
+ }).finally(() => { running.replicaCleaning = false; });
1768
+ return false;
1769
+ } else finalizeSubagentSurface(running, "ready_for_review", false, true);
1770
+ running.cleanupPending = false;
1771
+ if (running.lineage) {
1772
+ const ackId = `cleanup-done:${running.id}`;
1773
+ if (!appendLineageEvent(running.lineage.rootDir, ackId, "cleanup_done", running.id) && !hasLineageEvent(running.lineage.rootDir, ackId, "cleanup_done")) throw new Error("Unable to publish cleanup acknowledgement");
1774
+ }
1775
+ return true;
1776
+ } catch (error) {
1777
+ const text = String(error);
1778
+ if (!running.replication && (text.includes("pane_not_found") || text.includes("not found"))) {
1779
+ running.cleanupPending = false;
1780
+ if (running.lineage) {
1781
+ const ackId = `cleanup-done:${running.id}`;
1782
+ if (!appendLineageEvent(running.lineage.rootDir, ackId, "cleanup_done", running.id) && !hasLineageEvent(running.lineage.rootDir, ackId, "cleanup_done")) throw new Error("Unable to publish cleanup acknowledgement");
1783
+ }
1784
+ return true;
1785
+ }
1786
+ running.cleanupPending = true;
1787
+ if (running.lineage) appendLineageEvent(running.lineage.rootDir, `cleanup-pending:${running.id}`, "cleanup_pending", running.id, { error: text });
1788
+ return false;
1789
+ }
1790
+ }
1791
+
1792
+ function attemptPendingTerminalDelivery(running: RunningSubagent): void {
1793
+ const pending = running.pendingTerminalDelivery;
1794
+ if (!pending || pending.queued) return;
1795
+ // Queue the terminal job even when a descendant is still active. The owner
1796
+ // watcher may finish as soon as its pane closes, so a pre-queue return would
1797
+ // strand the terminal result with no later observer to retry it.
1798
+ pending.queued = true;
1799
+ void enqueueResultDelivery({
1800
+ queue: runtime.resultDeliveryQueue,
1801
+ childId: running.id,
1802
+ deliver: async () => {
1803
+ // The terminal observer can run before the last descendant observer has
1804
+ // published its terminal-delivered event. Wait here rather than relying
1805
+ // on another tick from a pane that may already be gone.
1806
+ await waitForDescendantDrain(running);
1807
+ if (pending.alreadyReported) {
1808
+ if (!running.lineage) throw new Error("Reported launch has no lineage ownership.");
1809
+ acknowledgeReportedLaunchFailure(running.lineage, pending.resultContent ?? "Replica launch failed.");
1810
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
1811
+ retireDeliveredSubagent(running);
1812
+ return;
1813
+ }
1814
+ const deliveryId = `terminal:${running.id}`;
1815
+
1816
+ const durable = prepareDurableDelivery(running, deliveryId, pending.resultContent, pending.status);
1817
+ running.resultPath = durable.resultPath;
1818
+ running.deliveryPayload = durable.payload;
1819
+ if (running.lineage) {
1820
+ if (!appendLineageEvent(running.lineage.rootDir, deliveryId, "terminal", running.id, {
1821
+ outcome: "terminal", ...(pending.resultContent ? { resultContent: pending.resultContent } : {}),
1822
+ })) {
1823
+ const existing = reduceLineage(running.lineage.rootDir).nodes.get(running.id)?.terminal;
1824
+ if (!existing) throw new Error("Unable to publish terminal lineage event");
1825
+ }
1826
+ if (!appendLineageInbox(running.lineage.rootDir, running.id, deliveryId, {
1827
+ sessionId: running.lineage.parentSessionId,
1828
+ sessionFile: running.lineage.parentSessionFile,
1829
+ }, { kind: "terminal", executionOwner: running.executionOwner, resultContent: durable.payload, resultPath: durable.resultPath, status: durable.status })) {
1830
+ throw new Error("Unable to publish terminal inbox");
1831
+ }
1832
+
1833
+ if (existsSync(`${running.sessionFile}.exit`)) acknowledgeExitSidecar(running.sessionFile);
1834
+ const parentActive = exactParentSessionActive(running) && Boolean(runtime.pi);
1835
+ if (parentActive) {
1836
+ const claim = claimLineageInboxMaterialization({
1837
+ rootDir: running.lineage.rootDir,
1838
+ deliveryId,
1839
+ nodeId: running.id,
1840
+ hasExactSessionEvidence: () => sessionContainsDelivery(running.lineage!.parentSessionFile!, deliveryId),
1841
+ });
1842
+ if (claim.status === "busy") {
1843
+ // Another observer owns this delivery, or this process already
1844
+ // queued the copy. That is not a delivery failure, so retry quietly
1845
+ // instead of projecting an error and feeding the widget a lie.
1846
+ pending.queued = false;
1847
+ return;
1848
+ }
1849
+ if (claim.status === "acquired") {
1850
+ try {
1851
+ await pending.finalize();
1852
+ runtime.inflightDeliveries.set(deliveryId, { rootDir: running.lineage.rootDir, nodeId: running.id, token: claim.token });
1853
+ acknowledgeRestoredInboxDelivery({
1854
+ rootDir: running.lineage.rootDir, nodeId: running.id, deliveryId,
1855
+ token: claim.token, sessionFile: running.lineage.parentSessionFile!, requiresParentRecord: true,
1856
+ });
1857
+ } catch (error) {
1858
+ releaseLineageInboxMaterialization({
1859
+ rootDir: running.lineage.rootDir,
1860
+ deliveryId,
1861
+ nodeId: running.id,
1862
+ token: claim.token,
1863
+ hasExactSessionEvidence: () => sessionContainsDelivery(running.lineage!.parentSessionFile!, deliveryId),
1864
+ });
1865
+ throw error;
1866
+ }
1867
+ }
1868
+ }
1869
+ } else {
1870
+ if (existsSync(`${running.sessionFile}.exit`)) acknowledgeExitSidecar(running.sessionFile);
1871
+ await pending.finalize();
1872
+ }
1873
+ const delivered = !running.lineage || appendLineageEvent(running.lineage.rootDir, `terminal-delivered:${running.id}`, "terminal_delivered", running.id, { deliveryId });
1874
+ if (!delivered && !reduceLineage(running.lineage!.rootDir).nodes.get(running.id)?.terminalDelivered) throw new Error("Unable to publish terminal delivery evidence");
1875
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
1876
+ retireDeliveredSubagent(running);
1877
+ },
1878
+ }).catch((error) => {
1879
+ if (running.pendingTerminalDelivery === pending) pending.queued = false;
1880
+ recordDeliveryFailure(running, `terminal:${running.id}`, error, "terminal-delivery");
1881
+ });
1882
+ }
1883
+
1884
+ /** Surface cleanup delays after a delivered result, in order, then give up. */
1885
+ const CLEANUP_RETRY_DELAYS_MS = [2_000, 15_000, 60_000];
1886
+
1887
+ /**
1888
+ * Retire one delivered child. The result is durable, so its row leaves the
1889
+ * widget now and the pane is closed in the background. A rejected Herdr close
1890
+ * retries a few times with backoff instead of running once per status tick.
1891
+ */
1892
+ function retireDeliveredSubagent(running: RunningSubagent): void {
1893
+ running.retired = true;
1894
+ running.pendingTerminalDelivery = undefined;
1895
+ const cleaned = tryCleanupSubagentSurface(running);
1896
+ running.cleanupPending = !cleaned;
1897
+ if (cleaned) {
1898
+ runningSubagents.delete(running.id);
1899
+ } else {
1900
+ running.cleanupAttempts = 1;
1901
+ running.cleanupRetryAt = Date.now() + CLEANUP_RETRY_DELAYS_MS[0];
1902
+ }
1903
+ updateWidget();
1904
+ }
1905
+
1906
+ /** Retry one retired child's surface cleanup without observing it again. */
1907
+ function retryRetiredSubagentCleanup(running: RunningSubagent, now: number): void {
1908
+ if (running.replicaCleaning) return;
1909
+ if ((running.cleanupRetryAt ?? 0) > now) return;
1910
+ if (tryCleanupSubagentSurface(running)) {
1911
+ runningSubagents.delete(running.id);
1912
+ updateWidget();
1913
+ return;
1914
+ }
1915
+ const attempts = (running.cleanupAttempts ?? 0) + 1;
1916
+ if (attempts > CLEANUP_RETRY_DELAYS_MS.length) {
1917
+ // Herdr keeps refusing the close. Keep the durable record and stop retrying
1918
+ // so one rejected surface cannot run forever.
1919
+ if (running.lineage) {
1920
+ try {
1921
+ recoveryDeliveryLog(running).record("cleanup-abandoned", {
1922
+ childId: running.id,
1923
+ surface: running.surface,
1924
+ phase: "cleanup",
1925
+ });
1926
+ } catch {
1927
+ // The durable record is best effort.
1928
+ }
1929
+ }
1930
+ runningSubagents.delete(running.id);
1931
+ updateWidget();
1932
+ return;
1933
+ }
1934
+ running.cleanupAttempts = attempts;
1935
+ running.cleanupRetryAt = now + CLEANUP_RETRY_DELAYS_MS[attempts - 1];
1936
+ }
1937
+
1938
+ function queueTerminalDelivery(
1939
+ running: RunningSubagent,
1940
+ finalAssistant: SettledDeliveryIdentity | undefined,
1941
+ finalize: () => void | Promise<void>,
1942
+ resultContent?: string,
1943
+ status?: DeliveryStatus,
1944
+ alreadyReported = false,
1945
+ ): void {
1946
+ if (running.pendingTerminalDelivery) return;
1947
+ running.pendingTerminalDelivery = { finalAssistant, resultContent, status, alreadyReported, queued: false, finalize };
1948
+ attemptPendingTerminalDelivery(running);
1949
+ }
1950
+
1951
+ function observeRunningSubagent(
1952
+ running: RunningSubagent,
1953
+ observedAt = Date.now(),
1954
+ ) {
1955
+ refreshChildLocation(running);
1956
+ if (running.retired) {
1957
+ retryRetiredSubagentCleanup(running, observedAt);
1958
+ return;
1959
+ }
1960
+ ensureLifecycle(running);
1961
+
1962
+ const activityFile = running.activityFile;
1963
+ const read: ActivityReadResult = activityFile
1964
+ ? readSubagentActivityFile(activityFile, running.id)
1965
+ : { ok: false, reason: "missing" };
1966
+
1967
+ running.activityRead = read.ok
1968
+ ? { ok: true }
1969
+ : { ok: false, reason: read.reason, error: read.error };
1970
+
1971
+ if (read.ok) {
1972
+ running.activity = read.activity;
1973
+ if (running.recoveryState && read.activity.updatedAt >= running.startTime) {
1974
+ recoveryDeliveryLog(running).record("recovery-observed", {
1975
+ childId: running.id,
1976
+ attempt: running.recoveryState.attempt,
1977
+ cause: running.recoveryState.cause,
1978
+ });
1979
+ running.recoveryState = undefined;
1980
+ }
1981
+ }
1982
+ running.lifecycle = observeActivity(
1983
+ ensureLifecycle(running),
1984
+ read,
1985
+ observedAt,
1986
+ );
1987
+ observeSettledInterruptBoundary(running);
1988
+ if (running.lineage && reduceLineage(running.lineage.rootDir).nodes.get(running.id)?.cancellation?.intent) void attemptPendingCancellation(running);
1989
+ if (running.pendingReplicaResult && exactParentSessionActive(running)) finishReplicationResult(running, running.pendingReplicaResult);
1990
+ attemptPendingTerminalDelivery(running);
1991
+ if (running.cleanupPending) retryRetiredSubagentCleanup(running, observedAt);
1992
+ }
1993
+
1994
+ function resolveInterruptTarget(params: {
1995
+ id?: string;
1996
+ name?: string;
1997
+ }): { running: RunningSubagent } | { error: string } {
1998
+ const requestedId = params.id?.trim();
1999
+ if (requestedId) {
2000
+ const running = runningSubagents.get(requestedId);
2001
+ return running && !running.retired
2002
+ ? { running }
2003
+ : { error: `No running subagent with id "${requestedId}".` };
2004
+ }
2005
+
2006
+ const requestedName = params.name?.trim();
2007
+ if (!requestedName) {
2008
+ return { error: "Provide a running subagent id or exact display name." };
2009
+ }
2010
+
2011
+ const matches = Array.from(runningSubagents.values()).filter(
2012
+ (running) => !running.retired && running.name === requestedName,
2013
+ );
2014
+ if (matches.length === 1) return { running: matches[0] };
2015
+ if (matches.length === 0) {
2016
+ return { error: `No running subagent named "${requestedName}".` };
2017
+ }
2018
+
2019
+ const candidates = matches
2020
+ .map((running) => `${running.name} [${running.id}]`)
2021
+ .join(", ");
2022
+ return {
2023
+ error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}`,
2024
+ };
2025
+ }
2026
+
2027
+ function requestSubagentInterrupt(
2028
+ running: RunningSubagent,
2029
+ interruptPaneKey: (surface: string) => void = interruptPane,
2030
+ ): { ok: true } | { error: string } {
2031
+ const marker = `${running.sessionFile}.mechanical-interrupt`;
2032
+ try {
2033
+ writeFileSync(marker, `${Date.now()}\n`, "utf8");
2034
+ interruptPaneKey(running.surface);
2035
+ return { ok: true };
2036
+ } catch (error: any) {
2037
+ rmSync(marker, { force: true });
2038
+ return {
2039
+ error:
2040
+ `Failed to send Escape to subagent "${running.name}" via herdr: ` +
2041
+ `${error?.message ?? String(error)}`,
2042
+ };
2043
+ }
2044
+ }
2045
+
2046
+ interface SubagentInterruptDetails {
2047
+ error?: string;
2048
+ id?: string;
2049
+ name?: string;
2050
+ status?: "interrupt_requested";
2051
+ }
2052
+
2053
+ interface SubagentCancelDetails {
2054
+ error?: string;
2055
+ id?: string;
2056
+ name?: string;
2057
+ status?: "cancelled" | "cancellation_pending";
2058
+ }
2059
+
2060
+ function sameProcessIdentity(captured: readonly number[], current: readonly number[]): boolean {
2061
+ // Processes may exit between restarts; a newly appearing PID means the pane
2062
+ // identity was reused and must not be terminated by this cancellation.
2063
+ return current.every((pid) => captured.includes(pid));
2064
+ }
2065
+
2066
+ /** Retry a durable cancellation intent, failing closed on uncertain identity. */
2067
+ async function attemptPendingCancellation(running: RunningSubagent): Promise<boolean> {
2068
+ if (!running.lineage) return false;
2069
+ if (running.cancellationAttempt) return running.cancellationAttempt;
2070
+ const attempt = (async () => {
2071
+ const lineage = running.lineage!;
2072
+ const state = reduceLineage(lineage.rootDir);
2073
+ const node = state.nodes.get(running.id);
2074
+ if (!node?.cancellation?.intent || node.terminal) return false;
2075
+ let pids = node.cancellation.pids;
2076
+ if (pids.length === 0) {
2077
+ let info: { pids: number[] };
2078
+ try { info = getPaneProcessInfo(node.cancellation.surface ?? running.surface); } catch { return false; }
2079
+ if (info.pids.length === 0) return false;
2080
+ if (!appendLineageEvent(lineage.rootDir, `cancel-identity:${running.id}`, "cancel_identity", running.id, { surface: node.cancellation.surface ?? running.surface, pids: info.pids })) return false;
2081
+ pids = info.pids;
2082
+ } else {
2083
+ try {
2084
+ const current = getPaneProcessInfo(node.cancellation.surface ?? running.surface);
2085
+ if (current.pids.length > 0 && !sameProcessIdentity(pids, current.pids)) return false;
2086
+ } catch {
2087
+ // The pane may already be gone; the persisted PID identity remains the
2088
+ // only safe identity to prove has exited.
2089
+ }
2090
+ }
2091
+ try { closePane(node.cancellation.surface ?? running.surface); } catch {}
2092
+ let gone: boolean;
2093
+ try { gone = await waitForPaneAbsence(node.cancellation.surface ?? running.surface, { timeoutMs: 5_000, intervalMs: 50 }); } catch { gone = false; }
2094
+ let survivors: number[];
2095
+ try { survivors = await waitForProcessesExit(pids, { timeoutMs: 5_000, intervalMs: 50 }); } catch { survivors = pids; }
2096
+ if (!gone || survivors.length > 0) return false;
2097
+ const after = reduceLineage(lineage.rootDir).nodes.get(running.id);
2098
+ if (after?.terminal) return false;
2099
+ if (!appendLineageEvent(lineage.rootDir, `cancel-proven:${running.id}`, "cancel_proven", running.id, { surface: node.cancellation.surface ?? running.surface, pids })) {
2100
+ const proven = reduceLineage(lineage.rootDir).nodes.get(running.id)?.cancellation?.proven;
2101
+ if (!proven) return false;
2102
+ }
2103
+ if (!appendLineageEvent(lineage.rootDir, `terminal:${running.id}`, "terminal", running.id, { outcome: "cancelled", resultContent: "Subagent cancelled." })) {
2104
+ return !!reduceLineage(lineage.rootDir).nodes.get(running.id)?.terminal;
2105
+ }
2106
+ if (!running.pendingTerminalDelivery) {
2107
+ running.pendingTerminalDelivery = {
2108
+ finalAssistant: undefined,
2109
+ resultContent: "Subagent cancelled.",
2110
+
2111
+ status: "cancelled",
2112
+ queued: false,
2113
+ finalize: () => {
2114
+ if (!exactParentSessionActive(running)) return;
2115
+ if (runtime.pi) sendSubagentResult(runtime.pi, "Subagent cancelled.", { name: running.name, task: running.task, childId: running.id, exitCode: 1, sessionFile: running.sessionFile, deliveryId: `terminal:${running.id}`, resultContent: "Subagent cancelled.", cancellation: true });
2116
+ },
2117
+ };
2118
+ }
2119
+ attemptPendingTerminalDelivery(running);
2120
+ return true;
2121
+ })().finally(() => { running.cancellationAttempt = undefined; });
2122
+ running.cancellationAttempt = attempt;
2123
+ return attempt;
2124
+ }
2125
+
2126
+ /** Build enough runtime state to finish a descendant whose owning Pi process is gone. */
2127
+ function recoveredLineageRunning(
2128
+ rootDir: string,
2129
+ rootId: string,
2130
+ node: LineageNodeState,
2131
+ ): RunningSubagent | undefined {
2132
+ if (!node.surface || !node.sessionFile) return undefined;
2133
+ let baseline: SessionBaselineCursor;
2134
+ try {
2135
+ baseline = captureSessionBaseline(node.sessionFile);
2136
+ } catch {
2137
+ baseline = { sessionFile: node.sessionFile, entryCount: 0, leafId: null, assistantEntryIds: [] };
2138
+ }
2139
+ return {
2140
+ lineage: {
2141
+ rootDir,
2142
+ rootId,
2143
+ nodeId: node.nodeId,
2144
+ ...(node.parentNodeId ? { parentNodeId: node.parentNodeId } : {}),
2145
+ ...(node.parentSessionId ? { parentSessionId: node.parentSessionId } : {}),
2146
+ ...(node.parentSessionFile ? { parentSessionFile: node.parentSessionFile } : {}),
2147
+ launchKind: (node.launchKind as LineageRegistration["launchKind"]) ?? "fresh",
2148
+ },
2149
+ id: node.nodeId,
2150
+ executionOwner: node.executionOwner,
2151
+ name: node.name ?? node.nodeId,
2152
+ task: node.task ?? "destroyed descendant",
2153
+ agent: node.agent,
2154
+ surface: node.surface,
2155
+ startTime: node.startTime ?? Date.now(),
2156
+ sessionFile: node.sessionFile,
2157
+ activityFile: node.activityFile,
2158
+ settledEventsFile: node.settledEventsFile,
2159
+ sessionBaseline: baseline,
2160
+ interactive: false,
2161
+ runtimePlan: undefined,
2162
+ lifecycle: createLifecycle(node.startTime ?? Date.now()),
2163
+ };
2164
+ }
2165
+
2166
+ /** Adopt and terminate descendants after their owning pane is destroyed. */
2167
+ async function destroyOwnedLineageSubtree(owner: RunningSubagent): Promise<void> {
2168
+ if (!owner.lineage) return;
2169
+ const { rootDir, rootId } = owner.lineage;
2170
+ for (;;) {
2171
+ const state = reduceLineage(rootDir);
2172
+ const descendants = [...state.nodes.values()].filter((candidate) => {
2173
+ let parent = candidate.parentNodeId;
2174
+ const seen = new Set<string>();
2175
+ while (parent && !seen.has(parent)) {
2176
+ if (parent === owner.id) return true;
2177
+ seen.add(parent);
2178
+ parent = state.nodes.get(parent)?.parentNodeId;
2179
+ }
2180
+ return false;
2181
+ });
2182
+ for (const node of descendants.filter((candidate) => candidate.terminal && !candidate.terminalDelivered)) {
2183
+ const deliveryId = `terminal:${node.nodeId}`;
2184
+ appendLineageInbox(rootDir, node.nodeId, deliveryId, {
2185
+ sessionId: node.parentSessionId,
2186
+ sessionFile: node.parentSessionFile,
2187
+ }, {
2188
+ kind: "terminal",
2189
+ executionOwner: node.executionOwner,
2190
+ ...(node.terminal?.resultContent ? { resultContent: node.terminal.resultContent } : {}),
2191
+ });
2192
+ appendLineageEvent(rootDir, `terminal-delivered:${node.nodeId}`, "terminal_delivered", node.nodeId, { deliveryId });
2193
+ }
2194
+ const refreshed = reduceLineage(rootDir);
2195
+ const open = [...refreshed.nodes.values()].filter((candidate) => {
2196
+ if (candidate.terminal) return false;
2197
+ let parent = candidate.parentNodeId;
2198
+ const seen = new Set<string>();
2199
+ while (parent && !seen.has(parent)) {
2200
+ if (parent === owner.id) return true;
2201
+ seen.add(parent);
2202
+ parent = refreshed.nodes.get(parent)?.parentNodeId;
2203
+ }
2204
+ return false;
2205
+ });
2206
+ if (open.length === 0) return;
2207
+
2208
+ let progressed = false;
2209
+ for (const node of open) {
2210
+ const running = recoveredLineageRunning(rootDir, rootId, node);
2211
+ if (!running) {
2212
+ const resultContent = "Subagent ownership was destroyed before launch metadata was complete.";
2213
+ const deliveryId = `terminal:${node.nodeId}`;
2214
+ if (!appendLineageEvent(rootDir, deliveryId, "terminal", node.nodeId, {
2215
+ outcome: "pane_closed",
2216
+ resultContent,
2217
+ }) && !reduceLineage(rootDir).nodes.get(node.nodeId)?.terminal) continue;
2218
+ if (!appendLineageInbox(rootDir, node.nodeId, deliveryId, {
2219
+ sessionId: node.parentSessionId,
2220
+ sessionFile: node.parentSessionFile,
2221
+ }, { kind: "terminal", executionOwner: node.executionOwner, resultContent })) continue;
2222
+ appendLineageEvent(rootDir, `terminal-delivered:${node.nodeId}`, "terminal_delivered", node.nodeId, { deliveryId });
2223
+ progressed = true;
2224
+ continue;
2225
+ }
2226
+ let inspection: PaneInspection;
2227
+ try {
2228
+ inspection = await inspectPane(running.surface);
2229
+ } catch {
2230
+ continue;
2231
+ }
2232
+ if (inspection.kind === "unavailable") continue;
2233
+ if (inspection.kind === "missing") {
2234
+ const resultContent = "Subagent pane was destroyed with its owning subtree.";
2235
+ if (!appendLineageEvent(rootDir, `terminal:${running.id}`, "terminal", running.id, {
2236
+ outcome: "pane_closed",
2237
+ resultContent,
2238
+ }) && !reduceLineage(rootDir).nodes.get(running.id)?.terminal) continue;
2239
+ queueTerminalDelivery(running, undefined, () => {}, resultContent);
2240
+ progressed = true;
2241
+ continue;
2242
+ }
2243
+ const current = reduceLineage(rootDir).nodes.get(running.id);
2244
+ if (!current?.cancellation?.intent) {
2245
+ appendLineageEvent(rootDir, `owner-closed:${owner.id}:${running.id}`, "cancel_intent", running.id, {
2246
+ surface: running.surface,
2247
+ });
2248
+ }
2249
+ if (await attemptPendingCancellation(running)) progressed = true;
2250
+ }
2251
+ if (!progressed) await new Promise<void>((resolve) => setTimeout(resolve, 250));
2252
+ }
2253
+ }
2254
+
2255
+ /** Persist a user-closed tab as cancellation before descendant drain can release stale settles. */
2256
+ async function recordManualTabCancellation(running: RunningSubagent): Promise<boolean> {
2257
+ if (!running.lineage) return true;
2258
+ const { rootDir } = running.lineage;
2259
+ const intentId = `manual-tab-close:${running.id}`;
2260
+ const current = reduceLineage(rootDir).nodes.get(running.id);
2261
+ if (current?.terminal) return false;
2262
+ if (!current?.cancellation?.intent) {
2263
+ const recorded = appendLineageEvent(rootDir, intentId, "cancel_intent", running.id, {
2264
+ surface: running.surface,
2265
+ source: "user_tab_close",
2266
+ });
2267
+ if (!recorded && !reduceLineage(rootDir).nodes.get(running.id)?.cancellation?.intent) {
2268
+ throw new Error("Unable to persist manual tab cancellation");
2269
+ }
2270
+ }
2271
+ await destroyOwnedLineageSubtree(running);
2272
+ if (reduceLineage(rootDir).nodes.get(running.id)?.terminal) return false;
2273
+ const provenId = `manual-tab-close-proven:${running.id}`;
2274
+ if (!appendLineageEvent(rootDir, provenId, "cancel_proven", running.id, {
2275
+ surface: running.surface,
2276
+ pids: [],
2277
+ source: "user_tab_close",
2278
+ }) && !reduceLineage(rootDir).nodes.get(running.id)?.cancellation?.proven) {
2279
+ throw new Error("Unable to prove manual tab cancellation");
2280
+ }
2281
+ return true;
2282
+ }
2283
+
2284
+ async function handleSubagentCancel(params: { id?: string; name?: string }): Promise<AgentToolResult<SubagentCancelDetails>> {
2285
+ const resolved = resolveInterruptTarget(params);
2286
+ if ("error" in resolved) return { content: [{ type: "text" as const, text: resolved.error }], details: { error: resolved.error } };
2287
+ const running = resolved.running;
2288
+ const lineage = running.lineage;
2289
+ if (!lineage) return { content: [{ type: "text" as const, text: "Cancellation pending: durable lineage is unavailable." }], details: { id: running.id, name: running.name, status: "cancellation_pending" } };
2290
+ const current = reduceLineage(lineage.rootDir).nodes.get(running.id);
2291
+ if (current?.terminal) {
2292
+ const delivered = !!current.terminalDelivered;
2293
+ return { content: [{ type: "text" as const, text: delivered ? `Cancellation already completed for subagent "${running.name}".` : "Cancellation pending: another terminal outcome won the race." }], details: { id: running.id, name: running.name, status: delivered ? "cancelled" : "cancellation_pending" } };
2294
+ }
2295
+ if (!current?.cancellation?.intent && !appendLineageEvent(lineage.rootDir, `cancel-intent:${running.id}`, "cancel_intent", running.id, { surface: running.surface })) {
2296
+ const recorded = reduceLineage(lineage.rootDir).nodes.get(running.id)?.cancellation?.intent;
2297
+ if (!recorded) return { content: [{ type: "text" as const, text: "Cancellation pending: could not persist cancellation intent." }], details: { id: running.id, name: running.name, status: "cancellation_pending" } };
2298
+ }
2299
+ const proved = await attemptPendingCancellation(running);
2300
+ const after = reduceLineage(lineage.rootDir).nodes.get(running.id);
2301
+ const complete = proved && !running.pendingTerminalDelivery && !!after?.terminalDelivered;
2302
+ const raced = !!after?.terminal && after.terminal.outcome !== "cancelled";
2303
+ return { content: [{ type: "text" as const, text: raced ? "Cancellation pending: another terminal outcome won the race." : complete ? `Cancellation verified for subagent "${running.name}".` : `Cancellation pending for subagent "${running.name}" until termination proof, descendants, and delivery complete.` }], details: { id: running.id, name: running.name, status: complete ? "cancelled" : "cancellation_pending" } };
2304
+ }
2305
+
2306
+ function handleSubagentInterrupt(
2307
+ params: { id?: string; name?: string },
2308
+ interruptPaneKey: (surface: string) => void = interruptPane,
2309
+ ): AgentToolResult<SubagentInterruptDetails> {
2310
+ const resolved = resolveInterruptTarget(params);
2311
+ if ("error" in resolved) {
2312
+ return {
2313
+ content: [{ type: "text" as const, text: resolved.error }],
2314
+ details: { error: resolved.error },
2315
+ };
2316
+ }
2317
+
2318
+ const running = resolved.running;
2319
+ const now = Date.now();
2320
+ observeRunningSubagent(running, now);
2321
+
2322
+ const interruption = requestSubagentInterrupt(running, interruptPaneKey);
2323
+ if ("error" in interruption) {
2324
+ return {
2325
+ content: [{ type: "text" as const, text: interruption.error }],
2326
+ details: {
2327
+ error: interruption.error,
2328
+ id: running.id,
2329
+ name: running.name,
2330
+ },
2331
+ };
2332
+ }
2333
+
2334
+ running.lifecycle = markInterruptRequested(ensureLifecycle(running), now);
2335
+ updateWidget();
2336
+
2337
+ return {
2338
+ content: [
2339
+ {
2340
+ type: "text" as const,
2341
+ text: `Interrupt requested for subagent "${running.name}".`,
2342
+ },
2343
+ ],
2344
+ details: {
2345
+ id: running.id,
2346
+ name: running.name,
2347
+ status: "interrupt_requested",
2348
+ },
2349
+ };
2350
+ }
2351
+
2352
+ function ownedDescendantWatchdogState(running: RunningSubagent): {
2353
+ waitingForDescendants: boolean;
2354
+ descendantActivityAt?: number;
2355
+ } {
2356
+ if (!running.lineage) return { waitingForDescendants: false };
2357
+ const state = reduceLineage(running.lineage.rootDir);
2358
+ const integrity = lineageIntegrityError(state);
2359
+ if (integrity) throw new Error(integrity);
2360
+ const descendantActivityAt = latestDescendantDeliveryAt(state, running.id);
2361
+ return {
2362
+ waitingForDescendants: hasUndrainedDescendants(state, running.id),
2363
+ ...(descendantActivityAt === undefined ? {} : { descendantActivityAt }),
2364
+ };
2365
+ }
2366
+
2367
+ function deliverRecoveryFailure(running: RunningSubagent, reason: string): void {
2368
+ running.recoveryFailureReason = reason;
2369
+ running.lifecycle = markFailed(ensureLifecycle(running), reason, Date.now(), 1);
2370
+ const presentation = resolveResultPresentation({
2371
+ summary: reason,
2372
+ exitCode: 1,
2373
+ elapsed: Math.floor((Date.now() - running.startTime) / 1000),
2374
+ sessionFile: running.sessionFile,
2375
+ errorMessage: reason,
2376
+ }, running.name, running.runtimePlan?.runtimeMismatch);
2377
+ queueTerminalDelivery(running, terminalAssistantIdentityFor(running), () => {
2378
+ if (runtime.pi) sendSubagentResult(runtime.pi, presentation, {
2379
+ name: running.name,
2380
+ task: running.task,
2381
+ childId: running.id,
2382
+ exitCode: 1,
2383
+ sessionFile: running.sessionFile,
2384
+ deliveryId: `terminal:${running.id}`,
2385
+ errorMessage: reason,
2386
+ resultContent: reason,
2387
+ });
2388
+ }, reason, "recovery-failed");
2389
+ }
2390
+
2391
+ async function superviseStaleSubagent(running: RunningSubagent, now: number): Promise<void> {
2392
+ // Replica continuation belongs to its parent, never the stale watchdog.
2393
+ if (running.retired || running.replication) return;
2394
+ if (!shouldStartStaleRecovery({
2395
+ interactive: running.interactive,
2396
+ pendingTerminalDelivery: Boolean(running.pendingTerminalDelivery),
2397
+ recoveryAttemptActive: Boolean(running.recoveryAttempt),
2398
+ recoveryExhausted: Boolean(running.recoveryExhausted),
2399
+ })) return;
2400
+ let descendants: ReturnType<typeof ownedDescendantWatchdogState>;
2401
+ try {
2402
+ descendants = ownedDescendantWatchdogState(running);
2403
+ } catch (error) {
2404
+ recordDeliveryFailure(running, `lineage:${running.id}`, error, "lineage-integrity");
2405
+ return;
2406
+ }
2407
+ if (classifyStaleness({
2408
+ autonomous: true,
2409
+ activity: running.activity,
2410
+ startedAt: running.startTime,
2411
+ now,
2412
+ quietThresholdMs,
2413
+ ...descendants,
2414
+ }) !== "stale") return;
2415
+
2416
+ running.recoveryAttempt = (async () => {
2417
+ const recovery = await attemptRecovery(running, "stale");
2418
+ if (recovery.recovered) {
2419
+ startRecoveredWatcher(running);
2420
+ return;
2421
+ }
2422
+ if (recovery.exhausted) {
2423
+ deliverRecoveryFailure(running, recovery.reason ?? "Recovery failed.");
2424
+ }
2425
+ })().finally(() => {
2426
+ running.recoveryAttempt = undefined;
2427
+ });
2428
+ await running.recoveryAttempt;
2429
+ }
2430
+
2431
+ function startStatusRefresh(_pi: ExtensionAPI) {
2432
+ if (statusInterval) return;
2433
+
2434
+ statusInterval = setInterval(() => {
2435
+ if (runtime.latestCtx) void restorePendingLineageInboxes(runtime.latestCtx);
2436
+ if (runningSubagents.size === 0) {
2437
+ if (statusInterval) {
2438
+ clearInterval(statusInterval);
2439
+ statusInterval = null;
2440
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
2441
+ }
2442
+ return;
2443
+ }
2444
+
2445
+ const now = Date.now();
2446
+ let shouldRefreshWidget = false;
2447
+ for (const running of runningSubagents.values()) {
2448
+ observeRunningSubagent(running, now);
2449
+ void superviseStaleSubagent(running, now);
2450
+ const projection = projectLifecycle(ensureLifecycle(running), now);
2451
+ if (running.lastProjectedKind !== projection.kind) shouldRefreshWidget = true;
2452
+ running.lastProjectedKind = projection.kind;
2453
+ }
2454
+ if (shouldRefreshWidget) updateWidget();
2455
+ }, 1000);
2456
+
2457
+ (globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
2458
+ }
2459
+
2460
+ export const __test__ = {
2461
+ launchSubagent,
2462
+ borderLine,
2463
+ renderSubagentWidgetLines,
2464
+ loadAgentDefaults,
2465
+ discoverAgentDefinitions,
2466
+ resolveEffectiveSessionMode,
2467
+ resolveLaunchBehavior,
2468
+ resolveEffectiveAutoExit,
2469
+ resolveEffectiveInteractive,
2470
+ buildSubagentToolAllowlist,
2471
+ buildPiPromptArgs,
2472
+ terminalAssistantIdentityFor,
2473
+ observeRunningSubagent,
2474
+ restorePendingLineageInboxes,
2475
+ attemptRecovery,
2476
+ startRecoveredWatcher,
2477
+ waitForDescendantDrain,
2478
+ resolveDenyTools,
2479
+ isSelfSpawnBlocked,
2480
+ resolveInterruptTarget,
2481
+ recordManualTabCancellation,
2482
+ handleSubagentCancel,
2483
+ requestSubagentInterrupt,
2484
+ handleSubagentInterrupt,
2485
+ resolveResultPresentation,
2486
+ resolveUnexpectedErrorPresentation,
2487
+ sendSubagentResult,
2488
+ shouldRetainSubagentSurface,
2489
+ captureWorktreeHandoff,
2490
+ runSubagentScript,
2491
+ writeWorktreeManifest,
2492
+ runningSubagents,
2493
+ formatElapsed,
2494
+ };
2495
+
2496
+ function startWidgetRefresh() {
2497
+ if (widgetInterval) return;
2498
+ updateWidget(); // immediate first render
2499
+ widgetInterval = setInterval(() => {
2500
+ updateWidget();
2501
+ }, 1000);
2502
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
2503
+ }
2504
+
2505
+ /**
2506
+ * Launch a subagent: creates the herdr pane, builds the command, and
2507
+ * sends it. Returns a RunningSubagent — does NOT poll.
2508
+ *
2509
+ * Call watchSubagent() on the returned object to observe completion.
2510
+ */
2511
+ async function launchSubagent(
2512
+ params: typeof SubagentParams.static,
2513
+ ctx: {
2514
+ sessionManager: {
2515
+ getSessionFile(): string | null | undefined;
2516
+ getSessionId(): string;
2517
+ getSessionDir(): string;
2518
+ };
2519
+ cwd: string;
2520
+ model?: { provider: string; id: string };
2521
+ modelRegistry: {
2522
+ find(provider: string, modelId: string): any;
2523
+ getAvailable?: () => any[];
2524
+ getAll?: () => any[];
2525
+ hasConfiguredAuth?: (model: any) => boolean;
2526
+ };
2527
+ },
2528
+ parentThinking: ThinkingLevel,
2529
+ options?: {
2530
+ surface?: string;
2531
+ executionOwner?: ExecutionOwner;
2532
+ runtimePlan?: ResolvedRuntimePlan;
2533
+ id?: string;
2534
+ },
2535
+ ): Promise<RunningSubagent> {
2536
+ const id = options?.id ?? Math.random().toString(16).slice(2, 10);
2537
+
2538
+ const agentDefs = params.agent
2539
+ ? loadAgentDefaults(params.agent, runtime.pi)
2540
+ : null;
2541
+ if (params.agent && !agentDefs) {
2542
+ const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
2543
+ (candidate) => candidate.agentName === params.agent,
2544
+ );
2545
+ throw new Error(
2546
+ diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2547
+ );
2548
+ }
2549
+ if (!ctx.model)
2550
+ throw new Error("Subagent launch requires a resolved parent model");
2551
+ const runtimePlan =
2552
+ options?.runtimePlan ??
2553
+ resolveRuntimePlan(
2554
+ { model: agentDefs?.model, thinking: agentDefs?.thinking },
2555
+ {
2556
+ provider: ctx.model.provider,
2557
+ modelId: ctx.model.id,
2558
+ thinking: parentThinking,
2559
+ },
2560
+ wrapPiModelRegistry(ctx.modelRegistry),
2561
+ );
2562
+ const effectiveTools = params.tools ?? agentDefs?.tools;
2563
+ const effectiveSkills = params.skills ?? agentDefs?.skills;
2564
+ const effectiveAutoExit = resolveEffectiveAutoExit(params, agentDefs);
2565
+ const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
2566
+ const parentSessionFile = ctx.sessionManager.getSessionFile();
2567
+ if (!parentSessionFile) throw new Error("No session file");
2568
+
2569
+ const running = await launchPiSubagent({
2570
+ kind: "fresh",
2571
+ id,
2572
+ executionOwner: options?.executionOwner,
2573
+ name: params.name,
2574
+ task: params.task,
2575
+ agent: params.agent,
2576
+ cwd: params.cwd,
2577
+ worktree: params.worktree,
2578
+ fork: params.fork,
2579
+ surface: options?.surface,
2580
+ parent: {
2581
+ cwd: ctx.cwd,
2582
+ invocationCwd: process.cwd(),
2583
+ sessionFile: parentSessionFile,
2584
+ sessionId: ctx.sessionManager.getSessionId(),
2585
+ sessionDir: ctx.sessionManager.getSessionDir(),
2586
+ agentDir: getAgentConfigDir(),
2587
+ },
2588
+ runtimePlan,
2589
+ behavior: {
2590
+ tools: effectiveTools,
2591
+ skills: effectiveSkills,
2592
+ deniedTools: [...resolveDenyTools(agentDefs)],
2593
+ autoExit: effectiveAutoExit,
2594
+ interactive: effectiveInteractive,
2595
+ identity: agentDefs?.body ?? params.systemPrompt,
2596
+ systemPromptMode: agentDefs?.systemPromptMode,
2597
+ sessionMode: resolveEffectiveSessionMode(params, agentDefs),
2598
+ cwd: agentDefs?.cwd,
2599
+ },
2600
+ });
2601
+ runningSubagents.set(id, running);
2602
+ return running;
2603
+ }
2604
+
2605
+ /**
2606
+ * Watch a launched subagent until it exits. Polls for completion, extracts
2607
+ * the summary from the session file, and closes ordinary panes. Worktree
2608
+ * workspaces are retained for parent review.
2609
+ */
2610
+ async function deliverHelpRequest(
2611
+ running: RunningSubagent,
2612
+ ping: { id?: string; name: string; message: string },
2613
+ ): Promise<void> {
2614
+ const deliveryId = `help:${running.id}:${ping.id ?? Date.now()}`;
2615
+ await waitForDescendantDrain(running);
2616
+ const durable = prepareDurableDelivery(running, deliveryId, ping.message, "help-request");
2617
+ const details = {
2618
+ kind: "help-request",
2619
+ name: ping.name,
2620
+ task: running.task,
2621
+ agent: running.agent,
2622
+ childId: running.id,
2623
+ sessionFile: running.sessionFile,
2624
+ deliveryId,
2625
+ resultPath: durable.resultPath,
2626
+ resultContent: durable.payload,
2627
+ };
2628
+ if (running.lineage) {
2629
+ if (!appendLineageInbox(
2630
+ running.lineage.rootDir,
2631
+ running.id,
2632
+ deliveryId,
2633
+ {
2634
+ sessionId: running.lineage.parentSessionId,
2635
+ sessionFile: running.lineage.parentSessionFile,
2636
+ },
2637
+ { kind: "help-request", executionOwner: running.executionOwner, resultContent: durable.payload, resultPath: durable.resultPath, status: "help-request" },
2638
+ )) throw new Error("Unable to publish help-request inbox");
2639
+
2640
+ acknowledgePingSidecar(running.sessionFile, ping.id);
2641
+ if (!exactParentSessionActive(running) || !runtime.pi) return;
2642
+ const claim = claimLineageInboxMaterialization({
2643
+ rootDir: running.lineage.rootDir,
2644
+ deliveryId,
2645
+ nodeId: running.id,
2646
+ hasExactSessionEvidence: () => sessionContainsDelivery(running.lineage!.parentSessionFile!, deliveryId),
2647
+ });
2648
+ // Another observer owns this help request and will deliver it, so the
2649
+ // child continues without recording a false delivery failure.
2650
+ if (claim.status === "busy") return;
2651
+ if (claim.status === "materialized") return;
2652
+ try {
2653
+ sendSubagentResult(runtime.pi, durable.payload, details);
2654
+ if (!completeLineageInboxMaterialization(running.lineage.rootDir, deliveryId, running.id, claim.token)) {
2655
+ throw new Error("Unable to publish help-request materialization acknowledgement");
2656
+ }
2657
+ } catch (error) {
2658
+ releaseLineageInboxMaterialization({
2659
+ rootDir: running.lineage.rootDir,
2660
+ deliveryId,
2661
+ nodeId: running.id,
2662
+ token: claim.token,
2663
+ hasExactSessionEvidence: () => sessionContainsDelivery(running.lineage!.parentSessionFile!, deliveryId),
2664
+ });
2665
+ throw error;
2666
+ }
2667
+ } else {
2668
+ if (!runtime.pi) throw new Error("Parent API is unavailable");
2669
+ sendSubagentResult(runtime.pi, durable.payload, details);
2670
+ acknowledgePingSidecar(running.sessionFile, ping.id);
2671
+ }
2672
+ createDeliveryLog({ logPath: join(dirname(running.sessionFile), "subagent-delivery.log") })
2673
+ .record("delivery-materialized", { childId: running.id, deliveryId, status: "help-request", resultPath: durable.resultPath });
2674
+ }
2675
+
2676
+ async function attemptRecovery(
2677
+ running: RunningSubagent,
2678
+ cause: RecoveryCause,
2679
+ ): Promise<{ recovered: boolean; exhausted: boolean; reason?: string }> {
2680
+ const lineage = running.lineage;
2681
+ if (!lineage?.parentSessionId || !lineage.parentSessionFile) {
2682
+ return {
2683
+ recovered: false,
2684
+ exhausted: true,
2685
+ reason: "Recovery failed because the exact creator-parent session is unavailable.",
2686
+ };
2687
+ }
2688
+
2689
+ let budget: ReturnType<typeof consumeRecoveryAttempt>;
2690
+ try {
2691
+ budget = consumeRecoveryAttempt({
2692
+ logPath: recoveryLogPath(running),
2693
+ childId: running.id,
2694
+ cause,
2695
+ });
2696
+ } catch (error) {
2697
+ return {
2698
+ recovered: false,
2699
+ exhausted: true,
2700
+ reason: `Recovery failed because its durable budget could not be read or written: ${error instanceof Error ? error.message : String(error)}`,
2701
+ };
2702
+ }
2703
+ running.recoveryState = {
2704
+ cause,
2705
+ attempt: budget.attempt,
2706
+ label: budget.exhausted
2707
+ ? `recovery failed (${budget.attempt}/${3})`
2708
+ : `recovering ${cause} (${budget.attempt}/${3})`,
2709
+ };
2710
+ if (budget.exhausted) {
2711
+ // The budget is spent. Killing or resuming again would repeat the same
2712
+ // failed attempt once per status tick forever.
2713
+ running.recoveryExhausted = true;
2714
+ running.recoveryFailureReason = budget.reason;
2715
+ return { recovered: false, exhausted: true, reason: budget.reason };
2716
+ }
2717
+ running.supervisionEpoch = (running.supervisionEpoch ?? 0) + 1;
2718
+
2719
+ if (cause === "stale" && childPaneOwned(running)) {
2720
+ recoveryDeliveryLog(running).record("recovery-kill", {
2721
+ childId: running.id,
2722
+ attempt: budget.attempt,
2723
+ cause,
2724
+ surface: running.surface,
2725
+ });
2726
+ try {
2727
+ closePane(running.surface);
2728
+ await waitForPaneAbsence(running.surface, { timeoutMs: 5_000, intervalMs: 100 });
2729
+ } catch (error) {
2730
+ recoveryDeliveryLog(running).record("recovery-kill-failed", {
2731
+ childId: running.id,
2732
+ attempt: budget.attempt,
2733
+ cause,
2734
+ error,
2735
+ });
2736
+ }
2737
+ }
2738
+ updateWidget();
2739
+
2740
+ let reusableSurface: string | undefined;
2741
+ if (cause === "crash") {
2742
+ try {
2743
+ if (childPaneOwned(running)) {
2744
+ getPaneProcessInfo(running.surface);
2745
+ reusableSurface = running.surface;
2746
+ }
2747
+ } catch {
2748
+ reusableSurface = undefined;
2749
+ }
2750
+ }
2751
+
2752
+ const priorLocation = childSessionLocation(lineage.rootDir, running.id);
2753
+ try {
2754
+ const resumed = await launchPiSubagent({
2755
+ kind: "resume",
2756
+ executionOwner: running.executionOwner,
2757
+ id: running.id,
2758
+ name: running.name,
2759
+ sessionFile: running.sessionFile,
2760
+ message: `Your previous process became ${cause === "crash" ? "unavailable" : "stale"}. Resume this exact session and continue the assigned task. Do not repeat already completed work.`,
2761
+ ...(reusableSurface ? { surface: reusableSurface } : {}),
2762
+
2763
+ ...(running.recoveryWorkspace ? { workspace: running.recoveryWorkspace } : {}),
2764
+ parent: {
2765
+ sessionId: lineage.parentSessionId,
2766
+ sessionFile: lineage.parentSessionFile,
2767
+ sessionDir: dirname(lineage.parentSessionFile),
2768
+ },
2769
+ behavior: {
2770
+ autoExit: !running.interactive,
2771
+ interactive: running.interactive,
2772
+ },
2773
+ });
2774
+ // A launch returns before Pi binds. Do not let the new watcher follow
2775
+ // the old closed pane and mistake recovery for a user's cancellation.
2776
+ const bindDeadline = Date.now() + 30_000;
2777
+ for (;;) {
2778
+ const location = childSessionLocation(lineage.rootDir, running.id);
2779
+ if (location?.paneId === resumed.surface &&
2780
+ (location.pid !== priorLocation?.pid || location.processStart !== priorLocation?.processStart) &&
2781
+ sessionOwnsPane(location, resumed.surface, getPaneProcessInfo(resumed.surface).shellPid)) break;
2782
+ if (Date.now() >= bindDeadline) throw new Error("Recovery replacement did not register its session location.");
2783
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
2784
+ }
2785
+ const epoch = running.supervisionEpoch;
2786
+ const recoveryState = running.recoveryState;
2787
+ const stable = {
2788
+ task: running.task,
2789
+ agent: running.agent,
2790
+ runtimePlan: running.runtimePlan,
2791
+ worktree: running.worktree,
2792
+
2793
+ recoveryWorkspace: running.recoveryWorkspace,
2794
+ };
2795
+ Object.assign(running, resumed, stable);
2796
+ running.supervisionEpoch = epoch;
2797
+
2798
+ running.ignorePriorSentinelEpoch = epoch;
2799
+ running.recoveryState = recoveryState;
2800
+ recoveryDeliveryLog(running).record("recovery-resumed", {
2801
+ childId: running.id,
2802
+ attempt: budget.attempt,
2803
+ cause,
2804
+ surface: running.surface,
2805
+ });
2806
+ updateWidget();
2807
+ return { recovered: true, exhausted: false };
2808
+ } catch (error) {
2809
+ recoveryDeliveryLog(running).record("recovery-launch-failed", {
2810
+ childId: running.id,
2811
+ attempt: budget.attempt,
2812
+ cause,
2813
+ error,
2814
+ });
2815
+ running.recoveryState = {
2816
+ cause,
2817
+ attempt: budget.attempt,
2818
+ label: `recovery launch failed (${budget.attempt}/${3})`,
2819
+ };
2820
+ updateWidget();
2821
+ return { recovered: false, exhausted: false };
2822
+ }
2823
+ }
2824
+
2825
+ async function watchSubagent(
2826
+ running: RunningSubagent,
2827
+ signal: AbortSignal,
2828
+ ): Promise<SubagentResult> {
2829
+ const { name, task, startTime } = running;
2830
+ let sessionFile = running.sessionFile;
2831
+
2832
+ const supervisionEpoch = running.supervisionEpoch ?? 0;
2833
+ let recoveryFailed = false;
2834
+
2835
+ let cancelledByUser = false;
2836
+
2837
+ try {
2838
+ let result;
2839
+ for (;;) {
2840
+ result = await waitForCompletion(signal, {
2841
+ intervalMs: 1000,
2842
+ sessionFile,
2843
+ resolveSessionFile: () => { refreshChildLocation(running); return running.sessionFile; },
2844
+ readTerminalTail: () => {
2845
+ refreshChildLocation(running);
2846
+ const active = running.replication ? launchFailureProcessActive(running) : !childPaneOwned(running);
2847
+ return !childMovePending(running) && !active ? readPaneAsync(running.surface, 5) : Promise.resolve("");
2848
+ },
2849
+ confirmTerminalExit: running.replication ? () => !childMovePending(running) && !launchFailureProcessActive(running) : undefined,
2850
+ readFailureOutput: running.replication ? async () => {
2851
+ refreshChildLocation(running);
2852
+ if (childMovePending(running) || !childPaneOwned(running)) return "";
2853
+ try {
2854
+ const output = await readHerdrScreenAsync(running.surface, 100, "recent-unwrapped");
2855
+ if (output.trim()) return output;
2856
+ } catch { /* The visible tail remains useful if scrollback is unavailable. */ }
2857
+ return readPaneAsync(running.surface, 100);
2858
+ } : undefined,
2859
+ inspectPane: async () => {
2860
+ refreshChildLocation(running);
2861
+ if (childMovePending(running)) return { kind: "unavailable" as const, error: "Waiting for the Teleport replacement to bind" };
2862
+ return childPaneOwned(running) ? inspectPane(running.surface) : { kind: "missing" as const, error: "Original child pane incarnation is gone" };
2863
+ },
2864
+ onPaneInspection: (inspection: PaneInspection, observedAt: number) => {
2865
+ ensureLifecycle(running);
2866
+ running.lifecycle = observePaneInspection(
2867
+ running.lifecycle,
2868
+ inspection,
2869
+ observedAt,
2870
+ );
2871
+ updateWidget();
2872
+ },
2873
+ onTick() {
2874
+ observeRunningSubagent(running);
2875
+ },
2876
+ });
2877
+ sessionFile = running.sessionFile;
2878
+ if (supervisionEpoch !== (running.supervisionEpoch ?? 0)) {
2879
+ return {
2880
+ name,
2881
+ task,
2882
+ summary: "Superseded by automatic same-session recovery.",
2883
+ sessionFile,
2884
+ exitCode: 0,
2885
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
2886
+ supervisionEpoch,
2887
+ };
2888
+ }
2889
+ if (!result.ping) {
2890
+ observeRunningSubagent(running);
2891
+ if (result.paneDisappeared) {
2892
+ const location = running.lineage && childSessionLocation(running.lineage.rootDir, running.id);
2893
+ if (!location || (!location.moveDeadline && sessionServerAlive(location) !== false)) {
2894
+ cancelledByUser = await recordManualTabCancellation(running);
2895
+ break;
2896
+ }
2897
+ result = { ...result, reason: "sentinel" as const, exitCode: 1, paneDisappeared: undefined };
2898
+ }
2899
+
2900
+ if (
2901
+ result.reason === "sentinel" &&
2902
+ result.exitCode !== 0 &&
2903
+ running.ignorePriorSentinelEpoch === supervisionEpoch
2904
+ ) {
2905
+ running.ignorePriorSentinelEpoch = undefined;
2906
+ continue;
2907
+ }
2908
+ if (running.replication && result.reason === "sentinel") {
2909
+ const errorMessage = result.errorMessage
2910
+ ? `Replica process exited with code ${result.exitCode} before saving completion.\n\n${result.errorMessage}`
2911
+ : "Replica process closed without a saved completion.";
2912
+ result = { reason: "error" as const, exitCode: 1, errorMessage };
2913
+ break;
2914
+ }
2915
+ if (result.reason === "sentinel" && result.exitCode !== 0 && !result.errorMessage) {
2916
+ let recovery: Awaited<ReturnType<typeof attemptRecovery>> | undefined;
2917
+ const recoveryLock = (async () => {
2918
+ recovery = await attemptRecovery(running, "crash");
2919
+ })();
2920
+ running.recoveryAttempt = recoveryLock;
2921
+ try {
2922
+ await recoveryLock;
2923
+ } finally {
2924
+ if (running.recoveryAttempt === recoveryLock) running.recoveryAttempt = undefined;
2925
+ }
2926
+ if (!recovery) throw new Error("Crash recovery did not produce a result");
2927
+ if (recovery.recovered) return watchSubagent(running, signal);
2928
+ if (!recovery.exhausted) continue;
2929
+ recoveryFailed = true;
2930
+ result = {
2931
+ reason: "error" as const,
2932
+ exitCode: 1,
2933
+ errorMessage: recovery.reason ?? "Recovery failed.",
2934
+ };
2935
+ }
2936
+ break;
2937
+ }
2938
+ try {
2939
+ await deliverHelpRequest(running, result.ping);
2940
+ running.deliveryFailure = undefined;
2941
+ updateWidget();
2942
+ } catch (error) {
2943
+ recordDeliveryFailure(running, `help:${running.id}:${result.ping.id ?? "unknown"}`, error, "help-request");
2944
+ }
2945
+ }
2946
+ // Refresh the final child state before terminal delivery.
2947
+ observeRunningSubagent(running);
2948
+ await runtime.resultDeliveryQueue.enqueue(running.id, () => undefined);
2949
+
2950
+ const detectedAt = Date.now();
2951
+ running.lifecycle = markCompletionDetected(
2952
+ running.lifecycle,
2953
+ result,
2954
+ detectedAt,
2955
+ );
2956
+ updateWidget();
2957
+ const elapsed = Math.floor((detectedAt - startTime) / 1000);
2958
+
2959
+ let summary: string;
2960
+
2961
+ let lastAssistant: string | null = null;
2962
+ if (existsSync(sessionFile)) {
2963
+ const allEntries = getNewEntries(sessionFile, 0);
2964
+ const observed = findObservedSessionRuntime(allEntries);
2965
+ if (running.runtimePlan && observed.provider && observed.modelId) {
2966
+ const observedModel = `${observed.provider}/${observed.modelId}`;
2967
+ const observedThinking =
2968
+ observed.thinking === "off" ||
2969
+ observed.thinking === "minimal" ||
2970
+ observed.thinking === "low" ||
2971
+ observed.thinking === "medium" ||
2972
+ observed.thinking === "high" ||
2973
+ observed.thinking === "xhigh" ||
2974
+ observed.thinking === "max"
2975
+ ? observed.thinking
2976
+ : undefined;
2977
+ const mismatch =
2978
+ observedModel === running.runtimePlan.model
2979
+ ? undefined
2980
+ : `Resolved model ${running.runtimePlan.model} but child reported ${observedModel}`;
2981
+ running.runtimePlan = {
2982
+ ...running.runtimePlan,
2983
+ ...(observedThinking ? { thinking: observedThinking } : {}),
2984
+ observed: {
2985
+ model: observedModel,
2986
+ ...(observedThinking ? { thinking: observedThinking } : {}),
2987
+ },
2988
+ ...(mismatch ? { runtimeMismatch: mismatch } : {}),
2989
+ };
2990
+ }
2991
+ lastAssistant = running.replication
2992
+ ? running.sessionBaseline
2993
+ ? findLastAssistantMessage(readEntriesAfterBaseline(sessionFile, running.sessionBaseline).entries.filter(entry => findNewestAppendedAssistant([entry])?.text?.trim()))
2994
+ : null // Without a fork boundary, inherited parent answers are not replica output.
2995
+ : findLastAssistantMessage(allEntries);
2996
+ summary =
2997
+ result.summary ??
2998
+ lastAssistant ??
2999
+ (result.errorMessage
3000
+ ? `Subagent error: ${result.errorMessage}`
3001
+ : result.exitCode === 0
3002
+ ? "Sub-agent exited without output"
3003
+ : `Sub-agent exited with code ${result.exitCode}`);
3004
+ } else {
3005
+ summary = result.errorMessage
3006
+ ? `Subagent error: ${result.errorMessage}`
3007
+ : result.exitCode === 0
3008
+ ? "Sub-agent exited without output"
3009
+ : `Sub-agent exited with code ${result.exitCode}`;
3010
+ }
3011
+
3012
+ if (cancelledByUser && !running.replication) summary = "Subagent cancelled by user.";
3013
+ const closeWithAnswer = !running.replication && !cancelledByUser && !!result.paneDisappeared && !!lastAssistant?.trim();
3014
+ const effectiveExitCode = closeWithAnswer ? 0 : result.exitCode;
3015
+ if (closeWithAnswer) summary = lastAssistant!;
3016
+
3017
+ const worktreeHandoff = running.replication ? undefined : finalizeSubagentSurface(
3018
+ running,
3019
+ result.ping
3020
+ ? "needs_help"
3021
+ : effectiveExitCode === 0
3022
+ ? "ready_for_review"
3023
+ : "failed",
3024
+ false,
3025
+ false,
3026
+ );
3027
+ running.lifecycle =
3028
+ effectiveExitCode === 0
3029
+ ? markCompleted(running.lifecycle, Date.now())
3030
+ : markFailed(
3031
+ running.lifecycle,
3032
+ result.errorMessage ?? summary,
3033
+ Date.now(),
3034
+ effectiveExitCode,
3035
+ );
3036
+
3037
+ return {
3038
+ name,
3039
+ task,
3040
+ summary,
3041
+ sessionFile,
3042
+ exitCode: effectiveExitCode,
3043
+ elapsed,
3044
+ ping: result.ping,
3045
+ ...(running.replication && cancelledByUser ? { errorMessage: "Replica pane closed without a saved completion." } : !closeWithAnswer && !cancelledByUser && result.errorMessage ? { errorMessage: result.errorMessage } : {}),
3046
+ ...(closeWithAnswer ? { closeWithAnswer: true as const } : {}),
3047
+
3048
+ supervisionEpoch,
3049
+ ...(recoveryFailed ? { recoveryFailed: true as const } : {}),
3050
+
3051
+ ...(cancelledByUser ? { cancelledByUser: true as const } : {}),
3052
+ ...(result.paneDisappeared ? { paneDisappeared: true as const } : {}),
3053
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
3054
+ };
3055
+ } catch (err: any) {
3056
+ const worktreeHandoff = running.replication ? undefined : finalizeSubagentSurface(running, "failed", true, false);
3057
+ running.lifecycle = markFailed(
3058
+ running.lifecycle,
3059
+ signal.aborted ? "Subagent cancelled." : (err?.message ?? String(err)),
3060
+ Date.now(),
3061
+ 1,
3062
+ );
3063
+ updateWidget();
3064
+
3065
+ if (signal.aborted) {
3066
+ return {
3067
+ name,
3068
+ task,
3069
+ summary: "Subagent cancelled.",
3070
+ exitCode: 1,
3071
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
3072
+ error: "cancelled",
3073
+ sessionFile,
3074
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
3075
+
3076
+ supervisionEpoch,
3077
+ };
3078
+ }
3079
+ return {
3080
+ name,
3081
+ task,
3082
+ summary: `Subagent error: ${err?.message ?? String(err)}`,
3083
+ exitCode: 1,
3084
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
3085
+ error: err?.message ?? String(err),
3086
+
3087
+ supervisionEpoch,
3088
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
3089
+ };
3090
+ }
3091
+ }
3092
+
3093
+ function installSubagentRuntime(pi: ExtensionAPI) {
3094
+ runtime.pi = pi;
3095
+ runtime.resolveExecutionOwner = trackExecutionOwners(pi);
3096
+ // Capture the UI context for widget updates and restore presentation for
3097
+ // subagents whose watchers survived a reload.
3098
+ pi.on("session_start", (_event, ctx) => {
3099
+ runtime.latestCtx = ctx;
3100
+ runtime.handoffPending = false;
3101
+ const binding = bindSessionRuntime(pi, ctx);
3102
+ const workFile = binding?.child?.environment.PI_SUBAGENT_REPLICA_WORK_FILE;
3103
+ const location = binding?.child && childSessionLocation(binding.child.lineage.rootDir, binding.child.lineage.nodeId);
3104
+ if (workFile && location) captureReplicaWorkLocation(workFile, location);
3105
+ if (binding?.child && !hasChildLifecycle(pi)) subagentLifecycle(pi, ctx);
3106
+ restoreLineageRuntime(ctx);
3107
+ void restorePendingLineageInboxes(ctx);
3108
+ if (runningSubagents.size > 0) {
3109
+ startWidgetRefresh();
3110
+ startStatusRefresh(pi);
3111
+ updateWidget();
3112
+ }
3113
+ });
3114
+
3115
+ // A session-start replay can race the parent's startup turn. Retry after the
3116
+ // agent becomes idle so an "already processing" steer stays durable and pending.
3117
+ pi.on("agent_end", (_event, ctx) => {
3118
+ if (launchFailurePending) {
3119
+ launchFailurePending = false;
3120
+ restoreLineageRuntime(ctx);
3121
+ }
3122
+ setTimeout(() => {
3123
+ if (runtime.latestCtx) void restorePendingLineageInboxes(runtime.latestCtx);
3124
+ }, 0);
3125
+ });
3126
+
3127
+ pi.on("tool_call", (event, ctx) => {
3128
+ if (event.toolName !== "teleport" || !["jump", "back"].includes(String(event.input.action))) return;
3129
+ runtime.handoffPending = true;
3130
+ const file = ctx.sessionManager.getSessionFile();
3131
+ const binding = file && readSessionBinding(file);
3132
+ if (binding) beginChildSessionMove(binding);
3133
+ });
3134
+ pi.on("tool_result", (event, ctx) => {
3135
+ if (event.toolName !== "teleport" || !event.isError) return;
3136
+ runtime.handoffPending = false;
3137
+ bindSessionRuntime(pi, ctx);
3138
+ });
3139
+ pi.on("message_end", (event, ctx) => {
3140
+ const message = event.message as { customType?: string };
3141
+ if (message.customType !== "pi-agent-teleport-failure") return;
3142
+ runtime.handoffPending = false;
3143
+ bindSessionRuntime(pi, ctx);
3144
+ });
3145
+ // Clean up on session shutdown
3146
+ pi.on("session_shutdown", async (event, _ctx) => {
3147
+ runtime.latestCtx = undefined;
3148
+ if (widgetInterval) {
3149
+ clearInterval(widgetInterval);
3150
+ widgetInterval = null;
3151
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
3152
+ }
3153
+ if (statusInterval) {
3154
+ clearInterval(statusInterval);
3155
+ statusInterval = null;
3156
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
3157
+ }
3158
+
3159
+ const shutdownReason = (event as any).reason;
3160
+ cleanupSubagentsForShutdown(shutdownReason, runningSubagents);
3161
+ });
3162
+
3163
+ registerSubagentRenderers(pi);
3164
+ }
3165
+
3166
+ /** Register fresh replication while retaining internal launch and delivery APIs. */
3167
+ export default function subagentsExtension(pi: ExtensionAPI) {
3168
+ registerReplicaDirectoryCommand(pi);
3169
+ // Discard the old disclosure handle on reload; in-flight watchers may still use it.
3170
+ delete (runtime as SubagentRuntime & { replicateTool?: unknown }).replicateTool;
3171
+ registerReplicateTool(pi, async (params, ctx, toolCallId) => {
3172
+ const executionOwner = runtime.resolveExecutionOwner?.(toolCallId);
3173
+ if (!isTerminalAvailable()) throw new Error("Replication requires Herdr.");
3174
+ const file = ctx.sessionManager.getSessionFile();
3175
+ if (!file || !ctx.model) throw new Error("Replication requires a persisted session and selected model.");
3176
+ const snapshot = {
3177
+ entries: JSON.parse(JSON.stringify(ctx.sessionManager.getBranch())),
3178
+ contextMessages: JSON.parse(JSON.stringify(replicationContextMessages(ctx))),
3179
+ systemPrompt: ctx.getSystemPrompt(),
3180
+ extensionArgs: replicationExtensionArgs(process.argv, ctx.cwd),
3181
+ };
3182
+ bindSessionRuntime(pi, ctx);
3183
+ const id = Math.random().toString(16).slice(2, 10);
3184
+ const name = replicationName(params.name, pi, ctx, getHerdrCurrentPaneTitle);
3185
+ const recordFile = replicaWorkFile(sessionBindingStore(file), id);
3186
+ try {
3187
+ const mode = readReplicaDirectoryMode(ctx.cwd);
3188
+ const source = mode === "worktree" ? snapshotSource(ctx.cwd, join(recordFile + ".artifacts", "fork")) : undefined;
3189
+ const inherited = { systemPrompt: snapshot.systemPrompt, extensionArgs: snapshot.extensionArgs, provider: ctx.model.provider, modelId: ctx.model.id, thinking: pi.getThinkingLevel(), tools: pi.getActiveTools() };
3190
+ if (source) createReplicaWork(recordFile, id, ctx.sessionManager.getSessionId(), source);
3191
+ else createSharedReplicaWork(recordFile, id, ctx.sessionManager.getSessionId(), ctx.cwd);
3192
+ saveReplicaRuntime(recordFile, inherited);
3193
+ const plan = resolveRuntimePlan({}, { provider: inherited.provider, modelId: inherited.modelId, thinking: inherited.thinking as ThinkingLevel }, wrapPiModelRegistry(ctx.modelRegistry));
3194
+ const child = await launchPiSubagent({
3195
+ kind: "fresh", id, name, task: replicationAssignment(params.task), executionOwner,
3196
+ fork: true, replication: { ...snapshot, recordFile }, runtimePlan: plan,
3197
+ ...(source ? { worktree: { branch: await reserveReplicaBranch(ctx.cwd, name, source.commit), base: source.commit } } : {}),
3198
+ parent: { cwd: ctx.cwd, invocationCwd: ctx.cwd, sessionFile: file, sessionId: ctx.sessionManager.getSessionId(), sessionDir: ctx.sessionManager.getSessionDir(), agentDir: getAgentConfigDir() },
3199
+ behavior: { tools: inherited.tools.join(","), deniedTools: [], autoExit: true, interactive: false, sessionMode: "fork", includeControlTools: false },
3200
+ });
3201
+ runningSubagents.set(id, child);
3202
+ startWidgetRefresh();
3203
+ startStatusRefresh(pi);
3204
+ startRecoveredWatcher(child);
3205
+ return { id, name, sessionFile: child.sessionFile, worktreePath: child.worktree?.path };
3206
+ } catch (error) {
3207
+ if (existsSync(recordFile)) {
3208
+ failReplicaLaunch(recordFile, error, toolCallId);
3209
+ launchFailurePending = true;
3210
+ }
3211
+ throw error;
3212
+ }
3213
+ });
3214
+ installSubagentRuntime(pi);
3215
+ }
3216
+
3217
+
3218
+
3219
+
3220
+
3221
+ function registerSubagentRenderers(pi: ExtensionAPI) {
3222
+ // ── subagent_result message renderer ──
3223
+ pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
3224
+ const details = message.details as any;
3225
+ if (!details) return undefined;
3226
+
3227
+ return {
3228
+ invalidate() {},
3229
+ render(width: number): string[] {
3230
+ const name = details.name ?? "subagent";
3231
+ const exitCode = details.exitCode ?? 0;
3232
+ const errorMessage =
3233
+ typeof details.errorMessage === "string" ? details.errorMessage : "";
3234
+ const failed = exitCode !== 0 || !!errorMessage || ["error", "crashed", "recovery-failed", "cancelled"].includes(details.status);
3235
+ const needsHelp = details.kind === "help-request" || details.status === "help-request";
3236
+ const elapsed =
3237
+ details.elapsed == null ? "?" : formatElapsed(details.elapsed);
3238
+ const bgFn = (text: string) => theme.bg(failed ? "toolErrorBg" : needsHelp ? "customMessageBg" : "toolSuccessBg", text);
3239
+ const icon = failed ? theme.fg("error", "✗") : needsHelp ? theme.fg("warning", "?") : theme.fg("success", "✓");
3240
+ const status = errorMessage
3241
+ ? "failed (provider/agent error)"
3242
+ : failed
3243
+ ? `failed (exit ${exitCode})`
3244
+ : needsHelp ? "needs help" : "completed";
3245
+ const agentTag = details.agent
3246
+ ? theme.fg("dim", ` (${details.agent})`)
3247
+ : "";
3248
+
3249
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
3250
+ const rawContent =
3251
+ typeof details.resultContent === "string"
3252
+ ? details.resultContent
3253
+ : typeof message.content === "string"
3254
+ ? message.content
3255
+ : "";
3256
+
3257
+ // Clean summary (remove session ref and leading label for display)
3258
+ const summary = rawContent
3259
+ .replace(/\n\nSession: .+\nResume: .+$/, "")
3260
+ .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
3261
+ .replace(
3262
+ `Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`,
3263
+ "",
3264
+ )
3265
+ .replace(
3266
+ new RegExp(
3267
+ `^Sub-agent "${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" failed after ${elapsed} \\(provider/agent error — auto-retry exhausted\\)\\.\\n\\n`,
3268
+ ),
3269
+ "",
3270
+ );
3271
+
3272
+ // Build content for the box
3273
+ const contentLines = [header];
3274
+
3275
+ if (options.expanded) {
3276
+ // Full view: complete summary + session info
3277
+ if (summary) {
3278
+ for (const line of summary.split("\n")) {
3279
+ contentLines.push(line);
3280
+ }
3281
+ }
3282
+ if (details.sessionFile) {
3283
+ contentLines.push("");
3284
+ contentLines.push(
3285
+ theme.fg("dim", `Session: ${details.sessionFile}`),
3286
+ );
3287
+ contentLines.push(
3288
+ theme.fg("dim", `Resume: pi --session ${details.sessionFile}`),
3289
+ );
3290
+ }
3291
+ } else {
3292
+ // Collapsed: preview + expand hint
3293
+ if (summary) {
3294
+ const previewLines = summary.split("\n").slice(0, 5);
3295
+ for (const line of previewLines) {
3296
+ contentLines.push(theme.fg("dim", line.slice(0, width - 6)));
3297
+ }
3298
+ const totalLines = summary.split("\n").length;
3299
+ if (totalLines > 5) {
3300
+ contentLines.push(
3301
+ theme.fg("muted", `… ${totalLines - 5} more lines`),
3302
+ );
3303
+ }
3304
+ }
3305
+ contentLines.push(
3306
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
3307
+ );
3308
+ }
3309
+
3310
+ // Render via Box for background + padding, with blank line above for separation
3311
+ const box = new Box(1, 1, bgFn);
3312
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
3313
+ return ["", ...box.render(width)];
3314
+ },
3315
+ };
3316
+ });
3317
+
3318
+ // ── subagent_status message renderer ──
3319
+ pi.registerMessageRenderer("subagent_status", (message, options, theme) => {
3320
+ const details = message.details as any;
3321
+ const lines = Array.isArray(details?.lines) ? details.lines : [];
3322
+ const overflow =
3323
+ typeof details?.overflow === "number" ? details.overflow : 0;
3324
+ if (lines.length === 0 && overflow === 0) return undefined;
3325
+
3326
+ return {
3327
+ invalidate() {},
3328
+ render(width: number): string[] {
3329
+ const lineWidth = Math.max(0, width - 6);
3330
+ const contentLines = [
3331
+ `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`,
3332
+ ...lines.map((line: string) =>
3333
+ theme.fg("dim", truncateToWidth(line, lineWidth)),
3334
+ ),
3335
+ ];
3336
+
3337
+ if (overflow > 0) {
3338
+ contentLines.push(theme.fg("muted", `+${overflow} more running.`));
3339
+ }
3340
+ if (!options.expanded) {
3341
+ contentLines.push(
3342
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
3343
+ );
3344
+ }
3345
+
3346
+ const box = new Box(1, 1, (text: string) =>
3347
+ theme.bg("customMessageBg", text),
3348
+ );
3349
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
3350
+ return ["", ...box.render(width)];
3351
+ },
3352
+ };
3353
+ });
3354
+
3355
+ // ── subagent_ping message renderer ──
3356
+ pi.registerMessageRenderer("subagent_ping", (message, options, theme) => {
3357
+ const details = message.details as any;
3358
+ if (!details) return undefined;
3359
+
3360
+ return {
3361
+ invalidate() {},
3362
+ render(width: number): string[] {
3363
+ const name = details.name ?? "subagent";
3364
+ const agentTag = details.agent
3365
+ ? theme.fg("dim", ` (${details.agent})`)
3366
+ : "";
3367
+ const bgFn = (text: string) => theme.bg("toolSuccessBg", text);
3368
+
3369
+ const icon = theme.fg("accent", "?");
3370
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`;
3371
+
3372
+ const contentLines = [header];
3373
+
3374
+ if (options.expanded) {
3375
+ contentLines.push("");
3376
+ contentLines.push(details.message ?? "");
3377
+ if (details.sessionFile) {
3378
+ contentLines.push("");
3379
+ contentLines.push(
3380
+ theme.fg("dim", `Session: ${details.sessionFile}`),
3381
+ );
3382
+ }
3383
+ } else {
3384
+ const preview = (details.message ?? "")
3385
+ .split("\n")[0]
3386
+ .slice(0, width - 10);
3387
+ contentLines.push(theme.fg("dim", preview));
3388
+ contentLines.push(
3389
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
3390
+ );
3391
+ }
3392
+
3393
+ const box = new Box(1, 1, bgFn);
3394
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
3395
+ return ["", ...box.render(width)];
3396
+ },
3397
+ };
3398
+ });
3399
+
3400
+ }