mixdog 0.9.52 → 0.9.53

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 (111) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench-run.mjs +2 -2
  3. package/scripts/compact-pressure-test.mjs +104 -0
  4. package/scripts/desktop-session-bridge-test.mjs +704 -0
  5. package/scripts/freevar-smoke.mjs +7 -4
  6. package/scripts/lifecycle-api-test.mjs +65 -4
  7. package/scripts/max-output-recovery-test.mjs +31 -0
  8. package/scripts/memory-core-input-test.mjs +10 -0
  9. package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
  10. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  11. package/scripts/parent-abort-link-test.mjs +24 -0
  12. package/scripts/process-lifecycle-test.mjs +80 -22
  13. package/scripts/provider-contract-test.mjs +257 -0
  14. package/scripts/provider-toolcall-test.mjs +172 -10
  15. package/scripts/session-orphan-sweep-test.mjs +27 -1
  16. package/src/lib/keychain-cjs.cjs +36 -23
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  18. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  19. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
  22. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
  24. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
  27. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  28. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  29. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  30. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
  41. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  42. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  43. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  44. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  45. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
  46. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  47. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  48. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  50. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  53. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  55. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  59. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  62. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  63. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  64. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  65. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  66. package/src/runtime/channels/backends/discord.mjs +6 -6
  67. package/src/runtime/channels/lib/config.mjs +15 -2
  68. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  69. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  70. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  72. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  73. package/src/runtime/memory/index.mjs +24 -198
  74. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  75. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  76. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  77. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  78. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  79. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  80. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  81. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  82. package/src/runtime/shared/config.mjs +58 -13
  83. package/src/runtime/shared/llm/cost.mjs +14 -4
  84. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  85. package/src/runtime/shared/process-lifecycle.mjs +92 -19
  86. package/src/runtime/shared/process-shutdown.mjs +6 -0
  87. package/src/runtime/shared/resource-admission.mjs +7 -2
  88. package/src/session-runtime/channel-config-api.mjs +7 -7
  89. package/src/session-runtime/config-lifecycle.mjs +20 -17
  90. package/src/session-runtime/cwd-plugins.mjs +9 -7
  91. package/src/session-runtime/env.mjs +1 -2
  92. package/src/session-runtime/hitch-profile.mjs +45 -0
  93. package/src/session-runtime/lifecycle-api.mjs +36 -9
  94. package/src/session-runtime/mcp-glue.mjs +6 -11
  95. package/src/session-runtime/provider-init-key.mjs +17 -0
  96. package/src/session-runtime/runtime-core.mjs +44 -103
  97. package/src/session-runtime/runtime-paths.mjs +20 -0
  98. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  99. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  100. package/src/session-runtime/tool-catalog.mjs +15 -89
  101. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  102. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  103. package/src/standalone/agent-tool.mjs +12 -162
  104. package/src/standalone/channel-admin.mjs +29 -0
  105. package/src/tui/App.jsx +11 -5
  106. package/src/tui/dist/index.mjs +202 -65
  107. package/src/tui/engine/session-api-ext.mjs +37 -4
  108. package/src/tui/index.jsx +1 -0
  109. package/src/tui/lib/voice-setup.mjs +5 -5
  110. package/scripts/_devtools-stub.mjs +0 -1
  111. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -3,7 +3,7 @@
3
3
  * Sessions are saved to disk so CLI and MCP server can share state,
4
4
  * and sessions survive server restarts (resume).
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
6
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, statSync } from 'fs';
7
7
  import * as fsp from 'fs/promises';
8
8
  import { randomBytes } from 'crypto';
9
9
  import { join } from 'path';
@@ -15,6 +15,15 @@ import { sanitizeContentForStoredHistory } from '../providers/media-normalizatio
15
15
  import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
16
16
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../../../../lib/mixdog-debug.cjs';
17
17
  import { resolveAgentTerminalReapMs } from '../../../../session-runtime/config-helpers.mjs';
18
+ import { getStoreDir, sessionPath, publishHeartbeat, deleteHeartbeat } from './store/paths-heartbeat.mjs';
19
+ import {
20
+ guardedSaveOptions as _guardedSaveOptions,
21
+ cancelSessionWrites as _cancelSessionWrites,
22
+ isCancelledWrite as _isCancelledWrite,
23
+ acquireWriteCommit as _acquireWriteCommit,
24
+ releaseWriteCommit as _releaseWriteCommit,
25
+ waitForWriteCommit as _waitForWriteCommit,
26
+ } from './store/write-guards.mjs';
18
27
  import {
19
28
  summaryIndexPath,
20
29
  _sessionSummary,
@@ -36,9 +45,112 @@ export {
36
45
  _upsertSessionSummary,
37
46
  _removeSessionSummary,
38
47
  } from './store-summary-index.mjs';
48
+ export { publishHeartbeat, deleteHeartbeat } from './store/paths-heartbeat.mjs';
39
49
 
40
50
  const _lastSaveError = new Map(); // id -> { message, at }
41
51
 
52
+ // Listing is much hotter than writing, especially while the desktop session
53
+ // browser is open. Keep the compact sidecar in memory after the first read;
54
+ // local durability mutations update this cache synchronously, while an
55
+ // explicit refresh remains the authoritative cross-process/disk reconciliation
56
+ // path. Pending overlays cover a write that lands before the first listing.
57
+ let _summaryRowsCache = null;
58
+ const _summaryCacheUpserts = new Map();
59
+ const _summaryCacheRemovals = new Set();
60
+ const _summaryCacheVersions = new Map();
61
+ let _summaryCacheDataDir = null;
62
+
63
+ function _ensureSummaryCacheDataDir() {
64
+ const dataDir = getPluginData();
65
+ if (_summaryCacheDataDir === dataDir) return;
66
+ _summaryCacheDataDir = dataDir;
67
+ _summaryRowsCache = null;
68
+ _summaryCacheUpserts.clear();
69
+ _summaryCacheRemovals.clear();
70
+ _summaryCacheVersions.clear();
71
+ }
72
+
73
+ function _summaryRowsWithLocalMutations(rows, { discardLocalMutations = false } = {}) {
74
+ if (discardLocalMutations) {
75
+ _summaryCacheUpserts.clear();
76
+ _summaryCacheRemovals.clear();
77
+ }
78
+ const byId = new Map(_normalizeSummaryIndex({ rows }).rows.map((row) => [row.id, row]));
79
+ for (const id of _summaryCacheRemovals) byId.delete(id);
80
+ for (const [id, row] of _summaryCacheUpserts) byId.set(id, row);
81
+ return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
82
+ }
83
+
84
+ function _setSummaryRowsCache(rows, options) {
85
+ if (options?.discardLocalMutations === true) {
86
+ _summaryCacheUpserts.clear();
87
+ _summaryCacheRemovals.clear();
88
+ }
89
+ _summaryRowsCache = _normalizeSummaryIndex({ rows }).rows;
90
+ return _summaryRowsWithLocalMutations(_summaryRowsCache);
91
+ }
92
+
93
+ function _cachedSummaryRows() {
94
+ return _summaryRowsCache === null ? null : _summaryRowsWithLocalMutations(_summaryRowsCache);
95
+ }
96
+
97
+ function _setCachedBaseSummary(row) {
98
+ if (!row || _summaryRowsCache === null) return;
99
+ const byId = new Map(_summaryRowsCache.map((existing) => [existing.id, existing]));
100
+ byId.set(row.id, row);
101
+ _summaryRowsCache = [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
102
+ }
103
+
104
+ function _removeCachedBaseSummary(id) {
105
+ if (_summaryRowsCache === null) return;
106
+ _summaryRowsCache = _summaryRowsCache.filter((row) => row.id !== id);
107
+ }
108
+
109
+ function _cacheSessionSummary(session) {
110
+ _ensureSummaryCacheDataDir();
111
+ const row = _sessionSummary(session);
112
+ if (!row) return;
113
+ _summaryCacheVersions.set(row.id, (_summaryCacheVersions.get(row.id) || 0) + 1);
114
+ _summaryCacheRemovals.delete(row.id);
115
+ _summaryCacheUpserts.set(row.id, row);
116
+ return _summaryCacheVersions.get(row.id);
117
+ }
118
+
119
+ function _uncacheSessionSummary(id) {
120
+ _ensureSummaryCacheDataDir();
121
+ if (!id) return;
122
+ _summaryCacheVersions.set(id, (_summaryCacheVersions.get(id) || 0) + 1);
123
+ _summaryCacheUpserts.delete(id);
124
+ _summaryCacheRemovals.add(id);
125
+ _removeCachedBaseSummary(id);
126
+ }
127
+
128
+ function _rollbackCachedSessionSummary(id, version) {
129
+ if ((_summaryCacheVersions.get(id) || 0) !== version) return;
130
+ _summaryCacheUpserts.delete(id);
131
+ }
132
+
133
+ function _queueSessionSummaryUpsert(session, version = null) {
134
+ const row = _sessionSummary(session);
135
+ if (!row) return;
136
+ _setCachedBaseSummary(row);
137
+ if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
138
+ _summaryCacheUpserts.delete(row.id);
139
+ _summaryCacheRemovals.delete(row.id);
140
+ }
141
+ _upsertSessionSummary(session);
142
+ }
143
+
144
+ function _queueSessionSummaryRemoval(id) {
145
+ _uncacheSessionSummary(id);
146
+ _removeSessionSummary(id);
147
+ }
148
+
149
+ function _queueSummaryIndexPrune(ids) {
150
+ for (const id of ids) _uncacheSessionSummary(id);
151
+ _pruneSummaryIndexIds(ids);
152
+ }
153
+
42
154
  // The live in-memory session (and every model request)
43
155
  // retains attached image bytes across turns so multi-turn recognition works.
44
156
  // The persisted session JSON, however, replaces image content with a short
@@ -82,21 +194,6 @@ function _renameWithRetrySync(tmp, target) {
82
194
  return renameWithRetrySync(tmp, target);
83
195
  }
84
196
 
85
- function getStoreDir() {
86
- const dir = join(getPluginData(), 'sessions');
87
- if (!existsSync(dir))
88
- mkdirSync(dir, { recursive: true });
89
- return dir;
90
- }
91
- function sessionPath(id) {
92
- // Enforce minted session-id shape before using it in a path to prevent
93
- // `../` traversal. session IDs are generated by createSession as
94
- // `sess_<timestamp>_<hex>` — reject anything that doesn't match.
95
- if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
96
- throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
97
- }
98
- return join(getStoreDir(), `${id}.json`);
99
- }
100
197
  /**
101
198
  * Ensure generation/closed defaults on every session object.
102
199
  * Older persisted sessions predate these fields; we normalise at load and save.
@@ -124,37 +221,6 @@ function _clearLiveSession(id) {
124
221
  if (id) _liveSessions.delete(id);
125
222
  }
126
223
 
127
- // ── Heartbeat publish ─────────────────────────────────────
128
- // Lightweight per-session timestamp file (`<id>.hb`) consumed by the
129
- // status aggregator for fresh-session detection. Decoupled from the
130
- // full session JSON save so it can fire at a tight cadence (≤5s)
131
- // without serialising the whole payload. The .hb file holds a single
132
- // ASCII line: `<msTimestamp>\n`. Aggregator scans the same sessions/
133
- // directory and matches `<id>.hb` to `<id>.json`.
134
- const _HEARTBEAT_THROTTLE_MS = 5_000;
135
- const _hbLastAt = new Map();
136
-
137
- function _heartbeatPath(id) {
138
- if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
139
- throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
140
- }
141
- return join(getStoreDir(), `${id}.hb`);
142
- }
143
-
144
- export function publishHeartbeat(id, ts) {
145
- if (!id) return;
146
- const now = ts || Date.now();
147
- const last = _hbLastAt.get(id) || 0;
148
- if (now - last < _HEARTBEAT_THROTTLE_MS) return;
149
- const target = _heartbeatPath(id);
150
- _hbLastAt.set(id, now);
151
- void fsp.writeFile(target, `${now}\n`, 'utf8').catch(() => {});
152
- }
153
-
154
- export function deleteHeartbeat(id) {
155
- try { unlinkSync(_heartbeatPath(id)); } catch { /* ignore */ }
156
- _hbLastAt.delete(id);
157
- }
158
224
  const _deleteHeartbeat = deleteHeartbeat;
159
225
 
160
226
  // ── 150 ms debounce window ────────────────────────────────────────────────────
@@ -181,12 +247,18 @@ export function saveSession(session, opts) {
181
247
  _ensureLifecycleFields(session);
182
248
  const id = session.id;
183
249
  setLiveSession(session);
184
- const payload = { session, opts: opts || null };
250
+ const summaryVersion = _cacheSessionSummary(session);
251
+ const payload = { session, opts: _guardedSaveOptions(id, opts), summaryVersion };
185
252
  // Synchronous durability path — explicit flush (tombstones, drain hooks).
186
253
  // createSession uses async debounced save + _liveSessions for same-process
187
254
  // read-your-writes; sync remains for callers that require immediate disk.
188
255
  if (opts?.sync) {
189
- _doSaveSync(payload);
256
+ try {
257
+ if (!_doSaveSync(payload)) _rollbackCachedSessionSummary(id, summaryVersion);
258
+ } catch (err) {
259
+ _rollbackCachedSessionSummary(id, summaryVersion);
260
+ throw err;
261
+ }
190
262
  return;
191
263
  }
192
264
  // Immediate-flush override: tombstone plants and explicit flushes skip the
@@ -202,8 +274,11 @@ export function saveSession(session, opts) {
202
274
  _flushScheduled(id);
203
275
  }
204
276
  } else {
205
- _savePending.set(id, { writing: true });
206
- _doSave(payload).catch(err => {
277
+ _savePending.set(id, { writing: true, payload });
278
+ _doSave(payload).then((saved) => {
279
+ if (!saved) _rollbackCachedSessionSummary(id, summaryVersion);
280
+ }).catch(err => {
281
+ _rollbackCachedSessionSummary(id, summaryVersion);
207
282
  process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
208
283
  _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
209
284
  });
@@ -247,7 +322,10 @@ function _flushScheduled(id) {
247
322
  const cur = _savePending.get(id);
248
323
  if (!cur || !cur.scheduled) return;
249
324
  _savePending.set(id, { writing: true, payload: cur.payload });
250
- _doSave(cur.payload).catch(err => {
325
+ _doSave(cur.payload).then((saved) => {
326
+ if (!saved) _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
327
+ }).catch(err => {
328
+ _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
251
329
  process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
252
330
  _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
253
331
  });
@@ -275,7 +353,7 @@ function _getOrSpawnWorker() {
275
353
  _saveWorker = new Worker(new URL('./save-session-worker.mjs', import.meta.url), {
276
354
  execArgv: [],
277
355
  });
278
- _saveWorker.on('message', ({ ok, error, reqId }) => {
356
+ _saveWorker.on('message', ({ ok, saved, error, reqId }) => {
279
357
  const p = _saveWorkerPending.get(reqId);
280
358
  if (!p) return;
281
359
  _saveWorkerPending.delete(reqId);
@@ -283,13 +361,18 @@ function _getOrSpawnWorker() {
283
361
  // becomes unref'd again once all in-flight writes settle. _saveWorker
284
362
  // null-check covers the error/exit race where the worker died first.
285
363
  if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
286
- const { id, waiters } = p;
364
+ const { id, session, summaryVersion, waiters } = p;
287
365
  _saveAsyncInflight.delete(id);
288
366
  // Resolve/reject every caller whose payload this write represents
289
367
  // (the originating call plus any that coalesced onto it before it was
290
368
  // posted). A supersede never lands here as a rejection — only a real
291
369
  // worker failure does.
292
370
  if (ok) {
371
+ // A close/delete may have completed while the worker was writing.
372
+ // Do not let this older completion put an open row back in the
373
+ // process-local cache after its tombstone/removal.
374
+ if (saved) _queueSessionSummaryUpsert(session, summaryVersion);
375
+ else _rollbackCachedSessionSummary(id, summaryVersion);
293
376
  clearSessionSaveError(id);
294
377
  for (const w of waiters) w.resolve();
295
378
  }
@@ -305,16 +388,23 @@ function _getOrSpawnWorker() {
305
388
  if (q) {
306
389
  _saveAsyncQueued.delete(id);
307
390
  try {
308
- _postAsyncWrite(id, q.session, q.opts, q.waiters);
391
+ _postAsyncWrite(id, q.session, q.opts, q.waiters, q.summaryVersion);
309
392
  } catch (err) {
393
+ _rollbackCachedSessionSummary(id, q.summaryVersion);
310
394
  for (const w of q.waiters) w.reject(err);
311
395
  }
312
396
  }
313
397
  });
314
398
  _saveWorker.on('error', (err) => {
315
- for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
399
+ for (const [, p] of _saveWorkerPending) {
400
+ _rollbackCachedSessionSummary(p.id, p.summaryVersion);
401
+ for (const w of p.waiters) w.reject(err);
402
+ }
316
403
  _saveWorkerPending.clear();
317
- for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
404
+ for (const [id, q] of _saveAsyncQueued) {
405
+ _rollbackCachedSessionSummary(id, q.summaryVersion);
406
+ for (const w of q.waiters) w.reject(err);
407
+ }
318
408
  _saveAsyncQueued.clear();
319
409
  _saveAsyncInflight.clear();
320
410
  _saveWorkerRefCount = 0;
@@ -328,9 +418,15 @@ function _getOrSpawnWorker() {
328
418
  // saveSessionAsync registered a resolver but before the worker
329
419
  // received the message.
330
420
  const err = new Error(`[session-store] save worker exited with code ${code}`);
331
- for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
421
+ for (const [, p] of _saveWorkerPending) {
422
+ _rollbackCachedSessionSummary(p.id, p.summaryVersion);
423
+ for (const w of p.waiters) w.reject(err);
424
+ }
332
425
  _saveWorkerPending.clear();
333
- for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
426
+ for (const [id, q] of _saveAsyncQueued) {
427
+ _rollbackCachedSessionSummary(id, q.summaryVersion);
428
+ for (const w of q.waiters) w.reject(err);
429
+ }
334
430
  _saveAsyncQueued.clear();
335
431
  _saveAsyncInflight.clear();
336
432
  _saveWorkerRefCount = 0;
@@ -346,9 +442,15 @@ function _getOrSpawnWorker() {
346
442
  * in flight for `id`. Throws (after cleaning its own map entries) if the
347
443
  * worker postMessage fails so the caller can reject the affected waiters.
348
444
  */
349
- function _postAsyncWrite(id, session, opts, waiters) {
445
+ function _postAsyncWrite(id, session, opts, waiters, summaryVersion) {
350
446
  const reqId = ++_saveWorkerReqId;
351
- _saveWorkerPending.set(reqId, { id, session, opts, waiters });
447
+ _saveWorkerPending.set(reqId, {
448
+ id,
449
+ session,
450
+ opts,
451
+ summaryVersion,
452
+ waiters,
453
+ });
352
454
  _saveAsyncInflight.set(id, reqId);
353
455
  try {
354
456
  const w = _getOrSpawnWorker();
@@ -379,7 +481,8 @@ export function saveSessionAsync(session, opts) {
379
481
  _ensureLifecycleFields(session);
380
482
  setLiveSession(session);
381
483
  const id = session.id;
382
- const safeOpts = opts || null;
484
+ const summaryVersion = _cacheSessionSummary(session);
485
+ const safeOpts = _guardedSaveOptions(id, opts);
383
486
  // The Worker `postMessage` below structured-clones the whole session on the
384
487
  // main thread. `session.liveTurnMessages` (live working transcript) and
385
488
  // `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
@@ -404,9 +507,15 @@ export function saveSessionAsync(session, opts) {
404
507
  if (q) {
405
508
  q.session = clonePayload;
406
509
  q.opts = safeOpts;
510
+ q.summaryVersion = summaryVersion;
407
511
  q.waiters.push(waiter);
408
512
  } else {
409
- _saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
513
+ _saveAsyncQueued.set(id, {
514
+ session: clonePayload,
515
+ opts: safeOpts,
516
+ summaryVersion,
517
+ waiters: [waiter],
518
+ });
410
519
  }
411
520
  return;
412
521
  }
@@ -414,8 +523,9 @@ export function saveSessionAsync(session, opts) {
414
523
  // in-flight entry persists {session, opts} so drainSessionStore can
415
524
  // sync-flush outstanding writes if process exit interrupts the queue.
416
525
  try {
417
- _postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
526
+ _postAsyncWrite(id, clonePayload, safeOpts, [waiter], summaryVersion);
418
527
  } catch (err) {
528
+ _rollbackCachedSessionSummary(id, summaryVersion);
419
529
  reject(err);
420
530
  }
421
531
  });
@@ -427,24 +537,35 @@ export function saveSessionAsync(session, opts) {
427
537
  */
428
538
  export function _saveSessionSync(session, opts) {
429
539
  _ensureLifecycleFields(session);
430
- _doSaveSync({ session, opts: opts || null });
540
+ return _doSaveSync({ session, opts: opts || null });
431
541
  }
432
542
 
433
543
  function _doSaveSync(payload) {
434
- const { session, opts } = payload;
544
+ const { session, opts, summaryVersion = null } = payload;
435
545
  const id = session.id;
436
- if (_shouldDrop(id, opts)) return;
546
+ if (_shouldDrop(id, opts)) return false;
437
547
  const target = sessionPath(id);
438
548
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
439
549
  try {
440
550
  writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
441
551
  if (_shouldDrop(id, opts)) {
442
552
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
443
- return;
553
+ return false;
444
554
  }
445
- _renameWithRetrySync(tmp, target);
446
- _upsertSessionSummary(session);
447
- clearSessionSaveError(id);
555
+ const commitControl = _acquireWriteCommit(opts);
556
+ if (commitControl === false || _shouldDrop(id, opts)) {
557
+ try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
558
+ _releaseWriteCommit(commitControl);
559
+ return false;
560
+ }
561
+ try {
562
+ _renameWithRetrySync(tmp, target);
563
+ _queueSessionSummaryUpsert(session, summaryVersion);
564
+ clearSessionSaveError(id);
565
+ } finally {
566
+ _releaseWriteCommit(commitControl);
567
+ }
568
+ return true;
448
569
  } catch (err) {
449
570
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
450
571
  throw err;
@@ -452,6 +573,7 @@ function _doSaveSync(payload) {
452
573
  }
453
574
 
454
575
  function _shouldDrop(id, opts) {
576
+ if (_isCancelledWrite(opts)) return true;
455
577
  if (!opts || opts.allowClosed) return false;
456
578
  const expected = typeof opts.expectedGeneration === 'number' ? opts.expectedGeneration : null;
457
579
  if (expected === null) return false;
@@ -551,7 +673,10 @@ function _drainQueue(id) {
551
673
  if (pending && pending.queued) {
552
674
  const next = pending.queued;
553
675
  _savePending.set(id, { writing: true, payload: next });
554
- _doSave(next).catch(err => {
676
+ _doSave(next).then((saved) => {
677
+ if (!saved) _rollbackCachedSessionSummary(id, next.summaryVersion);
678
+ }).catch(err => {
679
+ _rollbackCachedSessionSummary(id, next.summaryVersion);
555
680
  process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
556
681
  _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
557
682
  });
@@ -561,13 +686,13 @@ function _drainQueue(id) {
561
686
  }
562
687
 
563
688
  async function _doSave(payload) {
564
- const { session, opts } = payload;
689
+ const { session, opts, summaryVersion = null } = payload;
565
690
  const id = session.id;
566
691
  // First check: upfront, before any disk I/O. Cheap short-circuit when a
567
692
  // tombstone is already on disk when the caller arrives.
568
693
  if (_shouldDrop(id, opts)) {
569
694
  _drainQueue(id);
570
- return;
695
+ return false;
571
696
  }
572
697
  const target = sessionPath(id);
573
698
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
@@ -580,17 +705,29 @@ async function _doSave(payload) {
580
705
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
581
706
  process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
582
707
  _drainQueue(id);
583
- return;
708
+ return false;
584
709
  }
585
- _renameWithRetrySync(tmp, target);
586
- _upsertSessionSummary(session);
587
- clearSessionSaveError(id);
710
+ const commitControl = _acquireWriteCommit(opts);
711
+ if (commitControl === false || _shouldDrop(id, opts)) {
712
+ try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
713
+ _releaseWriteCommit(commitControl);
714
+ _drainQueue(id);
715
+ return false;
716
+ }
717
+ try {
718
+ _renameWithRetrySync(tmp, target);
719
+ _queueSessionSummaryUpsert(session, summaryVersion);
720
+ clearSessionSaveError(id);
721
+ } finally {
722
+ _releaseWriteCommit(commitControl);
723
+ }
724
+ _drainQueue(id);
725
+ return true;
588
726
  } catch (err) {
589
727
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
590
728
  _savePending.delete(id);
591
729
  throw err;
592
730
  }
593
- _drainQueue(id);
594
731
  }
595
732
 
596
733
  /**
@@ -604,6 +741,8 @@ export function markSessionClosed(id, reason = 'manual') {
604
741
  // that we are about to plant. The _shouldDrop() guard inside _doSave()
605
742
  // provides a second line of defence, but cancelling here is cheaper.
606
743
  _clearDebounce(id);
744
+ _cancelSessionWrites(id);
745
+ _uncacheSessionSummary(id);
607
746
  const existing = loadSession(id);
608
747
  if (!existing) return null;
609
748
  // Re-close idempotence: a session that is ALREADY tombstoned keeps its
@@ -651,7 +790,7 @@ export function markSessionClosed(id, reason = 'manual') {
651
790
  clearSessionSaveError(id);
652
791
  _clearLiveSession(id);
653
792
  _deleteHeartbeat(id);
654
- _upsertSessionSummary(tombstone);
793
+ _queueSessionSummaryUpsert(tombstone);
655
794
  // Structured close metric. Single emission point because every close
656
795
  // path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
657
796
  // straddles the tombstone (updatedAt was just set to Date.now()), so
@@ -694,6 +833,8 @@ export function markSessionClosed(id, reason = 'manual') {
694
833
  */
695
834
  export function bumpSessionGeneration(id, reason = 'detach') {
696
835
  _clearDebounce(id);
836
+ _cancelSessionWrites(id);
837
+ _uncacheSessionSummary(id);
697
838
  const existing = loadSession(id);
698
839
  if (!existing) return null;
699
840
  const newGen = (typeof existing.generation === 'number' ? existing.generation : 0) + 1;
@@ -711,7 +852,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
711
852
  clearSessionSaveError(id);
712
853
  _clearLiveSession(id);
713
854
  _deleteHeartbeat(id);
714
- _upsertSessionSummary(detached);
855
+ _queueSessionSummaryUpsert(detached);
715
856
  return newGen;
716
857
  }
717
858
 
@@ -754,6 +895,10 @@ export function clearSessionSaveError(id) {
754
895
  }
755
896
 
756
897
  export function deleteSession(id, options = {}) {
898
+ _cancelSessionWrites(id);
899
+ _clearDebounce(id);
900
+ _savePending.delete(id);
901
+ _waitForWriteCommit(id);
757
902
  const path = sessionPath(id);
758
903
  let removed = false;
759
904
  if (existsSync(path)) {
@@ -764,11 +909,13 @@ export function deleteSession(id, options = {}) {
764
909
  catch { /* fall through to .hb cleanup */ }
765
910
  }
766
911
  _deleteHeartbeat(id);
912
+ _clearLiveSession(id);
767
913
  if (removed || !existsSync(path)) clearSessionSaveError(id);
768
914
  // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
769
915
  // rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
770
916
  // summary index once PER DELETION. They batch the index update themselves.
771
- if (removed && options.deferSummaryUpdate !== true) _removeSessionSummary(id);
917
+ if (options.deferSummaryUpdate === true) _uncacheSessionSummary(id);
918
+ else _queueSessionSummaryRemoval(id);
772
919
  return removed;
773
920
  }
774
921
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
@@ -810,7 +957,9 @@ export function listStoredSessions(options = {}) {
810
957
  return [];
811
958
  const files = readdirSync(dir).filter(f => f.endsWith('.json'));
812
959
  const sessionsById = new Map();
813
- const invalidStorageIds = new Set();
960
+ const invalidStorageIds = options._invalidStorageIds instanceof Set
961
+ ? options._invalidStorageIds
962
+ : new Set();
814
963
  for (const f of files) {
815
964
  const session = _storedSessionFromFile(dir, f);
816
965
  if (session) {
@@ -820,71 +969,91 @@ export function listStoredSessions(options = {}) {
820
969
  const storageId = f.slice(0, -5);
821
970
  if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
822
971
  }
823
- if (options.includeLive === true) {
824
- // createSession publishes to both of these maps before its 150ms
825
- // debounce reaches disk. Merge them by their map/storage identity so
826
- // an immediate desktop list has read-your-writes semantics.
827
- for (const [id, pending] of _savePending) {
828
- const session = (pending.queued || pending.payload)?.session;
829
- if (!invalidStorageIds.has(id) && session?.id === id) {
830
- sessionsById.set(id, _ensureLifecycleFields(session));
831
- }
832
- }
833
- for (const [id, session] of _liveSessions) {
834
- if (!invalidStorageIds.has(id) && session?.id === id) {
835
- sessionsById.set(id, _ensureLifecycleFields(session));
836
- }
837
- }
972
+ const stored = [...sessionsById.values()];
973
+ return options.includeLive === true
974
+ ? _withUnpersistedSessions(stored, invalidStorageIds)
975
+ : stored.sort((a, b) => b.updatedAt - a.updatedAt);
976
+ }
977
+
978
+ function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
979
+ const sessionsById = new Map(stored.map((session) => [session.id, session]));
980
+ const addIfUnpersisted = (id, session, opts) => {
981
+ // A valid on-disk record is authoritative for refresh/resume. In
982
+ // particular, a long-lived runtime object must never replace a
983
+ // tombstone or changed desktop authorization metadata. Only active
984
+ // local writes with no disk record get read-your-writes visibility.
985
+ if (sessionsById.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
986
+ if (session?.id === id) sessionsById.set(id, _ensureLifecycleFields(session));
987
+ };
988
+ for (const [id, pending] of _savePending) {
989
+ const payload = pending.queued || pending.payload;
990
+ addIfUnpersisted(id, payload?.session, payload?.opts);
991
+ }
992
+ for (const [, pending] of _saveWorkerPending) {
993
+ addIfUnpersisted(pending.id, pending.session, pending.opts);
994
+ }
995
+ for (const [id, pending] of _saveAsyncQueued) {
996
+ addIfUnpersisted(id, pending.session, pending.opts);
838
997
  }
839
998
  return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
840
999
  }
841
1000
 
842
1001
  export function rebuildSessionSummaryIndex() {
1002
+ _ensureSummaryCacheDataDir();
843
1003
  const rows = listStoredSessions().map(_sessionSummary).filter(Boolean);
844
- return _writeSummaryIndex(rows);
1004
+ return _setSummaryRowsCache(_writeSummaryIndex(rows));
845
1005
  }
846
1006
 
847
1007
  export function listStoredSessionSummaries(options = {}) {
1008
+ _ensureSummaryCacheDataDir();
848
1009
  const rebuildIfMissing = options.rebuildIfMissing !== false;
849
- let indexedRows = [];
850
- let p;
851
- try {
852
- p = summaryIndexPath();
853
- if (existsSync(p)) {
854
- indexedRows = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8'))).rows;
855
- }
856
- } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
857
-
858
- // Desktop history is user-facing and must not miss a just-saved session
859
- // merely because the best-effort summary sidecar lost/delayed an upsert.
860
- // Explicit refresh callers scan the authoritative session JSON files. Keep
861
- // the scan fail-closed per listStoredSessions (corrupt files are skipped),
862
- // and still return valid scanned rows if only the sidecar rewrite fails.
1010
+ // This is intentionally the only path that rescans every session JSON:
1011
+ // callers use it as an on-demand authoritative refresh (including resume
1012
+ // authorization), so it must not trust either the cache or sidecar.
863
1013
  if (options.refreshFromStorage === true) {
864
1014
  try {
865
- const rows = listStoredSessions({ includeLive: true }).map(_sessionSummary).filter(Boolean);
866
- try { _writeSummaryIndex(rows); } catch { /* sidecar remains best-effort */ }
1015
+ const invalidStorageIds = new Set();
1016
+ const stored = listStoredSessions({ _invalidStorageIds: invalidStorageIds });
1017
+ const persistedRows = stored.map(_sessionSummary).filter(Boolean);
1018
+ const rows = _withUnpersistedSessions(stored, invalidStorageIds).map(_sessionSummary).filter(Boolean);
1019
+ try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
1020
+ // A direct scan settles deletion state too; retain only active
1021
+ // optimistic write overlays, never a stale local removal.
1022
+ _summaryCacheRemovals.clear();
1023
+ _setSummaryRowsCache(persistedRows);
867
1024
  return rows;
868
1025
  } catch {
869
1026
  // A refresh is an authorization boundary for desktop resume. If
870
- // authoritative storage cannot be enumerated, stale sidecar rows
871
- // must not be treated as proof that a session is available.
1027
+ // authoritative storage cannot be enumerated, stale cached/sidecar
1028
+ // rows must not be treated as proof that a session is available.
872
1029
  return [];
873
1030
  }
874
1031
  }
1032
+ if (_summaryRowsCache !== null) return _cachedSummaryRows().slice();
1033
+
1034
+ let indexedRows = [];
1035
+ let p;
1036
+ let hasIndex = false;
1037
+ try {
1038
+ p = summaryIndexPath();
1039
+ hasIndex = existsSync(p);
1040
+ if (hasIndex) {
1041
+ indexedRows = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8'))).rows;
1042
+ }
1043
+ } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
875
1044
 
876
- if (!p || !existsSync(p)) {
877
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : []; }
878
- catch { return indexedRows; }
1045
+ if (!p || !hasIndex) {
1046
+ try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache([]); }
1047
+ catch { return _setSummaryRowsCache(indexedRows); }
879
1048
  }
880
1049
  try {
881
- if (indexedRows.length > 0) return indexedRows;
1050
+ if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
882
1051
  const dir = getStoreDir();
883
1052
  const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
884
- return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : indexedRows;
1053
+ return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows);
885
1054
  } catch {
886
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : indexedRows; }
887
- catch { return indexedRows; }
1055
+ try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows); }
1056
+ catch { return _setSummaryRowsCache(indexedRows); }
888
1057
  }
889
1058
  }
890
1059
 
@@ -971,7 +1140,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
971
1140
  if (!row?.id) continue;
972
1141
  const jsonPath = sessionPath(row.id);
973
1142
  if (!existsSync(jsonPath)) {
974
- _removeSessionSummary(row.id);
1143
+ _queueSessionSummaryRemoval(row.id);
975
1144
  continue;
976
1145
  }
977
1146
  let jsonMtime = 0;
@@ -1011,6 +1180,15 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1011
1180
  }
1012
1181
  }
1013
1182
  if (diskClosed) {
1183
+ // A shared store can be tombstoned by another process while
1184
+ // this process still owns an in-flight controller for the same
1185
+ // id. Exclude it before unlinking: clearing only the local
1186
+ // runtime after deletion is too late because its eventual save
1187
+ // would see no tombstone and could resurrect the session.
1188
+ if (isSessionLive && isSessionLive(row.id)) {
1189
+ remaining++;
1190
+ continue;
1191
+ }
1014
1192
  // Closed sessions are EXEMPT from the freshness gate: a tombstone
1015
1193
  // whose file/hb mtime keeps getting bumped would otherwise stay
1016
1194
  // perpetually "fresh" and never mature. Maturity is governed ONLY
@@ -1036,7 +1214,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1036
1214
  // open: reflect the real closed state so the next sweep sees the
1037
1215
  // correct closed=true/updatedAt and never re-closes it.
1038
1216
  if (actual && !(row.closed === true || row.status === 'closed')) {
1039
- try { _upsertSessionSummary(actual); } catch { /* best-effort */ }
1217
+ try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
1040
1218
  }
1041
1219
  remaining++;
1042
1220
  continue;
@@ -1179,7 +1357,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1179
1357
  if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
1180
1358
  try {
1181
1359
  const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
1182
- _pruneSummaryIndexIds(deletedIds);
1360
+ _queueSummaryIndexPrune(deletedIds);
1183
1361
  } catch { /* summary index is best-effort */ }
1184
1362
  }
1185
1363
  return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };