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
@@ -11,6 +11,11 @@
11
11
  * This file keeps the stateful createEngineSession store + notification plan.
12
12
  */
13
13
  import { performance } from 'node:perf_hooks';
14
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { Worker } from 'node:worker_threads';
14
19
  import {
15
20
  aggregateToolCategoryEntry,
16
21
  classifyToolCategory,
@@ -98,6 +103,7 @@ import {
98
103
  import { createSessionFlow } from './engine/session-flow.mjs';
99
104
  import { createRunTurn } from './engine/turn.mjs';
100
105
  import { createEngineApi } from './engine/session-api.mjs';
106
+ import { createFrameBatchedStorePublisher } from './engine/frame-batched-store.mjs';
101
107
 
102
108
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
103
109
  // src/tui/dist/index.mjs.
@@ -134,6 +140,367 @@ const tuiDebug = (msg) => {
134
140
  let _idSeq = 0;
135
141
  const nextId = () => `it_${++_idSeq}`;
136
142
 
143
+ export const TRANSCRIPT_LIVE_ITEM_CAP = 512;
144
+ export const TRANSCRIPT_SPILL_CHUNK_ITEMS = 128;
145
+ const TRANSCRIPT_RESTORE_OVERLAP_ITEMS = 64;
146
+ const TRANSCRIPT_SPILL_STALE_MS = 24 * 60 * 60 * 1000;
147
+ const TRANSCRIPT_SPILL_HEARTBEAT_MS = 10_000;
148
+ const TRANSCRIPT_PROCESS_NONCE = randomUUID();
149
+
150
+ export function cleanupStaleTranscriptSpillDirs({
151
+ root = tmpdir(),
152
+ now = Date.now(),
153
+ staleMs = TRANSCRIPT_SPILL_STALE_MS,
154
+ } = {}) {
155
+ try {
156
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
157
+ if (!entry.isDirectory() || !entry.name.startsWith('mixdog-transcript-')) continue;
158
+ const path = join(root, entry.name);
159
+ try {
160
+ const ownerPid = Number(/^mixdog-transcript-(\d+)-/.exec(entry.name)?.[1]);
161
+ let pidAlive = false;
162
+ if (ownerPid > 0) {
163
+ try { process.kill(ownerPid, 0); pidAlive = true; } catch {}
164
+ }
165
+ if (!pidAlive) {
166
+ rmSync(path, { recursive: true, force: true });
167
+ continue;
168
+ }
169
+ // A fresh heartbeat proves the owning process is running. A stale one
170
+ // is ambiguous (suspended owner vs PID reuse), so retain it for the
171
+ // generous staleMs grace period, then reclaim it even if that PID is
172
+ // currently alive. This avoids both short suspension data loss and
173
+ // immortal crash leftovers after PID reuse.
174
+ let heartbeatAge;
175
+ try {
176
+ heartbeatAge = now - statSync(join(path, 'heartbeat')).mtimeMs;
177
+ } catch {
178
+ heartbeatAge = now - statSync(path).mtimeMs;
179
+ }
180
+ if (heartbeatAge <= staleMs) continue;
181
+ rmSync(path, { recursive: true, force: true });
182
+ } catch {}
183
+ }
184
+ } catch {}
185
+ }
186
+
187
+ // Serialized pages deliberately release the old item object graph while
188
+ // keeping every byte restorable. Only `items` is render-live and walkable.
189
+ export function createTranscriptSpillBuffer({
190
+ cap = TRANSCRIPT_LIVE_ITEM_CAP,
191
+ chunkSize = TRANSCRIPT_SPILL_CHUNK_ITEMS,
192
+ workerFactory = (source) => new Worker(source, { eval: true }),
193
+ onWarning = (message) => tuiDebug(message),
194
+ writeTimeoutMs = 5000,
195
+ } = {}) {
196
+ // Publish this process instance's nonce BEFORE cleanup. If the OS reused our
197
+ // PID after a crash, the old directory's owner nonce now differs from the
198
+ // live registry and cannot be mistaken for this process.
199
+ try {
200
+ writeFileSync(
201
+ join(tmpdir(), `mixdog-transcript-owner-${process.pid}.json`),
202
+ JSON.stringify({ pid: process.pid, nonce: TRANSCRIPT_PROCESS_NONCE }),
203
+ 'utf8',
204
+ );
205
+ } catch {}
206
+ cleanupStaleTranscriptSpillDirs();
207
+ const pages = [];
208
+ let cursor = null;
209
+ let spillDir = null;
210
+ let pageSequence = 0;
211
+ let spillWorker = null;
212
+ let workerSpawnCount = 0;
213
+ let activeWrite = null;
214
+ let activeWriteTimer = null;
215
+ let warningEmitted = false;
216
+ let spillDisabled = false;
217
+ const writeQueue = [];
218
+ const heartbeatTimers = new Map();
219
+ const snapshots = new Set();
220
+ const cleanupRecords = (records, directory) => {
221
+ for (const record of records) {
222
+ record.cancelled = true;
223
+ }
224
+ if (directory) {
225
+ const timer = heartbeatTimers.get(directory);
226
+ if (timer) clearInterval(timer);
227
+ heartbeatTimers.delete(directory);
228
+ try { rmSync(directory, { recursive: true, force: true }); } catch {}
229
+ }
230
+ };
231
+ const ensureSpillDir = () => {
232
+ if (spillDir) return spillDir;
233
+ const root = tmpdir();
234
+ writeFileSync(
235
+ join(root, `mixdog-transcript-owner-${process.pid}.json`),
236
+ JSON.stringify({ pid: process.pid, nonce: TRANSCRIPT_PROCESS_NONCE }),
237
+ 'utf8',
238
+ );
239
+ spillDir = mkdtempSync(join(root, `mixdog-transcript-${process.pid}-${TRANSCRIPT_PROCESS_NONCE}-`));
240
+ writeFileSync(
241
+ join(spillDir, 'owner.json'),
242
+ JSON.stringify({ pid: process.pid, nonce: TRANSCRIPT_PROCESS_NONCE }),
243
+ 'utf8',
244
+ );
245
+ const heartbeat = join(spillDir, 'heartbeat');
246
+ writeFileSync(heartbeat, String(Date.now()), 'utf8');
247
+ const heartbeatTimer = setInterval(() => {
248
+ try { writeFileSync(heartbeat, String(Date.now()), 'utf8'); } catch {}
249
+ }, TRANSCRIPT_SPILL_HEARTBEAT_MS);
250
+ heartbeatTimer.unref?.();
251
+ heartbeatTimers.set(spillDir, heartbeatTimer);
252
+ return spillDir;
253
+ };
254
+ const workerSource = `
255
+ const { parentPort } = require('node:worker_threads');
256
+ const { renameSync, writeFileSync } = require('node:fs');
257
+ parentPort.on('message', ({ id, targetPath, tempPath, items }) => {
258
+ try {
259
+ writeFileSync(tempPath, JSON.stringify(items), 'utf8');
260
+ renameSync(tempPath, targetPath);
261
+ parentPort.postMessage({ id, ok: true });
262
+ } catch (error) {
263
+ parentPort.postMessage({ id, ok: false, error: String(error && error.message || error) });
264
+ }
265
+ });`;
266
+ const ensureWorker = () => {
267
+ if (spillWorker) return spillWorker;
268
+ try {
269
+ const worker = workerFactory(workerSource);
270
+ spillWorker = worker;
271
+ workerSpawnCount += 1;
272
+ worker.on('message', (result) => {
273
+ if (spillWorker !== worker || result?.id !== activeWrite?.id) return;
274
+ finishWrite(result?.ok === true, result?.error);
275
+ });
276
+ const failWorker = (error) => {
277
+ if (spillWorker !== worker) return;
278
+ if (activeWriteTimer) clearTimeout(activeWriteTimer);
279
+ activeWriteTimer = null;
280
+ const failed = activeWrite;
281
+ activeWrite = null;
282
+ spillWorker = null;
283
+ try { worker.terminate?.(); } catch {}
284
+ if (failed) retryOrPin(failed, error?.message);
285
+ pumpWrites();
286
+ };
287
+ worker.on('error', failWorker);
288
+ worker.on('exit', (code) => {
289
+ failWorker(new Error(`spill worker exited (${code})`));
290
+ });
291
+ worker.unref?.();
292
+ } catch (error) {
293
+ spillWorker = null;
294
+ if (activeWrite) {
295
+ const failed = activeWrite;
296
+ activeWrite = null;
297
+ retryOrPin(failed, error?.message);
298
+ }
299
+ }
300
+ return spillWorker;
301
+ };
302
+ const retryOrPin = (record, error) => {
303
+ if (record.cancelled) return;
304
+ record.attempts += 1;
305
+ if (record.attempts <= 2) {
306
+ writeQueue.unshift(record);
307
+ return;
308
+ }
309
+ record.pinned = true;
310
+ spillDisabled = true;
311
+ for (const queued of writeQueue.splice(0)) {
312
+ if (!queued.cancelled) queued.pinned = true;
313
+ }
314
+ if (!warningEmitted) {
315
+ warningEmitted = true;
316
+ try { onWarning(`transcript spill write failed; history pinned in memory (${error || 'unknown error'})`); } catch {}
317
+ }
318
+ };
319
+ const finishWrite = (ok, error) => {
320
+ if (activeWriteTimer) clearTimeout(activeWriteTimer);
321
+ activeWriteTimer = null;
322
+ const record = activeWrite;
323
+ activeWrite = null;
324
+ if (record && !record.cancelled) {
325
+ if (ok) record.pendingItems = null;
326
+ else retryOrPin(record, error);
327
+ }
328
+ pumpWrites();
329
+ };
330
+ const pumpWrites = () => {
331
+ if (activeWrite) return;
332
+ while (writeQueue.length && writeQueue[0].cancelled) writeQueue.shift();
333
+ if (!writeQueue.length) return;
334
+ activeWrite = writeQueue.shift();
335
+ const worker = ensureWorker();
336
+ if (!worker) {
337
+ if (activeWrite) {
338
+ const failed = activeWrite;
339
+ activeWrite = null;
340
+ retryOrPin(failed, 'worker unavailable');
341
+ }
342
+ queueMicrotask(pumpWrites);
343
+ return;
344
+ }
345
+ // Pages are capped at chunkSize (128 by default), so the structured-clone
346
+ // post cost is bounded. Serialization and filesystem I/O stay in the worker.
347
+ // Every attempt writes a distinct temporary file; atomic rename is the sole
348
+ // commit point. A timed-out old worker can therefore expose only a complete
349
+ // page (the retry payload is identical), never a partial target JSON file.
350
+ const tempPath = `${activeWrite.path}.attempt-${activeWrite.attempts}-${randomUUID()}.tmp`;
351
+ worker.postMessage({
352
+ id: activeWrite.id,
353
+ targetPath: activeWrite.path,
354
+ tempPath,
355
+ items: activeWrite.pendingItems,
356
+ });
357
+ activeWriteTimer = setTimeout(() => {
358
+ if (!activeWrite || spillWorker !== worker) return;
359
+ const failed = activeWrite;
360
+ activeWrite = null;
361
+ activeWriteTimer = null;
362
+ spillWorker = null;
363
+ try { worker.terminate?.(); } catch {}
364
+ retryOrPin(failed, `write timed out after ${writeTimeoutMs}ms`);
365
+ pumpWrites();
366
+ }, Math.max(1, Number(writeTimeoutMs) || 5000));
367
+ activeWriteTimer.unref?.();
368
+ };
369
+ const encode = (items) => {
370
+ const page = join(ensureSpillDir(), `${++pageSequence}.json`);
371
+ const record = {
372
+ id: pageSequence,
373
+ path: page,
374
+ pendingItems: items,
375
+ cancelled: false,
376
+ attempts: 0,
377
+ pinned: false,
378
+ };
379
+ writeQueue.push(record);
380
+ pumpWrites();
381
+ return record;
382
+ };
383
+ const decode = (record) => record.pendingItems || JSON.parse(readFileSync(record.path, 'utf8'));
384
+ return {
385
+ get hasOlder() { return cursor == null ? pages.length > 0 : cursor > 0; },
386
+ get hasNewer() { return cursor != null; },
387
+ reset() {
388
+ const retained = [...snapshots].some((snapshot) => snapshot.spillDir === spillDir);
389
+ const oldPages = pages.splice(0);
390
+ const oldDir = spillDir;
391
+ pages.length = 0;
392
+ cursor = null;
393
+ spillDir = null;
394
+ pageSequence = 0;
395
+ spillDisabled = false;
396
+ warningEmitted = false;
397
+ if (!retained) cleanupRecords(oldPages, oldDir);
398
+ },
399
+ snapshot() {
400
+ const snapshot = {
401
+ pages: pages.slice(),
402
+ cursor,
403
+ spillDir,
404
+ pageSequence,
405
+ spillDisabled,
406
+ warningEmitted,
407
+ };
408
+ snapshots.add(snapshot);
409
+ return snapshot;
410
+ },
411
+ restoreSnapshot(snapshot) {
412
+ if (!snapshot || !snapshots.has(snapshot)) return false;
413
+ if (snapshot.spillDir === spillDir) {
414
+ cursor = snapshot.cursor;
415
+ snapshots.delete(snapshot);
416
+ return true;
417
+ }
418
+ cleanupRecords(pages, spillDir);
419
+ pages.splice(0, pages.length, ...snapshot.pages);
420
+ cursor = snapshot.cursor;
421
+ spillDir = snapshot.spillDir;
422
+ pageSequence = snapshot.pageSequence;
423
+ spillDisabled = snapshot.spillDisabled === true;
424
+ warningEmitted = snapshot.warningEmitted === true;
425
+ snapshots.delete(snapshot);
426
+ return true;
427
+ },
428
+ releaseSnapshot(snapshot) {
429
+ if (!snapshot || !snapshots.delete(snapshot)) return false;
430
+ if (snapshot.spillDir !== spillDir) cleanupRecords(snapshot.pages, snapshot.spillDir);
431
+ return true;
432
+ },
433
+ dispose() {
434
+ cleanupRecords(pages, spillDir);
435
+ for (const snapshot of snapshots) {
436
+ if (snapshot.spillDir !== spillDir) cleanupRecords(snapshot.pages, snapshot.spillDir);
437
+ }
438
+ pages.length = 0;
439
+ snapshots.clear();
440
+ cursor = null;
441
+ spillDir = null;
442
+ for (const timer of heartbeatTimers.values()) clearInterval(timer);
443
+ heartbeatTimers.clear();
444
+ writeQueue.length = 0;
445
+ activeWrite = null;
446
+ if (activeWriteTimer) clearTimeout(activeWriteTimer);
447
+ activeWriteTimer = null;
448
+ try { spillWorker?.terminate(); } catch {}
449
+ spillWorker = null;
450
+ },
451
+ get workerCount() { return workerSpawnCount; },
452
+ get pendingWriteCount() {
453
+ return writeQueue.length + (activeWrite ? 1 : 0);
454
+ },
455
+ get pinnedPageCount() {
456
+ return pages.filter((page) => page.pinned).length;
457
+ },
458
+ get disabled() { return spillDisabled; },
459
+ capLive(items) {
460
+ let live = Array.isArray(items) ? items : [];
461
+ if (spillDisabled) return live;
462
+ while (live.length > cap) {
463
+ pages.push(encode(live.slice(0, chunkSize)));
464
+ live = live.slice(chunkSize);
465
+ }
466
+ return live;
467
+ },
468
+ restoreOlder(liveItems) {
469
+ const nextCursor = cursor == null ? pages.length - 1 : cursor - 1;
470
+ if (nextCursor < 0) return null;
471
+ cursor = nextCursor;
472
+ const restored = decode(pages[cursor]);
473
+ const following = cursor + 1 < pages.length
474
+ ? decode(pages[cursor + 1])
475
+ : (Array.isArray(liveItems) ? liveItems : []);
476
+ return [...restored, ...following.slice(0, TRANSCRIPT_RESTORE_OVERLAP_ITEMS)];
477
+ },
478
+ restoreNewer(liveItems) {
479
+ if (cursor == null) return null;
480
+ const nextCursor = cursor + 1;
481
+ if (nextCursor >= pages.length) {
482
+ cursor = null;
483
+ return { items: null, atLive: true };
484
+ }
485
+ cursor = nextCursor;
486
+ const restored = decode(pages[cursor]);
487
+ const following = cursor + 1 < pages.length
488
+ ? decode(pages[cursor + 1])
489
+ : (Array.isArray(liveItems) ? liveItems : []);
490
+ return [...restored, ...following.slice(0, TRANSCRIPT_RESTORE_OVERLAP_ITEMS)];
491
+ },
492
+ };
493
+ }
494
+
495
+ export function refillTranscriptViewOverlap(viewItems, previousLiveItems, nextLiveItems) {
496
+ const view = Array.isArray(viewItems) ? viewItems : null;
497
+ if (!view) return null;
498
+ const previousIds = new Set((previousLiveItems || []).map((item) => item?.id).filter((id) => id != null));
499
+ if (!view.some((item) => previousIds.has(item?.id))) return view;
500
+ const historical = view.filter((item) => !previousIds.has(item?.id));
501
+ return [...historical, ...(nextLiveItems || []).slice(0, TRANSCRIPT_RESTORE_OVERLAP_ITEMS)];
502
+ }
503
+
137
504
  // Re-export the shared tool-result/notification helpers so importers (and tests)
138
505
  // keep resolving them from engine.mjs unchanged.
139
506
  export { toolResultText, toolAggregateDetailFallback, toolGroupedDisplayFallback };
@@ -166,7 +533,13 @@ export function replaceEngineItemsState({
166
533
 
167
534
  // Shared by the live engine and focused transcript tests so revision/tail
168
535
  // regressions exercise the exact mutation implementation used in production.
169
- export function createEngineItemMutators({ getState, set, itemIndexById }) {
536
+ export function createEngineItemMutators({
537
+ getState,
538
+ set,
539
+ itemIndexById,
540
+ normalizeItems = (items) => items,
541
+ itemStateExtra = () => ({}),
542
+ }) {
170
543
  const patchItem = (id, patch) => {
171
544
  const state = getState();
172
545
  let index = itemIndexById.get(id);
@@ -209,15 +582,21 @@ export function createEngineItemMutators({ getState, set, itemIndexById }) {
209
582
  streaming: false,
210
583
  };
211
584
  const index = state.items.length;
212
- const items = [...state.items, item];
213
- itemIndexById.set(id, index);
585
+ const items = normalizeItems([...state.items, item]);
586
+ itemIndexById.clear();
587
+ for (let i = 0; i < items.length; i++) {
588
+ const itemId = items[i]?.id;
589
+ if (itemId != null) itemIndexById.set(itemId, i);
590
+ }
591
+ const settledIndex = items.findIndex((entry) => entry?.id === id);
214
592
  set({
215
593
  items,
216
594
  structureRevision: (Number(state.structureRevision) || 0) + 1,
217
595
  streamingTail: null,
596
+ ...itemStateExtra(),
218
597
  ...extra,
219
598
  });
220
- return true;
599
+ return settledIndex >= 0;
221
600
  };
222
601
 
223
602
  return { patchItem, settleStreamingTail };
@@ -271,6 +650,7 @@ export async function createEngineSession({
271
650
  const pendingNotificationKeys = new Set();
272
651
  const displayedExecutionNotificationKeys = new Set();
273
652
  const bag = {};
653
+ let state;
274
654
  // Route/context/agent-status derivations live in ./engine/context-state.mjs.
275
655
  // getState()/getPendingSessionReset() are late-bound to the `state` and
276
656
  // `pendingSessionReset` closures declared below; the sync helpers mutate
@@ -285,6 +665,7 @@ export async function createEngineSession({
285
665
  } = createContextState({
286
666
  runtime,
287
667
  getState: () => state,
668
+ updateState: (patch) => { state = { ...state, ...patch }; },
288
669
  getPendingSessionReset: () => flags.pendingSessionReset,
289
670
  });
290
671
 
@@ -293,8 +674,12 @@ export async function createEngineSession({
293
674
  agentJobs: [],
294
675
  agentScope: null,
295
676
  };
296
- let state = {
677
+ state = {
297
678
  items: [],
679
+ transcriptViewItems: null,
680
+ transcriptViewRevision: 0,
681
+ transcriptHistoryBefore: false,
682
+ transcriptHistoryAfter: false,
298
683
  structureRevision: 0,
299
684
  streamingTail: null,
300
685
  toasts: [],
@@ -333,34 +718,44 @@ export async function createEngineSession({
333
718
  syncContextStats({ allowEstimated: true });
334
719
  bootProfile('engine:context-ready', { ms: (performance.now() - contextStartedAt).toFixed(1) });
335
720
  const listeners = new Set();
336
- // Coalesce store notifications: a single onToolCall batch / finalize path
337
- // fires many set() calls in one synchronous block (aggregate header sync,
338
- // spinner, item pushes). Notifying React on every set() painted the
339
- // intermediate layouts (jerky, stuttering header/count jitter). Collapsing the
340
- // notifications into one microtask means listeners see only the final state
341
- // of the current tick — "draw twice, keep the last one". getState() stays
342
- // synchronous and always returns the latest snapshot, so useSyncExternalStore
343
- // never tears.
344
- let emitScheduled = false;
345
- const flushEmit = () => {
346
- emitScheduled = false;
347
- for (const l of listeners) l();
348
- };
349
- const emit = () => {
350
- if (emitScheduled) return;
351
- emitScheduled = true;
352
- queueMicrotask(flushEmit);
353
- };
721
+ // React/useSyncExternalStore reads only this immutable published snapshot.
722
+ // `state` remains the engine's synchronous draft until a frame flush swaps
723
+ // the complete draft (including its single revision bump) into this slot.
724
+ let publishedState = process.env.NODE_ENV === 'production' ? state : Object.freeze(state);
725
+ // The mutable engine draft must never be the object exposed to React.
726
+ state = { ...state, stats: { ...state.stats } };
727
+ // Mutations stay synchronous, but React publications are frame-coalesced.
728
+ // structureRevision is committed by the publisher exactly once immediately
729
+ // before listeners observe the terminal snapshot for that frame.
730
+ const publisher = createFrameBatchedStorePublisher({
731
+ getState: () => state,
732
+ publishState: (next) => {
733
+ publishedState = process.env.NODE_ENV === 'production' ? next : Object.freeze(next);
734
+ // Detach the next draft, including the only intentionally mutable nested
735
+ // record, so legacy/internal draft writes cannot mutate the publication.
736
+ state = { ...next, stats: { ...next.stats } };
737
+ },
738
+ listeners,
739
+ isDisposed: () => flags.disposed,
740
+ });
741
+ const emit = publisher.emit;
742
+ const flushEmit = publisher.flush;
743
+ const flushEmitImmediate = publisher.flushImmediate;
354
744
  const set = (patch) => {
355
745
  if (!patch || typeof patch !== 'object') return false;
746
+ const requestsStructureChange = Object.prototype.hasOwnProperty.call(patch, 'structureRevision')
747
+ && !Object.is(patch.structureRevision, state.structureRevision);
748
+ const effectivePatch = requestsStructureChange
749
+ ? Object.fromEntries(Object.entries(patch).filter(([key]) => key !== 'structureRevision'))
750
+ : patch;
356
751
  let changed = false;
357
- for (const [key, value] of Object.entries(patch)) {
752
+ for (const [key, value] of Object.entries(effectivePatch)) {
358
753
  if (!Object.is(state[key], value)) {
359
754
  changed = true;
360
755
  break;
361
756
  }
362
757
  }
363
- if (!changed) return false;
758
+ if (!changed && !requestsStructureChange) return false;
364
759
  // Detect commandBusy releasing (true -> false). Submits that arrived while a
365
760
  // session command was in flight were queued and drain bailed on commandBusy;
366
761
  // re-kick drain here — one central point covers every command releaser
@@ -368,15 +763,66 @@ export async function createEngineSession({
368
763
  const commandBusyReleased = state.commandBusy === true
369
764
  && Object.prototype.hasOwnProperty.call(patch, 'commandBusy')
370
765
  && patch.commandBusy === false;
371
- state = { ...state, ...patch };
766
+ state = { ...state, ...effectivePatch };
767
+ if (requestsStructureChange) publisher.markStructureChange();
372
768
  emit();
769
+ // Preserve the old microtask-latency behavior for interaction gates and
770
+ // long command spinners that intentionally yield before doing heavy work.
771
+ if (effectivePatch.commandStatus || effectivePatch.toolApproval) {
772
+ flushEmitImmediate();
773
+ }
373
774
  if (commandBusyReleased) queueMicrotask(() => { void bag.drain?.(); });
374
775
  return true;
375
776
  };
376
777
 
377
778
  const itemIndexById = new Map();
378
- const replaceItems = (items, { preserveStreamingTail = false } = {}) => {
779
+ const transcriptSpill = createTranscriptSpillBuffer();
780
+ const reindexLiveItems = (items) => {
781
+ itemIndexById.clear();
782
+ for (let i = 0; i < items.length; i++) {
783
+ const id = items[i]?.id;
784
+ if (id != null) itemIndexById.set(id, i);
785
+ }
786
+ };
787
+ const transcriptHistoryFlags = () => ({
788
+ transcriptHistoryBefore: transcriptSpill.hasOlder,
789
+ transcriptHistoryAfter: transcriptSpill.hasNewer,
790
+ });
791
+ const restoreOlderTranscript = () => {
792
+ const transcriptViewItems = transcriptSpill.restoreOlder(state.items);
793
+ if (!transcriptViewItems) return false;
794
+ set({
795
+ transcriptViewItems,
796
+ transcriptViewRevision: state.transcriptViewRevision + 1,
797
+ ...transcriptHistoryFlags(),
798
+ });
799
+ flushEmitImmediate();
800
+ return true;
801
+ };
802
+ const restoreNewerTranscript = () => {
803
+ const restored = transcriptSpill.restoreNewer(state.items);
804
+ if (!restored) return false;
805
+ set({
806
+ transcriptViewItems: restored.atLive ? null : restored,
807
+ transcriptViewRevision: state.transcriptViewRevision + 1,
808
+ ...transcriptHistoryFlags(),
809
+ });
810
+ flushEmitImmediate();
811
+ return true;
812
+ };
813
+ const replaceItems = (items, {
814
+ preserveStreamingTail = false,
815
+ preserveSpill = false,
816
+ preserveTranscriptView = false,
817
+ } = {}) => {
379
818
  const nextItems = Array.isArray(items) ? items : [];
819
+ if (!preserveSpill) transcriptSpill.reset();
820
+ const liveItems = transcriptSpill.capLive(nextItems);
821
+ const previousTranscriptView = state.transcriptViewItems;
822
+ const nextTranscriptView = preserveTranscriptView && previousTranscriptView
823
+ ? refillTranscriptViewOverlap(previousTranscriptView, state.items, liveItems)
824
+ : null;
825
+ const transcriptViewChanged = nextTranscriptView !== previousTranscriptView;
380
826
  // Bulk item swap (session load / clear / compact). Derive the prompt-history
381
827
  // list from the NEW items and stage it onto state here so App never rescans;
382
828
  // the callers that invoke replaceItems always follow with a set({items:...,
@@ -384,22 +830,34 @@ export async function createEngineSession({
384
830
  // emit (the accompanying set() diffs the full patch). A bulk swap also
385
831
  // discards the old transcript, so drop any tracked active tool calls.
386
832
  activeToolCalls.clear();
833
+ const structureRevision = state.structureRevision;
387
834
  state = replaceEngineItemsState({
388
835
  state,
389
- items: nextItems,
836
+ items: liveItems,
390
837
  itemIndexById,
391
838
  preserveStreamingTail,
392
839
  extra: {
393
- promptHistoryList: buildMergedPromptHistory(recomputePromptHistory(nextItems), loadPromptHistory(state.cwd)),
840
+ promptHistoryList: preserveSpill
841
+ ? state.promptHistoryList
842
+ : buildMergedPromptHistory(recomputePromptHistory(nextItems), loadPromptHistory(state.cwd)),
394
843
  activeToolSummary: null,
844
+ transcriptViewItems: nextTranscriptView,
845
+ transcriptViewRevision: preserveTranscriptView
846
+ ? state.transcriptViewRevision + (transcriptViewChanged ? 1 : 0)
847
+ : state.transcriptViewRevision + 1,
848
+ ...transcriptHistoryFlags(),
395
849
  },
396
850
  });
851
+ // replaceEngineItemsState retains its standalone/test contract. In the live
852
+ // store, defer its revision increment to the frame publication boundary.
853
+ state = { ...state, structureRevision };
854
+ publisher.markStructureChange();
397
855
  // replaceItems stages the bulk state before its callers compose their
398
856
  // accompanying patch. Emit here as well so an items-only replacement
399
857
  // (for example removeNotice) cannot be hidden by the outer set seeing the
400
858
  // already-installed array identity.
401
859
  emit();
402
- return nextItems;
860
+ return liveItems;
403
861
  };
404
862
  // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
405
863
  // App previously rebuilt this from state.items on EVERY transcript change
@@ -458,8 +916,10 @@ export async function createEngineSession({
458
916
  if (!flags.pushingFromDeferredEntry && flags.flushDeferredBeforeImmediatePush) {
459
917
  flags.flushDeferredBeforeImmediatePush();
460
918
  }
461
- const index = state.items.length;
462
- const items = [...state.items, item];
919
+ const uncappedItems = [...state.items, item];
920
+ const items = transcriptSpill.capLive(uncappedItems);
921
+ if (items !== uncappedItems) reindexLiveItems(items);
922
+ const index = items.length - 1;
463
923
  if (item?.id != null) itemIndexById.set(item.id, index);
464
924
  if (item?.kind === 'user') {
465
925
  // Rebuild the derived history against the NEW list (not yet in state) and
@@ -467,11 +927,23 @@ export async function createEngineSession({
467
927
  // first — set() diffs against the current state, so a pre-assign would make
468
928
  // the references identical and skip emit().
469
929
  const promptHistoryList = buildMergedPromptHistory(recomputePromptHistory(items), loadPromptHistory(state.cwd));
470
- set({ items, structureRevision: state.structureRevision + 1, promptHistoryList });
930
+ set({ items, structureRevision: state.structureRevision + 1, promptHistoryList, ...transcriptHistoryFlags() });
931
+ flushEmitImmediate();
471
932
  } else {
472
- set({ items, structureRevision: state.structureRevision + 1 });
933
+ set({ items, structureRevision: state.structureRevision + 1, ...transcriptHistoryFlags() });
473
934
  }
474
935
  };
936
+ const appendItems = (newItems, extra = {}) => {
937
+ if (!Array.isArray(newItems) || newItems.length === 0) return set(extra);
938
+ const items = transcriptSpill.capLive([...state.items, ...newItems]);
939
+ reindexLiveItems(items);
940
+ return set({
941
+ items,
942
+ structureRevision: state.structureRevision + 1,
943
+ ...transcriptHistoryFlags(),
944
+ ...extra,
945
+ });
946
+ };
475
947
  const updateStreamingTail = (id, patch = {}, extra = {}) => {
476
948
  if (id == null) return false;
477
949
  const current = state.streamingTail?.id === id
@@ -499,6 +971,8 @@ export async function createEngineSession({
499
971
  getState: () => state,
500
972
  set,
501
973
  itemIndexById,
974
+ normalizeItems: (items) => transcriptSpill.capLive(items),
975
+ itemStateExtra: transcriptHistoryFlags,
502
976
  });
503
977
  const upsertSyntheticToolItem = (text, id = nextId(), parsed = null) => {
504
978
  const synthetic = parseSyntheticAgentMessage(text);
@@ -739,12 +1213,17 @@ export async function createEngineSession({
739
1213
  Object.assign(bag, {
740
1214
  runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS,
741
1215
  flags, lifecycle, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, listeners, itemIndexById,
742
- getState: () => state, set,
743
- pushItem, patchItem, replaceItems, updateStreamingTail, settleStreamingTail, clearStreamingTail,
1216
+ getState: () => state, getPublishedState: () => publishedState,
1217
+ set, flushEmit, flushEmitImmediate, disposeEmit: publisher.dispose,
1218
+ pushItem, appendItems, patchItem, replaceItems, restoreOlderTranscript, restoreNewerTranscript, updateStreamingTail, settleStreamingTail, clearStreamingTail,
744
1219
  pushToast, pushNotice, removeNotice, setProgressHint,
745
1220
  pushUserOrSyntheticItem, pushAsyncAgentResponse, upsertSyntheticToolItem,
746
1221
  markToolCallActive, markToolCallDone, clearActiveToolSummary, clearToastTimers,
747
1222
  autoClearState, agentStatusState, baseRouteState, routeState, syncContextStats,
1223
+ disposeTranscriptSpill: () => transcriptSpill.dispose(),
1224
+ snapshotTranscriptSpill: () => transcriptSpill.snapshot(),
1225
+ restoreTranscriptSpill: (snapshot) => transcriptSpill.restoreSnapshot(snapshot),
1226
+ releaseTranscriptSpill: (snapshot) => transcriptSpill.releaseSnapshot(snapshot),
748
1227
  presentNextToolApproval, finishToolApproval, denyAllToolApprovals, requestToolApproval,
749
1228
  patchToolCardResult, flushToolResults,
750
1229
  kickExecutionPendingResume, flushDeferredExecutionPendingResumeKick, scheduleExecutionPendingResumeKick, discardExecutionPendingResume, updateAgentJobCard, subscribeRuntimeNotifications,