tumwater 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +2 -1
  4. package/dist/src/agent/claude/claude-account.js +6 -20
  5. package/dist/src/agent/codex/codex-account.js +5 -6
  6. package/dist/src/agent/codex/codex-args.js +5 -5
  7. package/dist/src/agent/codex/codex-rollout.js +1 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +24 -22
  9. package/dist/src/agent/opencode/opencode-catalog.js +35 -9
  10. package/dist/src/agent/session-ledger.js +22 -26
  11. package/dist/src/agent/subscription-account.js +2 -83
  12. package/dist/src/backlog/backlog-archive.js +3 -4
  13. package/dist/src/backlog/backlog-eligibility.js +14 -36
  14. package/dist/src/backlog/backlog-question-answer.js +1 -1
  15. package/dist/src/backlog/backlog.js +2 -1
  16. package/dist/src/baseline/main-guardrails.js +2 -2
  17. package/dist/src/baseline/main-watch.js +84 -50
  18. package/dist/src/brief/brief.js +26 -13
  19. package/dist/src/budget/budget.js +4 -5
  20. package/dist/src/build/build-check-report.js +10 -0
  21. package/dist/src/build/build-check-scoped.js +0 -2
  22. package/dist/src/build/build-stage.js +6 -4
  23. package/dist/src/change/change-data.js +1 -4
  24. package/dist/src/cli/cli-completion-command.js +75 -0
  25. package/dist/src/cli/cli-doctor-command.js +103 -0
  26. package/dist/src/cli/cli-init.js +2 -2
  27. package/dist/src/cli/cli-query-commands.js +1 -1
  28. package/dist/src/cli/cli-role-command.js +2 -41
  29. package/dist/src/cli/cli-start-summary.js +4 -3
  30. package/dist/src/cli/cli-start-supervisor.js +190 -0
  31. package/dist/src/cli/cli-start.js +4 -187
  32. package/dist/src/cli/completion.js +3 -3
  33. package/dist/src/cli/follow-view.js +20 -2
  34. package/dist/src/cli/help.js +2 -7
  35. package/dist/src/cli.js +19 -171
  36. package/dist/src/collections.js +11 -14
  37. package/dist/src/concurrency/lock.js +1 -1
  38. package/dist/src/config/config-keys.js +61 -20
  39. package/dist/src/config/config-request.js +11 -1
  40. package/dist/src/config/config-schema.js +1 -1
  41. package/dist/src/config/config-validation-roles.js +6 -22
  42. package/dist/src/config/config-validation.js +0 -1
  43. package/dist/src/config/config-views.js +17 -16
  44. package/dist/src/config/config-write.js +122 -79
  45. package/dist/src/config/config.js +1 -9
  46. package/dist/src/doctor/doctor-backlog-plans.js +29 -96
  47. package/dist/src/doctor/doctor-backlog.js +10 -10
  48. package/dist/src/doctor/doctor-checks.js +4 -4
  49. package/dist/src/doctor/doctor-claude-checks.js +27 -9
  50. package/dist/src/doctor/doctor-contention-checks.js +13 -5
  51. package/dist/src/doctor/doctor-fallback-checks.js +46 -24
  52. package/dist/src/doctor/doctor-holds.js +2 -2
  53. package/dist/src/doctor/doctor-orphans.js +3 -2
  54. package/dist/src/doctor/doctor-project-checks.js +21 -8
  55. package/dist/src/events/event-format.js +15 -11
  56. package/dist/src/events/event-legacy.js +2 -2
  57. package/dist/src/events/event-notify.js +4 -4
  58. package/dist/src/events/event-window.js +1 -1
  59. package/dist/src/events/events.js +8 -0
  60. package/dist/src/failure/failure-cluster.js +4 -13
  61. package/dist/src/failure/failure-data.js +4 -4
  62. package/dist/src/failure/failure-render.js +2 -2
  63. package/dist/src/failure/failure-state-change.js +50 -86
  64. package/dist/src/gates/gate-polls.js +5 -6
  65. package/dist/src/gates/startup-gates.js +2 -1
  66. package/dist/src/git/git-diff.js +6 -2
  67. package/dist/src/gui/gui-args.js +12 -0
  68. package/dist/src/gui/gui-config-commands.js +2 -64
  69. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  70. package/dist/src/gui/gui-endpoint-common.js +19 -1
  71. package/dist/src/gui/gui-operator-commands.js +21 -25
  72. package/dist/src/gui/gui-prompt-commands.js +2 -2
  73. package/dist/src/gui/gui-server.js +1 -2
  74. package/dist/src/gui/gui-team-commands.js +2 -1
  75. package/dist/src/history/history.js +4 -4
  76. package/dist/src/merge/merge-check-failures.js +12 -9
  77. package/dist/src/merge/merge-core.js +6 -31
  78. package/dist/src/merge/merge-drain.js +3 -2
  79. package/dist/src/merge/merge-post-review-drain.js +1 -1
  80. package/dist/src/merge/merge-queue-render.js +3 -3
  81. package/dist/src/merge/merge-stack.js +17 -18
  82. package/dist/src/merge/merge-to-main.js +13 -58
  83. package/dist/src/merge/merge-verify.js +4 -3
  84. package/dist/src/operator/operator-commands.js +4 -3
  85. package/dist/src/orchestrator/orchestrator-poll.js +18 -38
  86. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  87. package/dist/src/orchestrator/scheduling-scope.js +17 -26
  88. package/dist/src/orchestrator/scheduling-skip.js +9 -13
  89. package/dist/src/paths.js +2 -2
  90. package/dist/src/pi/pi-event-line.js +27 -0
  91. package/dist/src/pi/pi-listed-models.js +13 -0
  92. package/dist/src/pi/pi-stream.js +9 -2
  93. package/dist/src/pi/pi-watchdogs.js +31 -10
  94. package/dist/src/process/process-group.js +2 -21
  95. package/dist/src/process/process.js +12 -4
  96. package/dist/src/process/run-marker.js +2 -2
  97. package/dist/src/prompt/gate-prompts.js +1 -44
  98. package/dist/src/redeploy/redeployer.js +21 -1
  99. package/dist/src/review/diff-rules.js +35 -21
  100. package/dist/src/review/post-merge-review.js +47 -40
  101. package/dist/src/review/review-precheck.js +3 -3
  102. package/dist/src/review/review.js +13 -22
  103. package/dist/src/roles/legacy-role-migration.js +6 -3
  104. package/dist/src/roles/role-render.js +6 -32
  105. package/dist/src/roles/role-view.js +2 -13
  106. package/dist/src/run/ci-parity.js +19 -8
  107. package/dist/src/run/run-apply.js +0 -9
  108. package/dist/src/run/run-stage.js +6 -6
  109. package/dist/src/run/run-verdict.js +13 -4
  110. package/dist/src/run/stage-check.js +17 -24
  111. package/dist/src/runner/runner-leftover.js +9 -26
  112. package/dist/src/runner/runner-outcome.js +1 -1
  113. package/dist/src/runner/runner-pi.js +6 -12
  114. package/dist/src/runner/runner-recovery.js +4 -12
  115. package/dist/src/runner/runner-revision.js +1 -2
  116. package/dist/src/runner/runner-run.js +7 -16
  117. package/dist/src/runner/runner-state-load.js +6 -19
  118. package/dist/src/runner/runner-state.js +0 -14
  119. package/dist/src/scheduling/backoff.js +8 -3
  120. package/dist/src/scheduling/claim-entries.js +2 -1
  121. package/dist/src/status/status-budget.js +4 -43
  122. package/dist/src/status/status-data.js +34 -38
  123. package/dist/src/status/status-roles.js +1 -2
  124. package/dist/src/team/team-hold.js +7 -5
  125. package/dist/src/team/team-orchestrator-info.js +53 -21
  126. package/dist/src/team/team-polls.js +36 -86
  127. package/dist/src/text/datetime.js +16 -7
  128. package/dist/src/text/phrases.js +9 -0
  129. package/dist/src/text/suggest.js +1 -1
  130. package/dist/src/text/text.js +3 -2
  131. package/dist/src/ui/alert-feeds.js +0 -41
  132. package/dist/src/ui/badges.js +14 -75
  133. package/dist/src/ui/gui/gui-client-boot.js +0 -6
  134. package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
  135. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  136. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  137. package/dist/src/ui/gui/gui-client-team.js +8 -2
  138. package/dist/src/ui/progress-data.js +54 -4
  139. package/dist/src/ui/run-progress-model.js +12 -3
  140. package/dist/src/ui/status-payload.js +10 -25
  141. package/dist/src/ui/status-render.js +10 -17
  142. package/dist/src/ui/team-alert-backlog.js +15 -205
  143. package/dist/src/ui/team-alert-shared.js +6 -29
  144. package/dist/src/ui/team-alerts.js +4 -10
  145. package/dist/src/ui/tui/tui-backlog.js +2 -2
  146. package/dist/src/ui/tui/tui-editors.js +5 -4
  147. package/dist/src/ui/tui/tui-input.js +8 -6
  148. package/dist/src/ui/tui/tui-pane-activity.js +2 -1
  149. package/dist/src/ui/tui/tui-pane-health.js +21 -23
  150. package/dist/src/ui/tui/tui-pane-history.js +7 -5
  151. package/dist/src/ui/tui/tui-pane-pending.js +19 -15
  152. package/dist/src/ui/tui/tui-pane-session.js +4 -3
  153. package/dist/src/ui/tui/tui-panes.js +9 -1
  154. package/dist/src/ui/tui/tui.js +3 -5
  155. package/dist/src/verdict/fix-claim.js +36 -7
  156. package/dist/src/verdict/reply-contract.js +3 -4
  157. package/package.json +2 -2
  158. package/dist/src/review/review-followup.js +0 -87
  159. package/dist/src/review/review-run.js +0 -395
  160. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  161. /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
  162. /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
  163. /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
  164. /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
@@ -1,9 +1,10 @@
1
1
  /** `tumwater start`'s exit-summary half: the machinery that turns a finished round or window
2
2
  * into its summary — the `--once` one-line report and its `--json` document, and the `--for`
3
3
  * window summary table and report. `src/cli/cli-start.ts` keeps `cmdStart` (the boot and the
4
- * run loop), the window's deadline and scheduled-stop watchdogs, the `--gui` child plan, and
5
- * the supervisor half; this module holds only the reporting that a `start`'s exit prints, so
6
- * the largest command module is no longer also the home of every way a run's tally is shaped.
4
+ * run loop), the window's deadline and scheduled-stop watchdogs, and the invocation guards;
5
+ * the supervisor half lives in `src/cli/cli-start-supervisor.ts`; this module holds only the
6
+ * reporting that a `start`'s exit prints, so the largest command module is no longer also the
7
+ * home of every way a run's tally is shaped.
7
8
  * `parseRunWindow` keeps `--for`'s shape and ceiling rules together with the window summary
8
9
  * they feed. */
9
10
  import { compareCodeUnits } from "../collections.js";
@@ -0,0 +1,190 @@
1
+ import { fail, say } from "./cli-output.js";
2
+ import { parsePortFlag } from "./cli-args.js";
3
+ import { runStartupProblem } from "../gates/startup-gates.js";
4
+ import { repairInterruptedSwap } from "../build/build-stage.js";
5
+ import { logEvent } from "../events/events.js";
6
+ import { notifyTeamDown } from "../events/event-notify.js";
7
+ import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
8
+ /** The env var that carries the dashboard gate token between a supervisor and its dashboard
9
+ * child (and across a re-exec'd dashboard generation), for the `run --gui`/`start --gui`
10
+ * supervisor's own use. Defined here rather than imported from gui-command.js so this module
11
+ * (and every `start`/`run` invocation) never pulls the GUI server graph onto the page; it
12
+ * must stay the same literal as gui-command's GUI_TOKEN_ENV, and a merge that renames one
13
+ * renames the other. */
14
+ export const GUI_TOKEN_ENV = "TUMWATER_GUI_TOKEN";
15
+ /** Split `run`'s args for `run --gui` and `run --tui`: whether the supervisor should also start
16
+ * the dashboard and/or host the observer TUI, and the args the supervised orchestrator
17
+ * generation gets with both stripped so the generation's own cmdStart never tries to supervise a
18
+ * second dashboard or re-attach a TUI in a terminal the parent owns (a self-redeploy spawns a
19
+ * fresh generation each time). Exported so the unit tests pin the stripping that removing the
20
+ * spawn wiring would otherwise silently drop. */
21
+ export function guiChildPlan(runArgs) {
22
+ const portIdx = runArgs.indexOf("--port");
23
+ const portRaw = portIdx >= 0 ? runArgs[portIdx + 1] : undefined;
24
+ const port = portRaw !== undefined ? parsePortFlag(portRaw) : null;
25
+ const tokenIdx = runArgs.indexOf("--token");
26
+ const tokenRaw = tokenIdx >= 0 ? runArgs[tokenIdx + 1] : undefined;
27
+ // Drop --port/--token and their value tokens from the supervized orchestrator generation
28
+ // along with --gui/--tui (and --all-interfaces by name): the child's own cmdStart would
29
+ // otherwise hit the dashboard-only-flag refusals (its args no longer carry --gui) or supervise
30
+ // a second dashboard, and a self-redeploy hand-off must never forward a dashboard flag it is
31
+ // not serving itself. --all-interfaces and --token have no value token to skip, so the name
32
+ // filter below drops them; the valued pair is skipped by index like --port's.
33
+ const skip = new Set();
34
+ if (portIdx >= 0) {
35
+ skip.add(portIdx);
36
+ if (portIdx + 1 < runArgs.length)
37
+ skip.add(portIdx + 1);
38
+ }
39
+ if (tokenIdx >= 0) {
40
+ skip.add(tokenIdx);
41
+ if (tokenIdx + 1 < runArgs.length)
42
+ skip.add(tokenIdx + 1);
43
+ }
44
+ const guiArgs = [];
45
+ if (port !== null)
46
+ guiArgs.push("--port", String(port));
47
+ if (runArgs.includes("--all-interfaces"))
48
+ guiArgs.push("--all-interfaces");
49
+ // The token is returned separately (guiToken) and reaches the child through its env, never
50
+ // guiArgs/argv: a secret on the command line is world-readable (/proc/<pid>/cmdline) and is
51
+ // re-run verbatim by a self-reload onto each new generation. The skip set still drops the
52
+ // operator's --token pair from the orchestrator generation (its cmdStart would refuse a
53
+ // dashboard flag without --gui, and a self-redeploy hand-off must never forward it).
54
+ return {
55
+ spawnGui: runArgs.includes("--gui"),
56
+ spawnTui: runArgs.includes("--tui"),
57
+ guiArgs,
58
+ guiToken: tokenRaw,
59
+ orchestratorArgs: runArgs.filter((arg, i) => arg !== "--gui" && arg !== "--tui" && arg !== "--all-interfaces" && !skip.has(i)),
60
+ childStdio: runArgs.includes("--tui") ? ["ignore", "ignore", "ignore"] : "inherit",
61
+ };
62
+ }
63
+ /** The env a supervised dashboard child is spawned with: it marks the child as owned by this
64
+ * supervisor (`childMarkerEnv`, DASHBOARD_CHILD_ENV, names the host pid so the child's reload
65
+ * watch at src/redeploy/self-reload.ts exits RESTART_EXIT_CODE / dies with the supervisor) and,
66
+ * when the operator gave a gate token, carries it under GUI_TOKEN_ENV. The token deliberately
67
+ * never reaches the child's argv: /proc/<pid>/cmdline is world-readable, and the child's own
68
+ * self-reload (reexecSelf, which propagates env via childEnv) would re-run a command-line secret
69
+ * onto each fresh generation. Kept out of superviseDashboard so this env wiring is unit-testable
70
+ * without spawning a live dashboard child (BUGS-FIXED.md's dashboard gate-token fix). */
71
+ export function dashboardChildEnv(childMarkerEnv, guiToken, hostPid) {
72
+ const env = { [childMarkerEnv]: String(hostPid) };
73
+ if (guiToken !== undefined)
74
+ env[GUI_TOKEN_ENV] = guiToken;
75
+ return env;
76
+ }
77
+ /** The dashboard half of `run --gui`: keep a `tumwater gui` child alive across its own
78
+ * self-reloads. The child is marked with DASHBOARD_CHILD_ENV naming this supervisor, so its
79
+ * reload watch (src/redeploy/self-reload.ts) exits RESTART_EXIT_CODE for a fresh sibling on a
80
+ * redeploy and exits on its own if this supervisor dies outright (SIGKILL, a crash) — a killed
81
+ * supervisor never leaves a dashboard holding its port. Any other dashboard exit (a failed bind
82
+ * prints its own message and exits) ends this loop and leaves the team running. */
83
+ async function superviseDashboard(signal, guiToken, stdio = "inherit", guiArgs = []) {
84
+ const { DASHBOARD_CHILD_ENV } = await import("../redeploy/self-reload.js");
85
+ const { spawnGuiChild } = await import("../process/supervisor.js");
86
+ const env = dashboardChildEnv(DASHBOARD_CHILD_ENV, guiToken, process.pid);
87
+ for (;;) {
88
+ const exit = await spawnGuiChild(signal, env, stdio, guiArgs);
89
+ if (signal.aborted || exit.code !== RESTART_EXIT_CODE)
90
+ return;
91
+ }
92
+ }
93
+ /** The supervisor half of `tumwater start` (src/process/supervisor.ts): spawn the orchestrator as a
94
+ * child generation and respawn it whenever it exits RESTART_EXIT_CODE after redeploying itself.
95
+ * Ctrl+C reaches the child directly from the terminal, so only SIGTERM is forwarded; the
96
+ * supervisor's own exit code is whatever the last generation's was. A team that goes down without
97
+ * the operator asking leaves a `supervisor_exit` event behind (BUGS.md 2026-09-23): the dead
98
+ * generation's stderr reached only this terminal, which nobody may be watching. */
99
+ export async function superviseStartCommand(root, runArgs, branchArg, forMs) {
100
+ // The supervisor machinery loads lazily, like cmdStart's orchestrator import above: a command
101
+ // that never boots the team never compiles it.
102
+ const { RUN_WINDOW_START_ENV, teamDownEvent, spawnStartChild, superviseStart } = await import("../process/supervisor.js");
103
+ const plan = guiChildPlan(runArgs);
104
+ // A TUI owns the terminal, so it can only be hosted when this supervisor's own stdin and
105
+ // stdout are a TTY — the same guard runTui enforces. Check before booting a team a TUI
106
+ // could never drive, surfacing the actionable tuiTerminalError instead of letting runTui's
107
+ // rejection sit unhandled until superviseStart settles and then crash the supervisor after
108
+ // the team has (pointlessly) started.
109
+ if (plan.spawnTui && (!process.stdin.isTTY || !process.stdout.isTTY)) {
110
+ const { tuiTerminalError } = await import("../ui/tui/tui-input.js");
111
+ fail(tuiTerminalError(Boolean(process.stdin.isTTY), Boolean(process.stdout.isTTY)));
112
+ }
113
+ const windowEnv = forMs !== null ? { [RUN_WINDOW_START_ENV]: String(Date.now()) } : {};
114
+ const controller = new AbortController();
115
+ // The dashboard lives beside the orchestrator generations rather than inside one: it must
116
+ // outlive every generation a redeploy swaps out. Its own abort stops the current dashboard
117
+ // child, which spawnGuiChild's abort wiring SIGTERMs.
118
+ const guiController = new AbortController();
119
+ let stopping = false;
120
+ process.on("SIGINT", () => {
121
+ stopping = true; // The child got the same SIGINT from the terminal and stops on its own.
122
+ });
123
+ process.on("SIGTERM", () => {
124
+ stopping = true;
125
+ controller.abort(); // Not delivered to the child by the kernel — forward it.
126
+ guiController.abort();
127
+ });
128
+ // The TUI is hosted in the supervisor process, beside the orchestrator generations it must
129
+ // outlive. Quitting it (q or Ctrl+C handled by ink's key handler) is the run's Ctrl+C:
130
+ // runTui resolves and the .then sets `stopping` and aborts the controller, so superviseStart
131
+ // drains in-flight runs and the team shuts down exactly as Ctrl+C on a plain `run` does;
132
+ // the team-down teardown (dashboard abort, onDone, terminal restore) is unchanged.
133
+ // reloadDisabled keeps the hosted TUI off its self-reload watch: the team's own first
134
+ // self-redeploy dist swap must not resolve runTui (which would read as a quit and abort
135
+ // this run's controller) or spawn a re-exec replacement; the host supervisor survives a
136
+ // redeploy by respawning its team children, and the hosted TUI — like the supervisor
137
+ // itself — stays on the build it started, getting fresh code on the next run. The abort
138
+ // signal is how this process ends the TUI when the run ends on its own (a `--for` window
139
+ // closes, or the team stops): runTui resolves on abort and takes its quit teardown, so the
140
+ // `await tui` below cannot hang on a TUI nothing else would ever close.
141
+ const tuiController = new AbortController();
142
+ const tui = plan.spawnTui
143
+ ? (await import("../ui/tui/tui.js")).runTui(root, { abortSignal: tuiController.signal, reloadDisabled: true }).then(() => {
144
+ stopping = true; // the TUI quit = the run's Ctrl+C
145
+ controller.abort();
146
+ guiController.abort();
147
+ })
148
+ : Promise.resolve();
149
+ const gui = plan.spawnGui ? superviseDashboard(guiController.signal, plan.guiToken, plan.childStdio, plan.guiArgs) : Promise.resolve();
150
+ const code = await superviseStart({
151
+ spawnChild: (signal) => spawnStartChild(signal, plan.orchestratorArgs, windowEnv, plan.childStdio),
152
+ stopping: () => stopping,
153
+ onRespawn: (generation) => {
154
+ // With a hosted TUI the supervisor owns no free stdout: its own say() would paint the
155
+ // restart line over the ink full-screen render and corrupt it — the same interleaving
156
+ // childStdio silences for the children. The TUI renders status from on-disk state, so
157
+ // the redeploy is visible there as the team coming back.
158
+ if (!plan.spawnTui && !runArgs.includes("--json"))
159
+ say(`\nrestarting onto the update (restart ${generation})\n`);
160
+ },
161
+ onCrashLoop: () => process.stderr.write("tumwater restarted itself too many times in a minute — giving up\n"),
162
+ // Re-ask the startup gate for the reason: a generation that dies right after a respawn
163
+ // most likely met an environment that no longer boots (the 2026-09-22 child found
164
+ // tumwater.json gone). A crash loop's generations all asked for restarts, so the gate
165
+ // has nothing to say about it.
166
+ onTeamDown: async (down) => {
167
+ const event = logEvent(root, teamDownEvent(down, down.crashLoop ? null : await runStartupProblem(root, branchArg)));
168
+ notifyTeamDown(root, event);
169
+ },
170
+ // The end-of-supervision build repair (see build-stage.repairInterruptedSwap): a swap
171
+ // killed between its two renames leaves dist/ missing, and this process is the only one
172
+ // still able to rename the preserved old build back before it exits — the next `tumwater
173
+ // run` would otherwise fail to load its own dist/src/cli.js. repairInterruptedSwap is a
174
+ // static import, so its code is in memory before any swap can move dist/ away: an import
175
+ // at call time would fail ERR_MODULE_NOT_FOUND exactly when it is needed. The repair
176
+ // reports its own outcome, so it can never change the exit code.
177
+ onDone: () => repairInterruptedSwap(root),
178
+ }, controller.signal);
179
+ // The orchestrator is gone; take the dashboard and the TUI with it so a stopped team never
180
+ // leaves a dashboard holding its port or a TUI holding the terminal. abort() SIGTERMs the
181
+ // current gui child; await lets the spawn loop observe the abort before this process exits.
182
+ // On a plain run the user's own Ctrl+C already resolved the TUI; on a windowed (--for) run
183
+ // the window just closed with the TUI still up, so it is aborted here and its teardown /
184
+ // terminal restore settle before this process exits.
185
+ tuiController.abort();
186
+ await tui; // let the TUI's promise settle / terminal restore
187
+ guiController.abort();
188
+ await gui;
189
+ process.exit(code);
190
+ }
@@ -2,13 +2,11 @@ import { baseRoleOf, runnerIds } from "../roles/runner-ids.js";
2
2
  import { knownRoleIds } from "../config/config.js";
3
3
  import { unknownRoleMessage } from "../roles/roles.js";
4
4
  import { fail, say, sayJson } from "./cli-output.js";
5
- import { durationLabel, failRivalShapes, parseBranchFlag, parsePortFlag, parseRoleListFlag, tokenValueProblem, } from "./cli-args.js";
5
+ import { durationLabel, failRivalShapes, parseBranchFlag, parseRoleListFlag, tokenValueProblem, } from "./cli-args.js";
6
6
  import { cancelScheduledStop, scheduledStopAt } from "../team/team-state.js";
7
7
  import { orchestratorAlive, readOrchestratorInfo } from "../team/team-orchestrator-info.js";
8
8
  import { runStartupCheck, runStartupProblem } from "../gates/startup-gates.js";
9
- import { repairInterruptedSwap } from "../build/build-stage.js";
10
9
  import { logEvent, subscribeEvents } from "../events/events.js";
11
- import { notifyTeamDown } from "../events/event-notify.js";
12
10
  import { formatEvent } from "../events/event-format.js";
13
11
  import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
14
12
  import { runStartupMigrations } from "../orchestrator/startup-migration-steps.js";
@@ -16,14 +14,8 @@ import { loadRunnerState } from "../runner/runner-state-load.js";
16
14
  import { shortSha } from "../text/format.js";
17
15
  import { readRunRowsSince, summarizeRunRows } from "../history/history-data.js";
18
16
  import { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, onceSummary, parseRunWindow, renderOnceSummary, windowReport, windowSummary, } from "./cli-start-summary.js";
19
- /** The env var that carries the dashboard gate token between a supervisor and its dashboard
20
- * child (and across a re-exec'd dashboard generation), for the `run --gui`/`start --gui`
21
- * supervisor's own use. Defined here rather than imported from gui-command.js so this module
22
- * (and every `start`/`run` invocation) never pulls the GUI server graph onto the page; it
23
- * must stay the same literal as gui-command's GUI_TOKEN_ENV, and a merge that renames one
24
- * renames the other. */
25
- const GUI_TOKEN_ENV = "TUMWATER_GUI_TOKEN";
26
17
  export { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, onceSummary, parseRunWindow, renderOnceSummary, windowReport, windowSummary, };
18
+ import { GUI_TOKEN_ENV, superviseStartCommand } from "./cli-start-supervisor.js";
27
19
  /** Refuse the --once run-mode rivals: a TUI and a dashboard each cannot be hosted by a run that
28
20
  * is about to stop, so both are refused together before any boot. --for stays allowed — both
29
21
  * serve for the window. Owns the run-mode rival refusals. */
@@ -334,7 +326,8 @@ async function runSupervisedGeneration(input) {
334
326
  }
335
327
  /** `tumwater start`: boot the team and stream its event feed to this terminal until it stops.
336
328
  * Three layers cooperate here: without SUPERVISED_ENV this process becomes the supervisor
337
- * (superviseStartCommand below), spawning the real orchestrator as a child generation; the child
329
+ * (src/cli/cli-start-supervisor.ts's superviseStartCommand), spawning the real orchestrator as a
330
+ * child generation; the child
338
331
  * runs runOrchestrator to completion, handing the terminal back via RESTART_EXIT_CODE after a
339
332
  * self-redeploy so the supervisor respawns on the new build; --once collapses the whole thing
340
333
  * into one round of runs and a summary line (onceSummary below), for cron-style invocations.
@@ -412,179 +405,3 @@ export function armScheduledStopWatch(root, onStop, quiet = false) {
412
405
  timer.unref();
413
406
  return { clear: () => clearInterval(timer) };
414
407
  }
415
- /** Split `run`'s args for `run --gui` and `run --tui`: whether the supervisor should also start
416
- * the dashboard and/or host the observer TUI, and the args the supervised orchestrator
417
- * generation gets with both stripped so the generation's own cmdStart never tries to supervise a
418
- * second dashboard or re-attach a TUI in a terminal the parent owns (a self-redeploy spawns a
419
- * fresh generation each time). Exported so the unit tests pin the stripping that removing the
420
- * spawn wiring would otherwise silently drop. */
421
- export function guiChildPlan(runArgs) {
422
- const portIdx = runArgs.indexOf("--port");
423
- const portRaw = portIdx >= 0 ? runArgs[portIdx + 1] : undefined;
424
- const port = portRaw !== undefined ? parsePortFlag(portRaw) : null;
425
- const tokenIdx = runArgs.indexOf("--token");
426
- const tokenRaw = tokenIdx >= 0 ? runArgs[tokenIdx + 1] : undefined;
427
- // Drop --port/--token and their value tokens from the supervized orchestrator generation
428
- // along with --gui/--tui (and --all-interfaces by name): the child's own cmdStart would
429
- // otherwise hit the dashboard-only-flag refusals (its args no longer carry --gui) or supervise
430
- // a second dashboard, and a self-redeploy hand-off must never forward a dashboard flag it is
431
- // not serving itself. --all-interfaces and --token have no value token to skip, so the name
432
- // filter below drops them; the valued pair is skipped by index like --port's.
433
- const skip = new Set();
434
- if (portIdx >= 0) {
435
- skip.add(portIdx);
436
- if (portIdx + 1 < runArgs.length)
437
- skip.add(portIdx + 1);
438
- }
439
- if (tokenIdx >= 0) {
440
- skip.add(tokenIdx);
441
- if (tokenIdx + 1 < runArgs.length)
442
- skip.add(tokenIdx + 1);
443
- }
444
- const guiArgs = [];
445
- if (port !== null)
446
- guiArgs.push("--port", String(port));
447
- if (runArgs.includes("--all-interfaces"))
448
- guiArgs.push("--all-interfaces");
449
- // The token is returned separately (guiToken) and reaches the child through its env, never
450
- // guiArgs/argv: a secret on the command line is world-readable (/proc/<pid>/cmdline) and is
451
- // re-run verbatim by a self-reload onto each new generation. The skip set still drops the
452
- // operator's --token pair from the orchestrator generation (its cmdStart would refuse a
453
- // dashboard flag without --gui, and a self-redeploy hand-off must never forward it).
454
- return {
455
- spawnGui: runArgs.includes("--gui"),
456
- spawnTui: runArgs.includes("--tui"),
457
- guiArgs,
458
- guiToken: tokenRaw,
459
- orchestratorArgs: runArgs.filter((arg, i) => arg !== "--gui" && arg !== "--tui" && arg !== "--all-interfaces" && !skip.has(i)),
460
- childStdio: runArgs.includes("--tui") ? ["ignore", "ignore", "ignore"] : "inherit",
461
- };
462
- }
463
- /** The env a supervised dashboard child is spawned with: it marks the child as owned by this
464
- * supervisor (`childMarkerEnv`, DASHBOARD_CHILD_ENV, names the host pid so the child's reload
465
- * watch at src/redeploy/self-reload.ts exits RESTART_EXIT_CODE / dies with the supervisor) and,
466
- * when the operator gave a gate token, carries it under GUI_TOKEN_ENV. The token deliberately
467
- * never reaches the child's argv: /proc/<pid>/cmdline is world-readable, and the child's own
468
- * self-reload (reexecSelf, which propagates env via childEnv) would re-run a command-line secret
469
- * onto each fresh generation. Kept out of superviseDashboard so this env wiring is unit-testable
470
- * without spawning a live dashboard child (BUGS-FIXED.md's dashboard gate-token fix). */
471
- export function dashboardChildEnv(childMarkerEnv, guiToken, hostPid) {
472
- const env = { [childMarkerEnv]: String(hostPid) };
473
- if (guiToken !== undefined)
474
- env[GUI_TOKEN_ENV] = guiToken;
475
- return env;
476
- }
477
- /** The dashboard half of `run --gui`: keep a `tumwater gui` child alive across its own
478
- * self-reloads. The child is marked with DASHBOARD_CHILD_ENV naming this supervisor, so its
479
- * reload watch (src/redeploy/self-reload.ts) exits RESTART_EXIT_CODE for a fresh sibling on a
480
- * redeploy and exits on its own if this supervisor dies outright (SIGKILL, a crash) — a killed
481
- * supervisor never leaves a dashboard holding its port. Any other dashboard exit (a failed bind
482
- * prints its own message and exits) ends this loop and leaves the team running. */
483
- async function superviseDashboard(signal, guiToken, stdio = "inherit", guiArgs = []) {
484
- const { DASHBOARD_CHILD_ENV } = await import("../redeploy/self-reload.js");
485
- const { spawnGuiChild } = await import("../process/supervisor.js");
486
- const env = dashboardChildEnv(DASHBOARD_CHILD_ENV, guiToken, process.pid);
487
- for (;;) {
488
- const exit = await spawnGuiChild(signal, env, stdio, guiArgs);
489
- if (signal.aborted || exit.code !== RESTART_EXIT_CODE)
490
- return;
491
- }
492
- }
493
- /** The supervisor half of `tumwater start` (src/process/supervisor.ts): spawn the orchestrator as a
494
- * child generation and respawn it whenever it exits RESTART_EXIT_CODE after redeploying itself.
495
- * Ctrl+C reaches the child directly from the terminal, so only SIGTERM is forwarded; the
496
- * supervisor's own exit code is whatever the last generation's was. A team that goes down without
497
- * the operator asking leaves a `supervisor_exit` event behind (BUGS.md 2026-09-23): the dead
498
- * generation's stderr reached only this terminal, which nobody may be watching. */
499
- async function superviseStartCommand(root, runArgs, branchArg, forMs) {
500
- // The supervisor machinery loads lazily, like cmdStart's orchestrator import above: a command
501
- // that never boots the team never compiles it.
502
- const { RUN_WINDOW_START_ENV, teamDownEvent, spawnStartChild, superviseStart } = await import("../process/supervisor.js");
503
- const plan = guiChildPlan(runArgs);
504
- // A TUI owns the terminal, so it can only be hosted when this supervisor's own stdin and
505
- // stdout are a TTY — the same guard runTui enforces. Check before booting a team a TUI
506
- // could never drive, surfacing the actionable tuiTerminalError instead of letting runTui's
507
- // rejection sit unhandled until superviseStart settles and then crash the supervisor after
508
- // the team has (pointlessly) started.
509
- if (plan.spawnTui && (!process.stdin.isTTY || !process.stdout.isTTY)) {
510
- const { tuiTerminalError } = await import("../ui/tui/tui-input.js");
511
- fail(tuiTerminalError(Boolean(process.stdin.isTTY), Boolean(process.stdout.isTTY)));
512
- }
513
- const windowEnv = forMs !== null ? { [RUN_WINDOW_START_ENV]: String(Date.now()) } : {};
514
- const controller = new AbortController();
515
- // The dashboard lives beside the orchestrator generations rather than inside one: it must
516
- // outlive every generation a redeploy swaps out. Its own abort stops the current dashboard
517
- // child, which spawnGuiChild's abort wiring SIGTERMs.
518
- const guiController = new AbortController();
519
- let stopping = false;
520
- process.on("SIGINT", () => {
521
- stopping = true; // The child got the same SIGINT from the terminal and stops on its own.
522
- });
523
- process.on("SIGTERM", () => {
524
- stopping = true;
525
- controller.abort(); // Not delivered to the child by the kernel — forward it.
526
- guiController.abort();
527
- });
528
- // The TUI is hosted in the supervisor process, beside the orchestrator generations it must
529
- // outlive. Quitting it (q or Ctrl+C handled by ink's key handler) is the run's Ctrl+C:
530
- // runTui resolves and the .then sets `stopping` and aborts the controller, so superviseStart
531
- // drains in-flight runs and the team shuts down exactly as Ctrl+C on a plain `run` does;
532
- // the team-down teardown (dashboard abort, onDone, terminal restore) is unchanged.
533
- // reloadDisabled keeps the hosted TUI off its self-reload watch: the team's own first
534
- // self-redeploy dist swap must not resolve runTui (which would read as a quit and abort
535
- // this run's controller) or spawn a re-exec replacement; the host supervisor survives a
536
- // redeploy by respawning its team children, and the hosted TUI — like the supervisor
537
- // itself — stays on the build it started, getting fresh code on the next run. The abort
538
- // signal is how this process ends the TUI when the run ends on its own (a `--for` window
539
- // closes, or the team stops): runTui resolves on abort and takes its quit teardown, so the
540
- // `await tui` below cannot hang on a TUI nothing else would ever close.
541
- const tuiController = new AbortController();
542
- const tui = plan.spawnTui
543
- ? (await import("../ui/tui/tui.js")).runTui(root, { abortSignal: tuiController.signal, reloadDisabled: true }).then(() => {
544
- stopping = true; // the TUI quit = the run's Ctrl+C
545
- controller.abort();
546
- guiController.abort();
547
- })
548
- : Promise.resolve();
549
- const gui = plan.spawnGui ? superviseDashboard(guiController.signal, plan.guiToken, plan.childStdio, plan.guiArgs) : Promise.resolve();
550
- const code = await superviseStart({
551
- spawnChild: (signal) => spawnStartChild(signal, plan.orchestratorArgs, windowEnv, plan.childStdio),
552
- stopping: () => stopping,
553
- onRespawn: (generation) => {
554
- // With a hosted TUI the supervisor owns no free stdout: its own say() would paint the
555
- // restart line over the ink full-screen render and corrupt it — the same interleaving
556
- // childStdio silences for the children. The TUI renders status from on-disk state, so
557
- // the redeploy is visible there as the team coming back.
558
- if (!plan.spawnTui && !runArgs.includes("--json"))
559
- say(`\nrestarting onto the update (restart ${generation})\n`);
560
- },
561
- onCrashLoop: () => process.stderr.write("tumwater restarted itself too many times in a minute — giving up\n"),
562
- // Re-ask the startup gate for the reason: a generation that dies right after a respawn
563
- // most likely met an environment that no longer boots (the 2026-09-22 child found
564
- // tumwater.json gone). A crash loop's generations all asked for restarts, so the gate
565
- // has nothing to say about it.
566
- onTeamDown: async (down) => {
567
- const event = logEvent(root, teamDownEvent(down, down.crashLoop ? null : await runStartupProblem(root, branchArg)));
568
- notifyTeamDown(root, event);
569
- },
570
- // The end-of-supervision build repair (see build-stage.repairInterruptedSwap): a swap
571
- // killed between its two renames leaves dist/ missing, and this process is the only one
572
- // still able to rename the preserved old build back before it exits — the next `tumwater
573
- // run` would otherwise fail to load its own dist/src/cli.js. repairInterruptedSwap is a
574
- // static import, so its code is in memory before any swap can move dist/ away: an import
575
- // at call time would fail ERR_MODULE_NOT_FOUND exactly when it is needed. The repair
576
- // reports its own outcome, so it can never change the exit code.
577
- onDone: () => repairInterruptedSwap(root),
578
- }, controller.signal);
579
- // The orchestrator is gone; take the dashboard and the TUI with it so a stopped team never
580
- // leaves a dashboard holding its port or a TUI holding the terminal. abort() SIGTERMs the
581
- // current gui child; await lets the spawn loop observe the abort before this process exits.
582
- // On a plain run the user's own Ctrl+C already resolved the TUI; on a windowed (--for) run
583
- // the window just closed with the TUI still up, so it is aborted here and its teardown /
584
- // terminal restore settle before this process exits.
585
- tuiController.abort();
586
- await tui; // let the TUI's promise settle / terminal restore
587
- guiController.abort();
588
- await gui;
589
- process.exit(code);
590
- }
@@ -4,7 +4,7 @@
4
4
  import { customRoleNames, knownRoleIds, loadConfig } from "../config/config.js";
5
5
  import { BACKEND_IDS, BACKEND_KEYS, CLAUDE_AGENT_KEYS, CODEX_BACKEND_KEYS, MODEL_TIERS, ROLE_ENTRY_KEYS, THINKING_LEVELS, TOP_LEVEL_KEYS } from "../config/config-schema.js";
6
6
  import { DOTTED_MAP_KEYS, DOTTED_SECTION_FIELDS } from "../config/config-keys.js";
7
- import { compareCodeUnits } from "../collections.js";
7
+ import { compareCodeUnits, distinctOrdered } from "../collections.js";
8
8
  import { HISTORY_MAX_RUNS, HISTORY_RESULT_FLAG_NAMES, HISTORY_SUMMARY_GROUPS, readRunRows } from "../history/history-data.js";
9
9
  import { templateIds } from "../init/init-templates.js";
10
10
  import { DOCTOR_CHECK_NAMES } from "../doctor/doctor-check-names.js";
@@ -77,7 +77,7 @@ function usageWords(firstLine, command) {
77
77
  }
78
78
  export function completionSpec(help = HELP) {
79
79
  const stanzas = helpStanzas(help);
80
- const commands = [...new Set(stanzas.map((s) => s.command))];
80
+ const commands = distinctOrdered(stanzas.map((s) => s.command));
81
81
  const subcommands = {};
82
82
  const flags = {};
83
83
  for (const command of commands) {
@@ -140,7 +140,7 @@ export function completionConfigKeys(root) {
140
140
  ...Object.entries(DOTTED_SECTION_FIELDS).flatMap(([section, fields]) => fields.map((field) => `${section}.${field}`)),
141
141
  ...roleIds.flatMap((id) => ROLE_ENTRY_KEYS.map((field) => `roles.${id}.${field}`)),
142
142
  ];
143
- return [...new Set(keys)];
143
+ return distinctOrdered(keys);
144
144
  }
145
145
  /** The value vocabulary `config set <key> <value>` completes for one key, or [] when the
146
146
  * field is free-form. The discrete-valued keys source the SAME closed constants the config
@@ -52,7 +52,18 @@ export async function followView(frameText, seams) {
52
52
  const timer = setInterval(() => {
53
53
  if (painting)
54
54
  return;
55
- const result = frameText();
55
+ let result;
56
+ try {
57
+ result = frameText();
58
+ }
59
+ catch {
60
+ // A sync frame (status, backlog, report) may throw the same transient failure as an
61
+ // async one — a snapshot lift, a momentarily unreadable file — and a throw out of a
62
+ // setInterval callback is an uncaught exception that kills the whole interactive
63
+ // follow, while the identical failure a tick later (async path) is skipped. Skip this
64
+ // repaint and retry on the next tick, the same policy the async path applies.
65
+ return;
66
+ }
56
67
  if (result instanceof Promise) {
57
68
  painting = true;
58
69
  void result
@@ -63,7 +74,14 @@ export async function followView(frameText, seams) {
63
74
  });
64
75
  return;
65
76
  }
66
- paint(result);
77
+ // A sync frame's paint is guarded like its read above: a paint that throws (a broken
78
+ // stdout mid-follow) is skipped and retried, never fatal.
79
+ try {
80
+ paint(result);
81
+ }
82
+ catch {
83
+ // Transient — retry on the next tick, like the async path.
84
+ }
67
85
  }, interval);
68
86
  startParentDeathWatch(() => {
69
87
  clearInterval(timer);
@@ -4,6 +4,7 @@
4
4
  * <command>` from it by splitting the usage block on its ` tumwater <command>` lines, so a
5
5
  * topic can never drift out of sync with the full listing. */
6
6
  import { suggestClosest } from "../text/suggest.js";
7
+ import { distinctOrdered } from "../collections.js";
7
8
  /** The role-grouped summary's JSON keys, quoted verbatim in `history --summary --json` help
8
9
  * (its `--by model` twin is the hardcoded `{ models, total }` beside it). */
9
10
  const SUMMARY_JSON_KEYS = "{ roles, total }";
@@ -526,12 +527,6 @@ Usage:
526
527
  written to roles.<id>.instances for the base id
527
528
  tumwater role <id> --clear-instances
528
529
  Clear that role's instance count (back to 1)
529
- tumwater role <id> --review-after-merge
530
- Review that role's commits after they merge, written
531
- to review.afterMerge (the base id for an instance)
532
- tumwater role <id> --clear-review-after-merge
533
- Review it before merge again, removing it from
534
- review.afterMerge
535
530
  tumwater prompt <text...> Send a message to the director's inbox
536
531
  tumwater prompt --file <path> Send a message read from a file ("-" reads stdin)
537
532
  tumwater prompt --role <id> <text...>
@@ -646,7 +641,7 @@ sleep while there is nothing to do and wake when main moves. Everything is local
646
641
  * stanza; the distance mechanics are text/suggest.ts's suggestClosest, shared with the config-key
647
642
  * suggestion. */
648
643
  export function suggestCommand(input, help = HELP) {
649
- return suggestClosest(input, [...new Set(helpStanzas(help).map((s) => s.command))]);
644
+ return suggestClosest(input, distinctOrdered(helpStanzas(help).map((s) => s.command)));
650
645
  }
651
646
  /** Split HELP's usage block into per-command stanzas. A stanza starts at a two-space-indented
652
647
  * `tumwater <command>` line; any further indented line continues it, and a column-0 line (the