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
@@ -1,18 +1,34 @@
1
- import { spawn } from "node:child_process";
2
- import { childEnv, releaseChildHandles, signalTree } from "../process/process.js";
1
+ // vocabulary: plain
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { childEnv } from "../process/process.js";
6
+ import { boundedProbe } from "./probe.js";
7
+ import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
8
+ import { opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
9
+ import { opencodeDbPath } from "../paths.js";
10
+ import { readTextOrNull, statOrNull } from "../files/files.js";
3
11
  import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
4
- import { configForRole, fallbackPair, reviewConfig, tierModel } from "../config/config-views.js";
5
- import { cacheReadUnpriced, fallbackModelPair, piModelsPath, readPiProviders } from "../pi/pi-models.js";
12
+ import { configForRole, resolvedAgentConfig } from "../config/config-views.js";
13
+ import { fallbackPair, resolveTierFallbacks, seamsInUse, tierChainSteps } from "../config/config-fallbacks.js";
14
+ import { cacheReadUnpriced, fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
6
15
  import { MODEL_TIERS } from "../config/config-schema.js";
7
- import { isAgentId, routeModel } from "../config/model-selector.js";
8
- import { claudeBackend } from "../agent/claude/claude-backend.js";
16
+ import { isAgentId } from "../config/model-selector.js";
17
+ import { imageReroute, seamImageInput } from "../agent/image-input.js";
18
+ import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
19
+ import { agentBackend } from "../agent/agent-backend.js";
20
+ import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
21
+ import { claudeBackend, claudeConfigDir } from "../agent/claude/claude-backend.js";
9
22
  import { claudeAuthUsesSubscription, parseClaudeAuthStatus, readClaudeAccount } from "../agent/claude/claude-account.js";
10
23
  import { claudeEnv } from "../agent/claude/claude-env.js";
11
24
  import { parseJsonObject } from "../files/json-object.js";
12
25
  import { envPath } from "../files/files.js";
26
+ import { modelPairName, pairDailyCost } from "../budget/budget.js";
27
+ import { usd, usdCap } from "../text/format.js";
28
+ import { loadLoopStates } from "./doctor-holds.js";
13
29
  import { resolveAgentBin } from "../pi/pi-bin.js";
14
30
  import { agree } from "../text/phrases.js";
15
- import { formatTime } from "../text/datetime.js";
31
+ import { formatDate, formatTime } from "../text/datetime.js";
16
32
  import { belowNodeFloor } from "../version.js";
17
33
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
18
34
  /** The doctor's model-readiness checks, split out of doctor-checks.ts (whose environment and
@@ -27,16 +43,18 @@ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
27
43
  * checkInit already fails on a broken config, so a model check only reports that it could not
28
44
  * run. One home for the guard and its wording, shared by checkFallbackModel and
29
45
  * checkTierModels (the same shape doctor-backlog.ts's readDocChecked uses for unreadable
30
- * files). */
46
+ * files). The config or the warn outcome, so each check's one-line guard — `const config =
47
+ * loadConfigForCheck(root); if ("level" in config) return config;` — is all a check needs to
48
+ * read its config instead of repeating a load-and-unwrap prologue. */
31
49
  function loadConfigForCheck(root) {
32
50
  const { config, error } = loadConfigSafe(root);
33
51
  if (config === undefined)
34
- return { outcome: warnOutcome(`cannot check — ${error}`) };
35
- return { config };
52
+ return warnOutcome(`cannot check — ${error}`);
53
+ return config;
36
54
  }
37
55
  /** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
38
56
  * `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
39
- * role loops pause at the cap exactly as if no fallback existed. That refusal is the right runtime
57
+ * roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
40
58
  * behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
41
59
  * after the day's budget is already spent — so doctor checks it up front, before the operator
42
60
  * needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
@@ -45,16 +63,15 @@ function loadConfigForCheck(root) {
45
63
  * verified" — unless the running orchestrator has published that its breaker demoted the pair
46
64
  * (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
47
65
  * which warns with the failure count and the next probe time. No fallbackModel configured is
48
- * informational, not a warning — plenty of fleets intend to stop at the cap. `modelsPath` is
66
+ * informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
49
67
  * injectable so tests need no real pi install. */
50
68
  export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
51
- const loaded = loadConfigForCheck(root);
52
- if ("outcome" in loaded)
53
- return loaded.outcome;
54
- const { config } = loaded;
69
+ const config = loadConfigForCheck(root);
70
+ if ("level" in config)
71
+ return config;
55
72
  const declared = fallbackPair(config);
56
73
  if (!declared)
57
- return okOutcome("none configured — role loops pause at the cap");
74
+ return okOutcome("none configured — roles pause at the cap");
58
75
  // A pair missing either half would fall through to pi's own (unverified) default, so it can
59
76
  // never be free; naming that case beats rendering a bare "?/model". The free branch names
60
77
  // the price-resolved pair (a map with no `default` entry borrows another tier's), while the
@@ -64,124 +81,134 @@ export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted =
64
81
  if (free) {
65
82
  const name = nameOf(free);
66
83
  if (demoted)
67
- return warnOutcome(`${name} is priced at zero but not serving — the running fleet demoted it after ${demoted.failures} consecutive failed ticks, so role loops pause at the cap; one probe tick retries it from ${formatTime(new Date(demoted.probeAt))}`);
84
+ return warnOutcome(`${name} is priced at zero but not serving — the running team demoted it after ${demoted.failures} consecutive failed runs, so roles pause at the cap; one probe run retries it from ${formatTime(new Date(demoted.probeAt))}`);
68
85
  return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
69
86
  }
70
- return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap role loops pause instead of switching`);
87
+ return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
88
+ }
89
+ /** Whether a local server answers `GET <url>`: node's built-in fetch with a short timeout, false
90
+ * on any failure — doctor is a pre-flight, not a place to wait out a hung server. */
91
+ async function probeModels(url) {
92
+ try {
93
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
94
+ return res.ok;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ const isLocalUrl = (url) => {
101
+ try {
102
+ const host = new URL(url).hostname;
103
+ return host === "127.0.0.1" || host === "localhost";
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ };
109
+ /** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
110
+ * step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
111
+ * spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
112
+ * models.json and the loop states; only a pi step on 127.0.0.1/localhost makes a network
113
+ * request. `probe` is injectable so tests need no real server. */
114
+ export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
115
+ const config = loadConfigForCheck(root);
116
+ if ("level" in config)
117
+ return config;
118
+ const steps = tierChainSteps(config);
119
+ if (steps.length === 0)
120
+ return okOutcome("none configured");
121
+ const states = loadLoopStates(root, config);
122
+ const providers = readPiProviders(modelsPath);
123
+ const account = readClaudeAccount(root);
124
+ const now = Date.now();
125
+ let down = false;
126
+ const fragments = [];
127
+ for (const { tier, step, pair } of steps) {
128
+ // A provider that names a backend other than pi (claude today; opencode from part 2) never
129
+ // asks pi for a base URL or runs pi's price probe — only a pi step may.
130
+ const nonPiBackend = isAgentId(pair.provider) && pair.provider !== "pi";
131
+ const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
132
+ const local = base !== null && isLocalUrl(base);
133
+ const up = local ? await probe(`${base}/models`) : null;
134
+ const cap = pair.maxDailyCostUsd;
135
+ const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
136
+ let verdict;
137
+ if (up === false) {
138
+ down = true;
139
+ verdict = `not running (GET ${base}/models failed)`;
140
+ }
141
+ else if (cap !== undefined && spent >= cap) {
142
+ verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
143
+ }
144
+ else if (nonPiBackend) {
145
+ verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
146
+ }
147
+ else if (piPairFree(providers, pair.provider, pair.model, account)) {
148
+ verdict = up ? "free · server up" : "free";
149
+ }
150
+ else {
151
+ verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
152
+ }
153
+ fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
154
+ }
155
+ const detail = fragments.join("; ");
156
+ return down ? warnOutcome(detail) : okOutcome(detail);
71
157
  }
72
158
  /** How long the credential probe may take before it counts as unknown — a doctor run is a
73
159
  * pre-flight, not a place to wait out a hung backend. */
74
- const AUTH_CHECK_TIMEOUT_MS = 15_000;
160
+ export const AUTH_CHECK_TIMEOUT_MS = 15_000;
75
161
  /** Cap on the probe's captured stdout. The auth reply is a tiny JSON object; a binary whose
76
162
  * output is a runaway log (or a pipe held open with no EOF) must not grow `out` without bound.
77
163
  * Sibling probes share EXEC_MAX_BUFFER, but 1 MiB is already orders of magnitude past any real
78
164
  * reply, so it is the tighter bound here. Over it the probe kills the child and reads as
79
165
  * unknown. */
80
- const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
81
- /** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"):
82
- * the top-level tiers (routed so a `claude/<id>` selector names its backend), every enabled
83
- * role's resolved pair, and the reviewer's while review is on. Deduplicated by
166
+ export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
167
+ /** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"): the
168
+ * distinct pairs of the one seamsInUse list — every enabled role, the review gate, the
169
+ * resolver, the director, and each tier's budget/model-failure fallback — so a claude or pi
170
+ * pair that exists only as a fallback is checked exactly like a primary one. Deduplicated by
84
171
  * backend/provider/model, so a model reached through several surfaces is checked once. */
85
172
  function seamPairs(config) {
86
173
  const pairs = new Map();
87
- const add = (pair) => {
88
- if (pair.model === undefined)
89
- return;
90
- pairs.set(`${pair.agent ?? ""}\0${pair.provider ?? ""}\0${pair.model}`, pair);
91
- };
92
- for (const tier of MODEL_TIERS) {
93
- const sel = tierModel(config, tier);
94
- if (!sel)
174
+ for (const seam of seamsInUse(config)) {
175
+ if (seam.model === undefined)
95
176
  continue;
96
- const routed = routeModel(sel.provider, sel.model, config.agent ?? "pi", tier);
97
- add({
98
- agent: routed.agent,
99
- provider: routed.agent === "pi" ? sel.provider : undefined,
100
- model: routed.model,
101
- thinking: sel.thinking,
177
+ pairs.set(`${seam.agent ?? ""}\0${seam.provider ?? ""}\0${seam.model}`, {
178
+ agent: seam.agent,
179
+ provider: seam.provider,
180
+ model: seam.model,
181
+ thinking: seam.thinking,
102
182
  });
103
183
  }
104
- for (const role of enabledRoleIds(config)) {
105
- const rc = configForRole(config, role);
106
- add({ agent: rc.agent, provider: rc.provider, model: rc.model, thinking: rc.thinking });
107
- }
108
- if (config.review.enabled) {
109
- const rv = reviewConfig(config);
110
- add({ agent: rv.agent, provider: rv.provider, model: rv.model, thinking: rv.thinking });
111
- }
112
184
  return [...pairs.values()];
113
185
  }
114
186
  /** Whether any seam runs on the claude backend — the doctor's claude checks stand down when
115
- * none does (a pi-only fleet reads no `~/.claude` state and spawns no claude). */
187
+ * none does (a pi-only team reads no `~/.claude` state and spawns no claude). */
116
188
  export function claudeSeamsInUse(config) {
117
189
  return seamPairs(config).some((pair) => pair.agent === "claude");
118
190
  }
119
- /** One bounded probe of an agent binary: `null` when the spawn threw, the child errored, the
120
- * deadline passed, or stdout overran the cap — every case the caller must read as "could not
121
- * check", never as a verdict. The child runs detached and at the deadline (or on overrun) its
122
- * whole process group is SIGKILLed and the promise settles, because `'close'` waits for the
123
- * stdio pipes and a grandchild holding them open would otherwise leave the promise pending past
124
- * the deadline and hang `tumwater doctor` (the same trap git-run.ts's bounded spawn closes for
125
- * git). `timeoutMs` is a test seam; production callers leave it at the default. */
126
- function boundedProbe(bin, args, env, timeoutMs) {
127
- return new Promise((resolve) => {
128
- let child;
129
- try {
130
- child = spawn(bin, args, {
131
- cwd: process.cwd(),
132
- // Detached so the deadline's signalTree reaches a grandchild too; stderr is ignored
133
- // rather than piped, since nothing reads it and a full stderr pipe would block the
134
- // child until the deadline.
135
- detached: true,
136
- stdio: ["ignore", "pipe", "ignore"],
137
- env,
138
- });
139
- }
140
- catch {
141
- resolve(null);
142
- return;
143
- }
144
- let out = "";
145
- let bytes = 0;
146
- let settled = false;
147
- let timer;
148
- const settle = (result) => {
149
- if (settled)
150
- return;
151
- settled = true;
152
- if (timer !== undefined)
153
- clearTimeout(timer);
154
- // Destroy the captured stdout and unref the process handle (releaseChildHandles): a
155
- // grandchild that escaped the group and holds the pipe's write end would otherwise keep
156
- // the harness's event loop alive though the probe already has its answer.
157
- releaseChildHandles(child);
158
- resolve(result);
159
- };
160
- // Deadline or runaway output: kill the whole group, tear down the handles, and settle —
161
- // do not wait for a `'close'` the group may never deliver.
162
- const abort = () => {
163
- signalTree(child, "SIGKILL");
164
- settle(null);
165
- };
166
- timer = setTimeout(abort, timeoutMs);
167
- child.stdout?.on("data", (chunk) => {
168
- out += chunk.toString("utf8");
169
- bytes += chunk.length;
170
- if (bytes > AUTH_CHECK_MAX_OUTPUT_BYTES)
171
- abort();
172
- });
173
- child.on("error", () => settle(null));
174
- child.on("close", (code) => settle({ code, stdout: out }));
175
- });
191
+ /** Whether any seam routes to opencode — the doctor's opencode checks stand down when none
192
+ * does (a pi-only team spawns no opencode and reads no opencode state). */
193
+ export function opencodeSeamsInUse(config) {
194
+ return seamPairs(config).some((pair) => pair.agent === "opencode");
195
+ }
196
+ /** Whether any seam routes to the pi backend — the doctor's pi-extension row stands down when
197
+ * none does (an all-claude or all-opencode team reads no `~/.pi` state). Unlike the model-based
198
+ * seam consult, this scans the routed seams directly (not seamPairs' model-filtered view) so a
199
+ * default pi team — whose seams route to pi with pi's own default model and no explicit
200
+ * selector — still gets the row: that is the feature's main scenario. */
201
+ export function piSeamsInUse(config) {
202
+ return seamsInUse(config).some((s) => s.agent === "pi");
176
203
  }
177
- /** The default credential probe: run the resolved agent binary's own auth check for one
178
- * provider and read `ready` out of its JSON. The binary resolves exactly as the spawn does
179
- * (resolveAgentBin — the same source a real tick would launch), so doctor tests the auth of
180
- * the pi it would actually run. Never throws; every failure mode lands on "unknown". The
181
- * bounded subprocess is boundedProbe above, shared with the claude probes. */
204
+ /** The default credential probe: run the resolved agent binary's own auth check for one provider
205
+ * and read `ready` out of its JSON. The binary resolves exactly as the spawn does (resolveAgentBin
206
+ * — the same source a real tick would launch), so doctor tests the auth of the pi it would actually
207
+ * run. Never throws; every failure mode ends on "unknown". The bounded subprocess is the shared
208
+ * boundedProbe (probe.ts), also used by the claude probes. */
182
209
  export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
183
210
  const resolved = resolveAgentBin(config);
184
- const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs);
211
+ const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
185
212
  if (result === null)
186
213
  return "unknown";
187
214
  if (result.code !== 0)
@@ -203,8 +230,18 @@ export async function piProviderAuth(config, provider, pathEnv = envPath(), time
203
230
  * so neither probes a different binary or environment than the other. */
204
231
  function claudeProbe(config, args, pathEnv, timeoutMs) {
205
232
  const resolved = claudeBackend.resolveBin(config);
206
- const env = claudeEnv(childEnv({ PATH: pathEnv }), { quietTimeoutSeconds: 0 });
207
- return boundedProbe(resolved.bin, args, env, timeoutMs);
233
+ // The configured team config dir must reach the probe's env the way a real run's does, or
234
+ // the auth/version rows would look in claude's default dir (usually ~/.claude) while a run
235
+ // with backends.claude.configDir writes and reads elsewhere — the very mismatch doctor exists
236
+ // to surface. Mirror backends.run's expansion: only claude accepts configDir, and unset leaves
237
+ // today's env byte-identical.
238
+ const configDir = resolvedAgentConfig(config, "claude").configDir;
239
+ const expandedConfigDir = configDir !== undefined ? claudeConfigDir(process.env, os.homedir(), configDir) : undefined;
240
+ const env = claudeEnv(childEnv({ PATH: pathEnv }), {
241
+ quietTimeoutSeconds: 0,
242
+ ...(expandedConfigDir !== undefined ? { configDir: expandedConfigDir } : {}),
243
+ });
244
+ return boundedProbe(resolved.bin, args, env, timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
208
245
  }
209
246
  /** Run `claude auth status` under the same scrubbed environment a real run gets (claudeEnv),
210
247
  * or null when the probe could not run or its reply was unreadable. */
@@ -220,14 +257,13 @@ async function claudeAuthProbe(config, pathEnv, timeoutMs) {
220
257
  * names the billing the reply implies — the subscription type, or an API key — so an operator
221
258
  * telling subscription spend from API spend does not have to run the command themselves. A
222
259
  * probe that could not run or a reply this cannot read warns rather than guesses. Stands down
223
- * (ok) when no seam routes to claude, so a pi-only fleet spawns nothing. */
260
+ * (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
224
261
  export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
225
- const loaded = loadConfigForCheck(root);
226
- if ("outcome" in loaded)
227
- return loaded.outcome;
228
- const { config } = loaded;
262
+ const config = loadConfigForCheck(root);
263
+ if ("level" in config)
264
+ return config;
229
265
  if (!claudeSeamsInUse(config))
230
- return okOutcome("no claude seams configured");
266
+ return okOutcome("no model jobs run on claude");
231
267
  const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
232
268
  if (auth === null)
233
269
  return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
@@ -239,25 +275,48 @@ export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUT
239
275
  : "API key";
240
276
  return okOutcome(`logged in — billing: ${billing}`);
241
277
  }
278
+ /** The subscription-vs-API billing trap (plans/provider-concurrency.md §9 gap 6): an operator
279
+ * who exports `ANTHROPIC_API_KEY` for pi's `anthropic` provider will silently move every claude
280
+ * seam to API billing — claude prefers the key over a claude.ai login — and subscription seams
281
+ * stop counting as free. Doctor warns when the key is set, some seam routes to claude, and
282
+ * `claude auth status` reports a claude.ai login (the subscription the key would override). It
283
+ * stays silent (ok) when no seam runs on claude, when the key is unset, or when claude reports
284
+ * an API-key login (then there is no subscription to lose). `env` is injectable so the check
285
+ * stays hermetic; a probe that cannot run warns rather than guesses like checkClaudeAuth. */
286
+ export async function checkAnthroApiKey(root, env = process.env, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
287
+ const config = loadConfigForCheck(root);
288
+ if ("level" in config)
289
+ return config;
290
+ if (!claudeSeamsInUse(config))
291
+ return okOutcome("no model jobs run on claude");
292
+ const apiKey = env.ANTHROPIC_API_KEY;
293
+ if (apiKey === undefined || apiKey === "")
294
+ return okOutcome("no ANTHROPIC_API_KEY set");
295
+ const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
296
+ if (auth === null)
297
+ return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
298
+ if (!auth.loggedIn || !claudeAuthUsesSubscription(auth))
299
+ return okOutcome("ANTHROPIC_API_KEY is set, but claude reports no claude.ai login");
300
+ return warnOutcome("ANTHROPIC_API_KEY is set while claude reports a claude.ai login — model jobs on claude will bill the API key, not the subscription; unset it or route every anthropic model job on pi explicitly");
301
+ }
242
302
  /** The dotted version in `claude --version`'s output, or undefined when it names none. */
243
303
  function parseClaudeCliVersion(output) {
244
304
  return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
245
305
  }
246
306
  /** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
247
- * configured model's minimum fails or misprices that model, and fleet runs disable the
307
+ * configured model's minimum fails or misprices that model, and team runs disable the
248
308
  * auto-updater, so the version an operator has is the version ticks run. Fails below the
249
309
  * highest floor among the claude-routed models, naming which model needs it; an ok detail
250
310
  * prints the installed version and the floor it satisfied (or no floor when none applies).
251
311
  * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
252
312
  * claude seams in hand. Stands down when no seam routes to claude. */
253
313
  export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
254
- const loaded = loadConfigForCheck(root);
255
- if ("outcome" in loaded)
256
- return loaded.outcome;
257
- const { config } = loaded;
314
+ const config = loadConfigForCheck(root);
315
+ if ("level" in config)
316
+ return config;
258
317
  const seams = seamPairs(config).filter((pair) => pair.agent === "claude" && pair.model !== undefined);
259
318
  if (seams.length === 0)
260
- return okOutcome("no claude seams configured");
319
+ return okOutcome("no model jobs run on claude");
261
320
  // The floor each configured model needs; a model the adapter does not gate contributes none.
262
321
  const floors = new Map();
263
322
  for (const seam of seams) {
@@ -289,39 +348,41 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
289
348
  return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
290
349
  return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
291
350
  }
292
- /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the
293
- * config can put on a seam must resolve in pi's definitions, and its provider must report
294
- * `ready` from the agent binary's own auth check. A strong tier pi cannot resolve would fail
295
- * every review, and so every landing — the check exists so the operator finds out before the
296
- * fleet does, mid-review. Pairs collected in the same sweep fleetModelsFree prices: each
297
- * tier's top-level selector (a string is default's), every enabled role's resolved pair
298
- * (per-role overrides included; tier-name overrides resolve through configForRole), and the
299
- * reviewer's while review is on. A pair with no model is pi's own default and out of scope;
300
- * fallback pairs are the sibling check's (checkFallbackModel prices the cap pair). Levels:
301
- * an unresolvable pair fails, a provider that is not ready warns (credentials are fixable
302
- * without a config edit), a probe that cannot run warns rather than guesses, and a priced
303
- * model whose cache reads declare $0 warns — the daily budget would undercount it
304
- * (BUGS.md 2026-10-06). When one of
305
- * PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is set the check also reports that tumwater
306
- * does not read it — the variables are oh-my-pi's, pi ignores them, and with omp as
307
- * `agentBin` they still reach omp through the inherited environment (src/pi/pi-backend.ts,
308
- * built on src/process/run-marker.ts's agentBaseEnv) — pointing at the tier keys that do the
309
- * same job (`model.small` / `model.strong`). `auth` is
310
- * injectable so tests need no pi on PATH; `env` so the PI_* branches stay hermetic. */
351
+ /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
352
+ * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
353
+ * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
354
+ * every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
355
+ * collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
356
+ * default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
357
+ * resolve through configForRole), and the reviewer's while review is on. A pair with no model is
358
+ * pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
359
+ * prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
360
+ * (credentials are fixable without a config edit), a probe that cannot run warns rather than
361
+ * guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
362
+ * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
363
+ * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
364
+ * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
365
+ * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's agentBaseEnv) — pointing at the tier
366
+ * keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests need no
367
+ * pi on PATH; `env` so the PI_* branches stay hermetic. */
311
368
  export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env) {
312
- const loaded = loadConfigForCheck(root);
313
- if ("outcome" in loaded)
314
- return loaded.outcome;
315
- const { config } = loaded;
369
+ const config = loadConfigForCheck(root);
370
+ if ("level" in config)
371
+ return config;
316
372
  const pairs = new Map();
317
- const claudeModels = new Set();
373
+ // Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
374
+ // each backend correctly; opencode is not claude.
375
+ const routedModels = new Map();
318
376
  const add = (agent, provider, model) => {
319
377
  if (model === undefined)
320
378
  return;
321
379
  // A non-pi backend's models are not in pi's definitions, so they are not swept for
322
- // resolution or auth here — the claude checks cover them instead.
380
+ // resolution or auth here — the backend's own checks cover them instead.
323
381
  if (agent !== undefined && agent !== "pi") {
324
- claudeModels.add(model);
382
+ let models = routedModels.get(agent);
383
+ if (models === undefined)
384
+ routedModels.set(agent, (models = new Set()));
385
+ models.add(model);
325
386
  return;
326
387
  }
327
388
  pairs.set(`${provider ?? ""}/${model}`, { provider, model });
@@ -332,8 +393,8 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
332
393
  const ompNote = ompVars.length === 0
333
394
  ? ""
334
395
  : ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`;
335
- if (pairs.size === 0 && claudeModels.size === 0)
336
- return (ompVars.length === 0 ? okOutcome : warnOutcome)(`no models declared — every seam uses pi's own default${ompNote}`);
396
+ if (pairs.size === 0 && routedModels.size === 0)
397
+ return (ompVars.length === 0 ? okOutcome : warnOutcome)(`no models declared — every model job uses pi's own default${ompNote}`);
337
398
  const providers = readPiProviders(modelsPath);
338
399
  const fails = [];
339
400
  const warns = [];
@@ -342,7 +403,7 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
342
403
  if (providers) {
343
404
  const reserved = [...providers.keys()].filter(isAgentId);
344
405
  if (reserved.length > 0)
345
- warns.push(`pi's definitions (${modelsPath}) declare provider ${reserved.map((p) => `"${p}"`).join(", ")} — the name is reserved for an agent backend, so selectors under it never reach pi`);
406
+ warns.push(`pi's definitions (${modelsPath}) declare provider ${reserved.map((p) => `"${p}"`).join(", ")} — the name is reserved for an agent backend, so model ids under it never reach pi`);
346
407
  }
347
408
  if (pairs.size > 0 && !providers)
348
409
  return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}${ompNote}`);
@@ -353,6 +414,14 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
353
414
  warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
354
415
  continue;
355
416
  }
417
+ // A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
418
+ // to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
419
+ // third-party-harness change a Claude plan's limits cover only first-party tools, so it is
420
+ // billed per token even under a Claude subscription login — the bare `claude-…` form on the
421
+ // claude CLI uses the plan's included usage instead.
422
+ if (model?.startsWith("claude-")) {
423
+ warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
424
+ }
356
425
  const defs = providers?.get(provider);
357
426
  if (!defs) {
358
427
  fails.push(`provider ${provider} is not in pi's definitions (${modelsPath})`);
@@ -384,13 +453,336 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
384
453
  }
385
454
  const found = [...fails, ...warns].join("; ");
386
455
  const resolvedPi = `${pairs.size} declared ${agree(pairs.size, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
387
- const claudeNote = claudeModels.size > 0
388
- ? `${claudeModels.size} claude-routed ${agree(claudeModels.size, "model", "models")} — resolved by the claude backend, not pi's definitions`
389
- : "";
456
+ // One routing note per non-pi backend in use, so an opencode-routed model is never labeled
457
+ // as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
458
+ // id always means the OpenCode CLI, never pi's built-in `opencode` gateway.
459
+ const routedNotes = [];
460
+ for (const [agent, models] of routedModels) {
461
+ const n = models.size;
462
+ routedNotes.push(agent === "opencode"
463
+ ? `${n} opencode-routed ${agree(n, "model", "models")} — \`opencode/…\` ids run on the OpenCode CLI, and pi's built-in \`opencode\` provider is not reachable from tumwater`
464
+ : `${n} claude-routed ${agree(n, "model", "models")} — resolved by the claude backend, not pi's definitions`);
465
+ }
466
+ const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
390
467
  const base = fails.length === 0 && warns.length === 0
391
- ? [pairs.size > 0 ? resolvedPi : "", claudeNote].filter(Boolean).join("; ")
468
+ ? [pairs.size > 0 ? resolvedPi : "", routedText].filter(Boolean).join("; ")
392
469
  : found;
393
470
  if (fails.length > 0)
394
471
  return failOutcome(`${base}${ompNote}`);
395
472
  return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
396
473
  }
474
+ /** The doctor's image-input capability check (plans/image-input.md "Doctor", parts 1-2/5):
475
+ * which of the team's seams claim to read images, and whether every text-only enabled loop can
476
+ * reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
477
+ * terminal `tumwater doctor` default) each claimed pi model with no fresh cached verdict is
478
+ * shown a real image and the answer is checked; with "all" (the `--reprobe` flag) every claimed
479
+ * model is re-probed; with "never" (the TUI Health tab and the GUI doctor endpoint) nothing is
480
+ * spawned and only cached verdicts are read, so a refresh never spends a model call. claude
481
+ * seams are never probed — every Claude model reads images, and a probe would spend subscription
482
+ * window to confirm it.
483
+ *
484
+ * Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
485
+ * reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
486
+ * text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
487
+ * failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
488
+ * reroute sweep never sends a text-only loop to a model that lost its probe. `providers`,
489
+ * `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
490
+ export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
491
+ const config = loadConfigForCheck(root);
492
+ if ("level" in config)
493
+ return config;
494
+ // `tier models` already fails on a missing/unknown pair, so this check names only what it can
495
+ // answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
496
+ // this check when a seam actually needs it — an all-claude team reads images without it.
497
+ const accountNow = account === undefined ? readClaudeAccount(root) : account;
498
+ const pairs = seamPairs(config);
499
+ const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
500
+ if (providers === null && needsDefinitions)
501
+ return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}`);
502
+ const label = modelLabel;
503
+ // The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
504
+ // model whose earlier probe failed (it must still be named and re-probed by --reprobe).
505
+ const claimed = new Map();
506
+ for (const pair of pairs) {
507
+ if (pair.model === undefined)
508
+ continue;
509
+ if (seamImageInput(pair, providers) === true)
510
+ claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
511
+ agent: pair.agent,
512
+ provider: pair.provider,
513
+ model: pair.model,
514
+ thinking: pair.thinking,
515
+ });
516
+ }
517
+ // The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
518
+ // fallback regime picks among these, so a text-only fallback team is a coverage gap here.
519
+ const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p.provider, p.model, accountNow));
520
+ for (const tier of MODEL_TIERS) {
521
+ const pair = resolved[tier].pair;
522
+ if (pair?.model === undefined)
523
+ continue;
524
+ // Fallback pairs are always pi (FallbackModelConfig has no agent), and the readers
525
+ // (seamImageInput, imageReroute) resolve a pi seam to the "pi" agent, so the cache key
526
+ // must carry "pi" — keying it `undefined` would write a verdict the readers never read.
527
+ const agent = "pi";
528
+ if (seamImageInput(pair, providers) === true)
529
+ claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
530
+ agent,
531
+ provider: pair.provider,
532
+ model: pair.model,
533
+ thinking: pair.thinking,
534
+ });
535
+ }
536
+ const probes = readImageProbeCache(root);
537
+ const now = Date.now();
538
+ const env = childEnv({ PATH: pathEnv });
539
+ const readsItems = [];
540
+ const failed = [];
541
+ const couldNot = [];
542
+ let probedAny = false;
543
+ // Settle one claimed seam: probe when asked and uncached, read a fresh cached verdict
544
+ // otherwise, or report "not verified yet". Only pass/fail are cached; could-not-verify is
545
+ // reported but never cached.
546
+ for (const seam of claimed.values()) {
547
+ const agent = seam.agent;
548
+ // A backend that registers an imageInput hook trusts the hook (claude today, which reads
549
+ // every model); a backend without one is probed through its model catalog.
550
+ if (agent !== undefined && agentBackend(agent)?.imageInput !== undefined) {
551
+ readsItems.push(label(agent, seam.provider, seam.model));
552
+ continue;
553
+ }
554
+ const key = imageProbeKey(agent, seam.provider, seam.model);
555
+ const cached = probes[key];
556
+ const valid = imageProbeEntryFresh(cached, now);
557
+ const wantProbe = probeImages !== "never" && (probeImages === "all" || !valid);
558
+ if (wantProbe) {
559
+ probedAny = true;
560
+ const result = await probe(config, { provider: seam.provider, model: seam.model, thinking: seam.thinking }, env);
561
+ if (result.verdict === "pass") {
562
+ probes[key] = { verdict: "pass", detail: result.detail, at: now, claim: true };
563
+ readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(now))})`);
564
+ }
565
+ else if (result.verdict === "fail") {
566
+ probes[key] = { verdict: "fail", detail: result.detail, at: now, claim: true };
567
+ failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${result.detail}`);
568
+ }
569
+ else {
570
+ couldNot.push(`${label(agent, seam.provider, seam.model)} could not be verified — ${result.detail}`);
571
+ }
572
+ }
573
+ else if (cached && imageProbeEntryFresh(cached, now)) {
574
+ if (cached.verdict === "pass") {
575
+ readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(cached.at))})`);
576
+ }
577
+ else {
578
+ failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${cached.detail}`);
579
+ }
580
+ }
581
+ else {
582
+ readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
583
+ }
584
+ }
585
+ if (probedAny)
586
+ writeImageProbeCache(root, probes);
587
+ // Text-only enabled loops: which can reroute to a tier that reads images (honoring failed
588
+ // probes), and which cannot.
589
+ const reroutes = [];
590
+ const noTarget = [];
591
+ for (const role of enabledRoleIds(config)) {
592
+ const rc = configForRole(config, role);
593
+ if (rc.model === undefined)
594
+ continue;
595
+ if (seamImageInput(rc, providers, probes) !== false)
596
+ continue; // reads images, or unknown (to tier models)
597
+ const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes);
598
+ if (rr && seamImageInput(rr.config, providers, probes) === true)
599
+ reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
600
+ else
601
+ noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
602
+ }
603
+ // A team that declared no models at all leaves capability to pi's own defaults — the same
604
+ // "nothing configured" stance as tier models, informational rather than a warning.
605
+ const declared = pairs.some((p) => p.model !== undefined) ||
606
+ MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
607
+ if (!declared)
608
+ return okOutcome("no models declared — every model job uses pi's own default");
609
+ const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
610
+ const details = [readsLine, ...reroutes].filter(Boolean).join("; ");
611
+ if (failed.length > 0)
612
+ return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
613
+ if (couldNot.length > 0)
614
+ return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
615
+ if (noTarget.length > 0)
616
+ return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
617
+ if (readsItems.length === 0)
618
+ return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
619
+ return okOutcome(details);
620
+ }
621
+ /** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
622
+ * undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
623
+ * own gateway with no credential). A model id with more than the `opencode/` namespace prefix
624
+ * is provider-qualified and needs a credential OpenCode lists. */
625
+ function opencodeProviderOf(model) {
626
+ const parts = model.split("/");
627
+ if (parts[0] === "opencode" && parts.length >= 3)
628
+ return parts[1];
629
+ if (parts[0] !== "opencode" && parts.length >= 2)
630
+ return parts[0];
631
+ return undefined;
632
+ }
633
+ /** Whether `opencode auth list --format json` (bounded) lists `provider` exactly. A probe that
634
+ * cannot run, exits nonzero, or omits the provider reads as NOT listed — the safe direction,
635
+ * since doctor is warning the operator to add the credential, never confirming one it did not
636
+ * see. The list is matched EXACTLY, not as a raw substring: when the output parses as an array
637
+ * (of names or of per-credential objects), a credential listed as `anthropic` must never vouch
638
+ * for `anthropic-foo` twice over; when it cannot be parsed as JSON (a shape the CLI may change),
639
+ * the text is matched word-bounded so a truncated or prose line still reads. */
640
+ async function opencodeAuthLists(config, provider, pathEnv, timeoutMs) {
641
+ const resolved = opencodeBackend.resolveBin(config);
642
+ const result = await boundedProbe(resolved.bin, ["auth", "list", "--format", "json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
643
+ if (result?.code !== 0)
644
+ return false;
645
+ const text = result.stdout;
646
+ try {
647
+ const parsed = JSON.parse(text);
648
+ if (Array.isArray(parsed)) {
649
+ return parsed.some((item) => {
650
+ if (typeof item === "string")
651
+ return item === provider;
652
+ return (item !== null &&
653
+ typeof item === "object" &&
654
+ Object.values(item).some((v) => v === provider));
655
+ });
656
+ }
657
+ }
658
+ catch {
659
+ // Not an array-shaped JSON payload — fall through to the word-bounded text match.
660
+ }
661
+ const esc = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
662
+ return new RegExp(`(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|$)`).test(text);
663
+ }
664
+ /** Whether a JSON-ish key (optionally quoted, followed by a colon) appears in a config file's
665
+ * text — a loose match that tolerates JSONC comments without parsing them. */
666
+ function jsonKeyPresent(text, key) {
667
+ return new RegExp(`["']?${key}["']?\\s*:`).test(text);
668
+ }
669
+ /** The operator's global opencode config files and AGENTS.md (plans/opencode-backend.md §7
670
+ * "Leaks", part 7/7): the operator's own config and instructions still load into team runs,
671
+ * so each MCP server (disabled for team runs) or plugin/agent/instructions entry or the
672
+ * AGENTS.md file itself produces one warning naming the file and what leaks in: an MCP server
673
+ * is switched off by the harness, while a plugin/agent/instructions entry or AGENTS.md is NOT
674
+ * switched off and still loads. `configHome` is the config root to scan — the XDG config home
675
+ * (when set) or `~/.config`, the SAME folder opencode's own operatorMcpNames reads, so the leak
676
+ * scan matches the config a run actually loads. Config files
677
+ * are JSON/C, so the keys are matched loosely (a word followed by a colon) rather than fully
678
+ * parsed — enough to name what still loads before a run inherits it. */
679
+ function opencodeConfigLeaks(configHome) {
680
+ const dir = path.join(configHome, "opencode");
681
+ const warns = [];
682
+ for (const file of ["config.json", "opencode.json", "opencode.jsonc"]) {
683
+ const text = readTextOrNull(path.join(dir, file));
684
+ if (text === null)
685
+ continue;
686
+ const parts = [];
687
+ let mcp = false;
688
+ if (jsonKeyPresent(text, "mcp") && jsonKeyPresent(text, "servers")) {
689
+ parts.push("an MCP server");
690
+ mcp = true;
691
+ }
692
+ for (const key of ["plugin", "agent", "instructions"]) {
693
+ if (jsonKeyPresent(text, key))
694
+ parts.push(`a ${key} entry`);
695
+ }
696
+ if (parts.length === 0)
697
+ continue;
698
+ const joined = parts.join(" and ");
699
+ // Only the MCP server is disabled for team runs; everything else still loads.
700
+ const verdict = mcp && parts.length === 1 ? " — disabled for team runs" : " — still loads into team runs";
701
+ warns.push(`${path.join(dir, file)} carries ${joined}${verdict}`);
702
+ }
703
+ const agentsMd = path.join(dir, "AGENTS.md");
704
+ if (statOrNull(agentsMd)?.isFile() === true)
705
+ warns.push(`${agentsMd} still loads into team runs`);
706
+ return warns;
707
+ }
708
+ /** Total bytes of every regular file under `dir` (a shallow recursive walk), 0 for a missing
709
+ * or unreadable dir. */
710
+ function opencodeDirBytes(dir) {
711
+ try {
712
+ let total = 0;
713
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
714
+ const p = path.join(dir, entry.name);
715
+ if (entry.isDirectory())
716
+ total += opencodeDirBytes(p);
717
+ else if (entry.isFile())
718
+ total += fs.statSync(p).size;
719
+ }
720
+ return total;
721
+ }
722
+ catch {
723
+ return 0;
724
+ }
725
+ }
726
+ /** A byte count for display: MB, KB, or bytes. */
727
+ function opencodeBytes(n) {
728
+ if (n >= 1024 * 1024)
729
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
730
+ if (n >= 1024)
731
+ return `${Math.round(n / 1024)} KB`;
732
+ return `${n} B`;
733
+ }
734
+ /** OpenCode model, credential and leak readiness (plans/opencode-backend.md §7, part 7/7):
735
+ * every model routed to opencode must be in the catalog cache — an unknown model fails, a
736
+ * cached one reads "free" or its per-M prices and context window, and a thinking level with no
737
+ * catalog effort is noted as dropped; a non-gateway provider (`opencode/<provider>/…`) must
738
+ * have a credential `opencode auth list` lists, else warns; and the operator's global opencode
739
+ * config and AGENTS.md must not leak an MCP server or a plugin/agent/instructions entry into
740
+ * team runs, else warns naming the file and key. Reports the team's opencode database and spill
741
+ * sizes as information. Stands down (ok) when no seam routes to opencode, so a pi-only team
742
+ * spawns nothing and reads nothing. `pathEnv` and `home` are injectable so tests need no real
743
+ * PATH or HOME. The version half of the opencode pre-flight is the sibling checkOpencodeVersion
744
+ * in doctor-checks.ts. */
745
+ export async function checkOpencode(root, pathEnv = envPath(), home = os.homedir(), xdgConfigHome = process.env.XDG_CONFIG_HOME ?? "", timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
746
+ const config = loadConfigForCheck(root);
747
+ if ("level" in config)
748
+ return config;
749
+ if (!opencodeSeamsInUse(config))
750
+ return okOutcome("no model jobs run on opencode");
751
+ const models = seamPairs(config).filter((p) => p.agent === "opencode" && p.model !== undefined);
752
+ const cache = readOpencodeCache(root);
753
+ const fails = [];
754
+ const warns = [];
755
+ const notes = [];
756
+ for (const pair of models) {
757
+ const key = opencodeModelKey(pair.model);
758
+ const entry = cache.models[key];
759
+ if (entry === undefined) {
760
+ fails.push(`${pair.model} is not in the opencode catalog cache`);
761
+ continue;
762
+ }
763
+ const price = entry.free ? "free" : `$${entry.inputPerM ?? 0}/$${entry.outputPerM ?? 0} per M`;
764
+ const window = entry.context !== undefined ? `, ${entry.context.toLocaleString("en-US")}-token window` : "";
765
+ notes.push(`${pair.model} — ${price}${window}`);
766
+ // A model with no effort list at all supports no thinking level, so a requested level is
767
+ // dropped just the same as one the list omits.
768
+ if (pair.thinking !== undefined &&
769
+ pair.thinking !== "" &&
770
+ !entry.efforts?.includes(pair.thinking)) {
771
+ warns.push(`${pair.model} has no ${pair.thinking} effort — that thinking level is dropped`);
772
+ }
773
+ const provider = opencodeProviderOf(pair.model);
774
+ if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
775
+ warns.push(`${pair.model} is a non-gateway provider opencode does not list a credential for`);
776
+ }
777
+ }
778
+ warns.push(...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")));
779
+ const db = statOrNull(opencodeDbPath(root));
780
+ const spillDir = opencodeSpillDir(root);
781
+ const spill = spillDir !== "" && statOrNull(spillDir)?.isDirectory() === true ? opencodeDirBytes(spillDir) : null;
782
+ const sizeNotes = `db ${opencodeBytes(db?.size ?? 0)}${spill !== null ? `, spill ${opencodeBytes(spill)}` : ", no spill dir"}`;
783
+ const found = [...fails, ...warns];
784
+ const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
785
+ if (fails.length > 0)
786
+ return failOutcome(base);
787
+ return (warns.length > 0 ? warnOutcome : okOutcome)(base);
788
+ }