@nuvin/session 0.4.0-rc.1 → 0.4.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/chunk-4AOFJGGN.js +141 -0
  2. package/dist/{chunk-DTLHDYZC.js → chunk-4FXP5TSI.js} +249 -46
  3. package/dist/{chunk-X6DYNSV2.js → chunk-O3YF6Q77.js} +50 -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 +8 -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 +270 -8
  11. package/dist/client/session-client.d.ts +8 -2
  12. package/dist/client/session-client.d.ts.map +1 -1
  13. package/dist/client/websocket.d.ts +1 -1
  14. package/dist/client/websocket.d.ts.map +1 -1
  15. package/dist/controller/agent-channel.d.ts +7 -2
  16. package/dist/controller/agent-channel.d.ts.map +1 -1
  17. package/dist/controller/index.js +469 -82
  18. package/dist/controller/session-controller.d.ts +37 -9
  19. package/dist/controller/session-controller.d.ts.map +1 -1
  20. package/dist/protocol/background-work.d.ts +3 -1
  21. package/dist/protocol/background-work.d.ts.map +1 -1
  22. package/dist/protocol/control-center.d.ts +141 -0
  23. package/dist/protocol/control-center.d.ts.map +1 -0
  24. package/dist/protocol/data-rpc.d.ts +13 -1
  25. package/dist/protocol/data-rpc.d.ts.map +1 -1
  26. package/dist/protocol/index.d.ts +2 -0
  27. package/dist/protocol/index.d.ts.map +1 -1
  28. package/dist/protocol/index.js +14 -2
  29. package/dist/protocol/submission-recovery.d.ts +6 -0
  30. package/dist/protocol/submission-recovery.d.ts.map +1 -0
  31. package/dist/protocol/types.d.ts +159 -17
  32. package/dist/protocol/types.d.ts.map +1 -1
  33. package/dist/protocol/version.d.ts +1 -1
  34. package/dist/state/approvals.d.ts.map +1 -1
  35. package/dist/state/index.js +11 -3
  36. package/dist/state/messages.d.ts +30 -2
  37. package/dist/state/messages.d.ts.map +1 -1
  38. package/dist/state/session.d.ts +4 -1
  39. package/dist/state/session.d.ts.map +1 -1
  40. package/dist/state/tool-preview.d.ts.map +1 -1
  41. package/dist/state/tool-preview.js +1 -45
  42. package/dist/test-utils/index.js +2 -2
  43. package/package.json +2 -2
  44. 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-DTLHDYZC.js";
16
+ } from "../chunk-4FXP5TSI.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,8 +92,10 @@ var AgentChannel = class {
80
92
  };
81
93
 
82
94
  // src/controller/session-controller.ts
95
+ import { randomUUID } from "crypto";
83
96
  import {
84
- isAbortError
97
+ isAbortError,
98
+ isAgentOnlyCoordinatorNotice
85
99
  } from "@nuvin/agent-core/shared";
86
100
  function formatBashPolicyDenial(diagnostics) {
87
101
  const denied = diagnostics.operations.filter((operation) => operation.decision === "deny");
@@ -94,22 +108,109 @@ function formatBashPolicyDenial(diagnostics) {
94
108
  });
95
109
  return `Bash command rejected by policy: ${details.join("; ")}.`;
96
110
  }
111
+ function coordinatorInfoMeta(submission) {
112
+ if (submission.origin !== "coordinator") return {};
113
+ if (isAgentOnlyCoordinatorNotice(submission)) {
114
+ return {
115
+ deliveryKind: submission.deliveryKind,
116
+ transcriptVisibility: "agent-only"
117
+ };
118
+ }
119
+ return submission.subagentReport === void 0 ? {} : { subagentReport: structuredClone(submission.subagentReport) };
120
+ }
97
121
  function preservesVerbatimCoordinatorInput(submission) {
98
- return submission?.origin === "coordinator" && submission.deliveryKind === "subagent-report";
122
+ return submission?.origin === "coordinator" && (submission.deliveryKind === "subagent-report" || isAgentOnlyCoordinatorNotice(submission));
99
123
  }
100
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
+ }
101
140
  function createSessionController(deps) {
102
141
  const now = deps.now ?? Date.now;
103
142
  const errorDetail = deps.errorDetail ?? ((error) => error instanceof Error ? error.message : String(error));
104
143
  let serverCommands = deps.serverCommands ?? [];
144
+ let submissionRecovery = deps.submissionRecovery;
105
145
  let compaction = deps.compaction;
106
146
  let guardPrompt = deps.guardPrompt;
107
147
  let autoCompactionChain = 0;
108
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
+ };
109
201
  let seq = 0;
110
202
  let approvalMode = deps.approvalMode ?? "readOnly";
111
203
  let abortController = null;
112
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
+ ]);
113
214
  let closePromise = null;
114
215
  let closing = false;
115
216
  let detached = false;
@@ -129,7 +230,7 @@ function createSessionController(deps) {
129
230
  let draining = false;
130
231
  const emit = (body, explicitTurnId) => {
131
232
  seq += 1;
132
- const turnId = explicitTurnId ?? activeTurnId;
233
+ const turnId = explicitTurnId === null ? void 0 : explicitTurnId ?? activeTurnId;
133
234
  const event = Object.assign(
134
235
  {},
135
236
  body,
@@ -155,26 +256,45 @@ function createSessionController(deps) {
155
256
  }
156
257
  };
157
258
  const emitTurnStatus = () => {
259
+ const visible = queue.filter((q) => !isAgentOnlyCoordinatorNotice(q.opts.submission));
158
260
  emit({
159
261
  type: "turn-status",
160
262
  busy: abortController !== null || hostBusyCount > 0,
161
- queuedCount: queue.length,
162
- queued: queue.map((q) => ({
263
+ queuedCount: visible.length,
264
+ queued: visible.map((q) => ({
163
265
  id: q.id,
164
266
  displayText: q.opts.displayText,
165
267
  ...q.opts.attachmentLabels?.length ? { attachmentLabels: q.opts.attachmentLabels } : {}
166
268
  }))
167
269
  });
168
270
  };
169
- 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
+ }
170
279
  if (event.type === "tool_call") return;
171
- 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 });
172
288
  });
173
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");
174
293
  if (persistenceInFlight.size > 0) {
175
294
  return;
176
295
  }
177
296
  queue.length = 0;
297
+ activeHumans.length = 0;
178
298
  alwaysApproved.clear();
179
299
  abortController?.abort(new Error("Session loaded; the active turn was cancelled."));
180
300
  rejectAllPending("Session loaded; pending interactions were cancelled.");
@@ -188,11 +308,15 @@ function createSessionController(deps) {
188
308
  ...createSessionViewState(),
189
309
  busy: hostBusyCount > 0,
190
310
  meta: state.meta,
191
- messages: createMessageStateFromMessages(loaded.messages)
311
+ messages: createMessageStateFromMessages(loaded.messages),
312
+ recall: loaded.recall
192
313
  }
193
314
  });
194
315
  });
195
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
+ }
196
320
  const { toolCall, agentId, toolCallPath, dirAccess, bashPolicy } = request;
197
321
  const scope = toolCallPath === void 0 ? void 0 : { agentId, toolCallPath: [...toolCallPath] };
198
322
  const approvalKey = request.approvalKey ?? `${agentId}:${toolCall.name}`;
@@ -408,80 +532,150 @@ function createSessionController(deps) {
408
532
  return { ok: false, message: errorDetail(error) };
409
533
  }
410
534
  }
535
+ function allowsSteering(opts) {
536
+ return opts.turnId === void 0 || deps.headless !== true && opts.submission?.origin === "coordinator" && opts.submission.deliveryKind === "subagent-report";
537
+ }
411
538
  function startNextQueuedSubmission() {
412
539
  if (closing || abortController || hostBusyCount > 0) return;
413
- const next = queue.shift();
540
+ let next = queue.shift();
541
+ let dropped = false;
542
+ while (next?.opts.signal?.aborted) {
543
+ dropped = true;
544
+ next = queue.shift();
545
+ }
546
+ if (dropped) emitTurnStatus();
414
547
  if (!next) return;
415
548
  const coordinatorOwned = next.opts.submission?.origin === "coordinator";
416
- if (!coordinatorOwned && !next.opts.silent) {
417
- emit(
418
- {
419
- type: "user-message",
420
- text: next.opts.displayText,
421
- ...next.opts.attachmentLabels?.length ? { attachmentLabels: next.opts.attachmentLabels } : {}
422
- },
423
- next.opts.turnId
424
- );
425
- }
426
- void startTrackedTurn(next.input, {
549
+ const turn = startTrackedTurn(next.input, {
550
+ echo: coordinatorOwned || next.opts.silent ? void 0 : next.opts,
551
+ human: next.opts.human,
427
552
  turnId: next.opts.turnId,
428
- allowSteering: next.opts.turnId === void 0,
553
+ allowSteering: allowsSteering(next.opts),
429
554
  submission: next.opts.submission ?? { origin: "user" },
430
555
  ...coordinatorOwned ? { coordinatorDisplayText: next.opts.displayText } : {},
431
556
  ...next.opts.signal === void 0 ? {} : { signal: next.opts.signal }
432
557
  });
558
+ if (next.opts.waitFor === "acceptance") observeAcceptedTurn(turn, next.opts.turnId);
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
+ );
433
567
  }
434
568
  function finalizeTurn(controller, reason) {
435
- if (abortController !== controller) return false;
436
- abortController = null;
569
+ if (abortController !== controller || executionOwner?.phase === "recovering") return false;
570
+ const endingOwner = executionOwner;
571
+ if (endingOwner?.controller === controller) sealExecution(endingOwner);
437
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 });
438
578
  emitTurnStatus();
439
579
  return true;
440
580
  }
441
581
  async function runTurn(input, opts) {
442
- 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;
443
617
  const abortFromCaller = () => controller.abort(opts.signal?.reason);
444
618
  if (opts.signal !== void 0) {
445
619
  if (opts.signal.aborted) abortFromCaller();
446
620
  else opts.signal.addEventListener("abort", abortFromCaller, { once: true });
447
621
  }
622
+ const steeringClosed = () => owner.phase === "recovering" || owner.phase === "finished";
448
623
  const drainSteering = async () => {
449
- if (abortController !== controller || controller.signal.aborted) return [];
624
+ if (abortController !== controller || controller.signal.aborted || steeringClosed())
625
+ return [];
450
626
  const barrierIndex = queue.findIndex((entry) => entry.delivery === "next-turn");
451
627
  const steeringCount = barrierIndex === -1 ? queue.length : barrierIndex;
452
628
  if (steeringCount === 0) return [];
453
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
+ }
454
634
  emitTurnStatus();
455
635
  const accepted = [];
456
636
  for (const entry of drained) {
637
+ if (entry.opts.signal?.aborted) continue;
457
638
  const guarded = await guardQueuedSteering(entry.input);
639
+ if (entry.opts.signal?.aborted) continue;
458
640
  if (guarded.ok) {
459
641
  accepted.push({
460
642
  input: preservesVerbatimCoordinatorInput(entry.opts.submission) ? entry.input : guarded.prompt,
461
- opts: entry.opts
643
+ opts: entry.opts,
644
+ queuedAt: entry.queuedAt
462
645
  });
463
646
  } else {
464
647
  emit({ type: "error", message: guarded.message });
465
648
  }
466
649
  }
467
- if (abortController !== controller || controller.signal.aborted) return [];
468
- for (const entry of accepted) {
650
+ if (abortController !== controller || controller.signal.aborted || steeringClosed())
651
+ return [];
652
+ const current = accepted.filter((entry) => !entry.opts.signal?.aborted);
653
+ for (const entry of current) {
469
654
  if (entry.opts.submission?.origin === "coordinator") {
470
655
  emit({
471
656
  type: "info",
472
657
  message: sanitizeCoordinatorDeliveryText(entry.opts.displayText),
473
- ...entry.opts.submission.subagentReport === void 0 ? {} : { subagentReport: structuredClone(entry.opts.submission.subagentReport) }
658
+ ...coordinatorInfoMeta(entry.opts.submission)
474
659
  });
475
660
  } else if (!entry.opts.silent) {
476
661
  emit({
477
662
  type: "user-message",
478
663
  text: entry.opts.displayText,
479
- ...entry.opts.attachmentLabels?.length ? { attachmentLabels: entry.opts.attachmentLabels } : {}
664
+ ...humanId(entry.opts.submission) === void 0 ? {} : { submissionId: humanId(entry.opts.submission) },
665
+ ...entry.opts.attachmentLabels?.length ? { attachmentLabels: entry.opts.attachmentLabels } : {},
666
+ // The drain stamps event.at at delivery; steeredAt preserves when
667
+ // the client actually queued the input — the HUD's "time since
668
+ // last input" and the persisted steer marker both key off it.
669
+ steeredAt: entry.queuedAt
480
670
  });
481
671
  }
482
672
  }
483
- return accepted.map(
484
- (entry) => entry.opts.submission?.origin === "coordinator" ? { input: entry.input, submission: entry.opts.submission } : entry.input
673
+ return current.map(
674
+ (entry) => ({
675
+ input: entry.input,
676
+ submission: entry.opts.submission ?? { origin: "user" },
677
+ steeredAt: entry.queuedAt
678
+ })
485
679
  );
486
680
  };
487
681
  const previousTurnId = activeTurnId;
@@ -491,6 +685,18 @@ function createSessionController(deps) {
491
685
  let terminalStatus = "completed";
492
686
  let terminalMessage;
493
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
+ }
494
700
  emitTurnStatus();
495
701
  if (!opts.systemInitiated) autoCompactionChain = 0;
496
702
  compaction?.setEnabled(true);
@@ -498,7 +704,15 @@ function createSessionController(deps) {
498
704
  if (!opts.systemInitiated && guardPrompt) {
499
705
  let guarded;
500
706
  try {
707
+ if (opts.signal?.aborted) {
708
+ terminalStatus = "aborted";
709
+ return;
710
+ }
501
711
  guarded = await guardPrompt(input);
712
+ if (opts.signal?.aborted) {
713
+ terminalStatus = "aborted";
714
+ return;
715
+ }
502
716
  } catch (error) {
503
717
  terminalStatus = "failed";
504
718
  terminalMessage = errorDetail(error);
@@ -513,18 +727,27 @@ function createSessionController(deps) {
513
727
  }
514
728
  finalInput = preservesVerbatimCoordinatorInput(opts.submission) ? input : guarded.prompt;
515
729
  }
730
+ if (opts.signal?.aborted) {
731
+ terminalStatus = "aborted";
732
+ return;
733
+ }
516
734
  if (opts.coordinatorDisplayText !== void 0) {
517
735
  emit({
518
736
  type: "info",
519
737
  message: sanitizeCoordinatorDeliveryText(opts.coordinatorDisplayText),
520
- ...opts.submission.origin === "coordinator" && opts.submission.subagentReport !== void 0 ? { subagentReport: structuredClone(opts.submission.subagentReport) } : {}
738
+ ...coordinatorInfoMeta(opts.submission)
521
739
  });
522
740
  }
523
741
  const compactionSend = (text) => runTurn(text, {
524
742
  systemInitiated: true,
525
743
  transientInput: true,
744
+ correlationTurnId: owner.turnId,
745
+ enclosingExecution: owner,
526
746
  allowSteering: opts.allowSteering,
527
- submission: opts.submission,
747
+ submission: {
748
+ origin: "synthetic",
749
+ syntheticKind: "compaction"
750
+ },
528
751
  ...opts.signal === void 0 ? {} : { signal: opts.signal }
529
752
  });
530
753
  const compactionIo = {
@@ -533,8 +756,14 @@ function createSessionController(deps) {
533
756
  onInfo: (message) => emit({ type: "info", message }),
534
757
  onError: (message) => emit({ type: "error", message })
535
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
+ };
536
765
  const runCompaction = async () => {
537
- if (!compaction) return;
766
+ if (!compaction || owner.phase === "recovering") return;
538
767
  if (autoCompactionChain >= MAX_AUTO_COMPACTION_CHAIN) {
539
768
  compaction.setEnabled(false);
540
769
  emit({
@@ -548,16 +777,17 @@ function createSessionController(deps) {
548
777
  };
549
778
  try {
550
779
  const result = await deps.agent.send(finalInput, {
780
+ execution: owner.control,
551
781
  streaming: true,
552
782
  signal: controller.signal,
553
783
  submission: opts.submission,
554
784
  ...opts.transientInput === true ? { transientInput: true } : {},
555
785
  ...opts.allowSteering ? { drainSteering } : {}
556
- });
786
+ }).finally(owner.settle);
557
787
  if (typeof result === "object" && result !== null && "status" in result && result.status === "aborted") {
558
788
  terminalStatus = "aborted";
559
789
  }
560
- if (compaction && !controller.signal.aborted) {
790
+ if (sealForMaintenance() && compaction && !controller.signal.aborted) {
561
791
  if (compaction.consumePendingAction() === "compact") {
562
792
  await runCompaction();
563
793
  }
@@ -567,7 +797,7 @@ function createSessionController(deps) {
567
797
  terminalStatus = "aborted";
568
798
  }
569
799
  try {
570
- const recovered = compaction ? compaction.messagesFromError(error) : null;
800
+ const recovered = sealForMaintenance() && compaction && !controller.signal.aborted ? compaction.messagesFromError(error) : null;
571
801
  if (recovered) {
572
802
  deps.agent.messages = recovered;
573
803
  await runCompaction();
@@ -583,67 +813,206 @@ function createSessionController(deps) {
583
813
  }
584
814
  }
585
815
  } finally {
816
+ owner.settle();
586
817
  opts.signal?.removeEventListener("abort", abortFromCaller);
587
- const ownedTurn = finalizeTurn(
588
- controller,
589
- "Turn ended before pending interactions could be resolved."
590
- );
591
- 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) {
592
836
  terminalStatus = "failed";
593
- terminalMessage = activePolicyDenial;
837
+ terminalMessage = policyDenial;
594
838
  }
595
839
  if (opts.turnId !== void 0) {
596
840
  emit(
597
841
  {
598
842
  type: "turn-completed",
599
843
  status: terminalStatus,
600
- ...activePolicyDenial !== void 0 ? { reason: "policy-denied" } : {},
844
+ ...policyDenial !== void 0 ? { reason: "policy-denied" } : {},
601
845
  ...terminalMessage !== void 0 ? { message: terminalMessage } : {}
602
846
  },
603
847
  opts.turnId
604
848
  );
605
849
  }
606
- if (!opts.systemInitiated) activePolicyDenial = void 0;
607
- activeTurnId = previousTurnId;
850
+ if (opts.enclosingExecution === void 0 && (executionOwner === owner || ownedTurn && executionOwner === null)) {
851
+ if (!opts.systemInitiated) activePolicyDenial = void 0;
852
+ activeTurnId = previousTurnId;
853
+ }
608
854
  if (ownedTurn) startNextQueuedSubmission();
609
855
  }
610
856
  }
611
857
  function startTrackedTurn(input, opts) {
612
- let tracked;
613
- tracked = runTurn(input, opts).finally(() => {
614
- if (activeTurnPromise === tracked) activeTurnPromise = null;
858
+ let resolve;
859
+ let reject;
860
+ const tracked = new Promise((done, fail) => {
861
+ resolve = done;
862
+ reject = fail;
615
863
  });
616
864
  activeTurnPromise = tracked;
865
+ void runTurn(input, opts).then(resolve, reject).finally(() => {
866
+ if (activeTurnPromise === tracked) activeTurnPromise = null;
867
+ });
617
868
  return tracked;
618
869
  }
870
+ function observeAcceptedTurn(turn, turnId) {
871
+ void turn.catch((error) => {
872
+ emit({ type: "error", message: errorDetail(error) }, turnId);
873
+ });
874
+ }
619
875
  function detachController() {
620
876
  if (detached) return;
621
877
  detached = true;
622
878
  unsubscribeEvents();
623
879
  unsubscribeLoaded();
880
+ unsubscribeRecall();
624
881
  deps.channel.setToolDecider(null);
625
882
  deps.channel.setQuestionHandler(null);
626
883
  listeners.clear();
627
884
  abortListeners.clear();
628
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
+ }
629
917
  return {
630
- abort() {
631
- queue.length = 0;
632
- const reason = new Error("User aborted the current turn.");
633
- for (const listener of abortListeners) {
634
- try {
635
- listener(reason);
636
- } catch {
637
- }
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
+ });
930
+ }
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
+ });
638
937
  }
639
- abortController?.abort(reason);
640
- rejectAllPending("User aborted the current turn.");
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;
641
1008
  },
1009
+ abort,
642
1010
  onAbort(listener) {
643
1011
  abortListeners.add(listener);
644
1012
  return () => abortListeners.delete(listener);
645
1013
  },
646
1014
  attachHostHooks(hooks) {
1015
+ if (hooks.submissionRecovery) submissionRecovery = hooks.submissionRecovery;
647
1016
  if (hooks.compaction) compaction = hooks.compaction;
648
1017
  if (hooks.guardPrompt) guardPrompt = hooks.guardPrompt;
649
1018
  },
@@ -666,8 +1035,10 @@ function createSessionController(deps) {
666
1035
  appendInfo(message) {
667
1036
  emit({ type: "info", message });
668
1037
  },
669
- commitCompaction(messages) {
1038
+ commitCompaction({ messages, recall }) {
1039
+ copyRecallState(recall);
670
1040
  queue.length = 0;
1041
+ activeHumans.length = 0;
671
1042
  rejectAllPending("Session compacted; pending interactions were cancelled.");
672
1043
  alwaysApproved.clear();
673
1044
  emit({
@@ -677,7 +1048,8 @@ function createSessionController(deps) {
677
1048
  busy: abortController !== null || hostBusyCount > 0,
678
1049
  meta: state.meta,
679
1050
  mcp: state.mcp,
680
- messages: createMessageStateFromMessages([...messages])
1051
+ messages: createMessageStateFromMessages([...messages]),
1052
+ recall
681
1053
  }
682
1054
  });
683
1055
  },
@@ -690,12 +1062,13 @@ function createSessionController(deps) {
690
1062
  rejectAllPending("Session closed.");
691
1063
  if (shouldEmitTurnStatus) emitTurnStatus();
692
1064
  const active = activeTurnPromise;
693
- if (!active) {
1065
+ const recovery = recoveryPromise;
1066
+ if (!active && !recovery) {
694
1067
  detachController();
695
1068
  closePromise = Promise.resolve();
696
1069
  return closePromise;
697
1070
  }
698
- closePromise = active.then(detachController, detachController);
1071
+ closePromise = Promise.allSettled([active, recovery]).then(detachController);
699
1072
  return closePromise;
700
1073
  },
701
1074
  dequeue(queuedId) {
@@ -821,12 +1194,27 @@ function createSessionController(deps) {
821
1194
  emitTurnStatus();
822
1195
  }
823
1196
  },
824
- 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;
825
1207
  emit({
826
1208
  type: "user-message",
827
1209
  text,
828
- ...attachmentLabels?.length ? { attachmentLabels } : {}
1210
+ ...key === void 0 ? {} : { submissionId: key.submissionId },
1211
+ ...attachmentLabels?.length ? { attachmentLabels } : {},
1212
+ // An echo emitted while a turn is in flight is a display-only
1213
+ // interjection, never a turn opener — mark it like a drained steer so
1214
+ // the WorkingStatusLine anchor and the persisted row both skip it.
1215
+ ...abortController !== null || hostBusyCount > 0 ? { steeredAt: now() } : {}
829
1216
  });
1217
+ return true;
830
1218
  },
831
1219
  getSnapshot() {
832
1220
  return { seq, state, serverCommands };
@@ -872,11 +1260,13 @@ function createSessionController(deps) {
872
1260
  publishMcpState(mcpState) {
873
1261
  emit({ type: "mcp-state", state: mcpState });
874
1262
  },
875
- reset(options = {}) {
1263
+ reset(options) {
1264
+ copyRecallState(options.recall);
876
1265
  if (persistenceInFlight.size > 0) {
877
1266
  return;
878
1267
  }
879
1268
  queue.length = 0;
1269
+ activeHumans.length = 0;
880
1270
  if (options.preserveHostBusy !== true) hostBusyCount = 0;
881
1271
  abortController?.abort(new Error("Session reset."));
882
1272
  rejectAllPending("Session reset; pending interactions were cancelled.");
@@ -886,6 +1276,7 @@ function createSessionController(deps) {
886
1276
  type: "state-reset",
887
1277
  state: {
888
1278
  ...createSessionViewState(),
1279
+ recall: options.recall,
889
1280
  busy: hostBusyCount > 0,
890
1281
  meta: state.meta,
891
1282
  mcp: state.mcp
@@ -907,34 +1298,30 @@ function createSessionController(deps) {
907
1298
  async submit(input, opts) {
908
1299
  if (closing) throw new Error("Session is closing.");
909
1300
  opts.signal?.throwIfAborted();
1301
+ opts = admitHuman(input, opts);
910
1302
  if (abortController || hostBusyCount > 0) {
911
1303
  queue.push({
912
1304
  id: `q-${++queueCounter}`,
913
1305
  input,
914
1306
  opts,
915
- delivery: abortController !== null && opts.turnId === void 0 ? "steering" : "next-turn"
1307
+ delivery: abortController !== null && executionOwner?.phase !== "recovering" && allowsSteering(opts) ? "steering" : "next-turn",
1308
+ queuedAt: now()
916
1309
  });
917
1310
  emitTurnStatus();
918
1311
  return;
919
1312
  }
920
1313
  const coordinatorOwned = opts.submission?.origin === "coordinator";
921
- if (!coordinatorOwned && !opts.silent) {
922
- emit(
923
- {
924
- type: "user-message",
925
- text: opts.displayText,
926
- ...opts.attachmentLabels?.length ? { attachmentLabels: opts.attachmentLabels } : {}
927
- },
928
- opts.turnId
929
- );
930
- }
931
- await startTrackedTurn(input, {
1314
+ const turn = startTrackedTurn(input, {
1315
+ echo: coordinatorOwned || opts.silent ? void 0 : opts,
1316
+ human: opts.human,
932
1317
  turnId: opts.turnId,
933
- allowSteering: opts.turnId === void 0,
1318
+ allowSteering: allowsSteering(opts),
934
1319
  submission: opts.submission ?? { origin: "user" },
935
1320
  ...coordinatorOwned ? { coordinatorDisplayText: opts.displayText } : {},
936
1321
  ...opts.signal === void 0 ? {} : { signal: opts.signal }
937
1322
  });
1323
+ if (opts.waitFor === "acceptance") observeAcceptedTurn(turn, opts.turnId);
1324
+ else await turn;
938
1325
  }
939
1326
  };
940
1327
  }