tumwater 0.2.0 → 0.3.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 (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,44 +1,51 @@
1
- // vocabulary: plain
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
1
  import { childEnv } from "../process/process.js";
6
2
  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";
11
3
  import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
12
- import { configForRole, resolvedAgentConfig } from "../config/config-views.js";
4
+ import { configForRole } from "../config/config-views.js";
13
5
  import { fallbackPair, resolveTierFallbacks, seamsInUse, tierChainSteps } from "../config/config-fallbacks.js";
14
6
  import { cacheReadUnpriced, fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
15
7
  import { MODEL_TIERS } from "../config/config-schema.js";
16
- import { isAgentId } from "../config/model-selector.js";
17
8
  import { imageReroute, seamImageInput } from "../agent/image-input.js";
18
9
  import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
19
- import { agentBackend } from "../agent/agent-backend.js";
10
+ import { backendById } from "../agent/agent-backend.js";
20
11
  import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
21
- import { claudeBackend, claudeConfigDir } from "../agent/claude/claude-backend.js";
22
- import { claudeAuthUsesSubscription, parseClaudeAuthStatus, readClaudeAccount } from "../agent/claude/claude-account.js";
23
- import { claudeEnv } from "../agent/claude/claude-env.js";
12
+ import { readClaudeAccount } from "../agent/claude/claude-account.js";
24
13
  import { parseJsonObject } from "../files/json-object.js";
25
14
  import { envPath } from "../files/files.js";
26
15
  import { modelPairName, pairDailyCost } from "../budget/budget.js";
27
16
  import { usd, usdCap } from "../text/format.js";
28
- import { loadLoopStates } from "./doctor-holds.js";
29
- import { resolveAgentBin } from "../pi/pi-bin.js";
17
+ import { loadRunnerStates } from "./doctor-holds.js";
18
+ import { resolveBackendBin } from "../pi/pi-bin.js";
19
+ import { parsePiModelImages, parsePiModelTable, readPiListedModels, writePiListedModels } from "../pi/pi-listed-models.js";
30
20
  import { agree } from "../text/phrases.js";
31
- import { formatDate, formatTime } from "../text/datetime.js";
32
- import { belowNodeFloor } from "../version.js";
21
+ import { formatClock, formatDate } from "../text/datetime.js";
22
+ import { errorMessage, joinParts } from "../text/text.js";
33
23
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
24
+ import { belowNodeFloor } from "../version.js";
25
+ /** The one phrase a configured-models check uses when the config declares no models at all:
26
+ * informational (capability falls to pi's own defaults), not a warning. Shared by
27
+ * checkTierModels and checkImageInput so the two checks word the same "nothing configured"
28
+ * stance identically. */
29
+ const PHRASE_NO_MODELS_DECLARED = "no models declared — every model job uses pi's own default";
30
+ /** The one phrase a configured-models check uses when pi's definitions file could not be read
31
+ * at all: the check cannot answer what it was asked, so it warns instead of guessing. Shared by
32
+ * checkTierModels and checkImageInput so the two checks word the "cannot check" stance
33
+ * identically; each appends its own modelsPath after the shared stem, and checkTierModels
34
+ * also appends its ompNote. */
35
+ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model definitions at ";
34
36
  /** The doctor's model-readiness checks, split out of doctor-checks.ts (whose environment and
35
- * repo checks stay there): the cap fallback's price check (checkFallbackModel), the agent
36
- * binary's own credential probe (piProviderAuth), and the declared-tier resolution check
37
- * (checkTierModels). These three are the doctor's only checks that read pi's model definitions
38
- * and, for the credential probe, spawn the agent binary — a distinct concern from the
39
- * filesystem/git checks beside them, kept together so a change to how doctor reads models does
40
- * not touch the environment checks. The shared report contract (CheckOutcome) is imported from
41
- * outcome.ts, exactly as doctor-orphans.ts and doctor-backlog.ts do. */
37
+ * repo-setup checks stay there, and whose state/disk/build checks live in
38
+ * doctor-project-checks.ts): the cap fallback price and fallback-chain checks (checkFallbackModel,
39
+ * checkFallbackChain), the pi auth/probe checks (piProviderAuth, piListedModels), the seam
40
+ * framework (seamPairs and the per-backend seam predicates), and the declared-tier and
41
+ * image-input checks (checkTierModels, checkImageInput). The claude and opencode provider
42
+ * checks live in their own modules (doctor-claude-checks.ts, doctor-opencode-checks.ts), each
43
+ * importing this file's shared seam framework and the AUTH_CHECK_* probe budgets rather than
44
+ * duplicating them. The shared report contract (CheckOutcome) is imported from outcome.ts,
45
+ * exactly as the other doctor-* modules do. Together these are the doctor's only checks that
46
+ * read pi's model definitions and, for the probes, spawn an agent binary — a distinct concern
47
+ * from the filesystem/git checks, kept together so a change to how doctor reads models does not
48
+ * touch the environment checks. */
42
49
  /** The config a readiness check reads, or the warn outcome a broken tumwater.json earns:
43
50
  * checkInit already fails on a broken config, so a model check only reports that it could not
44
51
  * run. One home for the guard and its wording, shared by checkFallbackModel and
@@ -46,7 +53,7 @@ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
46
53
  * files). The config or the warn outcome, so each check's one-line guard — `const config =
47
54
  * loadConfigForCheck(root); if ("level" in config) return config;` — is all a check needs to
48
55
  * read its config instead of repeating a load-and-unwrap prologue. */
49
- function loadConfigForCheck(root) {
56
+ export function loadConfigForCheck(root) {
50
57
  const { config, error } = loadConfigSafe(root);
51
58
  if (config === undefined)
52
59
  return warnOutcome(`cannot check — ${error}`);
@@ -58,7 +65,7 @@ function loadConfigForCheck(root) {
58
65
  * behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
59
66
  * after the day's budget is already spent — so doctor checks it up front, before the operator
60
67
  * needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
61
- * readiness, though: whether the backend can SERVE is known only from the fallback's own ticks
68
+ * readiness, though: whether the backend can SERVE is known only from the fallback's own runs
62
69
  * (src/budget/fallback-breaker.ts's breaker, BUGS.md 2026-09-20), so a free pair reads "serving not
63
70
  * verified" — unless the running orchestrator has published that its breaker demoted the pair
64
71
  * (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
@@ -77,11 +84,15 @@ export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted =
77
84
  // the price-resolved pair (a map with no `default` entry borrows another tier's), while the
78
85
  // refusal names the pair the operator declared.
79
86
  const nameOf = (pair) => pair.provider && pair.model ? `${pair.provider}/${pair.model}` : "a half-resolved pair";
80
- const free = fallbackModelPair(config, modelsPath, readClaudeAccount(root));
87
+ // The claude account answer (6a/7); the fallback price lookup reads it only for "claude"
88
+ // (part 2a/9).
89
+ const account = readClaudeAccount(root);
90
+ const accounts = (b) => (b === "claude" ? account : null);
91
+ const free = fallbackModelPair(config, modelsPath, accounts);
81
92
  if (free) {
82
93
  const name = nameOf(free);
83
94
  if (demoted)
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))}`);
95
+ 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 ${formatClock(demoted.probeAt)}`);
85
96
  return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
86
97
  }
87
98
  return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
@@ -109,7 +120,7 @@ const isLocalUrl = (url) => {
109
120
  /** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
110
121
  * step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
111
122
  * 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
123
+ * models.json and the role states; only a pi step on 127.0.0.1/localhost makes a network
113
124
  * request. `probe` is injectable so tests need no real server. */
114
125
  export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
115
126
  const config = loadConfigForCheck(root);
@@ -118,16 +129,18 @@ export async function checkFallbackChain(root, modelsPath = piModelsPath(), prob
118
129
  const steps = tierChainSteps(config);
119
130
  if (steps.length === 0)
120
131
  return okOutcome("none configured");
121
- const states = loadLoopStates(root, config);
132
+ const states = loadRunnerStates(root, config);
122
133
  const providers = readPiProviders(modelsPath);
123
134
  const account = readClaudeAccount(root);
135
+ // The free-ness lookup reads the one claude account held here (part 2a/9).
136
+ const accounts = (b) => (b === "claude" ? account : null);
124
137
  const now = Date.now();
125
138
  let down = false;
126
139
  const fragments = [];
127
140
  for (const { tier, step, pair } of steps) {
128
141
  // A provider that names a backend other than pi (claude today; opencode from part 2) never
129
142
  // 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";
143
+ const nonPiBackend = pair.backend !== undefined && pair.backend !== "pi";
131
144
  const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
132
145
  const local = base !== null && isLocalUrl(base);
133
146
  const up = local ? await probe(`${base}/models`) : null;
@@ -144,7 +157,7 @@ export async function checkFallbackChain(root, modelsPath = piModelsPath(), prob
144
157
  else if (nonPiBackend) {
145
158
  verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
146
159
  }
147
- else if (piPairFree(providers, pair.provider, pair.model, account)) {
160
+ else if (piPairFree(providers, pair, accounts)) {
148
161
  verdict = up ? "free · server up" : "free";
149
162
  }
150
163
  else {
@@ -169,7 +182,7 @@ export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
169
182
  * resolver, the director, and each tier's budget/model-failure fallback — so a claude or pi
170
183
  * pair that exists only as a fallback is checked exactly like a primary one. Deduplicated by
171
184
  * backend/provider/model, so a model reached through several surfaces is checked once. */
172
- function seamPairs(config) {
185
+ export function seamPairs(config) {
173
186
  const pairs = new Map();
174
187
  for (const seam of seamsInUse(config)) {
175
188
  if (seam.model === undefined)
@@ -183,6 +196,26 @@ function seamPairs(config) {
183
196
  }
184
197
  return [...pairs.values()];
185
198
  }
199
+ /** The model-carrying seam pairs for one backend — `seamPairs` with model guaranteed present
200
+ * (seamPairs never emits a pair without a model, so the guard only narrows the type) and
201
+ * narrowed to `agent`'s pairs. The backend readiness checks (claude, codex, opencode) each
202
+ * walk exactly this filtered list, so they share one filter rather than three copies. */
203
+ export function seamedModels(config, agent) {
204
+ return seamPairs(config).filter((p) => p.agent === agent && p.model !== undefined);
205
+ }
206
+ /** The warn/fail/ok verdict for a backend CLI's `--version` check against one flat minimum
207
+ * version (codex's checkCodexVersion and opencode's checkOpencodeVersion both require a
208
+ * single floor for the whole backend, unlike claude's per-model floors). `found` is the
209
+ * parsed dotted version, or undefined when the probe could not run or its output was
210
+ * unreadable. Centralizing the three messages and the belowNodeFloor comparison keeps the
211
+ * two backends' version verdicts from drifting. */
212
+ export function minVersionOutcome(name, found, min, upgradeHint) {
213
+ if (found === undefined)
214
+ return warnOutcome(`could not check — \`${name} --version\` did not run or its output was unreadable`);
215
+ if (belowNodeFloor(found, `>=${min}`))
216
+ return failOutcome(`${name} ${found} is older than ${min} — ${upgradeHint}`);
217
+ return okOutcome(`${name} ${found} (>= ${min})`);
218
+ }
186
219
  /** Whether any seam runs on the claude backend — the doctor's claude checks stand down when
187
220
  * none does (a pi-only team reads no `~/.claude` state and spawns no claude). */
188
221
  export function claudeSeamsInUse(config) {
@@ -193,6 +226,11 @@ export function claudeSeamsInUse(config) {
193
226
  export function opencodeSeamsInUse(config) {
194
227
  return seamPairs(config).some((pair) => pair.agent === "opencode");
195
228
  }
229
+ /** Whether any seam routes to codex — the doctor's codex checks stand down when none does
230
+ * (a pi-only team spawns no codex and reads no codex home state). */
231
+ export function codexSeamsInUse(config) {
232
+ return seamPairs(config).some((pair) => pair.agent === "codex");
233
+ }
196
234
  /** Whether any seam routes to the pi backend — the doctor's pi-extension row stands down when
197
235
  * none does (an all-claude or all-opencode team reads no `~/.pi` state). Unlike the model-based
198
236
  * seam consult, this scans the routed seams directly (not seamPairs' model-filtered view) so a
@@ -202,151 +240,60 @@ export function piSeamsInUse(config) {
202
240
  return seamsInUse(config).some((s) => s.agent === "pi");
203
241
  }
204
242
  /** 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. */
243
+ * and read `status` out of its JSON. The binary resolves exactly as the spawn does
244
+ * (resolveBackendBin — the same source a real run would launch), so doctor tests the auth of
245
+ * the pi it would actually run. pi's contract is `{"status": "ready"|"not_ready"|"invalid",
246
+ * provider, reason?, authType?}` with exit codes 0/1/2 — a non-zero exit there is a verdict, not
247
+ * a crash, so readiness is read from the body alone and a reply that did not run or does not
248
+ * parse is the only "unknown" (never not-ready, which would blame credentials). Never throws;
249
+ * every failure mode ends on "unknown". The bounded subprocess is the shared boundedProbe
250
+ * (probe.ts), also used by the claude probes. */
209
251
  export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
210
- const resolved = resolveAgentBin(config);
252
+ const resolved = resolveBackendBin(config);
211
253
  const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
212
254
  if (result === null)
213
255
  return "unknown";
214
- if (result.code !== 0)
215
- return "unknown";
216
256
  // An unreadable reply is unknown, never not-ready (which would blame the credentials).
217
257
  const doc = parseJsonObject(result.stdout);
218
258
  if (doc === null)
219
259
  return "unknown";
220
- if (doc.ready === true)
260
+ if (doc.status === "ready")
221
261
  return "ready";
222
- if (doc.ready === false)
262
+ // `invalid` (a bad provider name, exit 2) is likewise not a healthy credential: it reads
263
+ // not-ready so the warning blames the setup, not the probe.
264
+ if (doc.status === "not_ready" || doc.status === "invalid")
223
265
  return "not-ready";
224
266
  return "unknown";
225
267
  }
226
- /** One bounded probe of the resolved `claude` binary under the scrubbed run environment a real
227
- * tick gets: resolve the binary through the claude adapter that would spawn it and run `args`
228
- * with a short deadline. Null when the probe could not run, the child errored, or the deadline
229
- * passed. The one home of the resolveBin + claudeEnv pairing the auth and version checks share,
230
- * so neither probes a different binary or environment than the other. */
231
- function claudeProbe(config, args, pathEnv, timeoutMs) {
232
- const resolved = claudeBackend.resolveBin(config);
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);
268
+ /** Read the table `pi --list-models` prints into a set of `provider/model` keys. Null unless the
269
+ * first non-blank line is the `provider model …` header, so a "No models matching" reply or any
270
+ * other text is never taken for an empty catalog. */
271
+ export function parsePiModelList(stdout) {
272
+ const table = parsePiModelTable(stdout);
273
+ if (table === null)
274
+ return null;
275
+ const listed = new Set();
276
+ for (const [provider, model] of table.rows)
277
+ if (model !== undefined)
278
+ listed.add(`${provider}/${model}`);
279
+ return listed;
245
280
  }
246
- /** Run `claude auth status` under the same scrubbed environment a real run gets (claudeEnv),
247
- * or null when the probe could not run or its reply was unreadable. */
248
- async function claudeAuthProbe(config, pathEnv, timeoutMs) {
249
- const result = await claudeProbe(config, ["auth", "status"], pathEnv, timeoutMs);
281
+ /** The models pi itself lists (`pi --list-models`), which include its built-in catalog that
282
+ * models.json does not repeat. pi lists only the providers it has credentials for. Null when the
283
+ * probe could not run or failed; never throws. Given a `root`, it also records the images column
284
+ * for `seamImageInput` (src/pi/pi-listed-models.ts). */
285
+ export async function piListedModels(config, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS, root) {
286
+ const result = await boundedProbe(resolveBackendBin(config).bin, ["--list-models"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
250
287
  if (result?.code !== 0)
251
288
  return null;
252
- return parseClaudeAuthStatus(result.stdout);
253
- }
254
- /** claude credential readiness (plans/agent-backends.md "Every seam, checked"): a claude seam
255
- * whose login is gone fails every tick before it can work, so doctor runs `claude auth status`
256
- * under the scrubbed run environment and fails when it reports `loggedIn: false`. An ok detail
257
- * names the billing the reply implies — the subscription type, or an API key — so an operator
258
- * telling subscription spend from API spend does not have to run the command themselves. A
259
- * probe that could not run or a reply this cannot read warns rather than guesses. Stands down
260
- * (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
261
- export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
262
- const config = loadConfigForCheck(root);
263
- if ("level" in config)
264
- return config;
265
- if (!claudeSeamsInUse(config))
266
- return okOutcome("no model jobs run on claude");
267
- const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
268
- if (auth === null)
269
- return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
270
- if (!auth.loggedIn)
271
- return failOutcome("claude reports logged in: false — run `claude auth login`");
272
- const subscription = claudeAuthUsesSubscription(auth);
273
- const billing = subscription
274
- ? `subscription${auth.subscriptionType !== undefined ? ` (${auth.subscriptionType})` : ""}`
275
- : "API key";
276
- return okOutcome(`logged in — billing: ${billing}`);
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
- }
302
- /** The dotted version in `claude --version`'s output, or undefined when it names none. */
303
- function parseClaudeCliVersion(output) {
304
- return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
305
- }
306
- /** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
307
- * configured model's minimum fails or misprices that model, and team runs disable the
308
- * auto-updater, so the version an operator has is the version ticks run. Fails below the
309
- * highest floor among the claude-routed models, naming which model needs it; an ok detail
310
- * prints the installed version and the floor it satisfied (or no floor when none applies).
311
- * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
312
- * claude seams in hand. Stands down when no seam routes to claude. */
313
- export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
314
- const config = loadConfigForCheck(root);
315
- if ("level" in config)
316
- return config;
317
- const seams = seamPairs(config).filter((pair) => pair.agent === "claude" && pair.model !== undefined);
318
- if (seams.length === 0)
319
- return okOutcome("no model jobs run on claude");
320
- // The floor each configured model needs; a model the adapter does not gate contributes none.
321
- const floors = new Map();
322
- for (const seam of seams) {
323
- const min = claudeBackend.minVersion?.(seam.model);
324
- if (min !== undefined)
325
- floors.set(seam.model, min);
326
- }
327
- const result = await claudeProbe(config, ["--version"], pathEnv, timeoutMs);
328
- const found = result !== null && result.code === 0 ? parseClaudeCliVersion(result.stdout) : undefined;
329
- if (found === undefined)
330
- return warnOutcome("could not check — `claude --version` did not run or its output was unreadable");
331
- const clamped = seams.some((seam) => seam.thinking === "off" || seam.thinking === "minimal");
332
- const clampNote = clamped
333
- ? "; `off`/`minimal` thinking clamps to `--effort low` (claude has no lower setting)"
334
- : "";
335
- if (floors.size === 0)
336
- return okOutcome(`claude ${found}${clampNote}`);
337
- // The highest floor decides: whichever model needs the newest CLI. Below it, fail and name
338
- // that model; at or above, print the version against the floor.
339
- let required = "";
340
- let neededBy = "";
341
- for (const [model, min] of floors) {
342
- if (required === "" || belowNodeFloor(required, `>=${min}`)) {
343
- required = min;
344
- neededBy = model;
289
+ const images = root === undefined ? null : parsePiModelImages(result.stdout);
290
+ if (root !== undefined && images !== null) {
291
+ try {
292
+ writePiListedModels(root, images);
345
293
  }
294
+ catch { } // A failed write never changes the check.
346
295
  }
347
- if (belowNodeFloor(found, `>=${required}`))
348
- return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
349
- return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
296
+ return parsePiModelList(result.stdout);
350
297
  }
351
298
  /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
352
299
  * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
@@ -362,13 +309,16 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
362
309
  * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
363
310
  * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
364
311
  * 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. */
368
- export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env) {
312
+ * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
313
+ * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
314
+ * need no pi on PATH; `env` so the PI_* branches stay hermetic. */
315
+ export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
369
316
  const config = loadConfigForCheck(root);
370
317
  if ("level" in config)
371
318
  return config;
319
+ // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
320
+ let listing;
321
+ const listed = () => (listing ??= listModels());
372
322
  const pairs = new Map();
373
323
  // Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
374
324
  // each backend correctly; opencode is not claude.
@@ -394,19 +344,14 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
394
344
  ? ""
395
345
  : ` — ${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`;
396
346
  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}`);
398
- const providers = readPiProviders(modelsPath);
347
+ return (ompVars.length === 0 ? okOutcome : warnOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
348
+ let providers = readPiProviders(modelsPath);
349
+ if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
350
+ providers = new Map();
399
351
  const fails = [];
400
352
  const warns = [];
401
- // Reserved agent ids: a pi provider whose name is an agent backend's id can never reach pi
402
- // (the provider segment wins the routing), so its definitions are unreachable — warn.
403
- if (providers) {
404
- const reserved = [...providers.keys()].filter(isAgentId);
405
- if (reserved.length > 0)
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`);
407
- }
408
353
  if (pairs.size > 0 && !providers)
409
- return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}${ompNote}`);
354
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
410
355
  const readyProviders = new Set();
411
356
  const checkedProviders = new Set();
412
357
  for (const { provider, model } of pairs.values()) {
@@ -423,19 +368,29 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
423
368
  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
369
  }
425
370
  const defs = providers?.get(provider);
426
- if (!defs) {
427
- fails.push(`provider ${provider} is not in pi's definitions (${modelsPath})`);
428
- continue;
429
- }
430
- const def = defs.find((d) => d.id === model);
371
+ const def = defs?.find((d) => d.id === model);
431
372
  if (!def) {
432
- // No exact entry: pi clones the provider's default model for the id, inheriting that
433
- // default's price and context window (BUGS.md 2026-10-06) — name that consequence
434
- // instead of only the failed lookup.
435
- fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
436
- continue;
373
+ const listedModels = await listed();
374
+ if (!listedModels?.has(`${provider}/${model}`)) {
375
+ const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
376
+ if (!defs && !providerListed) {
377
+ fails.push(listedModels === null
378
+ ? `provider ${provider} is not in pi's definitions (${modelsPath})`
379
+ : `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`);
380
+ }
381
+ else if (listedModels === null) {
382
+ // No exact entry: pi clones the provider's default model for the id, inheriting that
383
+ // default's price and context window (BUGS.md 2026-10-06) — name that consequence
384
+ // instead of only the failed lookup.
385
+ fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
386
+ }
387
+ else {
388
+ fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
389
+ }
390
+ continue;
391
+ }
437
392
  }
438
- if (cacheReadUnpriced(providers, provider, model)) {
393
+ if (def && providers && cacheReadUnpriced(providers, provider, model)) {
439
394
  // A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
440
395
  // the daily budget undercounts the real spend (BUGS.md 2026-10-06).
441
396
  warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
@@ -455,37 +410,44 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
455
410
  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` : ""}`;
456
411
  // One routing note per non-pi backend in use, so an opencode-routed model is never labeled
457
412
  // 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.
413
+ // id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
414
+ // with the same `<n> <agent>-routed model[s] — ` frame — one phrase in one place (routedNote)
415
+ // so the branches cannot drift on it; the count prints beside the label, so the number form
416
+ // needs agree (not plural, which prints the count).
417
+ const routedNote = (agent, n, tail) => `${n} ${agent}-routed ${agree(n, "model", "models")} — ${tail}`;
459
418
  const routedNotes = [];
460
419
  for (const [agent, models] of routedModels) {
461
420
  const n = models.size;
462
421
  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`);
422
+ ? routedNote(agent, n, "`opencode/…` ids run on the OpenCode CLI, and pi's built-in `opencode` provider is not reachable from tumwater")
423
+ : agent === "codex"
424
+ ? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
425
+ : routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
465
426
  }
466
427
  const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
467
428
  const base = fails.length === 0 && warns.length === 0
468
- ? [pairs.size > 0 ? resolvedPi : "", routedText].filter(Boolean).join("; ")
429
+ ? joinParts([pairs.size > 0 ? resolvedPi : "", routedText])
469
430
  : found;
470
431
  if (fails.length > 0)
471
432
  return failOutcome(`${base}${ompNote}`);
472
433
  return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
473
434
  }
474
435
  /** 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
436
+ * which of the team's seams claim to read images, and whether every text-only enabled role can
476
437
  * reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
477
438
  * 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.
439
+ * shown a real image and the answer is checked; with "all" (the `--reprobe` flag, the
440
+ * dashboard's Re-probe models button and the TUI Health tab's Ctrl+P) every claimed model is
441
+ * re-probed; with "never" (the TUI Health tab and GUI doctor endpoint on entry and refresh)
442
+ * nothing is spawned and only cached verdicts are read, so a refresh never spends a model call.
443
+ * claude seams are never probed — every Claude model reads images, and a probe would spend
444
+ * subscription window to confirm it.
483
445
  *
484
446
  * Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
485
447
  * reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
486
448
  * text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
487
449
  * 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`,
450
+ * reroute sweep never sends a text-only role to a model that lost its probe. `providers`,
489
451
  * `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
490
452
  export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
491
453
  const config = loadConfigForCheck(root);
@@ -495,10 +457,13 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
495
457
  // answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
496
458
  // this check when a seam actually needs it — an all-claude team reads images without it.
497
459
  const accountNow = account === undefined ? readClaudeAccount(root) : account;
460
+ // The free-ness lookup reads the image-input check's one claude account (part 2a/9).
461
+ const accounts = (b) => (b === "claude" ? accountNow : null);
462
+ const listed = readPiListedModels(root);
498
463
  const pairs = seamPairs(config);
499
464
  const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
500
465
  if (providers === null && needsDefinitions)
501
- return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}`);
466
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}`);
502
467
  const label = modelLabel;
503
468
  // The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
504
469
  // model whose earlier probe failed (it must still be named and re-probed by --reprobe).
@@ -506,7 +471,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
506
471
  for (const pair of pairs) {
507
472
  if (pair.model === undefined)
508
473
  continue;
509
- if (seamImageInput(pair, providers) === true)
474
+ if (seamImageInput(pair, providers, undefined, listed) === true)
510
475
  claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
511
476
  agent: pair.agent,
512
477
  provider: pair.provider,
@@ -516,16 +481,16 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
516
481
  }
517
482
  // The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
518
483
  // 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));
484
+ const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p, accounts));
520
485
  for (const tier of MODEL_TIERS) {
521
486
  const pair = resolved[tier].pair;
522
487
  if (pair?.model === undefined)
523
488
  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)
489
+ // The readers (seamImageInput, imageReroute) resolve a pair with no backend to the "pi"
490
+ // agent, so the cache key must carry "pi" — keying it `undefined` would write a verdict the
491
+ // readers never read.
492
+ const agent = pair.backend ?? "pi";
493
+ if (seamImageInput({ ...pair, agent }, providers, undefined, listed) === true)
529
494
  claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
530
495
  agent,
531
496
  provider: pair.provider,
@@ -547,7 +512,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
547
512
  const agent = seam.agent;
548
513
  // A backend that registers an imageInput hook trusts the hook (claude today, which reads
549
514
  // every model); a backend without one is probed through its model catalog.
550
- if (agent !== undefined && agentBackend(agent)?.imageInput !== undefined) {
515
+ if (agent !== undefined && backendById(agent)?.imageInput !== undefined) {
551
516
  readsItems.push(label(agent, seam.provider, seam.model));
552
517
  continue;
553
518
  }
@@ -582,207 +547,76 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
582
547
  readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
583
548
  }
584
549
  }
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;
550
+ let saveProblem = null;
551
+ if (probedAny) {
552
+ try {
553
+ writeImageProbeCache(root, probes);
691
554
  }
692
- for (const key of ["plugin", "agent", "instructions"]) {
693
- if (jsonKeyPresent(text, key))
694
- parts.push(`a ${key} entry`);
555
+ catch (err) {
556
+ saveProblem = errorMessage(err);
695
557
  }
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
558
  }
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;
559
+ const verdict = () => {
560
+ // Text-only enabled roles: which can reroute to a tier that reads images (honoring failed
561
+ // probes), and which cannot.
562
+ const reroutes = [];
563
+ const noTarget = [];
564
+ for (const role of enabledRoleIds(config)) {
565
+ const rc = configForRole(config, role);
566
+ if (rc.model === undefined)
567
+ continue;
568
+ if (seamImageInput(rc, providers, probes, listed) !== false)
569
+ continue; // reads images, or unknown (to tier models)
570
+ const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes, null, listed);
571
+ if (rr && seamImageInput(rr.config, providers, probes, listed) === true)
572
+ 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)})`);
573
+ else
574
+ noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
719
575
  }
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`;
576
+ // A team that declared no models at all leaves capability to pi's own defaults — the same
577
+ // "nothing configured" stance as tier models, informational rather than a warning.
578
+ const declared = pairs.some((p) => p.model !== undefined) ||
579
+ MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
580
+ if (!declared)
581
+ return okOutcome(PHRASE_NO_MODELS_DECLARED);
582
+ const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
583
+ const details = joinParts([readsLine, ...reroutes]);
584
+ if (failed.length > 0)
585
+ return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
586
+ if (couldNot.length > 0)
587
+ return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
588
+ if (noTarget.length > 0)
589
+ 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`);
590
+ if (readsItems.length === 0)
591
+ 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");
592
+ return okOutcome(details);
593
+ };
594
+ const outcome = verdict();
595
+ if (saveProblem === null)
596
+ return outcome;
597
+ return {
598
+ level: outcome.level === "ok" ? "warn" : outcome.level,
599
+ detail: `${outcome.detail} — could not save the image-probe cache (${saveProblem}), so the next doctor probes again`,
600
+ };
733
601
  }
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) {
602
+ /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
603
+ * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
604
+ * resolves and `gh auth status` exits 0 before a run tries to file — the codex-login pattern
605
+ * (plans/codex-backend.md part 9/9). Shown only when the role is enabled: on a default install
606
+ * nothing files upstream, so there is nothing to check and the row is omitted (null). `probe`
607
+ * is injectable so tests use a fake gh (test/fakes/fake-gh.ts); the real probe runs `gh` under
608
+ * the scrubbed run environment with a bounded deadline. */
609
+ export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (args) => boundedProbe("gh", args, childEnv({ PATH: pathEnv }), AUTH_CHECK_TIMEOUT_MS, AUTH_CHECK_MAX_OUTPUT_BYTES)) {
746
610
  const config = loadConfigForCheck(root);
747
611
  if ("level" in config)
748
612
  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);
613
+ if (!config.roles.feedback?.enabled)
614
+ return null;
615
+ const version = await probe(["--version"]);
616
+ if (version?.code !== 0)
617
+ return failOutcome("gh is missing or unusable — install GitHub CLI (https://cli.github.com) so the feedback role can file issues as your login");
618
+ const auth = await probe(["auth", "status"]);
619
+ if (auth?.code !== 0)
620
+ return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
621
+ return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
788
622
  }