rovecode 0.4.0-beta.2 → 0.4.0

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 (428) hide show
  1. package/README.md +67 -69
  2. package/THIRD_PARTY_NOTICES.md +0 -44
  3. package/bin/rovecode.ts +21 -0
  4. package/package.json +16 -37
  5. package/src/account/keys.ts +97 -0
  6. package/src/account/login.ts +158 -0
  7. package/src/account/provision.ts +47 -0
  8. package/src/account/store.ts +63 -0
  9. package/src/acp/server.ts +373 -0
  10. package/src/cli/account-cmd.ts +116 -0
  11. package/src/cli/connect.ts +244 -0
  12. package/src/cli/context-cmd.ts +199 -0
  13. package/src/cli/dispatch.ts +109 -0
  14. package/src/cli/doctor.ts +324 -0
  15. package/src/cli/export.ts +278 -0
  16. package/src/cli/help.ts +240 -0
  17. package/src/cli/is-tui-invocation.ts +8 -0
  18. package/src/cli/main.ts +599 -0
  19. package/src/cli/market-cmd.ts +658 -0
  20. package/src/cli/mcp-market-cmd.ts +299 -0
  21. package/src/cli/output.ts +382 -0
  22. package/src/cli/repl.ts +172 -0
  23. package/src/cli/resume.ts +32 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +792 -0
  26. package/src/cli/setup.ts +187 -0
  27. package/src/cli/update-cmd.ts +78 -0
  28. package/src/cli/workflow-cmd.ts +100 -0
  29. package/src/coding/checkpoints.ts +270 -0
  30. package/src/coding/diff.ts +136 -0
  31. package/src/coding/files.ts +339 -0
  32. package/src/coding/hashline.ts +319 -0
  33. package/src/coding/lsp.ts +406 -0
  34. package/src/coding/repomap-cache.ts +99 -0
  35. package/src/coding/repomap-files.ts +110 -0
  36. package/src/coding/repomap.ts +392 -0
  37. package/src/core/compaction.ts +399 -0
  38. package/src/core/config.ts +289 -0
  39. package/src/core/context-report.ts +228 -0
  40. package/src/core/context.ts +60 -0
  41. package/src/core/count-remote.ts +107 -0
  42. package/src/core/execpolicy-rules.ts +196 -0
  43. package/src/core/execpolicy.ts +385 -0
  44. package/src/core/executor.ts +397 -0
  45. package/src/core/guardrails.ts +400 -0
  46. package/src/core/hooks.ts +398 -0
  47. package/src/core/images.ts +230 -0
  48. package/src/core/intro.ts +236 -0
  49. package/src/core/loop.ts +621 -0
  50. package/src/core/modes.ts +372 -0
  51. package/src/core/orchestrator.ts +207 -0
  52. package/src/core/reflection.ts +165 -0
  53. package/src/core/sandbox-config.ts +167 -0
  54. package/src/core/session-images.ts +73 -0
  55. package/src/core/session.ts +398 -0
  56. package/src/core/settings.ts +98 -0
  57. package/src/core/stuck-detector.ts +273 -0
  58. package/src/core/tasks.ts +374 -0
  59. package/src/core/token-scale.ts +108 -0
  60. package/src/core/tool-output-budget.ts +166 -0
  61. package/src/core/tools.ts +288 -0
  62. package/src/core/types.ts +330 -0
  63. package/src/core/update-check.ts +171 -0
  64. package/src/core/update.ts +158 -0
  65. package/src/core/usage.ts +204 -0
  66. package/src/core/validate.ts +121 -0
  67. package/src/core/verify-gate.ts +159 -0
  68. package/src/core/verify.ts +237 -0
  69. package/src/core/voice.ts +158 -0
  70. package/src/core/win-job.ts +183 -0
  71. package/src/design/audit.ts +797 -0
  72. package/src/design/direction.ts +190 -0
  73. package/src/design/rules.ts +157 -0
  74. package/src/eval/bench.ts +150 -0
  75. package/src/eval/gauntlet-runner.ts +218 -0
  76. package/src/eval/gauntlet.ts +226 -0
  77. package/src/eval/grader.ts +186 -0
  78. package/src/eval/record.ts +202 -0
  79. package/src/eval/redact.ts +141 -0
  80. package/src/eval/replay.ts +147 -0
  81. package/src/eval/trajectory.ts +373 -0
  82. package/src/index.ts +17 -0
  83. package/src/market/catalogs/mcp-docs.json +111 -0
  84. package/src/market/catalogs/plugins.json +111 -0
  85. package/src/market/catalogs/skills.json +478 -0
  86. package/src/market/clone.ts +72 -0
  87. package/src/market/context-cost.ts +121 -0
  88. package/src/market/digest.ts +106 -0
  89. package/src/market/index.ts +22 -0
  90. package/src/market/install.ts +578 -0
  91. package/src/market/manifest.ts +187 -0
  92. package/src/market/prereq.ts +145 -0
  93. package/src/market/registry.ts +363 -0
  94. package/src/market/resolve.ts +111 -0
  95. package/src/market/types.ts +236 -0
  96. package/src/market/validate.ts +227 -0
  97. package/src/mcp/client.ts +431 -0
  98. package/src/mcp/config.ts +239 -0
  99. package/src/mcp/local-package.ts +211 -0
  100. package/src/mcp/market-catalog.ts +84 -0
  101. package/src/mcp/market-install.ts +289 -0
  102. package/src/mcp/market.ts +0 -0
  103. package/src/mcp/tools.ts +131 -0
  104. package/src/mcp/trust.ts +49 -0
  105. package/src/memory/blocks.ts +175 -0
  106. package/src/memory/recall.ts +355 -0
  107. package/src/memory/store.ts +105 -0
  108. package/src/memory/tools.ts +99 -0
  109. package/src/plugins/cli.ts +123 -0
  110. package/src/plugins/discover.ts +108 -0
  111. package/src/plugins/index.ts +50 -0
  112. package/src/plugins/init.ts +140 -0
  113. package/src/plugins/install.ts +184 -0
  114. package/src/plugins/load.ts +149 -0
  115. package/src/plugins/manifest.ts +106 -0
  116. package/src/plugins/state.ts +83 -0
  117. package/src/providers/auth.ts +293 -0
  118. package/src/providers/cache.ts +223 -0
  119. package/src/providers/catalog-local.ts +160 -0
  120. package/src/providers/catalog.ts +408 -0
  121. package/src/providers/middleware-context.ts +86 -0
  122. package/src/providers/middleware.ts +373 -0
  123. package/src/providers/profile-glm53.ts +111 -0
  124. package/src/providers/profile-sonnet5-persona.ts +65 -0
  125. package/src/providers/profile-sonnet5-voice.ts +23 -0
  126. package/src/providers/profiles.ts +156 -0
  127. package/src/providers/provider-config.ts +311 -0
  128. package/src/providers/registry.ts +302 -0
  129. package/src/providers/response-validation.ts +80 -0
  130. package/src/providers/retry.ts +234 -0
  131. package/src/providers/router.ts +294 -0
  132. package/src/providers/sse.ts +26 -0
  133. package/src/providers/stream-errors.ts +117 -0
  134. package/src/providers/stream.ts +569 -0
  135. package/src/providers/thinking.ts +189 -0
  136. package/src/providers/wire-messages.ts +129 -0
  137. package/src/sdk/client.ts +225 -0
  138. package/src/sdk/index.ts +3 -0
  139. package/src/server/dashboard.ts +144 -0
  140. package/src/server/http.ts +343 -0
  141. package/src/server/openapi.ts +246 -0
  142. package/src/sextant/card-hits.ts +102 -0
  143. package/src/sextant/card-keys.ts +55 -0
  144. package/src/sextant/context-source.ts +157 -0
  145. package/src/sextant/draw-agents.ts +273 -0
  146. package/src/sextant/draw-code.ts +388 -0
  147. package/src/sextant/draw-context.ts +222 -0
  148. package/src/sextant/draw-frame.ts +164 -0
  149. package/src/sextant/draw-market.ts +573 -0
  150. package/src/sextant/draw-messages.ts +386 -0
  151. package/src/sextant/draw-pet.ts +230 -0
  152. package/src/sextant/draw-plan.ts +159 -0
  153. package/src/sextant/draw-tabs.ts +85 -0
  154. package/src/sextant/draw-util.ts +65 -0
  155. package/src/sextant/engine.ts +230 -0
  156. package/src/sextant/frame-hits.ts +25 -0
  157. package/src/sextant/frame.ts +101 -0
  158. package/src/sextant/git-status.ts +197 -0
  159. package/src/sextant/grid.ts +59 -0
  160. package/src/sextant/input.ts +119 -0
  161. package/src/sextant/keys.ts +488 -0
  162. package/src/sextant/layout.ts +86 -0
  163. package/src/sextant/local-commands.ts +156 -0
  164. package/src/sextant/market-source.ts +287 -0
  165. package/src/sextant/mentions.ts +141 -0
  166. package/src/sextant/message-hits.ts +26 -0
  167. package/src/sextant/model.ts +387 -0
  168. package/src/sextant/overlays.ts +451 -0
  169. package/src/sextant/panel-hits.ts +38 -0
  170. package/src/sextant/pet.ts +399 -0
  171. package/src/sextant/screen.ts +324 -0
  172. package/src/sextant/scroll-hits.ts +66 -0
  173. package/src/sextant/scrollbar.ts +82 -0
  174. package/src/sextant/selection.ts +123 -0
  175. package/src/sextant/sextant-bridge.ts +174 -0
  176. package/src/sextant/sextant-cards.ts +142 -0
  177. package/src/sextant/sextant-diff-base.ts +63 -0
  178. package/src/sextant/sextant-files.ts +154 -0
  179. package/src/sextant/sextant-frame-loop.ts +314 -0
  180. package/src/sextant/sextant-renderer.ts +478 -0
  181. package/src/sextant/sextant-repo.ts +131 -0
  182. package/src/sextant/theme.ts +66 -0
  183. package/src/sextant/tool-rows.ts +189 -0
  184. package/src/sextant/types.ts +473 -0
  185. package/src/skills/index.ts +306 -0
  186. package/src/skills/tools.ts +69 -0
  187. package/src/skills/versioned.ts +227 -0
  188. package/src/telemetry/otel.ts +353 -0
  189. package/src/telemetry/otlp.ts +68 -0
  190. package/src/tools/ask-user.ts +156 -0
  191. package/src/tools/design.ts +151 -0
  192. package/src/tools/evalcell.ts +338 -0
  193. package/src/tools/html-text.ts +139 -0
  194. package/src/tools/provider.ts +149 -0
  195. package/src/tools/task.ts +216 -0
  196. package/src/tools/todo.ts +320 -0
  197. package/src/tools/webfetch.ts +331 -0
  198. package/src/tui/app.ts +608 -0
  199. package/src/tui/attach.ts +127 -0
  200. package/src/tui/checkpoints-cmd.ts +70 -0
  201. package/src/tui/clipboard-image.ts +81 -0
  202. package/src/tui/commands.ts +277 -0
  203. package/src/tui/cost.ts +108 -0
  204. package/src/tui/info-cmd.ts +144 -0
  205. package/src/tui/mcp-cmd.ts +128 -0
  206. package/src/tui/modes-cmd.ts +45 -0
  207. package/src/tui/overlays.ts +97 -0
  208. package/src/tui/pi-renderer.ts +424 -0
  209. package/src/tui/providers-cmd.ts +366 -0
  210. package/src/tui/renderer.ts +101 -0
  211. package/src/tui/replay-marker.ts +29 -0
  212. package/src/tui/session-cmd.ts +146 -0
  213. package/src/tui/sextant-attach.ts +68 -0
  214. package/src/tui/sextant-io.ts +184 -0
  215. package/src/tui/sextant-smoke.ts +110 -0
  216. package/src/tui/smoke.ts +72 -0
  217. package/src/tui/theme.ts +59 -0
  218. package/src/tui/todo-label.ts +7 -0
  219. package/src/workflow/engine.ts +266 -0
  220. package/tsconfig.json +30 -0
  221. package/vendor/pi-tui/LICENSE +21 -0
  222. package/vendor/pi-tui/PATCHES.md +12 -0
  223. package/vendor/pi-tui/PROVENANCE.md +12 -0
  224. package/vendor/pi-tui/README.upstream.md +854 -0
  225. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  226. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  227. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  228. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  229. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  230. package/vendor/pi-tui/src/components/box.ts +138 -0
  231. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  232. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  233. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  234. package/vendor/pi-tui/src/components/image.ts +128 -0
  235. package/vendor/pi-tui/src/components/input.ts +448 -0
  236. package/vendor/pi-tui/src/components/loader.ts +93 -0
  237. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  238. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  239. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  240. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  241. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  242. package/vendor/pi-tui/src/components/stack.ts +155 -0
  243. package/vendor/pi-tui/src/components/text.ts +108 -0
  244. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  245. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  246. package/vendor/pi-tui/src/editor-component.ts +75 -0
  247. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  248. package/vendor/pi-tui/src/index.ts +149 -0
  249. package/vendor/pi-tui/src/keybindings.ts +321 -0
  250. package/vendor/pi-tui/src/keys.ts +1402 -0
  251. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  252. package/vendor/pi-tui/src/latex.ts +1381 -0
  253. package/vendor/pi-tui/src/layout-node.ts +52 -0
  254. package/vendor/pi-tui/src/layout.ts +411 -0
  255. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  256. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  257. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  258. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  259. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  260. package/vendor/pi-tui/src/terminal.ts +554 -0
  261. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  262. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  263. package/vendor/pi-tui/src/tui.ts +1264 -0
  264. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  265. package/vendor/pi-tui/src/utils.ts +1327 -0
  266. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  267. package/vendor/pi-tui/test/test-themes.ts +39 -0
  268. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
  269. package/CHANGELOG.md +0 -512
  270. package/bin/rovecode.js +0 -24
  271. package/dist/cli/app-dybnr56b.js +0 -2
  272. package/dist/cli/ask-user-p8hq4xgj.js +0 -2
  273. package/dist/cli/auth-login-ewpgw5sm.js +0 -2
  274. package/dist/cli/auth-m8p9grty.js +0 -2
  275. package/dist/cli/bench-xv3ypwev.js +0 -9
  276. package/dist/cli/catalog-737wb2s0.js +0 -2
  277. package/dist/cli/cli-arhg40m0.js +0 -2
  278. package/dist/cli/client-cf2pxx8q.js +0 -2
  279. package/dist/cli/commands-3p7e4xxs.js +0 -2
  280. package/dist/cli/connect-3q93d7cb.js +0 -2
  281. package/dist/cli/context-cmd-eqxmxhzq.js +0 -2
  282. package/dist/cli/context-report-hbw9zfes.js +0 -2
  283. package/dist/cli/count-remote-mby98cd0.js +0 -2
  284. package/dist/cli/design-122y0axd.js +0 -2
  285. package/dist/cli/dispatch-b4egzvvh.js +0 -2
  286. package/dist/cli/doctor-x4jkv72e.js +0 -3
  287. package/dist/cli/executor-ftvg6tsy.js +0 -2
  288. package/dist/cli/export-pdgdhkch.js +0 -2
  289. package/dist/cli/files-cez9a96p.js +0 -2
  290. package/dist/cli/gauntlet-r3xxaszc.js +0 -2
  291. package/dist/cli/gauntlet-runner-r515m7kk.js +0 -10
  292. package/dist/cli/gauntlet-wave3-bnkjk2v2.js +0 -5
  293. package/dist/cli/gauntlet-wave4-acs9s60q.js +0 -14
  294. package/dist/cli/hashline-ewg5hbe3.js +0 -2
  295. package/dist/cli/http-n0kehsk8.js +0 -5
  296. package/dist/cli/index-z5qt1s76.js +0 -2
  297. package/dist/cli/install-80mp63kx.js +0 -2
  298. package/dist/cli/loop-12twjcat.js +0 -2
  299. package/dist/cli/main-01pv9206.js +0 -4
  300. package/dist/cli/main-0jys2ccn.js +0 -3
  301. package/dist/cli/main-1ztz6fkj.js +0 -10
  302. package/dist/cli/main-23q7cmww.js +0 -9
  303. package/dist/cli/main-2rzbexn2.js +0 -3
  304. package/dist/cli/main-2wyax8k9.js +0 -9
  305. package/dist/cli/main-2yeveeve.js +0 -6
  306. package/dist/cli/main-2z3dek0b.js +0 -3
  307. package/dist/cli/main-2zgsknth.js +0 -3
  308. package/dist/cli/main-45ejth3a.js +0 -4
  309. package/dist/cli/main-45rn3trk.js +0 -22
  310. package/dist/cli/main-4p4e2w7x.js +0 -4
  311. package/dist/cli/main-4y0tnfpa.js +0 -16
  312. package/dist/cli/main-5py0rkmc.js +0 -4
  313. package/dist/cli/main-6dtqmbt6.js +0 -7
  314. package/dist/cli/main-6h9x282m.js +0 -4
  315. package/dist/cli/main-6vjeds42.js +0 -3
  316. package/dist/cli/main-78gq4bt9.js +0 -6
  317. package/dist/cli/main-7jd5vh3x.js +0 -4
  318. package/dist/cli/main-7kt6r53y.js +0 -4
  319. package/dist/cli/main-8c1tbazx.js +0 -58
  320. package/dist/cli/main-9a9rnh47.js +0 -19
  321. package/dist/cli/main-9ht36z12.js +0 -3
  322. package/dist/cli/main-a2yfvcy9.js +0 -7
  323. package/dist/cli/main-a3f51n0x.js +0 -5
  324. package/dist/cli/main-b8zq261k.js +0 -3
  325. package/dist/cli/main-bxtvnf6d.js +0 -13
  326. package/dist/cli/main-edxc3yzt.js +0 -4
  327. package/dist/cli/main-evgz4mp5.js +0 -21
  328. package/dist/cli/main-f33fc5je.js +0 -9
  329. package/dist/cli/main-fvnpq46y.js +0 -12
  330. package/dist/cli/main-gbbty4d4.js +0 -3
  331. package/dist/cli/main-gth53dnt.js +0 -25
  332. package/dist/cli/main-hqbz10aw.js +0 -9
  333. package/dist/cli/main-hrrvcfan.js +0 -38
  334. package/dist/cli/main-hzwtsb2m.js +0 -5
  335. package/dist/cli/main-j7ttv0sd.js +0 -34
  336. package/dist/cli/main-jak598k9.js +0 -5
  337. package/dist/cli/main-kba6zeyd.js +0 -6
  338. package/dist/cli/main-kwwsz6rq.js +0 -3
  339. package/dist/cli/main-m8vm17zq.js +0 -3
  340. package/dist/cli/main-mg4f96e1.js +0 -3
  341. package/dist/cli/main-mg9b20ac.js +0 -18
  342. package/dist/cli/main-mgb9ccnx.js +0 -3
  343. package/dist/cli/main-mjt2p7aj.js +0 -3
  344. package/dist/cli/main-n6qrdbmy.js +0 -3
  345. package/dist/cli/main-na7wse0x.js +0 -5
  346. package/dist/cli/main-nqveez48.js +0 -4
  347. package/dist/cli/main-ntqef02r.js +0 -10
  348. package/dist/cli/main-nvc3yjay.js +0 -136
  349. package/dist/cli/main-p0cfn6nr.js +0 -16
  350. package/dist/cli/main-qj2djy17.js +0 -19
  351. package/dist/cli/main-qsevpgsv.js +0 -3
  352. package/dist/cli/main-qvarybsp.js +0 -3
  353. package/dist/cli/main-rebtt91r.js +0 -5
  354. package/dist/cli/main-rpg7h8mb.js +0 -3
  355. package/dist/cli/main-rsy72qmw.js +0 -15
  356. package/dist/cli/main-rvetps99.js +0 -18
  357. package/dist/cli/main-s4bb0jav.js +0 -3
  358. package/dist/cli/main-s9v8k74e.js +0 -3
  359. package/dist/cli/main-tjvwmscs.js +0 -3
  360. package/dist/cli/main-tkgarpjj.js +0 -4
  361. package/dist/cli/main-v8y60bb2.js +0 -3
  362. package/dist/cli/main-vhrrq337.js +0 -3
  363. package/dist/cli/main-vp2dfb7s.js +0 -4
  364. package/dist/cli/main-vqbr22sz.js +0 -8
  365. package/dist/cli/main-vxnwe5xx.js +0 -18
  366. package/dist/cli/main-wgph00xf.js +0 -5
  367. package/dist/cli/main-wk2csfnj.js +0 -5
  368. package/dist/cli/main-wm997zjx.js +0 -3
  369. package/dist/cli/main-wpkyraxh.js +0 -3
  370. package/dist/cli/main-wqt32p5x.js +0 -4
  371. package/dist/cli/main-x9ct6y1a.js +0 -3
  372. package/dist/cli/main-xfekqh9m.js +0 -7
  373. package/dist/cli/main-xt9zc3n6.js +0 -7
  374. package/dist/cli/main-xx2z3zh5.js +0 -4
  375. package/dist/cli/main-y5c82rxr.js +0 -3
  376. package/dist/cli/main-yrjt2sqt.js +0 -14
  377. package/dist/cli/main-ys6zj3yr.js +0 -3
  378. package/dist/cli/main-ywbxshqc.js +0 -8
  379. package/dist/cli/main-z13755t8.js +0 -25
  380. package/dist/cli/main-zc7pyrbj.js +0 -4
  381. package/dist/cli/main.js +0 -279
  382. package/dist/cli/market-cmd-bm5xvn9f.js +0 -5
  383. package/dist/cli/mcp-login-bthtfpt7.js +0 -2
  384. package/dist/cli/mcp-market-cmd-mbeshfyd.js +0 -2
  385. package/dist/cli/notify-54v5z9dz.js +0 -2
  386. package/dist/cli/oauth-g5gme95c.js +0 -2
  387. package/dist/cli/output-satndjap.js +0 -16
  388. package/dist/cli/profiles-sfhpbq3m.js +0 -2
  389. package/dist/cli/provider-config-hv3xtdt4.js +0 -2
  390. package/dist/cli/provider-kwzq6g84.js +0 -2
  391. package/dist/cli/registry-fh0hdnyn.js +0 -2
  392. package/dist/cli/registry-y1y8e94r.js +0 -2
  393. package/dist/cli/repl-t4z03mqq.js +0 -11
  394. package/dist/cli/resume-fqt4chg8.js +0 -2
  395. package/dist/cli/run-flags-rysbag9t.js +0 -2
  396. package/dist/cli/runtime-j19fjbsa.js +0 -2
  397. package/dist/cli/sandbox-config-g4qxd7y5.js +0 -2
  398. package/dist/cli/server-r0b6bksk.js +0 -5
  399. package/dist/cli/session-arg-txmn5g4x.js +0 -2
  400. package/dist/cli/session-ed250d9j.js +0 -2
  401. package/dist/cli/sessions-cmd-adw7svfn.js +0 -7
  402. package/dist/cli/settings-y9rzcqx8.js +0 -2
  403. package/dist/cli/setup-jmbr11j0.js +0 -2
  404. package/dist/cli/sextant-smoke-tcth0vea.js +0 -5
  405. package/dist/cli/skills-cmd-zbdy99v6.js +0 -2
  406. package/dist/cli/smoke-1bg937kx.js +0 -8
  407. package/dist/cli/start-chat-p01cdks3.js +0 -12
  408. package/dist/cli/stream-4wmyaypz.js +0 -2
  409. package/dist/cli/task-eg4s093s.js +0 -2
  410. package/dist/cli/tasks-12v9rr9k.js +0 -2
  411. package/dist/cli/thinking-a5ngvqyh.js +0 -2
  412. package/dist/cli/todo-1wxpcecx.js +0 -2
  413. package/dist/cli/tools-2ftsya7w.js +0 -2
  414. package/dist/cli/tools-x1tj4fxm.js +0 -2
  415. package/dist/cli/trust-cmd-hccxehzb.js +0 -2
  416. package/dist/cli/update-check-ygt3vd7m.js +0 -2
  417. package/dist/cli/update-cmd-v23qhr8c.js +0 -2
  418. package/dist/cli/voice-g1gtck92.js +0 -2
  419. package/dist/cli/webfetch-0nnrjgb5.js +0 -2
  420. package/dist/cli/websearch-f0vr2p7d.js +0 -2
  421. package/dist/cli/workspace-9rq1w4ta.js +0 -2
  422. package/dist/lib/index.js +0 -62
  423. package/dist/lib/models-index.json +0 -1
  424. package/dist/lib/plugins.js +0 -6
  425. package/dist/lib/providers.js +0 -17
  426. package/dist/lib/public-api.js +0 -20
  427. package/dist/rovecode.exe +0 -4
  428. /package/{dist/cli → src/providers}/models-index.json +0 -0
@@ -0,0 +1,320 @@
1
+ /** Session todo / plan tool (PORT #32): `todo_write` + `todo_read` over one
2
+ * JSON file per session, plus pure render helpers for the TUI surface.
3
+ *
4
+ * Ported from opencode (MIT, snapshot ebece6e):
5
+ * - tool contract — ONE `todos` array argument that REPLACES the whole list
6
+ * (packages/opencode/src/tool/todo.ts:6-8, :31-34; packages/core/src/tool/
7
+ * todowrite.ts:14-16, :49); the store's update is delete-all + insert in
8
+ * list order (packages/opencode/src/session/todo.ts:29-51), reads come back
9
+ * in that order (:53-66);
10
+ * - item shape content / status / priority with the status and priority sets
11
+ * (packages/schema/src/session-todo.ts:7-15);
12
+ * - the when-to-use / when-not / rules guidance folded into the tool
13
+ * description (packages/opencode/src/tool/todowrite.txt:3-16, :24-30, :44).
14
+ * Validation follows gemini-cli (Apache-2.0, snapshot 0bd1d43)
15
+ * packages/core/src/tools/write-todos.ts:100-129 validateToolParamValues —
16
+ * array check, per-item object / non-empty description / status-enum checks,
17
+ * and the at-most-ONE-in_progress rule (:120-126); "Cleared todo list" is
18
+ * gemini's wording (:52, :68). Both upstreams reject an invalid list whole.
19
+ *
20
+ * Deviations: items carry a caller-chosen `id` (whole-list replace needs a
21
+ * stable handle the model can quote back; opencode is position-keyed, gemini
22
+ * has none); three statuses only (no cancelled/blocked — drop the item
23
+ * instead); bounds (≤50 items, id ≤64 chars, content ≤500 chars) so tool output
24
+ * stays bounded; storage is `<sessionDir>/todos.json` written tmp+rename
25
+ * (session.ts persistLeaf pattern; opencode uses SQLite, gemini is in-memory).
26
+ * No in-memory cache: every read hits disk, so a second tool instance, a
27
+ * resumed session and the TUI `/todos` surface all see the same truth. A
28
+ * corrupt file is reported as empty + note — loading never throws.
29
+ *
30
+ * Policy: todo_write is kind "memory" — a disk write of agent-private,
31
+ * session-scoped metadata, the same class as memory_edit (BlockStore under
32
+ * <session>/memory). core/tools.ts actionFor() maps it to memory.write, which
33
+ * the runtime's default gated rules ALLOW (runtime.ts buildCfg), so the list
34
+ * never prompts; kind "read" would also auto-run but would let read-only rule
35
+ * sets write to disk unseen — the mirror of recall.ts's argument against
36
+ * mislabeling kinds. The schemas declare no `path`/`command`, so the policy
37
+ * resource is the tool name: `memory.write todo_write` targets it precisely —
38
+ * which is how plan mode (modes.ts planModeRules) denies memory.write wholesale
39
+ * and then re-allows exactly todo_write: the list is the plan's own artifact
40
+ * (agent-private session metadata, not workspace state), while memory_edit,
41
+ * file.write and shell.exec stay denied there; todo_read (kind "read" →
42
+ * file.read) is always available. Not in checkpoints.ts MUTATING_KINDS, so
43
+ * todo writes never trigger snapshots. */
44
+
45
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
46
+ import { basename, join } from "node:path";
47
+ import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
48
+
49
+ // ---------- schema ----------
50
+
51
+ export type TodoStatus = "pending" | "in_progress" | "completed";
52
+ export type TodoPriority = "high" | "medium" | "low";
53
+ export interface TodoItem { id: string; content: string; status: TodoStatus; priority?: TodoPriority }
54
+
55
+ export const TODO_STATUSES: readonly TodoStatus[] = ["pending", "in_progress", "completed"];
56
+ export const TODO_PRIORITIES: readonly TodoPriority[] = ["high", "medium", "low"];
57
+ export const MAX_TODOS = 50;
58
+ export const MAX_ID_CHARS = 64;
59
+ export const MAX_CONTENT_CHARS = 500;
60
+ export const TODOS_FILE = "todos.json";
61
+ const FILE_VERSION = 1;
62
+ const RENDER_CONTENT_CHARS = 200; // per-line render clip (content is ≤500 on disk; the TUI note stays scannable)
63
+
64
+ function isStatus(v: unknown): v is TodoStatus { return (TODO_STATUSES as readonly unknown[]).includes(v); }
65
+ function isPriority(v: unknown): v is TodoPriority { return (TODO_PRIORITIES as readonly unknown[]).includes(v); }
66
+
67
+ /** Bounded echo of a rejected value — a 200k-char status must not reflect into the error. */
68
+ function show(v: unknown): string {
69
+ let s: string;
70
+ try { s = JSON.stringify(v) ?? String(v); } catch { s = String(v); }
71
+ return s.length > 40 ? s.slice(0, 40).replace(/[\uD800-\uDBFF]$/, "") + "…" : s;
72
+ }
73
+
74
+ export type TodoValidation = { ok: true; items: TodoItem[] } | { ok: false; error: string };
75
+
76
+ /** Whole-list validation (gemini-cli write-todos.ts:100-129 shape, plus ids and
77
+ * bounds). Returns NORMALIZED items — ids/content trimmed, unknown keys
78
+ * dropped, `priority` omitted when absent — or the first precise error. */
79
+ export function validateTodos(raw: unknown): TodoValidation {
80
+ const err = (error: string): TodoValidation => ({ ok: false, error });
81
+ if (!Array.isArray(raw)) return err("`todos` must be an array of {id, content, status, priority?}");
82
+ if (raw.length > MAX_TODOS) return err(`too many todos: ${raw.length} (max ${MAX_TODOS})`);
83
+ const items: TodoItem[] = [];
84
+ const seen = new Set<string>();
85
+ const inProgress: string[] = [];
86
+ for (let i = 0; i < raw.length; i++) {
87
+ const t: unknown = raw[i];
88
+ const at = `todos[${i}]`;
89
+ if (!t || typeof t !== "object" || Array.isArray(t)) return err(`${at} must be an object {id, content, status, priority?}`);
90
+ const o = t as Record<string, unknown>;
91
+ if (typeof o.id !== "string" || o.id.trim().length === 0) return err(`${at}.id must be a non-empty string`);
92
+ const id = o.id.trim();
93
+ if (id.length > MAX_ID_CHARS) return err(`${at}.id exceeds ${MAX_ID_CHARS} chars`);
94
+ if (seen.has(id)) return err(`duplicate id "${id}" at ${at} — ids must be unique`);
95
+ seen.add(id);
96
+ if (typeof o.content !== "string" || o.content.trim().length === 0) return err(`${at} ("${id}"): content must be a non-empty string`);
97
+ const content = o.content.trim();
98
+ if (content.length > MAX_CONTENT_CHARS) return err(`${at} ("${id}"): content exceeds ${MAX_CONTENT_CHARS} chars`);
99
+ if (!isStatus(o.status)) return err(`${at} ("${id}"): status must be one of ${TODO_STATUSES.join(", ")} (got ${show(o.status)})`);
100
+ const item: TodoItem = { id, content, status: o.status };
101
+ if (o.priority !== undefined && o.priority !== null) {
102
+ if (!isPriority(o.priority)) return err(`${at} ("${id}"): priority must be one of ${TODO_PRIORITIES.join(", ")} (got ${show(o.priority)})`);
103
+ item.priority = o.priority;
104
+ }
105
+ if (item.status === "in_progress") inProgress.push(id);
106
+ items.push(item);
107
+ }
108
+ // gemini-cli write-todos.ts:120-126: only one task can be in_progress at a time
109
+ if (inProgress.length > 1) return err(`only one todo may be in_progress at a time (found ${inProgress.length}: ${inProgress.join(", ")})`);
110
+ return { ok: true, items };
111
+ }
112
+
113
+ // ---------- persistence (<sessionDir>/todos.json) ----------
114
+
115
+ export interface LoadedTodos { items: TodoItem[]; note?: string }
116
+
117
+ /** Read the session's list. Missing file = empty (the normal initial state, no
118
+ * note). Unreadable / malformed / schema-invalid content = empty + a note
119
+ * saying so — never throws; the next todo_write replaces the file. */
120
+ export function loadTodos(sessionDir: string): LoadedTodos {
121
+ const file = join(sessionDir, TODOS_FILE);
122
+ const tail = "treating the list as empty; the next todo_write replaces it";
123
+ let text: string;
124
+ try {
125
+ text = readFileSync(file, "utf8");
126
+ } catch (e) {
127
+ if ((e as { code?: unknown } | null)?.code === "ENOENT") return { items: [] };
128
+ return { items: [], note: `${TODOS_FILE} could not be read (${e instanceof Error ? e.message : String(e)}) — ${tail}` };
129
+ }
130
+ let raw: unknown;
131
+ try { raw = JSON.parse(text); } catch { return { items: [], note: `${TODOS_FILE} is not valid JSON — ${tail}` }; }
132
+ const o = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
133
+ if (!o || o.version !== FILE_VERSION || !Array.isArray(o.items)) {
134
+ return { items: [], note: `${TODOS_FILE} has an unexpected shape (expected {version: ${FILE_VERSION}, items: [...]}) — ${tail}` };
135
+ }
136
+ const v = validateTodos(o.items);
137
+ if (!v.ok) return { items: [], note: `${TODOS_FILE} failed validation (${v.error}) — ${tail}` };
138
+ return { items: v.items };
139
+ }
140
+
141
+ /** Atomic replace: write `todos.json.tmp`, then rename over the target (session.ts
142
+ * persistLeaf). Creates the session dir if needed. IO errors propagate — the
143
+ * tool turns them into ok:false. */
144
+ export function saveTodos(sessionDir: string, items: readonly TodoItem[]): void {
145
+ mkdirSync(sessionDir, { recursive: true });
146
+ const file = join(sessionDir, TODOS_FILE);
147
+ const tmp = file + ".tmp";
148
+ writeFileSync(tmp, JSON.stringify({ version: FILE_VERSION, items }, null, 2) + "\n");
149
+ renameSync(tmp, file);
150
+ }
151
+
152
+ /** A session id must be a plain directory name: `<root>/<id>/todos.json` may never
153
+ * resolve outside the sessions root (listSessions identity = directory name). */
154
+ function sessionDirFor(root: string, sessionId: string): string | null {
155
+ // both slashes are refused on every host: a backslash is a legal file-name byte on POSIX, but an id
156
+ // that would be a path on Windows is not a plain directory name anywhere
157
+ if (!sessionId || sessionId === "." || sessionId === ".." || /[\\/]/.test(sessionId) || basename(sessionId) !== sessionId) return null;
158
+ return join(root, sessionId);
159
+ }
160
+
161
+ // ---------- rendering (pure; shared by tool output and the TUI) ----------
162
+
163
+ export interface TodoCounts { total: number; pending: number; inProgress: number; completed: number }
164
+
165
+ export function todoCounts(items: readonly TodoItem[]): TodoCounts {
166
+ const c: TodoCounts = { total: items.length, pending: 0, inProgress: 0, completed: 0 };
167
+ for (const t of items) {
168
+ if (t.status === "completed") c.completed++;
169
+ else if (t.status === "in_progress") c.inProgress++;
170
+ else c.pending++;
171
+ }
172
+ return c;
173
+ }
174
+
175
+ const GLYPH: Record<TodoStatus, string> = { pending: "[ ]", in_progress: "[>]", completed: "[x]" };
176
+
177
+ /** Single-line content: whitespace collapsed, clipped with an ellipsis (surrogate-safe). */
178
+ function oneLine(text: string): string {
179
+ const flat = text.replace(/\s+/g, " ").trim();
180
+ return flat.length > RENDER_CONTENT_CHARS
181
+ ? flat.slice(0, RENDER_CONTENT_CHARS).replace(/[\uD800-\uDBFF]$/, "") + "…"
182
+ : flat;
183
+ }
184
+
185
+ /** Checkbox rendering: a summary line, then one `[glyph] id: content (priority)`
186
+ * row per item in list order. Bounded: ≤MAX_TODOS rows, content clipped, and the
187
+ * id flattened too (a trimmed id may still carry an inner newline — one row per item). */
188
+ export function renderTodos(items: readonly TodoItem[]): string {
189
+ if (items.length === 0) return "todos: (empty)";
190
+ const c = todoCounts(items);
191
+ const lines = [`todos: ${c.total} total · ${c.completed} completed · ${c.inProgress} in progress · ${c.pending} pending`];
192
+ for (const t of items.slice(0, MAX_TODOS)) {
193
+ lines.push(`${GLYPH[t.status]} ${oneLine(t.id)}: ${oneLine(t.content)}${t.priority ? ` (${t.priority})` : ""}`);
194
+ }
195
+ if (items.length > MAX_TODOS) lines.push(`(+${items.length - MAX_TODOS} more not shown)`);
196
+ return lines.join("\n");
197
+ }
198
+
199
+ /** The re-send the loop makes while a plan is open (loop.ts LoopDeps.planReminder).
200
+ *
201
+ * A list written twenty turns ago is buried under tool results: the model stops marking items done,
202
+ * starts a second item without finishing the first, or forgets the tail of the plan entirely. This is
203
+ * the same shape as the tool's own output, plus the one instruction that matters right now — so the
204
+ * plan is always the most recent thing in the request, not the oldest.
205
+ *
206
+ * null when there is nothing to chase (no list, or everything completed): a finished plan must not
207
+ * keep nagging, and an empty one has nothing to say. */
208
+ export function planReminder(items: readonly TodoItem[]): string | null {
209
+ const c = todoCounts(items);
210
+ if (c.total === 0 || c.completed === c.total) return null;
211
+ return [
212
+ "<plan-reminder>",
213
+ "Your own todo list for this task, still open — not a message from the user.",
214
+ renderTodos(items),
215
+ c.inProgress === 0
216
+ ? "Nothing is in progress. Mark the next item in_progress with todo_write before you start it."
217
+ : "Mark the in_progress item completed with todo_write the moment it is done, then start the next one.",
218
+ "Rewrite the list if the plan changed. Never mention this reminder in your reply.",
219
+ "</plan-reminder>",
220
+ ].join("\n");
221
+ }
222
+
223
+ /** Status-bar label, e.g. "todos 1/3" (completed/total); "" when the list is empty. */
224
+ export function todoStatusLabel(items: readonly TodoItem[]): string {
225
+ if (items.length === 0) return "";
226
+ const c = todoCounts(items);
227
+ return `todos ${c.completed}/${c.total}`;
228
+ }
229
+
230
+ // ---------- tools ----------
231
+
232
+ const WRITE_DESCRIPTION =
233
+ "Create or update this session's structured todo list (your plan). REPLACES the whole list: send every item " +
234
+ `you want kept — items {id, content, status, priority?}; at most ${MAX_TODOS} items, ids unique and non-empty ` +
235
+ `(≤${MAX_ID_CHARS} chars), content non-empty (≤${MAX_CONTENT_CHARS} chars). Statuses: pending | in_progress ` +
236
+ "(exactly ONE at a time) | completed. Priority: high | medium | low (optional). An invalid list is rejected whole " +
237
+ "and nothing changes; an empty list clears the todos. Use proactively when: the task needs 3+ distinct steps; the " +
238
+ "work is non-trivial and benefits from planning; the user gives multiple tasks or asks for a todo list; new " +
239
+ "instructions arrive (capture them as todos); you start a step (mark it in_progress first) or finish one (mark it " +
240
+ "completed only once the work, including verification, is actually done — never on intent; add follow-ups " +
241
+ "discovered on the way). Skip it for a single straightforward task, a purely informational request, or when " +
242
+ "tracking adds no value. Update in real time — don't batch completions. When in doubt, use it.";
243
+
244
+ const READ_DESCRIPTION =
245
+ "Read this session's todo list as last written by todo_write: ids, content, status, priority. Use it to get the " +
246
+ "current ids before updating the list, or to check what remains. Empty until todo_write creates a list.";
247
+
248
+ const itemSchema = {
249
+ type: "object",
250
+ properties: {
251
+ id: { type: "string", description: `stable short id, unique within the list (e.g. "t1"); ≤${MAX_ID_CHARS} chars` },
252
+ content: { type: "string", description: `brief, specific, actionable description of the task; ≤${MAX_CONTENT_CHARS} chars` },
253
+ status: { type: "string", enum: [...TODO_STATUSES], description: "pending | in_progress (exactly one at a time) | completed" },
254
+ priority: { type: "string", enum: [...TODO_PRIORITIES], description: "optional priority" },
255
+ },
256
+ required: ["id", "content", "status"],
257
+ };
258
+
259
+ /** `todo_write` bound to a sessions root; the list lives at <root>/<ctx.sessionId>/todos.json. */
260
+ export function todoWriteTool(sessionsRoot: string): Tool {
261
+ return {
262
+ schema: {
263
+ name: "todo_write",
264
+ description: WRITE_DESCRIPTION,
265
+ args: {
266
+ type: "object",
267
+ properties: {
268
+ todos: { type: "array", description: "the complete, updated todo list (replaces the current one)", maxItems: MAX_TODOS, items: itemSchema },
269
+ },
270
+ required: ["todos"],
271
+ },
272
+ },
273
+ kind: "memory",
274
+ sequential: true,
275
+ execute(args: unknown, ctx: ToolContext): Promise<ToolOutput> {
276
+ const dir = sessionDirFor(sessionsRoot, ctx.sessionId);
277
+ if (!dir) return Promise.resolve({ ok: false, output: `todo_write failed: invalid session id ${show(ctx.sessionId)}` });
278
+ // keep ONLY the schema arg (recall.ts idiom): smuggled keys never influence behavior
279
+ const raw = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
280
+ const v = validateTodos(raw.todos);
281
+ if (!v.ok) return Promise.resolve({ ok: false, output: `todo_write failed: ${v.error}; the list was not changed` });
282
+ try {
283
+ saveTodos(dir, v.items);
284
+ } catch (e) {
285
+ return Promise.resolve({ ok: false, output: `todo_write failed: could not write ${TODOS_FILE}: ${e instanceof Error ? e.message : String(e)}` });
286
+ }
287
+ // gemini write-todos.ts:52/:68 wording for the clear case; otherwise the checkbox view
288
+ const output = v.items.length === 0 ? "Cleared todo list." : renderTodos(v.items);
289
+ return Promise.resolve({ ok: true, output, data: { items: v.items } });
290
+ },
291
+ };
292
+ }
293
+
294
+ /** `todo_read` bound to the same root. Pure read (kind "read"): never creates the file. */
295
+ export function todoReadTool(sessionsRoot: string): Tool {
296
+ return {
297
+ schema: {
298
+ name: "todo_read",
299
+ description: READ_DESCRIPTION,
300
+ args: { type: "object", properties: {} },
301
+ },
302
+ kind: "read",
303
+ sequential: false,
304
+ execute(_args: unknown, ctx: ToolContext): Promise<ToolOutput> {
305
+ const dir = sessionDirFor(sessionsRoot, ctx.sessionId);
306
+ if (!dir) return Promise.resolve({ ok: false, output: `todo_read failed: invalid session id ${show(ctx.sessionId)}` });
307
+ const { items, note } = loadTodos(dir);
308
+ if (items.length === 0) {
309
+ const head = note ? `${note}\n` : "";
310
+ return Promise.resolve({ ok: true, output: `${head}No todos for this session yet — use todo_write to create a list.`, data: { items, ...(note ? { note } : {}) } });
311
+ }
312
+ return Promise.resolve({ ok: true, output: renderTodos(items), data: { items } });
313
+ },
314
+ };
315
+ }
316
+
317
+ /** Both tools over one sessions root — the runtime registration unit. */
318
+ export function todoTools(sessionsRoot: string): Tool[] {
319
+ return [todoWriteTool(sessionsRoot), todoReadTool(sessionsRoot)];
320
+ }
@@ -0,0 +1,331 @@
1
+ /** web_fetch tool (PORT #31): bounded, SSRF-guarded GET → readable text.
2
+ * Ported from opencode (MIT, snapshot ebece6e, packages/opencode/src/tool/
3
+ * webfetch.ts): url arg + http/https scheme gate (:35-37), 30s default timeout
4
+ * (:10), response size cap via content-length + body (:96-104), content-type →
5
+ * mime split (:106-107), html-vs-passthrough branch (:129-152). SSRF handling
6
+ * follows gemini-cli (Apache-2.0, snapshot 0bd1d43): localhost/private host
7
+ * block BEFORE any request (packages/core/src/tools/web-fetch.ts:270-281,
8
+ * :618-631), resolve-then-check over ALL addresses (packages/core/src/utils/
9
+ * fetch.ts:150-169), IPv4-mapped unmapping + the 198.18/15 benchmark range
10
+ * (fetch.ts:94-145), timer-driven AbortController linked to the caller's signal
11
+ * (fetch.ts:190-230), streamed read under a byte limit (web-fetch.ts:555-587),
12
+ * per-mime handling (web-fetch.ts:686-721).
13
+ * Deviations: redirects are followed MANUALLY (≤5 hops) so the guard re-runs
14
+ * on every hop — upstreams let fetch follow them, the classic redirect-to-
15
+ * 127.0.0.1 bypass — and only while the host stays the same: policy consented
16
+ * to net.fetch on the ORIGINAL host, so a hop to another host stops with the
17
+ * target URL for the model to fetch directly (its own policy decision);
18
+ * ipaddr.js / html-to-text / htmlparser2 are replaced by the ~50 lines of
19
+ * address parsing below and html-text.ts; the byte cap TRUNCATES with a marker
20
+ * instead of failing; no LLM pass, retries or rate limiter. The timeout covers
21
+ * the DNS phase too (the guard's lookup is raced against the controller).
22
+ * Known gap: DNS is resolved once for the guard and again inside fetch
23
+ * (rebinding TOCTOU); pinning the socket to the checked address needs a
24
+ * dispatcher hook Bun's fetch does not expose.
25
+ * Policy: kind "network" → action net.fetch, resource = canonical URL host
26
+ * (core/tools.ts hostOf); runtime.ts buildCfg makes it PROMPT by default.
27
+ * Env: ROVECODE_WEBFETCH_TIMEOUT_MS (default 30000), ROVECODE_WEBFETCH_ALLOW_PRIVATE=1
28
+ * (skip the private-address guard, for local dev servers). */
29
+
30
+ import { lookup } from "node:dns/promises";
31
+ import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
32
+ import { htmlToText } from "./html-text.ts";
33
+
34
+ // ---------- bounds (advertised in the schema; args are clamped, never trusted) ----------
35
+
36
+ export const MAX_BYTES = 512 * 1024; // body bytes read before truncation
37
+ export const CHARS_DEFAULT = 50_000; // text chars returned by default
38
+ export const CHARS_CAP = 250_000; // ceiling for maxChars (gemini-cli MAX_CONTENT_LENGTH)
39
+ export const MAX_REDIRECTS = 5;
40
+ export const TIMEOUT_DEFAULT_MS = 30_000; // opencode DEFAULT_TIMEOUT
41
+ const USER_AGENT = "Mozilla/5.0 (compatible; rovecode/0.2 web_fetch)";
42
+ const ACCEPT = "text/html, application/xhtml+xml, application/json;q=0.9, text/*;q=0.8, application/xml;q=0.7, */*;q=0.1";
43
+
44
+ /** files.ts clampLimit contract: ceiling CHARS_CAP; absent/NaN/non-positive → default; fractions floor. */
45
+ export function clampChars(v: number | undefined): number {
46
+ return Number.isFinite(v) && v! > 0 ? Math.min(Math.floor(v!), CHARS_CAP) : CHARS_DEFAULT;
47
+ }
48
+ function timeoutMs(): number {
49
+ const v = Number(process.env.ROVECODE_WEBFETCH_TIMEOUT_MS ?? "");
50
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : TIMEOUT_DEFAULT_MS;
51
+ }
52
+
53
+ // ---------- SSRF guard: address classification ----------
54
+
55
+ function parseIPv4(s: string): number | null {
56
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
57
+ if (!m) return null;
58
+ let n = 0;
59
+ for (let i = 1; i <= 4; i++) { const o = Number(m[i]); if (o > 255) return null; n = (n << 8) | o; }
60
+ return n >>> 0;
61
+ }
62
+
63
+ /** 8 × 16-bit groups, or null when not an IPv6 literal. Handles `::`, an embedded
64
+ * dotted IPv4 tail (::ffff:10.0.0.1) and zone ids (fe80::1%eth0). */
65
+ function parseIPv6(input: string): number[] | null {
66
+ const s = input.includes("%") ? input.slice(0, input.indexOf("%")) : input;
67
+ if (!/^[0-9a-fA-F:.]+$/.test(s) || !s.includes(":")) return null;
68
+ const halves = s.split("::");
69
+ if (halves.length > 2) return null;
70
+ const groups = (part: string): number[] | null => {
71
+ if (part === "") return [];
72
+ const g: number[] = [];
73
+ for (const piece of part.split(":")) {
74
+ if (piece.includes(".")) { const v4 = parseIPv4(piece); if (v4 === null) return null; g.push(v4 >>> 16, v4 & 0xffff); }
75
+ else if (/^[0-9a-fA-F]{1,4}$/.test(piece)) g.push(parseInt(piece, 16));
76
+ else return null;
77
+ }
78
+ return g;
79
+ };
80
+ const head = groups(halves[0]!);
81
+ const tail = halves.length === 2 ? groups(halves[1]!) : [];
82
+ if (!head || !tail) return null;
83
+ const fill = 8 - head.length - tail.length;
84
+ if (halves.length === 2 ? fill < 1 : fill !== 0) return null;
85
+ return [...head, ...(new Array<number>(fill).fill(0)), ...tail];
86
+ }
87
+
88
+ /** Non-global IPv4 space: RFC 1918, loopback, link-local (incl. 169.254.169.254
89
+ * metadata), "this" network, CGNAT, IETF/TEST-NETs, benchmarking, multicast, reserved. */
90
+ const V4_BLOCKED: [number, number][] = ([
91
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
92
+ ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
93
+ ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
94
+ ] as [string, number][]).map(([p, bits]) => [parseIPv4(p)!, bits]);
95
+ function v4Blocked(n: number): boolean {
96
+ return V4_BLOCKED.some(([p, bits]) => (n >>> (32 - bits)) === (p >>> (32 - bits)));
97
+ }
98
+
99
+ /** True for any address a fetch must not reach: loopback, private, link-local,
100
+ * unspecified, multicast/reserved, IPv4-mapped/6to4 forms of those, and anything
101
+ * outside IPv6 global unicast (2000::/3). Unparseable input is treated as
102
+ * private (fail closed). */
103
+ export function isPrivateAddress(ip: string): boolean {
104
+ const v4 = parseIPv4(ip);
105
+ if (v4 !== null) return v4Blocked(v4);
106
+ const g = parseIPv6(ip);
107
+ if (!g) return true;
108
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = g as [number, number, number, number, number, number, number, number];
109
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) return v4Blocked(((g6 << 16) | g7) >>> 0); // ::ffff:a.b.c.d
110
+ if ((g0 & 0xe000) !== 0x2000) return true; // ::, ::1, fc00::/7, fe80::/10, ff00::/8, 64:ff9b::/96, 100::/64 …
111
+ if (g0 === 0x2002) return v4Blocked(((g1 << 16) | g2) >>> 0); // 6to4 embeds an IPv4
112
+ return g0 === 0x2001 && g1 === 0x0db8; // documentation prefix
113
+ }
114
+
115
+ /** Resolver seam: every address a host maps to (default node:dns lookup, all: true). */
116
+ export type Resolver = (host: string) => Promise<string[]>;
117
+ export const dnsResolver: Resolver = async (host) => (await lookup(host, { all: true })).map((a) => a.address);
118
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
119
+
120
+ /** Guard host form: brackets and trailing dot stripped, lowercased. */
121
+ export function canonicalHost(hostname: string): string {
122
+ let h = hostname.toLowerCase();
123
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
124
+ return h.endsWith(".") ? h.slice(0, -1) : h;
125
+ }
126
+
127
+ /** null when the host may be fetched; otherwise the reason it is refused. Literal
128
+ * IPs and localhost names never touch DNS; names are resolved and refused when
129
+ * ANY address is private (gemini-cli fetch.ts:159-160). */
130
+ export async function ssrfDenyReason(hostname: string, resolve: Resolver): Promise<string | null> {
131
+ const host = canonicalHost(hostname);
132
+ const kind = "a private, loopback, link-local or reserved address";
133
+ if (host === "") return "empty host";
134
+ if (host === "localhost" || host.endsWith(".localhost")) return `${host} is a loopback name`;
135
+ if (parseIPv4(host) !== null || host.includes(":")) return isPrivateAddress(host) ? `${host} is ${kind}` : null;
136
+ let addrs: string[];
137
+ try { addrs = await resolve(host); } catch (e) { return `could not resolve ${host}: ${e instanceof Error ? e.message : String(e)}`; }
138
+ if (addrs.length === 0) return `could not resolve ${host}`;
139
+ const bad = addrs.find(isPrivateAddress);
140
+ return bad === undefined ? null : `${host} resolves to ${bad}, ${kind}`;
141
+ }
142
+
143
+ // ---------- response handling ----------
144
+
145
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
146
+
147
+ function isTextual(mime: string): boolean {
148
+ return mime === "" || mime.startsWith("text/") || mime === "application/json" || mime === "application/xml"
149
+ || mime === "application/xhtml+xml" || mime === "application/javascript" || mime === "application/ecmascript"
150
+ || mime.endsWith("+json") || mime.endsWith("+xml");
151
+ }
152
+ function looksLikeHtml(mime: string, text: string): boolean {
153
+ if (mime === "text/html" || mime === "application/xhtml+xml") return true;
154
+ return mime === "" && /^\s*<(!doctype\s+html|html|head|body)[\s>]/i.test(text.slice(0, 1024));
155
+ }
156
+
157
+ /** Rejects as soon as `signal` aborts, so a phase that is not itself abortable
158
+ * (the guard's DNS lookup) cannot outlive the tool's timeout or the user's Esc. */
159
+ function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
160
+ if (signal.aborted) return Promise.reject(new Error("aborted"));
161
+ return new Promise<T>((resolve, reject) => {
162
+ const onAbort = (): void => reject(new Error("aborted"));
163
+ signal.addEventListener("abort", onAbort, { once: true });
164
+ p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
165
+ });
166
+ }
167
+
168
+ /** Streams the body up to `cap` bytes, then cancels the stream (gemini-cli
169
+ * web-fetch.ts:568-585 loop, truncating instead of throwing). An abort mid-body
170
+ * rejects out of reader.read() and is handled by the caller. */
171
+ async function readBounded(res: Response, cap: number): Promise<{ bytes: Uint8Array; truncated: boolean }> {
172
+ if (!res.body) return { bytes: new Uint8Array(0), truncated: false };
173
+ const reader = res.body.getReader();
174
+ const chunks: Uint8Array[] = [];
175
+ let total = 0;
176
+ let truncated = false;
177
+ try {
178
+ for (;;) {
179
+ const { done, value } = await reader.read();
180
+ if (done) break;
181
+ if (value.length === 0) continue; // an empty chunk carries no bytes: not overflow even at the cap
182
+ if (total >= cap) { truncated = true; break; }
183
+ const room = cap - total;
184
+ if (value.length > room) { chunks.push(value.subarray(0, room)); total = cap; truncated = true; break; }
185
+ chunks.push(value);
186
+ total += value.length;
187
+ }
188
+ } finally {
189
+ await reader.cancel().catch(() => {});
190
+ }
191
+ const bytes = new Uint8Array(total);
192
+ let off = 0;
193
+ for (const c of chunks) { bytes.set(c, off); off += c.length; }
194
+ return { bytes, truncated };
195
+ }
196
+
197
+ function decodeBody(bytes: Uint8Array, contentType: string, truncated: boolean): string {
198
+ const charset = /charset=["']?([\w.:-]+)/i.exec(contentType)?.[1];
199
+ let decoder: TextDecoder;
200
+ try { decoder = new TextDecoder(charset ?? "utf-8"); } catch { decoder = new TextDecoder(); }
201
+ const text = decoder.decode(bytes);
202
+ return truncated ? text.replace(/�$/, "") : text; // a cut mid-sequence leaves one replacement char
203
+ }
204
+
205
+ export interface WebFetchDeps { fetch?: FetchLike; resolve?: Resolver }
206
+
207
+ /** Builds the tool; `deps` are test seams (fixture-mapping fetch, scripted resolver).
208
+ * Production registrations use the module-level webFetchTool. */
209
+ export function createWebFetchTool(deps: WebFetchDeps = {}): Tool {
210
+ const fetchImpl: FetchLike = deps.fetch ?? ((u, i) => fetch(u, i));
211
+ const resolve: Resolver = deps.resolve ?? dnsResolver;
212
+
213
+ async function run(a: { url: string; maxChars?: number }, ctx: ToolContext): Promise<ToolOutput> {
214
+ if (typeof a.url !== "string" || a.url.trim() === "") return { ok: false, output: "web_fetch: url is required" };
215
+ let current: URL;
216
+ try { current = new URL(a.url.trim()); } catch { return { ok: false, output: `web_fetch: invalid URL: ${a.url}` }; }
217
+ const maxChars = clampChars(a.maxChars);
218
+ const allowPrivate = process.env.ROVECODE_WEBFETCH_ALLOW_PRIVATE === "1";
219
+ if (ctx.signal.aborted) return { ok: false, output: "web_fetch: aborted" };
220
+
221
+ // Timeout: a REF'D setTimeout drives the controller (Bun's AbortSignal.timeout
222
+ // timer is unref'd and never fires on an idle loop — see executor.ts trialSpawn);
223
+ // the caller's signal is chained so Esc mid-fetch aborts the socket too.
224
+ const ac = new AbortController();
225
+ const limitMs = timeoutMs();
226
+ let timedOut = false;
227
+ const timer = setTimeout(() => { timedOut = true; ac.abort(); }, limitMs);
228
+ (timer as unknown as { ref?: () => void }).ref?.();
229
+ const onAbort = (): void => ac.abort();
230
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
231
+ const fail = (e: unknown): ToolOutput => {
232
+ if (ac.signal.aborted) return { ok: false, output: timedOut ? `web_fetch: timed out after ${limitMs}ms` : "web_fetch: aborted" };
233
+ return { ok: false, output: `web_fetch: request failed: ${e instanceof Error ? e.message : String(e)}` };
234
+ };
235
+
236
+ try {
237
+ let hops = 0;
238
+ const origin = canonicalHost(current.hostname);
239
+ for (;;) {
240
+ // scheme + SSRF gate on the initial URL AND every redirect target
241
+ if (current.protocol !== "http:" && current.protocol !== "https:") {
242
+ return { ok: false, output: `web_fetch: unsupported URL scheme ${current.protocol} (http/https only)` };
243
+ }
244
+ if (!allowPrivate) {
245
+ let reason: string | null;
246
+ try { reason = await abortable(ssrfDenyReason(current.hostname, resolve), ac.signal); } catch (e) { return fail(e); }
247
+ if (reason !== null) {
248
+ return { ok: false, output: `web_fetch: refused ${current.href}: ${reason} (set ROVECODE_WEBFETCH_ALLOW_PRIVATE=1 for local dev servers)` };
249
+ }
250
+ }
251
+ // Policy consented to net.fetch on the host of the ORIGINAL url only
252
+ // (core/tools.ts hostOf), so a redirect to a different host is not
253
+ // followed: the target is reported for a direct fetch that gets its own
254
+ // decision. Checked AFTER the guard so a private target keeps its precise
255
+ // refusal instead of advice to fetch it. Same canonical host (case and
256
+ // trailing dot ignored, any port) still follows, so http→https upgrades
257
+ // and path moves stay seamless.
258
+ const host = canonicalHost(current.hostname);
259
+ if (host !== origin) {
260
+ return { ok: false, output: `web_fetch: redirected to ${current.href}; fetch it directly (a redirect from ${origin} to ${host} needs its own net.fetch permission)` };
261
+ }
262
+ let res: Response;
263
+ try {
264
+ res = await fetchImpl(current.href, { method: "GET", redirect: "manual", signal: ac.signal, headers: { "User-Agent": USER_AGENT, Accept: ACCEPT } });
265
+ } catch (e) { return fail(e); }
266
+
267
+ if (REDIRECT_STATUSES.has(res.status)) {
268
+ const location = res.headers.get("location");
269
+ await res.body?.cancel().catch(() => {});
270
+ if (!location) return { ok: false, output: `web_fetch: HTTP ${res.status} redirect from ${current.href} without a Location header` };
271
+ if (++hops > MAX_REDIRECTS) return { ok: false, output: `web_fetch: too many redirects (more than ${MAX_REDIRECTS}) starting from ${a.url}` };
272
+ try { current = new URL(location, current); } catch { return { ok: false, output: `web_fetch: invalid redirect target ${location}` }; }
273
+ continue;
274
+ }
275
+
276
+ const contentType = res.headers.get("content-type") ?? "";
277
+ const mime = (contentType.split(";")[0] ?? "").trim().toLowerCase();
278
+ if (!isTextual(mime)) {
279
+ await res.body?.cancel().catch(() => {}); // gate BEFORE the body is read
280
+ return { ok: false, output: `web_fetch: unsupported content-type: ${mime} (only text/*, JSON, XML and XHTML are fetched)` };
281
+ }
282
+ let body: { bytes: Uint8Array; truncated: boolean };
283
+ try { body = await readBounded(res, MAX_BYTES); } catch (e) { return fail(e); }
284
+ const raw = decodeBody(body.bytes, contentType, body.truncated);
285
+ let text = looksLikeHtml(mime, raw) ? htmlToText(raw, current.href) : raw;
286
+
287
+ const notes: string[] = [];
288
+ if (body.truncated) {
289
+ const declared = Number(res.headers.get("content-length"));
290
+ notes.push(`(Body truncated at ${MAX_BYTES} bytes${Number.isFinite(declared) && declared > 0 ? ` of ${declared}` : ""}.)`);
291
+ }
292
+ const totalChars = text.length;
293
+ const charsTruncated = totalChars > maxChars;
294
+ if (charsTruncated) {
295
+ text = text.slice(0, maxChars).replace(/[\uD800-\uDBFF]$/, "");
296
+ notes.push(`(Text truncated: showing first ${maxChars} of ${totalChars} characters.)`);
297
+ }
298
+ const header = `${current.href} (HTTP ${res.status}, ${mime || "no content-type"}, ${body.bytes.length} bytes${hops > 0 ? `, ${hops} redirect${hops === 1 ? "" : "s"}` : ""})`;
299
+ const output = [header, text, ...(notes.length > 0 ? [notes.join("\n")] : [])].filter((s) => s !== "").join("\n\n");
300
+ return {
301
+ ok: res.status < 400,
302
+ output,
303
+ data: { url: current.href, status: res.status, contentType: mime, bytes: body.bytes.length, redirects: hops, truncated: body.truncated || charsTruncated },
304
+ };
305
+ }
306
+ } finally {
307
+ clearTimeout(timer);
308
+ ctx.signal.removeEventListener("abort", onAbort);
309
+ }
310
+ }
311
+
312
+ return {
313
+ schema: {
314
+ name: "web_fetch",
315
+ description: `Fetch a public http(s) URL with GET and return its content as readable text. HTML is reduced to text (scripts/styles dropped, headings/paragraphs/lists kept, links as "text (href)"); JSON, XML and plain text pass through unchanged. Follows at most ${MAX_REDIRECTS} redirects, and only within the same host: a redirect to another host stops with that URL — fetch it directly. Only text/*, JSON, XML and XHTML responses are accepted. The body is read up to ${MAX_BYTES} bytes and the text is capped at maxChars (default ${CHARS_DEFAULT}, cap ${CHARS_CAP}); both truncations leave a marker. Times out after ${TIMEOUT_DEFAULT_MS / 1000}s. Private, loopback, link-local and unresolvable hosts are refused. Output starts with a header line: final URL, HTTP status, content-type, bytes read.`,
316
+ args: {
317
+ type: "object",
318
+ properties: {
319
+ url: { type: "string", description: "absolute http:// or https:// URL to fetch" },
320
+ maxChars: { type: "integer", description: `max characters of text returned (default ${CHARS_DEFAULT}, cap ${CHARS_CAP})` },
321
+ },
322
+ required: ["url"],
323
+ },
324
+ },
325
+ kind: "network",
326
+ sequential: false,
327
+ execute: (args, ctx) => run((args ?? {}) as { url: string; maxChars?: number }, ctx),
328
+ };
329
+ }
330
+
331
+ export const webFetchTool: Tool = createWebFetchTool();