@hheei/pi-subagents 0.1.2 → 0.2.2
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/README.md +42 -137
- package/dist/agent-resolver.d.ts +20 -0
- package/dist/agent-resolver.js +155 -21
- package/dist/bridge-client.d.ts +55 -0
- package/dist/bridge-client.js +453 -0
- package/dist/bridge-server.d.ts +72 -0
- package/dist/bridge-server.js +490 -0
- package/dist/builtin-agents.d.ts +24 -0
- package/dist/builtin-agents.js +152 -0
- package/dist/child-bridge.d.ts +31 -15
- package/dist/child-bridge.js +95 -117
- package/dist/child-control.d.ts +58 -0
- package/dist/child-control.js +176 -0
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +93 -0
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +52 -52
- package/dist/diagnostics.d.ts +0 -2
- package/dist/diagnostics.js +0 -8
- package/dist/domain.d.ts +57 -27
- package/dist/domain.js +60 -11
- package/dist/extension.d.ts +26 -1
- package/dist/extension.js +243 -46
- package/dist/host-adapter.d.ts +37 -12
- package/dist/host-adapter.js +214 -63
- package/dist/json-lines.js +2 -1
- package/dist/launch-spec.d.ts +8 -6
- package/dist/launch-spec.js +61 -18
- package/dist/manager.d.ts +93 -49
- package/dist/manager.js +1175 -528
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +21 -0
- package/dist/protocol.d.ts +33 -52
- package/dist/protocol.js +41 -52
- package/dist/registry.d.ts +3 -8
- package/dist/registry.js +41 -163
- package/dist/rpc-events.d.ts +3 -5
- package/dist/rpc-events.js +5 -9
- package/dist/runtime.d.ts +66 -11
- package/dist/runtime.js +182 -208
- package/dist/session-bootstrap.d.ts +23 -7
- package/dist/session-bootstrap.js +79 -15
- package/dist/session-fork.d.ts +16 -0
- package/dist/session-fork.js +67 -0
- package/dist/state.d.ts +7 -0
- package/dist/state.js +60 -25
- package/dist/tool-activation.d.ts +18 -0
- package/dist/tool-activation.js +227 -0
- package/dist/tools.d.ts +6 -3
- package/dist/tools.js +213 -72
- package/dist/widget.d.ts +4 -8
- package/dist/widget.js +55 -64
- package/package.json +51 -52
- package/dist/child-nudge.d.ts +0 -24
- package/dist/child-nudge.js +0 -102
- package/dist/connector.d.ts +0 -53
- package/dist/connector.js +0 -283
- package/dist/pause-gate.d.ts +0 -14
- package/dist/pause-gate.js +0 -64
- package/dist/rpc-adapter.d.ts +0 -40
- package/dist/rpc-adapter.js +0 -194
- package/dist/runner-entry.d.ts +0 -24
- package/dist/runner-entry.js +0 -166
- package/dist/runner.d.ts +0 -52
- package/dist/runner.js +0 -930
package/dist/manager.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import { isRecord } from "
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { planSessionPlacement } from "./session-bootstrap.js";
|
|
1
|
+
import { errorMessage, isRecord } from "@hheei/pi-ext-core";
|
|
2
|
+
import { stripHindsightContent } from "./launch-spec.js";
|
|
3
|
+
import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
|
|
4
|
+
import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
|
|
5
|
+
import { readSessionJsonlEntries } from "./session-fork.js";
|
|
5
6
|
import { createStateProjector } from "./state.js";
|
|
7
|
+
/**
|
|
8
|
+
* How long an idle child keeps its runtime. A background child holds a whole Pi process and its
|
|
9
|
+
* session lock, so an idle one is reclaimed after this window and a later `send_agent` starts it
|
|
10
|
+
* again from the same session.
|
|
11
|
+
*/
|
|
12
|
+
export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
|
|
13
|
+
export const SUBAGENT_SETTLED_CLOSE_TIMEOUT_MS = 15_000;
|
|
14
|
+
/** How long stop waits for an adopted child's bridge to go away before calling the exit unconfirmed. */
|
|
15
|
+
export const SUBAGENT_EXIT_WAIT_MS = 2_000;
|
|
16
|
+
const STOPPED_SEND_REASON = "Child is stopped; a stopped child never resumes — spawn a new child if this work is still needed";
|
|
17
|
+
function diagnose(message) {
|
|
18
|
+
if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
|
|
19
|
+
console.error(`pi-subagents: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
6
22
|
function failure(operation, reason, childId, state, sideEffects = [], safeToRetry = true) {
|
|
7
23
|
return {
|
|
8
24
|
operation,
|
|
@@ -21,10 +37,11 @@ function publicChild(record, live) {
|
|
|
21
37
|
? {}
|
|
22
38
|
: { displayName: record.launchConfig.agent.displayName }),
|
|
23
39
|
state: record.state,
|
|
24
|
-
|
|
40
|
+
presentation: record.presentation,
|
|
25
41
|
cwd: record.cwd,
|
|
26
42
|
sessionId: record.sessionId,
|
|
27
43
|
...(record.latestSummary === undefined ? {} : { summary: record.latestSummary }),
|
|
44
|
+
...(record.activeTool === undefined ? {} : { activeTool: record.activeTool }),
|
|
28
45
|
...(record.usage === undefined ? {} : { usage: record.usage }),
|
|
29
46
|
...(record.interrupted === undefined ? {} : { interrupted: record.interrupted }),
|
|
30
47
|
freshness: live ? "live" : "last_known",
|
|
@@ -47,16 +64,6 @@ function withDeadline(operation, timeoutMs) {
|
|
|
47
64
|
});
|
|
48
65
|
});
|
|
49
66
|
}
|
|
50
|
-
async function waitForRunnerDisconnect(runner, timeoutMs, signal) {
|
|
51
|
-
const deadline = Date.now() + timeoutMs;
|
|
52
|
-
while (runner.connected) {
|
|
53
|
-
if (signal?.aborted)
|
|
54
|
-
throw new Error("Runner shutdown wait was aborted");
|
|
55
|
-
if (Date.now() >= deadline)
|
|
56
|
-
throw new Error("Runner did not disconnect after shutdown");
|
|
57
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
67
|
function sessionContainsUserInput(entries, expected) {
|
|
61
68
|
return entries.some((entry) => {
|
|
62
69
|
const value = isRecord(entry) ? entry : undefined;
|
|
@@ -76,27 +83,90 @@ function sessionContainsUserInput(entries, expected) {
|
|
|
76
83
|
return text === expected;
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
function
|
|
84
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Extracts the trailing text of the most recent assistant message in session entries.
|
|
88
|
+
* Used to automatically capture a subagent's final output without requiring a tool call.
|
|
89
|
+
*/
|
|
90
|
+
export function extractLastAssistantText(entries) {
|
|
91
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
92
|
+
const entry = entries[i];
|
|
93
|
+
const message = isRecord(entry) && isRecord(entry.message) ? entry.message : undefined;
|
|
94
|
+
if (message?.role !== "assistant")
|
|
95
|
+
continue;
|
|
96
|
+
if (typeof message.content === "string") {
|
|
97
|
+
const text = message.content.trim();
|
|
98
|
+
if (text !== "")
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
else if (Array.isArray(message.content)) {
|
|
102
|
+
const text = message.content
|
|
103
|
+
.filter((p) => isRecord(p) && p.type === "text" && typeof p.text === "string")
|
|
104
|
+
.map((p) => p.text)
|
|
105
|
+
.join("\n")
|
|
106
|
+
.trim();
|
|
107
|
+
if (text !== "")
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
85
112
|
}
|
|
86
113
|
export class SubagentManager {
|
|
87
114
|
#deps;
|
|
88
115
|
#chains = new Map();
|
|
89
|
-
#
|
|
116
|
+
#processes = new Map();
|
|
117
|
+
/** Panels this process opened. A panel child's process is held by the host, not by us. */
|
|
118
|
+
#attachments = new Map();
|
|
119
|
+
/**
|
|
120
|
+
* Children whose process moved to another session. The connection may still be up, but that
|
|
121
|
+
* process is not this child's runtime any more, so it is neither live nor ours to close.
|
|
122
|
+
*/
|
|
123
|
+
#leftSessions = new Set();
|
|
124
|
+
/**
|
|
125
|
+
* Children whose runtime this process never saw: a panel child outlives a parent restart, so a
|
|
126
|
+
* record that still names a runtime after recovery did not reconnect may be running right now.
|
|
127
|
+
* Nothing here can observe it and one session never gets two writers, so the next send refuses
|
|
128
|
+
* until an explicit stop clears the record.
|
|
129
|
+
*/
|
|
130
|
+
#unresolvedRuntimes = new Set();
|
|
131
|
+
/** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
|
|
132
|
+
#activity = new Map();
|
|
90
133
|
#projectors = new Map();
|
|
134
|
+
/** Known child work in flight, separate from failures recorded before Pi finally settles. */
|
|
135
|
+
#working = new Set();
|
|
136
|
+
/** A settled event from an older run cannot clear a newer synchronous start signal. */
|
|
137
|
+
#workVersions = new Map();
|
|
138
|
+
#settledPanelCloseTimers = new Map();
|
|
139
|
+
#autoReportTimers = new Map();
|
|
140
|
+
#lastDeliveredTurn = new Map();
|
|
91
141
|
#stopping = new Set();
|
|
92
|
-
#frozen = new Set();
|
|
93
|
-
#attachCancels = new Map();
|
|
94
|
-
#hostAttachments = new Map();
|
|
95
|
-
#hostWatches = new Map();
|
|
96
|
-
#tuiQuitExpected = new Set();
|
|
97
142
|
#listeners = new Set();
|
|
143
|
+
#childEventListeners = new Set();
|
|
144
|
+
#unsubscribe = [];
|
|
145
|
+
#taskRegistry;
|
|
146
|
+
bindTaskRegistry(registry) {
|
|
147
|
+
this.#taskRegistry = registry;
|
|
148
|
+
return () => {
|
|
149
|
+
if (this.#taskRegistry === registry) {
|
|
150
|
+
this.#taskRegistry = undefined;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
98
154
|
constructor(deps) {
|
|
99
155
|
this.#deps = deps;
|
|
156
|
+
// The manager owns the transport's two inbound streams: child events and connection
|
|
157
|
+
// transitions are what keep a record in step with a child that is running somewhere else.
|
|
158
|
+
this.#unsubscribe.push(deps.bridge.onEvent((childId, event) => this.handleEvent(childId, event)));
|
|
159
|
+
this.#unsubscribe.push(deps.bridge.onConnectionChange((childId, connected) => this.handleConnectionChange(childId, connected)));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Observes the raw bridge events of every child. A Task producer needs the child's
|
|
163
|
+
* `task_result` and settle events without also owning the bridge connection.
|
|
164
|
+
*/
|
|
165
|
+
onChildEvent(listener) {
|
|
166
|
+
this.#childEventListeners.add(listener);
|
|
167
|
+
return () => {
|
|
168
|
+
this.#childEventListeners.delete(listener);
|
|
169
|
+
};
|
|
100
170
|
}
|
|
101
171
|
/** Presentation-only: widget and similar projections re-read list() after this. */
|
|
102
172
|
onChange(listener) {
|
|
@@ -105,166 +175,865 @@ export class SubagentManager {
|
|
|
105
175
|
this.#listeners.delete(listener);
|
|
106
176
|
};
|
|
107
177
|
}
|
|
178
|
+
dispose() {
|
|
179
|
+
for (const timer of this.#settledPanelCloseTimers.values()) {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
this.#settledPanelCloseTimers.clear();
|
|
183
|
+
for (const unsubscribe of this.#unsubscribe.splice(0))
|
|
184
|
+
unsubscribe();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Releases this process's local view of its children. Child processes are deliberately left
|
|
188
|
+
* alone: a background child is held by its stdin pipe, so it ends when this Pi process ends and
|
|
189
|
+
* not when extensions are reloaded.
|
|
190
|
+
*/
|
|
191
|
+
closeLocalConnections() {
|
|
192
|
+
this.dispose();
|
|
193
|
+
this.#processes.clear();
|
|
194
|
+
this.#attachments.clear();
|
|
195
|
+
this.#leftSessions.clear();
|
|
196
|
+
this.#unresolvedRuntimes.clear();
|
|
197
|
+
this.#activity.clear();
|
|
198
|
+
this.#projectors.clear();
|
|
199
|
+
this.#working.clear();
|
|
200
|
+
this.#workVersions.clear();
|
|
201
|
+
this.#notify();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Starts the child's runtime where its frozen presentation says. A panel child is owned by the
|
|
205
|
+
* host, so this process keeps only the attachment handle; a background child is ours to kill.
|
|
206
|
+
*/
|
|
207
|
+
async #start(record) {
|
|
208
|
+
// Whatever this child was doing before, a fresh runtime now serves the delegated session.
|
|
209
|
+
this.#leftSessions.delete(record.subagentId);
|
|
210
|
+
if (record.presentation === "panel") {
|
|
211
|
+
const openPanel = this.#deps.presentation?.openPanel;
|
|
212
|
+
if (openPanel === undefined) {
|
|
213
|
+
throw new Error("This parent has no presentation host for a panel child");
|
|
214
|
+
}
|
|
215
|
+
const launch = await openPanel(record);
|
|
216
|
+
if (launch.handle !== undefined)
|
|
217
|
+
this.#attachments.set(record.subagentId, launch.handle);
|
|
218
|
+
// A panel that could not be opened may still exist and still own the child's session, and
|
|
219
|
+
// nothing here can observe it: the child stays unavailable until an explicit stop.
|
|
220
|
+
if (launch.unconfirmed === true)
|
|
221
|
+
this.#unresolvedRuntimes.add(record.subagentId);
|
|
222
|
+
if (launch.failure !== undefined)
|
|
223
|
+
throw new Error(launch.failure);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const launch = await this.#deps.launch(record);
|
|
227
|
+
if (launch.handle !== undefined)
|
|
228
|
+
this.#watchRuntime(record.subagentId, launch.handle);
|
|
229
|
+
if (launch.failure !== undefined)
|
|
230
|
+
throw new Error(launch.failure);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Keeps a background child's process handle, and treats its exit as the end of its runtime: the
|
|
234
|
+
* bridge may already be gone by then (a child that disposes its client before exiting would
|
|
235
|
+
* otherwise stay "running" forever, with a handle nobody ever releases).
|
|
236
|
+
*/
|
|
237
|
+
#watchRuntime(id, runtime) {
|
|
238
|
+
this.#processes.set(id, runtime);
|
|
239
|
+
void runtime.exited.then(() => {
|
|
240
|
+
void this.#mutate(id, () => this.#runtimeEnded(id)).catch((error) => {
|
|
241
|
+
diagnose(`failed to settle child ${id} after exit: ${errorMessage(error)}`);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The child's runtime is gone: release what it left behind and say so on the record. Called from
|
|
247
|
+
* both exits a runtime can take — the process ending, and the bridge connection dropping — so the
|
|
248
|
+
* order they happen in does not matter.
|
|
249
|
+
*/
|
|
250
|
+
async #runtimeEnded(id) {
|
|
251
|
+
const runtime = this.#processes.get(id);
|
|
252
|
+
// A process that is still running is not an ended runtime: the bridge may be reconnecting.
|
|
253
|
+
if (runtime?.alive === true)
|
|
254
|
+
return;
|
|
255
|
+
if (runtime !== undefined)
|
|
256
|
+
this.#processes.delete(id);
|
|
257
|
+
if (this.#deps.bridge.isConnected(id))
|
|
258
|
+
return;
|
|
259
|
+
this.#working.delete(id);
|
|
260
|
+
await this.#releasePanel(id);
|
|
261
|
+
this.#clearSettledPanelClose(id);
|
|
262
|
+
this.#projectors.delete(id);
|
|
263
|
+
const record = await this.#update(id, (current) => {
|
|
264
|
+
if (current.intent === "stopped")
|
|
265
|
+
return current;
|
|
266
|
+
if (current.state !== "running")
|
|
267
|
+
return current;
|
|
268
|
+
return {
|
|
269
|
+
...current,
|
|
270
|
+
state: "blocked",
|
|
271
|
+
interrupted: current.presentation === "panel"
|
|
272
|
+
? "The child bridge disconnected; its panel process exit is not confirmed"
|
|
273
|
+
: "The child runtime ended while a turn was in flight; the session is unchanged and a send resumes it",
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
if (record.interrupted !== undefined && record.intent !== "stopped") {
|
|
277
|
+
this.#scheduleAutoReport(id, record);
|
|
278
|
+
}
|
|
279
|
+
if (record.presentation === "panel" &&
|
|
280
|
+
record.intent === "active" &&
|
|
281
|
+
record.state !== "done" &&
|
|
282
|
+
record.runtime !== undefined &&
|
|
283
|
+
!this.#attachments.has(id)) {
|
|
284
|
+
// An adopted child has no host handle: losing its bridge cannot prove its exit.
|
|
285
|
+
this.#unresolvedRuntimes.add(id);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** True when a runtime for this child was started here and may still own its session. */
|
|
289
|
+
#runtimeStarted(id) {
|
|
290
|
+
return this.#isLive(id) || this.#processes.has(id) || this.#attachments.has(id);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Says why this child cannot take input while a runtime may still own its session: one this
|
|
294
|
+
* process holds, one a host panel may still be running, or one the record names that never
|
|
295
|
+
* reconnected after a parent restart. Only an explicit stop clears that doubt.
|
|
296
|
+
*/
|
|
297
|
+
async #runtimeDoubt(id) {
|
|
298
|
+
if (this.#attachments.has(id)) {
|
|
299
|
+
// A panel the host confirms is gone stops holding this child — the handle it left behind is
|
|
300
|
+
// also what a retried stop would act on, so it is released here before refusing.
|
|
301
|
+
await this.#releasePanel(id).catch(() => undefined);
|
|
302
|
+
if ((await this.#deps.registry.get(id))?.intent === "stopped") {
|
|
303
|
+
return STOPPED_SEND_REASON;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (this.#runtimeStarted(id)) {
|
|
307
|
+
return "This child's previous runtime is not confirmed gone (its process or panel may still be running); stop the child to end it, then spawn a new one for this work";
|
|
308
|
+
}
|
|
309
|
+
if (this.#unresolvedRuntimes.has(id)) {
|
|
310
|
+
return "This child's panel runtime is disconnected and its exit cannot be confirmed; no replacement was started. Close any remaining panel, stop the child, then spawn a new child for this work";
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
/** Counts an inbound bridge signal, whether or not its handling is queued behind another task. */
|
|
315
|
+
#bumpActivity(id) {
|
|
316
|
+
this.#activity.set(id, (this.#activity.get(id) ?? 0) + 1);
|
|
317
|
+
this.#clearAutoReport(id);
|
|
318
|
+
this.#clearSettledPanelClose(id);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Releases a panel whose child has gone away, without being asked to. Only a panel the host
|
|
322
|
+
* confirms is no longer running its child is closed: a live process behind a missing bridge may
|
|
323
|
+
* be serving the session the human switched to, and nobody asked for that panel to be ended. A
|
|
324
|
+
* panel that stays keeps its handle, so an explicit stop can still close it.
|
|
325
|
+
*/
|
|
326
|
+
async #releasePanel(id) {
|
|
327
|
+
const attachment = this.#attachments.get(id);
|
|
328
|
+
if (attachment === undefined)
|
|
329
|
+
return;
|
|
330
|
+
const observed = await attachment.observe();
|
|
331
|
+
if (!(observed.known && !observed.alive))
|
|
332
|
+
return;
|
|
333
|
+
let retired;
|
|
334
|
+
const record = await this.#update(id, (current) => {
|
|
335
|
+
if (current.intent === "stopped")
|
|
336
|
+
return current;
|
|
337
|
+
retired = current.runtime?.runtimeIdentity;
|
|
338
|
+
const { runtime: _runtime, ...withoutRuntime } = current;
|
|
339
|
+
if (current.state === "done" || current.state === "blocked") {
|
|
340
|
+
return {
|
|
341
|
+
...withoutRuntime,
|
|
342
|
+
state: current.state,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
...withoutRuntime,
|
|
347
|
+
intent: "stopped",
|
|
348
|
+
state: "error",
|
|
349
|
+
interrupted: "The child panel was closed while running; spawn a new child for more work",
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
if (retired !== undefined)
|
|
353
|
+
this.#deps.tokens?.forget(retired);
|
|
354
|
+
const projector = this.#projectors.get(id);
|
|
355
|
+
if (record.state === "done" && projector !== undefined) {
|
|
356
|
+
projector.syncState("done");
|
|
357
|
+
}
|
|
358
|
+
if (record.intent === "stopped") {
|
|
359
|
+
this.#clearSettledPanelClose(id);
|
|
360
|
+
this.#projectors.delete(id);
|
|
361
|
+
this.#unresolvedRuntimes.delete(id);
|
|
362
|
+
}
|
|
363
|
+
this.#attachments.delete(id);
|
|
364
|
+
try {
|
|
365
|
+
const cleanup = await attachment.cleanup();
|
|
366
|
+
if (cleanup.exitCode !== 0) {
|
|
367
|
+
diagnose(`could not close the leftover ${attachment.identity.host} panel of child ${id}: ${cleanup.stderr}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
diagnose(`could not close the leftover ${attachment.identity.host} panel of child ${id}: ${errorMessage(error)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Waits for a child's bridge connection to end; false when it is still connected after the wait. */
|
|
375
|
+
#awaitDisconnect(id, timeoutMs) {
|
|
376
|
+
if (!this.#deps.bridge.isConnected(id))
|
|
377
|
+
return Promise.resolve(true);
|
|
378
|
+
return new Promise((resolve) => {
|
|
379
|
+
let unsubscribe = () => { };
|
|
380
|
+
const timer = setTimeout(() => {
|
|
381
|
+
unsubscribe();
|
|
382
|
+
resolve(false);
|
|
383
|
+
}, timeoutMs);
|
|
384
|
+
unsubscribe = this.#deps.bridge.onConnectionChange((childId, connected) => {
|
|
385
|
+
if (childId !== id || connected)
|
|
386
|
+
return;
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
unsubscribe();
|
|
389
|
+
resolve(true);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Arms the idle countdown for a child whose runtime this process can actually reclaim. A child
|
|
395
|
+
* this process does not own (one adopted after a parent restart) and a host that cannot report
|
|
396
|
+
* focus (cmux) never get a timer: neither the exit nor the focus could be judged, and a panel is
|
|
397
|
+
* only ever closed on a judgement, never on a guess.
|
|
398
|
+
*/
|
|
399
|
+
#scheduleAutoReport(id, record) {
|
|
400
|
+
this.#clearAutoReport(id);
|
|
401
|
+
const timer = setTimeout(() => {
|
|
402
|
+
this.#autoReportTimers.delete(id);
|
|
403
|
+
void this.#performAutoReport(id, record);
|
|
404
|
+
}, 5_000);
|
|
405
|
+
timer.unref?.();
|
|
406
|
+
this.#autoReportTimers.set(id, timer);
|
|
407
|
+
}
|
|
408
|
+
#clearAutoReport(id) {
|
|
409
|
+
const timer = this.#autoReportTimers.get(id);
|
|
410
|
+
if (timer !== undefined) {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
this.#autoReportTimers.delete(id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
#scheduleSettledPanelClose(id) {
|
|
416
|
+
const attachment = this.#attachments.get(id);
|
|
417
|
+
const runtime = this.#processes.get(id);
|
|
418
|
+
if (attachment === undefined && runtime === undefined)
|
|
419
|
+
return;
|
|
420
|
+
if (attachment !== undefined && !attachment.reportsFocus)
|
|
421
|
+
return;
|
|
422
|
+
this.#clearSettledPanelClose(id);
|
|
423
|
+
const timeoutMs = this.#deps.settledCloseTimeoutMs ??
|
|
424
|
+
this.#deps.failedPanelCloseTimeoutMs ??
|
|
425
|
+
this.#deps.idleTimeoutMs ??
|
|
426
|
+
SUBAGENT_SETTLED_CLOSE_TIMEOUT_MS;
|
|
427
|
+
const timer = setTimeout(() => {
|
|
428
|
+
this.#settledPanelCloseTimers.delete(id);
|
|
429
|
+
void this.#closeSettledPanel(id).catch((error) => {
|
|
430
|
+
diagnose(`failed to close settled panel for child ${id}: ${errorMessage(error)}`);
|
|
431
|
+
});
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
timer.unref?.();
|
|
434
|
+
this.#settledPanelCloseTimers.set(id, timer);
|
|
435
|
+
}
|
|
436
|
+
#clearSettledPanelClose(id) {
|
|
437
|
+
const timer = this.#settledPanelCloseTimers.get(id);
|
|
438
|
+
if (timer !== undefined) {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
this.#settledPanelCloseTimers.delete(id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async #closeSettledPanel(id) {
|
|
444
|
+
await this.#mutate(id, async () => {
|
|
445
|
+
const record = await this.#deps.registry.get(id);
|
|
446
|
+
if (record === undefined || record.state === "running" || record.intent === "stopped") {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const attachment = this.#attachments.get(id);
|
|
450
|
+
const runtime = this.#processes.get(id);
|
|
451
|
+
if (attachment === undefined && runtime === undefined)
|
|
452
|
+
return;
|
|
453
|
+
if (!this.#isLive(id))
|
|
454
|
+
return;
|
|
455
|
+
const activity = this.#activity.get(id) ?? 0;
|
|
456
|
+
if (attachment !== undefined) {
|
|
457
|
+
const observed = await attachment.observe();
|
|
458
|
+
if (observed.known && !observed.alive) {
|
|
459
|
+
await this.#releasePanel(id);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if ((this.#activity.get(id) ?? 0) !== activity) {
|
|
463
|
+
this.#scheduleSettledPanelClose(id);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (attachment.reportsFocus && (!observed.known || observed.focused !== false)) {
|
|
467
|
+
this.#scheduleSettledPanelClose(id);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (this.#deps.bridge.isConnected(id)) {
|
|
472
|
+
await this.#request(id, "shutdown").catch(() => undefined);
|
|
473
|
+
this.#deps.bridge.disconnect(id);
|
|
474
|
+
}
|
|
475
|
+
if (runtime !== undefined) {
|
|
476
|
+
await runtime.terminate().catch(() => undefined);
|
|
477
|
+
}
|
|
478
|
+
if (attachment !== undefined) {
|
|
479
|
+
try {
|
|
480
|
+
await attachment.cleanup();
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
diagnose(`failed to cleanup panel for child ${id}: ${errorMessage(error)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const exited = (runtime === undefined || !runtime.alive) && (await this.#panelGone(attachment));
|
|
487
|
+
if (exited) {
|
|
488
|
+
if (runtime !== undefined)
|
|
489
|
+
this.#processes.delete(id);
|
|
490
|
+
if (attachment !== undefined)
|
|
491
|
+
this.#attachments.delete(id);
|
|
492
|
+
this.#unresolvedRuntimes.delete(id);
|
|
493
|
+
await this.#update(id, (current) => {
|
|
494
|
+
if (current.intent === "stopped")
|
|
495
|
+
return current;
|
|
496
|
+
const { runtime: _runtime, ...withoutRuntime } = current;
|
|
497
|
+
return withoutRuntime;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
await this.#update(id, (current) => {
|
|
502
|
+
if (current.intent === "stopped")
|
|
503
|
+
return current;
|
|
504
|
+
return {
|
|
505
|
+
...current,
|
|
506
|
+
state: "error",
|
|
507
|
+
interrupted: "The child panel is not confirmed gone: the child process may still be running",
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async #performAutoReport(id, _record) {
|
|
514
|
+
if (this.#working.has(id))
|
|
515
|
+
return;
|
|
516
|
+
const record = (await this.#deps.registry.get(id)) ?? _record;
|
|
517
|
+
if (record.intent === "stopped" || record.state === "running")
|
|
518
|
+
return;
|
|
519
|
+
const entries = await this.#entries(id);
|
|
520
|
+
const text = entries !== undefined ? extractLastAssistantText(entries) : undefined;
|
|
521
|
+
const hasError = record.state === "error" ||
|
|
522
|
+
(record.interrupted !== undefined && record.interrupted.trim() !== "");
|
|
523
|
+
const isBlocked = hasError;
|
|
524
|
+
if (!isBlocked && (text === undefined || text.trim() === "")) {
|
|
525
|
+
const projector = this.#projectors.get(id);
|
|
526
|
+
if (projector !== undefined) {
|
|
527
|
+
projector.syncState("done");
|
|
528
|
+
}
|
|
529
|
+
await this.#update(id, (current) => {
|
|
530
|
+
const { interrupted: _interrupted, ...clean } = current;
|
|
531
|
+
return {
|
|
532
|
+
...clean,
|
|
533
|
+
state: "done",
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
if (this.#taskRegistry !== undefined) {
|
|
537
|
+
this.#taskRegistry.settle(id, {
|
|
538
|
+
status: "completed",
|
|
539
|
+
output: "",
|
|
540
|
+
truncated: false,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
let message;
|
|
546
|
+
if (isBlocked) {
|
|
547
|
+
if (record.interrupted !== undefined && record.interrupted.trim() !== "") {
|
|
548
|
+
message =
|
|
549
|
+
text !== undefined && text.trim() !== ""
|
|
550
|
+
? `${record.interrupted}\n\n${text}`
|
|
551
|
+
: record.interrupted;
|
|
552
|
+
}
|
|
553
|
+
else if (text !== undefined && text.trim() !== "") {
|
|
554
|
+
message = text;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
message = "Subagent became idle without completing its task (interrupted or failed).";
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
message = text ?? "";
|
|
562
|
+
}
|
|
563
|
+
if (this.#lastDeliveredTurn.get(id) === message)
|
|
564
|
+
return;
|
|
565
|
+
this.#lastDeliveredTurn.set(id, message);
|
|
566
|
+
const reason = isBlocked ? "blocked" : "success";
|
|
567
|
+
const taskStatus = isBlocked ? "failed" : "completed";
|
|
568
|
+
const projector = this.#projectors.get(id);
|
|
569
|
+
const updatedRecord = !isBlocked
|
|
570
|
+
? await this.#update(id, (current) => {
|
|
571
|
+
const { interrupted: _interrupted, ...clean } = current;
|
|
572
|
+
return {
|
|
573
|
+
...clean,
|
|
574
|
+
state: "done",
|
|
575
|
+
};
|
|
576
|
+
})
|
|
577
|
+
: await this.#update(id, (current) => ({
|
|
578
|
+
...current,
|
|
579
|
+
state: "blocked",
|
|
580
|
+
interrupted: current.interrupted ?? message,
|
|
581
|
+
}));
|
|
582
|
+
if (projector !== undefined) {
|
|
583
|
+
projector.syncState(isBlocked ? "blocked" : "done");
|
|
584
|
+
}
|
|
585
|
+
this.#scheduleSettledPanelClose(id);
|
|
586
|
+
if (this.#deps.channel !== undefined) {
|
|
587
|
+
await this.contactParent(id, reason, message, updatedRecord);
|
|
588
|
+
}
|
|
589
|
+
if (this.#taskRegistry !== undefined) {
|
|
590
|
+
this.#taskRegistry.settle(id, {
|
|
591
|
+
status: taskStatus,
|
|
592
|
+
output: message,
|
|
593
|
+
truncated: false,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Reads a child's session entries; exposed for Task completion inspection. */
|
|
598
|
+
async getEntries(id) {
|
|
599
|
+
return this.#entries(id);
|
|
600
|
+
}
|
|
601
|
+
/** A child is live while its bridge connection is up, or while a process we started still runs. */
|
|
602
|
+
#isLive(id) {
|
|
603
|
+
// A process that left the delegated session is not this child's runtime, however healthy its
|
|
604
|
+
// connection looks: sending to it would drive the session the human switched to.
|
|
605
|
+
if (this.#leftSessions.has(id))
|
|
606
|
+
return false;
|
|
607
|
+
if (this.#deps.bridge.isConnected(id))
|
|
608
|
+
return true;
|
|
609
|
+
return this.#processes.get(id)?.alive === true;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* True only when the host positively reports the panel is gone. An unobservable panel is not a
|
|
613
|
+
* closed one, and treating it as closed is what would let a second runtime start for a session
|
|
614
|
+
* whose first process may still be running.
|
|
615
|
+
*/
|
|
616
|
+
async #panelGone(attachment) {
|
|
617
|
+
if (attachment === undefined)
|
|
618
|
+
return true;
|
|
619
|
+
const observed = await attachment.observe();
|
|
620
|
+
return observed.known && !observed.alive;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Resolves where the child's session lives now: a child that finished while
|
|
624
|
+
* the parent was busy may have been flushed after its record was written.
|
|
625
|
+
*
|
|
626
|
+
* Discovery is not an existence test. A record that was only ever known by session
|
|
627
|
+
* id would otherwise turn an unreadable session directory into "the session does not
|
|
628
|
+
* exist", and the next resume would let Pi mint a new session under the same id and
|
|
629
|
+
* silently drop the child's context. A failure is therefore reported to the caller.
|
|
630
|
+
*/
|
|
631
|
+
async #currentPlacement(record) {
|
|
632
|
+
let placement;
|
|
633
|
+
try {
|
|
634
|
+
placement = await planSessionPlacement({
|
|
635
|
+
sessionId: record.sessionId,
|
|
636
|
+
cwd: record.cwd,
|
|
637
|
+
sessionDir: record.launchConfig.sessionDir,
|
|
638
|
+
persistence: record.persistence,
|
|
639
|
+
...(record.sessionPath === undefined ? {} : { sessionPath: record.sessionPath }),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
throw new Error(`Session placement for child ${record.subagentId} could not be verified: ${errorMessage(error)}`, { cause: error });
|
|
644
|
+
}
|
|
645
|
+
if (placement.persistence === "flushed" && placement.sessionPath !== undefined) {
|
|
646
|
+
return { sessionPath: placement.sessionPath, persistence: "flushed" };
|
|
647
|
+
}
|
|
648
|
+
return { sessionPath: record.sessionPath, persistence: record.persistence };
|
|
649
|
+
}
|
|
650
|
+
/** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
|
|
651
|
+
async #entries(id) {
|
|
652
|
+
if (this.#deps.bridge.isConnected(id)) {
|
|
653
|
+
const response = await this.#request(id, "get_entries").catch(() => undefined);
|
|
654
|
+
if (isRecord(response) && Array.isArray(response.entries) && response.entries.length > 0) {
|
|
655
|
+
return response.entries;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const record = await this.#deps.registry.get(id).catch(() => undefined);
|
|
659
|
+
if (record !== undefined) {
|
|
660
|
+
const sessionPath = record.sessionPath ??
|
|
661
|
+
(await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
|
|
662
|
+
if (sessionPath !== undefined) {
|
|
663
|
+
try {
|
|
664
|
+
const diskEntries = await readSessionJsonlEntries(sessionPath);
|
|
665
|
+
if (diskEntries.length > 0)
|
|
666
|
+
return diskEntries;
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// return undefined below
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
#request(id, operation, payload) {
|
|
676
|
+
return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
|
|
677
|
+
}
|
|
108
678
|
#notify() {
|
|
109
679
|
for (const listener of this.#listeners) {
|
|
110
680
|
try {
|
|
111
681
|
listener();
|
|
112
682
|
}
|
|
113
683
|
catch (error) {
|
|
114
|
-
|
|
684
|
+
diagnose(`onChange listener failed: ${errorMessage(error)}`);
|
|
115
685
|
}
|
|
116
686
|
}
|
|
117
687
|
}
|
|
118
|
-
/**
|
|
688
|
+
/**
|
|
689
|
+
* Adopts the children that are still running. Nothing is launched here: a child that survived a
|
|
690
|
+
* parent restart reconnects its bridge by itself, and a child that did not is simply not live
|
|
691
|
+
* until a later send starts it from its recorded session.
|
|
692
|
+
*/
|
|
119
693
|
async recover() {
|
|
120
|
-
const
|
|
121
|
-
if (connect === undefined)
|
|
122
|
-
return { recovered: [], failures: [] };
|
|
123
|
-
const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state !== "done" && record.state !== "stopped");
|
|
694
|
+
const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state === "running");
|
|
124
695
|
const outcomes = await Promise.all(records.map(async (record) => {
|
|
696
|
+
this.#working.add(record.subagentId);
|
|
697
|
+
if (!this.#projectors.has(record.subagentId)) {
|
|
698
|
+
this.#projectors.set(record.subagentId, createStateProjector(record.state));
|
|
699
|
+
}
|
|
125
700
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await this.#update(record.subagentId, (current) => {
|
|
134
|
-
const interrupted = snapshot?.interrupted ??
|
|
135
|
-
(current.unacknowledgedInput === undefined
|
|
136
|
-
? current.interrupted
|
|
137
|
-
: (current.interrupted ?? "Parent recovered; pending input was not replayed"));
|
|
138
|
-
return {
|
|
139
|
-
...current,
|
|
140
|
-
...(snapshot === undefined
|
|
141
|
-
? {}
|
|
142
|
-
: {
|
|
143
|
-
state: snapshot.state,
|
|
144
|
-
...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
|
|
145
|
-
usage: snapshot.usage,
|
|
146
|
-
}),
|
|
147
|
-
...(interrupted === undefined ? {} : { interrupted }),
|
|
148
|
-
};
|
|
149
|
-
});
|
|
701
|
+
if (!(await this.#deps.connect(record))) {
|
|
702
|
+
await this.#settleUnrecovered(record);
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
// The runtime this record names answered, so the doubt about it is gone.
|
|
706
|
+
this.#unresolvedRuntimes.delete(record.subagentId);
|
|
707
|
+
await this.#adopt(record.subagentId);
|
|
150
708
|
return { childId: record.subagentId };
|
|
151
709
|
}
|
|
152
710
|
catch (error) {
|
|
153
|
-
return {
|
|
154
|
-
childId: record.subagentId,
|
|
155
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
156
|
-
};
|
|
711
|
+
return { childId: record.subagentId, reason: errorMessage(error) };
|
|
157
712
|
}
|
|
158
713
|
}));
|
|
714
|
+
const settled = outcomes.filter((outcome) => outcome !== undefined);
|
|
159
715
|
this.#notify();
|
|
160
716
|
return {
|
|
161
|
-
recovered:
|
|
717
|
+
recovered: settled
|
|
162
718
|
.filter((outcome) => !("reason" in outcome))
|
|
163
719
|
.map((outcome) => outcome.childId),
|
|
164
|
-
failures:
|
|
720
|
+
failures: settled.filter((outcome) => "reason" in outcome),
|
|
165
721
|
};
|
|
166
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* A record that names a runtime which did not reconnect. A background child is held by its
|
|
725
|
+
* parent's stdin pipe, so it died with the parent and that evidence is stale; a panel child is
|
|
726
|
+
* held by the host and may still be running, so nothing here may start a second runtime for the
|
|
727
|
+
* same session until the user stops it.
|
|
728
|
+
*/
|
|
729
|
+
async #settleUnrecovered(record) {
|
|
730
|
+
this.#working.delete(record.subagentId);
|
|
731
|
+
const retired = record.runtime;
|
|
732
|
+
if (retired === undefined)
|
|
733
|
+
return;
|
|
734
|
+
if (record.presentation === "panel") {
|
|
735
|
+
this.#unresolvedRuntimes.add(record.subagentId);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
await this.#update(record.subagentId, (current) => {
|
|
739
|
+
const { runtime: _runtime, ...rest } = current;
|
|
740
|
+
return {
|
|
741
|
+
...rest,
|
|
742
|
+
...(current.state === "running" ? { state: "done" } : {}),
|
|
743
|
+
interrupted: "The child runtime ended with the parent process; a later send starts it again from the same session",
|
|
744
|
+
};
|
|
745
|
+
});
|
|
746
|
+
this.#deps.tokens?.forget(retired.runtimeIdentity);
|
|
747
|
+
}
|
|
748
|
+
/** Reads a connected child's real state and puts the record back in step with it. */
|
|
749
|
+
async #adopt(id) {
|
|
750
|
+
const workVersion = this.#workVersions.get(id) ?? 0;
|
|
751
|
+
const projector = this.#projectors.get(id);
|
|
752
|
+
const record = await this.#deps.registry.get(id);
|
|
753
|
+
if (projector === undefined || record === undefined)
|
|
754
|
+
return;
|
|
755
|
+
const entries = await this.#entries(id);
|
|
756
|
+
if (entries !== undefined)
|
|
757
|
+
projector.rebuild(entries);
|
|
758
|
+
const state = await this.#request(id, "get_state");
|
|
759
|
+
if (!isRecord(state) ||
|
|
760
|
+
typeof state.idle !== "boolean" ||
|
|
761
|
+
typeof state.pendingMessages !== "boolean") {
|
|
762
|
+
throw new BridgeError("invalid_state", `Child ${id} did not confirm its current work state`);
|
|
763
|
+
}
|
|
764
|
+
const idle = state.idle && !state.pendingMessages;
|
|
765
|
+
if ((this.#workVersions.get(id) ?? 0) === workVersion) {
|
|
766
|
+
if (idle)
|
|
767
|
+
this.#working.delete(id);
|
|
768
|
+
else
|
|
769
|
+
this.#working.add(id);
|
|
770
|
+
}
|
|
771
|
+
// The child's own answer decides whether it is working right now; a transcript that recorded a
|
|
772
|
+
// failed turn still outranks it. Without this the projector keeps saying "running" and the
|
|
773
|
+
// next event that carries no state of its own writes that back onto the record.
|
|
774
|
+
if (projector.snapshot().state !== "error")
|
|
775
|
+
projector.syncState(idle ? "done" : "running");
|
|
776
|
+
const snapshot = projector.snapshot();
|
|
777
|
+
const confirmedInput = entries !== undefined &&
|
|
778
|
+
record.unacknowledgedInput !== undefined &&
|
|
779
|
+
sessionContainsUserInput(entries, record.unacknowledgedInput)
|
|
780
|
+
? record.unacknowledgedInput
|
|
781
|
+
: undefined;
|
|
782
|
+
await this.#update(id, (current) => {
|
|
783
|
+
if (current.intent === "stopped")
|
|
784
|
+
return current;
|
|
785
|
+
const base = confirmedInput === undefined
|
|
786
|
+
? current
|
|
787
|
+
: (({ unacknowledgedInput: _input, ...rest }) => rest)(current);
|
|
788
|
+
const hasError = snapshot.state === "error";
|
|
789
|
+
return {
|
|
790
|
+
...base,
|
|
791
|
+
state: hasError ? "error" : idle ? "done" : "running",
|
|
792
|
+
...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
|
|
793
|
+
usage: snapshot.usage,
|
|
794
|
+
...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
|
|
795
|
+
};
|
|
796
|
+
}, workVersion);
|
|
797
|
+
if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
|
|
798
|
+
this.#scheduleSettledPanelClose(id);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
this.#clearSettledPanelClose(id);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Where this child runs. `auto` means a panel whenever this parent has a host and the background
|
|
806
|
+
* otherwise, and the fallback travels back in the spawn result so the caller sees that the child
|
|
807
|
+
* runs headless; only an explicit request for a panel can fail, because nothing else can satisfy
|
|
808
|
+
* it.
|
|
809
|
+
*/
|
|
810
|
+
#placement(input) {
|
|
811
|
+
const requested = input.presentation ?? "auto";
|
|
812
|
+
const panelAvailable = this.#deps.presentation?.openPanel !== undefined;
|
|
813
|
+
if (requested === "panel") {
|
|
814
|
+
return panelAvailable
|
|
815
|
+
? { presentation: "panel" }
|
|
816
|
+
: { reason: "This parent has no presentation host, so the child cannot run in a panel" };
|
|
817
|
+
}
|
|
818
|
+
if (requested === "background" || !panelAvailable) {
|
|
819
|
+
return {
|
|
820
|
+
presentation: "background",
|
|
821
|
+
...(requested === "background"
|
|
822
|
+
? {}
|
|
823
|
+
: {
|
|
824
|
+
note: `No presentation host is available (${this.#deps.presentation?.reason ?? "no host adapter"}), so this child runs in the background`,
|
|
825
|
+
}),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return { presentation: "panel" };
|
|
829
|
+
}
|
|
167
830
|
async spawn(input) {
|
|
168
|
-
|
|
831
|
+
const cleanTask = stripHindsightContent(input.task).trim();
|
|
832
|
+
if (cleanTask === "")
|
|
169
833
|
return failure("spawn", "Task must not be empty");
|
|
834
|
+
let childId;
|
|
170
835
|
try {
|
|
171
836
|
const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
|
|
837
|
+
const cleanTask = stripHindsightContent(input.task);
|
|
838
|
+
const placement = this.#placement(input);
|
|
839
|
+
if ("reason" in placement)
|
|
840
|
+
return failure("spawn", placement.reason, undefined, undefined, [], false);
|
|
172
841
|
const record = await withDeadline(this.#deps.bootstrap({
|
|
173
842
|
parentSessionId: this.#deps.parentSessionId,
|
|
174
|
-
task:
|
|
843
|
+
task: cleanTask,
|
|
175
844
|
launchConfig: config,
|
|
845
|
+
presentation: placement.presentation,
|
|
176
846
|
}), this.#deps.deadlineMs ?? 30_000);
|
|
847
|
+
this.#projectors.set(record.subagentId, createStateProjector(record.state));
|
|
848
|
+
childId = record.subagentId;
|
|
849
|
+
this.#working.add(childId);
|
|
177
850
|
this.#notify();
|
|
178
|
-
|
|
179
|
-
this.#attach(record, runner);
|
|
851
|
+
await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 30_000);
|
|
180
852
|
try {
|
|
181
|
-
await
|
|
853
|
+
await this.#request(record.subagentId, "prompt", { message: cleanTask });
|
|
182
854
|
}
|
|
183
855
|
catch (error) {
|
|
184
|
-
let failureState = "
|
|
856
|
+
let failureState = "error";
|
|
185
857
|
try {
|
|
186
|
-
await this.#
|
|
858
|
+
await this.#mutateUpdate(record.subagentId, (current) => ({
|
|
187
859
|
...current,
|
|
188
|
-
state: "
|
|
860
|
+
state: "error",
|
|
189
861
|
interrupted: "Initial task delivery was not confirmed",
|
|
190
|
-
unacknowledgedInput:
|
|
862
|
+
unacknowledgedInput: cleanTask,
|
|
191
863
|
}));
|
|
192
|
-
failureState = "
|
|
864
|
+
failureState = "error";
|
|
193
865
|
}
|
|
194
866
|
catch (updateError) {
|
|
195
|
-
|
|
867
|
+
diagnose(`failed to persist initial delivery uncertainty for ${record.subagentId}: ${errorMessage(updateError)}`);
|
|
196
868
|
}
|
|
197
|
-
return failure("spawn",
|
|
869
|
+
return failure("spawn", errorMessage(error), record.subagentId, failureState, [
|
|
198
870
|
"registry record persisted",
|
|
199
|
-
"
|
|
871
|
+
"child launched",
|
|
200
872
|
"initial task delivery was not confirmed",
|
|
201
873
|
], false);
|
|
202
874
|
}
|
|
203
|
-
await this.#
|
|
875
|
+
await this.#mutateUpdate(record.subagentId, (current) => {
|
|
204
876
|
const { latestSummary: _latestSummary, ...rest } = current;
|
|
205
877
|
return { ...rest, state: "running" };
|
|
206
878
|
});
|
|
879
|
+
if (this.#taskRegistry !== undefined) {
|
|
880
|
+
try {
|
|
881
|
+
this.#taskRegistry.create({
|
|
882
|
+
id: record.subagentId,
|
|
883
|
+
type: "agent",
|
|
884
|
+
purpose: `${input.agent}: ${input.task}`,
|
|
885
|
+
inlineResult: true,
|
|
886
|
+
begin: () => ({
|
|
887
|
+
stop: () => {
|
|
888
|
+
void this.stop(record.subagentId);
|
|
889
|
+
},
|
|
890
|
+
describe: () => ({
|
|
891
|
+
output: `Subagent ${record.subagentId} (${input.agent}) is running`,
|
|
892
|
+
truncated: false,
|
|
893
|
+
}),
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
catch (regError) {
|
|
898
|
+
diagnose(`failed to register background task for subagent ${record.subagentId}: ${errorMessage(regError)}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
207
901
|
return {
|
|
208
902
|
child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true),
|
|
903
|
+
...(placement.note === undefined ? {} : { note: placement.note }),
|
|
209
904
|
};
|
|
210
905
|
}
|
|
211
906
|
catch (error) {
|
|
212
|
-
|
|
907
|
+
if (childId !== undefined &&
|
|
908
|
+
!this.#runtimeStarted(childId) &&
|
|
909
|
+
!this.#unresolvedRuntimes.has(childId)) {
|
|
910
|
+
this.#working.delete(childId);
|
|
911
|
+
}
|
|
912
|
+
this.#notify();
|
|
913
|
+
return failure("spawn", errorMessage(error), undefined, undefined, [], true);
|
|
213
914
|
}
|
|
214
915
|
}
|
|
215
|
-
send(id,
|
|
916
|
+
send(id, rawMessage, mode = "auto", signal) {
|
|
917
|
+
const message = stripHindsightContent(rawMessage);
|
|
918
|
+
this.#clearSettledPanelClose(id);
|
|
216
919
|
return this.#mutate(id, async () => {
|
|
217
920
|
const record = await this.#deps.registry.get(id);
|
|
218
921
|
if (record === undefined)
|
|
219
922
|
return failure("send", "Unknown child", id);
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
923
|
+
if (record.intent === "stopped" || record.state === "error") {
|
|
924
|
+
const doubt = await this.#runtimeDoubt(id);
|
|
925
|
+
return failure("send", doubt ??
|
|
926
|
+
`Subagent ${id} is in error state and cannot continue (${record.interrupted ?? "fatal error"}). You can reference its context via spawn_agent with forkFrom: "${id}".`, id, record.state, [], false);
|
|
927
|
+
}
|
|
928
|
+
let isResumed = false;
|
|
929
|
+
// Deliverability is the bridge connection, minus a process that left the delegated session:
|
|
930
|
+
// input goes to a session, and this process no longer serves this child's session. What is
|
|
931
|
+
// left to decide is whether a runtime of ours may still own it.
|
|
932
|
+
if (this.#leftSessions.has(id) || !this.#deps.bridge.isConnected(id)) {
|
|
933
|
+
const doubt = await this.#runtimeDoubt(id);
|
|
934
|
+
if (doubt !== undefined) {
|
|
935
|
+
// A runtime is still unaccounted for: it may not answer, but it may also still own the
|
|
936
|
+
// delegated session, and one session never gets two writers.
|
|
937
|
+
return failure("send", doubt, id, record.state, ["runtime metadata retained"], false);
|
|
938
|
+
}
|
|
939
|
+
let placement;
|
|
940
|
+
try {
|
|
941
|
+
placement = await this.#currentPlacement(record);
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
// Nothing was launched, so the record is untouched and a later retry is safe.
|
|
945
|
+
return failure("send", errorMessage(error), id, record.state, ["no runtime was started"]);
|
|
946
|
+
}
|
|
947
|
+
const { sessionPath, persistence } = placement;
|
|
948
|
+
const resumedRecord = await this.#update(id, (current) => ({
|
|
949
|
+
...current,
|
|
950
|
+
state: "running",
|
|
951
|
+
persistence,
|
|
952
|
+
...(sessionPath === undefined ? {} : { sessionPath }),
|
|
953
|
+
launchConfig: sessionPath === undefined
|
|
954
|
+
? current.launchConfig
|
|
955
|
+
: { ...current.launchConfig, sessionPath },
|
|
956
|
+
}));
|
|
957
|
+
if (!this.#projectors.has(id)) {
|
|
958
|
+
this.#projectors.set(id, createStateProjector(resumedRecord.state));
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 30_000);
|
|
962
|
+
isResumed = true;
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
const reason = errorMessage(error);
|
|
966
|
+
// A launch that failed may still have started a process that owns the session, so the
|
|
967
|
+
// two outcomes must not collapse into one retry: while a runtime may exist, refusing is
|
|
968
|
+
// the only safe answer.
|
|
969
|
+
const started = this.#runtimeStarted(id);
|
|
970
|
+
const settled = await this.#update(id, (current) => {
|
|
971
|
+
if (current.state !== "running")
|
|
972
|
+
return current;
|
|
973
|
+
if (started) {
|
|
974
|
+
return {
|
|
975
|
+
...current,
|
|
976
|
+
state: "error",
|
|
977
|
+
interrupted: `Resume failed after the runtime was started (${reason}); the recorded runtime was kept and the child stays unavailable until it is gone`,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
...current,
|
|
982
|
+
state: record.state,
|
|
983
|
+
interrupted: `Resume failed before a runtime was confirmed started (${reason}); the child session is untouched`,
|
|
984
|
+
};
|
|
985
|
+
});
|
|
986
|
+
return failure("send", `Failed to resume subagent: ${reason}`, id, settled.state, started
|
|
987
|
+
? ["running state persisted", "runtime start unconfirmed"]
|
|
988
|
+
: ["running state rolled back", "no runtime was confirmed started"], !started);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const state = isResumed ? "running" : record.state;
|
|
992
|
+
const operation = isResumed
|
|
993
|
+
? "prompt"
|
|
994
|
+
: mode === "auto"
|
|
995
|
+
? state === "running"
|
|
996
|
+
? "steer"
|
|
997
|
+
: "follow_up"
|
|
998
|
+
: mode;
|
|
238
999
|
if (operation === undefined)
|
|
239
1000
|
return failure("send", "Cannot infer send mode from unknown state", id, state);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
1001
|
+
if (signal?.aborted)
|
|
1002
|
+
return failure("send", "Send was cancelled", id, state);
|
|
1003
|
+
this.#clearSettledPanelClose(id);
|
|
1004
|
+
const pending = await this.#update(id, (current) => {
|
|
1005
|
+
const { interrupted: _interrupted, ...clean } = current;
|
|
1006
|
+
return {
|
|
1007
|
+
...clean,
|
|
1008
|
+
state: "running",
|
|
1009
|
+
unacknowledgedInput: message,
|
|
1010
|
+
};
|
|
1011
|
+
});
|
|
245
1012
|
try {
|
|
246
|
-
await
|
|
1013
|
+
await this.#request(id, operation, { message });
|
|
247
1014
|
}
|
|
248
1015
|
catch (error) {
|
|
249
|
-
return failure("send",
|
|
1016
|
+
return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
|
|
250
1017
|
}
|
|
251
1018
|
return publicChild((await this.#deps.registry.get(id)) ?? record, true);
|
|
252
1019
|
});
|
|
253
1020
|
}
|
|
254
1021
|
async get(id) {
|
|
255
1022
|
const record = await this.#deps.registry.get(id);
|
|
256
|
-
const runner = this.#runners.get(id);
|
|
257
1023
|
return record === undefined
|
|
258
1024
|
? failure("get", "Unknown child", id)
|
|
259
|
-
: publicChild(record,
|
|
1025
|
+
: publicChild(record, this.#isLive(id));
|
|
260
1026
|
}
|
|
261
1027
|
async list() {
|
|
262
1028
|
const records = await this.#deps.registry.list();
|
|
263
|
-
return records.map((record) => publicChild(record, this.#
|
|
1029
|
+
return records.map((record) => publicChild(record, this.#isLive(record.subagentId)));
|
|
264
1030
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.#
|
|
1031
|
+
/** Child startup and runs count until Pi settles, including post-agent_end continuation. */
|
|
1032
|
+
get activeCount() {
|
|
1033
|
+
return this.#working.size;
|
|
1034
|
+
}
|
|
1035
|
+
stop(id) {
|
|
1036
|
+
this.#clearSettledPanelClose(id);
|
|
268
1037
|
return this.#mutate(id, async () => {
|
|
269
1038
|
if (this.#stopping.has(id))
|
|
270
1039
|
return this.get(id);
|
|
@@ -275,7 +1044,7 @@ export class SubagentManager {
|
|
|
275
1044
|
}
|
|
276
1045
|
catch (error) {
|
|
277
1046
|
this.#stopping.delete(id);
|
|
278
|
-
return failure("stop",
|
|
1047
|
+
return failure("stop", errorMessage(error), id, undefined, [], true);
|
|
279
1048
|
}
|
|
280
1049
|
if (record === undefined) {
|
|
281
1050
|
this.#stopping.delete(id);
|
|
@@ -283,509 +1052,385 @@ export class SubagentManager {
|
|
|
283
1052
|
}
|
|
284
1053
|
let stopped;
|
|
285
1054
|
try {
|
|
1055
|
+
// The intent is written before anything is ended, so an interrupted stop is still a stop.
|
|
286
1056
|
stopped = await this.#update(id, (current) => ({
|
|
287
1057
|
...current,
|
|
288
1058
|
intent: "stopped",
|
|
289
|
-
state: "
|
|
1059
|
+
state: "error",
|
|
290
1060
|
}));
|
|
291
1061
|
}
|
|
292
1062
|
catch (error) {
|
|
293
1063
|
this.#stopping.delete(id);
|
|
294
|
-
return failure("stop",
|
|
295
|
-
}
|
|
296
|
-
const runner = this.#runners.get(id);
|
|
297
|
-
if (runner === undefined && stopped.runtime !== undefined) {
|
|
298
|
-
this.#stopping.delete(id);
|
|
299
|
-
return failure("stop", "Runtime termination could not be confirmed because no live controller is attached", id, "stopped", ["stopped intent persisted", "runtime metadata retained"], true);
|
|
1064
|
+
return failure("stop", errorMessage(error), id, record.state, [], true);
|
|
300
1065
|
}
|
|
301
1066
|
try {
|
|
302
|
-
if (
|
|
303
|
-
await
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1067
|
+
if (this.#isLive(id) && this.#deps.bridge.isConnected(id)) {
|
|
1068
|
+
await this.#request(id, "shutdown").catch((error) => {
|
|
1069
|
+
diagnose(`child ${id} did not answer shutdown: ${errorMessage(error)}`);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const runtime = this.#processes.get(id);
|
|
1073
|
+
const attachment = this.#attachments.get(id);
|
|
1074
|
+
if (runtime !== undefined) {
|
|
1075
|
+
await runtime.terminate();
|
|
1076
|
+
if (runtime.alive) {
|
|
1077
|
+
// The handle is kept so a retried stop can try again instead of losing the process.
|
|
1078
|
+
return failure("stop", "Child process termination is not confirmed: the process may still be running", id, "error", ["stopped intent persisted", "runtime metadata retained"], true);
|
|
1079
|
+
}
|
|
1080
|
+
this.#processes.delete(id);
|
|
1081
|
+
}
|
|
1082
|
+
if (attachment !== undefined) {
|
|
1083
|
+
// The host owns a panel child, so ending it means closing the panel and confirming
|
|
1084
|
+
// it is gone; the close itself is not the proof. A panel that is still there keeps
|
|
1085
|
+
// its handle, so a retry can try the close again instead of falling back to a
|
|
1086
|
+
// bridge-only stop.
|
|
1087
|
+
const cleanup = await attachment.cleanup();
|
|
1088
|
+
if (!(await this.#panelGone(attachment))) {
|
|
1089
|
+
return failure("stop", `The child's ${attachment.identity.host} panel is not confirmed gone (${cleanup.stderr || "its state could not be observed"}): the child process may still be running`, id, "error", ["stopped intent persisted", "runtime metadata retained"], true);
|
|
1090
|
+
}
|
|
1091
|
+
this.#attachments.delete(id);
|
|
1092
|
+
}
|
|
1093
|
+
if (runtime === undefined &&
|
|
1094
|
+
attachment === undefined &&
|
|
1095
|
+
this.#isLive(id) &&
|
|
1096
|
+
this.#deps.bridge.isConnected(id)) {
|
|
1097
|
+
// Nothing of this child's runtime is ours: it was adopted after a parent restart, so
|
|
1098
|
+
// the shutdown request above is all this process can do and the bridge is the only
|
|
1099
|
+
// evidence that it worked.
|
|
1100
|
+
const gone = await this.#awaitDisconnect(id, SUBAGENT_EXIT_WAIT_MS);
|
|
1101
|
+
if (!gone) {
|
|
1102
|
+
return failure("stop", "Child was asked to shut down but its exit is unconfirmed: this process does not own the child's runtime, so close its panel manually", id, "error", ["stopped intent persisted", "runtime metadata retained"], true);
|
|
319
1103
|
}
|
|
320
1104
|
}
|
|
1105
|
+
this.#projectors.delete(id);
|
|
1106
|
+
// An explicit stop is the one thing that clears the doubt about a runtime this process
|
|
1107
|
+
// never saw, so a later send may start a fresh one.
|
|
1108
|
+
this.#unresolvedRuntimes.delete(id);
|
|
1109
|
+
this.#activity.delete(id);
|
|
1110
|
+
const runtimeIdentity = stopped.runtime?.runtimeIdentity;
|
|
1111
|
+
const current = await this.#deps.registry.get(id);
|
|
1112
|
+
if (current !== undefined) {
|
|
1113
|
+
stopped = await this.#deps.registry.update(id, current.revision, (value) => {
|
|
1114
|
+
const { runtime: _runtime, ...withoutRuntime } = value;
|
|
1115
|
+
return withoutRuntime;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
if (this.#taskRegistry !== undefined) {
|
|
1119
|
+
this.#taskRegistry.settle(id, {
|
|
1120
|
+
status: "cancelled",
|
|
1121
|
+
output: `Subagent ${id} was stopped`,
|
|
1122
|
+
truncated: false,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
if (runtimeIdentity !== undefined)
|
|
1126
|
+
this.#deps.tokens?.forget(runtimeIdentity);
|
|
321
1127
|
return publicChild(stopped, false);
|
|
322
1128
|
}
|
|
323
1129
|
catch (error) {
|
|
324
|
-
return failure("stop",
|
|
1130
|
+
return failure("stop", errorMessage(error), id, "error", ["stopped intent persisted", "runtime termination may be incomplete"], true);
|
|
325
1131
|
}
|
|
326
1132
|
finally {
|
|
327
1133
|
this.#stopping.delete(id);
|
|
328
1134
|
}
|
|
329
1135
|
});
|
|
330
1136
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
attach(id, options = {}) {
|
|
340
|
-
const cancel = new AbortController();
|
|
341
|
-
this.#attachCancels.set(id, cancel);
|
|
342
|
-
if (options.signal?.aborted)
|
|
343
|
-
cancel.abort();
|
|
344
|
-
else
|
|
345
|
-
options.signal?.addEventListener("abort", () => cancel.abort(), { once: true });
|
|
346
|
-
this.#frozen.add(id);
|
|
347
|
-
return this.#mutate(id, () => this.#runAttach(id, options.preferredHost, cancel.signal)).finally(() => {
|
|
348
|
-
this.#attachCancels.delete(id);
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
restoreRpc(id) {
|
|
352
|
-
return this.detach(id, { origin: "manual", closeHost: true, operation: "restore_rpc" });
|
|
353
|
-
}
|
|
354
|
-
detach(id, options) {
|
|
355
|
-
return this.#mutate(id, () => this.#runDetach(id, options));
|
|
356
|
-
}
|
|
357
|
-
inspectHost(id) {
|
|
358
|
-
return this.#mutate(id, async () => {
|
|
359
|
-
const attachment = this.#hostAttachments.get(id);
|
|
360
|
-
if (attachment === undefined) {
|
|
361
|
-
return failure("inspect_host", "No host attachment is owned", id);
|
|
362
|
-
}
|
|
363
|
-
const observed = await attachment.observe();
|
|
364
|
-
if (!observed.known || observed.alive) {
|
|
365
|
-
return { alive: observed.alive, known: observed.known, detail: observed.detail };
|
|
366
|
-
}
|
|
367
|
-
return this.#runDetach(id, {
|
|
368
|
-
origin: this.#tuiQuitExpected.has(id) ? "tui_exit" : "tui_crash",
|
|
369
|
-
closeHost: true,
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
async #runAttach(id, preferredHost, signal) {
|
|
374
|
-
const failBeforeClose = async (reason, record, sideEffects = []) => {
|
|
375
|
-
this.#frozen.delete(id);
|
|
376
|
-
return failure("attach", reason, id, record?.state, sideEffects, true);
|
|
377
|
-
};
|
|
1137
|
+
async contactParent(id, reason, message, knownRecord) {
|
|
1138
|
+
const record = knownRecord ?? (await this.#deps.registry.get(id));
|
|
1139
|
+
if (record === undefined)
|
|
1140
|
+
return failure("contact_parent", "Unknown child", id);
|
|
1141
|
+
if (this.#deps.channel === undefined)
|
|
1142
|
+
return failure("contact_parent", "Parent channel unavailable", id, record.state, [], true);
|
|
378
1143
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const runner = this.#runners.get(id);
|
|
388
|
-
if (runner === undefined || !runner.connected)
|
|
389
|
-
return failBeforeClose("Child runtime is not live", record);
|
|
390
|
-
const hostApi = this.#deps.attachHost;
|
|
391
|
-
if (hostApi === undefined)
|
|
392
|
-
return failBeforeClose("No presentation host is configured", record);
|
|
393
|
-
const token = record.runtime === undefined
|
|
394
|
-
? undefined
|
|
395
|
-
: this.#deps.tokens?.get(record.runtime.runtimeIdentity);
|
|
396
|
-
if (token === undefined)
|
|
397
|
-
return failBeforeClose("Bridge token is unavailable for TUI launch", record);
|
|
398
|
-
throwIfAborted(signal);
|
|
399
|
-
const selection = await hostApi.select(preferredHost);
|
|
400
|
-
if (!selection.available)
|
|
401
|
-
return failBeforeClose(selection.reason, record, ["RPC writer retained"]);
|
|
402
|
-
throwIfAborted(signal);
|
|
403
|
-
const state = await withDeadline(runner.request("get_state", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
|
|
404
|
-
if (!isIdlePiState(state)) {
|
|
405
|
-
try {
|
|
406
|
-
await withDeadline(runner.request("pause", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
|
|
407
|
-
}
|
|
408
|
-
catch (error) {
|
|
409
|
-
try {
|
|
410
|
-
await runner.request("cancel_pause");
|
|
411
|
-
}
|
|
412
|
-
catch {
|
|
413
|
-
// Keep RPC; attach still fails below.
|
|
414
|
-
}
|
|
415
|
-
return failBeforeClose(error instanceof Error && error.message === "Attach was cancelled"
|
|
416
|
-
? error.message
|
|
417
|
-
: `Child did not pause before attach deadline: ${error instanceof Error ? error.message : String(error)}`, record, ["RPC writer retained"]);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
throwIfAborted(signal);
|
|
421
|
-
const placement = await planSessionPlacement({
|
|
422
|
-
sessionId: record.sessionId,
|
|
423
|
-
cwd: record.cwd,
|
|
424
|
-
sessionDir: record.launchConfig.sessionDir,
|
|
425
|
-
persistence: record.persistence,
|
|
426
|
-
...(record.sessionPath === undefined ? {} : { sessionPath: record.sessionPath }),
|
|
1144
|
+
await this.#deps.channel.deliver({
|
|
1145
|
+
parentSessionId: record.parentSessionId,
|
|
1146
|
+
childId: id,
|
|
1147
|
+
agent: record.launchConfig.agent.displayName ?? record.launchConfig.agent.name,
|
|
1148
|
+
task: record.initialTask,
|
|
1149
|
+
status: record.state,
|
|
1150
|
+
reason,
|
|
1151
|
+
message,
|
|
427
1152
|
});
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
? record
|
|
435
|
-
: await this.#update(id, (current) => ({
|
|
1153
|
+
if (reason === "blocked") {
|
|
1154
|
+
const projector = this.#projectors.get(id);
|
|
1155
|
+
if (projector !== undefined) {
|
|
1156
|
+
projector.syncState("blocked");
|
|
1157
|
+
}
|
|
1158
|
+
await this.#update(id, (current) => ({
|
|
436
1159
|
...current,
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
launchConfig: { ...current.launchConfig, sessionPath },
|
|
1160
|
+
state: "blocked",
|
|
1161
|
+
interrupted: message,
|
|
440
1162
|
}));
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
config: {
|
|
448
|
-
...flushed.launchConfig,
|
|
449
|
-
sessionPath,
|
|
450
|
-
},
|
|
451
|
-
invocation: flushed.launchConfig.invocation,
|
|
452
|
-
mode: "tui",
|
|
453
|
-
persistence: "flushed",
|
|
454
|
-
bridge: {
|
|
455
|
-
parentSessionId: flushed.parentSessionId,
|
|
456
|
-
subagentId: flushed.subagentId,
|
|
457
|
-
runtimeIdentity: flushed.runtime?.runtimeIdentity ?? "missing",
|
|
458
|
-
endpoint: flushed.runtime?.endpoint ?? "",
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
const attachment = await selection.adapter.attach({
|
|
462
|
-
...launch,
|
|
463
|
-
env: withBridgeToken(launch.env, token),
|
|
464
|
-
});
|
|
465
|
-
if (signal.aborted) {
|
|
466
|
-
this.#hostAttachments.set(id, attachment);
|
|
467
|
-
const restored = await this.#restoreRpcAfterHost(id, runner);
|
|
468
|
-
this.#frozen.delete(id);
|
|
469
|
-
return failure("attach", "Attach was cancelled", id, flushed.state, [restored], false);
|
|
1163
|
+
if (this.#taskRegistry !== undefined) {
|
|
1164
|
+
this.#taskRegistry.settle(id, {
|
|
1165
|
+
status: "failed",
|
|
1166
|
+
output: message,
|
|
1167
|
+
truncated: false,
|
|
1168
|
+
});
|
|
470
1169
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (attachment.launch.timedOut && !observed.alive) {
|
|
480
|
-
this.#hostAttachments.set(id, attachment);
|
|
481
|
-
const restored = await this.#restoreRpcAfterHost(id, runner);
|
|
482
|
-
this.#frozen.delete(id);
|
|
483
|
-
return failure("attach", "Host timed out and TUI writer was not confirmed", id, flushed.state, [restored, "pending input was not replayed"], false);
|
|
484
|
-
}
|
|
485
|
-
if (attachment.launch.timedOut && observed.alive) {
|
|
486
|
-
this.#hostAttachments.set(id, attachment);
|
|
487
|
-
this.#watchHost(id);
|
|
488
|
-
await this.#update(id, (current) => ({ ...current, mode: "tui" }));
|
|
489
|
-
return failure("attach", "Host timed out after TUI started; not starting another writer", id, "idle", ["TUI writer may be live", "RPC writer closed"], false);
|
|
490
|
-
}
|
|
491
|
-
this.#hostAttachments.set(id, attachment);
|
|
492
|
-
this.#watchHost(id);
|
|
493
|
-
const next = await this.#update(id, (current) => ({
|
|
1170
|
+
this.#scheduleSettledPanelClose(id);
|
|
1171
|
+
}
|
|
1172
|
+
else if (reason === "error") {
|
|
1173
|
+
const projector = this.#projectors.get(id);
|
|
1174
|
+
if (projector !== undefined) {
|
|
1175
|
+
projector.syncState("error");
|
|
1176
|
+
}
|
|
1177
|
+
await this.#update(id, (current) => ({
|
|
494
1178
|
...current,
|
|
495
|
-
|
|
496
|
-
state: "
|
|
1179
|
+
intent: "stopped",
|
|
1180
|
+
state: "error",
|
|
1181
|
+
interrupted: message,
|
|
497
1182
|
}));
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
throw error;
|
|
507
|
-
const restored = await this.#restoreRpcAfterHost(id, runner);
|
|
508
|
-
this.#frozen.delete(id);
|
|
509
|
-
return failure("attach", error instanceof Error ? error.message : String(error), id, flushed.state, [restored, "pending input was not replayed"], false);
|
|
1183
|
+
if (this.#taskRegistry !== undefined) {
|
|
1184
|
+
this.#taskRegistry.settle(id, {
|
|
1185
|
+
status: "failed",
|
|
1186
|
+
output: message,
|
|
1187
|
+
truncated: false,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
this.#scheduleSettledPanelClose(id);
|
|
510
1191
|
}
|
|
1192
|
+
return { delivered: true };
|
|
511
1193
|
}
|
|
512
1194
|
catch (error) {
|
|
513
|
-
|
|
514
|
-
return failure("attach", error instanceof Error ? error.message : String(error), id, undefined, ["RPC writer retained"], true);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
async #restoreRpcAfterHost(id, runner) {
|
|
518
|
-
try {
|
|
519
|
-
await this.#releaseHost(id);
|
|
520
|
-
}
|
|
521
|
-
catch (error) {
|
|
522
|
-
return `RPC not restored: ${error instanceof Error ? error.message : String(error)}`;
|
|
1195
|
+
return failure("contact_parent", errorMessage(error), id, record.state, [], true);
|
|
523
1196
|
}
|
|
524
|
-
return this.#restoreRpc(id, runner);
|
|
525
1197
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1198
|
+
/**
|
|
1199
|
+
* Handles a request a child sent to its parent. The two child-initiated operations are the
|
|
1200
|
+
* child's report channel and its Task result, and both are answered here so the child learns
|
|
1201
|
+
* whether the parent accepted them.
|
|
1202
|
+
*/
|
|
1203
|
+
async handleChildRequest(childId, operation, payload) {
|
|
1204
|
+
const record = await this.#deps.registry.get(childId);
|
|
1205
|
+
if (record === undefined)
|
|
1206
|
+
throw new BridgeError("unknown_child", `Unknown child ${childId}`);
|
|
1207
|
+
if (operation === "contact_parent") {
|
|
1208
|
+
if (!isContactReportPayload(payload)) {
|
|
1209
|
+
throw new BridgeError("invalid_payload", "contact_parent requires a report payload");
|
|
1210
|
+
}
|
|
1211
|
+
if (payload.parentSessionId !== record.parentSessionId ||
|
|
1212
|
+
payload.childId !== childId ||
|
|
1213
|
+
payload.runtimeIdentity !== record.runtime?.runtimeIdentity) {
|
|
1214
|
+
throw new BridgeError("stale_report", "This report does not belong to the current runtime");
|
|
1215
|
+
}
|
|
1216
|
+
const reason = payload.reason ?? "blocked";
|
|
1217
|
+
const delivered = await this.contactParent(childId, reason, payload.message, record);
|
|
1218
|
+
if ("reason" in delivered)
|
|
1219
|
+
throw new BridgeError("delivery_failed", delivered.reason);
|
|
1220
|
+
if (reason === "success") {
|
|
1221
|
+
const projector = this.#projectors.get(childId);
|
|
1222
|
+
if (projector !== undefined) {
|
|
1223
|
+
projector.syncState("done");
|
|
1224
|
+
}
|
|
1225
|
+
await this.#update(childId, (current) => ({
|
|
1226
|
+
...current,
|
|
1227
|
+
state: "done",
|
|
1228
|
+
}));
|
|
1229
|
+
}
|
|
1230
|
+
else if (reason === "blocked") {
|
|
1231
|
+
const projector = this.#projectors.get(childId);
|
|
1232
|
+
if (projector !== undefined) {
|
|
1233
|
+
projector.syncState("blocked");
|
|
1234
|
+
}
|
|
1235
|
+
await this.#update(childId, (current) => ({
|
|
1236
|
+
...current,
|
|
1237
|
+
state: "blocked",
|
|
1238
|
+
}));
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
const projector = this.#projectors.get(childId);
|
|
1242
|
+
if (projector !== undefined) {
|
|
1243
|
+
projector.syncState("error");
|
|
1244
|
+
}
|
|
1245
|
+
await this.#update(childId, (current) => ({
|
|
1246
|
+
...current,
|
|
1247
|
+
intent: "stopped",
|
|
1248
|
+
state: "error",
|
|
1249
|
+
}));
|
|
1250
|
+
}
|
|
1251
|
+
return { delivered: true };
|
|
534
1252
|
}
|
|
1253
|
+
throw new BridgeError("unsupported_operation", `The parent does not accept ${operation} from a child`);
|
|
535
1254
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (hostCleanupFailed(result)) {
|
|
543
|
-
throw new Error(result.stderr || result.stdout || "Host cleanup failed");
|
|
544
|
-
}
|
|
545
|
-
const observed = await attachment.observe();
|
|
546
|
-
if (!observed.known || observed.alive) {
|
|
547
|
-
throw new Error(observed.known
|
|
548
|
-
? `TUI writer still alive after cleanup: ${observed.detail}`
|
|
549
|
-
: `TUI writer state unknown after cleanup: ${observed.detail}`);
|
|
1255
|
+
/** Applies a bridge event to this child's record and notifies the raw child-event listeners. */
|
|
1256
|
+
handleEvent(childId, event) {
|
|
1257
|
+
if (isRecord(event) && event.type === "agent_start") {
|
|
1258
|
+
this.#workVersions.set(childId, (this.#workVersions.get(childId) ?? 0) + 1);
|
|
1259
|
+
this.#working.add(childId);
|
|
1260
|
+
this.#notify();
|
|
550
1261
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
this.#hostWatches.delete(id);
|
|
1262
|
+
// Counted before the per-child queue: an event that arrives while a reclaim is running is
|
|
1263
|
+
// queued behind it and could otherwise not cancel a decision already in flight.
|
|
1264
|
+
this.#bumpActivity(childId);
|
|
1265
|
+
const workVersion = this.#workVersions.get(childId) ?? 0;
|
|
1266
|
+
void this.#mutate(childId, () => this.#observe(childId, event, workVersion)).catch((error) => {
|
|
1267
|
+
diagnose(`failed to project event for ${childId}: ${errorMessage(error)}`);
|
|
1268
|
+
});
|
|
559
1269
|
}
|
|
560
|
-
|
|
561
|
-
this.#
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const stop = new AbortController();
|
|
566
|
-
this.#hostWatches.set(id, stop);
|
|
567
|
-
const intervalMs = this.#deps.watchIntervalMs ?? 1_000;
|
|
568
|
-
const tick = () => {
|
|
569
|
-
if (stop.signal.aborted)
|
|
570
|
-
return;
|
|
571
|
-
if (this.#hostAttachments.get(id) !== attachment)
|
|
1270
|
+
handleConnectionChange(childId, connected) {
|
|
1271
|
+
this.#bumpActivity(childId);
|
|
1272
|
+
void this.#mutate(childId, async () => {
|
|
1273
|
+
const record = await this.#deps.registry.get(childId);
|
|
1274
|
+
if (record === undefined)
|
|
572
1275
|
return;
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
return;
|
|
576
|
-
if (this.#hostAttachments.get(id) !== attachment)
|
|
1276
|
+
if (connected) {
|
|
1277
|
+
if (record.intent === "stopped")
|
|
577
1278
|
return;
|
|
578
|
-
|
|
1279
|
+
// A process that left the delegated session is not this child: adopting it would rewrite
|
|
1280
|
+
// the child's transcript from the session the human switched to.
|
|
1281
|
+
if (this.#leftSessions.has(childId))
|
|
579
1282
|
return;
|
|
580
|
-
|
|
581
|
-
this.#
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
const record = await this.#deps.registry.get(id);
|
|
596
|
-
if (record === undefined)
|
|
597
|
-
return failure(operation, "Unknown child", id);
|
|
598
|
-
if (record.intent === "stopped" || record.state === "stopped") {
|
|
599
|
-
this.#stopHostWatch(id);
|
|
600
|
-
return failure(operation, "Stopped child is not restored", id, "stopped", [], false);
|
|
601
|
-
}
|
|
602
|
-
if (record.mode !== "tui") {
|
|
603
|
-
return publicChild(record, this.#runners.get(id)?.connected === true);
|
|
604
|
-
}
|
|
605
|
-
this.#stopHostWatch(id);
|
|
606
|
-
if (options.closeHost === false) {
|
|
607
|
-
this.#hostAttachments.delete(id);
|
|
608
|
-
}
|
|
609
|
-
else {
|
|
1283
|
+
// The runtime this record names is answering, so the doubt about it is gone.
|
|
1284
|
+
this.#unresolvedRuntimes.delete(childId);
|
|
1285
|
+
if (!this.#projectors.has(childId)) {
|
|
1286
|
+
this.#projectors.set(childId, createStateProjector(record.state));
|
|
1287
|
+
}
|
|
1288
|
+
await this.#adopt(childId);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
// No bridge any more: the runtime is gone when nothing of it is left here, which is also
|
|
1292
|
+
// what releases a panel whose child the host no longer runs.
|
|
1293
|
+
await this.#runtimeEnded(childId);
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
#dispatch(childId, event) {
|
|
1297
|
+
for (const listener of this.#childEventListeners) {
|
|
610
1298
|
try {
|
|
611
|
-
|
|
1299
|
+
listener(childId, event);
|
|
612
1300
|
}
|
|
613
1301
|
catch (error) {
|
|
614
|
-
|
|
615
|
-
return failure(operation, error instanceof Error ? error.message : String(error), id, record.state, ["TUI writer was not confirmed gone"], false);
|
|
1302
|
+
diagnose(`child event listener failed for ${childId}: ${errorMessage(error)}`);
|
|
616
1303
|
}
|
|
617
1304
|
}
|
|
618
|
-
const runner = this.#runners.get(id);
|
|
619
|
-
if (runner === undefined || !runner.connected) {
|
|
620
|
-
this.#frozen.delete(id);
|
|
621
|
-
return failure(operation, "Child runtime is not live", id);
|
|
622
|
-
}
|
|
623
|
-
const restored = await this.#restoreRpc(id, runner);
|
|
624
|
-
this.#frozen.delete(id);
|
|
625
|
-
const next = await this.#deps.registry.get(id);
|
|
626
|
-
if (next === undefined)
|
|
627
|
-
return failure(operation, "Unknown child", id);
|
|
628
|
-
if (restored.startsWith("RPC restore failed")) {
|
|
629
|
-
return failure(operation, restored, id, next.state, [restored], false);
|
|
630
|
-
}
|
|
631
|
-
if (options.origin === "tui_crash") {
|
|
632
|
-
await this.#update(id, (current) => ({
|
|
633
|
-
...current,
|
|
634
|
-
interrupted: "TUI exited abnormally; RPC restored waiting for input",
|
|
635
|
-
}));
|
|
636
|
-
await this.contactParent(id, "tui_crash", "Child TUI exited abnormally. RPC was restored and is waiting for input. Pending work was not replayed.");
|
|
637
|
-
}
|
|
638
|
-
return publicChild((await this.#deps.registry.get(id)) ?? next, true);
|
|
639
|
-
}
|
|
640
|
-
async contactParent(id, reason, message) {
|
|
641
|
-
const record = await this.#deps.registry.get(id);
|
|
642
|
-
if (record === undefined)
|
|
643
|
-
return failure("contact_parent", "Unknown child", id);
|
|
644
|
-
if (this.#deps.channel === undefined)
|
|
645
|
-
return failure("contact_parent", "Parent channel unavailable", id, record.state, [], true);
|
|
646
|
-
try {
|
|
647
|
-
await this.#deps.channel.deliver({
|
|
648
|
-
parentSessionId: record.parentSessionId,
|
|
649
|
-
childId: id,
|
|
650
|
-
agent: record.launchConfig.agent.displayName ?? record.launchConfig.agent.name,
|
|
651
|
-
task: record.initialTask,
|
|
652
|
-
status: record.state,
|
|
653
|
-
reason,
|
|
654
|
-
message,
|
|
655
|
-
});
|
|
656
|
-
return { delivered: true };
|
|
657
|
-
}
|
|
658
|
-
catch (error) {
|
|
659
|
-
return failure("contact_parent", error instanceof Error ? error.message : String(error), id, record.state, [], true);
|
|
660
|
-
}
|
|
661
1305
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
this.#stopHostWatch(id);
|
|
665
|
-
for (const runner of this.#runners.values())
|
|
666
|
-
runner.close();
|
|
667
|
-
this.#runners.clear();
|
|
668
|
-
this.#deps.tokens?.clear();
|
|
669
|
-
this.#notify();
|
|
670
|
-
}
|
|
671
|
-
async #observe(id, event, runner) {
|
|
672
|
-
if (this.#runners.get(id) !== runner)
|
|
673
|
-
return;
|
|
1306
|
+
async #observe(id, event, workVersion) {
|
|
1307
|
+
this.#dispatch(id, event);
|
|
674
1308
|
const value = isRecord(event) ? event : undefined;
|
|
675
1309
|
if (value === undefined)
|
|
676
1310
|
return;
|
|
677
|
-
if (value.type === "
|
|
678
|
-
const report = isRecord(value.report) ? value.report : undefined;
|
|
679
|
-
if (report?.type === "pi_subagent_report" &&
|
|
680
|
-
typeof report.parentSessionId === "string" &&
|
|
681
|
-
typeof report.childId === "string" &&
|
|
682
|
-
typeof report.runtimeIdentity === "string" &&
|
|
683
|
-
typeof report.reason === "string" &&
|
|
684
|
-
typeof report.message === "string") {
|
|
685
|
-
const record = await this.#deps.registry.get(id);
|
|
686
|
-
if (record === undefined ||
|
|
687
|
-
record.intent === "stopped" ||
|
|
688
|
-
report.parentSessionId !== record.parentSessionId ||
|
|
689
|
-
report.childId !== id ||
|
|
690
|
-
report.runtimeIdentity !== record.runtime?.runtimeIdentity)
|
|
691
|
-
return;
|
|
692
|
-
await this.contactParent(id, report.reason, report.message);
|
|
693
|
-
}
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
if (value.type === "child_lifecycle") {
|
|
1311
|
+
if (value.type === "child_lifecycle" && isChildLifecyclePayload(value)) {
|
|
697
1312
|
const record = await this.#deps.registry.get(id);
|
|
698
1313
|
if (record === undefined ||
|
|
699
1314
|
record.intent === "stopped" ||
|
|
700
|
-
typeof value.kind !== "string" ||
|
|
701
|
-
typeof value.parentSessionId !== "string" ||
|
|
702
|
-
typeof value.childId !== "string" ||
|
|
703
|
-
typeof value.runtimeIdentity !== "string" ||
|
|
704
1315
|
value.parentSessionId !== record.parentSessionId ||
|
|
705
1316
|
value.childId !== id ||
|
|
706
1317
|
value.runtimeIdentity !== record.runtime?.runtimeIdentity)
|
|
707
1318
|
return;
|
|
708
|
-
if (value.kind === "tui_quit") {
|
|
709
|
-
this.#tuiQuitExpected.add(id);
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
1319
|
if (value.kind === "user_interrupt") {
|
|
713
|
-
const message =
|
|
714
|
-
?
|
|
715
|
-
:
|
|
716
|
-
await this.#update(id, (current) => ({
|
|
1320
|
+
const message = value.message === undefined || value.message === ""
|
|
1321
|
+
? "Task is unfinished and waiting for user intent."
|
|
1322
|
+
: value.message;
|
|
1323
|
+
const updated = await this.#update(id, (current) => ({
|
|
717
1324
|
...current,
|
|
718
1325
|
interrupted: message,
|
|
719
1326
|
}));
|
|
720
|
-
await this.contactParent(id, "user_interrupt", message);
|
|
1327
|
+
await this.contactParent(id, "user_interrupt", message, updated);
|
|
721
1328
|
return;
|
|
722
1329
|
}
|
|
723
1330
|
if (value.kind === "left_session") {
|
|
724
|
-
|
|
1331
|
+
this.#working.delete(id);
|
|
1332
|
+
// The process now serves another session, so it is no longer this child's runtime. Its
|
|
1333
|
+
// credential is revoked and its runtime evidence dropped, so neither a reconnect nor a
|
|
1334
|
+
// later adopt can mistake the new session for this child; the panel belongs to that other
|
|
1335
|
+
// session from now on, which is why it is forgotten here and never closed.
|
|
1336
|
+
const retired = record.runtime?.runtimeIdentity;
|
|
1337
|
+
this.#clearSettledPanelClose(id);
|
|
1338
|
+
this.#projectors.delete(id);
|
|
1339
|
+
this.#attachments.delete(id);
|
|
1340
|
+
this.#leftSessions.add(id);
|
|
1341
|
+
this.#unresolvedRuntimes.delete(id);
|
|
1342
|
+
// The socket is not this child's channel any more either: leaving it up would let a
|
|
1343
|
+
// relaunch mistake the old connection for the new runtime.
|
|
1344
|
+
this.#deps.bridge.disconnect(id);
|
|
1345
|
+
await this.#update(id, (current) => {
|
|
1346
|
+
const { runtime: _runtime, ...withoutRuntime } = current;
|
|
1347
|
+
return {
|
|
1348
|
+
...withoutRuntime,
|
|
1349
|
+
state: "done",
|
|
1350
|
+
interrupted: "The child left its session; the surface is no longer controlled by the parent",
|
|
1351
|
+
};
|
|
1352
|
+
});
|
|
1353
|
+
if (retired !== undefined)
|
|
1354
|
+
this.#deps.tokens?.forget(retired);
|
|
1355
|
+
return;
|
|
725
1356
|
}
|
|
1357
|
+
// A child's own quit is a lifecycle notice, not state: the disconnect that follows says
|
|
1358
|
+
// whether the runtime is gone.
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (value.type === "child_input" && isChildInputPayload(value)) {
|
|
1362
|
+
// Someone typed into the panel, so the child is not idle any more. Only human input counts:
|
|
1363
|
+
// a message this parent sent over the bridge arrives as `rpc` and says nothing about the
|
|
1364
|
+
// panel. The turn that follows re-arms the countdown when it ends with nothing pending.
|
|
1365
|
+
if (value.source === "interactive")
|
|
1366
|
+
this.#clearSettledPanelClose(id);
|
|
726
1367
|
return;
|
|
727
1368
|
}
|
|
1369
|
+
const record = await this.#deps.registry.get(id);
|
|
1370
|
+
if (record === undefined || record.intent === "stopped")
|
|
1371
|
+
return;
|
|
728
1372
|
const projector = this.#projectors.get(id);
|
|
729
1373
|
if (projector === undefined)
|
|
730
1374
|
return;
|
|
731
1375
|
projector.applyEvent(event);
|
|
732
1376
|
let confirmedInput;
|
|
733
|
-
if (value.type === "agent_end"
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
if (record
|
|
740
|
-
sessionContainsUserInput(entries, record.unacknowledgedInput))
|
|
1377
|
+
if (value.type === "agent_end") {
|
|
1378
|
+
// A finished turn is exactly when the session file has the message, so this is where
|
|
1379
|
+
// unacknowledged input becomes part of the child's conversation.
|
|
1380
|
+
const entries = await this.#entries(id);
|
|
1381
|
+
if (entries !== undefined) {
|
|
1382
|
+
projector.rebuild(entries);
|
|
1383
|
+
if (record.unacknowledgedInput !== undefined &&
|
|
1384
|
+
sessionContainsUserInput(entries, record.unacknowledgedInput)) {
|
|
741
1385
|
confirmedInput = record.unacknowledgedInput;
|
|
742
|
-
}
|
|
743
|
-
else {
|
|
744
|
-
const piState = await runner.request("get_state").catch(() => undefined);
|
|
745
|
-
if (isIdlePiState(piState))
|
|
746
|
-
projector.applyEvent({ type: "agent_settled" });
|
|
747
|
-
else if (isRecord(piState) &&
|
|
748
|
-
(piState.isStreaming === true || piState.isCompacting === true)) {
|
|
749
|
-
projector.applyEvent({ type: "agent_start" });
|
|
750
1386
|
}
|
|
751
1387
|
}
|
|
752
1388
|
}
|
|
753
|
-
if (value.type === "runner_exit") {
|
|
754
|
-
this.#runners.delete(id);
|
|
755
|
-
runner.close();
|
|
756
|
-
}
|
|
757
1389
|
const current = await this.#deps.registry.get(id);
|
|
758
1390
|
if (current === undefined || current.intent === "stopped")
|
|
759
1391
|
return;
|
|
760
1392
|
const snapshot = projector.snapshot();
|
|
761
|
-
await this.#update(id, (
|
|
1393
|
+
await this.#update(id, (entry) => {
|
|
1394
|
+
const { interrupted: _prevInterrupted, activeTool: _prevActiveTool, ...cleanEntry } = entry;
|
|
762
1395
|
const base = confirmedInput === undefined
|
|
763
|
-
?
|
|
764
|
-
: (({ unacknowledgedInput: _input,
|
|
1396
|
+
? cleanEntry
|
|
1397
|
+
: (({ unacknowledgedInput: _input, ...rest }) => rest)(cleanEntry);
|
|
765
1398
|
return {
|
|
766
1399
|
...base,
|
|
767
1400
|
state: snapshot.state,
|
|
768
1401
|
...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
|
|
1402
|
+
...(snapshot.activeTool === undefined ? {} : { activeTool: snapshot.activeTool }),
|
|
769
1403
|
usage: snapshot.usage,
|
|
770
1404
|
...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
|
|
771
1405
|
};
|
|
772
|
-
});
|
|
1406
|
+
}, workVersion);
|
|
1407
|
+
if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
|
|
1408
|
+
this.#scheduleSettledPanelClose(id);
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
this.#clearSettledPanelClose(id);
|
|
1412
|
+
}
|
|
1413
|
+
if (value.type === "agent_settled" && (this.#workVersions.get(id) ?? 0) === workVersion) {
|
|
1414
|
+
this.#working.delete(id);
|
|
1415
|
+
this.#notify();
|
|
1416
|
+
const latestRecord = (await this.#deps.registry.get(id)) ?? current;
|
|
1417
|
+
this.#scheduleAutoReport(id, latestRecord);
|
|
1418
|
+
}
|
|
773
1419
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
this.#
|
|
777
|
-
this.#projectors.set(record.subagentId, createStateProjector(record.state));
|
|
778
|
-
runner.onEvent((event) => {
|
|
779
|
-
void this.#mutate(record.subagentId, () => this.#observe(record.subagentId, event, runner)).catch((error) => {
|
|
780
|
-
console.error(`pi-subagents: failed to project event for ${record.subagentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
781
|
-
});
|
|
782
|
-
});
|
|
1420
|
+
/** Runs an update inside the per-child chain used by the mutating operations. */
|
|
1421
|
+
#mutateUpdate(id, updater) {
|
|
1422
|
+
return this.#mutate(id, () => this.#update(id, updater));
|
|
783
1423
|
}
|
|
784
|
-
async #update(id, updater) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1424
|
+
async #update(id, updater, workVersion) {
|
|
1425
|
+
// The store re-reads the record inside its own write, so an undefined revision cannot
|
|
1426
|
+
// lose a concurrent field update and no stale_revision retry is needed.
|
|
1427
|
+
const next = await this.#deps.registry.update(id, undefined, updater);
|
|
1428
|
+
if (workVersion === undefined || workVersion === (this.#workVersions.get(id) ?? 0)) {
|
|
1429
|
+
if (next.state === "running")
|
|
1430
|
+
this.#working.add(id);
|
|
1431
|
+
else if (next.state !== "error")
|
|
1432
|
+
this.#working.delete(id);
|
|
1433
|
+
}
|
|
789
1434
|
this.#notify();
|
|
790
1435
|
return next;
|
|
791
1436
|
}
|
|
@@ -793,10 +1438,12 @@ export class SubagentManager {
|
|
|
793
1438
|
const previous = this.#chains.get(id) ?? Promise.resolve();
|
|
794
1439
|
const next = previous.catch(() => undefined).then(action);
|
|
795
1440
|
this.#chains.set(id, next);
|
|
796
|
-
void next
|
|
1441
|
+
void next
|
|
1442
|
+
.finally(() => {
|
|
797
1443
|
if (this.#chains.get(id) === next)
|
|
798
1444
|
this.#chains.delete(id);
|
|
799
|
-
})
|
|
1445
|
+
})
|
|
1446
|
+
.catch(() => undefined);
|
|
800
1447
|
return next;
|
|
801
1448
|
}
|
|
802
1449
|
}
|