mixdog 0.9.2 → 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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -8,27 +8,19 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Memory status/lifecycle or explicit mutation. Use recall for retrieval. Destructive jobs require exact confirm strings.',
11
+ description: 'Core-memory mutation and status. Use recall for retrieval. When the user explicitly asks to remember something (e.g. "remember this", a stated preference, or a decision), persist it immediately with action:"core" op:"add" — do not wait for a cycle.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
15
- action: { type: 'string', enum: ['core','manage','status','sleep','cycle1','cycle2','cycle3','flush','backfill','prune','rebuild','purge','retro_eval_active'], description: 'Operation.' },
16
- op: { type: 'string', enum: ['add','edit','delete','list'], description: 'Mutation op.' },
15
+ action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
+ op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
18
  element: { type: 'string', description: 'Memory key/title.' },
19
19
  summary: { type: 'string', description: 'Memory content.' },
20
20
  category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
- maxDays: { type: 'number', description: 'Prune age days.' },
23
- window: { type: 'string', description: 'Window, e.g. 24h/7d/30d.' },
24
22
  limit: { type: 'number', description: 'Max rows/items.' },
25
- concurrency: { type: 'number', description: 'Worker concurrency.' },
26
- sessionId: { type: 'string', description: 'Scoped session id.' },
27
- session_id: { type: 'string', description: 'Alias for sessionId.' },
28
- window_size: { type: 'number', description: 'cycle1 rows/window.' },
29
- rows_per_session: { type: 'number', description: 'cycle1 rows/session.' },
30
23
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
31
- cycle3Mode: { type: 'string', enum: ['proposal','conservative'], description: 'cycle3 mode.' },
32
24
  project_id: { type: 'string', description: 'Core pool: common, slug, or *.' },
33
25
  },
34
26
  required: ['action'],
@@ -38,13 +30,13 @@ export const TOOL_DEFS = [
38
30
  name: 'recall',
39
31
  title: 'Recall',
40
32
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
- description: 'Retrieve stored memory/session history. Use for earlier work, resumes, or possibly decided items; when in doubt, recall first.',
33
+ description: 'Retrieve stored memory/session history; when in doubt, recall first. Query is topic/semantic search (hybrid embedding+keyword), not regex. Common patterns — previous conversation: period:"last" (all history newest-first, grouped per session); current session: sessionId with no query; recent window: period:"3h"/"30m"/"24h"; specific timeframe: period:"2026-07-03" / "2026-07-01~2026-07-03" / "12:00~14:00" (today) / "2026-07-03 12:00~14:00"; topic: query alone or query+period; exact follow-up: id from a prior result.',
42
34
  inputSchema: {
43
35
  type: 'object',
44
36
  properties: {
45
37
  query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search text, or array for independent fan-out queries.' },
46
38
  id: { anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' }, minItems: 1 }], description: 'Exact #id(s) from recall. Do not invent ids.' },
47
- period: { type: 'string', description: 'last, 24h/7d/30d, all, date, or range.' },
39
+ period: { type: 'string', description: "last (all history newest-first), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
48
40
  limit: { type: 'number', description: 'Max entries.' },
49
41
  offset: { type: 'number', description: 'Skip entries.' },
50
42
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
@@ -71,7 +63,7 @@ export const TOOL_DEFS = [
71
63
  type: 'object',
72
64
  properties: {
73
65
  query: { type: 'string', description: 'Search text.' },
74
- period: { type: 'string', description: 'last, 24h/7d/30d, all, date, or range.' },
66
+ period: { type: 'string', description: "last, Nm/Nh/Nd, today/yesterday/this_week/last_week, all, YYYY-MM-DD, date range, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
75
67
  sort: { type: 'string', enum: ['date', 'importance'], description: 'date or importance.' },
76
68
  category: { anyOf: [{ type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, { type: 'array', items: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, minItems: 1 }], description: 'Category filter.' },
77
69
  limit: { type: 'number', default: 30, description: 'Max entries.' },
@@ -0,0 +1,274 @@
1
+ import { readFileSync } from 'fs'
2
+
3
+ import {
4
+ assertPublicUrl,
5
+ pinnedFetch,
6
+ } from './ssrf-guard.mjs'
7
+
8
+ const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
9
+
10
+ export function withTimeout(controller, timeoutMs) {
11
+ return setTimeout(() => controller.abort(), timeoutMs)
12
+ }
13
+
14
+ export function buildHeaders() {
15
+ return {
16
+ 'User-Agent': `mixdog-search/${PKG_VERSION}`,
17
+ }
18
+ }
19
+
20
+ export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
21
+ export const MAX_REDIRECTS = 5
22
+ // Hard cap on response body size (10 MB) to prevent memory DoS from a
23
+ // hostile / misconfigured URL returning a huge body. Applied in two places:
24
+ // 1. Content-Length pre-check (cheap reject before reading bytes).
25
+ // 2. Streaming byte counter (covers chunked transfer / missing header).
26
+ export const MAX_BODY_BYTES = 10 * 1024 * 1024
27
+
28
+ /** HTTP-path policy failures must not fall through to the Puppeteer extractor. */
29
+ export function isFatalHttpPathPolicyError(error) {
30
+ const msg = error instanceof Error ? error.message : String(error)
31
+ if (/response body too large|page content too large|Content-Length=.*> cap=/i.test(msg)) return true
32
+ if (/Blocked non-text content-type/i.test(msg)) return true
33
+ if (/cross-host redirect blocked/i.test(msg)) return true
34
+ if (/Blocked request to private|Blocked non-HTTP|Blocked URL with userinfo/i.test(msg)) return true
35
+ if (/DNS returned no addresses/i.test(msg)) return true
36
+ if (/Too many redirects/i.test(msg)) return true
37
+ return false
38
+ }
39
+
40
+ export async function readBodyWithCap(response, maxBytes) {
41
+ // Reject non-text content-types early; decode by content-type charset.
42
+ const contentType = (response.headers.get('content-type') || '').toLowerCase()
43
+ if (contentType) {
44
+ const isText = contentType.includes('text/') || contentType.includes('/html') ||
45
+ contentType.includes('/xml') || contentType.includes('/json') ||
46
+ contentType.includes('javascript') || contentType.includes('application/x-www-form-urlencoded')
47
+ if (!isText) {
48
+ // Cancel body before throwing so the underlying socket isn't held
49
+ // until GC — fetchHtml's caller would otherwise leak the connection.
50
+ try { await response.body?.cancel() } catch {}
51
+ throw new Error(`Blocked non-text content-type: ${contentType.split(';')[0].trim()}`)
52
+ }
53
+ }
54
+ const charsetMatch = contentType.match(/charset=([\w-]+)/i)
55
+ const charset = charsetMatch ? charsetMatch[1] : 'utf-8'
56
+
57
+ const contentLength = Number(response.headers.get('content-length') || 0)
58
+ if (contentLength > maxBytes) {
59
+ try { await response.body?.cancel() } catch {}
60
+ throw new Error(`response body too large: Content-Length=${contentLength} > cap=${maxBytes}`)
61
+ }
62
+ const reader = response.body?.getReader?.()
63
+ if (!reader) {
64
+ // Fallback for environments without a readable stream — post-check length.
65
+ const text = await response.text()
66
+ if (text.length > maxBytes) {
67
+ // response.text() already drained the body, but guard symmetrically.
68
+ try { await response.body?.cancel() } catch {}
69
+ throw new Error(`response body too large: ${text.length} bytes > cap=${maxBytes}`)
70
+ }
71
+ return text
72
+ }
73
+ const chunks = []
74
+ let total = 0
75
+ try {
76
+ while (true) {
77
+ const { done, value } = await reader.read()
78
+ if (done) break
79
+ total += value.byteLength
80
+ if (total > maxBytes) {
81
+ try { await reader.cancel() } catch {}
82
+ throw new Error(`response body too large: received ${total}+ bytes > cap=${maxBytes}`)
83
+ }
84
+ chunks.push(value)
85
+ }
86
+ } finally {
87
+ try { reader.releaseLock() } catch {}
88
+ }
89
+ const decoder = new TextDecoder(charset, { fatal: false })
90
+ let text = ''
91
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
92
+ text += decoder.decode()
93
+ return text
94
+ }
95
+
96
+ /** Binary-safe body reader for CDP Fetch fulfillment (no text-only filter). */
97
+ export async function readBodyBytesWithCap(response, maxBytes) {
98
+ const contentLength = Number(response.headers.get('content-length') || 0)
99
+ if (contentLength > maxBytes) {
100
+ try { await response.body?.cancel() } catch {}
101
+ throw new Error(`response body too large: Content-Length=${contentLength} > cap=${maxBytes}`)
102
+ }
103
+ const reader = response.body?.getReader?.()
104
+ if (!reader) {
105
+ const buf = Buffer.from(await response.arrayBuffer())
106
+ if (buf.byteLength > maxBytes) {
107
+ try { await response.body?.cancel() } catch {}
108
+ throw new Error(`response body too large: ${buf.byteLength} bytes > cap=${maxBytes}`)
109
+ }
110
+ return buf
111
+ }
112
+ const chunks = []
113
+ let total = 0
114
+ try {
115
+ while (true) {
116
+ const { done, value } = await reader.read()
117
+ if (done) break
118
+ total += value.byteLength
119
+ if (total > maxBytes) {
120
+ try { await reader.cancel() } catch {}
121
+ throw new Error(`response body too large: received ${total}+ bytes > cap=${maxBytes}`)
122
+ }
123
+ chunks.push(value)
124
+ }
125
+ } finally {
126
+ try { reader.releaseLock() } catch {}
127
+ }
128
+ return Buffer.concat(chunks.map((c) => Buffer.from(c)))
129
+ }
130
+
131
+ const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
132
+ 'content-length',
133
+ 'transfer-encoding',
134
+ // undici decodes gzip/br/deflate; body passed to fulfillRequest is plain bytes
135
+ 'content-encoding',
136
+ ])
137
+
138
+ export function headersToCdpPairs(headers) {
139
+ const out = []
140
+ headers.forEach((value, name) => {
141
+ const lower = name.toLowerCase()
142
+ if (CDP_FORBIDDEN_RESPONSE_HEADERS.has(lower)) return
143
+ out.push({ name, value })
144
+ })
145
+ return out
146
+ }
147
+
148
+ /**
149
+ * Pinned fetch for a paused Chromium request: validate each hop, follow redirects,
150
+ * return bytes for Fetch.fulfillRequest. Chromium never performs its own DNS/connect.
151
+ */
152
+ export async function fetchPinnedForPausedRequest(url, { signal, method = 'GET', headers = {}, body } = {}) {
153
+ const upperMethod = (method || 'GET').toUpperCase()
154
+ let currentUrl = url
155
+ for (let hops = 0; ; hops++) {
156
+ assertPublicUrl(currentUrl)
157
+ const response = await pinnedFetch(currentUrl, {
158
+ signal,
159
+ method: upperMethod,
160
+ headers,
161
+ body: hops === 0 ? body : undefined,
162
+ redirect: 'manual',
163
+ })
164
+ if (REDIRECT_STATUSES.has(response.status)) {
165
+ try { await response.body?.cancel() } catch {}
166
+ if (hops >= MAX_REDIRECTS) {
167
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
168
+ }
169
+ const location = response.headers.get('location')
170
+ if (!location) {
171
+ throw new Error(`Redirect ${response.status} without Location header`)
172
+ }
173
+ currentUrl = new URL(location, currentUrl).toString()
174
+ continue
175
+ }
176
+ const respBody = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
177
+ return {
178
+ status: response.status,
179
+ responseHeaders: headersToCdpPairs(response.headers),
180
+ body: respBody,
181
+ }
182
+ }
183
+ }
184
+
185
+ export async function fetchHtml(url, timeoutMs, signal) {
186
+ const controller = new AbortController()
187
+ const timer = withTimeout(controller, timeoutMs)
188
+ // Propagate an external (tool-call) abort into the local timeout controller
189
+ // so a cancelled web_fetch tears down the in-flight request promptly.
190
+ let onExternalAbort
191
+ if (signal) {
192
+ if (signal.aborted) controller.abort(signal.reason)
193
+ else {
194
+ onExternalAbort = () => controller.abort(signal.reason)
195
+ signal.addEventListener('abort', onExternalAbort, { once: true })
196
+ }
197
+ }
198
+ const originalHost = new URL(url).hostname.replace(/^www\./, '')
199
+ try {
200
+ let currentUrl = url
201
+ for (let hops = 0; ; hops++) {
202
+ // pinnedFetch resolves+validates the host once and forces the
203
+ // connection to the validated IP — closes the validate-then-fetch
204
+ // TOCTOU / DNS-rebinding window that bare `fetch` left open.
205
+ const response = await pinnedFetch(currentUrl, {
206
+ signal: controller.signal,
207
+ headers: buildHeaders(),
208
+ redirect: 'manual',
209
+ })
210
+ if (REDIRECT_STATUSES.has(response.status)) {
211
+ // Drain the redirect response body so the socket isn't held until GC.
212
+ try { await response.body?.cancel() } catch {}
213
+ if (hops >= MAX_REDIRECTS) {
214
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
215
+ }
216
+ const location = response.headers.get('location')
217
+ if (!location) {
218
+ throw new Error(`Redirect ${response.status} without Location header`)
219
+ }
220
+ const nextUrl = new URL(location, currentUrl).toString()
221
+ assertPublicUrl(nextUrl)
222
+ const nextHost = new URL(nextUrl).hostname.replace(/^www\./, '')
223
+ if (nextHost !== originalHost) {
224
+ throw new Error(`cross-host redirect blocked (redirected_to: ${nextUrl})`)
225
+ }
226
+ currentUrl = nextUrl
227
+ continue
228
+ }
229
+ if (!response.ok) {
230
+ // Drain the error response body before propagating.
231
+ try { await response.body?.cancel() } catch {}
232
+ const err = new Error(`HTTP ${response.status}`)
233
+ err.status = response.status
234
+ throw err
235
+ }
236
+ return await readBodyWithCap(response, MAX_BODY_BYTES)
237
+ }
238
+ } finally {
239
+ clearTimeout(timer)
240
+ if (onExternalAbort) signal.removeEventListener('abort', onExternalAbort)
241
+ }
242
+ }
243
+
244
+ // Parse a short-delay <meta http-equiv="refresh" content="N; url=..."> from
245
+ // the document head. Browsers treat these as redirects, but fetchHtml only
246
+ // follows HTTP-level (3xx) redirects — without this, a stub page like
247
+ // tree-sitter.github.io (tiny body + meta refresh) is returned as the
248
+ // "article". Long-delay refreshes (>5s) are page auto-reloads, not
249
+ // redirects, and are deliberately NOT followed.
250
+ export function _metaRefreshTarget(html, baseUrl) {
251
+ const head = String(html || '').slice(0, 8192)
252
+ const tags = head.match(/<meta\b[^>]*>/gi) || []
253
+ for (const tag of tags) {
254
+ if (!/http-equiv\s*=\s*["']?refresh\b/i.test(tag)) continue
255
+ // Quote-aware capture: the attribute value may NEST the other quote kind
256
+ // (content="0; url='...'"), so a combined ["'] char class would cut the
257
+ // capture at the inner quote. Match each quote style to its own closer.
258
+ const m = /content\s*=\s*"([^"]*)"/i.exec(tag)
259
+ || /content\s*=\s*'([^']*)'/i.exec(tag)
260
+ || /content\s*=\s*([^\s>]+)/i.exec(tag)
261
+ if (!m) continue
262
+ const cm = /^\s*(\d+(?:\.\d+)?)\s*[;,]\s*url\s*=\s*['"]?([^'"]+?)['"]?\s*$/i.exec(m[1])
263
+ if (!cm) continue
264
+ const delay = Number(cm[1])
265
+ if (!Number.isFinite(delay) || delay > 5) continue
266
+ try {
267
+ const resolved = new URL(cm[2].trim(), baseUrl)
268
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') continue
269
+ if (resolved.href === baseUrl) continue
270
+ return resolved.href
271
+ } catch { continue }
272
+ }
273
+ return null
274
+ }
@@ -0,0 +1,333 @@
1
+ import dns from 'dns'
2
+ import net from 'net'
3
+ import { Agent, fetch as undiciFetch } from 'undici'
4
+
5
+ export function normalizeUrl(url) {
6
+ const parsed = new URL(url)
7
+ parsed.hash = ''
8
+ return parsed.toString()
9
+ }
10
+
11
+ function assertPrivateIpv4(hostname) {
12
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
13
+ if (!ipv4Match) return
14
+ const [, a, b] = ipv4Match.map(Number)
15
+ if (a === 127 || a === 10 || a === 0 ||
16
+ (a === 172 && b >= 16 && b <= 31) ||
17
+ (a === 192 && b === 168) ||
18
+ (a === 169 && b === 254) ||
19
+ (a === 100 && b >= 64 && b <= 127) ||
20
+ (a === 198 && b >= 18 && b <= 19) ||
21
+ (a >= 224 && a <= 239) ||
22
+ (a >= 240)) {
23
+ throw new Error(`Blocked request to private address: ${hostname}`)
24
+ }
25
+ }
26
+
27
+ // Detect IPv4-mapped IPv6 (::ffff:/96) in BOTH dotted and hex forms and
28
+ // return the embedded IPv4 as a dotted-quad string, or null when the input
29
+ // is not an IPv4-mapped address. WHATWG URL canonicalises `[::ffff:127.0.0.1]`
30
+ // to `[::ffff:7f00:1]`, so the hex form must be handled or assertPublicUrl /
31
+ // _validateIpv6 will miss mapped loopback / private addresses.
32
+ function _mappedIpv4FromIpv6(bare) {
33
+ const lower = bare.toLowerCase()
34
+ // Dotted form: ::ffff:a.b.c.d
35
+ const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
36
+ if (dotted) return dotted[1]
37
+ // Hex form: ::ffff:HHHH:LLLL — low 32 bits of the /96 prefix carry the IPv4.
38
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
39
+ if (hex) {
40
+ const high = parseInt(hex[1], 16)
41
+ const low = parseInt(hex[2], 16)
42
+ if (Number.isFinite(high) && Number.isFinite(low) && high <= 0xffff && low <= 0xffff) {
43
+ const a = (high >> 8) & 0xff
44
+ const b = high & 0xff
45
+ const c = (low >> 8) & 0xff
46
+ const d = low & 0xff
47
+ return `${a}.${b}.${c}.${d}`
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ export function assertPublicUrl(url) {
54
+ const parsed = new URL(url)
55
+
56
+ // Block dangerous protocols
57
+ const blockedProtocols = ['file:', 'ftp:', 'data:', 'javascript:']
58
+ if (blockedProtocols.includes(parsed.protocol)) {
59
+ throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
60
+ }
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
62
+ throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
63
+ }
64
+
65
+ const hostname = parsed.hostname.toLowerCase()
66
+
67
+ // Reject userinfo (user:pass@host) — credential-injection / SSRF vector
68
+ if (parsed.username || parsed.password) {
69
+ throw new Error(`Blocked URL with userinfo credentials: ${hostname}`)
70
+ }
71
+
72
+ // Localhost
73
+ if (hostname === 'localhost') {
74
+ throw new Error(`Blocked request to private address: ${hostname}`)
75
+ }
76
+
77
+ // IPv4 private/reserved ranges
78
+ assertPrivateIpv4(hostname)
79
+
80
+ // Strip brackets for IPv6 analysis (URL parser stores IPv6 without brackets in .hostname)
81
+ const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
82
+
83
+ // IPv6 loopback
84
+ if (bare === '::1') {
85
+ throw new Error(`Blocked request to private address: ${hostname}`)
86
+ }
87
+
88
+ // IPv6 unspecified (::)
89
+ if (bare === '::') {
90
+ throw new Error(`Blocked request to private address: ${hostname}`)
91
+ }
92
+
93
+ // IPv6 multicast (ff00::/8)
94
+ if (/^ff/i.test(bare)) {
95
+ throw new Error(`Blocked request to private address: ${hostname}`)
96
+ }
97
+
98
+ // IPv4-mapped IPv6 — ::ffff:a.b.c.d
99
+ // Cover both dotted (::ffff:127.0.0.1) and hex (::ffff:7f00:1) forms —
100
+ // WHATWG URL canonicalises bracketed mapped literals to the hex shape.
101
+ const mappedIpv4 = _mappedIpv4FromIpv6(bare)
102
+ if (mappedIpv4) {
103
+ assertPrivateIpv4(mappedIpv4)
104
+ }
105
+
106
+ // IPv6 private (fc00::/7 — starts with fc or fd)
107
+ if (/^f[cd]/i.test(bare)) {
108
+ throw new Error(`Blocked request to private address: ${hostname}`)
109
+ }
110
+
111
+ // IPv6 link-local (fe80::/10 — starts with fe8, fe9, fea, feb)
112
+ if (/^fe[89ab]/i.test(bare)) {
113
+ throw new Error(`Blocked request to private address: ${hostname}`)
114
+ }
115
+ }
116
+
117
+ function _validateIpv6(ip) {
118
+ const lower = ip.toLowerCase()
119
+ if (lower === '::1') {
120
+ throw new Error(`Blocked request to private address: ${ip}`)
121
+ }
122
+ if (lower === '::') {
123
+ throw new Error(`Blocked request to private address: ${ip}`)
124
+ }
125
+ if (/^ff/i.test(lower)) {
126
+ throw new Error(`Blocked request to private address: ${ip}`)
127
+ }
128
+ if (/^f[cd]/i.test(lower)) {
129
+ throw new Error(`Blocked request to private address: ${ip}`)
130
+ }
131
+ if (/^fe[89ab]/i.test(lower)) {
132
+ throw new Error(`Blocked request to private address: ${ip}`)
133
+ }
134
+ // Cover both dotted and hex IPv4-mapped IPv6 forms — resolver output and
135
+ // WHATWG-canonicalised URL hostnames may arrive as `::ffff:7f00:1`.
136
+ const mappedIpv4 = _mappedIpv4FromIpv6(lower)
137
+ if (mappedIpv4) {
138
+ assertPrivateIpv4(mappedIpv4)
139
+ }
140
+ }
141
+
142
+ // Resolve hostname once, validate EVERY returned address (so a DNS round-robin
143
+ // can't smuggle a private IP behind a public one), and return the de-duped
144
+ // `{address, family}` list. The caller pins the real connection to one of
145
+ // these addresses so a second uncontrolled resolution (DNS rebinding / TOCTOU)
146
+ // cannot flip the IP between validation and connect.
147
+ // Race a DNS promise against an abort signal so a hung resolver cannot
148
+ // outlive the request's timeout budget. The signal is the same one that
149
+ // bounds the outbound fetch (AbortSignal.timeout / requestTimeoutMs), so
150
+ // DNS is bounded by the same deadline as the connection.
151
+ function _abortRace(promise, signal, label) {
152
+ if (!signal) return promise
153
+ if (signal.aborted) return Promise.reject(signal.reason || new Error(`${label} aborted`))
154
+ return new Promise((resolve, reject) => {
155
+ const onAbort = () => reject(signal.reason || new Error(`${label} aborted`))
156
+ signal.addEventListener('abort', onAbort, { once: true })
157
+ promise.then(
158
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
159
+ (err) => { signal.removeEventListener('abort', onAbort); reject(err) },
160
+ )
161
+ })
162
+ }
163
+
164
+ export async function resolveAndValidate(hostname, { signal } = {}) {
165
+ // Literal IPs bypass DNS entirely — validate directly.
166
+ if (net.isIP(hostname)) {
167
+ if (net.isIPv4(hostname)) {
168
+ assertPrivateIpv4(hostname)
169
+ return [{ address: hostname, family: 4 }]
170
+ }
171
+ _validateIpv6(hostname)
172
+ return [{ address: hostname, family: 6 }]
173
+ }
174
+
175
+ const addresses = []
176
+ const seen = new Set()
177
+ const push = (address, family) => {
178
+ const key = `${family}:${address}`
179
+ if (seen.has(key)) return
180
+ seen.add(key)
181
+ addresses.push({ address, family })
182
+ }
183
+
184
+ // dns.lookup mirrors what the platform resolver will hand to the connector;
185
+ // resolve4/resolve6 catch entries the stub resolver returns even when the
186
+ // OS lookup table would omit them.
187
+ let lookupAddrs = []
188
+ try {
189
+ lookupAddrs = await _abortRace(dns.promises.lookup(hostname, { all: true }), signal, 'dns.lookup')
190
+ } catch (err) {
191
+ if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
192
+ }
193
+ for (const entry of lookupAddrs) {
194
+ if (entry.family === 4) assertPrivateIpv4(entry.address)
195
+ else _validateIpv6(entry.address)
196
+ push(entry.address, entry.family)
197
+ }
198
+
199
+ let v4Addrs = []
200
+ try {
201
+ v4Addrs = await _abortRace(dns.promises.resolve4(hostname), signal, 'dns.resolve4')
202
+ } catch (err) {
203
+ if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
204
+ }
205
+ for (const ip of v4Addrs) {
206
+ assertPrivateIpv4(ip)
207
+ push(ip, 4)
208
+ }
209
+
210
+ let v6Addrs = []
211
+ try {
212
+ v6Addrs = await _abortRace(dns.promises.resolve6(hostname), signal, 'dns.resolve6')
213
+ } catch (err) {
214
+ if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') throw err
215
+ }
216
+ for (const ip of v6Addrs) {
217
+ _validateIpv6(ip)
218
+ push(ip, 6)
219
+ }
220
+
221
+ return addresses
222
+ }
223
+
224
+ export async function assertResolvedIps(hostname) {
225
+ // Backward-compatible wrapper: callers that only need validation (e.g. the
226
+ // Puppeteer request interceptor, which cannot pin Chromium's connect) still
227
+ // get the same throw-on-private behaviour.
228
+ // Fail closed: an empty result (no DNS records, all lookups returned
229
+ // ENODATA/ENOTFOUND) must NOT be treated as success — the Puppeteer path
230
+ // would otherwise hand the raw hostname to Chromium for a second,
231
+ // unvalidated resolution.
232
+ // Callers pass `new URL(...).hostname`, which on Node/Bun keeps the
233
+ // brackets around IPv6 literals (e.g. `[2606:4700::1111]`). Strip them
234
+ // here so resolveAndValidate's net.isIP() path recognises the literal
235
+ // instead of falling through to a doomed DNS lookup on `[..]`.
236
+ const bare = _bareHost(hostname)
237
+ const addresses = await resolveAndValidate(bare)
238
+ if (!addresses || addresses.length === 0) {
239
+ throw new Error(`DNS returned no addresses for ${hostname}`)
240
+ }
241
+ }
242
+
243
+ // Bare hostname helper that strips IPv6 brackets — undici / WHATWG URL stores
244
+ // IPv6 hostnames with the brackets included.
245
+ function _bareHost(hostname) {
246
+ return hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
247
+ }
248
+
249
+ // SSRF-hardened fetch: resolves the host ONCE, validates every returned
250
+ // address, then connects to a single pre-validated IP via a per-request
251
+ // undici Agent whose `connect.lookup` returns that IP only. This closes the
252
+ // validate-then-fetch TOCTOU / DNS-rebinding window because the connector
253
+ // never performs a second resolution against the live DNS — the Host header
254
+ // (undici fills from the URL) and TLS SNI (likewise) are unaffected, so
255
+ // virtual hosts and HTTPS certificate validation keep working against
256
+ // legitimate public sites.
257
+ export async function pinnedFetch(url, options = {}) {
258
+ const parsed = new URL(url)
259
+ const host = _bareHost(parsed.hostname)
260
+ // Bound the validating DNS lookups by the request's own abort signal so a
261
+ // hung resolver cannot outlive the fetch timeout.
262
+ const addresses = await resolveAndValidate(host, { signal: options.signal })
263
+ if (addresses.length === 0) {
264
+ throw new Error(`DNS returned no addresses for ${host}`)
265
+ }
266
+ // Deterministic: pin to the first validated address. Every entry in
267
+ // `addresses` already passed assertPrivateIpv4 / IPv6 checks, so picking any
268
+ // index is safe — first-match keeps behaviour stable across calls.
269
+ const pinned = addresses[0]
270
+ const dispatcher = new Agent({
271
+ connect: {
272
+ // Custom lookup invoked by undici's connector. We ignore the requested
273
+ // hostname argument and unconditionally hand back the pre-validated IP,
274
+ // so DNS rebinding cannot flip the address between assert and connect.
275
+ lookup: (_hostname, opts, cb) => {
276
+ if (opts && opts.all) {
277
+ cb(null, [{ address: pinned.address, family: pinned.family }])
278
+ } else {
279
+ cb(null, pinned.address, pinned.family)
280
+ }
281
+ },
282
+ },
283
+ })
284
+ // The per-request Agent owns a dedicated connection pool. If it is never
285
+ // closed it leaks the kept-alive socket until GC. Destroy it once the body
286
+ // is fully consumed, cancelled, or the request errors — wrapping the body
287
+ // stream so the dispatcher outlives streaming reads but is always reclaimed.
288
+ let response
289
+ try {
290
+ response = await undiciFetch(url, { ...options, dispatcher })
291
+ } catch (err) {
292
+ dispatcher.destroy().catch(() => {})
293
+ throw err
294
+ }
295
+ let cleaned = false
296
+ const cleanup = () => { if (!cleaned) { cleaned = true; dispatcher.destroy().catch(() => {}) } }
297
+ // If there's no body to stream, the response is already complete.
298
+ if (!response.body) {
299
+ cleanup()
300
+ return response
301
+ }
302
+ // Wrap the body in a ReadableStream that pulls from the original reader and
303
+ // destroys the dispatcher when the stream ends, errors, or the consumer
304
+ // cancels it. ReadableStream's underlying-source pull/cancel callbacks are
305
+ // reliably invoked, so the per-request Agent is always reclaimed instead of
306
+ // leaking its kept-alive socket until GC.
307
+ const reader = response.body.getReader()
308
+ const monitored = new ReadableStream({
309
+ async pull(controller) {
310
+ try {
311
+ const { done, value } = await reader.read()
312
+ if (done) {
313
+ controller.close()
314
+ cleanup()
315
+ return
316
+ }
317
+ controller.enqueue(value)
318
+ } catch (err) {
319
+ controller.error(err)
320
+ cleanup()
321
+ }
322
+ },
323
+ cancel(reason) {
324
+ reader.cancel(reason).catch(() => {})
325
+ cleanup()
326
+ },
327
+ })
328
+ return new Response(monitored, {
329
+ status: response.status,
330
+ statusText: response.statusText,
331
+ headers: response.headers,
332
+ })
333
+ }