tumwater 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (359) hide show
  1. package/README.md +63 -28
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-eligibility.js +332 -43
  26. package/dist/src/backlog/backlog-md.js +79 -17
  27. package/dist/src/backlog/backlog-render.js +62 -11
  28. package/dist/src/backlog/backlog-structure.js +261 -58
  29. package/dist/src/backlog/backlog-write.js +178 -441
  30. package/dist/src/backlog/backlog.js +113 -16
  31. package/dist/src/backlog/question-answer.js +339 -0
  32. package/dist/src/baseline/main-baseline.js +75 -88
  33. package/dist/src/baseline/main-red.js +32 -30
  34. package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
  35. package/dist/src/{brief.js → brief/brief.js} +33 -8
  36. package/dist/src/budget/budget.js +32 -15
  37. package/dist/src/budget/fallback-breaker.js +8 -7
  38. package/dist/src/build/build-check-detect.js +8 -7
  39. package/dist/src/build/build-check-events.js +68 -22
  40. package/dist/src/build/build-check-report.js +22 -23
  41. package/dist/src/build/build-check-scoped.js +51 -62
  42. package/dist/src/build/build-check.js +15 -14
  43. package/dist/src/build/build-info.js +2 -1
  44. package/dist/src/build/build-stage.js +23 -22
  45. package/dist/src/build/dep-install.js +22 -21
  46. package/dist/src/build/host-sleep.js +1 -1
  47. package/dist/src/change/change-data.js +13 -11
  48. package/dist/src/change/change-render.js +11 -10
  49. package/dist/src/cli/brief-command.js +9 -7
  50. package/dist/src/cli/cli-args.js +25 -5
  51. package/dist/src/cli/cli-command-args.js +43 -10
  52. package/dist/src/cli/cli-editor.js +13 -1
  53. package/dist/src/cli/cli-flag-specs.js +31 -6
  54. package/dist/src/cli/cli-marker-commands.js +13 -10
  55. package/dist/src/cli/cli-query-commands.js +439 -56
  56. package/dist/src/cli/cli-repo-gate.js +1 -1
  57. package/dist/src/cli/cli-run.js +124 -68
  58. package/dist/src/cli/completion.js +70 -12
  59. package/dist/src/cli/config-commands.js +14 -11
  60. package/dist/src/cli/help.js +264 -124
  61. package/dist/src/cli/log-commands.js +127 -21
  62. package/dist/src/cli/notify-command.js +33 -18
  63. package/dist/src/cli/question-commands.js +13 -224
  64. package/dist/src/cli.js +78 -36
  65. package/dist/src/collections.js +66 -16
  66. package/dist/src/concurrency/check-permit.js +16 -17
  67. package/dist/src/concurrency/lock.js +6 -5
  68. package/dist/src/concurrency/provider-lease.js +69 -0
  69. package/dist/src/concurrency/semaphore.js +258 -36
  70. package/dist/src/config/config-aliases.js +106 -0
  71. package/dist/src/config/config-editable-keys.js +3 -3
  72. package/dist/src/config/config-fallbacks.js +379 -0
  73. package/dist/src/config/config-field-checks.js +19 -10
  74. package/dist/src/config/config-keys.js +160 -0
  75. package/dist/src/config/config-live.js +64 -12
  76. package/dist/src/config/config-schema.js +34 -22
  77. package/dist/src/config/config-validation.js +132 -48
  78. package/dist/src/config/config-views.js +107 -325
  79. package/dist/src/config/config-write.js +138 -177
  80. package/dist/src/config/config.js +60 -49
  81. package/dist/src/config/model-selector.js +28 -2
  82. package/dist/src/doctor/doctor-backlog.js +494 -68
  83. package/dist/src/doctor/doctor-checks.js +509 -58
  84. package/dist/src/doctor/doctor-holds.js +139 -78
  85. package/dist/src/doctor/doctor-install.js +30 -0
  86. package/dist/src/doctor/doctor-launch-services.js +2 -2
  87. package/dist/src/doctor/doctor-model-checks.js +549 -157
  88. package/dist/src/doctor/doctor-notify.js +2 -3
  89. package/dist/src/doctor/doctor-orphans.js +10 -9
  90. package/dist/src/doctor/doctor-render.js +36 -9
  91. package/dist/src/doctor/doctor.js +76 -25
  92. package/dist/src/doctor/image-probe.js +244 -0
  93. package/dist/src/doctor/probe.js +64 -0
  94. package/dist/src/events/event-format.js +219 -106
  95. package/dist/src/events/event-labels.js +116 -0
  96. package/dist/src/events/event-legacy.js +140 -0
  97. package/dist/src/events/event-read.js +58 -22
  98. package/dist/src/events/event-window.js +27 -7
  99. package/dist/src/events/events.js +18 -14
  100. package/dist/src/events/notify.js +80 -30
  101. package/dist/src/failure/failure-cluster.js +12 -9
  102. package/dist/src/failure/failure-data.js +96 -48
  103. package/dist/src/failure/failure-render.js +69 -58
  104. package/dist/src/failure/failure-state-change.js +33 -26
  105. package/dist/src/failure/time-spend.js +79 -59
  106. package/dist/src/files/file-queue.js +16 -4
  107. package/dist/src/files/files.js +18 -6
  108. package/dist/src/files/json-object.js +18 -3
  109. package/dist/src/files/tail.js +1 -1
  110. package/dist/src/gates/bootstrap-gates.js +5 -4
  111. package/dist/src/gates/budget-gates.js +250 -34
  112. package/dist/src/gates/disk-gate.js +18 -18
  113. package/dist/src/gates/gate-polls.js +117 -69
  114. package/dist/src/gates/maintenance-quota.js +29 -29
  115. package/dist/src/gates/pause-gates.js +11 -11
  116. package/dist/src/gates/readiness.js +2 -1
  117. package/dist/src/gates/role-cap-gates.js +16 -21
  118. package/dist/src/gates/startup-gate.js +75 -31
  119. package/dist/src/gates/streak-gate.js +18 -16
  120. package/dist/src/git/commit-message.js +6 -5
  121. package/dist/src/git/git-diff.js +16 -16
  122. package/dist/src/git/git-run.js +8 -8
  123. package/dist/src/git/git.js +79 -59
  124. package/dist/src/git/slots-state.js +14 -5
  125. package/dist/src/git/worktree-pool.js +25 -18
  126. package/dist/src/git/worktree-use.js +15 -14
  127. package/dist/src/git/worktree.js +18 -18
  128. package/dist/src/gui/gui-args.js +10 -14
  129. package/dist/src/gui/gui-backlog-commands.js +273 -0
  130. package/dist/src/gui/gui-command.js +1 -1
  131. package/dist/src/gui/gui-config-commands.js +440 -0
  132. package/dist/src/gui/gui-endpoint-commands.js +16 -716
  133. package/dist/src/gui/gui-endpoint-common.js +49 -0
  134. package/dist/src/gui/gui-endpoints.js +105 -54
  135. package/dist/src/gui/gui-operator-commands.js +145 -0
  136. package/dist/src/gui/gui-prompt-commands.js +234 -0
  137. package/dist/src/gui/gui-server.js +25 -8
  138. package/dist/src/gui/gui-team-commands.js +164 -0
  139. package/dist/src/gui/http-body.js +17 -1
  140. package/dist/src/history/history-data.js +179 -104
  141. package/dist/src/history/history-render.js +76 -0
  142. package/dist/src/history/history.js +29 -70
  143. package/dist/src/inbox/inbox-attachments.js +66 -10
  144. package/dist/src/inbox/inbox-cancel.js +7 -5
  145. package/dist/src/inbox/inbox-edit.js +1 -1
  146. package/dist/src/inbox/inbox-submit.js +44 -4
  147. package/dist/src/inbox/inbox-top.js +83 -0
  148. package/dist/src/inbox/inbox.js +8 -2
  149. package/dist/src/inbox/pending-prompt.js +1 -1
  150. package/dist/src/inbox/prompt-commands.js +114 -73
  151. package/dist/src/init/init-templates.js +16 -14
  152. package/dist/src/init/init.js +12 -11
  153. package/dist/src/loop/leftover.js +28 -27
  154. package/dist/src/loop/loop-fallback.js +2 -2
  155. package/dist/src/loop/loop-pi.js +104 -50
  156. package/dist/src/loop/loop-recovery.js +15 -15
  157. package/dist/src/loop/loop-state.js +130 -13
  158. package/dist/src/loop/loop-tick.js +190 -88
  159. package/dist/src/loop/loop.js +68 -36
  160. package/dist/src/loop/model-fallback.js +3 -2
  161. package/dist/src/loop/revision.js +16 -7
  162. package/dist/src/merge/backlog-conflicts.js +155 -0
  163. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  164. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  165. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  166. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  167. package/dist/src/merge/merge-drain.js +212 -0
  168. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -20
  169. package/dist/src/merge/merge-pipeline.js +99 -0
  170. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  171. package/dist/src/merge/merge-queue-render.js +75 -0
  172. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  173. package/dist/src/merge/merge-slot.js +340 -0
  174. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  175. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  176. package/dist/src/merge/merge-verify.js +212 -0
  177. package/dist/src/notes-cli.js +97 -0
  178. package/dist/src/operator/operator-commands.js +113 -45
  179. package/dist/src/operator/operator-intent.js +74 -53
  180. package/dist/src/operator/operator-requests.js +52 -52
  181. package/dist/src/operator/retire.js +47 -28
  182. package/dist/src/operator/revert.js +71 -0
  183. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  184. package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
  185. package/dist/src/orchestrator/orchestrator.js +149 -96
  186. package/dist/src/orchestrator/retention.js +29 -12
  187. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  188. package/dist/src/orchestrator/startup-migrations.js +40 -0
  189. package/dist/src/paths.js +124 -38
  190. package/dist/src/pi/pi-args.js +26 -3
  191. package/dist/src/pi/pi-backend.js +15 -23
  192. package/dist/src/pi/pi-bin.js +18 -12
  193. package/dist/src/pi/pi-event-line.js +17 -1
  194. package/dist/src/pi/pi-failure.js +87 -0
  195. package/dist/src/pi/pi-models.js +126 -38
  196. package/dist/src/pi/pi-run-result.js +1 -1
  197. package/dist/src/pi/pi-settings.js +86 -0
  198. package/dist/src/pi/pi-stream.js +131 -107
  199. package/dist/src/pi/pi-watchdogs.js +4 -3
  200. package/dist/src/pi-extension/bounded-output.js +20 -5
  201. package/dist/src/pi-extension/context-budget.js +3 -1
  202. package/dist/src/pi-extension/context-shake.js +54 -21
  203. package/dist/src/pi-extension/context-window.js +37 -0
  204. package/dist/src/pi-extension/full-output.js +2 -2
  205. package/dist/src/pi-extension/strip-thinking.js +80 -0
  206. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  207. package/dist/src/process/launch-services.js +11 -10
  208. package/dist/src/process/process-table.js +1 -1
  209. package/dist/src/process/process.js +7 -7
  210. package/dist/src/process/run-marker.js +29 -6
  211. package/dist/src/process/supervisor.js +36 -25
  212. package/dist/src/prompt/dialect.js +30 -10
  213. package/dist/src/prompt/gate-prompts.js +170 -67
  214. package/dist/src/prompt/principles.js +1 -0
  215. package/dist/src/prompt/prompt-followup.js +29 -20
  216. package/dist/src/prompt/prompt.js +249 -91
  217. package/dist/src/redeploy/redeploy-policy.js +10 -10
  218. package/dist/src/redeploy/redeploy.js +6 -4
  219. package/dist/src/redeploy/redeployer.js +63 -55
  220. package/dist/src/redeploy/self-reload.js +1 -1
  221. package/dist/src/report/report-data.js +259 -254
  222. package/dist/src/report/report-fold.js +353 -0
  223. package/dist/src/report/report-render.js +235 -44
  224. package/dist/src/request-timeouts.js +1 -1
  225. package/dist/src/review/known-flakes.js +34 -14
  226. package/dist/src/review/protected-edits.js +4 -3
  227. package/dist/src/review/review-followup.js +16 -4
  228. package/dist/src/review/review-precheck.js +14 -13
  229. package/dist/src/review/review-verdict.js +14 -0
  230. package/dist/src/review/review.js +55 -34
  231. package/dist/src/review/secret-leaks.js +127 -0
  232. package/dist/src/review/suite-rerun.js +4 -3
  233. package/dist/src/roles/legacy-role-ids.js +21 -0
  234. package/dist/src/roles/legacy-role-migration.js +62 -0
  235. package/dist/src/roles/loop-ids.js +19 -2
  236. package/dist/src/roles/role-catalog.js +109 -131
  237. package/dist/src/roles/role-guidance.js +64 -31
  238. package/dist/src/roles/role-labels.js +10 -0
  239. package/dist/src/roles/role-render.js +194 -18
  240. package/dist/src/roles/role-view.js +216 -17
  241. package/dist/src/roles/roles.js +16 -14
  242. package/dist/src/scheduling/backoff.js +29 -19
  243. package/dist/src/scheduling/claims.js +15 -8
  244. package/dist/src/scheduling/once-round.js +11 -11
  245. package/dist/src/scheduling/quiet-hours.js +25 -32
  246. package/dist/src/scheduling/scheduling.js +28 -25
  247. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  248. package/dist/src/status/status-budget.js +115 -0
  249. package/dist/src/status/status-data.js +72 -184
  250. package/dist/src/status/status-inbox.js +47 -0
  251. package/dist/src/status/status-install.js +27 -0
  252. package/dist/src/status/status-polls.js +50 -39
  253. package/dist/src/status/status-roles.js +105 -0
  254. package/dist/src/{fleet → team}/error-storm.js +10 -10
  255. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  256. package/dist/src/{fleet → team}/orchestrator-info.js +84 -18
  257. package/dist/src/{fleet → team}/reclaim.js +5 -4
  258. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  259. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  260. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +39 -38
  261. package/dist/src/text/datetime.js +42 -2
  262. package/dist/src/text/format.js +10 -1
  263. package/dist/src/text/markdown.js +1 -1
  264. package/dist/src/text/phrases.js +35 -34
  265. package/dist/src/text/suggest.js +3 -2
  266. package/dist/src/text/text.js +2 -2
  267. package/dist/src/tick/qa-coverage.js +2 -1
  268. package/dist/src/tick/stage-check.js +142 -25
  269. package/dist/src/tick/telemetry-digest.js +10 -9
  270. package/dist/src/tick/tick-apply.js +101 -72
  271. package/dist/src/tick/tick-detail-data.js +23 -22
  272. package/dist/src/tick/tick-detail.js +37 -24
  273. package/dist/src/tick/tick-finalize.js +29 -22
  274. package/dist/src/tick/tick-outcome.js +2 -1
  275. package/dist/src/tick/tick-prompt.js +94 -26
  276. package/dist/src/tick/tick-resume.js +29 -13
  277. package/dist/src/tick/tick-stage.js +67 -53
  278. package/dist/src/tick/tick-timing.js +60 -59
  279. package/dist/src/tick/tick-usage.js +53 -23
  280. package/dist/src/tick/tick-verdict.js +75 -10
  281. package/dist/src/ui/badges.js +113 -69
  282. package/dist/src/ui/gui/gui-client-boot.js +202 -29
  283. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  284. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  285. package/dist/src/ui/gui/gui-client-history.js +94 -37
  286. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  287. package/dist/src/ui/gui/gui-client-loops.js +90 -47
  288. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  289. package/dist/src/ui/gui/gui-client-model.js +60 -43
  290. package/dist/src/ui/gui/gui-client-operator.js +73 -43
  291. package/dist/src/ui/gui/gui-client-pending.js +34 -31
  292. package/dist/src/ui/gui/gui-client-report.js +330 -47
  293. package/dist/src/ui/gui/gui-client-settings.js +126 -18
  294. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  295. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
  296. package/dist/src/ui/gui/gui-client.js +26 -20
  297. package/dist/src/ui/gui/gui-page.js +18 -19
  298. package/dist/src/ui/gui/gui-styles.js +12 -4
  299. package/dist/src/ui/progress-data.js +5 -3
  300. package/dist/src/ui/status-model.js +176 -102
  301. package/dist/src/ui/status-payload.js +114 -36
  302. package/dist/src/ui/status-render.js +121 -52
  303. package/dist/src/ui/team-alerts.js +341 -0
  304. package/dist/src/ui/tick-progress-model.js +14 -14
  305. package/dist/src/ui/tone.js +9 -8
  306. package/dist/src/ui/transcript-tail.js +1 -0
  307. package/dist/src/ui/transcript.js +23 -16
  308. package/dist/src/ui/tui/tui-app.js +1 -0
  309. package/dist/src/ui/tui/tui-backlog.js +85 -11
  310. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  311. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  312. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  313. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  314. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  315. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  316. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  317. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  318. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  319. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  320. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  321. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  322. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  323. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  324. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  325. package/dist/src/ui/tui/tui-editors.js +152 -0
  326. package/dist/src/ui/tui/tui-frame.js +61 -57
  327. package/dist/src/ui/tui/tui-history.js +12 -7
  328. package/dist/src/ui/tui/tui-input.js +44 -50
  329. package/dist/src/ui/tui/tui-keymap.js +1 -0
  330. package/dist/src/ui/tui/tui-keys.js +87 -1821
  331. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  332. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  333. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  334. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  335. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  336. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  337. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  338. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  339. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  340. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  341. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  342. package/dist/src/ui/tui/tui-pane.js +52 -28
  343. package/dist/src/ui/tui/tui-panes.js +294 -0
  344. package/dist/src/ui/tui/tui.js +44 -26
  345. package/dist/src/verdict/fix-claim.js +56 -33
  346. package/dist/src/verdict/no-change.js +19 -0
  347. package/dist/src/verdict/refusal.js +43 -11
  348. package/dist/src/verdict/reply-contract.js +17 -5
  349. package/dist/src/version.js +3 -2
  350. package/package.json +4 -2
  351. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  352. package/dist/src/landing/backlog-conflicts.js +0 -116
  353. package/dist/src/landing/landing-drain.js +0 -204
  354. package/dist/src/landing/landing-pipeline.js +0 -96
  355. package/dist/src/landing/landing-queue-render.js +0 -56
  356. package/dist/src/landing/landing-slot.js +0 -256
  357. package/dist/src/landing/landing-vetting.js +0 -196
  358. package/dist/src/pi-extension/role-notes.js +0 -40
  359. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The dashboard's POST config/budget command handlers (barrelled into
3
+ * gui/gui-endpoint-commands.ts, which routes to them from src/gui/gui-server.ts):
4
+ * config-set, config-unset, budget, role-cap, role-enable, role-disable, and
5
+ * notify-test — the
6
+ * post-handler layer onto
7
+ * the config-write domain module. Each handler adapts HTTP and touches no socket beyond its
8
+ * own `res`.
9
+ */
10
+ import { setConfigKey, setDailyBudgetUsd, setRoleModelFields, unsetConfigKey } from "../config/config-write.js";
11
+ import { checkDailyBudgetUsd, checkMinRunInterval } from "../config/config-keys.js";
12
+ import { testNotifyHook } from "../cli/notify-command.js";
13
+ import { rejectBadRole, requireConfigKey } from "./gui-args.js";
14
+ import { baseRoleOf } from "../roles/loop-ids.js";
15
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
16
+ import { badRequest, sendJson } from "./http-body.js";
17
+ import { gotSuffix } from "../text/text.js";
18
+ import { readPostBody } from "./gui-endpoint-common.js";
19
+ /** Handle POST /api/budget: the dashboard's budget-badge editor saves the daily cost cap —
20
+ * same body discipline as /api/prompt (readJsonObject), and the same shared validation rule +
21
+ * atomic setter the TUI's Ctrl+B uses, so both surfaces write tumwater.json identically and
22
+ * the running orchestrator picks the change up on its next ~2 s poll. */
23
+ export async function handleBudget(req, res, root) {
24
+ const body = await readPostBody(req, res, '{"maxDailyCostUsd": 25}');
25
+ if (!body)
26
+ return;
27
+ const value = body.maxDailyCostUsd;
28
+ if (value === undefined) {
29
+ badRequest(res, "maxDailyCostUsd required");
30
+ return;
31
+ }
32
+ // The shared rule (finite ≥ 0; 0 disables): missing/non-finite/negative → 400 with
33
+ // the offending value named.
34
+ const problem = checkDailyBudgetUsd(value);
35
+ if (problem) {
36
+ badRequest(res, problem);
37
+ return;
38
+ }
39
+ const result = setDailyBudgetUsd(root, value);
40
+ if (!result.ok) {
41
+ // The value was valid — this is a server-side failure (broken tumwater.json or
42
+ // disk), not the client's fault.
43
+ sendJson(res, 500, { error: result.error });
44
+ return;
45
+ }
46
+ sendJson(res, 200, { ok: true, maxDailyCostUsd: value });
47
+ }
48
+ /** Handle POST /api/role-cap: the dashboard role drawer's editor saves one role's daily cost
49
+ * cap — the control the amber role-cap alert remedies. Same body discipline as /api/budget
50
+ * (readPostBody → 400 malformed/non-object, 413 oversized); the value rides the team editor's
51
+ * shared checkDailyBudgetUsd screen (naming the per-role dotted key), then the write goes
52
+ * through the CLI's own setConfigKey dotted path so the per-key validator and whole-candidate
53
+ * validateConfig (which rejects an unknown role id) own the final truth. The director has no
54
+ * cap (role-view.ts's guard exempts it), so it is refused up front with wording that mirrors
55
+ * handleRoleNotes. A bare `feature-2` loop id maps to its base role's cap (baseRoleOf), since
56
+ * the cap is about the role. A write failure is configKeyError's 400 exactly as handleConfigSet
57
+ * reports it. */
58
+ export async function handleRoleCap(req, res, root) {
59
+ const body = await readPostBody(req, res, '{"role": "feature", "maxDailyCostUsd": 2}');
60
+ if (!body)
61
+ return;
62
+ const role = body.role;
63
+ if (typeof role !== "string" || role.trim() === "") {
64
+ badRequest(res, "role required");
65
+ return;
66
+ }
67
+ if (role === DIRECTOR_ROLE) {
68
+ badRequest(res, "the director has no daily cap");
69
+ return;
70
+ }
71
+ const value = body.maxDailyCostUsd;
72
+ if (value === undefined) {
73
+ badRequest(res, "maxDailyCostUsd required");
74
+ return;
75
+ }
76
+ const baseRole = baseRoleOf(role);
77
+ // The shared rule (finite ≥ 0; 0 disables), naming the per-role dotted key so a rejected
78
+ // write tells the operator which key the drawer would set.
79
+ const problem = checkDailyBudgetUsd(value, "maxDailyCostUsdPerRole." + baseRole);
80
+ if (problem) {
81
+ badRequest(res, problem);
82
+ return;
83
+ }
84
+ // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
85
+ // exact, so the shared per-key validator + whole-config validateConfig apply unchanged.
86
+ const result = setConfigKey(root, "maxDailyCostUsdPerRole." + baseRole, JSON.stringify(value));
87
+ if (!result.ok)
88
+ return configKeyError(res, "maxDailyCostUsdPerRole." + baseRole, result);
89
+ sendJson(res, 200, { ok: true, role: baseRole, maxDailyCostUsd: value });
90
+ }
91
+ /** Handle POST /api/role-interval: the role drawer's min run interval editor sets or clears
92
+ * one role's override of the team-wide minRunIntervalSeconds — the knob that makes one role
93
+ * act rarely, distinct from the fleet-wide value. Same body discipline as /api/role-cap
94
+ * (readPostBody → 400 malformed/non-object, 413 oversized); a null, "" or whitespace-only
95
+ * value clears the key through unsetConfigKey (so the role falls back to the team-wide
96
+ * interval), otherwise the write rides the CLI's own setConfigKey dotted path so
97
+ * checkMinRunInterval and whole-candidate validateConfig (which rejects an unknown role id)
98
+ * own the final truth. The director is accepted here — it ticks on the same clock, unlike
99
+ * the cap's director exemption. A bare `feature-2` loop id maps to its base role's override
100
+ * (baseRoleOf), since the interval is about the role. A write failure is configKeyError's 400
101
+ * exactly as handleRoleCap reports it. */
102
+ export async function handleRoleInterval(req, res, root) {
103
+ const body = await readPostBody(req, res, '{"role": "feature", "minRunIntervalSeconds": 300}');
104
+ if (!body)
105
+ return;
106
+ const role = body.role;
107
+ if (typeof role !== "string" || role.trim() === "") {
108
+ badRequest(res, "role required");
109
+ return;
110
+ }
111
+ const value = body.minRunIntervalSeconds;
112
+ if (value === undefined) {
113
+ badRequest(res, "minRunIntervalSeconds required");
114
+ return;
115
+ }
116
+ const base = baseRoleOf(role);
117
+ // A null, empty or whitespace-only value clears the override, so the role falls back to the
118
+ // team-wide minRunIntervalSeconds (unsetConfigKey, like the CLI's config unset of the key).
119
+ if (value === null || (typeof value === "string" && value.trim() === "")) {
120
+ // unsetConfigKey's no-op map path (a role the file never holds, an unknown id included)
121
+ // skips validateConfig, so without this the set branch's unknown-role refusal would never
122
+ // fire on a clear and a bogus id would answer 200 "cleared" — the explicit check mirrors
123
+ // the CLI's knownRoleIds guard and the role-quiet-hours clear branch.
124
+ if (rejectBadRole(root, res, role))
125
+ return;
126
+ const clearResult = unsetConfigKey(root, `roles.${base}.minRunIntervalSeconds`);
127
+ if (!clearResult.ok)
128
+ return configKeyError(res, `roles.${base}.minRunIntervalSeconds`, clearResult);
129
+ sendJson(res, 200, { ok: true, role: base, message: `${base}: min run interval cleared` });
130
+ return;
131
+ }
132
+ // The shared rule (finite 0..MAX_DURATION_SECONDS), naming the per-role dotted key so a
133
+ // rejected write tells the operator which key the drawer would set.
134
+ const problem = checkMinRunInterval(value, `roles.${base}.minRunIntervalSeconds`);
135
+ if (problem) {
136
+ badRequest(res, problem);
137
+ return;
138
+ }
139
+ // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
140
+ // exact, so the shared per-key validator + whole-config validateConfig apply unchanged.
141
+ const result = setConfigKey(root, `roles.${base}.minRunIntervalSeconds`, JSON.stringify(value));
142
+ if (!result.ok)
143
+ return configKeyError(res, `roles.${base}.minRunIntervalSeconds`, result);
144
+ sendJson(res, 200, { ok: true, role: base, minRunIntervalSeconds: value, message: `${base}: min run interval set to ${value}s` });
145
+ }
146
+ /** Handle POST /api/role-instances: the role drawer's instance-count editor sets or clears one
147
+ * role's parallel-runner count — the count that lets `feature-2`…`feature-N` tick. Same body
148
+ * discipline as /api/role-interval (readPostBody → 400 malformed/non-object, 413 oversized); a
149
+ * null, "" or whitespace-only value clears the key through unsetConfigKey (so the role falls
150
+ * back to the default 1), otherwise the write rides the CLI's own setConfigKey dotted path so
151
+ * whole-candidate validateConfig owns the final truth. There is no own range or role check here
152
+ * (unlike handleRoleCap's director exemption): setConfigKey runs the candidate through
153
+ * validateConfig, whose INSTANCE_COUNT rejects 0, 9, 1.5 and `abc` and whose INSTANCE_ROLES
154
+ * branch rejects an instances key under any role but feature and bugfix (the director and
155
+ * custom roles included). A bare `feature-2` loop id maps to its base role's count (baseRoleOf),
156
+ * since the count is about the role. A write failure is configKeyError's 400 exactly as
157
+ * handleRoleInterval reports it. */
158
+ export async function handleRoleInstances(req, res, root) {
159
+ const body = await readPostBody(req, res, '{"role": "feature", "instances": 3}');
160
+ if (!body)
161
+ return;
162
+ const role = body.role;
163
+ if (typeof role !== "string" || role.trim() === "") {
164
+ badRequest(res, "role required");
165
+ return;
166
+ }
167
+ const value = body.instances;
168
+ if (value === undefined) {
169
+ badRequest(res, "instances required");
170
+ return;
171
+ }
172
+ const base = baseRoleOf(role);
173
+ // A null, empty or whitespace-only value clears the key, so the role falls back to the
174
+ // default 1 (unsetConfigKey, like the CLI's config unset of the key).
175
+ if (value === null || (typeof value === "string" && value.trim() === "")) {
176
+ // unsetConfigKey's no-op map path (a role the file never holds, an unknown id included)
177
+ // skips validateConfig, so without this the set branch's unknown-role refusal would never
178
+ // fire on a clear and a bogus id would answer 200 "cleared" — the explicit check mirrors
179
+ // the CLI's knownRoleIds guard and the role-interval clear branch.
180
+ if (rejectBadRole(root, res, role))
181
+ return;
182
+ const clearResult = unsetConfigKey(root, `roles.${base}.instances`);
183
+ if (!clearResult.ok)
184
+ return configKeyError(res, `roles.${base}.instances`, clearResult);
185
+ sendJson(res, 200, { ok: true, role: base, message: `${base}: instances cleared` });
186
+ return;
187
+ }
188
+ // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
189
+ // exact, so the shared per-key validator + whole-config validateConfig (which owns which
190
+ // roles may split and the 1–8 integer bound) apply unchanged.
191
+ const result = setConfigKey(root, `roles.${base}.instances`, JSON.stringify(value));
192
+ if (!result.ok)
193
+ return configKeyError(res, `roles.${base}.instances`, result);
194
+ sendJson(res, 200, { ok: true, role: base, instances: value, message: `${base}: instances set to ${value}` });
195
+ }
196
+ /** Handle POST /api/role-model: the role drawer's Model overrides editor sets or clears one
197
+ * role's provider/model/thinking triple (roles.<base>.provider/.model/.thinking) in one
198
+ * atomic write, prefilled from the payload's raw overrides — the per-role model write the
199
+ * `Model` row it still shows read-only is missing (the CLI's --provider/--model/--thinking
200
+ * already write it). Same body discipline as /api/role-interval (readPostBody → 400
201
+ * malformed/non-object, 413 oversized); a missing/blank role is a 400 `role required`, each
202
+ * of the three must be a string when present (400 naming the field otherwise), and at least
203
+ * one must be present (400 `provider, model or thinking required`). A bare `feature-2` loop
204
+ * id maps to its base role's keys (baseRoleOf). The director is accepted — configForRole
205
+ * resolves roles.director.* like any role's, matching the CLI's accepted triple for it.
206
+ * Values are trimmed; `""` clears that field (never stored — checkStringField rejects an
207
+ * empty string). The write rides setRoleModelFields, whose validateConfig names
208
+ * roles.<base>.<field> on a bad value and refuses an unknown role, reported as a 400 exactly
209
+ * as handleRoleInterval reports a write failure. */
210
+ export async function handleRoleModel(req, res, root) {
211
+ const body = await readPostBody(req, res, '{"role": "feature", "provider": "claude", "model": "claude-opus-5", "thinking": "high"}');
212
+ if (!body)
213
+ return;
214
+ const role = body.role;
215
+ if (typeof role !== "string" || role.trim() === "") {
216
+ badRequest(res, "role required");
217
+ return;
218
+ }
219
+ const fields = {};
220
+ for (const key of ["provider", "model", "thinking"]) {
221
+ const value = body[key];
222
+ if (value === undefined)
223
+ continue;
224
+ if (typeof value !== "string") {
225
+ badRequest(res, `"${key}" must be a string — a model override, or "" to clear it${gotSuffix(value)}`);
226
+ return;
227
+ }
228
+ fields[key] = value.trim();
229
+ }
230
+ if (!("provider" in fields) && !("model" in fields) && !("thinking" in fields)) {
231
+ badRequest(res, "provider, model or thinking required");
232
+ return;
233
+ }
234
+ const base = baseRoleOf(role);
235
+ const result = setRoleModelFields(root, base, fields);
236
+ if (!result.ok)
237
+ return badRequest(res, result.error);
238
+ sendJson(res, 200, { ok: true, role: base, message: `${base}: model updated` });
239
+ }
240
+ /** Handle POST /api/role-instructions: the role drawer's standing-instructions editor — the
241
+ * `Instructions override` row's Edit/Save. Same body discipline as /api/role-cap (readPostBody
242
+ * → 400 malformed/non-object, 413 oversized); an empty role id is a 400, and a bare `feature-2`
243
+ * loop id maps to its base role's override (baseRoleOf), the key tick-prompt reads — so an
244
+ * instance edits the instructions its runs carry (readRoleBody's shared guard is not used
245
+ * because it rejects instance ids, and the unknown-role check is validateConfig's, below).
246
+ * The director is editable here (its override reaches its runs), unlike handleRoleNotes'
247
+ * director refusal. An empty/whitespace-only text clears the key (unsetConfigKey, like the
248
+ * CLI's --clear-instructions); otherwise the write goes through the CLI's own setConfigKey
249
+ * with the same JSON.stringify wrapper --instructions-file uses, so validateConfig owns the
250
+ * 4096-char cap and the unknown-role check, and a write failure is configKeyError's 400
251
+ * naming the dotted key. */
252
+ export async function handleRoleInstructions(req, res, root) {
253
+ const body = await readPostBody(req, res, '{"role": "feature", "text": "…"}');
254
+ if (!body)
255
+ return;
256
+ const role = body.role;
257
+ if (typeof role !== "string" || role.trim() === "")
258
+ return badRequest(res, "role required");
259
+ if (typeof body.text !== "string")
260
+ return badRequest(res, `"text" must be a string — the full replacement instructions, or "" to clear it${gotSuffix(body.text)}`);
261
+ const base = baseRoleOf(role);
262
+ // An empty or whitespace-only buffer clears the override, like the CLI's --clear-instructions;
263
+ // otherwise the same JSON.stringify wrapper --instructions-file uses (setConfigKey takes raw
264
+ // CLI text, so a body that parses as JSON is stored verbatim), with validateConfig owning the
265
+ // 4096-char cap and the unknown-role check in writeConfigMutation. Unlike quiet hours the
266
+ // clear arm carries no role guard (the director may clear, and the unknown-role clear gap is
267
+ // pre-existing).
268
+ writeRoleJsonKey(res, root, base, `roles.${base}.instructions`, "instructions", body.text, (v) => JSON.stringify(v), (v) => `${base}: instructions saved (${v.length} chars)`);
269
+ }
270
+ /** The shared write behind POST /api/role-enable and POST /api/role-disable: the Settings
271
+ * card's Enable button and the role drawer's two-click Disable both write
272
+ * `roles.<base>.enabled` through the CLI's own setConfigKey (raw JSON literal, so JSON.parse
273
+ * yields the boolean checkBoolean expects), and validateConfig's unknown-role check refuses an
274
+ * unknown id as configKeyError's 400 before anything is written. Same body discipline as
275
+ * /api/role-cap (readPostBody → 400 malformed/non-object, 413 oversized); a missing,
276
+ * non-string, or blank role is a 400 `role required`, and a bare `feature-2` loop id maps to
277
+ * its base role's key (baseRoleOf), so an instance disables/enables the role it belongs to.
278
+ * The director cannot be disabled (with it disabled loopIds omits it and no loop dequeues the
279
+ * operator's inbox), so a disable is refused explicitly; enabling the director stays harmless
280
+ * and mirrors the CLI's --enable. */
281
+ async function setRoleEnabled(req, res, root, enabled) {
282
+ const body = await readPostBody(req, res, '{"role": "selfcheck"}');
283
+ if (!body)
284
+ return;
285
+ const role = body.role;
286
+ if (typeof role !== "string" || role.trim() === "")
287
+ return badRequest(res, "role required");
288
+ const base = baseRoleOf(role);
289
+ if (!enabled && base === DIRECTOR_ROLE)
290
+ return badRequest(res, "the director cannot be disabled");
291
+ const key = `roles.${base}.enabled`;
292
+ // setConfigKey takes raw CLI text; the raw JSON literal round-trips to the boolean
293
+ // checkBoolean expects, and validateConfig owns the unknown-role check in writeConfigMutation.
294
+ const result = setConfigKey(root, key, enabled ? "true" : "false");
295
+ if (!result.ok)
296
+ return configKeyError(res, key, result);
297
+ sendJson(res, 200, { ok: true, role: base, message: `${base}: ${enabled ? "enabled" : "disabled"}` });
298
+ }
299
+ /** Handle POST /api/role-enable: the Settings view's "Disabled roles" card Enable button —
300
+ * re-enables one disabled role (the GUI half of the CLI's `tumwater role <id> --enable`). Body
301
+ * discipline, role/base mapping, refusal semantics, and the write live in setRoleEnabled. */
302
+ export async function handleRoleEnable(req, res, root) {
303
+ await setRoleEnabled(req, res, root, true);
304
+ }
305
+ /** Handle POST /api/role-disable: the role drawer's two-click Disable action — disables one
306
+ * enabled role (the GUI half of the CLI's `tumwater role <id> --disable`, completing the pair
307
+ * the Settings card's Enable starts). Body discipline, role/base mapping, refusal semantics,
308
+ * and the write live in setRoleEnabled. */
309
+ export async function handleRoleDisable(req, res, root) {
310
+ await setRoleEnabled(req, res, root, false);
311
+ }
312
+ /** Handle POST /api/role-quiet-hours: the role drawer's own-window editor sets or clears one
313
+ * role's daily quiet-hours window — the control the `Quiet hours` row it already shows leads
314
+ * to (the counterpart of the in-flight `tumwater role <id> --quiet-hours` CLI write). Same body
315
+ * discipline as /api/role-cap (readPostBody → 400 malformed/non-object, 413 oversized); a
316
+ * missing/blank role is a 400, and a bare `feature-2` loop id maps to its base role's key
317
+ * (baseRoleOf), so an instance edits the window its runs read. The director is refused up front:
318
+ * `quietHoursPerRole.director` is inert (roleQuietHold and doctor both exempt the director). An
319
+ * empty/whitespace-only window clears the key (unsetConfigKey, like the CLI's --clear-quiet-hours);
320
+ * the clear branch checks the role id explicitly (rejectBadRole) because unsetConfigKey's no-op
321
+ * map path skips validateConfig — the guard the set branch gets free from validateConfig's
322
+ * unknown-role refusal; otherwise the write goes through setConfigKey with the same
323
+ * JSON.stringify wrapper, so checkQuietHours (naming quietHoursPerRole.<base>) and validateConfig
324
+ * own the truth, and a write failure is configKeyError's 400 naming the dotted key. */
325
+ export async function handleRoleQuietHours(req, res, root) {
326
+ const body = await readPostBody(req, res, '{"role": "feature", "window": "23:00-07:00"}');
327
+ if (!body)
328
+ return;
329
+ const role = body.role;
330
+ if (typeof role !== "string" || role.trim() === "")
331
+ return badRequest(res, "role required");
332
+ if (role === DIRECTOR_ROLE)
333
+ return badRequest(res, "the director has no quiet hours");
334
+ if (typeof body.window !== "string")
335
+ return badRequest(res, `"window" must be a string — a window like 23:00-07:00, or "" to clear it${gotSuffix(body.window)}`);
336
+ const base = baseRoleOf(role);
337
+ // An empty or whitespace-only window clears the hold, like the CLI's --clear-quiet-hours;
338
+ // the clear arm carries rejectBadRole because unsetConfigKey's no-op map path (a role the
339
+ // file never holds, an unknown id included) skips validateConfig, so without it the set
340
+ // branch's unknown-role refusal would never fire on a clear and a bogus id would answer 200
341
+ // "cleared" (mirrors the CLI's knownRoleIds guard). The set arm runs setConfigKey raw, its
342
+ // map branch checking the dotted key as the label and validateConfig owning the unknown role.
343
+ writeRoleJsonKey(res, root, base, `quietHoursPerRole.${base}`, "quiet hours", body.window, (v) => JSON.stringify(v), (v) => `${base}: quiet hours set to ${v}`, () => rejectBadRole(root, res, role));
344
+ }
345
+ /** The shared 400 for a failed config mutation — the set and unset write paths both nest the
346
+ * write's error under the key it names, so the Settings view can tell which field failed and a
347
+ * retried request gets the same wording from either surface. Its void return keeps the guard
348
+ * a one-liner: `if (!result.ok) return configKeyError(res, key, result);` */
349
+ function configKeyError(res, key, result) {
350
+ badRequest(res, `${key}: ${result.error}`);
351
+ }
352
+ /** The set-or-clear write the two per-role JSON-config-key handlers (quiet hours, standing
353
+ * instructions) share: an empty/whitespace `raw` unsets `key` (after `guardClear`, a per-call
354
+ * check the clear arm runs first — quiet hours' unknown-role guard; instructions has none),
355
+ * else sets it via setConfigKey (which JSON-encodes nothing, so `serialize` shapes the stored
356
+ * value). `noun` names the hold in the "cleared" 200 and `setMessage` is the exact set-arm 200
357
+ * message, threaded so each handler keeps its own wording. Always writes the response, so the
358
+ * caller never falls through to further output. */
359
+ function writeRoleJsonKey(res, root, base, key, noun, raw, serialize, setMessage, guardClear) {
360
+ if (raw.trim() === "") {
361
+ if (guardClear?.())
362
+ return;
363
+ const result = unsetConfigKey(root, key);
364
+ if (!result.ok)
365
+ configKeyError(res, key, result);
366
+ else
367
+ sendJson(res, 200, { ok: true, role: base, message: `${base}: ${noun} cleared` });
368
+ return;
369
+ }
370
+ const result = setConfigKey(root, key, serialize(raw));
371
+ if (!result.ok)
372
+ configKeyError(res, key, result);
373
+ else
374
+ sendJson(res, 200, { ok: true, role: base, message: setMessage(raw) });
375
+ }
376
+ /** Handle POST /api/config-set: the Settings view's per-key Save — one curated key set to
377
+ * one JSON value, through setConfigKey so the browser cannot drift from the CLI's rules
378
+ * (unknown-key refusal, the per-key validators' messages, whole-candidate validateConfig,
379
+ * atomic write the running team picks up live). A key outside EDITABLE_CONFIG_KEYS is
380
+ * refused 400 with the key named — a known-but-not-curated key (customRoles) included, so
381
+ * the panel's reach stays exactly EDITABLE_CONFIG_KEYS. The body's value is re-encoded with
382
+ * JSON.stringify before setConfigKey parses it back, so `set model gpt-5`'s
383
+ * JSON-or-literal rule is bypassed harmlessly: the browser already sent parsed JSON.
384
+ * Same body discipline as /api/budget (readPostBody → 400 malformed/non-object, 413
385
+ * oversized). */
386
+ export async function handleConfigSet(req, res, root) {
387
+ const body = await readPostBody(req, res, '{"key": "model", "value": "gpt-5"}');
388
+ if (!body)
389
+ return;
390
+ const key = requireConfigKey(res, body);
391
+ if (key === null)
392
+ return;
393
+ if (body.value === undefined) {
394
+ badRequest(res, `value required for ${key}`);
395
+ return;
396
+ }
397
+ // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
398
+ // exact (JSON.parse(JSON.stringify(v)) === v), so the shared write path applies unchanged.
399
+ const result = setConfigKey(root, key, JSON.stringify(body.value));
400
+ if (!result.ok)
401
+ return configKeyError(res, key, result);
402
+ sendJson(res, 200, { ok: true, key, value: result.value, oldValue: result.oldValue });
403
+ }
404
+ /** Handle POST /api/config-unset: the Settings view's per-key Clear — one curated key removed,
405
+ * through unsetConfigKey so the browser cannot drift from `tumwater config unset`'s rules
406
+ * (unknown-key refusal, whole-candidate validateConfig, atomic write the running team picks
407
+ * up live). The same EDITABLE_CONFIG_KEYS gate as /api/config-set, so the panel's reach stays
408
+ * exactly the curated set; a key outside it is refused 400 with the key named. Same body
409
+ * discipline (readPostBody → 400 malformed/non-object, 413 oversized). The 200 body omits
410
+ * `oldValue` when the file never held the key (JSON.stringify drops an undefined), so the
411
+ * client can tell a retraction from a no-op; a file value of `null` still serializes and is a
412
+ * real retraction. */
413
+ export async function handleConfigUnset(req, res, root) {
414
+ const body = await readPostBody(req, res, '{"key": "model"}');
415
+ if (!body)
416
+ return;
417
+ const key = requireConfigKey(res, body);
418
+ if (key === null)
419
+ return;
420
+ const result = unsetConfigKey(root, key);
421
+ if (!result.ok)
422
+ return configKeyError(res, key, result);
423
+ sendJson(res, 200, { ok: true, key, oldValue: result.oldValue });
424
+ }
425
+ /** Handle POST /api/notify-test: the Settings view's "Send test" — what `tumwater notify --test`
426
+ * runs (testNotifyHook, under the same NOTIFY_TEST_TIMEOUT_MS bound), with the hook's stdio
427
+ * ignored so its output never reaches the server's terminal. The body is ignored, like
428
+ * /api/restart's. No hook set is a 409 with the CLI's wording; a hook that fails or times out is
429
+ * still a 200 — the request worked, and `passed` carries the verdict. */
430
+ export async function handleNotifyTest(req, res, root) {
431
+ const body = await readPostBody(req, res, "{}");
432
+ if (!body)
433
+ return;
434
+ const outcome = await testNotifyHook(root, "ignore");
435
+ if (outcome.status === "not_configured") {
436
+ sendJson(res, 409, { error: "notify is not configured (set `notify` in tumwater.json)" });
437
+ return;
438
+ }
439
+ sendJson(res, 200, { ok: true, passed: outcome.passed, message: outcome.message });
440
+ }