mixdog 1.1.2 → 1.2.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 (288) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +6 -0
  7. package/src/defaults/skills/setup/references/actions.md +5 -2
  8. package/src/defaults/skills/visualize/SKILL.md +157 -0
  9. package/src/defaults/skills/visualize/references/craft.md +88 -0
  10. package/src/headless-command.mjs +56 -3
  11. package/src/headless-exec.mjs +17 -1
  12. package/src/help.mjs +2 -0
  13. package/src/rules/shared/70-delivery.md +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  18. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  19. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/config.mjs +9 -1
  21. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
  22. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
  23. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
  24. package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
  25. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
  29. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
  33. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  34. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  37. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  39. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  40. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
  41. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
  42. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  43. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  44. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  45. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  53. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
  54. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
  56. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  57. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  58. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  60. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
  62. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  63. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  64. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  65. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  66. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  69. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
  70. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
  71. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
  76. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  78. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  79. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  80. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  81. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  82. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  83. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  87. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  88. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
  89. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  90. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  91. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  92. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
  93. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
  94. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
  95. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  96. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
  97. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  100. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
  103. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  114. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  115. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  116. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  119. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  120. package/src/runtime/attachments/store.mjs +38 -0
  121. package/src/runtime/shared/atomic-file.mjs +8 -2
  122. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  123. package/src/runtime/shared/code-graph-state.mjs +12 -0
  124. package/src/runtime/shared/defaults-separation.mjs +2 -0
  125. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  126. package/src/runtime/shared/git-runner.mjs +4 -3
  127. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  128. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  129. package/src/runtime/shared/llm/model-catalog.mjs +240 -118
  130. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  131. package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
  132. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  133. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  134. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  135. package/src/runtime/shared/provider-accounts.mjs +2 -2
  136. package/src/runtime/shared/provider-api-key.mjs +21 -1
  137. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  138. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  139. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  140. package/src/runtime/shared/singleton-owner.mjs +36 -6
  141. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  142. package/src/runtime/shared/tool-surface.mjs +7 -0
  143. package/src/runtime/shared/tool-work-units.mjs +14 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  145. package/src/runtime/shared/turn-changes.mjs +791 -0
  146. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  147. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  148. package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
  149. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  150. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  151. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  152. package/src/runtime/shared/worktrees/git.mjs +247 -0
  153. package/src/runtime/shared/worktrees/include.mjs +480 -0
  154. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  155. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  156. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  157. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  158. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  159. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  160. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  161. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  162. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  163. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  164. package/src/runtime/visualize-bridge/client.mjs +181 -0
  165. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  166. package/src/session-runtime/boot/apis.mjs +35 -0
  167. package/src/session-runtime/boot/config.mjs +2 -0
  168. package/src/session-runtime/boot/facade.mjs +20 -0
  169. package/src/session-runtime/boot/infrastructure.mjs +1 -0
  170. package/src/session-runtime/boot/session.mjs +1 -0
  171. package/src/session-runtime/boot/tools.mjs +4 -0
  172. package/src/session-runtime/config-lifecycle.mjs +10 -1
  173. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
  174. package/src/session-runtime/global-extensions.mjs +2 -2
  175. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  176. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  177. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  178. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  179. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  180. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  181. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  182. package/src/session-runtime/mcp-glue/connect.mjs +11 -6
  183. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
  184. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  185. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  186. package/src/session-runtime/model-route/set-route.mjs +15 -2
  187. package/src/session-runtime/notification-bus.mjs +9 -1
  188. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  189. package/src/session-runtime/resource-extension-sync.mjs +30 -7
  190. package/src/session-runtime/resource-mcp-api.mjs +20 -7
  191. package/src/session-runtime/route-resolution.mjs +10 -1
  192. package/src/session-runtime/route-state.mjs +16 -2
  193. package/src/session-runtime/runtime-core.mjs +5 -0
  194. package/src/session-runtime/runtime-facade.mjs +8 -0
  195. package/src/session-runtime/runtime-review-api.mjs +9 -0
  196. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  197. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  198. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  199. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  200. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  201. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  202. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  203. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  204. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  205. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  206. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  207. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  208. package/src/session-runtime/services/agent-tool.mjs +1 -0
  209. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  210. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  211. package/src/session-runtime/session-create/session-options.mjs +10 -1
  212. package/src/session-runtime/session-turn-api.mjs +1 -0
  213. package/src/session-runtime/settings-api.mjs +4 -2
  214. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  215. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  216. package/src/session-runtime/setup-tool/executor.mjs +11 -4
  217. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
  218. package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
  219. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  220. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
  221. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  222. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  223. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  224. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  225. package/src/session-runtime/turn/turn-run.mjs +6 -7
  226. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  227. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  228. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  229. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  230. package/src/session-runtime/workspace/transitions.mjs +651 -0
  231. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  232. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  233. package/src/standalone/channel-transport.mjs +13 -1
  234. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  235. package/src/standalone/daemon-owner.mjs +372 -0
  236. package/src/standalone/daemon-stored-session-views.mjs +4 -1
  237. package/src/standalone/daemon.mjs +139 -5
  238. package/src/standalone/desktop-service-registry.mjs +20 -0
  239. package/src/standalone/local-session-runtime.mjs +11 -0
  240. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  241. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  242. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  243. package/src/standalone/session-client.mjs +104 -13
  244. package/src/standalone/session-protocol.mjs +16 -0
  245. package/src/standalone/session-proxy.mjs +3 -0
  246. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  247. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  248. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  249. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  250. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  251. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  252. package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
  253. package/src/standalone/session-transport/call-route.mjs +13 -0
  254. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  255. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  256. package/src/standalone/session-transport/client-registry.mjs +20 -3
  257. package/src/standalone/session-transport/routes.mjs +79 -2
  258. package/src/standalone/session-transport/stack.mjs +2 -0
  259. package/src/standalone/session-transport.mjs +24 -1
  260. package/src/tui/app/message-selector.mjs +2 -1
  261. package/src/tui/app/model-options.mjs +35 -5
  262. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  263. package/src/tui/app/model-picker.mjs +4 -2
  264. package/src/tui/dist/index.mjs +332 -36
  265. package/src/tui/index.jsx +10 -2
  266. package/src/tui/session/compaction-view.mjs +147 -0
  267. package/src/tui/session/context-state.mjs +6 -0
  268. package/src/tui/session/initial-state.mjs +3 -0
  269. package/src/tui/session/quota-resume.mjs +2 -1
  270. package/src/tui/session/session-api/commands.mjs +10 -2
  271. package/src/tui/session/session-api/controls.mjs +4 -0
  272. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  273. package/src/tui/session/session-api/integrations.mjs +17 -0
  274. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  275. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  276. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  277. package/src/tui/session/session-api/routes.mjs +10 -1
  278. package/src/tui/session/session-api/settings.mjs +2 -0
  279. package/src/tui/session/session-api/worktree.mjs +60 -0
  280. package/src/tui/session/session-api.mjs +2 -0
  281. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  282. package/src/tui/session/session-flow/drain.mjs +15 -1
  283. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  284. package/src/tui/session/session-flow.mjs +2 -0
  285. package/src/tui/session/transcript-intake.mjs +14 -5
  286. package/src/tui/session/turn-stream.mjs +32 -4
  287. package/src/tui/session/turn-tool-cards.mjs +10 -2
  288. package/src/tui/session/turn.mjs +39 -10
@@ -7,10 +7,11 @@
7
7
  */
8
8
  import * as fsPromises from 'node:fs/promises';
9
9
  import { readFile } from 'node:fs/promises';
10
+ import { invalidateBuiltinResultCache } from './cache-layers.mjs';
10
11
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
11
12
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
12
-
13
- export const READ_PREFIX_HASH_BYTES = 65536;
13
+ import { canCacheReadFile, READ_PREFIX_HASH_BYTES } from './read-cache-policy.mjs';
14
+ export { READ_PREFIX_HASH_BYTES } from './read-cache-policy.mjs';
14
15
 
15
16
  // A mutation snapshot claims the full file because it knows the bytes it
16
17
  // wrote; it proves the session holds the body only when bodyDelivered says so.
@@ -25,14 +26,12 @@ function unchangedStub(filePath, helpers) {
25
26
  }
26
27
 
27
28
  /**
28
- * Race-guard prefix hash. Coarse filesystem timestamps can collide across a
29
- * same-size rewrite even with ctime in the cache key, so every cache write
30
- * records the hash of the first 64KiB (the whole body when it fits) and the
31
- * next hit recomputes it before trusting the entry. '' on any failure.
29
+ * Bounded raw-byte hash. Content caches only admit files that fit this
30
+ * budget, so validation covers their entire body. '' on any failure.
32
31
  */
33
32
  export async function readPrefixHash(fullPath, st, hashText) {
34
33
  try {
35
- if (st.size <= READ_PREFIX_HASH_BYTES) return hashText(await readFile(fullPath, 'utf-8'));
34
+ if (st.size <= READ_PREFIX_HASH_BYTES) return hashText(await readFile(fullPath));
36
35
  const fh = await fsPromises.open(fullPath, 'r');
37
36
  try {
38
37
  const buf = Buffer.allocUnsafe(READ_PREFIX_HASH_BYTES);
@@ -49,15 +48,15 @@ export async function readPrefixHash(fullPath, st, hashText) {
49
48
  /**
50
49
  * A successful edit/apply_patch leaves a session-scoped full-file snapshot.
51
50
  * The model already knows the resulting body from the prior body plus its
52
- * mutation, so a follow-up Read needs only the stat tuple to prove that no
53
- * external write landed afterward — the file body is never reopened/hashed
54
- * merely to return the unchanged stub. `bodyDelivered` guards that the
51
+ * mutation. Keep the existing small-file stat fast path; a large file's
52
+ * matching tuple cannot exclude a deep external rewrite, so read it again.
53
+ * `bodyDelivered` guards that the
55
54
  * session actually received this file's body before the mutation: an
56
55
  * edit/patch on a file that was never read otherwise answered
57
56
  * "[file unchanged]" forever, with the body never delivered to the model.
58
57
  */
59
58
  export function mutationUnchangedStub({ fullPath, filePath, st, readStateScope, options }, helpers) {
60
- if (options?.suppressReadUnchangedStub === true) return null;
59
+ if (options?.suppressReadUnchangedStub === true || !canCacheReadFile(st)) return null;
61
60
  const snapshot = readStateScope ? getReadSnapshot(fullPath, readStateScope) : null;
62
61
  const source = String(snapshot?.source || '');
63
62
  if (
@@ -71,43 +70,41 @@ export function mutationUnchangedStub({ fullPath, filePath, st, readStateScope,
71
70
  return null;
72
71
  }
73
72
 
74
- // Single-pass cache-hit guard. The cache key already pins mtimeMs+ctimeMs+
75
- // size, so a hit can differ only when all metadata collides across a rewrite
76
- // — caught by re-hashing the on-disk body. ≤64KiB: one full-body read
77
- // validates whichever hash the entry carries (prefix == full at this size;
78
- // the exact contentHash is preferred). >64KiB: contentHash may still be
79
- // stored, but validating it would sha a multi-megabyte body on every check,
80
- // so only the 64KiB head prefix is checked — it catches same-mtime/same-size
81
- // rewrites within the first 64KiB (the common case); writes through
82
- // edit/apply_patch/write invalidate by path and shell mutationMode='global'
83
- // wipes the caches, bounding stale risk past the head.
73
+ // Only whole-body bounded validation can admit a result-cache hit.
74
+ // The decoded snapshot hash remains a separate contract, never a fallback
75
+ // after a raw-byte hash mismatch.
84
76
  async function cachedEntryStillValid(cachedEntry, fullPath, st, hashText) {
85
77
  const prefixHash = cachedEntry.contentPrefixHash;
86
78
  const snapHash = cachedEntry.readSnapshotMeta?.contentHash;
87
- if (!prefixHash && !snapHash) return true;
88
- if (st.size <= READ_PREFIX_HASH_BYTES) {
89
- try {
90
- const freshHash = hashText(await readFile(fullPath, 'utf-8'));
91
- return !!freshHash && freshHash === (snapHash || prefixHash);
92
- } catch {
93
- return false;
94
- }
79
+ if (prefixHash) {
80
+ const curHash = await readPrefixHash(fullPath, st, hashText);
81
+ return !!curHash && curHash === prefixHash;
82
+ }
83
+ if (!snapHash) return true;
84
+ try {
85
+ const freshHash = hashText(await readFile(fullPath, 'utf-8'));
86
+ return !!freshHash && freshHash === snapHash;
87
+ } catch {
88
+ return false;
95
89
  }
96
- if (!prefixHash) return true;
97
- const curHash = await readPrefixHash(fullPath, st, hashText);
98
- return !!curHash && curHash === prefixHash;
99
90
  }
100
91
 
101
92
  /**
102
93
  * Validated result-cache hit.
103
- * @returns {Promise<{ result: unknown } | null>} null when there is no entry
104
- * or the entry lost the race guard (fresh read follows).
94
+ * @returns {Promise<{ result: unknown } | { rejected: true } | null>}
95
+ * A rejected entry requires fresh IO even if an older reader republishes.
105
96
  */
106
97
  export async function readCachedResult({ cacheKey, fullPath, filePath, st, readStateScope, options }, helpers) {
98
+ if (!canCacheReadFile(st)) return null;
107
99
  const { _cacheGetEntry, _hashText, _recordReadSnapshot, classifyResultKind } = helpers;
108
100
  const cachedEntry = _cacheGetEntry(cacheKey);
109
101
  if (cachedEntry === null) return null;
110
- if (!(await cachedEntryStillValid(cachedEntry, fullPath, st, _hashText))) return null;
102
+ if (!(await cachedEntryStillValid(cachedEntry, fullPath, st, _hashText))) {
103
+ // The metadata no longer proves byte identity. Detach raw/in-flight
104
+ // reuse too, or the fresh-read fallback can return the rejected body.
105
+ invalidateBuiltinResultCache([fullPath]);
106
+ return { rejected: true };
107
+ }
111
108
  // Cross-session stub guard: RESULT_CACHE is process-global, so a cache hit
112
109
  // can be an entry SET BY ANOTHER SESSION whose body this conversation never
113
110
  // received. The stub assumes the full body is already in a prior
@@ -7,7 +7,7 @@
7
7
  import * as fsPromises from 'node:fs/promises';
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { sameFileVersion } from './file-version.mjs';
10
- import { READ_PREFIX_HASH_BYTES } from './read-single-fast-paths.mjs';
10
+ import { canCacheReadFile, READ_PREFIX_HASH_BYTES } from './read-cache-policy.mjs';
11
11
  import { decodeUtf16Body, isUtf16Encoding } from './snapshot-helpers.mjs';
12
12
 
13
13
  /**
@@ -133,13 +133,12 @@ function renderReadWindow(content, ctx, helpers) {
133
133
 
134
134
  /**
135
135
  * Describe exactly which lines the model saw. A full-file view pins the
136
- * whole body with one contentHash (also reused as the ≤64KiB race-guard
137
- * prefix hash — the same SHA-256 over the identical `content`); anything
136
+ * whole decoded body with one contentHash; anything
138
137
  * narrower records rangeHashes over the visible window so
139
138
  * snapshotCoversFullFile never green-lights an overwrite against bytes the
140
139
  * read never returned.
141
140
  */
142
- function bufferedReadSnapshotMeta(content, render, ctx, helpers) {
141
+ function bufferedReadSnapshotMeta(content, rawBuf, render, ctx, helpers) {
143
142
  const { offset, limit } = ctx;
144
143
  const { _hashText, _rangeHashesForReadRanges } = helpers;
145
144
  const { lineCount, renderedLineCount, smartTruncated, smartVisibleRanges, byteCapTruncated } = render;
@@ -158,11 +157,9 @@ function bufferedReadSnapshotMeta(content, render, ctx, helpers) {
158
157
  ...(isFullFileView ? { contentHash: fullContentHash } : {}),
159
158
  ...(rangeHashes.length > 0 ? { rangeHashes } : {}),
160
159
  };
161
- // For files ≤64KiB the prefix hash equals the full-content hash; otherwise
162
- // hash the 64KiB head (enough to detect a same-mtime / same-size rewrite of
163
- // any bytes within the first 64KiB — the common case).
164
- const head = content.length <= READ_PREFIX_HASH_BYTES ? content : content.slice(0, READ_PREFIX_HASH_BYTES);
165
- const contentPrefixHash = head === content && fullContentHash ? fullContentHash : _hashText(head);
160
+ // Cache validation hashes on-disk bytes, unlike the decoded snapshot.
161
+ // Use a byte boundary for every encoding, matching the streaming path.
162
+ const contentPrefixHash = canCacheReadFile(ctx.st) ? _hashText(rawBuf.subarray(0, READ_PREFIX_HASH_BYTES)) : '';
166
163
  return { snapshotMeta, contentPrefixHash };
167
164
  }
168
165
 
@@ -189,9 +186,11 @@ export async function bufferedReadResult(ctx, { prefetched, readEnc }, helpers)
189
186
  }
190
187
  const render = renderReadWindow(content, ctx, helpers);
191
188
  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);
189
+ const { snapshotMeta, contentPrefixHash } = bufferedReadSnapshotMeta(content, rawBuf, render, ctx, helpers);
190
+ if (canCacheReadFile(st)) {
191
+ _cacheSet(cacheKey, render.out, { paths: [fullPath], readSnapshotMeta: snapshotMeta, contentPrefixHash });
192
+ _rawContentCacheSet(fullPath, st, rawBuf);
193
+ }
195
194
  _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
196
195
  }
197
196
  return render.out;
@@ -6,23 +6,25 @@
6
6
  */
7
7
  import { formatBinaryReadPreviewFromBuffer } from './binary-file.mjs';
8
8
  import { readPrefixHash } from './read-single-fast-paths.mjs';
9
+ import { canCacheReadFile } from './read-cache-policy.mjs';
9
10
 
10
11
  export function binaryPreviewResult(ctx, preview, helpers) {
11
12
  const { fullPath, filePath, st, cacheKey, readStateScope } = ctx;
12
13
  const { normalizeOutputPath, _recordReadSnapshot, _cacheSet } = helpers;
13
14
  const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(preview, normalizeOutputPath(filePath), st.size);
14
15
  _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
15
- _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
16
+ if (canCacheReadFile(st)) _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
16
17
  return text;
17
18
  }
18
19
 
19
20
  async function commitStreamResult(ctx, out, snapshotMeta, prefixHash, helpers) {
20
21
  const { fullPath, st, cacheKey, readStateScope } = ctx;
21
22
  const { _cacheSet, _recordReadSnapshot, _hashText } = helpers;
22
- // Prefix hash for the race guard on the next cache hit; async so a 64KB
23
- // read never blocks the event loop on the large-file streaming path.
24
- const contentPrefixHash = prefixHash || (await readPrefixHash(fullPath, st, _hashText));
25
- _cacheSet(cacheKey, out, { paths: [fullPath], readSnapshotMeta: snapshotMeta, contentPrefixHash });
23
+ // Do not reopen a large file just to hash a prefix that cannot certify it.
24
+ if (canCacheReadFile(st)) {
25
+ const contentPrefixHash = prefixHash || (await readPrefixHash(fullPath, st, _hashText));
26
+ _cacheSet(cacheKey, out, { paths: [fullPath], readSnapshotMeta: snapshotMeta, contentPrefixHash });
27
+ }
26
28
  _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
27
29
  return out;
28
30
  }
@@ -50,7 +52,8 @@ export async function streamRangeResult(ctx, { source, fileHandle, prefixBuffer
50
52
  const ranges =
51
53
  res.firstEmitted && res.lastEmitted ? [{ startLine: res.firstEmitted, endLine: res.lastEmitted }] : [];
52
54
  const snapshotMeta = { source, ranges, rangeHashes: _rangeHashesFromRenderedReadText(res.text, ranges) };
53
- return await commitStreamResult(ctx, res.text, snapshotMeta, res.prefixHash, helpers);
55
+ if (!res.readSnapshotStat) return res.text;
56
+ return await commitStreamResult({ ...ctx, st: res.readSnapshotStat }, res.text, snapshotMeta, res.prefixHash, helpers);
54
57
  } catch (err) {
55
58
  return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
56
59
  }
@@ -21,15 +21,16 @@ import { mutationUnchangedStub, pathSnapshotUnchangedStub, readCachedResult } fr
21
21
  import { readMediaFile } from './read-single-media.mjs';
22
22
  import { binaryPreviewResult, smartStreamResult, streamRangeResult } from './read-single-stream.mjs';
23
23
  import { bufferedReadResult } from './read-single-render.mjs';
24
+ import { runReadCachePublication } from './cache-layers.mjs';
24
25
 
25
26
  // Buffered reads take the whole body up front: the raw-content cache first,
26
27
  // else one readFile shared with in-flight reads of the same path AND version.
27
- async function prefetchRawBody(fullPath, st, helpers) {
28
+ async function prefetchRawBody(fullPath, st, helpers, forceFresh = false) {
28
29
  const { _rawContentCacheGet, _runRawContentInFlight } = helpers;
29
- const cachedRaw = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
30
+ const cachedRaw = !forceFresh && _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
30
31
  if (cachedRaw) return { buf: cachedRaw, fromCache: true };
31
32
  try {
32
- const buf = _runRawContentInFlight
33
+ const buf = !forceFresh && _runRawContentInFlight
33
34
  ? await _runRawContentInFlight(fullPath, readFile, st)
34
35
  : await readFile(fullPath);
35
36
  return { buf, fromCache: false };
@@ -39,6 +40,12 @@ async function prefetchRawBody(fullPath, st, helpers) {
39
40
  }
40
41
 
41
42
  export async function executeSingleReadTool(args, workDir, readStateScope, options = {}, helpers = {}) {
43
+ // A coalesced batch must not publish its union window as delivered evidence.
44
+ // Its internal recorder holds the producer's lease until the caller slots
45
+ // are sliced; ordinary reads retain their existing snapshot path.
46
+ if (typeof options._readSnapshotRecorder === 'function') {
47
+ helpers = { ...helpers, _recordReadSnapshot: options._readSnapshotRecorder };
48
+ }
42
49
  const {
43
50
  parseLineLimitArg,
44
51
  parseOffsetArg,
@@ -64,69 +71,76 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
64
71
  const fullPath = resolveAgainstCwd(filePath, workDir);
65
72
  const resolvedRejected = readResolvedPathGuard(fullPath, helpers);
66
73
  if (resolvedRejected) return resolvedRejected;
67
- const hasOffsetArg = args.offset !== undefined && args.offset !== null;
68
- const hasLimitArg = args.limit !== undefined && args.limit !== null;
69
- const hasRangeArgs = hasOffsetArg || hasLimitArg;
70
- const wantFull = args.full === true;
71
- let offset = parseOffsetArg(args.offset);
72
- // full:true bypasses the default 2000-line cap so the whole file can be
73
- // returned in one call; the byte cap still emits a compact truncation
74
- // marker when rendered bytes overflow READ_MAX_OUTPUT_BYTES.
75
- let limit = parseLineLimitArg(args.limit, wantFull ? Infinity : 2000);
76
- let widenNote = '';
77
- const target = await statReadTarget(fullPath, filePath, options, helpers);
78
- if (target.error) return target.error;
79
- if (!target.st) return readNotFoundResponse(target.statErr, { fullPath, filePath, workDir }, helpers);
80
- const st = target.st;
81
- // Reactive widen + window-history bookkeeping (ranged text reads only).
82
- if (
83
- readStateScope &&
84
- typeof readStateScope === 'object' &&
85
- hasOffsetArg &&
86
- hasLimitArg &&
87
- Number.isFinite(limit) &&
88
- limit > 0
89
- ) {
90
- ({ offset, limit, widenNote } = widenReadWindow(readStateScope, fullPath, { offset, limit }));
91
- }
92
- const unchanged = mutationUnchangedStub({ fullPath, filePath, st, readStateScope, options }, helpers);
93
- if (unchanged) return unchanged;
94
- // MEDIA-WINS: media dispatch runs before every cache/snapshot fast path so
95
- // a stale text entry can never short-circuit a media read.
96
- const media = await readMediaFile({ fullPath, st, args, readStateScope, options, hasRangeArgs }, helpers);
97
- if (media) return media.result;
98
- const cacheKey = `read|${fullPath}|${st.mtimeMs}|${st.ctimeMs}|${st.size}|${hasOffsetArg ? offset : 'd'}|${hasLimitArg ? limit : 'd'}|${wantFull ? 'f' : 's'}|base:${readOffsetBase}|budget:${readMaxOutputBytes}`;
99
- const ctx = {
100
- fullPath,
101
- filePath,
102
- st,
103
- cacheKey,
104
- readStateScope,
105
- options,
106
- offset,
107
- limit,
108
- hasRangeArgs,
109
- wantFull,
110
- readMaxOutputBytes,
111
- readOffsetBase,
112
- widenNote,
113
- };
114
- const cached = await readCachedResult(ctx, helpers);
115
- if (cached) return cached.result;
116
- const pathStub = await pathSnapshotUnchangedStub(ctx, helpers);
117
- if (pathStub) return pathStub;
118
- // Pre-read size cap: a small error response beats 25K tokens of truncated
119
- // content (Anthropic #21841 reverted truncation). With offset/limit the
120
- // requested window streams instead; without range args the cap still
121
- // refuses so the small-file default path can't pull megabytes by accident.
122
- const preferRangeStream =
123
- hasRangeArgs && !wantFull && (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES);
124
- const preferSmartStream = !hasRangeArgs && !wantFull && st.size >= READ_SMART_STREAM_MIN_BYTES;
125
- const preferBufferedRead = st.size <= READ_MAX_SIZE_BYTES && !preferRangeStream && !preferSmartStream;
126
- const prefetched = preferBufferedRead
127
- ? await prefetchRawBody(fullPath, st, helpers)
128
- : { buf: null, fromCache: false };
129
- return await readBodyResult(ctx, { prefetched, preferRangeStream, preferSmartStream }, helpers);
74
+ return runReadCachePublication(fullPath, async () => {
75
+ const hasOffsetArg = args.offset !== undefined && args.offset !== null;
76
+ const hasLimitArg = args.limit !== undefined && args.limit !== null;
77
+ const hasRangeArgs = hasOffsetArg || hasLimitArg;
78
+ const wantFull = args.full === true;
79
+ let offset = parseOffsetArg(args.offset);
80
+ // full:true bypasses the default 2000-line cap so the whole file can be
81
+ // returned in one call; the byte cap still emits a compact truncation
82
+ // marker when rendered bytes overflow READ_MAX_OUTPUT_BYTES.
83
+ let limit = parseLineLimitArg(args.limit, wantFull ? Infinity : 2000);
84
+ let widenNote = '';
85
+ const target = await statReadTarget(fullPath, filePath, options, helpers);
86
+ if (target.error) return target.error;
87
+ if (!target.st) return readNotFoundResponse(target.statErr, { fullPath, filePath, workDir }, helpers);
88
+ const st = target.st;
89
+ // Reactive widen + window-history bookkeeping (ranged text reads only).
90
+ if (
91
+ readStateScope &&
92
+ typeof readStateScope === 'object' &&
93
+ hasOffsetArg &&
94
+ hasLimitArg &&
95
+ Number.isFinite(limit) &&
96
+ limit > 0
97
+ ) {
98
+ ({ offset, limit, widenNote } = widenReadWindow(readStateScope, fullPath, { offset, limit }));
99
+ }
100
+ const unchanged = mutationUnchangedStub({ fullPath, filePath, st, readStateScope, options }, helpers);
101
+ if (unchanged) return unchanged;
102
+ // MEDIA-WINS: media dispatch runs before every cache/snapshot fast path so
103
+ // a stale text entry can never short-circuit a media read.
104
+ const media = await readMediaFile({ fullPath, st, args, readStateScope, options, hasRangeArgs }, helpers);
105
+ if (media) return media.result;
106
+ const cacheKey = `read|${fullPath}|${st.mtimeMs}|${st.ctimeMs}|${st.size}|${hasOffsetArg ? offset : 'd'}|${hasLimitArg ? limit : 'd'}|${wantFull ? 'f' : 's'}|base:${readOffsetBase}|budget:${readMaxOutputBytes}`;
107
+ const ctx = {
108
+ fullPath,
109
+ filePath,
110
+ st,
111
+ cacheKey,
112
+ readStateScope,
113
+ options,
114
+ offset,
115
+ limit,
116
+ hasRangeArgs,
117
+ wantFull,
118
+ readMaxOutputBytes,
119
+ readOffsetBase,
120
+ widenNote,
121
+ };
122
+ const cached = await readCachedResult(ctx, helpers);
123
+ if (cached && !cached.rejected) return cached.result;
124
+ const finishRead = async () => {
125
+ const pathStub = await pathSnapshotUnchangedStub(ctx, helpers);
126
+ if (pathStub) return pathStub;
127
+ // Pre-read size cap: a small error response beats 25K tokens of truncated
128
+ // content (Anthropic #21841 reverted). With offset/limit the
129
+ // requested window streams instead; without range args the cap still
130
+ // refuses so the small-file default path can't pull megabytes by accident.
131
+ const preferRangeStream =
132
+ hasRangeArgs && !wantFull && (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES);
133
+ const preferSmartStream = !hasRangeArgs && !wantFull && st.size >= READ_SMART_STREAM_MIN_BYTES;
134
+ const preferBufferedRead = st.size <= READ_MAX_SIZE_BYTES && !preferRangeStream && !preferSmartStream;
135
+ const prefetched = preferBufferedRead
136
+ ? await prefetchRawBody(fullPath, st, helpers, cached?.rejected === true)
137
+ : { buf: null, fromCache: false };
138
+ return await readBodyResult(ctx, { prefetched, preferRangeStream, preferSmartStream }, helpers);
139
+ };
140
+ // Rejection invalidates this lease too. Only the fresh continuation gets
141
+ // a new generation; readers still holding earlier bytes remain fenced.
142
+ return cached?.rejected ? runReadCachePublication(fullPath, finishRead) : finishRead();
143
+ });
130
144
  }
131
145
 
132
146
  // Body read: binary/encoding inspection, then the streamed (range or smart)
@@ -8,8 +8,9 @@ import {
8
8
  statMatchesSnapshot,
9
9
  decodeRawBufferForSnapshotCheck,
10
10
  } from './snapshot-helpers.mjs';
11
- import { rawContentCacheGet } from './cache-layers.mjs';
11
+ import { canPublishReadCache, rawContentCacheGet } from './cache-layers.mjs';
12
12
  import { fileVersion } from './file-version.mjs';
13
+ import { canCacheReadFile } from './read-cache-policy.mjs';
13
14
  import { publishRawContentAfterRead } from './raw-content-publication.mjs';
14
15
  import { rememberReadSnapshot, readFilesForScope, readScopeKey, scheduleScopePersist } from './snapshot-store.mjs';
15
16
  import {
@@ -118,11 +119,15 @@ function collectRangeHashRows(existing, sameFile, meta) {
118
119
  }
119
120
 
120
121
  export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
122
+ if (!canPublishReadCache(fullPath)) return;
121
123
  const readFiles = readFilesForScope(scope);
122
124
  const identity = snapshotStatIdentity(fullPath, st);
123
125
  const { mtimeMs, ctimeMs, size, ino, dev } = identity || {};
124
126
  const incomingRanges = Array.isArray(meta.ranges) ? meta.ranges : [{ startLine: 1, endLine: Infinity }];
125
- const replaceExisting = meta.replaceExisting === true;
127
+ // Equal metadata cannot carry a large file's old ranges or full-body proof
128
+ // into a new observation. Batched windows arrive together as one snapshot.
129
+ const freshLargeRead = !canCacheReadFile(identity) && !isMutationSource(meta.source);
130
+ const replaceExisting = meta.replaceExisting === true || freshLargeRead;
126
131
  const existing = replaceExisting ? null : readFiles.get(fullPath);
127
132
  const sameFile = existing && statMatchesSnapshot(identity, existing) && Array.isArray(existing.ranges);
128
133
  // A mutation snapshot claims the full range because it knows the bytes it
@@ -161,7 +166,7 @@ export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
161
166
  const bodyDelivered = resolveBodyDelivered({
162
167
  next,
163
168
  meta,
164
- priorSnapshot: readFiles.get(fullPath),
169
+ priorSnapshot: freshLargeRead ? null : readFiles.get(fullPath),
165
170
  identity,
166
171
  incomingIsGrep,
167
172
  });
@@ -1,4 +1,4 @@
1
- import { open } from 'node:fs/promises';
1
+ import { open, stat } from 'node:fs/promises';
2
2
  import { hashText } from './hash-utils.mjs';
3
3
  import { runResultCacheInFlight } from './cache-layers.mjs';
4
4
  import {
@@ -9,6 +9,7 @@ import {
9
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
+ import { canCacheReadFile } from './read-cache-policy.mjs';
12
13
 
13
14
  // Raw, one-based inclusive source windows. Callers merge/sort intervals first.
14
15
  // Unlike the display reader, this never truncates a line into usable patch text.
@@ -16,6 +17,9 @@ export async function readSourceWindows(path, intervals, { signal } = {}) {
16
17
  signal?.throwIfAborted();
17
18
  if (!intervals.length) return new Map();
18
19
  const snapshot = intervals.map(({ start, end }) => ({ start, end }));
20
+ // A later caller must not join a scan that already observed old deep bytes.
21
+ // Preserve small-file fan-out and its per-subscriber cancellation contract.
22
+ if (!canCacheReadFile(await stat(path))) return scanSourceWindows(path, snapshot, signal);
19
23
  const key = `source-windows:${canonicalCachePath(path)}:${JSON.stringify(snapshot.map((w) => [w.start, w.end]))}`;
20
24
  const shared = await runResultCacheInFlight(
21
25
  key,
@@ -1,5 +1,6 @@
1
1
  import { createReadStream } from 'node:fs';
2
2
  import { sameFileVersion } from './file-version.mjs';
3
+ import { canCacheReadFile, READ_PREFIX_HASH_BYTES } from './read-cache-policy.mjs';
3
4
  import * as fsPromises from 'node:fs/promises';
4
5
  import { createInterface } from 'node:readline';
5
6
  import { capShellOutput } from './shell-output.mjs';
@@ -55,7 +56,7 @@ async function readRangeFromHandle(fh, fullPath, offset, limit, stHint, hooks) {
55
56
  const anchor = nearestReadRangeAnchor(rangeIndex, offset);
56
57
  // Keep the first read large enough for the existing 64 KiB prefix hash.
57
58
  // Grow only while seeking distant lines; nearby windows avoid 1 MiB reads.
58
- const MIN_CHUNK_BYTES = 64 * 1024;
59
+ const MIN_CHUNK_BYTES = READ_PREFIX_HASH_BYTES;
59
60
  const MAX_CHUNK_BYTES = 1024 * 1024;
60
61
  let chunkBytes = MIN_CHUNK_BYTES;
61
62
  let buf = Buffer.allocUnsafe(chunkBytes);
@@ -83,10 +84,14 @@ async function readRangeFromHandle(fh, fullPath, offset, limit, stHint, hooks) {
83
84
  while (!stop) {
84
85
  checkDeadline();
85
86
  if (buf.length < chunkBytes) buf = Buffer.allocUnsafe(chunkBytes);
87
+ // Process all bytes inside the absolute cap before rejecting a deeper
88
+ // window. At the cap, a one-byte probe distinguishes EOF from a partial
89
+ // final line; any returned byte is rejected before reaching the collector.
90
+ const readBytes = Math.min(chunkBytes, Math.max(1, READ_MAX_SCAN_BYTES - position));
86
91
  const prefix = position === 0 && Buffer.isBuffer(prefixBuffer) ? prefixBuffer : null;
87
92
  const bytesRead = prefix?.length
88
- ? prefix.copy(buf, 0, 0, Math.min(prefix.length, chunkBytes))
89
- : (await fh.read(buf, 0, chunkBytes, position)).bytesRead;
93
+ ? prefix.copy(buf, 0, 0, Math.min(prefix.length, readBytes))
94
+ : (await fh.read(buf, 0, readBytes, position)).bytesRead;
90
95
  if (bytesRead === 0) break;
91
96
  const chunkStart = position;
92
97
  position += bytesRead;
@@ -94,7 +99,7 @@ async function readRangeFromHandle(fh, fullPath, offset, limit, stHint, hooks) {
94
99
  throw new Error(`read scan exceeds ${READ_MAX_SCAN_BYTES} bytes`);
95
100
  }
96
101
  if (!prefixHash && chunkStart === 0) {
97
- prefixHash = hashText(buf.subarray(0, Math.min(bytesRead, 65536)));
102
+ prefixHash = hashText(buf.subarray(0, Math.min(bytesRead, READ_PREFIX_HASH_BYTES)));
98
103
  if (rangeIndex && rangeIndex.prefixHash !== prefixHash) {
99
104
  rangeIndex.prefixHash = prefixHash;
100
105
  }
@@ -131,7 +136,10 @@ async function readRangeFromHandle(fh, fullPath, offset, limit, stHint, hooks) {
131
136
  if (start < bytesRead) lines.appendPartial(buf.subarray(start, bytesRead));
132
137
  }
133
138
  if (!stop) lines.finishTail();
134
- if (!(await publishReadRangeIndex(rangeIndex, fh, null, checkDeadline))) checkDeadline();
139
+ let readSnapshotStat = null;
140
+ if (!(await publishReadRangeIndex(rangeIndex, fh, null, checkDeadline, (version) => {
141
+ readSnapshotStat = version;
142
+ }))) checkDeadline();
135
143
 
136
144
  ioTraceDone('read_range_stream', traceStart, {
137
145
  pathHash: hashText(fullPath).slice(0, 12),
@@ -147,6 +155,7 @@ async function readRangeFromHandle(fh, fullPath, offset, limit, stHint, hooks) {
147
155
  firstEmitted: lines.firstEmitted,
148
156
  lastEmitted: lines.lastEmitted,
149
157
  prefixHash,
158
+ readSnapshotStat,
150
159
  };
151
160
  }
152
161
 
@@ -269,8 +278,8 @@ export async function countLogicalLinesBytesSync(fullPath, size, stHint = null,
269
278
  const toRead = Math.min(buf.length, size - position);
270
279
  const { bytesRead } = await fh.read(buf, 0, toRead, position);
271
280
  if (bytesRead <= 0) break;
272
- if (position === 0 && rangeIndex && !rangeIndex.prefixHash) {
273
- rangeIndex.prefixHash = hashText(buf.subarray(0, Math.min(bytesRead, 65536)));
281
+ if (position === 0 && rangeIndex && canCacheReadFile(rangeIndex.fileVersion) && !rangeIndex.prefixHash) {
282
+ rangeIndex.prefixHash = hashText(buf.subarray(0, Math.min(bytesRead, READ_PREFIX_HASH_BYTES)));
274
283
  }
275
284
  let start = 0;
276
285
  while (start < bytesRead) {
@@ -142,7 +142,8 @@ export async function dispatchBatchReads({
142
142
  readOutputBudgetBytes: outputBudget,
143
143
  toolOutputMaxBytes: outputBudget,
144
144
  });
145
- results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body };
145
+ // Retain producer identity when caller slots are restored from a union.
146
+ results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body, readEntry };
146
147
  };
147
148
  const key = entry.path || `#missing-${index}`;
148
149
  const prev = readChains.get(key) ?? Promise.resolve();