@hheei/pi-subagents 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +45 -137
  2. package/dist/agent-resolver.d.ts +20 -0
  3. package/dist/agent-resolver.js +155 -21
  4. package/dist/bridge-client.d.ts +55 -0
  5. package/dist/bridge-client.js +453 -0
  6. package/dist/bridge-server.d.ts +72 -0
  7. package/dist/bridge-server.js +490 -0
  8. package/dist/builtin-agents.d.ts +24 -0
  9. package/dist/builtin-agents.js +152 -0
  10. package/dist/child-bridge.d.ts +31 -15
  11. package/dist/child-bridge.js +95 -117
  12. package/dist/child-control.d.ts +58 -0
  13. package/dist/child-control.js +176 -0
  14. package/dist/child-process.d.ts +21 -0
  15. package/dist/child-process.js +93 -0
  16. package/dist/commands.d.ts +2 -2
  17. package/dist/commands.js +52 -60
  18. package/dist/diagnostics.d.ts +0 -2
  19. package/dist/diagnostics.js +0 -8
  20. package/dist/domain.d.ts +57 -27
  21. package/dist/domain.js +60 -11
  22. package/dist/extension.d.ts +26 -1
  23. package/dist/extension.js +243 -46
  24. package/dist/host-adapter.d.ts +37 -12
  25. package/dist/host-adapter.js +214 -63
  26. package/dist/json-lines.js +2 -1
  27. package/dist/launch-spec.d.ts +8 -6
  28. package/dist/launch-spec.js +61 -18
  29. package/dist/manager.d.ts +93 -49
  30. package/dist/manager.js +1175 -528
  31. package/dist/markdown.d.ts +8 -0
  32. package/dist/markdown.js +21 -0
  33. package/dist/protocol.d.ts +33 -52
  34. package/dist/protocol.js +41 -52
  35. package/dist/registry.d.ts +3 -8
  36. package/dist/registry.js +41 -163
  37. package/dist/rpc-events.d.ts +3 -5
  38. package/dist/rpc-events.js +5 -9
  39. package/dist/runtime.d.ts +66 -11
  40. package/dist/runtime.js +182 -208
  41. package/dist/session-bootstrap.d.ts +23 -7
  42. package/dist/session-bootstrap.js +79 -15
  43. package/dist/session-fork.d.ts +16 -0
  44. package/dist/session-fork.js +67 -0
  45. package/dist/state.d.ts +7 -0
  46. package/dist/state.js +60 -25
  47. package/dist/tool-activation.d.ts +18 -0
  48. package/dist/tool-activation.js +227 -0
  49. package/dist/tools.d.ts +6 -3
  50. package/dist/tools.js +213 -72
  51. package/dist/widget.d.ts +4 -8
  52. package/dist/widget.js +55 -64
  53. package/package.json +51 -52
  54. package/dist/child-nudge.d.ts +0 -24
  55. package/dist/child-nudge.js +0 -102
  56. package/dist/connector.d.ts +0 -53
  57. package/dist/connector.js +0 -283
  58. package/dist/pause-gate.d.ts +0 -14
  59. package/dist/pause-gate.js +0 -64
  60. package/dist/rpc-adapter.d.ts +0 -40
  61. package/dist/rpc-adapter.js +0 -194
  62. package/dist/runner-entry.d.ts +0 -24
  63. package/dist/runner-entry.js +0 -166
  64. package/dist/runner.d.ts +0 -52
  65. package/dist/runner.js +0 -930
package/dist/manager.js CHANGED
@@ -1,8 +1,24 @@
1
- import { isRecord } from "./domain.js";
2
- import { buildLaunchSpec, withBridgeToken } from "./launch-spec.js";
3
- import { isIdlePiState } from "./rpc-events.js";
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
- mode: record.mode,
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
- function throwIfAborted(signal) {
80
- if (signal?.aborted)
81
- throw new Error("Attach was cancelled");
82
- }
83
- function hostCleanupFailed(result) {
84
- return result.timedOut || result.exitCode !== 0;
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
- #runners = new Map();
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
- console.error(`pi-subagents: onChange listener failed: ${error instanceof Error ? error.message : String(error)}`);
684
+ diagnose(`onChange listener failed: ${errorMessage(error)}`);
115
685
  }
116
686
  }
117
687
  }
118
- /** Reattaches active persisted children without replaying any historical input. */
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 connect = this.#deps.connect;
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
- const runner = await withDeadline(connect(record), this.#deps.deadlineMs ?? 30_000);
127
- this.#attach(record, runner);
128
- const response = await runner.request("get_entries").catch(() => undefined);
129
- const entries = isRecord(response) && Array.isArray(response.entries) ? response.entries : [];
130
- const projector = this.#projectors.get(record.subagentId);
131
- projector?.rebuild(entries);
132
- const snapshot = projector?.snapshot();
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: outcomes
717
+ recovered: settled
162
718
  .filter((outcome) => !("reason" in outcome))
163
719
  .map((outcome) => outcome.childId),
164
- failures: outcomes.filter((outcome) => "reason" in outcome),
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
- if (input.task.trim() === "")
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: input.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
- const runner = await withDeadline(this.#deps.launch(record), this.#deps.deadlineMs ?? 30_000);
179
- this.#attach(record, runner);
851
+ await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 30_000);
180
852
  try {
181
- await withDeadline(runner.request("prompt", { message: input.task }), this.#deps.deadlineMs ?? 30_000);
853
+ await this.#request(record.subagentId, "prompt", { message: cleanTask });
182
854
  }
183
855
  catch (error) {
184
- let failureState = "starting";
856
+ let failureState = "error";
185
857
  try {
186
- await this.#update(record.subagentId, (current) => ({
858
+ await this.#mutateUpdate(record.subagentId, (current) => ({
187
859
  ...current,
188
- state: "failed",
860
+ state: "error",
189
861
  interrupted: "Initial task delivery was not confirmed",
190
- unacknowledgedInput: input.task,
862
+ unacknowledgedInput: cleanTask,
191
863
  }));
192
- failureState = "failed";
864
+ failureState = "error";
193
865
  }
194
866
  catch (updateError) {
195
- console.error(`pi-subagents: failed to persist initial delivery uncertainty for ${record.subagentId}: ${updateError instanceof Error ? updateError.message : String(updateError)}`);
867
+ diagnose(`failed to persist initial delivery uncertainty for ${record.subagentId}: ${errorMessage(updateError)}`);
196
868
  }
197
- return failure("spawn", error instanceof Error ? error.message : String(error), record.subagentId, failureState, [
869
+ return failure("spawn", errorMessage(error), record.subagentId, failureState, [
198
870
  "registry record persisted",
199
- "runner launched",
871
+ "child launched",
200
872
  "initial task delivery was not confirmed",
201
873
  ], false);
202
874
  }
203
- await this.#update(record.subagentId, (current) => {
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
- return failure("spawn", error instanceof Error ? error.message : String(error), undefined, undefined, [], true);
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, message, mode = "auto", signal) {
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 (this.#frozen.has(id) || record.mode === "tui")
221
- return failure("send", "Child input is frozen for attach", id, record.state);
222
- if (record.intent === "stopped" ||
223
- record.state === "done" ||
224
- record.state === "failed" ||
225
- record.state === "stopped")
226
- return failure("send", "Child is not accepting input", id, record.state);
227
- const runner = this.#runners.get(id);
228
- if (runner === undefined)
229
- return failure("send", "Child runtime is not live", id, record.state, [], true);
230
- const state = record.state;
231
- const operation = mode === "auto"
232
- ? state === "running"
233
- ? "steer"
234
- : state === "idle"
235
- ? "follow_up"
236
- : undefined
237
- : mode;
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
- const pending = await this.#update(id, (current) => ({
241
- ...current,
242
- unacknowledgedInput: message,
243
- interrupted: "Input delivery was accepted for dispatch but is not yet confirmed in the session",
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 withDeadline(runner.request(operation, { message }, signal === undefined ? {} : { signal }), this.#deps.deadlineMs ?? 30_000);
1013
+ await this.#request(id, operation, { message });
247
1014
  }
248
1015
  catch (error) {
249
- return failure("send", error instanceof Error ? error.message : String(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
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, runner?.connected === true);
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.#runners.get(record.subagentId)?.connected === true));
1029
+ return records.map((record) => publicChild(record, this.#isLive(record.subagentId)));
264
1030
  }
265
- stop(id, signal) {
266
- this.#attachCancels.get(id)?.abort();
267
- this.#stopHostWatch(id);
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", error instanceof Error ? error.message : String(error), id, undefined, [], true);
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: "stopped",
1059
+ state: "error",
290
1060
  }));
291
1061
  }
292
1062
  catch (error) {
293
1063
  this.#stopping.delete(id);
294
- return failure("stop", error instanceof Error ? error.message : String(error), id, record.state, [], true);
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 (runner !== undefined) {
303
- await withDeadline(runner.request("shutdown", undefined, signal === undefined ? {} : { signal }), this.#deps.deadlineMs ?? 30_000);
304
- await waitForRunnerDisconnect(runner, this.#deps.deadlineMs ?? 30_000, signal);
305
- runner.close();
306
- this.#runners.delete(id);
307
- }
308
- await this.#releaseHost(id);
309
- this.#frozen.delete(id);
310
- const runtimeIdentity = stopped.runtime?.runtimeIdentity;
311
- if (runtimeIdentity !== undefined) {
312
- this.#deps.tokens?.forget(runtimeIdentity);
313
- const current = await this.#deps.registry.get(id);
314
- if (current !== undefined) {
315
- stopped = await this.#deps.registry.update(id, current.revision, (value) => {
316
- const { runtime: _runtime, ...withoutRuntime } = value;
317
- return withoutRuntime;
318
- }, runtimeIdentity);
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", error instanceof Error ? error.message : String(error), id, "stopped", ["stopped intent persisted", "runtime termination may be incomplete"], true);
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
- ownsHostAttachment(identity) {
332
- for (const attachment of this.#hostAttachments.values()) {
333
- if (attachment.identity.host === identity.host &&
334
- attachment.identity.attachmentId === identity.attachmentId)
335
- return true;
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
- throwIfAborted(signal);
380
- const record = await this.#deps.registry.get(id);
381
- if (record === undefined)
382
- return failBeforeClose("Unknown child", undefined);
383
- if (record.intent === "stopped" || record.state === "stopped" || record.state === "done")
384
- return failBeforeClose("Child is not attachable", record);
385
- if (record.mode === "tui")
386
- return failBeforeClose("Child is already attached as TUI", record);
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 (placement.persistence !== "flushed" || placement.sessionPath === undefined)
429
- return failBeforeClose("Session has not been flushed; attach refused", record, [
430
- "RPC writer retained",
431
- ]);
432
- const sessionPath = placement.sessionPath;
433
- const flushed = sessionPath === record.sessionPath && record.persistence === "flushed"
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
- persistence: "flushed",
438
- sessionPath,
439
- launchConfig: { ...current.launchConfig, sessionPath },
1160
+ state: "blocked",
1161
+ interrupted: message,
440
1162
  }));
441
- throwIfAborted(signal);
442
- await withDeadline(runner.request("close_writer", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
443
- const writerClosed = true;
444
- try {
445
- throwIfAborted(signal);
446
- const launch = buildLaunchSpec({
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
- const observed = await attachment.observe();
472
- if (attachment.launch.timedOut && !observed.known) {
473
- this.#hostAttachments.set(id, attachment);
474
- this.#watchHost(id);
475
- await this.#update(id, (current) => ({ ...current, mode: "tui" }));
476
- this.#frozen.delete(id);
477
- return failure("attach", "Host timed out and TUI writer state is unknown; not starting another writer", id, flushed.state, ["RPC writer closed", "TUI writer state unknown"], false);
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
- mode: "tui",
496
- state: "idle",
1179
+ intent: "stopped",
1180
+ state: "error",
1181
+ interrupted: message,
497
1182
  }));
498
- return {
499
- child: publicChild(next, true),
500
- host: selection.selectedHost,
501
- attachmentId: attachment.identity.attachmentId,
502
- };
503
- }
504
- catch (error) {
505
- if (!writerClosed)
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
- this.#frozen.delete(id);
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
- async #restoreRpc(id, runner) {
527
- try {
528
- await withDeadline(runner.request("start_rpc"), this.#deps.deadlineMs ?? 30_000);
529
- await this.#update(id, (current) => ({ ...current, mode: "rpc" }));
530
- return "RPC writer restored; waiting for input";
531
- }
532
- catch (error) {
533
- return `RPC restore failed: ${error instanceof Error ? error.message : String(error)}`;
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
- async #releaseHost(id) {
537
- this.#stopHostWatch(id);
538
- const attachment = this.#hostAttachments.get(id);
539
- if (attachment === undefined)
540
- return;
541
- const result = await attachment.cleanup();
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
- this.#hostAttachments.delete(id);
552
- }
553
- #stopHostWatch(id) {
554
- const watch = this.#hostWatches.get(id);
555
- if (watch === undefined)
556
- return;
557
- watch.abort();
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
- #watchHost(id) {
561
- this.#stopHostWatch(id);
562
- const attachment = this.#hostAttachments.get(id);
563
- if (attachment === undefined)
564
- return;
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
- void attachment.observe().then((observed) => {
574
- if (stop.signal.aborted)
575
- return;
576
- if (this.#hostAttachments.get(id) !== attachment)
1276
+ if (connected) {
1277
+ if (record.intent === "stopped")
577
1278
  return;
578
- if (!observed.known || observed.alive)
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
- const origin = this.#tuiQuitExpected.has(id) ? "tui_exit" : "tui_crash";
581
- this.#tuiQuitExpected.delete(id);
582
- void this.detach(id, { origin, closeHost: true }).catch((error) => {
583
- console.error(`pi-subagents: detach after TUI exit failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
584
- });
585
- });
586
- };
587
- const timer = setInterval(tick, intervalMs);
588
- stop.signal.addEventListener("abort", () => {
589
- clearInterval(timer);
590
- }, { once: true });
591
- tick();
592
- }
593
- async #runDetach(id, options) {
594
- const operation = options.operation ?? "detach";
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
- await this.#releaseHost(id);
1299
+ listener(childId, event);
612
1300
  }
613
1301
  catch (error) {
614
- this.#frozen.delete(id);
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
- closeLocalConnections() {
663
- for (const id of [...this.#hostWatches.keys()])
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 === "subagent_report") {
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 = typeof value.message === "string" && value.message !== ""
714
- ? value.message
715
- : "Task is unfinished and waiting for user intent.";
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
- await this.#runDetach(id, { origin: "session_switch", closeHost: false });
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" || value.type === "runner_events_dropped") {
734
- const response = await runner.request("get_entries").catch(() => undefined);
735
- const entries = isRecord(response) && Array.isArray(response.entries) ? response.entries : [];
736
- projector.rebuild(entries);
737
- if (value.type === "agent_end") {
738
- const record = await this.#deps.registry.get(id);
739
- if (record?.unacknowledgedInput !== undefined &&
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, (record) => {
1393
+ await this.#update(id, (entry) => {
1394
+ const { interrupted: _prevInterrupted, activeTool: _prevActiveTool, ...cleanEntry } = entry;
762
1395
  const base = confirmedInput === undefined
763
- ? record
764
- : (({ unacknowledgedInput: _input, interrupted: _interrupted, ...rest }) => rest)(record);
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
- #attach(record, runner) {
775
- this.#runners.get(record.subagentId)?.close();
776
- this.#runners.set(record.subagentId, runner);
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
- const current = await this.#deps.registry.get(id);
786
- if (current === undefined)
787
- throw new Error("Child disappeared");
788
- const next = await this.#deps.registry.update(id, current.revision, updater);
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.finally(() => {
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
  }