@hheei/pi-subagents 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +35 -20
  2. package/dist/agent-resolver.d.ts +9 -0
  3. package/dist/agent-resolver.js +278 -51
  4. package/dist/builtin-agents.js +50 -37
  5. package/dist/child-bridge.d.ts +0 -8
  6. package/dist/child-bridge.js +60 -43
  7. package/dist/child-control.d.ts +6 -4
  8. package/dist/child-control.js +251 -47
  9. package/dist/child-process.d.ts +2 -0
  10. package/dist/child-process.js +24 -12
  11. package/dist/commands.d.ts +1 -4
  12. package/dist/commands.js +1 -52
  13. package/dist/domain.d.ts +27 -12
  14. package/dist/domain.js +2 -1
  15. package/dist/extension.d.ts +7 -6
  16. package/dist/extension.js +96 -112
  17. package/dist/host-adapter.d.ts +19 -0
  18. package/dist/host-adapter.js +140 -1
  19. package/dist/info.d.ts +9 -0
  20. package/dist/info.js +80 -0
  21. package/dist/launch-spec.d.ts +0 -1
  22. package/dist/launch-spec.js +38 -18
  23. package/dist/lease.d.ts +83 -0
  24. package/dist/lease.js +239 -0
  25. package/dist/mailbox.d.ts +46 -0
  26. package/dist/mailbox.js +368 -0
  27. package/dist/manager.d.ts +20 -20
  28. package/dist/manager.js +421 -398
  29. package/dist/process-lock.d.ts +11 -0
  30. package/dist/process-lock.js +55 -0
  31. package/dist/protocol.d.ts +2 -2
  32. package/dist/protocol.js +1 -0
  33. package/dist/registry.d.ts +1 -1
  34. package/dist/registry.js +99 -82
  35. package/dist/rpc-events.d.ts +1 -1
  36. package/dist/rpc-events.js +2 -1
  37. package/dist/runtime.d.ts +8 -21
  38. package/dist/runtime.js +61 -83
  39. package/dist/session-bootstrap.d.ts +2 -1
  40. package/dist/session-bootstrap.js +22 -14
  41. package/dist/state-db.d.ts +52 -0
  42. package/dist/state-db.js +209 -0
  43. package/dist/state.d.ts +25 -0
  44. package/dist/state.js +166 -37
  45. package/dist/status.d.ts +14 -0
  46. package/dist/status.js +36 -0
  47. package/dist/tool-activation.d.ts +3 -4
  48. package/dist/tool-activation.js +13 -215
  49. package/dist/tool-presentation.d.ts +14 -0
  50. package/dist/tool-presentation.js +193 -0
  51. package/dist/tools.d.ts +6 -1
  52. package/dist/tools.js +134 -180
  53. package/package.json +6 -6
  54. package/dist/bridge-client.d.ts +0 -55
  55. package/dist/bridge-client.js +0 -453
  56. package/dist/bridge-server.d.ts +0 -72
  57. package/dist/bridge-server.js +0 -490
  58. package/dist/session-fork.d.ts +0 -16
  59. package/dist/session-fork.js +0 -67
  60. package/dist/widget.d.ts +0 -12
  61. package/dist/widget.js +0 -149
package/dist/manager.js CHANGED
@@ -1,19 +1,37 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { errorMessage, isRecord } from "@hheei/pi-ext-core";
3
+ import { attachRetainedChild } from "./child-process.js";
4
+ import { isOperationError } from "./domain.js";
2
5
  import { stripHindsightContent } from "./launch-spec.js";
6
+ import { commitLease, createConversationLease, RUNTIME_ENDED_RESUME, } from "./lease.js";
3
7
  import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
4
8
  import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
5
- import { readSessionJsonlEntries } from "./session-fork.js";
6
- import { createStateProjector } from "./state.js";
9
+ async function readSessionJsonlEntries(filePath) {
10
+ const content = await readFile(filePath, "utf8");
11
+ const entries = [];
12
+ for (const line of content.split("\n")) {
13
+ const trimmed = line.trim();
14
+ if (trimmed.length > 0) {
15
+ try {
16
+ entries.push(JSON.parse(trimmed));
17
+ }
18
+ catch {
19
+ // Ignore corrupted line
20
+ }
21
+ }
22
+ }
23
+ return entries;
24
+ }
25
+ import { createStateProjector, parseTranscriptFacts, transcriptFacts, } from "./state.js";
7
26
  /**
8
27
  * 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
28
+ * session lock, so an idle one is reclaimed after this window and a later `agent_send` starts it
10
29
  * again from the same session.
11
30
  */
12
31
  export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
13
32
  export const SUBAGENT_SETTLED_CLOSE_TIMEOUT_MS = 15_000;
14
33
  /** How long stop waits for an adopted child's bridge to go away before calling the exit unconfirmed. */
15
34
  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
35
  function diagnose(message) {
18
36
  if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
19
37
  console.error(`pi-subagents: ${message}`);
@@ -29,9 +47,19 @@ function failure(operation, reason, childId, state, sideEffects = [], safeToRetr
29
47
  safeToRetry,
30
48
  };
31
49
  }
32
- function publicChild(record, live) {
50
+ function acknowledgedPath(response) {
51
+ if (!isRecord(response) ||
52
+ response.accepted !== true ||
53
+ typeof response.path !== "string" ||
54
+ response.path === "") {
55
+ throw new BridgeError("invalid_state", "Child did not confirm its session path");
56
+ }
57
+ return response.path;
58
+ }
59
+ function publicChild(record, live, path = record.sessionPath) {
33
60
  return {
34
61
  id: record.subagentId,
62
+ ...(path === undefined ? {} : { path }),
35
63
  agent: record.launchConfig.agent.name,
36
64
  ...(record.launchConfig.agent.displayName === undefined
37
65
  ? {}
@@ -40,12 +68,12 @@ function publicChild(record, live) {
40
68
  presentation: record.presentation,
41
69
  cwd: record.cwd,
42
70
  sessionId: record.sessionId,
71
+ ...(record.initialTask === undefined ? {} : { task: record.initialTask }),
43
72
  ...(record.latestSummary === undefined ? {} : { summary: record.latestSummary }),
44
73
  ...(record.activeTool === undefined ? {} : { activeTool: record.activeTool }),
45
74
  ...(record.usage === undefined ? {} : { usage: record.usage }),
46
75
  ...(record.interrupted === undefined ? {} : { interrupted: record.interrupted }),
47
76
  freshness: live ? "live" : "last_known",
48
- interactive: record.launchConfig.interactive,
49
77
  model: record.launchConfig.model,
50
78
  thinking: record.launchConfig.thinking,
51
79
  createdAt: record.createdAt,
@@ -64,25 +92,6 @@ function withDeadline(operation, timeoutMs) {
64
92
  });
65
93
  });
66
94
  }
67
- function sessionContainsUserInput(entries, expected) {
68
- return entries.some((entry) => {
69
- const value = isRecord(entry) ? entry : undefined;
70
- const message = isRecord(value?.message) ? value.message : undefined;
71
- if (message?.role !== "user")
72
- return false;
73
- if (typeof message.content === "string")
74
- return message.content === expected;
75
- if (!Array.isArray(message.content))
76
- return false;
77
- const text = message.content
78
- .map((part) => {
79
- const content = isRecord(part) ? part : undefined;
80
- return content?.type === "text" && typeof content.text === "string" ? content.text : "";
81
- })
82
- .join("");
83
- return text === expected;
84
- });
85
- }
86
95
  /**
87
96
  * Extracts the trailing text of the most recent assistant message in session entries.
88
97
  * Used to automatically capture a subagent's final output without requiring a tool call.
@@ -114,6 +123,7 @@ export class SubagentManager {
114
123
  #deps;
115
124
  #chains = new Map();
116
125
  #processes = new Map();
126
+ #ready;
117
127
  /** Panels this process opened. A panel child's process is held by the host, not by us. */
118
128
  #attachments = new Map();
119
129
  /**
@@ -121,36 +131,22 @@ export class SubagentManager {
121
131
  * process is not this child's runtime any more, so it is neither live nor ours to close.
122
132
  */
123
133
  #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
134
  /** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
132
135
  #activity = new Map();
133
136
  #projectors = new Map();
137
+ /** Display paths do not imply that a session has been flushed to disk. */
138
+ #paths = new Map();
134
139
  /** Known child work in flight, separate from failures recorded before Pi finally settles. */
135
140
  #working = new Set();
136
141
  /** A settled event from an older run cannot clear a newer synchronous start signal. */
137
142
  #workVersions = new Map();
138
143
  #settledPanelCloseTimers = new Map();
139
- #autoReportTimers = new Map();
144
+ #pendingAutoReports = new Map();
145
+ #autoReportBatchTimer;
140
146
  #lastDeliveredTurn = new Map();
141
147
  #stopping = new Set();
142
148
  #listeners = new Set();
143
- #childEventListeners = new Set();
144
149
  #unsubscribe = [];
145
- #taskRegistry;
146
- bindTaskRegistry(registry) {
147
- this.#taskRegistry = registry;
148
- return () => {
149
- if (this.#taskRegistry === registry) {
150
- this.#taskRegistry = undefined;
151
- }
152
- };
153
- }
154
150
  constructor(deps) {
155
151
  this.#deps = deps;
156
152
  // The manager owns the transport's two inbound streams: child events and connection
@@ -158,17 +154,7 @@ export class SubagentManager {
158
154
  this.#unsubscribe.push(deps.bridge.onEvent((childId, event) => this.handleEvent(childId, event)));
159
155
  this.#unsubscribe.push(deps.bridge.onConnectionChange((childId, connected) => this.handleConnectionChange(childId, connected)));
160
156
  }
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
- };
170
- }
171
- /** Presentation-only: widget and similar projections re-read list() after this. */
157
+ /** Presentation-only: status and similar projections re-read list() after this. */
172
158
  onChange(listener) {
173
159
  this.#listeners.add(listener);
174
160
  return () => {
@@ -176,6 +162,11 @@ export class SubagentManager {
176
162
  };
177
163
  }
178
164
  dispose() {
165
+ if (this.#autoReportBatchTimer !== undefined) {
166
+ clearTimeout(this.#autoReportBatchTimer);
167
+ this.#autoReportBatchTimer = undefined;
168
+ }
169
+ this.#pendingAutoReports.clear();
179
170
  for (const timer of this.#settledPanelCloseTimers.values()) {
180
171
  clearTimeout(timer);
181
172
  }
@@ -190,12 +181,17 @@ export class SubagentManager {
190
181
  */
191
182
  closeLocalConnections() {
192
183
  this.dispose();
184
+ if (this.#autoReportBatchTimer !== undefined) {
185
+ clearTimeout(this.#autoReportBatchTimer);
186
+ this.#autoReportBatchTimer = undefined;
187
+ }
188
+ this.#pendingAutoReports.clear();
193
189
  this.#processes.clear();
194
190
  this.#attachments.clear();
195
191
  this.#leftSessions.clear();
196
- this.#unresolvedRuntimes.clear();
197
192
  this.#activity.clear();
198
193
  this.#projectors.clear();
194
+ this.#paths.clear();
199
195
  this.#working.clear();
200
196
  this.#workVersions.clear();
201
197
  this.#notify();
@@ -215,10 +211,6 @@ export class SubagentManager {
215
211
  const launch = await openPanel(record);
216
212
  if (launch.handle !== undefined)
217
213
  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
214
  if (launch.failure !== undefined)
223
215
  throw new Error(launch.failure);
224
216
  return;
@@ -249,117 +241,61 @@ export class SubagentManager {
249
241
  */
250
242
  async #runtimeEnded(id) {
251
243
  const runtime = this.#processes.get(id);
252
- // A process that is still running is not an ended runtime: the bridge may be reconnecting.
244
+ // A live process is not over. The lock is the connection: an exit this process already
245
+ // saw is recorded here even when the claim has not dropped yet. A later release is the
246
+ // same end, and only the transition that leaves `running` schedules a report.
253
247
  if (runtime?.alive === true)
254
248
  return;
255
- if (runtime !== undefined)
249
+ const sawExit = runtime !== undefined;
250
+ if (sawExit) {
256
251
  this.#processes.delete(id);
257
- if (this.#deps.bridge.isConnected(id))
252
+ if (this.#deps.bridge.isConnected(id))
253
+ this.#deps.bridge.disconnect(id);
254
+ }
255
+ if (!sawExit && this.#deps.bridge.isConnected(id))
258
256
  return;
259
257
  this.#working.delete(id);
260
- await this.#releasePanel(id);
258
+ await this.#dropDeadPanel(id);
261
259
  this.#clearSettledPanelClose(id);
262
260
  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
- }
261
+ await this.#recordWriterGone(id);
287
262
  }
288
- /** True when a runtime for this child was started here and may still own its session. */
263
+ /** True while a writer this process can see is still there: a held lock, or a process handle. A panel tab is not a writer. */
289
264
  #runtimeStarted(id) {
290
- return this.#isLive(id) || this.#processes.has(id) || this.#attachments.has(id);
265
+ return this.#isLive(id) || this.#processes.has(id);
291
266
  }
292
267
  /**
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.
268
+ * A new runtime may not start while a writer this process can see is still there. A connected
269
+ * bridge is that writer, so input goes to it instead of through here. A leftover tab is not a writer.
296
270
  */
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";
271
+ #runtimeDoubt(id) {
272
+ if (this.#processes.get(id)?.alive === true) {
273
+ return "This child's process is still running; stop it, then send to this same child";
311
274
  }
312
275
  return undefined;
313
276
  }
314
- /** Counts an inbound bridge signal, whether or not its handling is queued behind another task. */
315
- #bumpActivity(id) {
277
+ /**
278
+ * Counts an inbound signal before its handler is queued, so a decision already in flight can see
279
+ * it. invalidateReport is only for a new turn: compaction, a late update, or the bridge dropping
280
+ * are not the child starting over, and treating them as such dropped the reply.
281
+ */
282
+ #bumpActivity(id, invalidateReport = true) {
316
283
  this.#activity.set(id, (this.#activity.get(id) ?? 0) + 1);
317
- this.#clearAutoReport(id);
284
+ if (invalidateReport)
285
+ this.#clearAutoReport(id);
318
286
  this.#clearSettledPanelClose(id);
319
287
  }
320
288
  /**
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.
289
+ * Forgets a panel the host already says is gone, and closes that leftover tab. A tab the host
290
+ * still reports, or cannot see, stays in hand so stop can close it. It does not block a send.
325
291
  */
326
- async #releasePanel(id) {
292
+ async #dropDeadPanel(id) {
327
293
  const attachment = this.#attachments.get(id);
328
294
  if (attachment === undefined)
329
295
  return;
330
296
  const observed = await attachment.observe();
331
297
  if (!(observed.known && !observed.alive))
332
298
  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 host reports that the child process exited while running; the cause is unknown. 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
299
  this.#attachments.delete(id);
364
300
  try {
365
301
  const cleanup = await attachment.cleanup();
@@ -371,6 +307,58 @@ export class SubagentManager {
371
307
  diagnose(`could not close the leftover ${attachment.identity.host} panel of child ${id}: ${errorMessage(error)}`);
372
308
  }
373
309
  }
310
+ /**
311
+ * The ledger stops naming a writer that is gone. A child that was running becomes blocked, once,
312
+ * unless its session already ends in a completed reply: that reply is delivered instead. The next
313
+ * send starts the same session again. Callers that still have a tab close it themselves.
314
+ */
315
+ async #recordWriterGone(id) {
316
+ const current = await this.#deps.registry.get(id);
317
+ if (current !== undefined &&
318
+ current.state === "running" &&
319
+ current.intent !== "stopped" &&
320
+ (await this.#finishedReply(id, current.unacknowledgedInput))) {
321
+ const released = await this.#releaseRuntime(id, "finished");
322
+ if (released === undefined)
323
+ return;
324
+ const next = await this.#update(id, (entry) => {
325
+ const { interrupted: _interrupted, ...rest } = entry;
326
+ return { ...rest, state: "done" };
327
+ });
328
+ this.#scheduleAutoReport(id, next);
329
+ return;
330
+ }
331
+ await this.#releaseRuntime(id, "drop");
332
+ }
333
+ /**
334
+ * True when the session file ends on a completed assistant reply and any send still being
335
+ * confirmed is already in that file. An unflushed prompt is not a finished turn.
336
+ */
337
+ async #finishedReply(id, expectedInput) {
338
+ const entries = await this.#diskEntries(id);
339
+ if (entries === undefined || !persistedReplyFinished(entries))
340
+ return false;
341
+ if (expectedInput === undefined)
342
+ return true;
343
+ return transcriptFacts(entries, expectedInput).containsInput;
344
+ }
345
+ /** Runtime leaves the ledger only through a lease edge. */
346
+ async #releaseRuntime(id, outcome) {
347
+ const current = await this.#deps.registry.get(id);
348
+ if (current === undefined)
349
+ return undefined;
350
+ const identity = current.runtime?.runtimeIdentity;
351
+ const next = await commitLease(this.#deps.registry, id, { type: "runtime-gone", outcome });
352
+ if (identity !== undefined && next.runtime === undefined)
353
+ this.#deps.tokens?.forget(identity);
354
+ if (outcome === "drop" &&
355
+ current.state === "running" &&
356
+ current.intent !== "stopped" &&
357
+ next.state === "blocked") {
358
+ this.#scheduleAutoReport(id, next);
359
+ }
360
+ return next;
361
+ }
374
362
  /** Waits for a child's bridge connection to end; false when it is still connected after the wait. */
375
363
  #awaitDisconnect(id, timeoutMs) {
376
364
  if (!this.#deps.bridge.isConnected(id))
@@ -397,21 +385,29 @@ export class SubagentManager {
397
385
  * only ever closed on a judgement, never on a guess.
398
386
  */
399
387
  #scheduleAutoReport(id, record) {
400
- this.#clearAutoReport(id);
401
- const timer = setTimeout(() => {
402
- this.#autoReportTimers.delete(id);
403
- void this.#performAutoReport(id, record).catch((error) => {
404
- diagnose(`failed to report child ${id}: ${errorMessage(error)}`);
405
- });
406
- }, 5_000);
407
- timer.unref?.();
408
- this.#autoReportTimers.set(id, timer);
388
+ this.#pendingAutoReports.set(id, record);
389
+ if (this.#autoReportBatchTimer !== undefined) {
390
+ clearTimeout(this.#autoReportBatchTimer);
391
+ }
392
+ // Batch reports: if all children settled, flush in 500ms; otherwise wait up to 10000ms for others
393
+ const delayMs = this.#working.size > 0 ? 10_000 : 500;
394
+ this.#autoReportBatchTimer = setTimeout(async () => {
395
+ this.#autoReportBatchTimer = undefined;
396
+ const batch = [...this.#pendingAutoReports.entries()];
397
+ this.#pendingAutoReports.clear();
398
+ for (const [childId, childRecord] of batch) {
399
+ await this.#performAutoReport(childId, childRecord).catch((error) => {
400
+ diagnose(`failed to report child ${childId}: ${errorMessage(error)}`);
401
+ });
402
+ }
403
+ }, delayMs);
404
+ this.#autoReportBatchTimer.unref?.();
409
405
  }
410
406
  #clearAutoReport(id) {
411
- const timer = this.#autoReportTimers.get(id);
412
- if (timer !== undefined) {
413
- clearTimeout(timer);
414
- this.#autoReportTimers.delete(id);
407
+ this.#pendingAutoReports.delete(id);
408
+ if (this.#pendingAutoReports.size === 0 && this.#autoReportBatchTimer !== undefined) {
409
+ clearTimeout(this.#autoReportBatchTimer);
410
+ this.#autoReportBatchTimer = undefined;
415
411
  }
416
412
  }
417
413
  #scheduleSettledPanelClose(id) {
@@ -461,7 +457,8 @@ export class SubagentManager {
461
457
  if (attachment !== undefined) {
462
458
  const observed = await attachment.observe();
463
459
  if (observed.known && !observed.alive) {
464
- await this.#releasePanel(id);
460
+ await this.#dropDeadPanel(id);
461
+ await this.#recordWriterGone(id);
465
462
  return;
466
463
  }
467
464
  if ((this.#activity.get(id) ?? 0) !== activity) {
@@ -494,25 +491,9 @@ export class SubagentManager {
494
491
  this.#processes.delete(id);
495
492
  if (attachment !== undefined)
496
493
  this.#attachments.delete(id);
497
- this.#unresolvedRuntimes.delete(id);
498
- await this.#update(id, (current) => {
499
- if (current.intent === "stopped")
500
- return current;
501
- const { runtime: _runtime, ...withoutRuntime } = current;
502
- return withoutRuntime;
503
- });
504
- }
505
- else {
506
- await this.#update(id, (current) => {
507
- if (current.intent === "stopped")
508
- return current;
509
- return {
510
- ...current,
511
- state: "error",
512
- interrupted: "The child panel is not confirmed gone: the child process may still be running",
513
- };
514
- });
494
+ await this.#releaseRuntime(id, "drop");
515
495
  }
496
+ // The tab is still there, or the host could not say. Leave it. The caller closes it.
516
497
  });
517
498
  }
518
499
  async #performAutoReport(id, _record) {
@@ -523,21 +504,37 @@ export class SubagentManager {
523
504
  return;
524
505
  const activity = this.#activity.get(id) ?? 0;
525
506
  const workVersion = this.#workVersions.get(id) ?? 0;
526
- const entries = await this.#entries(id);
527
- const text = entries !== undefined ? extractLastAssistantText(entries) : undefined;
507
+ const facts = await this.#facts(id);
508
+ const text = facts?.summary;
528
509
  await this.#mutate(id, async () => {
529
510
  const current = await this.#deps.registry.get(id);
511
+ const activityMoved = (this.#activity.get(id) ?? 0) !== activity;
512
+ const versionMoved = (this.#workVersions.get(id) ?? 0) !== workVersion;
530
513
  if (current === undefined ||
531
514
  current.intent === "stopped" ||
532
515
  current.state === "running" ||
533
516
  this.#working.has(id) ||
534
- (this.#activity.get(id) ?? 0) !== activity ||
535
- (this.#workVersions.get(id) ?? 0) !== workVersion)
517
+ activityMoved ||
518
+ versionMoved) {
519
+ // Still settled: the signal was bookkeeping, not a new turn. Dropping the reply here left
520
+ // the parent silent until the process died and the ledger was still running.
521
+ if (current !== undefined &&
522
+ current.intent !== "stopped" &&
523
+ current.state !== "running" &&
524
+ !this.#working.has(id) &&
525
+ (activityMoved || versionMoved)) {
526
+ this.#scheduleAutoReport(id, current);
527
+ }
536
528
  return;
537
- const hasError = record.state === "error" ||
538
- (record.interrupted !== undefined && record.interrupted.trim() !== "");
539
- const isBlocked = hasError;
540
- if (!isBlocked && (text === undefined || text.trim() === "")) {
529
+ }
530
+ const isError = current.state === "error" || record.state === "error";
531
+ const isBlocked = !isError &&
532
+ (current.state === "blocked" ||
533
+ record.state === "blocked" ||
534
+ (current.interrupted !== undefined && current.interrupted.trim() !== "") ||
535
+ (record.interrupted !== undefined && record.interrupted.trim() !== ""));
536
+ const failed = isError || isBlocked;
537
+ if (!failed && (text === undefined || text.trim() === "")) {
541
538
  const projector = this.#projectors.get(id);
542
539
  if (projector !== undefined) {
543
540
  projector.syncState("done");
@@ -549,22 +546,19 @@ export class SubagentManager {
549
546
  state: "done",
550
547
  };
551
548
  });
552
- if (this.#taskRegistry !== undefined) {
553
- this.#taskRegistry.settle(id, {
554
- status: "completed",
555
- output: "",
556
- truncated: false,
557
- });
558
- }
559
549
  return;
560
550
  }
561
551
  let message;
562
- if (isBlocked) {
563
- if (record.interrupted !== undefined && record.interrupted.trim() !== "") {
552
+ const interruptedText = current.interrupted?.trim() || record.interrupted?.trim();
553
+ if (isError) {
554
+ message = interruptedText || "Subagent failed without error details.";
555
+ }
556
+ else if (isBlocked) {
557
+ if (interruptedText !== undefined && interruptedText !== "") {
564
558
  message =
565
559
  text !== undefined && text.trim() !== ""
566
- ? `${record.interrupted}\n\n${text}`
567
- : record.interrupted;
560
+ ? `${interruptedText}\n\n${text}`
561
+ : interruptedText;
568
562
  }
569
563
  else if (text !== undefined && text.trim() !== "") {
570
564
  message = text;
@@ -576,13 +570,15 @@ export class SubagentManager {
576
570
  else {
577
571
  message = text ?? "";
578
572
  }
579
- if (this.#lastDeliveredTurn.get(id) === message)
573
+ const lastDelivered = this.#lastDeliveredTurn.get(id);
574
+ if (lastDelivered !== undefined &&
575
+ lastDelivered.workVersion === workVersion &&
576
+ lastDelivered.message === message) {
580
577
  return;
581
- this.#lastDeliveredTurn.set(id, message);
582
- const reason = isBlocked ? "blocked" : "success";
583
- const taskStatus = isBlocked ? "failed" : "completed";
578
+ }
579
+ const reason = isError ? "error" : isBlocked ? "blocked" : "success";
584
580
  const projector = this.#projectors.get(id);
585
- const updatedRecord = !isBlocked
581
+ const updatedRecord = !failed
586
582
  ? await this.#update(id, (current) => {
587
583
  const { interrupted: _interrupted, ...clean } = current;
588
584
  return {
@@ -592,26 +588,26 @@ export class SubagentManager {
592
588
  })
593
589
  : await this.#update(id, (current) => ({
594
590
  ...current,
595
- state: "blocked",
591
+ state: isError ? "error" : "blocked",
596
592
  interrupted: current.interrupted ?? message,
597
593
  }));
598
594
  if (projector !== undefined) {
599
- projector.syncState(isBlocked ? "blocked" : "done");
595
+ projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
600
596
  }
601
597
  this.#scheduleSettledPanelClose(id);
598
+ let deliveryOk = true;
602
599
  if (this.#deps.channel !== undefined) {
603
- await this.contactParent(id, reason, message, updatedRecord);
600
+ const contactResult = await this.contactParent(id, reason, message, updatedRecord);
601
+ if (isOperationError(contactResult)) {
602
+ deliveryOk = false;
603
+ }
604
604
  }
605
- if (this.#taskRegistry !== undefined) {
606
- this.#taskRegistry.settle(id, {
607
- status: taskStatus,
608
- output: message,
609
- truncated: false,
610
- });
605
+ if (deliveryOk) {
606
+ this.#lastDeliveredTurn.set(id, { workVersion, message });
611
607
  }
612
608
  });
613
609
  }
614
- /** Reads a child's session entries; exposed for Task completion inspection. */
610
+ /** Reads a child's session entries; used by transcript inspection fallbacks. */
615
611
  async getEntries(id) {
616
612
  return this.#entries(id);
617
613
  }
@@ -664,6 +660,37 @@ export class SubagentManager {
664
660
  }
665
661
  return { sessionPath: record.sessionPath, persistence: record.persistence };
666
662
  }
663
+ /** The session file, when the bridge cannot answer. A missing file is not an empty transcript. */
664
+ async #diskEntries(id) {
665
+ const record = await this.#deps.registry.get(id).catch(() => undefined);
666
+ if (record === undefined)
667
+ return undefined;
668
+ const sessionPath = record.sessionPath ??
669
+ (await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
670
+ if (sessionPath === undefined)
671
+ return undefined;
672
+ try {
673
+ const diskEntries = await readSessionJsonlEntries(sessionPath);
674
+ return diskEntries.length > 0 ? diskEntries : undefined;
675
+ }
676
+ catch {
677
+ return undefined;
678
+ }
679
+ }
680
+ /**
681
+ * Usage, the last assistant text, and whether one input landed. The child computes this
682
+ * from the session it already has; the transcript is not copied into the mailbox.
683
+ */
684
+ async #facts(id, expectedInput) {
685
+ if (this.#deps.bridge.isConnected(id)) {
686
+ const response = await this.#request(id, "session_facts", expectedInput === undefined ? undefined : { expectedInput }).catch(() => undefined);
687
+ const facts = parseTranscriptFacts(response);
688
+ if (facts !== undefined)
689
+ return facts;
690
+ }
691
+ const entries = await this.#diskEntries(id);
692
+ return entries === undefined ? undefined : transcriptFacts(entries, expectedInput);
693
+ }
667
694
  /** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
668
695
  async #entries(id) {
669
696
  if (this.#deps.bridge.isConnected(id)) {
@@ -672,22 +699,7 @@ export class SubagentManager {
672
699
  return response.entries;
673
700
  }
674
701
  }
675
- const record = await this.#deps.registry.get(id).catch(() => undefined);
676
- if (record !== undefined) {
677
- const sessionPath = record.sessionPath ??
678
- (await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
679
- if (sessionPath !== undefined) {
680
- try {
681
- const diskEntries = await readSessionJsonlEntries(sessionPath);
682
- if (diskEntries.length > 0)
683
- return diskEntries;
684
- }
685
- catch {
686
- // return undefined below
687
- }
688
- }
689
- }
690
- return undefined;
702
+ return this.#diskEntries(id);
691
703
  }
692
704
  #request(id, operation, payload) {
693
705
  return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
@@ -719,8 +731,6 @@ export class SubagentManager {
719
731
  await this.#settleUnrecovered(record);
720
732
  return undefined;
721
733
  }
722
- // The runtime this record names answered, so the doubt about it is gone.
723
- this.#unresolvedRuntimes.delete(record.subagentId);
724
734
  await this.#adopt(record.subagentId);
725
735
  return { childId: record.subagentId };
726
736
  }
@@ -738,29 +748,18 @@ export class SubagentManager {
738
748
  };
739
749
  }
740
750
  /**
741
- * A record that names a runtime which did not reconnect. A background child is held by its
742
- * parent's stdin pipe, so it died with the parent and that evidence is stale; a panel child is
743
- * held by the host and may still be running, so nothing here may start a second runtime for the
744
- * same session until the user stops it.
751
+ * A record that names a runtime which did not reconnect. A background child died with this
752
+ * process, because its stdin pipe did. A panel child is the same fact as any other missing
753
+ * writer: the ledger drops it, and a later send starts the session again.
745
754
  */
746
755
  async #settleUnrecovered(record) {
747
756
  this.#working.delete(record.subagentId);
748
- const retired = record.runtime;
749
- if (retired === undefined)
750
- return;
757
+ this.#projectors.delete(record.subagentId);
751
758
  if (record.presentation === "panel") {
752
- this.#unresolvedRuntimes.add(record.subagentId);
759
+ await this.#recordWriterGone(record.subagentId);
753
760
  return;
754
761
  }
755
- await this.#update(record.subagentId, (current) => {
756
- const { runtime: _runtime, ...rest } = current;
757
- return {
758
- ...rest,
759
- ...(current.state === "running" ? { state: "done" } : {}),
760
- interrupted: "The child runtime ended with the parent process; a later send starts it again from the same session",
761
- };
762
- });
763
- this.#deps.tokens?.forget(retired.runtimeIdentity);
762
+ await this.#releaseRuntime(record.subagentId, "parent-exit");
764
763
  }
765
764
  /** Reads a connected child's real state and puts the record back in step with it. */
766
765
  async #adopt(id) {
@@ -769,9 +768,9 @@ export class SubagentManager {
769
768
  const record = await this.#deps.registry.get(id);
770
769
  if (projector === undefined || record === undefined)
771
770
  return;
772
- const entries = await this.#entries(id);
773
- if (entries !== undefined)
774
- projector.rebuild(entries);
771
+ const facts = await this.#facts(id, record.unacknowledgedInput);
772
+ if (facts !== undefined)
773
+ projector.applyTranscriptFacts(facts);
775
774
  const state = await this.#request(id, "get_state");
776
775
  if (!isRecord(state) ||
777
776
  typeof state.idle !== "boolean" ||
@@ -779,6 +778,9 @@ export class SubagentManager {
779
778
  throw new BridgeError("invalid_state", `Child ${id} did not confirm its current work state`);
780
779
  }
781
780
  const idle = state.idle && !state.pendingMessages;
781
+ if (typeof state.path === "string" && state.path !== "") {
782
+ this.#paths.set(id, state.path);
783
+ }
782
784
  if ((this.#workVersions.get(id) ?? 0) === workVersion) {
783
785
  if (idle)
784
786
  this.#working.delete(id);
@@ -791,9 +793,7 @@ export class SubagentManager {
791
793
  if (projector.snapshot().state !== "error")
792
794
  projector.syncState(idle ? "done" : "running");
793
795
  const snapshot = projector.snapshot();
794
- const confirmedInput = entries !== undefined &&
795
- record.unacknowledgedInput !== undefined &&
796
- sessionContainsUserInput(entries, record.unacknowledgedInput)
796
+ const confirmedInput = facts?.containsInput && record.unacknowledgedInput !== undefined
797
797
  ? record.unacknowledgedInput
798
798
  : undefined;
799
799
  await this.#update(id, (current) => {
@@ -803,12 +803,11 @@ export class SubagentManager {
803
803
  ? current
804
804
  : (({ unacknowledgedInput: _input, ...rest }) => rest)(current);
805
805
  const hasError = snapshot.state === "error";
806
+ const { interrupted: _interrupted, ...withoutInterrupted } = base;
807
+ const { latestSummary: _summary, activeTool: _tool, usage: _usage, ...withoutVolatile } = withoutInterrupted;
806
808
  return {
807
- ...base,
809
+ ...withoutVolatile,
808
810
  state: hasError ? "error" : idle ? "done" : "running",
809
- ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
810
- usage: snapshot.usage,
811
- ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
812
811
  };
813
812
  }, workVersion);
814
813
  if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
@@ -866,8 +865,10 @@ export class SubagentManager {
866
865
  this.#working.add(childId);
867
866
  this.#notify();
868
867
  await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
868
+ let path;
869
869
  try {
870
- await this.#request(record.subagentId, "prompt", { message: cleanTask });
870
+ path = acknowledgedPath(await this.#request(record.subagentId, "prompt", { message: cleanTask }));
871
+ this.#paths.set(record.subagentId, path);
871
872
  }
872
873
  catch (error) {
873
874
  let failureState = "error";
@@ -893,30 +894,9 @@ export class SubagentManager {
893
894
  const { latestSummary: _latestSummary, ...rest } = current;
894
895
  return { ...rest, state: "running" };
895
896
  });
896
- if (this.#taskRegistry !== undefined) {
897
- try {
898
- this.#taskRegistry.create({
899
- id: record.subagentId,
900
- type: "agent",
901
- purpose: `${input.agent}: ${input.task}`,
902
- inlineResult: true,
903
- begin: () => ({
904
- stop: () => {
905
- void this.stop(record.subagentId);
906
- },
907
- describe: () => ({
908
- output: `Subagent ${record.subagentId} (${input.agent}) is running`,
909
- truncated: false,
910
- }),
911
- }),
912
- });
913
- }
914
- catch (regError) {
915
- diagnose(`failed to register background task for subagent ${record.subagentId}: ${errorMessage(regError)}`);
916
- }
917
- }
918
897
  return {
919
- child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true),
898
+ child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true, path),
899
+ path,
920
900
  ...(placement.note === undefined ? {} : { note: placement.note }),
921
901
  };
922
902
  }
@@ -926,12 +906,7 @@ export class SubagentManager {
926
906
  const failedId = childId;
927
907
  const reason = errorMessage(error);
928
908
  const failed = await this.#mutate(failedId, async () => {
929
- const record = await this.#deps.registry.get(failedId);
930
- const uncertain = this.#runtimeStarted(failedId) ||
931
- this.#unresolvedRuntimes.has(failedId) ||
932
- record?.runtime !== undefined;
933
- if (uncertain)
934
- this.#unresolvedRuntimes.add(failedId);
909
+ const uncertain = this.#runtimeStarted(failedId);
935
910
  if (!this.#isLive(failedId))
936
911
  this.#working.delete(failedId);
937
912
  await this.#update(failedId, (current) => ({
@@ -956,20 +931,13 @@ export class SubagentManager {
956
931
  const record = await this.#deps.registry.get(id);
957
932
  if (record === undefined)
958
933
  return failure("send", "Unknown child", id);
959
- if (record.intent === "stopped" || record.state === "error") {
960
- const doubt = await this.#runtimeDoubt(id);
961
- return failure("send", doubt ??
962
- `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);
963
- }
964
934
  let isResumed = false;
965
935
  // Deliverability is the bridge connection, minus a process that left the delegated session:
966
936
  // input goes to a session, and this process no longer serves this child's session. What is
967
937
  // left to decide is whether a runtime of ours may still own it.
968
938
  if (this.#leftSessions.has(id) || !this.#deps.bridge.isConnected(id)) {
969
- const doubt = await this.#runtimeDoubt(id);
939
+ const doubt = this.#runtimeDoubt(id);
970
940
  if (doubt !== undefined) {
971
- // A runtime is still unaccounted for: it may not answer, but it may also still own the
972
- // delegated session, and one session never gets two writers.
973
941
  return failure("send", doubt, id, record.state, ["runtime metadata retained"], false);
974
942
  }
975
943
  let placement;
@@ -984,6 +952,7 @@ export class SubagentManager {
984
952
  const resumedRecord = await this.#update(id, (current) => ({
985
953
  ...current,
986
954
  state: "running",
955
+ intent: "active",
987
956
  persistence,
988
957
  ...(sessionPath === undefined ? {} : { sessionPath }),
989
958
  launchConfig: sessionPath === undefined
@@ -1042,31 +1011,30 @@ export class SubagentManager {
1042
1011
  return {
1043
1012
  ...clean,
1044
1013
  state: "running",
1014
+ intent: "active",
1045
1015
  unacknowledgedInput: message,
1046
1016
  };
1047
1017
  });
1048
1018
  try {
1049
- await this.#request(id, operation, { message });
1019
+ const path = acknowledgedPath(await this.#request(id, operation, { message }));
1020
+ this.#paths.set(id, path);
1050
1021
  }
1051
1022
  catch (error) {
1052
1023
  return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
1053
1024
  }
1054
- return publicChild((await this.#deps.registry.get(id)) ?? record, true);
1025
+ return publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
1055
1026
  });
1056
1027
  }
1057
1028
  async get(id) {
1058
1029
  const record = await this.#deps.registry.get(id);
1059
- return record === undefined
1060
- ? failure("get", "Unknown child", id)
1061
- : publicChild(record, this.#isLive(id));
1030
+ if (record === undefined)
1031
+ return failure("get", "Unknown child", id);
1032
+ const live = this.#isLive(id);
1033
+ return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
1062
1034
  }
1063
1035
  async list() {
1064
1036
  const records = await this.#deps.registry.list();
1065
- return records.map((record) => publicChild(record, this.#isLive(record.subagentId)));
1066
- }
1067
- /** Child startup and runs count until Pi settles, including post-agent_end continuation. */
1068
- get activeCount() {
1069
- return this.#working.size;
1037
+ return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
1070
1038
  }
1071
1039
  stop(id) {
1072
1040
  this.#clearSettledPanelClose(id);
@@ -1089,11 +1057,7 @@ export class SubagentManager {
1089
1057
  let stopped;
1090
1058
  try {
1091
1059
  // The intent is written before anything is ended, so an interrupted stop is still a stop.
1092
- stopped = await this.#update(id, (current) => ({
1093
- ...current,
1094
- intent: "stopped",
1095
- state: "error",
1096
- }));
1060
+ stopped = await commitLease(this.#deps.registry, id, { type: "stop-intent" });
1097
1061
  }
1098
1062
  catch (error) {
1099
1063
  this.#stopping.delete(id);
@@ -1122,6 +1086,7 @@ export class SubagentManager {
1122
1086
  // bridge-only stop.
1123
1087
  const cleanup = await attachment.cleanup();
1124
1088
  if (!(await this.#panelGone(attachment))) {
1089
+ await this.#update(id, (current) => ({ ...current, state: "error" })).catch(() => undefined);
1125
1090
  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);
1126
1091
  }
1127
1092
  this.#attachments.delete(id);
@@ -1139,27 +1104,10 @@ export class SubagentManager {
1139
1104
  }
1140
1105
  }
1141
1106
  this.#projectors.delete(id);
1142
- // An explicit stop is the one thing that clears the doubt about a runtime this process
1143
- // never saw, so a later send may start a fresh one.
1144
- this.#unresolvedRuntimes.delete(id);
1145
1107
  this.#activity.delete(id);
1146
- const runtimeIdentity = stopped.runtime?.runtimeIdentity;
1147
- const current = await this.#deps.registry.get(id);
1148
- if (current !== undefined) {
1149
- stopped = await this.#deps.registry.update(id, current.revision, (value) => {
1150
- const { runtime: _runtime, ...withoutRuntime } = value;
1151
- return withoutRuntime;
1152
- });
1153
- }
1154
- if (this.#taskRegistry !== undefined) {
1155
- this.#taskRegistry.settle(id, {
1156
- status: "cancelled",
1157
- output: `Subagent ${id} was stopped`,
1158
- truncated: false,
1159
- });
1160
- }
1161
- if (runtimeIdentity !== undefined)
1162
- this.#deps.tokens?.forget(runtimeIdentity);
1108
+ const finished = await this.#releaseRuntime(id, "finished");
1109
+ if (finished !== undefined)
1110
+ stopped = finished;
1163
1111
  return publicChild(stopped, false);
1164
1112
  }
1165
1113
  catch (error) {
@@ -1196,13 +1144,6 @@ export class SubagentManager {
1196
1144
  state: "blocked",
1197
1145
  interrupted: message,
1198
1146
  }));
1199
- if (this.#taskRegistry !== undefined) {
1200
- this.#taskRegistry.settle(id, {
1201
- status: "failed",
1202
- output: message,
1203
- truncated: false,
1204
- });
1205
- }
1206
1147
  this.#scheduleSettledPanelClose(id);
1207
1148
  }
1208
1149
  else if (reason === "error") {
@@ -1212,17 +1153,9 @@ export class SubagentManager {
1212
1153
  }
1213
1154
  await this.#update(id, (current) => ({
1214
1155
  ...current,
1215
- intent: "stopped",
1216
1156
  state: "error",
1217
1157
  interrupted: message,
1218
1158
  }));
1219
- if (this.#taskRegistry !== undefined) {
1220
- this.#taskRegistry.settle(id, {
1221
- status: "failed",
1222
- output: message,
1223
- truncated: false,
1224
- });
1225
- }
1226
1159
  this.#scheduleSettledPanelClose(id);
1227
1160
  }
1228
1161
  return { delivered: true };
@@ -1231,11 +1164,7 @@ export class SubagentManager {
1231
1164
  return failure("contact_parent", errorMessage(error), id, record.state, [], true);
1232
1165
  }
1233
1166
  }
1234
- /**
1235
- * Handles a request a child sent to its parent. The two child-initiated operations are the
1236
- * child's report channel and its Task result, and both are answered here so the child learns
1237
- * whether the parent accepted them.
1238
- */
1167
+ /** Answers a request a child sent to its parent, so the child learns whether the parent accepted it. */
1239
1168
  async handleChildRequest(childId, operation, payload) {
1240
1169
  const record = await this.#deps.registry.get(childId);
1241
1170
  if (record === undefined)
@@ -1296,15 +1225,16 @@ export class SubagentManager {
1296
1225
  this.#notify();
1297
1226
  }
1298
1227
  // Counted before the per-child queue: an event that arrives while a reclaim is running is
1299
- // queued behind it and could otherwise not cancel a decision already in flight.
1300
- this.#bumpActivity(childId);
1228
+ // queued behind it and could otherwise not cancel a decision already in flight. Only a new
1229
+ // turn cancels a reply that is already waiting.
1230
+ this.#bumpActivity(childId, isRecord(event) && resumesChildWork(event.type));
1301
1231
  const workVersion = this.#workVersions.get(childId) ?? 0;
1302
1232
  void this.#mutate(childId, () => this.#observe(childId, event, workVersion)).catch((error) => {
1303
1233
  diagnose(`failed to project event for ${childId}: ${errorMessage(error)}`);
1304
1234
  });
1305
1235
  }
1306
1236
  handleConnectionChange(childId, connected) {
1307
- this.#bumpActivity(childId);
1237
+ this.#bumpActivity(childId, false);
1308
1238
  void this.#mutate(childId, async () => {
1309
1239
  const record = await this.#deps.registry.get(childId);
1310
1240
  if (record === undefined)
@@ -1316,8 +1246,6 @@ export class SubagentManager {
1316
1246
  // the child's transcript from the session the human switched to.
1317
1247
  if (this.#leftSessions.has(childId))
1318
1248
  return;
1319
- // The runtime this record names is answering, so the doubt about it is gone.
1320
- this.#unresolvedRuntimes.delete(childId);
1321
1249
  if (!this.#projectors.has(childId)) {
1322
1250
  this.#projectors.set(childId, createStateProjector(record.state));
1323
1251
  }
@@ -1329,18 +1257,7 @@ export class SubagentManager {
1329
1257
  await this.#runtimeEnded(childId);
1330
1258
  });
1331
1259
  }
1332
- #dispatch(childId, event) {
1333
- for (const listener of this.#childEventListeners) {
1334
- try {
1335
- listener(childId, event);
1336
- }
1337
- catch (error) {
1338
- diagnose(`child event listener failed for ${childId}: ${errorMessage(error)}`);
1339
- }
1340
- }
1341
- }
1342
1260
  async #observe(id, event, workVersion) {
1343
- this.#dispatch(id, event);
1344
1261
  const value = isRecord(event) ? event : undefined;
1345
1262
  if (value === undefined)
1346
1263
  return;
@@ -1356,11 +1273,7 @@ export class SubagentManager {
1356
1273
  const message = value.message === undefined || value.message === ""
1357
1274
  ? "Task is unfinished and waiting for user intent."
1358
1275
  : value.message;
1359
- const updated = await this.#update(id, (current) => ({
1360
- ...current,
1361
- interrupted: message,
1362
- }));
1363
- await this.contactParent(id, "user_interrupt", message, updated);
1276
+ await this.contactParent(id, "user_interrupt", message, record);
1364
1277
  return;
1365
1278
  }
1366
1279
  if (value.kind === "left_session") {
@@ -1374,18 +1287,10 @@ export class SubagentManager {
1374
1287
  this.#projectors.delete(id);
1375
1288
  this.#attachments.delete(id);
1376
1289
  this.#leftSessions.add(id);
1377
- this.#unresolvedRuntimes.delete(id);
1378
1290
  // The socket is not this child's channel any more either: leaving it up would let a
1379
1291
  // relaunch mistake the old connection for the new runtime.
1380
1292
  this.#deps.bridge.disconnect(id);
1381
- await this.#update(id, (current) => {
1382
- const { runtime: _runtime, ...withoutRuntime } = current;
1383
- return {
1384
- ...withoutRuntime,
1385
- state: "done",
1386
- interrupted: "The child left its session; the surface is no longer controlled by the parent",
1387
- };
1388
- });
1293
+ await commitLease(this.#deps.registry, id, { type: "left-session" });
1389
1294
  if (retired !== undefined)
1390
1295
  this.#deps.tokens?.forget(retired);
1391
1296
  return;
@@ -1410,14 +1315,13 @@ export class SubagentManager {
1410
1315
  return;
1411
1316
  projector.applyEvent(event);
1412
1317
  let confirmedInput;
1413
- if (value.type === "agent_end") {
1318
+ if (value.type === "turn_end" || value.type === "agent_end") {
1414
1319
  // A finished turn is exactly when the session file has the message, so this is where
1415
1320
  // unacknowledged input becomes part of the child's conversation.
1416
- const entries = await this.#entries(id);
1417
- if (entries !== undefined) {
1418
- projector.rebuild(entries);
1419
- if (record.unacknowledgedInput !== undefined &&
1420
- sessionContainsUserInput(entries, record.unacknowledgedInput)) {
1321
+ const facts = await this.#facts(id, record.unacknowledgedInput);
1322
+ if (facts !== undefined) {
1323
+ projector.applyTranscriptFacts(facts);
1324
+ if (record.unacknowledgedInput !== undefined && facts.containsInput) {
1421
1325
  confirmedInput = record.unacknowledgedInput;
1422
1326
  }
1423
1327
  }
@@ -1426,20 +1330,23 @@ export class SubagentManager {
1426
1330
  if (current === undefined || current.intent === "stopped")
1427
1331
  return;
1428
1332
  const snapshot = projector.snapshot();
1429
- await this.#update(id, (entry) => {
1430
- const { interrupted: _prevInterrupted, activeTool: _prevActiveTool, ...cleanEntry } = entry;
1431
- const base = confirmedInput === undefined
1432
- ? cleanEntry
1433
- : (({ unacknowledgedInput: _input, ...rest }) => rest)(cleanEntry);
1434
- return {
1435
- ...base,
1436
- state: snapshot.state,
1437
- ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
1438
- ...(snapshot.activeTool === undefined ? {} : { activeTool: snapshot.activeTool }),
1439
- usage: snapshot.usage,
1440
- ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
1441
- };
1442
- }, workVersion);
1333
+ const durable = durableProjection(current, snapshot, confirmedInput !== undefined);
1334
+ if (durable !== undefined) {
1335
+ await this.#update(id, (entry) => {
1336
+ const { interrupted: _prevInterrupted, activeTool: _prevActiveTool, latestSummary: _prevSummary, usage: _prevUsage, ...cleanEntry } = entry;
1337
+ const base = confirmedInput === undefined
1338
+ ? cleanEntry
1339
+ : (({ unacknowledgedInput: _input, ...rest }) => rest)(cleanEntry);
1340
+ return {
1341
+ ...base,
1342
+ state: snapshot.state,
1343
+ ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
1344
+ };
1345
+ }, workVersion);
1346
+ }
1347
+ else {
1348
+ this.#notify();
1349
+ }
1443
1350
  if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
1444
1351
  this.#scheduleSettledPanelClose(id);
1445
1352
  }
@@ -1453,6 +1360,82 @@ export class SubagentManager {
1453
1360
  this.#scheduleAutoReport(id, latestRecord);
1454
1361
  }
1455
1362
  }
1363
+ holdAttachment(id, attachment) {
1364
+ this.#attachments.set(id, attachment);
1365
+ }
1366
+ /** Starts restoration. `before_agent_start` waits on `ready` so the turn sees the same ledger. */
1367
+ reconcile() {
1368
+ if (this.#ready !== undefined)
1369
+ return this.#ready;
1370
+ const ready = this.#reconcile();
1371
+ this.#ready = ready;
1372
+ return ready;
1373
+ }
1374
+ ready() {
1375
+ return this.reconcile();
1376
+ }
1377
+ async #reconcile() {
1378
+ const directory = this.#deps.directory;
1379
+ if (directory === undefined) {
1380
+ const recovery = await this.recover();
1381
+ return {
1382
+ ok: true,
1383
+ rebound: recovery.recovered,
1384
+ rehydrated: [],
1385
+ stopped: [],
1386
+ released: [],
1387
+ };
1388
+ }
1389
+ const lease = createConversationLease({
1390
+ registry: this.#deps.registry,
1391
+ hosts: directory,
1392
+ processes: { alive: processAlive },
1393
+ forgetRuntime: (runtimeIdentity) => {
1394
+ this.#deps.tokens?.forget(runtimeIdentity);
1395
+ },
1396
+ });
1397
+ const result = await lease.ready();
1398
+ if (!result.ok)
1399
+ return result;
1400
+ for (const id of result.rebound) {
1401
+ const record = await this.#deps.registry.get(id);
1402
+ const attachment = record?.runtime?.attachment;
1403
+ if (attachment?.kind === "panel") {
1404
+ const handle = directory.rebind?.(attachment);
1405
+ if (handle !== undefined)
1406
+ this.#attachments.set(id, handle);
1407
+ }
1408
+ else if (attachment?.kind === "process") {
1409
+ const runtime = attachRetainedChild(attachment.pid);
1410
+ if (runtime !== undefined)
1411
+ this.#watchRuntime(id, runtime);
1412
+ }
1413
+ }
1414
+ for (const id of result.released) {
1415
+ await this.#noteReleasedRuntime(id);
1416
+ }
1417
+ return result;
1418
+ }
1419
+ /** A writer the lease already dropped. Same ledger line as a watched exit. */
1420
+ async #noteReleasedRuntime(id) {
1421
+ const record = await this.#deps.registry.get(id);
1422
+ if (record?.state !== "blocked" || record.interrupted !== RUNTIME_ENDED_RESUME)
1423
+ return;
1424
+ this.#scheduleAutoReport(id, record);
1425
+ }
1426
+ #withSnapshot(record) {
1427
+ const projector = this.#projectors.get(record.subagentId);
1428
+ if (projector === undefined)
1429
+ return record;
1430
+ const snap = projector.snapshot();
1431
+ return {
1432
+ ...record,
1433
+ ...(snap.activeTool !== undefined ? { activeTool: snap.activeTool } : {}),
1434
+ ...(snap.summary !== undefined ? { latestSummary: snap.summary } : {}),
1435
+ ...(snap.interrupted !== undefined ? { interrupted: snap.interrupted } : {}),
1436
+ usage: snap.usage ?? record.usage,
1437
+ };
1438
+ }
1456
1439
  /** Runs an update inside the per-child chain used by the mutating operations. */
1457
1440
  #mutateUpdate(id, updater) {
1458
1441
  return this.#mutate(id, () => this.#update(id, updater));
@@ -1483,3 +1466,43 @@ export class SubagentManager {
1483
1466
  return next;
1484
1467
  }
1485
1468
  }
1469
+ function durableProjection(record, snapshot, inputConfirmed) {
1470
+ if (inputConfirmed)
1471
+ return true;
1472
+ if (snapshot.state !== record.state)
1473
+ return true;
1474
+ return undefined;
1475
+ }
1476
+ function resumesChildWork(type) {
1477
+ return (type === "agent_start" ||
1478
+ type === "turn_start" ||
1479
+ type === "auto_retry_start" ||
1480
+ type === "auto_retry");
1481
+ }
1482
+ /** The last conversational message is an assistant reply that already finished. */
1483
+ function persistedReplyFinished(entries) {
1484
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
1485
+ const entry = entries[index];
1486
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message))
1487
+ continue;
1488
+ const message = entry.message;
1489
+ if (message.role === "user")
1490
+ return false;
1491
+ if (message.role !== "assistant")
1492
+ continue;
1493
+ return (message.stopReason !== "error" &&
1494
+ message.stopReason !== "aborted" &&
1495
+ message.stopReason !== "pending" &&
1496
+ message.stopReason !== "deferred");
1497
+ }
1498
+ return false;
1499
+ }
1500
+ function processAlive(pid) {
1501
+ try {
1502
+ process.kill(pid, 0);
1503
+ return true;
1504
+ }
1505
+ catch {
1506
+ return false;
1507
+ }
1508
+ }