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
@@ -12,6 +12,7 @@
12
12
  * depends on node built-ins alone, so any layer (harness or observer) can import it without
13
13
  * reaching into another module's internals — and the collapse/truncation/compaction/
14
14
  * abbreviation semantics live in exactly one place instead of drifting per consumer. */
15
+ import { distinctOrdered } from "../collections.js";
15
16
  /** Collapse every run of whitespace to a single space and trim both ends — the shape every
16
17
  * one-line label takes before display (multi-line pi text, command strings, error messages). */
17
18
  export function collapseWhitespace(s) {
@@ -94,9 +95,9 @@ export function gotSuffix(v) {
94
95
  * and its legacy provider, exempt paths, custom-role tasks), pi-event-line.ts's tool-result content
95
96
  * test, quiet-hours.ts's per-role window lookup, and inbox-attachments.ts's image-name check, so
96
97
  * the blank-means-unset rule cannot drift per call site. Callers where a bare empty string means
97
- * unset rather than blank text (notify.ts, version.ts, inbox-cancel.ts) keep their own `=== ""`
98
- * check; typed-string callers (checkStringField's two-branch shape, checkStringArray's blank-index
99
- * report) spell the trim test inline. */
98
+ * unset rather than blank text (event-notify.ts, version.ts, inbox-cancel.ts) keep their own
99
+ * `=== ""` check; typed-string callers (checkStringField's two-branch shape,
100
+ * checkStringArray's blank-index report) spell the trim test inline. */
100
101
  export function isNonBlankString(v) {
101
102
  return typeof v === "string" && v.trim() !== "";
102
103
  }
@@ -112,7 +113,7 @@ export function splitCommaList(raw) {
112
113
  * filter and the GUI's `role` and `model` query parameters, each of which treats a repeat as
113
114
  * the same selector. */
114
115
  export function splitCommaListUnique(raw) {
115
- return [...new Set(splitCommaList(raw))];
116
+ return distinctOrdered(splitCommaList(raw));
116
117
  }
117
118
  /** A value or list of `T` as a list: a bare `T` is one item, an array stays itself. The
118
119
  * single home of the string-or-list normalization (a `--role` selector is either one id or a
@@ -272,5 +273,5 @@ export function tooLongMessage(subject, n, max, tail) {
272
273
  * role-note missing-path scan (run/run-prompt.ts), so the two agree on what reads as a path. */
273
274
  export function pathTokens(text) {
274
275
  const tokenRe = /[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\.[A-Za-z0-9]+/g;
275
- return [...new Set([...text.matchAll(tokenRe)].map((m) => m[0]))];
276
+ return distinctOrdered([...text.matchAll(tokenRe)].map((m) => m[0]));
276
277
  }
@@ -1,115 +1,50 @@
1
- import { openQuestionEntries, ageInDays } from "../backlog/backlog.js";
2
- import { stripEntryStamp } from "../backlog/backlog-structure.js";
3
- import { questionFilingDate } from "../backlog/question-answer.js";
4
- import { byDaysThenIndex } from "../collections.js";
5
- import { eventsSince } from "../events/event-window.js";
6
- import { STALE_QUESTION_DAYS } from "../doctor/doctor-backlog.js";
7
- import { regressedFixCandidates, matchedRegressedFixes, regressedFixesPhrase } from "../doctor/doctor-backlog-bugs.js";
8
- import { planDependencyProblem } from "../doctor/doctor-backlog-plans.js";
9
- import { checkWaitContention, providerConcurrencyProblems, providerWaitContention, slotWaitContention, smallPoolProblems, staleWorkspacePins, CHECK_WAIT_CHECK_WINDOW_MS, PROVIDER_WAIT_CHECK_WINDOW_MS, SLOT_WAIT_CHECK_WINDOW_MS } from "../doctor/doctor-contention-checks.js";
10
- import { readSlotsState } from "../git/slots-state.js";
11
- import { configForStatus } from "../status/status-polls.js";
12
- import { piModelsPath } from "../pi/pi-models.js";
13
- import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
14
- /** This module is the alert-feed half of src/ui/status-payload.ts: each always-on team alert's
15
- * data-sourcing function (capacityContention, …) lives here, a
16
- * sibling of the src/ui/team-alert-*.ts builders that turn them into banners. status-payload.ts
17
- * stays the one status-document builder, importing these feeds and folding each feed's result
18
- * into the `alerts` field; status-render.ts reads the feeds directly for the TUI. */
19
- /** The open questions the doctor's 7-day rule (src/doctor/doctor-backlog-bugs.ts
20
- * checkStaleQuestions, STALE_QUESTION_DAYS) calls stale: an open QUESTIONS.md entry whose
21
- * `(asked by …)` stamp is at least 7 days old. This is the always-on feed for the stale-questions
22
- * team alert — the shared rule's twin on every observer surface, computed from the same threshold
23
- * and the same questionFilingDate parser the doctor's check uses, so the two cannot drift on which
24
- * questions are stale. An undated heading is skipped (like the doctor's check); questions carry no
25
- * refused note to filter. The returned objects carry the 1-based position (for
26
- * `tumwater questions answer <n>`), the 0-based index (kept only as the file-order tiebreak), the
27
- * stamp-stripped title, and the whole-day age. Returned oldest-first — most days waited, ties in
28
- * file order — so a caller naming the first element names the most-stale question. */
29
- export function staleOpenQuestions(root, now = Date.now()) {
30
- return openQuestionEntries(root)
31
- .flatMap((entry, index) => {
32
- const stamp = questionFilingDate(entry.title);
33
- if (stamp === null)
34
- return [];
35
- const days = ageInDays(stamp, now);
36
- return days >= STALE_QUESTION_DAYS ? [{ index, position: index + 1, title: stripEntryStamp(entry.title), days }] : [];
37
- })
38
- .sort(byDaysThenIndex);
1
+ import { findOrphans, orphanFailDetail } from "../doctor/doctor-orphans.js";
2
+ import { systemProcessProbe } from "../process/process-table.js";
3
+ /** The orphan scan's alert shape, given a scan it is handed — the pure feed, so a test stages a
4
+ * fake probe's scan and gets exactly the row's message without a real host (the shared
5
+ * findOrphans/orphanFailDetail the doctor row reads, so the two cannot drift). `root` trims this
6
+ * repo's path from the commands, like the doctor row. */
7
+ export function orphanAlertFromScan(scan, root = "") {
8
+ if ("error" in scan || scan.orphans.length === 0)
9
+ return null;
10
+ const detail = orphanFailDetail(root, scan);
11
+ return detail === null ? null : { count: scan.orphans.length, detail };
39
12
  }
40
- /** A broken plan-dependency clause, the always-on twin of the doctor's `plan dependencies` row
41
- * (src/doctor/doctor-backlog-plans.ts checkPlanDependencies): the same rule's problem set,
42
- * severity order, and exact message, so the alert and the doctor cannot drift. The reader owns
43
- * the rule — planDependencyAlert passes the doctor's planDependencyProblem through unchanged,
44
- * like staleOpenQuestions and the other stale feeds do their doctor's rules. null when PLANS.md's
45
- * planned entries have no cycle, dangling ref, unparsed clause, or misworded dependency. */
46
- export function planDependencyAlert(root) {
47
- return planDependencyProblem(root);
13
+ /** Run the doctor's orphan scan and shape it into the standing alert. Async (it does the real
14
+ * process-table read); the always-on surfaces read it through getOrphanAlert's cache below, so
15
+ * a status frame never blocks on the host scan. */
16
+ export async function refreshOrphanAlert(root, probe = systemProcessProbe) {
17
+ return orphanAlertFromScan(await findOrphans(root, probe), root);
48
18
  }
49
- /** @see CapacityBottleneck */
50
- export function capacityContention(root, now = Date.now()) {
51
- const out = [];
52
- /** Resolve one capacity verdict and push a "capacity bottleneck" row when it warns. Shared
53
- * by the three capacity checks (workspace, provider, check slots) so the try/catch that keeps
54
- * each one from crying wolf on an unreadable event log lives in one place, and the row's
55
- * `name`/`detail` shape cannot drift between them. */
56
- const maybeWarn = (name, windowMs, keep, resolve) => {
57
- try {
58
- const verdict = resolve(eventsSince(root, windowMs, now, keep));
59
- if (verdict.warn)
60
- out.push({ name, detail: verdict.detail });
19
+ /** How stale a cached orphan scan may be before the next status read starts a fresh one:
20
+ * orphan leaks grow over days, so a sixty-second refresh is plenty and keeps the host scan
21
+ * (one `ps`, plus cwd reads only for the parentless candidates argv did not settle) off the
22
+ * hot render path. */
23
+ const ORPHAN_ALERT_REFRESH_MS = 60_000;
24
+ let orphanCache = null;
25
+ let orphanLoading = false;
26
+ /** The synchronous orphan feed the always-on surfaces (status-payload, status-render, the TUI)
27
+ * read each frame: the last scan's value while it is fresher than ORPHAN_ALERT_REFRESH_MS, else a
28
+ * background refresh is kicked (one at a time) and the previous value — null on the first ever
29
+ * read — is returned until that scan lands. The refresh runs off the render path, so a slow host
30
+ * scan never stalls a status frame. Per-process: each long-lived surface (the GUI server, the
31
+ * TUI) and the CLI each run their own throttled scan against the same doctor rule. */
32
+ export function getOrphanAlert(root, probe = systemProcessProbe, now = Date.now()) {
33
+ if (orphanCache === null || now - orphanCache.at >= ORPHAN_ALERT_REFRESH_MS) {
34
+ if (!orphanLoading) {
35
+ orphanLoading = true;
36
+ void (async () => {
37
+ try {
38
+ orphanCache = { value: await refreshOrphanAlert(root, probe), at: Date.now() };
39
+ }
40
+ catch {
41
+ orphanCache = { value: null, at: Date.now() };
42
+ }
43
+ finally {
44
+ orphanLoading = false;
45
+ }
46
+ })();
61
47
  }
62
- catch {
63
- // A read error skips this row, so the alert never cries wolf on an unreadable log.
64
- }
65
- };
66
- maybeWarn("workspace slots", SLOT_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "slot_wait", (waits) => slotWaitContention(waits, readSlotsState(root).slots.length));
67
- maybeWarn("provider slots", PROVIDER_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "provider_wait", (waits) => providerWaitContention(waits, configForStatus(root)));
68
- maybeWarn("check slots", CHECK_WAIT_CHECK_WINDOW_MS, (e) => e.type === "check_wait", (waits) => checkWaitContention(waits, configForStatus(root)));
69
- return out;
70
- }
71
- /** A `maxConcurrentPerProvider` map that cannot do its job — the always-on twin of the doctor's
72
- * `provider concurrency` row (src/doctor/doctor-contention-checks.ts checkProviderConcurrency):
73
- * a cap key no seam resolves to (a typo, or a provider the team never runs on), a cap at or
74
- * above `maxConcurrent` (it never binds), or a keyless seam in a capped team. The `contention`
75
- * and `provider-hold` alerts fire only once a too-small cap has already parked runs or a storm
76
- * has struck; this standing alert names the static misconfiguration itself, which the doctor
77
- * row is the only other surface that otherwise exposes. Reads the same last-known-good config
78
- * the snapshot uses (configForStatus) and audits it through the row's own
79
- * providerConcurrencyProblems, so the alert and the row cannot drift on the trigger or the
80
- * wording; `[]` when no cap is configured or the map is well-formed. */
81
- export function providerCapProblems(root) {
82
- return providerConcurrencyProblems(configForStatus(root), readPiDefaultModel(piSettingsPath(piModelsPath()), root));
83
- }
84
- /** The always-on twin of the doctor `parallel instances` row's pool-mismatch clause
85
- * (doctor-contention-checks.ts smallPoolProblems): an enabled `feature`/`bugfix` running
86
- * more than one instance while slotCount(config) sits below maxConcurrent — the shared pool
87
- * is smaller than the run cap, so those instances contend for the same checkouts. Built from
88
- * the same smallPoolProblems selector the doctor row uses, so the alert and the row cannot
89
- * drift on the trigger, the role list, or the wording. configForStatus never returns
90
- * undefined (an unreadable tumwater.json falls back to defaultConfig(), whose pool is the
91
- * default maxConcurrent + 1 — never below maxConcurrent), so the [] is the only silent
92
- * outcome. */
93
- export function instancePoolProblems(root) {
94
- return smallPoolProblems(configForStatus(root));
95
- }
96
- /** How far back the regressed-fixes alert looks — one day, so a recently Fixed test that failed
97
- * again is a standing amber alert until a day passes (or the failure is filed). The doctor's
98
- * own `regressed fixes` row keeps its since-the-fix read; this is the alert's daily trigger, the
99
- * same trailing day the regressed-fixes alert reads. */
100
- export const REGRESSED_FIX_WINDOW_MS = 24 * 60 * 60_000;
101
- /** The always-on twin of the doctor's `regressed fixes` row (src/doctor/doctor-backlog-bugs.ts
102
- * checkRegressedFixes): the same candidate selection (regressedFixCandidates) and the same
103
- * failureMentions match (matchedRegressedFixes) and sentence (regressedFixesPhrase), read off the
104
- * trailing REGRESSED_FIX_WINDOW_MS of events (the doctor, whose window is since each fix, still
105
- * warns on an older recurrence). An unreadable event log yields none. */
106
- export function regressedFixesAlert(root, now = Date.now()) {
107
- const candidates = regressedFixCandidates(root);
108
- const rows = matchedRegressedFixes(candidates, eventsSince(root, REGRESSED_FIX_WINDOW_MS, now));
109
- const detail = regressedFixesPhrase(rows);
110
- return detail === null ? null : { count: rows.length, detail };
48
+ }
49
+ return orphanCache === null ? null : orphanCache.value;
111
50
  }
112
- /** Re-export so the always-on alert's stale-pin selector stays in its one home
113
- * (src/doctor/doctor-contention-checks.ts) while tui.tsx keeps importing every alert feed from
114
- * this one module. */
115
- export { staleWorkspacePins };
@@ -1,5 +1,3 @@
1
- import { sortEntriesByKey } from "../collections.js";
2
- import { utilizationPct, windowLabel } from "../agent/subscription-account.js";
3
1
  import { MODEL_TIERS } from "../config/config-schema.js";
4
2
  import { budgetGate, budgetReached, fallbackTierEntries } from "../budget/budget.js";
5
3
  import { fallbackDisplayPair } from "../config/config-views.js";
@@ -23,12 +21,21 @@ import { formatGB, shortSha, usd, usdCap } from "../text/format.js";
23
21
  * team keeps running the stale build, so the badge reads "work continues" and keeps the
24
22
  * deadline; `restart pending` (the drain that precedes the swap, which actually holds new runs
25
23
  * and merges) resolves itself; `restart BLOCKED` never will until main moves. Telling them apart
26
- * at a glance is the whole point (see BuildStatus.restartBlocked). Empty when the dist carries
27
- * no stamp. Shared by the TUI/status header and the GUI's. */
24
+ * at a glance is the whole point (see BuildStatus.restartBlocked). A build that is current against
25
+ * the newest commit that passed main's check, while main's tip has moved its code on, says the
26
+ * update waits for that check (BuildStatus.waitingForCheck) — otherwise nothing tells the
27
+ * operator why the team stays on an old build. Empty when the dist carries no stamp. Shared by
28
+ * the TUI/status header and the GUI's. */
28
29
  export function buildBadge(build) {
29
30
  if (!build)
30
31
  return "";
31
- const stale = build.stale ? ` — update available (${plural(build.aheadCommits ?? 0, "commit")} behind)` : "";
32
+ const waiting = build.waitingForCheck;
33
+ const stale = build.stale
34
+ ? ` — update available (${plural(build.aheadCommits ?? 0, "commit")} behind)`
35
+ : waiting
36
+ ? ` — update waiting: main is ${plural(waiting.aheadCommits, "commit")} ahead at ` +
37
+ `${shortSha(waiting.head)}, and no newer commit has passed main's check yet`
38
+ : "";
32
39
  const restart = build.restartBlocked
33
40
  ? build.restartBlocked.startsWith("cooldown until ")
34
41
  ? ` — restarts after cooldown; work continues (${build.restartBlocked})`
@@ -103,55 +110,6 @@ export function bootstrapBadge(bootstrap) {
103
110
  return "";
104
111
  return ` · bootstrap: ${bootstrap.plansDone}/${bootstrap.untilPlansDone} plans done`;
105
112
  }
106
- /** One window status as its badge word when no utilization was reported. */
107
- function claudeWindowStatusWord(status) {
108
- if (status === "allowed_warning")
109
- return "near limit";
110
- if (status === "rejected")
111
- return "limited";
112
- return status === "reset" ? "reset" : "ok";
113
- }
114
- /** How far off a window's reset is, two units wide: `4h 12m`, `5d 3h`, `37m`. A clock time
115
- * alone cannot tell a 7d window resetting Monday from one resetting tonight. */
116
- function untilReset(resetsAt, now) {
117
- const minutes = Math.floor((resetsAt - now) / 60_000);
118
- if (minutes < 60)
119
- return `${minutes}m`;
120
- const hours = Math.floor(minutes / 60);
121
- if (hours < 24)
122
- return `${hours}h ${minutes % 60}m`;
123
- return `${Math.floor(hours / 24)}d ${hours % 24}h`;
124
- }
125
- /** The subscription usage-window fragment (plans/agent-backends.md §8, PLANS.md part 6d/7):
126
- * `· Claude 5h · 64% · resets in 4h 12m` (`resets now` once the deadline has passed), with
127
- * the status word when no `utilization` was reported and an `overage` marker while the
128
- * account spends into overage. A rejected window reads
129
- * `<backend> model jobs wait until 14:30` in place of `resets` while a future deadline holds
130
- * the seams; a reset reading names neither. Reads the most-constrained block (`blocks[0]`,
131
- * already most-constrained-first), rendering under that backend's own display label. The
132
- * hold clause names the backend by its lowercase id (not the display label), so a claude-only
133
- * team keeps the byte-identical `claude model jobs wait` the pre-3b badge rendered. The one
134
- * home of the string, so the TUI header and the payload's preformatted badge cannot drift. */
135
- function subscriptionWindowBadge(blocks, now, moved = false) {
136
- const block = blocks[0];
137
- const w = block.primary;
138
- const parts = [`${block.label} ${windowLabel(w.type)}`];
139
- parts.push(typeof w.utilization === "number" ? `${utilizationPct(w.utilization)}%` : claudeWindowStatusWord(w.status));
140
- if (w.isUsingOverage === true)
141
- parts.push("overage");
142
- if (w.status === "rejected" && block.heldUntil !== null) {
143
- parts.push(`${moved ? "on fallback" : `${block.backend} model jobs wait`} until ${clockHm(block.heldUntil)}`);
144
- }
145
- else if (w.status !== "reset" && typeof w.resetsAt === "number") {
146
- parts.push(w.resetsAt - now < 60_000 ? "resets now" : `resets in ${untilReset(w.resetsAt, now)}`);
147
- }
148
- return ` · ${parts.join(" · ")}`;
149
- }
150
- /** Whether any tier of a chain block runs on a fallback because the Claude window (or its
151
- * reserve) is used up. */
152
- function chainMovedByWindow(chain) {
153
- return Object.values(chain ?? {}).some((t) => t.reason === "window" || t.reason === "reserve");
154
- }
155
113
  /** The tiers' fallback steps as badge text, ` · `-joined without a leading separator:
156
114
  * `default → DeepSeek-V4-Flash $3.10/$5 until 17:40`, one fragment per tier in MODEL_TIERS
157
115
  * order. Empty when the block is absent or empty. */
@@ -172,18 +130,6 @@ export function chainBadge(chain) {
172
130
  const text = chainFragments(chain);
173
131
  return text ? ` · ${text}` : "";
174
132
  }
175
- /** The window fragment's tone rank, for the header badge and the GUI meter: a rejected window
176
- * is red, an allowed_warning amber, anything else no tone. Reads the most-constrained block
177
- * (`blocks[0]`), so a multi-backend team's tone follows its tightest window. */
178
- export function subscriptionWindowTone(blocks) {
179
- if (!blocks || blocks.length === 0)
180
- return undefined;
181
- if (blocks[0].primary.status === "rejected")
182
- return "red";
183
- if (blocks[0].primary.status === "allowed_warning")
184
- return "yellow";
185
- return undefined;
186
- }
187
133
  /** The header's daily-cost-budget fragment, standing in every cap state (the badge is also
188
134
  * the affordance for editing the cap, so a disabled team needs it too): `· budget: n/a`
189
135
  * for a team whose models are all free (spend can never accumulate against a cap that
@@ -204,16 +150,8 @@ export function subscriptionWindowTone(blocks) {
204
150
  * needs no forecast slot: a disabled cap never carries a capHitAt (projectCapHit returns null
205
151
  * for capUsd <= 0), and the fallback fragment only stands once the cap IS reached — the one
206
152
  * state where the forecast is itself null — so the fragments can never compete for the line. */
207
- export function budgetBadge(budget, windows, only = false, now = Date.now(), chain) {
208
- const blocks = windows && windows.length > 0 ? windows : undefined;
209
- const window = (blocks ? subscriptionWindowBadge(blocks, now, chainMovedByWindow(chain)) : "") + chainBadge(chain);
210
- // Subscription-only: the window stands IN PLACE of the daily spend — there is no priced
211
- // spend for a dollar figure to report. This holds only while a window block actually
212
- // exists: an all-free subscription-only team (only and free, no windows) reads ` · budget: n/a`
213
- // like any other all-free team, so the free branch below stays reachable. Mixed and none
214
- // fall through to the daily spend.
215
- if (only && blocks)
216
- return window;
153
+ export function budgetBadge(budget, chain) {
154
+ const window = chainBadge(chain);
217
155
  if (budget.free)
218
156
  return ` · budget: n/a${window}`;
219
157
  // Only while the fallback is actually carrying the team (plans/fallback-model.md): the cap
@@ -383,21 +321,12 @@ export function diskBadge(disk) {
383
321
  ? ` · disk ${free}`
384
322
  : "";
385
323
  }
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. */
324
+ /** The permit pool's header fragment (plans/provider-concurrency.md §8): `slots 5/8` — the
325
+ * team total's in-use/cap. Empty when no snapshot was published. */
392
326
  export function permitsBadge(permits) {
393
327
  if (!permits)
394
328
  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}`;
329
+ return ` · running ${permits.inUse} of ${permits.limit}`;
401
330
  }
402
331
  /** The check-permit pool's reading, ` · checks 1/2` plus `, 3 waiting` while checks queue. Empty
403
332
  * 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
@@ -383,12 +383,6 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
383
383
  const thinking = thinkingBox ? thinkingBox.value.trim() : "";
384
384
  const backend = backendBox ? backendBox.value.trim() : "";
385
385
  postAction("/api/role-model", { role: arg, provider, model, thinking, backend }, settingDone(() => { modelEditing = null; }, "Model overrides updated"));
386
- } else if (act === "review-toggle") {
387
- const after = drawerRole?.view?.reviewedAfterMerge === true;
388
- postAction("/api/role-review-after-merge", { role: arg, afterMerge: !after }, (d) => {
389
- loadDrawerRole(arg);
390
- return d && d.message ? d.message : "Review tier updated";
391
- });
392
386
  } else if (act === "mention") {
393
387
  const { file, index } = entryRef(arg);
394
388
  const title = entryTitleAt(file, index);
@@ -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,40 +90,17 @@ 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")
101
96
  : "unknown";
102
97
  rows.push(kvRow("Images", images));
103
98
  }
104
- if (view.reviewEnabled === true && role !== "director") {
105
- // The review tier, shown whenever the gate is on (rolePayload's reviewEnabled) so a
106
- // before-merge-reviewed role still gets the control — the browser twin of the CLI's
107
- // tumwater role <id> --review-after-merge. The director shows no row, like the cap and
108
- // quiet-hours rows and the CLI render: its membership is refused by validateConfig's
109
- // review.afterMerge rule, so a toggle it could never act on would only answer 400. The
110
- // body names whether the role is reviewed after merge or before it, and the button posts
111
- // the opposite membership through /api/role-review-after-merge; one click posts (no
112
- // editor state), like the Settings view's Enable button.
113
- const revAfter = view.reviewedAfterMerge === true;
114
- const revBody = revAfter ? "after merge" : "before merge";
115
- 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
- rows.push("<div><dt>Review</dt><dd title='" + esc(revBody) + "'>" + esc(revBody) + revControl + "</dd></div>");
117
- }
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
99
  if (view.fallback && typeof view.fallback === "object" && view.fallback.model !== undefined) {
124
100
  rows.push(kvRow("Budget fallback", fallbackLabel(view.fallback, "") + " (" + (view.fallbackFree ? "free" : "priced") + ")"));
125
101
  }
126
102
  if (view.billing !== undefined) {
127
- const window = view.billing === "subscription" && view.billingWindow ? " (" + view.billingWindow + " window)" : "";
128
- rows.push(kvRow("Billing", (view.billing === "api" ? "API" : view.billing) + window));
103
+ rows.push(kvRow("Billing", view.billing === "api" ? "API" : view.billing));
129
104
  }
130
105
  // The min run interval (rolePayload's minRunIntervalSeconds), worded like role-render.ts's
131
106
  // Min run interval line, plus a control to set/clear the per-role override — the last
@@ -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>";
@@ -110,7 +110,7 @@ export const GUI_CLIENT_FEED_JS = String.raw `
110
110
  const emptyFeedRow = (head, body) => "<li class='empty'><strong>" + head + "</strong>" + body +
111
111
  "<button type='button' class='linkish' data-action='feedclear'>Clear filter</button></li>";
112
112
  // A per-type tally for the Activity feed, mirroring the CLI's logs --count display rule
113
- // (read-only from src/cli/log-commands.ts's sayTally): one N × <type> chip per present event
113
+ // (read-only from src/cli/cli-log-commands.ts's sayTally): one N × <type> chip per present event
114
114
  // type, count-descending then type-name ascending. A pure function of the rows in hand, so the
115
115
  // chips and the <li> rows below cannot drift on which events count; "" for an empty array so
116
116
  // empty and empty-state feeds paint no stray chips. Each chip is a button: clicking the
@@ -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" };