@hheei/pi-subagents 0.6.0 → 0.8.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 (60) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +14 -12
  4. package/dist/child-control.d.ts +0 -8
  5. package/dist/child-control.js +66 -3
  6. package/dist/commands.js +1 -1
  7. package/dist/controller-ipc.d.ts +38 -0
  8. package/dist/controller-ipc.js +292 -0
  9. package/dist/controller-main.d.ts +1 -0
  10. package/dist/controller-main.js +26 -0
  11. package/dist/controller-role.d.ts +40 -0
  12. package/dist/controller-role.js +37 -0
  13. package/dist/controller-socket.d.ts +6 -0
  14. package/dist/controller-socket.js +43 -0
  15. package/dist/controller.d.ts +25 -0
  16. package/dist/controller.js +217 -0
  17. package/dist/delegation.d.ts +73 -0
  18. package/dist/delegation.js +71 -0
  19. package/dist/domain.d.ts +20 -18
  20. package/dist/domain.js +6 -1
  21. package/dist/extension.js +43 -26
  22. package/dist/host-adapter.js +1 -1
  23. package/dist/launch-spec.d.ts +1 -3
  24. package/dist/launch-spec.js +39 -15
  25. package/dist/lease.d.ts +0 -1
  26. package/dist/lease.js +8 -11
  27. package/dist/mailbox.js +110 -34
  28. package/dist/manager.d.ts +52 -6
  29. package/dist/manager.js +585 -48
  30. package/dist/mcp/app.html +948 -0
  31. package/dist/mcp/delegate.d.ts +30 -0
  32. package/dist/mcp/delegate.js +44 -0
  33. package/dist/mcp/views.d.ts +5 -0
  34. package/dist/mcp/views.js +12 -0
  35. package/dist/mcp-server.d.ts +6 -0
  36. package/dist/mcp-server.js +287 -0
  37. package/dist/mcp-stdio.d.ts +2 -0
  38. package/dist/mcp-stdio.js +111 -0
  39. package/dist/process-lock.d.ts +1 -1
  40. package/dist/process-lock.js +4 -2
  41. package/dist/protocol.d.ts +5 -5
  42. package/dist/protocol.js +3 -4
  43. package/dist/receipts.d.ts +80 -0
  44. package/dist/receipts.js +326 -0
  45. package/dist/runtime.d.ts +5 -26
  46. package/dist/runtime.js +15 -57
  47. package/dist/session-bootstrap.js +2 -6
  48. package/dist/state-db.d.ts +11 -4
  49. package/dist/state-db.js +102 -24
  50. package/dist/state.d.ts +1 -0
  51. package/dist/state.js +29 -3
  52. package/dist/tool-activation.d.ts +2 -2
  53. package/dist/tool-activation.js +5 -5
  54. package/dist/tool-presentation.d.ts +2 -6
  55. package/dist/tool-presentation.js +75 -122
  56. package/dist/tools.d.ts +0 -3
  57. package/dist/tools.js +104 -132
  58. package/package.json +12 -5
  59. package/dist/json-lines.d.ts +0 -14
  60. package/dist/json-lines.js +0 -95
package/dist/manager.js CHANGED
@@ -1,10 +1,14 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
2
4
  import { errorMessage, isRecord } from "@hheei/pi-ext-core";
3
5
  import { attachRetainedChild } from "./child-process.js";
6
+ import { agentView, delegationView, } from "./delegation.js";
4
7
  import { isOperationError } from "./domain.js";
5
8
  import { stripHindsightContent } from "./launch-spec.js";
6
9
  import { commitLease, createConversationLease, RUNTIME_ENDED_RESUME, } from "./lease.js";
7
10
  import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
11
+ import { abandonReceipts, latestReceiptForChild, latestReceiptSummaries, maxRunBoundary, readReceiptRevision, readStoredReceipt, reserveReceipt, settleReceipts, sweepControlReceipts, terminalReceipt, updateReceipt, waitForReceipt, } from "./receipts.js";
8
12
  import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
9
13
  async function readSessionJsonlEntries(filePath) {
10
14
  const content = await readFile(filePath, "utf8");
@@ -22,10 +26,53 @@ async function readSessionJsonlEntries(filePath) {
22
26
  }
23
27
  return entries;
24
28
  }
29
+ /** One transcript page, parsed only through the requested window. */
30
+ async function readSessionJsonlPage(filePath, cursor, limit) {
31
+ const entries = [];
32
+ let index = 0;
33
+ let past = false;
34
+ const stream = createReadStream(filePath, { encoding: "utf8" });
35
+ stream.on("error", () => {
36
+ // The async iterator rejects with the same error; the listener keeps it from escaping.
37
+ });
38
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
39
+ try {
40
+ for await (const line of lines) {
41
+ const trimmed = line.trim();
42
+ if (trimmed.length === 0)
43
+ continue;
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(trimmed);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (index >= cursor) {
52
+ if (entries.length >= limit) {
53
+ past = true;
54
+ break;
55
+ }
56
+ entries.push(parsed);
57
+ }
58
+ index += 1;
59
+ }
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ finally {
65
+ lines.close();
66
+ stream.destroy();
67
+ }
68
+ if (index === 0 && entries.length === 0)
69
+ return undefined;
70
+ return { entries, ...(past ? { nextCursor: cursor + entries.length } : {}) };
71
+ }
25
72
  import { createStateProjector, parseTranscriptFacts, transcriptFacts, } from "./state.js";
26
73
  /**
27
74
  * How long an idle child keeps its runtime. A background child holds a whole Pi process and its
28
- * session lock, so an idle one is reclaimed after this window and a later `agent_send` starts it
75
+ * session lock, so an idle one is reclaimed after this window and a later `agents_delegate` starts it
29
76
  * again from the same session.
30
77
  */
31
78
  export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
@@ -56,6 +103,18 @@ function acknowledgedPath(response) {
56
103
  }
57
104
  return response.path;
58
105
  }
106
+ /** A compaction request is a control operation, not a turn. */
107
+ function isCompactCommand(task) {
108
+ const trimmed = task.trim();
109
+ return trimmed === "/compact" || trimmed.startsWith("/compact ");
110
+ }
111
+ /** The child names the compaction a request started or joined; without it, nothing can settle it. */
112
+ function acknowledgedControl(response) {
113
+ if (!isRecord(response) || typeof response.controlId !== "string" || response.controlId === "") {
114
+ throw new BridgeError("invalid_state", "Child did not confirm which compaction it runs");
115
+ }
116
+ return response.controlId;
117
+ }
59
118
  function publicChild(record, live, path = record.sessionPath) {
60
119
  return {
61
120
  id: record.subagentId,
@@ -131,6 +190,8 @@ export class SubagentManager {
131
190
  * process is not this child's runtime any more, so it is neither live nor ours to close.
132
191
  */
133
192
  #leftSessions = new Set();
193
+ /** Aborted on dispose, so no wait or delivery survives the session it belongs to. */
194
+ #disposed = new AbortController();
134
195
  /** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
135
196
  #activity = new Map();
136
197
  #projectors = new Map();
@@ -140,6 +201,16 @@ export class SubagentManager {
140
201
  #working = new Set();
141
202
  /** A settled event from an older run cannot clear a newer synchronous start signal. */
142
203
  #workVersions = new Map();
204
+ /** Highest run boundary reserved for a child that has not reached that turn yet. */
205
+ #lastClaim = new Map();
206
+ /** The compaction a child's last confirmed input controls, instead of a run boundary. */
207
+ #lastControl = new Map();
208
+ #recentSettlement = new Map();
209
+ /**
210
+ * Waiting delegations of this process, by child. Occupancy lives here and not in the ledger: a
211
+ * stored `pending` row is history, and a waiter of a previous process does not exist any more.
212
+ */
213
+ #waiters = new Map();
143
214
  #settledPanelCloseTimers = new Map();
144
215
  #pendingAutoReports = new Map();
145
216
  #autoReportBatchTimer;
@@ -162,11 +233,14 @@ export class SubagentManager {
162
233
  };
163
234
  }
164
235
  dispose() {
236
+ // Ending the session ends its waits: a pending tool call must not outlive the manager.
237
+ this.#disposed.abort();
165
238
  if (this.#autoReportBatchTimer !== undefined) {
166
239
  clearTimeout(this.#autoReportBatchTimer);
167
240
  this.#autoReportBatchTimer = undefined;
168
241
  }
169
242
  this.#pendingAutoReports.clear();
243
+ this.#waiters.clear();
170
244
  for (const timer of this.#settledPanelCloseTimers.values()) {
171
245
  clearTimeout(timer);
172
246
  }
@@ -186,6 +260,7 @@ export class SubagentManager {
186
260
  this.#autoReportBatchTimer = undefined;
187
261
  }
188
262
  this.#pendingAutoReports.clear();
263
+ this.#waiters.clear();
189
264
  this.#processes.clear();
190
265
  this.#attachments.clear();
191
266
  this.#leftSessions.clear();
@@ -194,6 +269,9 @@ export class SubagentManager {
194
269
  this.#paths.clear();
195
270
  this.#working.clear();
196
271
  this.#workVersions.clear();
272
+ this.#lastClaim.clear();
273
+ this.#lastControl.clear();
274
+ this.#recentSettlement.clear();
197
275
  this.#notify();
198
276
  }
199
277
  /**
@@ -347,10 +425,7 @@ export class SubagentManager {
347
425
  const current = await this.#deps.registry.get(id);
348
426
  if (current === undefined)
349
427
  return undefined;
350
- const identity = current.runtime?.runtimeIdentity;
351
428
  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
429
  if (outcome === "drop" &&
355
430
  current.state === "running" &&
356
431
  current.intent !== "stopped" &&
@@ -546,6 +621,12 @@ export class SubagentManager {
546
621
  state: "done",
547
622
  };
548
623
  });
624
+ this.#recordSettlement({
625
+ childId: id,
626
+ status: "failed",
627
+ message: "Subagent finished without a result.",
628
+ workVersion,
629
+ });
549
630
  return;
550
631
  }
551
632
  let message;
@@ -570,13 +651,19 @@ export class SubagentManager {
570
651
  else {
571
652
  message = text ?? "";
572
653
  }
654
+ const reason = isError ? "error" : isBlocked ? "blocked" : "success";
655
+ if (reason === "success" &&
656
+ facts?.contextTokens !== undefined &&
657
+ facts.contextTokens >= 100_000) {
658
+ const kTokens = `${Math.round(facts.contextTokens / 1000)}k`;
659
+ message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agents_delegate to trim memory.]`;
660
+ }
573
661
  const lastDelivered = this.#lastDeliveredTurn.get(id);
574
662
  if (lastDelivered !== undefined &&
575
663
  lastDelivered.workVersion === workVersion &&
576
664
  lastDelivered.message === message) {
577
665
  return;
578
666
  }
579
- const reason = isError ? "error" : isBlocked ? "blocked" : "success";
580
667
  const projector = this.#projectors.get(id);
581
668
  const updatedRecord = !failed
582
669
  ? await this.#update(id, (current) => {
@@ -595,8 +682,20 @@ export class SubagentManager {
595
682
  projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
596
683
  }
597
684
  this.#scheduleSettledPanelClose(id);
685
+ // The receipt is the durable record of this turn: it is written before delivery is
686
+ // coordinated, so a delivery failure cannot leave a settled turn looking unfinished.
687
+ const settled = this.#recordSettlement({
688
+ childId: id,
689
+ status: isError ? "failed" : isBlocked ? "blocked" : "completed",
690
+ message,
691
+ workVersion,
692
+ });
693
+ // Without a durable settlement there is no record to deliver against: the turn stays
694
+ // unsettled and undelivered rather than being pushed and then pushed again on a retry.
695
+ if (settled === undefined)
696
+ return;
598
697
  let deliveryOk = true;
599
- if (this.#deps.channel !== undefined) {
698
+ if (!this.#claimedByWaiter(settled) && this.#deps.channel !== undefined) {
600
699
  const contactResult = await this.contactParent(id, reason, message, updatedRecord);
601
700
  if (isOperationError(contactResult)) {
602
701
  deliveryOk = false;
@@ -608,8 +707,8 @@ export class SubagentManager {
608
707
  });
609
708
  }
610
709
  /** Reads a child's session entries; used by transcript inspection fallbacks. */
611
- async getEntries(id) {
612
- return this.#entries(id);
710
+ async getEntries(id, range) {
711
+ return this.#entries(id, range?.cursor ?? 0, range?.limit ?? 20);
613
712
  }
614
713
  /** A child is live while its bridge connection is up, or while a process we started still runs. */
615
714
  #isLive(id) {
@@ -692,18 +791,137 @@ export class SubagentManager {
692
791
  return entries === undefined ? undefined : transcriptFacts(entries, expectedInput);
693
792
  }
694
793
  /** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
695
- async #entries(id) {
794
+ async #entries(id, cursor = 0, limit = 20) {
696
795
  if (this.#deps.bridge.isConnected(id)) {
697
- const response = await this.#request(id, "get_entries").catch(() => undefined);
698
- if (isRecord(response) && Array.isArray(response.entries) && response.entries.length > 0) {
699
- return response.entries;
796
+ const response = await this.#request(id, "get_entries", { cursor, limit }).catch(() => undefined);
797
+ if (isRecord(response) &&
798
+ Array.isArray(response.entries) &&
799
+ (response.entries.length > 0 || cursor > 0)) {
800
+ const nextCursor = typeof response.nextCursor === "number" ? response.nextCursor : undefined;
801
+ return {
802
+ entries: response.entries,
803
+ ...(nextCursor === undefined ? {} : { nextCursor }),
804
+ };
700
805
  }
701
806
  }
702
- return this.#diskEntries(id);
807
+ const record = await this.#deps.registry.get(id).catch(() => undefined);
808
+ if (record === undefined)
809
+ return undefined;
810
+ const sessionPath = record.sessionPath ??
811
+ (await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
812
+ if (sessionPath === undefined)
813
+ return undefined;
814
+ return readSessionJsonlPage(sessionPath, cursor, limit);
703
815
  }
704
816
  #request(id, operation, payload) {
705
817
  return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
706
818
  }
819
+ /**
820
+ * Binds one confirmed input to a Pi work version. A steer into a turn that already started
821
+ * (`agent_start` has incremented the version) shares that version. Anything still queued
822
+ * reserves the next version, so a later completion cannot answer both.
823
+ */
824
+ #restoreWorkVersion(id) {
825
+ const stored = maxRunBoundary(this.#deps.registry.path, this.#deps.parentSessionId, id);
826
+ const current = this.#workVersions.get(id) ?? 0;
827
+ if (stored > current)
828
+ this.#workVersions.set(id, stored);
829
+ const claim = this.#lastClaim.get(id) ?? 0;
830
+ if (stored > claim)
831
+ this.#lastClaim.set(id, stored);
832
+ }
833
+ #claimRunBoundary(id, joining) {
834
+ // A steer into a turn that already started shares that turn's version. Anything else opens
835
+ // the next one, which is exactly the version the child's `agent_start` will announce, even
836
+ // when that announcement wins the race against this dispatch's own acknowledgement.
837
+ const current = this.#workVersions.get(id) ?? 0;
838
+ const claimed = joining && current > 0 ? current : current + 1;
839
+ this.#lastClaim.set(id, claimed);
840
+ return claimed;
841
+ }
842
+ /**
843
+ * Binds one confirmed input to what can settle it. A compaction is a control operation: it opens
844
+ * no turn, so it is bound to the child's operation id rather than to a work version that would
845
+ * never arrive.
846
+ */
847
+ #claimDispatch(id, response, claim) {
848
+ if (claim === undefined) {
849
+ this.#lastClaim.delete(id);
850
+ this.#lastControl.set(id, acknowledgedControl(response));
851
+ return;
852
+ }
853
+ this.#lastControl.delete(id);
854
+ this.#lastClaim.set(id, claim);
855
+ }
856
+ #claimedSubmission(id) {
857
+ const controlId = this.#lastControl.get(id);
858
+ if (controlId !== undefined)
859
+ return { controlId };
860
+ const workVersion = this.#lastClaim.get(id);
861
+ if (workVersion === undefined) {
862
+ throw new Error(`Delegation for ${id} was not bound to a run`);
863
+ }
864
+ const recent = this.#recentSettlement.get(id);
865
+ return recent?.workVersion === workVersion ? { workVersion, settled: recent } : { workVersion };
866
+ }
867
+ /**
868
+ * Records a settlement in the ledger and returns what it settled. `undefined` means the write
869
+ * itself failed: nothing was recorded, so no consumer may treat the turn as delivered and the
870
+ * caller that can retry is told about it instead.
871
+ */
872
+ #recordSettlement(event) {
873
+ this.#recentSettlement.set(event.childId, event);
874
+ // The ledger is the manager's own, so a settlement is durable before any consumer sees it and
875
+ // no caller has to wire this up for delegated turns to settle.
876
+ try {
877
+ return settleReceipts(this.#deps.registry.path, this.#deps.parentSessionId, event);
878
+ }
879
+ catch (error) {
880
+ diagnose(`failed to record settlement for ${event.childId}: ${errorMessage(error)}`);
881
+ return undefined;
882
+ }
883
+ }
884
+ /**
885
+ * True when a waiting tool call owns this settlement. A steer into the turn it is waiting on is
886
+ * answered by the same settlement, so pushing it would deliver that turn twice; a compaction has
887
+ * no boundary of its own and stays a separate result.
888
+ */
889
+ #claimedByWaiter(settled) {
890
+ return settled.some((receipt) => {
891
+ const childId = receipt.childId;
892
+ if (childId === undefined)
893
+ return false;
894
+ const waiter = this.#waiters.get(childId);
895
+ if (waiter === undefined || waiter.aborted)
896
+ return false;
897
+ if (waiter.delegationId === receipt.delegationId)
898
+ return true;
899
+ return waiter.runBoundary !== undefined && receipt.runBoundary === waiter.runBoundary;
900
+ });
901
+ }
902
+ /**
903
+ * Delivers a settled turn whose waiting tool call went away. The result is already durable, so
904
+ * this is the same automatic delivery an unwatched turn gets, not a second copy of it.
905
+ */
906
+ async #pushSettled(receipt) {
907
+ const childId = receipt.childId;
908
+ if (this.#deps.channel === undefined || childId === undefined)
909
+ return;
910
+ const text = receipt.resultText;
911
+ if (text === undefined)
912
+ return;
913
+ const record = await this.#deps.registry.get(childId);
914
+ if (record === undefined)
915
+ return;
916
+ const reason = receipt.status === "completed"
917
+ ? "success"
918
+ : receipt.status === "blocked"
919
+ ? "blocked"
920
+ : "error";
921
+ await this.contactParent(childId, reason, text, record).catch((error) => {
922
+ diagnose(`failed to deliver the settled turn of ${childId}: ${errorMessage(error)}`);
923
+ });
924
+ }
707
925
  #notify() {
708
926
  for (const listener of this.#listeners) {
709
927
  try {
@@ -720,6 +938,14 @@ export class SubagentManager {
720
938
  * until a later send starts it from its recorded session.
721
939
  */
722
940
  async recover() {
941
+ // A compaction that was in flight when this process started cannot be confirmed or replayed, so
942
+ // it ends as uncertain instead of leaving a waiter on a report that may never come.
943
+ try {
944
+ sweepControlReceipts(this.#deps.registry.path, this.#deps.parentSessionId, "Compaction was not confirmed before this parent session restarted");
945
+ }
946
+ catch (error) {
947
+ diagnose(`failed to sweep unconfirmed compactions: ${errorMessage(error)}`);
948
+ }
723
949
  const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state === "running");
724
950
  const outcomes = await Promise.all(records.map(async (record) => {
725
951
  this.#working.add(record.subagentId);
@@ -760,9 +986,14 @@ export class SubagentManager {
760
986
  return;
761
987
  }
762
988
  await this.#releaseRuntime(record.subagentId, "parent-exit");
989
+ // The interrupted turn will not finish. Leaving it accepted lets the next run reuse its work
990
+ // boundary and settle the old receipt with the new answer.
991
+ this.#restoreWorkVersion(record.subagentId);
992
+ abandonReceipts(this.#deps.registry.path, this.#deps.parentSessionId, record.subagentId, "Delegation was interrupted because the background agent ended with the parent session");
763
993
  }
764
994
  /** Reads a connected child's real state and puts the record back in step with it. */
765
995
  async #adopt(id) {
996
+ this.#restoreWorkVersion(id);
766
997
  const workVersion = this.#workVersions.get(id) ?? 0;
767
998
  const projector = this.#projectors.get(id);
768
999
  const record = await this.#deps.registry.get(id);
@@ -843,13 +1074,214 @@ export class SubagentManager {
843
1074
  }
844
1075
  return { presentation: "panel" };
845
1076
  }
1077
+ /**
1078
+ * One dispatch path for MCP's new-child and existing-child forms. This only confirms input
1079
+ * delivery; waiting for the specific turn's final report belongs to the MCP delegation receipt,
1080
+ * not to spawn/send or the child's durable identity.
1081
+ */
1082
+ async submitDelegation(input, onTarget) {
1083
+ if (stripHindsightContent(input.task).trim() === "") {
1084
+ return failure("delegate", "Task must not be empty", undefined, undefined, [], false);
1085
+ }
1086
+ if (input.id !== undefined) {
1087
+ if (input.id.trim() === "") {
1088
+ return failure("delegate", "Child id must not be empty", undefined, undefined, [], false);
1089
+ }
1090
+ onTarget?.(input.id);
1091
+ // Ignore agent and cwd entirely: this child's launch config was frozen at spawn.
1092
+ const child = await this.send(input.id, input.task);
1093
+ return isOperationError(child)
1094
+ ? child
1095
+ : { child, action: "reuse", ...this.#claimedSubmission(child.id) };
1096
+ }
1097
+ if (input.agent === undefined || input.agent.trim() === "") {
1098
+ return failure("delegate", "Agent is required without an id", undefined, undefined, [], false);
1099
+ }
1100
+ const spawned = await this.#spawnWith({
1101
+ task: input.task,
1102
+ agent: input.agent,
1103
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
1104
+ }, (config) => onTarget?.(config.subagentId));
1105
+ if (isOperationError(spawned))
1106
+ return spawned;
1107
+ return {
1108
+ child: spawned.child,
1109
+ action: "spawn",
1110
+ ...this.#claimedSubmission(spawned.child.id),
1111
+ ...(spawned.note === undefined ? {} : { note: spawned.note }),
1112
+ };
1113
+ }
1114
+ /**
1115
+ * The acceptance path both consumers share: reserve this delegation's revision, dispatch it into
1116
+ * the agent session, then either wait for that revision's settlement or return its identity.
1117
+ * Waiting never holds the per-agent chain, so settlements and later steers still get in.
1118
+ */
1119
+ async delegate(input, signal, onAccepted) {
1120
+ const wait = input.wait ?? true;
1121
+ const task = stripHindsightContent(input.task).trim();
1122
+ if (task === "") {
1123
+ return failure("delegate", "Task must not be empty", undefined, undefined, [], false);
1124
+ }
1125
+ if (input.id !== undefined && input.id.trim() === "") {
1126
+ return failure("delegate", "Child id must not be empty", undefined, undefined, [], false);
1127
+ }
1128
+ if (input.id === undefined && (input.agent === undefined || input.agent.trim() === "")) {
1129
+ return failure("delegate", "Agent is required without an id", undefined, undefined, [], false);
1130
+ }
1131
+ const filePath = this.#deps.registry.path;
1132
+ const parentSessionId = this.#deps.parentSessionId;
1133
+ if (input.id !== undefined && (await this.#deps.registry.get(input.id)) === undefined) {
1134
+ // Nothing is reserved for a target that does not exist: a revision is only ever spent on
1135
+ // work this parent actually accepted.
1136
+ return failure("delegate", "Unknown child", input.id);
1137
+ }
1138
+ const active = input.id === undefined ? undefined : this.#waiters.get(input.id);
1139
+ if (wait && active !== undefined) {
1140
+ // Refused before anything is dispatched: a conflicted wait must not steer first.
1141
+ return failure("delegate", `Child ${input.id ?? ""} already has a waiting delegation ${active.delegationId}`, input.id);
1142
+ }
1143
+ const target = {};
1144
+ const delivery = wait ? "pending" : "detached";
1145
+ let submitted;
1146
+ try {
1147
+ submitted = await this.submitDelegation(input, (childId) => {
1148
+ target.receipt = this.#reserveDelegation(filePath, parentSessionId, childId, task, delivery);
1149
+ // Registered before dispatch, so a settlement that lands during it already knows the
1150
+ // tool result owns this turn and does not push the same answer as well.
1151
+ if (wait) {
1152
+ this.#waiters.set(childId, {
1153
+ delegationId: target.receipt.delegationId,
1154
+ aborted: false,
1155
+ });
1156
+ }
1157
+ });
1158
+ }
1159
+ catch (error) {
1160
+ // A refused reservation happened before any dispatch, so nothing has to be undone.
1161
+ return failure("delegate", errorMessage(error), input.id, undefined, [], true);
1162
+ }
1163
+ const receipt = target.receipt;
1164
+ if (receipt === undefined) {
1165
+ // Without a reserved revision nothing was accepted, so this is the dispatch's own refusal.
1166
+ if (isOperationError(submitted))
1167
+ return submitted;
1168
+ return failure("delegate", "Delegation was accepted without a reserved revision");
1169
+ }
1170
+ if (isOperationError(submitted)) {
1171
+ // Only this call's own occupancy is released: a wait:false steer never owns one, so
1172
+ // deleting here would let a second waiter take over a delivery that is already claimed.
1173
+ if (wait)
1174
+ this.#waiters.delete(receipt.childId ?? "");
1175
+ const uncertain = submitted.sideEffects.length > 0 && !submitted.safeToRetry;
1176
+ const failed = updateReceipt(filePath, parentSessionId, receipt.delegationId, {
1177
+ from: ["dispatching"],
1178
+ status: uncertain ? "uncertain" : "failed",
1179
+ resultText: submitted.reason,
1180
+ safeToRetry: submitted.safeToRetry,
1181
+ sideEffects: submitted.sideEffects,
1182
+ });
1183
+ return {
1184
+ ...delegationView(failed ?? receipt),
1185
+ error: {
1186
+ reason: submitted.reason,
1187
+ safeToRetry: submitted.safeToRetry,
1188
+ sideEffects: submitted.sideEffects,
1189
+ },
1190
+ };
1191
+ }
1192
+ const childId = submitted.child.id;
1193
+ const bound = this.#bindDispatch(filePath, parentSessionId, receipt, submitted);
1194
+ if (!wait) {
1195
+ return agentView(bound, submitted.child, { includeOutput: bound.status !== "accepted" });
1196
+ }
1197
+ if (bound.status !== "accepted") {
1198
+ this.#waiters.delete(childId);
1199
+ return agentView(bound, submitted.child, { includeOutput: true });
1200
+ }
1201
+ const waiter = this.#waiters.get(childId);
1202
+ if (waiter !== undefined && bound.runBoundary !== undefined) {
1203
+ waiter.runBoundary = bound.runBoundary;
1204
+ }
1205
+ try {
1206
+ onAccepted?.(await agentView(bound, submitted.child));
1207
+ const settled = await waitForReceipt(filePath, parentSessionId, receipt.delegationId, signal === undefined
1208
+ ? this.#disposed.signal
1209
+ : AbortSignal.any([signal, this.#disposed.signal]));
1210
+ // The runtime moved on while the turn was in flight, so the projection is read fresh.
1211
+ const latest = await this.#deps.registry.get(childId);
1212
+ return await agentView(settled, latest === undefined ? submitted.child : publicChild(latest, false), { includeOutput: true });
1213
+ }
1214
+ catch (error) {
1215
+ // The wait ended, not the delegation. A result the tool result can no longer carry goes
1216
+ // back to the automatic path, so the turn is delivered exactly once either way.
1217
+ if (waiter !== undefined)
1218
+ waiter.aborted = true;
1219
+ const current = readStoredReceipt(filePath, parentSessionId, receipt.delegationId) ?? bound;
1220
+ if (!this.#disposed.signal.aborted && terminalReceipt(current.status)) {
1221
+ await this.#pushSettled(current);
1222
+ }
1223
+ if (signal?.aborted === true || this.#disposed.signal.aborted) {
1224
+ return delegationView(current, submitted.child);
1225
+ }
1226
+ throw error;
1227
+ }
1228
+ finally {
1229
+ if (this.#waiters.get(childId) === waiter)
1230
+ this.#waiters.delete(childId);
1231
+ }
1232
+ }
1233
+ #reserveDelegation(filePath, parentSessionId, childId, task, delivery) {
1234
+ const reserved = reserveReceipt(filePath, { parentSessionId, task, childId, delivery });
1235
+ const dispatching = updateReceipt(filePath, parentSessionId, reserved.delegationId, {
1236
+ from: ["reserved"],
1237
+ status: "dispatching",
1238
+ });
1239
+ if (dispatching === undefined)
1240
+ throw new Error("Delegation was not reserved");
1241
+ return dispatching;
1242
+ }
1243
+ /** Binds the accepted revision to what can settle it: a control operation, or a work version. */
1244
+ #bindDispatch(filePath, parentSessionId, receipt, submitted) {
1245
+ // A settlement that arrived before this binding landed is still that delegation's answer, and
1246
+ // the manager already holds it.
1247
+ const recent = this.#recentSettlement.get(submitted.child.id);
1248
+ const settled = submitted.settled ??
1249
+ (submitted.controlId !== undefined && recent?.controlId === submitted.controlId
1250
+ ? recent
1251
+ : undefined);
1252
+ const bound = updateReceipt(filePath, parentSessionId, receipt.delegationId, {
1253
+ from: ["dispatching"],
1254
+ status: settled === undefined ? "accepted" : settled.status,
1255
+ ...(submitted.controlId !== undefined
1256
+ ? { controlId: submitted.controlId }
1257
+ : submitted.workVersion === undefined
1258
+ ? {}
1259
+ : { runBoundary: submitted.workVersion }),
1260
+ ...(settled === undefined ? {} : { resultText: settled.message }),
1261
+ });
1262
+ if (bound !== undefined)
1263
+ return bound;
1264
+ // The turn settled before this binding landed, so the stored row is already past dispatching.
1265
+ const current = readStoredReceipt(filePath, parentSessionId, receipt.delegationId);
1266
+ if (current === undefined)
1267
+ throw new Error("Delegation acceptance was lost");
1268
+ return current;
1269
+ }
846
1270
  async spawn(input) {
1271
+ return this.#spawnWith(input);
1272
+ }
1273
+ /**
1274
+ * The spawn path with one seam: a caller that must own the new identity (a delegation reserves
1275
+ * its revision) sees it here, after it is minted and before anything is persisted or launched.
1276
+ */
1277
+ async #spawnWith(input, onConfig) {
847
1278
  const cleanTask = stripHindsightContent(input.task).trim();
848
1279
  if (cleanTask === "")
849
1280
  return failure("spawn", "Task must not be empty");
850
1281
  let childId;
851
1282
  try {
852
1283
  const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
1284
+ onConfig?.(config);
853
1285
  const cleanTask = stripHindsightContent(input.task);
854
1286
  const placement = this.#placement(input);
855
1287
  if ("reason" in placement)
@@ -866,9 +1298,16 @@ export class SubagentManager {
866
1298
  this.#notify();
867
1299
  await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
868
1300
  let path;
1301
+ // A fresh child's first turn is version 1, claimed before the prompt goes out so a
1302
+ // preceding `agent_start` cannot push the receipt onto a version that never settles.
1303
+ const claim = isCompactCommand(cleanTask)
1304
+ ? undefined
1305
+ : this.#claimRunBoundary(record.subagentId, false);
869
1306
  try {
870
- path = acknowledgedPath(await this.#request(record.subagentId, "prompt", { message: cleanTask }));
1307
+ const response = await this.#request(record.subagentId, "prompt", { message: cleanTask });
1308
+ path = acknowledgedPath(response);
871
1309
  this.#paths.set(record.subagentId, path);
1310
+ this.#claimDispatch(record.subagentId, response, claim);
872
1311
  }
873
1312
  catch (error) {
874
1313
  let failureState = "error";
@@ -923,7 +1362,11 @@ export class SubagentManager {
923
1362
  return failed;
924
1363
  }
925
1364
  }
926
- send(id, rawMessage, mode = "steer", signal) {
1365
+ send(id, rawMessage, signal) {
1366
+ return this.#sendWithClaim(id, rawMessage, signal).then((result) => isOperationError(result) ? result : result.child);
1367
+ }
1368
+ /** The same delivery, with the work version this input was bound to. */
1369
+ async #sendWithClaim(id, rawMessage, signal) {
927
1370
  const message = stripHindsightContent(rawMessage);
928
1371
  this.#bumpActivity(id);
929
1372
  this.#clearSettledPanelClose(id);
@@ -965,6 +1408,9 @@ export class SubagentManager {
965
1408
  try {
966
1409
  await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
967
1410
  isResumed = true;
1411
+ // A new process counts its turns from one again, so this parent adopts the boundary the
1412
+ // ledger already spent before it opens the next one.
1413
+ this.#restoreWorkVersion(id);
968
1414
  }
969
1415
  catch (error) {
970
1416
  const reason = errorMessage(error);
@@ -994,12 +1440,19 @@ export class SubagentManager {
994
1440
  }
995
1441
  }
996
1442
  const state = isResumed ? "running" : record.state;
997
- // A resume already starts a fresh run, so the mode has nothing to decide: the message is
998
- // that run's prompt. Otherwise the caller's mode is the request, with no state guessing:
999
- // a running child steers at its next model step, an idle one simply starts a run.
1000
- const operation = isResumed ? "prompt" : mode;
1443
+ // A resume already starts a fresh run, so the message is that run's prompt. Otherwise the
1444
+ // input steers: a running child takes it at its next model step, an idle one starts a run.
1445
+ const operation = isResumed ? "prompt" : "steer";
1001
1446
  if (signal?.aborted)
1002
1447
  return failure("send", "Send was cancelled", id, state);
1448
+ // Read before this dispatch marks the child running: only a child already inside a turn
1449
+ // shares that turn's boundary, a fresh dispatch always opens the next one. The boundary
1450
+ // is claimed before the request goes out, because the child may announce `agent_start`
1451
+ // before this dispatch's own acknowledgement arrives.
1452
+ // A resume starts a fresh run in a new process, so it never shares the boundary of the
1453
+ // turn the dead one left behind.
1454
+ const joining = !isResumed && this.#working.has(id) && (this.#workVersions.get(id) ?? 0) > 0;
1455
+ const claim = isCompactCommand(message) ? undefined : this.#claimRunBoundary(id, joining);
1003
1456
  this.#clearSettledPanelClose(id);
1004
1457
  const pending = await this.#update(id, (current) => {
1005
1458
  const { interrupted: _interrupted, ...clean } = current;
@@ -1011,13 +1464,16 @@ export class SubagentManager {
1011
1464
  };
1012
1465
  });
1013
1466
  try {
1014
- const path = acknowledgedPath(await this.#request(id, operation, { message }));
1467
+ const response = await this.#request(id, operation, { message });
1468
+ const path = acknowledgedPath(response);
1015
1469
  this.#paths.set(id, path);
1470
+ this.#claimDispatch(id, response, claim);
1016
1471
  }
1017
1472
  catch (error) {
1018
1473
  return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
1019
1474
  }
1020
- return publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
1475
+ const child = publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
1476
+ return { child, action: "reuse", ...this.#claimedSubmission(id) };
1021
1477
  });
1022
1478
  }
1023
1479
  async get(id) {
@@ -1027,6 +1483,62 @@ export class SubagentManager {
1027
1483
  const live = this.#isLive(id);
1028
1484
  return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
1029
1485
  }
1486
+ /**
1487
+ * One delegation of one agent, by revision: the newest by default. The result body is only read
1488
+ * when it is asked for, and then only within the shared output budget.
1489
+ */
1490
+ async inspect(input) {
1491
+ const record = await this.#deps.registry.get(input.id);
1492
+ if (record === undefined)
1493
+ return failure("get", "Unknown child", input.id);
1494
+ const filePath = this.#deps.registry.path;
1495
+ const receipt = input.revision === undefined
1496
+ ? latestReceiptForChild(filePath, this.#deps.parentSessionId, input.id)
1497
+ : readReceiptRevision(filePath, this.#deps.parentSessionId, input.id, input.revision);
1498
+ if (receipt === undefined) {
1499
+ return failure("get", input.revision === undefined
1500
+ ? "This agent has no delegation"
1501
+ : `This agent has no delegation revision ${input.revision}`, input.id, record.state);
1502
+ }
1503
+ return agentView(receipt, publicChild(this.#withSnapshot(record), this.#isLive(input.id), this.#paths.get(input.id)), { includeOutput: input.includeOutput === true });
1504
+ }
1505
+ /** Every agent of this parent: runtime state now, latest delegation status, no result bodies. */
1506
+ async dashboard() {
1507
+ const records = await this.#deps.registry.list();
1508
+ const summaries = latestReceiptSummaries(this.#deps.registry.path, this.#deps.parentSessionId);
1509
+ const children = [];
1510
+ for (const record of records) {
1511
+ const summary = summaries.get(record.subagentId);
1512
+ if (summary === undefined)
1513
+ continue;
1514
+ const live = this.#withSnapshot(record);
1515
+ children.push({
1516
+ id: record.subagentId,
1517
+ agent: record.launchConfig.agent.name,
1518
+ runtimeState: live.state,
1519
+ revision: summary.revision,
1520
+ status: summary.status,
1521
+ hasOutput: summary.hasOutput,
1522
+ ...(live.latestSummary === undefined ? {} : { summary: live.latestSummary }),
1523
+ });
1524
+ }
1525
+ const count = (state) => children.filter((child) => child.runtimeState === state).length;
1526
+ return {
1527
+ total: children.length,
1528
+ running: count("running"),
1529
+ blocked: count("blocked"),
1530
+ completed: count("done"),
1531
+ failed: count("error"),
1532
+ children,
1533
+ };
1534
+ }
1535
+ /** Stops the runtime and reports the same projection every other read returns. */
1536
+ async stopAgent(id) {
1537
+ const stopped = await this.stop(id);
1538
+ if (isOperationError(stopped))
1539
+ return stopped;
1540
+ return this.inspect({ id });
1541
+ }
1030
1542
  async list() {
1031
1543
  const records = await this.#deps.registry.list();
1032
1544
  return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
@@ -1103,6 +1615,9 @@ export class SubagentManager {
1103
1615
  const finished = await this.#releaseRuntime(id, "finished");
1104
1616
  if (finished !== undefined)
1105
1617
  stopped = finished;
1618
+ // Nothing can settle these any more: a waiter that was waiting on this child must end
1619
+ // with the reason, not hang on a runtime that is gone.
1620
+ abandonReceipts(this.#deps.registry.path, this.#deps.parentSessionId, id, "Child was stopped before the delegation finished");
1106
1621
  return publicChild(stopped, false);
1107
1622
  }
1108
1623
  catch (error) {
@@ -1174,28 +1689,42 @@ export class SubagentManager {
1174
1689
  throw new BridgeError("stale_report", "This report does not belong to the current runtime");
1175
1690
  }
1176
1691
  const reason = payload.reason ?? "blocked";
1177
- const delivered = await this.contactParent(childId, reason, payload.message, record);
1692
+ const settles = reason === "success" || reason === "blocked";
1693
+ // The settlement is durable before delivery is coordinated, and a waiting tool call that
1694
+ // already owns this turn's answer suppresses the push, so it is delivered exactly once.
1695
+ const settled = settles
1696
+ ? this.#recordSettlement({
1697
+ childId,
1698
+ status: reason === "blocked" ? "blocked" : "completed",
1699
+ message: payload.message,
1700
+ ...(payload.controlId === undefined
1701
+ ? { workVersion: this.#workVersions.get(childId) ?? 0 }
1702
+ : { controlId: payload.controlId }),
1703
+ })
1704
+ : [];
1705
+ if (settled === undefined) {
1706
+ // The child still holds the report, so it can retry instead of losing the settlement.
1707
+ throw new BridgeError("delivery_failed", "Settlement was not recorded");
1708
+ }
1709
+ const delivered = this.#claimedByWaiter(settled)
1710
+ ? { delivered: true }
1711
+ : await this.contactParent(childId, reason, payload.message, record);
1178
1712
  if ("reason" in delivered)
1179
1713
  throw new BridgeError("delivery_failed", delivered.reason);
1180
- if (reason === "success") {
1714
+ if (reason === "success" || reason === "blocked") {
1715
+ const terminalState = reason === "success" ? "done" : "blocked";
1181
1716
  const projector = this.#projectors.get(childId);
1182
1717
  if (projector !== undefined) {
1183
- projector.syncState("done");
1718
+ projector.syncState(terminalState);
1184
1719
  }
1185
- await this.#update(childId, (current) => ({
1186
- ...current,
1187
- state: "done",
1188
- }));
1189
- }
1190
- else if (reason === "blocked") {
1191
- const projector = this.#projectors.get(childId);
1192
- if (projector !== undefined) {
1193
- projector.syncState("blocked");
1194
- }
1195
- await this.#update(childId, (current) => ({
1196
- ...current,
1197
- state: "blocked",
1198
- }));
1720
+ await this.#update(childId, (current) => {
1721
+ const { unacknowledgedInput: _input, ...clean } = current;
1722
+ return {
1723
+ ...clean,
1724
+ state: terminalState,
1725
+ };
1726
+ });
1727
+ this.#scheduleSettledPanelClose(childId);
1199
1728
  }
1200
1729
  else {
1201
1730
  const projector = this.#projectors.get(childId);
@@ -1268,16 +1797,23 @@ export class SubagentManager {
1268
1797
  const message = value.message === undefined || value.message === ""
1269
1798
  ? "Task is unfinished and waiting for user intent."
1270
1799
  : value.message;
1271
- await this.contactParent(id, "user_interrupt", message, record);
1800
+ const settled = this.#recordSettlement({
1801
+ childId: id,
1802
+ status: "blocked",
1803
+ message,
1804
+ workVersion,
1805
+ });
1806
+ if (settled !== undefined && !this.#claimedByWaiter(settled)) {
1807
+ await this.contactParent(id, "user_interrupt", message, record);
1808
+ }
1272
1809
  return;
1273
1810
  }
1274
1811
  if (value.kind === "left_session") {
1275
1812
  this.#working.delete(id);
1276
1813
  // The process now serves another session, so it is no longer this child's runtime. Its
1277
- // credential is revoked and its runtime evidence dropped, so neither a reconnect nor a
1278
- // later adopt can mistake the new session for this child; the panel belongs to that other
1279
- // session from now on, which is why it is forgotten here and never closed.
1280
- const retired = record.runtime?.runtimeIdentity;
1814
+ // runtime evidence is dropped, so neither a reconnect nor a later adopt can mistake the new
1815
+ // session for this child; the panel belongs to that other session from now on, which is why
1816
+ // it is forgotten here and never closed.
1281
1817
  this.#clearSettledPanelClose(id);
1282
1818
  this.#projectors.delete(id);
1283
1819
  this.#attachments.delete(id);
@@ -1286,8 +1822,12 @@ export class SubagentManager {
1286
1822
  // relaunch mistake the old connection for the new runtime.
1287
1823
  this.#deps.bridge.disconnect(id);
1288
1824
  await commitLease(this.#deps.registry, id, { type: "left-session" });
1289
- if (retired !== undefined)
1290
- this.#deps.tokens?.forget(retired);
1825
+ this.#recordSettlement({
1826
+ childId: id,
1827
+ status: "failed",
1828
+ message: "Child left the delegated session.",
1829
+ workVersion,
1830
+ });
1291
1831
  return;
1292
1832
  }
1293
1833
  // A child's own quit is a lifecycle notice, not state: the disconnect that follows says
@@ -1385,9 +1925,6 @@ export class SubagentManager {
1385
1925
  registry: this.#deps.registry,
1386
1926
  hosts: directory,
1387
1927
  processes: { alive: processAlive },
1388
- forgetRuntime: (runtimeIdentity) => {
1389
- this.#deps.tokens?.forget(runtimeIdentity);
1390
- },
1391
1928
  });
1392
1929
  const result = await lease.ready();
1393
1930
  if (!result.ok)