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,13 +2,18 @@
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
4
  import { readFileSync } from 'fs';
5
+ import { createHash, randomBytes } from 'crypto';
5
6
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
6
7
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
7
8
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
8
- import { loadSession } from '../store.mjs';
9
+ import { loadSession, saveSessionAsync } from '../store.mjs';
9
10
  import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
10
11
 
11
12
  const _sessionPendingMessages = new Map();
13
+ // Persisted entries are claimed once, asynchronously, when askSession takes
14
+ // ownership of a session. Hot-path drains consume this in-memory snapshot and
15
+ // never touch the global spool (or its cross-process lock).
16
+ const _hydratedPendingMessages = new Map();
12
17
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
13
18
  const PENDING_MESSAGES_MODE = 0o600;
14
19
  const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -19,8 +24,33 @@ const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
19
24
  // user/steering messages carry no marker and keep full queue + replay behavior.
20
25
  export const COMPLETION_NOTIFICATION_KIND = 'completion_notification';
21
26
  const _pendingPersistBuffers = new Map();
27
+ const _pendingPersistTails = new Map();
28
+ const _inDeliveryPendingIds = new Map();
29
+ const _ackedPendingIds = new Map();
30
+ const _pendingHydrations = new Map();
22
31
  let _pendingPersistImmediate = null;
23
32
 
33
+ function pendingIdSet(map, sessionId) {
34
+ let ids = map.get(sessionId);
35
+ if (!ids) {
36
+ ids = new Set();
37
+ map.set(sessionId, ids);
38
+ }
39
+ return ids;
40
+ }
41
+
42
+ function newPendingMessageId() {
43
+ return randomBytes(12).toString('hex');
44
+ }
45
+
46
+ function pendingMessageId(entry) {
47
+ return typeof entry?.id === 'string' && entry.id ? entry.id : null;
48
+ }
49
+
50
+ function legacyPendingMessageId(sessionId, index, value) {
51
+ return `legacy_${createHash('sha256').update(`${sessionId}:${index}:${value}`).digest('hex').slice(0, 24)}`;
52
+ }
53
+
24
54
  function isCompletionNotificationEntry(entry) {
25
55
  return Boolean(entry) && typeof entry === 'object'
26
56
  && entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
@@ -81,10 +111,24 @@ function normalizeTuiSteeringQueueEntry(entry) {
81
111
  return text || null;
82
112
  }
83
113
  if (!entry || typeof entry !== 'object') return null;
84
- if (typeof entry.text === 'string' && entry.text.trim()) {
85
- const text = entry.text.trim();
114
+ const rawText = typeof entry.text === 'string'
115
+ ? entry.text
116
+ : (typeof entry.message === 'string'
117
+ ? entry.message
118
+ : (typeof entry.content === 'string' ? entry.content : ''));
119
+ if (rawText.trim()) {
120
+ const text = rawText.trim();
86
121
  const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null;
87
- return id ? { id, text } : text;
122
+ if (!id) return text;
123
+ const normalized = {
124
+ id,
125
+ text,
126
+ message: text,
127
+ enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
128
+ };
129
+ return entry.notificationKind === COMPLETION_NOTIFICATION_KIND
130
+ ? { ...normalized, notificationKind: COMPLETION_NOTIFICATION_KIND }
131
+ : normalized;
88
132
  }
89
133
  return null;
90
134
  }
@@ -102,7 +146,10 @@ function normalizePendingStore(raw) {
102
146
  if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
103
147
  const q = isTuiSteeringPendingKey(sid)
104
148
  ? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
105
- : value.map(normalizePersistedEntry).filter(Boolean);
149
+ : value.map((entry, index) => normalizePersistedEntry(entry, {
150
+ legacyId: legacyPendingMessageId(sid, index, typeof entry === 'string' ? entry : JSON.stringify(entry)),
151
+ fallbackEnqueuedAt: storeUpdatedAt + index,
152
+ })).filter(Boolean);
106
153
  if (q.length > 0) {
107
154
  out.sessions[sid] = q;
108
155
  const touched = Number(touchedRaw[sid]);
@@ -128,10 +175,18 @@ function normalizePendingMessageEntry(entry) {
128
175
  return { content: entry, text };
129
176
  }
130
177
  if (!entry || typeof entry !== 'object') return null;
178
+ const identity = {
179
+ id: pendingMessageId(entry),
180
+ enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
181
+ };
131
182
  const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
132
183
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
133
184
  : null;
134
- const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
185
+ const content = Object.prototype.hasOwnProperty.call(entry, 'content')
186
+ ? entry.content
187
+ : (typeof entry.message === 'string'
188
+ ? entry.message
189
+ : (typeof entry.text === 'string' ? entry.text : null));
135
190
  if (content == null) return null;
136
191
  const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
137
192
  let out = null;
@@ -143,7 +198,8 @@ function normalizePendingMessageEntry(entry) {
143
198
  const fallback = promptContentText(content).trim();
144
199
  out = fallback ? { content: fallback, text: text || fallback } : null;
145
200
  }
146
- return out && marker ? { ...out, ...marker } : out;
201
+ if (!out) return null;
202
+ return marker ? { ...out, ...identity, ...marker } : { ...out, ...identity };
147
203
  }
148
204
 
149
205
  function pendingMessageText(entry) {
@@ -154,11 +210,14 @@ function pendingMessageText(entry) {
154
210
  function pendingMessageQueueEntry(entry) {
155
211
  const normalized = normalizePendingMessageEntry(entry);
156
212
  if (!normalized) return null;
213
+ const identity = {
214
+ id: normalized.id || newPendingMessageId(),
215
+ enqueuedAt: Number(normalized.enqueuedAt) || Date.now(),
216
+ };
157
217
  const marker = isCompletionNotificationEntry(normalized)
158
218
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
159
219
  : null;
160
- if (typeof normalized.content === 'string' && normalized.content === normalized.text && !marker) return normalized.content;
161
- const base = { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
220
+ const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
162
221
  return marker ? { ...base, ...marker } : base;
163
222
  }
164
223
 
@@ -167,20 +226,32 @@ function pendingMessageQueueEntry(entry) {
167
226
  // notifications so the marker survives an on-disk round trip. Accepts either an
168
227
  // in-memory queue entry (content/text) or an already-persisted entry
169
228
  // (string | { message } legacy | marked object); back-compatible with both.
170
- function normalizePersistedEntry(entry) {
171
- if (typeof entry === 'string') { const t = entry.trim(); return t || null; }
229
+ function normalizePersistedEntry(entry, options = {}) {
230
+ if (typeof entry === 'string') {
231
+ const message = entry.trim();
232
+ return message ? {
233
+ id: options.legacyId || newPendingMessageId(),
234
+ message,
235
+ enqueuedAt: Number(options.fallbackEnqueuedAt) || Date.now(),
236
+ } : null;
237
+ }
172
238
  if (!entry || typeof entry !== 'object') return null;
239
+ const id = pendingMessageId(entry) || options.legacyId || newPendingMessageId();
240
+ const enqueuedAt = Number(entry.enqueuedAt) || Number(options.fallbackEnqueuedAt) || Date.now();
173
241
  if (isCompletionNotificationEntry(entry)) {
174
242
  const message = (typeof entry.message === 'string' && entry.message.trim())
175
243
  ? entry.message.trim()
176
244
  : pendingMessageText(entry);
177
245
  return message
178
- ? { message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
246
+ ? { id, message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt }
179
247
  : null;
180
248
  }
181
- if (typeof entry.message === 'string') { const t = entry.message.trim(); return t || null; }
249
+ if (typeof entry.message === 'string') {
250
+ const message = entry.message.trim();
251
+ return message ? { id, message, enqueuedAt } : null;
252
+ }
182
253
  const t = pendingMessageText(entry);
183
- return t || null;
254
+ return t ? { id, message: t, enqueuedAt } : null;
184
255
  }
185
256
 
186
257
  function persistPendingMessages(sessionId, messages) {
@@ -194,7 +265,7 @@ function persistPendingMessages(sessionId, messages) {
194
265
  // process contention on the shared spool never freezes the renderer.
195
266
  // Best-effort: the returned promise is fire-and-forget; depth is reported
196
267
  // optimistically from the buffered batch length.
197
- updateJsonAtomic(pendingMessagesPath(), (raw) => {
268
+ const operation = updateJsonAtomic(pendingMessagesPath(), (raw) => {
198
269
  const next = normalizePendingStore(raw);
199
270
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
200
271
  q.push(...persistedMessages);
@@ -210,11 +281,16 @@ function persistPendingMessages(sessionId, messages) {
210
281
  // were already cleared by the flush, so push them back so the next
211
282
  // scheduled flush or session takeover retries instead of losing them.
212
283
  try {
284
+ const acked = pendingIdSet(_ackedPendingIds, sessionId);
213
285
  const q = _pendingPersistBuffers.get(sessionId) || [];
214
- q.push(...persistedMessages);
286
+ q.push(...persistedMessages.filter((entry) => !acked.has(pendingMessageId(entry))));
215
287
  _pendingPersistBuffers.set(sessionId, q);
216
288
  } catch {}
217
289
  });
290
+ _pendingPersistTails.set(sessionId, operation);
291
+ operation.finally(() => {
292
+ if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
293
+ }).catch(() => {});
218
294
  return persistedMessages.length;
219
295
  }
220
296
 
@@ -255,28 +331,188 @@ function takeBufferedPendingMessages(sessionId) {
255
331
  return buffered.slice();
256
332
  }
257
333
 
258
- function drainPersistedPendingMessages(sessionId) {
259
- if (!isValidPendingSessionId(sessionId)) return [];
260
- let drained = [];
261
- try {
262
- // Sync drain: called synchronously by drainPendingMessages, whose
263
- // return value is spread immediately. Kept sync (updateJsonAtomicSync)
264
- // so ordering/return contract is preserved; this fires only at
265
- // session takeover, not on the keystroke/turn hot path.
266
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
267
- const next = normalizePendingStore(raw);
268
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
269
- drained = q.filter((m) => (typeof m === 'string' && m.length > 0) || isCompletionNotificationEntry(m));
270
- if (drained.length === 0) return undefined;
334
+ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
335
+ const ids = new Set((Array.isArray(deliveredEntries) ? deliveredEntries : [])
336
+ .map(pendingMessageId).filter(Boolean));
337
+ if (ids.size === 0) return Promise.resolve(false);
338
+ const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
339
+ const acked = pendingIdSet(_ackedPendingIds, sessionId);
340
+ for (const id of ids) { inDelivery.delete(id); acked.add(id); }
341
+ const purgeMemory = () => {
342
+ for (const map of [_pendingPersistBuffers, _sessionPendingMessages, _hydratedPendingMessages]) {
343
+ const q = map.get(sessionId);
344
+ if (!Array.isArray(q)) continue;
345
+ const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
346
+ if (kept.length > 0) map.set(sessionId, kept);
347
+ else map.delete(sessionId);
348
+ }
349
+ };
350
+ purgeMemory();
351
+ const precedingPersist = _pendingPersistTails.get(sessionId) || Promise.resolve();
352
+ const operation = precedingPersist.catch(() => {}).then(() => {
353
+ // A failed preceding persist may have requeued after the first purge.
354
+ purgeMemory();
355
+ return updateJsonAtomic(pendingMessagesPath(), (raw) => {
356
+ const next = normalizePendingStore(raw);
357
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
358
+ const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
359
+ const removed = q.length - kept.length;
360
+ if (removed === 0) return undefined;
361
+ if (kept.length > 0) next.sessions[sessionId] = kept;
362
+ else {
271
363
  delete next.sessions[sessionId];
272
364
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
273
- next.updatedAt = Date.now();
274
- return next;
365
+ }
366
+ next.updatedAt = Date.now();
367
+ return next;
275
368
  }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
276
- } catch (err) {
277
- try { process.stderr.write(`[session] pending-message drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
369
+ });
370
+ _pendingPersistTails.set(sessionId, operation);
371
+ const reported = operation.then(() => true).catch((err) => {
372
+ try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
373
+ return false;
374
+ }).finally(() => {
375
+ const currentAcked = pendingIdSet(_ackedPendingIds, sessionId);
376
+ for (const id of ids) currentAcked.delete(id);
377
+ if (currentAcked.size === 0) _ackedPendingIds.delete(sessionId);
378
+ if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
379
+ });
380
+ return reported;
381
+ }
382
+
383
+ export function recordPendingMessageDelivery(session, deliveredEntries) {
384
+ if (!session || !Array.isArray(deliveredEntries) || deliveredEntries.length === 0) return;
385
+ const added = deliveredEntries.map(pendingMessageId).filter(Boolean);
386
+ if (added.length === 0) return;
387
+ const ledger = Array.isArray(session.deliveredPendingMessageIds)
388
+ ? session.deliveredPendingMessageIds.filter((id) => typeof id === 'string' && id)
389
+ : [];
390
+ // This may temporarily exceed the nominal bound while spool cleanup is
391
+ // failing. Never evict an ID whose durable spool copy may still exist.
392
+ session.deliveredPendingMessageIds = [...new Set([...ledger, ...added])];
393
+ }
394
+
395
+ async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null) {
396
+ const confirmedIds = new Set((Array.isArray(confirmedEntries) ? confirmedEntries : [])
397
+ .map(pendingMessageId).filter(Boolean));
398
+ if (confirmedIds.size === 0) return false;
399
+ const target = session || loadSession(sessionId);
400
+ if (!target) return false;
401
+ const ledger = Array.isArray(target.deliveredPendingMessageIds)
402
+ ? target.deliveredPendingMessageIds.filter((id) => typeof id === 'string' && id)
403
+ : [];
404
+ const kept = ledger.filter((id) => !confirmedIds.has(id));
405
+ if (kept.length === ledger.length) return false;
406
+ // Confirmed IDs need no replay protection and are removed immediately
407
+ // (therefore bounded below any finite confirmed-ID retention cap).
408
+ // Unconfirmed IDs are never size-evicted.
409
+ target.deliveredPendingMessageIds = kept;
410
+ if (typeof persist === 'function') await persist();
411
+ else await saveSessionAsync(target, { expectedGeneration: target.generation });
412
+ return true;
413
+ }
414
+
415
+ export function finalizePendingMessageDelivery(session, deliveredEntries, durableSave, persistPrunedLedger) {
416
+ const ids = new Set((Array.isArray(deliveredEntries) ? deliveredEntries : [])
417
+ .map(pendingMessageId).filter(Boolean));
418
+ if (!session || ids.size === 0) return Promise.resolve(false);
419
+ // Strict durability order: ledger/session first, spool deletion second.
420
+ // Both operations are detached from the completion tick.
421
+ return Promise.resolve(durableSave).then(async () => {
422
+ const cleaned = await acknowledgePendingMessages(session.id, deliveredEntries);
423
+ if (!cleaned) return false;
424
+ await pruneCleanupConfirmedLedger(
425
+ session.id,
426
+ deliveredEntries,
427
+ session,
428
+ persistPrunedLedger,
429
+ );
430
+ return true;
431
+ });
432
+ }
433
+
434
+ export function releasePendingMessages(sessionId, deliveredEntries) {
435
+ const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
436
+ for (const entry of Array.isArray(deliveredEntries) ? deliveredEntries : []) {
437
+ const id = pendingMessageId(entry);
438
+ if (id) inDelivery.delete(id);
278
439
  }
279
- return drained;
440
+ if (inDelivery.size === 0) _inDeliveryPendingIds.delete(sessionId);
441
+ }
442
+
443
+ export function hydratePendingMessages(sessionId, options = {}) {
444
+ if (!isValidPendingSessionId(sessionId)) return Promise.resolve(0);
445
+ const existingHydration = _pendingHydrations.get(sessionId);
446
+ if (existingHydration) return existingHydration;
447
+ const hydration = (async () => {
448
+ const precedingPersist = _pendingPersistTails.get(sessionId);
449
+ if (precedingPersist) await precedingPersist.catch(() => {});
450
+ let hydrated = [];
451
+ let alreadyDelivered = [];
452
+ let staleLedgerEntries = [];
453
+ const ledgerSession = loadSession(sessionId);
454
+ try {
455
+ const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
456
+ const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
457
+ const acked = pendingIdSet(_ackedPendingIds, sessionId);
458
+ await updateJsonAtomic(pendingMessagesPath(), (raw) => {
459
+ const next = normalizePendingStore(raw);
460
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
461
+ const spoolIds = new Set(q.map(pendingMessageId).filter(Boolean));
462
+ // Ledger IDs only suppress matching durable spool entries. If no
463
+ // such entry exists, cleanup was already completed (possibly just
464
+ // before a crash) and the ledger ID is structurally stale.
465
+ staleLedgerEntries = [...deliveredLedger]
466
+ .filter((id) => !spoolIds.has(id))
467
+ .map((id) => ({ id }));
468
+ hydrated = q.filter((entry) => {
469
+ const id = pendingMessageId(entry);
470
+ if (id && deliveredLedger.has(id)) {
471
+ alreadyDelivered.push(entry);
472
+ return false;
473
+ }
474
+ return id && !inDelivery.has(id) && !acked.has(id);
475
+ });
476
+ // Read-only claim: durable data remains until successful delivery
477
+ // acknowledges these exact ids. A crash here therefore redelivers.
478
+ return undefined;
479
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
480
+ await options.beforePublish?.(hydrated);
481
+ } catch (err) {
482
+ try { process.stderr.write(`[session] pending-message hydrate failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
483
+ return 0;
484
+ }
485
+ const cleanupConfirmed = staleLedgerEntries.slice();
486
+ if (alreadyDelivered.length > 0) {
487
+ const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
488
+ if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
489
+ }
490
+ if (cleanupConfirmed.length > 0) {
491
+ try {
492
+ // One session save prunes both IDs whose replay spool was removed
493
+ // now and IDs whose spool was already absent at hydration start.
494
+ await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession);
495
+ } catch (err) {
496
+ try { process.stderr.write(`[session] pending-message ledger prune failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
497
+ }
498
+ }
499
+ if (hydrated.length > 0) {
500
+ const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
501
+ for (const entry of hydrated) {
502
+ const id = pendingMessageId(entry);
503
+ if (id) inDelivery.add(id);
504
+ }
505
+ const existing = _hydratedPendingMessages.get(sessionId) || [];
506
+ existing.push(...hydrated);
507
+ _hydratedPendingMessages.set(sessionId, existing);
508
+ }
509
+ return hydrated.length;
510
+ })();
511
+ _pendingHydrations.set(sessionId, hydration);
512
+ hydration.finally(() => {
513
+ if (_pendingHydrations.get(sessionId) === hydration) _pendingHydrations.delete(sessionId);
514
+ }).catch(() => {});
515
+ return hydration;
280
516
  }
281
517
 
282
518
  function clearPersistedPendingMessages(sessionId) {
@@ -391,7 +627,9 @@ export function _mergePendingMessageEntries(entries) {
391
627
  }
392
628
 
393
629
  export function enqueuePendingMessage(sessionId, message) {
394
- const entry = pendingMessageQueueEntry(message);
630
+ const normalized = pendingMessageQueueEntry(message);
631
+ // Caller-provided ids are never trusted across sessions/processes.
632
+ const entry = normalized ? { ...normalized, id: newPendingMessageId() } : null;
395
633
  if (!sessionId || !entry) return 0;
396
634
  let q = _sessionPendingMessages.get(sessionId);
397
635
  if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
@@ -404,11 +642,12 @@ export function drainPendingMessages(sessionId) {
404
642
  const q = _sessionPendingMessages.get(sessionId);
405
643
  const memory = q && q.length > 0 ? q.slice() : [];
406
644
  _sessionPendingMessages.delete(sessionId);
407
- // FIFO: disk-persisted entries were flushed before the not-yet-flushed
408
- // in-memory persist buffer, so they are strictly older — drain them
409
- // first. Reversing this order (buffer before disk) delivered newer
410
- // buffered sends ahead of older persisted ones after a restart.
411
- const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
645
+ const hydrated = _hydratedPendingMessages.get(sessionId) || [];
646
+ _hydratedPendingMessages.delete(sessionId);
647
+ // FIFO: takeover-hydrated disk entries predate the not-yet-flushed buffer.
648
+ // This path is deliberately memory-only: no file lock, stat, parse, or
649
+ // atomic rename can run in the agent-completion tick.
650
+ const buffered = takeBufferedPendingMessages(sessionId);
412
651
  // Deferred completion/task notifications are dropped ONLY from the persisted
413
652
  // (disk/buffer) path. Those are the entries a later session resume/restart
414
653
  // would replay out-of-order into a future turn, once the in-memory queue is
@@ -433,30 +672,40 @@ export function drainPendingMessages(sessionId) {
433
672
  }
434
673
  return true;
435
674
  });
436
- const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m)
437
- && !isLegacyUnmarkedCompletionNotification(m));
438
- const memoryVisible = modelVisiblePendingMessages(memoryKept);
439
- const persistedVisible = modelVisiblePendingMessages(persistedKept);
440
- if (memoryVisible.length === 0) return persistedVisible;
441
- if (persistedVisible.length === 0) return memoryVisible;
442
- const persistedTexts = persistedVisible.map(pendingMessageText);
443
- const prefixMatches = memoryVisible.every((m, i) => persistedTexts[i] === pendingMessageText(m));
444
- if (prefixMatches) return [...memoryVisible, ...persistedVisible.slice(memoryVisible.length)];
445
- // The live in-memory queue holds the authoritative order — including the
446
- // completion entries that were filtered out of the persisted path — so an
447
- // interleaved [user, completion, user] must stay in that order and never be
448
- // flattened to [user, user, completion] by rebuilding from persisted first.
449
- // Start from the memory order and append only genuinely persisted-only
450
- // entries; buffered duplicates of live sends are skipped by text.
451
- const out = memoryVisible.slice();
452
- const seen = new Set(out.map(pendingMessageText).filter(Boolean));
453
- for (const m of persistedVisible) {
454
- const text = pendingMessageText(m);
455
- if (!text || seen.has(text)) continue;
456
- out.push(m);
457
- seen.add(text);
675
+ const tagged = [
676
+ ...hydrated.map((entry, index) => ({ entry, source: 0, index })),
677
+ ...buffered.map((entry, index) => ({ entry, source: 1, index })),
678
+ ...memoryKept.map((entry, index) => ({ entry, source: 2, index })),
679
+ ];
680
+ tagged.sort((a, b) => {
681
+ const at = Number(a.entry?.enqueuedAt) || 0;
682
+ const bt = Number(b.entry?.enqueuedAt) || 0;
683
+ return at - bt || a.source - b.source || a.index - b.index;
684
+ });
685
+ const byId = new Map();
686
+ for (const item of tagged) {
687
+ const normalized = pendingMessageQueueEntry(item.entry);
688
+ if (!normalized?.id) continue;
689
+ // Prefer the live form for duplicate spool/buffer copies; content and id
690
+ // are identical, but the live completion marker is authoritative.
691
+ const prior = byId.get(normalized.id);
692
+ if (!prior || item.source > prior.source) byId.set(normalized.id, { ...item, entry: normalized });
458
693
  }
459
- return out;
694
+ const ordered = [...byId.values()].sort((a, b) => {
695
+ const at = Number(a.entry.enqueuedAt) || 0;
696
+ const bt = Number(b.entry.enqueuedAt) || 0;
697
+ return at - bt || a.source - b.source || a.index - b.index;
698
+ });
699
+ const dropped = ordered.filter(({ entry, source }) => source === 0
700
+ && (isCompletionNotificationEntry(entry) || isLegacyUnmarkedCompletionNotification(pendingMessageText(entry))))
701
+ .map(({ entry }) => entry);
702
+ if (dropped.length > 0) acknowledgePendingMessages(sessionId, dropped);
703
+ const visible = modelVisiblePendingMessages(ordered
704
+ .filter(({ entry }) => !dropped.includes(entry))
705
+ .map(({ entry }) => entry));
706
+ const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
707
+ for (const entry of visible) if (entry.id) inDelivery.add(entry.id);
708
+ return visible;
460
709
  }
461
710
 
462
711
  // Snapshot queued entries without draining them. Compaction uses this to keep
@@ -494,7 +743,11 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
494
743
  }
495
744
  }
496
745
  try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
746
+ try { _hydratedPendingMessages.delete(id); } catch { /* ignore */ }
497
747
  try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
748
+ try { _inDeliveryPendingIds.delete(id); } catch { /* ignore */ }
749
+ try { _ackedPendingIds.delete(id); } catch { /* ignore */ }
750
+ try { _pendingHydrations.delete(id); } catch { /* ignore */ }
498
751
  if (clearPersisted) {
499
752
  try { clearPersistedPendingMessages(id); } catch { /* ignore */ }
500
753
  }
@@ -124,7 +124,7 @@ function finalizeInterruptedTurn({
124
124
  const pairedMessages = sanitizeToolPairs(messages);
125
125
  // Claude Code omits the synthetic marker when a queued user submission
126
126
  // interrupted the active request; that queued message is the boundary.
127
- if (abortReason !== 'interrupt') {
127
+ if (abortReason !== 'interrupt' && abortReason !== 'provider-error') {
128
128
  pairedMessages.push({
129
129
  role: 'user',
130
130
  content: phase === 'tools'
@@ -138,6 +138,7 @@ function finalizeInterruptedTurn({
138
138
  export function createTurnInterruptionTracker() {
139
139
  let responseStarted = false;
140
140
  let partialAssistantContent = '';
141
+ let tombstonedAssistantContent = '';
141
142
  let partialReasoningContent = '';
142
143
  let phase = 'streaming';
143
144
  const observedToolCalls = new Map();
@@ -150,6 +151,26 @@ export function createTurnInterruptionTracker() {
150
151
  responseStarted = true;
151
152
  partialAssistantContent += value;
152
153
  },
154
+ tombstoneText(chars) {
155
+ const count = Math.max(0, Number(chars) || 0);
156
+ if (!count) return;
157
+ const cutAt = Math.max(0, partialAssistantContent.length - count);
158
+ tombstonedAssistantContent = partialAssistantContent.slice(cutAt)
159
+ + tombstonedAssistantContent;
160
+ partialAssistantContent = partialAssistantContent.slice(
161
+ 0,
162
+ cutAt,
163
+ );
164
+ },
165
+ restoreTombstonedText() {
166
+ if (!tombstonedAssistantContent) return false;
167
+ partialAssistantContent += tombstonedAssistantContent;
168
+ tombstonedAssistantContent = '';
169
+ return true;
170
+ },
171
+ hasResponseStarted() {
172
+ return responseStarted;
173
+ },
153
174
  recordReasoningDelta(chunk) {
154
175
  const value = String(chunk ?? '');
155
176
  if (!value) return;
@@ -166,6 +187,7 @@ export function createTurnInterruptionTracker() {
166
187
  },
167
188
  markAssistantMessageCommitted() {
168
189
  partialAssistantContent = '';
190
+ tombstonedAssistantContent = '';
169
191
  partialReasoningContent = '';
170
192
  observedToolCalls.clear();
171
193
  },
@@ -347,6 +347,8 @@ let _saveAsyncQueued = new Map();
347
347
  let _saveAsyncInflight = new Map();
348
348
  let _saveWorkerReqId = 0;
349
349
  let _saveWorkerRefCount = 0;
350
+ let _deferredSaveReqId = 0;
351
+ const _deferredSessionSaves = new Map();
350
352
 
351
353
  function _getOrSpawnWorker() {
352
354
  if (_saveWorker) return _saveWorker;
@@ -482,7 +484,7 @@ export function saveSessionAsync(session, opts) {
482
484
  setLiveSession(session);
483
485
  const id = session.id;
484
486
  const summaryVersion = _cacheSessionSummary(session);
485
- const safeOpts = _guardedSaveOptions(id, opts);
487
+ const safeOpts = opts?._sessionWriteGuard ? opts : _guardedSaveOptions(id, opts);
486
488
  // The Worker `postMessage` below structured-clones the whole session on the
487
489
  // main thread. `session.liveTurnMessages` (live working transcript) and
488
490
  // `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
@@ -531,6 +533,31 @@ export function saveSessionAsync(session, opts) {
531
533
  });
532
534
  }
533
535
 
536
+ /**
537
+ * Register a save for the exit drain now, but yield one check phase before
538
+ * Worker.postMessage performs its main-thread structured clone.
539
+ */
540
+ export function saveSessionAsyncDeferred(session, opts) {
541
+ _ensureLifecycleFields(session);
542
+ setLiveSession(session);
543
+ _cacheSessionSummary(session);
544
+ const reqId = ++_deferredSaveReqId;
545
+ return new Promise((resolve, reject) => {
546
+ _deferredSessionSaves.set(reqId, {
547
+ session,
548
+ opts: _guardedSaveOptions(session.id, opts),
549
+ resolve,
550
+ reject,
551
+ });
552
+ setImmediate(() => {
553
+ const pending = _deferredSessionSaves.get(reqId);
554
+ if (!pending) return;
555
+ _deferredSessionSaves.delete(reqId);
556
+ saveSessionAsync(pending.session, pending.opts).then(resolve, reject);
557
+ });
558
+ });
559
+ }
560
+
534
561
  /**
535
562
  * Exported for save-session-worker — not part of the public API.
536
563
  * External callers should use saveSession / saveSessionAsync.
@@ -627,6 +654,13 @@ export function drainSessionStore() {
627
654
  }
628
655
  }
629
656
  _savePending.clear();
657
+ // Invalidate older worker writes for sessions whose newer terminal snapshot
658
+ // is deferred, then wait out any commit already holding the write guard.
659
+ for (const [, pending] of _deferredSessionSaves) {
660
+ _cancelSessionWrites(pending.session.id);
661
+ _waitForWriteCommit(pending.session.id);
662
+ pending.opts = _guardedSaveOptions(pending.session.id, pending.opts);
663
+ }
630
664
  // Summary-index ops queued by the deferred/no-wait flush path: give them
631
665
  // one last best-effort flush before exit (still zero-wait; losing them is
632
666
  // acceptable — the index self-heals on next rebuild).
@@ -662,6 +696,17 @@ export function drainSessionStore() {
662
696
  try { w.reject(_drainErr); } catch { /* best-effort */ }
663
697
  }
664
698
  }
699
+ // Terminal/deferred snapshots are newest and must be written LAST.
700
+ for (const [, pending] of _deferredSessionSaves) {
701
+ try {
702
+ _saveSessionSync(pending.session, pending.opts);
703
+ pending.resolve();
704
+ } catch (err) {
705
+ pending.reject(err);
706
+ process.stderr.write(`[session-store] drain deferred save failed: ${err?.message}\n`);
707
+ }
708
+ }
709
+ _deferredSessionSaves.clear();
665
710
  _saveWorkerPending.clear();
666
711
  _saveAsyncQueued.clear();
667
712
  _saveAsyncInflight.clear();