mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,17 +1,33 @@
1
1
  import { closeSync, openSync, readSync } from 'fs';
2
+ import { open } from 'fs/promises';
3
+
4
+ const HEAD_CAP = 64 * 1024;
5
+ const TAIL_SIZE = 4 * 1024;
6
+
7
+ function hasUtf16Bom(buf, length = buf?.length || 0) {
8
+ return length >= 2
9
+ && ((buf[0] === 0xff && buf[1] === 0xfe)
10
+ || (buf[0] === 0xfe && buf[1] === 0xff));
11
+ }
12
+
13
+ function containsNull(buf, length = buf?.length || 0) {
14
+ for (let i = 0; i < length; i++) {
15
+ if (buf[i] === 0) return true;
16
+ }
17
+ return false;
18
+ }
2
19
 
3
20
  // Binary detection: reading a PNG / ELF / zip / compressed blob as utf-8
4
21
  // pollutes the context with U+FFFD characters and wastes tokens. Sample the
5
22
  // head and tail of the file and look for a null byte — the canonical signal
6
23
  // that the file is not plain text. Head window scales with file size:
7
24
  // min(fileSize, 64KB) head + 4KB tail, so a 250KB file with a null byte at
8
- // 9KB or 249KB is caught equally. The sampling is synchronous and cheap
9
- // relative to the 256KB read budget it guards.
25
+ // 9KB or 249KB is caught equally. This synchronous form remains for legacy
26
+ // mode readers. The main read path uses inspectBinaryFile(), which performs
27
+ // the same probes asynchronously.
10
28
  // Callers inside the ≤READ_MAX_SIZE_BYTES branch should pass st.size so the
11
29
  // tail probe fires; callers above the cap pass the real size from err.size.
12
30
  export function isBinaryFile(fullPath, fileSize = 0) {
13
- const HEAD_CAP = 64 * 1024; // 64 KB max head window
14
- const TAIL_SIZE = 4 * 1024; // 4 KB tail probe
15
31
  const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
16
32
  let fd = null;
17
33
  try {
@@ -25,20 +41,14 @@ export function isBinaryFile(fullPath, fileSize = 0) {
25
41
  // (detectReadEncodingFromBuffer/decodeReadBuffer support utf16le and
26
42
  // utf16be), so exempt it rather than reject the file as binary.
27
43
  // FF FE = UTF-16LE, FE FF = UTF-16BE.
28
- if (nHead >= 2
29
- && ((headBuf[0] === 0xff && headBuf[1] === 0xfe)
30
- || (headBuf[0] === 0xfe && headBuf[1] === 0xff))) return false;
31
- for (let i = 0; i < nHead; i++) {
32
- if (headBuf[i] === 0) return true;
33
- }
44
+ if (hasUtf16Bom(headBuf, nHead)) return false;
45
+ if (containsNull(headBuf, nHead)) return true;
34
46
  // Tail probe (only when file is larger than head window)
35
47
  if (fileSize > headBytes && fileSize > TAIL_SIZE) {
36
48
  const tailOffset = fileSize - TAIL_SIZE;
37
49
  const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
38
50
  const nTail = readSync(fd, tailBuf, 0, TAIL_SIZE, tailOffset);
39
- for (let i = 0; i < nTail; i++) {
40
- if (tailBuf[i] === 0) return true;
41
- }
51
+ if (containsNull(tailBuf, nTail)) return true;
42
52
  }
43
53
  return false;
44
54
  } catch {
@@ -48,8 +58,71 @@ export function isBinaryFile(fullPath, fileSize = 0) {
48
58
  }
49
59
  }
50
60
 
61
+ /** Classify an already-loaded whole file without another open/read cycle. */
62
+ export function isBinaryBuffer(buf, fileSize = buf?.length || 0) {
63
+ if (!Buffer.isBuffer(buf) || buf.length === 0) return false;
64
+ const size = fileSize > 0 ? Math.min(fileSize, buf.length) : buf.length;
65
+ const headBytes = Math.min(size, HEAD_CAP);
66
+ if (hasUtf16Bom(buf, headBytes)) return false;
67
+ if (containsNull(buf, headBytes)) return true;
68
+ if (size > headBytes && size > TAIL_SIZE) {
69
+ const tail = buf.subarray(Math.max(0, size - TAIL_SIZE), size);
70
+ if (containsNull(tail)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Async binary probe that keeps one file handle for head, tail, and preview.
77
+ * Returns the head bytes even when the null marker is in the tail so callers
78
+ * can render a hex preview without reopening the file.
79
+ */
80
+ export async function inspectBinaryFile(fullPath, fileSize = 0, { previewBytes = 256 } = {}) {
81
+ const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
82
+ let fh;
83
+ try {
84
+ fh = await open(fullPath, 'r');
85
+ const headBuf = Buffer.allocUnsafe(Math.max(0, headBytes));
86
+ const { bytesRead: nHead } = await fh.read(headBuf, 0, headBytes, 0);
87
+ const head = headBuf.subarray(0, nHead);
88
+ const preview = head.subarray(0, Math.min(previewBytes, nHead));
89
+ if (nHead === 0 || hasUtf16Bom(head, nHead)) return { isBinary: false, preview };
90
+ if (containsNull(head, nHead)) return { isBinary: true, preview };
91
+ if (fileSize > headBytes && fileSize > TAIL_SIZE) {
92
+ const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
93
+ const { bytesRead: nTail } = await fh.read(
94
+ tailBuf,
95
+ 0,
96
+ TAIL_SIZE,
97
+ fileSize - TAIL_SIZE,
98
+ );
99
+ if (containsNull(tailBuf, nTail)) return { isBinary: true, preview };
100
+ }
101
+ return { isBinary: false, preview };
102
+ } catch {
103
+ return { isBinary: false, preview: Buffer.alloc(0) };
104
+ } finally {
105
+ if (fh) { try { await fh.close(); } catch {} }
106
+ }
107
+ }
108
+
51
109
  const BINARY_PREVIEW_BYTES = 256;
52
110
 
111
+ export function formatBinaryReadPreviewFromBuffer(
112
+ buffer,
113
+ displayPath,
114
+ fileSize,
115
+ { previewBytes = BINARY_PREVIEW_BYTES } = {},
116
+ ) {
117
+ const source = Buffer.isBuffer(buffer) ? buffer : Buffer.alloc(0);
118
+ const slice = source.subarray(0, Math.min(previewBytes, source.length));
119
+ const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
120
+ const disp = displayPath || '(binary file)';
121
+ const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
122
+ const text = `${disp}\n${note}\n${hex || '(empty)'}`;
123
+ return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
124
+ }
125
+
53
126
  /** Short hex preview for read when null bytes mark the file as binary. */
54
127
  export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previewBytes = BINARY_PREVIEW_BYTES } = {}) {
55
128
  const n = Math.max(0, Math.min(previewBytes, fileSize > 0 ? fileSize : previewBytes));
@@ -58,12 +131,12 @@ export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previ
58
131
  fd = openSync(fullPath, 'r');
59
132
  const buf = Buffer.alloc(n);
60
133
  const bytesRead = readSync(fd, buf, 0, n, 0);
61
- const slice = buf.subarray(0, bytesRead);
62
- const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
63
- const disp = displayPath || fullPath;
64
- const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
65
- const text = `${disp}\n${note}\n${hex || '(empty)'}`;
66
- return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
134
+ return formatBinaryReadPreviewFromBuffer(
135
+ buf.subarray(0, bytesRead),
136
+ displayPath || fullPath,
137
+ fileSize,
138
+ { previewBytes },
139
+ );
67
140
  } catch {
68
141
  const disp = displayPath || fullPath;
69
142
  return {
@@ -5,7 +5,6 @@
5
5
  // only (behavior + argument shapes); usage policy lives in rules/shared/01-tool.md.
6
6
  import {
7
7
  TOOL_ASYNC_EXECUTION_CONTRACT,
8
- TOOL_MANUAL_CONTROL_CONTRACT,
9
8
  executionModeSchemaDescription,
10
9
  } from '../../../../shared/background-tasks.mjs';
11
10
 
@@ -28,7 +27,7 @@ function _shellMaxTimeoutMs() {
28
27
  // process lifetime, so this is evaluated once at module load.
29
28
  const _shellSyntaxCheat =
30
29
  process.platform === 'win32'
31
- ? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, if && is unsupported use ;, $PID is reserved.'
30
+ ? ' PowerShell: use ; between commands, /c/→C:\\, and note that $PID is reserved.'
32
31
  : '';
33
32
 
34
33
  export const BUILTIN_TOOLS = [
@@ -36,7 +35,7 @@ export const BUILTIN_TOOLS = [
36
35
  name: 'read',
37
36
  title: 'Mixdog Read',
38
37
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
39
- description: 'Read file contents. Batch paths/regions as real arrays: path[] or {path,offset,limit}[] regions in one call. Not for directories.',
38
+ description: 'Read file contents or line ranges; not directories.',
40
39
  inputSchema: {
41
40
  type: 'object',
42
41
  properties: {
@@ -99,12 +98,12 @@ export const BUILTIN_TOOLS = [
99
98
  name: 'task',
100
99
  title: 'Background Task Control',
101
100
  annotations: { title: 'Background Task Control', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
102
- description: `Control a shell async task_id: actions list/status/read/wait/cancel. ${TOOL_MANUAL_CONTROL_CONTRACT} Not sess_* or agent ids.`,
101
+ description: 'Control a shell background task_id; not session or agent ids.',
103
102
  inputSchema: {
104
103
  type: 'object',
105
104
  properties: {
106
105
  task_id: { type: 'string', description: 'shell async task_id.' },
107
- action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Defaults to list, or wait when task_id is set; avoid polling loops.' },
106
+ action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Default list; with task_id, default wait.' },
108
107
  timeout_ms: { type: 'number', description: 'Wait timeout ms.' },
109
108
  },
110
109
  required: [],
@@ -115,7 +114,7 @@ export const BUILTIN_TOOLS = [
115
114
  name: 'grep',
116
115
  title: 'Mixdog Grep',
117
116
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
118
- description: 'Content search by literal or regex over file/dir scopes; matches return copy-ready raw source spans.',
117
+ description: 'Literal/regex search over file/dir scopes; returns source blocks with context.',
119
118
  inputSchema: {
120
119
  type: 'object',
121
120
  properties: {
@@ -156,7 +155,7 @@ export const BUILTIN_TOOLS = [
156
155
  name: 'glob',
157
156
  title: 'Mixdog Glob',
158
157
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
159
- description: 'Match exact glob patterns from base directories; batch pattern[]/path[].',
158
+ description: 'Match glob patterns under base directories.',
160
159
  inputSchema: {
161
160
  type: 'object',
162
161
  properties: {
@@ -185,7 +184,7 @@ export const BUILTIN_TOOLS = [
185
184
  name: 'find',
186
185
  title: 'Mixdog Find Files',
187
186
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
188
- description: 'Fuzzy lookup for partial paths/names (dot dirs included). Returns matching paths; not file contents.',
187
+ description: 'Fuzzy partial path/name lookup; returns paths only.',
189
188
  inputSchema: {
190
189
  type: 'object',
191
190
  properties: {
@@ -207,7 +206,7 @@ export const BUILTIN_TOOLS = [
207
206
  name: 'list',
208
207
  title: 'Mixdog List Directory',
209
208
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
210
- description: 'List directory entries (path + type); batch independent dirs as path[].',
209
+ description: 'List directory entries (path + type).',
211
210
  inputSchema: {
212
211
  type: 'object',
213
212
  properties: {
@@ -238,7 +238,7 @@ export function getCachedReadOnlyStat(fullPath, loader = statSync, now = Date.no
238
238
  return stat;
239
239
  }
240
240
 
241
- export async function statPathsForMtime(paths, workDir, concurrency = 64, opts = {}) {
241
+ export async function statPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
242
242
  const items = Array.isArray(paths) ? paths : [];
243
243
  const out = new Array(items.length);
244
244
  const now = Date.now();
@@ -302,7 +302,7 @@ export async function statPathsForMtime(paths, workDir, concurrency = 64, opts =
302
302
  // lstat variant — does NOT follow symlinks. Use for directory-listing
303
303
  // surfaces where a symlink to a 200 GB file should report as a symlink,
304
304
  // not as the target's size/mtime.
305
- export async function lstatPathsForMtime(paths, workDir, concurrency = 64, opts = {}) {
305
+ export async function lstatPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
306
306
  const items = Array.isArray(paths) ? paths : [];
307
307
  const out = new Array(items.length);
308
308
  const inflight = new Map();
@@ -19,8 +19,8 @@ export function isBlockedDevicePath(p) {
19
19
 
20
20
  // R1: UNC / SMB share paths (\\server\share or //server/share). Reading these
21
21
  // causes Windows to auto-authenticate to the remote host, leaking the NTLM
22
- // hash of the current user to any attacker-controlled SMB target. CC parity:
23
- // FileReadTool.ts:461 rejects the same prefix before stat. Accepts both
22
+ // hash of the current user to any attacker-controlled SMB target. The prefix
23
+ // is rejected before stat. Accepts both
24
24
  // backslash and forward-slash leaders so a normalize that picked either form
25
25
  // is caught.
26
26
  export function isUncPath(p) {
@@ -47,6 +47,6 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
47
47
  // the wall-clock cost is one deadline, not N.
48
48
  export async function assertPathsReachable(paths, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
49
49
  const list = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p.length) : [];
50
- if (list.length === 0) return;
51
- await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
50
+ if (list.length === 0) return [];
51
+ return await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
52
52
  }
@@ -17,7 +17,6 @@ import {
17
17
  } from './search-input-helpers.mjs';
18
18
 
19
19
  export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT = 10_000;
20
- const GREP_CONTEXT_READ_CONCURRENCY = 4;
21
20
  const GREP_FOCUSED_CONTEXT_RADIUS = 12;
22
21
  const GREP_FOCUSED_RAW_BLOCKS = 3;
23
22
  // Anchors must stay usable as evidence without a follow-up read: keep the
@@ -229,21 +228,13 @@ async function readAnchorSources(anchors, radius, signal) {
229
228
  groups.get(anchor.absolutePath).anchors.push(anchor);
230
229
  }
231
230
  const entries = [...groups.values()];
232
- let next = 0;
233
- const worker = async () => {
234
- while (next < entries.length) {
235
- const entry = entries[next++];
236
- try {
237
- entry.lines = await readFileWindows(entry, radius, signal);
238
- } catch (err) {
239
- entry.error = err;
240
- }
231
+ await Promise.all(entries.map(async (entry) => {
232
+ try {
233
+ entry.lines = await readFileWindows(entry, radius, signal);
234
+ } catch (err) {
235
+ entry.error = err;
241
236
  }
242
- };
243
- await Promise.all(Array.from(
244
- { length: Math.min(GREP_CONTEXT_READ_CONCURRENCY, entries.length) },
245
- () => worker(),
246
- ));
237
+ }));
247
238
  return groups;
248
239
  }
249
240
 
@@ -18,10 +18,12 @@ import {
18
18
  cacheGet,
19
19
  cacheSet,
20
20
  getCachedReadOnlyStat,
21
+ lstatPathsForMtime,
21
22
  statCacheSet,
22
23
  statPathsForMtime,
23
24
  registerCacheInvalidationListener,
24
25
  } from './cache-layers.mjs';
26
+ import { formatListSize, formatMtime } from './list-formatting.mjs';
25
27
  import {
26
28
  compileSimpleGlob,
27
29
  NOISE_DIR_NAMES,
@@ -88,16 +90,12 @@ export async function executeListTool(args, workDir, options = {}) {
88
90
  args.path = coerceReadFamilyPathArg(args.path, workDir);
89
91
  if (Array.isArray(args.path)) {
90
92
  const list = [...new Set(args.path.map((p) => (typeof p === 'string' ? p.trim() : '')).filter((p) => p.length > 0))];
91
- const capped = list.length > 10;
92
- const targets = capped ? list.slice(0, 10) : list;
93
+ const targets = list;
93
94
  if (targets.length > 1) {
94
- // Bounded parallel fan-out: each target is an independent listing
95
+ // Full parallel fan-out: each target is an independent listing
95
96
  // (own guard/cache/reachability), so run them concurrently instead
96
97
  // of serially. Bodies land in a fixed-index array so the emitted
97
- // section order still matches the caller's `path[]` order — only the
98
- // wall-clock cost changes. Concurrency is capped so a 10-path batch
99
- // cannot exhaust the child-spawn / FS-handle budget.
100
- const LIST_FANOUT_CONCURRENCY = 4;
98
+ // section order still matches the caller's `path[]` order.
101
99
  // Collapse semantic-duplicate targets: two spellings that resolve to
102
100
  // the same directory (e.g. `foo` and `./foo`) are stat'd/walked ONCE.
103
101
  // Each caller label keeps its own `# list <p>` section reusing the
@@ -116,24 +114,14 @@ export async function executeListTool(args, workDir, options = {}) {
116
114
  if (!repByKey.has(k)) { repByKey.set(k, targets[i]); uniqueKeys.push(k); }
117
115
  }
118
116
  const bodyByKey = new Map();
119
- let cursor = 0;
120
- const runWorker = async () => {
121
- for (;;) {
122
- const i = cursor++;
123
- if (i >= uniqueKeys.length) return;
124
- const k = uniqueKeys[i];
125
- try {
126
- bodyByKey.set(k, await executeListTool({ ...args, path: repByKey.get(k) }, workDir, options));
127
- } catch (err) {
128
- bodyByKey.set(k, `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`);
129
- }
117
+ await Promise.all(uniqueKeys.map(async (k) => {
118
+ try {
119
+ bodyByKey.set(k, await executeListTool({ ...args, path: repByKey.get(k) }, workDir, options));
120
+ } catch (err) {
121
+ bodyByKey.set(k, `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`);
130
122
  }
131
- };
132
- await Promise.all(
133
- Array.from({ length: Math.min(LIST_FANOUT_CONCURRENCY, uniqueKeys.length) }, runWorker),
134
- );
123
+ }));
135
124
  const sections = targets.map((p, i) => `# list ${p}\n${bodyByKey.get(keyByIndex[i])}`);
136
- if (capped) sections.push(`... [capped at 10 of ${list.length} paths]`);
137
125
  return sections.join('\n\n');
138
126
  }
139
127
  args.path = targets[0];
@@ -582,8 +570,7 @@ async function getTargetedFindEnumeration({
582
570
  export async function executeFuzzyFindTool(args, workDir, options = {}) {
583
571
  if (Array.isArray(args.query)) {
584
572
  const list = [...new Set(args.query.map((q) => (typeof q === 'string' ? q.trim() : '')).filter((q) => q.length > 0))];
585
- const capped = list.length > 5;
586
- const targets = capped ? list.slice(0, 5) : list;
573
+ const targets = list;
587
574
  if (targets.length > 1) {
588
575
  // head_limit is one call-level result budget, not a multiplier per
589
576
  // query. Split it deterministically in caller order; if it cannot
@@ -601,7 +588,6 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
601
588
  + `retry query=${JSON.stringify(omittedByHeadLimit)}]`,
602
589
  );
603
590
  }
604
- if (capped) notes.push(`... [capped at 5 of ${list.length} queries]`);
605
591
  const sectionHeaders = activeTargets.map((query) => `# find ${query}\n`);
606
592
  const separatorBytes = Math.max(0, activeTargets.length + notes.length - 1) * 2;
607
593
  const fixedBytes = sectionHeaders.reduce(
@@ -1,4 +1,4 @@
1
- import { readFileSync, statSync } from 'fs';
1
+ import { readFile, stat } from 'fs/promises';
2
2
  import { extname } from 'path';
3
3
  import {
4
4
  API_IMAGE_MAX_BASE64_SIZE,
@@ -6,7 +6,7 @@ import {
6
6
  resizeImageBuffer,
7
7
  } from './read-image-resize.mjs';
8
8
 
9
- // Image extensions native Read renders as image blocks (FileReadTool.ts).
9
+ // Image extensions that Read renders as image blocks.
10
10
  const IMAGE_MIME = {
11
11
  '.png': 'image/png',
12
12
  '.jpg': 'image/jpeg',
@@ -36,14 +36,14 @@ export function imageMimeForPath(p) {
36
36
  // sharp absent / resize failed: GRACEFUL fallback to the legacy
37
37
  // pass-through-with-cap behaviour (refuse over-cap originals with an isError
38
38
  // text result, else emit the raw base64 image block). No throw on missing sharp.
39
- export async function readImageAsContent(fullPath, displayPath) {
39
+ export async function readImageAsContent(fullPath, displayPath, preflightStat = null) {
40
40
  const mimeType = imageMimeForPath(fullPath);
41
41
  if (!mimeType) return null;
42
42
  let st;
43
- try { st = statSync(fullPath); } catch { return null; }
43
+ try { st = preflightStat || await stat(fullPath); } catch { return null; }
44
44
 
45
45
  let buf;
46
- try { buf = readFileSync(fullPath); } catch { return null; }
46
+ try { buf = await readFile(fullPath); } catch { return null; }
47
47
  if (buf.length === 0) {
48
48
  return {
49
49
  content: [{ type: 'text', text: `Error: image "${displayPath}" is empty (0 bytes).` }],
@@ -1,4 +1,3 @@
1
- import { lstatSync, realpathSync, readdirSync, statSync } from 'fs';
2
1
  import * as fsPromises from 'fs/promises';
3
2
  import { readFile } from 'fs/promises';
4
3
  import { extname } from 'path';
@@ -7,7 +6,11 @@ import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRed
7
6
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
8
7
  import { recordReadPathRedirect } from './snapshot-store.mjs';
9
8
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
10
- import { formatBinaryReadPreview } from './binary-file.mjs';
9
+ import {
10
+ formatBinaryReadPreviewFromBuffer,
11
+ inspectBinaryFile,
12
+ isBinaryBuffer,
13
+ } from './binary-file.mjs';
11
14
 
12
15
  function snapshotBodyWasReturnedByRead(snapshot) {
13
16
  return String(snapshot?.source || '').startsWith('read');
@@ -27,22 +30,27 @@ function withSymbolReadNote(text, args) {
27
30
  // decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
28
31
  // U+FEFF is stripped for display downstream, so bomLen is reported but not
29
32
  // applied for utf8.
33
+ function detectReadEncodingFromBuffer(head) {
34
+ const n = head?.length || 0;
35
+ if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
36
+ return { encoding: 'utf16le', bomLen: 2 };
37
+ }
38
+ if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
39
+ return { encoding: 'utf16be', bomLen: 2 };
40
+ }
41
+ if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
42
+ return { encoding: 'utf8', bomLen: 3 };
43
+ }
44
+ return { encoding: 'utf8', bomLen: 0 };
45
+ }
46
+
30
47
  async function detectReadEncoding(fullPath) {
31
48
  let fh;
32
49
  try {
33
50
  fh = await fsPromises.open(fullPath, 'r');
34
51
  const head = Buffer.alloc(3);
35
52
  const { bytesRead: n } = await fh.read(head, 0, 3, 0);
36
- if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
37
- return { encoding: 'utf16le', bomLen: 2 };
38
- }
39
- if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
40
- return { encoding: 'utf16be', bomLen: 2 };
41
- }
42
- if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
43
- return { encoding: 'utf8', bomLen: 3 };
44
- }
45
- return { encoding: 'utf8', bomLen: 0 };
53
+ return detectReadEncodingFromBuffer(head.subarray(0, n));
46
54
  } catch {
47
55
  return { encoding: 'utf8', bomLen: 0 };
48
56
  } finally {
@@ -112,7 +120,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
112
120
  // R1: UNC / SMB share reject (\\server\share, //server/share). Reading
113
121
  // these on Windows auto-authenticates to the remote host and leaks the
114
122
  // NTLM hash of the current user to any attacker-controlled SMB target.
115
- // CC parity: FileReadTool.ts:461 rejects the same prefix before stat.
123
+ // The prefix is rejected before stat.
116
124
  // Must run before resolveAgainstCwd so a relative path can't be coerced
117
125
  // into a UNC share by the cwd resolution.
118
126
  if (typeof isUncPath === 'function' && isUncPath(filePath))
@@ -142,7 +150,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
142
150
  return `Error: cannot read Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(fullPath)}`;
143
151
  if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(fullPath))
144
152
  return `Error: cannot read Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(fullPath)}`;
145
- // Pre-read size cap (Anthropic FileReadTool/limits.ts pattern):
153
+ // Pre-read size cap:
146
154
  // throw a small error response when the file is too big rather
147
155
  // than truncating to 25K tokens of content. Throw is decisively
148
156
  // more token-efficient (Anthropic #21841 reverted truncation).
@@ -164,7 +172,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
164
172
  let st;
165
173
  let _statErr;
166
174
  try {
167
- st = statSync(fullPath);
175
+ st = options?._preflightStats?.get?.(fullPath) || await fsPromises.stat(fullPath);
168
176
  } catch (err) {
169
177
  // Fall through to the existing similar-file recovery path below.
170
178
  st = null;
@@ -174,7 +182,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
174
182
  if (st.isDirectory()) {
175
183
  let entries = [];
176
184
  try {
177
- entries = readdirSync(fullPath, { withFileTypes: true })
185
+ entries = (await fsPromises.readdir(fullPath, { withFileTypes: true }))
178
186
  .slice(0, 20)
179
187
  .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
180
188
  } catch { /* best-effort preview */ }
@@ -191,14 +199,14 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
191
199
  return `Error: cannot read special file (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)}`;
192
200
  // R1+R2: realpath the resolved path so a symlink → /dev/zero (or any
193
201
  // other blocked device, UNC, or Windows reserved name) is caught on
194
- // the REAL target, not the symlink name. lstatSync detects whether
195
- // the entry IS a symlink first so realpathSync is only called when
202
+ // the REAL target, not the symlink name. lstat detects whether
203
+ // the entry IS a symlink first so realpath is only called when
196
204
  // it would actually differ — saves a syscall on the common case.
197
205
  try {
198
- const _lst = lstatSync(fullPath);
206
+ const _lst = await fsPromises.lstat(fullPath);
199
207
  if (_lst && typeof _lst.isSymbolicLink === 'function' && _lst.isSymbolicLink()) {
200
208
  let _realTarget = null;
201
- try { _realTarget = realpathSync(fullPath); } catch { _realTarget = null; }
209
+ try { _realTarget = await fsPromises.realpath(fullPath); } catch { _realTarget = null; }
202
210
  if (_realTarget && _realTarget !== fullPath) {
203
211
  if (isBlockedDevicePath(_realTarget))
204
212
  return `Error: cannot read device file via symlink (would block or produce infinite output): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
@@ -211,7 +219,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
211
219
  // the target stat could differ from the link stat in
212
220
  // pathological cases (replaced under us).
213
221
  try {
214
- const _rst = statSync(_realTarget);
222
+ const _rst = await fsPromises.stat(_realTarget);
215
223
  if (typeof isSpecialFileStat === 'function' && isSpecialFileStat(_rst))
216
224
  return `Error: cannot read special file via symlink (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
217
225
  } catch { /* if the target is gone, let the normal path surface ENOENT */ }
@@ -430,11 +438,42 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
430
438
  // large UTF-16LE+BOM file is recognized as text up front and routed to
431
439
  // the bounded in-memory utf16le path below, never mis-decoded as utf-8
432
440
  // or rejected by the utf-8 streaming/binary branch (Bug 1).
433
- const _readEnc = await detectReadEncoding(fullPath);
441
+ const _preferRangeStream = hasRangeArgs && !wantFull
442
+ && (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES);
443
+ const _preferSmartStream = !hasRangeArgs && !wantFull
444
+ && st.size >= READ_SMART_STREAM_MIN_BYTES;
445
+ const _preferBufferedRead = st.size <= READ_MAX_SIZE_BYTES
446
+ && !_preferRangeStream
447
+ && !_preferSmartStream;
448
+ let _prefetchedRawBuf = null;
449
+ let _prefetchedRawFromCache = false;
450
+ if (_preferBufferedRead) {
451
+ const _cachedRaw = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
452
+ if (_cachedRaw) {
453
+ _prefetchedRawBuf = _cachedRaw;
454
+ _prefetchedRawFromCache = true;
455
+ } else {
456
+ try { _prefetchedRawBuf = await readFile(fullPath); } catch { _prefetchedRawBuf = null; }
457
+ }
458
+ }
459
+ const _readEnc = _prefetchedRawBuf
460
+ ? detectReadEncodingFromBuffer(_prefetchedRawBuf.subarray(0, Math.min(3, _prefetchedRawBuf.length)))
461
+ : await detectReadEncoding(fullPath);
434
462
  // UTF-16 (LE or BE) reads share one constraint: the streaming/binary
435
463
  // paths decode chunks as utf-8, so a BOM-flagged UTF-16 file must route
436
464
  // to the bounded in-memory decode below regardless of byte order.
437
465
  const _isUtf16 = _readEnc.encoding === 'utf16le' || _readEnc.encoding === 'utf16be';
466
+ let _binaryInspection = null;
467
+ const _inspectBinary = async () => {
468
+ if (_binaryInspection) return _binaryInspection;
469
+ _binaryInspection = _prefetchedRawBuf
470
+ ? {
471
+ isBinary: isBinaryBuffer(_prefetchedRawBuf, st.size),
472
+ preview: _prefetchedRawBuf.subarray(0, Math.min(256, _prefetchedRawBuf.length)),
473
+ }
474
+ : await inspectBinaryFile(fullPath, st.size);
475
+ return _binaryInspection;
476
+ };
438
477
  if (st.size > READ_MAX_SIZE_BYTES) {
439
478
  // .pdf/.ipynb were already dispatched up front (MEDIA-WINS), so this
440
479
  // >10MiB branch only handles the non-media text path from here on.
@@ -450,8 +489,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
450
489
  if (!hasRangeArgs) {
451
490
  return `Error: file size ${st.size} bytes exceeds ${READ_MAX_SIZE_BYTES}-byte cap.`;
452
491
  }
453
- if (isBinaryFile(fullPath, st.size)) {
454
- const { text, snapshotMeta } = formatBinaryReadPreview(fullPath, normalizeOutputPath(filePath), st.size);
492
+ const _binary = await _inspectBinary();
493
+ if (_binary.isBinary) {
494
+ const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
495
+ _binary.preview,
496
+ normalizeOutputPath(filePath),
497
+ st.size,
498
+ );
455
499
  _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
456
500
  _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
457
501
  return withSymbolReadNote(text, args);
@@ -510,11 +554,18 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
510
554
  // unambiguous TEXT signal, so classify it as utf16le up front and skip
511
555
  // NUL rejection. (_readEnc was detected above, before the >10MiB size
512
556
  // branch, so the large-file path can classify utf16le.)
513
- if (!_isUtf16 && isBinaryFile(fullPath, st.size)) {
514
- const { text, snapshotMeta } = formatBinaryReadPreview(fullPath, normalizeOutputPath(filePath), st.size);
515
- _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
516
- _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
517
- return withSymbolReadNote(text, args);
557
+ if (!_isUtf16) {
558
+ const _binary = await _inspectBinary();
559
+ if (_binary.isBinary) {
560
+ const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
561
+ _binary.preview,
562
+ normalizeOutputPath(filePath),
563
+ st.size,
564
+ );
565
+ _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
566
+ _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
567
+ return withSymbolReadNote(text, args);
568
+ }
518
569
  }
519
570
  // Whole-file reads above READ_WHOLE_FILE_MAX_BYTES use stream smart-elide
520
571
  // (then READ_MAX_OUTPUT_BYTES truncation) instead of refusing. Absolute
@@ -588,8 +639,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
588
639
  }
589
640
  }
590
641
  try {
591
- const cachedRawBuf = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
592
- const rawBuf = cachedRawBuf || await readFile(fullPath);
642
+ const rawBuf = _prefetchedRawBuf || await readFile(fullPath);
593
643
  // Encoding-aware decode (fresh read AND raw-content cache hit both
594
644
  // flow through here). For a BOM-flagged UTF-16LE file, strip the
595
645
  // 2-byte FF FE BOM and decode as utf16le so it reverses the write
@@ -614,7 +664,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
614
664
  // the cache + snapshot record stale bytes.
615
665
  let _stPostRead;
616
666
  let _readStableForRawCache = true;
617
- if (cachedRawBuf) {
667
+ if (_prefetchedRawFromCache) {
618
668
  _stPostRead = st;
619
669
  } else {
620
670
  try { _stPostRead = await fsPromises.stat(fullPath); } catch { _stPostRead = st; }
@@ -712,7 +762,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
712
762
  out = appendReadContextAdvisory(out, { filePath, lineCount, bytes: st.size });
713
763
  }
714
764
  }
715
- // CC parity: empty file gets a system-reminder instead of
765
+ // An empty file gets a system-reminder instead of
716
766
  // a bare `1│` line. The reminder makes the empty-state
717
767
  // explicit so the agent doesn't assume content was elided.
718
768
  if (content.length === 0) {