tumwater 0.1.2 → 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 (361) hide show
  1. package/README.md +74 -108
  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-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -0,0 +1,255 @@
1
+ // vocabulary: plain
2
+ /** The opencode adapter for the agent seam (plans/opencode-backend.md "The invocation",
3
+ * "Sessions", §6). It shapes one run's argv/stdin/env, translates opencode's `run --format
4
+ * json` into the canonical stream, runs the authoritative session export after the process
5
+ * exits, and classifies the exit's stderr — everything backend-specific. src/agent/run-agent.ts
6
+ * owns the spawn and records this run in the session ledger; opencode mints its own session id
7
+ * (a `ses_…` id, or the resumed record's id), which `invocation` returns so the ledger can
8
+ * carry it.
9
+ *
10
+ * Sessions resume by id from the cwd the session started in, out of the run's shared database
11
+ * (opencodeDbPath). A continue takes the ledger's newest record of this run's kind and resumes
12
+ * only when it is opencode's, has this cwd, and `opencode session export <id> --standalone`
13
+ * actually finds it — `--session` silently creates a missing session, so the existence check is
14
+ * the only thing that stops a follow-up running against an empty session. Anything else mints a
15
+ * fresh id (design §4).
16
+ *
17
+ * Registration lives in src/agent/run-agent.ts beside the pi and claude adapters; a test selects
18
+ * opencode by setting `config.agent = "opencode"` on a run. A run carrying a notebook gets the
19
+ * notes path in its environment (the `tumwater-notes` command on PATH reads it), as the pi and
20
+ * claude adapters do. */
21
+ import { spawnSync } from "node:child_process";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { loadConfigSafe } from "../../config/config.js";
25
+ import { resolvedAgentConfig } from "../../config/config-views.js";
26
+ import { agentBaseEnv, withNotesPath } from "../../process/run-marker.js";
27
+ import { resolveBackendBin } from "../../pi/pi-bin.js";
28
+ import { opencodeDbPath, teamRootOf } from "../../paths.js";
29
+ import { resumableSession } from "../session-ledger.js";
30
+ import { opencodeArgs } from "./opencode-args.js";
31
+ import { opencodeConfigContent, operatorMcpNames } from "./opencode-config.js";
32
+ import { outputLimits } from "../../pi-extension/bounded-output.js";
33
+ import { seamContextWindow } from "../../pi/pi-models.js";
34
+ import { opencodeModelKey, readOpencodeCache } from "./opencode-catalog.js";
35
+ import { opencodeEnv } from "./opencode-env.js";
36
+ import { mintOpencodeSessionId } from "./opencode-session-id.js";
37
+ import { opencodeExportLines, opencodeTranslator } from "./opencode-stream.js";
38
+ /** How long a post-run `opencode session export` may take before it gives up (design §6): the
39
+ * export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. Used for
40
+ * both the resume-existence check and the reconciling export. */
41
+ const EXPORT_TIMEOUT_MS = 10_000;
42
+ /** The minimum OpenCode CLI version this harness accepts (plans/opencode-backend.md "Which
43
+ * OpenCode", part 7/7): 2.0.32, the version H1 verified live. V1 is maintenance-only and has
44
+ * the git-repo hang (issue 55037), so doctor fails an installed CLI below this and names
45
+ * `brew install opencode`. */
46
+ export const OPENCODE_MIN_VERSION = "2.0.32";
47
+ /** The repository's root (initial) commit hash, or undefined when git cannot answer (not a
48
+ * repo, or git missing). `git rev-list --max-parents=0 HEAD` is the same read OpenCode makes
49
+ * to key its shell spill by the tree's root commit. Read-only. */
50
+ function rootCommitOf(root) {
51
+ try {
52
+ const res = spawnSync("git", ["-C", root, "rev-list", "--max-parents=0", "HEAD"], {
53
+ encoding: "utf8",
54
+ timeout: 5_000,
55
+ stdio: ["ignore", "pipe", "ignore"],
56
+ });
57
+ const line = res.stdout?.trim();
58
+ return line !== undefined && /^[0-9a-f]{40}$/i.test(line) ? line : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ /** The tree's shell spill dir OpenCode writes (finding 17, part 7/7):
65
+ * `~/.local/share/opencode/shell/<root commit>/`, keyed by the repository's root commit hash and
66
+ * OUTSIDE `.tumwater/`, so only a `session delete` or an explicit spill prune reaches it. Empty
67
+ * string when the root commit cannot be read — callers then skip spill work (doctor reports no
68
+ * spill dir; retention prunes no spill files). */
69
+ export function opencodeSpillDir(root) {
70
+ const commit = rootCommitOf(root);
71
+ if (commit === undefined)
72
+ return "";
73
+ return path.join(os.homedir(), ".local", "share", "opencode", "shell", commit);
74
+ }
75
+ /** The catalog cache key for a run's resolved model id (part 6d/7): a bare gateway id gets the
76
+ * `opencode/` provider prefix, a provider-qualified id passes as is — the same rule
77
+ * opencodeArgs uses to build the `--model` arg and opencode-catalog.ts uses to key the cache,
78
+ * so an invocation's lookup always matches the key a run is priced and billed by. */
79
+ function catalogModelKey(model) {
80
+ return opencodeModelKey(model);
81
+ }
82
+ /** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
83
+ * 10 s) and return whether it reported success — the existence check that decides a resume.
84
+ * Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
85
+ * direction: `--session` would otherwise silently create a missing session. */
86
+ function opencodeSessionExists(id, env, bin, cwd) {
87
+ try {
88
+ const res = spawnSync(bin, ["session", "export", id, "--standalone"], {
89
+ env,
90
+ cwd,
91
+ timeout: EXPORT_TIMEOUT_MS,
92
+ stdio: "ignore",
93
+ });
94
+ return res.status === 0;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /** Run the reconciling export after a successful run and return its two canonical lines
101
+ * (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
102
+ * timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
103
+ * (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
104
+ function opencodeExport(result) {
105
+ let res;
106
+ try {
107
+ res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
108
+ env: result.env,
109
+ cwd: result.cwd,
110
+ timeout: EXPORT_TIMEOUT_MS,
111
+ encoding: "utf8",
112
+ });
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ if (res.status !== 0 || !res.stdout)
118
+ return [];
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(res.stdout);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ return opencodeExportLines(parsed);
127
+ }
128
+ /** The session id this run uses: for a continue, the ledger's newest record of this kind when it
129
+ * is opencode's, has this cwd, and the export finds the session; otherwise a freshly minted id.
130
+ * Returns the id; the runner records it in the ledger. */
131
+ function resolveOpencodeSession(run, env) {
132
+ if (run.continueSession) {
133
+ const record = resumableSession(run.sessionDir, {
134
+ kind: run.kind,
135
+ agent: "opencode",
136
+ cwd: run.cwd,
137
+ });
138
+ if (record !== null && opencodeSessionExists(record.id, env, run.agentBin, run.cwd)) {
139
+ return { sessionId: record.id };
140
+ }
141
+ }
142
+ return { sessionId: mintOpencodeSessionId() };
143
+ }
144
+ /** Classify opencode's stderr: on a nonzero exit, a free-tier refusal is a provider failure
145
+ * (moves the job to its fallback) and an unknown model or variant is a permanent config error.
146
+ * A run the harness killed has its own cause and is never classified here. The structured
147
+ * signals (the stream's `error` lines) are classified by the translator; this is the stderr
148
+ * half for a CLI that died before emitting one. */
149
+ function classifyOpencodeExit(info) {
150
+ if (info.killed)
151
+ return {};
152
+ if (info.code !== 0 && /FreeTierError/i.test(info.stderr)) {
153
+ return { transientBackend: true, backendKind: "server" };
154
+ }
155
+ if (info.code !== 0 && /Model unavailable|Variant unavailable/i.test(info.stderr)) {
156
+ return { configError: true };
157
+ }
158
+ return {};
159
+ }
160
+ /** The agent-backend seam's opencode adapter. */
161
+ export const opencodeBackend = {
162
+ id: "opencode",
163
+ label: "OpenCode",
164
+ defaultBin: "opencode",
165
+ binEnv: "TUMWATER_OPENCODE_BIN",
166
+ resolveBin(config) {
167
+ return resolveBackendBin(config, this);
168
+ },
169
+ invocation(run) {
170
+ // The shared database and injected config are per-root, built the same way for every run of
171
+ // this team so a resume's export check reads the same OPENCODE_DB the original run wrote.
172
+ const root = teamRootOf(run.sessionDir);
173
+ const dbPath = opencodeDbPath(root);
174
+ // Window-scaled tool-output caps (part 6f/7): OpenCode's `tool_output` default is about
175
+ // three times pi's tuned budgets, so every turn would re-send far more tool output than a
176
+ // pi run — critical on the 200K free model's window. Cap it at pi's `outputLimits(window)
177
+ // .bash` (16,000 at 128k and above, scaled down below), with the ~40 chars/line line cap
178
+ // pi's "12k chars ≈ 300 lines" read budget implies. An unknown window (no catalog entry)
179
+ // keeps the 128k values. The window is part 6e/7's `seamContextWindow` for this run's
180
+ // model.
181
+ const maxBytes = outputLimits(seamContextWindow({ agent: "opencode", model: run.config.model }, root)).bash;
182
+ const configContent = opencodeConfigContent({
183
+ cwd: run.cwd,
184
+ operatorMcpNames: operatorMcpNames(process.env, os.homedir()),
185
+ toolOutput: { maxLines: Math.round(maxBytes / 40), maxBytes },
186
+ });
187
+ // The notebook path rides beside the run marker, as the pi and claude adapters' envs do
188
+ // (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes` command
189
+ // on PATH, so OpenCode configures no tumwater MCP server for notes.
190
+ const base = withNotesPath(agentBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
191
+ const env = opencodeEnv(base, { dbPath, configContent });
192
+ const { sessionId } = resolveOpencodeSession(run, env);
193
+ // The thinking variant (part 6d/7): ride the catalog's effort levels for the resolved
194
+ // model as a `#level` suffix, and drop a level the catalog does not list — an unlisted
195
+ // variant would otherwise be a permanent config error from OpenCode (finding 9).
196
+ const model = run.config.model ?? "";
197
+ const thinking = run.config.thinking;
198
+ const entry = readOpencodeCache(root).models[catalogModelKey(model)];
199
+ const variant = thinking !== undefined && thinking !== "" && entry?.efforts?.includes(thinking) ? thinking : undefined;
200
+ const { args, stdin } = opencodeArgs({
201
+ model,
202
+ ...(variant !== undefined ? { variant } : {}),
203
+ sessionId,
204
+ title: run.sessionName,
205
+ args: resolvedAgentConfig(run.config, "opencode").args,
206
+ prompt: run.prompt,
207
+ });
208
+ return { args, stdin, env, sessionId };
209
+ },
210
+ translator() {
211
+ return opencodeTranslator();
212
+ },
213
+ imageInput(model) {
214
+ // Answer from the catalog cache (part 6d/7): a model reads images when its catalog entry
215
+ // says so; a model missing from the cache answers false — the safe direction, so a blind
216
+ // image read is never silently assumed readable. The cache is per-root, found by walking up
217
+ // from the process's working directory to its `.tumwater`, the same root the runs' shared
218
+ // database and cache live under.
219
+ if (model === undefined)
220
+ return false;
221
+ return readOpencodeCache(teamRootOf(process.cwd())).models[catalogModelKey(model)]?.images === true;
222
+ },
223
+ resumable(dir, opts) {
224
+ const record = resumableSession(dir, { kind: opts.kind, agent: "opencode", cwd: opts.cwd });
225
+ if (record === null)
226
+ return false;
227
+ const root = teamRootOf(dir);
228
+ const dbPath = opencodeDbPath(root);
229
+ const env = { ...process.env, OPENCODE_DB: dbPath };
230
+ // Resolve the binary exactly as the run's invocation does — same env → backends.opencode.bin
231
+ // → default precedence — so a configured custom binary's resume guard agrees with the
232
+ // `run.agentBin` the invocation runs (they must not disagree, or a follow-up is silently
233
+ // skipped). The team's config is loaded from the root `dir` lives under.
234
+ const { config } = loadConfigSafe(root);
235
+ const bin = config !== undefined ? opencodeBackend.resolveBin(config).bin : "opencode";
236
+ return opencodeSessionExists(record.id, env, bin, opts.cwd ?? path.dirname(dir));
237
+ },
238
+ afterRun(run, invocation, translator) {
239
+ if (invocation.sessionId === undefined)
240
+ return [];
241
+ const lines = opencodeExport({
242
+ id: invocation.sessionId,
243
+ env: invocation.env,
244
+ bin: run.agentBin,
245
+ cwd: run.cwd,
246
+ });
247
+ if (lines.length === 0) {
248
+ // The export is authoritative, so its loss is worth one warning: the run keeps its
249
+ // streamed numbers and the caller surfaces `opencode_usage_partial` in the event feed.
250
+ translator.noteOpencodeUsagePartial();
251
+ }
252
+ return lines;
253
+ },
254
+ classifyExit: classifyOpencodeExit,
255
+ };
@@ -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
+ }