@hheei/pi-subagents 0.3.0 → 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.
package/dist/manager.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { errorMessage, isRecord } from "@hheei/pi-ext-core";
3
+ import { attachRetainedChild } from "./child-process.js";
3
4
  import { isOperationError } from "./domain.js";
4
5
  import { stripHindsightContent } from "./launch-spec.js";
6
+ import { commitLease, createConversationLease, RUNTIME_ENDED_RESUME, } from "./lease.js";
5
7
  import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
6
8
  import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
7
9
  async function readSessionJsonlEntries(filePath) {
@@ -20,7 +22,7 @@ async function readSessionJsonlEntries(filePath) {
20
22
  }
21
23
  return entries;
22
24
  }
23
- import { createStateProjector } from "./state.js";
25
+ import { createStateProjector, parseTranscriptFacts, transcriptFacts, } from "./state.js";
24
26
  /**
25
27
  * How long an idle child keeps its runtime. A background child holds a whole Pi process and its
26
28
  * session lock, so an idle one is reclaimed after this window and a later `agent_send` starts it
@@ -30,7 +32,6 @@ export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
30
32
  export const SUBAGENT_SETTLED_CLOSE_TIMEOUT_MS = 15_000;
31
33
  /** How long stop waits for an adopted child's bridge to go away before calling the exit unconfirmed. */
32
34
  export const SUBAGENT_EXIT_WAIT_MS = 2_000;
33
- const STOPPED_SEND_REASON = "Child is stopped; a stopped child never resumes — spawn a new child if this work is still needed";
34
35
  function diagnose(message) {
35
36
  if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
36
37
  console.error(`pi-subagents: ${message}`);
@@ -91,25 +92,6 @@ function withDeadline(operation, timeoutMs) {
91
92
  });
92
93
  });
93
94
  }
94
- function sessionContainsUserInput(entries, expected) {
95
- return entries.some((entry) => {
96
- const value = isRecord(entry) ? entry : undefined;
97
- const message = isRecord(value?.message) ? value.message : undefined;
98
- if (message?.role !== "user")
99
- return false;
100
- if (typeof message.content === "string")
101
- return message.content === expected;
102
- if (!Array.isArray(message.content))
103
- return false;
104
- const text = message.content
105
- .map((part) => {
106
- const content = isRecord(part) ? part : undefined;
107
- return content?.type === "text" && typeof content.text === "string" ? content.text : "";
108
- })
109
- .join("");
110
- return text === expected;
111
- });
112
- }
113
95
  /**
114
96
  * Extracts the trailing text of the most recent assistant message in session entries.
115
97
  * Used to automatically capture a subagent's final output without requiring a tool call.
@@ -141,6 +123,7 @@ export class SubagentManager {
141
123
  #deps;
142
124
  #chains = new Map();
143
125
  #processes = new Map();
126
+ #ready;
144
127
  /** Panels this process opened. A panel child's process is held by the host, not by us. */
145
128
  #attachments = new Map();
146
129
  /**
@@ -148,13 +131,6 @@ export class SubagentManager {
148
131
  * process is not this child's runtime any more, so it is neither live nor ours to close.
149
132
  */
150
133
  #leftSessions = new Set();
151
- /**
152
- * Children whose runtime this process never saw: a panel child outlives a parent restart, so a
153
- * record that still names a runtime after recovery did not reconnect may be running right now.
154
- * Nothing here can observe it and one session never gets two writers, so the next send refuses
155
- * until an explicit stop clears the record.
156
- */
157
- #unresolvedRuntimes = new Set();
158
134
  /** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
159
135
  #activity = new Map();
160
136
  #projectors = new Map();
@@ -170,17 +146,7 @@ export class SubagentManager {
170
146
  #lastDeliveredTurn = new Map();
171
147
  #stopping = new Set();
172
148
  #listeners = new Set();
173
- #childEventListeners = new Set();
174
149
  #unsubscribe = [];
175
- #taskRegistry;
176
- bindTaskRegistry(registry) {
177
- this.#taskRegistry = registry;
178
- return () => {
179
- if (this.#taskRegistry === registry) {
180
- this.#taskRegistry = undefined;
181
- }
182
- };
183
- }
184
150
  constructor(deps) {
185
151
  this.#deps = deps;
186
152
  // The manager owns the transport's two inbound streams: child events and connection
@@ -188,16 +154,6 @@ export class SubagentManager {
188
154
  this.#unsubscribe.push(deps.bridge.onEvent((childId, event) => this.handleEvent(childId, event)));
189
155
  this.#unsubscribe.push(deps.bridge.onConnectionChange((childId, connected) => this.handleConnectionChange(childId, connected)));
190
156
  }
191
- /**
192
- * Observes the raw bridge events of every child. A Task producer needs the child's
193
- * `task_result` and settle events without also owning the bridge connection.
194
- */
195
- onChildEvent(listener) {
196
- this.#childEventListeners.add(listener);
197
- return () => {
198
- this.#childEventListeners.delete(listener);
199
- };
200
- }
201
157
  /** Presentation-only: status and similar projections re-read list() after this. */
202
158
  onChange(listener) {
203
159
  this.#listeners.add(listener);
@@ -233,7 +189,6 @@ export class SubagentManager {
233
189
  this.#processes.clear();
234
190
  this.#attachments.clear();
235
191
  this.#leftSessions.clear();
236
- this.#unresolvedRuntimes.clear();
237
192
  this.#activity.clear();
238
193
  this.#projectors.clear();
239
194
  this.#paths.clear();
@@ -256,10 +211,6 @@ export class SubagentManager {
256
211
  const launch = await openPanel(record);
257
212
  if (launch.handle !== undefined)
258
213
  this.#attachments.set(record.subagentId, launch.handle);
259
- // A panel that could not be opened may still exist and still own the child's session, and
260
- // nothing here can observe it: the child stays unavailable until an explicit stop.
261
- if (launch.unconfirmed === true)
262
- this.#unresolvedRuntimes.add(record.subagentId);
263
214
  if (launch.failure !== undefined)
264
215
  throw new Error(launch.failure);
265
216
  return;
@@ -290,119 +241,61 @@ export class SubagentManager {
290
241
  */
291
242
  async #runtimeEnded(id) {
292
243
  const runtime = this.#processes.get(id);
293
- // 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.
294
247
  if (runtime?.alive === true)
295
248
  return;
296
- if (runtime !== undefined)
249
+ const sawExit = runtime !== undefined;
250
+ if (sawExit) {
297
251
  this.#processes.delete(id);
298
- 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))
299
256
  return;
300
257
  this.#working.delete(id);
301
- await this.#releasePanel(id);
258
+ await this.#dropDeadPanel(id);
302
259
  this.#clearSettledPanelClose(id);
303
260
  this.#projectors.delete(id);
304
- const record = await this.#update(id, (current) => {
305
- if (current.intent === "stopped")
306
- return current;
307
- if (current.state !== "running")
308
- return current;
309
- return {
310
- ...current,
311
- state: "blocked",
312
- interrupted: current.presentation === "panel"
313
- ? "The child bridge disconnected; its panel process exit is not confirmed"
314
- : "The child runtime ended while a turn was in flight; the session is unchanged and a send resumes it",
315
- };
316
- });
317
- if (record.interrupted !== undefined && record.intent !== "stopped") {
318
- this.#scheduleAutoReport(id, record);
319
- }
320
- if (record.presentation === "panel" &&
321
- record.intent === "active" &&
322
- record.state !== "done" &&
323
- record.runtime !== undefined &&
324
- !this.#attachments.has(id)) {
325
- // An adopted child has no host handle: losing its bridge cannot prove its exit.
326
- this.#unresolvedRuntimes.add(id);
327
- }
261
+ await this.#recordWriterGone(id);
328
262
  }
329
- /** 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. */
330
264
  #runtimeStarted(id) {
331
- return this.#isLive(id) || this.#processes.has(id) || this.#attachments.has(id);
265
+ return this.#isLive(id) || this.#processes.has(id);
332
266
  }
333
267
  /**
334
- * Says why this child cannot take input while a runtime may still own its session: one this
335
- * process holds, one a host panel may still be running, or one the record names that never
336
- * 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.
337
270
  */
338
- async #runtimeDoubt(id) {
339
- if (this.#attachments.has(id)) {
340
- // A panel the host confirms is gone stops holding this child — the handle it left behind is
341
- // also what a retried stop would act on, so it is released here before refusing.
342
- await this.#releasePanel(id).catch(() => undefined);
343
- }
344
- if (this.#runtimeStarted(id)) {
345
- 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";
346
- }
347
- if (this.#attachments.has(id)) {
348
- if ((await this.#deps.registry.get(id))?.intent === "stopped") {
349
- return STOPPED_SEND_REASON;
350
- }
351
- }
352
- if (this.#unresolvedRuntimes.has(id)) {
353
- 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";
354
274
  }
355
275
  return undefined;
356
276
  }
357
- /** Counts an inbound bridge signal, whether or not its handling is queued behind another task. */
358
- #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) {
359
283
  this.#activity.set(id, (this.#activity.get(id) ?? 0) + 1);
360
- this.#clearAutoReport(id);
284
+ if (invalidateReport)
285
+ this.#clearAutoReport(id);
361
286
  this.#clearSettledPanelClose(id);
362
287
  }
363
288
  /**
364
- * Releases a panel whose child has gone away, without being asked to. Only a panel the host
365
- * confirms is no longer running its child is closed: a live process behind a missing bridge may
366
- * be serving the session the human switched to, and nobody asked for that panel to be ended. A
367
- * 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.
368
291
  */
369
- async #releasePanel(id) {
292
+ async #dropDeadPanel(id) {
370
293
  const attachment = this.#attachments.get(id);
371
294
  if (attachment === undefined)
372
295
  return;
373
296
  const observed = await attachment.observe();
374
297
  if (!(observed.known && !observed.alive))
375
298
  return;
376
- let retired;
377
- const record = await this.#update(id, (current) => {
378
- if (current.intent === "stopped")
379
- return current;
380
- retired = current.runtime?.runtimeIdentity;
381
- const { runtime: _runtime, ...withoutRuntime } = current;
382
- if (current.state === "done" || current.state === "blocked") {
383
- return {
384
- ...withoutRuntime,
385
- state: current.state,
386
- };
387
- }
388
- return {
389
- ...withoutRuntime,
390
- intent: "stopped",
391
- state: "error",
392
- interrupted: "The host reports that the child process exited while running; the cause is unknown. Spawn a new child for more work",
393
- };
394
- });
395
- if (retired !== undefined)
396
- this.#deps.tokens?.forget(retired);
397
- const projector = this.#projectors.get(id);
398
- if (record.state === "done" && projector !== undefined) {
399
- projector.syncState("done");
400
- }
401
- if (record.intent === "stopped") {
402
- this.#clearSettledPanelClose(id);
403
- this.#projectors.delete(id);
404
- this.#unresolvedRuntimes.delete(id);
405
- }
406
299
  this.#attachments.delete(id);
407
300
  try {
408
301
  const cleanup = await attachment.cleanup();
@@ -414,6 +307,58 @@ export class SubagentManager {
414
307
  diagnose(`could not close the leftover ${attachment.identity.host} panel of child ${id}: ${errorMessage(error)}`);
415
308
  }
416
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
+ }
417
362
  /** Waits for a child's bridge connection to end; false when it is still connected after the wait. */
418
363
  #awaitDisconnect(id, timeoutMs) {
419
364
  if (!this.#deps.bridge.isConnected(id))
@@ -512,7 +457,8 @@ export class SubagentManager {
512
457
  if (attachment !== undefined) {
513
458
  const observed = await attachment.observe();
514
459
  if (observed.known && !observed.alive) {
515
- await this.#releasePanel(id);
460
+ await this.#dropDeadPanel(id);
461
+ await this.#recordWriterGone(id);
516
462
  return;
517
463
  }
518
464
  if ((this.#activity.get(id) ?? 0) !== activity) {
@@ -545,25 +491,9 @@ export class SubagentManager {
545
491
  this.#processes.delete(id);
546
492
  if (attachment !== undefined)
547
493
  this.#attachments.delete(id);
548
- this.#unresolvedRuntimes.delete(id);
549
- await this.#update(id, (current) => {
550
- if (current.intent === "stopped")
551
- return current;
552
- const { runtime: _runtime, ...withoutRuntime } = current;
553
- return withoutRuntime;
554
- });
555
- }
556
- else {
557
- await this.#update(id, (current) => {
558
- if (current.intent === "stopped")
559
- return current;
560
- return {
561
- ...current,
562
- state: "error",
563
- interrupted: "The child panel is not confirmed gone: the child process may still be running",
564
- };
565
- });
494
+ await this.#releaseRuntime(id, "drop");
566
495
  }
496
+ // The tab is still there, or the host could not say. Leave it. The caller closes it.
567
497
  });
568
498
  }
569
499
  async #performAutoReport(id, _record) {
@@ -574,17 +504,29 @@ export class SubagentManager {
574
504
  return;
575
505
  const activity = this.#activity.get(id) ?? 0;
576
506
  const workVersion = this.#workVersions.get(id) ?? 0;
577
- const entries = await this.#entries(id);
578
- const text = entries !== undefined ? extractLastAssistantText(entries) : undefined;
507
+ const facts = await this.#facts(id);
508
+ const text = facts?.summary;
579
509
  await this.#mutate(id, async () => {
580
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;
581
513
  if (current === undefined ||
582
514
  current.intent === "stopped" ||
583
515
  current.state === "running" ||
584
516
  this.#working.has(id) ||
585
- (this.#activity.get(id) ?? 0) !== activity ||
586
- (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
+ }
587
528
  return;
529
+ }
588
530
  const isError = current.state === "error" || record.state === "error";
589
531
  const isBlocked = !isError &&
590
532
  (current.state === "blocked" ||
@@ -604,13 +546,6 @@ export class SubagentManager {
604
546
  state: "done",
605
547
  };
606
548
  });
607
- if (this.#taskRegistry !== undefined) {
608
- this.#taskRegistry.settle(id, {
609
- status: "completed",
610
- output: "",
611
- truncated: false,
612
- });
613
- }
614
549
  return;
615
550
  }
616
551
  let message;
@@ -642,7 +577,6 @@ export class SubagentManager {
642
577
  return;
643
578
  }
644
579
  const reason = isError ? "error" : isBlocked ? "blocked" : "success";
645
- const taskStatus = failed ? "failed" : "completed";
646
580
  const projector = this.#projectors.get(id);
647
581
  const updatedRecord = !failed
648
582
  ? await this.#update(id, (current) => {
@@ -661,13 +595,6 @@ export class SubagentManager {
661
595
  projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
662
596
  }
663
597
  this.#scheduleSettledPanelClose(id);
664
- if (this.#taskRegistry !== undefined) {
665
- this.#taskRegistry.settle(id, {
666
- status: taskStatus,
667
- output: message,
668
- truncated: false,
669
- });
670
- }
671
598
  let deliveryOk = true;
672
599
  if (this.#deps.channel !== undefined) {
673
600
  const contactResult = await this.contactParent(id, reason, message, updatedRecord);
@@ -680,7 +607,7 @@ export class SubagentManager {
680
607
  }
681
608
  });
682
609
  }
683
- /** Reads a child's session entries; exposed for Task completion inspection. */
610
+ /** Reads a child's session entries; used by transcript inspection fallbacks. */
684
611
  async getEntries(id) {
685
612
  return this.#entries(id);
686
613
  }
@@ -733,6 +660,37 @@ export class SubagentManager {
733
660
  }
734
661
  return { sessionPath: record.sessionPath, persistence: record.persistence };
735
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
+ }
736
694
  /** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
737
695
  async #entries(id) {
738
696
  if (this.#deps.bridge.isConnected(id)) {
@@ -741,22 +699,7 @@ export class SubagentManager {
741
699
  return response.entries;
742
700
  }
743
701
  }
744
- const record = await this.#deps.registry.get(id).catch(() => undefined);
745
- if (record !== undefined) {
746
- const sessionPath = record.sessionPath ??
747
- (await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
748
- if (sessionPath !== undefined) {
749
- try {
750
- const diskEntries = await readSessionJsonlEntries(sessionPath);
751
- if (diskEntries.length > 0)
752
- return diskEntries;
753
- }
754
- catch {
755
- // return undefined below
756
- }
757
- }
758
- }
759
- return undefined;
702
+ return this.#diskEntries(id);
760
703
  }
761
704
  #request(id, operation, payload) {
762
705
  return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
@@ -788,8 +731,6 @@ export class SubagentManager {
788
731
  await this.#settleUnrecovered(record);
789
732
  return undefined;
790
733
  }
791
- // The runtime this record names answered, so the doubt about it is gone.
792
- this.#unresolvedRuntimes.delete(record.subagentId);
793
734
  await this.#adopt(record.subagentId);
794
735
  return { childId: record.subagentId };
795
736
  }
@@ -807,55 +748,18 @@ export class SubagentManager {
807
748
  };
808
749
  }
809
750
  /**
810
- * A record that names a runtime which did not reconnect. A background child is held by its
811
- * parent's stdin pipe, so it died with the parent and that evidence is stale; a panel child is
812
- * held by the host and may still be running, so nothing here may start a second runtime for the
813
- * 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.
814
754
  */
815
755
  async #settleUnrecovered(record) {
816
756
  this.#working.delete(record.subagentId);
817
757
  this.#projectors.delete(record.subagentId);
818
- const retired = record.runtime;
819
758
  if (record.presentation === "panel") {
820
- if (retired !== undefined) {
821
- this.#unresolvedRuntimes.add(record.subagentId);
822
- }
823
- await this.#update(record.subagentId, (current) => {
824
- if (current.intent === "stopped")
825
- return current;
826
- if (current.state !== "running")
827
- return current;
828
- return {
829
- ...current,
830
- state: "blocked",
831
- interrupted: "The child panel runtime did not reconnect; its process exit is not confirmed",
832
- };
833
- });
759
+ await this.#recordWriterGone(record.subagentId);
834
760
  return;
835
761
  }
836
- if (retired === undefined) {
837
- await this.#update(record.subagentId, (current) => {
838
- if (current.intent === "stopped")
839
- return current;
840
- if (current.state !== "running")
841
- return current;
842
- return {
843
- ...current,
844
- state: "done",
845
- interrupted: "The child runtime ended with the parent process; a later send starts it again from the same session",
846
- };
847
- });
848
- return;
849
- }
850
- await this.#update(record.subagentId, (current) => {
851
- const { runtime: _runtime, ...rest } = current;
852
- return {
853
- ...rest,
854
- ...(current.state === "running" ? { state: "done" } : {}),
855
- interrupted: "The child runtime ended with the parent process; a later send starts it again from the same session",
856
- };
857
- });
858
- this.#deps.tokens?.forget(retired.runtimeIdentity);
762
+ await this.#releaseRuntime(record.subagentId, "parent-exit");
859
763
  }
860
764
  /** Reads a connected child's real state and puts the record back in step with it. */
861
765
  async #adopt(id) {
@@ -864,9 +768,9 @@ export class SubagentManager {
864
768
  const record = await this.#deps.registry.get(id);
865
769
  if (projector === undefined || record === undefined)
866
770
  return;
867
- const entries = await this.#entries(id);
868
- if (entries !== undefined)
869
- projector.rebuild(entries);
771
+ const facts = await this.#facts(id, record.unacknowledgedInput);
772
+ if (facts !== undefined)
773
+ projector.applyTranscriptFacts(facts);
870
774
  const state = await this.#request(id, "get_state");
871
775
  if (!isRecord(state) ||
872
776
  typeof state.idle !== "boolean" ||
@@ -889,9 +793,7 @@ export class SubagentManager {
889
793
  if (projector.snapshot().state !== "error")
890
794
  projector.syncState(idle ? "done" : "running");
891
795
  const snapshot = projector.snapshot();
892
- const confirmedInput = entries !== undefined &&
893
- record.unacknowledgedInput !== undefined &&
894
- sessionContainsUserInput(entries, record.unacknowledgedInput)
796
+ const confirmedInput = facts?.containsInput && record.unacknowledgedInput !== undefined
895
797
  ? record.unacknowledgedInput
896
798
  : undefined;
897
799
  await this.#update(id, (current) => {
@@ -902,12 +804,10 @@ export class SubagentManager {
902
804
  : (({ unacknowledgedInput: _input, ...rest }) => rest)(current);
903
805
  const hasError = snapshot.state === "error";
904
806
  const { interrupted: _interrupted, ...withoutInterrupted } = base;
807
+ const { latestSummary: _summary, activeTool: _tool, usage: _usage, ...withoutVolatile } = withoutInterrupted;
905
808
  return {
906
- ...withoutInterrupted,
809
+ ...withoutVolatile,
907
810
  state: hasError ? "error" : idle ? "done" : "running",
908
- ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
909
- usage: snapshot.usage,
910
- ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
911
811
  };
912
812
  }, workVersion);
913
813
  if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
@@ -994,31 +894,6 @@ export class SubagentManager {
994
894
  const { latestSummary: _latestSummary, ...rest } = current;
995
895
  return { ...rest, state: "running" };
996
896
  });
997
- if (this.#taskRegistry !== undefined) {
998
- try {
999
- this.#taskRegistry.create({
1000
- id: record.subagentId,
1001
- type: "agent",
1002
- purpose: `${input.agent}: ${input.task}`,
1003
- inlineResult: true,
1004
- begin: () => ({
1005
- stop: () => {
1006
- void this.stop(record.subagentId);
1007
- },
1008
- describe: () => ({
1009
- output: `Subagent ${record.subagentId} (${input.agent}) is running`,
1010
- truncated: false,
1011
- }),
1012
- consume: () => {
1013
- this.#deps.channel?.dismiss?.(record.subagentId);
1014
- },
1015
- }),
1016
- });
1017
- }
1018
- catch (regError) {
1019
- diagnose(`failed to register background task for subagent ${record.subagentId}: ${errorMessage(regError)}`);
1020
- }
1021
- }
1022
897
  return {
1023
898
  child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true, path),
1024
899
  path,
@@ -1031,12 +906,7 @@ export class SubagentManager {
1031
906
  const failedId = childId;
1032
907
  const reason = errorMessage(error);
1033
908
  const failed = await this.#mutate(failedId, async () => {
1034
- const record = await this.#deps.registry.get(failedId);
1035
- const uncertain = this.#runtimeStarted(failedId) ||
1036
- this.#unresolvedRuntimes.has(failedId) ||
1037
- record?.runtime !== undefined;
1038
- if (uncertain)
1039
- this.#unresolvedRuntimes.add(failedId);
909
+ const uncertain = this.#runtimeStarted(failedId);
1040
910
  if (!this.#isLive(failedId))
1041
911
  this.#working.delete(failedId);
1042
912
  await this.#update(failedId, (current) => ({
@@ -1061,21 +931,13 @@ export class SubagentManager {
1061
931
  const record = await this.#deps.registry.get(id);
1062
932
  if (record === undefined)
1063
933
  return failure("send", "Unknown child", id);
1064
- if (record.intent === "stopped") {
1065
- const doubt = await this.#runtimeDoubt(id);
1066
- if (doubt !== undefined) {
1067
- return failure("send", doubt, id, record.state, ["runtime metadata retained"], false);
1068
- }
1069
- }
1070
934
  let isResumed = false;
1071
935
  // Deliverability is the bridge connection, minus a process that left the delegated session:
1072
936
  // input goes to a session, and this process no longer serves this child's session. What is
1073
937
  // left to decide is whether a runtime of ours may still own it.
1074
938
  if (this.#leftSessions.has(id) || !this.#deps.bridge.isConnected(id)) {
1075
- const doubt = await this.#runtimeDoubt(id);
939
+ const doubt = this.#runtimeDoubt(id);
1076
940
  if (doubt !== undefined) {
1077
- // A runtime is still unaccounted for: it may not answer, but it may also still own the
1078
- // delegated session, and one session never gets two writers.
1079
941
  return failure("send", doubt, id, record.state, ["runtime metadata retained"], false);
1080
942
  }
1081
943
  let placement;
@@ -1103,31 +965,6 @@ export class SubagentManager {
1103
965
  try {
1104
966
  await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
1105
967
  isResumed = true;
1106
- if (this.#taskRegistry !== undefined) {
1107
- try {
1108
- this.#taskRegistry.create({
1109
- id: resumedRecord.subagentId,
1110
- type: "agent",
1111
- purpose: `${resumedRecord.launchConfig.agent}: resumed task`,
1112
- inlineResult: true,
1113
- begin: () => ({
1114
- stop: () => {
1115
- void this.stop(resumedRecord.subagentId);
1116
- },
1117
- describe: () => ({
1118
- output: `Subagent ${resumedRecord.subagentId} (${resumedRecord.launchConfig.agent}) is running`,
1119
- truncated: false,
1120
- }),
1121
- consume: () => {
1122
- this.#deps.channel?.dismiss?.(resumedRecord.subagentId);
1123
- },
1124
- }),
1125
- });
1126
- }
1127
- catch (regError) {
1128
- diagnose(`failed to renew background task for subagent ${resumedRecord.subagentId}: ${errorMessage(regError)}`);
1129
- }
1130
- }
1131
968
  }
1132
969
  catch (error) {
1133
970
  const reason = errorMessage(error);
@@ -1193,27 +1030,11 @@ export class SubagentManager {
1193
1030
  if (record === undefined)
1194
1031
  return failure("get", "Unknown child", id);
1195
1032
  const live = this.#isLive(id);
1196
- const projector = this.#projectors.get(id);
1197
- if (projector !== undefined) {
1198
- const snap = projector.snapshot();
1199
- const mergedRecord = {
1200
- ...record,
1201
- ...(snap.activeTool !== undefined ? { activeTool: snap.activeTool } : {}),
1202
- ...(snap.summary !== undefined ? { latestSummary: snap.summary } : {}),
1203
- ...(snap.interrupted !== undefined ? { interrupted: snap.interrupted } : {}),
1204
- usage: snap.usage ?? record.usage,
1205
- };
1206
- return publicChild(mergedRecord, live, this.#paths.get(id));
1207
- }
1208
- return publicChild(record, live, this.#paths.get(id));
1033
+ return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
1209
1034
  }
1210
1035
  async list() {
1211
1036
  const records = await this.#deps.registry.list();
1212
- return records.map((record) => publicChild(record, this.#isLive(record.subagentId)));
1213
- }
1214
- /** Child startup and runs count until Pi settles, including post-agent_end continuation. */
1215
- get activeCount() {
1216
- return this.#working.size;
1037
+ return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
1217
1038
  }
1218
1039
  stop(id) {
1219
1040
  this.#clearSettledPanelClose(id);
@@ -1236,10 +1057,7 @@ export class SubagentManager {
1236
1057
  let stopped;
1237
1058
  try {
1238
1059
  // The intent is written before anything is ended, so an interrupted stop is still a stop.
1239
- stopped = await this.#update(id, (current) => ({
1240
- ...current,
1241
- intent: "stopped",
1242
- }));
1060
+ stopped = await commitLease(this.#deps.registry, id, { type: "stop-intent" });
1243
1061
  }
1244
1062
  catch (error) {
1245
1063
  this.#stopping.delete(id);
@@ -1286,30 +1104,10 @@ export class SubagentManager {
1286
1104
  }
1287
1105
  }
1288
1106
  this.#projectors.delete(id);
1289
- // An explicit stop is the one thing that clears the doubt about a runtime this process
1290
- // never saw, so a later send may start a fresh one.
1291
- this.#unresolvedRuntimes.delete(id);
1292
1107
  this.#activity.delete(id);
1293
- const runtimeIdentity = stopped.runtime?.runtimeIdentity;
1294
- const current = await this.#deps.registry.get(id);
1295
- if (current !== undefined) {
1296
- stopped = await this.#deps.registry.update(id, current.revision, (value) => {
1297
- const { runtime: _runtime, ...withoutRuntime } = value;
1298
- return {
1299
- ...withoutRuntime,
1300
- state: "done",
1301
- };
1302
- });
1303
- }
1304
- if (this.#taskRegistry !== undefined) {
1305
- this.#taskRegistry.settle(id, {
1306
- status: "cancelled",
1307
- output: `Subagent ${id} was stopped`,
1308
- truncated: false,
1309
- });
1310
- }
1311
- if (runtimeIdentity !== undefined)
1312
- this.#deps.tokens?.forget(runtimeIdentity);
1108
+ const finished = await this.#releaseRuntime(id, "finished");
1109
+ if (finished !== undefined)
1110
+ stopped = finished;
1313
1111
  return publicChild(stopped, false);
1314
1112
  }
1315
1113
  catch (error) {
@@ -1346,13 +1144,6 @@ export class SubagentManager {
1346
1144
  state: "blocked",
1347
1145
  interrupted: message,
1348
1146
  }));
1349
- if (this.#taskRegistry !== undefined) {
1350
- this.#taskRegistry.settle(id, {
1351
- status: "failed",
1352
- output: message,
1353
- truncated: false,
1354
- });
1355
- }
1356
1147
  this.#scheduleSettledPanelClose(id);
1357
1148
  }
1358
1149
  else if (reason === "error") {
@@ -1365,13 +1156,6 @@ export class SubagentManager {
1365
1156
  state: "error",
1366
1157
  interrupted: message,
1367
1158
  }));
1368
- if (this.#taskRegistry !== undefined) {
1369
- this.#taskRegistry.settle(id, {
1370
- status: "failed",
1371
- output: message,
1372
- truncated: false,
1373
- });
1374
- }
1375
1159
  this.#scheduleSettledPanelClose(id);
1376
1160
  }
1377
1161
  return { delivered: true };
@@ -1380,11 +1164,7 @@ export class SubagentManager {
1380
1164
  return failure("contact_parent", errorMessage(error), id, record.state, [], true);
1381
1165
  }
1382
1166
  }
1383
- /**
1384
- * Handles a request a child sent to its parent. The two child-initiated operations are the
1385
- * child's report channel and its Task result, and both are answered here so the child learns
1386
- * whether the parent accepted them.
1387
- */
1167
+ /** Answers a request a child sent to its parent, so the child learns whether the parent accepted it. */
1388
1168
  async handleChildRequest(childId, operation, payload) {
1389
1169
  const record = await this.#deps.registry.get(childId);
1390
1170
  if (record === undefined)
@@ -1445,15 +1225,16 @@ export class SubagentManager {
1445
1225
  this.#notify();
1446
1226
  }
1447
1227
  // Counted before the per-child queue: an event that arrives while a reclaim is running is
1448
- // queued behind it and could otherwise not cancel a decision already in flight.
1449
- 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));
1450
1231
  const workVersion = this.#workVersions.get(childId) ?? 0;
1451
1232
  void this.#mutate(childId, () => this.#observe(childId, event, workVersion)).catch((error) => {
1452
1233
  diagnose(`failed to project event for ${childId}: ${errorMessage(error)}`);
1453
1234
  });
1454
1235
  }
1455
1236
  handleConnectionChange(childId, connected) {
1456
- this.#bumpActivity(childId);
1237
+ this.#bumpActivity(childId, false);
1457
1238
  void this.#mutate(childId, async () => {
1458
1239
  const record = await this.#deps.registry.get(childId);
1459
1240
  if (record === undefined)
@@ -1465,8 +1246,6 @@ export class SubagentManager {
1465
1246
  // the child's transcript from the session the human switched to.
1466
1247
  if (this.#leftSessions.has(childId))
1467
1248
  return;
1468
- // The runtime this record names is answering, so the doubt about it is gone.
1469
- this.#unresolvedRuntimes.delete(childId);
1470
1249
  if (!this.#projectors.has(childId)) {
1471
1250
  this.#projectors.set(childId, createStateProjector(record.state));
1472
1251
  }
@@ -1478,18 +1257,7 @@ export class SubagentManager {
1478
1257
  await this.#runtimeEnded(childId);
1479
1258
  });
1480
1259
  }
1481
- #dispatch(childId, event) {
1482
- for (const listener of this.#childEventListeners) {
1483
- try {
1484
- listener(childId, event);
1485
- }
1486
- catch (error) {
1487
- diagnose(`child event listener failed for ${childId}: ${errorMessage(error)}`);
1488
- }
1489
- }
1490
- }
1491
1260
  async #observe(id, event, workVersion) {
1492
- this.#dispatch(id, event);
1493
1261
  const value = isRecord(event) ? event : undefined;
1494
1262
  if (value === undefined)
1495
1263
  return;
@@ -1505,11 +1273,7 @@ export class SubagentManager {
1505
1273
  const message = value.message === undefined || value.message === ""
1506
1274
  ? "Task is unfinished and waiting for user intent."
1507
1275
  : value.message;
1508
- const updated = await this.#update(id, (current) => ({
1509
- ...current,
1510
- interrupted: message,
1511
- }));
1512
- await this.contactParent(id, "user_interrupt", message, updated);
1276
+ await this.contactParent(id, "user_interrupt", message, record);
1513
1277
  return;
1514
1278
  }
1515
1279
  if (value.kind === "left_session") {
@@ -1523,18 +1287,10 @@ export class SubagentManager {
1523
1287
  this.#projectors.delete(id);
1524
1288
  this.#attachments.delete(id);
1525
1289
  this.#leftSessions.add(id);
1526
- this.#unresolvedRuntimes.delete(id);
1527
1290
  // The socket is not this child's channel any more either: leaving it up would let a
1528
1291
  // relaunch mistake the old connection for the new runtime.
1529
1292
  this.#deps.bridge.disconnect(id);
1530
- await this.#update(id, (current) => {
1531
- const { runtime: _runtime, ...withoutRuntime } = current;
1532
- return {
1533
- ...withoutRuntime,
1534
- state: "done",
1535
- interrupted: "The child left its session; the surface is no longer controlled by the parent",
1536
- };
1537
- });
1293
+ await commitLease(this.#deps.registry, id, { type: "left-session" });
1538
1294
  if (retired !== undefined)
1539
1295
  this.#deps.tokens?.forget(retired);
1540
1296
  return;
@@ -1562,11 +1318,10 @@ export class SubagentManager {
1562
1318
  if (value.type === "turn_end" || value.type === "agent_end") {
1563
1319
  // A finished turn is exactly when the session file has the message, so this is where
1564
1320
  // unacknowledged input becomes part of the child's conversation.
1565
- const entries = await this.#entries(id);
1566
- if (entries !== undefined) {
1567
- projector.rebuild(entries);
1568
- if (record.unacknowledgedInput !== undefined &&
1569
- 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) {
1570
1325
  confirmedInput = record.unacknowledgedInput;
1571
1326
  }
1572
1327
  }
@@ -1575,20 +1330,23 @@ export class SubagentManager {
1575
1330
  if (current === undefined || current.intent === "stopped")
1576
1331
  return;
1577
1332
  const snapshot = projector.snapshot();
1578
- await this.#update(id, (entry) => {
1579
- const { interrupted: _prevInterrupted, activeTool: _prevActiveTool, ...cleanEntry } = entry;
1580
- const base = confirmedInput === undefined
1581
- ? cleanEntry
1582
- : (({ unacknowledgedInput: _input, ...rest }) => rest)(cleanEntry);
1583
- return {
1584
- ...base,
1585
- state: snapshot.state,
1586
- ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
1587
- ...(snapshot.activeTool === undefined ? {} : { activeTool: snapshot.activeTool }),
1588
- usage: snapshot.usage,
1589
- ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
1590
- };
1591
- }, 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
+ }
1592
1350
  if (snapshot.state === "done" || snapshot.state === "blocked" || snapshot.state === "error") {
1593
1351
  this.#scheduleSettledPanelClose(id);
1594
1352
  }
@@ -1602,6 +1360,82 @@ export class SubagentManager {
1602
1360
  this.#scheduleAutoReport(id, latestRecord);
1603
1361
  }
1604
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
+ }
1605
1439
  /** Runs an update inside the per-child chain used by the mutating operations. */
1606
1440
  #mutateUpdate(id, updater) {
1607
1441
  return this.#mutate(id, () => this.#update(id, updater));
@@ -1632,3 +1466,43 @@ export class SubagentManager {
1632
1466
  return next;
1633
1467
  }
1634
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
+ }