@hheei/pi-subagents 0.7.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 (59) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +13 -13
  4. package/dist/child-control.d.ts +0 -8
  5. package/dist/child-control.js +40 -8
  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 +38 -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 +568 -32
  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.js +12 -3
  51. package/dist/tool-activation.d.ts +2 -2
  52. package/dist/tool-activation.js +5 -5
  53. package/dist/tool-presentation.d.ts +2 -6
  54. package/dist/tool-presentation.js +75 -122
  55. package/dist/tools.d.ts +0 -3
  56. package/dist/tools.js +104 -132
  57. package/package.json +12 -5
  58. package/dist/json-lines.d.ts +0 -14
  59. 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;
@@ -575,7 +656,7 @@ export class SubagentManager {
575
656
  facts?.contextTokens !== undefined &&
576
657
  facts.contextTokens >= 100_000) {
577
658
  const kTokens = `${Math.round(facts.contextTokens / 1000)}k`;
578
- message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agent_send to trim memory.]`;
659
+ message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agents_delegate to trim memory.]`;
579
660
  }
580
661
  const lastDelivered = this.#lastDeliveredTurn.get(id);
581
662
  if (lastDelivered !== undefined &&
@@ -601,8 +682,20 @@ export class SubagentManager {
601
682
  projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
602
683
  }
603
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;
604
697
  let deliveryOk = true;
605
- if (this.#deps.channel !== undefined) {
698
+ if (!this.#claimedByWaiter(settled) && this.#deps.channel !== undefined) {
606
699
  const contactResult = await this.contactParent(id, reason, message, updatedRecord);
607
700
  if (isOperationError(contactResult)) {
608
701
  deliveryOk = false;
@@ -614,8 +707,8 @@ export class SubagentManager {
614
707
  });
615
708
  }
616
709
  /** Reads a child's session entries; used by transcript inspection fallbacks. */
617
- async getEntries(id) {
618
- return this.#entries(id);
710
+ async getEntries(id, range) {
711
+ return this.#entries(id, range?.cursor ?? 0, range?.limit ?? 20);
619
712
  }
620
713
  /** A child is live while its bridge connection is up, or while a process we started still runs. */
621
714
  #isLive(id) {
@@ -698,18 +791,137 @@ export class SubagentManager {
698
791
  return entries === undefined ? undefined : transcriptFacts(entries, expectedInput);
699
792
  }
700
793
  /** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
701
- async #entries(id) {
794
+ async #entries(id, cursor = 0, limit = 20) {
702
795
  if (this.#deps.bridge.isConnected(id)) {
703
- const response = await this.#request(id, "get_entries").catch(() => undefined);
704
- if (isRecord(response) && Array.isArray(response.entries) && response.entries.length > 0) {
705
- 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
+ };
706
805
  }
707
806
  }
708
- 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);
709
815
  }
710
816
  #request(id, operation, payload) {
711
817
  return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
712
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
+ }
713
925
  #notify() {
714
926
  for (const listener of this.#listeners) {
715
927
  try {
@@ -726,6 +938,14 @@ export class SubagentManager {
726
938
  * until a later send starts it from its recorded session.
727
939
  */
728
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
+ }
729
949
  const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state === "running");
730
950
  const outcomes = await Promise.all(records.map(async (record) => {
731
951
  this.#working.add(record.subagentId);
@@ -766,9 +986,14 @@ export class SubagentManager {
766
986
  return;
767
987
  }
768
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");
769
993
  }
770
994
  /** Reads a connected child's real state and puts the record back in step with it. */
771
995
  async #adopt(id) {
996
+ this.#restoreWorkVersion(id);
772
997
  const workVersion = this.#workVersions.get(id) ?? 0;
773
998
  const projector = this.#projectors.get(id);
774
999
  const record = await this.#deps.registry.get(id);
@@ -849,13 +1074,214 @@ export class SubagentManager {
849
1074
  }
850
1075
  return { presentation: "panel" };
851
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
+ }
852
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) {
853
1278
  const cleanTask = stripHindsightContent(input.task).trim();
854
1279
  if (cleanTask === "")
855
1280
  return failure("spawn", "Task must not be empty");
856
1281
  let childId;
857
1282
  try {
858
1283
  const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
1284
+ onConfig?.(config);
859
1285
  const cleanTask = stripHindsightContent(input.task);
860
1286
  const placement = this.#placement(input);
861
1287
  if ("reason" in placement)
@@ -872,9 +1298,16 @@ export class SubagentManager {
872
1298
  this.#notify();
873
1299
  await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
874
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);
875
1306
  try {
876
- 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);
877
1309
  this.#paths.set(record.subagentId, path);
1310
+ this.#claimDispatch(record.subagentId, response, claim);
878
1311
  }
879
1312
  catch (error) {
880
1313
  let failureState = "error";
@@ -929,7 +1362,11 @@ export class SubagentManager {
929
1362
  return failed;
930
1363
  }
931
1364
  }
932
- 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) {
933
1370
  const message = stripHindsightContent(rawMessage);
934
1371
  this.#bumpActivity(id);
935
1372
  this.#clearSettledPanelClose(id);
@@ -971,6 +1408,9 @@ export class SubagentManager {
971
1408
  try {
972
1409
  await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
973
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);
974
1414
  }
975
1415
  catch (error) {
976
1416
  const reason = errorMessage(error);
@@ -1000,12 +1440,19 @@ export class SubagentManager {
1000
1440
  }
1001
1441
  }
1002
1442
  const state = isResumed ? "running" : record.state;
1003
- // A resume already starts a fresh run, so the mode has nothing to decide: the message is
1004
- // that run's prompt. Otherwise the caller's mode is the request, with no state guessing:
1005
- // a running child steers at its next model step, an idle one simply starts a run.
1006
- 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";
1007
1446
  if (signal?.aborted)
1008
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);
1009
1456
  this.#clearSettledPanelClose(id);
1010
1457
  const pending = await this.#update(id, (current) => {
1011
1458
  const { interrupted: _interrupted, ...clean } = current;
@@ -1017,13 +1464,16 @@ export class SubagentManager {
1017
1464
  };
1018
1465
  });
1019
1466
  try {
1020
- const path = acknowledgedPath(await this.#request(id, operation, { message }));
1467
+ const response = await this.#request(id, operation, { message });
1468
+ const path = acknowledgedPath(response);
1021
1469
  this.#paths.set(id, path);
1470
+ this.#claimDispatch(id, response, claim);
1022
1471
  }
1023
1472
  catch (error) {
1024
1473
  return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
1025
1474
  }
1026
- 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) };
1027
1477
  });
1028
1478
  }
1029
1479
  async get(id) {
@@ -1033,6 +1483,62 @@ export class SubagentManager {
1033
1483
  const live = this.#isLive(id);
1034
1484
  return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
1035
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
+ }
1036
1542
  async list() {
1037
1543
  const records = await this.#deps.registry.list();
1038
1544
  return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
@@ -1109,6 +1615,9 @@ export class SubagentManager {
1109
1615
  const finished = await this.#releaseRuntime(id, "finished");
1110
1616
  if (finished !== undefined)
1111
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");
1112
1621
  return publicChild(stopped, false);
1113
1622
  }
1114
1623
  catch (error) {
@@ -1180,7 +1689,26 @@ export class SubagentManager {
1180
1689
  throw new BridgeError("stale_report", "This report does not belong to the current runtime");
1181
1690
  }
1182
1691
  const reason = payload.reason ?? "blocked";
1183
- 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);
1184
1712
  if ("reason" in delivered)
1185
1713
  throw new BridgeError("delivery_failed", delivered.reason);
1186
1714
  if (reason === "success" || reason === "blocked") {
@@ -1269,16 +1797,23 @@ export class SubagentManager {
1269
1797
  const message = value.message === undefined || value.message === ""
1270
1798
  ? "Task is unfinished and waiting for user intent."
1271
1799
  : value.message;
1272
- 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
+ }
1273
1809
  return;
1274
1810
  }
1275
1811
  if (value.kind === "left_session") {
1276
1812
  this.#working.delete(id);
1277
1813
  // The process now serves another session, so it is no longer this child's runtime. Its
1278
- // credential is revoked and its runtime evidence dropped, so neither a reconnect nor a
1279
- // later adopt can mistake the new session for this child; the panel belongs to that other
1280
- // session from now on, which is why it is forgotten here and never closed.
1281
- 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.
1282
1817
  this.#clearSettledPanelClose(id);
1283
1818
  this.#projectors.delete(id);
1284
1819
  this.#attachments.delete(id);
@@ -1287,8 +1822,12 @@ export class SubagentManager {
1287
1822
  // relaunch mistake the old connection for the new runtime.
1288
1823
  this.#deps.bridge.disconnect(id);
1289
1824
  await commitLease(this.#deps.registry, id, { type: "left-session" });
1290
- if (retired !== undefined)
1291
- this.#deps.tokens?.forget(retired);
1825
+ this.#recordSettlement({
1826
+ childId: id,
1827
+ status: "failed",
1828
+ message: "Child left the delegated session.",
1829
+ workVersion,
1830
+ });
1292
1831
  return;
1293
1832
  }
1294
1833
  // A child's own quit is a lifecycle notice, not state: the disconnect that follows says
@@ -1386,9 +1925,6 @@ export class SubagentManager {
1386
1925
  registry: this.#deps.registry,
1387
1926
  hosts: directory,
1388
1927
  processes: { alive: processAlive },
1389
- forgetRuntime: (runtimeIdentity) => {
1390
- this.#deps.tokens?.forget(runtimeIdentity);
1391
- },
1392
1928
  });
1393
1929
  const result = await lease.ready();
1394
1930
  if (!result.ok)