tumwater 0.1.0 → 0.1.2

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 (353) hide show
  1. package/README.md +94 -25
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +34 -0
  4. package/dist/src/agent/claude/claude-account.js +243 -0
  5. package/dist/src/agent/claude/claude-args.js +104 -0
  6. package/dist/src/agent/claude/claude-backend.js +162 -0
  7. package/dist/src/agent/claude/claude-env.js +84 -0
  8. package/dist/src/agent/claude/claude-stream.js +384 -0
  9. package/dist/src/agent/mcp/role-notes-server.js +160 -0
  10. package/dist/src/agent/role-notes-write.js +65 -0
  11. package/dist/src/agent/run-agent.js +439 -0
  12. package/dist/src/agent/session-ledger.js +169 -0
  13. package/dist/src/backlog/backlog-eligibility.js +195 -0
  14. package/dist/src/{backlog.js → backlog/backlog-md.js} +86 -82
  15. package/dist/src/backlog/backlog-render.js +28 -0
  16. package/dist/src/backlog/backlog-structure.js +355 -0
  17. package/dist/src/backlog/backlog-write.js +653 -0
  18. package/dist/src/backlog/backlog.js +88 -0
  19. package/dist/src/baseline/main-baseline.js +357 -0
  20. package/dist/src/{main-red.js → baseline/main-red.js} +64 -50
  21. package/dist/src/{readme.js → brief.js} +17 -7
  22. package/dist/src/{budget.js → budget/budget.js} +62 -28
  23. package/dist/src/{fallback-breaker.js → budget/fallback-breaker.js} +60 -10
  24. package/dist/src/build/build-check-counts.js +58 -0
  25. package/dist/src/{build-check-detect.js → build/build-check-detect.js} +34 -47
  26. package/dist/src/{build-check-events.js → build/build-check-events.js} +69 -10
  27. package/dist/src/{build-check-report.js → build/build-check-report.js} +9 -9
  28. package/dist/src/{build-check-scoped.js → build/build-check-scoped.js} +40 -40
  29. package/dist/src/{build-check.js → build/build-check.js} +11 -8
  30. package/dist/src/{build-info.js → build/build-info.js} +12 -6
  31. package/dist/src/{build-stage.js → build/build-stage.js} +105 -30
  32. package/dist/src/{dep-install.js → build/dep-install.js} +14 -7
  33. package/dist/src/{host-sleep.js → build/host-sleep.js} +6 -4
  34. package/dist/src/{change-data.js → change/change-data.js} +21 -18
  35. package/dist/src/{ui/change-preview.js → change/change-render.js} +14 -7
  36. package/dist/src/{cli-args.js → cli/cli-args.js} +113 -37
  37. package/dist/src/{cli-command-args.js → cli/cli-command-args.js} +167 -85
  38. package/dist/src/cli/cli-flag-specs.js +321 -0
  39. package/dist/src/cli/cli-marker-commands.js +44 -0
  40. package/dist/src/cli/cli-output.js +63 -0
  41. package/dist/src/cli/cli-query-commands.js +249 -0
  42. package/dist/src/cli/cli-repo-gate.js +15 -0
  43. package/dist/src/cli/cli-run.js +376 -0
  44. package/dist/src/cli/config-commands.js +131 -0
  45. package/dist/src/cli/help.js +348 -0
  46. package/dist/src/{ui → cli}/log-commands.js +23 -23
  47. package/dist/src/cli/notify-command.js +68 -0
  48. package/dist/src/{question-commands.js → cli/question-commands.js} +87 -50
  49. package/dist/src/cli.js +143 -268
  50. package/dist/src/collections.js +77 -0
  51. package/dist/src/{check-permit.js → concurrency/check-permit.js} +14 -15
  52. package/dist/src/concurrency/lock.js +236 -0
  53. package/dist/src/{semaphore.js → concurrency/semaphore.js} +44 -18
  54. package/dist/src/config/config-editable-keys.js +26 -0
  55. package/dist/src/{config-example.js → config/config-example.js} +33 -17
  56. package/dist/src/config/config-field-checks.js +216 -0
  57. package/dist/src/{config-live.js → config/config-live.js} +50 -17
  58. package/dist/src/{config-schema.js → config/config-schema.js} +47 -4
  59. package/dist/src/config/config-validation.js +468 -0
  60. package/dist/src/config/config-views.js +528 -0
  61. package/dist/src/config/config-write.js +641 -0
  62. package/dist/src/{config.js → config/config.js} +123 -31
  63. package/dist/src/config/model-selector.js +74 -0
  64. package/dist/src/{doctor-backlog.js → doctor/doctor-backlog.js} +35 -38
  65. package/dist/src/doctor/doctor-checks.js +457 -0
  66. package/dist/src/doctor/doctor-holds.js +122 -0
  67. package/dist/src/doctor/doctor-launch-services.js +22 -0
  68. package/dist/src/doctor/doctor-model-checks.js +396 -0
  69. package/dist/src/{doctor-orphans.js → doctor/doctor-orphans.js} +12 -20
  70. package/dist/src/doctor/doctor-render.js +13 -0
  71. package/dist/src/{doctor.js → doctor/doctor.js} +31 -10
  72. package/dist/src/doctor/outcome.js +16 -0
  73. package/dist/src/errno.js +1 -1
  74. package/dist/src/events/event-format.js +446 -0
  75. package/dist/src/{event-read.js → events/event-read.js} +59 -25
  76. package/dist/src/{event-window.js → events/event-window.js} +106 -18
  77. package/dist/src/{events.js → events/events.js} +67 -34
  78. package/dist/src/{notify.js → events/notify.js} +26 -14
  79. package/dist/src/{failure-cluster.js → failure/failure-cluster.js} +52 -19
  80. package/dist/src/failure/failure-data.js +356 -0
  81. package/dist/src/{failure-report.js → failure/failure-render.js} +110 -66
  82. package/dist/src/{failure-state-change.js → failure/failure-state-change.js} +65 -26
  83. package/dist/src/failure/time-spend.js +240 -0
  84. package/dist/src/{file-queue.js → files/file-queue.js} +1 -1
  85. package/dist/src/files/files.js +376 -0
  86. package/dist/src/{json-files.js → files/json-files.js} +18 -15
  87. package/dist/src/files/json-object.js +94 -0
  88. package/dist/src/{stat-cache.js → files/stat-cache.js} +16 -3
  89. package/dist/src/{tail.js → files/tail.js} +14 -13
  90. package/dist/src/{error-storm.js → fleet/error-storm.js} +9 -10
  91. package/dist/src/{failure-spread.js → fleet/failure-spread.js} +7 -8
  92. package/dist/src/{fleet-hold.js → fleet/fleet-hold.js} +77 -21
  93. package/dist/src/fleet/fleet-polls.js +207 -0
  94. package/dist/src/{fleet-state.js → fleet/fleet-state.js} +98 -56
  95. package/dist/src/fleet/orchestrator-info.js +150 -0
  96. package/dist/src/fleet/reclaim.js +299 -0
  97. package/dist/src/gates/bootstrap-gates.js +101 -0
  98. package/dist/src/gates/budget-gates.js +218 -0
  99. package/dist/src/gates/disk-gate.js +78 -0
  100. package/dist/src/gates/gate-polls.js +283 -0
  101. package/dist/src/gates/maintenance-quota.js +198 -0
  102. package/dist/src/{pause-gates.js → gates/pause-gates.js} +10 -10
  103. package/dist/src/gates/readiness.js +11 -0
  104. package/dist/src/gates/role-cap-gates.js +135 -0
  105. package/dist/src/gates/startup-gate.js +124 -0
  106. package/dist/src/{streak-gate.js → gates/streak-gate.js} +31 -33
  107. package/dist/src/{commit-message.js → git/commit-message.js} +49 -14
  108. package/dist/src/{git-diff.js → git/git-diff.js} +81 -46
  109. package/dist/src/git/git-run.js +237 -0
  110. package/dist/src/git/git.js +488 -0
  111. package/dist/src/git/slots-state.js +109 -0
  112. package/dist/src/git/worktree-pool.js +399 -0
  113. package/dist/src/git/worktree-use.js +173 -0
  114. package/dist/src/git/worktree.js +263 -0
  115. package/dist/src/git/xcrun-git.js +24 -0
  116. package/dist/src/{ui → gui}/gui-args.js +68 -18
  117. package/dist/src/{ui/gui.js → gui/gui-command.js} +5 -8
  118. package/dist/src/gui/gui-endpoint-commands.js +671 -0
  119. package/dist/src/gui/gui-endpoints.js +322 -0
  120. package/dist/src/gui/gui-server.js +270 -0
  121. package/dist/src/{ui → gui}/http-body.js +35 -16
  122. package/dist/src/{history-data.js → history/history-data.js} +161 -68
  123. package/dist/src/{ui → history}/history.js +59 -60
  124. package/dist/src/inbox/inbox-attachments.js +286 -0
  125. package/dist/src/inbox/inbox-cancel.js +118 -0
  126. package/dist/src/inbox/inbox-edit.js +144 -0
  127. package/dist/src/{inbox-submit.js → inbox/inbox-submit.js} +46 -22
  128. package/dist/src/inbox/inbox.js +371 -0
  129. package/dist/src/inbox/pending-prompt.js +148 -0
  130. package/dist/src/inbox/prompt-commands.js +222 -0
  131. package/dist/src/inbox/prompt-not-before.js +61 -0
  132. package/dist/src/{init-templates.js → init/init-templates.js} +78 -4
  133. package/dist/src/{init.js → init/init.js} +89 -92
  134. package/dist/src/landing/backlog-conflicts.js +116 -0
  135. package/dist/src/{landing-batch.js → landing/landing-batch.js} +112 -73
  136. package/dist/src/{landing-check-failures.js → landing/landing-check-failures.js} +66 -40
  137. package/dist/src/{landing-core.js → landing/landing-core.js} +109 -23
  138. package/dist/src/landing/landing-diff.js +57 -0
  139. package/dist/src/{landing-drain.js → landing/landing-drain.js} +66 -17
  140. package/dist/src/{landing-git.js → landing/landing-git.js} +78 -18
  141. package/dist/src/landing/landing-merge.js +308 -0
  142. package/dist/src/{landing-pipeline.js → landing/landing-pipeline.js} +33 -9
  143. package/dist/src/landing/landing-questions.js +16 -0
  144. package/dist/src/landing/landing-queue-render.js +31 -0
  145. package/dist/src/{landing-queue.js → landing/landing-queue.js} +60 -20
  146. package/dist/src/{landing-slot.js → landing/landing-slot.js} +80 -40
  147. package/dist/src/{landing-stack.js → landing/landing-stack.js} +35 -21
  148. package/dist/src/{landing-vetting.js → landing/landing-vetting.js} +27 -22
  149. package/dist/src/{leftover.js → loop/leftover.js} +53 -20
  150. package/dist/src/loop/loop-fallback.js +66 -0
  151. package/dist/src/loop/loop-pi.js +370 -0
  152. package/dist/src/loop/loop-recovery.js +90 -0
  153. package/dist/src/loop/loop-state.js +195 -0
  154. package/dist/src/loop/loop-tick.js +539 -0
  155. package/dist/src/loop/loop.js +311 -0
  156. package/dist/src/loop/model-fallback.js +75 -0
  157. package/dist/src/loop/revision.js +61 -0
  158. package/dist/src/{operator-commands.js → operator/operator-commands.js} +140 -43
  159. package/dist/src/{operator-intent.js → operator/operator-intent.js} +133 -42
  160. package/dist/src/operator/operator-requests.js +340 -0
  161. package/dist/src/operator/retire.js +145 -0
  162. package/dist/src/orchestrator/orchestrator-launch.js +159 -0
  163. package/dist/src/orchestrator/orchestrator-scheduling.js +304 -0
  164. package/dist/src/{orchestrator.js → orchestrator/orchestrator.js} +196 -185
  165. package/dist/src/{retention.js → orchestrator/retention.js} +15 -6
  166. package/dist/src/paths.js +135 -22
  167. package/dist/src/{command-shape.js → pi/command-shape.js} +15 -3
  168. package/dist/src/{pi-args.js → pi/pi-args.js} +8 -6
  169. package/dist/src/pi/pi-backend.js +90 -0
  170. package/dist/src/pi/pi-bin.js +138 -0
  171. package/dist/src/{pi-event-line.js → pi/pi-event-line.js} +26 -25
  172. package/dist/src/pi/pi-models.js +197 -0
  173. package/dist/src/pi/pi-run-result.js +7 -0
  174. package/dist/src/{pi-stream.js → pi/pi-stream.js} +252 -48
  175. package/dist/src/pi/pi-watchdogs.js +193 -0
  176. package/dist/src/pi/pi.js +9 -0
  177. package/dist/src/pi-extension/bounded-output.js +5 -51
  178. package/dist/src/pi-extension/context-budget.js +44 -37
  179. package/dist/src/pi-extension/context-shake.js +241 -0
  180. package/dist/src/pi-extension/context-usage.js +20 -0
  181. package/dist/src/pi-extension/full-output.js +62 -0
  182. package/dist/src/pi-extension/role-notes.js +40 -0
  183. package/dist/src/pi-extension/tool-result-content.js +15 -0
  184. package/dist/src/{launch-services.js → process/launch-services.js} +16 -30
  185. package/dist/src/{process-group.js → process/process-group.js} +79 -51
  186. package/dist/src/{process-table.js → process/process-table.js} +23 -13
  187. package/dist/src/{process.js → process/process.js} +66 -22
  188. package/dist/src/{run-marker.js → process/run-marker.js} +22 -9
  189. package/dist/src/process/supervisor.js +205 -0
  190. package/dist/src/prompt/dialect.js +35 -0
  191. package/dist/src/prompt/gate-prompts.js +409 -0
  192. package/dist/src/prompt/principles.js +14 -0
  193. package/dist/src/{prompt-followup.js → prompt/prompt-followup.js} +31 -9
  194. package/dist/src/{prompt.js → prompt/prompt.js} +111 -51
  195. package/dist/src/{redeploy-policy.js → redeploy/redeploy-policy.js} +15 -11
  196. package/dist/src/{redeploy-probes.js → redeploy/redeploy-probes.js} +5 -2
  197. package/dist/src/{redeploy.js → redeploy/redeploy.js} +24 -18
  198. package/dist/src/{redeployer.js → redeploy/redeployer.js} +46 -18
  199. package/dist/src/{self-reload.js → redeploy/self-reload.js} +29 -3
  200. package/dist/src/report/report-data.js +425 -0
  201. package/dist/src/{ui → report}/report-render.js +62 -35
  202. package/dist/src/{ui → report}/report.js +32 -19
  203. package/dist/src/request-timeouts.js +6 -0
  204. package/dist/src/review/known-flakes.js +45 -0
  205. package/dist/src/{review-followup.js → review/review-followup.js} +40 -44
  206. package/dist/src/{review-precheck.js → review/review-precheck.js} +38 -20
  207. package/dist/src/review/review-verdict.js +177 -0
  208. package/dist/src/{review.js → review/review.js} +193 -71
  209. package/dist/src/{suite-rerun.js → review/suite-rerun.js} +6 -5
  210. package/dist/src/roles/loop-ids.js +60 -0
  211. package/dist/src/{role-catalog.js → roles/role-catalog.js} +175 -29
  212. package/dist/src/{role-guidance.js → roles/role-guidance.js} +23 -10
  213. package/dist/src/{ui/role-report.js → roles/role-render.js} +49 -13
  214. package/dist/src/{role-view.js → roles/role-view.js} +44 -17
  215. package/dist/src/{roles.js → roles/roles.js} +49 -22
  216. package/dist/src/{backoff.js → scheduling/backoff.js} +13 -3
  217. package/dist/src/scheduling/claims.js +149 -0
  218. package/dist/src/{once-round.js → scheduling/once-round.js} +3 -6
  219. package/dist/src/{quiet-hours.js → scheduling/quiet-hours.js} +30 -11
  220. package/dist/src/{scheduling.js → scheduling/scheduling.js} +33 -43
  221. package/dist/src/{work-landed-cache.js → scheduling/work-landed-cache.js} +4 -4
  222. package/dist/src/status/status-data.js +272 -0
  223. package/dist/src/status/status-polls.js +202 -0
  224. package/dist/src/{datetime.js → text/datetime.js} +70 -16
  225. package/dist/src/text/format.js +58 -0
  226. package/dist/src/text/markdown.js +30 -0
  227. package/dist/src/text/phrases.js +228 -0
  228. package/dist/src/text/suggest.js +66 -0
  229. package/dist/src/{text-width.js → text/text-width.js} +1 -1
  230. package/dist/src/text/text.js +199 -0
  231. package/dist/src/{qa-coverage.js → tick/qa-coverage.js} +14 -6
  232. package/dist/src/tick/stage-check.js +298 -0
  233. package/dist/src/{telemetry-digest.js → tick/telemetry-digest.js} +6 -6
  234. package/dist/src/{tick-apply.js → tick/tick-apply.js} +69 -15
  235. package/dist/src/{tick-detail-data.js → tick/tick-detail-data.js} +9 -8
  236. package/dist/src/tick/tick-detail.js +122 -0
  237. package/dist/src/{tick-finalize.js → tick/tick-finalize.js} +59 -17
  238. package/dist/src/tick/tick-prompt.js +201 -0
  239. package/dist/src/tick/tick-resume.js +115 -0
  240. package/dist/src/tick/tick-stage.js +193 -0
  241. package/dist/src/{tick-timing.js → tick/tick-timing.js} +67 -63
  242. package/dist/src/tick/tick-usage.js +142 -0
  243. package/dist/src/tick/tick-verdict.js +200 -0
  244. package/dist/src/ui/badges.js +155 -24
  245. package/dist/src/ui/fleet-alerts.js +35 -16
  246. package/dist/src/ui/{gui-client-boot.js → gui/gui-client-boot.js} +101 -7
  247. package/dist/src/ui/{gui-client-composer.js → gui/gui-client-composer.js} +51 -14
  248. package/dist/src/ui/gui/gui-client-drawer.js +392 -0
  249. package/dist/src/ui/gui/gui-client-fleet.js +568 -0
  250. package/dist/src/ui/{gui-client-history.js → gui/gui-client-history.js} +84 -37
  251. package/dist/src/ui/{gui-client-loops.js → gui/gui-client-loops.js} +98 -12
  252. package/dist/src/ui/{gui-client-model.js → gui/gui-client-model.js} +48 -21
  253. package/dist/src/ui/{gui-client-operator.js → gui/gui-client-operator.js} +95 -25
  254. package/dist/src/ui/gui/gui-client-pending.js +109 -0
  255. package/dist/src/ui/gui/gui-client-report.js +420 -0
  256. package/dist/src/ui/gui/gui-client-settings.js +122 -0
  257. package/dist/src/ui/{gui-client.js → gui/gui-client.js} +116 -19
  258. package/dist/src/ui/{gui-page.js → gui/gui-page.js} +15 -7
  259. package/dist/src/ui/{gui-styles.js → gui/gui-styles.js} +11 -5
  260. package/dist/src/{progress-data.js → ui/progress-data.js} +92 -60
  261. package/dist/src/ui/status-model.js +140 -149
  262. package/dist/src/ui/status-payload.js +88 -31
  263. package/dist/src/ui/status-render.js +58 -20
  264. package/dist/src/ui/tick-progress-model.js +117 -0
  265. package/dist/src/ui/tone.js +28 -9
  266. package/dist/src/ui/transcript-tail.js +38 -14
  267. package/dist/src/ui/transcript.js +49 -40
  268. package/dist/src/ui/{tui-app.js → tui/tui-app.js} +2 -4
  269. package/dist/src/ui/{tui-backlog.js → tui/tui-backlog.js} +11 -8
  270. package/dist/src/ui/tui/tui-frame.js +162 -0
  271. package/dist/src/ui/tui/tui-history.js +29 -0
  272. package/dist/src/ui/{tui-input.js → tui/tui-input.js} +108 -90
  273. package/dist/src/ui/tui/tui-keys.js +1783 -0
  274. package/dist/src/ui/tui/tui-pane.js +175 -0
  275. package/dist/src/ui/tui/tui-prompt-history.js +80 -0
  276. package/dist/src/ui/{tui.js → tui/tui.js} +53 -73
  277. package/dist/src/{fix-claim.js → verdict/fix-claim.js} +62 -47
  278. package/dist/src/{refusal.js → verdict/refusal.js} +11 -5
  279. package/dist/src/{reply-contract.js → verdict/reply-contract.js} +54 -42
  280. package/dist/src/version.js +3 -3
  281. package/package.json +3 -1
  282. package/dist/src/backlog-structure.js +0 -166
  283. package/dist/src/budget-gates.js +0 -128
  284. package/dist/src/build-check-counts.js +0 -36
  285. package/dist/src/cli-flag-specs.js +0 -185
  286. package/dist/src/cli-output.js +0 -44
  287. package/dist/src/cli-run.js +0 -243
  288. package/dist/src/config-commands.js +0 -58
  289. package/dist/src/config-field-checks.js +0 -132
  290. package/dist/src/config-validation.js +0 -307
  291. package/dist/src/config-views.js +0 -106
  292. package/dist/src/config-write.js +0 -217
  293. package/dist/src/doctor-checks.js +0 -301
  294. package/dist/src/event-format.js +0 -261
  295. package/dist/src/failure-data.js +0 -216
  296. package/dist/src/files.js +0 -168
  297. package/dist/src/fleet-polls.js +0 -113
  298. package/dist/src/gate-polls.js +0 -150
  299. package/dist/src/gate-prompts.js +0 -210
  300. package/dist/src/git.js +0 -333
  301. package/dist/src/help.js +0 -185
  302. package/dist/src/inbox-attachments.js +0 -114
  303. package/dist/src/inbox.js +0 -392
  304. package/dist/src/json-object.js +0 -31
  305. package/dist/src/landing-merge.js +0 -285
  306. package/dist/src/lock.js +0 -165
  307. package/dist/src/loop-pi.js +0 -218
  308. package/dist/src/loop-state.js +0 -44
  309. package/dist/src/loop.js +0 -541
  310. package/dist/src/main-baseline.js +0 -195
  311. package/dist/src/operator-requests.js +0 -114
  312. package/dist/src/orchestrator-launch.js +0 -119
  313. package/dist/src/pending-prompt.js +0 -96
  314. package/dist/src/phrases.js +0 -116
  315. package/dist/src/pi-models.js +0 -114
  316. package/dist/src/pi.js +0 -333
  317. package/dist/src/prompt-commands.js +0 -188
  318. package/dist/src/rank.js +0 -18
  319. package/dist/src/readiness.js +0 -78
  320. package/dist/src/report-data.js +0 -186
  321. package/dist/src/review-verdict.js +0 -80
  322. package/dist/src/role-cap-gates.js +0 -76
  323. package/dist/src/startup-gate.js +0 -88
  324. package/dist/src/status-data.js +0 -133
  325. package/dist/src/status-polls.js +0 -133
  326. package/dist/src/supervisor.js +0 -127
  327. package/dist/src/text.js +0 -183
  328. package/dist/src/tick-prompt.js +0 -120
  329. package/dist/src/tick-resume.js +0 -54
  330. package/dist/src/tick-stage.js +0 -95
  331. package/dist/src/tick-usage.js +0 -67
  332. package/dist/src/tick-verdict.js +0 -114
  333. package/dist/src/time-spend.js +0 -176
  334. package/dist/src/ui/backlog-report.js +0 -36
  335. package/dist/src/ui/doctor-report.js +0 -9
  336. package/dist/src/ui/gui-client-drawer.js +0 -219
  337. package/dist/src/ui/gui-client-fleet.js +0 -308
  338. package/dist/src/ui/gui-client-report.js +0 -276
  339. package/dist/src/ui/gui-client-settings.js +0 -70
  340. package/dist/src/ui/gui-endpoint-commands.js +0 -296
  341. package/dist/src/ui/gui-endpoints.js +0 -142
  342. package/dist/src/ui/gui-server.js +0 -198
  343. package/dist/src/ui/tick-detail.js +0 -87
  344. package/dist/src/ui/tui-frame.js +0 -111
  345. package/dist/src/ui/tui-keys.js +0 -439
  346. package/dist/src/worktree.js +0 -154
  347. /package/dist/src/{exemptions.js → review/exemptions.js} +0 -0
  348. /package/dist/src/{tick-outcome.js → tick/tick-outcome.js} +0 -0
  349. /package/dist/src/ui/{gui-client-markdown.js → gui/gui-client-markdown.js} +0 -0
  350. /package/dist/src/ui/{gui-client-sound.js → gui/gui-client-sound.js} +0 -0
  351. /package/dist/src/ui/{gui-icons.js → gui/gui-icons.js} +0 -0
  352. /package/dist/src/ui/{tui-keymap.js → tui/tui-keymap.js} +0 -0
  353. /package/dist/src/{no-change.js → verdict/no-change.js} +0 -0
@@ -0,0 +1,322 @@
1
+ import { openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
2
+ import { runDoctor } from "../doctor/doctor.js";
3
+ import { loadConfigSafe } from "../config/config.js";
4
+ import { EDITABLE_CONFIG_KEYS } from "../config/config-editable-keys.js";
5
+ import { collectReport, collectReportSince } from "../report/report-data.js";
6
+ import { collectFailureReport, collectFailureReportSince } from "../failure/failure-data.js";
7
+ import { renderFailureMarkdown } from "../failure/failure-render.js";
8
+ import { renderSinceReportMarkdown } from "../report/report-render.js";
9
+ import { REPORT_SINCE_MAX_MS } from "../events/event-window.js";
10
+ import { humanSeconds } from "../text/datetime.js";
11
+ import { collectFleetChanges, collectRoleChange } from "../change/change-data.js";
12
+ import { readTranscript } from "../ui/transcript.js";
13
+ import { HISTORY_DEFAULT_TICKS, HISTORY_MAX_TICKS, readTickRows, readTickRowsSince, tickRowsMatchingGrep } from "../history/history-data.js";
14
+ import { LOGS_SINCE_MAX_MS, SPARSE_WINDOW_NOTE } from "../events/event-window.js";
15
+ import { eventRole, readEvents } from "../events/event-read.js";
16
+ import { eventFeedItem, eventMatchesGrep } from "../events/event-format.js";
17
+ import { readTickDetail } from "../tick/tick-detail-data.js";
18
+ import { renderTickDetail, tickNotFoundMessage } from "../tick/tick-detail.js";
19
+ import { rolePayload } from "../roles/role-view.js";
20
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
21
+ import { readQueuedPromptBody } from "../inbox/inbox-edit.js";
22
+ import { intQuery, rejectBadQueueFileName, rejectBadRole, sinceMsQuery, windowDays } from "./gui-args.js";
23
+ import { sendJson } from "./http-body.js";
24
+ import { typoSuffix } from "../text/suggest.js";
25
+ /** Handle GET /api/transcript?role=<id>&n=N: rendered transcript lines for one loop's pi
26
+ * log (same rendering as `tumwater logs --role <id>`). Unknown/missing role or a bad n → 400.
27
+ * User-defined loops are valid targets too — the GUI marks them with an asterisk, so clicking
28
+ * one must open its transcript: ids validate through rejectBadRole. The 400 message lists
29
+ * exactly the ids accepted. The query arrives pre-parsed — the server parses the target
30
+ * once (gui/gui-server.ts's parseRequestTarget) and threads it down, so the parse idiom lives
31
+ * in one place and cannot disagree with the routing or the token gate about what the URL said. */
32
+ export function handleTranscript(q, res, root) {
33
+ const role = q.get("role");
34
+ if (rejectBadRole(root, res, role))
35
+ return;
36
+ const n = intQuery(q, res, "n", "positive", 50);
37
+ if (n === null)
38
+ return;
39
+ sendJson(res, 200, { lines: readTranscript(root, role, n) });
40
+ }
41
+ /** The /api/backlog `file` values and the open-section reader each addresses — one table so
42
+ * the on-demand dispatch and the "valid values" list in both 400s cannot drift apart. A Map,
43
+ * not an object literal, so no `file` spelling (`constructor`, `toString`, `__proto__`) can
44
+ * reach an inherited member through the lookup. Insertion order is the list order. */
45
+ const BACKLOG_FILE_READERS = new Map([
46
+ ["plans", plannedPlanEntries],
47
+ ["bugs", openBugEntries],
48
+ ["questions", openQuestionEntries],
49
+ ]);
50
+ const BACKLOG_FILE_NAMES = [...BACKLOG_FILE_READERS.keys()];
51
+ const BACKLOG_FILE_VALUES = BACKLOG_FILE_NAMES.join(", ");
52
+ /** Handle GET /api/backlog?file=<plans|bugs|questions>&index=N: one backlog entry's full
53
+ * text ({title, body}), fetched on demand so multi-KB bodies (long repros, whole plans) never
54
+ * ride the 1-second /api/status poll. index addresses the Nth entry of that file's open
55
+ * section in the same order statusPayload lists its titles — PLANS.md ## Planned,
56
+ * BUGS.md ## Open, QUESTIONS.md ## Open — zero-based. Unknown/missing file, missing or bad
57
+ * index, and out-of-range index → 400 JSON error via sendJson. The query arrives pre-parsed
58
+ * (gui/gui-server.ts's parseRequestTarget), like every GET-data handler here. */
59
+ export function handleBacklog(q, res, root) {
60
+ const file = q.get("file");
61
+ if (file === null) {
62
+ sendJson(res, 400, { error: `file required (valid values: ${BACKLOG_FILE_VALUES})` });
63
+ return;
64
+ }
65
+ const reader = BACKLOG_FILE_READERS.get(file);
66
+ if (!reader) {
67
+ sendJson(res, 400, {
68
+ error: `unknown file ${JSON.stringify(file)} (valid values: ${BACKLOG_FILE_VALUES})${typoSuffix(file, BACKLOG_FILE_NAMES)}`,
69
+ });
70
+ return;
71
+ }
72
+ const entries = reader(root);
73
+ const index = intQuery(q, res, "index", "non-negative");
74
+ if (index === null)
75
+ return;
76
+ const entry = entries[index];
77
+ if (!entry) {
78
+ sendJson(res, 400, { error: `index ${index} out of range (${file} has ${entries.length} open entries)` });
79
+ return;
80
+ }
81
+ sendJson(res, 200, { title: entry.title, body: entry.body });
82
+ }
83
+ /** Handle GET /api/diff[?role=<id>]: the unlanded-change view the CLI prints from the same
84
+ * collectors — no `role` → collectFleetChanges (exactly `tumwater diff --json`'s document:
85
+ * `{ mainBranch, roles }`, no per-role patch halves); `?role=<id>` → collectRoleChange (the
86
+ * full per-role view with `diff`/`uncommittedDiff`, `tumwater diff --role <id> --json`). The
87
+ * role is a target, not a filter, so it validates through rejectBadRole exactly like
88
+ * /api/transcript and /api/tick (unknown/missing → 400 naming the valid ids) — the same ids
89
+ * `parseRoleFlag` accepts on the CLI, including disabled and user-defined loops. The
90
+ * collectors degrade absent worktrees and missing base branches to their `state` instead of
91
+ * throwing, so a fresh or half-built repo still answers 200. Reads git plumbing directly, so
92
+ * it works whether or not the fleet is running. */
93
+ export async function handleDiff(q, res, root) {
94
+ const role = q.get("role");
95
+ if (role === null) {
96
+ sendJson(res, 200, await collectFleetChanges(root));
97
+ return;
98
+ }
99
+ if (rejectBadRole(root, res, role))
100
+ return;
101
+ sendJson(res, 200, await collectRoleChange(root, role));
102
+ }
103
+ /** Handle GET /api/report?days=N[&role=<id>] or /api/report?sinceMs=<ms>[&role=<id>]: the
104
+ * usage report — the day series (collectReport's ReportData) as JSON the dashboard's report tab
105
+ * charts, or, when sinceMs is set, the trailing-window totals rendered through
106
+ * renderSinceReportMarkdown as { markdown } (the CLI's `report --since` shape), bounded by the
107
+ * reportSinceMs helper. The day window follows windowDays. An absent role is the fleet-wide
108
+ * report exactly as before; a present one is a filter, validated through rejectBadRole (unknown,
109
+ * including the empty string → 400 naming the valid ids) and handed to the collector's role
110
+ * scope. Reads files directly, so it works whether or not the fleet is running. */
111
+ export function handleReport(q, res, root) {
112
+ const role = q.get("role");
113
+ if (role !== null && rejectBadRole(root, res, role))
114
+ return;
115
+ if (q.get("sinceMs") !== null) {
116
+ const sinceMs = reportSinceMs(q, res);
117
+ if (sinceMs === null)
118
+ return;
119
+ sendJson(res, 200, { markdown: renderSinceReportMarkdown(collectReportSince(root, sinceMs, role ?? undefined)) });
120
+ return;
121
+ }
122
+ sendJson(res, 200, collectReport(root, windowDays(q), role ?? undefined));
123
+ }
124
+ /** Parse and bound the report endpoints' `?sinceMs=` trailing-window length: a simultaneous
125
+ * `?days=` is the CLI's rival shape (whole local days vs a trailing window) → 400 naming both;
126
+ * otherwise the length rides sinceMsQuery (gui-args.ts) — positive-integer parse, then a 400
127
+ * naming the cap rather than a silent clamp (an explicit window typo must not shrink to some
128
+ * other window) — with the "must be between 1 and …" wording. Returns the parsed length, or
129
+ * null once the 400 is sent. */
130
+ function reportSinceMs(q, res) {
131
+ if (q.get("days") !== null) {
132
+ sendJson(res, 400, { error: "days and sinceMs are mutually exclusive; days counts whole local days, sinceMs totals a trailing window" });
133
+ return null;
134
+ }
135
+ return sinceMsQuery(q, res, REPORT_SINCE_MAX_MS, (sinceMs) => `sinceMs must be between 1 and ${humanSeconds(REPORT_SINCE_MAX_MS / 1000)} (got ${sinceMs})`);
136
+ }
137
+ /** Handle GET /api/failures?days=N[&role=<id>] or /api/failures?sinceMs=<ms>[&role=<id>]: the
138
+ * same bounded Markdown failure digest the telemetry loop feeds on and `tumwater report
139
+ * --failures` prints, as JSON ({ markdown }) — the dashboard's failures tab renders it; with
140
+ * sinceMs it is the trailing-window digest (collectFailureReportSince) through the same render,
141
+ * the CLI's `report --failures --since` shape. The days window follows windowDays (so it can
142
+ * never drift from /api/report's). role rides the same absent-fleet / present-filter rule as
143
+ * handleReport: absent → the fleet digest, present → validated and scoped. Reads files
144
+ * directly, so it works with no fleet running. */
145
+ export function handleFailures(q, res, root) {
146
+ const role = q.get("role");
147
+ if (role !== null && rejectBadRole(root, res, role))
148
+ return;
149
+ if (q.get("sinceMs") !== null) {
150
+ const sinceMs = reportSinceMs(q, res);
151
+ if (sinceMs === null)
152
+ return;
153
+ sendJson(res, 200, { markdown: renderFailureMarkdown(collectFailureReportSince(root, sinceMs, role ?? undefined)) });
154
+ return;
155
+ }
156
+ sendJson(res, 200, { markdown: renderFailureMarkdown(collectFailureReport(root, windowDays(q), role ?? undefined)) });
157
+ }
158
+ /** Handle GET /api/history?role=<id>&n=N[&grep=<text>] — or ?role=<id>&sinceMs=<ms> for a
159
+ * trailing window: the per-tick rows the CLI's `tumwater history` derives from the event log,
160
+ * the dashboard's history tab renders them. The count shape ({ rows }) rides readTickRows (the
161
+ * shared scan, window growth included, so a role-filtered ask on a busy fleet cannot silently
162
+ * come back short); n is optional: absent → HISTORY_DEFAULT_TICKS; present but not a plain
163
+ * non-negative integer → 400 (the handleBacklog index discipline — an explicit count that does
164
+ * not parse is a client error, unlike the report's degrade-to-default typo rule); then clamped
165
+ * to [1, HISTORY_MAX_TICKS] — the GUI clamps where the CLI fails fast, its established
166
+ * convention. grep is optional: absent or empty filters nothing; present → the rows are
167
+ * filtered through history-data.ts's shared tickRowsMatchingGrep rule, the same case-insensitive haystack rule
168
+ * `tumwater history --grep` uses (shared, not re-implemented), after the n/role scan so n
169
+ * bounds the scanned window and not the printed rows — the CLI's rule. The window shape
170
+ * ({ rows, covered, note }) rides readTickRowsSince, the collector behind `history --since`,
171
+ * so the browser and the CLI cannot disagree about the window's rows; a simultaneous n is a
172
+ * rival shape → 400 (the CLI's count-vs-window rule), sinceMs parses and is capped through
173
+ * sinceMsQuery (gui-args.ts, LOGS_SINCE_MAX_MS — the shared `history --since` cap), and note is
174
+ * SPARSE_WINDOW_NOTE when covered is false, else "". role is optional: absent or empty → all
175
+ * loops; present → passed straight through as the role filter with no config validation (a
176
+ * filter is not a target — an id with no ticks legitimately yields zero rows), so the endpoint
177
+ * reads nothing but files, like /api/report and /api/failures. Reads files directly, so it
178
+ * works whether or not the fleet is running. */
179
+ export function handleHistory(q, res, root) {
180
+ const role = q.get("role") || null; // "" and absent both read all loops
181
+ const grep = q.get("grep") || null; // "" and absent both filter nothing
182
+ if (q.get("sinceMs") !== null) {
183
+ if (q.get("n") !== null) {
184
+ sendJson(res, 400, { error: "sinceMs cannot be combined with n (a count and a window are rival shapes)" });
185
+ return;
186
+ }
187
+ const sinceMs = sinceMsQuery(q, res, LOGS_SINCE_MAX_MS, (ms) => `sinceMs is capped at ${humanSeconds(LOGS_SINCE_MAX_MS / 1000)} (got ${ms})`);
188
+ if (sinceMs === null)
189
+ return;
190
+ const { rows, covered } = readTickRowsSince(root, sinceMs, role);
191
+ const matched = grep === null ? rows : tickRowsMatchingGrep(rows, grep);
192
+ sendJson(res, 200, { rows: matched, covered, note: covered ? "" : SPARSE_WINDOW_NOTE });
193
+ return;
194
+ }
195
+ const parsed = intQuery(q, res, "n", "non-negative", HISTORY_DEFAULT_TICKS);
196
+ if (parsed === null)
197
+ return;
198
+ const n = Math.min(HISTORY_MAX_TICKS, Math.max(1, parsed));
199
+ let rows = readTickRows(root, n, role);
200
+ if (grep !== null)
201
+ rows = tickRowsMatchingGrep(rows, grep);
202
+ sendJson(res, 200, { rows });
203
+ }
204
+ /** How many of the log's newest events `GET /api/logs` scans by default, and the most it will
205
+ * scan however large `n` asks: the CLI's `-n` shape (n bounds the scanned tail, not the printed
206
+ * rows), sized so the dashboard's search can reach well past the RECENT_EVENTS window the
207
+ * status poll carries. */
208
+ const LOGS_SEARCH_EVENTS = 500;
209
+ const LOGS_SEARCH_MAX_EVENTS = 5000;
210
+ /** Handle GET /api/logs?n=N[&grep=<text>][&role=<id>]: a slice of the raw event log scanned
211
+ * from its newest end, filtered with the CLI's own rules and shaped as the activity feed's
212
+ * items. `n` is optional (absent → LOGS_SEARCH_EVENTS) and clamps to [1, LOGS_SEARCH_MAX_EVENTS]
213
+ * after intQuery's non-negative parse (an explicit count that does not parse is a 400, the
214
+ * handleHistory discipline); it bounds the scanned tail, so a matching event older than the
215
+ * last n events is absent — the same scan-then-filter rule `tumwater logs -n N --grep` applies.
216
+ * grep (absent or empty filters nothing) matches through eventMatchesGrep, and role is a
217
+ * filter, not a target: an id with no events legitimately yields an empty list, so it does not
218
+ * validate through rejectBadRole (the handleHistory rule). The matched events answer
219
+ * oldest-first as eventFeedItem objects — the same shape the status payload's eventItems carry.
220
+ * Reads the tail through readEvents' stat-keyed cache (no covered byte end, unlike the CLI's
221
+ * follow), so a dashboard search that repeats on an unchanged log — the debounced filter fires
222
+ * one per filter change — costs one stat instead of re-reading and re-parsing the scanned tail.
223
+ * Reads files directly, so it works whether or not the fleet is running. */
224
+ export function handleLogs(q, res, root) {
225
+ const parsed = intQuery(q, res, "n", "non-negative", LOGS_SEARCH_EVENTS);
226
+ if (parsed === null)
227
+ return;
228
+ const n = Math.min(LOGS_SEARCH_MAX_EVENTS, Math.max(1, parsed));
229
+ const role = q.get("role") || null; // "" and absent both read all loops
230
+ const grep = q.get("grep") || null; // "" and absent both filter nothing
231
+ const needle = grep === null ? null : grep.toLowerCase();
232
+ const events = readEvents(root, n);
233
+ const matched = events.filter((e) => (role === null || eventRole(e) === role) && (needle === null || eventMatchesGrep(e, needle)));
234
+ sendJson(res, 200, { events: matched.map(eventFeedItem) });
235
+ }
236
+ /** Handle GET /api/tick?role=<id>&tick=N: one tick's full event trail — readTickDetail's
237
+ * TickDetail, the collector `tumwater tick <role> <n>` prints from, plus `text`: the same
238
+ * payload through renderTickDetail, pre-rendered server-side like /api/transcript's lines so
239
+ * the browser shows the CLI's exact rendering instead of re-implementing the event formatting.
240
+ * Serving the shared collector is the point: the CLI and this endpoint cannot drift on what one
241
+ * tick's block contains. The role is a target here, not a filter, so it validates through
242
+ * rejectBadRole (unknown/missing → 400 naming the valid ids); the tick is an explicit count
243
+ * through intQuery (missing or not a positive integer → 400); and a tick the scanned window
244
+ * does not hold — never ran, or rotation ate it — answers 404 with tickNotFoundMessage, the
245
+ * CLI's exact not-found wording,
246
+ * never a crash. Reads files directly, so it works whether or not the fleet is running. */
247
+ export function handleTick(q, res, root) {
248
+ const role = q.get("role");
249
+ if (rejectBadRole(root, res, role))
250
+ return;
251
+ const tick = intQuery(q, res, "tick", "positive");
252
+ if (tick === null)
253
+ return;
254
+ const detail = readTickDetail(root, role, tick);
255
+ if (detail === null) {
256
+ sendJson(res, 404, { error: tickNotFoundMessage(role, tick) });
257
+ return;
258
+ }
259
+ sendJson(res, 200, { ...detail, text: renderTickDetail(detail) });
260
+ }
261
+ /** Handle GET /api/role?role=<id>: one loop's inspection payload — rolePayload's object,
262
+ * the same collection behind `tumwater role <id>` — so the drawer's Notebook and Next tick
263
+ * prompt sections render what the CLI shows without re-implementing the assembly. The role
264
+ * is a target here, so it validates through rejectBadRole exactly like /api/transcript and
265
+ * /api/tick (unknown/missing → the shared 400 naming the valid ids and correcting typos);
266
+ * rolePayload is only reached with an id rejectBadRole accepted, so its own unknown-role
267
+ * throw never fires. Reads files directly, so it works whether or not the fleet is running. */
268
+ export function handleRole(q, res, root) {
269
+ const role = q.get("role");
270
+ if (rejectBadRole(root, res, role))
271
+ return;
272
+ sendJson(res, 200, rolePayload(root, role));
273
+ }
274
+ /** Handle GET /api/prompt-text?file=<name>[&role=<id>]: one queued prompt's editable body —
275
+ * the text an edit replaces, for the dashboard's per-row edit affordance (PLANS.md
276
+ * 2026-10-11). The prompt is addressed by its queue-file basename (the /api/prompt-cancel
277
+ * address), so a stale poll's snapshot can never read the wrong entry; the not-before deferral
278
+ * marker and the `[image attached: …]` reference block are stripped, both plumbing the edit
279
+ * carries over unchanged. The role is optional (absent reads the director's queue) and
280
+ * validates exactly like /api/transcript; a file name failing the shared
281
+ * rejectBadQueueFileName guard is a user-input error answered 400 with nothing read. A vanished file answers 200
282
+ * { ok: true, status: "gone" } — the loop already dequeued it, which is data for the UI, not
283
+ * a server fault. Reads one file directly, so it works whether or not the fleet is running. */
284
+ export function handlePromptText(q, res, root) {
285
+ const roleParam = q.get("role");
286
+ if (roleParam !== null && rejectBadRole(root, res, roleParam))
287
+ return;
288
+ const role = roleParam ?? DIRECTOR_ROLE;
289
+ const file = q.get("file");
290
+ if (rejectBadQueueFileName(res, file))
291
+ return;
292
+ const text = readQueuedPromptBody(root, role, file);
293
+ if (text === null) {
294
+ sendJson(res, 200, { ok: true, status: "gone" });
295
+ return;
296
+ }
297
+ sendJson(res, 200, { ok: true, text });
298
+ }
299
+ /** Handle GET /api/doctor: the same DoctorReport `tumwater doctor --json` prints, so the
300
+ * Settings view's Health check card and the CLI cannot disagree. Read-only — the checks write
301
+ * nothing (runDoctor's own contract) — and it works whether or not the fleet is running. `run`
302
+ * exists so tests can skip the host probes; a throw reaches the server's outer 500. */
303
+ export async function handleDoctor(res, root, run = runDoctor) {
304
+ sendJson(res, 200, await run(root));
305
+ }
306
+ /** Handle GET /api/config: the Settings view's resolved values for exactly
307
+ * EDITABLE_CONFIG_KEYS — read through the same load path `tumwater config get` uses
308
+ * (loadConfigSafe, defaults merged in), so the page shows what the fleet would actually
309
+ * load. A broken or invalid tumwater.json answers 500 with validateConfig's message instead
310
+ * of serving a half-empty panel. */
311
+ export function handleConfig(res, root) {
312
+ const { config, error } = loadConfigSafe(root);
313
+ if (config === undefined) {
314
+ sendJson(res, 500, { error });
315
+ return;
316
+ }
317
+ const record = config;
318
+ const out = {};
319
+ for (const key of EDITABLE_CONFIG_KEYS)
320
+ out[key] = record[key] ?? null;
321
+ sendJson(res, 200, out);
322
+ }
@@ -0,0 +1,270 @@
1
+ /** The dashboard HTTP server: routing, the shared-token gate, the static page, /api/status, and the
2
+ * self-reload watch. Every /api endpoint's handler lives in gui/gui-endpoints.ts (the GET data
3
+ * endpoints) and gui/gui-endpoint-commands.ts (the POST operator endpoints) — this module owns
4
+ * the socket, not the data. The `tumwater gui` CLI entry (cmdGui) lives in gui/gui-command.ts,
5
+ * which imports startGui from here, so the server and the command that boots it stay adjacent. */
6
+ import crypto from "node:crypto";
7
+ import http from "node:http";
8
+ import { GUI_PAGE } from "../ui/gui/gui-page.js";
9
+ import { statusPayload } from "../ui/status-payload.js";
10
+ import { captureStartupBuild, createReloadWatch, reexecSelf, watchReloadSupervisor, } from "../redeploy/self-reload.js";
11
+ import { warnEvent } from "../events/events.js";
12
+ import { errorMessage } from "../text/text.js";
13
+ import { handleBacklog, handleConfig, handleDiff, handleFailures, handleReport, handleHistory, handleLogs, handlePromptText, handleTick, handleRole, handleTranscript, handleDoctor, } from "./gui-endpoints.js";
14
+ import { handleAbort, handleBudget, handleConfigSet, handleConfigUnset, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handleQuestionAnswer, handleReclaim, handleRestart, handleResetCounters, handleRoleNotes, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
15
+ import { sendJson } from "./http-body.js";
16
+ /** Constant-time credential comparison for the shared-token gate: `timingSafeEqual` throws
17
+ * on unequal lengths, so the length equality is the guard. A naive `===` string compare
18
+ * would leak the token's length and prefix byte by byte. */
19
+ function tokenMatches(expected, provided) {
20
+ const a = Buffer.from(expected, "utf8");
21
+ const b = Buffer.from(provided, "utf8");
22
+ if (a.length !== b.length)
23
+ return false;
24
+ return crypto.timingSafeEqual(a, b);
25
+ }
26
+ /** The request target parsed once (path, query, and the token gate all read the same
27
+ * object), or null when `req.url` is not a parseable URL — such requests fall through to
28
+ * 404 instead of throwing into the handler's 500 catch, token gate included: the gate reads
29
+ * the same parsed target rather than re-parsing, so a malformed target answers 401 there
30
+ * like the 404 it earns on the token-less server, never a 500 for a bad request.
31
+ * Routing compares the exact pathname, never the raw target: a startsWith on req.url
32
+ * answered ANY path prefixing a route (e.g. /api/transcripts?role=… returned 200 transcript
33
+ * data), and an equality check on the raw target would miss query strings on exact routes
34
+ * (/api/status?x → 404). */
35
+ function parseRequestTarget(req) {
36
+ try {
37
+ return new URL(req.url ?? "", "http://localhost");
38
+ }
39
+ catch {
40
+ return null; // Unparseable target — not a route.
41
+ }
42
+ }
43
+ /** True when a request's Origin header names a server other than this one — the browser's
44
+ * cross-origin signal. Browsers attach Origin to every cross-site POST (fetch and form
45
+ * alike) and to same-origin POSTs too, so a POST from the dashboard carries this server's
46
+ * own Host authority, while curl and scripts send no Origin at all. `Origin: null`
47
+ * (sandboxed frames, data: redirects) is the hostile shape and is refused too. Pure header
48
+ * reading, so the router can gate every POST route through it in one place. */
49
+ function crossOriginRequest(req) {
50
+ const raw = req.headers.origin;
51
+ if (raw === undefined)
52
+ return false; // no Origin: not a browser — allow
53
+ const origin = Array.isArray(raw) ? raw[0] : raw;
54
+ if (origin === "null")
55
+ return true;
56
+ try {
57
+ return new URL(origin).host.toLowerCase() !== (req.headers.host ?? "").toLowerCase();
58
+ }
59
+ catch {
60
+ return true; // unparseable Origin — refuse a signal we cannot verify
61
+ }
62
+ }
63
+ /** The address `startGui` binds for a given `allInterfaces` choice: loopback-only by default,
64
+ * the unspecified address (`undefined` — every interface, IPv4 and IPv6) on request. Pure and
65
+ * host-independent, so the mapping is testable without a live socket or this host's address
66
+ * family. */
67
+ export function guiBindHost(allInterfaces) {
68
+ return allInterfaces ? undefined : "127.0.0.1";
69
+ }
70
+ /** Whether a request's `Host` header names the loopback-bound server itself — `localhost`,
71
+ * `127.0.0.1`, or `::1`, each with or without its port (`[::1]:7180` bracketed). The check
72
+ * exists for a loopback-bound server only (see startGui): a browser that resolves an
73
+ * attacker-controlled name to 127.0.0.1 (DNS rebinding) reaches a loopback socket while its
74
+ * page's origin and the request's Host both name the attacker's domain, so crossOriginRequest's
75
+ * Origin/Host comparison calls the forged request same-origin. The Host is the one header the
76
+ * rebinding browser must set to that domain, so refusing a non-loopback Host closes both the
77
+ * read endpoints and the state-changing POSTs to a server reachable only from this machine.
78
+ * A non-bracketed IPv6 literal is malformed per RFC 7230 and is not accepted. Pure, so the
79
+ * parsing is testable without a socket. */
80
+ export function loopbackHostAllowed(hostHeader) {
81
+ let host = hostHeader.trim().toLowerCase();
82
+ if (host.startsWith("[")) {
83
+ const end = host.indexOf("]");
84
+ if (end === -1)
85
+ return false;
86
+ host = host.slice(1, end);
87
+ }
88
+ else {
89
+ const colon = host.lastIndexOf(":");
90
+ if (colon !== -1)
91
+ host = host.slice(0, colon);
92
+ }
93
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
94
+ }
95
+ /** Record a post-listen dashboard server error without letting the recording itself take the
96
+ * dashboard down. warnEvent appends to the event log, which can fail (ENOSPC, EACCES under
97
+ * disk pressure); a throw out of a Node 'error' listener is an uncaught exception that kills
98
+ * the process, turning a recoverable socket error into a dead dashboard — the opposite of this
99
+ * handler's purpose. The warning is best-effort, the same policy the cleanup warn sites apply. */
100
+ function recordServerError(root, err) {
101
+ try {
102
+ warnEvent(root, "harness", `dashboard server error: ${errorMessage(err)}`);
103
+ }
104
+ catch {
105
+ // The log is another process's to fix; the dashboard stays up either way.
106
+ }
107
+ }
108
+ /** Start the dashboard server. Binds to 127.0.0.1 by default; with `allInterfaces` it
109
+ * binds the unspecified address (every interface, IPv4 and IPv6), making the dashboard —
110
+ * including the director prompt box, which anyone reaching it can use to steer the fleet —
111
+ * available to the whole network. There is no authentication by default — exposing it is
112
+ * the caller's deliberate choice — and an optional shared token (`gui --token <secret>`,
113
+ * passed as `token`) gates every route: requests must carry it as `Authorization: Bearer
114
+ * <token>` or `?token=`, anything else gets a 401 JSON error. An empty token means no
115
+ * check — byte-for-byte the open server. Resolves once it is listening.
116
+ *
117
+ * `watch` overrides the self-reload watch's seams (self-reload.ts's injectables) plus the
118
+ * re-exec itself, and the supervised child's supervisor watch plus its exit; production
119
+ * callers omit it and get the real disk-stamp poll, reexecSelf and process.exit — tests
120
+ * inject fakes so the wiring (close, then re-exec, at most once; close, then exit, once the
121
+ * reload supervisor is gone) is assertable without launching a process or touching this
122
+ * dist's own stamp. */
123
+ export function startGui(root, port, allInterfaces = false, token = "", watch = {}) {
124
+ // The serving process's own startup stamp: added to every /api/status payload so the page can
125
+ // notice a newer server (a redeploy or manual build re-execs this process) and reload itself.
126
+ const startupBuild = captureStartupBuild();
127
+ // The routes as one table, keyed by `METHOD path`: dispatch is a single lookup where the
128
+ // previous twenty-branch if/else chain restated `req.method === … && pathname === …` on
129
+ // every arm. Each path appears once (the page's two spellings share servePage), so lookup
130
+ // order carries no meaning. A handler that needs the query reads the one parsed target
131
+ // (parseRequestTarget) — routing matched its exact pathname, so the target is non-null.
132
+ const servePage = (_req, res) => {
133
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
134
+ res.end(GUI_PAGE);
135
+ };
136
+ const guiRoutes = {
137
+ "GET /": servePage,
138
+ "GET /index.html": servePage,
139
+ "GET /api/status": (_req, res) => {
140
+ sendJson(res, 200, { ...statusPayload(root), serverBuildSha: startupBuild?.sha ?? null });
141
+ },
142
+ "GET /api/report": (_req, res, target) => handleReport(target.searchParams, res, root),
143
+ "GET /api/failures": (_req, res, target) => handleFailures(target.searchParams, res, root),
144
+ "GET /api/history": (_req, res, target) => handleHistory(target.searchParams, res, root),
145
+ "GET /api/logs": (_req, res, target) => handleLogs(target.searchParams, res, root),
146
+ "GET /api/transcript": (_req, res, target) => handleTranscript(target.searchParams, res, root),
147
+ "GET /api/prompt-text": (_req, res, target) => handlePromptText(target.searchParams, res, root),
148
+ "GET /api/tick": (_req, res, target) => handleTick(target.searchParams, res, root),
149
+ "GET /api/role": (_req, res, target) => handleRole(target.searchParams, res, root),
150
+ "GET /api/diff": (_req, res, target) => handleDiff(target.searchParams, res, root),
151
+ "GET /api/backlog": (_req, res, target) => handleBacklog(target.searchParams, res, root),
152
+ "GET /api/config": (_req, res) => handleConfig(res, root),
153
+ "GET /api/doctor": (_req, res) => handleDoctor(res, root),
154
+ "POST /api/config-set": (req, res) => handleConfigSet(req, res, root),
155
+ "POST /api/config-unset": (req, res) => handleConfigUnset(req, res, root),
156
+ "POST /api/prompt": (req, res) => handlePrompt(req, res, root),
157
+ "POST /api/prompt-role": (req, res) => handlePromptRole(req, res, root),
158
+ "POST /api/backlog-file": (req, res) => handleBacklogFile(req, res, root),
159
+ "POST /api/backlog-cancel": (req, res) => handleBacklogCancel(req, res, root),
160
+ "POST /api/backlog-edit": (req, res) => handleBacklogEdit(req, res, root),
161
+ "POST /api/prompt-cancel": (req, res) => handlePromptCancel(req, res, root),
162
+ "POST /api/prompt-edit": (req, res) => handlePromptEdit(req, res, root),
163
+ "POST /api/question-answer": (req, res) => handleQuestionAnswer(req, res, root),
164
+ "POST /api/budget": (req, res) => handleBudget(req, res, root),
165
+ "POST /api/pause": (req, res) => handlePause(req, res, root),
166
+ "POST /api/wake": (req, res) => handleWake(req, res, root),
167
+ "POST /api/wake-cancel": (req, res) => handleWakeCancel(req, res, root),
168
+ "POST /api/reset-counters": (req, res) => handleResetCounters(req, res, root),
169
+ "POST /api/role-notes": (req, res) => handleRoleNotes(req, res, root),
170
+ "POST /api/restart": (req, res) => handleRestart(req, res, root),
171
+ "POST /api/reclaim": (req, res) => handleReclaim(req, res, root),
172
+ "POST /api/abort": (req, res) => handleAbort(req, res, root),
173
+ "POST /api/pause-role": (req, res) => handlePauseRole(req, res, root),
174
+ };
175
+ const server = http.createServer(async (req, res) => {
176
+ try {
177
+ const target = parseRequestTarget(req);
178
+ const pathname = target?.pathname ?? null;
179
+ // DNS-rebinding gate, ahead of every route on a loopback-bound server: a hostile page
180
+ // whose domain resolves to 127.0.0.1 is same-origin with this server, so the
181
+ // cross-origin gate below cannot tell its requests from the dashboard's — but its
182
+ // Host header must name the attacker's domain, which a request to the loopback
183
+ // dashboard never legitimately does. Refusing that Host blocks the read endpoints
184
+ // (status, config, transcripts) and the state-changing POSTs together. An
185
+ // all-interfaces server is deliberately network-facing, so it keeps accepting every
186
+ // Host (the operator's exposure choice); a token, if set, still gates it.
187
+ if (!allInterfaces && !loopbackHostAllowed(req.headers.host ?? "")) {
188
+ sendJson(res, 403, {
189
+ error: "request Host is not this loopback dashboard's — a rebinding name is refused",
190
+ });
191
+ return;
192
+ }
193
+ // Opt-in shared-token gate, ahead of every route: with a token set, the page and all
194
+ // /api endpoints require it as `Authorization: Bearer <token>` or `?token=`; anything
195
+ // else — including `GET /` — gets the same JSON 401 every handler uses, deliberately
196
+ // no HTML login form: the CLI prints the token-bearing URL, and a bare 401 body is the
197
+ // honest signal that the URL needs `?token=`.
198
+ if (token) {
199
+ const auth = req.headers.authorization ?? "";
200
+ const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
201
+ const provided = bearer || target?.searchParams.get("token") || "";
202
+ if (!tokenMatches(token, provided)) {
203
+ sendJson(res, 401, { error: "token required" });
204
+ return;
205
+ }
206
+ }
207
+ // Cross-origin gate, ahead of every state-changing route: a hostile webpage can POST
208
+ // to the open localhost server with a CORS-safelisted text/plain body (no preflight,
209
+ // no readable response) and the side effect alone is the payload — a forged director
210
+ // prompt, a fleet pause, an abort. Browsers mark every cross-site POST with an Origin
211
+ // naming the hostile server, so one header check here refuses the forgery while the
212
+ // dashboard's own POSTs (same-host Origin) and non-browser clients (no Origin) pass.
213
+ if (req.method === "POST" && crossOriginRequest(req)) {
214
+ sendJson(res, 403, {
215
+ error: "cross-origin request rejected — dashboard POSTs must come from this dashboard's own origin",
216
+ });
217
+ return;
218
+ }
219
+ const route = guiRoutes[`${req.method ?? ""} ${pathname ?? ""}`];
220
+ if (route)
221
+ await route(req, res, target);
222
+ else {
223
+ res.writeHead(404, { "content-type": "text/plain" });
224
+ res.end("not found");
225
+ }
226
+ }
227
+ catch (err) {
228
+ sendJson(res, 500, { error: errorMessage(err) });
229
+ }
230
+ });
231
+ // Reload onto a newer compiled tree: close the server, then re-exec. The page's own poll sees
232
+ // the new process's changed serverBuildSha and reloads; failed polls while the port is down
233
+ // are swallowed by its existing catch.
234
+ const reloadWatch = createReloadWatch({
235
+ root,
236
+ startupInfo: startupBuild,
237
+ ...watch,
238
+ onTrigger: () => {
239
+ server.close();
240
+ (watch.reexec ?? reexecSelf)();
241
+ },
242
+ });
243
+ void reloadWatch.start();
244
+ // A reloaded child whose reload supervisor died outright: nothing will respawn or stop it,
245
+ // so it frees the port and exits instead of serving on at PPID 1.
246
+ const stopSupervisorWatch = watchReloadSupervisor(() => {
247
+ reloadWatch.stop();
248
+ server.close();
249
+ (watch.exit ?? process.exit)(0);
250
+ }, watch.supervisor);
251
+ server.once("close", stopSupervisorWatch);
252
+ return new Promise((resolve, reject) => {
253
+ const onListenError = (err) => reject(err);
254
+ server.once("error", onListenError);
255
+ const onListening = () => {
256
+ // The listen-time listener must not outlive the listen: an 'error' after this point
257
+ // would call reject on a settled promise and be swallowed. Replace it with a handler
258
+ // that records the failure, so a post-listen socket error (EMFILE on accept, …) is
259
+ // visible in the event feed instead of silently leaving a dead dashboard.
260
+ server.removeListener("error", onListenError);
261
+ server.on("error", (err) => recordServerError(root, err));
262
+ resolve(server);
263
+ };
264
+ const host = guiBindHost(allInterfaces);
265
+ if (host === undefined)
266
+ server.listen(port, onListening);
267
+ else
268
+ server.listen(port, host, onListening);
269
+ });
270
+ }