@hheei/pi-subagents 0.2.3 → 0.3.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 (44) hide show
  1. package/README.md +32 -17
  2. package/dist/agent-resolver.d.ts +9 -0
  3. package/dist/agent-resolver.js +278 -51
  4. package/dist/bridge-client.d.ts +5 -0
  5. package/dist/bridge-client.js +6 -0
  6. package/dist/builtin-agents.js +50 -37
  7. package/dist/child-bridge.d.ts +0 -8
  8. package/dist/child-bridge.js +60 -43
  9. package/dist/child-control.d.ts +5 -3
  10. package/dist/child-control.js +176 -35
  11. package/dist/commands.d.ts +1 -4
  12. package/dist/commands.js +1 -52
  13. package/dist/domain.d.ts +12 -12
  14. package/dist/domain.js +2 -1
  15. package/dist/extension.d.ts +5 -3
  16. package/dist/extension.js +42 -44
  17. package/dist/host-adapter.js +1 -1
  18. package/dist/info.d.ts +9 -0
  19. package/dist/info.js +54 -0
  20. package/dist/launch-spec.d.ts +0 -1
  21. package/dist/launch-spec.js +38 -18
  22. package/dist/manager.d.ts +4 -2
  23. package/dist/manager.js +299 -114
  24. package/dist/registry.js +19 -5
  25. package/dist/rpc-events.d.ts +1 -1
  26. package/dist/rpc-events.js +0 -1
  27. package/dist/runtime.js +8 -0
  28. package/dist/session-bootstrap.d.ts +2 -1
  29. package/dist/session-bootstrap.js +22 -14
  30. package/dist/state.d.ts +13 -0
  31. package/dist/state.js +59 -16
  32. package/dist/status.d.ts +14 -0
  33. package/dist/status.js +36 -0
  34. package/dist/tool-activation.d.ts +3 -4
  35. package/dist/tool-activation.js +13 -215
  36. package/dist/tool-presentation.d.ts +17 -0
  37. package/dist/tool-presentation.js +160 -0
  38. package/dist/tools.d.ts +6 -1
  39. package/dist/tools.js +133 -180
  40. package/package.json +6 -6
  41. package/dist/session-fork.d.ts +0 -16
  42. package/dist/session-fork.js +0 -67
  43. package/dist/widget.d.ts +0 -12
  44. package/dist/widget.js +0 -149
package/dist/manager.js CHANGED
@@ -1,12 +1,29 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { errorMessage, isRecord } from "@hheei/pi-ext-core";
3
+ import { isOperationError } from "./domain.js";
2
4
  import { stripHindsightContent } from "./launch-spec.js";
3
5
  import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
4
6
  import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
5
- import { readSessionJsonlEntries } from "./session-fork.js";
7
+ async function readSessionJsonlEntries(filePath) {
8
+ const content = await readFile(filePath, "utf8");
9
+ const entries = [];
10
+ for (const line of content.split("\n")) {
11
+ const trimmed = line.trim();
12
+ if (trimmed.length > 0) {
13
+ try {
14
+ entries.push(JSON.parse(trimmed));
15
+ }
16
+ catch {
17
+ // Ignore corrupted line
18
+ }
19
+ }
20
+ }
21
+ return entries;
22
+ }
6
23
  import { createStateProjector } from "./state.js";
7
24
  /**
8
25
  * 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
26
+ * session lock, so an idle one is reclaimed after this window and a later `agent_send` starts it
10
27
  * again from the same session.
11
28
  */
12
29
  export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
@@ -29,9 +46,19 @@ function failure(operation, reason, childId, state, sideEffects = [], safeToRetr
29
46
  safeToRetry,
30
47
  };
31
48
  }
32
- function publicChild(record, live) {
49
+ function acknowledgedPath(response) {
50
+ if (!isRecord(response) ||
51
+ response.accepted !== true ||
52
+ typeof response.path !== "string" ||
53
+ response.path === "") {
54
+ throw new BridgeError("invalid_state", "Child did not confirm its session path");
55
+ }
56
+ return response.path;
57
+ }
58
+ function publicChild(record, live, path = record.sessionPath) {
33
59
  return {
34
60
  id: record.subagentId,
61
+ ...(path === undefined ? {} : { path }),
35
62
  agent: record.launchConfig.agent.name,
36
63
  ...(record.launchConfig.agent.displayName === undefined
37
64
  ? {}
@@ -40,12 +67,12 @@ function publicChild(record, live) {
40
67
  presentation: record.presentation,
41
68
  cwd: record.cwd,
42
69
  sessionId: record.sessionId,
70
+ ...(record.initialTask === undefined ? {} : { task: record.initialTask }),
43
71
  ...(record.latestSummary === undefined ? {} : { summary: record.latestSummary }),
44
72
  ...(record.activeTool === undefined ? {} : { activeTool: record.activeTool }),
45
73
  ...(record.usage === undefined ? {} : { usage: record.usage }),
46
74
  ...(record.interrupted === undefined ? {} : { interrupted: record.interrupted }),
47
75
  freshness: live ? "live" : "last_known",
48
- interactive: record.launchConfig.interactive,
49
76
  model: record.launchConfig.model,
50
77
  thinking: record.launchConfig.thinking,
51
78
  createdAt: record.createdAt,
@@ -131,12 +158,15 @@ export class SubagentManager {
131
158
  /** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
132
159
  #activity = new Map();
133
160
  #projectors = new Map();
161
+ /** Display paths do not imply that a session has been flushed to disk. */
162
+ #paths = new Map();
134
163
  /** Known child work in flight, separate from failures recorded before Pi finally settles. */
135
164
  #working = new Set();
136
165
  /** A settled event from an older run cannot clear a newer synchronous start signal. */
137
166
  #workVersions = new Map();
138
167
  #settledPanelCloseTimers = new Map();
139
- #autoReportTimers = new Map();
168
+ #pendingAutoReports = new Map();
169
+ #autoReportBatchTimer;
140
170
  #lastDeliveredTurn = new Map();
141
171
  #stopping = new Set();
142
172
  #listeners = new Set();
@@ -168,7 +198,7 @@ export class SubagentManager {
168
198
  this.#childEventListeners.delete(listener);
169
199
  };
170
200
  }
171
- /** Presentation-only: widget and similar projections re-read list() after this. */
201
+ /** Presentation-only: status and similar projections re-read list() after this. */
172
202
  onChange(listener) {
173
203
  this.#listeners.add(listener);
174
204
  return () => {
@@ -176,6 +206,11 @@ export class SubagentManager {
176
206
  };
177
207
  }
178
208
  dispose() {
209
+ if (this.#autoReportBatchTimer !== undefined) {
210
+ clearTimeout(this.#autoReportBatchTimer);
211
+ this.#autoReportBatchTimer = undefined;
212
+ }
213
+ this.#pendingAutoReports.clear();
179
214
  for (const timer of this.#settledPanelCloseTimers.values()) {
180
215
  clearTimeout(timer);
181
216
  }
@@ -190,12 +225,18 @@ export class SubagentManager {
190
225
  */
191
226
  closeLocalConnections() {
192
227
  this.dispose();
228
+ if (this.#autoReportBatchTimer !== undefined) {
229
+ clearTimeout(this.#autoReportBatchTimer);
230
+ this.#autoReportBatchTimer = undefined;
231
+ }
232
+ this.#pendingAutoReports.clear();
193
233
  this.#processes.clear();
194
234
  this.#attachments.clear();
195
235
  this.#leftSessions.clear();
196
236
  this.#unresolvedRuntimes.clear();
197
237
  this.#activity.clear();
198
238
  this.#projectors.clear();
239
+ this.#paths.clear();
199
240
  this.#working.clear();
200
241
  this.#workVersions.clear();
201
242
  this.#notify();
@@ -299,13 +340,15 @@ export class SubagentManager {
299
340
  // A panel the host confirms is gone stops holding this child — the handle it left behind is
300
341
  // also what a retried stop would act on, so it is released here before refusing.
301
342
  await this.#releasePanel(id).catch(() => undefined);
302
- if ((await this.#deps.registry.get(id))?.intent === "stopped") {
303
- return STOPPED_SEND_REASON;
304
- }
305
343
  }
306
344
  if (this.#runtimeStarted(id)) {
307
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";
308
346
  }
347
+ if (this.#attachments.has(id)) {
348
+ if ((await this.#deps.registry.get(id))?.intent === "stopped") {
349
+ return STOPPED_SEND_REASON;
350
+ }
351
+ }
309
352
  if (this.#unresolvedRuntimes.has(id)) {
310
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";
311
354
  }
@@ -346,7 +389,7 @@ export class SubagentManager {
346
389
  ...withoutRuntime,
347
390
  intent: "stopped",
348
391
  state: "error",
349
- interrupted: "The child panel was closed while running; spawn a new child for more work",
392
+ interrupted: "The host reports that the child process exited while running; the cause is unknown. Spawn a new child for more work",
350
393
  };
351
394
  });
352
395
  if (retired !== undefined)
@@ -397,19 +440,29 @@ export class SubagentManager {
397
440
  * only ever closed on a judgement, never on a guess.
398
441
  */
399
442
  #scheduleAutoReport(id, record) {
400
- this.#clearAutoReport(id);
401
- const timer = setTimeout(() => {
402
- this.#autoReportTimers.delete(id);
403
- void this.#performAutoReport(id, record);
404
- }, 5_000);
405
- timer.unref?.();
406
- this.#autoReportTimers.set(id, timer);
443
+ this.#pendingAutoReports.set(id, record);
444
+ if (this.#autoReportBatchTimer !== undefined) {
445
+ clearTimeout(this.#autoReportBatchTimer);
446
+ }
447
+ // Batch reports: if all children settled, flush in 500ms; otherwise wait up to 10000ms for others
448
+ const delayMs = this.#working.size > 0 ? 10_000 : 500;
449
+ this.#autoReportBatchTimer = setTimeout(async () => {
450
+ this.#autoReportBatchTimer = undefined;
451
+ const batch = [...this.#pendingAutoReports.entries()];
452
+ this.#pendingAutoReports.clear();
453
+ for (const [childId, childRecord] of batch) {
454
+ await this.#performAutoReport(childId, childRecord).catch((error) => {
455
+ diagnose(`failed to report child ${childId}: ${errorMessage(error)}`);
456
+ });
457
+ }
458
+ }, delayMs);
459
+ this.#autoReportBatchTimer.unref?.();
407
460
  }
408
461
  #clearAutoReport(id) {
409
- const timer = this.#autoReportTimers.get(id);
410
- if (timer !== undefined) {
411
- clearTimeout(timer);
412
- this.#autoReportTimers.delete(id);
462
+ this.#pendingAutoReports.delete(id);
463
+ if (this.#pendingAutoReports.size === 0 && this.#autoReportBatchTimer !== undefined) {
464
+ clearTimeout(this.#autoReportBatchTimer);
465
+ this.#autoReportBatchTimer = undefined;
413
466
  }
414
467
  }
415
468
  #scheduleSettledPanelClose(id) {
@@ -443,7 +496,10 @@ export class SubagentManager {
443
496
  async #closeSettledPanel(id) {
444
497
  await this.#mutate(id, async () => {
445
498
  const record = await this.#deps.registry.get(id);
446
- if (record === undefined || record.state === "running" || record.intent === "stopped") {
499
+ if (record === undefined ||
500
+ record.state === "running" ||
501
+ record.intent === "stopped" ||
502
+ this.#working.has(id)) {
447
503
  return;
448
504
  }
449
505
  const attachment = this.#attachments.get(id);
@@ -516,83 +572,113 @@ export class SubagentManager {
516
572
  const record = (await this.#deps.registry.get(id)) ?? _record;
517
573
  if (record.intent === "stopped" || record.state === "running")
518
574
  return;
575
+ const activity = this.#activity.get(id) ?? 0;
576
+ const workVersion = this.#workVersions.get(id) ?? 0;
519
577
  const entries = await this.#entries(id);
520
578
  const text = entries !== undefined ? extractLastAssistantText(entries) : undefined;
521
- const hasError = record.state === "error" ||
522
- (record.interrupted !== undefined && record.interrupted.trim() !== "");
523
- const isBlocked = hasError;
524
- if (!isBlocked && (text === undefined || text.trim() === "")) {
579
+ await this.#mutate(id, async () => {
580
+ const current = await this.#deps.registry.get(id);
581
+ if (current === undefined ||
582
+ current.intent === "stopped" ||
583
+ current.state === "running" ||
584
+ this.#working.has(id) ||
585
+ (this.#activity.get(id) ?? 0) !== activity ||
586
+ (this.#workVersions.get(id) ?? 0) !== workVersion)
587
+ return;
588
+ const isError = current.state === "error" || record.state === "error";
589
+ const isBlocked = !isError &&
590
+ (current.state === "blocked" ||
591
+ record.state === "blocked" ||
592
+ (current.interrupted !== undefined && current.interrupted.trim() !== "") ||
593
+ (record.interrupted !== undefined && record.interrupted.trim() !== ""));
594
+ const failed = isError || isBlocked;
595
+ if (!failed && (text === undefined || text.trim() === "")) {
596
+ const projector = this.#projectors.get(id);
597
+ if (projector !== undefined) {
598
+ projector.syncState("done");
599
+ }
600
+ await this.#update(id, (current) => {
601
+ const { interrupted: _interrupted, ...clean } = current;
602
+ return {
603
+ ...clean,
604
+ state: "done",
605
+ };
606
+ });
607
+ if (this.#taskRegistry !== undefined) {
608
+ this.#taskRegistry.settle(id, {
609
+ status: "completed",
610
+ output: "",
611
+ truncated: false,
612
+ });
613
+ }
614
+ return;
615
+ }
616
+ let message;
617
+ const interruptedText = current.interrupted?.trim() || record.interrupted?.trim();
618
+ if (isError) {
619
+ message = interruptedText || "Subagent failed without error details.";
620
+ }
621
+ else if (isBlocked) {
622
+ if (interruptedText !== undefined && interruptedText !== "") {
623
+ message =
624
+ text !== undefined && text.trim() !== ""
625
+ ? `${interruptedText}\n\n${text}`
626
+ : interruptedText;
627
+ }
628
+ else if (text !== undefined && text.trim() !== "") {
629
+ message = text;
630
+ }
631
+ else {
632
+ message = "Subagent became idle without completing its task (interrupted or failed).";
633
+ }
634
+ }
635
+ else {
636
+ message = text ?? "";
637
+ }
638
+ const lastDelivered = this.#lastDeliveredTurn.get(id);
639
+ if (lastDelivered !== undefined &&
640
+ lastDelivered.workVersion === workVersion &&
641
+ lastDelivered.message === message) {
642
+ return;
643
+ }
644
+ const reason = isError ? "error" : isBlocked ? "blocked" : "success";
645
+ const taskStatus = failed ? "failed" : "completed";
525
646
  const projector = this.#projectors.get(id);
647
+ const updatedRecord = !failed
648
+ ? await this.#update(id, (current) => {
649
+ const { interrupted: _interrupted, ...clean } = current;
650
+ return {
651
+ ...clean,
652
+ state: "done",
653
+ };
654
+ })
655
+ : await this.#update(id, (current) => ({
656
+ ...current,
657
+ state: isError ? "error" : "blocked",
658
+ interrupted: current.interrupted ?? message,
659
+ }));
526
660
  if (projector !== undefined) {
527
- projector.syncState("done");
661
+ projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
528
662
  }
529
- await this.#update(id, (current) => {
530
- const { interrupted: _interrupted, ...clean } = current;
531
- return {
532
- ...clean,
533
- state: "done",
534
- };
535
- });
663
+ this.#scheduleSettledPanelClose(id);
536
664
  if (this.#taskRegistry !== undefined) {
537
665
  this.#taskRegistry.settle(id, {
538
- status: "completed",
539
- output: "",
666
+ status: taskStatus,
667
+ output: message,
540
668
  truncated: false,
541
669
  });
542
670
  }
543
- return;
544
- }
545
- let message;
546
- if (isBlocked) {
547
- if (record.interrupted !== undefined && record.interrupted.trim() !== "") {
548
- message =
549
- text !== undefined && text.trim() !== ""
550
- ? `${record.interrupted}\n\n${text}`
551
- : record.interrupted;
552
- }
553
- else if (text !== undefined && text.trim() !== "") {
554
- message = text;
671
+ let deliveryOk = true;
672
+ if (this.#deps.channel !== undefined) {
673
+ const contactResult = await this.contactParent(id, reason, message, updatedRecord);
674
+ if (isOperationError(contactResult)) {
675
+ deliveryOk = false;
676
+ }
555
677
  }
556
- else {
557
- message = "Subagent became idle without completing its task (interrupted or failed).";
678
+ if (deliveryOk) {
679
+ this.#lastDeliveredTurn.set(id, { workVersion, message });
558
680
  }
559
- }
560
- else {
561
- message = text ?? "";
562
- }
563
- if (this.#lastDeliveredTurn.get(id) === message)
564
- return;
565
- this.#lastDeliveredTurn.set(id, message);
566
- const reason = isBlocked ? "blocked" : "success";
567
- const taskStatus = isBlocked ? "failed" : "completed";
568
- const projector = this.#projectors.get(id);
569
- const updatedRecord = !isBlocked
570
- ? await this.#update(id, (current) => {
571
- const { interrupted: _interrupted, ...clean } = current;
572
- return {
573
- ...clean,
574
- state: "done",
575
- };
576
- })
577
- : await this.#update(id, (current) => ({
578
- ...current,
579
- state: "blocked",
580
- interrupted: current.interrupted ?? message,
581
- }));
582
- if (projector !== undefined) {
583
- projector.syncState(isBlocked ? "blocked" : "done");
584
- }
585
- this.#scheduleSettledPanelClose(id);
586
- if (this.#deps.channel !== undefined) {
587
- await this.contactParent(id, reason, message, updatedRecord);
588
- }
589
- if (this.#taskRegistry !== undefined) {
590
- this.#taskRegistry.settle(id, {
591
- status: taskStatus,
592
- output: message,
593
- truncated: false,
594
- });
595
- }
681
+ });
596
682
  }
597
683
  /** Reads a child's session entries; exposed for Task completion inspection. */
598
684
  async getEntries(id) {
@@ -728,11 +814,37 @@ export class SubagentManager {
728
814
  */
729
815
  async #settleUnrecovered(record) {
730
816
  this.#working.delete(record.subagentId);
817
+ this.#projectors.delete(record.subagentId);
731
818
  const retired = record.runtime;
732
- if (retired === undefined)
733
- return;
734
819
  if (record.presentation === "panel") {
735
- this.#unresolvedRuntimes.add(record.subagentId);
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
+ });
834
+ return;
835
+ }
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
+ });
736
848
  return;
737
849
  }
738
850
  await this.#update(record.subagentId, (current) => {
@@ -762,6 +874,9 @@ export class SubagentManager {
762
874
  throw new BridgeError("invalid_state", `Child ${id} did not confirm its current work state`);
763
875
  }
764
876
  const idle = state.idle && !state.pendingMessages;
877
+ if (typeof state.path === "string" && state.path !== "") {
878
+ this.#paths.set(id, state.path);
879
+ }
765
880
  if ((this.#workVersions.get(id) ?? 0) === workVersion) {
766
881
  if (idle)
767
882
  this.#working.delete(id);
@@ -786,8 +901,9 @@ export class SubagentManager {
786
901
  ? current
787
902
  : (({ unacknowledgedInput: _input, ...rest }) => rest)(current);
788
903
  const hasError = snapshot.state === "error";
904
+ const { interrupted: _interrupted, ...withoutInterrupted } = base;
789
905
  return {
790
- ...base,
906
+ ...withoutInterrupted,
791
907
  state: hasError ? "error" : idle ? "done" : "running",
792
908
  ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
793
909
  usage: snapshot.usage,
@@ -848,9 +964,11 @@ export class SubagentManager {
848
964
  childId = record.subagentId;
849
965
  this.#working.add(childId);
850
966
  this.#notify();
851
- await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 30_000);
967
+ await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
968
+ let path;
852
969
  try {
853
- await this.#request(record.subagentId, "prompt", { message: cleanTask });
970
+ path = acknowledgedPath(await this.#request(record.subagentId, "prompt", { message: cleanTask }));
971
+ this.#paths.set(record.subagentId, path);
854
972
  }
855
973
  catch (error) {
856
974
  let failureState = "error";
@@ -891,6 +1009,9 @@ export class SubagentManager {
891
1009
  output: `Subagent ${record.subagentId} (${input.agent}) is running`,
892
1010
  truncated: false,
893
1011
  }),
1012
+ consume: () => {
1013
+ this.#deps.channel?.dismiss?.(record.subagentId);
1014
+ },
894
1015
  }),
895
1016
  });
896
1017
  }
@@ -899,31 +1020,52 @@ export class SubagentManager {
899
1020
  }
900
1021
  }
901
1022
  return {
902
- child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true),
1023
+ child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true, path),
1024
+ path,
903
1025
  ...(placement.note === undefined ? {} : { note: placement.note }),
904
1026
  };
905
1027
  }
906
1028
  catch (error) {
907
- if (childId !== undefined &&
908
- !this.#runtimeStarted(childId) &&
909
- !this.#unresolvedRuntimes.has(childId)) {
910
- this.#working.delete(childId);
911
- }
912
- this.#notify();
913
- return failure("spawn", errorMessage(error), undefined, undefined, [], true);
1029
+ if (childId === undefined)
1030
+ return failure("spawn", errorMessage(error));
1031
+ const failedId = childId;
1032
+ const reason = errorMessage(error);
1033
+ 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);
1040
+ if (!this.#isLive(failedId))
1041
+ this.#working.delete(failedId);
1042
+ await this.#update(failedId, (current) => ({
1043
+ ...current,
1044
+ state: "error",
1045
+ interrupted: reason,
1046
+ }));
1047
+ this.#projectors.get(failedId)?.syncState("error");
1048
+ return failure("spawn", reason, failedId, "error", [
1049
+ "registry record persisted",
1050
+ ...(uncertain ? ["runtime start or cleanup unconfirmed"] : ["no runtime remains"]),
1051
+ ], !uncertain);
1052
+ });
1053
+ return failed;
914
1054
  }
915
1055
  }
916
1056
  send(id, rawMessage, mode = "auto", signal) {
917
1057
  const message = stripHindsightContent(rawMessage);
1058
+ this.#bumpActivity(id);
918
1059
  this.#clearSettledPanelClose(id);
919
1060
  return this.#mutate(id, async () => {
920
1061
  const record = await this.#deps.registry.get(id);
921
1062
  if (record === undefined)
922
1063
  return failure("send", "Unknown child", id);
923
- if (record.intent === "stopped" || record.state === "error") {
1064
+ if (record.intent === "stopped") {
924
1065
  const doubt = await this.#runtimeDoubt(id);
925
- return failure("send", doubt ??
926
- `Subagent ${id} is in error state and cannot continue (${record.interrupted ?? "fatal error"}). You can reference its context via spawn_agent with forkFrom: "${id}".`, id, record.state, [], false);
1066
+ if (doubt !== undefined) {
1067
+ return failure("send", doubt, id, record.state, ["runtime metadata retained"], false);
1068
+ }
927
1069
  }
928
1070
  let isResumed = false;
929
1071
  // Deliverability is the bridge connection, minus a process that left the delegated session:
@@ -948,6 +1090,7 @@ export class SubagentManager {
948
1090
  const resumedRecord = await this.#update(id, (current) => ({
949
1091
  ...current,
950
1092
  state: "running",
1093
+ intent: "active",
951
1094
  persistence,
952
1095
  ...(sessionPath === undefined ? {} : { sessionPath }),
953
1096
  launchConfig: sessionPath === undefined
@@ -958,8 +1101,33 @@ export class SubagentManager {
958
1101
  this.#projectors.set(id, createStateProjector(resumedRecord.state));
959
1102
  }
960
1103
  try {
961
- await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 30_000);
1104
+ await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
962
1105
  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
+ }
963
1131
  }
964
1132
  catch (error) {
965
1133
  const reason = errorMessage(error);
@@ -1006,23 +1174,38 @@ export class SubagentManager {
1006
1174
  return {
1007
1175
  ...clean,
1008
1176
  state: "running",
1177
+ intent: "active",
1009
1178
  unacknowledgedInput: message,
1010
1179
  };
1011
1180
  });
1012
1181
  try {
1013
- await this.#request(id, operation, { message });
1182
+ const path = acknowledgedPath(await this.#request(id, operation, { message }));
1183
+ this.#paths.set(id, path);
1014
1184
  }
1015
1185
  catch (error) {
1016
1186
  return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
1017
1187
  }
1018
- return publicChild((await this.#deps.registry.get(id)) ?? record, true);
1188
+ return publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
1019
1189
  });
1020
1190
  }
1021
1191
  async get(id) {
1022
1192
  const record = await this.#deps.registry.get(id);
1023
- return record === undefined
1024
- ? failure("get", "Unknown child", id)
1025
- : publicChild(record, this.#isLive(id));
1193
+ if (record === undefined)
1194
+ return failure("get", "Unknown child", id);
1195
+ 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));
1026
1209
  }
1027
1210
  async list() {
1028
1211
  const records = await this.#deps.registry.list();
@@ -1056,7 +1239,6 @@ export class SubagentManager {
1056
1239
  stopped = await this.#update(id, (current) => ({
1057
1240
  ...current,
1058
1241
  intent: "stopped",
1059
- state: "error",
1060
1242
  }));
1061
1243
  }
1062
1244
  catch (error) {
@@ -1086,6 +1268,7 @@ export class SubagentManager {
1086
1268
  // bridge-only stop.
1087
1269
  const cleanup = await attachment.cleanup();
1088
1270
  if (!(await this.#panelGone(attachment))) {
1271
+ await this.#update(id, (current) => ({ ...current, state: "error" })).catch(() => undefined);
1089
1272
  return failure("stop", `The child's ${attachment.identity.host} panel is not confirmed gone (${cleanup.stderr || "its state could not be observed"}): the child process may still be running`, id, "error", ["stopped intent persisted", "runtime metadata retained"], true);
1090
1273
  }
1091
1274
  this.#attachments.delete(id);
@@ -1112,7 +1295,10 @@ export class SubagentManager {
1112
1295
  if (current !== undefined) {
1113
1296
  stopped = await this.#deps.registry.update(id, current.revision, (value) => {
1114
1297
  const { runtime: _runtime, ...withoutRuntime } = value;
1115
- return withoutRuntime;
1298
+ return {
1299
+ ...withoutRuntime,
1300
+ state: "done",
1301
+ };
1116
1302
  });
1117
1303
  }
1118
1304
  if (this.#taskRegistry !== undefined) {
@@ -1176,7 +1362,6 @@ export class SubagentManager {
1176
1362
  }
1177
1363
  await this.#update(id, (current) => ({
1178
1364
  ...current,
1179
- intent: "stopped",
1180
1365
  state: "error",
1181
1366
  interrupted: message,
1182
1367
  }));
@@ -1374,7 +1559,7 @@ export class SubagentManager {
1374
1559
  return;
1375
1560
  projector.applyEvent(event);
1376
1561
  let confirmedInput;
1377
- if (value.type === "agent_end") {
1562
+ if (value.type === "turn_end" || value.type === "agent_end") {
1378
1563
  // A finished turn is exactly when the session file has the message, so this is where
1379
1564
  // unacknowledged input becomes part of the child's conversation.
1380
1565
  const entries = await this.#entries(id);