mixdog 0.9.3 → 0.9.4

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 (329) hide show
  1. package/package.json +7 -3
  2. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  3. package/scripts/bench/lead-review-tasks.json +20 -0
  4. package/scripts/bench/r4-mixed-tasks.json +20 -0
  5. package/scripts/bench/review-tasks.json +20 -0
  6. package/scripts/bench/round-codex.json +114 -0
  7. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  8. package/scripts/bench/round-mixdog-lead.json +269 -0
  9. package/scripts/bench/round-mixdog.json +126 -0
  10. package/scripts/bench/round-r10-bigsample.json +679 -0
  11. package/scripts/bench/round-r11-codexalign.json +257 -0
  12. package/scripts/bench/round-r4-codex.json +114 -0
  13. package/scripts/bench/round-r4-mixed.json +225 -0
  14. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  15. package/scripts/bench/round-r6-codex.json +114 -0
  16. package/scripts/bench/round-r6-solo.json +257 -0
  17. package/scripts/bench/round-r7-full.json +254 -0
  18. package/scripts/bench/round-r8-fulldefault.json +255 -0
  19. package/scripts/bench-run.mjs +215 -29
  20. package/scripts/freevar-smoke.mjs +95 -0
  21. package/scripts/internal-comms-bench.mjs +1 -0
  22. package/scripts/internal-comms-smoke.mjs +10 -9
  23. package/scripts/mouse-probe.mjs +45 -0
  24. package/scripts/output-style-bench.mjs +13 -6
  25. package/scripts/output-style-smoke.mjs +4 -4
  26. package/scripts/provider-toolcall-test.mjs +7 -3
  27. package/scripts/recall-usecase-cases.json +18 -0
  28. package/scripts/recall-usecase-probe.json +6 -0
  29. package/scripts/session-bench.mjs +152 -6
  30. package/scripts/tool-smoke.mjs +23 -63
  31. package/scripts/tui-render-smoke.mjs +90 -0
  32. package/scripts/webhook-smoke.mjs +208 -0
  33. package/src/agents/debugger/AGENT.md +4 -1
  34. package/src/agents/heavy-worker/AGENT.md +6 -5
  35. package/src/agents/maintainer/AGENT.md +4 -0
  36. package/src/agents/reviewer/AGENT.md +2 -1
  37. package/src/agents/worker/AGENT.md +8 -4
  38. package/src/lib/rules-builder.cjs +4 -0
  39. package/src/mixdog-session-runtime.mjs +632 -2042
  40. package/src/output-styles/default.md +34 -9
  41. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  42. package/src/output-styles/minimal.md +4 -1
  43. package/src/output-styles/simple.md +22 -7
  44. package/src/rules/agent/00-common.md +2 -0
  45. package/src/rules/lead/lead-brief.md +12 -0
  46. package/src/rules/lead/lead-tool.md +0 -11
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  49. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  50. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  51. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  52. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  53. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  54. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  55. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  56. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  57. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  58. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  59. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  60. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  62. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  63. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  65. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  66. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  67. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  69. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  70. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +297 -2123
  76. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +130 -1002
  77. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  80. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  82. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  83. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  84. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  85. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  86. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  87. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  88. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  89. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  90. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  91. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  93. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  94. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  95. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  97. package/src/runtime/agent/orchestrator/session/loop.mjs +251 -397
  98. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  99. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  100. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  101. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  102. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  103. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  104. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  105. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  106. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  107. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  108. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  109. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +32 -41
  110. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  113. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  115. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  116. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  118. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  119. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  120. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  124. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  125. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  126. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  128. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  130. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  131. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  132. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  133. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  134. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  136. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  137. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  138. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  139. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  140. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  141. package/src/runtime/channels/backends/discord.mjs +12 -292
  142. package/src/runtime/channels/index.mjs +229 -663
  143. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  144. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  145. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  146. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  147. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  148. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  149. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  150. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  151. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  152. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  153. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  154. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  155. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  156. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  157. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  158. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  159. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  160. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  161. package/src/runtime/channels/lib/webhook.mjs +36 -570
  162. package/src/runtime/channels/tool-defs.mjs +11 -130
  163. package/src/runtime/memory/index.mjs +201 -1948
  164. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  165. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  166. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  167. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  168. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  169. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  170. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  171. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  172. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  173. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  174. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  175. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  177. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  178. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  179. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  180. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  181. package/src/runtime/memory/tool-defs.mjs +5 -13
  182. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  183. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  184. package/src/runtime/search/lib/web-tools.mjs +24 -602
  185. package/src/runtime/shared/atomic-file.mjs +26 -1
  186. package/src/runtime/shared/launcher-control.mjs +2 -2
  187. package/src/runtime/shared/tool-primitives.mjs +308 -0
  188. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  189. package/src/runtime/shared/tool-surface.mjs +80 -898
  190. package/src/runtime/shared/transcript-writer.mjs +23 -0
  191. package/src/runtime/shared/update-checker.mjs +7 -4
  192. package/src/session-runtime/config-helpers.mjs +84 -2
  193. package/src/session-runtime/config-lifecycle.mjs +232 -0
  194. package/src/session-runtime/cwd-plugins.mjs +226 -0
  195. package/src/session-runtime/mcp-glue.mjs +177 -0
  196. package/src/session-runtime/model-recency.mjs +111 -0
  197. package/src/session-runtime/native-search.mjs +247 -0
  198. package/src/session-runtime/output-styles.mjs +11 -9
  199. package/src/session-runtime/prewarm.mjs +142 -0
  200. package/src/session-runtime/provider-models.mjs +278 -0
  201. package/src/session-runtime/provider-usage.mjs +120 -0
  202. package/src/session-runtime/quick-model-rows.mjs +170 -0
  203. package/src/session-runtime/quick-search-models.mjs +46 -0
  204. package/src/session-runtime/session-hooks.mjs +93 -0
  205. package/src/session-runtime/settings-api.mjs +319 -0
  206. package/src/session-runtime/tool-catalog.mjs +29 -29
  207. package/src/session-runtime/tool-defs.mjs +84 -0
  208. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  209. package/src/standalone/agent-tool/helpers.mjs +237 -0
  210. package/src/standalone/agent-tool/notify.mjs +107 -0
  211. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  212. package/src/standalone/agent-tool/render.mjs +152 -0
  213. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  214. package/src/standalone/agent-tool.mjs +110 -671
  215. package/src/standalone/channel-worker.mjs +4 -7
  216. package/src/standalone/explore-tool.mjs +30 -9
  217. package/src/standalone/hook-bus/config.mjs +207 -0
  218. package/src/standalone/hook-bus/constants.mjs +90 -0
  219. package/src/standalone/hook-bus/handlers.mjs +481 -0
  220. package/src/standalone/hook-bus/payload.mjs +31 -0
  221. package/src/standalone/hook-bus/rules.mjs +77 -0
  222. package/src/standalone/hook-bus.mjs +77 -870
  223. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  224. package/src/standalone/opencode-go-login.mjs +5 -1
  225. package/src/standalone/provider-admin.mjs +1 -16
  226. package/src/standalone/usage-dashboard.mjs +3 -1
  227. package/src/tui/App.jsx +945 -8094
  228. package/src/tui/app/app-format.mjs +206 -0
  229. package/src/tui/app/channel-pickers.mjs +510 -0
  230. package/src/tui/app/clipboard.mjs +67 -0
  231. package/src/tui/app/core-memory-picker.mjs +210 -0
  232. package/src/tui/app/extension-pickers.mjs +506 -0
  233. package/src/tui/app/input-parsers.mjs +193 -0
  234. package/src/tui/app/maintenance-pickers.mjs +324 -0
  235. package/src/tui/app/model-options.mjs +330 -0
  236. package/src/tui/app/model-picker.mjs +365 -0
  237. package/src/tui/app/onboarding-steps.mjs +400 -0
  238. package/src/tui/app/project-picker.mjs +247 -0
  239. package/src/tui/app/provider-setup-picker.mjs +580 -0
  240. package/src/tui/app/resume-picker.mjs +55 -0
  241. package/src/tui/app/route-pickers.mjs +419 -0
  242. package/src/tui/app/settings-picker.mjs +490 -0
  243. package/src/tui/app/slash-commands.mjs +101 -0
  244. package/src/tui/app/slash-dispatch.mjs +427 -0
  245. package/src/tui/app/text-layout.mjs +46 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  247. package/src/tui/app/transcript-window.mjs +671 -0
  248. package/src/tui/app/use-mouse-input.mjs +460 -0
  249. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  250. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  251. package/src/tui/app/use-transcript-window.mjs +589 -0
  252. package/src/tui/components/ConfirmBar.jsx +1 -1
  253. package/src/tui/components/Picker.jsx +32 -4
  254. package/src/tui/components/PromptInput.jsx +23 -101
  255. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  256. package/src/tui/components/StatusLine.jsx +63 -12
  257. package/src/tui/components/TextEntryPanel.jsx +11 -0
  258. package/src/tui/components/ToolExecution.jsx +52 -594
  259. package/src/tui/components/TranscriptItem.jsx +105 -0
  260. package/src/tui/components/UsagePanel.jsx +18 -4
  261. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  262. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  263. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  264. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  265. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  266. package/src/tui/display-width.mjs +20 -3
  267. package/src/tui/dist/index.mjs +19652 -18630
  268. package/src/tui/engine/agent-job-feed.mjs +133 -0
  269. package/src/tui/engine/notification-plan.mjs +76 -0
  270. package/src/tui/engine/render-timing.mjs +17 -0
  271. package/src/tui/engine/tool-approval.mjs +94 -0
  272. package/src/tui/engine/tool-card-results.mjs +234 -0
  273. package/src/tui/engine/tool-result-status.mjs +135 -0
  274. package/src/tui/engine.mjs +122 -562
  275. package/src/tui/figures.mjs +5 -0
  276. package/src/tui/index.jsx +105 -0
  277. package/src/tui/input-editing.mjs +2 -2
  278. package/src/tui/markdown/format-token.mjs +4 -1
  279. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  280. package/src/tui/theme.mjs +6 -0
  281. package/src/ui/statusline-agents.mjs +213 -0
  282. package/src/ui/statusline-format.mjs +146 -0
  283. package/src/ui/statusline-segments.mjs +148 -0
  284. package/src/ui/statusline.mjs +67 -501
  285. package/src/ui/tool-card.mjs +0 -1
  286. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  287. package/src/workflows/default/WORKFLOW.md +1 -1
  288. package/src/workflows/sequential/WORKFLOW.md +1 -1
  289. package/vendor/ink/build/display-width.js +19 -3
  290. package/vendor/ink/build/ink.js +103 -6
  291. package/vendor/ink/build/log-update.js +17 -3
  292. package/vendor/ink/build/wrap-text.js +125 -0
  293. package/scripts/_test-folder-dialog.mjs +0 -30
  294. package/scripts/fix-brief-fn.mjs +0 -35
  295. package/scripts/fix-format-tool-surface.mjs +0 -24
  296. package/scripts/fix-tool-exec-visible.mjs +0 -42
  297. package/scripts/patch-agent-brief.mjs +0 -48
  298. package/scripts/patch-app.mjs +0 -21
  299. package/scripts/patch-app2.mjs +0 -18
  300. package/scripts/patch-dist-brief.mjs +0 -96
  301. package/scripts/patch-tool-exec.mjs +0 -70
  302. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  303. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  304. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  305. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  306. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  307. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  308. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  309. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  310. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  311. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  312. package/src/standalone/agent-task-status.test.mjs +0 -76
  313. package/src/tui/components/tool-output-format.test.mjs +0 -399
  314. package/src/tui/display-width.test.mjs +0 -35
  315. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  316. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  317. package/src/tui/input-editing.selection.test.mjs +0 -75
  318. package/src/tui/markdown/format-token.test.mjs +0 -354
  319. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  320. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  321. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  322. package/src/tui/paste-fix.test.mjs +0 -119
  323. package/src/tui/prompt-history-store.test.mjs +0 -52
  324. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  325. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  326. package/src/ui/markdown.test.mjs +0 -70
  327. package/src/ui/statusline-context-label.test.mjs +0 -15
  328. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  329. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,11 +1,31 @@
1
- import fs, { readFileSync } from 'fs'
2
- import dns from 'dns'
3
- import net from 'net'
4
- import { Agent, fetch as undiciFetch } from 'undici'
1
+ import fs from 'fs'
5
2
 
6
3
  import { Readability } from '@mozilla/readability'
7
4
  import { isWSL } from '../../shared/wsl.mjs'
8
5
  import { startChildGuardian } from '../../shared/child-guardian.mjs'
6
+ import {
7
+ normalizeUrl,
8
+ assertPublicUrl,
9
+ assertResolvedIps,
10
+ } from './ssrf-guard.mjs'
11
+ import {
12
+ buildHeaders,
13
+ MAX_BODY_BYTES,
14
+ isFatalHttpPathPolicyError,
15
+ fetchPinnedForPausedRequest,
16
+ fetchHtml,
17
+ _metaRefreshTarget,
18
+ } from './http-fetch.mjs'
19
+
20
+ // Facade re-exports: SSRF-guard and HTTP-fetch clusters moved to dedicated
21
+ // modules; keep the original public surface resolving unchanged for importers.
22
+ export {
23
+ assertPublicUrl,
24
+ resolveAndValidate,
25
+ assertResolvedIps,
26
+ pinnedFetch,
27
+ } from './ssrf-guard.mjs'
28
+ export { isFatalHttpPathPolicyError } from './http-fetch.mjs'
9
29
 
10
30
  // Lazy heavy deps: importing jsdom (~400ms) and puppeteer-core (~130ms) at
11
31
  // module load added ~540ms to the first web search even when the request never
@@ -23,8 +43,6 @@ async function loadPuppeteer() {
23
43
  if (!_puppeteer) _puppeteer = (await import('puppeteer-core')).default
24
44
  return _puppeteer
25
45
  }
26
-
27
- const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
28
46
  import {
29
47
  noteProviderFailure,
30
48
  noteProviderSuccess,
@@ -98,346 +116,6 @@ export function getScrapeCapabilities() {
98
116
  }
99
117
  }
100
118
 
101
- function normalizeUrl(url) {
102
- const parsed = new URL(url)
103
- parsed.hash = ''
104
- return parsed.toString()
105
- }
106
-
107
- function assertPrivateIpv4(hostname) {
108
- const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
109
- if (!ipv4Match) return
110
- const [, a, b] = ipv4Match.map(Number)
111
- if (a === 127 || a === 10 || a === 0 ||
112
- (a === 172 && b >= 16 && b <= 31) ||
113
- (a === 192 && b === 168) ||
114
- (a === 169 && b === 254) ||
115
- (a === 100 && b >= 64 && b <= 127) ||
116
- (a === 198 && b >= 18 && b <= 19) ||
117
- (a >= 224 && a <= 239) ||
118
- (a >= 240)) {
119
- throw new Error(`Blocked request to private address: ${hostname}`)
120
- }
121
- }
122
-
123
- // Detect IPv4-mapped IPv6 (::ffff:/96) in BOTH dotted and hex forms and
124
- // return the embedded IPv4 as a dotted-quad string, or null when the input
125
- // is not an IPv4-mapped address. WHATWG URL canonicalises `[::ffff:127.0.0.1]`
126
- // to `[::ffff:7f00:1]`, so the hex form must be handled or assertPublicUrl /
127
- // _validateIpv6 will miss mapped loopback / private addresses.
128
- function _mappedIpv4FromIpv6(bare) {
129
- const lower = bare.toLowerCase()
130
- // Dotted form: ::ffff:a.b.c.d
131
- const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
132
- if (dotted) return dotted[1]
133
- // Hex form: ::ffff:HHHH:LLLL — low 32 bits of the /96 prefix carry the IPv4.
134
- const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
135
- if (hex) {
136
- const high = parseInt(hex[1], 16)
137
- const low = parseInt(hex[2], 16)
138
- if (Number.isFinite(high) && Number.isFinite(low) && high <= 0xffff && low <= 0xffff) {
139
- const a = (high >> 8) & 0xff
140
- const b = high & 0xff
141
- const c = (low >> 8) & 0xff
142
- const d = low & 0xff
143
- return `${a}.${b}.${c}.${d}`
144
- }
145
- }
146
- return null
147
- }
148
-
149
- export function assertPublicUrl(url) {
150
- const parsed = new URL(url)
151
-
152
- // Block dangerous protocols
153
- const blockedProtocols = ['file:', 'ftp:', 'data:', 'javascript:']
154
- if (blockedProtocols.includes(parsed.protocol)) {
155
- throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
156
- }
157
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
158
- throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
159
- }
160
-
161
- const hostname = parsed.hostname.toLowerCase()
162
-
163
- // Reject userinfo (user:pass@host) — credential-injection / SSRF vector
164
- if (parsed.username || parsed.password) {
165
- throw new Error(`Blocked URL with userinfo credentials: ${hostname}`)
166
- }
167
-
168
- // Localhost
169
- if (hostname === 'localhost') {
170
- throw new Error(`Blocked request to private address: ${hostname}`)
171
- }
172
-
173
- // IPv4 private/reserved ranges
174
- assertPrivateIpv4(hostname)
175
-
176
- // Strip brackets for IPv6 analysis (URL parser stores IPv6 without brackets in .hostname)
177
- const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
178
-
179
- // IPv6 loopback
180
- if (bare === '::1') {
181
- throw new Error(`Blocked request to private address: ${hostname}`)
182
- }
183
-
184
- // IPv6 unspecified (::)
185
- if (bare === '::') {
186
- throw new Error(`Blocked request to private address: ${hostname}`)
187
- }
188
-
189
- // IPv6 multicast (ff00::/8)
190
- if (/^ff/i.test(bare)) {
191
- throw new Error(`Blocked request to private address: ${hostname}`)
192
- }
193
-
194
- // IPv4-mapped IPv6 — ::ffff:a.b.c.d
195
- // Cover both dotted (::ffff:127.0.0.1) and hex (::ffff:7f00:1) forms —
196
- // WHATWG URL canonicalises bracketed mapped literals to the hex shape.
197
- const mappedIpv4 = _mappedIpv4FromIpv6(bare)
198
- if (mappedIpv4) {
199
- assertPrivateIpv4(mappedIpv4)
200
- }
201
-
202
- // IPv6 private (fc00::/7 — starts with fc or fd)
203
- if (/^f[cd]/i.test(bare)) {
204
- throw new Error(`Blocked request to private address: ${hostname}`)
205
- }
206
-
207
- // IPv6 link-local (fe80::/10 — starts with fe8, fe9, fea, feb)
208
- if (/^fe[89ab]/i.test(bare)) {
209
- throw new Error(`Blocked request to private address: ${hostname}`)
210
- }
211
- }
212
-
213
- function _validateIpv6(ip) {
214
- const lower = ip.toLowerCase()
215
- if (lower === '::1') {
216
- throw new Error(`Blocked request to private address: ${ip}`)
217
- }
218
- if (lower === '::') {
219
- throw new Error(`Blocked request to private address: ${ip}`)
220
- }
221
- if (/^ff/i.test(lower)) {
222
- throw new Error(`Blocked request to private address: ${ip}`)
223
- }
224
- if (/^f[cd]/i.test(lower)) {
225
- throw new Error(`Blocked request to private address: ${ip}`)
226
- }
227
- if (/^fe[89ab]/i.test(lower)) {
228
- throw new Error(`Blocked request to private address: ${ip}`)
229
- }
230
- // Cover both dotted and hex IPv4-mapped IPv6 forms — resolver output and
231
- // WHATWG-canonicalised URL hostnames may arrive as `::ffff:7f00:1`.
232
- const mappedIpv4 = _mappedIpv4FromIpv6(lower)
233
- if (mappedIpv4) {
234
- assertPrivateIpv4(mappedIpv4)
235
- }
236
- }
237
-
238
- // Resolve hostname once, validate EVERY returned address (so a DNS round-robin
239
- // can't smuggle a private IP behind a public one), and return the de-duped
240
- // `{address, family}` list. The caller pins the real connection to one of
241
- // these addresses so a second uncontrolled resolution (DNS rebinding / TOCTOU)
242
- // cannot flip the IP between validation and connect.
243
- // Race a DNS promise against an abort signal so a hung resolver cannot
244
- // outlive the request's timeout budget. The signal is the same one that
245
- // bounds the outbound fetch (AbortSignal.timeout / requestTimeoutMs), so
246
- // DNS is bounded by the same deadline as the connection.
247
- function _abortRace(promise, signal, label) {
248
- if (!signal) return promise
249
- if (signal.aborted) return Promise.reject(signal.reason || new Error(`${label} aborted`))
250
- return new Promise((resolve, reject) => {
251
- const onAbort = () => reject(signal.reason || new Error(`${label} aborted`))
252
- signal.addEventListener('abort', onAbort, { once: true })
253
- promise.then(
254
- (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
255
- (err) => { signal.removeEventListener('abort', onAbort); reject(err) },
256
- )
257
- })
258
- }
259
-
260
- export async function resolveAndValidate(hostname, { signal } = {}) {
261
- // Literal IPs bypass DNS entirely — validate directly.
262
- if (net.isIP(hostname)) {
263
- if (net.isIPv4(hostname)) {
264
- assertPrivateIpv4(hostname)
265
- return [{ address: hostname, family: 4 }]
266
- }
267
- _validateIpv6(hostname)
268
- return [{ address: hostname, family: 6 }]
269
- }
270
-
271
- const addresses = []
272
- const seen = new Set()
273
- const push = (address, family) => {
274
- const key = `${family}:${address}`
275
- if (seen.has(key)) return
276
- seen.add(key)
277
- addresses.push({ address, family })
278
- }
279
-
280
- // dns.lookup mirrors what the platform resolver will hand to the connector;
281
- // resolve4/resolve6 catch entries the stub resolver returns even when the
282
- // OS lookup table would omit them.
283
- let lookupAddrs = []
284
- try {
285
- lookupAddrs = await _abortRace(dns.promises.lookup(hostname, { all: true }), signal, 'dns.lookup')
286
- } catch (err) {
287
- if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
288
- }
289
- for (const entry of lookupAddrs) {
290
- if (entry.family === 4) assertPrivateIpv4(entry.address)
291
- else _validateIpv6(entry.address)
292
- push(entry.address, entry.family)
293
- }
294
-
295
- let v4Addrs = []
296
- try {
297
- v4Addrs = await _abortRace(dns.promises.resolve4(hostname), signal, 'dns.resolve4')
298
- } catch (err) {
299
- if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
300
- }
301
- for (const ip of v4Addrs) {
302
- assertPrivateIpv4(ip)
303
- push(ip, 4)
304
- }
305
-
306
- let v6Addrs = []
307
- try {
308
- v6Addrs = await _abortRace(dns.promises.resolve6(hostname), signal, 'dns.resolve6')
309
- } catch (err) {
310
- if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
311
- }
312
- for (const ip of v6Addrs) {
313
- _validateIpv6(ip)
314
- push(ip, 6)
315
- }
316
-
317
- return addresses
318
- }
319
-
320
- export async function assertResolvedIps(hostname) {
321
- // Backward-compatible wrapper: callers that only need validation (e.g. the
322
- // Puppeteer request interceptor, which cannot pin Chromium's connect) still
323
- // get the same throw-on-private behaviour.
324
- // Fail closed: an empty result (no DNS records, all lookups returned
325
- // ENODATA/ENOTFOUND) must NOT be treated as success — the Puppeteer path
326
- // would otherwise hand the raw hostname to Chromium for a second,
327
- // unvalidated resolution.
328
- // Callers pass `new URL(...).hostname`, which on Node/Bun keeps the
329
- // brackets around IPv6 literals (e.g. `[2606:4700::1111]`). Strip them
330
- // here so resolveAndValidate's net.isIP() path recognises the literal
331
- // instead of falling through to a doomed DNS lookup on `[..]`.
332
- const bare = _bareHost(hostname)
333
- const addresses = await resolveAndValidate(bare)
334
- if (!addresses || addresses.length === 0) {
335
- throw new Error(`DNS returned no addresses for ${hostname}`)
336
- }
337
- }
338
-
339
- // Bare hostname helper that strips IPv6 brackets — undici / WHATWG URL stores
340
- // IPv6 hostnames with the brackets included.
341
- function _bareHost(hostname) {
342
- return hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
343
- }
344
-
345
- // SSRF-hardened fetch: resolves the host ONCE, validates every returned
346
- // address, then connects to a single pre-validated IP via a per-request
347
- // undici Agent whose `connect.lookup` returns that IP only. This closes the
348
- // validate-then-fetch TOCTOU / DNS-rebinding window because the connector
349
- // never performs a second resolution against the live DNS — the Host header
350
- // (undici fills from the URL) and TLS SNI (likewise) are unaffected, so
351
- // virtual hosts and HTTPS certificate validation keep working against
352
- // legitimate public sites.
353
- export async function pinnedFetch(url, options = {}) {
354
- const parsed = new URL(url)
355
- const host = _bareHost(parsed.hostname)
356
- // Bound the validating DNS lookups by the request's own abort signal so a
357
- // hung resolver cannot outlive the fetch timeout.
358
- const addresses = await resolveAndValidate(host, { signal: options.signal })
359
- if (addresses.length === 0) {
360
- throw new Error(`DNS returned no addresses for ${host}`)
361
- }
362
- // Deterministic: pin to the first validated address. Every entry in
363
- // `addresses` already passed assertPrivateIpv4 / IPv6 checks, so picking any
364
- // index is safe — first-match keeps behaviour stable across calls.
365
- const pinned = addresses[0]
366
- const dispatcher = new Agent({
367
- connect: {
368
- // Custom lookup invoked by undici's connector. We ignore the requested
369
- // hostname argument and unconditionally hand back the pre-validated IP,
370
- // so DNS rebinding cannot flip the address between assert and connect.
371
- lookup: (_hostname, opts, cb) => {
372
- if (opts && opts.all) {
373
- cb(null, [{ address: pinned.address, family: pinned.family }])
374
- } else {
375
- cb(null, pinned.address, pinned.family)
376
- }
377
- },
378
- },
379
- })
380
- // The per-request Agent owns a dedicated connection pool. If it is never
381
- // closed it leaks the kept-alive socket until GC. Destroy it once the body
382
- // is fully consumed, cancelled, or the request errors — wrapping the body
383
- // stream so the dispatcher outlives streaming reads but is always reclaimed.
384
- let response
385
- try {
386
- response = await undiciFetch(url, { ...options, dispatcher })
387
- } catch (err) {
388
- dispatcher.destroy().catch(() => {})
389
- throw err
390
- }
391
- let cleaned = false
392
- const cleanup = () => { if (!cleaned) { cleaned = true; dispatcher.destroy().catch(() => {}) } }
393
- // If there's no body to stream, the response is already complete.
394
- if (!response.body) {
395
- cleanup()
396
- return response
397
- }
398
- // Wrap the body in a ReadableStream that pulls from the original reader and
399
- // destroys the dispatcher when the stream ends, errors, or the consumer
400
- // cancels it. ReadableStream's underlying-source pull/cancel callbacks are
401
- // reliably invoked, so the per-request Agent is always reclaimed instead of
402
- // leaking its kept-alive socket until GC.
403
- const reader = response.body.getReader()
404
- const monitored = new ReadableStream({
405
- async pull(controller) {
406
- try {
407
- const { done, value } = await reader.read()
408
- if (done) {
409
- controller.close()
410
- cleanup()
411
- return
412
- }
413
- controller.enqueue(value)
414
- } catch (err) {
415
- controller.error(err)
416
- cleanup()
417
- }
418
- },
419
- cancel(reason) {
420
- reader.cancel(reason).catch(() => {})
421
- cleanup()
422
- },
423
- })
424
- return new Response(monitored, {
425
- status: response.status,
426
- statusText: response.statusText,
427
- headers: response.headers,
428
- })
429
- }
430
-
431
- function withTimeout(controller, timeoutMs) {
432
- return setTimeout(() => controller.abort(), timeoutMs)
433
- }
434
-
435
- function buildHeaders() {
436
- return {
437
- 'User-Agent': `mixdog-search/${PKG_VERSION}`,
438
- }
439
- }
440
-
441
119
  function buildContentPayload(url, title, content, extractor, extra = {}) {
442
120
  // Whitespace-normalize extracted text so blank-line runs from page layout
443
121
  // don't eat the caller's maxLength window. Per-line interior spacing is
@@ -560,262 +238,6 @@ async function extractReadableArticle(url, html) {
560
238
  }
561
239
  }
562
240
 
563
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
564
- const MAX_REDIRECTS = 5
565
- // Hard cap on response body size (10 MB) to prevent memory DoS from a
566
- // hostile / misconfigured URL returning a huge body. Applied in two places:
567
- // 1. Content-Length pre-check (cheap reject before reading bytes).
568
- // 2. Streaming byte counter (covers chunked transfer / missing header).
569
- const MAX_BODY_BYTES = 10 * 1024 * 1024
570
-
571
- /** HTTP-path policy failures must not fall through to the Puppeteer extractor. */
572
- export function isFatalHttpPathPolicyError(error) {
573
- const msg = error instanceof Error ? error.message : String(error)
574
- if (/response body too large|page content too large|Content-Length=.*> cap=/i.test(msg)) return true
575
- if (/Blocked non-text content-type/i.test(msg)) return true
576
- if (/cross-host redirect blocked/i.test(msg)) return true
577
- if (/Blocked request to private|Blocked non-HTTP|Blocked URL with userinfo/i.test(msg)) return true
578
- if (/DNS returned no addresses/i.test(msg)) return true
579
- if (/Too many redirects/i.test(msg)) return true
580
- return false
581
- }
582
-
583
- async function readBodyWithCap(response, maxBytes) {
584
- // Reject non-text content-types early; decode by content-type charset.
585
- const contentType = (response.headers.get('content-type') || '').toLowerCase()
586
- if (contentType) {
587
- const isText = contentType.includes('text/') || contentType.includes('/html') ||
588
- contentType.includes('/xml') || contentType.includes('/json') ||
589
- contentType.includes('javascript') || contentType.includes('application/x-www-form-urlencoded')
590
- if (!isText) {
591
- // Cancel body before throwing so the underlying socket isn't held
592
- // until GC — fetchHtml's caller would otherwise leak the connection.
593
- try { await response.body?.cancel() } catch {}
594
- throw new Error(`Blocked non-text content-type: ${contentType.split(';')[0].trim()}`)
595
- }
596
- }
597
- const charsetMatch = contentType.match(/charset=([\w-]+)/i)
598
- const charset = charsetMatch ? charsetMatch[1] : 'utf-8'
599
-
600
- const contentLength = Number(response.headers.get('content-length') || 0)
601
- if (contentLength > maxBytes) {
602
- try { await response.body?.cancel() } catch {}
603
- throw new Error(`response body too large: Content-Length=${contentLength} > cap=${maxBytes}`)
604
- }
605
- const reader = response.body?.getReader?.()
606
- if (!reader) {
607
- // Fallback for environments without a readable stream — post-check length.
608
- const text = await response.text()
609
- if (text.length > maxBytes) {
610
- // response.text() already drained the body, but guard symmetrically.
611
- try { await response.body?.cancel() } catch {}
612
- throw new Error(`response body too large: ${text.length} bytes > cap=${maxBytes}`)
613
- }
614
- return text
615
- }
616
- const chunks = []
617
- let total = 0
618
- try {
619
- while (true) {
620
- const { done, value } = await reader.read()
621
- if (done) break
622
- total += value.byteLength
623
- if (total > maxBytes) {
624
- try { await reader.cancel() } catch {}
625
- throw new Error(`response body too large: received ${total}+ bytes > cap=${maxBytes}`)
626
- }
627
- chunks.push(value)
628
- }
629
- } finally {
630
- try { reader.releaseLock() } catch {}
631
- }
632
- const decoder = new TextDecoder(charset, { fatal: false })
633
- let text = ''
634
- for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
635
- text += decoder.decode()
636
- return text
637
- }
638
-
639
- /** Binary-safe body reader for CDP Fetch fulfillment (no text-only filter). */
640
- async function readBodyBytesWithCap(response, maxBytes) {
641
- const contentLength = Number(response.headers.get('content-length') || 0)
642
- if (contentLength > maxBytes) {
643
- try { await response.body?.cancel() } catch {}
644
- throw new Error(`response body too large: Content-Length=${contentLength} > cap=${maxBytes}`)
645
- }
646
- const reader = response.body?.getReader?.()
647
- if (!reader) {
648
- const buf = Buffer.from(await response.arrayBuffer())
649
- if (buf.byteLength > maxBytes) {
650
- try { await response.body?.cancel() } catch {}
651
- throw new Error(`response body too large: ${buf.byteLength} bytes > cap=${maxBytes}`)
652
- }
653
- return buf
654
- }
655
- const chunks = []
656
- let total = 0
657
- try {
658
- while (true) {
659
- const { done, value } = await reader.read()
660
- if (done) break
661
- total += value.byteLength
662
- if (total > maxBytes) {
663
- try { await reader.cancel() } catch {}
664
- throw new Error(`response body too large: received ${total}+ bytes > cap=${maxBytes}`)
665
- }
666
- chunks.push(value)
667
- }
668
- } finally {
669
- try { reader.releaseLock() } catch {}
670
- }
671
- return Buffer.concat(chunks.map((c) => Buffer.from(c)))
672
- }
673
-
674
- const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
675
- 'content-length',
676
- 'transfer-encoding',
677
- // undici decodes gzip/br/deflate; body passed to fulfillRequest is plain bytes
678
- 'content-encoding',
679
- ])
680
-
681
- function headersToCdpPairs(headers) {
682
- const out = []
683
- headers.forEach((value, name) => {
684
- const lower = name.toLowerCase()
685
- if (CDP_FORBIDDEN_RESPONSE_HEADERS.has(lower)) return
686
- out.push({ name, value })
687
- })
688
- return out
689
- }
690
-
691
- /**
692
- * Pinned fetch for a paused Chromium request: validate each hop, follow redirects,
693
- * return bytes for Fetch.fulfillRequest. Chromium never performs its own DNS/connect.
694
- */
695
- async function fetchPinnedForPausedRequest(url, { signal, method = 'GET', headers = {}, body } = {}) {
696
- const upperMethod = (method || 'GET').toUpperCase()
697
- let currentUrl = url
698
- for (let hops = 0; ; hops++) {
699
- assertPublicUrl(currentUrl)
700
- const response = await pinnedFetch(currentUrl, {
701
- signal,
702
- method: upperMethod,
703
- headers,
704
- body: hops === 0 ? body : undefined,
705
- redirect: 'manual',
706
- })
707
- if (REDIRECT_STATUSES.has(response.status)) {
708
- try { await response.body?.cancel() } catch {}
709
- if (hops >= MAX_REDIRECTS) {
710
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
711
- }
712
- const location = response.headers.get('location')
713
- if (!location) {
714
- throw new Error(`Redirect ${response.status} without Location header`)
715
- }
716
- currentUrl = new URL(location, currentUrl).toString()
717
- continue
718
- }
719
- const respBody = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
720
- return {
721
- status: response.status,
722
- responseHeaders: headersToCdpPairs(response.headers),
723
- body: respBody,
724
- }
725
- }
726
- }
727
-
728
- async function fetchHtml(url, timeoutMs, signal) {
729
- const controller = new AbortController()
730
- const timer = withTimeout(controller, timeoutMs)
731
- // Propagate an external (tool-call) abort into the local timeout controller
732
- // so a cancelled web_fetch tears down the in-flight request promptly.
733
- let onExternalAbort
734
- if (signal) {
735
- if (signal.aborted) controller.abort(signal.reason)
736
- else {
737
- onExternalAbort = () => controller.abort(signal.reason)
738
- signal.addEventListener('abort', onExternalAbort, { once: true })
739
- }
740
- }
741
- const originalHost = new URL(url).hostname.replace(/^www\./, '')
742
- try {
743
- let currentUrl = url
744
- for (let hops = 0; ; hops++) {
745
- // pinnedFetch resolves+validates the host once and forces the
746
- // connection to the validated IP — closes the validate-then-fetch
747
- // TOCTOU / DNS-rebinding window that bare `fetch` left open.
748
- const response = await pinnedFetch(currentUrl, {
749
- signal: controller.signal,
750
- headers: buildHeaders(),
751
- redirect: 'manual',
752
- })
753
- if (REDIRECT_STATUSES.has(response.status)) {
754
- // Drain the redirect response body so the socket isn't held until GC.
755
- try { await response.body?.cancel() } catch {}
756
- if (hops >= MAX_REDIRECTS) {
757
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
758
- }
759
- const location = response.headers.get('location')
760
- if (!location) {
761
- throw new Error(`Redirect ${response.status} without Location header`)
762
- }
763
- const nextUrl = new URL(location, currentUrl).toString()
764
- assertPublicUrl(nextUrl)
765
- const nextHost = new URL(nextUrl).hostname.replace(/^www\./, '')
766
- if (nextHost !== originalHost) {
767
- throw new Error(`cross-host redirect blocked (redirected_to: ${nextUrl})`)
768
- }
769
- currentUrl = nextUrl
770
- continue
771
- }
772
- if (!response.ok) {
773
- // Drain the error response body before propagating.
774
- try { await response.body?.cancel() } catch {}
775
- const err = new Error(`HTTP ${response.status}`)
776
- err.status = response.status
777
- throw err
778
- }
779
- return await readBodyWithCap(response, MAX_BODY_BYTES)
780
- }
781
- } finally {
782
- clearTimeout(timer)
783
- if (onExternalAbort) signal.removeEventListener('abort', onExternalAbort)
784
- }
785
- }
786
-
787
- // Parse a short-delay <meta http-equiv="refresh" content="N; url=..."> from
788
- // the document head. Browsers treat these as redirects, but fetchHtml only
789
- // follows HTTP-level (3xx) redirects — without this, a stub page like
790
- // tree-sitter.github.io (tiny body + meta refresh) is returned as the
791
- // "article". Long-delay refreshes (>5s) are page auto-reloads, not
792
- // redirects, and are deliberately NOT followed.
793
- function _metaRefreshTarget(html, baseUrl) {
794
- const head = String(html || '').slice(0, 8192)
795
- const tags = head.match(/<meta\b[^>]*>/gi) || []
796
- for (const tag of tags) {
797
- if (!/http-equiv\s*=\s*["']?refresh\b/i.test(tag)) continue
798
- // Quote-aware capture: the attribute value may NEST the other quote kind
799
- // (content="0; url='...'"), so a combined ["'] char class would cut the
800
- // capture at the inner quote. Match each quote style to its own closer.
801
- const m = /content\s*=\s*"([^"]*)"/i.exec(tag)
802
- || /content\s*=\s*'([^']*)'/i.exec(tag)
803
- || /content\s*=\s*([^\s>]+)/i.exec(tag)
804
- if (!m) continue
805
- const cm = /^\s*(\d+(?:\.\d+)?)\s*[;,]\s*url\s*=\s*['"]?([^'"]+?)['"]?\s*$/i.exec(m[1])
806
- if (!cm) continue
807
- const delay = Number(cm[1])
808
- if (!Number.isFinite(delay) || delay > 5) continue
809
- try {
810
- const resolved = new URL(cm[2].trim(), baseUrl)
811
- if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') continue
812
- if (resolved.href === baseUrl) continue
813
- return resolved.href
814
- } catch { continue }
815
- }
816
- return null
817
- }
818
-
819
241
  async function scrapeWithReadability(url, timeoutMs, signal) {
820
242
  let currentUrl = url
821
243
  let html = await fetchHtml(currentUrl, timeoutMs, signal)
@@ -376,7 +376,32 @@ export function writeFileAtomicSync(filePath, data, opts = {}) {
376
376
  }
377
377
  try { unlinkSync(tmp); } catch {}
378
378
  } else {
379
- renameWithRetrySync(tmp, filePath, opts);
379
+ try {
380
+ renameWithRetrySync(tmp, filePath, opts);
381
+ } catch (err) {
382
+ // Opt-in fallback for callers whose target is a rename-lock hot
383
+ // spot on win32 (e.g. AV/indexer briefly holding the destination
384
+ // handle open), where rename-over keeps failing with EPERM/EACCES
385
+ // /EBUSY even after renameWithRetrySync's backoff loop. The write
386
+ // is already serialized by the caller's own withFileLockSync
387
+ // (opts.lock / updateJsonAtomicSync's lock), so a direct
388
+ // truncate+write of the target here is still race-safe against
389
+ // other writers using the same lock path; it just loses the
390
+ // rename's fully-atomic-swap guarantee for THIS write. Default
391
+ // behavior (throw) is unchanged for every other caller.
392
+ if (
393
+ opts.renameFallback === 'truncate'
394
+ && opts.secret !== true
395
+ && process.platform === 'win32'
396
+ && RETRY_CODES.has(err?.code)
397
+ ) {
398
+ const data = readFileSync(tmp);
399
+ writeFileSync(filePath, data, { mode: opts.mode !== undefined ? opts.mode : 0o600 });
400
+ try { unlinkSync(tmp); } catch {}
401
+ } else {
402
+ throw err;
403
+ }
404
+ }
380
405
  }
381
406
  if (opts.secret === true) {
382
407
  // Re-assert owner-only on the final path (createOnly's linkSync may