tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,19 +1,17 @@
1
1
  /** The budget/tier FALLBACK side of the config views, split out of config-views.ts: how the
2
2
  * team falls back at the daily spend cap — which free pair each tier runs on
3
- * (resolveTierFallbacks and its BORROW_ORDER), the per-role fallback config a model-failure
4
- * or cap episode hands a role (fallbackRoleConfig, applyFallbackModel), and the surfaces
5
- * that name those pairs (seamsInUse, modelFallbackView, tierFallbackLabels). The per-seam
3
+ * (resolveTierFallbacks and its BORROW_ORDER), the per-role fallback config a cap episode
4
+ * hands a role (applyFallbackModel), and the surfaces
5
+ * that name those pairs (seamsInUse, tierFallbackLabels). The per-seam
6
6
  * resolution these build on — how one role/reviewer/resolver provider/model/thinking resolves
7
7
  * (configForRole, reviewConfig, withModelOverrides, …) — stays in config-views.ts, so the
8
8
  * one-way dependency runs fallback → seam and never back. Pure, like its sibling: no I/O and
9
9
  * no caching. */
10
10
  import { MODEL_TIERS, } from "./config-schema.js";
11
- import { formatModelSelector, parseModelSelector, routeModel } from "./model-selector.js";
11
+ import { formatModelSelector, parseModelSelector, routeModel } from "./config-model-selector.js";
12
12
  import { modelPairName } from "../budget/budget.js";
13
13
  import { DIRECTOR_ROLE } from "../roles/roles.js";
14
- import { baseRoleOf } from "../roles/runner-ids.js";
15
14
  import { enabledRoleIds } from "./config.js";
16
- import { modelFallbackActive } from "../runner/model-fallback.js";
17
15
  import { FALLBACK_REVIEW_TIMEOUT_S, REVIEW_TIMEOUT_S, configForRole, isTierName, pairOverrides, resolvedModelFields, resolverConfig, reviewConfig, reviewRunConfig, roleSeamTier, topTierBackend, topTierSelector, withModelOverrides, } from "./config-views.js";
18
16
  /** The resolved fields of one fallback step, keyed by the seam it routes to (PLANS.md "Mixed
19
17
  * claude + pi fleets, part 2/4"): the backend is the step's own, else its tier primary's
@@ -80,6 +78,9 @@ const BORROW_ORDER = {
80
78
  default: ["default", "strong", "small"],
81
79
  strong: ["strong", "default"],
82
80
  };
81
+ /** Exported for imageRerouteTarget (src/agent/image-input.ts), which walks a rerouting role's
82
+ * tier through the same borrow order the budget fallback uses (skipping the tier itself). */
83
+ export { BORROW_ORDER };
83
84
  /** A tier's OWN fallback chain, first step first — what the `fallback` key declares at that
84
85
  * tier's map entry (a selector or a list of them), or (default only) the string/list form of
85
86
  * `fallback` or the legacy `fallbackModel` object (a one-element chain). Empty when the tier
@@ -163,27 +164,10 @@ export function resolveTierFallbacks(config, usable) {
163
164
  };
164
165
  return { small: resolve("small"), default: resolve("default"), strong: resolve("strong") };
165
166
  }
166
- /** The config a role's runs use while a model-fallback episode is active (PLANS.md "Model
167
- * failure fallback, part 1/2"): the role's tier pair from resolveTierFallbacks (the tier's own
168
- * pair, else BORROW_ORDER's borrow), shaped like configForRole — provider/model/thinking over
169
- * the role's own and top-level values, plus the role's minRunIntervalSeconds. Null when the
170
- * tier resolves to pause (no fallback configured), so the feature stays off. Pure. */
171
- export function fallbackRoleConfig(config, role) {
172
- const tier = roleSeamTier(config, role);
173
- const pair = resolveTierFallbacks(config, () => true)[tier].pair;
174
- if (pair === null)
175
- return null;
176
- const rc = config.roles[baseRoleOf(role)];
177
- return {
178
- ...withModelOverrides(config, tier, pairOverrides(pair)),
179
- minRunIntervalSeconds: rc?.minRunIntervalSeconds ?? config.minRunIntervalSeconds,
180
- };
181
- }
182
167
  /** Every seam the team can spawn, each with its label: every enabled role's resolved config
183
168
  * (configForRole), the review gate when it is on (reviewRunConfig), the conflict resolver
184
169
  * (resolverConfig), the director (a catalog role, named explicitly so it is never missed), and
185
- * each tier's budget fallback (resolveTierFallbacks) — the model-failure fallback
186
- * (fallbackRoleConfig) runs the same tier pair, so one enumeration covers it. Deduplicated by
170
+ * each tier's budget fallback (resolveTierFallbacks). Deduplicated by
187
171
  * label + routed pair so a seam reached through several surfaces is listed once. Read by the
188
172
  * startup gate's agent list, doctor's pair sweep (and so its binary/credential/version checks
189
173
  * and routing table), and the live-edit reload's newly-routed-backend check — no surface can
@@ -211,25 +195,6 @@ export function seamsInUse(config) {
211
195
  }
212
196
  return [...seams.values()];
213
197
  }
214
- /** Build a ModelFallbackView for an episode, or null while the episode is not running the
215
- * fallback (no episode, or a due probe on the primary). role-view.ts's
216
- * RoleViewPayload.modelFallback and status/status-data.ts's per-role row both build this
217
- * shape, so one function keeps the two surfaces from disagreeing about the pair, its start,
218
- * and its reason. `now` is the caller's clock (the status snapshot passes its own poll
219
- * clock), so a row and its poll agree on what "active" means. */
220
- export function modelFallbackView(episode, config, role, now = Date.now()) {
221
- if (episode === undefined || !modelFallbackActive(episode, now))
222
- return null;
223
- const episodeConfig = fallbackRoleConfig(config, role);
224
- if (episodeConfig === null)
225
- return null;
226
- return {
227
- ...resolvedModelFields(episodeConfig.provider, episodeConfig.model, episodeConfig.thinking),
228
- ...(episodeConfig.agent !== undefined && episodeConfig.agent !== "pi" ? { backend: episodeConfig.agent } : {}),
229
- since: episode.since,
230
- reason: episode.reason,
231
- };
232
- }
233
198
  /** The per-tier fallback labels the observability surfaces render (part 7b/8): tier →
234
199
  * `provider/model[ (from <tier>)]` for every tier the resolution gives a pair, in tier order.
235
200
  * The `(from …)` suffix marks a borrowed pair — a strong-tier borrow is the fact an operator
@@ -300,6 +265,74 @@ function freeRoleModels(config, free) {
300
265
  return [id, isTierName(rc?.model) ? stripBackend(rc) : stripModel(rc)];
301
266
  }));
302
267
  }
268
+ /** A pair's selector as a tier-map entry. Keeps pi's provider; a non-pi pair's model is
269
+ * already the whole id. On a team whose backend is not pi — or a pair routed off pi — the
270
+ * entry names the pair's backend, so the team's own backend cannot take a pi pair's id as
271
+ * its own: a bare selector when the pair rides the team backend and it is pi, else an
272
+ * object entry naming the pair's backend. */
273
+ function pairSelectorOf(config, pair) {
274
+ if (!pair)
275
+ return undefined;
276
+ const selector = formatModelSelector({
277
+ provider: pair.backend === undefined ? pair.provider : undefined,
278
+ model: pair.model ?? "",
279
+ thinking: pair.backend === undefined ? pair.thinking : undefined,
280
+ });
281
+ if (pair.backend === undefined && (config.backend ?? "pi") === "pi")
282
+ return formatModelSelector({ provider: pair.provider, model: pair.model ?? "", thinking: pair.thinking });
283
+ return {
284
+ backend: pair.backend ?? "pi",
285
+ model: selector,
286
+ ...(pair.thinking !== undefined ? { thinking: pair.thinking } : {}),
287
+ };
288
+ }
289
+ /** What a free seam keeps running: the tier's own primary selector — the backend namespace a
290
+ * resolved seam has cleared, plus the ambient thinking level `topTierSelector` alone omits.
291
+ * A moved seam gets its pair selector, which already carries that ambient level. */
292
+ function freeTierEntry(config, tier, seam) {
293
+ const own = topTierSelector(config, tier);
294
+ // A tier entry that names a backend keeps it as an object entry: a bare selector string
295
+ // would route by namespace and drop the backend the entry chose.
296
+ const backend = topTierBackend(config, tier);
297
+ if (own !== undefined) {
298
+ const thinking = own.thinking ?? config.thinking;
299
+ if (backend !== undefined)
300
+ return {
301
+ backend,
302
+ model: formatModelSelector({ ...own, thinking: undefined }),
303
+ ...(thinking !== undefined ? { thinking } : {}),
304
+ };
305
+ else
306
+ return formatModelSelector({ ...own, thinking });
307
+ }
308
+ // A tier with no top-level selector — `agent: "claude"` riding its agent default —
309
+ // has no own selector to carry the level, so name the routed default plus it.
310
+ if (seam.model !== undefined) {
311
+ const routed = { provider: seam.provider, model: seam.model };
312
+ return backend !== undefined
313
+ ? {
314
+ backend,
315
+ model: formatModelSelector(routed),
316
+ ...(seam.thinking !== undefined ? { thinking: seam.thinking } : {}),
317
+ }
318
+ : formatModelSelector({ ...routed, thinking: seam.thinking });
319
+ }
320
+ return undefined;
321
+ }
322
+ /** A config with no resolved tier map installs the single fallback pair at the top level,
323
+ * keeping every other value (and the reviewer's time budget floor) as in the resolved-map
324
+ * path's legacy peer. */
325
+ function legacyPairConfig(config, pair) {
326
+ return {
327
+ ...config,
328
+ ...(pair.backend !== undefined || config.backend !== undefined ? { backend: pair.backend ?? "pi" } : {}),
329
+ provider: pair.backend === undefined ? pair.provider : undefined,
330
+ model: pair.model,
331
+ thinking: pair.thinking,
332
+ review: fallbackReview(config.review),
333
+ roles: stripRoleModels(config.roles, false),
334
+ };
335
+ }
303
336
  /** The config as seen by a role running on the free fallback model
304
337
  * (plans/fallback-model.md): the fallback's provider/model/thinking installed as the top-level
305
338
  * values AND every per-role and reviewer model override dropped, so that EVERY seam that could
@@ -323,26 +356,6 @@ function freeRoleModels(config, free) {
323
356
  * model, while every priced seam still lands on its tier's fallback pair. */
324
357
  export function applyFallbackModel(config, resolved, free) {
325
358
  if (resolved) {
326
- // A pair's selector keeps pi's provider; a non-pi pair's model is already the whole id. On a
327
- // team whose backend is not pi — or a pair routed off pi — the entry names the pair's
328
- // backend, so the team's own backend cannot take a pi pair's id as its own.
329
- const selectorOf = (f) => {
330
- const pair = f.pair;
331
- if (!pair)
332
- return undefined;
333
- const selector = formatModelSelector({
334
- provider: pair.backend === undefined ? pair.provider : undefined,
335
- model: pair.model ?? "",
336
- thinking: pair.backend === undefined ? pair.thinking : undefined,
337
- });
338
- if (pair.backend === undefined && (config.backend ?? "pi") === "pi")
339
- return formatModelSelector({ provider: pair.provider, model: pair.model ?? "", thinking: pair.thinking });
340
- return {
341
- backend: pair.backend ?? "pi",
342
- model: selector,
343
- ...(pair.thinking !== undefined ? { thinking: pair.thinking } : {}),
344
- };
345
- };
346
359
  const model = {};
347
360
  const stepCaps = {};
348
361
  for (const tier of MODEL_TIERS) {
@@ -351,37 +364,12 @@ export function applyFallbackModel(config, resolved, free) {
351
364
  // seam gets its pair selector, which already carries that ambient level.
352
365
  const seam = withModelOverrides(config, tier, {});
353
366
  if (free?.(seam)) {
354
- const own = topTierSelector(config, tier);
355
- // A tier entry that names a backend keeps it as an object entry: a bare selector string
356
- // would route by namespace and drop the backend the entry chose.
357
- const backend = topTierBackend(config, tier);
358
- if (own !== undefined) {
359
- const thinking = own.thinking ?? config.thinking;
360
- if (backend !== undefined)
361
- model[tier] = {
362
- backend,
363
- model: formatModelSelector({ ...own, thinking: undefined }),
364
- ...(thinking !== undefined ? { thinking } : {}),
365
- };
366
- else
367
- model[tier] = formatModelSelector({ ...own, thinking });
368
- }
369
- // A tier with no top-level selector — `agent: "claude"` riding its agent default —
370
- // has no own selector to carry the level, so name the routed default plus it.
371
- else if (seam.model !== undefined) {
372
- const routed = { provider: seam.provider, model: seam.model };
373
- model[tier] =
374
- backend !== undefined
375
- ? {
376
- backend,
377
- model: formatModelSelector(routed),
378
- ...(seam.thinking !== undefined ? { thinking: seam.thinking } : {}),
379
- }
380
- : formatModelSelector({ ...routed, thinking: seam.thinking });
381
- }
367
+ const own = freeTierEntry(config, tier, seam);
368
+ if (own !== undefined)
369
+ model[tier] = own;
382
370
  continue;
383
371
  }
384
- const sel = selectorOf(resolved[tier]);
372
+ const sel = pairSelectorOf(config, resolved[tier].pair);
385
373
  if (sel !== undefined)
386
374
  model[tier] = sel;
387
375
  const cap = resolved[tier].pair?.maxDailyCostUsd;
@@ -401,13 +389,5 @@ export function applyFallbackModel(config, resolved, free) {
401
389
  const pair = fallbackPair(config);
402
390
  if (!pair)
403
391
  return config;
404
- return {
405
- ...config,
406
- ...(pair.backend !== undefined || config.backend !== undefined ? { backend: pair.backend ?? "pi" } : {}),
407
- provider: pair.backend === undefined ? pair.provider : undefined,
408
- model: pair.model,
409
- thinking: pair.thinking,
410
- review: fallbackReview(config.review),
411
- roles: stripRoleModels(config.roles, false),
412
- };
392
+ return legacyPairConfig(config, pair);
413
393
  }
@@ -96,6 +96,22 @@ export const AT_LEAST_ONE = { ok: (n) => n >= 1, what: "a number of at least 1"
96
96
  * duration rules (config-validation.ts) and checkMinRunInterval (config-keys.ts), so the
97
97
  * dashboard's interval editor cannot drift from the schema's bound. */
98
98
  export const MAX_DURATION_SECONDS = 2147483;
99
+ /** The seconds-valued duration rules, bounded by MAX_DURATION_SECONDS so a `×1000` delay stays
100
+ * inside node's signed-32-bit range. One home for the family, so the whole-gate section
101
+ * (config-validation.ts) and the per-role section (config-validation-roles.ts) name the same
102
+ * rules instead of each declaring DURATION_NON_NEGATIVE for itself. */
103
+ export const DURATION_POSITIVE = {
104
+ ok: (n) => n > 0 && n <= MAX_DURATION_SECONDS,
105
+ what: `a number greater than 0, at most ${MAX_DURATION_SECONDS}`,
106
+ };
107
+ export const DURATION_NON_NEGATIVE = {
108
+ ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
109
+ what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS}`,
110
+ };
111
+ export const DURATION_OR_DISABLED = {
112
+ ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
113
+ what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS} (0 disables)`,
114
+ };
99
115
  /** Shared known-role guard for the per-role config sections (`roles.<id>` entries and
100
116
  * `maxDailyCostUsdPerRole.<id>` keys): an id outside the role catalog and customRoles cannot
101
117
  * work — a `roles` typo spawns a phantom role that errors every run forever; a caps typo
@@ -120,6 +136,53 @@ export function checkStringField(problems, obj, prefix, key, allowEmpty = true)
120
136
  problems.push(`${prefix}${key} must not be empty (got ${show(v)})`);
121
137
  });
122
138
  }
139
+ function checkModelSiblingFields(problems, obj, prefix) {
140
+ for (const key of MODEL_TRIPLE_KEYS)
141
+ if (key !== "model")
142
+ checkStringField(problems, obj, prefix, key, false);
143
+ const t = obj.thinking;
144
+ if (isNonBlankString(t) && !THINKING_LEVELS.has(t))
145
+ problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})${typoSuffix(t, [...THINKING_LEVELS])}`);
146
+ }
147
+ function checkTierMapModel(problems, obj, prefix, m) {
148
+ checkKnownKeys(m, TIER_MAP_KEYS, `${prefix}model`, problems);
149
+ if (obj.provider !== undefined)
150
+ problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's model id (got provider ${show(obj.provider)})`);
151
+ for (const [tier, v] of Object.entries(m))
152
+ // An object entry (`{ backend?, model?, thinking? }`) is validated by validateConfig.
153
+ if (!isNonBlankString(v) && !isJsonObject(v))
154
+ problems.push(`${prefix}model.${tier} must be a non-empty model id (got ${show(v)})`);
155
+ }
156
+ function whatModelMustBe(modelShape) {
157
+ return modelShape === "tier-map"
158
+ ? "a model id or a map by tier (small, default, strong)"
159
+ : modelShape === "tier-name"
160
+ ? "a model id or a tier name (small, default, strong)"
161
+ : "a string";
162
+ }
163
+ function checkStringModel(problems, obj, prefix, m, modelShape) {
164
+ if (typeof m !== "string") {
165
+ problems.push(`${prefix}model must be ${whatModelMustBe(modelShape)} (got ${show(m)})`);
166
+ return;
167
+ }
168
+ if (m.trim() === "") {
169
+ problems.push(`${prefix}model must not be empty (got ${show(m)})`);
170
+ return;
171
+ }
172
+ if (MODEL_TIERS.includes(m)) {
173
+ if (modelShape === "tier-name")
174
+ return; // A valid tier reference.
175
+ problems.push(`${prefix}model must name a model id — tier names are valid only as roles.<id>.model or review.model values (got ${show(m)})`);
176
+ return;
177
+ }
178
+ if (modelShape === "tier-name" && !m.includes("/") && obj.provider === undefined)
179
+ suggestTierFix(problems, prefix, m);
180
+ }
181
+ function suggestTierFix(problems, prefix, m) {
182
+ const tier = suggestClosest(m, MODEL_TIERS);
183
+ if (tier !== null)
184
+ problems.push(`${prefix}model ${show(m)} is not a tier name or a model id pi can resolve${didYouMean(tier)}`);
185
+ }
123
186
  /** Every model-override section validates the same provider/model/thinking triple the same
124
187
  * way: empty strings are rejected (see checkStringField) and thinking is value-checked —
125
188
  * pi only recognizes THINKING_LEVELS and silently drops anything else, so a typo would run
@@ -137,49 +200,15 @@ export function checkStringField(problems, obj, prefix, key, allowEmpty = true)
137
200
  * places that accept it, so a misplaced `"model": "strong"` is an error naming its fix instead of a
138
201
  * bare model pattern pi can never resolve. */
139
202
  export function checkModelTripleField(problems, obj, prefix, modelShape = "selector") {
140
- for (const key of MODEL_TRIPLE_KEYS)
141
- if (key !== "model")
142
- checkStringField(problems, obj, prefix, key, false);
143
- const t = obj.thinking;
144
- if (isNonBlankString(t) && !THINKING_LEVELS.has(t))
145
- problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})${typoSuffix(t, [...THINKING_LEVELS])}`);
203
+ checkModelSiblingFields(problems, obj, prefix);
146
204
  const m = obj.model;
147
205
  if (m === undefined)
148
206
  return;
149
207
  if (modelShape === "tier-map" && isJsonObject(m)) {
150
- checkKnownKeys(m, TIER_MAP_KEYS, `${prefix}model`, problems);
151
- if (obj.provider !== undefined)
152
- problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's model id (got provider ${show(obj.provider)})`);
153
- for (const [tier, v] of Object.entries(m))
154
- // An object entry (`{ backend?, model?, thinking? }`) is validated by validateConfig.
155
- if (!isNonBlankString(v) && !isJsonObject(v))
156
- problems.push(`${prefix}model.${tier} must be a non-empty model id (got ${show(v)})`);
157
- return;
158
- }
159
- if (typeof m !== "string") {
160
- const what = modelShape === "tier-map"
161
- ? "a model id or a map by tier (small, default, strong)"
162
- : modelShape === "tier-name"
163
- ? "a model id or a tier name (small, default, strong)"
164
- : "a string";
165
- problems.push(`${prefix}model must be ${what} (got ${show(m)})`);
208
+ checkTierMapModel(problems, obj, prefix, m);
166
209
  return;
167
210
  }
168
- if (m.trim() === "") {
169
- problems.push(`${prefix}model must not be empty (got ${show(m)})`);
170
- return;
171
- }
172
- if (MODEL_TIERS.includes(m)) {
173
- if (modelShape === "tier-name")
174
- return; // A valid tier reference.
175
- problems.push(`${prefix}model must name a model id — tier names are valid only as roles.<id>.model or review.model values (got ${show(m)})`);
176
- return;
177
- }
178
- if (modelShape === "tier-name" && !m.includes("/") && obj.provider === undefined) {
179
- const tier = suggestClosest(m, MODEL_TIERS);
180
- if (tier !== null)
181
- problems.push(`${prefix}model ${show(m)} is not a tier name or a model id pi can resolve${didYouMean(tier)}`);
182
- }
211
+ checkStringModel(problems, obj, prefix, m, modelShape);
183
212
  }
184
213
  /** Validate one numeric field against a NumberRule when present. */
185
214
  export function checkNumberField(problems, obj, prefix, key, rule) {
@@ -11,7 +11,7 @@
11
11
  * writers and the read side show. */
12
12
  import { BACKEND_KEYS, BACKOFF_KEYS, BOOTSTRAP_KEYS, CHECK_KEYS, CLAUDE_AGENT_KEYS, CODEX_BACKEND_KEYS, REVIEW_KEYS, ROLE_ENTRY_KEYS, TIER_MAP_KEYS, TOP_LEVEL_KEYS } from "./config-schema.js";
13
13
  import { typoSuffix } from "../text/suggest.js";
14
- import { MAX_DURATION_SECONDS, POSITIVE_INTEGER, show } from "./config-field-checks.js";
14
+ import { MAX_DURATION_SECONDS, show } from "./config-field-checks.js";
15
15
  import { parseQuietHours } from "../scheduling/quiet-hours.js";
16
16
  /** One definition of "a valid daily budget cap" (the TUI's Ctrl+B editor and the GUI's
17
17
  * /api/budget endpoint both run their input through it): a finite number of 0 or more —
@@ -31,18 +31,6 @@ export function checkDailyBudgetUsd(value, key = "maxDailyCostUsd") {
31
31
  return `${key} must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}, 0 disables (got ${show(value)})`;
32
32
  return null;
33
33
  }
34
- /** One definition of "a valid per-provider concurrency cap" (plans/provider-concurrency.md
35
- * §1): a positive integer, accepted for any (possibly unknown) provider name — a fallback's
36
- * provider may only come into use later, so the key is not checked against a catalog. The one
37
- * validation both `config set maxConcurrentPerProvider.<provider>` and validateConfig show,
38
- * so they cannot drift apart on what a valid cap is. `key` names the config key in the error,
39
- * defaulting to the map name; the dotted map passes `maxConcurrentPerProvider.<provider>`
40
- * so a rejected entry names the key actually set. */
41
- export function checkProviderConcurrencyCap(value, key = "maxConcurrentPerProvider") {
42
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1)
43
- return `${key} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`;
44
- return null;
45
- }
46
34
  /** One definition of "a valid quietHours window" (src/scheduling/quiet-hours.ts owns the format):
47
35
  * "HH:MM-HH:MM" in local time, wrapping permitted, empty means off. parseQuietHours's
48
36
  * message is the one wording every surface (validateConfig, `config set`, the TUI/GUI
@@ -78,12 +66,9 @@ export const PER_KEY_VALIDATORS = {
78
66
  };
79
67
  /** The per-role maps `config get/set` reaches with dotted keys: each names ONE entry
80
68
  * (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`) that merges into the
81
- * existing map, so raising one role's cap never requires re-typing another's. The
82
- * `maxConcurrentPerProvider` map is dotted the same way (`maxConcurrentPerProvider.<provider>`);
83
- * its keys are provider names, not role ids, so its per-key validation skips the role-catalog
84
- * check the per-role maps apply. The per-key value validator runs before the write, the same
85
- * screening a bare top-level key gets. */
86
- export const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole", "maxConcurrentPerProvider"];
69
+ * existing map, so raising one role's cap never requires re-typing another's. The per-key
70
+ * value validator runs before the write, the same screening a bare top-level key gets. */
71
+ export const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole"];
87
72
  /** The fixed-shape top-level sections `config get/set/unset` reaches one field of with a
88
73
  * two-segment key (`review.model`, `bootstrap.untilPlansDone`), each with its field list. */
89
74
  export const DOTTED_SECTION_FIELDS = {
@@ -97,26 +82,36 @@ export const DOTTED_SECTION_FIELDS = {
97
82
  * (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
98
83
  * unknown-key errors attach. */
99
84
  const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "backends", ...Object.keys(DOTTED_SECTION_FIELDS)];
100
- /** Split a config key into its write/get shape: `foo` is top-level,
101
- * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
102
- * `roles.qa.model` names one role-entry field, and anything else dotted is an error
103
- * naming the supported dotted forms. */
104
- export function parseConfigKey(key) {
105
- if (!key.includes("."))
106
- return { kind: "top", key };
107
- const parts = key.split(".");
85
+ /** The per-role map entry `maxDailyCostUsdPerRole.<role>` / `quietHoursPerRole.<role>` — a
86
+ * whole-value bare key with no deeper segment names one map entry. Returns undefined when
87
+ * the shape is not that of a two-segment per-role map entry. */
88
+ function parseMapEntry(parts) {
108
89
  const first = parts[0] ?? "";
109
90
  const second = parts.length > 1 ? parts[1] ?? "" : undefined;
110
91
  const third = parts.length > 2 ? parts[2] ?? "" : undefined;
111
92
  if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
112
93
  return { kind: "map", map: first, role: second };
94
+ return undefined;
95
+ }
96
+ /** One role-entry field `roles.<id>.<field>` (e.g. `roles.qa.model`), with both segments
97
+ * non-empty. Returns undefined when the shape is not a three-segment `roles.` key. */
98
+ function parseRoleField(parts) {
99
+ const first = parts[0] ?? "";
100
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
101
+ const third = parts.length > 2 ? parts[2] ?? "" : undefined;
113
102
  if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
114
103
  return { kind: "role", id: second, field: third };
115
- // `backends.<id>.<field>` names one field of one agent backend's entry (the way
116
- // `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
117
- // which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
104
+ return undefined;
105
+ }
106
+ /** `backends.<id>.<field>` names one field of one agent backend's entry (the way
107
+ * `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
108
+ * which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named. */
109
+ function parseBackendField(parts) {
110
+ const first = parts[0] ?? "";
111
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
112
+ const third = parts.length > 2 ? parts[2] ?? "" : undefined;
118
113
  if (parts.length === 3 && first === "backends" && second !== undefined && second !== "" && third !== undefined) {
119
- // configDir/windowReserve are claude/codex-only (CLAUDE_AGENT_KEYS / CODEX_BACKEND_KEYS):
114
+ // configDir is claude/codex-only (CLAUDE_AGENT_KEYS / CODEX_BACKEND_KEYS):
120
115
  // a config set backends.pi.configDir would write a key nothing reads, so it is refused
121
116
  // everywhere but claude and codex.
122
117
  const validKeys = second === "claude" ? CLAUDE_AGENT_KEYS : second === "codex" ? CODEX_BACKEND_KEYS : BACKEND_KEYS;
@@ -127,10 +122,15 @@ export function parseConfigKey(key) {
127
122
  error: `unknown backend field "${third}" for backends.${second} (valid fields: ${validKeys.join(", ")})${typoSuffix(third, validKeys)}`,
128
123
  };
129
124
  }
130
- // `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
131
- // names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
132
- // must be a real tier: `config set model.turbo x` would otherwise write a dead map key
133
- // the runtime (and doctor's checkKnownKeys) would silently never resolve.
125
+ return undefined;
126
+ }
127
+ /** `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
128
+ * names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
129
+ * must be a real tier: `config set model.turbo x` would otherwise write a dead map key
130
+ * the runtime (and doctor's checkKnownKeys) would silently never resolve. */
131
+ function parseModelTier(parts) {
132
+ const first = parts[0] ?? "";
133
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
134
134
  if (parts.length === 2 && first === "model") {
135
135
  if ((TIER_MAP_KEYS).includes(second ?? ""))
136
136
  return { kind: "tier", map: "model", tier: second };
@@ -139,8 +139,13 @@ export function parseConfigKey(key) {
139
139
  error: `unknown model tier "${second ?? ""}" — a dotted \`model.<tier>\` names one of ${TIER_MAP_KEYS.join(", ")} (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`)${typoSuffix(second ?? "", TIER_MAP_KEYS)}`,
140
140
  };
141
141
  }
142
- // `<section>.<field>` names one field of a fixed-shape section (review, check, idleBackoff,
143
- // bootstrap); the field must be one the schema lists, or the write would be a dead key.
142
+ return undefined;
143
+ }
144
+ /** `<section>.<field>` names one field of a fixed-shape section (review, check, idleBackoff,
145
+ * bootstrap); the field must be one the schema lists, or the write would be a dead key. */
146
+ function parseSectionField(parts) {
147
+ const first = parts[0] ?? "";
148
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
144
149
  if (parts.length === 2 && Object.hasOwn(DOTTED_SECTION_FIELDS, first)) {
145
150
  const section = first;
146
151
  const fields = DOTTED_SECTION_FIELDS[section];
@@ -151,9 +156,14 @@ export function parseConfigKey(key) {
151
156
  error: `unknown ${first} field "${second ?? ""}" (valid fields: ${fields.join(", ")})${typoSuffix(second ?? "", fields)}`,
152
157
  };
153
158
  }
154
- // A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
155
- // `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
156
- // suggesting that head back would mislead — the generic wording is the whole story there.
159
+ return undefined;
160
+ }
161
+ /** The generic dotted-shape rejection: the head is not one of the accepted shapes in a way
162
+ * the earlier parsers would have claimed, so name the supported dotted forms wholesale. A
163
+ * typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`, `modle.small`);
164
+ * when the head is one of the valid ones the shape itself is wrong, so suggesting that head
165
+ * back would mislead — hence the hint only when the head is unknown. */
166
+ function dottedShapeError(key, first) {
157
167
  const headHint = DOTTED_KEY_HEADS.includes(first)
158
168
  ? ""
159
169
  : typoSuffix(first, DOTTED_KEY_HEADS);
@@ -162,6 +172,22 @@ export function parseConfigKey(key) {
162
172
  error: `unknown config key "${key}" — dotted keys name one entry of a per-role map or one field of a role entry (e.g. \`maxDailyCostUsdPerRole.feature 1.5\`, \`roles.qa.model x\`, \`review.model x\`); a bare key names a whole top-level value${headHint}`,
163
173
  };
164
174
  }
175
+ /** Split a config key into its write/get shape: `foo` is top-level,
176
+ * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
177
+ * `roles.qa.model` names one role-entry field, and anything else dotted is an error
178
+ * naming the supported dotted forms. */
179
+ export function parseConfigKey(key) {
180
+ if (!key.includes("."))
181
+ return { kind: "top", key };
182
+ const parts = key.split(".");
183
+ const first = parts[0] ?? "";
184
+ return (parseMapEntry(parts) ??
185
+ parseRoleField(parts) ??
186
+ parseBackendField(parts) ??
187
+ parseModelTier(parts) ??
188
+ parseSectionField(parts) ??
189
+ dottedShapeError(key, first));
190
+ }
165
191
  /** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
166
192
  * key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
167
193
  * or null when the key is known. One home for the phrasing so whichever verb misspells a
@@ -5,9 +5,9 @@ import { bestEffortEventLogger, TUMWATER_SOURCE } from "../events/events.js";
5
5
  import { RoleRunner } from "../runner/runner.js";
6
6
  import { configPath } from "../paths.js";
7
7
  import { errorMessage } from "../text/text.js";
8
- import { backendsInUse, seedOpencodeCatalog } from "../gates/startup-gate.js";
8
+ import { backendsInUse, seedOpencodeCatalog } from "../gates/startup-gates.js";
9
9
  import { seedClaudeAccount } from "../agent/claude/claude-account.js";
10
- import { seedCodexAccount, readCodexAccount } from "../agent/codex/codex-account.js";
10
+ import { codexApiKeyRefusalMessage, seedCodexAccount, readCodexAccount } from "../agent/codex/codex-account.js";
11
11
  import { backendMissingMessage, findBackendBinary, resolveBackendBin } from "../pi/pi-bin.js";
12
12
  /** A live config reloader over the given wiring; poll it once per scheduler cycle (see
13
13
  * LiveConfigReload for the contract). */
@@ -26,10 +26,6 @@ export function newLiveConfigReload(deps) {
26
26
  // The cap last applied to the semaphore (live-resized on each reload), so a change logs
27
27
  // exactly one event per distinct value — not once per poll.
28
28
  let lastMaxConcurrent = Math.max(1, deps.config.maxConcurrent);
29
- // The per-provider caps last applied to the semaphore (live-resized on each reload), so a
30
- // change logs exactly one event per distinct value — not once per poll. Seeded from the
31
- // startup config, so the first poll of an unchanged file emits nothing.
32
- let lastKeyCaps = deps.config.maxConcurrentPerProvider ?? {};
33
29
  // Roots this process has already attempted a live-edit claude-account seed for (see the
34
30
  // reload block below), so repeated polls of a config that keeps a claude seam cannot respawn
35
31
  // `claude auth status` on every cycle.
@@ -86,7 +82,7 @@ export function newLiveConfigReload(deps) {
86
82
  if (codexLiveRefused)
87
83
  return true;
88
84
  codexLiveRefused = true;
89
- warn("refusing live edit: Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`");
85
+ warn(`refusing live edit: ${codexApiKeyRefusalMessage("<home>")}`);
90
86
  return true;
91
87
  }
92
88
  // A poll that did not refuse clears the warn-once marker so a later re-entry into the
@@ -154,7 +150,7 @@ export function newLiveConfigReload(deps) {
154
150
  }
155
151
  }
156
152
  }
157
- // Live-resize the concurrency cap and the per-provider key caps.
153
+ // Live-resize the concurrency cap.
158
154
  function resizeConcurrency(nextConfig) {
159
155
  // Live-resize the concurrency cap: a mid-run edit changes how many pi runs execute
160
156
  // concurrently within this poll — no restart. Growing admits already-queued runs;
@@ -165,26 +161,6 @@ export function newLiveConfigReload(deps) {
165
161
  emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
166
162
  lastMaxConcurrent = newMaxConcurrent;
167
163
  }
168
- // Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
169
- // re-applies the whole map to the pool, and each changed provider fires its own
170
- // `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
171
- // withholds future grants on that key until releases drain it under the new cap.
172
- const nextKeyCaps = nextConfig.maxConcurrentPerProvider ?? {};
173
- deps.semaphore.setKeyCaps(nextKeyCaps);
174
- for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
175
- const from = lastKeyCaps[provider];
176
- const to = nextKeyCaps[provider];
177
- if (from !== to) {
178
- emit({
179
- source: TUMWATER_SOURCE,
180
- type: "provider_concurrency_changed",
181
- provider,
182
- from: from ?? 0,
183
- to: to ?? 0,
184
- });
185
- }
186
- }
187
- lastKeyCaps = nextKeyCaps;
188
164
  }
189
165
  // Sync the runner list to newly enabled roles and warn on enable/disable.
190
166
  function syncRunners(nextConfig) {
@@ -65,12 +65,12 @@ const BACKEND_DEFAULT_MODELS = {
65
65
  // belongs in tumwater.json, not in code.
66
66
  opencode: {},
67
67
  // codex's tier defaults (plans/codex-backend.md decision 4), from the catalog's own
68
- // descriptions (design finding 14): strong is `gpt-6-astra`, default is `gpt-6.1-sol` (Codex's
69
- // own default), small is `gpt-6-luna`. The director runs the strong tier, as on claude.
68
+ // descriptions (design finding 14): strong is `gpt-6.1-sol`, default and small are
69
+ // `gpt-6-luna`. The director runs the strong tier, as on claude.
70
70
  codex: {
71
71
  small: "gpt-6-luna",
72
- default: "gpt-6.1-sol",
73
- strong: "gpt-6-astra",
72
+ default: "gpt-6-luna",
73
+ strong: "gpt-6.1-sol",
74
74
  },
75
75
  };
76
76
  /** The backend and concrete model a resolved seam's provider/model pair routes to