tumwater 0.4.2 → 0.4.3

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 (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { enabledRoleIds, loadConfigSafe } from "../config/config.js";
4
4
  import { configForRole, resolverConfig, resolvedBackendConfig, reviewConfig } from "../config/config-views.js";
5
5
  import { fallbackPair } from "../config/config-fallbacks.js";
6
- import { seamAgent } from "../config/model-selector.js";
6
+ import { seamAgent } from "../config/config-model-selector.js";
7
7
  import { cachedByStat } from "../files/stat-cache.js";
8
8
  import { isJsonObject } from "../files/json-object.js";
9
9
  import { readJsonFile } from "../files/json-files.js";
@@ -107,40 +107,54 @@ export function seamContextWindow(seam, projectRoot, modelsPath = piModelsPath()
107
107
  const backend = seamAgent(seam);
108
108
  switch (backend) {
109
109
  case "pi":
110
- let { provider, model } = seam;
111
- if (!provider || !model) {
112
- const piDefault = readPiDefaultModel(piSettingsPath(modelsPath), projectRoot);
113
- provider = piDefault?.provider;
114
- model = piDefault?.model;
115
- }
116
- return declaredWindow(resolveModelDef(readPiProviders(modelsPath), provider, model)?.contextWindow);
110
+ return piSeamContextWindow(seam, projectRoot, modelsPath);
117
111
  case "opencode":
118
- // The models.dev cache (readOpencodeCache, an empty cache when the file is missing) holds
119
- // the window part 6a/7 parsed from `limit.context`, keyed by the gateway-qualified id the
120
- // run actually invokes (opencodeModelKey) — the same key and entry the free-ness check
121
- // prices by, so a seam's window never disagrees with the model it runs.
122
- if (seam.model === undefined)
123
- return null;
124
- return declaredWindow(readOpencodeCache(projectRoot).models[opencodeModelKey(seam.model)]?.context);
112
+ return opencodeSeamContextWindow(seam, projectRoot);
125
113
  case "codex":
126
- // The model catalog (codex-catalog.ts) holds the window part 8/9 parsed from
127
- // `context_window × effective_context_window_percent / 100`, read from the team's codex
128
- // home (backends.codex.configDir) at this project root — the same home the runs use. An
129
- // unknown model, an absent home or a missing catalog reads null (declaredWindow's safe
130
- // answer for an unknown window).
131
- if (seam.model === undefined)
132
- return null;
133
- {
134
- const { config } = loadConfigSafe(projectRoot);
135
- const home = codexHome(resolvedBackendConfig(config ?? {}, "codex"));
136
- if (home === undefined)
137
- return null;
138
- return declaredWindow(readCodexCatalog(home).get(seam.model)?.contextWindow);
139
- }
114
+ return codexSeamContextWindow(seam, projectRoot);
140
115
  default:
141
116
  return null;
142
117
  }
143
118
  }
119
+ /** The window a pi seam declares, or null when it cannot be known: reads the pair's
120
+ * `contextWindow` from pi's models.json, running on pi's saved default
121
+ * (readPiDefaultModel) when the seam names no provider/model — exactly the model pi will
122
+ * start on. An unresolvable pair or a missing/unusable window reads null. */
123
+ function piSeamContextWindow(seam, projectRoot, modelsPath) {
124
+ let { provider, model } = seam;
125
+ if (!provider || !model) {
126
+ const piDefault = readPiDefaultModel(piSettingsPath(modelsPath), projectRoot);
127
+ provider = piDefault?.provider;
128
+ model = piDefault?.model;
129
+ }
130
+ return declaredWindow(resolveModelDef(readPiProviders(modelsPath), provider, model)?.contextWindow);
131
+ }
132
+ /** The window an opencode seam declares, or null when it cannot be known. The models.dev cache
133
+ * (readOpencodeCache, an empty cache when the file is missing) holds the window part 6a/7
134
+ * parsed from `limit.context`, keyed by the gateway-qualified id the run actually invokes
135
+ * (opencodeModelKey) — the same key and entry the free-ness check prices by, so a seam's
136
+ * window never disagrees with the model it runs. A missing cache, an unknown model or an
137
+ * unusable `context` reads null. */
138
+ function opencodeSeamContextWindow(seam, projectRoot) {
139
+ if (seam.model === undefined)
140
+ return null;
141
+ return declaredWindow(readOpencodeCache(projectRoot).models[opencodeModelKey(seam.model)]?.context);
142
+ }
143
+ /** The window a codex seam declares, or null when it cannot be known. The model catalog
144
+ * (codex-catalog.ts) holds the window part 8/9 parsed from
145
+ * `context_window × effective_context_window_percent / 100`, read from the team's codex home
146
+ * (backends.codex.configDir) at this project root — the same home the runs use. An unknown
147
+ * model, an absent home or a missing catalog reads null (declaredWindow's safe answer for an
148
+ * unknown window). */
149
+ function codexSeamContextWindow(seam, projectRoot) {
150
+ if (seam.model === undefined)
151
+ return null;
152
+ const { config } = loadConfigSafe(projectRoot);
153
+ const home = codexHome(resolvedBackendConfig(config ?? {}, "codex"));
154
+ if (home === undefined)
155
+ return null;
156
+ return declaredWindow(readCodexCatalog(home).get(seam.model)?.contextWindow);
157
+ }
144
158
  /** The resolved primary seam's declared context window for a role — `configForRole` resolves
145
159
  * the role's seam (the view the role page, status table/drawer and run history all print), then
146
160
  * `seamContextWindow` names the window that seam declares, or null when it cannot be known. The
@@ -50,37 +50,60 @@ export function readPiDefaultModel(settingsPath, projectRoot) {
50
50
  return cachedByStat(settingsCache, settingsPath, // Keyed by path so distinct roots and test files never collide.
51
51
  settingsPath, () => readPiDefaultModelUncached(settingsPath, projectRoot), (defaultModel) => ({ ...defaultModel }));
52
52
  }
53
- function readPiDefaultModelUncached(settingsPath, projectRoot) {
54
- // readJsonFile is the shared "missing or torn reads as no data" policy for a JSON object
55
- // file; the nested shapes get the same isJsonObject guard.
56
- const doc = readJsonFile(settingsPath);
57
- if (!doc)
58
- return null;
53
+ /** The saved default pair, or null when either half is missing or empty — pi uses a saved
54
+ * default only as a pair. */
55
+ function savedDefaultPair(doc) {
59
56
  const { defaultProvider, defaultModel } = doc;
60
57
  if (typeof defaultProvider !== "string" || defaultProvider === "")
61
58
  return null;
62
59
  if (typeof defaultModel !== "string" || defaultModel === "")
63
60
  return null;
64
- if (Array.isArray(doc.enabledModels) && doc.enabledModels.length > 0)
65
- return null;
61
+ return { provider: defaultProvider, model: defaultModel };
62
+ }
63
+ /** Whether pi starts on the first scoped model instead of the saved default — a pattern
64
+ * tumwater does not resolve, so the reader shows nothing. */
65
+ function startsWithScopedModels(doc) {
66
+ return Array.isArray(doc.enabledModels) && doc.enabledModels.length > 0;
67
+ }
68
+ /** Whether the project's own `.pi/settings.json` names any default key, which makes pi's final
69
+ * choice depend on whether it trusts the project — unknowable to tumwater, so show nothing. */
70
+ function projectOverridesSavedDefault(projectRoot) {
66
71
  // A project-local override that names any default key means pi's final choice depends on
67
72
  // whether it trusts the project — unknowable to tumwater, so show nothing.
68
73
  const proj = readJsonFile(path.join(projectRoot, ".pi", "settings.json"));
69
- if (proj) {
70
- for (const key of DEFAULT_KEYS) {
71
- if (proj[key] !== undefined)
72
- return null;
73
- }
74
+ if (!proj)
75
+ return false;
76
+ for (const key of DEFAULT_KEYS) {
77
+ if (proj[key] !== undefined)
78
+ return true;
74
79
  }
75
- const key = `${defaultProvider}/${defaultModel}`;
80
+ return false;
81
+ }
82
+ /** pi's thinking for the pair: `modelThinkingLevels[key]` first, else `defaultThinkingLevel`. */
83
+ function savedThinkingLevel(doc, key) {
76
84
  const levels = doc.modelThinkingLevels;
77
85
  const level = isJsonObject(levels) ? levels[key] : undefined;
78
- let thinking;
79
86
  if (typeof level === "string")
80
- thinking = level;
81
- else if (typeof doc.defaultThinkingLevel === "string")
82
- thinking = doc.defaultThinkingLevel;
87
+ return level;
88
+ if (typeof doc.defaultThinkingLevel === "string")
89
+ return doc.defaultThinkingLevel;
90
+ return undefined;
91
+ }
92
+ function readPiDefaultModelUncached(settingsPath, projectRoot) {
93
+ // readJsonFile is the shared "missing or torn reads as no data" policy for a JSON object
94
+ // file; the nested shapes get the same isJsonObject guard.
95
+ const doc = readJsonFile(settingsPath);
96
+ if (!doc)
97
+ return null;
98
+ const pair = savedDefaultPair(doc);
99
+ if (!pair)
100
+ return null;
101
+ if (startsWithScopedModels(doc))
102
+ return null;
103
+ if (projectOverridesSavedDefault(projectRoot))
104
+ return null;
105
+ const thinking = savedThinkingLevel(doc, `${pair.provider}/${pair.model}`);
83
106
  return thinking !== undefined
84
- ? { provider: defaultProvider, model: defaultModel, thinking }
85
- : { provider: defaultProvider, model: defaultModel };
107
+ ? { provider: pair.provider, model: pair.model, thinking }
108
+ : { provider: pair.provider, model: pair.model };
86
109
  }
@@ -251,6 +251,17 @@ export class PiStreamParser {
251
251
  * treat a `rejected` window as the transient rate limit it is (with the resetsAt-derived
252
252
  * wait the translator attached). */
253
253
  applyRateLimit(event) {
254
+ const reading = this.readRateWindow(event);
255
+ this.upsertRateWindow(reading);
256
+ if (reading.status === "rejected") {
257
+ this.transientRateLimit = true;
258
+ this.setRetryAfterSeconds(event.retryAfterSeconds);
259
+ }
260
+ }
261
+ /** Build one `RateWindow` reading from a canonical `rate_limit` event: derive the `type`
262
+ * (falling back to `unknown` when pi names none), default the `status` to "" when absent,
263
+ * and copy through the four optional fields that the event carries. */
264
+ readRateWindow(event) {
254
265
  const type = typeof event.rateLimitType === "string" && event.rateLimitType ? event.rateLimitType : "unknown";
255
266
  const reading = { type, status: typeof event.status === "string" ? event.status : "" };
256
267
  if (typeof event.resetsAt === "number" && Number.isFinite(event.resetsAt))
@@ -261,9 +272,16 @@ export class PiStreamParser {
261
272
  reading.overageStatus = event.overageStatus;
262
273
  if (event.isUsingOverage === true)
263
274
  reading.isUsingOverage = true;
264
- const existing = this.rateWindows.findIndex((w) => w.type === type);
275
+ return reading;
276
+ }
277
+ /** Insert one reading, keeping the newest per type: replace the window whose `type` matches
278
+ * and, when the reading is status-less (a non-binding window's utilization), carry the last
279
+ * known status of the window it replaces; note overage so a caller can see a rate limit hit
280
+ * even before it is rejected. */
281
+ upsertRateWindow(reading) {
282
+ const existing = this.rateWindows.findIndex((w) => w.type === reading.type);
265
283
  // A reading without a status (a non-binding window's utilization) keeps the last known one.
266
- if (reading.status === "" && existing >= 0)
284
+ if (reading.status === "")
267
285
  reading.status = this.rateWindows[existing]?.status ?? "";
268
286
  if (existing >= 0)
269
287
  this.rateWindows[existing] = reading;
@@ -271,10 +289,6 @@ export class PiStreamParser {
271
289
  this.rateWindows.push(reading);
272
290
  if (reading.isUsingOverage === true)
273
291
  this.usedOverage = true;
274
- if (reading.status === "rejected") {
275
- this.transientRateLimit = true;
276
- this.setRetryAfterSeconds(event.retryAfterSeconds);
277
- }
278
292
  }
279
293
  /** Fold a provider Retry-After hint into `retryAfterSeconds`: ignore a non-number or
280
294
  * non-finite value, and clamp a negative or fractional one to whole seconds. */
@@ -494,6 +508,12 @@ export class PiStreamParser {
494
508
  }
495
509
  applyAssistantMessage(msg) {
496
510
  const text = messageText(msg);
511
+ this.applyAssistantReply(msg, text);
512
+ this.applyAssistantRefusal(text);
513
+ this.applyAssistantUsage(msg);
514
+ this.applyAssistantStop(msg);
515
+ }
516
+ applyAssistantReply(msg, text) {
497
517
  this.finalMessageContentless = !messageContent(msg).some((c) => c.type === "toolCall" ||
498
518
  (c.type === "text" && typeof c.text === "string" && Boolean(c.text.trim())));
499
519
  if (!this.finalMessageContentless)
@@ -502,6 +522,8 @@ export class PiStreamParser {
502
522
  this.finalText = text;
503
523
  if (isNothingToDo(text))
504
524
  this.declaredNothingToDo = true;
525
+ }
526
+ applyAssistantRefusal(text) {
505
527
  // The refusal comes only from an anchored `TUMWATER_REFUSED: <reason>` line: a bare
506
528
  // sentinel and a mid-sentence mention are not refusals, and a negating reason ("none",
507
529
  // "(n/a …)") clears one — BUGS.md 2026-09-23, four runs ended ordinary replies with
@@ -521,6 +543,8 @@ export class PiStreamParser {
521
543
  }
522
544
  if (hasVerdictLine(text))
523
545
  this.verdictText = text;
546
+ }
547
+ applyAssistantUsage(msg) {
524
548
  this.turns += 1;
525
549
  this.outputTokens += usageNumber(msg.usage?.output);
526
550
  const promptTokens = usageNumber(msg.usage?.input) + usageNumber(msg.usage?.cacheRead) + usageNumber(msg.usage?.cacheWrite);
@@ -536,6 +560,8 @@ export class PiStreamParser {
536
560
  }
537
561
  this.peakContextTokens = Math.max(this.peakContextTokens, usageNumber(msg.usage?.totalTokens));
538
562
  this.costUsd += usageNumber(msg.usage?.cost?.total);
563
+ }
564
+ applyAssistantStop(msg) {
539
565
  this.stopReason = msg.stopReason;
540
566
  if (msg.errorMessage)
541
567
  this.errorMessage = msg.errorMessage;
@@ -1,10 +1,10 @@
1
1
  /** The run's watchdog clocks, split out of pi.ts's runPi (2026-10-04, organize): the run
2
2
  * deadline and the quiet watchdog (quiet-kill plus the stalled-tool-call warning) — the
3
3
  * timing policy of one pi run, which changes for different reasons than the spawn, stream,
4
- * and result-distillation wiring that drives it (src/agent/run-agent.ts keeps those). The
4
+ * and result-distillation wiring that drives it (src/agent/agent-run.ts keeps those). The
5
5
  * factory owns all the mutable state the two timers kept in runPi's closure (the progress
6
6
  * window, the zero-byte grace, the warned-call set) and exposes the flags the run's close
7
- * handler reads back; run-agent.ts feeds the parser live through the deps accessors, so no
7
+ * handler reads back; agent-run.ts feeds the parser live through the deps accessors, so no
8
8
  * notification path is needed when events land. */
9
9
  import { bufferedCommandStallMs, callBuffersOutput } from "./command-shape.js";
10
10
  /** The run-deadline clock: kills the process tree when it fires, after giving a
@@ -41,7 +41,9 @@ function startDeadlineWatch(state, deps) {
41
41
  // discard (BUGS.md 2026-10-26).
42
42
  const progressGraceMs = 250;
43
43
  let extended = false;
44
- let graceArmed = false; // one grace per deadline, so a genuinely stalled run is never granted two
44
+ let graceArmed = false; // one grace for the whole run, so a genuinely stalled run is never granted two
45
+ // (see the deadline comment below: a run reaches this branch at most once, so per-run and
46
+ // per-deadline scope grant the same single short re-check)
45
47
  state.timeoutBudgetMs = deps.runTimeoutMs;
46
48
  const fireDeadline = () => {
47
49
  if (deps.extendOnProgress && !extended && deps.progressCount() > checkpointProgress) {
@@ -65,8 +67,13 @@ function startDeadlineWatch(state, deps) {
65
67
  // count and a progressing run looks stalled (BUGS.md 2026-10-26). A run that has begun
66
68
  // producing structured progress therefore gets ONE short grace to drain queued output and
67
69
  // re-prove itself; only a run still flat after the grace — or one that never progressed —
68
- // keeps the discard path. The grace is one-shot, so a genuinely stalled run is not
69
- // granted a second chance.
70
+ // keeps the discard path. The grace is one short beat, armed once for the whole run:
71
+ // graceArmed is a closure local of startDeadlineWatch, one per run, and the branch is
72
+ // reached at most once because a gate run's earlier base deadline is handled by its
73
+ // one-time extension (the block above) while an authoring run has a single deadline — so
74
+ // "once per run" and "once per deadline" grant the very same single re-check, and a
75
+ // progressing gate run is never left without one while a genuinely stalled run is never
76
+ // granted two.
70
77
  if (!graceArmed && deps.progressCount() > 0) {
71
78
  graceArmed = true;
72
79
  state.timeout = setTimeout(fireDeadline, progressGraceMs);
package/dist/src/pi/pi.js CHANGED
@@ -1,9 +1,9 @@
1
- /** The pi-side facade over the backend-neutral agent runner (src/agent/run-agent.ts) and the
1
+ /** The pi-side facade over the backend-neutral agent runner (src/agent/agent-run.ts) and the
2
2
  * pi adapter (src/pi/pi-backend.ts). `runPi` is a thin alias for `runAgent` so every existing
3
3
  * call site, fake, and test keeps importing the historical name; the stderr-match constants and
4
4
  * the run-kind/backend-failure types stay here for the same reason. The seam itself lives in
5
5
  * src/agent/agent-backend.ts, and `hasResumableSession` now lives beside the ledger it reads
6
6
  * (src/agent/session-ledger.ts) and is re-exported here so existing importers are unchanged. */
7
- export { runAgent, runPi } from "../agent/run-agent.js";
7
+ export { runAgent, runPi } from "../agent/agent-run.js";
8
8
  export { MODEL_FALLBACK_CLONE, TRANSIENT_PI_CRASH } from "./pi-backend.js";
9
9
  export { hasResumableSession } from "../agent/session-ledger.js";
@@ -130,32 +130,22 @@ function isCandidate(msg, newerTokens, protectedTokens) {
130
130
  return false;
131
131
  return true;
132
132
  }
133
- /**
134
- * Plan a shake pass: the `context_edit` replacements that reclaim the most space from old
135
- * bulky tool results. Empty unless the run is at or above the first pass's percent, something
136
- * outside the newest protected slice is reclaimable, and the pass clears the reclaim floor
137
- * together — all three from `shakeLimits` for the usage's window. Deterministic and
138
- * filesystem-free except the injected
139
- * `writeFullOutput`, which is only called for a bash result pi has not already persisted.
140
- */
141
- export function shakePlan(messages, usage, writeFullOutput) {
142
- const percent = percentOf(usage);
143
- const limits = shakeLimits(usage.contextWindow);
144
- if (percent === null || percent < limits.percent)
145
- return EMPTY_PLAN;
146
- // Tokens newer than each message, so a candidate is one the model has already moved past.
133
+ /** Tokens newer than each message, so a candidate is one the model has already moved past. */
134
+ function newerTokenCounts(messages) {
147
135
  const newerTokens = new Array(messages.length).fill(0);
148
136
  let running = 0;
149
137
  for (let i = messages.length - 1; i >= 0; i -= 1) {
150
138
  newerTokens[i] = running;
151
139
  running += estimateTokens(messages[i]?.text ?? "");
152
140
  }
153
- const candidates = messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0, limits.protectedTokens));
154
- // The pointer is far shorter than the result it replaces, so the full text's tokens are a
155
- // safe upper bound on the reclaim; bail before touching the writer when even that is short.
156
- const upperBound = candidates.reduce((sum, msg) => sum + estimateTokens(msg.text), 0);
157
- if (upperBound < limits.minReclaimTokens)
158
- return EMPTY_PLAN;
141
+ return newerTokens;
142
+ }
143
+ /** The messages the model has already moved past and can reclaim space from. */
144
+ function reclaimableCandidates(messages, newerTokens, protectedTokens) {
145
+ return messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0, protectedTokens));
146
+ }
147
+ /** The edits that reclaim space from one candidate list, and how much they reclaim together. */
148
+ function shakeEdits(candidates, writeFullOutput) {
159
149
  const edits = [];
160
150
  let reclaimedTokens = 0;
161
151
  for (const msg of candidates) {
@@ -171,6 +161,28 @@ export function shakePlan(messages, usage, writeFullOutput) {
171
161
  edits.push({ targetId: msg.entryId, toolName: msg.toolName, replacement, reclaimedTokens: reclaimed });
172
162
  reclaimedTokens += reclaimed;
173
163
  }
164
+ return { edits, reclaimedTokens };
165
+ }
166
+ /**
167
+ * Plan a shake pass: the `context_edit` replacements that reclaim the most space from old
168
+ * bulky tool results. Empty unless the run is at or above the first pass's percent, something
169
+ * outside the newest protected slice is reclaimable, and the pass clears the reclaim floor
170
+ * together — all three from `shakeLimits` for the usage's window. Deterministic and
171
+ * filesystem-free except the injected
172
+ * `writeFullOutput`, which is only called for a bash result pi has not already persisted.
173
+ */
174
+ export function shakePlan(messages, usage, writeFullOutput) {
175
+ const percent = percentOf(usage);
176
+ const limits = shakeLimits(usage.contextWindow);
177
+ if (percent === null || percent < limits.percent)
178
+ return EMPTY_PLAN;
179
+ const candidates = reclaimableCandidates(messages, newerTokenCounts(messages), limits.protectedTokens);
180
+ // The pointer is far shorter than the result it replaces, so the full text's tokens are a
181
+ // safe upper bound on the reclaim; bail before touching the writer when even that is short.
182
+ const upperBound = candidates.reduce((sum, msg) => sum + estimateTokens(msg.text), 0);
183
+ if (upperBound < limits.minReclaimTokens)
184
+ return EMPTY_PLAN;
185
+ const { edits, reclaimedTokens } = shakeEdits(candidates, writeFullOutput);
174
186
  if (reclaimedTokens < limits.minReclaimTokens)
175
187
  return EMPTY_PLAN;
176
188
  return { edits, reclaimedTokens, elidedCount: edits.length };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Bundled pi extension: refuse a `bash` call that kills processes by pattern, next to the write
3
+ * guard (src/pi-extension/write-guard.ts). Loaded on every pi run via `-e <this file>` from
4
+ * src/pi/pi-args.ts.
5
+ *
6
+ * The run prompt already forbids pattern kills (KILL_BY_PID_RULE, src/prompt/prompt.ts): "Never
7
+ * kill processes by name or pattern; stop only a process you started, by its PID (`kill $!`)".
8
+ * But that rule is words, and BUGS.md 2026-10-28 records one agent answering a timed-out test run
9
+ * with `ps aux | grep test-runner | xargs kill` and a `for p in $(ps …) kill` loop, which reached
10
+ * every process-table row the pattern matched — the team's own in-flight checks included — and
11
+ * took them down mid-run. The process table is shared, so none of those kills is provably the
12
+ * runner's own; `kill $!` and the run's exit sweep are the sanctioned way to stop what it started.
13
+ *
14
+ * This is a tool-layer net beside the prompt rule, not a replacement for it. The same `tool_call`
15
+ * event pi fires for `edit`/`write` fires for `bash`; blocking a call when a handler returns
16
+ * `{ block: true, reason }` shows the model the reason as the tool's error. The detection is a
17
+ * heuristic for the common by-pattern shapes — `pkill`/`killall` as a command word, and `ps` or
18
+ * `pgrep` output fed into `kill` via `xargs` or a `$(ps|pgrep)` substitution — and fails open like
19
+ * the write guard does: a missed shape under load still leaves the prompt rule and the run's sweep,
20
+ * and an unexpected failure here lets the call through rather than stopping every bash command of
21
+ * the run (a broken guard cannot block the team's own checks).
22
+ */
23
+ /** The refusal shown as the tool's error, naming the safe ways to stop a run's own process. */
24
+ export const PATTERN_KILL_REFUSAL = "kill by pattern is refused: `pkill`/`killall` and `ps`/`pgrep` output fed into `kill` reach every " +
25
+ "process on the host the pattern matches, the team's own checks included. Stop only a process you " +
26
+ "started, by its PID: `kill $!` right after backgrounding it — the run's own processes are reaped " +
27
+ "when the run ends.";
28
+ /** A `pkill` or `killall` spelled as a command word, skipping a line that only mentions them
29
+ * (built after `echo`/`grep`/`man` etc.). Preceded by a command boundary so `echo pkill x` and a
30
+ * quoted mention pass while `; pkill x` and `| killall x` are caught. */
31
+ const DIRECT_PATTERN_KILL = /(?:^|[;&|(\n])\s*(?:pkill|killall)(?:\s|$)/;
32
+ /** `ps` or `pgrep` that reads the process table. */
33
+ const READ_PROCESS_TABLE = /\b(?:ps|pgrep)\b/;
34
+ /** That table piped into `kill` through `xargs` (the incident's `ps … | … | xargs -r kill`). */
35
+ const XARGS_KILL = /\bxargs\b[^\n;]*\bkill\b/;
36
+ /** A `$(ps …)` / `$(pgrep …)` substitution that feeds ids to a `kill` (`kill -9 $(pgrep -f npm)`,
37
+ * a `for p in $(ps …)` loop whose body kills). */
38
+ const TABLE_SUBSTITUTION = /\$\(\s*(?:ps|pgrep)\b/;
39
+ /** The refusal for one `bash` command, or null to let it run. Exported for tests. */
40
+ export function patternKillRefusal(command) {
41
+ if (typeof command !== "string" || command.trim() === "")
42
+ return null;
43
+ if (DIRECT_PATTERN_KILL.test(command))
44
+ return PATTERN_KILL_REFUSAL;
45
+ const feedsTable = READ_PROCESS_TABLE.test(command);
46
+ const feedsKill = XARGS_KILL.test(command) || (TABLE_SUBSTITUTION.test(command) && /\bkill\b/.test(command));
47
+ if (feedsTable && feedsKill)
48
+ return PATTERN_KILL_REFUSAL;
49
+ return null;
50
+ }
51
+ /** The refusal for one `tool_call` event, or undefined to let it run. Exported for tests. */
52
+ export function guardKillToolCall(event) {
53
+ if (event.toolName !== "bash")
54
+ return undefined;
55
+ const input = event.input;
56
+ if (typeof input !== "object" || input === null)
57
+ return undefined;
58
+ try {
59
+ const reason = patternKillRefusal(input.command);
60
+ return reason === null ? undefined : { block: true, reason };
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /** The pi extension entry point. */
67
+ export default function killGuard(pi) {
68
+ pi.on("tool_call", guardKillToolCall);
69
+ }
@@ -122,6 +122,13 @@ export function runScriptGroup(cmd, args, opts) {
122
122
  env: withoutLaunchServicesCheckIn(process.env),
123
123
  });
124
124
  let settled = false;
125
+ let aborted = false;
126
+ // Abort-stop timers, the mirror of armGroupDeadline's — armed only while an abort fires
127
+ // before settle (never at spawn), SIGTERM first then SIGKILL after killGraceMs for a
128
+ // survivor, settling as soon as the whole group is gone — cleared on finish, the caller's
129
+ // single settle path.
130
+ let abortPoll;
131
+ let abortKillTimer;
125
132
  // Captured output is capped at maxBuffer per stream (further chunks are dropped —
126
133
  // classification reads the tail, so a chatty script can neither wedge the check nor balloon
127
134
  // memory). The overflow is deliberately ignored here: truncated output still classifies.
@@ -134,11 +141,34 @@ export function runScriptGroup(cmd, args, opts) {
134
141
  killGraceMs: opts.killGraceMs,
135
142
  onTimedOut: () => finish({ signal: "SIGTERM" }),
136
143
  });
144
+ function stopForAbort() {
145
+ if (settled)
146
+ return;
147
+ aborted = true;
148
+ // The harness's own stop, signalled the way a timeout stops the tree: SIGTERM the whole
149
+ // group (grandchild included), then SIGKILL anything still alive after the grace. The
150
+ // run settles when the group is gone, so the caller never sees a live tree behind it.
151
+ signalTree(child, "SIGTERM");
152
+ abortPoll = setInterval(() => {
153
+ if (!groupAlive(child.pid))
154
+ finish({ aborted: true });
155
+ }, GROUP_POLL_MS);
156
+ abortKillTimer = setTimeout(() => {
157
+ signalTree(child, "SIGKILL");
158
+ finish({ aborted: true });
159
+ }, opts.killGraceMs);
160
+ }
137
161
  function finish(result) {
138
162
  if (settled)
139
163
  return;
140
164
  settled = true;
141
165
  deadline.dispose();
166
+ if (abortPoll)
167
+ clearInterval(abortPoll);
168
+ if (abortKillTimer)
169
+ clearTimeout(abortKillTimer);
170
+ if (opts.signal)
171
+ opts.signal.removeEventListener("abort", stopForAbort);
142
172
  const timedOut = deadline.fired();
143
173
  if (timedOut) {
144
174
  // Nothing more is read once the deadline has fired: release the handles so a pipe
@@ -148,16 +178,30 @@ export function runScriptGroup(cmd, args, opts) {
148
178
  const run = { spawnedAt, settledAt: Date.now(), timeoutMs: opts.timeoutMs };
149
179
  if (timedOut)
150
180
  run.deadlineLateMs = deadline.lateMs();
151
- resolve({ ...result, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
181
+ resolve({ ...result, aborted, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
152
182
  }
183
+ // An abort signal stops the tree mid-check — the harness's own stop, so the outcome says
184
+ // the harness aborted it, distinct from a timeout and from an external killed signal. The
185
+ // listener is removed as soon as the run settles. An already-aborted signal is deliberately
186
+ // NOT acted on at spawn: the swap happens mid-run (a user abort or a shutdown fires while
187
+ // the check is running), and a signal already aborted before the check started is caught by
188
+ // the caller's `aborted` check when the run returns — a fast failing check under a
189
+ // pre-aborted signal still reaches its own verdict first (the pre-existing shutdown-test
190
+ // behavior).
191
+ if (opts.signal)
192
+ opts.signal.addEventListener("abort", stopForAbort, { once: true });
153
193
  child.on("error", (err) => {
154
- // Before the deadline this is the spawn failing (npm missing from PATH). After it, it can
155
- // only be a teardown signal that could not be delivered: the run is a timeout either way,
156
- // settled by the group probe or the grace timer.
157
- if (!deadline.fired())
194
+ // Before a stop this is the spawn failing (npm missing from PATH). After the deadline or
195
+ // an abort it can only be a teardown signal that could not be delivered — the stop's own
196
+ // group probe or grace timer settles the run.
197
+ if (!deadline.fired() && !aborted)
158
198
  finish({ spawnError: err });
159
199
  });
160
200
  child.on("close", (code, signal) => {
201
+ // During an abort-stop the run settles on the whole group being gone (the probe or the
202
+ // grace timer), never on the leader's close alone. Otherwise the timeout rule stands.
203
+ if (aborted)
204
+ return;
161
205
  // Before the deadline the leader's close settles the run. After it, settle only once the
162
206
  // whole group is gone: something in the group may outlive the leader, and the poll
163
207
  // settles the run when it goes, or the grace timer SIGKILLs it and settles then.
@@ -3,10 +3,10 @@ import { promisify } from "node:util";
3
3
  /** Child-process plumbing: the harness's one execFile helper (execFileAsync), the pid-liveness
4
4
  * probe every place that decides whether a recorded pid still belongs to a live process shares
5
5
  * (the merge lock's stale-holder check in lock.ts, the orchestrator-alive status in
6
- * team/orchestrator-info.ts), the group-signal helpers behind every teardown, the register/abort
7
- * race helper the spawns and merge's shutdown wiring share, and the child environment
8
- * that keeps the harness's Node processes out of LaunchServices on macOS. Reading the host's
9
- * process table and the per-run TUMWATER_RUN marks and sweep built on it live in
6
+ * team/team-orchestrator-info.ts), the group-signal helpers behind every teardown, the
7
+ * register/abort race helper the spawns and merge's shutdown wiring share, and the child
8
+ * environment that keeps the harness's Node processes out of LaunchServices on macOS. Reading the
9
+ * host's process table and the per-run TUMWATER_RUN marks and sweep built on it live in
10
10
  * process-table.ts, on top of the exec helper here. */
11
11
  const execFileRaw = promisify(execFile);
12
12
  /** The output ceiling every execFile the harness runs shares (git, tsc, the build check, the
@@ -24,7 +24,7 @@ export function execFileAsync(file, args, options = {}) {
24
24
  * added after the abort event never fires, so `addEventListener` alone would leave the caller
25
25
  * waiting on a shutdown that has come and gone. A missing signal (no shutdown wiring)
26
26
  * registers nothing. Shared by spawnSupervised (supervisor.ts), runPi's child spawn
27
- * (src/agent/run-spawn.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
27
+ * (src/agent/agent-spawn.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
28
28
  * (merge-pipeline.ts).
29
29
  *
30
30
  * Returns a disposer that detaches `fn`. A `{ once: true }` listener detaches itself when the
@@ -113,7 +113,7 @@ export function terminateChild(child) {
113
113
  * reached a live process: false when nothing was left to receive it — ESRCH from a group that
114
114
  * died with its leader, the normal case once the child has exited. Never throws: a process
115
115
  * that died between the caller's decision and this call is the normal case. Shared by runPi's
116
- * teardown (src/agent/run-agent.ts) and the detached process-group runner (runScriptGroup,
116
+ * teardown (src/agent/agent-run.ts) and the detached process-group runner (runScriptGroup,
117
117
  * process-group.ts) — the same "signal the tree, not just the direct child" guarantee in one
118
118
  * home, this module. */
119
119
  export function signalTree(child, signal) {
@@ -186,8 +186,8 @@ export function captureCappedOutput(child, maxBuffer, onOverflow) {
186
186
  // ── Child environments ───────────────────────────────────────────────────────────────────
187
187
  /** The child-environment policy every spawn that hands a child the harness's environment shares:
188
188
  * start from `process.env` and apply `overrides` on top. The explicit spawn sites
189
- * (supervisor.ts, self-reload.ts, notify.ts, git-run.ts, doctor-model-checks.ts) all want exactly
190
- * this, so the spread lives here once instead of in each spawn's options. */
189
+ * (supervisor.ts, self-reload.ts, event-notify.ts, git-run.ts, doctor-model-checks.ts) all want
190
+ * exactly this, so the spread lives here once instead of in each spawn's options. */
191
191
  export function childEnv(overrides = {}) {
192
192
  return { ...process.env, ...overrides };
193
193
  }