mixdog 0.9.147 → 0.9.149

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 (265) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +5 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -19,8 +19,9 @@ import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './pa
19
19
  import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component, isBlockedDevicePath, isSpecialFileStat } from './device-paths.mjs';
20
20
  import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
21
21
  import { executeBashTool } from './bash-tool.mjs';
22
- import { runServerEdit } from '../patch/native-server.mjs';
23
- import { nearestPatchLineMatch } from '../patch/matcher.mjs';
22
+ import { nativeEditSessionSatisfiesContract, runServerEdit } from '../patch/native-server.mjs';
23
+ import { decodeValidUtf8OrNull, detectPatchTargetCodec, nearestPatchLineMatch } from '../patch/matcher.mjs';
24
+ import { hashText } from './hash-utils.mjs';
24
25
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
25
26
  import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
26
27
 
@@ -134,12 +135,17 @@ function editScope(options) {
134
135
  return options?.readStateScope || options?.sessionId || null;
135
136
  }
136
137
 
137
- function recordEditSnapshot(fullPath, options, contentHash = null) {
138
+ // `preMutationStat` is the target's identity immediately BEFORE this write. The
139
+ // snapshot recorder needs it to prove the session's earlier read still
140
+ // described the file at edit time; without that proof an external change made
141
+ // between the read and the edit would stay hidden behind "[file unchanged]".
142
+ function recordEditSnapshot(fullPath, options, contentHash = null, preMutationStat = null) {
138
143
  try {
139
144
  recordReadSnapshot(fullPath, statSync(fullPath), editScope(options), {
140
145
  source: 'edit',
141
146
  isPartialView: false,
142
147
  replaceExisting: true,
148
+ preMutationStat,
143
149
  ...(contentHash ? { contentHash } : {}),
144
150
  });
145
151
  } catch { /* best-effort */ }
@@ -159,13 +165,6 @@ async function recordEditUiDiff(options, workDir, fullPath, before, after) {
159
165
  } catch { /* best-effort display channel */ }
160
166
  }
161
167
 
162
- function countOccurrences(haystack, needle) {
163
- let count = 0;
164
- let idx = -1;
165
- while ((idx = haystack.indexOf(needle, idx + 1)) !== -1) count += 1;
166
- return count;
167
- }
168
-
169
168
  function formatEditFailureExcerpt(content, oldStr, errorText) {
170
169
  const source = String(content ?? '').replace(/\r\n/g, '\n');
171
170
  const lines = source.split('\n');
@@ -270,6 +269,162 @@ function applyFileQuoteStyle(actualSpan, replacement) {
270
269
  return out.join('');
271
270
  }
272
271
 
272
+ // ---------------------------------------------------------------------------
273
+ // Encoding / line-ending fidelity (Windows-first).
274
+ //
275
+ // The edit path used to decode every target as UTF-8 and hand the native
276
+ // engine LF-delimited UTF-8 bytes. On Windows that fails twice: a multiline
277
+ // old_string copied out of Read (LF) can never match a CRLF file, and a
278
+ // UTF-16 file decoded as UTF-8 is mangled on write. Both are handled here:
279
+ // the file's BOM decides the codec, the file's dominant EOL decides the line
280
+ // endings of old_string/new_string, and non-UTF-8 targets are edited in-process
281
+ // (the native engine's payload is UTF-8 by protocol) and re-encoded in place.
282
+ // ---------------------------------------------------------------------------
283
+ const UTF8_BOM_BYTES = Buffer.from([0xEF, 0xBB, 0xBF]);
284
+ const UTF16LE_BOM_BYTES = Buffer.from([0xFF, 0xFE]);
285
+ const UTF16BE_BOM_BYTES = Buffer.from([0xFE, 0xFF]);
286
+
287
+ /**
288
+ * Decode on-disk bytes. Returns null when the file cannot be round-tripped:
289
+ * invalid UTF-8, or UTF-16 with an odd trailing byte (that byte is undecodable
290
+ * and would be silently dropped on re-encode).
291
+ */
292
+ function decodeEditBuffer(rawBuf, enc) {
293
+ if (enc.encoding === 'utf16le' || enc.encoding === 'utf16be') {
294
+ const body = rawBuf.subarray(enc.bomLen);
295
+ if (body.length % 2 !== 0) return null;
296
+ return enc.encoding === 'utf16le'
297
+ ? body.toString('utf16le')
298
+ // Node has no 'utf16be': swap pairs into LE and decode.
299
+ : Buffer.from(body).swap16().toString('utf16le');
300
+ }
301
+ return decodeValidUtf8OrNull(rawBuf.subarray(enc.bomLen));
302
+ }
303
+
304
+ function encodeEditText(text, enc) {
305
+ if (enc?.encoding === 'utf16le') return Buffer.from(String(text), 'utf16le');
306
+ if (enc?.encoding === 'utf16be') return Buffer.from(String(text), 'utf16le').swap16();
307
+ return Buffer.from(String(text), 'utf8');
308
+ }
309
+
310
+ /** Whole-file payload: re-attaches the BOM the decode stripped. */
311
+ function encodeEditContent(text, enc) {
312
+ const body = encodeEditText(text, enc);
313
+ if (!enc?.bomLen) return body;
314
+ const bom = enc.encoding === 'utf16le'
315
+ ? UTF16LE_BOM_BYTES
316
+ : (enc.encoding === 'utf16be' ? UTF16BE_BOM_BYTES : UTF8_BOM_BYTES);
317
+ return Buffer.concat([bom, body]);
318
+ }
319
+
320
+ function convertTextEol(text, eol) {
321
+ const lf = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
322
+ return eol === '\n' ? lf : lf.replace(/\n/g, eol);
323
+ }
324
+
325
+ const EDIT_EOL_RE = /\r\n|\n|\r/;
326
+
327
+ function textEolStyle(text) {
328
+ if (text.includes('\r\n')) return '\r\n';
329
+ if (text.includes('\n')) return '\n';
330
+ if (text.includes('\r')) return '\r';
331
+ return null;
332
+ }
333
+
334
+ // The convention SURROUNDING one match: the style inside the matched span,
335
+ // else the terminator of the line the match sits on. Only the replaced span
336
+ // may adopt it — the file is never normalized, so a deliberately mixed region
337
+ // keeps every terminator the edit did not replace.
338
+ function surroundingEol(content, matchStart, matchEnd, matched) {
339
+ const inSpan = textEolStyle(matched);
340
+ if (inSpan) return inSpan;
341
+ const after = EDIT_EOL_RE.exec(content.slice(matchEnd));
342
+ if (after) return after[0];
343
+ const before = content.slice(0, matchStart);
344
+ const nl = before.lastIndexOf('\n');
345
+ const cr = before.lastIndexOf('\r');
346
+ if (nl > 0 && before[nl - 1] === '\r') return '\r\n';
347
+ if (nl >= 0 || cr >= 0) return nl > cr ? '\n' : '\r';
348
+ return null;
349
+ }
350
+
351
+ function findEditOccurrences(content, needle) {
352
+ const out = [];
353
+ if (!needle) return out;
354
+ for (let at = content.indexOf(needle); at !== -1; at = content.indexOf(needle, at + needle.length)) {
355
+ out.push(at);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ // Match plan. old_string is rebased onto the file's own line endings for
361
+ // MATCHING only, and every occurrence gets its own replacement text speaking
362
+ // that occurrence's local convention. Nothing outside a matched span is ever
363
+ // considered, let alone rewritten.
364
+ function planEdit(content, oldStr, newStr, { absorbTrailingNewline = false } = {}) {
365
+ let matchOld = oldStr;
366
+ let rebased = false;
367
+ if (!content.includes(matchOld) && EDIT_EOL_RE.test(matchOld)) {
368
+ for (const eol of ['\r\n', '\n', '\r']) {
369
+ const candidate = convertTextEol(matchOld, eol);
370
+ if (candidate !== matchOld && content.includes(candidate)) {
371
+ matchOld = candidate;
372
+ rebased = true;
373
+ break;
374
+ }
375
+ }
376
+ // Block deletion absorbs its own trailing terminator so no blank line
377
+ // is left behind; the pre-rebase attempt could not see the file's real
378
+ // endings.
379
+ if (rebased && absorbTrailingNewline && newStr === '' && !EDIT_EOL_RE.test(matchOld.slice(-2))) {
380
+ for (const eol of ['\r\n', '\n', '\r']) {
381
+ if (content.includes(`${matchOld}${eol}`)) {
382
+ matchOld += eol;
383
+ break;
384
+ }
385
+ }
386
+ }
387
+ }
388
+ const positions = findEditOccurrences(content, matchOld);
389
+ const replacements = positions.map((at) => {
390
+ if (!EDIT_EOL_RE.test(newStr)) return newStr;
391
+ const eol = surroundingEol(content, at, at + matchOld.length, matchOld);
392
+ return eol ? convertTextEol(newStr, eol) : newStr;
393
+ });
394
+ return {
395
+ oldStr: matchOld,
396
+ rebased,
397
+ positions,
398
+ replacements,
399
+ uniform: replacements.every((replacement) => replacement === newStr),
400
+ };
401
+ }
402
+
403
+ // Splice the planned replacements into the content. Everything outside a
404
+ // matched span is copied verbatim, so the result differs from the input only
405
+ // inside the replaced spans.
406
+ function applyEditPlan(content, oldStr, plan, replaceAll) {
407
+ const count = replaceAll ? plan.positions.length : Math.min(1, plan.positions.length);
408
+ if (count === 0) return null;
409
+ let out = '';
410
+ let cursor = 0;
411
+ for (let k = 0; k < count; k++) {
412
+ const at = plan.positions[k];
413
+ out += content.slice(cursor, at) + plan.replacements[k];
414
+ cursor = at + oldStr.length;
415
+ }
416
+ return out + content.slice(cursor);
417
+ }
418
+
419
+ function readEditTextForDisplay(fullPath, enc) {
420
+ try {
421
+ const decoded = decodeEditBuffer(readFileSync(fullPath), enc);
422
+ return typeof decoded === 'string' ? decoded : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
273
428
  async function adaptStrReplace(args, workDir, options) {
274
429
  let oldStr = args?.old_string;
275
430
  let newStr = args?.new_string;
@@ -301,17 +456,28 @@ async function adaptStrReplace(args, workDir, options) {
301
456
  return `Created ${fullPath} (${Buffer.byteLength(newStr, 'utf8')} bytes)`;
302
457
  }
303
458
  let content;
459
+ let fileEnc = { encoding: 'utf8', bomLen: 0 };
304
460
  let statBefore = null;
305
461
  try {
306
462
  try { statBefore = statSync(fullPath); } catch { /* readFileSync below surfaces the real error */ }
307
- content = readFileSync(fullPath, 'utf8');
463
+ const rawBuf = readFileSync(fullPath);
464
+ // Codec by DETECTION (BOM, else NUL parity), not by BOM presence: a
465
+ // BOM-less UTF-16 file must not be spliced as UTF-8.
466
+ fileEnc = detectPatchTargetCodec(rawBuf);
467
+ content = fileEnc.certain ? decodeEditBuffer(rawBuf, fileEnc) : null;
308
468
  } catch (err) {
309
469
  return `Error: cannot read ${fullPath} (${err?.message || err})`;
310
470
  }
471
+ if (typeof content !== 'string') {
472
+ return `Error: cannot edit ${normalizeOutputPath(fullPath)}: its bytes are not decidable text`
473
+ + ' (invalid UTF-8, malformed UTF-16 with an odd trailing byte, or NUL bytes without a'
474
+ + ' consistent UTF-16 pattern), so any edit would alter bytes outside the replacement.'
475
+ + ' Convert the encoding first.';
476
+ }
311
477
  if (oldStr.length === 0) {
312
478
  if (content.length > 0) return `Error: cannot create ${fullPath}: file already exists and is not empty`;
313
479
  try {
314
- await atomicWrite(fullPath, newStr, {
480
+ await atomicWrite(fullPath, encodeEditContent(newStr, fileEnc), {
315
481
  sessionId: editScope(options),
316
482
  signal: options?.signal,
317
483
  expectedTargetSnapshot: {
@@ -329,7 +495,7 @@ async function adaptStrReplace(args, workDir, options) {
329
495
  throw err;
330
496
  }
331
497
  invalidateAfterWrite(fullPath);
332
- recordEditSnapshot(fullPath, options);
498
+ recordEditSnapshot(fullPath, options, null, statBefore);
333
499
  await recordEditUiDiff(options, workDir, fullPath, content, newStr);
334
500
  return `Updated ${fullPath} (filled empty file)`;
335
501
  }
@@ -366,6 +532,17 @@ async function adaptStrReplace(args, workDir, options) {
366
532
  }
367
533
  if (oldStr === newStr) return 'Error: old_string and new_string are exactly the same';
368
534
  }
535
+ // EOL planning runs for EVERY edit (batch members included): a CRLF file is
536
+ // the Windows default, the rebase is 1:1 over the same match positions, and
537
+ // the replacement adopts the convention of the span it replaces.
538
+ const editPlan = planEdit(content, oldStr, newStr, {
539
+ absorbTrailingNewline: !options?.editOccurrence,
540
+ });
541
+ oldStr = editPlan.oldStr;
542
+ if (editPlan.positions.length > 0
543
+ && editPlan.replacements.every((replacement) => replacement === oldStr)) {
544
+ return 'Error: old_string and new_string are exactly the same';
545
+ }
369
546
  // Batch sequential occupation (tool-batch `_editSeqGroups`): this call is
370
547
  // the next member of a same-anchor edit batch and exactly `expected`
371
548
  // occurrences of old_string must remain. Deterministically consume the
@@ -374,15 +551,12 @@ async function adaptStrReplace(args, workDir, options) {
374
551
  // drift falls through to the native engine's strict ambiguity reject.
375
552
  const _occupation = options?.editOccurrence;
376
553
  if (_occupation && !replaceAll && Number.isInteger(_occupation.expected) && _occupation.expected >= 2) {
377
- const positions = [];
378
- for (let at = content.indexOf(oldStr); at !== -1; at = content.indexOf(oldStr, at + oldStr.length)) {
379
- positions.push(at);
380
- }
554
+ const positions = editPlan.positions;
381
555
  if (positions.length === _occupation.expected) {
382
556
  const at = positions[0];
383
- const next = `${content.slice(0, at)}${newStr}${content.slice(at + oldStr.length)}`;
557
+ const next = `${content.slice(0, at)}${editPlan.replacements[0]}${content.slice(at + oldStr.length)}`;
384
558
  try {
385
- await atomicWrite(fullPath, next, {
559
+ await atomicWrite(fullPath, encodeEditContent(next, fileEnc), {
386
560
  sessionId: editScope(options),
387
561
  signal: options?.signal,
388
562
  expectedTargetSnapshot: statBefore ? {
@@ -400,11 +574,55 @@ async function adaptStrReplace(args, workDir, options) {
400
574
  throw err;
401
575
  }
402
576
  invalidateAfterWrite(fullPath);
403
- recordEditSnapshot(fullPath, options);
577
+ recordEditSnapshot(fullPath, options, null, statBefore);
404
578
  await recordEditUiDiff(options, workDir, fullPath, content, next);
405
579
  return `Updated ${fullPath} (1 replacement)`;
406
580
  }
407
581
  }
582
+ // In-process apply, used whenever the native engine cannot be trusted with
583
+ // these bytes: a non-UTF-8 codec (its EDIT protocol is UTF-8), an
584
+ // old_string rebased onto the file's line endings, or per-occurrence
585
+ // replacement text. The splice copies the file verbatim around each
586
+ // matched span, so no byte outside a replacement can change.
587
+ // An unverified native edit session never receives work: the in-process
588
+ // apply below is the fallback, so a stale/spoofed engine cannot touch bytes.
589
+ const nativeEditVerified = await nativeEditSessionSatisfiesContract();
590
+ if (!nativeEditVerified || fileEnc.encoding !== 'utf8' || editPlan.rebased || !editPlan.uniform) {
591
+ const occurrences = editPlan.positions.length;
592
+ if (occurrences === 0) {
593
+ const message = 'old_string not found';
594
+ return `Error: edit failed (${message})${formatEditFailureExcerpt(content, oldStr, message)}`;
595
+ }
596
+ if (occurrences > 1 && !replaceAll) {
597
+ const message = `old_string found ${occurrences} times`;
598
+ return `Error: edit failed (${message}; pass replace_all:true or extend old_string)`
599
+ + formatEditFailureExcerpt(content, oldStr, message);
600
+ }
601
+ const applied = replaceAll ? occurrences : 1;
602
+ const next = applyEditPlan(content, oldStr, editPlan, replaceAll);
603
+ try {
604
+ await atomicWrite(fullPath, encodeEditContent(next, fileEnc), {
605
+ sessionId: editScope(options),
606
+ signal: options?.signal,
607
+ expectedTargetSnapshot: statBefore ? {
608
+ exists: true,
609
+ size: statBefore.size,
610
+ mtimeMs: statBefore.mtimeMs,
611
+ ctimeMs: statBefore.ctimeMs,
612
+ ino: statBefore.ino,
613
+ } : { exists: false },
614
+ });
615
+ } catch (err) {
616
+ if (err?.code === 'ESTALE_TARGET') {
617
+ return `Error: ${fullPath} changed on disk during the edit; read it again`;
618
+ }
619
+ throw err;
620
+ }
621
+ invalidateAfterWrite(fullPath);
622
+ recordEditSnapshot(fullPath, options, null, statBefore);
623
+ await recordEditUiDiff(options, workDir, fullPath, content, next);
624
+ return `Updated ${fullPath} (${applied} replacement${applied === 1 ? '' : 's'})`;
625
+ }
408
626
  let result;
409
627
  try {
410
628
  result = await runServerEdit({
@@ -417,17 +635,29 @@ async function adaptStrReplace(args, workDir, options) {
417
635
  } catch (err) {
418
636
  const message = err?.message || String(err);
419
637
  if (/old_string (?:not found|found \d+ times)/i.test(message)) {
420
- let latest = content;
421
- try { latest = readFileSync(fullPath, 'utf8'); } catch { /* retain pre-dispatch content */ }
638
+ const latest = readEditTextForDisplay(fullPath, fileEnc) ?? content;
422
639
  return `Error: edit failed (${message})${formatEditFailureExcerpt(latest, oldStr, message)}`;
423
640
  }
424
641
  throw err;
425
642
  }
426
643
  invalidateAfterWrite(fullPath);
427
- recordEditSnapshot(fullPath, options, result.contentHash);
644
+ // TOCTOU proof for the native route: the engine reads and writes the file
645
+ // itself, so a stat taken before the call cannot vouch for what it edited.
646
+ // Our pre-edit content plus the planned replacement must hash to exactly
647
+ // what the engine wrote; anything else means it saw different bytes, so the
648
+ // session's earlier read is not evidence of the pre-edit body.
649
+ const expectedAfter = applyEditPlan(content, oldStr, editPlan, replaceAll);
650
+ const editedExactlyWhatWeRead = typeof result?.contentHash === 'string'
651
+ && expectedAfter !== null
652
+ && result.contentHash === hashText(expectedAfter);
653
+ recordEditSnapshot(
654
+ fullPath,
655
+ options,
656
+ result.contentHash,
657
+ editedExactlyWhatWeRead ? statBefore : null,
658
+ );
428
659
  {
429
- let after = null;
430
- try { after = readFileSync(fullPath, 'utf8'); } catch { /* diff omitted */ }
660
+ const after = readEditTextForDisplay(fullPath, fileEnc);
431
661
  if (typeof after === 'string') await recordEditUiDiff(options, workDir, fullPath, content, after);
432
662
  }
433
663
  return `Updated ${fullPath} (${result.replacements} replacement${result.replacements === 1 ? '' : 's'})`;
@@ -0,0 +1,87 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { _searchRemainingMsForTest } from './native-search-client.mjs';
8
+ import { executeFuzzyFindTool } from './list-tool.mjs';
9
+ import { runWithLocalSearchTelemetry } from './local-search-telemetry.mjs';
10
+
11
+ test('server setup is not charged to the search budget', () => {
12
+ const deadline = 1_500;
13
+ const startedAt = 0;
14
+ // 1000ms of the elapsed wall time was server startup: the search itself has
15
+ // not begun, so the full deadline must still be available.
16
+ assert.equal(_searchRemainingMsForTest(deadline, startedAt, 1_000, 1_000), 1_500);
17
+ // Once searching starts, elapsed search time does reduce the budget.
18
+ assert.equal(_searchRemainingMsForTest(deadline, startedAt, 1_000, 1_400), 1_100);
19
+ // No setup recorded means the old behaviour: every elapsed ms counts.
20
+ assert.equal(_searchRemainingMsForTest(deadline, startedAt, 0, 400), 1_100);
21
+ // The budget never goes to zero or negative, which would break timers.
22
+ assert.equal(_searchRemainingMsForTest(deadline, startedAt, 0, 9_000), 1);
23
+ });
24
+
25
+ test('a fuzzy deadline recovers a real candidate through one targeted path scan', async () => {
26
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-budget-'));
27
+ try {
28
+ writeFileSync(join(root, 'anchor.txt'), 'anchor\n');
29
+ const timeout = Object.assign(
30
+ new Error('native fuzzy search timed out after 1500ms.'),
31
+ { code: 'NATIVE_SEARCH_TIMEOUT' },
32
+ );
33
+ let targetedEnumerationCalls = 0;
34
+ let targetedArgs = null;
35
+ const telemetry = {};
36
+ const out = String(await runWithLocalSearchTelemetry(telemetry, () => executeFuzzyFindTool(
37
+ { query: 'needle-that-never-ranks', path: '.', head_limit: 5 },
38
+ root,
39
+ {
40
+ __tryServeFuzzySearch: async () => { throw timeout; },
41
+ __runRg: async (args) => {
42
+ targetedEnumerationCalls += 1;
43
+ targetedArgs = args;
44
+ return 'src/needle-that-never-ranks.ts\n';
45
+ },
46
+ },
47
+ )));
48
+ assert.ok(!/^Error[\s:[]/.test(out.trimStart()), `must not surface as a tool failure:\n${out}`);
49
+ assert.match(out, /src\/needle-that-never-ranks\.ts/);
50
+ assert.match(out, /targeted path matches shown/);
51
+ assert.equal(targetedEnumerationCalls, 1);
52
+ assert.ok(targetedArgs.includes('--iglob'), `expected a targeted --iglob scan: ${JSON.stringify(targetedArgs)}`);
53
+ assert.equal(telemetry.native_fuzzy_partials, 1);
54
+ assert.equal(telemetry.native_fuzzy_targeted_hits, 1);
55
+ assert.equal(telemetry.native_fuzzy_errors, undefined);
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test('a served partial still reports its ranked matches', async () => {
62
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-partial-'));
63
+ try {
64
+ const out = String(await executeFuzzyFindTool(
65
+ { query: 'alpha', path: '.', head_limit: 5 },
66
+ root,
67
+ {
68
+ __tryServeFuzzySearch: async () => ({
69
+ matches: ['src/alpha.ts'],
70
+ hasMore: false,
71
+ totalMatches: 1,
72
+ totalSeen: 1,
73
+ complete: false,
74
+ partial: true,
75
+ timeout: true,
76
+ scanErrors: 0,
77
+ walkErrorDetails: [],
78
+ }),
79
+ __runRg: async () => '',
80
+ },
81
+ ));
82
+ assert.match(out, /src\/alpha\.ts/);
83
+ assert.ok(!/^Error[\s:[]/.test(out.trimStart()), `partials are results, not failures:\n${out}`);
84
+ } finally {
85
+ rmSync(root, { recursive: true, force: true });
86
+ }
87
+ });
@@ -7,7 +7,7 @@ const ALWAYS_READ = new Set([
7
7
  'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
8
8
  'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
9
9
  'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
10
- 'whatchanged',
10
+ 'version', 'whatchanged',
11
11
  ]);
12
12
 
13
13
  const SHELL_OPERATOR_CHARS = '|&;<>()';
@@ -1,6 +1,7 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
3
  import { tmpdir } from 'node:os';
3
- import { basename, join, resolve } from 'node:path';
4
+ import { basename, dirname, join, resolve } from 'node:path';
4
5
  import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
5
6
  import { withBuiltinPathLocks } from './path-locks.mjs';
6
7
  import { withAdvisoryLocks } from './advisory-lock.mjs';
@@ -32,7 +33,19 @@ const SUPPORTED = new Set([
32
33
  'revert', 'rev-list', 'rev-parse', 'rm', 'shortlog', 'show', 'show-branch',
33
34
  'show-ref', 'sparse-checkout', 'stash', 'status', 'submodule', 'switch',
34
35
  'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
35
- 'verify-tag', 'whatchanged', 'worktree', 'write-tree',
36
+ 'verify-tag', 'version', 'whatchanged', 'worktree', 'write-tree',
37
+ ]);
38
+ // `git --version` / `git --help` are the standard availability probes and
39
+ // arrive in global-flag position, not as subcommands. Map them onto their
40
+ // subcommand form so the probe answers instead of erroring out.
41
+ // These answer anywhere: demanding a repository first turned an availability
42
+ // probe into `git rev-parse exited 128` inside a plain directory.
43
+ const REPO_FREE_OPERATIONS = new Set(['version', 'help']);
44
+ const OPERATION_ALIASES = new Map([
45
+ ['--version', 'version'],
46
+ ['-v', 'version'],
47
+ ['--help', 'help'],
48
+ ['-h', 'help'],
36
49
  ]);
37
50
  const PUSH_NOISE = [
38
51
  'Enumerating objects:', 'Counting objects:', 'Compressing objects:',
@@ -181,8 +194,9 @@ function parseCommand(command, workDir) {
181
194
  }
182
195
  break;
183
196
  }
184
- const operation = String(tokens[index] || '').toLowerCase();
185
- if (!SUPPORTED.has(operation)) throw new Error(`unsupported git subcommand: ${operation || '(missing)'}`);
197
+ const rawOperation = String(tokens[index] || '').toLowerCase();
198
+ const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
199
+ if (!SUPPORTED.has(operation)) throw new Error(`unsupported git subcommand: ${rawOperation || '(missing)'}`);
186
200
  return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
187
201
  }
188
202
 
@@ -298,6 +312,38 @@ function semanticExit(plan, result) {
298
312
  return null;
299
313
  }
300
314
 
315
+ // "There is no repository here" is the ANSWER to a read-only repository
316
+ // question, not a failure of the tool that asked it. Reported as an error it
317
+ // sent the caller looking for the same fact another way: one measured run
318
+ // repeated status/diff nine times across three directories before accepting
319
+ // it. Mutations keep failing — a merge cannot succeed without a repository.
320
+ function notARepository(result) {
321
+ if (!result || result.error || result.timedOut || result.aborted || result.overflow) return false;
322
+ if (result.exitCode !== 128) return false;
323
+ return /not a git repository|must be run in a work tree/i.test(String(result.stderr || ''));
324
+ }
325
+
326
+ // Merge-family conflicts are git's documented outcome for exit 1: the command
327
+ // ran correctly and the conflicting paths ARE the result the caller needs.
328
+ // Wrapping them in a failure envelope hid that list behind "git merge exited 1".
329
+ const CONFLICT_OPERATIONS = new Set(['am', 'apply', 'cherry-pick', 'merge', 'rebase', 'revert', 'stash']);
330
+
331
+ function conflictOutcome(plan, result) {
332
+ if (!CONFLICT_OPERATIONS.has(plan.operation)) return null;
333
+ if (!result || result.error || result.timedOut || result.aborted || result.overflow) return null;
334
+ if (result.exitCode !== 1) return null;
335
+ const text = `${String(result.stdout || '')}\n${String(result.stderr || '')}`;
336
+ if (!/CONFLICT \(|Automatic merge failed|could not apply|needs merge/i.test(text)) return null;
337
+ const paths = [...text.matchAll(/^CONFLICT \([^)]*\): Merge conflict in (.+)$/gm)]
338
+ .map((match) => match[1].trim())
339
+ .filter(Boolean);
340
+ return {
341
+ summary: 'conflicts require resolution',
342
+ conflicted: true,
343
+ ...(paths.length ? { conflicts: [...new Set(paths)] } : {}),
344
+ };
345
+ }
346
+
301
347
  function runGit(plan, argv, options = {}) {
302
348
  return runProcess('git', [...plan.globalArgs, ...argv], { cwd: plan.cwd, signal: options.signal });
303
349
  }
@@ -561,9 +607,64 @@ function mutationData(plan, stdout, stderr, limit) {
561
607
  return { summary, ...(rows.lines.length ? { output: rows.lines } : {}), ...(rows.omitted ? { omittedOutputLines: rows.omitted } : {}) };
562
608
  }
563
609
 
610
+ // Repo-root resolution used to spawn an extra `git rev-parse` on EVERY call —
611
+ // on Windows that is half the cost of a git tool call (82ms for one process,
612
+ // 180ms for two). The answer is cached per resolved cwd and re-validated with
613
+ // a few stats: a cached root stays correct only while it is still the NEAREST
614
+ // repository, so an init/clone/worktree that appears closer to cwd invalidates
615
+ // it. Explicit --git-dir/--work-tree overrides re-point resolution entirely and
616
+ // never read the cache.
617
+ const repoRootCache = new Map();
618
+ const REPO_ROOT_CACHE_MAX = 64;
619
+
620
+ function repoRootCacheKey(plan) {
621
+ return plan.globalArgs.length ? null : resolve(plan.cwd);
622
+ }
623
+
624
+ function nearestGitDir(startDir) {
625
+ let dir = resolve(startDir);
626
+ for (;;) {
627
+ if (existsSync(join(dir, '.git'))) return dir;
628
+ const parent = dirname(dir);
629
+ if (parent === dir) return null;
630
+ dir = parent;
631
+ }
632
+ }
633
+
634
+ function cachedRepoRoot(key, plan) {
635
+ if (!key) return null;
636
+ const root = repoRootCache.get(key);
637
+ if (!root) return null;
638
+ const nearest = nearestGitDir(plan.cwd);
639
+ if (!nearest || resolve(nearest) !== resolve(root)) {
640
+ repoRootCache.delete(key);
641
+ return null;
642
+ }
643
+ repoRootCache.delete(key);
644
+ repoRootCache.set(key, root);
645
+ return root;
646
+ }
647
+
648
+ function rememberRepoRoot(key, root) {
649
+ if (!key || !root) return;
650
+ repoRootCache.delete(key);
651
+ repoRootCache.set(key, root);
652
+ while (repoRootCache.size > REPO_ROOT_CACHE_MAX) {
653
+ const oldest = repoRootCache.keys().next().value;
654
+ if (oldest === undefined) break;
655
+ repoRootCache.delete(oldest);
656
+ }
657
+ }
658
+
564
659
  async function resolveRepo(plan, signal) {
660
+ const key = repoRootCacheKey(plan);
661
+ const hit = cachedRepoRoot(key, plan);
662
+ if (hit) return { root: hit, probe: null };
565
663
  const result = await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
566
- return succeeded(result) ? cleanText(result.stdout) : null;
664
+ if (!succeeded(result)) return { root: null, probe: result };
665
+ const root = cleanText(result.stdout);
666
+ rememberRepoRoot(key, root);
667
+ return { root, probe: result };
567
668
  }
568
669
 
569
670
  function localizeConfigPlan(plan) {
@@ -720,10 +821,22 @@ export async function executeGitTool(input, workDir, options = {}) {
720
821
  const target = creationTarget(plan);
721
822
  return withGitRepoWriteLock(target, () => executeCreation(plan, target, limit, signal), { signal });
722
823
  }
723
- const repo = await resolveRepo(plan, signal);
824
+ if (REPO_FREE_OPERATIONS.has(plan.operation)) {
825
+ const result = await runGit(plan, [plan.operation, ...plan.args], { signal });
826
+ if (!succeeded(result)) return commandFailure(plan, result, limit);
827
+ return ok(mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit));
828
+ }
829
+ // Report the command the caller actually ran. Naming the internal probe
830
+ // ("git rev-parse exited 128") for a `git status` in a plain directory hid
831
+ // git's own "fatal: not a git repository" line behind a command the model
832
+ // never issued — and re-ran the probe just to build that message.
833
+ const { root: repo, probe } = await resolveRepo(plan, signal);
724
834
  if (!repo) {
725
- const probe = await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
726
- return commandFailure({ ...plan, operation: 'rev-parse' }, probe, limit);
835
+ const missing = probe ?? await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
836
+ if (isReadOnly(plan) && notARepository(missing)) {
837
+ return ok({ repo: false, cwd: plan.cwd, reason: 'not a git repository' });
838
+ }
839
+ return commandFailure(plan, missing, limit);
727
840
  }
728
841
  const prepared = prepare(plan, limit);
729
842
  if (isReadOnly(plan)) {
@@ -760,7 +873,17 @@ export async function executeGitTool(input, workDir, options = {}) {
760
873
  const after = await statusSnapshot(repo, signal);
761
874
  invalidateBuiltinResultCache();
762
875
  drainCodeGraphCache();
763
- if (!succeeded(result)) return `${commandFailure(plan, result, limit)}\n${JSON.stringify({ status: statusDelta(before, after, limit) })}`;
876
+ if (!succeeded(result)) {
877
+ const conflict = conflictOutcome(plan, result);
878
+ if (conflict) {
879
+ return ok({
880
+ ...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit),
881
+ ...conflict,
882
+ status: statusDelta(before, after, limit),
883
+ });
884
+ }
885
+ return `${commandFailure(plan, result, limit)}\n${JSON.stringify({ status: statusDelta(before, after, limit) })}`;
886
+ }
764
887
  return ok({ ...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit), status: statusDelta(before, after, limit) });
765
888
  })), { signal });
766
889
  }