tumwater 0.2.0 → 0.4.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 (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,11 +1,16 @@
1
1
  import { eventsSince } from "../events/event-window.js";
2
2
  import { isNotifyFailure } from "../events/notify.js";
3
- import { humanSeconds } from "../text/datetime.js";
3
+ import { DAY_MS, humanAgo } from "../text/datetime.js";
4
4
  import { plural } from "../text/phrases.js";
5
5
  import { errorMessage, squash } from "../text/text.js";
6
6
  import { okOutcome, warnOutcome } from "./outcome.js";
7
- /** How far back the `notify` check looks for failed pages. */
8
- const NOTIFY_CHECK_WINDOW_MS = 24 * 3600_000;
7
+ /** How far back the `notify` check looks for failed pages: a day, fixed. An opinionated
8
+ * default with no knob, like STALE_BUG_DAYS: long enough that a transient hook failure shows
9
+ * up, short enough that a page that did not go through is still the operator's business. The
10
+ * always-on notify team alert (src/ui/status-payload.ts notifyFailures) reuses this same
11
+ * threshold and the same isNotifyFailure window, so it cannot drift from the doctor's check
12
+ * — the STALE_BUG_DAYS precedent. */
13
+ export const NOTIFY_CHECK_WINDOW_MS = DAY_MS;
9
14
  /** The `notify` check: whether a notify command is configured, and a warning when it failed to
10
15
  * page within the last day (a nonzero exit, a kill, or a failed start — the warnings
11
16
  * spawnNotifyCommand logs). Warns but never fails, like the hold rows: a dead webhook must not
@@ -18,8 +23,7 @@ export function checkNotify(root, config, now = Date.now()) {
18
23
  if (failures.length === 0)
19
24
  return okOutcome("configured — no failed pages in the last 24h");
20
25
  const latest = failures[failures.length - 1];
21
- const age = humanSeconds(Math.max(0, Math.round((now - latest.ts) / 1000)));
22
- return warnOutcome(`${plural(failures.length, "failed page")} in the last 24h; latest ${age} ago: ${squash(String(latest.message), 200)}`);
26
+ return warnOutcome(`${plural(failures.length, "failed page")} in the last 24h; latest ${humanAgo(latest.ts, now)}: ${squash(String(latest.message), 200)}`);
23
27
  }
24
28
  catch (err) {
25
29
  return warnOutcome(`could not read the event log for failed pages: ${errorMessage(err)}`);
@@ -0,0 +1,164 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { childEnv } from "../process/process.js";
4
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
5
+ import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
6
+ import { opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
7
+ import { opencodeDbPath } from "../paths.js";
8
+ import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes } from "../files/files.js";
9
+ import { formatBytes, tokenWindowLabel } from "../text/format.js";
10
+ import { escapeRegExp } from "../text/text.js";
11
+ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
12
+ import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-model-checks.js";
13
+ /** The doctor's opencode credential and leak readiness check, split out of doctor-model-checks.ts
14
+ * (whose shared seam/model framework and the pi, tier-models and image-input checks stay there):
15
+ * checkOpencode and its helpers (opencodeProviderOf, opencodeAuthLists, jsonKeyPresent,
16
+ * opencodeConfigLeaks). This is the doctor's only check that spawns the `opencode` CLI and reads
17
+ * its catalog and the operator's global opencode config — a distinct concern from the
18
+ * filesystem/git checks beside it, kept together so a change to how doctor reads opencode does
19
+ * not touch the other model checks. The shared report contract (CheckOutcome) and the
20
+ * seam/model framework (loadConfigForCheck, opencodeSeamsInUse, seamPairs) come from
21
+ * doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from probe.ts, and the shared report
22
+ * contract (CheckOutcome) from outcome.ts, as the other doctor-* modules do. */
23
+ /** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
24
+ * undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
25
+ * own gateway with no credential). A model id with more than the `opencode/` namespace prefix
26
+ * is provider-qualified and needs a credential OpenCode lists. */
27
+ function opencodeProviderOf(model) {
28
+ const parts = model.split("/");
29
+ if (parts[0] === "opencode" && parts.length >= 3)
30
+ return parts[1];
31
+ if (parts[0] !== "opencode" && parts.length >= 2)
32
+ return parts[0];
33
+ return undefined;
34
+ }
35
+ /** Whether `opencode auth list --format json` (bounded) lists `provider` exactly. A probe that
36
+ * cannot run, exits nonzero, or omits the provider reads as NOT listed — the safe direction,
37
+ * since doctor is warning the operator to add the credential, never confirming one it did not
38
+ * see. The list is matched EXACTLY, not as a raw substring: when the output parses as an array
39
+ * (of names or of per-credential objects), a credential listed as `anthropic` must never vouch
40
+ * for `anthropic-foo` twice over; when it cannot be parsed as JSON (a shape the CLI may change),
41
+ * the text is matched word-bounded so a truncated or prose line still reads. */
42
+ async function opencodeAuthLists(config, provider, pathEnv, timeoutMs) {
43
+ const resolved = opencodeBackend.resolveBin(config);
44
+ const result = await boundedProbe(resolved.bin, ["auth", "list", "--format", "json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
45
+ if (result?.code !== 0)
46
+ return false;
47
+ const text = result.stdout;
48
+ try {
49
+ const parsed = JSON.parse(text);
50
+ if (Array.isArray(parsed)) {
51
+ return parsed.some((item) => {
52
+ if (typeof item === "string")
53
+ return item === provider;
54
+ return (item !== null &&
55
+ typeof item === "object" &&
56
+ Object.values(item).some((v) => v === provider));
57
+ });
58
+ }
59
+ }
60
+ catch {
61
+ // Not an array-shaped JSON payload — fall through to the word-bounded text match.
62
+ }
63
+ const esc = escapeRegExp(provider);
64
+ return new RegExp(`(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|$)`).test(text);
65
+ }
66
+ /** Whether a JSON-ish key (optionally quoted, followed by a colon) appears in a config file's
67
+ * text — a loose match that tolerates JSONC comments without parsing them. */
68
+ function jsonKeyPresent(text, key) {
69
+ return new RegExp(`["']?${key}["']?\\s*:`).test(text);
70
+ }
71
+ /** The operator's global opencode config files and AGENTS.md (plans/opencode-backend.md §7
72
+ * "Leaks", part 7/7): the operator's own config and instructions still load into team runs,
73
+ * so each MCP server (disabled for team runs) or plugin/agent/instructions entry or the
74
+ * AGENTS.md file itself produces one warning naming the file and what leaks in: an MCP server
75
+ * is switched off by the harness, while a plugin/agent/instructions entry or AGENTS.md is NOT
76
+ * switched off and still loads. `configHome` is the config root to scan — the XDG config home
77
+ * (when set) or `~/.config`, the SAME folder opencode's own operatorMcpNames reads, so the leak
78
+ * scan matches the config a run actually loads. Config files
79
+ * are JSON/C, so the keys are matched loosely (a word followed by a colon) rather than fully
80
+ * parsed — enough to name what still loads before a run inherits it. */
81
+ function opencodeConfigLeaks(configHome) {
82
+ const dir = path.join(configHome, "opencode");
83
+ const warns = [];
84
+ for (const file of ["config.json", "opencode.json", "opencode.jsonc"]) {
85
+ const text = readTextOrNull(path.join(dir, file));
86
+ if (text === null)
87
+ continue;
88
+ const parts = [];
89
+ let mcp = false;
90
+ if (jsonKeyPresent(text, "mcp") && jsonKeyPresent(text, "servers")) {
91
+ parts.push("an MCP server");
92
+ mcp = true;
93
+ }
94
+ for (const key of ["plugin", "agent", "instructions"]) {
95
+ if (jsonKeyPresent(text, key))
96
+ parts.push(`a ${key} entry`);
97
+ }
98
+ if (parts.length === 0)
99
+ continue;
100
+ const joined = parts.join(" and ");
101
+ // Only the MCP server is disabled for team runs; everything else still loads.
102
+ const verdict = mcp && parts.length === 1 ? " — disabled for team runs" : " — still loads into team runs";
103
+ warns.push(`${path.join(dir, file)} carries ${joined}${verdict}`);
104
+ }
105
+ const agentsMd = path.join(dir, "AGENTS.md");
106
+ if (statOrNull(agentsMd)?.isFile() === true)
107
+ warns.push(`${agentsMd} still loads into team runs`);
108
+ return warns;
109
+ }
110
+ /** OpenCode model, credential and leak readiness (plans/opencode-backend.md §7, part 7/7):
111
+ * every model routed to opencode must be in the catalog cache — an unknown model fails, a
112
+ * cached one reads "free" or its per-M prices and context window, and a thinking level with no
113
+ * catalog effort is noted as dropped; a non-gateway provider (`opencode/<provider>/…`) must
114
+ * have a credential `opencode auth list` lists, else warns; and the operator's global opencode
115
+ * config and AGENTS.md must not leak an MCP server or a plugin/agent/instructions entry into
116
+ * team runs, else warns naming the file and key. Reports the team's opencode database and spill
117
+ * sizes as information. Stands down (ok) when no seam routes to opencode, so a pi-only team
118
+ * spawns nothing and reads nothing. `pathEnv` and `home` are injectable so tests need no real
119
+ * PATH or HOME. The version half of the opencode pre-flight is the sibling checkOpencodeVersion
120
+ * in doctor-checks.ts. */
121
+ export async function checkOpencode(root, pathEnv = envPath(), home = os.homedir(), xdgConfigHome = process.env.XDG_CONFIG_HOME ?? "", timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
122
+ const config = loadConfigForCheck(root);
123
+ if ("level" in config)
124
+ return config;
125
+ if (!opencodeSeamsInUse(config))
126
+ return okOutcome("no model jobs run on opencode");
127
+ const models = seamedModels(config, "opencode");
128
+ const cache = readOpencodeCache(root);
129
+ const fails = [];
130
+ const warns = [];
131
+ const notes = [];
132
+ for (const pair of models) {
133
+ const key = opencodeModelKey(pair.model);
134
+ const entry = cache.models[key];
135
+ if (entry === undefined) {
136
+ fails.push(`${pair.model} is not in the opencode catalog cache`);
137
+ continue;
138
+ }
139
+ const price = entry.free ? "free" : `$${entry.inputPerM ?? 0}/$${entry.outputPerM ?? 0} per M`;
140
+ const window = entry.context !== undefined ? `, ${tokenWindowLabel(entry.context)}` : "";
141
+ notes.push(`${pair.model} — ${price}${window}`);
142
+ // A model with no effort list at all supports no thinking level, so a requested level is
143
+ // dropped just the same as one the list omits.
144
+ if (pair.thinking !== undefined &&
145
+ pair.thinking !== "" &&
146
+ !entry.efforts?.includes(pair.thinking)) {
147
+ warns.push(`${pair.model} has no ${pair.thinking} effort — that thinking level is dropped`);
148
+ }
149
+ const provider = opencodeProviderOf(pair.model);
150
+ if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
151
+ warns.push(`${pair.model} is a non-gateway provider opencode does not list a credential for`);
152
+ }
153
+ }
154
+ warns.push(...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")));
155
+ const db = statOrNull(opencodeDbPath(root));
156
+ const spillDir = opencodeSpillDir(root);
157
+ const spill = spillDir !== "" && isDirectory(spillDir) ? treeBytes(spillDir) : null;
158
+ const sizeNotes = `db ${formatBytes(db?.size ?? 0)}${spill !== null ? `, spill ${formatBytes(spill)}` : ", no spill dir"}`;
159
+ const found = [...fails, ...warns];
160
+ const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
161
+ if (fails.length > 0)
162
+ return failOutcome(base);
163
+ return (warns.length > 0 ? warnOutcome : okOutcome)(base);
164
+ }
@@ -1,9 +1,10 @@
1
- // vocabulary: plain
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import { worktreesDir } from "../paths.js";
5
4
  import { pidAlive } from "../process/process.js";
5
+ import { GROUP_POLL_MS, KILL_GRACE_MS } from "../process/process-group.js";
6
6
  import { systemProcessProbe } from "../process/process-table.js";
7
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
7
8
  import { errorMessage, truncate } from "../text/text.js";
8
9
  import { plural } from "../text/phrases.js";
9
10
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
@@ -71,10 +72,10 @@ function relativeArgvIsOurs(command, cwd, root, dirs) {
71
72
  }
72
73
  return false;
73
74
  }
74
- /** How many processes descend from `pid`, given the table's parent → children map — an
75
+ /** The processes that descend from `pid`, given the table's parent → children map — an
75
76
  * orphaned test runner's workers keep the runner as their parent, so they are the orphan's
76
77
  * tree, not orphans of their own, and killing the runner alone leaves them running. */
77
- function descendantCount(children, pid) {
78
+ function descendantPids(children, pid) {
78
79
  const seen = new Set();
79
80
  const stack = [...(children.get(pid) ?? [])];
80
81
  for (let p = stack.pop(); p !== undefined; p = stack.pop()) {
@@ -83,52 +84,43 @@ function descendantCount(children, pid) {
83
84
  seen.add(p);
84
85
  stack.push(...(children.get(p) ?? []));
85
86
  }
86
- return seen.size;
87
+ return [...seen];
87
88
  }
88
- /** Orphaned worktree processes — the detector half of the grandchild-leak fix (BUGS.md
89
- * 2026-09-21): a process reparented to PID 1 that belongs to one of this repo's worktrees.
90
- * Leaks of this kind (a killed tick's tool-call grandchildren, a timed-out build check's test
91
- * tree, a pi run's backgrounded server, a stray orchestrator an orphaned suite started) hold
92
- * no lock, write no event and touch no state file, so nothing else in the harness can see
93
- * them. A process is this repo's when its argv names a path under `.tumwater/worktrees/`
94
- * (absolute; or relative, confirmed by cwd where it can be — relativeArgvIsOurs) or when its
95
- * cwd lies under it: a leaked `node dist/test/test-runner.js` names no worktree at all.
96
- *
97
- * PPID 1 is what keeps the live team out: a running orchestrator's pi runs and build checks
98
- * have the orchestrator as parent, and the orchestrator and its supervisor run from the repo
99
- * root, never from a worktree, even when a detached `nohup` launch leaves them parentless.
100
- * (macOS always reparents an orphan to launchd, PID 1; on Linux a process under a child
101
- * subreaper — `systemd --user` in a desktop session — is reparented to that instead, and is
102
- * not seen here.)
103
- *
104
- * A second kind of evidence closes the gap the 2026-09-30 sweep fix left (BUGS.md, part
105
- * 2/2): every process a run starts carries `TUMWATER_RUN=<harness pid>-<nonce>` in its
106
- * environment (appended to any inherited mark), wherever its cwd is — exactly where the
107
- * prompts send agents (scratch dirs under the system temp). A parentless process whose
108
- * every mark names a harness pid that is no longer alive is this team's orphan: nothing
109
- * will reap it, because the run sweep that would have dies with its harness. A mark naming
110
- * ANY live harness is not reported — that harness's own sweep kills everything carrying the
111
- * mark when its run folds. A pid that died and was recycled by an unrelated process reads
112
- * as live (pidAlive cannot tell), so such a straggler stays unseen here — the same limit the
113
- * merge lock's stale-holder check carries. An unparseable mark (a torn or foreign value
114
- * without a `<pid>-` prefix) judges nothing; on macOS ps -E hides platform binaries'
115
- * environments, so a marked sleep/sh leak stays unseen there, as the sweep's own scan does.
116
- *
117
- * Cheap by construction: one `ps`, then one cwd lookup covering only the parentless
118
- * processes argv did not already settle (and only those this user can inspect — lsof and
119
- * /proc cannot read anyone else's, root reads all). An unreadable table degrades to a warn,
120
- * never a crashed doctor; unreadable cwds degrade to the argv match and say so. Any orphan is
121
- * a fail — the exit code is the point: a scripted doctor must notice. `probe` is injectable so
122
- * tests run against a fake table. */
123
- export async function checkOrphans(root, probe = systemProcessProbe) {
89
+ /** A mark's harness is dead (true), alive (false), or the mark names no harness (null — a torn
90
+ * or foreign value judges nothing rather than counting as dead). */
91
+ function harnessGone(marker) {
92
+ const m = /^(\d+)-/.exec(marker);
93
+ return m ? !pidAlive(Number(m[1])) : null;
94
+ }
95
+ /** A parent pid → child pids map of the table. */
96
+ function childrenOf(rows) {
97
+ const children = new Map();
98
+ for (const r of rows) {
99
+ const siblings = children.get(r.ppid);
100
+ if (siblings)
101
+ siblings.push(r.pid);
102
+ else
103
+ children.set(r.ppid, [r.pid]);
104
+ }
105
+ return children;
106
+ }
107
+ /** An orphan's command with the repo root cut out, longest spelling first: the resolved one can
108
+ * contain the given one (/private/var/… holds /var/…) — then trimmed to ORPHAN_COMMAND_MAX. */
109
+ function shownCommand(command, root) {
110
+ const cut = [...rootSpellings(root)].sort((a, b) => b.length - a.length);
111
+ return truncate(cut.reduce((c, rt) => c.split(`${rt}/`).join(""), command), ORPHAN_COMMAND_MAX);
112
+ }
113
+ /** The detection half of checkOrphans, shared with killOrphans so the check and the kill cannot
114
+ * disagree about the set. */
115
+ async function findOrphans(root, probe) {
116
+ const roots = rootSpellings(root);
124
117
  let rows;
125
118
  try {
126
119
  rows = await probe.list();
127
120
  }
128
121
  catch (err) {
129
- return warnOutcome(`could not scan the process table — ${errorMessage(err)}`);
122
+ return { error: errorMessage(err) };
130
123
  }
131
- const roots = rootSpellings(root);
132
124
  const dirs = roots.map(worktreesDir);
133
125
  // Never doctor itself, should it be run parentless from a worktree's build.
134
126
  const parentless = rows.filter((r) => r.ppid === 1 && r.pid !== process.pid);
@@ -156,12 +148,6 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
156
148
  // Best-effort per the probe contract: argv and cwd still carry the check alone.
157
149
  }
158
150
  }
159
- /** A mark's harness is dead (true), alive (false), or the mark names no harness (null —
160
- * a torn or foreign value judges nothing rather than counting as dead). */
161
- const harnessGone = (marker) => {
162
- const m = /^(\d+)-/.exec(marker);
163
- return m ? !pidAlive(Number(m[1])) : null;
164
- };
165
151
  const byMark = new Set();
166
152
  for (const r of parentless) {
167
153
  if (byArgv.has(r.pid))
@@ -176,35 +162,168 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
176
162
  const cwd = cwds.get(r.pid);
177
163
  return (cwd !== undefined && isUnder(cwd, dirs)) || relativeArgvIsOurs(r.command, cwd, root, dirs);
178
164
  });
165
+ return { rows, orphans, marks, cwdProblem };
166
+ }
167
+ /** Orphaned worktree processes — the detector half of the grandchild-leak fix (BUGS.md
168
+ * 2026-09-21): a process reparented to PID 1 that belongs to one of this repo's worktrees.
169
+ * Leaks of this kind (a killed run's tool-call grandchildren, a timed-out build check's test
170
+ * tree, a pi run's backgrounded server, a stray orchestrator an orphaned suite started) hold
171
+ * no lock, write no event and touch no state file, so nothing else in the harness can see
172
+ * them. A process is this repo's when its argv names a path under `.tumwater/worktrees/`
173
+ * (absolute; or relative, confirmed by cwd where it can be — relativeArgvIsOurs) or when its
174
+ * cwd lies under it: a leaked `node dist/test/test-runner.js` names no worktree at all.
175
+ *
176
+ * PPID 1 is what keeps the live team out: a running orchestrator's pi runs and build checks
177
+ * have the orchestrator as parent, and the orchestrator and its supervisor run from the repo
178
+ * root, never from a worktree, even when a detached `nohup` launch leaves them parentless.
179
+ * (macOS always reparents an orphan to launchd, PID 1; on Linux a process under a child
180
+ * subreaper — `systemd --user` in a desktop session — is reparented to that instead, and is
181
+ * not seen here.)
182
+ *
183
+ * A second kind of evidence closes the gap the 2026-09-30 sweep fix left (BUGS.md, part
184
+ * 2/2): every process a run starts carries `TUMWATER_RUN=<harness pid>-<nonce>` in its
185
+ * environment (appended to any inherited mark), wherever its cwd is — exactly where the
186
+ * prompts send agents (scratch dirs under the system temp). A parentless process whose
187
+ * every mark names a harness pid that is no longer alive is this team's orphan: nothing
188
+ * will reap it, because the run sweep that would have dies with its harness. A mark naming
189
+ * ANY live harness is not reported — that harness's own sweep kills everything carrying the
190
+ * mark when its run folds. A pid that died and was recycled by an unrelated process reads
191
+ * as live (pidAlive cannot tell), so such a straggler stays unseen here — the same limit the
192
+ * merge lock's stale-holder check carries. An unparseable mark (a torn or foreign value
193
+ * without a `<pid>-` prefix) judges nothing; on macOS ps -E hides platform binaries'
194
+ * environments, so a marked sleep/sh leak stays unseen there, as the sweep's own scan does.
195
+ *
196
+ * Cheap by construction: one `ps`, then one cwd lookup covering only the parentless
197
+ * processes argv did not already settle (and only those this user can inspect — lsof and
198
+ * /proc cannot read anyone else's, root reads all). An unreadable table degrades to a warn,
199
+ * never a crashed doctor; unreadable cwds degrade to the argv match and say so. Any orphan is
200
+ * a fail — the exit code is the point: a scripted doctor must notice. `probe` is injectable so
201
+ * tests run against a fake table. */
202
+ export async function checkOrphans(root, probe = systemProcessProbe) {
203
+ const scan = await findOrphans(root, probe);
204
+ if ("error" in scan)
205
+ return warnOutcome(`could not scan the process table — ${scan.error}`);
206
+ const { rows, orphans, marks, cwdProblem } = scan;
179
207
  if (orphans.length === 0) {
180
208
  if (cwdProblem !== null)
181
209
  return warnOutcome(`none named in argv, but process cwds are unreadable (${cwdProblem}) — an orphan started inside a worktree, like a leaked test runner, would be missed`);
182
210
  return okOutcome("none — no process reparented to PID 1 runs from .tumwater/worktrees/ or carries only dead runs' marks");
183
211
  }
184
- const children = new Map();
185
- for (const r of rows) {
186
- const siblings = children.get(r.ppid);
187
- if (siblings)
188
- siblings.push(r.pid);
189
- else
190
- children.set(r.ppid, [r.pid]);
191
- }
192
- // The root is cut from each shown command, longest spelling first: the resolved one can
193
- // contain the given one (/private/var/… holds /var/…).
194
- const cut = [...roots].sort((a, b) => b.length - a.length);
212
+ const children = childrenOf(rows);
195
213
  const listed = orphans.slice(0, ORPHANS_LISTED_MAX).map((r) => {
196
- const n = descendantCount(children, r.pid);
214
+ const n = descendantPids(children, r.pid).length;
197
215
  const tree = n > 0 ? `, +${plural(n, "descendant")}` : "";
198
- const command = cut.reduce((c, rt) => c.split(`${rt}/`).join(""), r.command);
199
216
  // A mark-caught orphan says which run marked it: the operator killing it wants to know
200
217
  // nothing else will, and the dead harness's pid is the evidence argv and cwd lacked.
201
218
  const mark = (marks.get(r.pid) ?? []).find((m) => harnessGone(m) === true);
202
219
  const marked = mark !== undefined ? ` (run ${mark}; tumwater ${mark.split("-")[0]} exited)` : "";
203
- return `pid ${r.pid} (age ${r.etime}, cpu ${r.time}${tree}) ${truncate(command, ORPHAN_COMMAND_MAX)}${marked}`;
220
+ return `pid ${r.pid} (age ${r.etime}, cpu ${r.time}${tree}) ${shownCommand(r.command, root)}${marked}`;
204
221
  });
205
222
  const more = orphans.length - listed.length;
206
223
  const count = `${plural(orphans.length, "orphaned process", "orphaned processes")} (PPID 1)`;
207
224
  return failOutcome(`${count}: ${listed.join("; ")}${more > 0 ? `; and ${more} more` : ""}` +
208
- " — nothing reaps these; kill each with its descendants" +
225
+ " — nothing reaps these; `tumwater doctor --kill-orphans` (or the dashboard Health card's Kill orphans, or the TUI Health tab's Ctrl+K pressed twice) kills each with its descendants" +
209
226
  (cwdProblem !== null ? ` (process cwds unreadable — ${cwdProblem}; argv matched only)` : ""));
210
227
  }
228
+ const systemSignals = {
229
+ kill: (pid, signal) => void process.kill(pid, signal),
230
+ alive: pidAlive,
231
+ graceMs: KILL_GRACE_MS,
232
+ pollMs: GROUP_POLL_MS,
233
+ };
234
+ /** The lines `doctor --kill-orphans` prints for a report, shared with the dashboard's Kill
235
+ * orphans toast. */
236
+ export function orphanKillLines(report) {
237
+ if ("error" in report)
238
+ return [`could not scan the process table — ${report.error}; nothing killed`];
239
+ const lines = report.killed.map((k) => {
240
+ const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
241
+ return `killed pid ${k.pid}${tree} ${k.command}`;
242
+ });
243
+ for (const f of report.failed)
244
+ lines.push(`could not kill pid ${f.pid}: ${f.reason}`);
245
+ if (lines.length === 0)
246
+ lines.push("no orphaned processes to kill");
247
+ return lines;
248
+ }
249
+ /** Kill the processes checkOrphans lists, each with its descendants from the same scan:
250
+ * SIGTERM all, wait up to `graceMs` for them to go, SIGKILL what remains. A pid already gone
251
+ * (ESRCH) counts as killed; any other refusal (EPERM) is reported and the rest are still
252
+ * signalled. An orphan with a refused pid in its tree is reported failed, not killed. */
253
+ export async function killOrphans(root, probe = systemProcessProbe, signals = systemSignals) {
254
+ const scan = await findOrphans(root, probe);
255
+ if ("error" in scan)
256
+ return { error: scan.error };
257
+ return killTrees(root, scan.rows, scan.orphans, signals);
258
+ }
259
+ /** The signal-and-wait half of killOrphans: SIGTERM each chosen row's tree from `rows`, wait,
260
+ * SIGKILL the rest, and report what went. */
261
+ async function killTrees(root, rows, chosen, signals) {
262
+ const children = childrenOf(rows);
263
+ const refused = new Map();
264
+ const send = (pid, signal) => {
265
+ try {
266
+ signals.kill(pid, signal);
267
+ }
268
+ catch (err) {
269
+ if (err.code !== "ESRCH")
270
+ refused.set(pid, errorMessage(err));
271
+ }
272
+ };
273
+ const trees = chosen.map((r) => ({ r, pids: [r.pid, ...descendantPids(children, r.pid)] }));
274
+ const all = [...new Set(trees.flatMap((t) => t.pids))];
275
+ for (const pid of all)
276
+ send(pid, "SIGTERM");
277
+ const stillAlive = () => all.filter((pid) => !refused.has(pid) && signals.alive(pid));
278
+ const deadline = Date.now() + signals.graceMs;
279
+ while (stillAlive().length > 0 && Date.now() < deadline)
280
+ await new Promise((resolve) => setTimeout(resolve, signals.pollMs));
281
+ for (const pid of stillAlive())
282
+ send(pid, "SIGKILL");
283
+ const killed = [];
284
+ const failed = [];
285
+ for (const { r, pids } of trees) {
286
+ const bad = pids.find((pid) => refused.has(pid));
287
+ if (bad === undefined)
288
+ killed.push({ pid: r.pid, command: shownCommand(r.command, root), descendants: pids.length - 1 });
289
+ else
290
+ failed.push({ pid: bad, reason: refused.get(bad) ?? "" });
291
+ }
292
+ return { killed, failed };
293
+ }
294
+ /** Team start's reap of a crashed predecessor's leaks: kill the parentless processes whose run
295
+ * marks all name dead harnesses and at least one names `previousPid`, the orchestrator the
296
+ * info file recorded. Safe without an operator's judgment: a mark proves a tumwater start
297
+ * started the process, the previous-pid match scopes it to this repo, and the dead-harness
298
+ * test excludes any run whose own sweep is still coming. A live `previousPid` (a hand-off
299
+ * whose old orchestrator is draining) scans nothing. Orphans caught only by argv or cwd carry
300
+ * no mark read (findOrphans asks marks only of processes argv did not settle) and stay for
301
+ * `doctor --kill-orphans`. Returns the killed count; never throws. */
302
+ export async function reapPreviousTeamOrphans(root, previousPid, probe = systemProcessProbe, signals = systemSignals) {
303
+ try {
304
+ if (pidAlive(previousPid))
305
+ return 0;
306
+ const scan = await findOrphans(root, probe);
307
+ if ("error" in scan)
308
+ return 0;
309
+ const chosen = scan.orphans.filter((r) => {
310
+ const marks = scan.marks.get(r.pid) ?? [];
311
+ return marks.length > 0 && marks.every((m) => harnessGone(m) === true) &&
312
+ marks.some((m) => m.startsWith(`${previousPid}-`));
313
+ });
314
+ if (chosen.length === 0)
315
+ return 0;
316
+ const { killed, failed } = await killTrees(root, scan.rows, chosen, signals);
317
+ const items = killed.map((k) => {
318
+ const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
319
+ return `pid ${k.pid}${tree} ${k.command}`;
320
+ });
321
+ const refused = failed.map((f) => `could not kill pid ${f.pid}: ${f.reason}`);
322
+ warnEventBestEffort(root, TUMWATER_SOURCE, `killed ${plural(killed.length, "orphaned process", "orphaned processes")} left by tumwater ` +
323
+ `${previousPid}'s runs at team start: ${[...items, ...refused].join("; ")}`);
324
+ return killed.length;
325
+ }
326
+ catch {
327
+ return 0;
328
+ }
329
+ }
@@ -0,0 +1,62 @@
1
+ import { childEnv } from "../process/process.js";
2
+ import { boundedProbe, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./probe.js";
3
+ import { parseJsonObject } from "../files/json-object.js";
4
+ import { envPath } from "../files/files.js";
5
+ import { resolveBackendBin } from "../pi/pi-bin.js";
6
+ import { parsePiModelImages, parsePiModelTable, writePiListedModels } from "../pi/pi-listed-models.js";
7
+ /** The default credential probe: run the resolved agent binary's own auth check for one provider
8
+ * and read `status` out of its JSON. The binary resolves exactly as the spawn does
9
+ * (resolveBackendBin — the same source a real run would launch), so doctor tests the auth of
10
+ * the pi it would actually run. pi's contract is `{"status": "ready"|"not_ready"|"invalid",
11
+ * provider, reason?, authType?}` with exit codes 0/1/2 — a non-zero exit there is a verdict, not
12
+ * a crash, so readiness is read from the body alone and a reply that did not run or does not
13
+ * parse is the only "unknown" (never not-ready, which would blame credentials). Never throws;
14
+ * every failure mode ends on "unknown". The bounded subprocess is the shared boundedProbe
15
+ * (probe.ts), also used by the claude probes. */
16
+ export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
17
+ const resolved = resolveBackendBin(config);
18
+ const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
19
+ if (result === null)
20
+ return "unknown";
21
+ // An unreadable reply is unknown, never not-ready (which would blame the credentials).
22
+ const doc = parseJsonObject(result.stdout);
23
+ if (doc === null)
24
+ return "unknown";
25
+ if (doc.status === "ready")
26
+ return "ready";
27
+ // `invalid` (a bad provider name, exit 2) is likewise not a healthy credential: it reads
28
+ // not-ready so the warning blames the setup, not the probe.
29
+ if (doc.status === "not_ready" || doc.status === "invalid")
30
+ return "not-ready";
31
+ return "unknown";
32
+ }
33
+ /** Read the table `pi --list-models` prints into a set of `provider/model` keys. Null unless the
34
+ * first non-blank line is the `provider model …` header, so a "No models matching" reply or any
35
+ * other text is never taken for an empty catalog. */
36
+ export function parsePiModelList(stdout) {
37
+ const table = parsePiModelTable(stdout);
38
+ if (table === null)
39
+ return null;
40
+ const listed = new Set();
41
+ for (const [provider, model] of table.rows)
42
+ if (model !== undefined)
43
+ listed.add(`${provider}/${model}`);
44
+ return listed;
45
+ }
46
+ /** The models pi itself lists (`pi --list-models`), which include its built-in catalog that
47
+ * models.json does not repeat. pi lists only the providers it has credentials for. Null when the
48
+ * probe could not run or failed; never throws. Given a `root`, it also records the images column
49
+ * for `seamImageInput` (src/pi/pi-listed-models.ts). */
50
+ export async function piListedModels(config, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS, root) {
51
+ const result = await boundedProbe(resolveBackendBin(config).bin, ["--list-models"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
52
+ if (result?.code !== 0)
53
+ return null;
54
+ const images = root === undefined ? null : parsePiModelImages(result.stdout);
55
+ if (root !== undefined && images !== null) {
56
+ try {
57
+ writePiListedModels(root, images);
58
+ }
59
+ catch { } // A failed write never changes the check.
60
+ }
61
+ return parsePiModelList(result.stdout);
62
+ }