mixdog 0.9.53 → 0.9.55

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 (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -2,14 +2,10 @@
2
2
  * src/tui/engine/context-state.mjs - route/context/agent-status derivations.
3
3
  *
4
4
  * Extracted from engine.mjs unchanged. These read the live runtime + store
5
- * snapshot and (for the two sync helpers) mutate state.stats / the display
6
- * context fields IN PLACE the exact same object the store owns — so the
7
- * immutable-emit contract is preserved by their callers, which follow up with
8
- * a set({ stats: { ...state.stats }, ...routeState() }). getState() must return
9
- * the live (latest) store object; getPendingSessionReset() gates the stats
10
- * sync exactly as the old inline `pendingSessionReset` closure did.
5
+ * snapshot. The two sync helpers stage immutable draft patches through
6
+ * updateState; callers still follow with set(...) to schedule publication.
11
7
  */
12
- export function createContextState({ runtime, getState, getPendingSessionReset }) {
8
+ export function createContextState({ runtime, getState, updateState, getPendingSessionReset }) {
13
9
  const autoClearState = () => runtime.getAutoClear?.() || runtime.autoClear || { enabled: true, idleMs: 60 * 60 * 1000, custom: false, providerDefault: 60 * 60 * 1000, provider: null, minContextPercent: 10 };
14
10
  const AGENT_STATUS_CACHE_MS = 250;
15
11
  let agentStatusCache = null;
@@ -68,7 +64,6 @@ export function createContextState({ runtime, getState, getPendingSessionReset }
68
64
  function syncContextDisplayFields(ctx = null) {
69
65
  const status = ctx || runtime.contextStatus?.() || null;
70
66
  if (!status) return;
71
- const state = getState();
72
67
  const displayWindow = Number(status.contextWindow || 0);
73
68
  const compactBoundary = Number(status.compaction?.boundaryTokens || 0);
74
69
  // Prefer the resolved trigger (boundary - buffer): the statusline uses it
@@ -80,9 +75,11 @@ export function createContextState({ runtime, getState, getPendingSessionReset }
80
75
  || runtime.session?.autoCompactTokenLimit
81
76
  || 0,
82
77
  );
83
- if (displayWindow > 0) state.displayContextWindow = displayWindow;
84
- if (compactBoundary > 0) state.compactBoundaryTokens = compactBoundary;
85
- if (autoCompact > 0) state.autoCompactTokenLimit = autoCompact;
78
+ const patch = {};
79
+ if (displayWindow > 0) patch.displayContextWindow = displayWindow;
80
+ if (compactBoundary > 0) patch.compactBoundaryTokens = compactBoundary;
81
+ if (autoCompact > 0) patch.autoCompactTokenLimit = autoCompact;
82
+ if (Object.keys(patch).length > 0) updateState(patch);
86
83
  }
87
84
 
88
85
  const syncContextStats = ({ allowEstimated = false } = {}) => {
@@ -91,17 +88,19 @@ export function createContextState({ runtime, getState, getPendingSessionReset }
91
88
  if (!ctx) return null;
92
89
  syncContextDisplayFields(ctx);
93
90
  const state = getState();
94
- const hasProviderUsage = Number(state.stats.latestPromptTokens || state.stats.latestInputTokens || state.stats.inputTokens || 0) > 0;
91
+ const stats = { ...state.stats };
92
+ const hasProviderUsage = Number(stats.latestPromptTokens || stats.latestInputTokens || stats.inputTokens || 0) > 0;
95
93
  const hasApiContextUsage = Number(ctx?.lastApiRequestTokens ?? ctx?.usage?.lastContextTokens ?? 0) > 0;
96
94
  const hasTurnActivity = state.busy === true
97
95
  || state.spinner != null
98
96
  || state.thinking != null;
99
97
  const isFreshSession = !hasProviderUsage && !hasApiContextUsage && !hasTurnActivity;
100
98
  if (isFreshSession) {
101
- state.stats.currentEstimatedContextTokens = 0;
102
- state.stats.currentContextTokens = 0;
103
- state.stats.currentContextSource = null;
104
- state.stats.currentContextUpdatedAt = Date.now();
99
+ stats.currentEstimatedContextTokens = 0;
100
+ stats.currentContextTokens = 0;
101
+ stats.currentContextSource = null;
102
+ stats.currentContextUpdatedAt = Date.now();
103
+ updateState({ stats });
105
104
  return ctx;
106
105
  }
107
106
  const estimatedTokens = Math.max(0, Number(ctx.currentEstimatedTokens ?? ctx.usedTokens ?? 0));
@@ -114,32 +113,33 @@ export function createContextState({ runtime, getState, getPendingSessionReset }
114
113
  );
115
114
  if (!allowEstimated && !hasProviderUsage && usedSource !== 'last_api_request') return ctx;
116
115
  if (shouldPublishEstimate) {
117
- state.stats.currentEstimatedContextTokens = estimatedTokens;
118
- state.stats.currentContextSource = 'estimated';
119
- state.stats.currentContextTokens = 0;
116
+ stats.currentEstimatedContextTokens = estimatedTokens;
117
+ stats.currentContextSource = 'estimated';
118
+ stats.currentContextTokens = 0;
120
119
  } else if (allowEstimated && (hasProviderUsage || hasApiContextUsage || hasTurnActivity)) {
121
- state.stats.currentEstimatedContextTokens = estimatedTokens;
122
- state.stats.currentContextSource = usedSource || (estimatedTokens > 0 ? 'estimated' : null);
123
- const publishedSource = String(state.stats.currentContextSource || '').toLowerCase();
120
+ stats.currentEstimatedContextTokens = estimatedTokens;
121
+ stats.currentContextSource = usedSource || (estimatedTokens > 0 ? 'estimated' : null);
122
+ const publishedSource = String(stats.currentContextSource || '').toLowerCase();
124
123
  if (publishedSource === 'last_api_request') {
125
124
  const apiUsed = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
126
- state.stats.currentContextTokens = apiUsed;
125
+ stats.currentContextTokens = apiUsed;
127
126
  } else if (publishedSource === 'estimated') {
128
- state.stats.currentContextTokens = 0;
127
+ stats.currentContextTokens = 0;
129
128
  } else {
130
- state.stats.currentContextTokens = usedTokens > 0 ? usedTokens : 0;
129
+ stats.currentContextTokens = usedTokens > 0 ? usedTokens : 0;
131
130
  }
132
131
  } else {
133
- state.stats.currentEstimatedContextTokens = 0;
132
+ stats.currentEstimatedContextTokens = 0;
134
133
  if (usedSource === 'last_api_request' && Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0) > 0) {
135
- state.stats.currentContextTokens = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
136
- state.stats.currentContextSource = 'last_api_request';
134
+ stats.currentContextTokens = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
135
+ stats.currentContextSource = 'last_api_request';
137
136
  } else {
138
- state.stats.currentContextTokens = 0;
139
- state.stats.currentContextSource = null;
137
+ stats.currentContextTokens = 0;
138
+ stats.currentContextSource = null;
140
139
  }
141
140
  }
142
- state.stats.currentContextUpdatedAt = Date.now();
141
+ stats.currentContextUpdatedAt = Date.now();
142
+ updateState({ stats });
143
143
  return ctx;
144
144
  };
145
145
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Coalesces external-store publications to one terminal frame snapshot.
3
+ * Internal draft mutations remain synchronous. The public snapshot, listener
4
+ * delivery, and structureRevision commit are swapped atomically at flush.
5
+ */
6
+ export function createFrameBatchedStorePublisher({
7
+ getState,
8
+ publishState,
9
+ listeners,
10
+ isDisposed = () => false,
11
+ frameMs = 16,
12
+ setTimer = setTimeout,
13
+ clearTimer = clearTimeout,
14
+ enqueueMicrotask = queueMicrotask,
15
+ }) {
16
+ let timer = null;
17
+ let emitPending = false;
18
+ let structureChangePending = false;
19
+ let immediatePending = false;
20
+
21
+ const flush = () => {
22
+ if (timer !== null) {
23
+ clearTimer(timer);
24
+ timer = null;
25
+ }
26
+ immediatePending = false;
27
+ if (!emitPending || isDisposed()) return false;
28
+ emitPending = false;
29
+ const current = getState();
30
+ let next = current;
31
+ if (structureChangePending) {
32
+ structureChangePending = false;
33
+ next = {
34
+ ...current,
35
+ structureRevision: (Number(current.structureRevision) || 0) + 1,
36
+ };
37
+ }
38
+ publishState(next);
39
+ for (const listener of listeners) listener();
40
+ return true;
41
+ };
42
+
43
+ const emit = () => {
44
+ emitPending = true;
45
+ if (timer !== null || isDisposed()) return;
46
+ timer = setTimer(flush, frameMs);
47
+ timer?.unref?.();
48
+ };
49
+
50
+ const markStructureChange = () => {
51
+ structureChangePending = true;
52
+ };
53
+
54
+ // Let the current synchronous mutation chain finish, then publish without
55
+ // waiting for the frame timer (input echo / terminal turn boundaries).
56
+ const flushImmediate = () => {
57
+ if (!emitPending || immediatePending || isDisposed()) return false;
58
+ immediatePending = true;
59
+ enqueueMicrotask(flush);
60
+ return true;
61
+ };
62
+
63
+ const dispose = () => {
64
+ // Disposal is itself an immediate boundary: publish the final pending
65
+ // snapshot once, while subscribers are still present, then disarm.
66
+ if (emitPending && !isDisposed()) flush();
67
+ else if (timer !== null) clearTimer(timer);
68
+ timer = null;
69
+ emitPending = false;
70
+ structureChangePending = false;
71
+ immediatePending = false;
72
+ };
73
+
74
+ return { emit, flush, flushImmediate, markStructureChange, dispose };
75
+ }
@@ -10,7 +10,7 @@ import { getVoiceStatus, toggleVoice } from '../lib/voice-setup.mjs';
10
10
 
11
11
  export function createEngineApiB(bag) {
12
12
  const {
13
- runtime, nextId, flags, lifecycle, listeners, getState, set, replaceItems, pushNotice, removeNotice, setProgressHint, clearToastTimers, routeState, syncContextStats, finishToolApproval, denyAllToolApprovals, restoreLeadSteeringFromDisk, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, resetStatsAndSyncContext,
13
+ runtime, nextId, flags, lifecycle, listeners, getState, set, disposeEmit, replaceItems, pushNotice, removeNotice, setProgressHint, clearToastTimers, disposeTranscriptSpill, routeState, syncContextStats, finishToolApproval, denyAllToolApprovals, restoreLeadSteeringFromDisk, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, commitTuiSessionReset, resetStatsAndSyncContext,
14
14
  } = bag;
15
15
  return {
16
16
  resolveToolApproval: (id, decision = {}) => {
@@ -363,6 +363,7 @@ export function createEngineApiB(bag) {
363
363
  flags.pendingSessionReset = false;
364
364
  resetStatsAndSyncContext();
365
365
  set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...getState().stats } });
366
+ commitTuiSessionReset(rollbackSnapshot);
366
367
  flags.lastUserActivityAt = Date.now();
367
368
  return true;
368
369
  } catch (error) {
@@ -400,6 +401,7 @@ export function createEngineApiB(bag) {
400
401
  ...routeState(),
401
402
  stats: { ...getState().stats },
402
403
  });
404
+ commitTuiSessionReset(rollbackSnapshot);
403
405
  return true;
404
406
  } catch (error) {
405
407
  restoreTuiAfterFailedSessionReset(rollbackSnapshot);
@@ -432,6 +434,7 @@ export function createEngineApiB(bag) {
432
434
  flags.pendingSessionReset = false;
433
435
  resetStatsAndSyncContext();
434
436
  set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...getState().stats } });
437
+ commitTuiSessionReset(rollbackSnapshot);
435
438
  return true;
436
439
  } catch (error) {
437
440
  restoreTuiAfterFailedSessionReset(rollbackSnapshot);
@@ -506,8 +509,10 @@ export function createEngineApiB(bag) {
506
509
 
507
510
  dispose: async (reason = 'cli-react-exit', options = {}) => {
508
511
  if (flags.disposed) return;
512
+ disposeEmit?.();
509
513
  flags.disposed = true;
510
514
  clearToastTimers();
515
+ disposeTranscriptSpill?.();
511
516
  try { clearInterval(lifecycle.runtimePulseTimer); } catch {}
512
517
  try { lifecycle.unsubscribeRuntimeNotifications?.(); } catch {}
513
518
  lifecycle.unsubscribeRuntimeNotifications = null;
@@ -24,11 +24,13 @@ export function createEngineApi(bag) {
24
24
 
25
25
  export function createEngineApiA(bag) {
26
26
  const {
27
- runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
27
+ runtime, nextId, flags, pending, listeners, getState, getPublishedState = getState, set, flushEmitImmediate, pushItem, patchItem, replaceItems, restoreOlderTranscript, restoreNewerTranscript, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
28
28
  } = bag;
29
29
  return {
30
- getState: () => getState(),
30
+ getState: () => getPublishedState(),
31
31
  patchItem,
32
+ restoreOlderTranscript,
33
+ restoreNewerTranscript,
32
34
  subscribe: (listener) => {
33
35
  listeners.add(listener);
34
36
  return () => listeners.delete(listener);
@@ -592,7 +594,10 @@ export function createEngineApiA(bag) {
592
594
  if (idSet.size > 0) {
593
595
  const items = getState().items.filter((item) => !idSet.has(item?.id));
594
596
  if (items.length !== getState().items.length) {
595
- patch.items = replaceItems(items);
597
+ patch.items = replaceItems(items, {
598
+ preserveSpill: true,
599
+ preserveTranscriptView: true,
600
+ });
596
601
  }
597
602
  }
598
603
  set(patch);
@@ -637,6 +642,7 @@ export function createEngineApiA(bag) {
637
642
  flags.drainEpoch = (Number(flags.drainEpoch) || 0) + 1;
638
643
  if (flags.draining) flags.draining = false;
639
644
  pushNotice('Interrupt did not settle — input restored.', 'warn', { transcript: true });
645
+ flushEmitImmediate?.();
640
646
  if (pending.length > 0 && typeof drain === 'function') void drain();
641
647
  // busy→false here bypasses the normal turn-end + drain-finally flushes,
642
648
  // so re-arm any deferred completion kick explicitly (idempotent).
@@ -9,7 +9,8 @@ import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersi
9
9
 
10
10
  export function createSessionFlow(bag) {
11
11
  const {
12
- runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, clearToastTimers, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
12
+ runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, clearToastTimers, getState, set, flushEmitImmediate, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
13
+ snapshotTranscriptSpill, restoreTranscriptSpill, releaseTranscriptSpill,
13
14
  } = bag;
14
15
 
15
16
  // Upper bound on the awaited compacting clear. requireCompactSuccess makes
@@ -272,7 +273,10 @@ export function createSessionFlow(bag) {
272
273
  if (getState().busy && shouldMirrorSteeringEntry(entry)) {
273
274
  appendTuiSteeringPersist(leadSessionId(), entry);
274
275
  }
275
- if (isQueuedEntryVisible(entry)) set({ queued: [...getState().queued, entry] });
276
+ if (isQueuedEntryVisible(entry)) {
277
+ set({ queued: [...getState().queued, entry] });
278
+ if (isQueuedEntryEditable(entry)) flushEmitImmediate?.();
279
+ }
276
280
  if (getState().busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
277
281
  void drain();
278
282
  return true;
@@ -495,19 +499,23 @@ export function createSessionFlow(bag) {
495
499
  }
496
500
 
497
501
  const resetStats = () => {
498
- getState().stats = createSessionStats();
499
- return getState().stats;
502
+ const stats = createSessionStats();
503
+ set({ stats });
504
+ return stats;
500
505
  };
501
506
  const clearUiActivityBeforeContextSync = () => {
502
507
  clearToastTimers();
503
508
  resetAllStreamingMarkdownStablePrefixes();
504
- getState().items = replaceItems([]);
505
- getState().toasts = [];
506
- getState().queued = [];
507
- getState().thinking = null;
508
- getState().spinner = null;
509
- getState().lastTurn = null;
510
- getState().busy = false;
509
+ const items = replaceItems([]);
510
+ set({
511
+ items,
512
+ toasts: [],
513
+ queued: [],
514
+ thinking: null,
515
+ spinner: null,
516
+ lastTurn: null,
517
+ busy: false,
518
+ });
511
519
  pendingNotificationKeys.clear();
512
520
  displayedExecutionNotificationKeys.clear();
513
521
  clearExecutionDedupState?.();
@@ -545,16 +553,26 @@ export function createSessionFlow(bag) {
545
553
  flags.pendingSessionReset = true;
546
554
  clearUiActivityBeforeContextSync();
547
555
  resetStats();
548
- getState().stats.currentContextTokens = 0;
549
- getState().stats.currentEstimatedContextTokens = 0;
550
- getState().stats.currentContextSource = null;
551
- getState().stats.currentContextUpdatedAt = Date.now();
552
- getState().displayContextWindow = 0;
553
- getState().compactBoundaryTokens = 0;
554
- getState().autoCompactTokenLimit = 0;
556
+ set({
557
+ stats: {
558
+ ...getState().stats,
559
+ currentContextTokens: 0,
560
+ currentEstimatedContextTokens: 0,
561
+ currentContextSource: null,
562
+ currentContextUpdatedAt: Date.now(),
563
+ },
564
+ displayContextWindow: 0,
565
+ compactBoundaryTokens: 0,
566
+ autoCompactTokenLimit: 0,
567
+ });
555
568
  };
556
569
  const snapshotTuiBeforeSessionReset = () => ({
557
570
  items: getState().items.slice(),
571
+ transcriptViewItems: Array.isArray(getState().transcriptViewItems)
572
+ ? getState().transcriptViewItems.slice()
573
+ : null,
574
+ transcriptViewRevision: getState().transcriptViewRevision,
575
+ transcriptSpill: snapshotTranscriptSpill?.() || null,
558
576
  toasts: getState().toasts.slice(),
559
577
  queued: getState().queued.slice(),
560
578
  thinking: getState().thinking,
@@ -567,14 +585,20 @@ export function createSessionFlow(bag) {
567
585
  const restoreTuiAfterFailedSessionReset = (snapshot) => {
568
586
  if (!snapshot) return;
569
587
  flags.pendingSessionReset = false;
570
- getState().items = replaceItems(snapshot.items);
571
- getState().toasts = snapshot.toasts.slice();
572
- getState().queued = snapshot.queued.slice();
573
- getState().thinking = snapshot.thinking;
574
- getState().spinner = snapshot.spinner;
575
- getState().lastTurn = snapshot.lastTurn;
576
- getState().busy = snapshot.busy;
577
- getState().stats = { ...snapshot.stats };
588
+ restoreTranscriptSpill?.(snapshot.transcriptSpill);
589
+ const items = replaceItems(snapshot.items, { preserveSpill: true });
590
+ set({
591
+ items,
592
+ transcriptViewItems: snapshot.transcriptViewItems,
593
+ transcriptViewRevision: snapshot.transcriptViewRevision,
594
+ toasts: snapshot.toasts.slice(),
595
+ queued: snapshot.queued.slice(),
596
+ thinking: snapshot.thinking,
597
+ spinner: snapshot.spinner,
598
+ lastTurn: snapshot.lastTurn,
599
+ busy: snapshot.busy,
600
+ stats: { ...snapshot.stats },
601
+ });
578
602
  syncContextStats({ allowEstimated: true });
579
603
  set({
580
604
  items: getState().items,
@@ -589,11 +613,14 @@ export function createSessionFlow(bag) {
589
613
  ...agentStatusState(),
590
614
  });
591
615
  };
616
+ const commitTuiSessionReset = (snapshot) => {
617
+ releaseTranscriptSpill?.(snapshot?.transcriptSpill);
618
+ };
592
619
  const resetStatsAndSyncContext = () => {
593
620
  resetStats();
594
621
  syncContextStats({ allowEstimated: true });
595
622
  return getState().stats;
596
623
  };
597
624
 
598
- return { leadSessionId, shouldMirrorSteeringEntry, commitSteeringQueueEntries, makeQueueEntry, removeQueuedEntries, requeueEntriesFront, dequeueQueueBatch, drain, enqueue, drainPendingSteering, restoreLeadSteeringFromDisk, autoClearBeforeSubmit, performSessionClear, restoreQueued, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, resetStatsAndSyncContext };
625
+ return { leadSessionId, shouldMirrorSteeringEntry, commitSteeringQueueEntries, makeQueueEntry, removeQueuedEntries, requeueEntriesFront, dequeueQueueBatch, drain, enqueue, drainPendingSteering, restoreLeadSteeringFromDisk, autoClearBeforeSubmit, performSessionClear, restoreQueued, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, commitTuiSessionReset, resetStatsAndSyncContext };
599
626
  }
@@ -12,7 +12,7 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
12
12
 
13
13
  export function createRunTurn(bag) {
14
14
  const {
15
- runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
15
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, flushEmit, flushEmitImmediate, pushItem, appendItems, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
16
16
  } = bag;
17
17
  // Small fallbacks keep isolated createRunTurn harnesses source-compatible;
18
18
  // the real engine supplies atomic implementations that also maintain revision.
@@ -179,6 +179,7 @@ export function createRunTurn(bag) {
179
179
  // drain-finally flush, so a deferred completion kick would never re-arm.
180
180
  // Fire it explicitly (idempotent: guarded by deferred flag + !busy).
181
181
  flushDeferredExecutionPendingResumeKick();
182
+ flushEmitImmediate?.();
182
183
  }, 5_000);
183
184
  watchdogGraceTimer.unref?.();
184
185
  }, delay);
@@ -354,6 +355,10 @@ export function createRunTurn(bag) {
354
355
  const appendItemsBatch = (newItems, extra = {}) => {
355
356
  if (!isCurrentTurn()) return;
356
357
  if (!newItems || !newItems.length) { set(extra); return; }
358
+ if (appendItems) {
359
+ appendItems(newItems, extra);
360
+ return;
361
+ }
357
362
  const base = getState().items.length;
358
363
  const items = [...getState().items, ...newItems];
359
364
  for (let i = 0; i < newItems.length; i++) {
@@ -1073,6 +1078,37 @@ export function createRunTurn(bag) {
1073
1078
  if (thinkingLastEndedAt) _pendingThinkingLastEndedAt = thinkingLastEndedAt;
1074
1079
  scheduleStreamFlush();
1075
1080
  },
1081
+ onTextReset: ({ chars } = {}) => {
1082
+ if (!isCurrentTurn()) return false;
1083
+ const count = Math.max(0, Number(chars) || 0);
1084
+ if (!count) return false;
1085
+ flushStreamBatch();
1086
+ assistantText = assistantText.slice(0, Math.max(0, assistantText.length - count));
1087
+ currentAssistantText = currentAssistantText.slice(
1088
+ 0,
1089
+ Math.max(0, currentAssistantText.length - count),
1090
+ );
1091
+ _streamScanLen = 0;
1092
+ _lastNewlineIdx = -1;
1093
+ _emittedNewlineIdx = -2;
1094
+ _emittedVisibleText = '';
1095
+ if (currentAssistantId) {
1096
+ if (currentAssistantText) {
1097
+ set({
1098
+ streamingTail: {
1099
+ kind: 'assistant',
1100
+ id: currentAssistantId,
1101
+ text: currentAssistantText,
1102
+ streaming: true,
1103
+ },
1104
+ });
1105
+ } else {
1106
+ clearStreamingTail(currentAssistantId);
1107
+ currentAssistantId = null;
1108
+ }
1109
+ }
1110
+ return true;
1111
+ },
1076
1112
  onAssistantText: (text) => {
1077
1113
  // Mid-turn assistant text that precedes a tool call. Providers that
1078
1114
  // stream via onTextDelta already accumulated it into assistantText;
@@ -1113,7 +1149,9 @@ export function createRunTurn(bag) {
1113
1149
  },
1114
1150
  onUsageDelta: (delta) => {
1115
1151
  if (!markTurnProgress('usage-delta')) return;
1116
- applyUsageDelta(getState().stats, delta);
1152
+ const stats = { ...getState().stats };
1153
+ applyUsageDelta(stats, delta);
1154
+ set({ stats });
1117
1155
  syncContextStats({ allowEstimated: true });
1118
1156
  const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
1119
1157
  const currentTurnOutput = Math.max(0, getState().stats.outputTokens - outputBaseline);
@@ -1269,7 +1307,7 @@ export function createRunTurn(bag) {
1269
1307
  && !assistantOutput
1270
1308
  && !producedTranscriptItem;
1271
1309
  if (!isNoOpTurn) {
1272
- getState().stats.turns = (getState().stats.turns || 0) + 1;
1310
+ set({ stats: { ...getState().stats, turns: (getState().stats.turns || 0) + 1 } });
1273
1311
  }
1274
1312
  // Pin the post-think summary into the transcript right after this turn's
1275
1313
  // output so it scrolls up with the answer and stays in the scrollback,
@@ -1295,6 +1333,9 @@ export function createRunTurn(bag) {
1295
1333
  // Shared UI getState(): a stale unwind must not wipe a newer turn's live
1296
1334
  // tool-summary line (same epoch rule as the shared-getState() block above).
1297
1335
  if (flags.leadTurnEpoch === turnEpoch) clearActiveToolSummary();
1336
+ // Turn completion is latency-sensitive and must publish busy=false plus all
1337
+ // terminal transcript/card mutations as one final snapshot.
1338
+ flushEmit?.();
1298
1339
  _publishedThinkingActive = false; // turn teardown cleared getState().thinking
1299
1340
  const finalStatus = cancelled ? 'cancelled' : (failed ? 'failed' : 'done');
1300
1341
  tuiDebug(`runTurn end turn=${turnIndex} status=${finalStatus} elapsedMs=${Date.now() - startedAt}${watchdogTripped ? ' watchdogTripped=1' : ''} pending=${pending.length}`);