mixdog 1.2.0 → 1.2.1

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 (201) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -0
  4. package/src/defaults/skills/pptx/SKILL.md +6 -6
  5. package/src/defaults/skills/pptx/references/review.md +6 -4
  6. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  7. package/src/defaults/skills/setup/references/actions.md +13 -3
  8. package/src/defaults/skills/visualize/SKILL.md +12 -9
  9. package/src/rules/shared/60-verification.md +3 -1
  10. package/src/rules/shared/70-delivery.md +4 -2
  11. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  12. package/src/runtime/agent/orchestrator/config.mjs +10 -4
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  14. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  15. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  16. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +86 -8
  17. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +65 -7
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +128 -44
  20. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  21. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +19 -11
  25. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  27. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  28. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  29. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  31. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +9 -2
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  33. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  34. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  35. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
  36. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
  37. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
  38. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +9 -3
  44. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  45. package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
  46. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  48. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  49. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +267 -84
  50. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  53. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  54. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  55. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  56. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  57. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
  58. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  59. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  60. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  61. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  62. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  63. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  64. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  66. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
  68. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +6 -4
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  71. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +3 -1
  73. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  77. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  78. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
  79. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  80. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  81. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  82. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +19 -12
  84. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  85. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
  86. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  87. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +21 -2
  89. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  92. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
  95. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  99. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  101. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  104. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  105. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  107. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  108. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  109. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  110. package/src/runtime/attachments/store.d.mts +3 -0
  111. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  112. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  113. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  114. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  115. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  116. package/src/runtime/memory/lib/memory.mjs +1 -1
  117. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  118. package/src/runtime/shared/atomic-file.mjs +25 -4
  119. package/src/runtime/shared/extension-scopes.mjs +5 -3
  120. package/src/runtime/shared/file-lock.mjs +37 -11
  121. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  122. package/src/runtime/shared/llm/model-catalog.mjs +0 -12
  123. package/src/runtime/shared/llm/provider-catalog-registry.mjs +6 -0
  124. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  125. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  126. package/src/runtime/shared/pristine-execution.mjs +28 -4
  127. package/src/runtime/shared/provider-accounts.mjs +124 -20
  128. package/src/runtime/shared/provider-api-key.mjs +12 -0
  129. package/src/runtime/shared/shell-display.mjs +9 -3
  130. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  131. package/src/runtime/shared/skill-mentions.mjs +133 -0
  132. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  133. package/src/runtime/shared/tool-primitives.mjs +35 -2
  134. package/src/runtime/shared/turn-worktree-snapshot.mjs +25 -5
  135. package/src/runtime/shared/worker-requests.mjs +15 -2
  136. package/src/session-runtime/automation-agents.mjs +8 -2
  137. package/src/session-runtime/boot/apis.mjs +55 -0
  138. package/src/session-runtime/boot/config.mjs +5 -2
  139. package/src/session-runtime/boot/facade.mjs +1 -0
  140. package/src/session-runtime/boot/infrastructure.mjs +29 -18
  141. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  142. package/src/session-runtime/conversation-import/service.mjs +369 -0
  143. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
  144. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  145. package/src/session-runtime/mcp-glue/connect.mjs +57 -10
  146. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  147. package/src/session-runtime/mcp-glue/server-catalog.mjs +115 -30
  148. package/src/session-runtime/mcp-glue.mjs +22 -2
  149. package/src/session-runtime/new-session-config.mjs +3 -2
  150. package/src/session-runtime/notification-bus.mjs +9 -2
  151. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  152. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  153. package/src/session-runtime/provider-readiness.mjs +16 -10
  154. package/src/session-runtime/resource-extension-sync.mjs +20 -5
  155. package/src/session-runtime/resource-mcp-api.mjs +101 -77
  156. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  157. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  158. package/src/session-runtime/resource-skills-api.mjs +23 -2
  159. package/src/session-runtime/schedule-session-run.mjs +1 -1
  160. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  161. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  162. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  163. package/src/session-runtime/services/provider-admin.mjs +64 -18
  164. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  165. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  166. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  167. package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
  168. package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
  169. package/src/session-runtime/skills-api.mjs +7 -3
  170. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
  171. package/src/session-runtime/webhook-session-run.mjs +16 -4
  172. package/src/skill-script-entry.mjs +36 -0
  173. package/src/standalone/daemon-stored-session-views.mjs +11 -3
  174. package/src/standalone/daemon.mjs +41 -4
  175. package/src/standalone/session-protocol.mjs +5 -0
  176. package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
  177. package/src/tui/App.jsx +9 -1
  178. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  179. package/src/tui/app/app-view.jsx +6 -0
  180. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  181. package/src/tui/app/model-options.mjs +2 -0
  182. package/src/tui/app/skill-mention-palette.mjs +88 -0
  183. package/src/tui/app/slash-palette.mjs +23 -7
  184. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  185. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  186. package/src/tui/app/use-prompt-state.mjs +5 -0
  187. package/src/tui/components/Message.jsx +22 -1
  188. package/src/tui/components/PromptInput.jsx +21 -3
  189. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  190. package/src/tui/components/TranscriptItem.jsx +9 -1
  191. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  192. package/src/tui/components/user-message-skills.mjs +38 -0
  193. package/src/tui/dist/index.mjs +507 -171
  194. package/src/tui/session/queue-helpers.mjs +7 -2
  195. package/src/tui/session/session-api/extensions.mjs +4 -2
  196. package/src/tui/session/session-api/settings.mjs +7 -0
  197. package/src/tui/session/session-flow/drain.mjs +14 -4
  198. package/src/tui/session/tui-steering-persist.mjs +25 -9
  199. package/src/tui/session/turn.mjs +17 -0
  200. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  201. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -0,0 +1,817 @@
1
+ /**
2
+ * The ordinary session catalog engine: summary index (base + delta log),
3
+ * session-file reconcile, `.hb` liveness and the background verification.
4
+ *
5
+ * Leaf by design (no store.mjs, no config, no workers): the cold reader
6
+ * (store-summary-reader.mjs) runs one instance on its own thread for the JSON
7
+ * path, and the session catalog DB worker (catalog-db/worker.mjs) runs another
8
+ * that feeds SQLite. Both therefore detect exactly the same changes.
9
+ */
10
+ import { readdirSync, readFileSync } from 'node:fs';
11
+ import { stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ // Leaf helpers only: the three-way present/absent/unreadable classification
14
+ // and the strict record parser the authoritative store uses, so the catalog
15
+ // cannot disagree with it.
16
+ import { probePath, readTextFile, PROBE_PRESENT, PROBE_ABSENT } from './store/fs-probe.mjs';
17
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from './lifecycle-scan.mjs';
18
+ import { isAgentOnlySession, isRootLeadSession, sessionVisibility } from './store-summary-visibility.mjs';
19
+ import { applySummaryLogText, summaryLogPath } from './store-summary-log.mjs';
20
+ import { dataDir } from './store-summary-locations.mjs';
21
+ import {
22
+ desktopSession,
23
+ importKey,
24
+ isStoredSessionId,
25
+ positiveNumber,
26
+ workspaceSummary,
27
+ } from './store-summary-fields.mjs';
28
+ import { byActivityThenId } from './catalog-db/schema.mjs';
29
+ import {
30
+ sessionStampOf,
31
+ statSessionStamp,
32
+ sameSessionStamp,
33
+ isSettledSessionStamp,
34
+ } from './store/canonical-reader.mjs';
35
+
36
+ export const STAMP_ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
37
+
38
+ /** The one catalog order of every listing path (JSON and DB): newest
39
+ * conversation activity first (see normalizedRow's lastUsedAt note), ties by
40
+ * id, so a fallback and the DB never disagree on equal activity times. */
41
+ export const byCatalogOrder = byActivityThenId;
42
+
43
+ // ── Stamp-keyed strict derivations ──────────────────────────────────────────
44
+ // Session metadata and catalog rows are small values derived from a strict
45
+ // parse of the whole record. They are reused only while the file still has
46
+ // the FULL stamp { dev, ino, size, mtimeNs, ctimeNs } the parsed bytes were
47
+ // proven to come from (stat → read → stat) and that stamp is past the racy
48
+ // window. Any other file — changed, replaced by another writer, unreadable —
49
+ // is read and strictly parsed again.
50
+ const STAMPED_DERIVATION_LIMIT = 2048;
51
+ const stampedDerivations = new Map(); // `${kind}|${path}` → { stamp, value }
52
+
53
+ export function observeStamp(path) {
54
+ try {
55
+ return statSessionStamp(path);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** `{ state, value }`: `derive(text)` for the record at `path`, or its cached
62
+ * result while the file is provably unchanged. */
63
+ export function stampedDerivation(kind, path, derive) {
64
+ const key = `${kind}|${path}`;
65
+ const before = observeStamp(path);
66
+ const cached = stampedDerivations.get(key);
67
+ if (before && cached && sameSessionStamp(cached.stamp, before)) {
68
+ stampedDerivations.delete(key);
69
+ stampedDerivations.set(key, cached);
70
+ return { state: PROBE_PRESENT, value: cached.value };
71
+ }
72
+ stampedDerivations.delete(key);
73
+ const read = readTextFile(path);
74
+ if (read.state !== PROBE_PRESENT) return { state: read.state, value: null };
75
+ const value = derive(read.text);
76
+ if (isSettledSessionStamp(before) && sameSessionStamp(before, observeStamp(path))) {
77
+ stampedDerivations.set(key, { stamp: before, value });
78
+ if (stampedDerivations.size > STAMPED_DERIVATION_LIMIT) {
79
+ stampedDerivations.delete(stampedDerivations.keys().next().value);
80
+ }
81
+ }
82
+ return { state: PROBE_PRESENT, value };
83
+ }
84
+
85
+ const SESSION_SUMMARY_INDEX_VERSION = 2;
86
+
87
+ // Mirror of lifecycle-api.mjs listLeadSessions visibility: the cold catalog
88
+ // must never surface worker/agent dispatches (memory ingest chunks, judges,
89
+ // spawned agents) — the authoritative engine excludes them, so a click on
90
+ // such a row dead-ends in "Session is not available." (user report).
91
+ const LEAD_OWNERS = new Set(['cli', 'user', 'mixdog']);
92
+
93
+ export function isLeadVisibleRow(row) {
94
+ const owner = String(row.owner || 'user')
95
+ .trim()
96
+ .toLowerCase();
97
+ if (isAgentOnlySession(row)) return false;
98
+ if (!isRootLeadSession(row) && owner && !LEAD_OWNERS.has(owner)) return false;
99
+ // Mirror listLeadSessions: a previewless zero-message row is an unusable
100
+ // scratch (desktop boot leftovers, crashed first turns) — resuming it
101
+ // shows an empty conversation, so the catalog hides it.
102
+ if (!row.preview && row.messageCount === 0) return false;
103
+ const sourceType = String(row.sourceType || '')
104
+ .trim()
105
+ .toLowerCase();
106
+ // Mirror listLeadSessions: one-shot `mixdog exec` runs stay out of Recent.
107
+ if (sourceType === 'exec') return false;
108
+ const sourceName = String(row.sourceName || '')
109
+ .trim()
110
+ .toLowerCase();
111
+ const agent = String(row.agent || '')
112
+ .trim()
113
+ .toLowerCase();
114
+ return (
115
+ agent === 'lead' ||
116
+ sourceType === 'lead' ||
117
+ sourceType === 'cli' ||
118
+ sourceType === 'schedule' ||
119
+ sourceType === 'webhook' ||
120
+ (!sourceType && !sourceName && owner !== 'agent')
121
+ );
122
+ }
123
+
124
+ function cleanText(value, maximum = 240) {
125
+ return (
126
+ String(value || '')
127
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
128
+ .replace(/<mcp-instructions>[\s\S]*?<\/mcp-instructions>/gi, ' ')
129
+ // Session-context envelope (mirror of session-text.mjs
130
+ // stripSessionDisplayEnvelope): the "# Session / Cwd / Model /
131
+ // Workflow" header must never become a Recent title.
132
+ .replace(/^\s*# Session\r?\n(?:(?:Cwd|Model|Workflow):[^\r\n]*(?:\r?\n|$))+(?:\r?\n)?/i, ' ')
133
+ .replace(/^\s*#\s*Session\s+Cwd:\s+\S+(?:\s+Model:[^\r\n]*?)?(?:\s+Workflow:\s+\S+)?\s*/i, ' ')
134
+ .replace(/\s+/g, ' ')
135
+ .trim()
136
+ .slice(0, maximum)
137
+ );
138
+ }
139
+
140
+ function messageText(content) {
141
+ if (typeof content === 'string') return content;
142
+ if (!Array.isArray(content)) return '';
143
+ return content
144
+ .map((part) => (typeof part === 'string' ? part : String(part?.text || part?.content || '')))
145
+ .filter(Boolean)
146
+ .join(' ');
147
+ }
148
+
149
+ export function normalizedRow(row, heartbeatAt = 0) {
150
+ if (!row || typeof row.id !== 'string' || !isStoredSessionId(row.id)) return null;
151
+ return {
152
+ id: row.id,
153
+ updatedAt: positiveNumber(row.updatedAt, 0),
154
+ // Conversation-activity timestamp (mirror of listLeadSessions):
155
+ // detach/resume bookkeeping bumps updatedAt in bulk on restarts, so
156
+ // Recent must order by lastUsedAt or every restart reshuffles rows.
157
+ lastUsedAt: positiveNumber(row.lastUsedAt, 0),
158
+ createdAt: positiveNumber(row.createdAt, 0),
159
+ lastHeartbeatAt: positiveNumber(row.lastHeartbeatAt, 0),
160
+ // Liveness comes from the .hb sidecar mtime alone: stored row fields
161
+ // (summary index / final session save) survive completion and must not
162
+ // keep the desktop working indicator on after the sidecar is deleted.
163
+ heartbeatAt: positiveNumber(heartbeatAt, 0),
164
+ closed: row.closed === true,
165
+ status: String(row.status || (row.closed === true ? 'closed' : 'idle')),
166
+ owner: row.owner || 'user',
167
+ agent: row.agent || null,
168
+ sourceType: row.sourceType || null,
169
+ sourceName: row.sourceName || null,
170
+ scopeKey: row.scopeKey || null,
171
+ ownerSessionId: row.ownerSessionId || row.parentSessionId || null,
172
+ visibility: sessionVisibility(row),
173
+ clientHostPid: positiveNumber(row.clientHostPid, 0) || null,
174
+ cwd: row.cwd || '',
175
+ desktopSession: desktopSession(row.desktopSession, row.cwd),
176
+ ...(workspaceSummary(row.workspace) ? { workspace: workspaceSummary(row.workspace) } : {}),
177
+ provider: row.provider || null,
178
+ model: row.model || null,
179
+ agentTag: row.agentTag || null,
180
+ task_id: row.task_id || row.taskId || null,
181
+ permission: row.permission || null,
182
+ toolPermission: row.toolPermission || null,
183
+ messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
184
+ title: cleanText(row.title, 100),
185
+ preview: cleanText(row.preview),
186
+ generation: typeof row.generation === 'number' ? row.generation : 0,
187
+ storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
188
+ storageSize: positiveNumber(row.storageSize, 0),
189
+ detachedReason: row.detachedReason || null,
190
+ importKey: importKey(row),
191
+ };
192
+ }
193
+
194
+ /** Newest heartbeat of each Lead's live agents, from rows carrying `heartbeatAt`. */
195
+ export function agentHeartbeatsByOwner(rows) {
196
+ const agentHeartbeatByOwner = new Map();
197
+ for (const row of rows) {
198
+ const heartbeatAt = positiveNumber(row?.heartbeatAt, 0);
199
+ const ownerSessionId = String(row?.ownerSessionId || '').trim();
200
+ const owner = String(row?.owner || '')
201
+ .trim()
202
+ .toLowerCase();
203
+ const agent = String(row?.agent || '')
204
+ .trim()
205
+ .toLowerCase();
206
+ if (!heartbeatAt || !ownerSessionId || (owner !== 'agent' && (!agent || agent === 'lead'))) continue;
207
+ agentHeartbeatByOwner.set(ownerSessionId, Math.max(agentHeartbeatByOwner.get(ownerSessionId) || 0, heartbeatAt));
208
+ }
209
+ return agentHeartbeatByOwner;
210
+ }
211
+
212
+ function leadRowsWithAgentHeartbeat(rows) {
213
+ const agentHeartbeatByOwner = agentHeartbeatsByOwner(rows);
214
+ return rows
215
+ .filter((row) => row && isLeadVisibleRow(row))
216
+ .map((row) => {
217
+ const agentHeartbeatAt = agentHeartbeatByOwner.get(row.id) || 0;
218
+ return agentHeartbeatAt > 0 ? { ...row, agentHeartbeatAt } : row;
219
+ });
220
+ }
221
+
222
+ function rowFromSession(session, heartbeatAt = 0) {
223
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
224
+ const preview =
225
+ messages
226
+ .filter((message) => message?.role === 'user')
227
+ // Cold-path mirror of isSessionPreviewNoise's synthetic skips: compact
228
+ // handoffs and runtime notices must not become session titles.
229
+ .filter(
230
+ (message) =>
231
+ !/^\s*(?:a previous model worked on this task|re-attached after compaction\b|reference files:\s|\[mixdog-runtime\]|the async (?:agent|shell) task\b)/i.test(
232
+ messageText(message.content)
233
+ )
234
+ )
235
+ .map((message) => cleanText(messageText(message.content)))
236
+ .find(Boolean) || '';
237
+ return normalizedRow(
238
+ {
239
+ ...session,
240
+ messageCount: messages.filter((message) => message?.role === 'user' || message?.role === 'assistant').length,
241
+ preview,
242
+ },
243
+ heartbeatAt
244
+ );
245
+ }
246
+
247
+ // ── Session file reconcile ──────────────────────────────────────────────────
248
+ // What one `sessions/<id>.json` name contributes to the catalog, with the full
249
+ // stamp { dev, ino, size, mtimeNs, ctimeNs } it was judged at (and the
250
+ // { mtimeMs, size } fingerprint the summary index records):
251
+ // indexed its summary-index row (the file matches the row's fingerprint)
252
+ // row a row strictly parsed from the file (the index does not describe it)
253
+ // none parsed, but yields no row (ambiguous or foreign record)
254
+ // unreadable present but not statable/readable: the index row is retained
255
+ const UNREADABLE_ENTRY = Object.freeze({ kind: 'unreadable', stamp: null, mtimeMs: 0, size: 0 });
256
+
257
+ /** `stat().mtimeMs` of a bigint stat: Node's own sec * 1e3 + nsec / 1e6. */
258
+ function stampMtimeMs(stamp) {
259
+ return Number(stamp.mtimeNs / 1_000_000_000n) * 1e3 + Number(stamp.mtimeNs % 1_000_000_000n) / 1e6;
260
+ }
261
+
262
+ /** The entry for `<storageId>.json`, or null when the file is provably gone. */
263
+ function reconcileSessionFile(directory, storageId, retained, forceRead) {
264
+ const path = join(directory, `${storageId}.json`);
265
+ let stamp;
266
+ try {
267
+ stamp = statSessionStamp(path);
268
+ } catch (error) {
269
+ // Provably gone: the row legitimately disappears with the file.
270
+ return STAMP_ABSENT_CODES.has(error?.code) ? null : UNREADABLE_ENTRY;
271
+ }
272
+ const fingerprint = { stamp, mtimeMs: stampMtimeMs(stamp), size: Number(stamp.size) };
273
+ if (
274
+ !forceRead &&
275
+ retained &&
276
+ retained.storageMtimeMs > 0 &&
277
+ retained.storageSize > 0 &&
278
+ retained.storageMtimeMs === fingerprint.mtimeMs &&
279
+ retained.storageSize === fingerprint.size
280
+ ) {
281
+ return { kind: 'indexed', ...fingerprint };
282
+ }
283
+ // A file the index does not describe (never saved by the store, or
284
+ // saved after the index was written) is parsed once per full stamp.
285
+ const read = stampedDerivation('row', path, (text) => {
286
+ // Same strict authority as the store: a duplicate/ambiguous top-level
287
+ // record or a foreign identity is not this file's session.
288
+ const record = readTopLevelLifecycleRecord(text);
289
+ if (isLifecycleUnreadable(record) || record.id !== storageId) return null;
290
+ return rowFromSession(record.doc, 0);
291
+ });
292
+ if (read.state === PROBE_ABSENT) return null;
293
+ // Exists and could not be read (EACCES/EIO): retain the last known row
294
+ // rather than let a transient IO error delete it from view.
295
+ if (read.state !== PROBE_PRESENT) return UNREADABLE_ENTRY;
296
+ if (!read.value) return { kind: 'none', ...fingerprint };
297
+ return {
298
+ kind: 'row',
299
+ row: deepFreeze({
300
+ ...structuredClone(read.value),
301
+ storageMtimeMs: fingerprint.mtimeMs,
302
+ storageSize: fingerprint.size,
303
+ }),
304
+ ...fingerprint,
305
+ };
306
+ }
307
+
308
+ /** Cached rows are shared by every listing that hands them out: frozen, so
309
+ * no caller can edit what the next caller sees. */
310
+ function deepFreeze(value) {
311
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
312
+ Object.freeze(value);
313
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
314
+ }
315
+ return value;
316
+ }
317
+
318
+ /** The session ids (`<id>.json`) and live sidecar ids (`<id>.hb`) of
319
+ * `directory`, or null when it cannot be enumerated. */
320
+ function listSessionNames(directory) {
321
+ let names;
322
+ try {
323
+ names = readdirSync(directory);
324
+ } catch {
325
+ return null;
326
+ }
327
+ const sessionIds = [];
328
+ const heartbeatIds = [];
329
+ for (const filename of names) {
330
+ if (filename.endsWith('.json')) sessionIds.push(filename.slice(0, -5));
331
+ else if (filename.endsWith('.hb') && isStoredSessionId(filename.slice(0, -3))) {
332
+ heartbeatIds.push(filename.slice(0, -3));
333
+ }
334
+ }
335
+ return { sessionIds, heartbeatIds };
336
+ }
337
+
338
+ /** Every `.json` entry and `.hb` sidecar id of an enumerable `directory`, or
339
+ * null when it cannot be enumerated. */
340
+ function scanSessionEntries(directory, indexRowsById, forceRead) {
341
+ const names = listSessionNames(directory);
342
+ if (!names) return null;
343
+ const entries = new Map();
344
+ for (const storageId of names.sessionIds) {
345
+ const entry = reconcileSessionFile(directory, storageId, indexRowsById.get(storageId), forceRead);
346
+ if (entry) entries.set(storageId, entry);
347
+ }
348
+ return { entries, heartbeatIds: names.heartbeatIds };
349
+ }
350
+
351
+ /** The row an entry contributes (before liveness overlays), or null. */
352
+ function entryRow(entry, id, indexRowsById) {
353
+ if (!entry || entry.kind === 'none') return null;
354
+ return (entry.kind === 'row' ? entry.row : indexRowsById.get(id)) || null;
355
+ }
356
+
357
+ /** Catalog rows of reconciled `entries`, in catalog order. */
358
+ function assembleCatalog(entries, indexRowsById, heartbeatMtimes) {
359
+ const rows = [];
360
+ for (const [id, entry] of entries) {
361
+ const row = entryRow(entry, id, indexRowsById);
362
+ if (row) rows.push({ ...row, heartbeatAt: positiveNumber(heartbeatMtimes.get(id), 0) });
363
+ }
364
+ return leadRowsWithAgentHeartbeat(rows).sort(byCatalogOrder);
365
+ }
366
+
367
+ /**
368
+ * Read-only scan of the authoritative session files.
369
+ * Returns `null` when the directory itself is NOT enumerable (unreadable
370
+ * stat / readdir): that is not "no sessions", and the caller must keep the
371
+ * authority it already has (the index rows) instead of publishing an empty
372
+ * catalog. `indexRowsById` carries those rows so unchanged files can reuse the
373
+ * durable summary and a single UNREADABLE file retains its last known row
374
+ * instead of vanishing.
375
+ */
376
+ export function scanSessionFiles(heartbeatMtimes, indexRowsById, forceRead) {
377
+ const directory = join(dataDir(), 'sessions');
378
+ const dirProbe = probePath(directory);
379
+ if (dirProbe.state === PROBE_ABSENT) return [];
380
+ if (dirProbe.state !== PROBE_PRESENT) return null;
381
+ const scan = scanSessionEntries(directory, indexRowsById, forceRead);
382
+ return scan && assembleCatalog(scan.entries, indexRowsById, heartbeatMtimes);
383
+ }
384
+
385
+ /** Listing from the index rows alone, or null without a valid index. */
386
+ export function indexListing(index, heartbeatMtimes) {
387
+ return (
388
+ index.rows &&
389
+ leadRowsWithAgentHeartbeat(
390
+ index.rows.map((row) => ({ ...row, heartbeatAt: positiveNumber(heartbeatMtimes.get(row.id), 0) }))
391
+ ).sort(byCatalogOrder)
392
+ );
393
+ }
394
+
395
+ // ── Summary index ───────────────────────────────────────────────────────────
396
+ // Base + delta log, parsed once per full stamp of the pair. Stamps are taken
397
+ // BEFORE the reads: a write landing mid-read changes them, and the next call
398
+ // reads again. Every session update appends to the log; when the log has only
399
+ // grown — its bytes up to what was applied last are byte-for-byte unchanged
400
+ // and the base stamp is the same — only the appended lines are applied. A
401
+ // compaction (new base, truncated log) or any other rewrite fails that check
402
+ // and reloads both. Only newline-terminated lines are applied: a line still
403
+ // being appended is picked up whole by the next call.
404
+ const NO_INDEX_ROWS = new Map();
405
+ const NEWLINE = 0x0a;
406
+ let summaryIndexSerial = 0;
407
+
408
+ function sameOptionalStamp(left, right) {
409
+ return (!left && !right) || sameSessionStamp(left, right);
410
+ }
411
+
412
+ /** `{ bytes, failed }`: the log, empty when absent; `failed` when it exists
413
+ * and could not be read (EACCES/EIO), which is never cached. */
414
+ function readLogBytes(logPath) {
415
+ try {
416
+ return { bytes: readFileSync(logPath), failed: false };
417
+ } catch (error) {
418
+ return { bytes: Buffer.alloc(0), failed: !STAMP_ABSENT_CODES.has(error?.code) };
419
+ }
420
+ }
421
+
422
+ function freshIndex(indexPath, baseStamp, logStamp, logBytes) {
423
+ return {
424
+ serial: ++summaryIndexSerial,
425
+ // The index this one extends by appended log lines, and the ids they touched.
426
+ parentSerial: 0,
427
+ touchedIds: null,
428
+ indexPath,
429
+ baseStamp,
430
+ logStamp,
431
+ // The log prefix applied: every byte up to and including its last newline.
432
+ logBytes: logBytes.subarray(0, logBytes.lastIndexOf(NEWLINE) + 1),
433
+ // `rows` null = missing, unreadable or malformed: the files are the authority.
434
+ rows: null,
435
+ rowsById: NO_INDEX_ROWS,
436
+ // JSON of every row of base + log by id. Without a valid base the log's
437
+ // rows are still recorded here: they say which sessions an index-updating
438
+ // writer touched, even though they are not used as rows.
439
+ signatures: new Map(),
440
+ };
441
+ }
442
+
443
+ /** The summary index, extending `cached` by appended log lines when it can. */
444
+ function readSummaryIndex(cached) {
445
+ const indexPath = join(dataDir(), 'session-summaries.json');
446
+ const baseStamp = observeStamp(indexPath);
447
+ const logStamp = observeStamp(summaryLogPath(indexPath));
448
+ if (cached?.indexPath !== indexPath) cached = null;
449
+ if (cached && sameOptionalStamp(cached.baseStamp, baseStamp) && sameOptionalStamp(cached.logStamp, logStamp)) {
450
+ return { index: cached, cacheable: true };
451
+ }
452
+ // The delta log (per-session updates since the last compaction) is read
453
+ // before the base; see store-summary-log.mjs.
454
+ const log = readLogBytes(summaryLogPath(indexPath));
455
+ const index = freshIndex(indexPath, baseStamp, logStamp, log.bytes);
456
+ const appliedBytes = cached?.logBytes.length ?? 0;
457
+ if (
458
+ !log.failed &&
459
+ cached?.rows &&
460
+ sameOptionalStamp(cached.baseStamp, baseStamp) &&
461
+ index.logBytes.length >= appliedBytes &&
462
+ index.logBytes.compare(cached.logBytes, 0, appliedBytes, 0, appliedBytes) === 0
463
+ ) {
464
+ const rowsById = new Map(cached.rowsById);
465
+ const touched = new Set();
466
+ for (const line of index.logBytes.toString('utf8', appliedBytes).split('\n')) {
467
+ if (!line) continue;
468
+ let op;
469
+ try {
470
+ op = JSON.parse(line);
471
+ } catch {
472
+ continue;
473
+ }
474
+ // Same semantics as applySummaryLogText: last writer wins per id.
475
+ if (op?.u && typeof op.u.id === 'string') {
476
+ const row = normalizedRow(op.u, 0);
477
+ if (row) rowsById.set(row.id, deepFreeze(row));
478
+ else rowsById.delete(op.u.id);
479
+ touched.add(op.u.id);
480
+ } else if (Array.isArray(op?.r)) {
481
+ for (const id of op.r) {
482
+ rowsById.delete(id);
483
+ touched.add(id);
484
+ }
485
+ }
486
+ }
487
+ index.parentSerial = cached.serial;
488
+ index.touchedIds = touched;
489
+ index.rows = [...rowsById.values()];
490
+ index.rowsById = rowsById;
491
+ index.signatures = new Map(cached.signatures);
492
+ for (const id of touched) {
493
+ const row = rowsById.get(id);
494
+ if (row) index.signatures.set(id, JSON.stringify(row));
495
+ else index.signatures.delete(id);
496
+ }
497
+ return { index, cacheable: true };
498
+ }
499
+ const indexRead = readTextFile(indexPath);
500
+ let baseRows = null;
501
+ if (indexRead.state === PROBE_PRESENT) {
502
+ try {
503
+ const raw = JSON.parse(indexRead.text);
504
+ if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) baseRows = Array.isArray(raw.rows) ? raw.rows : [];
505
+ } catch {
506
+ /* malformed sidecar: the files are the authority */
507
+ }
508
+ }
509
+ const rows = applySummaryLogText({ rows: baseRows ?? [] }, index.logBytes.toString('utf8'))
510
+ .rows.map((row) => normalizedRow(row, 0))
511
+ .filter(Boolean)
512
+ .map(deepFreeze);
513
+ for (const row of rows) index.signatures.set(row.id, JSON.stringify(row));
514
+ if (baseRows) {
515
+ index.rows = rows;
516
+ index.rowsById = new Map(rows.map((row) => [row.id, row]));
517
+ }
518
+ // A read that failed (EACCES/EIO) is not cached: the next call reads again.
519
+ return {
520
+ index,
521
+ cacheable: !log.failed && (indexRead.state === PROBE_PRESENT || indexRead.state === PROBE_ABSENT),
522
+ };
523
+ }
524
+
525
+ /** Ids whose index row differs between two loads (added, changed or removed). */
526
+ function changedIndexIds(previous, next) {
527
+ const ids = [];
528
+ const candidates = next.parentSerial === previous.serial ? next.touchedIds : next.signatures.keys();
529
+ for (const id of candidates) if (previous.signatures.get(id) !== next.signatures.get(id)) ids.push(id);
530
+ if (next.parentSerial !== previous.serial) {
531
+ for (const id of previous.signatures.keys()) if (!next.signatures.has(id)) ids.push(id);
532
+ }
533
+ return ids;
534
+ }
535
+
536
+ function sameMtimes(left, right) {
537
+ if (!right || left.size !== right.size) return false;
538
+ for (const [id, mtimeMs] of left) if (right.get(id) !== mtimeMs) return false;
539
+ return true;
540
+ }
541
+
542
+ // ── Ordinary catalog ────────────────────────────────────────────────────────
543
+ // The ordinary listing keeps its last reconcile and answers an unchanged call
544
+ // from cheap stamps alone: the index pair (above), the sessions DIRECTORY and
545
+ // the handful of live `.hb` sidecars — no per-session-file filesystem work.
546
+ //
547
+ // The directory stamp changes on every create, delete and rename in it
548
+ // (including a temp-file + rename replace), and NOT on an in-place write
549
+ // (measured on NTFS; POSIX directories behave the same). So:
550
+ // - directory stamp changed → one enumeration; names that appeared are
551
+ // reconciled, names that vanished are dropped, names present before and
552
+ // after keep their entry. A stamp still inside the racy window is
553
+ // re-enumerated on every call until it settles: on a coarse-clock
554
+ // filesystem a create/delete in the same tick would leave it unchanged;
555
+ // - index rows changed (this process, another process, a compaction) → only
556
+ // those ids are re-reconciled — this is what refreshes a session an
557
+ // index-updating writer replaced under its existing name;
558
+ // - a file replaced (rename) or rewritten in place by a writer that does not
559
+ // update the index keeps its name and its index row: a background pass
560
+ // (async stats, never blocking a listing) finds it, the next listing
561
+ // re-reconciles just that file, and subscribers are told to list again.
562
+ // A pass runs at most once per CATALOG_VERIFY_INTERVAL_MS and at the
563
+ // latest that long after a listing.
564
+ const CATALOG_VERIFY_INTERVAL_MS = 10_000;
565
+ const CATALOG_VERIFY_BATCH = 32;
566
+
567
+ function unlistableRefresh() {
568
+ return { rows: null, catalog: null, fresh: false, changedIds: null, absent: false };
569
+ }
570
+
571
+ function newSessionCatalog(directory, dirStamp, index, scan) {
572
+ return {
573
+ directory,
574
+ dirStamp,
575
+ dirSettled: isSettledSessionStamp(dirStamp),
576
+ index,
577
+ entries: scan.entries,
578
+ heartbeatIds: scan.heartbeatIds,
579
+ heartbeatMtimes: null,
580
+ rows: null,
581
+ dirty: new Set(),
582
+ verifiedAt: Date.now(),
583
+ verifying: null,
584
+ rearm: false,
585
+ timer: null,
586
+ };
587
+ }
588
+
589
+ /** Brings an existing `catalog` up to date with the index, the directory and
590
+ * the `hinted` ids: `{ changed, changedIds }`, or null when the directory
591
+ * can no longer be enumerated. */
592
+ function reconcileKnownCatalog(catalog, directory, dirStamp, index, hinted) {
593
+ let changed = false;
594
+ const changedIds = new Set();
595
+ // Files the background pass found with a new full stamp: their index row
596
+ // can still match size + mtime (a same-length, mtime-preserving
597
+ // replacement), so they are re-derived from the file itself.
598
+ const forced = catalog.dirty;
599
+ catalog.dirty = new Set();
600
+ const ids = new Set([...forced, ...hinted]);
601
+ if (catalog.index !== index) {
602
+ for (const id of changedIndexIds(catalog.index, index)) ids.add(id);
603
+ catalog.index = index;
604
+ changed = true;
605
+ }
606
+ if (!catalog.dirSettled || !sameSessionStamp(catalog.dirStamp, dirStamp)) {
607
+ const names = listSessionNames(directory);
608
+ if (!names) return null;
609
+ const present = new Set(names.sessionIds);
610
+ for (const id of catalog.entries.keys()) {
611
+ if (present.has(id)) continue;
612
+ catalog.entries.delete(id);
613
+ changedIds.add(id);
614
+ changed = true;
615
+ }
616
+ for (const id of names.sessionIds) if (!catalog.entries.has(id)) ids.add(id);
617
+ catalog.heartbeatIds = names.heartbeatIds;
618
+ catalog.dirStamp = dirStamp;
619
+ catalog.dirSettled = isSettledSessionStamp(dirStamp);
620
+ }
621
+ for (const id of ids) {
622
+ const entry = reconcileSessionFile(directory, id, index.rowsById.get(id), forced.has(id));
623
+ if (entry) catalog.entries.set(id, entry);
624
+ else catalog.entries.delete(id);
625
+ changedIds.add(id);
626
+ changed = true;
627
+ }
628
+ return { changed, changedIds };
629
+ }
630
+
631
+ /** Liveness: `.hb` sidecars are rewritten in place, so their mtimes are
632
+ * probed every call (absent or unreadable contributes nothing). True when
633
+ * they differ from the previous probe. */
634
+ function refreshHeartbeatMtimes(catalog, directory) {
635
+ const heartbeatMtimes = new Map();
636
+ for (const id of catalog.heartbeatIds) {
637
+ const probe = probePath(join(directory, `${id}.hb`));
638
+ if (probe.state === PROBE_PRESENT && probe.mtimeMs > 0) heartbeatMtimes.set(id, probe.mtimeMs);
639
+ }
640
+ const differs = !sameMtimes(heartbeatMtimes, catalog.heartbeatMtimes);
641
+ catalog.heartbeatMtimes = heartbeatMtimes;
642
+ return differs;
643
+ }
644
+
645
+ /** Marks every entry of `snapshot` whose file no longer has the full stamp it
646
+ * was judged at dirty (a replacement brings a new inode and change time even
647
+ * when its size and mtime match). Stops early once `isCurrent()` is false. */
648
+ async function markChangedEntriesDirty(catalog, snapshot, isCurrent) {
649
+ for (let start = 0; start < snapshot.length; start += CATALOG_VERIFY_BATCH) {
650
+ if (!isCurrent()) return;
651
+ await Promise.all(
652
+ snapshot.slice(start, start + CATALOG_VERIFY_BATCH).map(async ([id, entry]) => {
653
+ let changed;
654
+ try {
655
+ const info = await stat(join(catalog.directory, `${id}.json`), { bigint: true });
656
+ changed = entry.kind === 'unreadable' || !sameSessionStamp(sessionStampOf(info), entry.stamp);
657
+ } catch (error) {
658
+ changed = entry.kind !== 'unreadable' || STAMP_ABSENT_CODES.has(error?.code);
659
+ }
660
+ if (changed && catalog.entries.get(id) === entry) catalog.dirty.add(id);
661
+ })
662
+ );
663
+ }
664
+ }
665
+
666
+ /**
667
+ * One ordinary catalog with its own index cache, reconcile state, background
668
+ * pass and change listeners.
669
+ */
670
+ export function createOrdinaryCatalog() {
671
+ let summaryIndexCache = null;
672
+ let sessionCatalog = null;
673
+ // Ids a writer of this process just changed on disk (storage hints): the
674
+ // next refresh re-reconciles them against the index row and the file.
675
+ let hintedIds = new Set();
676
+ const listeners = new Set();
677
+
678
+ function loadIndex() {
679
+ const { index, cacheable } = readSummaryIndex(summaryIndexCache);
680
+ if (cacheable) summaryIndexCache = index;
681
+ return index;
682
+ }
683
+
684
+ function dropSessionCatalog() {
685
+ if (sessionCatalog?.timer) clearTimeout(sessionCatalog.timer);
686
+ sessionCatalog = null;
687
+ }
688
+
689
+ /**
690
+ * Reconciles the catalog against storage: `{ rows, catalog, fresh,
691
+ * changedIds, absent }`. `rows` are the listing (assembled only when
692
+ * `assemble`), [] when the sessions directory is absent, null when it
693
+ * cannot be enumerated. `fresh` means the catalog was (re)built from a full
694
+ * scan; otherwise `changedIds` are the ids whose entry was dropped or
695
+ * re-reconciled by this call.
696
+ */
697
+ function refresh(index, { assemble = true } = {}) {
698
+ const directory = join(dataDir(), 'sessions');
699
+ let dirStamp;
700
+ try {
701
+ dirStamp = statSessionStamp(directory);
702
+ } catch (error) {
703
+ dropSessionCatalog();
704
+ const absent = STAMP_ABSENT_CODES.has(error?.code);
705
+ return { rows: absent ? [] : null, catalog: null, fresh: false, changedIds: null, absent };
706
+ }
707
+ let catalog = sessionCatalog?.directory === directory ? sessionCatalog : null;
708
+ const fresh = !catalog;
709
+ let changed = true;
710
+ let changedIds = new Set();
711
+ const hinted = hintedIds;
712
+ hintedIds = new Set();
713
+ if (fresh) {
714
+ const scan = scanSessionEntries(directory, index.rowsById, false);
715
+ if (!scan) return unlistableRefresh();
716
+ catalog = newSessionCatalog(directory, dirStamp, index, scan);
717
+ sessionCatalog = catalog;
718
+ } else {
719
+ const reconciled = reconcileKnownCatalog(catalog, directory, dirStamp, index, hinted);
720
+ if (!reconciled) {
721
+ dropSessionCatalog();
722
+ return unlistableRefresh();
723
+ }
724
+ ({ changed, changedIds } = reconciled);
725
+ }
726
+ if (refreshHeartbeatMtimes(catalog, directory)) changed = true;
727
+ if (changed) catalog.rows = null;
728
+ if (assemble) rowsOf(catalog);
729
+ armCatalogVerify(catalog);
730
+ // Rows (and everything in them) are frozen: callers share them read-only.
731
+ return { rows: assemble ? catalog.rows.slice() : null, catalog, fresh, changedIds, absent: false };
732
+ }
733
+
734
+ /** The (frozen) listing of a reconciled catalog, assembled on demand. */
735
+ function rowsOf(catalog) {
736
+ catalog.rows ||= assembleCatalog(catalog.entries, catalog.index.rowsById, catalog.heartbeatMtimes).map(
737
+ Object.freeze
738
+ );
739
+ return catalog.rows;
740
+ }
741
+
742
+ /** Schedules the next background pass: CATALOG_VERIFY_INTERVAL_MS after the
743
+ * last one started, unless one is already scheduled or running. */
744
+ function armCatalogVerify(catalog) {
745
+ if (catalog.verifying) {
746
+ // A listing during a pass: the next pass is scheduled when this one ends.
747
+ catalog.rearm = true;
748
+ return;
749
+ }
750
+ if (catalog.timer) return;
751
+ const delay = Math.max(0, catalog.verifiedAt + CATALOG_VERIFY_INTERVAL_MS - Date.now());
752
+ catalog.timer = setTimeout(() => {
753
+ catalog.timer = null;
754
+ if (sessionCatalog === catalog) startCatalogVerify(catalog);
755
+ }, delay);
756
+ catalog.timer.unref?.();
757
+ }
758
+
759
+ function startCatalogVerify(catalog) {
760
+ if (catalog.verifying) return catalog.verifying;
761
+ if (catalog.timer) clearTimeout(catalog.timer);
762
+ catalog.timer = null;
763
+ catalog.verifiedAt = Date.now();
764
+ const verifying = verifyCatalogEntries(catalog, [...catalog.entries]).finally(() => {
765
+ if (catalog.verifying !== verifying) return;
766
+ catalog.verifying = null;
767
+ if (!catalog.rearm) return;
768
+ catalog.rearm = false;
769
+ if (sessionCatalog === catalog) armCatalogVerify(catalog);
770
+ });
771
+ catalog.verifying = verifying;
772
+ return verifying;
773
+ }
774
+
775
+ /** Background pass: marks changed entries dirty and tells the listeners. */
776
+ async function verifyCatalogEntries(catalog, snapshot) {
777
+ await markChangedEntriesDirty(catalog, snapshot, () => sessionCatalog === catalog);
778
+ if (sessionCatalog !== catalog || catalog.dirty.size === 0) return;
779
+ for (const listener of listeners) {
780
+ try {
781
+ listener();
782
+ } catch {
783
+ /* a listener's failure is its own */
784
+ }
785
+ }
786
+ }
787
+
788
+ return {
789
+ loadIndex,
790
+ refresh,
791
+ rowsOf,
792
+ /** The row `id` contributes to the current catalog (before overlays). */
793
+ entryRow: (catalog, id) => entryRow(catalog.entries.get(id), id, catalog.index.rowsById),
794
+ current: () => sessionCatalog,
795
+ /** `listener()` runs when a background pass finds a session file that
796
+ * changed without a directory or index change. Returns the unsubscribe. */
797
+ subscribe(listener) {
798
+ listeners.add(listener);
799
+ return () => listeners.delete(listener);
800
+ },
801
+ /** Run a background verification now (or join the running one). */
802
+ verify: () => (sessionCatalog ? startCatalogVerify(sessionCatalog) : Promise.resolve()),
803
+ /** Storage hints: `ids` changed on disk (a save landed, a delete removed
804
+ * the file). The next refresh stats them and re-reads a file its index
805
+ * row does not describe; a gone file drops its entry. Without a catalog
806
+ * the next refresh is a full scan anyway. */
807
+ hint(ids) {
808
+ if (sessionCatalog) for (const id of ids) hintedIds.add(id);
809
+ },
810
+ /** Forget the catalog and the parsed summary index. */
811
+ clear() {
812
+ dropSessionCatalog();
813
+ summaryIndexCache = null;
814
+ hintedIds = new Set();
815
+ },
816
+ };
817
+ }