mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -15,7 +15,7 @@ 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';
18
+ import { getStoreDir, sessionPath, publishHeartbeat, deleteHeartbeat, deleteSessionPresence } from './store/paths-heartbeat.mjs';
19
19
  import {
20
20
  guardedSaveOptions as _guardedSaveOptions,
21
21
  cancelSessionWrites as _cancelSessionWrites,
@@ -25,6 +25,7 @@ import {
25
25
  waitForWriteCommit as _waitForWriteCommit,
26
26
  } from './store/write-guards.mjs';
27
27
  import {
28
+ SESSION_SUMMARY_INDEX_VERSION,
28
29
  summaryIndexPath,
29
30
  _sessionSummary,
30
31
  _normalizeSummaryIndex,
@@ -45,10 +46,64 @@ export {
45
46
  _upsertSessionSummary,
46
47
  _removeSessionSummary,
47
48
  } from './store-summary-index.mjs';
48
- export { publishHeartbeat, deleteHeartbeat } from './store/paths-heartbeat.mjs';
49
+ export {
50
+ publishHeartbeat,
51
+ deleteHeartbeat,
52
+ publishSessionPresence,
53
+ deleteSessionPresence,
54
+ readSessionPresenceMtime,
55
+ isSessionPresenceOwnerDead,
56
+ } from './store/paths-heartbeat.mjs';
49
57
 
50
58
  const _lastSaveError = new Map(); // id -> { message, at }
51
59
 
60
+ // Recent full-session reads are much hotter than writes while a user hops
61
+ // between conversations. Verify the file identity on every access, but reuse
62
+ // the parsed object while the atomic file has not changed.
63
+ const SESSION_LOAD_CACHE_LIMIT = 8;
64
+ const _sessionLoadCache = new Map(); // path → { signature, session }
65
+ let _sessionLoadCacheDataDir = null;
66
+
67
+ function _readStoredSessionCached(id, path) {
68
+ const dataDir = getPluginData();
69
+ if (_sessionLoadCacheDataDir !== dataDir) {
70
+ _sessionLoadCacheDataDir = dataDir;
71
+ _sessionLoadCache.clear();
72
+ }
73
+ let signature;
74
+ try {
75
+ const info = statSync(path, { bigint: true });
76
+ signature = `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`;
77
+ } catch {
78
+ _sessionLoadCache.delete(path);
79
+ return { exists: existsSync(path), session: null };
80
+ }
81
+ const cached = _sessionLoadCache.get(path);
82
+ if (cached?.signature === signature) {
83
+ _sessionLoadCache.delete(path);
84
+ _sessionLoadCache.set(path, cached);
85
+ return { exists: true, session: cached.session };
86
+ }
87
+ try {
88
+ const stored = JSON.parse(readFileSync(path, 'utf-8'));
89
+ if (stored?.id !== id) {
90
+ _sessionLoadCache.delete(path);
91
+ return { exists: true, session: null };
92
+ }
93
+ _sessionLoadCache.delete(path);
94
+ _sessionLoadCache.set(path, { signature, session: stored });
95
+ while (_sessionLoadCache.size > SESSION_LOAD_CACHE_LIMIT) {
96
+ const oldest = _sessionLoadCache.keys().next().value;
97
+ if (oldest === undefined) break;
98
+ _sessionLoadCache.delete(oldest);
99
+ }
100
+ return { exists: true, session: stored };
101
+ } catch {
102
+ _sessionLoadCache.delete(path);
103
+ return { exists: true, session: null };
104
+ }
105
+ }
106
+
52
107
  // Listing is much hotter than writing, especially while the desktop session
53
108
  // browser is open. Keep the compact sidecar in memory after the first read;
54
109
  // local durability mutations update this cache synchronously, while an
@@ -65,6 +120,7 @@ function _ensureSummaryCacheDataDir() {
65
120
  if (_summaryCacheDataDir === dataDir) return;
66
121
  _summaryCacheDataDir = dataDir;
67
122
  _summaryRowsCache = null;
123
+ _summaryScanCache.clear();
68
124
  _summaryCacheUpserts.clear();
69
125
  _summaryCacheRemovals.clear();
70
126
  _summaryCacheVersions.clear();
@@ -212,15 +268,124 @@ const _savePending = new Map();
212
268
  /** Same-process authoritative session snapshots (createSession → loadSession / askSession). */
213
269
  const _liveSessions = new Map();
214
270
 
271
+ // Session ids whose most recent save attempt was DROPPED by the ownership
272
+ // guard (_shouldDrop: disk generation moved past the caller's expected
273
+ // generation) or failed outright. For these ids the local live snapshot is
274
+ // AHEAD of disk — the on-disk transcript froze at the last landed save — so
275
+ // it must not be evicted (data loss) nor shadowed by the stale disk copy.
276
+ const _droppedSaveIds = new Set();
277
+
215
278
  export function setLiveSession(session) {
216
279
  if (!session?.id) return;
217
280
  _liveSessions.set(session.id, session);
218
281
  }
219
282
 
283
+ /**
284
+ * Cheap authoritative lifecycle read straight from disk (no live/pending
285
+ * cache). Used by askSession's split-brain re-adoption: a new ask on a
286
+ * non-closed session claims ownership by adopting the on-disk generation.
287
+ */
288
+ export function readSessionLifecycleFromDisk(id) {
289
+ if (!id) return null;
290
+ const target = sessionPath(id);
291
+ if (!existsSync(target)) return null;
292
+ try {
293
+ const raw = readFileSync(target, 'utf-8');
294
+ let onDisk = scanTopLevelLifecycle(raw);
295
+ if (onDisk === null) onDisk = JSON.parse(raw);
296
+ return {
297
+ generation: typeof onDisk.generation === 'number' ? onDisk.generation : 0,
298
+ closed: onDisk.closed === true,
299
+ };
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Freshness of a session's `.hb` heartbeat sidecar (0 when absent). Used by
307
+ * the fork-on-resume guard: a fresh heartbeat published by another process
308
+ * means the session is actively being driven there RIGHT NOW.
309
+ */
310
+ export function readSessionHeartbeatMtime(id) {
311
+ if (!id) return 0;
312
+ return _heartbeatMtime(id);
313
+ }
314
+
220
315
  function _clearLiveSession(id) {
221
316
  if (id) _liveSessions.delete(id);
222
317
  }
223
318
 
319
+ /** True while any pending/in-flight persistence still references this id. */
320
+ function _hasPendingPersistence(id) {
321
+ if (_savePending.has(id) || _saveAsyncInflight.has(id) || _saveAsyncQueued.has(id)) return true;
322
+ for (const [, pending] of _deferredSessionSaves) {
323
+ if (pending?.session?.id === id) return true;
324
+ }
325
+ return false;
326
+ }
327
+
328
+ /**
329
+ * Drop one session's same-process snapshot once its state is durable on disk.
330
+ * No-op while any write for the id is still pending/in flight.
331
+ */
332
+ export function evictLiveSession(id) {
333
+ if (!id || _hasPendingPersistence(id)) return false;
334
+ return _liveSessions.delete(id);
335
+ }
336
+
337
+ // Live snapshots that still carry raw media bytes (images are placeholder'd
338
+ // in the persisted JSON) stay resident for this long after their last use so
339
+ // multi-turn image recognition keeps working across an idle gap. Beyond the
340
+ // TTL the memory cost wins and the snapshot is reclaimed like any other.
341
+ const LIVE_MEDIA_RETENTION_MS = 60 * 60 * 1000; // 1h
342
+
343
+ function _messagesCarryLiveMedia(messages) {
344
+ if (!Array.isArray(messages)) return false;
345
+ for (const m of messages) {
346
+ if (!m || typeof m !== 'object') continue;
347
+ if (sanitizeContentForStoredHistory(m.content) !== m.content) return true;
348
+ }
349
+ return false;
350
+ }
351
+
352
+ /**
353
+ * Idle sweep for the same-process snapshot cache. _liveSessions previously
354
+ * grew without bound — every clear-fork and every touched user session pinned
355
+ * its FULL message array (image bytes included) for process lifetime, the
356
+ * observed multi-GB RSS leak. Disk is the source of truth for anything not
357
+ * actively owned by this process, so an entry is dropped when it (a) has no
358
+ * live runtime owner, (b) has no pending persistence, and (c) already exists
359
+ * on disk — loadSession then falls back to the session file. Media-carrying
360
+ * snapshots get a grace TTL (see LIVE_MEDIA_RETENTION_MS) because eviction is
361
+ * lossy for them; text-only snapshots evict losslessly right away.
362
+ */
363
+ export function evictIdleLiveSessions(options = {}) {
364
+ const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
365
+ const now = Date.now();
366
+ let evicted = 0;
367
+ for (const [id, session] of [..._liveSessions.entries()]) {
368
+ if (isSessionLive && isSessionLive(id)) continue;
369
+ if (_hasPendingPersistence(id)) continue;
370
+ if (!existsSync(sessionPath(id))) continue;
371
+ // A dropped last save means the disk copy is BEHIND this snapshot
372
+ // (ownership split-brain). Evicting would lose the only complete
373
+ // transcript; keep it until a save lands again (re-adoption).
374
+ if (_droppedSaveIds.has(id)) continue;
375
+ if (_messagesCarryLiveMedia(session?.messages)) {
376
+ const lastActive = Math.max(session?.updatedAt || 0, session?.lastUsedAt || 0);
377
+ if (lastActive > 0 && now - lastActive <= LIVE_MEDIA_RETENTION_MS) continue;
378
+ }
379
+ _liveSessions.delete(id);
380
+ // With no pending persistence the rollback-race version counter for
381
+ // this id is dead weight — reclaim it too (it regrows from 1 on the
382
+ // next save, which is safe precisely because nothing is in flight).
383
+ _summaryCacheVersions.delete(id);
384
+ evicted++;
385
+ }
386
+ return evicted;
387
+ }
388
+
224
389
  const _deleteHeartbeat = deleteHeartbeat;
225
390
 
226
391
  // ── 150 ms debounce window ────────────────────────────────────────────────────
@@ -355,7 +520,20 @@ function _getOrSpawnWorker() {
355
520
  _saveWorker = new Worker(new URL('./save-session-worker.mjs', import.meta.url), {
356
521
  execArgv: [],
357
522
  });
358
- _saveWorker.on('message', ({ ok, saved, error, reqId }) => {
523
+ // Worker logs arrive as `{ __log }` messages, NOT via piped stdio
524
+ // (stdout:true/stderr:true): once the parent starts reading a worker's
525
+ // piped stdio, the underlying MessagePort stays ref'd for the worker's
526
+ // lifetime regardless of worker.unref(), so every process that ever
527
+ // saved a session could no longer exit (test runners hung after
528
+ // completion). The worker overrides its own process.stdout/stderr.write
529
+ // to forward through this channel, which keeps stray prints off the TUI
530
+ // frame (routed through the parent's guardable stderr) without holding
531
+ // the event loop.
532
+ _saveWorker.on('message', ({ __log, ok, saved, error, reqId }) => {
533
+ if (__log !== undefined) {
534
+ try { process.stderr.write(String(__log)); } catch { /* best-effort */ }
535
+ return;
536
+ }
359
537
  const p = _saveWorkerPending.get(reqId);
360
538
  if (!p) return;
361
539
  _saveWorkerPending.delete(reqId);
@@ -373,8 +551,16 @@ function _getOrSpawnWorker() {
373
551
  // A close/delete may have completed while the worker was writing.
374
552
  // Do not let this older completion put an open row back in the
375
553
  // process-local cache after its tombstone/removal.
376
- if (saved) _queueSessionSummaryUpsert(session, summaryVersion);
377
- else _rollbackCachedSessionSummary(id, summaryVersion);
554
+ if (saved) {
555
+ _queueSessionSummaryUpsert(session, summaryVersion);
556
+ _droppedSaveIds.delete(id);
557
+ } else {
558
+ // The worker's _shouldDrop declined the write: disk ownership
559
+ // moved past this snapshot — flag the split-brain so eviction
560
+ // and disk-over-live arbitration keep the richer local copy.
561
+ _rollbackCachedSessionSummary(id, summaryVersion);
562
+ _droppedSaveIds.add(id);
563
+ }
378
564
  clearSessionSaveError(id);
379
565
  for (const w of waiters) w.resolve();
380
566
  }
@@ -570,25 +756,28 @@ export function _saveSessionSync(session, opts) {
570
756
  function _doSaveSync(payload) {
571
757
  const { session, opts, summaryVersion = null } = payload;
572
758
  const id = session.id;
573
- if (_shouldDrop(id, opts)) return false;
759
+ if (_shouldDrop(id, opts)) { _droppedSaveIds.add(id); return false; }
574
760
  const target = sessionPath(id);
575
761
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
576
762
  try {
577
763
  writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
578
764
  if (_shouldDrop(id, opts)) {
579
765
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
766
+ _droppedSaveIds.add(id);
580
767
  return false;
581
768
  }
582
769
  const commitControl = _acquireWriteCommit(opts);
583
770
  if (commitControl === false || _shouldDrop(id, opts)) {
584
771
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
585
772
  _releaseWriteCommit(commitControl);
773
+ _droppedSaveIds.add(id);
586
774
  return false;
587
775
  }
588
776
  try {
589
777
  _renameWithRetrySync(tmp, target);
590
778
  _queueSessionSummaryUpsert(session, summaryVersion);
591
779
  clearSessionSaveError(id);
780
+ _droppedSaveIds.delete(id);
592
781
  } finally {
593
782
  _releaseWriteCommit(commitControl);
594
783
  }
@@ -736,6 +925,7 @@ async function _doSave(payload) {
736
925
  // First check: upfront, before any disk I/O. Cheap short-circuit when a
737
926
  // tombstone is already on disk when the caller arrives.
738
927
  if (_shouldDrop(id, opts)) {
928
+ _droppedSaveIds.add(id);
739
929
  _drainQueue(id);
740
930
  return false;
741
931
  }
@@ -749,6 +939,7 @@ async function _doSave(payload) {
749
939
  if (_shouldDrop(id, opts)) {
750
940
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
751
941
  process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
942
+ _droppedSaveIds.add(id);
752
943
  _drainQueue(id);
753
944
  return false;
754
945
  }
@@ -756,6 +947,7 @@ async function _doSave(payload) {
756
947
  if (commitControl === false || _shouldDrop(id, opts)) {
757
948
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
758
949
  _releaseWriteCommit(commitControl);
950
+ _droppedSaveIds.add(id);
759
951
  _drainQueue(id);
760
952
  return false;
761
953
  }
@@ -763,6 +955,7 @@ async function _doSave(payload) {
763
955
  _renameWithRetrySync(tmp, target);
764
956
  _queueSessionSummaryUpsert(session, summaryVersion);
765
957
  clearSessionSaveError(id);
958
+ _droppedSaveIds.delete(id);
766
959
  } finally {
767
960
  _releaseWriteCommit(commitControl);
768
961
  }
@@ -781,10 +974,51 @@ async function _doSave(payload) {
781
974
  * Used by closeSession() to plant a tombstone that races against in-flight
782
975
  * saveSession() calls.
783
976
  */
784
- export function markSessionClosed(id, reason = 'manual') {
785
- // Cancel any pending debounced save so it cannot overwrite the tombstone
786
- // that we are about to plant. The _shouldDrop() guard inside _doSave()
787
- // provides a second line of defence, but cancelling here is cheaper.
977
+ function _heartbeatMtime(id) {
978
+ try {
979
+ const path = join(getStoreDir(), `${id}.hb`);
980
+ return existsSync(path) ? (statSync(path).mtimeMs || 0) : 0;
981
+ } catch {
982
+ return 0;
983
+ }
984
+ }
985
+
986
+ function _runtimeLivenessVeto(id, options = {}) {
987
+ return typeof options.isSessionLive === 'function' && options.isSessionLive(id);
988
+ }
989
+
990
+ function _heartbeatLivenessVeto(id, options = {}) {
991
+ const heartbeatMtime = _heartbeatMtime(id);
992
+ if (!(heartbeatMtime > 0)) return false;
993
+ const hasHeartbeatSnapshot = Object.prototype.hasOwnProperty.call(options, 'heartbeatSnapshotMtime');
994
+ const snapshotMtime = Number(options.heartbeatSnapshotMtime) || 0;
995
+ if (hasHeartbeatSnapshot && heartbeatMtime > snapshotMtime) return true;
996
+ const freshMs = Number(options.heartbeatFreshMs);
997
+ return Number.isFinite(freshMs) && freshMs > 0 && Date.now() - heartbeatMtime <= freshMs;
998
+ }
999
+
1000
+ function _deleteHeartbeatUnlessNewer(id, options = {}) {
1001
+ const hasHeartbeatSnapshot = Object.prototype.hasOwnProperty.call(options, 'heartbeatSnapshotMtime');
1002
+ const snapshotMtime = Number(options.heartbeatSnapshotMtime) || 0;
1003
+ if (!hasHeartbeatSnapshot || _heartbeatMtime(id) <= snapshotMtime) {
1004
+ _deleteHeartbeat(id);
1005
+ }
1006
+ }
1007
+
1008
+ export function markSessionClosed(id, reason = 'manual', options = {}) {
1009
+ // Caller-provided probes may re-enter the store, so evaluate them before
1010
+ // taking the non-reentrant Atomics commit lock. A veto must also precede
1011
+ // pending-save cancellation so debounce durability remains intact.
1012
+ if (_runtimeLivenessVeto(id, options) || _heartbeatLivenessVeto(id, options)) return null;
1013
+ const closeGuard = _guardedSaveOptions(id);
1014
+ const commitControl = _acquireWriteCommit(closeGuard);
1015
+ if (commitControl === false) return null;
1016
+ try {
1017
+ // Cross-process heartbeat revival after full-TTL silence is accepted as a
1018
+ // best-effort race: the tombstone resurrection guard is the authoritative
1019
+ // post-race arbiter. Re-stat here, but never invoke caller code under lock.
1020
+ if (_heartbeatLivenessVeto(id, options)) return null;
1021
+ // Only a committed close may disrupt pending persistence.
788
1022
  _clearDebounce(id);
789
1023
  _cancelSessionWrites(id);
790
1024
  _uncacheSessionSummary(id);
@@ -834,8 +1068,10 @@ export function markSessionClosed(id, reason = 'manual') {
834
1068
  _savePending.delete(id);
835
1069
  clearSessionSaveError(id);
836
1070
  _clearLiveSession(id);
837
- _deleteHeartbeat(id);
1071
+ // Preserve a sidecar published strictly after the sweep's scan snapshot.
1072
+ _deleteHeartbeatUnlessNewer(id, options);
838
1073
  _queueSessionSummaryUpsert(tombstone);
1074
+ _droppedSaveIds.delete(id);
839
1075
  // Structured close metric. Single emission point because every close
840
1076
  // path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
841
1077
  // straddles the tombstone (updatedAt was just set to Date.now()), so
@@ -861,6 +1097,9 @@ export function markSessionClosed(id, reason = 'manual') {
861
1097
  }
862
1098
  } catch { /* logger never breaks the close path */ }
863
1099
  return newGen;
1100
+ } finally {
1101
+ _releaseWriteCommit(commitControl);
1102
+ }
864
1103
  }
865
1104
 
866
1105
  /**
@@ -898,22 +1137,18 @@ export function bumpSessionGeneration(id, reason = 'detach') {
898
1137
  _clearLiveSession(id);
899
1138
  _deleteHeartbeat(id);
900
1139
  _queueSessionSummaryUpsert(detached);
1140
+ _droppedSaveIds.delete(id);
901
1141
  return newGen;
902
1142
  }
903
1143
 
904
1144
  export function loadSession(id) {
905
1145
  const path = sessionPath(id);
906
- let stored = null;
907
- if (existsSync(path)) {
908
- try {
909
- stored = JSON.parse(readFileSync(path, 'utf-8'));
910
- // An existing file owns this identity. Its contents must validate
911
- // before fresher in-memory state is allowed to shadow it.
912
- if (stored?.id !== id) return null;
913
- } catch {
914
- return null;
915
- }
916
- }
1146
+ // An existing file owns this identity. Its contents must validate before
1147
+ // fresher in-memory state is allowed to shadow it. Unchanged atomic files
1148
+ // reuse their recent parsed object instead of reparsing the full transcript.
1149
+ const disk = _readStoredSessionCached(id, path);
1150
+ if (disk.exists && !disk.session) return null;
1151
+ const stored = disk.session;
917
1152
  // Read-your-writes: if a save is pending (debouncing, scheduled, or queued
918
1153
  // behind an in-flight write) return that payload instead of stale disk state.
919
1154
  // The most-recently-queued slot is checked first (queued > payload).
@@ -923,7 +1158,24 @@ export function loadSession(id) {
923
1158
  if (inMemory?.id === id) return _ensureLifecycleFields(inMemory);
924
1159
  }
925
1160
  const live = _liveSessions.get(id);
926
- if (live?.id === id) return _ensureLifecycleFields(live);
1161
+ if (live?.id === id) {
1162
+ // Terminal ↔ desktop interop: `generation` only moves on close/detach
1163
+ // (markSessionClosed / bumpSessionGeneration). A disk record with a
1164
+ // HIGHER generation means another process took ownership of this
1165
+ // session after our snapshot was cached — the local copy is stale and
1166
+ // must not shadow the newer on-disk transcript (its late saves would
1167
+ // be dropped by _shouldDrop's ownership rule anyway).
1168
+ const liveGen = typeof live.generation === 'number' ? live.generation : 0;
1169
+ const storedGen = stored && typeof stored.generation === 'number' ? stored.generation : 0;
1170
+ // A flagged dropped save means the generation moved WITHOUT newer
1171
+ // content landing (detach re-persists stale content) — the local
1172
+ // snapshot is the only complete transcript, so it keeps winning.
1173
+ if (stored && storedGen > liveGen && !_droppedSaveIds.has(id)) {
1174
+ _liveSessions.delete(id);
1175
+ } else {
1176
+ return _ensureLifecycleFields(live);
1177
+ }
1178
+ }
927
1179
  return stored ? _ensureLifecycleFields(stored) : null;
928
1180
  }
929
1181
 
@@ -940,10 +1192,19 @@ export function clearSessionSaveError(id) {
940
1192
  }
941
1193
 
942
1194
  export function deleteSession(id, options = {}) {
1195
+ // Keep caller probes and all vetoes ahead of the non-reentrant lock and
1196
+ // ahead of pending-save disruption, matching markSessionClosed().
1197
+ if (_runtimeLivenessVeto(id, options) || _heartbeatLivenessVeto(id, options)) return false;
1198
+ const deleteGuard = _guardedSaveOptions(id);
1199
+ const commitControl = _acquireWriteCommit(deleteGuard);
1200
+ if (commitControl === false) return false;
1201
+ try {
1202
+ // Cross-process revival after full-TTL silence remains best-effort; the
1203
+ // tombstone resurrection guard is authoritative. Only re-stat .hb here.
1204
+ if (_heartbeatLivenessVeto(id, options)) return false;
943
1205
  _cancelSessionWrites(id);
944
1206
  _clearDebounce(id);
945
1207
  _savePending.delete(id);
946
- _waitForWriteCommit(id);
947
1208
  const path = sessionPath(id);
948
1209
  let removed = false;
949
1210
  if (existsSync(path)) {
@@ -953,7 +1214,9 @@ export function deleteSession(id, options = {}) {
953
1214
  }
954
1215
  catch { /* fall through to .hb cleanup */ }
955
1216
  }
956
- _deleteHeartbeat(id);
1217
+ // Preserve a sidecar published strictly after the sweep's scan snapshot.
1218
+ _deleteHeartbeatUnlessNewer(id, options);
1219
+ deleteSessionPresence(id);
957
1220
  _clearLiveSession(id);
958
1221
  if (removed || !existsSync(path)) clearSessionSaveError(id);
959
1222
  // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
@@ -962,6 +1225,9 @@ export function deleteSession(id, options = {}) {
962
1225
  if (options.deferSummaryUpdate === true) _uncacheSessionSummary(id);
963
1226
  else _queueSessionSummaryRemoval(id);
964
1227
  return removed;
1228
+ } finally {
1229
+ _releaseWriteCommit(commitControl);
1230
+ }
965
1231
  }
966
1232
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
967
1233
  const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
@@ -982,6 +1248,9 @@ const RUNNING_STALL_MS = 10 * 60 * 1000;
982
1248
  // (options.isSessionLive) is additionally protected as defense-in-depth.
983
1249
  const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
984
1250
  const RESUMABLE_OPEN_MAX_COUNT = 300;
1251
+ // Blank scratch sessions (zero user/assistant conversation) are reaped once
1252
+ // idle this long — see the blank-scratch branch in sweepStaleSessions.
1253
+ const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
985
1254
 
986
1255
  function _storedSessionFromFile(dir, filename, ensureLifecycle = true) {
987
1256
  if (!filename.endsWith('.json')) return null;
@@ -1043,9 +1312,82 @@ function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
1043
1312
  return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
1044
1313
  }
1045
1314
 
1315
+ // ── Incremental storage scan ────────────────────────────────────────────────
1316
+ // refreshFromStorage used to re-parse EVERY session JSON (full transcripts,
1317
+ // multi-MB files) on each desktop sidebar refresh. A summary only changes when
1318
+ // its file changes, so key a per-file row cache on (mtimeMs, size): unchanged
1319
+ // files reuse the cached row, changed/new files re-parse, vanished files drop
1320
+ // out. Storage stays the truth source — the sidecar index is never trusted.
1321
+ const _summaryScanCache = new Map(); // filename → { mtimeMs, size, row|null }
1322
+
1323
+ function _scanStoredSessionSummaryRows() {
1324
+ const dir = getStoreDir();
1325
+ if (!existsSync(dir)) {
1326
+ const changed = _summaryScanCache.size > 0;
1327
+ _summaryScanCache.clear();
1328
+ return { rows: [], invalidStorageIds: new Set(), changed };
1329
+ }
1330
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
1331
+ const present = new Set(files);
1332
+ let changed = false;
1333
+ for (const key of [..._summaryScanCache.keys()]) {
1334
+ if (!present.has(key)) {
1335
+ _summaryScanCache.delete(key);
1336
+ changed = true;
1337
+ }
1338
+ }
1339
+ const rows = [];
1340
+ const invalidStorageIds = new Set();
1341
+ const markInvalid = (filename) => {
1342
+ const storageId = filename.slice(0, -5);
1343
+ if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
1344
+ };
1345
+ for (const f of files) {
1346
+ let fileStat = null;
1347
+ try { fileStat = statSync(join(dir, f)); } catch { /* deleted mid-scan */ }
1348
+ if (!fileStat) {
1349
+ if (_summaryScanCache.delete(f)) changed = true;
1350
+ continue;
1351
+ }
1352
+ const cached = _summaryScanCache.get(f);
1353
+ if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
1354
+ if (cached.row) rows.push(cached.row);
1355
+ else markInvalid(f);
1356
+ continue;
1357
+ }
1358
+ const session = _storedSessionFromFile(dir, f);
1359
+ const row = session ? _sessionSummary(session) : null;
1360
+ _summaryScanCache.set(f, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, row });
1361
+ changed = true;
1362
+ if (row) rows.push(row);
1363
+ else markInvalid(f);
1364
+ }
1365
+ rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1366
+ return { rows, invalidStorageIds, changed };
1367
+ }
1368
+
1369
+ // Summary-level twin of _withUnpersistedSessions: overlay queued/in-flight
1370
+ // saves that have no disk record yet (read-your-writes for brand-new sessions).
1371
+ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
1372
+ const byId = new Map(rows.map((row) => [row.id, row]));
1373
+ const addIfUnpersisted = (id, session, opts) => {
1374
+ if (!id || byId.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
1375
+ if (session?.id !== id) return;
1376
+ const row = _sessionSummary(_ensureLifecycleFields(session));
1377
+ if (row) byId.set(id, row);
1378
+ };
1379
+ for (const [id, pending] of _savePending) {
1380
+ const payload = pending.queued || pending.payload;
1381
+ addIfUnpersisted(id, payload?.session, payload?.opts);
1382
+ }
1383
+ for (const [, pending] of _saveWorkerPending) addIfUnpersisted(pending.id, pending.session, pending.opts);
1384
+ for (const [id, pending] of _saveAsyncQueued) addIfUnpersisted(id, pending.session, pending.opts);
1385
+ return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1386
+ }
1387
+
1046
1388
  export function rebuildSessionSummaryIndex() {
1047
1389
  _ensureSummaryCacheDataDir();
1048
- const rows = listStoredSessions().map(_sessionSummary).filter(Boolean);
1390
+ const { rows } = _scanStoredSessionSummaryRows();
1049
1391
  return _setSummaryRowsCache(_writeSummaryIndex(rows));
1050
1392
  }
1051
1393
 
@@ -1057,11 +1399,14 @@ export function listStoredSessionSummaries(options = {}) {
1057
1399
  // authorization), so it must not trust either the cache or sidecar.
1058
1400
  if (options.refreshFromStorage === true) {
1059
1401
  try {
1060
- const invalidStorageIds = new Set();
1061
- const stored = listStoredSessions({ _invalidStorageIds: invalidStorageIds });
1062
- const persistedRows = stored.map(_sessionSummary).filter(Boolean);
1063
- const rows = _withUnpersistedSessions(stored, invalidStorageIds).map(_sessionSummary).filter(Boolean);
1064
- try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
1402
+ const { rows: persistedRows, invalidStorageIds, changed } = _scanStoredSessionSummaryRows();
1403
+ const rows = _overlayUnpersistedSummaryRows(persistedRows, invalidStorageIds);
1404
+ // Unchanged scans skip the sidecar rewrite — refresh is called on
1405
+ // every sidebar poll/push and must not grind a multi-MB atomic
1406
+ // write when no session actually changed.
1407
+ if (changed) {
1408
+ try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
1409
+ }
1065
1410
  // A direct scan settles deletion state too; retain only active
1066
1411
  // optimistic write overlays, never a stale local removal.
1067
1412
  _summaryCacheRemovals.clear();
@@ -1083,7 +1428,9 @@ export function listStoredSessionSummaries(options = {}) {
1083
1428
  p = summaryIndexPath();
1084
1429
  hasIndex = existsSync(p);
1085
1430
  if (hasIndex) {
1086
- indexedRows = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8'))).rows;
1431
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
1432
+ hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
1433
+ if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
1087
1434
  }
1088
1435
  } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
1089
1436
 
@@ -1290,6 +1637,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1290
1637
  : (sweepTombstones ? tombstoneMaxAgeMs : 0);
1291
1638
  const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
1292
1639
  if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
1640
+ // Fresh agent/legacy sessions survive idle close but still
1641
+ // participate in the resumable-open retention cap. The cap
1642
+ // performs its own commit-edge liveness veto before deletion.
1643
+ if (retainOpen && sweepIdle
1644
+ && (!(typeof gateOwner === 'string' && gateOwner.length > 0)
1645
+ || isAgentOwner({ owner: gateOwner }))) {
1646
+ openCandidates.push({
1647
+ id: row.id,
1648
+ lastActive: newestKnown,
1649
+ heartbeatSnapshotMtime: heartbeatMtime,
1650
+ heartbeatFreshMs: terminalReapMsForGate ?? maxAge,
1651
+ });
1652
+ }
1293
1653
  remaining++;
1294
1654
  continue;
1295
1655
  }
@@ -1314,6 +1674,33 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1314
1674
  // foreground session, which is idle during a gated sweep). Only the
1315
1675
  // ephemeral agent/ownerless sessions below feed the cap.
1316
1676
  if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
1677
+ // Blank-scratch exception to user-session permanence: a session
1678
+ // with ZERO user/assistant conversation (engine boot artifact,
1679
+ // force-killed window, crashed host) has nothing to preserve.
1680
+ // Relaunch storms otherwise pile hundreds of "(blank)" rows
1681
+ // that no sweep may touch. Reap once cold; liveness/heartbeat
1682
+ // vetoes inside deleteSession still protect an in-flight boot.
1683
+ const _msgsArr = Array.isArray(actual?.messages) ? actual.messages : null;
1684
+ const _convCount = _msgsArr
1685
+ ? _msgsArr.filter((m) => m && (m.role === 'user' || m.role === 'assistant')).length
1686
+ : (Number(row.messageCount) || 0);
1687
+ const _blankLastActive = Math.max(effUpdatedAt, effLastHb, effCreatedAt, heartbeatMtime || 0);
1688
+ if (sweepIdle && _convCount === 0
1689
+ && now - _blankLastActive > BLANK_SCRATCH_MAX_AGE_MS
1690
+ && !(isSessionLive && isSessionLive(row.id))) {
1691
+ try {
1692
+ if (deleteSession(row.id, {
1693
+ deferSummaryUpdate: true,
1694
+ isSessionLive,
1695
+ heartbeatSnapshotMtime: heartbeatMtime,
1696
+ heartbeatFreshMs: BLANK_SCRATCH_MAX_AGE_MS,
1697
+ })) {
1698
+ openPruned++;
1699
+ openPrunedDetails.push({ id: row.id, ageSeconds: Math.floor((now - _blankLastActive) / 1000) });
1700
+ continue;
1701
+ }
1702
+ } catch { /* keep the row on failure */ }
1703
+ }
1317
1704
  remaining++;
1318
1705
  continue;
1319
1706
  }
@@ -1321,6 +1708,14 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1321
1708
  remaining++;
1322
1709
  continue;
1323
1710
  }
1711
+ // The manager may sweep while unrelated sessions are active. Protect
1712
+ // this specific locally-current/in-flight session regardless of stale
1713
+ // on-disk timestamps; its controller/heartbeat owner decides when it
1714
+ // is safe to become an idle-sweep candidate.
1715
+ if (isSessionLive && isSessionLive(row.id)) {
1716
+ remaining++;
1717
+ continue;
1718
+ }
1324
1719
  // Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
1325
1720
  // serialising the full JSON, so it reflects true liveness more
1326
1721
  // accurately than the JSON timestamp fields.
@@ -1338,11 +1733,38 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1338
1733
  const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
1339
1734
  const sessionMaxAge = terminalReapMs ?? maxAge;
1340
1735
  if (now - lastActive > sessionMaxAge) {
1341
- try { markSessionClosed(row.id, 'idle-sweep'); }
1736
+ // Close is destructive and the earlier heartbeat stat can race a
1737
+ // different process publishing fresh liveness. Re-check both
1738
+ // local runtime ownership and the sidecar at the commit edge.
1739
+ if (isSessionLive && isSessionLive(row.id)) {
1740
+ remaining++;
1741
+ continue;
1742
+ }
1743
+ let preCloseHeartbeatMtime = 0;
1744
+ try {
1745
+ const hbPath = join(dir, `${row.id}.hb`);
1746
+ if (existsSync(hbPath)) preCloseHeartbeatMtime = statSync(hbPath).mtimeMs || 0;
1747
+ } catch { /* sidecar unavailable — retain scan-time gates */ }
1748
+ if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
1749
+ remaining++;
1750
+ continue;
1751
+ }
1752
+ let closeResult = null;
1753
+ try {
1754
+ closeResult = markSessionClosed(row.id, 'idle-sweep', {
1755
+ isSessionLive,
1756
+ heartbeatSnapshotMtime: heartbeatMtime,
1757
+ heartbeatFreshMs: sessionMaxAge,
1758
+ });
1759
+ }
1342
1760
  catch (err) {
1343
1761
  process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
1344
1762
  continue;
1345
1763
  }
1764
+ if (closeResult == null) {
1765
+ remaining++;
1766
+ continue;
1767
+ }
1346
1768
  cleaned++;
1347
1769
  details.push({
1348
1770
  id: row.id,
@@ -1351,7 +1773,12 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1351
1773
  bashSessionId: effBashId,
1352
1774
  });
1353
1775
  } else {
1354
- if (retainOpen) openCandidates.push({ id: row.id, lastActive });
1776
+ if (retainOpen) openCandidates.push({
1777
+ id: row.id,
1778
+ lastActive,
1779
+ heartbeatSnapshotMtime: heartbeatMtime,
1780
+ heartbeatFreshMs: sessionMaxAge,
1781
+ });
1355
1782
  remaining++;
1356
1783
  }
1357
1784
  }
@@ -1370,7 +1797,12 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1370
1797
  const overCount = kept >= openMaxCount;
1371
1798
  if (!tooOld && !overCount) { kept++; continue; }
1372
1799
  try {
1373
- if (deleteSession(c.id, { deferSummaryUpdate: true })) {
1800
+ if (deleteSession(c.id, {
1801
+ deferSummaryUpdate: true,
1802
+ isSessionLive,
1803
+ heartbeatSnapshotMtime: c.heartbeatSnapshotMtime,
1804
+ heartbeatFreshMs: c.heartbeatFreshMs,
1805
+ })) {
1374
1806
  openPruned++;
1375
1807
  openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
1376
1808
  if (remaining > 0) remaining--;
@@ -1380,14 +1812,14 @@ export function sweepStaleSessions(ttlMs, options = {}) {
1380
1812
  } catch { kept++; }
1381
1813
  }
1382
1814
  }
1383
- // Orphan .hb reap: a heartbeat sidecar whose .json no longer exists is dead
1384
- // weight once it is also stale (older than maxAge) — the session JSON was
1385
- // swept/closed but the .hb lingered (a pre-fix orphaned heartbeat). The
1386
- // staleness gate avoids nuking the .hb of a session mid-create whose .json
1387
- // write has not landed yet.
1815
+ // Orphan .hb/.own reap: a heartbeat/presence sidecar whose .json no longer
1816
+ // exists is dead weight once it is also stale (older than maxAge) — the
1817
+ // session JSON was swept/closed but the sidecar lingered (crashed owner or
1818
+ // pre-fix orphan). The staleness gate avoids nuking the sidecar of a
1819
+ // session mid-create whose .json write has not landed yet.
1388
1820
  try {
1389
- for (const h of readdirSync(dir).filter(f => f.endsWith('.hb'))) {
1390
- if (existsSync(join(dir, h.replace(/\.hb$/, '.json')))) continue;
1821
+ for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
1822
+ if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
1391
1823
  let hbMtime = 0;
1392
1824
  try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
1393
1825
  if (now - hbMtime > maxAge) {