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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Keep coalesced child observations private until the caller's exact windows
3
+ * have been restored. A fresh child may outlive the batch's original cache
4
+ * generation; retain that child's lease, never renew the whole batch.
5
+ */
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
7
+ import { canPublishReadCache, runReadCachePublication } from '../cache-layers.mjs';
8
+ import { fileVersion, sameFileVersion } from '../file-version.mjs';
9
+ import { mergeReadRanges } from '../read-ranges.mjs';
10
+
11
+ // Streamed reads have numbered rows but no total-line footer. Prefer the
12
+ // rows themselves so smart-elided gaps never become delivered evidence.
13
+ export function renderedBatchReadRanges(body) {
14
+ if (typeof body !== 'string') return [];
15
+ const rows = [...body.matchAll(/^(\d+)[\t│→]/gm)]
16
+ .map((match) => Number(match[1]))
17
+ .filter((line) => Number.isSafeInteger(line) && line > 0)
18
+ .map((line) => ({ startLine: line, endLine: line }));
19
+ if (rows.length) return mergeReadRanges(rows);
20
+ const footer = /\[lines\s+(\d+)-(\d+)\s+of\s+\d+/.exec(body);
21
+ return footer ? mergeReadRanges([{ startLine: Number(footer[1]), endLine: Number(footer[2]) }]) : [];
22
+ }
23
+
24
+ function hasRenderedLines(body) {
25
+ return renderedBatchReadRanges(body).length > 0;
26
+ }
27
+
28
+ export async function runBatchReadSnapshotPublication(executeChildBuiltinTool, helpers, run) {
29
+ const tasks = [];
30
+ const records = [];
31
+ const corrections = new Map();
32
+ let closed = false;
33
+
34
+ function capture(task, fullPath, st, scope, meta) {
35
+ if (closed) return;
36
+ const identity = fileVersion(st);
37
+ const record = {
38
+ fullPath,
39
+ scope: scope ?? null,
40
+ identity,
41
+ ranges: mergeReadRanges(meta?.ranges),
42
+ isCurrent: () => false,
43
+ publish: () => {},
44
+ release: () => {},
45
+ done: Promise.resolve(),
46
+ };
47
+ task.records.push(record);
48
+ records.push(record);
49
+ // Acquiring a new lease must not rehabilitate an already stale producer.
50
+ if (!identity || !canPublishReadCache(fullPath)) return;
51
+ const decision = Promise.withResolvers();
52
+ record.publish = (replacement = meta) => decision.resolve(replacement);
53
+ record.release = () => decision.resolve(null);
54
+ record.done = runReadCachePublication(fullPath, async () => {
55
+ record.isCurrent = AsyncLocalStorage.bind(() => canPublishReadCache(fullPath));
56
+ const selected = await decision.promise;
57
+ if (selected) helpers._recordReadSnapshot(fullPath, identity, scope, selected);
58
+ });
59
+ }
60
+
61
+ const executeChild = async (name, args, workDir, options = {}) => {
62
+ if (name !== 'read' || (args.mode && args.mode !== 'full')) {
63
+ return executeChildBuiltinTool(name, args, workDir, options);
64
+ }
65
+ const task = {
66
+ entry: args,
67
+ fullPath: helpers.resolveAgainstCwd(args.path, workDir),
68
+ records: [],
69
+ body: null,
70
+ };
71
+ tasks.push(task);
72
+ task.body = await executeChildBuiltinTool(name, args, workDir, {
73
+ ...options,
74
+ _readSnapshotRecorder: (...snapshot) => capture(task, ...snapshot),
75
+ });
76
+ return task.body;
77
+ };
78
+ const snapshotHelpers = {
79
+ ...helpers,
80
+ _filterReadSnapshotRanges: (entry, fullPath, scope, ranges) => {
81
+ const task = tasks.find((task) => task.entry === entry);
82
+ const observation = task?.records.findLast(
83
+ (record) => record.fullPath === fullPath && record.scope === (scope ?? null)
84
+ );
85
+ // A numbered row may end halfway through its content at the byte cap.
86
+ // Only this slot's producer can certify complete lines; another read of
87
+ // the same path must not authorize hashing this slot's partial text.
88
+ return mergeReadRanges(ranges.flatMap((range) => (observation?.ranges ?? []).map((complete) => ({
89
+ startLine: Math.max(range.startLine, complete.startLine),
90
+ endLine: Math.min(range.endLine, complete.endLine),
91
+ }))));
92
+ },
93
+ _recordReadSnapshot: (fullPath, _st, scope, meta) => {
94
+ corrections.set(fullPath, { scope: scope ?? null, meta });
95
+ },
96
+ };
97
+
98
+ try {
99
+ const result = await run(executeChild, snapshotHelpers);
100
+ // reject_partial refuses the entire payload: staged bodies were not
101
+ // delivered, so none of their snapshots may become session evidence.
102
+ if (result?.isError === true || (typeof result === 'string' && helpers.classifyResultKind(result) === 'error')) {
103
+ return result;
104
+ }
105
+ for (const [fullPath, correction] of corrections) {
106
+ const delivered = tasks.filter((task) => task.fullPath === fullPath && hasRenderedLines(task.body));
107
+ const observations = delivered.map((task) => task.records.findLast(
108
+ (record) => record.fullPath === fullPath && record.scope === correction.scope
109
+ ));
110
+ // Never combine different versions or bless stale/missing observations.
111
+ // The eventual recorder rechecks the retained lease after all awaits.
112
+ if (observations.length && observations.every((record) =>
113
+ record?.isCurrent() && sameFileVersion(record.identity, observations[0].identity)
114
+ )) {
115
+ observations[0].publish(correction.meta);
116
+ }
117
+ }
118
+ // Rich media/binary results have no line slices. Preserve their original
119
+ // evidence, still under the producer's lease rather than the batch's.
120
+ for (const task of tasks) {
121
+ if (hasRenderedLines(task.body) || task.body?.isError === true ||
122
+ (typeof task.body === 'string' && helpers.classifyResultKind(task.body) === 'error')) continue;
123
+ for (const record of task.records) {
124
+ if (!corrections.has(record.fullPath)) record.publish();
125
+ }
126
+ }
127
+ for (const record of records) record.release();
128
+ await Promise.all(records.map((record) => record.done));
129
+ return result;
130
+ } finally {
131
+ // Promise.all in the dispatcher may reject while other children remain
132
+ // active. Late captures then become no-ops and cannot leak a held lease.
133
+ closed = true;
134
+ for (const record of records) record.release();
135
+ await Promise.allSettled(records.map((record) => record.done));
136
+ }
137
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { rangeTextOf } from '../snapshot-helpers.mjs';
9
+ import { renderedBatchReadRanges } from './batch-publication.mjs';
9
10
 
10
11
  // Coalesced batch reads fetch the union window from disk; every caller slot
11
12
  // must be sliced back to its original request window (_orig*), not the
@@ -32,22 +33,22 @@ export function restoreCallerSlots(results, origEntries, entryMap, options, { sl
32
33
  function deliveredRangesByPath(
33
34
  orderedResults,
34
35
  workDir,
35
- { classifyResultKind, resolveAgainstCwd, _rangeHashesFromRenderedReadText }
36
+ readStateScope,
37
+ { classifyResultKind, resolveAgainstCwd, _rangeHashesFromRenderedReadText, _filterReadSnapshotRanges }
36
38
  ) {
37
39
  const exactRangesByPath = new Map();
38
40
  const rangeHashesByPath = new Map();
39
41
  for (const r of orderedResults) {
40
42
  if (r?.mode !== 'full' || classifyResultKind(String(r.body || '')) === 'error') continue;
41
- const m = String(r.body || '').match(/\[lines\s+(\d+)-(\d+)\s+of\s+(\d+)/);
42
- if (!m) continue;
43
- const startLine = Number(m[1]);
44
- const endLine = Number(m[2]);
45
- if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) continue;
46
43
  const fullPath = resolveAgainstCwd(r.path, workDir);
44
+ const renderedRanges = renderedBatchReadRanges(r.body);
45
+ const ranges = _filterReadSnapshotRanges
46
+ ? _filterReadSnapshotRanges(r.readEntry, fullPath, readStateScope, renderedRanges)
47
+ : renderedRanges;
48
+ if (ranges.length === 0) continue;
47
49
  if (!exactRangesByPath.has(fullPath)) exactRangesByPath.set(fullPath, []);
48
- const range = { startLine, endLine };
49
- exactRangesByPath.get(fullPath).push(range);
50
- const renderedHashes = _rangeHashesFromRenderedReadText(r.body, [range]);
50
+ exactRangesByPath.get(fullPath).push(...ranges);
51
+ const renderedHashes = _rangeHashesFromRenderedReadText(r.body, ranges);
51
52
  if (renderedHashes.length > 0) {
52
53
  if (!rangeHashesByPath.has(fullPath)) rangeHashesByPath.set(fullPath, []);
53
54
  rangeHashesByPath.get(fullPath).push(...renderedHashes);
@@ -68,7 +69,7 @@ async function rawRangeHashes(fullPath, mergedRanges, { _hashText }) {
68
69
 
69
70
  export async function recordSlicedBatchSnapshots(orderedResults, workDir, readStateScope, helpers) {
70
71
  const { _mergeReadRanges, _recordReadSnapshot } = helpers;
71
- const { exactRangesByPath, rangeHashesByPath } = deliveredRangesByPath(orderedResults, workDir, helpers);
72
+ const { exactRangesByPath, rangeHashesByPath } = deliveredRangesByPath(orderedResults, workDir, readStateScope, helpers);
72
73
  for (const [fullPath, ranges] of exactRangesByPath) {
73
74
  const mergedRanges = _mergeReadRanges(ranges);
74
75
  let rangeHashes = rangeHashesByPath.get(fullPath) || [];
@@ -18,6 +18,8 @@ import { normalizeObjectBatchArgs, batchEntriesFromArgs, primaryReadIndexes } fr
18
18
  import { dispatchBatchReads } from './read-tool/batch-dispatch.mjs';
19
19
  import { restoreCallerSlots, recordSlicedBatchSnapshots } from './read-tool/batch-snapshot.mjs';
20
20
  import { renderBatchResults } from './read-tool/batch-render.mjs';
21
+ import { runBatchReadSnapshotPublication } from './read-tool/batch-publication.mjs';
22
+ import { runReadCachePublication } from './cache-layers.mjs';
21
23
 
22
24
  function hasLineCoordinate(path) {
23
25
  return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
@@ -54,17 +56,25 @@ async function executeBatchRead(args, workDir, readStateScope, executeChildBuilt
54
56
  const origEntries = Array.isArray(args._readsOrigEntries) ? args._readsOrigEntries : null;
55
57
  const entryMap = Array.isArray(args._readsEntryToDeduped) ? args._readsEntryToDeduped : null;
56
58
  const readIndexFor = primaryReadIndexes(entries, overrides, (p) => helpers.resolveAgainstCwd(p, workDir));
57
- const results = await dispatchBatchReads({
58
- entries,
59
- readIndexFor,
60
- workDir,
61
- options,
62
- helpers,
63
- executeChildBuiltinTool,
64
- });
65
- const orderedResults = origEntries ? restoreCallerSlots(results, origEntries, entryMap, options, helpers) : results;
66
- if (origEntries) await recordSlicedBatchSnapshots(orderedResults, workDir, readStateScope, helpers);
67
- return renderBatchResults(orderedResults, args, helpers);
59
+ const run = async (executeChild = executeChildBuiltinTool, snapshotHelpers = helpers) => {
60
+ const results = await dispatchBatchReads({
61
+ entries,
62
+ readIndexFor,
63
+ workDir,
64
+ options,
65
+ helpers,
66
+ executeChildBuiltinTool: executeChild,
67
+ });
68
+ const orderedResults = origEntries ? restoreCallerSlots(results, origEntries, entryMap, options, helpers) : results;
69
+ if (origEntries) await recordSlicedBatchSnapshots(orderedResults, workDir, readStateScope, snapshotHelpers);
70
+ return renderBatchResults(orderedResults, args, helpers);
71
+ };
72
+ if (!origEntries) return run();
73
+ // Keep the original batch lease for glance/media paths outside the scalar
74
+ // pipeline. Sliced evidence uses the producing child's retained lease, so
75
+ // a hash rejection never requires renewing this potentially stale parent.
76
+ const paths = entries.filter((entry) => entry?.path).map((entry) => helpers.resolveAgainstCwd(entry.path, workDir));
77
+ return runReadCachePublication(paths, () => runBatchReadSnapshotPublication(executeChildBuiltinTool, helpers, run));
68
78
  }
69
79
 
70
80
  // Glance modes served by their own child tool: the tool name and the args it
@@ -102,7 +102,7 @@ function findRepositoryRoot(startDirectory, { maxDepth = 64 } = {}) {
102
102
  const marker = pathJoin(current, '.git');
103
103
  try {
104
104
  const stats = statSync(marker);
105
- return { root: current, gitDirectory: stats.isDirectory() ? marker : null };
105
+ return { root: current, gitDirectory: stats.isDirectory() ? marker : _linkedGitDirectory(marker, current) };
106
106
  } catch {}
107
107
  const parent = pathDirname(current);
108
108
  if (parent === current) break;
@@ -111,6 +111,17 @@ function findRepositoryRoot(startDirectory, { maxDepth = 64 } = {}) {
111
111
  return null;
112
112
  }
113
113
 
114
+ // A linked worktree's `.git` file names its own git directory (`gitdir: …`),
115
+ // which holds that worktree's HEAD.
116
+ function _linkedGitDirectory(markerFile, root) {
117
+ try {
118
+ const target = /^gitdir:\s*(.+)$/m.exec(readFileSync(markerFile, 'utf8'))?.[1]?.trim();
119
+ return target ? pathResolveAbsolute(root, target) : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
114
125
  function _headBranch(gitDirectory) {
115
126
  if (!gitDirectory) return null;
116
127
  try {
@@ -180,7 +180,10 @@ async function waitForShellTask(task, taskId, args, options) {
180
180
  return interruptedByUser;
181
181
  }
182
182
 
183
- async function cancelShellTask(task, taskId, options) {
183
+ // Outcome of a shell cancel: `outcome` is 'cancelled' only when the process is
184
+ // confirmed gone (or never had one), 'unconfirmed' when the kill was delivered
185
+ // but the exit could not be confirmed, 'not-found' for an unknown task.
186
+ async function cancelShellTaskOutcome(task, taskId, options) {
184
187
  // A tracked task with no native job is still cancellable: the
185
188
  // registry's own cancel hook owns it (descendants that outlived
186
189
  // their shell). Remember that it WAS live so the outcome is
@@ -196,11 +199,14 @@ async function cancelShellTask(task, taskId, options) {
196
199
  if (confirmed && confirmed.status === 'running') {
197
200
  // Watcher and notify context stay armed: the eventual exit
198
201
  // must still reach the owner.
199
- return [
200
- 'status: cancel-unconfirmed',
201
- `task_id: ${taskId}`,
202
- 'the cancellation was delivered but the process has not exited yet. It stays tracked and its completion is still delivered; re-run task cancel or task read to check again.',
203
- ].join('\n');
202
+ return {
203
+ outcome: 'unconfirmed',
204
+ text: [
205
+ 'status: cancel-unconfirmed',
206
+ `task_id: ${taskId}`,
207
+ 'the cancellation was delivered but the process has not exited yet. It stays tracked and its completion is still delivered; re-run task cancel or task read to check again.',
208
+ ].join('\n'),
209
+ };
204
210
  }
205
211
  if (!confirmed) {
206
212
  // No terminal event and no observable task: absence of
@@ -209,19 +215,56 @@ async function cancelShellTask(task, taskId, options) {
209
215
  cancelBackgroundShellJobWatch(taskId);
210
216
  clearShellJobNotifyCtx(taskId);
211
217
  cancelBackgroundTask(taskId, 'cancelled by task control; termination unconfirmed');
212
- return [
213
- 'status: cancel-unconfirmed',
214
- `task_id: ${taskId}`,
215
- 'the cancellation was delivered but this task is no longer observable, so its termination could not be confirmed. Verify the process directly if it must be gone.',
216
- ].join('\n');
218
+ return {
219
+ outcome: 'unconfirmed',
220
+ text: [
221
+ 'status: cancel-unconfirmed',
222
+ `task_id: ${taskId}`,
223
+ 'the cancellation was delivered but this task is no longer observable, so its termination could not be confirmed. Verify the process directly if it must be gone.',
224
+ ].join('\n'),
225
+ };
217
226
  }
218
227
  }
219
228
  cancelBackgroundShellJobWatch(taskId);
220
229
  clearShellJobNotifyCtx(taskId);
221
230
  cancelBackgroundTask(taskId, 'cancelled by task control');
222
231
  return job || wasRunning
223
- ? renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task)
224
- : buildJobNotFoundMessage(taskId);
232
+ ? {
233
+ outcome: 'cancelled',
234
+ text: renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task),
235
+ }
236
+ : { outcome: 'not-found', text: buildJobNotFoundMessage(taskId) };
237
+ }
238
+
239
+ async function cancelShellTask(task, taskId, options) {
240
+ return (await cancelShellTaskOutcome(task, taskId, options)).text;
241
+ }
242
+
243
+ /**
244
+ * Stop one BACKGROUND shell job of the caller's session (desktop stop button).
245
+ * The same cancel the `task` tool runs, with a structured answer: `stopped` is
246
+ * true only when the job was actually cancelled. Only promoted/async jobs hold
247
+ * a background task, so a foreground command can never match; the session
248
+ * scope is the caller's, so another session's job reads as unknown.
249
+ */
250
+ export async function stopBackgroundShellJob(taskId, options = {}) {
251
+ const id = typeof taskId === 'string' ? taskId.trim() : '';
252
+ if (!id) return { stopped: false, error: 'task_id is required' };
253
+ const task = getBackgroundTask(id, { context: options });
254
+ if (!task || task.surface !== 'shell') return { stopped: false, error: `task not found: ${id}` };
255
+ const job = peekShellJob(id);
256
+ const status = job ? job.status : task.status;
257
+ if (status !== 'running') return { stopped: false, error: `task already ${status}: ${id}` };
258
+ try {
259
+ const { outcome, text } = await cancelShellTaskOutcome(task, id, options);
260
+ if (outcome === 'cancelled') return { stopped: true };
261
+ return {
262
+ stopped: false,
263
+ error: outcome === 'unconfirmed' ? `cancellation not confirmed: ${id}` : text.replace(/^Error:\s*/, ''),
264
+ };
265
+ } catch (error) {
266
+ return { stopped: false, error: error instanceof Error ? error.message : String(error) };
267
+ }
225
268
  }
226
269
 
227
270
  export async function executeTaskTool(args, options = {}) {
@@ -573,6 +573,64 @@ export function listCachedCodeGraphRoots() {
573
573
  }
574
574
  }
575
575
 
576
+ // Remove one root (exact canonical match) from the disk cache: its resident
577
+ // entry, its staged call-site sidecar, its manifest row and its <hash>.json /
578
+ // <hash>.calls.json files. The resident drop is synchronous and happens first,
579
+ // so a flush already scheduled for other roots can no longer write this one
580
+ // back; the manifest is edited under the same cross-process lock the persist
581
+ // path takes, so a concurrent writer merges the result instead of restoring
582
+ // the row. Never creates the cache directory. Returns { removed, kept }; kept
583
+ // are cache files that matched but could not be deleted.
584
+ export function forgetDiskCodeGraphRoot(root) {
585
+ const key = _canonicalGraphCwd(root);
586
+ const removed = [];
587
+ const kept = [];
588
+ if (_diskCodeGraphCache.has(key)) removed.push(`resident:${key}`);
589
+ _dropDiskEntry(key);
590
+ if (_pendingCallsSidecars.delete(key)) removed.push(`pending-calls-sidecar:${key}`);
591
+ const dir = _codeGraphDiskDir();
592
+ if (!existsSync(dir)) return { removed, kept };
593
+ withFileLockSync(
594
+ join(dir, '.persist.lock'),
595
+ () => {
596
+ const manifest = _readPreservedManifest(dir);
597
+ const hashes = new Set([_hashCwd(key)]);
598
+ let changed = false;
599
+ for (const [cwd, meta] of Object.entries(manifest)) {
600
+ let same = false;
601
+ try {
602
+ same = _canonicalGraphCwd(cwd) === key;
603
+ } catch {
604
+ /* a malformed key is not this root */
605
+ }
606
+ if (!same) continue;
607
+ if (_isCodeGraphCacheHash(meta?.hash)) hashes.add(meta.hash);
608
+ delete manifest[cwd];
609
+ changed = true;
610
+ removed.push(`manifest:${cwd}`);
611
+ }
612
+ for (const hash of hashes) {
613
+ for (const file of [join(dir, `${hash}.json`), callsSidecarPath(dir, hash)]) {
614
+ if (!existsSync(file)) continue;
615
+ try {
616
+ unlinkSync(file);
617
+ removed.push(file);
618
+ } catch {
619
+ kept.push(file);
620
+ }
621
+ }
622
+ }
623
+ if (changed) writeJsonAtomicSync(join(dir, 'manifest.json'), manifest, { compact: true, lock: false });
624
+ if (_diskManifest) {
625
+ delete _diskManifest[key];
626
+ if (changed) _diskManifest = manifest;
627
+ }
628
+ },
629
+ { timeoutMs: 10_000 }
630
+ );
631
+ return { removed, kept };
632
+ }
633
+
576
634
  // ── call-site sidecar ───────────────────────────────────────────────────────
577
635
  // Resolve the sidecar file of a canonical cwd. The manifest hash wins when the
578
636
  // entry is known (it is what the persist loop wrote); otherwise the hash is
@@ -1,6 +1,6 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
3
- import { listProjects } from '../../../../shared/projects.mjs';
3
+ import { listProjects, removeProject } from '../../../../shared/projects.mjs';
4
4
  import { explicitSessionCwd, readLastSessionCwd } from '../../../../shared/user-cwd.mjs';
5
5
  import { canonicalGraphPath } from '../code-graph-state.mjs';
6
6
  import { listCachedCodeGraphRoots } from './disk-cache.mjs';
@@ -85,6 +85,30 @@ export function collectTrustedCodeGraphRoots(
85
85
  return [...found.values()].sort((a, b) => a.localeCompare(b));
86
86
  }
87
87
 
88
+ /**
89
+ * Stop trusting one root as a federation target. Trust is derived from the
90
+ * Project registry, the session's selected cwd and the cache manifest; the
91
+ * manifest row goes with forgetDiskCodeGraphRoot() and a selected cwd is
92
+ * dropped by the directory check once the folder is gone, so the registry is
93
+ * the only stored source left. Only the exact root is removed (nested Projects
94
+ * stay). Returns { removed, kept }; kept are registered paths that matched but
95
+ * could not be unregistered.
96
+ */
97
+ export function forgetTrustedCodeGraphRoot(
98
+ root,
99
+ { registered = () => listProjects().map((entry) => entry.path), unregister = removeProject } = {}
100
+ ) {
101
+ const removed = [];
102
+ const kept = [];
103
+ const key = canonicalGraphPath(root);
104
+ for (const path of registered()) {
105
+ if (!path || canonicalGraphPath(path) !== key) continue;
106
+ if (unregister(path)) removed.push(path);
107
+ else kept.push(path);
108
+ }
109
+ return { removed, kept };
110
+ }
111
+
88
112
  export function owningTrustedCodeGraphRoot(file, roots) {
89
113
  const matches = (roots || []).filter((root) => _pathIsWithin(root, file));
90
114
  matches.sort((a, b) => resolve(b).length - resolve(a).length);
@@ -1,6 +1,7 @@
1
1
  // UI-diff side channel: a committed apply_patch (or edit-tool write) records
2
2
  // a turn-review change entry and a per-call diff the desktop/TUI tool cards
3
3
  // pick up. Display only — never affects edit semantics.
4
+ import { createTwoFilesPatch } from 'diff';
4
5
  import { boundReviewPatch } from '../../../../shared/review-diff.mjs';
5
6
  import { recordTurnDiffChanges } from '../../../../shared/turn-snapshot.mjs';
6
7
  import { pathKey, resolveV4AEntryPath } from './paths.mjs';
@@ -29,6 +30,77 @@ export function takeApplyPatchUiDiff(callId) {
29
30
  return value;
30
31
  }
31
32
 
33
+ // A display path is a plain relative or absolute path; the patch header only
34
+ // needs forward slashes. Its own leading "a/" is part of the name.
35
+ function headerPath(value) {
36
+ return String(value || '')
37
+ .trim()
38
+ .replace(/\\/g, '/');
39
+ }
40
+
41
+ const DIFF_TIMEOUT_MS = 100;
42
+
43
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
44
+
45
+ function isOmitted(value) {
46
+ return Boolean(value) && typeof value === 'object' && !(value instanceof Uint8Array) && value.omitted === true;
47
+ }
48
+
49
+ // Text of one side: '' for an absent file, null when it is not UTF-8 text.
50
+ function sideText(value) {
51
+ if (value == null) return '';
52
+ if (typeof value === 'string') return value;
53
+ if (value instanceof Uint8Array) {
54
+ try {
55
+ return utf8.decode(value);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // One file's change, from the call's own before and after states: absent
64
+ // (null), bytes or text, or an omitted marker when the bytes were not kept.
65
+ function renderCallChange({ displayPath, newDisplayPath, before, after }) {
66
+ const oldName = headerPath(displayPath) || 'unknown file';
67
+ const newName = headerPath(newDisplayPath) || oldName;
68
+ const oldHeader = before == null ? '/dev/null' : `a/${oldName}`;
69
+ const newHeader = after == null ? '/dev/null' : `b/${newName}`;
70
+ let head = `diff --git a/${oldName} b/${newName}\n`;
71
+ if (before == null && after != null) head += 'new file mode 100644\n';
72
+ else if (before != null && after == null) head += 'deleted file mode 100644\n';
73
+ if (isOmitted(before) || isOmitted(after)) {
74
+ return `${head}Binary files ${oldHeader} and ${newHeader} differ\n`;
75
+ }
76
+ const oldText = sideText(before);
77
+ const newText = sideText(after);
78
+ if (oldText === null || newText === null) return `${head}Binary files ${oldHeader} and ${newHeader} differ\n`;
79
+ if (oldText === newText) {
80
+ // An empty file created or deleted still changed: git writes the mode line alone.
81
+ if ((before == null) !== (after == null)) return head;
82
+ return oldName === newName ? '' : `${head}similarity index 100%\nrename from ${oldName}\nrename to ${newName}\n`;
83
+ }
84
+ const rename = oldName === newName ? '' : `rename from ${oldName}\nrename to ${newName}\n`;
85
+ try {
86
+ const generated = createTwoFilesPatch(oldHeader, newHeader, oldText, newText, '', '', {
87
+ context: 3,
88
+ timeout: DIFF_TIMEOUT_MS,
89
+ });
90
+ const start = generated.search(/^--- /m);
91
+ if (start >= 0) return `${head}${rename}${generated.slice(start).replace(/\n*$/, '\n')}`;
92
+ } catch {
93
+ // A pathological diff timing out must not stall tool completion.
94
+ }
95
+ return `${head}${rename}Binary files ${oldHeader} and ${newHeader} differ\n`;
96
+ }
97
+
98
+ // A call's card shows the change that call made — its own before against its
99
+ // own after — never the turn's running total for the same files.
100
+ export function callUiDiff(changes) {
101
+ return (changes || []).map(renderCallChange).join('');
102
+ }
103
+
32
104
  // Edit-tool UI diff: a successful single-file str-replace edit records the
33
105
  // same turn-review change entry and per-call UI diff side channel that a
34
106
  // committed apply_patch gets, so desktop/TUI tool cards render both edit
@@ -36,15 +108,16 @@ export function takeApplyPatchUiDiff(callId) {
36
108
  export function registerEditToolUiDiff({ callId, sessionId, basePath, fullPath, before, after }) {
37
109
  if (!callId || !sessionId || typeof fullPath !== 'string') return;
38
110
  try {
39
- const turnDiff = recordTurnDiffChanges(sessionId, [
111
+ const changes = [
40
112
  {
41
113
  path: fullPath,
42
114
  displayPath: patchHeaderPathForResolved(basePath || '', fullPath),
43
115
  before: before ?? null,
44
116
  after: after ?? null,
45
117
  },
46
- ]);
47
- registerApplyPatchUiDiff(callId, turnDiff);
118
+ ];
119
+ recordTurnDiffChanges(sessionId, changes);
120
+ registerApplyPatchUiDiff(callId, callUiDiff(changes));
48
121
  } catch {
49
122
  // best-effort display channel
50
123
  }
@@ -111,8 +184,8 @@ export function registerCommittedPatchUiDiff({
111
184
  after: snapshotContent(after),
112
185
  });
113
186
  }
114
- const turnDiff = recordTurnDiffChanges(sessionId, changes);
115
- registerApplyPatchUiDiff(callId, turnDiff);
187
+ recordTurnDiffChanges(sessionId, changes);
188
+ registerApplyPatchUiDiff(callId, callUiDiff(changes));
116
189
  } catch {
117
190
  // Review collection is a side channel and must never affect committed
118
191
  // apply_patch success/failure semantics.
@@ -75,6 +75,8 @@ export function formatToolStartProgress(name, args = {}) {
75
75
  }
76
76
  case 'terminal':
77
77
  return a.action === 'read' ? `reading terminal${a.tab ? ` ${_t(a.tab)}` : ''}` : 'listing terminals';
78
+ case 'visualize':
79
+ return `visualize ${_t(a.action || 'command')}`;
78
80
  case 'office':
79
81
  return a.path ? `office ${_t(a.action || 'command')} ${_t(a.path)}` : `office ${_t(a.action || 'command')}`;
80
82
  case 'media': {
@@ -8,9 +8,13 @@
8
8
  */
9
9
  import { createHash, randomBytes } from 'node:crypto';
10
10
  import {
11
+ closeSync,
11
12
  existsSync,
13
+ fstatSync,
12
14
  mkdirSync,
15
+ openSync,
13
16
  readFileSync,
17
+ readSync,
14
18
  renameSync,
15
19
  statSync,
16
20
  unlinkSync,
@@ -303,6 +307,40 @@ function readAttachmentText(value) {
303
307
  return readAttachmentBuffer(value).toString('utf8');
304
308
  }
305
309
 
310
+ /**
311
+ * A referenced blob's UTF-8 text when the blob on disk holds at most
312
+ * `maxBytes`, else null. The size comes from the opened file, never from the
313
+ * reference's claimed sizeBytes, and at most `maxBytes + 1` bytes are read, so
314
+ * a blob that grows between the check and the read is rejected too. Missing,
315
+ * invalid or tampered blobs throw like readAttachmentBuffer.
316
+ */
317
+ export function readAttachmentTextWithin(value, maxBytes) {
318
+ const ref = typeof value === 'string' ? value : value?.attachmentRef;
319
+ const fd = openSync(attachmentPath(ref), 'r');
320
+ try {
321
+ const info = fstatSync(fd);
322
+ if (!info.isFile() || info.size <= 0) {
323
+ throw Object.assign(new Error('prompt attachment blob is invalid'), { code: ATTACHMENT_INVALID });
324
+ }
325
+ if (info.size > maxBytes) return null;
326
+ const buffer = Buffer.alloc(info.size + 1);
327
+ let length = 0;
328
+ while (length < buffer.length) {
329
+ const read = readSync(fd, buffer, length, buffer.length - length, length);
330
+ if (read === 0) break;
331
+ length += read;
332
+ }
333
+ if (length > maxBytes) return null;
334
+ const bytes = buffer.subarray(0, length);
335
+ if (attachmentRefForBuffer(bytes) !== ref) {
336
+ throw Object.assign(new Error('prompt attachment integrity check failed'), { code: ATTACHMENT_INTEGRITY });
337
+ }
338
+ return bytes.toString('utf8');
339
+ } finally {
340
+ closeSync(fd);
341
+ }
342
+ }
343
+
306
344
  export function attachmentTextForPart(part) {
307
345
  if (!isAttachmentReference(part)) return typeof part?.text === 'string' ? part.text : '';
308
346
  const text = readAttachmentText(part);