tumwater 0.1.3 → 0.2.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 (359) hide show
  1. package/README.md +63 -28
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-eligibility.js +332 -43
  26. package/dist/src/backlog/backlog-md.js +79 -17
  27. package/dist/src/backlog/backlog-render.js +62 -11
  28. package/dist/src/backlog/backlog-structure.js +261 -58
  29. package/dist/src/backlog/backlog-write.js +178 -441
  30. package/dist/src/backlog/backlog.js +113 -16
  31. package/dist/src/backlog/question-answer.js +339 -0
  32. package/dist/src/baseline/main-baseline.js +75 -88
  33. package/dist/src/baseline/main-red.js +32 -30
  34. package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
  35. package/dist/src/{brief.js → brief/brief.js} +33 -8
  36. package/dist/src/budget/budget.js +32 -15
  37. package/dist/src/budget/fallback-breaker.js +8 -7
  38. package/dist/src/build/build-check-detect.js +8 -7
  39. package/dist/src/build/build-check-events.js +68 -22
  40. package/dist/src/build/build-check-report.js +22 -23
  41. package/dist/src/build/build-check-scoped.js +51 -62
  42. package/dist/src/build/build-check.js +15 -14
  43. package/dist/src/build/build-info.js +2 -1
  44. package/dist/src/build/build-stage.js +23 -22
  45. package/dist/src/build/dep-install.js +22 -21
  46. package/dist/src/build/host-sleep.js +1 -1
  47. package/dist/src/change/change-data.js +13 -11
  48. package/dist/src/change/change-render.js +11 -10
  49. package/dist/src/cli/brief-command.js +9 -7
  50. package/dist/src/cli/cli-args.js +25 -5
  51. package/dist/src/cli/cli-command-args.js +43 -10
  52. package/dist/src/cli/cli-editor.js +13 -1
  53. package/dist/src/cli/cli-flag-specs.js +31 -6
  54. package/dist/src/cli/cli-marker-commands.js +13 -10
  55. package/dist/src/cli/cli-query-commands.js +439 -56
  56. package/dist/src/cli/cli-repo-gate.js +1 -1
  57. package/dist/src/cli/cli-run.js +124 -68
  58. package/dist/src/cli/completion.js +70 -12
  59. package/dist/src/cli/config-commands.js +14 -11
  60. package/dist/src/cli/help.js +264 -124
  61. package/dist/src/cli/log-commands.js +127 -21
  62. package/dist/src/cli/notify-command.js +33 -18
  63. package/dist/src/cli/question-commands.js +13 -224
  64. package/dist/src/cli.js +78 -36
  65. package/dist/src/collections.js +66 -16
  66. package/dist/src/concurrency/check-permit.js +16 -17
  67. package/dist/src/concurrency/lock.js +6 -5
  68. package/dist/src/concurrency/provider-lease.js +69 -0
  69. package/dist/src/concurrency/semaphore.js +258 -36
  70. package/dist/src/config/config-aliases.js +106 -0
  71. package/dist/src/config/config-editable-keys.js +3 -3
  72. package/dist/src/config/config-fallbacks.js +379 -0
  73. package/dist/src/config/config-field-checks.js +19 -10
  74. package/dist/src/config/config-keys.js +160 -0
  75. package/dist/src/config/config-live.js +64 -12
  76. package/dist/src/config/config-schema.js +34 -22
  77. package/dist/src/config/config-validation.js +132 -48
  78. package/dist/src/config/config-views.js +107 -325
  79. package/dist/src/config/config-write.js +138 -177
  80. package/dist/src/config/config.js +60 -49
  81. package/dist/src/config/model-selector.js +28 -2
  82. package/dist/src/doctor/doctor-backlog.js +494 -68
  83. package/dist/src/doctor/doctor-checks.js +509 -58
  84. package/dist/src/doctor/doctor-holds.js +139 -78
  85. package/dist/src/doctor/doctor-install.js +30 -0
  86. package/dist/src/doctor/doctor-launch-services.js +2 -2
  87. package/dist/src/doctor/doctor-model-checks.js +549 -157
  88. package/dist/src/doctor/doctor-notify.js +2 -3
  89. package/dist/src/doctor/doctor-orphans.js +10 -9
  90. package/dist/src/doctor/doctor-render.js +36 -9
  91. package/dist/src/doctor/doctor.js +76 -25
  92. package/dist/src/doctor/image-probe.js +244 -0
  93. package/dist/src/doctor/probe.js +64 -0
  94. package/dist/src/events/event-format.js +219 -106
  95. package/dist/src/events/event-labels.js +116 -0
  96. package/dist/src/events/event-legacy.js +140 -0
  97. package/dist/src/events/event-read.js +58 -22
  98. package/dist/src/events/event-window.js +27 -7
  99. package/dist/src/events/events.js +18 -14
  100. package/dist/src/events/notify.js +80 -30
  101. package/dist/src/failure/failure-cluster.js +12 -9
  102. package/dist/src/failure/failure-data.js +96 -48
  103. package/dist/src/failure/failure-render.js +69 -58
  104. package/dist/src/failure/failure-state-change.js +33 -26
  105. package/dist/src/failure/time-spend.js +79 -59
  106. package/dist/src/files/file-queue.js +16 -4
  107. package/dist/src/files/files.js +18 -6
  108. package/dist/src/files/json-object.js +18 -3
  109. package/dist/src/files/tail.js +1 -1
  110. package/dist/src/gates/bootstrap-gates.js +5 -4
  111. package/dist/src/gates/budget-gates.js +250 -34
  112. package/dist/src/gates/disk-gate.js +18 -18
  113. package/dist/src/gates/gate-polls.js +117 -69
  114. package/dist/src/gates/maintenance-quota.js +29 -29
  115. package/dist/src/gates/pause-gates.js +11 -11
  116. package/dist/src/gates/readiness.js +2 -1
  117. package/dist/src/gates/role-cap-gates.js +16 -21
  118. package/dist/src/gates/startup-gate.js +75 -31
  119. package/dist/src/gates/streak-gate.js +18 -16
  120. package/dist/src/git/commit-message.js +6 -5
  121. package/dist/src/git/git-diff.js +16 -16
  122. package/dist/src/git/git-run.js +8 -8
  123. package/dist/src/git/git.js +79 -59
  124. package/dist/src/git/slots-state.js +14 -5
  125. package/dist/src/git/worktree-pool.js +25 -18
  126. package/dist/src/git/worktree-use.js +15 -14
  127. package/dist/src/git/worktree.js +18 -18
  128. package/dist/src/gui/gui-args.js +10 -14
  129. package/dist/src/gui/gui-backlog-commands.js +273 -0
  130. package/dist/src/gui/gui-command.js +1 -1
  131. package/dist/src/gui/gui-config-commands.js +440 -0
  132. package/dist/src/gui/gui-endpoint-commands.js +16 -716
  133. package/dist/src/gui/gui-endpoint-common.js +49 -0
  134. package/dist/src/gui/gui-endpoints.js +105 -54
  135. package/dist/src/gui/gui-operator-commands.js +145 -0
  136. package/dist/src/gui/gui-prompt-commands.js +234 -0
  137. package/dist/src/gui/gui-server.js +25 -8
  138. package/dist/src/gui/gui-team-commands.js +164 -0
  139. package/dist/src/gui/http-body.js +17 -1
  140. package/dist/src/history/history-data.js +179 -104
  141. package/dist/src/history/history-render.js +76 -0
  142. package/dist/src/history/history.js +29 -70
  143. package/dist/src/inbox/inbox-attachments.js +66 -10
  144. package/dist/src/inbox/inbox-cancel.js +7 -5
  145. package/dist/src/inbox/inbox-edit.js +1 -1
  146. package/dist/src/inbox/inbox-submit.js +44 -4
  147. package/dist/src/inbox/inbox-top.js +83 -0
  148. package/dist/src/inbox/inbox.js +8 -2
  149. package/dist/src/inbox/pending-prompt.js +1 -1
  150. package/dist/src/inbox/prompt-commands.js +114 -73
  151. package/dist/src/init/init-templates.js +16 -14
  152. package/dist/src/init/init.js +12 -11
  153. package/dist/src/loop/leftover.js +28 -27
  154. package/dist/src/loop/loop-fallback.js +2 -2
  155. package/dist/src/loop/loop-pi.js +104 -50
  156. package/dist/src/loop/loop-recovery.js +15 -15
  157. package/dist/src/loop/loop-state.js +130 -13
  158. package/dist/src/loop/loop-tick.js +190 -88
  159. package/dist/src/loop/loop.js +68 -36
  160. package/dist/src/loop/model-fallback.js +3 -2
  161. package/dist/src/loop/revision.js +16 -7
  162. package/dist/src/merge/backlog-conflicts.js +155 -0
  163. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  164. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  165. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  166. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  167. package/dist/src/merge/merge-drain.js +212 -0
  168. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -20
  169. package/dist/src/merge/merge-pipeline.js +99 -0
  170. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  171. package/dist/src/merge/merge-queue-render.js +75 -0
  172. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  173. package/dist/src/merge/merge-slot.js +340 -0
  174. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  175. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  176. package/dist/src/merge/merge-verify.js +212 -0
  177. package/dist/src/notes-cli.js +97 -0
  178. package/dist/src/operator/operator-commands.js +113 -45
  179. package/dist/src/operator/operator-intent.js +74 -53
  180. package/dist/src/operator/operator-requests.js +52 -52
  181. package/dist/src/operator/retire.js +47 -28
  182. package/dist/src/operator/revert.js +71 -0
  183. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  184. package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
  185. package/dist/src/orchestrator/orchestrator.js +149 -96
  186. package/dist/src/orchestrator/retention.js +29 -12
  187. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  188. package/dist/src/orchestrator/startup-migrations.js +40 -0
  189. package/dist/src/paths.js +124 -38
  190. package/dist/src/pi/pi-args.js +26 -3
  191. package/dist/src/pi/pi-backend.js +15 -23
  192. package/dist/src/pi/pi-bin.js +18 -12
  193. package/dist/src/pi/pi-event-line.js +17 -1
  194. package/dist/src/pi/pi-failure.js +87 -0
  195. package/dist/src/pi/pi-models.js +126 -38
  196. package/dist/src/pi/pi-run-result.js +1 -1
  197. package/dist/src/pi/pi-settings.js +86 -0
  198. package/dist/src/pi/pi-stream.js +131 -107
  199. package/dist/src/pi/pi-watchdogs.js +4 -3
  200. package/dist/src/pi-extension/bounded-output.js +20 -5
  201. package/dist/src/pi-extension/context-budget.js +3 -1
  202. package/dist/src/pi-extension/context-shake.js +54 -21
  203. package/dist/src/pi-extension/context-window.js +37 -0
  204. package/dist/src/pi-extension/full-output.js +2 -2
  205. package/dist/src/pi-extension/strip-thinking.js +80 -0
  206. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  207. package/dist/src/process/launch-services.js +11 -10
  208. package/dist/src/process/process-table.js +1 -1
  209. package/dist/src/process/process.js +7 -7
  210. package/dist/src/process/run-marker.js +29 -6
  211. package/dist/src/process/supervisor.js +36 -25
  212. package/dist/src/prompt/dialect.js +30 -10
  213. package/dist/src/prompt/gate-prompts.js +170 -67
  214. package/dist/src/prompt/principles.js +1 -0
  215. package/dist/src/prompt/prompt-followup.js +29 -20
  216. package/dist/src/prompt/prompt.js +249 -91
  217. package/dist/src/redeploy/redeploy-policy.js +10 -10
  218. package/dist/src/redeploy/redeploy.js +6 -4
  219. package/dist/src/redeploy/redeployer.js +63 -55
  220. package/dist/src/redeploy/self-reload.js +1 -1
  221. package/dist/src/report/report-data.js +259 -254
  222. package/dist/src/report/report-fold.js +353 -0
  223. package/dist/src/report/report-render.js +235 -44
  224. package/dist/src/request-timeouts.js +1 -1
  225. package/dist/src/review/known-flakes.js +34 -14
  226. package/dist/src/review/protected-edits.js +4 -3
  227. package/dist/src/review/review-followup.js +16 -4
  228. package/dist/src/review/review-precheck.js +14 -13
  229. package/dist/src/review/review-verdict.js +14 -0
  230. package/dist/src/review/review.js +55 -34
  231. package/dist/src/review/secret-leaks.js +127 -0
  232. package/dist/src/review/suite-rerun.js +4 -3
  233. package/dist/src/roles/legacy-role-ids.js +21 -0
  234. package/dist/src/roles/legacy-role-migration.js +62 -0
  235. package/dist/src/roles/loop-ids.js +19 -2
  236. package/dist/src/roles/role-catalog.js +109 -131
  237. package/dist/src/roles/role-guidance.js +64 -31
  238. package/dist/src/roles/role-labels.js +10 -0
  239. package/dist/src/roles/role-render.js +194 -18
  240. package/dist/src/roles/role-view.js +216 -17
  241. package/dist/src/roles/roles.js +16 -14
  242. package/dist/src/scheduling/backoff.js +29 -19
  243. package/dist/src/scheduling/claims.js +15 -8
  244. package/dist/src/scheduling/once-round.js +11 -11
  245. package/dist/src/scheduling/quiet-hours.js +25 -32
  246. package/dist/src/scheduling/scheduling.js +28 -25
  247. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  248. package/dist/src/status/status-budget.js +115 -0
  249. package/dist/src/status/status-data.js +72 -184
  250. package/dist/src/status/status-inbox.js +47 -0
  251. package/dist/src/status/status-install.js +27 -0
  252. package/dist/src/status/status-polls.js +50 -39
  253. package/dist/src/status/status-roles.js +105 -0
  254. package/dist/src/{fleet → team}/error-storm.js +10 -10
  255. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  256. package/dist/src/{fleet → team}/orchestrator-info.js +84 -18
  257. package/dist/src/{fleet → team}/reclaim.js +5 -4
  258. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  259. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  260. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +39 -38
  261. package/dist/src/text/datetime.js +42 -2
  262. package/dist/src/text/format.js +10 -1
  263. package/dist/src/text/markdown.js +1 -1
  264. package/dist/src/text/phrases.js +35 -34
  265. package/dist/src/text/suggest.js +3 -2
  266. package/dist/src/text/text.js +2 -2
  267. package/dist/src/tick/qa-coverage.js +2 -1
  268. package/dist/src/tick/stage-check.js +142 -25
  269. package/dist/src/tick/telemetry-digest.js +10 -9
  270. package/dist/src/tick/tick-apply.js +101 -72
  271. package/dist/src/tick/tick-detail-data.js +23 -22
  272. package/dist/src/tick/tick-detail.js +37 -24
  273. package/dist/src/tick/tick-finalize.js +29 -22
  274. package/dist/src/tick/tick-outcome.js +2 -1
  275. package/dist/src/tick/tick-prompt.js +94 -26
  276. package/dist/src/tick/tick-resume.js +29 -13
  277. package/dist/src/tick/tick-stage.js +67 -53
  278. package/dist/src/tick/tick-timing.js +60 -59
  279. package/dist/src/tick/tick-usage.js +53 -23
  280. package/dist/src/tick/tick-verdict.js +75 -10
  281. package/dist/src/ui/badges.js +113 -69
  282. package/dist/src/ui/gui/gui-client-boot.js +202 -29
  283. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  284. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  285. package/dist/src/ui/gui/gui-client-history.js +94 -37
  286. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  287. package/dist/src/ui/gui/gui-client-loops.js +90 -47
  288. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  289. package/dist/src/ui/gui/gui-client-model.js +60 -43
  290. package/dist/src/ui/gui/gui-client-operator.js +73 -43
  291. package/dist/src/ui/gui/gui-client-pending.js +34 -31
  292. package/dist/src/ui/gui/gui-client-report.js +330 -47
  293. package/dist/src/ui/gui/gui-client-settings.js +126 -18
  294. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  295. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
  296. package/dist/src/ui/gui/gui-client.js +26 -20
  297. package/dist/src/ui/gui/gui-page.js +18 -19
  298. package/dist/src/ui/gui/gui-styles.js +12 -4
  299. package/dist/src/ui/progress-data.js +5 -3
  300. package/dist/src/ui/status-model.js +176 -102
  301. package/dist/src/ui/status-payload.js +114 -36
  302. package/dist/src/ui/status-render.js +121 -52
  303. package/dist/src/ui/team-alerts.js +341 -0
  304. package/dist/src/ui/tick-progress-model.js +14 -14
  305. package/dist/src/ui/tone.js +9 -8
  306. package/dist/src/ui/transcript-tail.js +1 -0
  307. package/dist/src/ui/transcript.js +23 -16
  308. package/dist/src/ui/tui/tui-app.js +1 -0
  309. package/dist/src/ui/tui/tui-backlog.js +85 -11
  310. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  311. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  312. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  313. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  314. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  315. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  316. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  317. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  318. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  319. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  320. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  321. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  322. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  323. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  324. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  325. package/dist/src/ui/tui/tui-editors.js +152 -0
  326. package/dist/src/ui/tui/tui-frame.js +61 -57
  327. package/dist/src/ui/tui/tui-history.js +12 -7
  328. package/dist/src/ui/tui/tui-input.js +44 -50
  329. package/dist/src/ui/tui/tui-keymap.js +1 -0
  330. package/dist/src/ui/tui/tui-keys.js +87 -1821
  331. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  332. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  333. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  334. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  335. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  336. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  337. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  338. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  339. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  340. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  341. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  342. package/dist/src/ui/tui/tui-pane.js +52 -28
  343. package/dist/src/ui/tui/tui-panes.js +294 -0
  344. package/dist/src/ui/tui/tui.js +44 -26
  345. package/dist/src/verdict/fix-claim.js +56 -33
  346. package/dist/src/verdict/no-change.js +19 -0
  347. package/dist/src/verdict/refusal.js +43 -11
  348. package/dist/src/verdict/reply-contract.js +17 -5
  349. package/dist/src/version.js +3 -2
  350. package/package.json +4 -2
  351. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  352. package/dist/src/landing/backlog-conflicts.js +0 -116
  353. package/dist/src/landing/landing-drain.js +0 -204
  354. package/dist/src/landing/landing-pipeline.js +0 -96
  355. package/dist/src/landing/landing-queue-render.js +0 -56
  356. package/dist/src/landing/landing-slot.js +0 -256
  357. package/dist/src/landing/landing-vetting.js +0 -196
  358. package/dist/src/pi-extension/role-notes.js +0 -40
  359. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -0,0 +1,184 @@
1
+ // vocabulary: plain
2
+ /** The data half of OpenCode free-ness (plans/opencode-backend.md §7; part 6a/7): a bounded
3
+ * fetch of models.dev's `api.json` — the catalog OpenCode itself caches (finding 10) — a parse
4
+ * that keeps only the providers the config routes through OpenCode, a cache at
5
+ * `.tumwater/state/opencode-models.json` that survives a failed fetch, and
6
+ * `opencodeModelFree` as the one predicate part 6b/7's seam reads. No seam is wired here: that
7
+ * is part 6b/7 (the budget gate), part 6c/7 (a billedAt demotion on an observed cost) and
8
+ * part 7/7 (a startup-cache seed and doctor), so this module's only callers today are its own
9
+ * tests. */
10
+ import { spawnSync } from "node:child_process";
11
+ import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
12
+ import { opencodeModelsPath } from "../../paths.js";
13
+ import { logEventBestEffort } from "../../events/events.js";
14
+ import { opencodeQualifiedModel } from "./opencode-args.js";
15
+ /** models.dev's catalog, the data OpenCode caches in its database `kv` table (finding 10). */
16
+ const CATALOG_URL = "https://models.dev/api.json";
17
+ /** The fetch bound (finding 10; the plan's 30 s): OpenCode prices from the same endpoint at
18
+ * most once a day, so a slow or wedged network must not hold the team up. */
19
+ const CATALOG_FETCH_TIMEOUT_MS = 30_000;
20
+ /** How long a fetched cache is trusted before it is refreshed. */
21
+ const CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
22
+ const defaultFetch = async (url, timeoutMs) => {
23
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
24
+ if (!res.ok)
25
+ throw new Error(`models.dev catalog ${res.status} ${res.statusText}`);
26
+ return res.json();
27
+ };
28
+ /** GET models.dev's catalog, bounded at 30 s. Returns the parsed JSON body, or the injected
29
+ * (or default) fetch throws on a failure. So a spare `opencode` binary is never needed just to
30
+ * cable this up; the version check that forces a refresh lives in refreshOpencodeCatalog. */
31
+ export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
32
+ return fetchFn(CATALOG_URL, CATALOG_FETCH_TIMEOUT_MS);
33
+ }
34
+ /** Parse models.dev's `api.json` into the models the config routes through OpenCode: always the
35
+ * `opencode` provider, plus every provider named in `providers` (the config's
36
+ * `opencode/<provider>/…` routes — `opencode-go` and so on). The live `GET /api.json` returns
37
+ * the provider map at the top level; OpenCode's cached copy wraps that same map under a `body`
38
+ * key (the 2.0.32 capture in test/fixtures), so both shapes are accepted. Each kept model is
39
+ * keyed `<provider>/<model>`. A model never present yields no entry, so an unknown model is
40
+ * automatically "not free" to opencodeModelFree. */
41
+ export function parseOpencodeCatalog(body, providers) {
42
+ const out = {};
43
+ const root = body;
44
+ // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
45
+ const wrapped = root?.body;
46
+ const catalog = typeof wrapped === "object" && wrapped !== null && !Array.isArray(wrapped)
47
+ ? wrapped
48
+ : (body ?? {});
49
+ for (const [provider, providerRaw] of Object.entries(catalog)) {
50
+ if (provider !== "opencode" && !providers.includes(provider))
51
+ continue;
52
+ const providerInfo = providerRaw;
53
+ if (providerInfo?.models === undefined)
54
+ continue;
55
+ for (const [id, raw] of Object.entries(providerInfo.models)) {
56
+ const m = raw;
57
+ // Only a present cost block marks a model free: a missing cost block is unknown price data,
58
+ // and treating it as $0 is the unsafe direction for a budget gate.
59
+ const cost = m?.cost;
60
+ const input = cost?.input ?? 0;
61
+ const output = cost?.output ?? 0;
62
+ const model = { free: cost !== undefined && input === 0 && output === 0 };
63
+ if (cost !== undefined) {
64
+ model.inputPerM = input;
65
+ model.outputPerM = output;
66
+ }
67
+ if (m?.limit?.context !== undefined)
68
+ model.context = m.limit.context;
69
+ if (m?.modalities?.input !== undefined)
70
+ model.images = m.modalities.input.includes("image");
71
+ const efforts = m?.reasoning_options
72
+ ?.filter((r) => r.type === "effort")
73
+ .flatMap((r) => r.values ?? []);
74
+ if (efforts !== undefined && efforts.length > 0)
75
+ model.efforts = efforts;
76
+ out[`${provider}/${id}`] = model;
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** True when the catalog says the model is free and no run has marked it billed (part 6c/7).
82
+ * A model missing from the cache, a priced one, and a free one with `billedAt` set are all not
83
+ * free — the safe direction for a budget gate. */
84
+ export function opencodeModelFree(cache, model) {
85
+ const entry = cache.models[model];
86
+ return entry !== undefined && entry.free && entry.billedAt === undefined;
87
+ }
88
+ /** The catalog cache key for an opencode run's resolved selector: the `<provider>/<model>`
89
+ * form part 6a/7 keys the cache by. Delegates the bare-gateway-prefix rule to
90
+ * opencode-args.ts's opencodeQualifiedModel (the single home), so the key a run is priced by
91
+ * always matches the id a run is invoked by. */
92
+ export function opencodeModelKey(selector) {
93
+ return opencodeQualifiedModel(selector);
94
+ }
95
+ /** Demote a catalog-free model the moment a run bills it (part 6c/7): when a run reports a
96
+ * nonzero cost on a model the cache calls free, mark that entry priced (`billedAt`) in the
97
+ * cache and log `opencode_free_model_billed` {model, costUsd} once. A subsequent priced run on
98
+ * the same model sees `billedAt` already set and stays silent, and the next
99
+ * `opencodeModelFree` answer for it is false. A run reporting $0, a priced model, or an
100
+ * unknown model leaves the cache untouched — a $0 run never demotes (missing and priced
101
+ * models are already "not free" to opencodeModelFree). Best-effort like observeClaudeAccount:
102
+ * the caller wraps the write/log in try/catch so an unwritable cache or feed never fails the
103
+ * run that observed it. Returns the cache after the observation, or null when nothing changed. */
104
+ export function observeOpencodeRun(root, run, opts) {
105
+ const cache = readOpencodeCache(root);
106
+ const entry = cache.models[run.model];
107
+ if (entry === undefined || !entry.free || entry.billedAt !== undefined || run.costUsd <= 0) {
108
+ return null;
109
+ }
110
+ entry.billedAt = opts.now !== undefined ? opts.now() : Date.now();
111
+ writeOpencodeCache(root, cache);
112
+ const log = opts.log !== undefined ? opts.log : (e) => logEventBestEffort(root, e);
113
+ log({ source: opts.source, type: "opencode_free_model_billed", model: run.model, costUsd: run.costUsd });
114
+ return cache;
115
+ }
116
+ /** Read the cached catalog, or an empty one (fetchedAt 0, no models) when the file is missing
117
+ * or malformed — the harness's no-data policy, so a torn write reads as "needs a refresh".
118
+ * Exported so the opencode backend's adapter (part 6d/7) and doctor can read the same cache
119
+ * without triggering a network fetch. The synchronous read the sync poll surfaces
120
+ * (pollBudgetGate, collectBudget) use to thread the catalog-cache into their free-ness reads
121
+ * (part 6b/7); the async refresh (which runs once per process at the startup gate) is what
122
+ * fills the file in. */
123
+ export function readOpencodeCache(root) {
124
+ const existing = readJsonFile(opencodeModelsPath(root));
125
+ if (existing !== null && typeof existing.fetchedAt === "number" && existing.models !== undefined && typeof existing.models === "object" && !Array.isArray(existing.models)) {
126
+ return {
127
+ fetchedAt: existing.fetchedAt,
128
+ cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
129
+ models: existing.models,
130
+ };
131
+ }
132
+ return { fetchedAt: 0, models: {} };
133
+ }
134
+ /** Persist the catalog cache atomically under `.tumwater/state/`. */
135
+ export function writeOpencodeCache(root, cache) {
136
+ writeJsonAtomic(opencodeModelsPath(root), cache);
137
+ }
138
+ /** Read the installed opencode CLI's version, undefined when it cannot be read (no binary, a
139
+ * timeout, or a spawn failure). A changed version forces a catalog refresh: OpenCode serves from
140
+ * the same models.dev data, so a new CLI may route to providers the old cache does not know.
141
+ * `bin` is the configured opencode binary (default `opencode` on PATH), so the seed probes the
142
+ * same binary the runs spawn. */
143
+ function readCliVersion(bin = "opencode") {
144
+ try {
145
+ const res = spawnSync(bin, ["--version"], { stdio: "pipe", encoding: "utf8", timeout: 5_000 });
146
+ const version = res.stdout?.trim();
147
+ return version === undefined || version === "" ? undefined : version;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ /** Return a cache fresh enough to price by, fetching and persisting when the current one is
154
+ * missing, stale (older than 24 h) or fetched by a different `opencode --version`. A failed
155
+ * fetch keeps the old cache (logging one warning) so the team still prices by the last good
156
+ * data; `billedAt` from the old cache survives a successful refresh (part 6c/7). */
157
+ export async function refreshOpencodeCatalog(root, opts) {
158
+ const now = opts.now !== undefined ? opts.now() : Date.now();
159
+ const version = opts.cliVersion !== undefined ? opts.cliVersion : readCliVersion(opts.cliBin);
160
+ const current = readOpencodeCache(root);
161
+ const fresh = current.fetchedAt !== 0 &&
162
+ now - current.fetchedAt < CATALOG_CACHE_TTL_MS &&
163
+ (version === undefined || current.cliVersion === version);
164
+ if (fresh)
165
+ return current;
166
+ let body;
167
+ try {
168
+ body = await fetchOpencodeCatalog(opts.fetch);
169
+ }
170
+ catch {
171
+ opts.warn?.(`open code model catalog fetch failed; keeping the cached copy`);
172
+ return current.fetchedAt === 0 ? { fetchedAt: 0, cliVersion: version, models: {} } : current;
173
+ }
174
+ const parsed = parseOpencodeCatalog(body, opts.providers);
175
+ for (const key of Object.keys(parsed)) {
176
+ const fresh = parsed[key];
177
+ const old = current.models[key];
178
+ if (fresh !== undefined && old?.billedAt !== undefined)
179
+ fresh.billedAt = old.billedAt;
180
+ }
181
+ const cache = { fetchedAt: now, cliVersion: version, models: parsed };
182
+ writeOpencodeCache(root, cache);
183
+ return cache;
184
+ }
@@ -0,0 +1,188 @@
1
+ // vocabulary: plain
2
+ /** Building one OpenCode run's injected config (`OPENCODE_CONFIG_CONTENT`) (plans/opencode-
3
+ * backend.md "The invocation") — pure JSON construction, with no subprocess. The config
4
+ * applies after the operator's global and project files, so only managed config wins over it:
5
+ * the gate-safe isolation switches (snapshots off, updates off, six denials), `enabled →
6
+ * disabled: true` for every operator MCP server (config merges, so disabling is the only way
7
+ * to remove one), and an `instructions` entry mirroring claude's AGENTS.md handling. No
8
+ * tool is disabled beyond the six gate-safe denials — denying `read` trips the free-tier gate
9
+ * (finding 11). Part 5/7 wires this string into the run's environment.
10
+ *
11
+ * From part 3/4 tumwater no longer adds an MCP server of its own: run notes ride the
12
+ * `tumwater-notes` command on PATH, so every operator MCP server is disabled alike — an
13
+ * operator server named `tumwater` is disabled like any other (a later config replaces a
14
+ * server object whole, design decision 8 and plans/opencode-backend.md "The invocation"). No
15
+ * XDG/config files are read here except the operator's global opencode config, and that only
16
+ * to enumerate its MCP server names (operatorMcpNames): a broken or missing file yields `[]`
17
+ * here, and doctor reports it in part 7/7. */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ /** The operator's global opencode config lives in `$XDG_CONFIG_HOME/opencode/` (or
21
+ * `~/.config/opencode/`), in `config.json`, `opencode.json` or `opencode.jsonc`. */
22
+ const OPERATOR_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"];
23
+ /** The six gate-safe denials (plans/opencode-backend.md "The invocation", findings 11–12).
24
+ * `read` is never denied — it trips the free-tier gate. `shell`, `glob`, `grep`, `edit` and
25
+ * `write` stay allowed for parity with pi. */
26
+ const DENIED_PERMISSIONS = [
27
+ { action: "execute", resource: "*", effect: "deny" },
28
+ { action: "subagent", resource: "*", effect: "deny" },
29
+ { action: "webfetch", resource: "*", effect: "deny" },
30
+ { action: "websearch", resource: "*", effect: "deny" },
31
+ { action: "question", resource: "*", effect: "deny" },
32
+ { action: "skill", resource: "*", effect: "deny" },
33
+ ];
34
+ /** True at `i` when a `//` line comment or a `/*`-style block comment starts there. */
35
+ function isCommentStart(src, i) {
36
+ return src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*");
37
+ }
38
+ /** Advance `j` past any whitespace and comments, for a position that is never inside a string.
39
+ * Returns the index of the next significant (non-comment, non-whitespace) character, or
40
+ * `src.length` when the input ends in comments/whitespace. */
41
+ function skipWhitespaceAndComments(src, j) {
42
+ while (j < src.length) {
43
+ const c = src[j];
44
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
45
+ j += 1;
46
+ continue;
47
+ }
48
+ if (c === "/" && src[j + 1] === "/") {
49
+ while (j < src.length && src[j] !== "\n")
50
+ j += 1;
51
+ continue;
52
+ }
53
+ if (c === "/" && src[j + 1] === "*") {
54
+ j += 2;
55
+ while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
56
+ j += 1;
57
+ j += 2;
58
+ continue;
59
+ }
60
+ break;
61
+ }
62
+ return j;
63
+ }
64
+ /** Strip comments and trailing commas from a JSONC string so a strict JSON parse succeeds.
65
+ * Walks the string so a `//` or a block-comment opener, and `,}` / `,]`, inside a normal
66
+ * double-quoted string value survive (escape sequences included); comments and trailing
67
+ * commas are removed only outside strings, where they are legal JSONC but not JSON. A
68
+ * trailing comma is dropped when the next significant character — past any whitespace AND
69
+ * any comment — is a closing brace or bracket, so a comma then a comment then `}` strips
70
+ * correctly. */
71
+ function stripJsonc(src) {
72
+ let out = "";
73
+ let inString = false;
74
+ let i = 0;
75
+ while (i < src.length) {
76
+ const c = src[i];
77
+ const next = src[i + 1];
78
+ if (inString) {
79
+ out += c;
80
+ if (c === "\\" && next !== undefined) {
81
+ out += next;
82
+ i += 2;
83
+ }
84
+ else {
85
+ if (c === '"')
86
+ inString = false;
87
+ i += 1;
88
+ }
89
+ continue;
90
+ }
91
+ if (c === '"') {
92
+ inString = true;
93
+ out += c;
94
+ i += 1;
95
+ continue;
96
+ }
97
+ if (isCommentStart(src, i)) {
98
+ i = skipWhitespaceAndComments(src, i);
99
+ continue;
100
+ }
101
+ if (c === ",") {
102
+ // A trailing comma may be followed by whitespace and comments before the closing
103
+ // brace/bracket (`,\n // c\n }`); look past all of it to decide, without touching a
104
+ // comma inside a string value.
105
+ const j = skipWhitespaceAndComments(src, i + 1);
106
+ if (src[j] === "}" || src[j] === "]") {
107
+ i += 1;
108
+ continue;
109
+ }
110
+ }
111
+ out += c;
112
+ i += 1;
113
+ }
114
+ return out;
115
+ }
116
+ /** Read the MCP server names in the operator's global opencode config. Each name is a server
117
+ * the managed config must explicitly disable (config merges, so it cannot be removed). Reads
118
+ * both config shapes: the keys of V2's `mcp.servers` plus V1-shaped `mcp.<name>` keys other
119
+ * than `servers`. Returns `[]` for a missing file, an unreadable file, or one that does not
120
+ * parse; doctor reports a broken one in part 7/7. `homeDir` is the operator's home directory;
121
+ * the process's own `XDG_CONFIG_HOME` env, when set, wins over `~/.config` (the XDG default). */
122
+ export function operatorMcpNames(env, homeDir) {
123
+ const configHome = env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
124
+ const dir = path.join(configHome, "opencode");
125
+ for (const file of OPERATOR_CONFIG_FILES) {
126
+ let text;
127
+ try {
128
+ text = fs.readFileSync(path.join(dir, file), "utf8");
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(file.endsWith(".jsonc") ? stripJsonc(text) : text);
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ const mcp = parsed?.mcp;
141
+ if (mcp === undefined || mcp === null)
142
+ return [];
143
+ if (typeof mcp !== "object" || Array.isArray(mcp))
144
+ return [];
145
+ const names = new Set();
146
+ const servers = mcp.servers;
147
+ if (typeof servers === "object" && servers !== null && !Array.isArray(servers)) {
148
+ for (const key of Object.keys(servers))
149
+ names.add(key);
150
+ }
151
+ // V1-shaped `mcp.<name>` keys, other than the V2 `servers` grouping.
152
+ for (const key of Object.keys(mcp)) {
153
+ if (key !== "servers")
154
+ names.add(key);
155
+ }
156
+ return [...names];
157
+ }
158
+ return [];
159
+ }
160
+ /** True when the worktree has a `CLAUDE.md` and no `AGENTS.md`: OpenCode reads `AGENTS.md`
161
+ * only, so the managed config injects `CLAUDE.md` — but only when the repo has no `AGENTS.md`,
162
+ * exactly the mirror of claude's `shouldAppendAgents`. */
163
+ function shouldInjectClaudeMd(cwd) {
164
+ return fs.existsSync(path.join(cwd, "CLAUDE.md")) && !fs.existsSync(path.join(cwd, "AGENTS.md"));
165
+ }
166
+ /** Build the injected opencode config JSON string. Never reads the worktree beyond the
167
+ * CLAUDE.md/AGENTS.md probe. */
168
+ export function opencodeConfigContent(opts) {
169
+ const config = {
170
+ snapshots: false,
171
+ update: "disable",
172
+ permissions: DENIED_PERMISSIONS,
173
+ };
174
+ if (opts.toolOutput !== undefined) {
175
+ config.tool_output = { max_lines: opts.toolOutput.maxLines, max_bytes: opts.toolOutput.maxBytes };
176
+ }
177
+ const servers = {};
178
+ for (const name of opts.operatorMcpNames) {
179
+ // Every operator server is disabled alike: an operator server named `tumwater` gets no
180
+ // special treatment now that tumwater ships no MCP server of its own (part 3/4).
181
+ servers[name] = { disabled: true };
182
+ }
183
+ if (Object.keys(servers).length > 0)
184
+ config.mcp = { servers };
185
+ if (shouldInjectClaudeMd(opts.cwd))
186
+ config.instructions = ["CLAUDE.md"];
187
+ return JSON.stringify(config);
188
+ }
@@ -0,0 +1,36 @@
1
+ // vocabulary: plain
2
+ /** Building the environment for one opencode run (plans/opencode-backend.md "The invocation")
3
+ * — pure scrub-and-set over a base environment, with no subprocess. The caller layers the
4
+ * run-marker and the macOS preload on first (claude's adapter shows the pie pattern), then
5
+ * hands the result here alongside the injected config and the session database path.
6
+ *
7
+ * Inherited OpenCode session variables are scrubbed (every `OPENCODE_*` except the operator's
8
+ * `OPENCODE_API_KEY`, plus the bare `OPENCODE` and `AI_AGENT`): a run spawned from inside an
9
+ * OpenCode session must not attach to that session's server, config or database, and `AI_AGENT`
10
+ * must not leak so the shell tool sets its own (finding 16). The set half disables Claude Code
11
+ * interop and its skills, auto-update and the file watcher, and isolates the run to its own
12
+ * database and injected config.
13
+ *
14
+ * No shell-timeout variable exists in 2.0.32 (finding 16), so none is set; the dialect tells
15
+ * the model to pass `timeout` for the check (§5). `XDG_CONFIG_HOME` and its siblings are NOT
16
+ * set: OpenCode's shell-tool children inherit the environment, and pointing XDG at a private
17
+ * dir would stop `git` reading `~/.config/git/config`, losing commits' author identity (§3). */
18
+ /** Build the opencode process environment from `base`. Never mutates `base`. */
19
+ export function opencodeEnv(base, opts) {
20
+ const env = {};
21
+ for (const [key, value] of Object.entries(base)) {
22
+ // Everything an inherited OpenCode session set is scrubbed — server, config, database,
23
+ // client and session id — except the operator's API key, which is operator intent and must
24
+ // survive. `AI_AGENT` goes too, so the shell tool sets its own (finding 16).
25
+ if (key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT")
26
+ continue;
27
+ env[key] = value;
28
+ }
29
+ env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
30
+ env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
31
+ env.OPENCODE_DISABLE_AUTOUPDATE = "1";
32
+ env.OPENCODE_DISABLE_FILEWATCHER = "1";
33
+ env.OPENCODE_DB = opts.dbPath;
34
+ env.OPENCODE_CONFIG_CONTENT = opts.configContent;
35
+ return env;
36
+ }
@@ -0,0 +1,37 @@
1
+ // vocabulary: plain
2
+ /** Minting an OpenCode session id (plans/opencode-backend.md "Sessions", finding 3) — a pure
3
+ * reproduction of OpenCode's `Identifier.descending()` (packages/schema/src/identifier.ts), so
4
+ * a fresh run's `--session` id is accepted by the free-tier gate. A `ses_…` id in any other
5
+ * shape is refused with a 403 (finding 3), so this must match OpenCode exactly.
6
+ *
7
+ * The id is `ses_` + 12 hex digits + 14 base-62 characters. The hex is the low 48 bits of the
8
+ * bitwise NOT of (Unix milliseconds × 4096 + a per-millisecond counter), big-endian; the 14
9
+ * characters are drawn from `0-9A-Za-z` one random byte each. */
10
+ import crypto from "node:crypto";
11
+ /** The 62-character alphabet OpenCode draws a session id's trailing characters from. */
12
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
13
+ /** Per-process minting state, mirroring OpenCode's per-millisecond counter: the counter
14
+ * restarts at 1 for each new millisecond, and increments within the same millisecond so two
15
+ * ids minted in one tick still differ in their hex. */
16
+ let lastMintMs = -1;
17
+ let counter = 0;
18
+ /** Mint an OpenCode session id. `now` is a Unix-millisecond epoch (injected for tests);
19
+ * `randomBytes` supplies the 14 base-62 characters (defaults to `crypto.randomBytes`, so the
20
+ * trailing part is random, never guessed from the visible prefix). */
21
+ export function mintOpencodeSessionId(now = Date.now(), randomBytes = (n) => crypto.randomBytes(n)) {
22
+ if (now === lastMintMs)
23
+ counter += 1;
24
+ else {
25
+ lastMintMs = now;
26
+ counter = 1;
27
+ }
28
+ // `Identifier.descending()`: current = now × 4096 + counter, then the low 48 bits of ~current.
29
+ const current = BigInt(now) * 0x1000n + BigInt(counter);
30
+ const hex = ((~current) & 0xffffffffffffn).toString(16).padStart(12, "0");
31
+ const bytes = randomBytes(14);
32
+ let suffix = "";
33
+ for (let i = 0; i < 14; i += 1) {
34
+ suffix += ALPHABET[bytes[i] % 62];
35
+ }
36
+ return `ses_${hex}${suffix}`;
37
+ }