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
@@ -6,7 +6,9 @@
6
6
  // mixdog-patch Rust engine via the persistent stdio server. There is NO JS
7
7
  // apply fallback: unsupported / unsafe input returns a clean Error string.
8
8
 
9
- import { existsSync } from 'node:fs';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import { tmpdir } from 'node:os';
10
12
  import { spawn } from 'node:child_process';
11
13
  import { createInterface } from 'node:readline';
12
14
  import { fileURLToPath } from 'node:url';
@@ -75,6 +77,261 @@ function nativePatchPersistent() {
75
77
  return /^(1|true|yes|server|persistent)$/i.test(nativePatchMode());
76
78
  }
77
79
 
80
+ // Engine-contract gate. The Rust engine embeds this exact marker string; an
81
+ // artifact WITHOUT it predates the byte-fidelity contract this build requires
82
+ // (line-span rewrites, per-line terminators, BOM as a file prefix), and using
83
+ // it silently corrupts bytes — a stale installed binary in the field hits
84
+ // exactly that. The check is a literal byte search over the artifact, keyed by
85
+ // the digest of those bytes: deterministic, no spawn, no behaviour heuristics.
86
+ // The engine contract is proven by the RUNNING SESSION, never by a separate
87
+ // probe of a path: the process that will execute the work ANSWERS `CONTRACT`
88
+ // over the real protocol — bytes that merely appear (a startup printer) are
89
+ // not an answer, and the artifact judged is always the one about to run. A wrapper that prints the marker cannot serve the
90
+ // protocol, a swapped artifact cannot change a process that is already
91
+ // running, and one session means one verification for any number of callers.
92
+ // The marker search below is only a negative pre-filter (skip spawning an
93
+ // artifact that cannot possibly answer); it is never the proof.
94
+ export const NATIVE_PATCH_ENGINE_CONTRACT = 'mixdog-patch-engine-contract:3';
95
+ export const NATIVE_PATCH_CONTRACT_FAILED = 'ENATIVEPATCHCONTRACT';
96
+ const ENGINE_CONTRACT_TIMEOUT_MS = 5_000;
97
+ // Hard read bound for the handshake: a flood is cut off as it arrives instead
98
+ // of being buffered until the timeout.
99
+ const ENGINE_CONTRACT_MAX_BYTES = 4_096;
100
+
101
+ // The handshake exchange itself, over any object that speaks the session
102
+ // surface (the live server; a stub in tests). It is a TWO-STEP proof, because
103
+ // arrival order proves nothing: a binary's startup output can be scheduled late
104
+ // and land inside the post-request window, and a gate that trusts the window
105
+ // accepts a printer that never read a byte.
106
+ //
107
+ // 1. CHALLENGE — a request whose answer can only be produced by READING it: a
108
+ // dry-run EDIT of a probe path carrying a fresh 128-bit nonce. The engine
109
+ // echoes that path back in its `stat` error, so the reply is derived from
110
+ // this request. Bytes printed unasked cannot contain a nonce invented
111
+ // microseconds earlier, whenever they happen to arrive.
112
+ // 2. CONTRACT — answered by EXACTLY one `OK\t<marker>` frame.
113
+ //
114
+ // The challenge writes nothing anywhere: dry_run=1, and the probe path sits
115
+ // inside a directory that does not exist, so even a hostile peer that "obeys"
116
+ // it cannot create a file. Queued output, trailing bytes, an oversized flood or
117
+ // a late frame all invalidate the session — leftover output must never become
118
+ // the next command's response.
119
+ export async function verifyContractOverSession(session, {
120
+ timeoutMs = ENGINE_CONTRACT_TIMEOUT_MS,
121
+ maxBytes = ENGINE_CONTRACT_MAX_BYTES,
122
+ } = {}) {
123
+ _contractVerificationCount += 1;
124
+ try { session.assertAlive?.('contract'); } catch { return false; }
125
+ const expected = `OK\t${NATIVE_PATCH_ENGINE_CONTRACT}`;
126
+ let timer = null;
127
+ let onData = null;
128
+ let seen = 0;
129
+ let overflow = false;
130
+ let signalOverflow = null;
131
+ const overflowed = new Promise((resolve) => { signalOverflow = resolve; });
132
+ session.ref?.();
133
+ try {
134
+ onData = (chunk) => {
135
+ seen += chunk.length;
136
+ if (seen > maxBytes && !overflow) {
137
+ overflow = true;
138
+ signalOverflow(null);
139
+ }
140
+ };
141
+ session.child.stdout.on('data', onData);
142
+ const timeout = new Promise((resolve) => {
143
+ timer = setTimeout(() => resolve(null), timeoutMs);
144
+ timer.unref?.();
145
+ });
146
+ // One request/response exchange. Output that is already queued was produced
147
+ // without being asked for, so it can never be an answer — refuse rather
148
+ // than consume it, and refuse again if anything trails the reply.
149
+ const ask = async (payload) => {
150
+ if (Array.isArray(session.lines) && session.lines.length > 0) return null;
151
+ const linePromise = session.nextLine();
152
+ linePromise.catch(() => {});
153
+ session.child.stdin.write(payload);
154
+ const line = await Promise.race([linePromise, overflowed, timeout]);
155
+ if (overflow || typeof line !== 'string') return null;
156
+ if (Array.isArray(session.lines) && session.lines.length > 0) return null;
157
+ return line;
158
+ };
159
+ const nonce = randomBytes(16).toString('hex');
160
+ const probePath = pathJoin(tmpdir(), `mixdog-engine-challenge-${nonce}`, 'probe');
161
+ const probeBuf = Buffer.from(probePath, 'utf8');
162
+ const challenge = await ask(Buffer.concat([
163
+ Buffer.from(`EDIT ${probeBuf.length} 1 0 0 1\n`, 'utf8'),
164
+ probeBuf,
165
+ Buffer.from('x', 'utf8'),
166
+ ]));
167
+ // Evidence, not timing: the answer carries the nonce this request invented.
168
+ if (typeof challenge !== 'string' || !challenge.includes(nonce)) return false;
169
+ const seenBeforeContract = seen;
170
+ const line = await ask('CONTRACT\n');
171
+ if (line !== expected) return false;
172
+ // Exactly one frame answered CONTRACT — no trailing bytes, no second line.
173
+ const contractBytes = seen - seenBeforeContract;
174
+ if (contractBytes < expected.length || contractBytes > expected.length + 2) return false;
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ } finally {
179
+ if (timer) clearTimeout(timer);
180
+ if (onData) { try { session.child.stdout.off('data', onData); } catch { /* detached */ } }
181
+ session.unref?.();
182
+ }
183
+ }
184
+ // Content-keyed NEGATIVE PRE-FILTER only: "these bytes do not carry the marker"
185
+ // is a property of the bytes themselves, so remembering it is always true of
186
+ // them and skips a pointless spawn. A session VERDICT is never keyed by
187
+ // content — see verifiedSession().
188
+ const _markerlessDigests = new Set();
189
+ let _contractVerificationCount = 0;
190
+ // What bounds a non-conforming artifact is this backoff, not a verdict cache:
191
+ // after three consecutive failed handshakes, stop spawning for a while.
192
+ const CONTRACT_FAILURE_LIMIT = 3;
193
+ const CONTRACT_BACKOFF_MS = 30_000;
194
+ let _contractFailures = 0;
195
+ let _contractBackoffUntil = 0;
196
+ let _beforeSpawnHook = null;
197
+
198
+ export function _engineContractVerificationCountForTest() {
199
+ return _contractVerificationCount;
200
+ }
201
+
202
+ // Clears the CACHES — never the failure ledger. The three-failure bound is
203
+ // absolute: only a proven session or the passage of real time reopens it, so no
204
+ // exported hatch can turn a flapping artifact back into an unbounded respawn
205
+ // loop. Tests that need a pristine ledger import a fresh module instance.
206
+ export function _resetEngineContractCachesForTest() {
207
+ _markerlessDigests.clear();
208
+ _beforeSpawnHook = null;
209
+ }
210
+
211
+ /** Read-only: proves the bound is a finite cost, not a permanent lockout. */
212
+ export function _contractBackoffRemainingMsForTest() {
213
+ return Math.max(0, _contractBackoffUntil - Date.now());
214
+ }
215
+
216
+ /** Test seam: runs between reading the artifact and spawning the session. */
217
+ export function _setBeforeEngineSpawnHookForTest(hook) {
218
+ _beforeSpawnHook = typeof hook === 'function' ? hook : null;
219
+ }
220
+
221
+ function contractBackoffActive() {
222
+ if (_contractBackoffUntil === 0) return false;
223
+ if (Date.now() < _contractBackoffUntil) return true;
224
+ _contractBackoffUntil = 0;
225
+ _contractFailures = 0;
226
+ return false;
227
+ }
228
+
229
+ function noteContractFailure() {
230
+ _contractFailures += 1;
231
+ if (_contractFailures >= CONTRACT_FAILURE_LIMIT) {
232
+ _contractBackoffUntil = Date.now() + CONTRACT_BACKOFF_MS;
233
+ }
234
+ }
235
+
236
+ function clearContractFailures() {
237
+ _contractFailures = 0;
238
+ _contractBackoffUntil = 0;
239
+ }
240
+
241
+ function readArtifactOrNull(binPath) {
242
+ try {
243
+ return readFileSync(binPath);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function digestOf(bytes) {
250
+ return bytes ? createHash('sha256').update(bytes).digest('hex') : null;
251
+ }
252
+
253
+ function contractFailure(action) {
254
+ const err = new Error(
255
+ `native patch engine did not state contract ${NATIVE_PATCH_ENGINE_CONTRACT} (${action})`,
256
+ );
257
+ err.code = NATIVE_PATCH_CONTRACT_FAILED;
258
+ return err;
259
+ }
260
+
261
+ function currentSession(kind) {
262
+ return kind === 'edit' ? _nativeEditServer : _nativePatchServer;
263
+ }
264
+
265
+ // The ONE binary resolution, for every kind. The gate hashes what this returns
266
+ // and the session spawns exactly the same path, so "verified artifact" and
267
+ // "executed artifact" cannot be two different files. The edit override is
268
+ // resolved HERE and nowhere else: a second resolution inside the edit getter
269
+ // let a markerless override run while the genuine engine took the exam.
270
+ function nativeBinPathFor(kind) {
271
+ if (kind === 'edit' && process.env.MIXDOG_EDIT_NATIVE_BIN) {
272
+ return process.env.MIXDOG_EDIT_NATIVE_BIN;
273
+ }
274
+ return nativePatchBinPath();
275
+ }
276
+
277
+ function spawnSession(kind, binPath) {
278
+ return kind === 'edit' ? getNativeEditServer(binPath) : getNativePatchServer(binPath);
279
+ }
280
+
281
+ // A LIVE session that already proved itself IS the proof — no disk read and no
282
+ // metadata are consulted while it lives. Only a cold start looks at the file.
283
+ function liveVerifiedSession(kind) {
284
+ const server = currentSession(kind);
285
+ if (!server || server.exited || server.contractFailed) return null;
286
+ return server.contractVerified === true ? server : null;
287
+ }
288
+
289
+ // Shared by the apply and edit sessions: reuse a proven session, else read the
290
+ // artifact ONCE, key the verdict on those exact bytes, and make the spawned
291
+ // session prove itself. Returns a verified server or `null`.
292
+ async function verifiedSession(kind) {
293
+ const live = liveVerifiedSession(kind);
294
+ if (live) return live;
295
+ if (contractBackoffActive()) return null;
296
+ const binPath = nativeBinPathFor(kind);
297
+ const bytes = readArtifactOrNull(binPath);
298
+ if (!bytes) return null; // missing / unreadable artifact — fail closed
299
+ // Pre-filter on the BYTES ACTUALLY READ from the artifact that will be
300
+ // spawned: a stat-keyed memo let a genuine engine that replaced a bad one at
301
+ // the same path/size/mtime inherit a stale `false` and never be re-verified.
302
+ const digest = digestOf(bytes);
303
+ if (_markerlessDigests.has(digest)) return null; // known markerless — no spawn
304
+ if (!bytes.includes(NATIVE_PATCH_ENGINE_CONTRACT)) {
305
+ _markerlessDigests.add(digest);
306
+ return null;
307
+ }
308
+ let server = null;
309
+ let ok = false;
310
+ try {
311
+ if (_beforeSpawnHook) await _beforeSpawnHook();
312
+ server = spawnSession(kind, binPath);
313
+ ok = await server.contract();
314
+ } catch {
315
+ ok = false;
316
+ }
317
+ // A session verdict is NEVER memoized by content. Re-hashing the path after
318
+ // the fact cannot prove which bytes the process loaded — an A→B→A swap-back
319
+ // hashes A while B ran, and A then carried B's failure forever. The bounded
320
+ // backoff, not a cache, is what stops a bad artifact from being respawned in
321
+ // a loop; every fresh session simply proves itself again.
322
+ if (ok) clearContractFailures();
323
+ else noteContractFailure();
324
+ return ok ? server : null;
325
+ }
326
+
327
+ export async function nativePatchSessionSatisfiesContract() {
328
+ return (await verifiedSession('patch')) !== null;
329
+ }
330
+
331
+ export async function nativeEditSessionSatisfiesContract() {
332
+ return (await verifiedSession('edit')) !== null;
333
+ }
334
+
78
335
  export function nativePatchBinPath(options = {}) {
79
336
  if (process.env.MIXDOG_PATCH_NATIVE_BIN) return process.env.MIXDOG_PATCH_NATIVE_BIN;
80
337
  // Local cargo build first, then a fetched/cached prebuilt; absence is
@@ -136,13 +393,47 @@ function decodeNativeFailures(hexPayload) {
136
393
  // retry is safe.
137
394
  export const NATIVE_PATCH_TRANSPORT_DEAD = 'ENATIVEPATCHDEAD';
138
395
 
396
+ // The child's pipes ARE the engine's protocol: a caller holding `stdin` can
397
+ // speak APPLY/EDIT around every gate — after a bare PING, a direct
398
+ // `child.stdin.write('EDIT …')` wrote a file on an unproven session. So the
399
+ // exported view of the child is lifecycle-only (kill, pid, events); there is
400
+ // nothing on it to write into.
401
+ const CHILD_TRANSPORT_KEYS = new Set(['stdin', 'stdout', 'stderr', 'stdio', 'channel']);
402
+
403
+ function lifecycleOnlyChild(child) {
404
+ return new Proxy(child, {
405
+ get(target, prop) {
406
+ if (CHILD_TRANSPORT_KEYS.has(prop)) return undefined;
407
+ const value = Reflect.get(target, prop, target);
408
+ return typeof value === 'function' ? value.bind(target) : value;
409
+ },
410
+ set() { return false; },
411
+ defineProperty() { return false; },
412
+ deleteProperty() { return false; },
413
+ });
414
+ }
415
+
139
416
  class NativePatchServer {
417
+ // Private state. The transport is unreachable from outside, and the
418
+ // verification flag is not assignable: a public `_contractVerified = true`
419
+ // used to unlock a raw .edit() with no handshake at all.
420
+ #child;
421
+
422
+ #rl;
423
+
424
+ #lifecycleChild;
425
+
426
+ #contractVerified = false;
427
+
428
+ #contractPromise = null;
429
+
140
430
  constructor(binPath) {
141
431
  this.binPath = binPath;
142
432
  // windowsHide: mixdog-patch.exe is a console binary; without this each spawn
143
433
  // flashes an empty console window on Windows. Especially visible now that the
144
434
  // idle watchdog exits the server and it respawns on the next request.
145
- this.child = spawn(binPath, ['--server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
435
+ this.#child = spawn(binPath, ['--server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
436
+ this.#lifecycleChild = lifecycleOnlyChild(this.#child);
146
437
  // No child guardian here. The server is self-terminating on BOTH orphan
147
438
  // paths: stdin EOF ends its request loop the moment this host's pipe handle
148
439
  // closes, and MIXDOG_PATCH_SERVER_IDLE_MS bounds the surviving-handle case
@@ -155,28 +446,57 @@ class NativePatchServer {
155
446
  this.waiters = [];
156
447
  this.exited = false;
157
448
  this.transportError = null;
158
- this.child.stderr.setEncoding('utf8');
159
- this.child.stderr.on('data', (chunk) => { this.stderr += chunk; });
160
- this.rl = createInterface({ input: this.child.stdout });
161
- this.rl.on('line', (line) => {
449
+ this.#child.stderr.setEncoding('utf8');
450
+ this.#child.stderr.on('data', (chunk) => { this.stderr += chunk; });
451
+ this.#rl = createInterface({ input: this.#child.stdout });
452
+ this.#rl.on('line', (line) => {
162
453
  const waiter = this.waiters.shift();
163
- if (waiter) waiter.resolve(line);
164
- else this.lines.push(line);
454
+ if (waiter) { waiter.resolve(line); return; }
455
+ // Unsolicited output. Every command registers its waiter BEFORE writing,
456
+ // so a line with no waiter is off-protocol chatter — queueing it would
457
+ // hand it to the NEXT command as that command's response. Abandon the
458
+ // session instead of letting stray bytes leak forward.
459
+ this.lines.push(line);
460
+ this.markProtocolViolation(line);
165
461
  });
166
462
  // A child that died mid-request turns every pending `stdin.write()` into an
167
463
  // UNHANDLED 'error' event (EPIPE) that takes the whole host process down —
168
464
  // a release validate job was lost exactly that way. Absorb it here: mark
169
465
  // the transport dead and reject the waiters so callers see an ordinary
170
466
  // rejection (and respawn) instead of a crash.
171
- this.child.stdin.on('error', (err) => { this.failTransport(err); });
172
- this.child.on('exit', (code, signal) => {
467
+ this.#child.stdin.on('error', (err) => { this.failTransport(err); });
468
+ this.#child.on('exit', (code, signal) => {
173
469
  this.exited = true;
174
470
  const err = new Error(`native patch server exited code=${code} signal=${signal} stderr=${this.stderr}`);
175
471
  for (const waiter of this.waiters.splice(0)) waiter.reject(err);
176
- try { this.rl.close(); } catch {}
472
+ try { this.#rl.close(); } catch {}
177
473
  });
178
474
  }
179
475
 
476
+ /** Lifecycle only: kill/pid/events. The pipes stay inside this class. */
477
+ get child() {
478
+ return this.#lifecycleChild;
479
+ }
480
+
481
+ /** Read-only; true only after a completed handshake on THIS process. */
482
+ get contractVerified() {
483
+ return this.#contractVerified === true;
484
+ }
485
+
486
+ // The handshake is the one thing that needs the raw pipes. This view is built
487
+ // inside the class body, handed straight to the verifier, and never returned
488
+ // to a caller.
489
+ #handshakeView() {
490
+ return {
491
+ child: this.#child,
492
+ lines: this.lines,
493
+ assertAlive: (action) => this.assertAlive(action),
494
+ nextLine: () => this.nextLine(),
495
+ ref: () => this.ref(),
496
+ unref: () => this.unref(),
497
+ };
498
+ }
499
+
180
500
  // Idempotent death record: the stdin error and the child exit usually both
181
501
  // fire for the same death, and only the first one owns the reason.
182
502
  failTransport(cause) {
@@ -187,12 +507,54 @@ class NativePatchServer {
187
507
  this.transportError = err;
188
508
  }
189
509
  for (const waiter of this.waiters.splice(0)) waiter.reject(this.transportError);
190
- try { this.rl.close(); } catch {}
510
+ try { this.#rl.close(); } catch {}
191
511
  }
192
512
 
193
513
  // Pre-flight for every request: refuse BEFORE a single byte is written, so
194
514
  // the caller may respawn and retry without any risk of double-applying.
515
+ // Contract handshake on THIS session, memoized per instance: N concurrent
516
+ // callers await one exchange, and a respawned session proves itself again.
517
+ contract() {
518
+ if (!this.#contractPromise) {
519
+ this.#contractPromise = verifyContractOverSession(this.#handshakeView()).then(
520
+ (ok) => {
521
+ this.#contractVerified = ok === true;
522
+ if (!ok) this.markContractFailed();
523
+ return ok;
524
+ },
525
+ () => { this.#contractVerified = false; this.markContractFailed(); return false; },
526
+ );
527
+ }
528
+ return this.#contractPromise;
529
+ }
530
+
531
+ // A session that cannot state the contract — or that emitted anything
532
+ // off-protocol — is abandoned outright: it never receives work, and the
533
+ // module slot is cleared so nothing reuses it.
534
+ markContractFailed() {
535
+ this.contractFailed = true;
536
+ this.#contractVerified = false;
537
+ if (_nativePatchServer === this) _nativePatchServer = null;
538
+ if (_nativeEditServer === this) _nativeEditServer = null;
539
+ try { this.#child.kill('SIGKILL'); } catch { /* already gone */ }
540
+ }
541
+
542
+ markProtocolViolation(detail) {
543
+ this.protocolViolation = String(detail ?? '').slice(0, 200);
544
+ this.markContractFailed();
545
+ }
546
+
547
+ // Every byte-writing command goes through here first. `_contractVerified` is
548
+ // set ONLY by a completed handshake on this very process, so no exported
549
+ // surface — including the raw session getNativePatchServer() hands out — can
550
+ // reach apply/edit on an unproven engine. (PING writes nothing and stays
551
+ // open, so prewarm can still warm a process before it is judged.)
552
+ assertContractVerified(action) {
553
+ if (this.#contractVerified !== true) throw contractFailure(action);
554
+ }
555
+
195
556
  assertAlive(action) {
557
+ if (this.contractFailed) throw contractFailure(action);
196
558
  if (!this.exited && !this.transportError) return;
197
559
  const reason = this.transportError?.message || this.stderr || 'exited';
198
560
  const err = new Error(`native patch server is not running (${action}): ${reason}`);
@@ -205,7 +567,7 @@ class NativePatchServer {
205
567
  err.name = 'AbortError';
206
568
  if (_nativePatchServer === this) _nativePatchServer = null;
207
569
  for (const waiter of this.waiters.splice(0)) waiter.reject(err);
208
- try { this.child.kill('SIGTERM'); } catch {}
570
+ try { this.#child.kill('SIGTERM'); } catch {}
209
571
  return err;
210
572
  }
211
573
 
@@ -216,24 +578,24 @@ class NativePatchServer {
216
578
  }
217
579
 
218
580
  ref() {
219
- try { this.child.ref(); } catch {}
220
- try { this.child.stdin.ref?.(); } catch {}
221
- try { this.child.stdout.ref?.(); } catch {}
222
- try { this.child.stderr.ref?.(); } catch {}
581
+ try { this.#child.ref(); } catch {}
582
+ try { this.#child.stdin.ref?.(); } catch {}
583
+ try { this.#child.stdout.ref?.(); } catch {}
584
+ try { this.#child.stderr.ref?.(); } catch {}
223
585
  }
224
586
 
225
587
  unref() {
226
- try { this.child.unref(); } catch {}
227
- try { this.child.stdin.unref?.(); } catch {}
228
- try { this.child.stdout.unref?.(); } catch {}
229
- try { this.child.stderr.unref?.(); } catch {}
588
+ try { this.#child.unref(); } catch {}
589
+ try { this.#child.stdin.unref?.(); } catch {}
590
+ try { this.#child.stdout.unref?.(); } catch {}
591
+ try { this.#child.stderr.unref?.(); } catch {}
230
592
  }
231
593
 
232
594
  async ping() {
233
595
  this.ref();
234
596
  this.assertAlive('ping');
235
597
  const linePromise = this.nextLine();
236
- this.child.stdin.write('PING\n');
598
+ this.#child.stdin.write('PING\n');
237
599
  const line = await linePromise;
238
600
  if (line !== 'OK\tPONG') {
239
601
  throw new Error(`native patch server ping failed: ${line || 'no native response'}`);
@@ -242,6 +604,7 @@ class NativePatchServer {
242
604
 
243
605
  async apply(basePath, patchText, { fuzz = 2, rejectPartial = true, dryRun = false, signal = null } = {}) {
244
606
  this.ref();
607
+ this.assertContractVerified('apply');
245
608
  this.assertAlive('apply');
246
609
  if (signal?.aborted) {
247
610
  const err = new Error(signal.reason?.message || signal.reason || 'native patch aborted');
@@ -268,9 +631,9 @@ class NativePatchServer {
268
631
  const fuzzTok = Number.isFinite(fuzz) && fuzz >= 0 ? Math.floor(fuzz) : 2;
269
632
  const rpTok = rejectPartial ? 1 : 0;
270
633
  const dryTok = dryRun ? 1 : 0;
271
- this.child.stdin.write(`APPLY ${baseBuf.length} ${patchBuf.length} 1 ${dryTok} ${fuzzTok} ${rpTok}\n`);
272
- this.child.stdin.write(baseBuf);
273
- this.child.stdin.write(patchBuf);
634
+ this.#child.stdin.write(`APPLY ${baseBuf.length} ${patchBuf.length} 1 ${dryTok} ${fuzzTok} ${rpTok}\n`);
635
+ this.#child.stdin.write(baseBuf);
636
+ this.#child.stdin.write(patchBuf);
274
637
  let line;
275
638
  try {
276
639
  line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
@@ -339,6 +702,7 @@ class NativePatchServer {
339
702
  // matched tier.
340
703
  async edit(fullPath, oldBuf, newBuf, { replaceAll = false, dryRun = false, signal = null } = {}) {
341
704
  this.ref();
705
+ this.assertContractVerified('edit');
342
706
  this.assertAlive('edit');
343
707
  if (signal?.aborted) {
344
708
  const err = new Error(signal.reason?.message || signal.reason || 'native edit aborted');
@@ -354,12 +718,12 @@ class NativePatchServer {
354
718
  abortListener = () => { reject(this.abort(signal)); };
355
719
  signal.addEventListener('abort', abortListener, { once: true });
356
720
  }) : null;
357
- this.child.stdin.write(
721
+ this.#child.stdin.write(
358
722
  `EDIT ${pathBuf.length} ${oldBuf.length} ${newBuf.length} ${replaceAll ? 1 : 0} ${dryRun ? 1 : 0}\n`,
359
723
  );
360
- this.child.stdin.write(pathBuf);
361
- this.child.stdin.write(oldBuf);
362
- this.child.stdin.write(newBuf);
724
+ this.#child.stdin.write(pathBuf);
725
+ this.#child.stdin.write(oldBuf);
726
+ this.#child.stdin.write(newBuf);
363
727
  let line;
364
728
  try {
365
729
  line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
@@ -389,20 +753,19 @@ class NativePatchServer {
389
753
  if (this.exited) return;
390
754
  const waitForExit = options?.waitForExit !== false;
391
755
  if (!waitForExit) {
392
- try { this.child.stdin.end('QUIT\n'); } catch {}
756
+ try { this.#child.stdin.end('QUIT\n'); } catch {}
393
757
  this.unref();
394
758
  return;
395
759
  }
396
760
  this.ref();
397
- try { this.child.stdin.end('QUIT\n'); } catch {}
398
- await new Promise((resolve) => this.child.once('exit', resolve));
399
- try { this.rl.close(); } catch {}
761
+ try { this.#child.stdin.end('QUIT\n'); } catch {}
762
+ await new Promise((resolve) => this.#child.once('exit', resolve));
763
+ try { this.#rl.close(); } catch {}
400
764
  }
401
765
  }
402
766
 
403
- export function getNativePatchServer() {
767
+ export function getNativePatchServer(binPath = nativeBinPathFor('patch')) {
404
768
  markNativePatchRuntimeTouched();
405
- const binPath = nativePatchBinPath();
406
769
  if (!existsSync(binPath)) {
407
770
  throw new Error(`native patch binary not found: ${binPath}`);
408
771
  }
@@ -412,11 +775,11 @@ export function getNativePatchServer() {
412
775
  return _nativePatchServer;
413
776
  }
414
777
 
415
- function getNativeEditServer() {
778
+ // The path comes from nativeBinPathFor('edit') — the same call the gate hashed.
779
+ // This getter resolves nothing itself, so there is no second path a session
780
+ // could be spawned from.
781
+ function getNativeEditServer(binPath = nativeBinPathFor('edit')) {
416
782
  markNativePatchRuntimeTouched();
417
- // Honor MIXDOG_EDIT_NATIVE_BIN (the same override the edit gating checks) so
418
- // the gated binary and the spawned server binary cannot diverge.
419
- const binPath = process.env.MIXDOG_EDIT_NATIVE_BIN || nativePatchBinPath();
420
783
  if (!existsSync(binPath)) {
421
784
  throw new Error(`native patch binary not found: ${binPath}`);
422
785
  }
@@ -431,12 +794,19 @@ function getNativeEditServer() {
431
794
  // patch requests never interleave their stdin framing on one stdout stream.
432
795
  export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = false, dryRun = false, signal = null }) {
433
796
  try {
434
- return await getNativeEditServer().edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
797
+ // Defence in depth: no work is written to a session that has not proven
798
+ // its contract, even if a caller skipped the gate.
799
+ const verified = await verifiedSession('edit');
800
+ if (!verified) throw contractFailure('edit');
801
+ return await verified.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
435
802
  } catch (err) {
436
803
  if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
437
804
  // The dead instance was refused before any byte went out; the getter
438
- // respawns because the previous one is marked exited.
439
- return getNativeEditServer().edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
805
+ // respawns because the previous one is marked exited — and the respawn
806
+ // must prove itself again.
807
+ const respawned = await verifiedSession('edit');
808
+ if (!respawned) throw contractFailure('edit');
809
+ return respawned.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
440
810
  } finally {
441
811
  if (!nativePatchPersistent() && _nativeEditServer) {
442
812
  if (process.versions?.bun) {
@@ -454,10 +824,14 @@ export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = fal
454
824
  // between requests must cost a respawn, not the request.
455
825
  export async function runServerApply(basePath, patchText, options = {}) {
456
826
  try {
457
- return await getNativePatchServer().apply(basePath, patchText, options);
827
+ const verified = await verifiedSession('patch');
828
+ if (!verified) throw contractFailure('apply');
829
+ return await verified.apply(basePath, patchText, options);
458
830
  } catch (err) {
459
831
  if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
460
- return getNativePatchServer().apply(basePath, patchText, options);
832
+ const respawned = await verifiedSession('patch');
833
+ if (!respawned) throw contractFailure('apply');
834
+ return respawned.apply(basePath, patchText, options);
461
835
  }
462
836
  }
463
837
 
@@ -36,6 +36,8 @@ import { assertPathReachable } from '../builtin/fs-reachability.mjs';
36
36
  import { findBySuffixStrip, findFileByBasename } from '../builtin/path-diagnostics.mjs';
37
37
  import { resolveReadPathRedirect } from '../builtin/snapshot-store.mjs';
38
38
  import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
39
+ import { patchTargetUsesUtf16 } from './matcher.mjs';
40
+ import { nativePatchSessionSatisfiesContract } from './native-server.mjs';
39
41
  import {
40
42
  planV4ARenameSections,
41
43
  applyV4ARenameSections,
@@ -184,15 +186,37 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
184
186
  // Create entries use the JS atomic writer even inside the base path. Its
185
187
  // expected-absent snapshot makes Add File create-only under external races;
186
188
  // the native patch engine intentionally supports overwrite-style additions.
189
+ // Route by the codec actually DETECTED, not by BOM presence: a BOM-less
190
+ // UTF-16 file reaching the UTF-8 native engine came back as mixed
191
+ // UTF-16/UTF-8 bytes. Any UTF-16 target (BOM or not) takes the JS writer,
192
+ // which decodes and re-encodes with that exact codec; undecidable files stay
193
+ // native, where the encoding gate refuses them before any write. Deletes
194
+ // touch no bytes and stay native.
195
+ //
196
+ // The engine-contract gate is the same protection for a STALE artifact: a
197
+ // binary that predates this build's byte-fidelity contract is not used at
198
+ // all — everything takes the JS writer instead.
199
+ const engineContractOk = await nativePatchSessionSatisfiesContract();
200
+ // Codec routing only concerns entries that REWRITE bytes; a delete does not.
201
+ // An unverified engine, however, must receive no work at all — deletes
202
+ // included — so the whole wave takes the JS writer.
203
+ const codecNeedsJs = (fullPath, kind) => kind !== 'create' && kind !== 'delete'
204
+ && patchTargetUsesUtf16(fullPath);
205
+ const needsJsCodec = (fullPath, kind) => !engineContractOk || codecNeedsJs(fullPath, kind);
187
206
  const nativeEntries = wentries.filter(
188
- (entry) => entry.kind !== 'create' && !isResolvedPathOutsideBase(entry.fullPath, basePath),
207
+ (entry) => entry.kind !== 'create'
208
+ && !isResolvedPathOutsideBase(entry.fullPath, basePath)
209
+ && !needsJsCodec(entry.fullPath, entry.kind),
189
210
  );
190
211
  const jsEntries = wentries.filter(
191
- (entry) => entry.kind === 'create' || isResolvedPathOutsideBase(entry.fullPath, basePath),
212
+ (entry) => entry.kind === 'create'
213
+ || isResolvedPathOutsideBase(entry.fullPath, basePath)
214
+ || needsJsCodec(entry.fullPath, entry.kind),
192
215
  );
193
216
  const parsedInside = (wparsed || []).filter(
194
217
  (entry) => classifyEntry(entry) !== 'create'
195
- && !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
218
+ && !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath)
219
+ && !needsJsCodec(parsedEntryResolvedPath(entry, basePath), classifyEntry(entry)),
196
220
  );
197
221
  const executor = jsEntries.length > 0
198
222
  ? (nativeEntries.length > 0 ? 'native+js-patch' : 'js-patch')