tumwater 0.4.2 → 0.4.3

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 (305) 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} +25 -12
  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 +1 -8
  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 +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -1,4 +1,3 @@
1
- import { sortEntriesByKey } from "../collections.js";
2
1
  import { utilizationPct, windowLabel } from "../agent/subscription-account.js";
3
2
  import { MODEL_TIERS } from "../config/config-schema.js";
4
3
  import { budgetGate, budgetReached, fallbackTierEntries } from "../budget/budget.js";
@@ -383,21 +382,12 @@ export function diskBadge(disk) {
383
382
  ? ` · disk ${free}`
384
383
  : "";
385
384
  }
386
- /** The permit pool's header fragment (plans/provider-concurrency.md §8): `slots 5/8 · omlx
387
- * 3/3 · claude 2/6` — the team total's in-use/cap followed by each capped provider key's own
388
- * in-use/cap. The WHOLE fragment appears only while at least one key carries a cap (the
389
- * `maxConcurrentPerProvider` map is set), so a team without it renders the header exactly
390
- * as before; uncapped keys that happen to be in the snapshot are dropped rather than shown
391
- * with a bare in-use that would read like a cap. */
385
+ /** The permit pool's header fragment (plans/provider-concurrency.md §8): `slots 5/8` — the
386
+ * team total's in-use/cap. Empty when no snapshot was published. */
392
387
  export function permitsBadge(permits) {
393
388
  if (!permits)
394
389
  return "";
395
- const providers = sortEntriesByKey(Object.entries(permits.byKey).filter(([, e]) => e.limit !== undefined))
396
- .map(([k, e]) => `${k} ${e.inUse}/${e.limit}`)
397
- .join(" · ");
398
- if (providers.length === 0)
399
- return "";
400
- return ` · running ${permits.inUse} of ${permits.limit} · ${providers}`;
390
+ return ` · running ${permits.inUse} of ${permits.limit}`;
401
391
  }
402
392
  /** The check-permit pool's reading, ` · checks 1/2` plus `, 3 waiting` while checks queue. Empty
403
393
  * when no check runs or waits, so an idle check pool leaves the header byte-identical. */
@@ -73,13 +73,13 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
73
73
  // submit listener saves the row's key (saveSetting, gui-client-settings). No per-row wiring,
74
74
  // so rows can repaint freely.
75
75
  document.addEventListener("submit", (ev) => {
76
- const form = ev.target instanceof Element ? ev.target.closest("form.settings-row") : null;
76
+ const form = clickClosest(ev, "form.settings-row");
77
77
  if (form) { ev.preventDefault(); saveSetting(form); }
78
78
  });
79
79
  // The Brief card's Save: its own form class, so the settings listener above never
80
80
  // routes it to saveSetting.
81
81
  document.addEventListener("submit", (ev) => {
82
- const form = ev.target instanceof Element ? ev.target.closest("form.brief-form") : null;
82
+ const form = clickClosest(ev, "form.brief-form");
83
83
  if (form) { ev.preventDefault(); saveBrief(form); }
84
84
  });
85
85
  // The Settings view's Clear buttons: one delegated click listener resolves the row's Clear
@@ -221,7 +221,7 @@ export const GUI_CLIENT_COMPOSER_JS = String.raw ` // ---- composer: one box fo
221
221
  "<button type='button' data-idx='" + i + "' title='Remove' aria-label='Remove " + esc(img.name) + "'>×</button></span>").join("");
222
222
  }
223
223
  $("promptimages").addEventListener("click", (ev) => {
224
- const b = ev.target instanceof Element ? ev.target.closest("button[data-idx]") : null;
224
+ const b = clickClosest(ev, "button[data-idx]");
225
225
  if (!b) return;
226
226
  promptImages.splice(Number(b.dataset.idx), 1);
227
227
  renderPromptImages();
@@ -7,22 +7,20 @@
7
7
  export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
8
8
  // A Configuration section built from the same /api/role payload the CLI's tumwater role
9
9
  // <id> renders (rolePayload), so the drawer's and the terminal's rows read one document and
10
- // cannot drift: what the role RUNS ON (model/tier, provider cap, image reading, the
10
+ // cannot drift: what the role RUNS ON (model/tier, image reading, the
11
11
  // fallback pairs, billing, min run interval) and CARRIES (queued prompts, the instructions
12
12
  // override, find text). Each row is guarded by a present/own check so an absent field
13
- // renders no row — a cap-less team shows no Provider row, no fallback shows no fallback
14
- // rows, and a notes-only payload adds an empty (omitted) section. Labels and wording mirror
13
+ // renders no row — no fallback shows no fallback rows, and a notes-only payload adds an
14
+ // empty (omitted) section. Labels and wording mirror
15
15
  // role-render.ts's family; no backticks allowed here (this whole script is one big
16
16
  // String.raw template).
17
17
  function drawerConfigHtml(view, role) {
18
18
  const pair = (p, m) => [p, m].filter(Boolean).join("/");
19
19
  // A fallback pair's label, mirroring role-render.ts's fallbackPairLabel so the drawer's
20
- // Model fallback and Budget fallback rows name a non-pi backend's namespace the same way
21
- // the CLI's fallback lines do: a non-pi backend with a model reads <backend>/<model>, else
22
- // <provider>/<model>, else the model, else the provider, else the pair-less default. The
23
- // pi provider/model cases and a budget pair (whose provider already holds the backend) stay
24
- // byte-identical; only a model-fallback non-pi pair (modelFallbackView drops pi's provider
25
- // for a non-pi backend) gains the namespace the CLI already shows.
20
+ // Budget fallback row names a non-pi backend's namespace the same way the CLI's fallback
21
+ // lines do: a non-pi backend with a model reads <backend>/<model>, else <provider>/<model>,
22
+ // else the model, else the provider, else the pair-less default. A budget pair's provider
23
+ // already holds the backend, so its row stays byte-identical.
26
24
  // Local wall-clock HH:MM for the role-cap forecast's ~cap at stamp — the drawer is browser
27
25
  // code and cannot import ../text/datetime.js (the quietWindowEnd precedent's own note), so
28
26
  // it routes through the bundle's shared clockHm in FORMAT_JS, the same helper the operator
@@ -92,9 +90,6 @@ export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
92
90
  if (view.contextWindow !== undefined) {
93
91
  rows.push(kvRow("Context window", view.contextWindow === null ? "unknown" : fmtTokens(view.contextWindow) + " tokens"));
94
92
  }
95
- if (view.providerKey !== undefined && view.providerCap !== undefined) {
96
- rows.push(kvRow("Provider", view.providerKey + " (cap " + view.providerCap + ")"));
97
- }
98
93
  if (view.imageInput !== undefined) {
99
94
  const images = view.imageInput === true ? "yes"
100
95
  : view.imageInput === false ? (view.imageReroute ? "no — reruns on " + view.imageReroute : "no")
@@ -115,11 +110,6 @@ export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
115
110
  const revControl = "<button type='button' class='btn btn-sm' data-act='review-toggle' data-arg='" + esc(role) + "'>" + (revAfter ? "Review before merge" : "Review after merge") + "</button>";
116
111
  rows.push("<div><dt>Review</dt><dd title='" + esc(revBody) + "'>" + esc(revBody) + revControl + "</dd></div>");
117
112
  }
118
- if (view.modelFallback && typeof view.modelFallback === "object" && view.modelFallback.model !== undefined) {
119
- const fb = view.modelFallback;
120
- const when = typeof fb.since === "number" ? " since " + fmtAgo(fb.since) : "";
121
- rows.push(kvRow("Model fallback", fallbackLabel(fb, "") + " — on fallback" + when + " (primary failing: " + (fb.reason ?? "") + ")"));
122
- }
123
113
  if (view.fallback && typeof view.fallback === "object" && view.fallback.model !== undefined) {
124
114
  rows.push(kvRow("Budget fallback", fallbackLabel(view.fallback, "") + " (" + (view.fallbackFree ? "free" : "priced") + ")"));
125
115
  }
@@ -208,7 +208,7 @@ export const GUI_CLIENT_DRAWER_JS = String.raw ` let drawer = null; // { kind:
208
208
  const commits = Array.isArray(view.commits) ? view.commits : [];
209
209
  const dirty = Array.isArray(view.dirtyFiles) ? view.dirtyFiles : [];
210
210
  if (view.state === "absent") return "<p class='muted'>No workspace yet — this role has not run.</p>";
211
- if (view.state === "no-base") return "<p class='muted'>Main branch " + esc(view.mainBranch) + " does not exist.</p>";
211
+ if (view.state === "no-base") return "<p class='muted'>" + mainBranchMissingPhrase(view.mainBranch) + ".</p>";
212
212
  const scoped = typeof view.path === "string";
213
213
  if (scoped && view.state === "ready" && commits.length === 0 && dirty.length === 0) {
214
214
  return "<p class='muted'>No pending change to " + esc(view.path) + ".</p>";
@@ -71,13 +71,13 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
71
71
  fetchHistory();
72
72
  });
73
73
  $("histfilter").addEventListener("click", (ev) => {
74
- const b = ev.target.closest("button[data-hist]");
74
+ const b = clickClosest(ev, "button[data-hist]");
75
75
  if (!b) return;
76
76
  histFilter = b.dataset.hist;
77
77
  renderHistory();
78
78
  });
79
79
  $("histsum").addEventListener("click", (ev) => {
80
- const b = ev.target.closest("button[data-histsum]");
80
+ const b = clickClosest(ev, "button[data-histsum]");
81
81
  if (!b) return;
82
82
  histMode = b.dataset.histsum;
83
83
  store("hist-mode", histMode);
@@ -98,7 +98,7 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
98
98
  // data-rundetail button).
99
99
  // A By model row's label drills into that model's runs: the table, filtered to the selector.
100
100
  $("histbody").addEventListener("click", (ev) => {
101
- const b = ev.target instanceof Element ? ev.target.closest("button[data-histmodel]") : null;
101
+ const b = clickClosest(ev, "button[data-histmodel]");
102
102
  if (!b) return;
103
103
  histModel = b.dataset.histmodel;
104
104
  histMode = "table";
@@ -106,43 +106,43 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
106
106
  fetchHistory();
107
107
  });
108
108
  $("histbody").addEventListener("click", (ev) => {
109
- const b = ev.target instanceof Element ? ev.target.closest("button[data-rundetail]") : null;
109
+ const b = clickClosest(ev, "button[data-rundetail]");
110
110
  if (!b) return;
111
111
  toggleHistDetail(b.dataset.role, b.dataset.run);
112
112
  });
113
113
  // The drill-down's revert buttons: the first click previews, the preview's own button commits.
114
114
  $("histbody").addEventListener("click", (ev) => {
115
- const b = ev.target instanceof Element ? ev.target.closest("button[data-hist-revert], button[data-hist-revert-go]") : null;
115
+ const b = clickClosest(ev, "button[data-hist-revert], button[data-hist-revert-go]");
116
116
  if (!b) return;
117
117
  if (b.dataset.histRevertGo !== undefined) runHistRevert(b.dataset.histRevertGo);
118
118
  else previewHistRevert(b.dataset.histRevert);
119
119
  });
120
120
  // The drill-down's "Show patch" / "Hide patch" button.
121
121
  $("histbody").addEventListener("click", (ev) => {
122
- const b = ev.target instanceof Element ? ev.target.closest("button[data-hist-patch]") : null;
122
+ const b = clickClosest(ev, "button[data-hist-patch]");
123
123
  if (!b) return;
124
124
  toggleHistPatch(b.dataset.histPatch);
125
125
  });
126
126
  // The drill-down's "Show transcript" / "Hide transcript" button.
127
127
  $("histbody").addEventListener("click", (ev) => {
128
- const b = ev.target instanceof Element ? ev.target.closest("button[data-hist-transcript]") : null;
128
+ const b = clickClosest(ev, "button[data-hist-transcript]");
129
129
  if (!b) return;
130
130
  toggleHistTranscript(b.dataset.histTranscript);
131
131
  });
132
132
  // The open transcript's "Show prompts" / "Hide prompts" button.
133
133
  $("histbody").addEventListener("click", (ev) => {
134
- const b = ev.target instanceof Element ? ev.target.closest("button[data-hist-transcript-prompts]") : null;
134
+ const b = clickClosest(ev, "button[data-hist-transcript-prompts]");
135
135
  if (!b) return;
136
136
  toggleHistTranscriptPrompts(b.dataset.histTranscriptPrompts);
137
137
  });
138
138
  // The open transcript's search box (applied on Enter or blur: the card re-renders each change).
139
139
  $("histbody").addEventListener("change", (ev) => {
140
- const el = ev.target instanceof Element ? ev.target.closest("input[data-hist-transcript-grep]") : null;
140
+ const el = clickClosest(ev, "input[data-hist-transcript-grep]");
141
141
  if (el) searchHistTranscript(el.dataset.histTranscriptGrep, el.value);
142
142
  });
143
143
  // The pinned newest-failure card's × closes it.
144
144
  $("histbody").addEventListener("click", (ev) => {
145
- const b = ev.target instanceof Element ? ev.target.closest("button[data-hist-newest-close]") : null;
145
+ const b = clickClosest(ev, "button[data-hist-newest-close]");
146
146
  if (!b) return;
147
147
  closeHistNewest();
148
148
  });
@@ -187,11 +187,16 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
187
187
  renderHistory();
188
188
  try {
189
189
  const d = await getJson("/api/run?last=1&result=problems");
190
- // Name the endpoint and the run it answered for: a 200 without a text or role field is a
191
- // server too old (or too new) for this dashboard's client, not a lost run.
190
+ // Name the endpoint, the run it answered for, and the actually missing field: a 200
191
+ // without either text or role is a server too old (or too new) for this dashboard's
192
+ // client, not a lost run. text is the usual miss (the card would render nothing) and
193
+ // role decides the card's cache key, so name role when text is present but the role
194
+ // is not rather than blaming text for a field the reply did carry.
192
195
  if (!d || typeof d.text !== "string" || typeof d.role !== "string")
193
196
  throw new Error(
194
- "/api/run answered the newest failing run without a text field — the running tumwater may not match this dashboard",
197
+ "/api/run answered the newest failing run without a " +
198
+ (!d || typeof d.text !== "string" ? "text" : "role") +
199
+ " field — the running tumwater may not match this dashboard",
195
200
  );
196
201
  const key = histDetailKey(d.role, d.run);
197
202
  histDetails.set(key, histDetailOk(d));
@@ -299,7 +304,16 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
299
304
  // drift apart: every one fetches runApiUrl, then insists the reply carries its field.
300
305
  async function fetchRunField(key, query, field) {
301
306
  const d = await getJson(runApiUrl(key, query));
302
- if (!d || typeof d[field] !== "string") throw new Error("/api/run answered without a " + field + " field");
307
+ if (!d || typeof d[field] !== "string") {
308
+ // Name the endpoint, the run it answered for, and the likely cause — the same
309
+ // ownership fetchHistDetail and openHistNewest give their /api/run errors: a 200
310
+ // without the promised field is a server too old (or too new) for this dashboard's
311
+ // client, not a silently dropped patch or transcript.
312
+ const at = key.lastIndexOf("#");
313
+ throw new Error(
314
+ "/api/run answered " + key.slice(0, at) + " run " + key.slice(at + 1) + " without a " + field + " field — the running tumwater may not match this dashboard",
315
+ );
316
+ }
303
317
  return d;
304
318
  }
305
319
  // Fetch or toggle one card add-on field (the patch or the transcript) at key ("role#run").
@@ -29,18 +29,6 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
29
29
  if (p.startsWith("merging")) return { key: "landing", label: "Merging", tone: "orange", live: true, detail: after(7) }; // vocabulary-ok: internal key the row classes and filters match
30
30
  if (p.startsWith("approved")) return { key: "vetted", label: "Approved", tone: "indigo", live: false, detail: "waiting to merge" }; // vocabulary-ok: internal key the row classes and filters match
31
31
  if (p.startsWith("waiting to start")) return { key: "awaiting", label: "Waiting to start", tone: "gray", live: false, detail: after(16) ? "for " + after(16) : "" };
32
- // A waiter parked on a full provider key (provider-concurrency part 5/5): the phase reads
33
- // waiting for <key> 2m, and this chip names the provider alongside the wait, so the
34
- // operator sees which cap is full — the same chip and tone as a team-total waiter.
35
- if (p.startsWith("waiting for ")) {
36
- const m = after(12).match(/^(\S+)(?:\s+(.*))?$/);
37
- const prov = m ? m[1] : "";
38
- const dur = m && m[2] ? m[2].trim() : "";
39
- return {
40
- key: "awaiting", label: "Waiting to start", tone: "gray", live: false,
41
- detail: (prov ? "on " + prov : "") + (dur ? " for " + dur : ""),
42
- };
43
- }
44
32
  if (p === "failing") return { key: "failing", label: "Failing", tone: "red", live: false, detail: "failed runs in a row" };
45
33
  if (p === "main's check is failing") return { key: "mainred", label: "Main's check failing", tone: "red", live: false, detail: "change pinned until main goes green" };
46
34
  if (p.startsWith("paused")) return { key: "paused", label: "Paused", tone: "amber", live: false, detail: "starts no new runs" };
@@ -58,7 +58,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
58
58
  if (!team || !Array.isArray(team.roles) || team.roles.length === 0)
59
59
  return "<div class='empty'><strong>No roles yet</strong>Roles appear here once the team is configured.</div>";
60
60
  if (team.roles.every((r) => r.state === "no-base"))
61
- return "<div class='empty'><strong>Main branch " + esc(team.mainBranch) + " does not exist</strong>There is no base to compare unmerged work against.</div>";
61
+ return "<div class='empty'><strong>" + mainBranchMissingPhrase(team.mainBranch) + "</strong>There is no base to compare unmerged work against.</div>";
62
62
  const rows = team.roles.map((r) =>
63
63
  "<tr class='clickable' data-open='" + esc(r.role) + "'>" +
64
64
  "<td class='mono'>" + esc(roleLabel(r.role)) + "</td>" +
@@ -202,7 +202,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
202
202
  let body;
203
203
  let head = "<div class='card-head'><h2>Overlapping files</h2></div>";
204
204
  if (ok && view.state === "no-base") {
205
- body = "<div class='empty'>Main branch " + esc(view.mainBranch) + " does not exist</div>";
205
+ body = "<div class='empty'>" + mainBranchMissingPhrase(view.mainBranch) + "</div>";
206
206
  } else if (!ok || !Array.isArray(view.overlaps) || view.overlaps.length === 0) {
207
207
  body = "<div class='empty'>No file is touched by two pending changes</div>";
208
208
  } else {
@@ -267,6 +267,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
267
267
  case "fixed":
268
268
  return "[fixed — the repair " + shortSha(e.fixed) + " already merged on main]";
269
269
  case "rejected":
270
+ if (!e.rejected || e.rejected.length === 0) return "[rejected]";
270
271
  return "[rejected — " + e.rejected.map((r) => squashText(r, 60)).join("; ") + "]";
271
272
  case "failing":
272
273
  if (e && typeof e.lastFailure === "string" && e.lastFailure.length > 0) {
@@ -388,7 +389,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
388
389
  // A Merge queue row's Move to top: a single click posts /api/queue-top by sha, moving
389
390
  // that entry to the head of the queue and refetching (the move is reversible and
390
391
  // non-destructive, so it needs no two-step arm).
391
- const t = e.target.closest("[data-top]");
392
+ const t = clickClosest(e, "[data-top]");
392
393
  if (t) {
393
394
  const sha = t.getAttribute("data-top");
394
395
  if (sha) {
@@ -397,7 +398,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
397
398
  }
398
399
  return;
399
400
  }
400
- const b = e.target.closest("[data-cancel]");
401
+ const b = clickClosest(e, "[data-cancel]");
401
402
  if (!b) return;
402
403
  const sha = b.getAttribute("data-cancel");
403
404
  if (!sha) return;
@@ -415,7 +416,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
415
416
  // (repainting that button as Confirm accept), a second on the armed commit within 5 s posts
416
417
  // /api/post-review-accept then clears the arm and refetches so the removed row disappears.
417
418
  $("pendingreviews").addEventListener("click", async (e) => {
418
- const b = e.target.closest("[data-accept]");
419
+ const b = clickClosest(e, "[data-accept]");
419
420
  if (!b) return;
420
421
  const commit = b.getAttribute("data-accept");
421
422
  if (!commit) return;
@@ -445,7 +446,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
445
446
  }
446
447
  });
447
448
  $("pendingoverlaps").addEventListener("click", (e) => {
448
- const b = e.target.closest("[data-overlap]");
449
+ const b = clickClosest(e, "[data-overlap]");
449
450
  if (b) togglePendingOverlap(Number(b.dataset.overlap));
450
451
  });
451
452
  $("pendingoverlaps").addEventListener("change", (e) => {
@@ -48,7 +48,7 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
48
48
  // Object.prototype key whose inherited value would otherwise seed the counter and satisfy
49
49
  // the membership test (the browser twin of addTo's fix; BUGS.md 2026-10-10).
50
50
  function reportRoleOrder(data) {
51
- return tallyAcross(data.series, ["runsByRole"], ["costByRole", "notionalByRole", "rejectionsByRole", "revisionsByRole", "refusalsByRole", "fallbacksByRole", "failedRunsByRole", "mergeFailedByRole", "commitsByRole", "mergedByRole"]);
51
+ return tallyAcross(data.series, ["runsByRole"], ["costByRole", "notionalByRole", "rejectionsByRole", "revisionsByRole", "refusalsByRole", "imageReroutesByRole", "failedRunsByRole", "mergeFailedByRole", "commitsByRole", "mergedByRole"]);
52
52
  }
53
53
 
54
54
  // X-axis labels: MM-DD, thinned to at most seven, counted back from the newest day so today
@@ -371,10 +371,10 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
371
371
  function chartRefusalsByRole(data) {
372
372
  return roleStackChart(data, "refusalsByRole", (n) => plural(n, "refusal"), countAxis, true);
373
373
  }
374
- // The team's model-fallback episodes, per role, on the same count axis: one segment per
375
- // (day, role) with a fallback.
376
- function chartFallbacksByRole(data) {
377
- return roleStackChart(data, "fallbacksByRole", (n) => plural(n, "fallback"), countAxis, true);
374
+ // Image reroutes per role, drawn like the other role stacks: one segment per (day, role)
375
+ // with a reroute.
376
+ function chartImageReroutesByRole(data) {
377
+ return roleStackChart(data, "imageReroutesByRole", (n) => plural(n, "image reroute"), countAxis, true);
378
378
  }
379
379
  // Runs that ended in an error, per role, drawn the same way.
380
380
  function chartFailedRunsByRole(data) {
@@ -335,7 +335,7 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
335
335
  (seriesHasAny(d, "postMergeRejectionsByRole") ? block("Post-merge rejections by role", chartPostMergeRejectionsByRole(d)) : "") +
336
336
  (seriesHasAny(d, "revisionsByRole") ? block("Revisions by role", chartRevisionsByRole(d)) : "") +
337
337
  (seriesHasAny(d, "refusalsByRole") ? block("Refusals by role", chartRefusalsByRole(d)) : "") +
338
- (seriesHasAny(d, "fallbacksByRole") ? block("Fallbacks by role", chartFallbacksByRole(d)) : "") +
338
+ (seriesHasAny(d, "imageReroutesByRole") ? block("Image reroutes by role", chartImageReroutesByRole(d)) : "") +
339
339
  (seriesHasAny(d, "failedRunsByRole") ? block("Failed runs by role", chartFailedRunsByRole(d)) : "") +
340
340
  (seriesHasAny(d, "mergedByRole") ? block("Merged by role", chartMergedByRole(d)) : "") +
341
341
  (!d.role && seriesHasAny(d, "runsByModel") ? block("Runs by model", chartRunsByModel(d)) : "") +
@@ -163,7 +163,6 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
163
163
  const name = "<div class='role-name'><button type='button' class='linkish role' data-open='" + esc(l.role) + "' title='Open " + esc(roleLabel(l.role)) + "'>" + esc(roleLabel(l.role)) + "</button>" +
164
164
  (l.custom ? "<span class='tag' title='A custom role from tumwater.json'>custom</span>" : "") +
165
165
  (l.modelTier ? "<span class='tag' title='The model tier this role resolves at'>" + esc(l.modelTier) + "</span>" : "") +
166
- (l.fallback ? "<span class='tag t-amber' title='Running on the model fallback while its primary keeps failing provider-class errors'>fallback</span>" : "") +
167
166
  (queued ? "<span class='tag t-indigo' title='" + esc(plural(queued, "message") + " in this role's inbox") + "'>" + icon("chat") + queued + "</span>" : "") +
168
167
  "</div><div class='sub'>" + esc(plural(l.commits, "commit") + " · " + plural(l.runs, "run") + " · merged " + (l.lastMergedAt ? fmtAgo(l.lastMergedAt) : "never")) + (l.model ? " · " + esc(l.model) : "") +
169
168
  (l.modelFromPi ? " <span class='tag' title='pi&#39;s own default model, from its settings.json: tumwater.json names no model'>(pi default)</span>" : "") + "</div>";
@@ -34,10 +34,8 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
34
34
  ["fallback", "Fallback (free at the spend cap)"],
35
35
  ["maxDailyCostUsd", "Daily spend cap (USD)"],
36
36
  ["maxConcurrent", "Max concurrent runs"],
37
- ["maxConcurrentPerProvider", "Provider concurrency caps (JSON)"],
38
37
  ["maxConcurrentChecks", "Max concurrent checks"],
39
38
  ["worktreeSlots", "Worktree slots"],
40
- ["claudeWindowReserve", "Claude window reserve (backends.claude.windowReserve)"],
41
39
  ["quietHours", "Quiet hours"],
42
40
  ["notify", "Notify hook"],
43
41
  ["backends", "Agent backends (JSON)"],
@@ -162,13 +160,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
162
160
  // The row's Enable button: re-enable the role through POST /api/role-enable (writing
163
161
  // roles.<base>.enabled), flash the server's message, and refetch so the card drops the row.
164
162
  async function enableRole(role) {
165
- try {
166
- const d = await postJson("/api/role-enable", { role: role });
167
- showFlash(d.message);
168
- } catch (err) {
169
- showFlash("error: " + clientErrorMessage(err));
170
- }
171
- await fetchRetirable();
163
+ await postSetting("/api/role-enable", { role: role }, fetchRetirable, (d) => d.message);
172
164
  }
173
165
  // First click arms the role's button; a second within the confirm window posts the retire.
174
166
  async function confirmRetire(role) {
@@ -177,13 +169,42 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
177
169
  return;
178
170
  }
179
171
  retireSlot.disarm();
172
+ await postSetting("/api/retire", { role: role }, fetchRetirable, (d) => d.message);
173
+ }
174
+ // POST one Settings action and flash its outcome, then refetch the card it changed — the
175
+ // try/catch tail every Enable/Retire/Health-button handler shares, so the thrown-error
176
+ // coercion and the success/error wording live in one place. news derives the success line
177
+ // from the endpoint's answer: the default is the team-wide "ok: / error: " prefix, Enable
178
+ // and Retire pass the server's message through verbatim, and Retire-leftovers overrides the
179
+ // none-left fallback. Deliberately not named postAction (gui-client.ts's core helper, which
180
+ // closes menus, refreshes the standing poll, and does not refetch on failure — this one
181
+ // refetches the card its action changed even when the post failed).
182
+ async function postSetting(path, body, refetch, news) {
180
183
  try {
181
- const d = await postJson("/api/retire", { role: role });
182
- showFlash(d.message);
184
+ const d = await postJson(path, body);
185
+ showFlash(news ? news(d) : (d.ok ? "" : "error: ") + d.message);
183
186
  } catch (err) {
184
187
  showFlash("error: " + clientErrorMessage(err));
185
188
  }
186
- await fetchRetirable();
189
+ await refetch();
190
+ }
191
+ // POST one two-click Health-card action after its slot was armed by a prior click: the first
192
+ // click only arms the slot (the handler's button repaints to "Confirm …") and returns; a
193
+ // second click within the confirm window disarms, repaints, flashes the action line, posts,
194
+ // and re-runs the card's checks. The Health card's four repair buttons (Kill orphans, Drop
195
+ // broken pins, Retire leftovers, Archive backlog) share this one confirm shape: only the slot
196
+ // key, the paint fn, the flash line, the endpoint, and an optional news override (passed
197
+ // through to postSetting, e.g. Retire-leftovers' outcome-line override) differ, so they fold
198
+ // into this one helper instead of four copies of the arm/disarm/paint/flash/postSetting tail.
199
+ async function postConfirmedHealthAction(slot, key, paint, flash, path, news) {
200
+ if (!slot.armed(key)) {
201
+ slot.arm(key);
202
+ return;
203
+ }
204
+ slot.disarm();
205
+ paint();
206
+ showFlash(flash);
207
+ await postSetting(path, {}, fetchDoctor, news);
187
208
  }
188
209
  // The Health card's Kill orphans button: a two-click confirm, then POST /api/kill-orphans runs
189
210
  // the same kill as "tumwater doctor --kill-orphans" and the card re-runs its checks.
@@ -193,20 +214,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
193
214
  }
194
215
  const killSlot = confirmSlot(paintKillButton);
195
216
  async function killOrphansClick() {
196
- if (!killSlot.armed("orphans")) {
197
- killSlot.arm("orphans");
198
- return;
199
- }
200
- killSlot.disarm();
201
- paintKillButton();
202
- showFlash("Killing orphaned processes…");
203
- try {
204
- const d = await postJson("/api/kill-orphans", {});
205
- showFlash((d.ok ? "" : "error: ") + d.message);
206
- } catch (err) {
207
- showFlash("error: " + clientErrorMessage(err));
208
- }
209
- await fetchDoctor();
217
+ await postConfirmedHealthAction(killSlot, "orphans", paintKillButton, "Killing orphaned processes…", "/api/kill-orphans");
210
218
  }
211
219
  // The Health card's Drop broken pins button: a two-click confirm, then POST
212
220
  // /api/doctor-drop-broken-pins runs the same drop as "tumwater doctor --drop-broken-pins" and
@@ -217,20 +225,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
217
225
  }
218
226
  const dropSlot = confirmSlot(paintDropButton);
219
227
  async function dropBrokenPinsClick() {
220
- if (!dropSlot.armed("broken-pins")) {
221
- dropSlot.arm("broken-pins");
222
- return;
223
- }
224
- dropSlot.disarm();
225
- paintDropButton();
226
- showFlash("Dropping broken pins…");
227
- try {
228
- const d = await postJson("/api/doctor-drop-broken-pins", {});
229
- showFlash((d.ok ? "" : "error: ") + d.message);
230
- } catch (err) {
231
- showFlash("error: " + clientErrorMessage(err));
232
- }
233
- await fetchDoctor();
228
+ await postConfirmedHealthAction(dropSlot, "broken-pins", paintDropButton, "Dropping broken pins…", "/api/doctor-drop-broken-pins");
234
229
  }
235
230
  // The Health card's Retire leftovers button: a two-click confirm, then POST
236
231
  // /api/doctor-retire-leftovers runs the same reap as "tumwater doctor --retire-leftovers" and
@@ -242,22 +237,11 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
242
237
  }
243
238
  const retireLeftoversSlot = confirmSlot(paintRetireLeftoversButton);
244
239
  async function retireLeftoversClick() {
245
- if (!retireLeftoversSlot.armed("retire-leftovers")) {
246
- retireLeftoversSlot.arm("retire-leftovers");
247
- return;
248
- }
249
- retireLeftoversSlot.disarm();
250
- paintRetireLeftoversButton();
251
- showFlash("Retiring every disabled role's leftovers…");
252
- try {
253
- const d = await postJson("/api/doctor-retire-leftovers", {});
254
- // The shared leftoverRetireLines join is empty when nothing is retirable, so the
255
- // button falls back to the same none-left sentence the TUI Health key flashes.
256
- showFlash((d.ok ? "" : "error: ") + (d.message || "no leftover roles to retire"));
257
- } catch (err) {
258
- showFlash("error: " + clientErrorMessage(err));
259
- }
260
- await fetchDoctor();
240
+ // The news override's none-left fallback matches the none-left sentence the TUI Health key
241
+ // flashes when nothing is retirable: the shared leftoverRetireLines join is empty when
242
+ // nothing is retirable, so the button falls back to that same sentence.
243
+ await postConfirmedHealthAction(retireLeftoversSlot, "retire-leftovers", paintRetireLeftoversButton, "Retiring every disabled role's leftovers…", "/api/doctor-retire-leftovers",
244
+ (d) => (d.ok ? "" : "error: ") + (d.message || "no leftover roles to retire"));
261
245
  }
262
246
  // The Health card's Archive backlog button: a two-click confirm, then POST
263
247
  // /api/doctor-archive-backlog runs the same archive as "tumwater archive" (holding the merge
@@ -270,20 +254,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
270
254
  }
271
255
  const archiveBacklogSlot = confirmSlot(paintArchiveBacklogButton);
272
256
  async function archiveBacklogClick() {
273
- if (!archiveBacklogSlot.armed("archive-backlog")) {
274
- archiveBacklogSlot.arm("archive-backlog");
275
- return;
276
- }
277
- archiveBacklogSlot.disarm();
278
- paintArchiveBacklogButton();
279
- showFlash("Archiving the finished backlog entries…");
280
- try {
281
- const d = await postJson("/api/doctor-archive-backlog", {});
282
- showFlash((d.ok ? "" : "error: ") + d.message);
283
- } catch (err) {
284
- showFlash("error: " + clientErrorMessage(err));
285
- }
286
- await fetchDoctor();
257
+ await postConfirmedHealthAction(archiveBacklogSlot, "archive-backlog", paintArchiveBacklogButton, "Archiving the finished backlog entries…", "/api/doctor-archive-backlog");
287
258
  }
288
259
  // The Health card's Commit backlog button: a single click, because the commit is the doctor's
289
260
  // own recommended repair and is reversible (unlike the two-click Kill orphans arm), POSTing
@@ -292,13 +263,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
292
263
  // clean), then re-running the checks so the uncommitted-backlog row repaints.
293
264
  async function commitBacklogClick() {
294
265
  showFlash("Committing the uncommitted backlog/brief files…");
295
- try {
296
- const d = await postJson("/api/doctor-commit-backlog", {});
297
- showFlash((d.ok ? "" : "error: ") + d.message);
298
- } catch (err) {
299
- showFlash("error: " + clientErrorMessage(err));
300
- }
301
- await fetchDoctor();
266
+ await postSetting("/api/doctor-commit-backlog", {}, fetchDoctor);
302
267
  }
303
268
  // The Health card's Re-sync install button: a single click, because the re-sync is the doctor's
304
269
  // own recommended repair and is a no-op when the install already matches the lockfile, POSTing
@@ -307,13 +272,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
307
272
  // the checks so the root-install row and the install amber alert clear once the drift is gone.
308
273
  async function resyncInstallClick() {
309
274
  showFlash("Re-syncing the root install…");
310
- try {
311
- const d = await postJson("/api/doctor-resync-install", {});
312
- showFlash((d.ok ? "" : "error: ") + d.message);
313
- } catch (err) {
314
- showFlash("error: " + clientErrorMessage(err));
315
- }
316
- await fetchDoctor();
275
+ await postSetting("/api/doctor-resync-install", {}, fetchDoctor);
317
276
  }
318
277
  async function fetchSettings() {
319
278
  buildSettingsView();
@@ -95,13 +95,13 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
95
95
  : "");
96
96
  }
97
97
  // The permit pool (plans/provider-concurrency.md §8): the payload's preformatted
98
- // permitsBadge — running 5 of 8 · omlx 3/3 · claude 2/6 — upcased for the row's label
99
- // style, the same reading the TUI/status header carries, so the two surfaces cannot
100
- // drift. Empty on a map-less team, so the sidebar stays unchanged.
98
+ // permitsBadge — running 5 of 8 — upcased for the row's label style, the same reading
99
+ // the TUI/status header carries, so the two surfaces cannot drift. Present for any
100
+ // team with a published permit snapshot.
101
101
  if (d.permitsBadge) {
102
102
  const tok = String(d.permitsBadge).replace(/^ · /, "");
103
103
  rows += row("blue", icon("gauge"), esc(tok),
104
- "Roles running at once out of the team limit, then each capped provider's own running/cap; a role waits to start when its provider is at its cap.");
104
+ "Roles running at once out of the team limit (maxConcurrent); a role waits to start when the team is at its limit.");
105
105
  }
106
106
  // The check-permit pool: the payload's checksBadge, present only while a check runs or waits.
107
107
  if (d.checksBadge) {
@@ -151,6 +151,12 @@ const FORMAT_JS = String.raw ` // format:start
151
151
  // section had the byte-identical map; a missing or non-array list paints nothing.
152
152
  const commitRowsHtml = (commits) =>
153
153
  (Array.isArray(commits) ? commits : []).map((c) => "<div class='mono clamp1'>" + esc(c.sha) + " " + esc(c.subject || "—") + "</div>").join("");
154
+ // The empty state for a missing main branch, shared by the Pending roster, its
155
+ // overlapping-files card, and the role drawer's Pending change section so the esced branch
156
+ // name and the wording cannot drift between them — change-render.ts's mainMissingPhrase,
157
+ // whose terminal spelling is lowercase/unescaped. The browser client cannot import that TS
158
+ // module, so this is its own copy, capitalized and esced for the HTML sentence it leads.
159
+ const mainBranchMissingPhrase = (mainBranch) => "Main branch " + esc(mainBranch) + " does not exist";
154
160
  // Title-case a phrase's first character and keep the rest — backend-traits.ts's
155
161
  // backendDisplayName rule, the one home of the upcasing gui-client-model.ts's result labels
156
162
  // and gui-client-history.ts's window options each inlined, so a label cannot drift between
@@ -226,7 +232,7 @@ const FORMAT_JS = String.raw ` // format:start
226
232
  // phase rather than throwing: the browser copy is untyped, and its sibling phaseInfo/
227
233
  // roleRank already coerce the same way, so one bad row must not blank the whole table.
228
234
  const phase = String(l.phase || "");
229
- const active = isActivePhase(phase) || phase.startsWith("waiting to start") || phase.startsWith("waiting for ") || phase.startsWith("director working");
235
+ const active = isActivePhase(phase) || phase.startsWith("waiting to start") || phase.startsWith("director working");
230
236
  if (!teamRunning || active) return "-";
231
237
  const sec = Math.round((l.nextRunAt - Date.now()) / 1000);
232
238
  const suffix = l.yieldMultiplier > 1 ? " ×" + l.yieldMultiplier : "";