pi-agent-replicate 0.0.0-stage → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- 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
|
+
}
|