mixdog 1.0.12 → 1.1.0

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 (256) hide show
  1. package/README.md +75 -181
  2. package/package.json +4 -2
  3. package/scripts/build-shipped-history.mjs +131 -0
  4. package/scripts/lib/run-node-tests.mjs +235 -13
  5. package/scripts/lib/test-failure-records.mjs +5 -0
  6. package/scripts/lib/test-failure-summary.mjs +9 -3
  7. package/scripts/lib/test-spawn-ledger.mjs +19 -0
  8. package/scripts/prune-embedding-runtime.mjs +9 -5
  9. package/scripts/test-direct.mjs +16 -1
  10. package/src/agents/advisor/AGENT.md +38 -0
  11. package/src/agents/advisor/agent.json +6 -0
  12. package/src/agents/reviewer/AGENT.md +5 -2
  13. package/src/agents/reviewer/agent.json +1 -1
  14. package/src/defaults/shipped-history.json +381 -0
  15. package/src/defaults/skills/goal-management/SKILL.md +11 -5
  16. package/src/defaults/skills/setup/SKILL.md +5 -8
  17. package/src/defaults/skills/setup/references/actions.md +61 -31
  18. package/src/defaults/skills/setup/references/surfaces.md +12 -10
  19. package/src/lib/rules-builder.cjs +1 -1
  20. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +3 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  22. package/src/runtime/agent/orchestrator/config-presets.d.mts +1 -0
  23. package/src/runtime/agent/orchestrator/config-presets.mjs +44 -0
  24. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +9 -1
  25. package/src/runtime/agent/orchestrator/config.mjs +102 -21
  26. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +77 -26
  27. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +44 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +11 -2
  30. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +3 -3
  32. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +388 -0
  33. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +92 -109
  34. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +107 -0
  35. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +51 -9
  36. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +2 -1
  37. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +14 -9
  38. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +51 -11
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +6 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +7 -5
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +12 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +27 -7
  43. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +12 -2
  44. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/quota-reading.mjs +70 -0
  47. package/src/runtime/agent/orchestrator/providers/registry.mjs +38 -3
  48. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +19 -35
  49. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +5 -0
  50. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +0 -17
  51. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +0 -3
  52. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +0 -1
  53. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -2
  56. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +18 -4
  57. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -1
  58. package/src/runtime/agent/orchestrator/runtime-core/session-route-policy.mjs +16 -0
  59. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +7 -7
  62. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -0
  63. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +28 -3
  64. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +24 -11
  65. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +2 -6
  66. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +63 -3
  67. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +4 -4
  68. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  69. package/src/runtime/agent/orchestrator/stall-policy.mjs +0 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +19 -20
  72. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -11
  73. package/src/runtime/agent/orchestrator/tools/builtin/file-version-test-helpers.mjs +11 -0
  74. package/src/runtime/agent/orchestrator/tools/builtin/file-version.mjs +13 -0
  75. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +8 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +25 -12
  77. package/src/runtime/agent/orchestrator/tools/builtin/raw-content-publication.mjs +17 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +136 -76
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +15 -17
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -2
  81. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +11 -10
  82. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +30 -16
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +4 -11
  85. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +7 -3
  86. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-validation.mjs +16 -19
  87. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  88. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +4 -1
  89. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +14 -4
  90. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +58 -8
  91. package/src/runtime/agent/orchestrator/tools/patch/content-guard.mjs +47 -0
  92. package/src/runtime/agent/orchestrator/tools/patch/delete-quarantine.mjs +155 -0
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-proofs.mjs +6 -2
  94. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-snapshots.mjs +6 -2
  95. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +19 -16
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +13 -44
  97. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/patch/post-patch-excerpts.mjs +5 -2
  99. package/src/runtime/agent/orchestrator/tools/patch/replay-capture.mjs +47 -7
  100. package/src/runtime/agent/orchestrator/tools/patch/rollback-state.mjs +72 -8
  101. package/src/runtime/agent/orchestrator/tools/patch/section-coalesce.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/patch/sequence/run.mjs +9 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +9 -1
  104. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +6 -6
  105. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +16 -4
  106. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -14
  107. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +42 -4
  108. package/src/runtime/agent/orchestrator/tools/shell-codepage.mjs +296 -0
  109. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +75 -24
  110. package/src/runtime/channels/lib/config.mjs +3 -8
  111. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +7 -3
  112. package/src/{tui → runtime/channels}/lib/voice-setup.mjs +38 -22
  113. package/src/runtime/channels/lib/webhook/request-handler.mjs +42 -8
  114. package/src/runtime/computer-bridge/actions.mjs +2 -0
  115. package/src/runtime/effort-judge/judge-client.mjs +12 -3
  116. package/src/runtime/effort-judge/model-manifest.json +7 -7
  117. package/src/runtime/effort-judge/previous-human-request.mjs +162 -0
  118. package/src/runtime/effort-judge/verified-context-input.mjs +10 -0
  119. package/src/runtime/memory/lib/embedding-provider.mjs +12 -0
  120. package/src/runtime/memory/lib/http-router/session-routes.mjs +2 -1
  121. package/src/runtime/memory/lib/memory-config-flags.mjs +3 -2
  122. package/src/runtime/memory/lib/memory-embed.mjs +5 -0
  123. package/src/runtime/shared/agent-route-config.mjs +50 -2
  124. package/src/runtime/shared/bounded-file-read.mjs +166 -0
  125. package/src/runtime/shared/builtin-grandfathering.mjs +37 -0
  126. package/src/runtime/shared/config.mjs +24 -4
  127. package/src/runtime/shared/defaults-separation.mjs +263 -0
  128. package/src/runtime/shared/llm/anthropic-effort.mjs +4 -0
  129. package/src/runtime/shared/llm/model-catalog.mjs +23 -0
  130. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +105 -1
  131. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  132. package/src/runtime/shared/llm/usage-ledger.mjs +72 -5
  133. package/src/runtime/shared/onnx-runtime-support.mjs +6 -0
  134. package/src/runtime/shared/orchestration.mjs +14 -6
  135. package/src/runtime/shared/projects.mjs +65 -48
  136. package/src/runtime/shared/provider-accounts.mjs +47 -6
  137. package/src/runtime/shared/quota-resume-store.mjs +255 -0
  138. package/src/runtime/shared/shipped-definitions.mjs +275 -0
  139. package/src/runtime/shared/tool-card-model.golden.json +3 -3
  140. package/src/runtime/shared/tool-result-summary.mjs +86 -15
  141. package/src/runtime/shared/transcript-metadata.mjs +2 -0
  142. package/src/runtime/shared/turn-review-reply.mjs +2 -0
  143. package/src/runtime/shared/turn-snapshot.mjs +65 -6
  144. package/src/runtime/shared/turn-worktree-snapshot.mjs +202 -22
  145. package/src/session-runtime/boot/apis.mjs +19 -0
  146. package/src/session-runtime/boot/config.mjs +4 -0
  147. package/src/session-runtime/boot/facade.mjs +4 -1
  148. package/src/session-runtime/boot/infrastructure.mjs +21 -2
  149. package/src/session-runtime/boot/session.mjs +3 -0
  150. package/src/session-runtime/channel-config-api.mjs +5 -0
  151. package/src/session-runtime/config-lifecycle/config-writers.mjs +3 -0
  152. package/src/session-runtime/config-lifecycle.mjs +31 -6
  153. package/src/session-runtime/goal-facade-api.mjs +4 -0
  154. package/src/session-runtime/lifecycle/inheritance.mjs +28 -6
  155. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +3 -0
  156. package/src/session-runtime/lifecycle/session-switch/resume.mjs +7 -1
  157. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +6 -0
  158. package/src/session-runtime/lifecycle/teardown/session-close.mjs +14 -2
  159. package/src/session-runtime/media-api.mjs +32 -22
  160. package/src/session-runtime/model-recency.mjs +14 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +3 -5
  162. package/src/session-runtime/model-route/set-route.mjs +49 -21
  163. package/src/session-runtime/model-route-api.mjs +2 -1
  164. package/src/session-runtime/notification-bus.mjs +31 -6
  165. package/src/session-runtime/provider-auth/login.mjs +6 -0
  166. package/src/session-runtime/provider-models/catalog-load.mjs +3 -1
  167. package/src/session-runtime/provider-models/row-hydration.mjs +15 -14
  168. package/src/session-runtime/quick-model-rows/model-meta.mjs +26 -0
  169. package/src/session-runtime/quick-model-rows/provider-rows.mjs +40 -14
  170. package/src/session-runtime/resource-api.mjs +8 -1
  171. package/src/session-runtime/runtime-core.mjs +4 -0
  172. package/src/session-runtime/runtime-facade.mjs +20 -1
  173. package/src/session-runtime/runtime-review-api.mjs +12 -1
  174. package/src/session-runtime/services/agent-tool/helpers.mjs +4 -0
  175. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +10 -15
  176. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +11 -3
  177. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +2 -0
  178. package/src/session-runtime/services/channel-admin.mjs +87 -13
  179. package/src/session-runtime/services/provider-admin.mjs +256 -63
  180. package/src/session-runtime/services/seeds.mjs +73 -142
  181. package/src/session-runtime/services/usage-dashboard-model.mjs +29 -0
  182. package/src/session-runtime/services/usage-dashboard.mjs +7 -0
  183. package/src/session-runtime/session-address.mjs +125 -0
  184. package/src/session-runtime/session-create.mjs +57 -2
  185. package/src/session-runtime/settings-builtin-tools-api.mjs +8 -0
  186. package/src/session-runtime/settings-compaction-api.mjs +13 -0
  187. package/src/session-runtime/setup-tool/executor.mjs +92 -40
  188. package/src/session-runtime/setup-tool/extended-actions.mjs +64 -5
  189. package/src/session-runtime/setup-tool/settings-contract.mjs +44 -1
  190. package/src/session-runtime/setup-tool/tool-defs.mjs +12 -7
  191. package/src/session-runtime/tool-surface.mjs +16 -9
  192. package/src/session-runtime/turn/session-ops.mjs +3 -0
  193. package/src/session-runtime/turn/share-ops.mjs +4 -1
  194. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +6 -0
  195. package/src/session-runtime/usage-stats-api.mjs +15 -0
  196. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +31 -5
  197. package/src/session-runtime/workflow-agents-api/agent-route.mjs +4 -0
  198. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +27 -7
  199. package/src/standalone/daemon-boot-coordinator.mjs +10 -0
  200. package/src/standalone/daemon-drain-policy.mjs +9 -0
  201. package/src/standalone/daemon-stored-session-views.mjs +7 -8
  202. package/src/standalone/daemon.mjs +21 -5
  203. package/src/standalone/local-session-runtime.mjs +11 -0
  204. package/src/standalone/session-client.mjs +52 -7
  205. package/src/standalone/session-protocol.mjs +8 -0
  206. package/src/standalone/session-service/agent-tree/agent-child.mjs +2 -0
  207. package/src/standalone/session-service/entries/lifecycle.mjs +2 -0
  208. package/src/standalone/session-service/entries/session-loads.mjs +9 -0
  209. package/src/standalone/session-service/projection/revisions.mjs +28 -4
  210. package/src/standalone/session-service/projection.mjs +1 -0
  211. package/src/standalone/session-service/quota-resume.mjs +140 -0
  212. package/src/standalone/session-service/session-calls/session-action.mjs +2 -2
  213. package/src/standalone/session-service/session-calls.mjs +10 -2
  214. package/src/standalone/session-service/stored-reader.mjs +26 -1
  215. package/src/standalone/session-service.mjs +21 -0
  216. package/src/standalone/session-transport/routes.mjs +2 -0
  217. package/src/tui/app/doctor.mjs +7 -11
  218. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +3 -5
  219. package/src/tui/app/settings-picker/settings-rows.mjs +4 -6
  220. package/src/tui/app/settings-picker/settings-toggles/store-toggles.mjs +1 -1
  221. package/src/tui/app/slash-dispatch/route-commands.mjs +3 -2
  222. package/src/tui/components/ContextPanel.jsx +5 -1
  223. package/src/tui/components/Spinner.jsx +34 -319
  224. package/src/tui/components/SpinnerByline.jsx +256 -0
  225. package/src/tui/components/use-spinner-progress.mjs +113 -0
  226. package/src/tui/dist/index.mjs +15540 -14452
  227. package/src/tui/session/agent-job-feed/execution-delivery.mjs +13 -1
  228. package/src/tui/session/agent-job-feed/notification-router.mjs +3 -0
  229. package/src/tui/session/agent-job-feed/pending-resume.mjs +14 -1
  230. package/src/tui/session/agent-job-feed.mjs +4 -0
  231. package/src/tui/session/context-state.mjs +13 -1
  232. package/src/tui/session/initial-state.mjs +6 -0
  233. package/src/tui/session/quota-resume.mjs +263 -0
  234. package/src/tui/session/session-api/intake/abort.mjs +39 -35
  235. package/src/tui/session/session-api/intake/submission.mjs +13 -21
  236. package/src/tui/session/session-api/intake.mjs +3 -3
  237. package/src/tui/session/session-api/integrations.mjs +5 -1
  238. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  239. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -2
  240. package/src/tui/session/session-api/lifecycle/session-reset.mjs +4 -0
  241. package/src/tui/session/session-api/lifecycle.mjs +71 -2
  242. package/src/tui/session/session-api/media.mjs +2 -2
  243. package/src/tui/session/session-api/quota-resume.mjs +21 -0
  244. package/src/tui/session/session-api/routes.mjs +2 -0
  245. package/src/tui/session/session-api/settings.mjs +1 -0
  246. package/src/tui/session/session-api.mjs +2 -0
  247. package/src/tui/session/session-flow/auto-clear.mjs +14 -6
  248. package/src/tui/session/session-flow/drain.mjs +52 -5
  249. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -3
  250. package/src/tui/session/session-flow/queue.mjs +3 -0
  251. package/src/tui/session/session-flow/steering.mjs +11 -0
  252. package/src/tui/session/session-flow.mjs +1 -0
  253. package/src/tui/session/tool-approval.mjs +7 -2
  254. package/src/tui/session/transcript-intake.mjs +1 -0
  255. package/src/tui/session/turn.mjs +40 -3
  256. package/src/vendor/statusline/bin/statusline-route.mjs +3 -2
@@ -15,13 +15,14 @@
15
15
  // exactly — multiline patterns, type filters, a PCRE-only regex, a binary or
16
16
  // oversized file — declines (returns null) and the caller keeps the original
17
17
  // native error.
18
- import { readFile } from 'node:fs/promises';
18
+ import { PATCH_SNAPSHOT_MAX_BYTES, readBoundedFile } from '../../../../../shared/bounded-file-read.mjs';
19
19
 
20
20
  import { formatGrepContextOutput, formatGrepOutput, grepNoMatchesBody } from './grep-output.mjs';
21
21
 
22
22
  /** Reading is the whole point of the rescue, so the file must stay small
23
- * enough that reading it is cheaper than the failed search. */
24
- export const MAX_RESCUE_BYTES = 8 * 1024 * 1024;
23
+ * enough that reading it is cheaper than the failed search. The read is
24
+ * bounded: an oversized (or growing) file is declined without being read. */
25
+ export const MAX_RESCUE_BYTES = PATCH_SNAPSHOT_MAX_BYTES;
25
26
  /** Bounds memory on a pathological match rate; a truncated window is reported
26
27
  * as incomplete, exactly like a capped native result. */
27
28
  const MAX_RESCUE_LINES = 5_000;
@@ -69,11 +70,13 @@ export async function runGrepSingleFileRescue({
69
70
 
70
71
  let raw;
71
72
  try {
72
- raw = await readFile(filePath);
73
+ const read = readBoundedFile(filePath, { maxBytes: MAX_RESCUE_BYTES });
74
+ if (read.state !== 'present') return null;
75
+ raw = read.content;
73
76
  } catch {
74
77
  return null;
75
78
  }
76
- if (raw.length > MAX_RESCUE_BYTES || raw.includes(0)) return null;
79
+ if (raw.includes(0)) return null;
77
80
  const text = raw.toString('utf8');
78
81
  const fileLines = text.split(/\r?\n/);
79
82
  if (fileLines.length > 0 && fileLines[fileLines.length - 1] === '') fileLines.pop();
@@ -1,13 +1,20 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
- import { StringDecoder } from 'node:string_decoder';
3
2
  import { stripAnsi } from '../../shell-exec-output.mjs';
3
+ import { FileEncodingSniffer, ShellStreamDecoder, alignedOffset, oemTextEncoding } from '../../shell-codepage.mjs';
4
4
 
5
5
  const PAGE_BYTES = 8 * 1024;
6
6
  const COMPLETION_BYTES = 12 * 1024;
7
7
  const cursors = new WeakMap();
8
8
 
9
9
  function newCursor() {
10
- return { offset: 0, preview: '', decoder: new StringDecoder('utf8'), ansi: '' };
10
+ return {
11
+ offset: 0,
12
+ preview: '',
13
+ decoder: new ShellStreamDecoder(),
14
+ ansi: '',
15
+ sniffer: new FileEncodingSniffer(),
16
+ committed: false,
17
+ };
11
18
  }
12
19
 
13
20
  // Only the introducer is retained, plus the ESC that may open a split OSC
@@ -85,20 +92,26 @@ export function readShellTaskOutput(task, result = {}, { output = 'new' } = {})
85
92
  cursor = newCursor();
86
93
  streams.set(key, cursor);
87
94
  }
88
- const start = terminal || replay ? Math.max(cursor.offset, size - limit) : cursor.offset;
89
- const skipped = start - cursor.offset;
90
- if (skipped) {
91
- cursor.decoder = new StringDecoder('utf8');
95
+ // Raw capture files carry the child's bytes. The stream's encoding is
96
+ // decided once, firmly (or when the task is terminal); until then only
97
+ // the ASCII prefix is read.
98
+ const label = cursor.sniffer.probe(fd, size, terminal);
99
+ if (label && !cursor.committed) {
100
+ cursor.committed = true;
101
+ cursor.decoder.force(label);
102
+ }
103
+ const readEnd = label ? size : cursor.sniffer.safeEnd(size);
104
+ let start = terminal || replay ? Math.max(cursor.offset, readEnd - limit) : cursor.offset;
105
+ if (start > cursor.offset) {
106
+ start = Math.min(readEnd, alignedOffset(fd, start, label || 'utf-8'));
107
+ sections.push(`[${stream}: ${start - cursor.offset} earlier bytes omitted; read ${path} for the original log]`);
108
+ cursor.decoder = new ShellStreamDecoder(oemTextEncoding(), label);
92
109
  cursor.ansi = '';
93
110
  }
94
- const buffer = Buffer.alloc(Math.min(limit, Math.max(0, size - start)));
111
+ const buffer = Buffer.alloc(Math.min(limit, Math.max(0, readEnd - start)));
95
112
  const count = buffer.length ? readSync(fd, buffer, 0, buffer.length, start) : 0;
96
- const prefix = skipped ? utf8Boundary(buffer, 0, count) : 0;
97
- if (skipped + prefix) {
98
- sections.push(`[${stream}: ${skipped + prefix} earlier bytes omitted; read ${path} for the original log]`);
99
- }
100
113
  cursor.offset = start + count;
101
- let decoded = cursor.decoder.write(buffer.subarray(prefix, count));
114
+ let decoded = cursor.decoder.write(buffer.subarray(0, count));
102
115
  if (terminal && cursor.offset === size) decoded += cursor.decoder.end();
103
116
  const text = visibleText(cursor, decoded);
104
117
  if (text) sections.push(`[${stream}]\n${text}`);
@@ -0,0 +1,17 @@
1
+ import { statSync } from 'node:fs';
2
+ import { rawContentCacheSet } from './cache-layers.mjs';
3
+ import { sameFileVersion } from './file-version.mjs';
4
+
5
+ // `before` is an owned version captured BEFORE the fresh read. A failed
6
+ // post-read stat is uncertainty, never evidence that the bytes are stable.
7
+ // Keep the current invocation's observation; do not retry or relabel it.
8
+ export function publishRawContentAfterRead(fullPath, before, rawBuf, readStat = statSync) {
9
+ if (!before || !Buffer.isBuffer(rawBuf) || rawBuf.length !== before.size) return;
10
+ let after;
11
+ try {
12
+ after = readStat(fullPath);
13
+ } catch {
14
+ return;
15
+ }
16
+ if (sameFileVersion(before, after)) rawContentCacheSet(fullPath, before, rawBuf);
17
+ }
@@ -1,4 +1,6 @@
1
1
  import { mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
3
+ import { fileVersion, sameFileVersion } from './file-version.mjs';
2
4
  import { hashText as sha256Hex } from './hash-utils.mjs';
3
5
  import { join, normalize, resolve } from 'node:path';
4
6
  import { getPluginData } from '../../config.mjs';
@@ -11,7 +13,10 @@ const READ_RANGE_INDEX_MAX_PERSISTED_ANCHORS = 8192;
11
13
  const READ_RANGE_INDEX_PERSIST_DEBOUNCE_MS = 750;
12
14
  const READ_RANGE_INDEX_SWEEP_CONCURRENCY = 8;
13
15
 
16
+ // Slots own publication rights; readers only mutate private drafts. Deleting
17
+ // or replacing a slot detaches outstanding lookups/scans without a tombstone log.
14
18
  const READ_RANGE_INDEX_CACHE = new Map();
19
+ const READ_RANGE_INDEX_DRAFTS = new WeakMap();
15
20
  const READ_RANGE_INDEX_PERSIST_PENDING = new Map();
16
21
  let readRangeIndexDiskSwept = false;
17
22
 
@@ -43,7 +48,7 @@ function readRangeIndexFilePath(fullPath) {
43
48
  }
44
49
 
45
50
  function serialiseReadRangeIndex(index) {
46
- if (!index?.fullPath || !index.anchors) return null;
51
+ if (!index?.published || !sameFileVersion(index.fileVersion, index.fileVersion)) return null;
47
52
  const anchors = [...index.anchors.entries()]
48
53
  .filter(
49
54
  ([line, byteOffset]) => Number.isFinite(line) && Number.isFinite(byteOffset) && line >= 0 && byteOffset >= 0
@@ -52,25 +57,22 @@ function serialiseReadRangeIndex(index) {
52
57
  .slice(-READ_RANGE_INDEX_MAX_PERSISTED_ANCHORS);
53
58
  if (!anchors.some(([line]) => line === 0)) anchors.unshift([0, 0]);
54
59
  return {
55
- version: 1,
60
+ version: 2,
56
61
  path: index.fullPath,
57
- size: index.size,
58
- mtimeMs: index.mtimeMs,
62
+ fileVersion: index.fileVersion,
59
63
  prefixHash: index.prefixHash || '',
60
64
  totalLines: Number.isFinite(index.totalLines) ? index.totalLines : undefined,
61
65
  anchors,
62
66
  };
63
67
  }
64
68
 
65
- function readRangeIndexMatches(row, fullPath, st) {
69
+ function readRangeIndexMatches(row, fullPath, version) {
70
+ // Version-1 rows lack replacement identity. Never upgrade their anchors by
71
+ // attaching today's metadata; rebuild them from current bytes instead.
66
72
  return (
67
- row &&
68
- row.version === 1 &&
73
+ row?.version === 2 &&
69
74
  row.path === fullPath &&
70
- typeof row.size === 'number' &&
71
- row.size === st.size &&
72
- Number.isFinite(row.mtimeMs) &&
73
- Math.abs(row.mtimeMs - st.mtimeMs) <= 1 &&
75
+ sameFileVersion(row.fileVersion, version) &&
74
76
  Array.isArray(row.anchors)
75
77
  );
76
78
  }
@@ -123,21 +125,10 @@ async function loadReadRangeIndexFromDisk(fullPath, st, handle, prefixBuffer) {
123
125
  try {
124
126
  const row = JSON.parse(await readFile(file, 'utf-8'));
125
127
  if (!readRangeIndexMatches(row, fullPath, st)) return null;
126
- // Anchors are byte offsets into the file. A same-size / same-mtime
127
- // rewrite (touch-restore, in-place edit of equal-length content)
128
- // leaves the stat gate above happy but invalidates every persisted
129
- // byte offset, causing seeks to land on the wrong line. When the
130
- // persisted index carries a prefixHash, the current file's prefix
131
- // must match; on mismatch drop the on-disk row so the caller
132
- // rebuilds from scratch.
128
+ // The prefix is an extra check, not proof that later byte offsets survived.
133
129
  if (typeof row.prefixHash === 'string' && row.prefixHash) {
134
130
  const cur = await computePrefixHashForIndex(fullPath, st, handle, prefixBuffer);
135
- if (!cur || cur !== row.prefixHash) {
136
- try {
137
- await unlink(file);
138
- } catch {}
139
- return null;
140
- }
131
+ if (!cur || cur !== row.prefixHash) return null;
141
132
  }
142
133
  const anchors = new Map([[0, 0]]);
143
134
  for (const entry of row.anchors) {
@@ -149,8 +140,7 @@ async function loadReadRangeIndexFromDisk(fullPath, st, handle, prefixBuffer) {
149
140
  }
150
141
  const loaded = {
151
142
  fullPath,
152
- size: st.size,
153
- mtimeMs: st.mtimeMs,
143
+ fileVersion: fileVersion(st),
154
144
  prefixHash: typeof row.prefixHash === 'string' ? row.prefixHash : '',
155
145
  anchors,
156
146
  };
@@ -166,36 +156,41 @@ async function loadReadRangeIndexFromDisk(fullPath, st, handle, prefixBuffer) {
166
156
  }
167
157
  }
168
158
 
159
+ function currentReadRangeIndex(index) {
160
+ return !!index?.published && READ_RANGE_INDEX_CACHE.get(canonicalCachePath(index.fullPath)) === index;
161
+ }
162
+
169
163
  function persistReadRangeIndexSync(index) {
170
164
  const file = readRangeIndexFilePath(index?.fullPath);
171
- if (!file) return;
172
- const row = serialiseReadRangeIndex(index);
173
- if (!row) return;
165
+ if (!file || !currentReadRangeIndex(index)) return;
174
166
  try {
175
- writeJsonAtomicSync(file, row, { compact: true, lock: true, fsync: false });
167
+ if (!sameFileVersion(index.fileVersion, statSync(index.fullPath))) return;
168
+ const row = serialiseReadRangeIndex(index);
169
+ if (row) writeJsonAtomicSync(file, row, { compact: true, lock: true, fsync: false });
176
170
  } catch {}
177
171
  }
178
172
 
179
- async function persistReadRangeIndex(index) {
173
+ async function persistReadRangeIndex(index, evictedRow = null) {
180
174
  const file = readRangeIndexFilePath(index?.fullPath);
181
- if (!file) return;
182
- const row = serialiseReadRangeIndex(index);
183
- if (!row) return;
175
+ if (!file || (!evictedRow && !currentReadRangeIndex(index))) return;
184
176
  try {
177
+ if (!sameFileVersion(evictedRow?.fileVersion || index.fileVersion, await stat(index.fullPath))) return;
185
178
  await mkdir(READ_RANGE_INDEX_DISK_DIR, { recursive: true });
186
- await writeJsonAtomicAsync(file, row, { compact: true, lock: true, fsync: false });
179
+ if (!evictedRow && !currentReadRangeIndex(index)) return;
180
+ const row = evictedRow || serialiseReadRangeIndex(index);
181
+ if (row) await writeJsonAtomicAsync(file, row, { compact: true, lock: true, fsync: false });
187
182
  } catch {}
188
183
  }
189
184
 
190
185
  export function scheduleReadRangeIndexPersist(index) {
191
- if (!READ_RANGE_INDEX_DISK_DIR || !index?.fullPath) return;
186
+ if (!READ_RANGE_INDEX_DISK_DIR || !currentReadRangeIndex(index)) return;
192
187
  const key = canonicalCachePath(index.fullPath);
193
188
  if (READ_RANGE_INDEX_PERSIST_PENDING.has(key)) return;
194
189
  const t = setTimeout(() => {
195
190
  READ_RANGE_INDEX_PERSIST_PENDING.delete(key);
196
191
  void persistReadRangeIndex(index);
197
192
  }, READ_RANGE_INDEX_PERSIST_DEBOUNCE_MS);
198
- if (t.unref) t.unref();
193
+ t.unref();
199
194
  READ_RANGE_INDEX_PERSIST_PENDING.set(key, t);
200
195
  }
201
196
 
@@ -210,16 +205,14 @@ export function flushReadRangeIndexesSync() {
210
205
  }
211
206
  }
212
207
 
213
- export function deleteReadRangeIndexForPath(fullPath) {
214
- const key = canonicalCachePath(fullPath);
208
+ function dropReadRangeIndexSlot(key) {
215
209
  READ_RANGE_INDEX_CACHE.delete(key);
216
- const timer = READ_RANGE_INDEX_PERSIST_PENDING.get(key);
217
- if (timer) {
218
- try {
219
- clearTimeout(timer);
220
- } catch {}
221
- READ_RANGE_INDEX_PERSIST_PENDING.delete(key);
222
- }
210
+ clearTimeout(READ_RANGE_INDEX_PERSIST_PENDING.get(key));
211
+ READ_RANGE_INDEX_PERSIST_PENDING.delete(key);
212
+ }
213
+
214
+ export function deleteReadRangeIndexForPath(fullPath) {
215
+ dropReadRangeIndexSlot(canonicalCachePath(fullPath));
223
216
  const file = readRangeIndexFilePath(fullPath);
224
217
  if (file) void unlink(file).catch(() => {});
225
218
  }
@@ -248,46 +241,114 @@ export async function sweepStaleReadRangeIndexes(directory = READ_RANGE_INDEX_DI
248
241
  await Promise.all(Array.from({ length: Math.min(READ_RANGE_INDEX_SWEEP_CONCURRENCY, entries.length) }, worker));
249
242
  }
250
243
 
251
- // Re-insert so the entry moves to the LRU tail, then hand it back.
244
+ // Re-insert so the entry moves to the LRU tail. All insertion paths obey the
245
+ // same cap, including disk hits. Eviction also revokes unfinished readers.
252
246
  function refreshReadRangeIndexSlot(key, index) {
253
247
  READ_RANGE_INDEX_CACHE.delete(key);
254
248
  READ_RANGE_INDEX_CACHE.set(key, index);
249
+ while (READ_RANGE_INDEX_CACHE.size > READ_RANGE_INDEX_MAX_ENTRIES) {
250
+ const oldest = READ_RANGE_INDEX_CACHE.keys().next().value;
251
+ const evicted = READ_RANGE_INDEX_CACHE.get(oldest);
252
+ // Preserve only committed anchors before cancelling the debounce. The
253
+ // owned row may finish saving after eviction, but outstanding drafts lose
254
+ // their slot immediately. Disk IO must not delay the foreground lookup.
255
+ const row = serialiseReadRangeIndex(evicted);
256
+ dropReadRangeIndexSlot(oldest);
257
+ if (row) void persistReadRangeIndex(evicted, row);
258
+ }
255
259
  return index;
256
260
  }
257
261
 
258
- // A supplied handle is borrowed; validation never changes its position or closes it.
262
+ function freshReadRangeIndex(fullPath, version = null) {
263
+ return { fullPath, fileVersion: version, prefixHash: '', anchors: new Map([[0, 0]]), published: false };
264
+ }
265
+
266
+ // A supplied handle is borrowed; validation uses positional IO and never closes
267
+ // it. The stat argument is only a hint for a previously read prefix, not proof
268
+ // of the current handle/path version. A cache miss always starts at byte zero.
259
269
  export async function getReadRangeIndex(fullPath, st, handle = null, prefixBuffer = null) {
260
- if (!st) return null;
261
270
  const key = canonicalCachePath(fullPath);
262
- const cached = READ_RANGE_INDEX_CACHE.get(key);
263
- if (cached && cached.size === st.size && Math.abs(cached.mtimeMs - st.mtimeMs) <= 1) {
264
- // In-memory anchors share the same byte-offset hazard as the
265
- // on-disk row: a same-size / same-mtime overwrite would otherwise
266
- // be reused with the wrong offsets. When a prefixHash was recorded
267
- // for the cached index, verify it before refreshing the LRU slot;
268
- // on mismatch evict and fall through to disk-load / fresh rebuild.
269
- // When prefixHash is empty (e.g. a freshly-created index that no
270
- // streaming read has populated yet) keep the prior stat-only
271
- // behavior so genuinely unchanged files are not re-walked.
272
- if (!cached.prefixHash) return refreshReadRangeIndexSlot(key, cached);
273
- const cur = await computePrefixHashForIndex(fullPath, st, handle, prefixBuffer);
274
- if (cur && cur === cached.prefixHash) return refreshReadRangeIndexSlot(key, cached);
275
- READ_RANGE_INDEX_CACHE.delete(key);
271
+ let slot = READ_RANGE_INDEX_CACHE.get(key);
272
+ if (!slot) slot = refreshReadRangeIndexSlot(key, freshReadRangeIndex(fullPath));
273
+ const hint = fileVersion(st);
274
+ const readStat = () => (handle ? handle.stat() : stat(fullPath));
275
+ let version;
276
+ try {
277
+ const observed = await readStat();
278
+ if (!observed.isFile()) return null;
279
+ version = fileVersion(observed);
280
+ } catch {
281
+ return null;
276
282
  }
277
- const loaded = await loadReadRangeIndexFromDisk(fullPath, st, handle, prefixBuffer);
278
- if (loaded) {
279
- READ_RANGE_INDEX_CACHE.set(key, loaded);
280
- return loaded;
283
+ if (!version || READ_RANGE_INDEX_CACHE.get(key) !== slot) return null;
284
+ version = Object.freeze(version);
285
+ if (!slot.fileVersion) slot.fileVersion = version;
286
+ if (!sameFileVersion(slot.fileVersion, version)) {
287
+ dropReadRangeIndexSlot(key);
288
+ slot = refreshReadRangeIndexSlot(key, freshReadRangeIndex(fullPath, version));
281
289
  }
282
- const fresh = { fullPath, size: st.size, mtimeMs: st.mtimeMs, prefixHash: '', anchors: new Map([[0, 0]]) };
283
- READ_RANGE_INDEX_CACHE.set(key, fresh);
284
- while (READ_RANGE_INDEX_CACHE.size > READ_RANGE_INDEX_MAX_ENTRIES) {
285
- const oldest = READ_RANGE_INDEX_CACHE.keys().next().value;
286
- const oldIndex = READ_RANGE_INDEX_CACHE.get(oldest);
287
- if (oldIndex) void persistReadRangeIndex(oldIndex);
288
- READ_RANGE_INDEX_CACHE.delete(oldest);
290
+ const prefix = sameFileVersion(hint, version) ? prefixBuffer : null;
291
+ let source = slot;
292
+ if (slot.published && slot.prefixHash) {
293
+ const cur = await computePrefixHashForIndex(fullPath, version, handle, prefix);
294
+ if (READ_RANGE_INDEX_CACHE.get(key) !== slot) return null;
295
+ if (!cur || cur !== slot.prefixHash) {
296
+ dropReadRangeIndexSlot(key);
297
+ slot = refreshReadRangeIndexSlot(key, freshReadRangeIndex(fullPath, version));
298
+ source = slot;
299
+ }
300
+ }
301
+ if (!slot.published) source = (await loadReadRangeIndexFromDisk(fullPath, version, handle, prefix)) || slot;
302
+ // Neither a delayed disk lookup nor a changed source can authorize offsets.
303
+ try {
304
+ if (!sameFileVersion(version, await readStat())) return null;
305
+ } catch {
306
+ return null;
307
+ }
308
+ if (READ_RANGE_INDEX_CACHE.get(key) !== slot) return null;
309
+ refreshReadRangeIndexSlot(key, slot);
310
+ const draft = {
311
+ fullPath,
312
+ fileVersion: version,
313
+ prefixHash: source.prefixHash,
314
+ anchors: new Map(source.anchors),
315
+ ...(Number.isFinite(source.totalLines) ? { totalLines: source.totalLines } : {}),
316
+ };
317
+ READ_RANGE_INDEX_DRAFTS.set(draft, { slot, version });
318
+ return draft;
319
+ }
320
+
321
+ // Successful readers publish once, before closing their handle. A failed,
322
+ // cancelled or detached scan keeps its observation private. Post-stat failures
323
+ // are cache misses, not reasons to retry the operation or relabel its anchors.
324
+ export async function publishReadRangeIndex(index, handle, signal = null, beforePublish = null) {
325
+ const owned = READ_RANGE_INDEX_DRAFTS.get(index);
326
+ if (!owned) return false;
327
+ READ_RANGE_INDEX_DRAFTS.delete(index);
328
+ const { slot, version } = owned;
329
+ const key = canonicalCachePath(slot.fullPath);
330
+ if (signal?.aborted || READ_RANGE_INDEX_CACHE.get(key) !== slot) return false;
331
+ try {
332
+ if (!sameFileVersion(version, await handle.stat()) || !sameFileVersion(version, await stat(slot.fullPath)))
333
+ return false;
334
+ } catch {
335
+ return false;
289
336
  }
290
- return fresh;
337
+ if (signal?.aborted || READ_RANGE_INDEX_CACHE.get(key) !== slot) return false;
338
+ if (slot.prefixHash && index.prefixHash && slot.prefixHash !== index.prefixHash) return false;
339
+ // The reader's synchronous deadline/cancellation decision belongs after all
340
+ // awaited validation and outside its best-effort stat catch. Once committed,
341
+ // the caller must not make a second deadline decision for this scan.
342
+ beforePublish?.();
343
+ // Same-version concurrent windows may contribute disjoint anchors. No draft
344
+ // shares this map, including the one whose successful read is being committed.
345
+ slot.anchors = new Map([...slot.anchors, ...index.anchors]);
346
+ slot.prefixHash = index.prefixHash || slot.prefixHash;
347
+ if (Number.isFinite(index.totalLines)) slot.totalLines = index.totalLines;
348
+ slot.published = true;
349
+ refreshReadRangeIndexSlot(key, slot);
350
+ scheduleReadRangeIndexPersist(slot);
351
+ return true;
291
352
  }
292
353
 
293
354
  export function nearestReadRangeAnchor(index, offset) {
@@ -309,6 +370,5 @@ export function maybeRecordReadRangeAnchor(index, line, byteOffset) {
309
370
  if (line === 0 || line % READ_RANGE_INDEX_STRIDE_LINES === 0) {
310
371
  if (index.anchors.get(line) === byteOffset) return;
311
372
  index.anchors.set(line, byteOffset);
312
- scheduleReadRangeIndexPersist(index);
313
373
  }
314
374
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as fsPromises from 'node:fs/promises';
8
8
  import { readFile } from 'node:fs/promises';
9
+ import { sameFileVersion } from './file-version.mjs';
9
10
  import { READ_PREFIX_HASH_BYTES } from './read-single-fast-paths.mjs';
10
11
  import { decodeUtf16Body, isUtf16Encoding } from './snapshot-helpers.mjs';
11
12
 
@@ -174,27 +175,24 @@ export async function bufferedReadResult(ctx, { prefetched, readEnc }, helpers)
174
175
  const { _cacheSet, _rawContentCacheSet, _recordReadSnapshot } = helpers;
175
176
  const rawBuf = prefetched.buf || (await readFile(fullPath));
176
177
  const content = decodeReadBuffer(rawBuf, readEnc);
177
- // W1 M: re-stat after the async readFile so a concurrent Write that landed
178
- // during the read is detected before the cache + snapshot record stale
179
- // bytes. A raw-cache hit was already validated against `st`.
180
- let st = ctx.st;
181
- let readStableForRawCache = true;
178
+ // A cache hit was checked against this exact version. Fresh IO must
179
+ // also agree after reading, including ctime and replacement identity.
180
+ // Never relabel observed bytes with a later stat or certify a failed stat.
181
+ const st = ctx.st;
182
+ let readStable = sameFileVersion(st, st) && rawBuf.length === st.size;
182
183
  if (!prefetched.fromCache) {
183
- let stPostRead;
184
184
  try {
185
- stPostRead = await fsPromises.stat(fullPath);
185
+ readStable = readStable && sameFileVersion(st, await fsPromises.stat(fullPath));
186
186
  } catch {
187
- stPostRead = st;
188
- }
189
- if (stPostRead.mtimeMs !== st.mtimeMs || stPostRead.size !== st.size) {
190
- st = stPostRead;
191
- readStableForRawCache = false;
187
+ readStable = false;
192
188
  }
193
189
  }
194
- const render = renderReadWindow(content, { ...ctx, st }, helpers);
195
- const { snapshotMeta, contentPrefixHash } = bufferedReadSnapshotMeta(content, render, ctx, helpers);
196
- _cacheSet(cacheKey, render.out, { paths: [fullPath], readSnapshotMeta: snapshotMeta, contentPrefixHash });
197
- if (readStableForRawCache) _rawContentCacheSet(fullPath, st, rawBuf);
198
- _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
190
+ const render = renderReadWindow(content, ctx, helpers);
191
+ if (readStable) {
192
+ const { snapshotMeta, contentPrefixHash } = bufferedReadSnapshotMeta(content, render, ctx, helpers);
193
+ _cacheSet(cacheKey, render.out, { paths: [fullPath], readSnapshotMeta: snapshotMeta, contentPrefixHash });
194
+ _rawContentCacheSet(fullPath, st, rawBuf);
195
+ _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
196
+ }
199
197
  return render.out;
200
198
  }
@@ -23,13 +23,15 @@ import { binaryPreviewResult, smartStreamResult, streamRangeResult } from './rea
23
23
  import { bufferedReadResult } from './read-single-render.mjs';
24
24
 
25
25
  // Buffered reads take the whole body up front: the raw-content cache first,
26
- // else one readFile shared with any in-flight read of the same path.
26
+ // else one readFile shared with in-flight reads of the same path AND version.
27
27
  async function prefetchRawBody(fullPath, st, helpers) {
28
28
  const { _rawContentCacheGet, _runRawContentInFlight } = helpers;
29
29
  const cachedRaw = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
30
30
  if (cachedRaw) return { buf: cachedRaw, fromCache: true };
31
31
  try {
32
- const buf = _runRawContentInFlight ? await _runRawContentInFlight(fullPath, readFile) : await readFile(fullPath);
32
+ const buf = _runRawContentInFlight
33
+ ? await _runRawContentInFlight(fullPath, readFile, st)
34
+ : await readFile(fullPath);
33
35
  return { buf, fromCache: false };
34
36
  } catch {
35
37
  return { buf: null, fromCache: false };
@@ -8,7 +8,9 @@ import {
8
8
  statMatchesSnapshot,
9
9
  decodeRawBufferForSnapshotCheck,
10
10
  } from './snapshot-helpers.mjs';
11
- import { rawContentCacheGet, rawContentCacheSet } from './cache-layers.mjs';
11
+ import { rawContentCacheGet } from './cache-layers.mjs';
12
+ import { fileVersion } from './file-version.mjs';
13
+ import { publishRawContentAfterRead } from './raw-content-publication.mjs';
12
14
  import { rememberReadSnapshot, readFilesForScope, readScopeKey, scheduleScopePersist } from './snapshot-store.mjs';
13
15
  import {
14
16
  isSnapshotStale as isSnapshotStaleImpl,
@@ -38,25 +40,24 @@ export function readTextForSnapshotCheck(fullPath, cache = null, st = null) {
38
40
  return cache.content;
39
41
  }
40
42
  const cachedRaw = getCachedRaw();
43
+ const before = fileVersion(statForRawCache);
41
44
  const rawBuf = cachedRaw || readFileSync(fullPath);
42
45
  const content = decodeRawBufferForSnapshotCheck(rawBuf);
43
46
  if (cache) {
44
47
  cache.rawBuf = rawBuf;
45
48
  cache.content = content;
46
49
  }
47
- if (!cachedRaw && statForRawCache) rawContentCacheSet(fullPath, statForRawCache, rawBuf);
50
+ if (!cachedRaw) publishRawContentAfterRead(fullPath, before, rawBuf);
48
51
  return content;
49
52
  }
50
53
 
51
- // The stat identity a snapshot is keyed on: the caller's stat when it has
52
- // one, else a fresh stat; an unstattable path gets a never-matching identity.
54
+ // Own the observed version. Supplied incomplete evidence must not be
55
+ // replaced by a later stat, which could relabel previously observed bytes.
53
56
  function snapshotStatIdentity(fullPath, st) {
54
57
  try {
55
- const source = st && typeof st.mtimeMs === 'number' ? st : statSync(fullPath);
56
- return { mtimeMs: source.mtimeMs, ctimeMs: source.ctimeMs, size: source.size };
58
+ return fileVersion(st ?? statSync(fullPath));
57
59
  } catch {
58
- const now = Date.now();
59
- return { mtimeMs: now, ctimeMs: now, size: 0 };
60
+ return null;
60
61
  }
61
62
  }
62
63
 
@@ -119,7 +120,7 @@ function collectRangeHashRows(existing, sameFile, meta) {
119
120
  export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
120
121
  const readFiles = readFilesForScope(scope);
121
122
  const identity = snapshotStatIdentity(fullPath, st);
122
- const { mtimeMs, ctimeMs, size } = identity;
123
+ const { mtimeMs, ctimeMs, size, ino, dev } = identity || {};
123
124
  const incomingRanges = Array.isArray(meta.ranges) ? meta.ranges : [{ startLine: 1, endLine: Infinity }];
124
125
  const replaceExisting = meta.replaceExisting === true;
125
126
  const existing = replaceExisting ? null : readFiles.get(fullPath);
@@ -146,7 +147,7 @@ export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
146
147
  preMutationStat: _omitPreMutationStat,
147
148
  ...restMeta
148
149
  } = meta;
149
- const next = { ...restMeta, mtimeMs, ctimeMs, size, ranges: merged };
150
+ const next = { ...restMeta, mtimeMs, ctimeMs, size, ino, dev, ranges: merged };
150
151
  if (!next.contentHash && sameFile && existing.contentHash) {
151
152
  next.contentHash = existing.contentHash;
152
153
  }
@@ -6,7 +6,7 @@ import {
6
6
  getReadRangeIndex,
7
7
  nearestReadRangeAnchor,
8
8
  maybeRecordReadRangeAnchor,
9
- scheduleReadRangeIndexPersist,
9
+ publishReadRangeIndex,
10
10
  } from './read-range-index.mjs';
11
11
  import { READ_MAX_SCAN_BYTES, READ_STREAM_TIMEOUT_MS } from './read-constants.mjs';
12
12
 
@@ -74,9 +74,8 @@ async function scanSourceWindows(path, intervals, signal) {
74
74
  }
75
75
  scanned += bytesRead;
76
76
  if (scanned > READ_MAX_SCAN_BYTES) throw new Error('source context scan exceeds byte budget');
77
- if (position === 0 && !index.prefixHash) {
77
+ if (position === 0 && index && !index.prefixHash) {
78
78
  index.prefixHash = hashText(buffer.subarray(0, bytesRead));
79
- scheduleReadRangeIndexPersist(index);
80
79
  }
81
80
  let start = 0;
82
81
  while (start < bytesRead && interval < intervals.length) {
@@ -93,6 +92,9 @@ async function scanSourceWindows(path, intervals, signal) {
93
92
  }
94
93
  position += bytesRead;
95
94
  }
95
+ // Publication checks after its async stats, immediately before committing.
96
+ // An unpublished result still owes a final deadline/cancellation check.
97
+ if (!(await publishReadRangeIndex(index, handle, signal, check))) check();
96
98
  return lines;
97
99
  } finally {
98
100
  await handle.close();