mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
4
4
  import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
5
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
5
6
 
6
7
  // The live in-memory session (and every model request)
7
8
  // retains attached image bytes across turns so multi-turn recognition works.
@@ -9,6 +10,21 @@ import { sanitizeContentForStoredHistory } from '../../providers/media-normaliza
9
10
  // text placeholder at serialization time — keeping session files small without
10
11
  // starving the model of the image mid-conversation. Returns the same object
11
12
  // reference when nothing changed (no-image sessions pay only a shallow scan).
13
+ // Per-message disk projection shared by _sessionForDisk and the save-worker
14
+ // delta path (tail-only projection). Returns the SAME array reference when no
15
+ // message changed. Idempotent: already-sanitized content passes through
16
+ // unchanged, so projecting a projected message is a no-op.
17
+ export function _messagesForDisk(messages) {
18
+ let changed = false;
19
+ const out = messages.map((m) => {
20
+ if (!m || typeof m !== 'object') return m;
21
+ const content = sanitizeContentForStoredHistory(m.content);
22
+ if (content !== m.content) { changed = true; return { ...m, content }; }
23
+ return m;
24
+ });
25
+ return changed ? out : messages;
26
+ }
27
+
12
28
  export function _sessionForDisk(session) {
13
29
  // Strip transient in-flight aliases askSession sets for the turn duration:
14
30
  // - liveTurnMessages: live working transcript (so contextStatus() can
@@ -26,14 +42,8 @@ export function _sessionForDisk(session) {
26
42
  const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
27
43
  return rest;
28
44
  }
29
- let changed = false;
30
- const out = messages.map((m) => {
31
- if (!m || typeof m !== 'object') return m;
32
- const content = sanitizeContentForStoredHistory(m.content);
33
- if (content !== m.content) { changed = true; return { ...m, content }; }
34
- return m;
35
- });
36
- if (!changed) {
45
+ const out = _messagesForDisk(messages);
46
+ if (out === messages) {
37
47
  if (!hasTransient) return session;
38
48
  const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
39
49
  return rest;
@@ -58,13 +68,36 @@ export function _ensureLifecycleFields(session) {
58
68
  return session;
59
69
  }
60
70
 
71
+ /**
72
+ * Sentinel for "the file is THERE and could not be read" (EIO/EACCES/EBUSY…),
73
+ * as opposed to `null` = "read fine, but the record is corrupt/foreign".
74
+ * Callers that own retention (summary scan) must never treat the former as an
75
+ * empty/invalid row.
76
+ */
77
+ export const STORED_SESSION_UNREADABLE = Symbol('stored-session-unreadable');
78
+
61
79
  export function _storedSessionFromFile(dir, filename, ensureLifecycle = true) {
62
80
  if (!filename.endsWith('.json')) return null;
63
81
  const storageId = filename.slice(0, -5);
64
82
  if (!storageId || !/^[A-Za-z0-9_-]+$/.test(storageId)) return null;
83
+ let text;
84
+ try {
85
+ text = readFileSync(join(dir, filename), 'utf-8');
86
+ } catch (err) {
87
+ const code = err?.code || 'EUNKNOWN';
88
+ // Only ENOENT/ENOTDIR is absence; everything else is present-but-
89
+ // unreadable and must fail closed for retention decisions.
90
+ return (code === 'ENOENT' || code === 'ENOTDIR') ? null : STORED_SESSION_UNREADABLE;
91
+ }
65
92
  try {
66
- const session = JSON.parse(readFileSync(join(dir, filename), 'utf-8'));
67
- if (session?.id !== storageId) return null;
93
+ // One strict authority for identity: a plain JSON.parse resolves a
94
+ // duplicate top-level `id` LAST-WINS, so `{"id":"other",…,"id":"me"}`
95
+ // would be listed (and later acted on) as this file's own session.
96
+ // Ambiguous/malformed records are treated exactly like corrupt ones:
97
+ // the caller keeps owning the identity but gets no session object.
98
+ const record = readTopLevelLifecycleRecord(text);
99
+ if (isLifecycleUnreadable(record) || record.id !== storageId) return null;
100
+ const session = record.doc;
68
101
  return ensureLifecycle ? _ensureLifecycleFields(session) : session;
69
102
  } catch {
70
103
  return null;
@@ -1,8 +1,9 @@
1
- import { readdirSync, statSync, existsSync } from 'fs';
1
+ import { readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { getPluginData } from '../../config.mjs';
4
4
  import { getStoreDir } from './paths-heartbeat.mjs';
5
- import { _storedSessionFromFile } from './serialize.mjs';
5
+ import { probePath, PROBE_PRESENT, PROBE_ABSENT } from './fs-probe.mjs';
6
+ import { STORED_SESSION_UNREADABLE, _storedSessionFromFile } from './serialize.mjs';
6
7
  import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummaryRow, _removeSessionSummary, _pruneSummaryIndexIds } from '../store-summary-index.mjs';
7
8
 
8
9
  // Listing is much hotter than writing, especially while the desktop session
@@ -108,6 +109,17 @@ export function _rollbackCachedSessionSummary(id, version) {
108
109
  export function _queueSessionSummaryUpsert(session, version = null) {
109
110
  const latest = version === null ? null : _summaryCacheLatestRows.get(session?.id);
110
111
  const row = latest?.version === version ? latest.row : _sessionSummary(session);
112
+ return _queueSessionSummaryUpsertRow(row, version);
113
+ }
114
+
115
+ /**
116
+ * Publish an ALREADY-CAPTURED row. Async writers snapshot the row at the
117
+ * moment their payload is built and publish THAT snapshot when the write
118
+ * lands, so an older completion can never republish metadata read from the
119
+ * (since-mutated) live session — the row always describes the bytes that
120
+ * actually reached disk.
121
+ */
122
+ export function _queueSessionSummaryUpsertRow(row, version = null) {
111
123
  if (!row) return;
112
124
  _setCachedBaseSummary(row);
113
125
  if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
@@ -141,11 +153,22 @@ const _summaryScanCache = new Map(); // filename → { mtimeMs, size, row|null }
141
153
 
142
154
  export function _scanStoredSessionSummaryRows() {
143
155
  const dir = getStoreDir();
144
- if (!existsSync(dir)) {
156
+ const dirProbe = probePath(dir);
157
+ if (dirProbe.state === PROBE_ABSENT) {
145
158
  const changed = _summaryScanCache.size > 0;
146
159
  _summaryScanCache.clear();
147
160
  return { rows: [], invalidStorageIds: new Set(), changed };
148
161
  }
162
+ if (dirProbe.state !== PROBE_PRESENT) {
163
+ // The store dir exists as far as we know and simply cannot be probed
164
+ // (EACCES/EIO). Reporting "no sessions" here would let the caller
165
+ // rewrite the sidecar index from an empty scan — i.e. delete every
166
+ // row because of a transient stat error. Fail loudly instead; the
167
+ // refresh caller treats a throw as "storage not enumerable".
168
+ const err = new Error(`[session-store] session dir not readable (${dirProbe.code})`);
169
+ err.code = dirProbe.code;
170
+ throw err;
171
+ }
149
172
  const files = readdirSync(dir).filter(f => f.endsWith('.json'));
150
173
  const present = new Set(files);
151
174
  let changed = false;
@@ -162,21 +185,43 @@ export function _scanStoredSessionSummaryRows() {
162
185
  if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
163
186
  };
164
187
  for (const f of files) {
165
- let fileStat = null;
166
- try { fileStat = statSync(join(dir, f)); } catch { /* deleted mid-scan */ }
167
- if (!fileStat) {
188
+ const fileProbe = probePath(join(dir, f));
189
+ // Proven absence only: the file was deleted mid-scan, so its cached
190
+ // row legitimately retires.
191
+ if (fileProbe.state === PROBE_ABSENT) {
168
192
  if (_summaryScanCache.delete(f)) changed = true;
169
193
  continue;
170
194
  }
195
+ if (fileProbe.state !== PROBE_PRESENT) {
196
+ // Unreadable probe (EACCES/EIO/EBUSY): NOT a deletion. Keep the
197
+ // cache entry exactly as it is — `changed` stays untouched so no
198
+ // sidecar rewrite can drop this row — and keep serving the last
199
+ // known row. With no cached row the id is marked invalid instead,
200
+ // so no pending/live overlay may stand in for the unreadable file.
201
+ const stale = _summaryScanCache.get(f);
202
+ if (stale?.row) rows.push(stale.row);
203
+ else markInvalid(f);
204
+ continue;
205
+ }
171
206
  const cached = _summaryScanCache.get(f);
172
- if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
207
+ if (cached && cached.mtimeMs === fileProbe.mtimeMs && cached.size === fileProbe.size) {
173
208
  if (cached.row) rows.push(cached.row);
174
209
  else markInvalid(f);
175
210
  continue;
176
211
  }
177
212
  const session = _storedSessionFromFile(dir, f);
213
+ if (session === STORED_SESSION_UNREADABLE) {
214
+ // Stat said PRESENT, the READ failed: identical contract to an
215
+ // unreadable probe. Retain the last known row, leave `changed`
216
+ // untouched (no sidecar rewrite may drop it) and never cache a
217
+ // null row for a file we could not read.
218
+ const stale = _summaryScanCache.get(f);
219
+ if (stale?.row) rows.push(stale.row);
220
+ else markInvalid(f);
221
+ continue;
222
+ }
178
223
  const row = session ? _sessionSummary(session) : null;
179
- _summaryScanCache.set(f, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, row });
224
+ _summaryScanCache.set(f, { mtimeMs: fileProbe.mtimeMs, size: fileProbe.size, row });
180
225
  changed = true;
181
226
  if (row) rows.push(row);
182
227
  else markInvalid(f);
@@ -0,0 +1,20 @@
1
+ import { parentPort, workerData } from 'worker_threads';
2
+
3
+ const dataDir = String(workerData?.dataDir || '');
4
+ if (dataDir) process.env.MIXDOG_DATA_DIR = dataDir;
5
+
6
+ try {
7
+ const [{ _scanStoredSessionSummaryRows }, { _writeSummaryIndex }] = await Promise.all([
8
+ import('./summary-cache.mjs'),
9
+ import('../store-summary-index.mjs'),
10
+ ]);
11
+ const { rows } = _scanStoredSessionSummaryRows();
12
+ const indexedRows = _writeSummaryIndex(rows);
13
+ parentPort?.postMessage({ ok: true, dataDir, rows: indexedRows });
14
+ } catch (error) {
15
+ parentPort?.postMessage({
16
+ ok: false,
17
+ dataDir,
18
+ error: error?.message || String(error),
19
+ });
20
+ }
@@ -1,21 +1,51 @@
1
- /** Module-level map tracking per-session cancellation generations and commit locks. */
1
+ /**
2
+ * Module-level map tracking per-session cancellation generations, commit locks
3
+ * and the highest save epoch that actually LANDED for the id. All three live
4
+ * in one SharedArrayBuffer per id, so the save worker (which receives the
5
+ * buffer inside the write guard) observes the same state as the parent.
6
+ */
2
7
  const _writeControls = new Map();
3
8
 
9
+ // Layout of the per-id SharedArrayBuffer:
10
+ // int32 [0] cancellation generation (equality-compared, wrap-safe)
11
+ // int32 [1] commit lock
12
+ // int64 @8 highest LANDED save epoch — 64-bit and 8-byte aligned so the
13
+ // fence can never wrap or read back negative, unlike an int32
14
+ // slot which a long-lived process could in principle overflow.
15
+ const CONTROL_I32_LENGTH = 2;
16
+ const LANDED_EPOCH_BYTE_OFFSET = 8;
17
+ const CONTROL_BYTES = LANDED_EPOCH_BYTE_OFFSET + BigInt64Array.BYTES_PER_ELEMENT;
18
+
4
19
  function writeControl(id) {
5
20
  let control = _writeControls.get(id);
6
21
  if (!control) {
7
- // [0] cancellation generation, [1] commit lock
8
- control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2));
22
+ control = new Int32Array(new SharedArrayBuffer(CONTROL_BYTES), 0, CONTROL_I32_LENGTH);
9
23
  _writeControls.set(id, control);
10
24
  }
11
25
  return control;
12
26
  }
13
27
 
14
- export function guardedSaveOptions(id, opts) {
28
+ // Both realms build this view over the SAME SharedArrayBuffer they received in
29
+ // the guard, so parent and save worker observe one landed-epoch value.
30
+ function landedEpochView(buffer) {
31
+ return new BigInt64Array(buffer, LANDED_EPOCH_BYTE_OFFSET, 1);
32
+ }
33
+
34
+ export function guardedSaveOptions(id, opts, epoch = null) {
15
35
  const control = writeControl(id);
36
+ // Only exact integers may fence a commit; anything else is treated as
37
+ // "no identity" rather than silently truncated.
38
+ const issued = Number.isSafeInteger(epoch) ? epoch : null;
16
39
  return {
17
40
  ...(opts || {}),
18
- _sessionWriteGuard: { buffer: control.buffer, version: Atomics.load(control, 0) },
41
+ _sessionWriteGuard: {
42
+ buffer: control.buffer,
43
+ version: Atomics.load(control, 0),
44
+ // Identity of the snapshot this write carries. Absent for the
45
+ // lifecycle barriers (tombstone/detach/delete), which are
46
+ // authoritative by construction and never epoch-fenced.
47
+ ...(issued === null ? {} : { epoch: issued }),
48
+ },
19
49
  };
20
50
  }
21
51
 
@@ -33,18 +63,80 @@ export function isCancelledWrite(opts) {
33
63
  }
34
64
  }
35
65
 
36
- export function acquireWriteCommit(opts) {
66
+ /**
67
+ * Publish the epoch of a write that just renamed onto the canonical path.
68
+ * Monotonic max, so a late/older commit can never lower the bar.
69
+ */
70
+ export function publishLandedWriteEpoch(opts, epoch) {
71
+ const guard = opts?._sessionWriteGuard;
72
+ if (!guard?.buffer || !Number.isSafeInteger(epoch)) return;
73
+ try {
74
+ const view = landedEpochView(guard.buffer);
75
+ const next = BigInt(epoch);
76
+ let current = Atomics.load(view, 0);
77
+ while (next > current) {
78
+ const previous = Atomics.compareExchange(view, 0, current, next);
79
+ if (previous === current) return;
80
+ current = previous;
81
+ }
82
+ } catch { /* buffer gone: nothing to fence */ }
83
+ }
84
+
85
+ /**
86
+ * True when a STRICTLY NEWER save already landed on disk for this id, i.e.
87
+ * committing this snapshot would revert durable history. Covers the exit
88
+ * drain of an outstanding older payload and an older worker write finishing
89
+ * after a newer sync/async write (the commit lock serialises renames, it does
90
+ * not order them by content age).
91
+ */
92
+ export function isStaleWriteEpoch(opts) {
93
+ const guard = opts?._sessionWriteGuard;
94
+ if (!guard?.buffer || !Number.isSafeInteger(guard.epoch)) return false;
95
+ try {
96
+ return BigInt(guard.epoch) < Atomics.load(landedEpochView(guard.buffer), 0);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Sentinel returned when `timeoutMs` elapsed before the id's commit lock could
104
+ * be taken. Distinct from `false` (cancelled): nothing is wrong with the
105
+ * write, this realm simply may not block any longer (exit drain).
106
+ */
107
+ export const WRITE_COMMIT_TIMEOUT = Symbol('session-write-commit-timeout');
108
+
109
+ /**
110
+ * Sentinel returned when a newer write for the id landed while this one waited
111
+ * for the lock: the caller must discard its scratch file and commit nothing.
112
+ */
113
+ export const WRITE_COMMIT_STALE = Symbol('session-write-commit-stale');
114
+
115
+ export function acquireWriteCommit(opts, options = {}) {
37
116
  const guard = opts?._sessionWriteGuard;
38
117
  if (!guard?.buffer) return null;
39
118
  const control = new Int32Array(guard.buffer);
119
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs >= 0
120
+ ? options.timeoutMs
121
+ : Infinity;
122
+ const deadline = timeoutMs === Infinity ? Infinity : Date.now() + timeoutMs;
40
123
  while (Atomics.compareExchange(control, 1, 0, 1) !== 0) {
41
- Atomics.wait(control, 1, 1, 25);
124
+ if (Date.now() >= deadline) return WRITE_COMMIT_TIMEOUT;
125
+ const slice = deadline === Infinity ? 25 : Math.min(25, Math.max(1, deadline - Date.now()));
126
+ Atomics.wait(control, 1, 1, slice);
42
127
  }
43
128
  if (isCancelledWrite(opts)) {
44
129
  Atomics.store(control, 1, 0);
45
130
  Atomics.notify(control, 1);
46
131
  return false;
47
132
  }
133
+ // Re-checked UNDER the lock: the newer write may have landed while this
134
+ // one was waiting for it.
135
+ if (isStaleWriteEpoch(opts)) {
136
+ Atomics.store(control, 1, 0);
137
+ Atomics.notify(control, 1);
138
+ return WRITE_COMMIT_STALE;
139
+ }
48
140
  return control;
49
141
  }
50
142
 
@@ -54,9 +146,21 @@ export function releaseWriteCommit(control) {
54
146
  Atomics.notify(control, 1);
55
147
  }
56
148
 
57
- export function waitForWriteCommit(id) {
149
+ /**
150
+ * Wait until no commit holds this id's rename lock. `timeoutMs` bounds the
151
+ * wait: the exit drain must never hang behind a worker/thread that is stuck
152
+ * inside its commit, and a timeout is safe — the caller's own write still
153
+ * serialises on the same Atomics lock. Returns true when the lock was
154
+ * observed free, false on timeout.
155
+ */
156
+ export function waitForWriteCommit(id, opts = {}) {
58
157
  const control = writeControl(id);
158
+ const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs >= 0 ? opts.timeoutMs : Infinity;
159
+ const deadline = timeoutMs === Infinity ? Infinity : Date.now() + timeoutMs;
59
160
  while (Atomics.load(control, 1) !== 0) {
60
- Atomics.wait(control, 1, 1, 25);
161
+ if (Date.now() >= deadline) return false;
162
+ const slice = deadline === Infinity ? 25 : Math.min(25, Math.max(1, deadline - Date.now()));
163
+ Atomics.wait(control, 1, 1, slice);
61
164
  }
165
+ return true;
62
166
  }
@@ -150,6 +150,7 @@ export function _sessionSummary(session) {
150
150
  permission: session.permission || null,
151
151
  toolPermission: session.toolPermission || null,
152
152
  messageCount: messageProjection.count,
153
+ title: _cleanPreview(session.title || '', 100),
153
154
  preview: messageProjection.preview,
154
155
  generation: typeof session.generation === 'number' ? session.generation : 0,
155
156
  implicitBashSessionId: session.implicitBashSessionId || null,
@@ -185,6 +186,7 @@ function _normalizeSummaryRow(row) {
185
186
  permission: row.permission || null,
186
187
  toolPermission: row.toolPermission || null,
187
188
  messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
189
+ title: _cleanPreview(row.title || '', 100),
188
190
  preview: _cleanPreview(row.preview || ''),
189
191
  generation: typeof row.generation === 'number' ? row.generation : 0,
190
192
  implicitBashSessionId: row.implicitBashSessionId || null,