@nuvin/session 0.4.0-rc.10 → 0.4.0-rc.12

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 (37) hide show
  1. package/dist/chunk-4AOFJGGN.js +141 -0
  2. package/dist/{chunk-U6V6IQJR.js → chunk-B54P6RLO.js} +158 -10
  3. package/dist/{chunk-Y7JPSYTH.js → chunk-STY4IJ7W.js} +43 -1
  4. package/dist/client/control-center-socket.d.ts +14 -0
  5. package/dist/client/control-center-socket.d.ts.map +1 -0
  6. package/dist/client/daemon-client.d.ts.map +1 -1
  7. package/dist/client/data-client.d.ts +2 -1
  8. package/dist/client/data-client.d.ts.map +1 -1
  9. package/dist/client/http-data-client.d.ts.map +1 -1
  10. package/dist/client/index.js +209 -5
  11. package/dist/client/session-client.d.ts +4 -2
  12. package/dist/client/session-client.d.ts.map +1 -1
  13. package/dist/controller/agent-channel.d.ts +7 -2
  14. package/dist/controller/agent-channel.d.ts.map +1 -1
  15. package/dist/controller/index.js +394 -65
  16. package/dist/controller/session-controller.d.ts +25 -5
  17. package/dist/controller/session-controller.d.ts.map +1 -1
  18. package/dist/protocol/control-center.d.ts +141 -0
  19. package/dist/protocol/control-center.d.ts.map +1 -0
  20. package/dist/protocol/data-rpc.d.ts +5 -0
  21. package/dist/protocol/data-rpc.d.ts.map +1 -1
  22. package/dist/protocol/index.d.ts +2 -0
  23. package/dist/protocol/index.d.ts.map +1 -1
  24. package/dist/protocol/index.js +12 -2
  25. package/dist/protocol/submission-recovery.d.ts +6 -0
  26. package/dist/protocol/submission-recovery.d.ts.map +1 -0
  27. package/dist/protocol/types.d.ts +71 -6
  28. package/dist/protocol/types.d.ts.map +1 -1
  29. package/dist/protocol/version.d.ts +1 -1
  30. package/dist/state/index.js +9 -1
  31. package/dist/state/messages.d.ts +6 -2
  32. package/dist/state/messages.d.ts.map +1 -1
  33. package/dist/state/session.d.ts +4 -1
  34. package/dist/state/session.d.ts.map +1 -1
  35. package/dist/test-utils/index.js +2 -2
  36. package/package.json +2 -2
  37. package/dist/chunk-5PHEC5GZ.js +0 -85
@@ -2,7 +2,9 @@ import {
2
2
  resolveGrantDir
3
3
  } from "../chunk-UVJIG4DT.js";
4
4
  import {
5
+ appendRecallEntry,
5
6
  asJsonObject,
7
+ copyRecallState,
6
8
  createMessageStateFromMessages,
7
9
  createSessionViewState,
8
10
  isToolAutoApprovedForMode,
@@ -11,7 +13,7 @@ import {
11
13
  sanitizeCoordinatorDeliveryText,
12
14
  scopedToolKey,
13
15
  summarizeToolCallInput
14
- } from "../chunk-U6V6IQJR.js";
16
+ } from "../chunk-B54P6RLO.js";
15
17
 
16
18
  // src/controller/agent-channel.ts
17
19
  import { EventEmitter } from "events";
@@ -27,12 +29,22 @@ var AgentChannel = class {
27
29
  decider = null;
28
30
  questionHandler = null;
29
31
  // ---------- Agent-side (publishers) ----------
30
- publishEvent(event, scope) {
32
+ publishEvent(event, scope, executionId) {
31
33
  this.emitter.emit("event", {
32
34
  event,
35
+ ...executionId === void 0 ? {} : { executionId },
33
36
  scope: scope === void 0 ? void 0 : toWireSessionScope(scope)
34
37
  });
35
38
  }
39
+ publishRecallEntry(entry) {
40
+ this.emitter.emit("recall-entry", entry);
41
+ }
42
+ onRecallEntry(listener) {
43
+ this.emitter.on("recall-entry", listener);
44
+ return () => {
45
+ this.emitter.off("recall-entry", listener);
46
+ };
47
+ }
36
48
  publishSessionLoaded(loaded) {
37
49
  this.emitter.emit("session-loaded", loaded);
38
50
  }
@@ -80,6 +92,7 @@ var AgentChannel = class {
80
92
  };
81
93
 
82
94
  // src/controller/session-controller.ts
95
+ import { randomUUID } from "crypto";
83
96
  import {
84
97
  isAbortError,
85
98
  isAgentOnlyCoordinatorNotice
@@ -109,18 +122,95 @@ function preservesVerbatimCoordinatorInput(submission) {
109
122
  return submission?.origin === "coordinator" && (submission.deliveryKind === "subagent-report" || isAgentOnlyCoordinatorNotice(submission));
110
123
  }
111
124
  var MAX_AUTO_COMPACTION_CHAIN = 10;
125
+ function substantiveEventActivity(event) {
126
+ if (event.type === "assistant_chunk")
127
+ return event.chunk.text?.length ? "assistant-text" : void 0;
128
+ if (event.type === "reasoning_chunk") return event.text.length ? "reasoning" : void 0;
129
+ if (event.type === "tool_call" || event.type === "tool_use_chunk" || event.type === "tool_use_message")
130
+ return "tool";
131
+ const content = event.type === "model_response" ? event.response.content : event.type === "assistant_message" || event.type === "reasoning_message" || event.type === "final_message" ? event.message.content : [];
132
+ for (const block of content) {
133
+ if (block.type === "text" && block.text.length > 0) return "assistant-text";
134
+ if (block.type === "tool_use") return "tool";
135
+ if (block.type === "anthropic_thinking" && block.thinking.length > 0 || block.type === "anthropic_redacted_thinking" && block.data.length > 0 || block.type === "openai_reasoning" && (block.text?.length || block.encryptedContent?.length || block.summary.some((part) => part.text.length > 0)))
136
+ return "reasoning";
137
+ }
138
+ return void 0;
139
+ }
112
140
  function createSessionController(deps) {
113
141
  const now = deps.now ?? Date.now;
114
142
  const errorDetail = deps.errorDetail ?? ((error) => error instanceof Error ? error.message : String(error));
115
143
  let serverCommands = deps.serverCommands ?? [];
144
+ let submissionRecovery = deps.submissionRecovery;
116
145
  let compaction = deps.compaction;
117
146
  let guardPrompt = deps.guardPrompt;
118
147
  let autoCompactionChain = 0;
119
148
  let state = createSessionViewState();
149
+ const activeHumans = [];
150
+ const hostHumanReservations = /* @__PURE__ */ new Set();
151
+ const matchesIdentity = (key) => {
152
+ const identity = state.recall.identity;
153
+ return identity !== null && identity.profile === key.identity.profile && identity.workspace === key.identity.workspace && identity.sessionId === key.identity.sessionId;
154
+ };
155
+ const inputText = (input) => typeof input === "string" ? input : typeof input.content === "string" ? input.content : (input.content ?? []).filter((block) => block.type === "text").map((block) => block.text).join("");
156
+ const humanId = (submission) => submission?.origin === "user" ? submission.humanSubmission?.submissionId : void 0;
157
+ const admitHuman = (input, opts) => {
158
+ if (opts.human !== void 0 && !matchesIdentity({
159
+ identity: opts.human.identity,
160
+ submissionId: opts.human.submission.submissionId
161
+ }))
162
+ throw new Error("Human submission identity mismatch");
163
+ if (opts.submission !== void 0 && opts.submission.origin !== "user") {
164
+ return {
165
+ ...opts,
166
+ ...opts.human === void 0 ? {} : { human: structuredClone(opts.human) }
167
+ };
168
+ }
169
+ const humanSubmission = structuredClone(
170
+ opts.human?.submission ?? (opts.submission?.origin === "user" ? opts.submission.humanSubmission : void 0) ?? {
171
+ submissionId: randomUUID(),
172
+ rawText: inputText(input)
173
+ }
174
+ );
175
+ if (typeof humanSubmission.submissionId !== "string" || humanSubmission.submissionId.trim().length === 0 || typeof humanSubmission.rawText !== "string" || Object.keys(humanSubmission).some((key) => key !== "submissionId" && key !== "rawText"))
176
+ throw new Error("Invalid human submission");
177
+ const previous = [
178
+ ...state.recall.entries.map((entry) => ({
179
+ submissionId: entry.key.submissionId,
180
+ rawText: entry.rawText
181
+ })),
182
+ ...activeHumans,
183
+ ...[...hostHumanReservations].filter(
184
+ (entry) => matchesIdentity({
185
+ identity: entry.identity,
186
+ submissionId: entry.submission.submissionId
187
+ })
188
+ ).map((entry) => entry.submission),
189
+ ...queue.flatMap(
190
+ (entry) => entry.opts.submission?.origin === "user" && entry.opts.submission.humanSubmission !== void 0 ? [entry.opts.submission.humanSubmission] : []
191
+ )
192
+ ].find((entry) => entry.submissionId === humanSubmission.submissionId);
193
+ if (previous !== void 0 && previous.rawText !== humanSubmission.rawText)
194
+ throw new Error("Conflicting submission raw text");
195
+ return {
196
+ ...opts,
197
+ submission: { origin: "user", humanSubmission },
198
+ ...opts.human === void 0 ? {} : { human: structuredClone(opts.human) }
199
+ };
200
+ };
120
201
  let seq = 0;
121
202
  let approvalMode = deps.approvalMode ?? "readOnly";
122
203
  let abortController = null;
123
204
  let activeTurnPromise = null;
205
+ let executionOwner = null;
206
+ let recoveryPromise = null;
207
+ const recoveryResults = /* @__PURE__ */ new Map();
208
+ const recoveryKey = (key) => JSON.stringify([
209
+ key.identity.profile,
210
+ key.identity.workspace,
211
+ key.identity.sessionId,
212
+ key.submissionId
213
+ ]);
124
214
  let closePromise = null;
125
215
  let closing = false;
126
216
  let detached = false;
@@ -140,7 +230,7 @@ function createSessionController(deps) {
140
230
  let draining = false;
141
231
  const emit = (body, explicitTurnId) => {
142
232
  seq += 1;
143
- const turnId = explicitTurnId ?? activeTurnId;
233
+ const turnId = explicitTurnId === null ? void 0 : explicitTurnId ?? activeTurnId;
144
234
  const event = Object.assign(
145
235
  {},
146
236
  body,
@@ -178,15 +268,33 @@ function createSessionController(deps) {
178
268
  }))
179
269
  });
180
270
  };
181
- const unsubscribeEvents = deps.channel.onEvent(({ event, scope }) => {
271
+ const unsubscribeEvents = deps.channel.onEvent(({ event, scope, executionId }) => {
272
+ const owner = executionId === void 0 ? void 0 : executionOwner;
273
+ if (executionId !== void 0) {
274
+ if (!owner || owner.control.executionId !== executionId || owner.phase === "recovering" || owner.phase === "finished")
275
+ return;
276
+ const substantive = substantiveEventActivity(event);
277
+ if (substantive && !owner.control.claimActivity(substantive)) return;
278
+ }
182
279
  if (event.type === "tool_call") return;
183
- emit({ type: "agent-event", event, ...scope ? { scope } : {} });
280
+ emit(
281
+ { type: "agent-event", event, ...scope ? { scope } : {} },
282
+ executionId === void 0 ? void 0 : owner?.turnId ?? null
283
+ );
284
+ });
285
+ const unsubscribeRecall = deps.channel.onRecallEntry((entry) => {
286
+ const recall = appendRecallEntry(state.recall, entry);
287
+ if (recall !== state.recall) emit({ type: "recall-state", recall });
184
288
  });
185
289
  const unsubscribeLoaded = deps.channel.onSessionLoaded((loaded) => {
290
+ copyRecallState(loaded.recall);
291
+ if (loaded.recall.identity !== null && loaded.recall.identity.sessionId !== loaded.id)
292
+ throw new Error("Loaded recall identity mismatch");
186
293
  if (persistenceInFlight.size > 0) {
187
294
  return;
188
295
  }
189
296
  queue.length = 0;
297
+ activeHumans.length = 0;
190
298
  alwaysApproved.clear();
191
299
  abortController?.abort(new Error("Session loaded; the active turn was cancelled."));
192
300
  rejectAllPending("Session loaded; pending interactions were cancelled.");
@@ -200,11 +308,15 @@ function createSessionController(deps) {
200
308
  ...createSessionViewState(),
201
309
  busy: hostBusyCount > 0,
202
310
  meta: state.meta,
203
- messages: createMessageStateFromMessages(loaded.messages)
311
+ messages: createMessageStateFromMessages(loaded.messages),
312
+ recall: loaded.recall
204
313
  }
205
314
  });
206
315
  });
207
316
  deps.channel.setToolDecider((request) => {
317
+ if (request.executionId !== void 0 && (executionOwner?.control.executionId !== request.executionId || !executionOwner.control.claimActivity("tool"))) {
318
+ return { action: "reject", reason: "Originating execution is no longer active." };
319
+ }
208
320
  const { toolCall, agentId, toolCallPath, dirAccess, bashPolicy } = request;
209
321
  const scope = toolCallPath === void 0 ? void 0 : { agentId, toolCallPath: [...toolCallPath] };
210
322
  const approvalKey = request.approvalKey ?? `${agentId}:${toolCall.name}`;
@@ -434,17 +546,9 @@ function createSessionController(deps) {
434
546
  if (dropped) emitTurnStatus();
435
547
  if (!next) return;
436
548
  const coordinatorOwned = next.opts.submission?.origin === "coordinator";
437
- if (!coordinatorOwned && !next.opts.silent) {
438
- emit(
439
- {
440
- type: "user-message",
441
- text: next.opts.displayText,
442
- ...next.opts.attachmentLabels?.length ? { attachmentLabels: next.opts.attachmentLabels } : {}
443
- },
444
- next.opts.turnId
445
- );
446
- }
447
549
  const turn = startTrackedTurn(next.input, {
550
+ echo: coordinatorOwned || next.opts.silent ? void 0 : next.opts,
551
+ human: next.opts.human,
448
552
  turnId: next.opts.turnId,
449
553
  allowSteering: allowsSteering(next.opts),
450
554
  submission: next.opts.submission ?? { origin: "user" },
@@ -453,26 +557,80 @@ function createSessionController(deps) {
453
557
  });
454
558
  if (next.opts.waitFor === "acceptance") observeAcceptedTurn(turn, next.opts.turnId);
455
559
  }
560
+ function sealExecution(owner) {
561
+ owner.phase = "finished";
562
+ if (owner.key && !recoveryResults.has(recoveryKey(owner.key)))
563
+ recoveryResults.set(
564
+ recoveryKey(owner.key),
565
+ Promise.resolve({ status: "not-retracted", target: owner.key, reason: "already-finished" })
566
+ );
567
+ }
456
568
  function finalizeTurn(controller, reason) {
457
- if (abortController !== controller) return false;
458
- abortController = null;
569
+ if (abortController !== controller || executionOwner?.phase === "recovering") return false;
570
+ const endingOwner = executionOwner;
571
+ if (endingOwner?.controller === controller) sealExecution(endingOwner);
459
572
  rejectAllPending(reason);
573
+ if (abortController !== controller || executionOwner !== endingOwner) return false;
574
+ executionOwner = null;
575
+ abortController = null;
576
+ activeHumans.length = 0;
577
+ emit({ type: "active-human-submission", key: null });
460
578
  emitTurnStatus();
461
579
  return true;
462
580
  }
463
581
  async function runTurn(input, opts) {
464
- const controller = new AbortController();
582
+ if (opts.submission.origin === "user" && opts.submission.humanSubmission !== void 0)
583
+ activeHumans.push(opts.submission.humanSubmission);
584
+ const controller = opts.enclosingExecution?.controller ?? new AbortController();
585
+ const openingHuman = opts.human?.submission ?? (opts.submission.origin === "user" ? opts.submission.humanSubmission : void 0);
586
+ const identity = opts.human?.identity ?? state.recall.identity;
587
+ let resolveSettlement;
588
+ let rejectSettlement;
589
+ const settled = new Promise((resolve, reject) => {
590
+ resolveSettlement = resolve;
591
+ rejectSettlement = reject;
592
+ });
593
+ void settled.catch(() => {
594
+ });
595
+ const owner = {
596
+ controller,
597
+ turnId: opts.correlationTurnId ?? opts.turnId,
598
+ key: openingHuman && identity ? { identity: { ...identity }, submissionId: openingHuman.submissionId } : null,
599
+ rawText: openingHuman?.rawText ?? "",
600
+ phase: "eligible",
601
+ settled,
602
+ settle() {
603
+ if (owner.abortFailure !== void 0) rejectSettlement(owner.abortFailure);
604
+ else resolveSettlement();
605
+ },
606
+ control: Object.freeze({
607
+ executionId: randomUUID(),
608
+ claimActivity() {
609
+ if (executionOwner !== owner || owner.phase === "recovering" || owner.phase === "finished")
610
+ return false;
611
+ owner.phase = "activity-started";
612
+ return true;
613
+ }
614
+ })
615
+ };
616
+ executionOwner = owner;
465
617
  const abortFromCaller = () => controller.abort(opts.signal?.reason);
466
618
  if (opts.signal !== void 0) {
467
619
  if (opts.signal.aborted) abortFromCaller();
468
620
  else opts.signal.addEventListener("abort", abortFromCaller, { once: true });
469
621
  }
622
+ const steeringClosed = () => owner.phase === "recovering" || owner.phase === "finished";
470
623
  const drainSteering = async () => {
471
- if (abortController !== controller || controller.signal.aborted) return [];
624
+ if (abortController !== controller || controller.signal.aborted || steeringClosed())
625
+ return [];
472
626
  const barrierIndex = queue.findIndex((entry) => entry.delivery === "next-turn");
473
627
  const steeringCount = barrierIndex === -1 ? queue.length : barrierIndex;
474
628
  if (steeringCount === 0) return [];
475
629
  const drained = queue.splice(0, steeringCount);
630
+ for (const entry of drained) {
631
+ if (entry.opts.submission?.origin === "user" && entry.opts.submission.humanSubmission !== void 0)
632
+ activeHumans.push(entry.opts.submission.humanSubmission);
633
+ }
476
634
  emitTurnStatus();
477
635
  const accepted = [];
478
636
  for (const entry of drained) {
@@ -489,7 +647,8 @@ function createSessionController(deps) {
489
647
  emit({ type: "error", message: guarded.message });
490
648
  }
491
649
  }
492
- if (abortController !== controller || controller.signal.aborted) return [];
650
+ if (abortController !== controller || controller.signal.aborted || steeringClosed())
651
+ return [];
493
652
  const current = accepted.filter((entry) => !entry.opts.signal?.aborted);
494
653
  for (const entry of current) {
495
654
  if (entry.opts.submission?.origin === "coordinator") {
@@ -502,6 +661,7 @@ function createSessionController(deps) {
502
661
  emit({
503
662
  type: "user-message",
504
663
  text: entry.opts.displayText,
664
+ ...humanId(entry.opts.submission) === void 0 ? {} : { submissionId: humanId(entry.opts.submission) },
505
665
  ...entry.opts.attachmentLabels?.length ? { attachmentLabels: entry.opts.attachmentLabels } : {},
506
666
  // The drain stamps event.at at delivery; steeredAt preserves when
507
667
  // the client actually queued the input — the HUD's "time since
@@ -525,6 +685,18 @@ function createSessionController(deps) {
525
685
  let terminalStatus = "completed";
526
686
  let terminalMessage;
527
687
  try {
688
+ emit({ type: "active-human-submission", key: owner.key });
689
+ if (opts.echo) {
690
+ emit(
691
+ {
692
+ type: "user-message",
693
+ text: opts.echo.displayText,
694
+ ...humanId(opts.submission) === void 0 ? {} : { submissionId: humanId(opts.submission) },
695
+ ...opts.echo.attachmentLabels?.length ? { attachmentLabels: opts.echo.attachmentLabels } : {}
696
+ },
697
+ opts.turnId
698
+ );
699
+ }
528
700
  emitTurnStatus();
529
701
  if (!opts.systemInitiated) autoCompactionChain = 0;
530
702
  compaction?.setEnabled(true);
@@ -569,8 +741,13 @@ function createSessionController(deps) {
569
741
  const compactionSend = (text) => runTurn(text, {
570
742
  systemInitiated: true,
571
743
  transientInput: true,
744
+ correlationTurnId: owner.turnId,
745
+ enclosingExecution: owner,
572
746
  allowSteering: opts.allowSteering,
573
- submission: opts.submission,
747
+ submission: {
748
+ origin: "synthetic",
749
+ syntheticKind: "compaction"
750
+ },
574
751
  ...opts.signal === void 0 ? {} : { signal: opts.signal }
575
752
  });
576
753
  const compactionIo = {
@@ -579,8 +756,14 @@ function createSessionController(deps) {
579
756
  onInfo: (message) => emit({ type: "info", message }),
580
757
  onError: (message) => emit({ type: "error", message })
581
758
  };
759
+ const sealForMaintenance = () => {
760
+ if (owner.phase === "recovering") return false;
761
+ sealExecution(owner);
762
+ if (executionOwner === owner) emit({ type: "active-human-submission", key: null });
763
+ return true;
764
+ };
582
765
  const runCompaction = async () => {
583
- if (!compaction) return;
766
+ if (!compaction || owner.phase === "recovering") return;
584
767
  if (autoCompactionChain >= MAX_AUTO_COMPACTION_CHAIN) {
585
768
  compaction.setEnabled(false);
586
769
  emit({
@@ -594,16 +777,17 @@ function createSessionController(deps) {
594
777
  };
595
778
  try {
596
779
  const result = await deps.agent.send(finalInput, {
780
+ execution: owner.control,
597
781
  streaming: true,
598
782
  signal: controller.signal,
599
783
  submission: opts.submission,
600
784
  ...opts.transientInput === true ? { transientInput: true } : {},
601
785
  ...opts.allowSteering ? { drainSteering } : {}
602
- });
786
+ }).finally(owner.settle);
603
787
  if (typeof result === "object" && result !== null && "status" in result && result.status === "aborted") {
604
788
  terminalStatus = "aborted";
605
789
  }
606
- if (compaction && !controller.signal.aborted) {
790
+ if (sealForMaintenance() && compaction && !controller.signal.aborted) {
607
791
  if (compaction.consumePendingAction() === "compact") {
608
792
  await runCompaction();
609
793
  }
@@ -613,7 +797,7 @@ function createSessionController(deps) {
613
797
  terminalStatus = "aborted";
614
798
  }
615
799
  try {
616
- const recovered = compaction ? compaction.messagesFromError(error) : null;
800
+ const recovered = sealForMaintenance() && compaction && !controller.signal.aborted ? compaction.messagesFromError(error) : null;
617
801
  if (recovered) {
618
802
  deps.agent.messages = recovered;
619
803
  await runCompaction();
@@ -629,37 +813,58 @@ function createSessionController(deps) {
629
813
  }
630
814
  }
631
815
  } finally {
816
+ owner.settle();
632
817
  opts.signal?.removeEventListener("abort", abortFromCaller);
633
- const ownedTurn = finalizeTurn(
634
- controller,
635
- "Turn ended before pending interactions could be resolved."
636
- );
637
- if (activePolicyDenial !== void 0) {
818
+ const ownedExecution = executionOwner === owner && abortController === controller;
819
+ const policyDenial = ownedExecution ? activePolicyDenial : void 0;
820
+ let ownedTurn = false;
821
+ if (opts.enclosingExecution !== void 0) {
822
+ if (ownedExecution) {
823
+ sealExecution(owner);
824
+ executionOwner = opts.enclosingExecution;
825
+ abortController = opts.enclosingExecution.controller;
826
+ activeTurnId = opts.enclosingExecution.turnId;
827
+ }
828
+ } else if (ownedExecution) {
829
+ ownedTurn = finalizeTurn(
830
+ controller,
831
+ "Turn ended before pending interactions could be resolved."
832
+ );
833
+ }
834
+ if (controller.signal.aborted) terminalStatus = "aborted";
835
+ if (policyDenial !== void 0) {
638
836
  terminalStatus = "failed";
639
- terminalMessage = activePolicyDenial;
837
+ terminalMessage = policyDenial;
640
838
  }
641
839
  if (opts.turnId !== void 0) {
642
840
  emit(
643
841
  {
644
842
  type: "turn-completed",
645
843
  status: terminalStatus,
646
- ...activePolicyDenial !== void 0 ? { reason: "policy-denied" } : {},
844
+ ...policyDenial !== void 0 ? { reason: "policy-denied" } : {},
647
845
  ...terminalMessage !== void 0 ? { message: terminalMessage } : {}
648
846
  },
649
847
  opts.turnId
650
848
  );
651
849
  }
652
- if (!opts.systemInitiated) activePolicyDenial = void 0;
653
- activeTurnId = previousTurnId;
850
+ if (opts.enclosingExecution === void 0 && (executionOwner === owner || ownedTurn && executionOwner === null)) {
851
+ if (!opts.systemInitiated) activePolicyDenial = void 0;
852
+ activeTurnId = previousTurnId;
853
+ }
654
854
  if (ownedTurn) startNextQueuedSubmission();
655
855
  }
656
856
  }
657
857
  function startTrackedTurn(input, opts) {
658
- let tracked;
659
- tracked = runTurn(input, opts).finally(() => {
660
- if (activeTurnPromise === tracked) activeTurnPromise = null;
858
+ let resolve;
859
+ let reject;
860
+ const tracked = new Promise((done, fail) => {
861
+ resolve = done;
862
+ reject = fail;
661
863
  });
662
864
  activeTurnPromise = tracked;
865
+ void runTurn(input, opts).then(resolve, reject).finally(() => {
866
+ if (activeTurnPromise === tracked) activeTurnPromise = null;
867
+ });
663
868
  return tracked;
664
869
  }
665
870
  function observeAcceptedTurn(turn, turnId) {
@@ -672,29 +877,142 @@ function createSessionController(deps) {
672
877
  detached = true;
673
878
  unsubscribeEvents();
674
879
  unsubscribeLoaded();
880
+ unsubscribeRecall();
675
881
  deps.channel.setToolDecider(null);
676
882
  deps.channel.setQuestionHandler(null);
677
883
  listeners.clear();
678
884
  abortListeners.clear();
679
885
  }
886
+ function cancelRecoveryOwner(owner) {
887
+ queue.length = 0;
888
+ const reason = new Error("User aborted the current turn.");
889
+ for (const listener of abortListeners) {
890
+ try {
891
+ listener(reason, "cancel");
892
+ } catch (error) {
893
+ owner.abortFailure = new AggregateError(
894
+ [
895
+ ...owner.abortFailure instanceof AggregateError ? owner.abortFailure.errors : [],
896
+ error
897
+ ],
898
+ "Interactive cancellation failed"
899
+ );
900
+ }
901
+ }
902
+ owner.controller.abort(reason);
903
+ rejectAllPending(reason.message);
904
+ }
905
+ function abort(intent = "cancel") {
906
+ const reason = new Error("User aborted the current turn.");
907
+ for (const listener of abortListeners) {
908
+ try {
909
+ listener(reason, intent);
910
+ } catch {
911
+ }
912
+ }
913
+ queue.length = 0;
914
+ abortController?.abort(reason);
915
+ rejectAllPending("User aborted the current turn.");
916
+ }
680
917
  return {
681
- abort(intent = "cancel") {
682
- const reason = new Error("User aborted the current turn.");
683
- for (const listener of abortListeners) {
684
- try {
685
- listener(reason, intent);
686
- } catch {
687
- }
918
+ abortWithRecovery(request) {
919
+ request = structuredClone(request);
920
+ const known = request.target === null ? void 0 : recoveryResults.get(recoveryKey(request.target));
921
+ if (known) return known;
922
+ const owner = executionOwner;
923
+ if (request.target === null && (owner?.key == null || owner.phase === "finished")) {
924
+ abort();
925
+ return Promise.resolve({
926
+ status: "not-retracted",
927
+ target: null,
928
+ reason: "no-active-human"
929
+ });
688
930
  }
689
- queue.length = 0;
690
- abortController?.abort(reason);
691
- rejectAllPending("User aborted the current turn.");
931
+ if (!owner?.key || !request.target || !matchesIdentity(request.target) || recoveryKey(owner.key) !== recoveryKey(request.target)) {
932
+ return Promise.resolve({
933
+ status: "not-retracted",
934
+ target: request.target,
935
+ reason: "target-mismatch"
936
+ });
937
+ }
938
+ if (owner.phase !== "eligible") {
939
+ const result = Promise.resolve({
940
+ status: "not-retracted",
941
+ target: request.target,
942
+ reason: owner.phase === "finished" ? "already-finished" : "activity-started"
943
+ });
944
+ recoveryResults.set(recoveryKey(owner.key), result);
945
+ if (owner.phase === "activity-started") abort();
946
+ return result;
947
+ }
948
+ owner.phase = "recovering";
949
+ let resolve;
950
+ const pending = new Promise((done) => {
951
+ resolve = done;
952
+ });
953
+ recoveryResults.set(recoveryKey(owner.key), pending);
954
+ recoveryPromise = pending;
955
+ const persistenceAvailable = submissionRecovery !== void 0;
956
+ let work;
957
+ try {
958
+ if (!submissionRecovery) throw new Error("Submission recovery persistence is unavailable");
959
+ work = submissionRecovery.retract(
960
+ { key: owner.key, rawText: owner.rawText, executionId: owner.control.executionId },
961
+ owner.settled
962
+ );
963
+ } catch (error) {
964
+ work = Promise.reject(error);
965
+ }
966
+ cancelRecoveryOwner(owner);
967
+ const key = owner.key;
968
+ void work.then(
969
+ async () => {
970
+ await owner.settled;
971
+ const result = { status: "retracted", key, rawText: owner.rawText };
972
+ const removedMessageIds = state.messages.messages.filter((row) => row.role === "user" && row.submissionId === key.submissionId).map((row) => row.id);
973
+ emit({ type: "submission-retracted", result, removedMessageIds });
974
+ return result;
975
+ },
976
+ async (error) => {
977
+ await owner.settled.catch(() => {
978
+ });
979
+ return {
980
+ status: "failed",
981
+ target: key,
982
+ code: owner.abortFailure !== void 0 ? "abort-failed" : persistenceAvailable ? "persistence-failed" : "persistence-unavailable",
983
+ message: errorDetail(owner.abortFailure ?? error)
984
+ };
985
+ }
986
+ ).catch(
987
+ (error) => ({
988
+ status: "failed",
989
+ target: key,
990
+ code: owner.abortFailure !== void 0 ? "abort-failed" : "persistence-failed",
991
+ message: error instanceof Error ? error.message : "Recovery publication failed"
992
+ })
993
+ ).finally(() => {
994
+ owner.phase = "finished";
995
+ finalizeTurn(owner.controller, "Recovery ended.");
996
+ if (recoveryPromise === pending) recoveryPromise = null;
997
+ startNextQueuedSubmission();
998
+ }).then(
999
+ resolve,
1000
+ (error) => resolve({
1001
+ status: "failed",
1002
+ target: key,
1003
+ code: "persistence-failed",
1004
+ message: error instanceof Error ? error.message : "Recovery finalization failed"
1005
+ })
1006
+ );
1007
+ return pending;
692
1008
  },
1009
+ abort,
693
1010
  onAbort(listener) {
694
1011
  abortListeners.add(listener);
695
1012
  return () => abortListeners.delete(listener);
696
1013
  },
697
1014
  attachHostHooks(hooks) {
1015
+ if (hooks.submissionRecovery) submissionRecovery = hooks.submissionRecovery;
698
1016
  if (hooks.compaction) compaction = hooks.compaction;
699
1017
  if (hooks.guardPrompt) guardPrompt = hooks.guardPrompt;
700
1018
  },
@@ -717,8 +1035,10 @@ function createSessionController(deps) {
717
1035
  appendInfo(message) {
718
1036
  emit({ type: "info", message });
719
1037
  },
720
- commitCompaction(messages) {
1038
+ commitCompaction({ messages, recall }) {
1039
+ copyRecallState(recall);
721
1040
  queue.length = 0;
1041
+ activeHumans.length = 0;
722
1042
  rejectAllPending("Session compacted; pending interactions were cancelled.");
723
1043
  alwaysApproved.clear();
724
1044
  emit({
@@ -728,7 +1048,8 @@ function createSessionController(deps) {
728
1048
  busy: abortController !== null || hostBusyCount > 0,
729
1049
  meta: state.meta,
730
1050
  mcp: state.mcp,
731
- messages: createMessageStateFromMessages([...messages])
1051
+ messages: createMessageStateFromMessages([...messages]),
1052
+ recall
732
1053
  }
733
1054
  });
734
1055
  },
@@ -741,12 +1062,13 @@ function createSessionController(deps) {
741
1062
  rejectAllPending("Session closed.");
742
1063
  if (shouldEmitTurnStatus) emitTurnStatus();
743
1064
  const active = activeTurnPromise;
744
- if (!active) {
1065
+ const recovery = recoveryPromise;
1066
+ if (!active && !recovery) {
745
1067
  detachController();
746
1068
  closePromise = Promise.resolve();
747
1069
  return closePromise;
748
1070
  }
749
- closePromise = active.then(detachController, detachController);
1071
+ closePromise = Promise.allSettled([active, recovery]).then(detachController);
750
1072
  return closePromise;
751
1073
  },
752
1074
  dequeue(queuedId) {
@@ -872,16 +1194,27 @@ function createSessionController(deps) {
872
1194
  emitTurnStatus();
873
1195
  }
874
1196
  },
875
- echoUserMessage(text, attachmentLabels) {
1197
+ reserveHumanSubmission(human) {
1198
+ admitHuman("", { displayText: "", human });
1199
+ const captured = structuredClone(human);
1200
+ hostHumanReservations.add(captured);
1201
+ return () => {
1202
+ hostHumanReservations.delete(captured);
1203
+ };
1204
+ },
1205
+ echoUserMessage(text, attachmentLabels, key) {
1206
+ if (key !== void 0 && !matchesIdentity(key)) return false;
876
1207
  emit({
877
1208
  type: "user-message",
878
1209
  text,
1210
+ ...key === void 0 ? {} : { submissionId: key.submissionId },
879
1211
  ...attachmentLabels?.length ? { attachmentLabels } : {},
880
1212
  // An echo emitted while a turn is in flight is a display-only
881
1213
  // interjection, never a turn opener — mark it like a drained steer so
882
1214
  // the WorkingStatusLine anchor and the persisted row both skip it.
883
1215
  ...abortController !== null || hostBusyCount > 0 ? { steeredAt: now() } : {}
884
1216
  });
1217
+ return true;
885
1218
  },
886
1219
  getSnapshot() {
887
1220
  return { seq, state, serverCommands };
@@ -927,11 +1260,13 @@ function createSessionController(deps) {
927
1260
  publishMcpState(mcpState) {
928
1261
  emit({ type: "mcp-state", state: mcpState });
929
1262
  },
930
- reset(options = {}) {
1263
+ reset(options) {
1264
+ copyRecallState(options.recall);
931
1265
  if (persistenceInFlight.size > 0) {
932
1266
  return;
933
1267
  }
934
1268
  queue.length = 0;
1269
+ activeHumans.length = 0;
935
1270
  if (options.preserveHostBusy !== true) hostBusyCount = 0;
936
1271
  abortController?.abort(new Error("Session reset."));
937
1272
  rejectAllPending("Session reset; pending interactions were cancelled.");
@@ -941,6 +1276,7 @@ function createSessionController(deps) {
941
1276
  type: "state-reset",
942
1277
  state: {
943
1278
  ...createSessionViewState(),
1279
+ recall: options.recall,
944
1280
  busy: hostBusyCount > 0,
945
1281
  meta: state.meta,
946
1282
  mcp: state.mcp
@@ -962,29 +1298,22 @@ function createSessionController(deps) {
962
1298
  async submit(input, opts) {
963
1299
  if (closing) throw new Error("Session is closing.");
964
1300
  opts.signal?.throwIfAborted();
1301
+ opts = admitHuman(input, opts);
965
1302
  if (abortController || hostBusyCount > 0) {
966
1303
  queue.push({
967
1304
  id: `q-${++queueCounter}`,
968
1305
  input,
969
1306
  opts,
970
- delivery: abortController !== null && allowsSteering(opts) ? "steering" : "next-turn",
1307
+ delivery: abortController !== null && executionOwner?.phase !== "recovering" && allowsSteering(opts) ? "steering" : "next-turn",
971
1308
  queuedAt: now()
972
1309
  });
973
1310
  emitTurnStatus();
974
1311
  return;
975
1312
  }
976
1313
  const coordinatorOwned = opts.submission?.origin === "coordinator";
977
- if (!coordinatorOwned && !opts.silent) {
978
- emit(
979
- {
980
- type: "user-message",
981
- text: opts.displayText,
982
- ...opts.attachmentLabels?.length ? { attachmentLabels: opts.attachmentLabels } : {}
983
- },
984
- opts.turnId
985
- );
986
- }
987
1314
  const turn = startTrackedTurn(input, {
1315
+ echo: coordinatorOwned || opts.silent ? void 0 : opts,
1316
+ human: opts.human,
988
1317
  turnId: opts.turnId,
989
1318
  allowSteering: allowsSteering(opts),
990
1319
  submission: opts.submission ?? { origin: "user" },