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,11 +7,14 @@
7
7
  * Kept apart from the cache/fingerprint policy in store-summary-reader.mjs
8
8
  * because this runs exactly once per distinct content and is the only part
9
9
  * that lazily enters the heavier reconnect-recovery boundary (dynamic imports
10
- * preserved verbatim so the cold summary path stays leaf-only).
10
+ * keep the cold summary path leaf-only).
11
11
  */
12
12
  import { sessionContextMeasurement, contextMeasurementStats } from '../../../shared/context-measurement.mjs';
13
13
  import { nextProjectionStamp } from './store-transcript-cache.mjs';
14
+ import { join } from 'node:path';
14
15
  import { cleanValue, desktopSession, positiveNumber } from './store-summary-fields.mjs';
16
+ import { compactionArchiveRange, readCompactionArchivePage } from './compaction-archive.mjs';
17
+ import { dataDir } from './store-summary-locations.mjs';
15
18
 
16
19
  /** The parse/recovery/projection body of a stored transcript read. Runs once
17
20
  * per distinct content; its result is shared read-only through the cache. */
@@ -90,17 +93,31 @@ export async function projectStoredTranscript(sessionId, doc, options) {
90
93
  // tail is identical either way.
91
94
  const itemLimit = Number.isFinite(options.itemLimit) ? options.itemLimit : Number.POSITIVE_INFINITY;
92
95
  const restored = restoreTranscriptItems(messages, { sessionId, itemLimit: itemLimit + 1 });
93
- const transcriptHasOlder = restored.length > itemLimit;
96
+ let transcriptHasOlder = restored.length > itemLimit;
97
+ let items = transcriptHasOlder ? restored.slice(-itemLimit) : restored;
98
+ // A paged window continues past the first stored message into the
99
+ // display-only compaction archive (newest segment first). A legacy whole
100
+ // read never receives it.
101
+ const archiveRange = Number.isFinite(itemLimit) && !transcriptHasOlder ? compactionArchiveRange(session) : null;
102
+ if (archiveRange) {
103
+ const archived = await readCompactionArchivePage(join(dataDir(), 'sessions'), sessionId, {
104
+ ...archiveRange,
105
+ limit: itemLimit - restored.length,
106
+ });
107
+ if (archived.items.length) items = [...archived.items, ...restored];
108
+ transcriptHasOlder = archived.hasOlder;
109
+ }
94
110
  return {
95
111
  sessionId,
96
112
  projectionStamp: nextProjectionStamp(),
97
113
  ...(options.includeMessages ? { messages } : {}),
98
- items: transcriptHasOlder ? restored.slice(-itemLimit) : restored,
114
+ items,
99
115
  transcriptHasOlder,
100
116
  provider: session.provider || '',
101
117
  model: session.model || '',
102
118
  effort: session.effort || '',
103
119
  fast: session.fast === true,
120
+ ...(session.autoEffort === false ? { autoEffort: false } : {}),
104
121
  modelParameters: session.modelParameters || {},
105
122
  cwd: session.cwd || '',
106
123
  desktopSession: desktopSession(session.desktopSession, session.cwd),
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { contextMeasurementStats } from '../../../../shared/context-measurement.mjs';
8
8
  import { toolResultText } from './tool-result-text.mjs';
9
+ import { restoredUserText } from './user-text.mjs';
9
10
  import {
10
11
  completionCardFromExecution,
11
12
  parseModelVisibleCompletionWrapper,
@@ -48,12 +49,19 @@ function restoredTranscriptMetadata(message) {
48
49
  if (completionValue && completionStatus && Number.isFinite(completionElapsedMs)) {
49
50
  completion = { status: completionStatus, elapsedMs: Math.max(0, completionElapsedMs) };
50
51
  if (typeof completionValue.verb === 'string' && completionValue.verb) completion.verb = completionValue.verb;
52
+ if (typeof completionValue.checkpointId === 'string' && completionValue.checkpointId) {
53
+ completion.checkpointId = completionValue.checkpointId;
54
+ }
51
55
  }
52
56
  return {
57
+ // The stable message id lets a live view map a compaction's dropped
58
+ // messages back to these rows.
59
+ ...(typeof value.messageId === 'string' && value.messageId ? { messageId: value.messageId } : {}),
53
60
  ...(Number.isFinite(Number(value.at)) ? { at: Number(value.at) } : {}),
54
61
  ...transcriptRouteMetadataFields(value),
55
62
  ...(typeof value.sender === 'string' && value.sender ? { sender: value.sender } : {}),
56
63
  ...(typeof value.device === 'string' && value.device ? { device: value.device } : {}),
64
+ ...(typeof value.checkpointId === 'string' && value.checkpointId ? { checkpointId: value.checkpointId } : {}),
57
65
  ...(completion ? { completion } : {}),
58
66
  };
59
67
  }
@@ -70,15 +78,16 @@ function restoredAssistantTranscriptItems(message, nextId) {
70
78
  ...completion,
71
79
  ...(completion.status === 'done' ? { finalAssistantId: items[0].id } : {}),
72
80
  ...(metadata.at ? { at: metadata.at } : {}),
81
+ ...(metadata.messageId ? { messageId: metadata.messageId } : {}),
73
82
  });
74
83
  }
75
84
  return items;
76
85
  }
77
86
 
78
87
  // Restored tool cards: stored assistant messages keep their (compacted)
79
- // tool_calls and the follow-up role:'tool' results, but resume used to drop
80
- // both — a reopened session lost every tool marker (user bug). Rebuild one
81
- // transcript tool item per call and attach its result by tool_call_id.
88
+ // tool_calls and the follow-up role:'tool' results. Rebuild one transcript
89
+ // tool item per call and attach its result by tool_call_id, so a reopened
90
+ // session keeps its tool markers.
82
91
  function restoredMessageToolCalls(message) {
83
92
  if (Array.isArray(message?.tool_calls)) return message.tool_calls;
84
93
  if (Array.isArray(message?.toolCalls)) return message.toolCalls;
@@ -112,7 +121,12 @@ function restoredToolCallItems(message, nextId, pendingByCallId) {
112
121
  ...(Number.isFinite(at) ? { at, startedAt: at, completedAt: at } : {}),
113
122
  };
114
123
  const callId = typeof call?.id === 'string' ? call.id : '';
115
- if (callId) pendingByCallId.set(callId, item);
124
+ // The call id ties the card to its call and result messages (a live view
125
+ // greys it when a compaction drops them).
126
+ if (callId) {
127
+ item.callId = callId;
128
+ pendingByCallId.set(callId, item);
129
+ }
116
130
  items.push(item);
117
131
  }
118
132
  return items;
@@ -203,6 +217,9 @@ function buildRestoredAggregateItem(members) {
203
217
  ...(loadingTargets.length > 0 ? { loadingTargets } : {}),
204
218
  },
205
219
  aggregate: true,
220
+ ...(members.some(({ item }) => item.callId)
221
+ ? { callIds: members.map(({ item }) => item.callId).filter(Boolean) }
222
+ : {}),
206
223
  categories: Object.fromEntries(header.categories),
207
224
  doneCategories: aggregateDoneCategories(calls),
208
225
  completedCount: calls.length,
@@ -219,8 +236,8 @@ function buildRestoredAggregateItem(members) {
219
236
 
220
237
  // Restored transcripts must mirror the live turn's category merging: the live
221
238
  // session runtime (turn.mjs) collapses consecutive same-bucket tool calls into one
222
- // aggregate card, but resume rebuilt one card per call, so a reopened session
223
- // un-merged every run (user bug). Walk the restored items and merge adjacent
239
+ // aggregate card, so a reopened session must not show them un-merged. Walk the
240
+ // restored items and merge adjacent
224
241
  // tool cards whose aggregateBucketForCategory matches; any non-tool item
225
242
  // (user/assistant/turndone) is a block boundary, same as the live seal rule.
226
243
  // Runs of 1 keep the plain per-call card (its argument summary stays visible).
@@ -279,9 +296,11 @@ function restoredMessageItemUpperBound(message) {
279
296
  function restoredNotificationToolItem(parsed, text, message, nextId, errorLabel) {
280
297
  const label = parsed.label || 'notification';
281
298
  const at = Number(message?.meta?.transcript?.at);
299
+ const messageId = message?.meta?.transcript?.messageId;
282
300
  return {
283
301
  kind: 'tool',
284
302
  id: nextId(),
303
+ ...(typeof messageId === 'string' && messageId ? { messageId } : {}),
285
304
  name: parsed.name || 'agent',
286
305
  args: parsed.args || {
287
306
  type: label,
@@ -303,7 +322,7 @@ function restoredUserTranscriptItems(message, nextId) {
303
322
  // skill bodies (meta:'skill'), hook/system reminders, and tag-wrapped context
304
323
  // blocks must not restore as user bubbles in any client.
305
324
  if (message?.meta === 'skill' || message?.meta === 'hook') return [];
306
- const text = (typeof message?.content === 'string' ? message.content : toolResultText(message?.content)).trim();
325
+ const text = restoredUserText(message?.content).trim();
307
326
  // Persisted async-completion wrappers ("Async shell task ... finished. Result:
308
327
  // > ...") are the only durable record of a background completion — the live
309
328
  // Response card is an event-time push that does not survive a transcript
@@ -329,7 +348,8 @@ function restoredUserTranscriptItems(message, nextId) {
329
348
  // step, but render it like a live cancel tail (◈ Cancelled) instead of a
330
349
  // raw user bubble with bracketed internals.
331
350
  if (isTranscriptCancelledStatusText(text)) {
332
- return [{ kind: 'turndone', id: nextId(), status: 'cancelled', elapsedMs: 0 }];
351
+ const { messageId } = restoredTranscriptMetadata(message);
352
+ return [{ kind: 'turndone', id: nextId(), status: 'cancelled', elapsedMs: 0, ...(messageId ? { messageId } : {}) }];
333
353
  }
334
354
  const synthetic = parseSyntheticAgentMessage(text);
335
355
  if (!synthetic) {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The text a restored user bubble shows for a stored prompt, matching what the
3
+ * live bubble showed for it:
4
+ * - An image part is drawn by the `[Image: …]` metadata line the composer put
5
+ * before it; the part itself adds no raw `[image: image/png]` marker (a bare
6
+ * one gets a metadata line of its own, which renders as an image chip).
7
+ * - A desktop `<visualize_feedback>` block (the agent-facing pin/choice
8
+ * record) is shown as the lines the live bubble showed: `📌 N · label —
9
+ * note` per pin, one `☑ group: label, …` line for the picks and one
10
+ * `🎚 id: value (was old), …` line for tweaks (drawn with an icon). Intake
11
+ * moves a long block into the attachment store; it is read back from there.
12
+ */
13
+ import { isAttachmentReference, readAttachmentTextWithin } from '../../../../attachments/store.mjs';
14
+ import { toolResultText } from './tool-result-text.mjs';
15
+
16
+ const IMAGE_METADATA_LINE = /\[Image(?::| source:) [^\]\r\n]+\]\s*$/;
17
+ const FEEDBACK_OPEN = '<visualize_feedback>';
18
+ const FEEDBACK_BLOCK = /<visualize_feedback>\n([\s\S]*?)\n?<\/visualize_feedback>/g;
19
+ // Feedback blocks are bounded (20 pins, trimmed HTML); a larger blob is a long
20
+ // prompt or paste, which restores as before without being read. The cap holds
21
+ // on the blob itself: the reference's claimed sizeBytes is not trusted.
22
+ const FEEDBACK_MAX_BYTES = 128 * 1024;
23
+ const QUOTED = '("(?:[^"\\\\]|\\\\.)*")';
24
+ const CHOICE_LINE = new RegExp(
25
+ `· group: (?:${QUOTED}|\\(none\\)) → choice: ${QUOTED}(?: \\(${QUOTED}\\))?$`
26
+ );
27
+
28
+ function unquote(value) {
29
+ if (!value) return '';
30
+ try {
31
+ return String(JSON.parse(value));
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ /** The live bubble's lines for one feedback block body. */
38
+ function feedbackDisplay(body) {
39
+ const pins = [];
40
+ const choices = [];
41
+ let pin = null;
42
+ let note = false;
43
+ let inChoices = false;
44
+ const tweakSets = [];
45
+ let tweaks = null;
46
+ for (const line of body.split('\n')) {
47
+ if (line.startsWith('tweaks (')) {
48
+ pin = null;
49
+ inChoices = false;
50
+ tweaks = null;
51
+ tweakSets.push([]);
52
+ tweaks = tweakSets.at(-1);
53
+ continue;
54
+ }
55
+ if (tweaks) {
56
+ const change = /^ (.+?) \([^)]*\): (.*) \(was (.*)\)$/.exec(line);
57
+ if (change) tweaks.push(`${change[1]}: ${change[2]} (was ${change[3]})`);
58
+ else if (line.startsWith(' - tab: ')) {
59
+ tweaks = [];
60
+ tweakSets.push(tweaks);
61
+ } else tweaks = null;
62
+ if (tweaks) continue;
63
+ }
64
+ const head = /^pin (\d+) · (.*)$/.exec(line);
65
+ if (head) {
66
+ pin = { n: head[1], label: head[2], note: '' };
67
+ pins.push(pin);
68
+ note = false;
69
+ inChoices = false;
70
+ continue;
71
+ }
72
+ if (line === 'choices:') {
73
+ pin = null;
74
+ inChoices = true;
75
+ continue;
76
+ }
77
+ if (pin && line.startsWith(' note: ')) {
78
+ pin.note = line.slice(8);
79
+ note = true;
80
+ continue;
81
+ }
82
+ if (pin && note && line.startsWith(' ')) {
83
+ pin.note += `\n${line.slice(4)}`;
84
+ continue;
85
+ }
86
+ note = false;
87
+ const choice = inChoices ? CHOICE_LINE.exec(line) : null;
88
+ if (choice) {
89
+ choices.push({ group: unquote(choice[1]), choice: unquote(choice[2]), label: unquote(choice[3]) });
90
+ }
91
+ }
92
+ const lines = pins.map((entry) => `📌 ${entry.n} · ${entry.label} — ${entry.note.replace(/\s+/g, ' ')}`);
93
+ if (choices.length) {
94
+ lines.push(
95
+ `☑ ${choices.map((entry) => (entry.group ? `${entry.group}: ` : '') + (entry.label || entry.choice)).join(', ')}`
96
+ );
97
+ }
98
+ const changed = tweakSets.filter((set) => set.length).map((set) => set.join(', '));
99
+ if (changed.length) lines.push(`🎚 ${changed.join('; ')}`);
100
+ return lines.join('\n');
101
+ }
102
+
103
+ export function withVisualizeFeedbackDisplay(text) {
104
+ const value = String(text ?? '');
105
+ if (!value.includes(FEEDBACK_OPEN)) return value;
106
+ return value.replace(FEEDBACK_BLOCK, (_block, body) => feedbackDisplay(body));
107
+ }
108
+
109
+ /** A stored text reference's body when it is a feedback block, else ''. */
110
+ function referencedFeedbackText(part) {
111
+ if (part?.type !== 'text' || part.textKind) return '';
112
+ try {
113
+ const text = readAttachmentTextWithin(part, FEEDBACK_MAX_BYTES);
114
+ return text?.startsWith(FEEDBACK_OPEN) ? text : '';
115
+ } catch {
116
+ // A blob gone from the store restores as it always did: without its text.
117
+ return '';
118
+ }
119
+ }
120
+
121
+ export function restoredUserText(content) {
122
+ if (!Array.isArray(content)) {
123
+ return withVisualizeFeedbackDisplay(typeof content === 'string' ? content : toolResultText(content));
124
+ }
125
+ const texts = [];
126
+ let previous = '';
127
+ for (const part of content) {
128
+ let text;
129
+ if (part?.type === 'image' || part?.type === 'input_image') {
130
+ text = IMAGE_METADATA_LINE.test(previous) ? '' : `[Image: ${part.mimeType || part.mediaType || 'image'}]`;
131
+ } else if (isAttachmentReference(part)) {
132
+ text = referencedFeedbackText(part);
133
+ } else {
134
+ text = toolResultText([part]);
135
+ }
136
+ previous = text;
137
+ if (text) texts.push(text);
138
+ }
139
+ return withVisualizeFeedbackDisplay(texts.join('\n'));
140
+ }
@@ -5,10 +5,15 @@ import { isAbsolute, sep } from 'node:path';
5
5
  import { canonicalCachePath, deleteReadRangeIndexForPath } from './read-range-index.mjs';
6
6
  import { resolveAgainstCwd } from './path-utils.mjs';
7
7
  import { fileVersion, sameFileVersion } from './file-version.mjs';
8
+ import { canCacheReadFile } from './read-cache-policy.mjs';
8
9
 
9
10
  const RESULT_CACHE = new Map(); // key → { ts, value, paths, scopes, readSnapshotMeta, contentPrefixHash, bytes }
10
11
  const RESULT_CACHE_INFLIGHT = new Map(); // key → { promise, controller, subscribers, settled }
11
12
  const RESULT_CACHE_COMPUTE = new AsyncLocalStorage();
13
+ const READ_CACHE_PUBLICATION = new AsyncLocalStorage();
14
+ // Only active reads retain a slot. Invalidating an inactive path creates
15
+ // no history, and the final reader releases its path in a finally block.
16
+ const ACTIVE_READ_CACHE_PATHS = new Map();
12
17
  const RESULT_CACHE_TTL_MS = 30_000;
13
18
  const RESULT_CACHE_MAX_ENTRIES = 200;
14
19
  // A byte budget from the bytes env var, else the megabytes one, else the
@@ -72,6 +77,37 @@ function cachePathsOverlap(a, b) {
72
77
  return a.startsWith(b.endsWith(sep) ? b : `${b}${sep}`) || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
73
78
  }
74
79
 
80
+ export async function runReadCachePublication(fullPaths, read) {
81
+ const paths = normalizeCacheMetaPaths(Array.isArray(fullPaths) ? fullPaths : [fullPaths]);
82
+ const leases = paths.map((path) => {
83
+ let slot = ACTIVE_READ_CACHE_PATHS.get(path);
84
+ if (!slot) {
85
+ slot = { generation: 0, references: 0 };
86
+ ACTIVE_READ_CACHE_PATHS.set(path, slot);
87
+ }
88
+ slot.references++;
89
+ return { path, slot, generation: slot.generation, released: false };
90
+ });
91
+ try {
92
+ return await READ_CACHE_PUBLICATION.run(leases, read);
93
+ } finally {
94
+ for (const lease of leases) {
95
+ lease.released = true;
96
+ if (--lease.slot.references === 0) ACTIVE_READ_CACHE_PATHS.delete(lease.path);
97
+ }
98
+ }
99
+ }
100
+
101
+ export function canPublishReadCache(targets = null) {
102
+ const leases = READ_CACHE_PUBLICATION.getStore();
103
+ if (!leases) return true;
104
+ const paths = normalizeCacheMetaPaths(Array.isArray(targets) ? targets : [targets]);
105
+ return leases.every((lease) => {
106
+ if (paths.length && !paths.some((path) => cachePathsOverlap(path, lease.path))) return true;
107
+ return !lease.released && lease.generation === lease.slot.generation;
108
+ });
109
+ }
110
+
75
111
  function cacheEntryOverlapsPaths(entry, affectedPaths) {
76
112
  const entryPaths = Array.isArray(entry?.paths) ? entry.paths : [];
77
113
  const entryScopes = Array.isArray(entry?.scopes) ? entry.scopes : [];
@@ -107,6 +143,11 @@ export function cacheSet(key, value, meta = {}) {
107
143
  // A detached/aborted computation must never repopulate a newer cache.
108
144
  const computation = RESULT_CACHE_COMPUTE.getStore();
109
145
  if (computation?.invalidated || computation?.controller?.signal?.aborted) return;
146
+ const publicationPaths = [
147
+ ...(Array.isArray(meta.paths) ? meta.paths : []),
148
+ ...(Array.isArray(meta.scopes) ? meta.scopes : []),
149
+ ];
150
+ if (!canPublishReadCache(publicationPaths)) return;
110
151
  // Replace-in-place: clear old entry's byte accounting before write.
111
152
  if (RESULT_CACHE.has(key)) resultCacheDelete(key);
112
153
  const bytes = estimateResultBytes(value);
@@ -239,7 +280,7 @@ export async function runRawContentInFlight(fullPath, loader = fsPromises.readFi
239
280
  // Buffered reads provide their observed version: a later read must not
240
281
  // join older bytes merely because a watcher has not fired yet. Preserve
241
282
  // the unversioned helper contract for callers that supply no stat.
242
- if (stat !== undefined && !version) return await loader(fullPath);
283
+ if (stat !== undefined && (!version || !canCacheReadFile(version))) return await loader(fullPath);
243
284
  const key = version ? JSON.stringify([path, version]) : path;
244
285
  return await runPathInFlight(RAW_CONTENT_INFLIGHT, key, path, () => loader(fullPath));
245
286
  }
@@ -260,6 +301,10 @@ function rawContentCacheDelete(key) {
260
301
  export function rawContentCacheGet(fullPath, stat, now = Date.now()) {
261
302
  if (!fullPath || !stat) return null;
262
303
  const key = canonicalCachePath(fullPath);
304
+ if (!canCacheReadFile(stat)) {
305
+ rawContentCacheDelete(key);
306
+ return null;
307
+ }
263
308
  const entry = RAW_CONTENT_CACHE.get(key);
264
309
  if (!entry) return null;
265
310
  if (now - entry.ts > RAW_CONTENT_CACHE_TTL_MS) {
@@ -276,9 +321,10 @@ export function rawContentCacheGet(fullPath, stat, now = Date.now()) {
276
321
  }
277
322
 
278
323
  export function rawContentCacheSet(fullPath, stat, rawBuf, now = Date.now()) {
324
+ if (!canPublishReadCache(fullPath)) return;
279
325
  if (!fullPath || !Buffer.isBuffer(rawBuf)) return;
280
326
  const version = fileVersion(stat);
281
- if (!version || rawBuf.length !== version.size || rawBuf.length > RAW_CONTENT_CACHE_MAX_BYTES) return;
327
+ if (!version || !canCacheReadFile(version) || rawBuf.length !== version.size || rawBuf.length > RAW_CONTENT_CACHE_MAX_BYTES) return;
282
328
  const key = canonicalCachePath(fullPath);
283
329
  rawContentCacheDelete(key);
284
330
  RAW_CONTENT_CACHE.set(key, {
@@ -450,6 +496,7 @@ function runExtraInvalidationListeners(affectedPaths = null) {
450
496
  }
451
497
 
452
498
  function cacheInvalidateAll() {
499
+ for (const slot of ACTIVE_READ_CACHE_PATHS.values()) slot.generation++;
453
500
  for (const entry of RESULT_CACHE_INFLIGHT.values()) {
454
501
  entry.invalidated = true;
455
502
  }
@@ -470,6 +517,9 @@ function cacheInvalidatePaths(paths) {
470
517
  cacheInvalidateAll();
471
518
  return;
472
519
  }
520
+ for (const [path, slot] of ACTIVE_READ_CACHE_PATHS) {
521
+ if (affectedPaths.some((affected) => cachePathsOverlap(path, affected))) slot.generation++;
522
+ }
473
523
  for (const inFlight of [RAW_CONTENT_INFLIGHT, READ_ONLY_STAT_INFLIGHT]) {
474
524
  for (const [key, entry] of inFlight) {
475
525
  if (affectedPaths.some((affected) => cachePathsOverlap(entry.path, affected))) inFlight.delete(key);
@@ -6,7 +6,7 @@ import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
6
6
  import { GIT_DIFF_EXIT_SIGNAL_FLAGS } from './bash-tool/benign-exit.mjs';
7
7
  import { withBuiltinPathLocks } from './path-locks.mjs';
8
8
  import { withAdvisoryLocks } from './advisory-lock.mjs';
9
- import { withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
9
+ import { gitRepoLockKey, withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
10
10
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
11
11
  import { drainCodeGraphCache } from '../code-graph-state.mjs';
12
12
  import { ensureNativeSpawnServer, tryNativeSpawn } from '../lib/native-spawn-client.mjs';
@@ -460,22 +460,22 @@ function nearestGitDir(startDir) {
460
460
 
461
461
  function cachedRepoRoot(key, plan) {
462
462
  if (!key) return null;
463
- const root = repoRootCache.get(key);
464
- if (!root) return null;
463
+ const hit = repoRootCache.get(key);
464
+ if (!hit) return null;
465
465
  const nearest = nearestGitDir(plan.cwd);
466
- if (!nearest || resolve(nearest) !== resolve(root)) {
466
+ if (!nearest || resolve(nearest) !== resolve(hit.root)) {
467
467
  repoRootCache.delete(key);
468
468
  return null;
469
469
  }
470
470
  repoRootCache.delete(key);
471
- repoRootCache.set(key, root);
472
- return root;
471
+ repoRootCache.set(key, hit);
472
+ return hit;
473
473
  }
474
474
 
475
- function rememberRepoRoot(key, root) {
476
- if (!key || !root) return;
475
+ function rememberRepoRoot(key, hit) {
476
+ if (!key || !hit?.root) return;
477
477
  repoRootCache.delete(key);
478
- repoRootCache.set(key, root);
478
+ repoRootCache.set(key, hit);
479
479
  while (repoRootCache.size > REPO_ROOT_CACHE_MAX) {
480
480
  const oldest = repoRootCache.keys().next().value;
481
481
  if (oldest === undefined) break;
@@ -483,15 +483,22 @@ function rememberRepoRoot(key, root) {
483
483
  }
484
484
  }
485
485
 
486
+ // `lockKey` is the repository's canonical common git dir: the main checkout
487
+ // and its linked worktrees share one object store and ref namespace, so their
488
+ // git calls serialize on one repository lock (managed worktree mutations take
489
+ // the same key).
486
490
  async function resolveRepo(plan, signal) {
487
491
  const key = repoRootCacheKey(plan);
488
492
  const hit = cachedRepoRoot(key, plan);
489
- if (hit) return { root: hit, probe: null };
490
- const result = await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
493
+ if (hit) return { root: hit.root, lockKey: hit.lockKey, probe: null };
494
+ const result = await runGit(plan, ['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir'], {
495
+ signal,
496
+ });
491
497
  if (succeeded(result)) {
492
- const root = cleanText(result.stdout);
493
- rememberRepoRoot(key, root);
494
- return { root, probe: result };
498
+ const [root, commonDir] = cleanText(result.stdout).split('\n');
499
+ const resolved = { root, lockKey: gitRepoLockKey(commonDir || root) };
500
+ rememberRepoRoot(key, resolved);
501
+ return { ...resolved, probe: result };
495
502
  }
496
503
  // A bare repository (mirror clone, server-side repo) has no work tree, so
497
504
  // `--show-toplevel` refuses; that is still a repository. Answering
@@ -499,12 +506,13 @@ async function resolveRepo(plan, signal) {
499
506
  // to inspect objects it already had.
500
507
  if (/must be run in a work tree/i.test(String(result.stderr || ''))) {
501
508
  const bare = await runGit(plan, ['rev-parse', '--absolute-git-dir'], { signal });
502
- if (!succeeded(bare)) return { root: null, probe: bare };
509
+ if (!succeeded(bare)) return { root: null, lockKey: null, probe: bare };
503
510
  const root = cleanText(bare.stdout);
504
- rememberRepoRoot(key, root);
505
- return { root, probe: bare };
511
+ const resolved = { root, lockKey: gitRepoLockKey(root) };
512
+ rememberRepoRoot(key, resolved);
513
+ return { ...resolved, probe: bare };
506
514
  }
507
- return { root: null, probe: result };
515
+ return { root: null, lockKey: null, probe: result };
508
516
  }
509
517
 
510
518
  function localizeConfigPlan(plan) {
@@ -650,8 +658,9 @@ async function executeGitStage(input, workDir, options = {}) {
650
658
  const limit = outputLimit(input?.output_limit, 50);
651
659
  const signal = abortSignalOf(options);
652
660
  const repo = snapshot.repo;
661
+ const { lockKey } = await resolveRepo({ ...snapshot.plan, cwd: repo }, signal);
653
662
  return withGitRepoWriteLock(
654
- repo,
663
+ lockKey || repo,
655
664
  () =>
656
665
  withGitMutationLocks(repo, async () => {
657
666
  const current = await runStageableDiff(snapshot.plan, snapshot.argv, repo, signal);
@@ -722,7 +731,7 @@ async function executeSingleGitTool(input, workDir, options = {}) {
722
731
  // ("git rev-parse exited 128") for a `git status` in a plain directory hid
723
732
  // git's own "fatal: not a git repository" line behind a command the model
724
733
  // never issued — and re-ran the probe just to build that message.
725
- const { root: repo, probe } = await resolveRepo(plan, signal);
734
+ const { root: repo, lockKey, probe } = await resolveRepo(plan, signal);
726
735
  if (!repo) {
727
736
  const missing = probe ?? (await runGit(plan, ['rev-parse', '--show-toplevel'], { signal }));
728
737
  return commandResult(plan, missing, limit);
@@ -732,7 +741,7 @@ async function executeSingleGitTool(input, workDir, options = {}) {
732
741
  if (stageableRequest) prepared.argv.splice(1, 0, '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/');
733
742
  if (isReadOnly(plan)) {
734
743
  return withGitRepoReadLock(
735
- repo,
744
+ lockKey,
736
745
  async () => {
737
746
  const result = stageableRequest
738
747
  ? await runStageableDiff(plan, prepared.argv, repo, signal)
@@ -754,7 +763,7 @@ async function executeSingleGitTool(input, workDir, options = {}) {
754
763
  );
755
764
  }
756
765
  return withGitRepoWriteLock(
757
- repo,
766
+ lockKey,
758
767
  () =>
759
768
  withGitMutationLocks(repo, async () => {
760
769
  const result = await runGit(plan, prepared.argv, { signal });
@@ -896,4 +905,5 @@ export const _gitCommandInternals = {
896
905
  localizeConfigPlan,
897
906
  parseCommand,
898
907
  prepare,
908
+ resolveRepo,
899
909
  };
@@ -1,10 +1,17 @@
1
- import { resolve } from 'node:path';
1
+ import { pathKey } from '../../../../shared/worktrees/paths.mjs';
2
2
 
3
3
  const states = new Map();
4
4
 
5
+ // Callers key by the canonical `git rev-parse --git-common-dir`, so the main
6
+ // checkout and every linked worktree of one repository share one lock. The key
7
+ // is the real path (8.3 names, symlinked parents) and case-insensitive on
8
+ // Windows, so two spellings of one directory never hold separate locks.
9
+ export function gitRepoLockKey(repo) {
10
+ return pathKey(String(repo || '.'));
11
+ }
12
+
5
13
  function keyFor(repo) {
6
- const key = resolve(String(repo || '.'));
7
- return process.platform === 'win32' ? key.toLowerCase() : key;
14
+ return gitRepoLockKey(repo);
8
15
  }
9
16
 
10
17
  function stateFor(key) {
@@ -0,0 +1,8 @@
1
+ // The bounded cache validator can certify the whole file only within this
2
+ // byte budget. Larger reads must not reuse metadata-only or prefix-only
3
+ // content/line-position evidence: equal stats can hide edits past the prefix.
4
+ export const READ_PREFIX_HASH_BYTES = 65536;
5
+
6
+ export function canCacheReadFile(stat) {
7
+ return Number.isFinite(stat?.size) && stat.size >= 0 && stat.size <= READ_PREFIX_HASH_BYTES;
8
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises';
2
2
  import { statSync } from 'node:fs';
3
3
  import { fileVersion, sameFileVersion } from './file-version.mjs';
4
+ import { canCacheReadFile, READ_PREFIX_HASH_BYTES } from './read-cache-policy.mjs';
4
5
  import { hashText as sha256Hex } from './hash-utils.mjs';
5
6
  import { join, normalize, resolve } from 'node:path';
6
7
  import { getPluginData } from '../../config.mjs';
@@ -83,7 +84,7 @@ function readRangeIndexMatches(row, fullPath, version) {
83
84
  // `prefixHash` they persist into the anchor index.
84
85
  async function computePrefixHashForIndex(fullPath, st, handle = null, prefixBuffer = null) {
85
86
  try {
86
- const cap = Math.min(Number(st?.size) || 0, 65536);
87
+ const cap = Math.min(Number(st?.size) || 0, READ_PREFIX_HASH_BYTES);
87
88
  if (cap <= 0) return '';
88
89
  if (Buffer.isBuffer(prefixBuffer) && prefixBuffer.length === cap) {
89
90
  return sha256Hex(prefixBuffer);
@@ -269,6 +270,8 @@ function freshReadRangeIndex(fullPath, version = null) {
269
270
  export async function getReadRangeIndex(fullPath, st, handle = null, prefixBuffer = null) {
270
271
  const key = canonicalCachePath(fullPath);
271
272
  let slot = READ_RANGE_INDEX_CACHE.get(key);
273
+ // Reserve ownership before awaiting the real stat even when the hint is
274
+ // large: a now-small file must not renew a lookup invalidated during IO.
272
275
  if (!slot) slot = refreshReadRangeIndexSlot(key, freshReadRangeIndex(fullPath));
273
276
  const hint = fileVersion(st);
274
277
  const readStat = () => (handle ? handle.stat() : stat(fullPath));
@@ -280,8 +283,18 @@ export async function getReadRangeIndex(fullPath, st, handle = null, prefixBuffe
280
283
  } catch {
281
284
  return null;
282
285
  }
283
- if (!version || READ_RANGE_INDEX_CACHE.get(key) !== slot) return null;
286
+ if (!version) return null;
284
287
  version = Object.freeze(version);
288
+ // Metadata and a bounded prefix cannot certify suffix line positions.
289
+ // Large scans own only this call's handle identity and byte-zero draft:
290
+ // never load, hash-validate, or publish cross-read anchors/counts.
291
+ if (!canCacheReadFile(version)) {
292
+ if (slot && READ_RANGE_INDEX_CACHE.get(key) === slot) dropReadRangeIndexSlot(key);
293
+ const draft = freshReadRangeIndex(fullPath, version);
294
+ READ_RANGE_INDEX_DRAFTS.set(draft, { slot: null, version, fullPath });
295
+ return draft;
296
+ }
297
+ if (READ_RANGE_INDEX_CACHE.get(key) !== slot) return null;
285
298
  if (!slot.fileVersion) slot.fileVersion = version;
286
299
  if (!sameFileVersion(slot.fileVersion, version)) {
287
300
  dropReadRangeIndexSlot(key);
@@ -314,27 +327,33 @@ export async function getReadRangeIndex(fullPath, st, handle = null, prefixBuffe
314
327
  anchors: new Map(source.anchors),
315
328
  ...(Number.isFinite(source.totalLines) ? { totalLines: source.totalLines } : {}),
316
329
  };
317
- READ_RANGE_INDEX_DRAFTS.set(draft, { slot, version });
330
+ READ_RANGE_INDEX_DRAFTS.set(draft, { slot, version, fullPath });
318
331
  return draft;
319
332
  }
320
333
 
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) {
334
+ // Successful small-file readers publish once, before closing their handle.
335
+ // Large or detached scans remain private, but terminal validation can still
336
+ // certify their producer identity via onValidated. Post-stat failures never
337
+ // authorize relabelling bytes with a later version.
338
+ export async function publishReadRangeIndex(index, handle, signal = null, beforePublish = null, onValidated = null) {
325
339
  const owned = READ_RANGE_INDEX_DRAFTS.get(index);
326
340
  if (!owned) return false;
327
341
  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;
342
+ const { slot, version, fullPath } = owned;
343
+ const key = canonicalCachePath(fullPath);
344
+ if (signal?.aborted) return false;
345
+ // Detached cache-only publishers must not wait on validation IO. A streamed
346
+ // result requesting producer certification still owes its terminal stats.
347
+ if (slot && READ_RANGE_INDEX_CACHE.get(key) !== slot && !onValidated) return false;
331
348
  try {
332
- if (!sameFileVersion(version, await handle.stat()) || !sameFileVersion(version, await stat(slot.fullPath)))
349
+ if (!sameFileVersion(version, await handle.stat()) || !sameFileVersion(version, await stat(fullPath)))
333
350
  return false;
334
351
  } catch {
335
352
  return false;
336
353
  }
337
- if (signal?.aborted || READ_RANGE_INDEX_CACHE.get(key) !== slot) return false;
354
+ if (signal?.aborted) return false;
355
+ onValidated?.(fileVersion(version));
356
+ if (!slot || READ_RANGE_INDEX_CACHE.get(key) !== slot) return false;
338
357
  if (slot.prefixHash && index.prefixHash && slot.prefixHash !== index.prefixHash) return false;
339
358
  // The reader's synchronous deadline/cancellation decision belongs after all
340
359
  // awaited validation and outside its best-effort stat catch. Once committed,