@genesislcap/ai-assistant 15.26.0 → 15.27.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 (43) hide show
  1. package/dist/ai-assistant.api.json +150 -7
  2. package/dist/ai-assistant.d.ts +82 -99
  3. package/dist/chat-driver.cjs +460 -373
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +460 -373
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +682 -202
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +28 -200
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/interaction-controller.d.ts +99 -0
  11. package/dist/dts/components/chat-driver/interaction-controller.d.ts.map +1 -0
  12. package/dist/dts/components/chat-driver/turn-recorder.d.ts +191 -0
  13. package/dist/dts/components/chat-driver/turn-recorder.d.ts.map +1 -0
  14. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +20 -0
  15. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +32 -1
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts +19 -0
  20. package/dist/dts/main/main.template.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +7 -1
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts +5 -0
  24. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  25. package/dist/dts/state/session-store.d.ts +1 -0
  26. package/dist/dts/state/session-store.d.ts.map +1 -1
  27. package/dist/dts/utils/format-message-timestamp.d.ts +77 -0
  28. package/dist/dts/utils/format-message-timestamp.d.ts.map +1 -0
  29. package/dist/dts/utils/resolve-preference-baseline.d.ts +18 -1
  30. package/dist/dts/utils/resolve-preference-baseline.d.ts.map +1 -1
  31. package/dist/esm/components/chat-driver/chat-driver.js +68 -404
  32. package/dist/esm/components/chat-driver/interaction-controller.js +235 -0
  33. package/dist/esm/components/chat-driver/turn-recorder.js +206 -0
  34. package/dist/esm/components/settings-modal/settings-modal.template.js +53 -2
  35. package/dist/esm/main/main.js +80 -17
  36. package/dist/esm/main/main.styles.js +75 -0
  37. package/dist/esm/main/main.template.js +43 -2
  38. package/dist/esm/state/ai-assistant-slice.js +5 -0
  39. package/dist/esm/utils/format-message-timestamp.js +126 -0
  40. package/dist/esm/utils/resolve-preference-baseline.js +54 -2
  41. package/package.json +16 -16
  42. package/sandbox/README.md +45 -2
  43. package/sandbox/sandbox.ts +95 -3
@@ -3719,70 +3719,6 @@ function clearSession(key) {
3719
3719
  // src/utils/logger.ts
3720
3720
  var logger2 = createLogger("ai-assistant");
3721
3721
 
3722
- // src/state/interaction-context.ts
3723
- function createInteractionContext(interactionId) {
3724
- const state = {};
3725
- const onceCache = /* @__PURE__ */ new Map();
3726
- const listeners = /* @__PURE__ */ new Set();
3727
- const disposers = /* @__PURE__ */ new Set();
3728
- let disposed = false;
3729
- const context = {
3730
- interactionId,
3731
- state,
3732
- once(key, factory) {
3733
- if (onceCache.has(key)) return onceCache.get(key);
3734
- if (disposed) {
3735
- logger2.warn(
3736
- `InteractionContext.once("${key}") called after dispose; not creating a resource on an ended interaction.`
3737
- );
3738
- return null;
3739
- }
3740
- const value = factory();
3741
- onceCache.set(key, value);
3742
- return value;
3743
- },
3744
- notify() {
3745
- for (const listener of listeners) {
3746
- try {
3747
- listener();
3748
- } catch (e) {
3749
- logger2.error("InteractionContext subscriber threw during notify()", e);
3750
- }
3751
- }
3752
- },
3753
- subscribe(listener) {
3754
- listeners.add(listener);
3755
- return () => {
3756
- listeners.delete(listener);
3757
- };
3758
- },
3759
- onDispose(fn) {
3760
- if (disposed) {
3761
- fn();
3762
- return;
3763
- }
3764
- disposers.add(fn);
3765
- }
3766
- };
3767
- return {
3768
- context,
3769
- dispose() {
3770
- if (disposed) return;
3771
- disposed = true;
3772
- for (const fn of disposers) {
3773
- try {
3774
- fn();
3775
- } catch (e) {
3776
- logger2.error("InteractionContext onDispose callback threw", e);
3777
- }
3778
- }
3779
- disposers.clear();
3780
- listeners.clear();
3781
- onceCache.clear();
3782
- }
3783
- };
3784
- }
3785
-
3786
3722
  // src/suggestions/generate-suggestions.ts
3787
3723
  var HISTORY_WINDOW = 8;
3788
3724
  function capabilities(ctx, allAgentInfo) {
@@ -4755,6 +4691,250 @@ var FoldController = class {
4755
4691
  }
4756
4692
  };
4757
4693
 
4694
+ // src/state/interaction-context.ts
4695
+ function createInteractionContext(interactionId) {
4696
+ const state = {};
4697
+ const onceCache = /* @__PURE__ */ new Map();
4698
+ const listeners = /* @__PURE__ */ new Set();
4699
+ const disposers = /* @__PURE__ */ new Set();
4700
+ let disposed = false;
4701
+ const context = {
4702
+ interactionId,
4703
+ state,
4704
+ once(key, factory) {
4705
+ if (onceCache.has(key)) return onceCache.get(key);
4706
+ if (disposed) {
4707
+ logger2.warn(
4708
+ `InteractionContext.once("${key}") called after dispose; not creating a resource on an ended interaction.`
4709
+ );
4710
+ return null;
4711
+ }
4712
+ const value = factory();
4713
+ onceCache.set(key, value);
4714
+ return value;
4715
+ },
4716
+ notify() {
4717
+ for (const listener of listeners) {
4718
+ try {
4719
+ listener();
4720
+ } catch (e) {
4721
+ logger2.error("InteractionContext subscriber threw during notify()", e);
4722
+ }
4723
+ }
4724
+ },
4725
+ subscribe(listener) {
4726
+ listeners.add(listener);
4727
+ return () => {
4728
+ listeners.delete(listener);
4729
+ };
4730
+ },
4731
+ onDispose(fn) {
4732
+ if (disposed) {
4733
+ fn();
4734
+ return;
4735
+ }
4736
+ disposers.add(fn);
4737
+ }
4738
+ };
4739
+ return {
4740
+ context,
4741
+ dispose() {
4742
+ if (disposed) return;
4743
+ disposed = true;
4744
+ for (const fn of disposers) {
4745
+ try {
4746
+ fn();
4747
+ } catch (e) {
4748
+ logger2.error("InteractionContext onDispose callback threw", e);
4749
+ }
4750
+ }
4751
+ disposers.clear();
4752
+ listeners.clear();
4753
+ onceCache.clear();
4754
+ }
4755
+ };
4756
+ }
4757
+
4758
+ // src/components/chat-driver/interaction-controller.ts
4759
+ var InteractionController = class {
4760
+ constructor(deps) {
4761
+ this.deps = deps;
4762
+ this.pending = /* @__PURE__ */ new Map();
4763
+ /**
4764
+ * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in `request`
4765
+ * alongside the pending entry, disposed + removed in `resolve`. Non-serialisable (sockets,
4766
+ * timers, subscriber sets), so they live here — not on the DOM, not in the persisted store — and
4767
+ * therefore survive the element remounts (layout reflow/drag, transcript re-render) that this
4768
+ * feature exists to make harmless. At most one is live at a time (see the
4769
+ * one-interaction-at-a-time guard in `request`).
4770
+ */
4771
+ this.contexts = /* @__PURE__ */ new Map();
4772
+ }
4773
+ /** See `ChatDriver.setHostInteractionRequester`. */
4774
+ setHostRequester(fn) {
4775
+ this.hostRequester = fn;
4776
+ }
4777
+ /** See `ChatDriver.requestInteraction`. */
4778
+ request(componentName, data, options) {
4779
+ if (this.hostRequester) {
4780
+ return this.hostRequester(componentName, data, options);
4781
+ }
4782
+ if (this.pending.size > 0) {
4783
+ throw new Error(
4784
+ "requestInteraction: another user interaction is already in flight. Only one interaction may be active at a time \u2014 sequence them in a single tool handler rather than spawning widgets from parallel sub-agents or parallel tool calls."
4785
+ );
4786
+ }
4787
+ const interactionId = crypto.randomUUID();
4788
+ const chatInputDuringExecution = options?.chatInputDuringExecution;
4789
+ const timeoutMs = options?.timeoutMs;
4790
+ const presentation = options?.presentation;
4791
+ return new Promise((resolve, reject) => {
4792
+ this.pending.set(interactionId, {
4793
+ resolve,
4794
+ reject,
4795
+ overrideId: chatInputDuringExecution ? interactionId : void 0,
4796
+ // Resolve the interaction with a timeout result if the user does not
4797
+ // respond in time. `resolve` runs the same teardown a normal resolution
4798
+ // does (marks the widget read-only, reverts any input override) and
4799
+ // clears any still-pending handle.
4800
+ timeoutHandle: timeoutMs != null ? setTimeout(() => {
4801
+ this.resolve(interactionId, {
4802
+ status: "timeout",
4803
+ message: `Interaction timed out after ${timeoutMs}ms.`
4804
+ });
4805
+ }, timeoutMs) : void 0
4806
+ });
4807
+ recordMetaEvent(this.deps.sessionKey, "interaction.requested", {
4808
+ interactionId,
4809
+ component: componentName,
4810
+ agent: this.deps.activeAgentName()
4811
+ });
4812
+ this.deps.activityBus.publish("interaction-requested", void 0);
4813
+ if (chatInputDuringExecution) {
4814
+ this.deps.emit(
4815
+ new CustomEvent("interaction-start", {
4816
+ detail: { interactionId, chatInputDuringExecution }
4817
+ })
4818
+ );
4819
+ }
4820
+ this.contexts.set(interactionId, createInteractionContext(interactionId));
4821
+ this.deps.appendToHistory({
4822
+ role: "assistant",
4823
+ content: "",
4824
+ interaction: {
4825
+ interactionId,
4826
+ componentName,
4827
+ data,
4828
+ ...presentation ? { presentation } : {}
4829
+ }
4830
+ });
4831
+ });
4832
+ }
4833
+ /**
4834
+ * See `ChatDriver.resolveInteraction`. Warns and returns when the id is unknown — a widget can
4835
+ * report twice, and the second report has nothing left to settle.
4836
+ */
4837
+ resolve(interactionId, result) {
4838
+ const interaction = this.pending.get(interactionId);
4839
+ if (!interaction) {
4840
+ logger2.warn(`Interaction with ID ${interactionId} not found.`);
4841
+ return;
4842
+ }
4843
+ if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
4844
+ this.deps.foldDiagnostics(
4845
+ interactionId,
4846
+ result?.diagnostics
4847
+ );
4848
+ const costUsd = typeof result?.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd > 0 ? result.costUsd : void 0;
4849
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => ({
4850
+ ...message,
4851
+ interaction: { ...message.interaction, resolved: result },
4852
+ ...costUsd != null ? { externalCostUsd: (message.externalCostUsd ?? 0) + costUsd } : {}
4853
+ }));
4854
+ if (patched) this.deps.notifyHistoryUpdated();
4855
+ if (typeof result?.userMessage === "string" && result.userMessage.trim()) {
4856
+ this.deps.appendToHistory({ role: "synthetic-user", content: result.userMessage });
4857
+ }
4858
+ if (interaction.overrideId) {
4859
+ this.deps.emit(new CustomEvent("interaction-stop", { detail: { interactionId } }));
4860
+ }
4861
+ recordMetaEvent(this.deps.sessionKey, "interaction.resolved", { interactionId });
4862
+ this.deps.activityBus.publish("interaction-resolved", void 0);
4863
+ interaction.resolve(result);
4864
+ this.pending.delete(interactionId);
4865
+ this.contexts.get(interactionId)?.dispose();
4866
+ this.contexts.delete(interactionId);
4867
+ }
4868
+ /**
4869
+ * See `ChatDriver.recordExternalCost`.
4870
+ *
4871
+ * **Why this exists separately from `resolve`.** A widget's spend reaches the session total only
4872
+ * through `InteractionResult.costUsd`, which is read exactly once — `resolve` does its whole job
4873
+ * against a live pending entry and then deletes it. That is correct for the resolution itself,
4874
+ * but it assumes a widget stops costing money when it resolves, and several deliberately do not:
4875
+ * a widget that resolves EARLY so the assistant stops waiting (options shown, a preview
4876
+ * rendered) may stay interactive for minutes afterwards and do more paid work on the user's
4877
+ * behalf. Re-emitting `interaction-completed` does not help — with the interaction gone from the
4878
+ * map the call is a silent no-op — so that spend had nowhere to go and was simply missing from
4879
+ * the figure shown to the user.
4880
+ *
4881
+ * Touches only `externalCostUsd`: the recorded `interaction.resolved` payload is left exactly as
4882
+ * the resolution wrote it, because this is not a second resolution and must not read like one.
4883
+ */
4884
+ recordExternalCost(interactionId, costUsd) {
4885
+ const drop = (reason) => {
4886
+ recordMetaEvent(this.deps.sessionKey, "interaction.external-cost-dropped", {
4887
+ interactionId,
4888
+ costUsd,
4889
+ reason
4890
+ });
4891
+ return false;
4892
+ };
4893
+ if (!interactionId) return drop("no interactionId");
4894
+ if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd <= 0) {
4895
+ return drop("not a positive finite amount");
4896
+ }
4897
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => ({
4898
+ ...message,
4899
+ externalCostUsd: (message.externalCostUsd ?? 0) + costUsd
4900
+ }));
4901
+ if (!patched) return drop("interaction message no longer in history");
4902
+ recordMetaEvent(this.deps.sessionKey, "interaction.external-cost", { interactionId, costUsd });
4903
+ this.deps.emit(
4904
+ new CustomEvent("external-cost-recorded", {
4905
+ detail: { interactionId, costUsd }
4906
+ })
4907
+ );
4908
+ return true;
4909
+ }
4910
+ /** See `ChatDriver.getInteractionContext`. */
4911
+ context(interactionId) {
4912
+ return this.contexts.get(interactionId)?.context;
4913
+ }
4914
+ /**
4915
+ * Resolve every in-flight interaction with a `'timeout'` result, closing its
4916
+ * widget read-only. Used when a sub-agent that owns the open widget times out:
4917
+ * the widget is parked in *this* driver's map (interactions resolve on the
4918
+ * host, not the child), so cancelling it here unblocks the sub-agent handler
4919
+ * awaiting it. The "one interaction at a time" invariant means there is at
4920
+ * most one.
4921
+ */
4922
+ cancelAll(message) {
4923
+ for (const interactionId of [...this.pending.keys()]) {
4924
+ this.resolve(interactionId, { status: "timeout", message });
4925
+ }
4926
+ }
4927
+ /**
4928
+ * Drop any live interaction contexts on driver teardown. Normally empty (a context is created
4929
+ * and destroyed with its pending interaction, and dispose is not reached mid-interaction);
4930
+ * disposed defensively so a stray context can never leak an open socket/timer.
4931
+ */
4932
+ disposeContexts() {
4933
+ for (const handle of this.contexts.values()) handle.dispose();
4934
+ this.contexts.clear();
4935
+ }
4936
+ };
4937
+
4758
4938
  // src/components/chat-driver/turn-failures.ts
4759
4939
  var TURN_FAILURE_EXITS = {
4760
4940
  contextGuard: {
@@ -4836,6 +5016,160 @@ function turnDone(failureReason, budget, providerRefused) {
4836
5016
  };
4837
5017
  }
4838
5018
 
5019
+ // src/components/chat-driver/turn-recorder.ts
5020
+ var TurnRecorder = class {
5021
+ constructor(sessionKey, maxSnapshots) {
5022
+ this.sessionKey = sessionKey;
5023
+ this.maxSnapshots = maxSnapshots;
5024
+ /**
5025
+ * Ring buffer of per-LLM-call snapshots, capped at `maxSnapshots`; older entries drop off as
5026
+ * new ones arrive.
5027
+ */
5028
+ this.buffer = [];
5029
+ /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
5030
+ this.turnIndex = 0;
5031
+ /**
5032
+ * `turn` + `message` diagnostic entries harvested from an out-of-band source. `event` entries
5033
+ * are NOT kept here — they are folded into the session's meta-event registry so they inherit
5034
+ * fresh host indices, and reach the log through `getMetaEvents`. Append-only, and never cleared:
5035
+ * it lives with the recorder, so a new chat (fresh driver) starts empty while a compaction (same
5036
+ * instance) keeps it. (GENC-1461.)
5037
+ */
5038
+ this.externalEntries = [];
5039
+ /**
5040
+ * Monotonic count of external-diagnostics batches folded this driver-lifetime. Namespaces each
5041
+ * batch's re-keyed `turn` entries so distinct server generations in one chat session can't
5042
+ * collide on `turnIndex` in the forward-capture persister (which dedups turns by that key).
5043
+ */
5044
+ this.externalBatches = 0;
5045
+ }
5046
+ /** Point at the incoming agent's `getDebugSnapshot`, or clear it when the agent supplies none. */
5047
+ setSnapshotter(snapshotter) {
5048
+ this.snapshotter = snapshotter;
5049
+ }
5050
+ /**
5051
+ * The per-turn snapshots captured so far — what the LLM saw on each turn: system prompt, tool
5052
+ * surface, and agent-supplied state (e.g. a machine snapshot).
5053
+ */
5054
+ snapshots() {
5055
+ return this.buffer;
5056
+ }
5057
+ /**
5058
+ * The harvested `turn` + `message` diagnostic entries. Opaque, and chronologically sorted by the
5059
+ * host at assemble time.
5060
+ */
5061
+ externalDiagnostics() {
5062
+ return this.externalEntries;
5063
+ }
5064
+ /**
5065
+ * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
5066
+ * before each LLM call — that's the latest point where the prompt, tool
5067
+ * surface, and agent state line up with what the model is about to see.
5068
+ *
5069
+ * Returns the pushed object so the caller can back-fill what only the response
5070
+ * knows (`usage`). Mutating it after the fact is safe whether or not the ring
5071
+ * buffer has since evicted it — an evicted snapshot is simply no longer exported.
5072
+ */
5073
+ record(turn) {
5074
+ let agentSnapshot;
5075
+ if (this.snapshotter) {
5076
+ try {
5077
+ agentSnapshot = this.snapshotter();
5078
+ } catch (e) {
5079
+ agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
5080
+ }
5081
+ }
5082
+ const turnIndex = String(this.turnIndex);
5083
+ this.turnIndex += 1;
5084
+ const snapshot = {
5085
+ ...turn,
5086
+ turnIndex,
5087
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5088
+ agentSnapshot
5089
+ };
5090
+ this.buffer.push(snapshot);
5091
+ this.trim();
5092
+ return snapshot;
5093
+ }
5094
+ /**
5095
+ * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
5096
+ * as `kind:'turn'` entries in the exported debug log. The child runs as a
5097
+ * separate, discarded driver, so its snapshots would otherwise be lost. Each is
5098
+ * re-labelled under the parent turn that activated the sub-agent: the child's
5099
+ * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
5100
+ * any already-forwarded grand-child labels (strings) have their leading segment
5101
+ * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
5102
+ *
5103
+ * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
5104
+ * their labels can repeat — `agentName` on each snapshot disambiguates them.
5105
+ */
5106
+ forwardSubAgent(childSnapshots) {
5107
+ if (childSnapshots.length === 0) return;
5108
+ const parentTurn = Math.max(0, this.turnIndex - 1);
5109
+ const ownTurnLabel = /* @__PURE__ */ new Map();
5110
+ let ownPos = 0;
5111
+ for (const snap of childSnapshots) {
5112
+ let turnIndex;
5113
+ if (!snap.turnIndex.includes("-")) {
5114
+ ownPos += 1;
5115
+ turnIndex = `${parentTurn}-${ownPos}`;
5116
+ ownTurnLabel.set(snap.turnIndex, turnIndex);
5117
+ } else {
5118
+ const [lead, ...rest] = snap.turnIndex.split("-");
5119
+ const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
5120
+ turnIndex = [leadLabel, ...rest].join("-");
5121
+ }
5122
+ this.buffer.push({ ...snap, turnIndex });
5123
+ }
5124
+ this.trim();
5125
+ }
5126
+ /**
5127
+ * Fold a widget's out-of-band diagnostics into this session — typically a server-side
5128
+ * ChatDriver's collated log, arriving on `InteractionResult.diagnostics` — so the host's debug
5129
+ * log and persisted diagnostics stream include them. Independent of the interaction's own
5130
+ * message: these describe the widget's work, not that message. A non-array or empty harvest is
5131
+ * a no-op.
5132
+ */
5133
+ foldDiagnostics(interactionId, harvested) {
5134
+ if (!Array.isArray(harvested) || !harvested.length) return;
5135
+ const batch = this.externalBatches += 1;
5136
+ const externalEvents = [];
5137
+ let turnCount = 0;
5138
+ let messageCount = 0;
5139
+ for (const raw of harvested) {
5140
+ if (!raw || typeof raw !== "object" || typeof raw.kind !== "string") {
5141
+ continue;
5142
+ }
5143
+ const entry = raw;
5144
+ if (entry.kind === "event") {
5145
+ const { kind: _kind, ...event } = entry;
5146
+ externalEvents.push(event);
5147
+ } else if (entry.kind === "turn") {
5148
+ this.externalEntries.push({
5149
+ ...entry,
5150
+ turnIndex: `server-generation.${batch}:${turnCount}`
5151
+ });
5152
+ turnCount += 1;
5153
+ } else if (entry.kind === "message") {
5154
+ messageCount += 1;
5155
+ this.externalEntries.push(entry);
5156
+ }
5157
+ }
5158
+ if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
5159
+ recordMetaEvent(this.sessionKey, "external-diagnostics.folded", {
5160
+ interactionId,
5161
+ batch,
5162
+ counts: { turn: turnCount, event: externalEvents.length, message: messageCount }
5163
+ });
5164
+ }
5165
+ /** Hold the ring buffer at its cap, dropping oldest-first. */
5166
+ trim() {
5167
+ while (this.buffer.length > this.maxSnapshots) {
5168
+ this.buffer.shift();
5169
+ }
5170
+ }
5171
+ };
5172
+
4839
5173
  // src/components/chat-driver/chat-driver.ts
4840
5174
  var toolResultImages = (result) => {
4841
5175
  if (!result || typeof result !== "object") return void 0;
@@ -4873,17 +5207,6 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4873
5207
  this.busy = false;
4874
5208
  /** Epoch ms when the current turn loop began — drives the `turn.end` duration. */
4875
5209
  this.turnStartedAt = 0;
4876
- this.pendingInteractions = /* @__PURE__ */ new Map();
4877
- /**
4878
- * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in
4879
- * `requestInteraction` alongside the pending entry, disposed + removed in
4880
- * `resolveInteraction`. Non-serialisable (sockets, timers, subscriber sets), so
4881
- * they live here on the driver — not on the DOM, not in the persisted store — and
4882
- * therefore survive the element remounts (layout reflow/drag, transcript
4883
- * re-render) that this feature exists to make harmless. At most one is live at a
4884
- * time (see the one-interaction-at-a-time guard in `requestInteraction`).
4885
- */
4886
- this.interactionContexts = /* @__PURE__ */ new Map();
4887
5210
  /**
4888
5211
  * Tool-declared condensation policies, keyed by tool-call id. Populated by
4889
5212
  * `condenseWhen` (first-wins per call); read by `applyCondensation` before each
@@ -4982,31 +5305,6 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4982
5305
  * doesn't leak forward.
4983
5306
  */
4984
5307
  this.agentReleaseRequested = false;
4985
- /**
4986
- * Ring buffer of per-LLM-call snapshots. Cap is configurable via
4987
- * `chatConfig.agent.maxTurnSnapshots`; older entries drop off as new ones
4988
- * arrive. See {@link TurnSnapshot} for the captured shape.
4989
- */
4990
- this.turnSnapshots = [];
4991
- /**
4992
- * `turn` + `message` diagnostic-log entries harvested from an out-of-band source (a resolved
4993
- * interaction whose widget returned `InteractionResult.diagnostics` — e.g. a server-side
4994
- * ChatDriver's debug log). `event` entries are NOT kept here — they're folded into the session's
4995
- * meta-event registry via `mergeMetaEvents` (see `resolveInteraction`) so they inherit fresh host
4996
- * indices, and reach the log through `getMetaEvents`. Append-only; the host folds these into its
4997
- * debug log alongside this driver's own timeline. Not cleared here — it lives with the driver
4998
- * instance, like `turnSnapshots`, so a new chat (fresh driver) starts empty while a compaction
4999
- * (same instance) keeps it. (GENC-1461.)
5000
- */
5001
- this.externalDiagnostics = [];
5002
- /**
5003
- * Monotonic count of external-diagnostics batches harvested this driver-lifetime. Namespaces each
5004
- * batch's re-keyed `turn` entries so distinct server generations in one chat session can't collide
5005
- * on `turnIndex` in the forward-capture persister (which dedups turns by that key). (GENC-1461.)
5006
- */
5007
- this.externalDiagnosticsBatches = 0;
5008
- /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
5009
- this.globalTurnIndex = 0;
5010
5308
  /**
5011
5309
  * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
5012
5310
  * Threaded into every provider call as `ChatRequestOptions.signal`, so a
@@ -5116,7 +5414,17 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5116
5414
  this.systemPrompt = systemPrompt;
5117
5415
  this.primerHistory = primerHistory;
5118
5416
  this.maxFoldOperations = maxFoldOperations;
5119
- this.maxTurnSnapshots = maxTurnSnapshots;
5417
+ this.turnRecorder = new TurnRecorder(sessionKey, maxTurnSnapshots);
5418
+ this.interactions = new InteractionController({
5419
+ sessionKey,
5420
+ activityBus,
5421
+ activeAgentName: () => this.activeAgentName,
5422
+ appendToHistory: (message) => this.appendToHistory(message),
5423
+ patchInteractionMessage: (interactionId, patch) => this.patchInteractionMessage(interactionId, patch),
5424
+ notifyHistoryUpdated: () => this.notifyHistoryUpdated(),
5425
+ emit: (event) => this.dispatchEvent(event),
5426
+ foldDiagnostics: (interactionId, harvested) => this.turnRecorder.foldDiagnostics(interactionId, harvested)
5427
+ });
5120
5428
  if (isObservableAIProviderRegistry(this.providerRegistry)) {
5121
5429
  this.unsubscribeRegistry = this.providerRegistry.subscribe(() => {
5122
5430
  this.resolvedProviderCache.clear();
@@ -5134,7 +5442,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5134
5442
  * on to completion or the transport timeout. Called by the host on driver
5135
5443
  * swap and by `OrchestratingDriver.dispose()`. Idempotent.
5136
5444
  *
5137
- * Deliberately does NOT settle `pendingInteractions` here: dispose is never
5445
+ * Deliberately does NOT settle pending interactions here: dispose is never
5138
5446
  * reached while one is pending. The only host-level dispose is the agent-swap
5139
5447
  * rebuild, which is guarded by `isBusy()` — a pending interaction keeps the
5140
5448
  * tool loop suspended (`busy === true`), so the swap is deferred until the
@@ -5145,8 +5453,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5145
5453
  */
5146
5454
  async dispose() {
5147
5455
  this.lifecycleController.abort(new DOMException("AI assistant driver disposed", "AbortError"));
5148
- for (const handle of this.interactionContexts.values()) handle.dispose();
5149
- this.interactionContexts.clear();
5456
+ this.interactions.disposeContexts();
5150
5457
  this.unsubscribeRegistry?.();
5151
5458
  this.unsubscribeRegistry = void 0;
5152
5459
  await this.agentSession.dispose();
@@ -5403,7 +5710,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5403
5710
  this.activeAgentName = config.name;
5404
5711
  this.displayName = config.displayName;
5405
5712
  this.activeAgentLabel = typeof config.displayName === "string" ? config.displayName : config.name;
5406
- this.debugSnapshotter = config.getDebugSnapshot;
5713
+ this.turnRecorder.setSnapshotter(config.getDebugSnapshot);
5407
5714
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
5408
5715
  this.activeProviderInput = config.provider;
5409
5716
  this.activeTemperatureInput = config.temperature;
@@ -5600,93 +5907,21 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5600
5907
  * log exporter to show what the LLM saw on each turn — system prompt, tool
5601
5908
  * surface, and agent-supplied state (e.g. a machine snapshot).
5602
5909
  *
5603
- * Ring-buffered at `MAX_TURN_SNAPSHOTS`; older entries are dropped.
5910
+ * Ring-buffered at `maxTurnSnapshots`; older entries are dropped.
5604
5911
  */
5605
5912
  getTurnSnapshots() {
5606
- return this.turnSnapshots;
5913
+ return this.turnRecorder.snapshots();
5607
5914
  }
5608
5915
  /**
5609
5916
  * The `turn` + `message` diagnostic-log entries harvested from resolved interactions'
5610
5917
  * `InteractionResult.diagnostics` (e.g. a server-side ChatDriver's collated debug log). The host
5611
5918
  * concatenates these into its own debug log so `downloadDebugLog`/the persisted diagnostics stream
5612
5919
  * include external-engine logs; `event` entries are excluded here because they're merged into the
5613
- * session's meta-event registry instead (see `resolveInteraction`) and reach the log via
5614
- * `getMetaEvents`. Opaque, chronologically sorted by the host at assemble time. (GENC-1461.)
5920
+ * session's meta-event registry instead and reach the log via `getMetaEvents`. Opaque,
5921
+ * chronologically sorted by the host at assemble time. (GENC-1461.)
5615
5922
  */
5616
5923
  getExternalDiagnostics() {
5617
- return this.externalDiagnostics;
5618
- }
5619
- /**
5620
- * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
5621
- * as `kind:'turn'` entries in the exported debug log. The child runs as a
5622
- * separate, discarded driver, so its snapshots would otherwise be lost. Each is
5623
- * re-labelled under the parent turn that activated the sub-agent: the child's
5624
- * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
5625
- * any already-forwarded grand-child labels (strings) have their leading segment
5626
- * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
5627
- *
5628
- * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
5629
- * their labels can repeat — `agentName` on each snapshot disambiguates them.
5630
- */
5631
- forwardSubAgentSnapshots(childSnapshots) {
5632
- if (childSnapshots.length === 0) return;
5633
- const parentTurn = Math.max(0, this.globalTurnIndex - 1);
5634
- const ownTurnLabel = /* @__PURE__ */ new Map();
5635
- let ownPos = 0;
5636
- for (const snap of childSnapshots) {
5637
- let turnIndex;
5638
- if (!snap.turnIndex.includes("-")) {
5639
- ownPos += 1;
5640
- turnIndex = `${parentTurn}-${ownPos}`;
5641
- ownTurnLabel.set(snap.turnIndex, turnIndex);
5642
- } else {
5643
- const [lead, ...rest] = snap.turnIndex.split("-");
5644
- const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
5645
- turnIndex = [leadLabel, ...rest].join("-");
5646
- }
5647
- this.turnSnapshots.push({ ...snap, turnIndex });
5648
- }
5649
- while (this.turnSnapshots.length > this.maxTurnSnapshots) {
5650
- this.turnSnapshots.shift();
5651
- }
5652
- }
5653
- /**
5654
- * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
5655
- * before each LLM call — that's the latest point where the prompt, tool
5656
- * surface, and agent state line up with what the model is about to see.
5657
- *
5658
- * Returns the pushed object so the caller can back-fill what only the response
5659
- * knows (`usage`). Mutating it after the fact is safe whether or not the ring
5660
- * buffer has since evicted it — an evicted snapshot is simply no longer exported.
5661
- */
5662
- recordTurnSnapshot(resolvedSystemPrompt, temperature, toolChoice, tailContext) {
5663
- let agentSnapshot;
5664
- if (this.debugSnapshotter) {
5665
- try {
5666
- agentSnapshot = this.debugSnapshotter();
5667
- } catch (e) {
5668
- agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
5669
- }
5670
- }
5671
- const turnIndex = String(this.globalTurnIndex);
5672
- this.globalTurnIndex += 1;
5673
- const snapshot = {
5674
- turnIndex,
5675
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5676
- agentName: this.activeAgentName,
5677
- agentLabel: this.activeAgentLabel,
5678
- systemPrompt: resolvedSystemPrompt,
5679
- tailContext,
5680
- toolNames: this.toolDefinitions.map((t) => t.name),
5681
- temperature,
5682
- toolChoice,
5683
- agentSnapshot
5684
- };
5685
- this.turnSnapshots.push(snapshot);
5686
- if (this.turnSnapshots.length > this.maxTurnSnapshots) {
5687
- this.turnSnapshots.shift();
5688
- }
5689
- return snapshot;
5924
+ return this.turnRecorder.externalDiagnostics();
5690
5925
  }
5691
5926
  /**
5692
5927
  * Set the mid-loop context guard. Its margin is deliberately far smaller than
@@ -5731,6 +5966,10 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5731
5966
  );
5732
5967
  return { estimated, threshold };
5733
5968
  }
5969
+ /**
5970
+ * Optional transform applied to conversation history immediately before each LLM request.
5971
+ * Cleared when `undefined`. Does not alter stored history.
5972
+ */
5734
5973
  setProviderHistoryTransform(transform) {
5735
5974
  this.providerHistoryTransform = transform;
5736
5975
  }
@@ -5884,7 +6123,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5884
6123
  * naturally: a grandchild → child → root.
5885
6124
  */
5886
6125
  setHostInteractionRequester(fn) {
5887
- this.hostInteractionRequester = fn;
6126
+ this.interactions.setHostRequester(fn);
5888
6127
  }
5889
6128
  /**
5890
6129
  * Request a custom UI interaction. Emits a new message with the interaction.
@@ -5906,76 +6145,15 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5906
6145
  * bubble and/or shows the "Assistant" label.
5907
6146
  */
5908
6147
  async requestInteraction(componentName, data, options) {
5909
- if (this.hostInteractionRequester) {
5910
- return this.hostInteractionRequester(componentName, data, options);
5911
- }
5912
- if (this.pendingInteractions.size > 0) {
5913
- throw new Error(
5914
- "requestInteraction: another user interaction is already in flight. Only one interaction may be active at a time \u2014 sequence them in a single tool handler rather than spawning widgets from parallel sub-agents or parallel tool calls."
5915
- );
5916
- }
5917
- const interactionId = crypto.randomUUID();
5918
- const chatInputDuringExecution = options?.chatInputDuringExecution;
5919
- const timeoutMs = options?.timeoutMs;
5920
- const presentation = options?.presentation;
5921
- return new Promise((resolve, reject) => {
5922
- this.pendingInteractions.set(interactionId, {
5923
- resolve,
5924
- reject,
5925
- overrideId: chatInputDuringExecution ? interactionId : void 0,
5926
- // Resolve the interaction with a timeout result if the user does not
5927
- // respond in time. `resolveInteraction` runs the same teardown a normal
5928
- // resolution does (marks the widget read-only, reverts any input
5929
- // override) and clears any still-pending handle.
5930
- timeoutHandle: timeoutMs != null ? setTimeout(() => {
5931
- this.resolveInteraction(interactionId, {
5932
- status: "timeout",
5933
- message: `Interaction timed out after ${timeoutMs}ms.`
5934
- });
5935
- }, timeoutMs) : void 0
5936
- });
5937
- recordMetaEvent(this.sessionKey, "interaction.requested", {
5938
- interactionId,
5939
- component: componentName,
5940
- agent: this.activeAgentName
5941
- });
5942
- this.activityBus.publish("interaction-requested", void 0);
5943
- if (chatInputDuringExecution) {
5944
- this.dispatchEvent(
5945
- new CustomEvent("interaction-start", {
5946
- detail: { interactionId, chatInputDuringExecution }
5947
- })
5948
- );
5949
- }
5950
- this.interactionContexts.set(interactionId, createInteractionContext(interactionId));
5951
- this.appendToHistory({
5952
- role: "assistant",
5953
- content: "",
5954
- interaction: {
5955
- interactionId,
5956
- componentName,
5957
- data,
5958
- ...presentation ? { presentation } : {}
5959
- }
5960
- });
5961
- });
6148
+ return this.interactions.request(componentName, data, options);
5962
6149
  }
5963
6150
  /**
5964
- * Add external (non-LLM) spend to an interaction's message AFTER it has resolved.
6151
+ * Add external (non-LLM) spend to an interaction's message AFTER it has resolved — for a widget
6152
+ * that resolves early so the assistant stops waiting, then keeps doing paid work on the user's
6153
+ * behalf. `InteractionController.recordExternalCost` explains why the resolution path cannot
6154
+ * carry this.
5965
6155
  *
5966
- * **Why this exists separately from `resolveInteraction`.** A widget's spend reaches the session
5967
- * total only through `InteractionResult.costUsd`, which is read exactly once — `resolveInteraction`
5968
- * does its whole job inside `if (interaction)` and then deletes the entry from
5969
- * `pendingInteractions`. That is correct for the resolution itself, but it assumes a widget stops
5970
- * costing money when it resolves, and several deliberately do not: a widget that resolves EARLY so
5971
- * the assistant stops waiting (options shown, a preview rendered) may stay interactive for minutes
5972
- * afterwards and do more paid work on the user's behalf. Re-emitting `interaction-completed` does
5973
- * not help — with the interaction gone from the map the call is a silent no-op — so that spend had
5974
- * nowhere to go and was simply missing from the figure shown to the user.
5975
- *
5976
- * Additive by design: the caller reports a DELTA, not a running total, so repeated calls sum. It
5977
- * touches only `externalCostUsd`; the recorded `interaction.resolved` payload is left exactly as
5978
- * the resolution wrote it, because this is not a second resolution and must not read like one.
6156
+ * Additive by design: the caller reports a DELTA, not a running total, so repeated calls sum.
5979
6157
  *
5980
6158
  * Deliberately does NOT require the interaction to be pending, does not resurrect it, and has no
5981
6159
  * loading/turn side effects — this is bookkeeping about a turn that already happened.
@@ -5985,106 +6163,19 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5985
6163
  * @returns `true` when the spend was recorded; `false` when it was not — either the value was
5986
6164
  * not a positive finite amount, or the message is no longer in history (trimmed or condensed
5987
6165
  * away). A real outcome a caller may want to log rather than a failure to throw on; every
5988
- * `false` also records an `interaction.external-cost-dropped` meta event here, so money can
5989
- * never disappear without a trace.
6166
+ * `false` also records an `interaction.external-cost-dropped` meta event, so money can never
6167
+ * disappear without a trace.
5990
6168
  * @beta
5991
6169
  */
5992
6170
  recordExternalCost(interactionId, costUsd) {
5993
- const drop = (reason) => {
5994
- recordMetaEvent(this.sessionKey, "interaction.external-cost-dropped", {
5995
- interactionId,
5996
- costUsd,
5997
- reason
5998
- });
5999
- return false;
6000
- };
6001
- if (!interactionId) return drop("no interactionId");
6002
- if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd <= 0) {
6003
- return drop("not a positive finite amount");
6004
- }
6005
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6006
- if (idx === -1) return drop("interaction message no longer in history");
6007
- this.history[idx] = {
6008
- ...this.history[idx],
6009
- externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd
6010
- };
6011
- recordMetaEvent(this.sessionKey, "interaction.external-cost", { interactionId, costUsd });
6012
- this.dispatchEvent(
6013
- new CustomEvent("external-cost-recorded", {
6014
- detail: { interactionId, costUsd }
6015
- })
6016
- );
6017
- return true;
6171
+ return this.interactions.recordExternalCost(interactionId, costUsd);
6018
6172
  }
6019
6173
  /**
6020
6174
  * Resolve a pending interaction. The wrapper component calls this on completion.
6021
6175
  * Marks the interaction message as resolved so it renders read-only on re-render.
6022
6176
  */
6023
6177
  resolveInteraction(interactionId, result) {
6024
- const interaction = this.pendingInteractions.get(interactionId);
6025
- if (interaction) {
6026
- if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
6027
- const harvestedDiagnostics = result?.diagnostics;
6028
- if (Array.isArray(harvestedDiagnostics) && harvestedDiagnostics.length) {
6029
- const batch = this.externalDiagnosticsBatches += 1;
6030
- const externalEvents = [];
6031
- let turnCount = 0;
6032
- let messageCount = 0;
6033
- for (const raw of harvestedDiagnostics) {
6034
- if (!raw || typeof raw !== "object" || typeof raw.kind !== "string") {
6035
- continue;
6036
- }
6037
- const entry = raw;
6038
- if (entry.kind === "event") {
6039
- const { kind: _kind, ...event } = entry;
6040
- externalEvents.push(event);
6041
- } else if (entry.kind === "turn") {
6042
- this.externalDiagnostics.push({
6043
- ...entry,
6044
- turnIndex: `server-generation.${batch}:${turnCount}`
6045
- });
6046
- turnCount += 1;
6047
- } else if (entry.kind === "message") {
6048
- messageCount += 1;
6049
- this.externalDiagnostics.push(entry);
6050
- }
6051
- }
6052
- if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
6053
- recordMetaEvent(this.sessionKey, "external-diagnostics.folded", {
6054
- interactionId,
6055
- batch,
6056
- counts: { turn: turnCount, event: externalEvents.length, message: messageCount }
6057
- });
6058
- }
6059
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6060
- if (idx !== -1) {
6061
- const costUsd = typeof result?.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd > 0 ? result.costUsd : void 0;
6062
- this.history[idx] = {
6063
- ...this.history[idx],
6064
- interaction: { ...this.history[idx].interaction, resolved: result },
6065
- ...costUsd != null ? { externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd } : {}
6066
- };
6067
- this.dispatchEvent(
6068
- new CustomEvent("history-updated", {
6069
- detail: this.history
6070
- })
6071
- );
6072
- }
6073
- if (typeof result?.userMessage === "string" && result.userMessage.trim()) {
6074
- this.appendToHistory({ role: "synthetic-user", content: result.userMessage });
6075
- }
6076
- if (interaction.overrideId) {
6077
- this.dispatchEvent(new CustomEvent("interaction-stop", { detail: { interactionId } }));
6078
- }
6079
- recordMetaEvent(this.sessionKey, "interaction.resolved", { interactionId });
6080
- this.activityBus.publish("interaction-resolved", void 0);
6081
- interaction.resolve(result);
6082
- this.pendingInteractions.delete(interactionId);
6083
- this.interactionContexts.get(interactionId)?.dispose();
6084
- this.interactionContexts.delete(interactionId);
6085
- } else {
6086
- logger2.warn(`Interaction with ID ${interactionId} not found.`);
6087
- }
6178
+ this.interactions.resolve(interactionId, result);
6088
6179
  }
6089
6180
  /**
6090
6181
  * The live {@link InteractionContext} for a pending interaction (GENC-1390), or
@@ -6092,23 +6183,17 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6092
6183
  * host) to build the widget's pending `interaction` descriptor.
6093
6184
  */
6094
6185
  getInteractionContext(interactionId) {
6095
- return this.interactionContexts.get(interactionId)?.context;
6186
+ return this.interactions.context(interactionId);
6096
6187
  }
6097
6188
  /**
6098
- * Resolve every in-flight interaction with a `'timeout'` result, closing its
6099
- * widget read-only. Used when a sub-agent that owns the open widget times out:
6100
- * the widget is parked in *this* driver's map (interactions resolve on the
6101
- * host, not the child), so cancelling it here unblocks the sub-agent handler
6102
- * awaiting it. The "one interaction at a time" invariant means there is at
6103
- * most one.
6189
+ * Replace the history message owning `interactionId`. `false` when it is no longer in history.
6190
+ * Does not announce the change — the interaction paths differ on whether they should.
6104
6191
  */
6105
- cancelPendingInteractions(message) {
6106
- for (const interactionId of [...this.pendingInteractions.keys()]) {
6107
- this.resolveInteraction(interactionId, {
6108
- status: "timeout",
6109
- message
6110
- });
6111
- }
6192
+ patchInteractionMessage(interactionId, patch) {
6193
+ const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6194
+ if (idx === -1) return false;
6195
+ this.history[idx] = patch(this.history[idx]);
6196
+ return true;
6112
6197
  }
6113
6198
  /**
6114
6199
  * Seeds the driver with a prior conversation history so a new component instance
@@ -6116,11 +6201,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6116
6201
  */
6117
6202
  loadHistory(messages) {
6118
6203
  this.history = [...messages];
6119
- this.dispatchEvent(
6120
- new CustomEvent("history-updated", {
6121
- detail: this.history
6122
- })
6123
- );
6204
+ this.notifyHistoryUpdated();
6124
6205
  }
6125
6206
  async sendMessage(userInput, attachments) {
6126
6207
  if (this.busy || !userInput.trim() && !attachments?.length) return { reason: "done" };
@@ -6340,7 +6421,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6340
6421
  if (winner === "timeout") {
6341
6422
  timedOut = true;
6342
6423
  child.dispose();
6343
- this.cancelPendingInteractions(`Sub-agent "${name}" timed out after ${timeoutMs}ms.`);
6424
+ this.interactions.cancelAll(`Sub-agent "${name}" timed out after ${timeoutMs}ms.`);
6344
6425
  await runPromise.catch(() => void 0);
6345
6426
  }
6346
6427
  } finally {
@@ -6353,7 +6434,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6353
6434
  child.removeEventListener("provider-changed", forwardProviderChanged);
6354
6435
  child.dispose();
6355
6436
  this.dispatchEvent(new CustomEvent("sub-agent-stop", { detail: lifecycleDetail }));
6356
- this.forwardSubAgentSnapshots(child.getTurnSnapshots());
6437
+ this.turnRecorder.forwardSubAgent(child.getTurnSnapshots());
6357
6438
  const parentName = this.activeAgentName ?? "?";
6358
6439
  const harvested = getMetaEvents(childSessionKey).filter((e) => HARVESTED_SUBAGENT_EVENTS.has(e.type)).map((e) => {
6359
6440
  const existing = e.detail?.subAgent;
@@ -6441,7 +6522,10 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6441
6522
  // ---------------------------------------------------------------------------
6442
6523
  // Tool loop
6443
6524
  // ---------------------------------------------------------------------------
6444
- // oxlint-disable-next-line complexity
6525
+ // Over both ceilings deliberately: the per-iteration phases share a dozen accumulators, so
6526
+ // splitting threads that state through signatures rather than removing it. `turn-setup.ts`
6527
+ // (docs/code-health.md §11.3) is the intended carve-up.
6528
+ // oxlint-disable-next-line complexity, max-lines-per-function
6445
6529
  async runToolLoop(userInput, attachments, transientPrimer) {
6446
6530
  if (!this.systemPrompt) {
6447
6531
  logger2.warn(
@@ -6597,12 +6681,15 @@ ${EMPTY_RESPONSE_NUDGE}` : "";
6597
6681
  ${tailBody}
6598
6682
  </system-reminder>` : void 0;
6599
6683
  const effectiveToolChoice = resolvedToolChoice ?? (this.isSubAgent ? "required" : void 0);
6600
- const turnSnapshot = this.recordTurnSnapshot(
6684
+ const turnSnapshot = this.turnRecorder.record({
6685
+ agentName: this.activeAgentName,
6686
+ agentLabel: this.activeAgentLabel,
6601
6687
  systemPrompt,
6602
- resolvedTemperature,
6603
- effectiveToolChoice,
6604
- tailContext
6605
- );
6688
+ tailContext,
6689
+ toolNames: this.toolDefinitions.map((t) => t.name),
6690
+ temperature: resolvedTemperature,
6691
+ toolChoice: effectiveToolChoice
6692
+ });
6606
6693
  const userInputForCall = currentInput;
6607
6694
  const attachmentsForCall = currentAttachments;
6608
6695
  currentInput = "";
@@ -7104,11 +7191,7 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
7104
7191
  };
7105
7192
  });
7106
7193
  this.history[tcMsgIdx] = { ...tcMsg, toolCalls: annotatedCalls };
7107
- this.dispatchEvent(
7108
- new CustomEvent("history-updated", {
7109
- detail: this.history
7110
- })
7111
- );
7194
+ this.notifyHistoryUpdated();
7112
7195
  }
7113
7196
  if (hitUnknownToolLimit) {
7114
7197
  logger2.error(
@@ -7191,6 +7274,10 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
7191
7274
  } : {}
7192
7275
  };
7193
7276
  this.history = [...this.history, tagged];
7277
+ this.notifyHistoryUpdated();
7278
+ }
7279
+ /** Announce the current history to the host. */
7280
+ notifyHistoryUpdated() {
7194
7281
  this.dispatchEvent(
7195
7282
  new CustomEvent("history-updated", {
7196
7283
  detail: this.history