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.
- package/package.json +2 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
package/src/tui/engine.mjs
CHANGED
|
@@ -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({
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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, ...
|
|
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
|
|
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:
|
|
836
|
+
items: liveItems,
|
|
390
837
|
itemIndexById,
|
|
391
838
|
preserveStreamingTail,
|
|
392
839
|
extra: {
|
|
393
|
-
promptHistoryList:
|
|
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
|
|
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
|
|
462
|
-
const items =
|
|
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,
|
|
743
|
-
|
|
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,
|