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
@@ -65,7 +65,7 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
65
65
  // The subscription usage window (plans/agent-backends.md §8, PLANS.md part 6d/7): the
66
66
  // sidebar's spend card renders it beside the daily spend, or in its place for a
67
67
  // subscription-only team. The label/status/clock rules mirror badges.ts's
68
- // subscriptionWindowBadge — the preformatted header fragment the TUI uses cannot be reused here
68
+ // window badge — the preformatted header fragment the TUI uses cannot be reused here
69
69
  // because the card lists EVERY window of EVERY block, not just the most constrained one.
70
70
  function windowLabel(type) {
71
71
  const m = /^(\w+)_(hour|day|week)$/.exec(String(type));
@@ -87,9 +87,9 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
87
87
  if (hours < 24) return "resets in " + hours + "h " + (minutes % 60) + "m";
88
88
  return "resets in " + Math.floor(hours / 24) + "d " + (hours % 24) + "h";
89
89
  }
90
- // A window's consumed fraction as a whole-number percent, the same shape as node-side
91
- // utilizationPct - the browser script keeps its inline copy, so the text percentage and
92
- // the meter width share this one home instead of each rounding w.utilization by hand.
90
+ // A window's consumed fraction as a whole-number percent, the same shape as the node-side
91
+ // utilization percent — the browser script keeps its inline copy, so the text percentage
92
+ // and the meter width share this one home instead of each rounding w.utilization by hand.
93
93
  function windowPct(w) {
94
94
  return Math.round(w.utilization * 100);
95
95
  }
@@ -171,7 +171,9 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
171
171
  function renderBudgetBadge(d) {
172
172
  if (budgetEditing) return; // the open editor owns the slot until save or cancel
173
173
  const b = d && d.budget;
174
- paintPanel("budgetwrap", b ? budgetCardHtml(b, d && d.subscriptionWindows, d && d.subscriptionOnly, d && d.chain) : "");
174
+ // The subscription-window block and its only flag no longer ride the status payload
175
+ // (Subtract 5x/6); the window card is dormant, so the budget card renders chain only.
176
+ paintPanel("budgetwrap", b ? budgetCardHtml(b, undefined, undefined, d && d.chain) : "");
175
177
  }
176
178
  function openBudgetEditor() {
177
179
  const b = lastStatus && lastStatus.budget;
@@ -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();
@@ -48,8 +48,14 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
48
48
  // markup alertParts() emits for the build alert; a fresh build's icon stays inert
49
49
  // decoration.
50
50
  const lead = stale ? restartButton(icon("refresh")) : icon("refresh");
51
- rows += row(stale ? (d.build.restartBlocked ? "amber" : "blue") : "", lead,
52
- "Version <span class='mono'>" + esc(shortSha(d.build.sha)) + "</span>" + (stale ? " · " + esc(plural(d.build.aheadCommits || 0, "commit")) + " behind" : ""),
51
+ // Main's tip moved the code on but no newer commit has passed main's check, so redeploy has
52
+ // nothing to move to (BuildStatus.waitingForCheck): amber, and the Main row below says why.
53
+ // No restart button — a forced restart would land on this same build.
54
+ const waiting = !stale && d.build.waitingForCheck;
55
+ rows += row(stale ? (d.build.restartBlocked ? "amber" : "blue") : waiting ? "amber" : "", lead,
56
+ "Version <span class='mono'>" + esc(shortSha(d.build.sha)) + "</span>" +
57
+ (stale ? " · " + esc(plural(d.build.aheadCommits || 0, "commit")) + " behind"
58
+ : waiting ? " · update waits for main's check" : ""),
53
59
  localizeInstants(("Running version" + (d.buildBadge || "").replace(/^, build/, "")).trim()));
54
60
  }
55
61
  if (d.mainCheck) {
@@ -95,13 +101,13 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
95
101
  : "");
96
102
  }
97
103
  // 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.
104
+ // permitsBadge — running 5 of 8 — upcased for the row's label style, the same reading
105
+ // the TUI/status header carries, so the two surfaces cannot drift. Present for any
106
+ // team with a published permit snapshot.
101
107
  if (d.permitsBadge) {
102
108
  const tok = String(d.permitsBadge).replace(/^ · /, "");
103
109
  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.");
110
+ "Roles running at once out of the team limit (maxConcurrent); a role waits to start when the team is at its limit.");
105
111
  }
106
112
  // The check-permit pool: the payload's checksBadge, present only while a check runs or waits.
107
113
  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 : "";
@@ -1,10 +1,10 @@
1
1
  import path from "node:path";
2
- import { applyToolExecutionEvent, parsePiEventLine, toolCallCommand, } from "../pi/pi-event-line.js";
2
+ import { applyToolExecutionEvent, foldPendingReply, messageUpdateHasContent, parsePiEventLine, piEventType, toolCallCommand, } from "../pi/pi-event-line.js";
3
3
  import { bufferedCommandStallMs, callBuffersOutput } from "../pi/command-shape.js";
4
4
  import { describeToolCall } from "../text/phrases.js";
5
5
  import { squash } from "../text/text.js";
6
6
  import { defaultConfig, liveConfig } from "../config/config.js";
7
- import { nonNegativeNumber } from "../files/json-object.js";
7
+ import { nonNegativeNumber, parseJsonObject } from "../files/json-object.js";
8
8
  import { worktreesDir } from "../paths.js";
9
9
  import { statRoleLog, readCompleteLines, withTail } from "../files/tail.js";
10
10
  /** How much log tail a first observation scans initially (or after rotation); findSeedOffset
@@ -78,6 +78,7 @@ function freshProgress(quietMs, agent) {
78
78
  // stalledTool is deliberately NOT part of fresh: it is derived from wall-clock time on
79
79
  // every read, never folded from lines.
80
80
  openToolCalls: undefined,
81
+ pendingReply: undefined,
81
82
  currentWork: undefined,
82
83
  agent,
83
84
  quietMs,
@@ -86,7 +87,7 @@ function freshProgress(quietMs, agent) {
86
87
  /** The event types feedLine acts on — everything else (streaming deltas, turn/agent
87
88
  * bookkeeping) is ignored. Also passed as parsePiEventLine's pre-filter to skip JSON.parse for
88
89
  * pi lines whose type is verifiably not one of these; a new case in the switch must be added
89
- * here too. `tumwater_run` is the harness's own marker (src/agent/run-spawn.ts), read for its
90
+ * here too. `tumwater_run` is the harness's own marker (src/agent/agent-spawn.ts), read for its
90
91
  * run kind: a kind-bearing marker flips the demux and starts that accumulator fresh before
91
92
  * its `session` event lands, and a legacy label-only marker ("review") still flips the demux
92
93
  * to gate. */
@@ -96,59 +97,103 @@ const PROGRESS_TYPES = new Set([
96
97
  "tool_execution_start",
97
98
  "tool_execution_update",
98
99
  "tool_execution_end",
100
+ "message_start",
99
101
  "message_end",
100
102
  ]);
103
+ /** pi's compact serialization of a message_start, up to the role's value. */
104
+ const MESSAGE_START_ROLE = '{"type":"message_start","message":{"role":"';
105
+ /** A new run starts: everything before it was a previous run — restore the at-time-zero state. */
106
+ function applySessionEvent(progress) {
107
+ Object.assign(progress, freshProgress(progress.quietMs));
108
+ }
109
+ /** A tool execution starts: count the call, record its label as the last tool, and seed the
110
+ * open-call entry the stall flag reads. Track the open call for the stall flag — pi runs a
111
+ * message's tool calls concurrently by default, so several can be open at once and end in
112
+ * completion order. The state machine is shared with runPi's stall warning
113
+ * (src/pi/pi-watchdogs.ts); only the fallback label differs here. The full raw command rides
114
+ * along because the flag classifies the command's shape, and describeToolCall's label
115
+ * truncates at 32 chars — an operator past that point would be invisible there. */
116
+ function applyToolStart(progress, event) {
117
+ progress.toolCalls += 1;
118
+ const label = event.toolName ? describeToolCall(event.toolName, event.args) : undefined;
119
+ if (label)
120
+ progress.lastTool = label;
121
+ applyToolExecutionEvent(progress.openToolCalls ??= [], event.type, event.toolCallId, event.partialResult, label ?? "tool", toolCallCommand(event.args));
122
+ }
123
+ /** A tool update or end folds through the same call: `applyToolExecutionEvent` reads `event.type`
124
+ * to decide what to do (a content-bearing update moves the activity clock; end closes the
125
+ * call), and both no-op when no call is tracked. Only content-bearing updates prove the
126
+ * command is alive — bash emits one empty-content update right after start, and a
127
+ * content-free keepalive must not mask a hang — a rule applyToolExecutionEvent owns. */
128
+ function applyToolProgress(progress, event) {
129
+ if (progress.openToolCalls)
130
+ applyToolExecutionEvent(progress.openToolCalls, event.type, event.toolCallId, event.partialResult);
131
+ }
132
+ /** An assistant message ends: count the turn and fold usage/peak/context and the first work item.
133
+ * Guard each count like pi-stream's usageNumber does: a string-serialized or corrupt wire value
134
+ * would otherwise poison the live cell — `+=` would concatenate output into a string and
135
+ * Math.max would yield NaN. An unusable total keeps the previous context (this cell is "latest
136
+ * value"), while output and the peak floor treat it as absent. The first text the role speaks
137
+ * in this run is its work item ("I'll implement plan X"); later messages never replace it. */
138
+ function applyAssistantTurn(progress, event) {
139
+ if (event.message?.role !== "assistant")
140
+ return;
141
+ progress.turns += 1;
142
+ const usage = event.message.usage;
143
+ if (usage) {
144
+ progress.contextTokens = nonNegativeNumber(usage.totalTokens, progress.contextTokens);
145
+ progress.outputTokens += nonNegativeNumber(usage.output, 0);
146
+ progress.peakContextTokens = Math.max(progress.peakContextTokens, nonNegativeNumber(usage.totalTokens, 0));
147
+ }
148
+ if (!progress.currentWork)
149
+ progress.currentWork = workItemFromContent(event.message.content);
150
+ }
101
151
  /** Apply one parsed progress event to a progress object (mutates it). */
102
152
  function feedLine(progress, event) {
103
153
  switch (event.type) {
104
154
  case "session": // A new run starts: everything before it was a previous run — restore the at-time-zero state.
105
- Object.assign(progress, freshProgress(progress.quietMs));
155
+ applySessionEvent(progress);
106
156
  break;
107
- case "tool_execution_start": {
108
- progress.toolCalls += 1;
109
- const label = event.toolName ? describeToolCall(event.toolName, event.args) : undefined;
110
- if (label)
111
- progress.lastTool = label;
112
- // Track the open call for the stall flag — pi runs a message's tool calls concurrently
113
- // by default, so several can be open at once and end in completion order. The state
114
- // machine is shared with runPi's stall warning (src/pi/pi-watchdogs.ts); only the
115
- // fallback label differs here. The full raw command rides along because the flag
116
- // classifies the command's shape, and describeToolCall's label truncates at 32 chars —
117
- // an operator past that point would be invisible there.
118
- applyToolExecutionEvent(progress.openToolCalls ??= [], event.type, event.toolCallId, event.partialResult, label ?? "tool", toolCallCommand(event.args));
157
+ case "tool_execution_start":
158
+ applyToolStart(progress, event);
119
159
  break;
120
- }
121
- // Updates and ends fold through the same call: `applyToolExecutionEvent` reads `event.type`
122
- // to decide what to do (a content-bearing update moves the activity clock; end closes the
123
- // call), and both no-op when no call is tracked. Only content-bearing updates prove the
124
- // command is alive — bash emits one empty-content update right after start, and a
125
- // content-free keepalive must not mask a hang — a rule applyToolExecutionEvent owns.
126
160
  case "tool_execution_update":
127
161
  case "tool_execution_end":
128
- if (progress.openToolCalls)
129
- applyToolExecutionEvent(progress.openToolCalls, event.type, event.toolCallId, event.partialResult);
162
+ applyToolProgress(progress, event);
163
+ break;
164
+ case "message_start":
165
+ applyReplyEvent(progress, event);
130
166
  break;
131
167
  case "message_end":
132
- if (event.message?.role === "assistant") {
133
- progress.turns += 1;
134
- const usage = event.message.usage;
135
- if (usage) {
136
- // Guard each count like pi-stream's usageNumber does: a string-serialized or corrupt
137
- // wire value would otherwise poison the live cell — `+=` would concatenate output into
138
- // a string and Math.max would yield NaN. An unusable total keeps the previous context
139
- // (this cell is "latest value"), while output and the peak floor treat it as absent.
140
- progress.contextTokens = nonNegativeNumber(usage.totalTokens, progress.contextTokens);
141
- progress.outputTokens += nonNegativeNumber(usage.output, 0);
142
- progress.peakContextTokens = Math.max(progress.peakContextTokens, nonNegativeNumber(usage.totalTokens, 0));
143
- }
144
- // The first text the role speaks in this run is its work item ("I'll implement plan X");
145
- // later messages never replace it.
146
- if (!progress.currentWork)
147
- progress.currentWork = workItemFromContent(event.message.content);
148
- }
168
+ applyAssistantTurn(progress, event);
169
+ applyReplyEvent(progress, event);
149
170
  break;
150
171
  }
151
172
  }
173
+ /** A message starts or ends: fold it into the pending-reply state. A request opened before this
174
+ * observer attached is stamped with pi's own timestamp on the message, not with "now", so a
175
+ * dashboard started mid-wait still shows the whole wait. */
176
+ function applyReplyEvent(progress, event) {
177
+ const sent = event.message?.timestamp;
178
+ const now = typeof sent === "number" && Number.isFinite(sent) ? sent : Date.now();
179
+ progress.pendingReply = foldPendingReply(progress.pendingReply, event, { now });
180
+ }
181
+ /** The raw-line step both feeds take before parsing (`line` already trimmed), true when the line
182
+ * needs nothing more. A streaming delta (most of a pi log) is consumed without a parse unless a
183
+ * model request is still waiting on its answer: then the first content-bearing delta is that
184
+ * answer. A message_start whose compact prefix verifiably names a non-assistant role (a user or
185
+ * tool-result message, whose content can run to tens of KB) is consumed unparsed too: only an
186
+ * assistant message_start opens a request. */
187
+ function consumedUnparsed(progress, line) {
188
+ if (piEventType(line) === "message_update") {
189
+ if (progress.pendingReply) {
190
+ const hasContent = messageUpdateHasContent(parseJsonObject(line));
191
+ progress.pendingReply = foldPendingReply(progress.pendingReply, { type: "message_update" }, { hasContent });
192
+ }
193
+ return true;
194
+ }
195
+ return line.startsWith(MESSAGE_START_ROLE) && !line.startsWith('assistant"', MESSAGE_START_ROLE.length);
196
+ }
152
197
  /** Parse pi event lines of ONE run (the `tumwater_run` marker is ignored; the run's
153
198
  * `session` event resets the accumulator). Exported for tests; readLiveProgress routes
154
199
  * through feedDemuxed instead, since a real role log can interleave two runs' lines. A
@@ -156,7 +201,10 @@ function feedLine(progress, event) {
156
201
  export function parseProgress(lines, quietMs) {
157
202
  const progress = freshProgress(quietMs);
158
203
  for (const line of lines) {
159
- const event = parsePiEventLine(line, PROGRESS_TYPES);
204
+ const trimmed = line.trim();
205
+ if (consumedUnparsed(progress, trimmed))
206
+ continue;
207
+ const event = parsePiEventLine(trimmed, PROGRESS_TYPES);
160
208
  if (event)
161
209
  feedLine(progress, event);
162
210
  }
@@ -164,6 +212,7 @@ export function parseProgress(lines, quietMs) {
164
212
  // exists here so callers that pass a hand-built tail through workingDetail see the same
165
213
  // shape readLiveProgress returns.
166
214
  progress.stalledTool = stalledToolLabel(progress.openToolCalls);
215
+ progress.unansweredReply = unansweredReply(progress.pendingReply);
167
216
  return progress;
168
217
  }
169
218
  /** The configured threshold for flagging an open tool call as stalled, in ms (0 when
@@ -200,6 +249,16 @@ export function stalledToolLabel(open, now = Date.now(), stallMs = defaultConfig
200
249
  }
201
250
  return undefined;
202
251
  }
252
+ /** How long `pending` has waited and over how many attempts, once that wait reaches `stallMs` —
253
+ * the in-flight cell's "no model reply" flag, on the same threshold runPi's unanswered-request
254
+ * warning uses (src/pi/pi-watchdogs.ts), so the state cell and the event feed agree. `now` and
255
+ * `stallMs` are injectable for tests. */
256
+ export function unansweredReply(pending, now = Date.now(), stallMs = defaultConfig().toolCallStallSeconds * 1000) {
257
+ if (!pending || stallMs <= 0)
258
+ return undefined;
259
+ const waitedMs = now - pending.since;
260
+ return waitedMs >= stallMs ? { waitedMs, attempts: pending.attempts } : undefined;
261
+ }
203
262
  /** Fold one raw log line into a role log's per-kind tail state (mutates it): the harness's
204
263
  * `tumwater_run` marker or a `session` event switches which accumulator following lines fold
205
264
  * into. A marker carries the run's kind, so a `session` preceded by one keeps that kind even
@@ -210,7 +269,10 @@ export function stalledToolLabel(open, now = Date.now(), stallMs = defaultConfig
210
269
  * run's turns/context can never show in the new run's cell. Everything else folds into the
211
270
  * current kind's accumulator. Non-JSON noise is skipped. */
212
271
  function feedDemuxed(tail, line, gateCwd) {
213
- const event = parsePiEventLine(line, PROGRESS_TYPES);
272
+ const trimmed = line.trim();
273
+ if (consumedUnparsed(tail[tail.cur], trimmed))
274
+ return;
275
+ const event = parsePiEventLine(trimmed, PROGRESS_TYPES);
214
276
  if (!event)
215
277
  return; // Blank, unparseable, or a type this feed does not act on.
216
278
  if (event.type === "session") {
@@ -251,7 +313,7 @@ function feedDemuxed(tail, line, gateCwd) {
251
313
  * run in the role's own worktree, the default; `gate` — the review gate's runs in a leased pool
252
314
  * slot, what the reviewing cell shows), or null when there is no log yet. The raw log is
253
315
  * append-only while pi runs and every run starts with a `tumwater_run` marker
254
- * (src/agent/run-spawn.ts), so after seeding from the tail window once we only read and parse
316
+ * (src/agent/agent-spawn.ts), so after seeding from the tail window once we only read and parse
255
317
  * bytes appended since the last poll — observers that call this every second (TUI, GUI) stop
256
318
  * rescanning up to TAIL_BYTES
257
319
  * of JSON per role per poll. */
@@ -269,6 +331,7 @@ export function readLiveProgress(root, role, kind = "author") {
269
331
  // observer first saw the log stamps "now" at seed, so it cannot be flagged retroactively;
270
332
  // the mtime-based "no pi output" flag covers late observers.
271
333
  progress.stalledTool = stalledToolLabel(progress.openToolCalls, Date.now(), toolCallStallMs(root), progress.agent);
334
+ progress.unansweredReply = unansweredReply(progress.pendingReply, Date.now(), toolCallStallMs(root));
272
335
  // Copy the open-call entries: feedLine mutates the stored tail value in place — an
273
336
  // un-copied collection would alias one mutable structure across every frame ever returned
274
337
  // AND with the live tail state.