tumwater 0.4.2 → 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 (354) 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} +27 -13
  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 +6 -27
  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 +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { extractRefusal, hasVerdictLine, isNegatedRefusal, isNothingToDo } from "../verdict/reply-contract.js";
2
- import { applyToolExecutionEvent, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
2
+ import { applyToolExecutionEvent, foldPendingReply, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
3
3
  import { describeToolCall } from "../text/phrases.js";
4
4
  import { objectOrEmpty, nonNegativeNumber, parseJsonObject, isJsonObject } from "../files/json-object.js";
5
5
  import { backendKind, isBackendFailureKind, isPermanentConfigError, TRANSIENT_BACKEND } from "./pi-failure.js";
@@ -204,6 +204,10 @@ export class PiStreamParser {
204
204
  * empty-content update right after start, and a content-free keepalive must not mask a hang.
205
205
  * Feeds runPi's stall warning; entries clear at tool_execution_end. */
206
206
  openToolCalls = [];
207
+ /** The model request pi has sent and the provider has not yet answered (see PendingReply), or
208
+ * undefined. Feeds runPi's unanswered-request warning; stamped with this parser's clock, since
209
+ * it observes the request live. */
210
+ pendingReply = undefined;
207
211
  /** Read tool calls started but not yet ended, keyed by pi's toolCallId so concurrent reads
208
212
  * attribute their end to the right start (pi runs one message's tool calls concurrently, so
209
213
  * several can be open at once and end in completion order — the same rule
@@ -251,6 +255,17 @@ export class PiStreamParser {
251
255
  * treat a `rejected` window as the transient rate limit it is (with the resetsAt-derived
252
256
  * wait the translator attached). */
253
257
  applyRateLimit(event) {
258
+ const reading = this.readRateWindow(event);
259
+ this.upsertRateWindow(reading);
260
+ if (reading.status === "rejected") {
261
+ this.transientRateLimit = true;
262
+ this.setRetryAfterSeconds(event.retryAfterSeconds);
263
+ }
264
+ }
265
+ /** Build one `RateWindow` reading from a canonical `rate_limit` event: derive the `type`
266
+ * (falling back to `unknown` when pi names none), default the `status` to "" when absent,
267
+ * and copy through the four optional fields that the event carries. */
268
+ readRateWindow(event) {
254
269
  const type = typeof event.rateLimitType === "string" && event.rateLimitType ? event.rateLimitType : "unknown";
255
270
  const reading = { type, status: typeof event.status === "string" ? event.status : "" };
256
271
  if (typeof event.resetsAt === "number" && Number.isFinite(event.resetsAt))
@@ -261,9 +276,16 @@ export class PiStreamParser {
261
276
  reading.overageStatus = event.overageStatus;
262
277
  if (event.isUsingOverage === true)
263
278
  reading.isUsingOverage = true;
264
- const existing = this.rateWindows.findIndex((w) => w.type === type);
279
+ return reading;
280
+ }
281
+ /** Insert one reading, keeping the newest per type: replace the window whose `type` matches
282
+ * and, when the reading is status-less (a non-binding window's utilization), carry the last
283
+ * known status of the window it replaces; note overage so a caller can see a rate limit hit
284
+ * even before it is rejected. */
285
+ upsertRateWindow(reading) {
286
+ const existing = this.rateWindows.findIndex((w) => w.type === reading.type);
265
287
  // A reading without a status (a non-binding window's utilization) keeps the last known one.
266
- if (reading.status === "" && existing >= 0)
288
+ if (reading.status === "")
267
289
  reading.status = this.rateWindows[existing]?.status ?? "";
268
290
  if (existing >= 0)
269
291
  this.rateWindows[existing] = reading;
@@ -271,10 +293,6 @@ export class PiStreamParser {
271
293
  this.rateWindows.push(reading);
272
294
  if (reading.isUsingOverage === true)
273
295
  this.usedOverage = true;
274
- if (reading.status === "rejected") {
275
- this.transientRateLimit = true;
276
- this.setRetryAfterSeconds(event.retryAfterSeconds);
277
- }
278
296
  }
279
297
  /** Fold a provider Retry-After hint into `retryAfterSeconds`: ignore a non-number or
280
298
  * non-finite value, and clamp a negative or fractional one to whole seconds. */
@@ -355,6 +373,7 @@ export class PiStreamParser {
355
373
  if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
356
374
  this.billing = event.billing;
357
375
  this.countProgress(event);
376
+ this.pendingReply = foldPendingReply(this.pendingReply, event);
358
377
  this.applyToolExecution(event);
359
378
  if (event.type !== "message_end" || event.message?.role !== "assistant")
360
379
  return;
@@ -369,8 +388,10 @@ export class PiStreamParser {
369
388
  // Nothing else in this parser acts on the type, so the line is done either way.
370
389
  applyMessageUpdate(line) {
371
390
  if (piEventType(line) === "message_update") {
372
- if (messageUpdateHasContent(parseJsonObject(line)))
391
+ const hasContent = messageUpdateHasContent(parseJsonObject(line));
392
+ if (hasContent)
373
393
  this.progressCount += 1;
394
+ this.pendingReply = foldPendingReply(this.pendingReply, { type: "message_update" }, { hasContent });
374
395
  return true;
375
396
  }
376
397
  return false;
@@ -494,6 +515,12 @@ export class PiStreamParser {
494
515
  }
495
516
  applyAssistantMessage(msg) {
496
517
  const text = messageText(msg);
518
+ this.applyAssistantReply(msg, text);
519
+ this.applyAssistantRefusal(text);
520
+ this.applyAssistantUsage(msg);
521
+ this.applyAssistantStop(msg);
522
+ }
523
+ applyAssistantReply(msg, text) {
497
524
  this.finalMessageContentless = !messageContent(msg).some((c) => c.type === "toolCall" ||
498
525
  (c.type === "text" && typeof c.text === "string" && Boolean(c.text.trim())));
499
526
  if (!this.finalMessageContentless)
@@ -502,6 +529,8 @@ export class PiStreamParser {
502
529
  this.finalText = text;
503
530
  if (isNothingToDo(text))
504
531
  this.declaredNothingToDo = true;
532
+ }
533
+ applyAssistantRefusal(text) {
505
534
  // The refusal comes only from an anchored `TUMWATER_REFUSED: <reason>` line: a bare
506
535
  // sentinel and a mid-sentence mention are not refusals, and a negating reason ("none",
507
536
  // "(n/a …)") clears one — BUGS.md 2026-09-23, four runs ended ordinary replies with
@@ -521,6 +550,8 @@ export class PiStreamParser {
521
550
  }
522
551
  if (hasVerdictLine(text))
523
552
  this.verdictText = text;
553
+ }
554
+ applyAssistantUsage(msg) {
524
555
  this.turns += 1;
525
556
  this.outputTokens += usageNumber(msg.usage?.output);
526
557
  const promptTokens = usageNumber(msg.usage?.input) + usageNumber(msg.usage?.cacheRead) + usageNumber(msg.usage?.cacheWrite);
@@ -536,6 +567,8 @@ export class PiStreamParser {
536
567
  }
537
568
  this.peakContextTokens = Math.max(this.peakContextTokens, usageNumber(msg.usage?.totalTokens));
538
569
  this.costUsd += usageNumber(msg.usage?.cost?.total);
570
+ }
571
+ applyAssistantStop(msg) {
539
572
  this.stopReason = msg.stopReason;
540
573
  if (msg.errorMessage)
541
574
  this.errorMessage = msg.errorMessage;
@@ -1,10 +1,11 @@
1
1
  /** The run's watchdog clocks, split out of pi.ts's runPi (2026-10-04, organize): the run
2
- * deadline and the quiet watchdog (quiet-kill plus the stalled-tool-call warning) — the
2
+ * deadline and the quiet watchdog (quiet-kill plus the stalled-tool-call and
3
+ * unanswered-model-request warnings) — the
3
4
  * 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
5
+ * and result-distillation wiring that drives it (src/agent/agent-run.ts keeps those). The
5
6
  * factory owns all the mutable state the two timers kept in runPi's closure (the progress
6
7
  * 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
8
+ * handler reads back; agent-run.ts feeds the parser live through the deps accessors, so no
8
9
  * notification path is needed when events land. */
9
10
  import { bufferedCommandStallMs, callBuffersOutput } from "./command-shape.js";
10
11
  /** The run-deadline clock: kills the process tree when it fires, after giving a
@@ -41,7 +42,9 @@ function startDeadlineWatch(state, deps) {
41
42
  // discard (BUGS.md 2026-10-26).
42
43
  const progressGraceMs = 250;
43
44
  let extended = false;
44
- let graceArmed = false; // one grace per deadline, so a genuinely stalled run is never granted two
45
+ let graceArmed = false; // one grace for the whole run, so a genuinely stalled run is never granted two
46
+ // (see the deadline comment below: a run reaches this branch at most once, so per-run and
47
+ // per-deadline scope grant the same single short re-check)
45
48
  state.timeoutBudgetMs = deps.runTimeoutMs;
46
49
  const fireDeadline = () => {
47
50
  if (deps.extendOnProgress && !extended && deps.progressCount() > checkpointProgress) {
@@ -65,8 +68,13 @@ function startDeadlineWatch(state, deps) {
65
68
  // count and a progressing run looks stalled (BUGS.md 2026-10-26). A run that has begun
66
69
  // producing structured progress therefore gets ONE short grace to drain queued output and
67
70
  // 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.
71
+ // keeps the discard path. The grace is one short beat, armed once for the whole run:
72
+ // graceArmed is a closure local of startDeadlineWatch, one per run, and the branch is
73
+ // reached at most once because a gate run's earlier base deadline is handled by its
74
+ // one-time extension (the block above) while an authoring run has a single deadline — so
75
+ // "once per run" and "once per deadline" grant the very same single re-check, and a
76
+ // progressing gate run is never left without one while a genuinely stalled run is never
77
+ // granted two.
70
78
  if (!graceArmed && deps.progressCount() > 0) {
71
79
  graceArmed = true;
72
80
  state.timeout = setTimeout(fireDeadline, progressGraceMs);
@@ -78,9 +86,13 @@ function startDeadlineWatch(state, deps) {
78
86
  };
79
87
  state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
80
88
  }
89
+ /** Whole minutes read cleaner in the feed; sub-minute thresholds stay in seconds. */
90
+ function silenceLabel(ms) {
91
+ return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
92
+ }
81
93
  /** The quiet/stall clock: quiet-kills a run that stopped making progress and warns on
82
- * stalled tool calls, on one interval against the wall clock (BUGS.md 2026-09-23,
83
- * 2026-09-28, 2026-09-29, 2026-10-06). */
94
+ * stalled tool calls and unanswered model requests, on one interval against the wall clock
95
+ * (BUGS.md 2026-09-23, 2026-09-28, 2026-09-29, 2026-10-06, 2026-10-29). */
84
96
  function startQuietWatch(state, deps) {
85
97
  // Quiet watchdog: a healthy run makes PROGRESS continuously even when slow — messages
86
98
  // start and end, turns and tool calls complete. Prolonged lack of progress means a hung
@@ -147,21 +159,35 @@ function startQuietWatch(state, deps) {
147
159
  const bufferedStallMs = bufferedCommandStallMs(deps.stallMs);
148
160
  if (openMs >= bufferedStallMs) {
149
161
  warnedStalledCalls.add(call.id);
150
- deps.onToolCallStalled?.(`tool call stalled: ${call.label} — no exit for ${Math.round(openMs / 60_000)}m`);
162
+ deps.onStallWarning?.(`tool call stalled: ${call.label} — no exit for ${silenceLabel(openMs)}`);
151
163
  }
152
164
  continue;
153
165
  }
154
166
  const silentMs = Date.now() - call.lastActivityAt;
155
167
  if (silentMs >= deps.stallMs) {
156
168
  warnedStalledCalls.add(call.id);
157
- // Whole minutes read cleaner in the feed; sub-minute thresholds stay in seconds.
158
- const silent = silentMs < 60_000
159
- ? `${Math.round(silentMs / 1000)}s`
160
- : `${Math.round(silentMs / 60_000)}m`;
161
- deps.onToolCallStalled?.(`tool call stalled: ${call.label} — no output for ${silent}`);
169
+ deps.onStallWarning?.(`tool call stalled: ${call.label} — no output for ${silenceLabel(silentMs)}`);
162
170
  }
163
171
  }
164
172
  };
173
+ // The unanswered-request warning, the model-side twin of the stall warning: a provider can
174
+ // accept a request and never answer it (BUGS.md 2026-10-29: deepinfra's DeepSeek-V4.1-Flash
175
+ // sent plan only SSE keepalives, which pi never surfaces). pi then waits out its own idle
176
+ // timeout and retries, and each retry is progress to the quiet watchdog, so nothing else
177
+ // names the hang for hours. Like the stall warning it only warns: a slow local prefill can
178
+ // legitimately run this long, and pi's own timeout still owns each attempt. One warning per
179
+ // unanswered stretch, keyed by its start, not one per retry.
180
+ let warnedReplySince;
181
+ const warnUnansweredReply = () => {
182
+ const pending = deps.pendingReply?.();
183
+ if (!pending || pending.since === warnedReplySince)
184
+ return;
185
+ const waitedMs = Date.now() - pending.since;
186
+ if (waitedMs < deps.stallMs)
187
+ return;
188
+ warnedReplySince = pending.since;
189
+ deps.onStallWarning?.(`model request unanswered: ${pending.target || "model"} — no reply for ${silenceLabel(waitedMs)}`);
190
+ };
165
191
  const checkEveryMs = deps.quietMs > 0 ? deps.quietMs : deps.stallMs;
166
192
  state.quietCheck =
167
193
  checkEveryMs > 0
@@ -174,8 +200,10 @@ function startQuietWatch(state, deps) {
174
200
  state.quietKilled = true;
175
201
  deps.kill();
176
202
  }
177
- if (deps.stallMs > 0)
203
+ if (deps.stallMs > 0) {
178
204
  warnStalledToolCalls();
205
+ warnUnansweredReply();
206
+ }
179
207
  }, Math.min(Math.max(checkEveryMs / 2, 250), 30_000))
180
208
  : undefined;
181
209
  }
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
+ }
@@ -1,26 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
- import { captureCappedOutput, releaseChildHandles, signalTree, withoutLaunchServicesCheckIn } from "./process.js";
2
+ import { captureCappedOutput, KILL_GRACE_MS, releaseChildHandles, signalTree, withoutLaunchServicesCheckIn, } from "./process.js";
3
+ export { KILL_GRACE_MS };
3
4
  import { errCode } from "../errno.js";
4
- /** The detached process-group runner: runScriptGroup starts a command in its own process
5
- * group — exactly like pi — and settles exactly once, bounding a timed-out tree at its
6
- * deadline plus the SIGTERM → SIGKILL escalation grace. Split out of process.ts, whose other
7
- * residents (the exec helper, the liveness and process-table probes, the LaunchServices
8
- * preload) are generic child-process plumbing shared across the harness: this runner's two
9
- * runtime consumers both sit in the build boundary — the build check (build/build-check.ts)
10
- * and the lockfile install (build/dep-install.ts) — and its result carries the build check's
11
- * run record, so it lives beside that boundary instead of inside the shared module. The
12
- * signals it escalates with come from process.ts's signalTree; the children it starts carry
13
- * withoutLaunchServicesCheckIn's preload, so its npm trees check in with launchservicesd
14
- * neither on macOS nor anywhere else. */
15
- /** SIGTERM → SIGKILL escalation window once a run's timeout has FIRED: the whole process
16
- * group gets SIGTERM, and anything still alive this much later (a SIGTERM-trapping runner, a
17
- * wedged worker) is SIGKILLed — and the run settles then, whether or not the tree ever closed
18
- * its pipes, so a timed-out run is bounded at its deadline plus this grace. The default of
19
- * runBuildCheck's killGraceMs parameter (build/build-check.ts), which tests shrink — pinning that
20
- * the escalation is armed on timeout (never at spawn: a healthy run that merely outlasts the
21
- * grace period must run to completion), that a surviving grandchild is taken down before the
22
- * run settles, and that a tree the SIGTERM already took down does not wait out the grace. */
23
- export const KILL_GRACE_MS = 10_000;
24
5
  /** True while any process in the group led by `pid` still exists: a signal-0 probe of the
25
6
  * group, where EPERM still means a member exists. armGroupDeadline probes with it for both
26
7
  * detached-process-group runners (runScriptGroup and git-run's execGitBounded), whose
@@ -122,6 +103,13 @@ export function runScriptGroup(cmd, args, opts) {
122
103
  env: withoutLaunchServicesCheckIn(process.env),
123
104
  });
124
105
  let settled = false;
106
+ let aborted = false;
107
+ // Abort-stop timers, the mirror of armGroupDeadline's — armed only while an abort fires
108
+ // before settle (never at spawn), SIGTERM first then SIGKILL after killGraceMs for a
109
+ // survivor, settling as soon as the whole group is gone — cleared on finish, the caller's
110
+ // single settle path.
111
+ let abortPoll;
112
+ let abortKillTimer;
125
113
  // Captured output is capped at maxBuffer per stream (further chunks are dropped —
126
114
  // classification reads the tail, so a chatty script can neither wedge the check nor balloon
127
115
  // memory). The overflow is deliberately ignored here: truncated output still classifies.
@@ -134,11 +122,34 @@ export function runScriptGroup(cmd, args, opts) {
134
122
  killGraceMs: opts.killGraceMs,
135
123
  onTimedOut: () => finish({ signal: "SIGTERM" }),
136
124
  });
125
+ function stopForAbort() {
126
+ if (settled)
127
+ return;
128
+ aborted = true;
129
+ // The harness's own stop, signalled the way a timeout stops the tree: SIGTERM the whole
130
+ // group (grandchild included), then SIGKILL anything still alive after the grace. The
131
+ // run settles when the group is gone, so the caller never sees a live tree behind it.
132
+ signalTree(child, "SIGTERM");
133
+ abortPoll = setInterval(() => {
134
+ if (!groupAlive(child.pid))
135
+ finish({ aborted: true });
136
+ }, GROUP_POLL_MS);
137
+ abortKillTimer = setTimeout(() => {
138
+ signalTree(child, "SIGKILL");
139
+ finish({ aborted: true });
140
+ }, opts.killGraceMs);
141
+ }
137
142
  function finish(result) {
138
143
  if (settled)
139
144
  return;
140
145
  settled = true;
141
146
  deadline.dispose();
147
+ if (abortPoll)
148
+ clearInterval(abortPoll);
149
+ if (abortKillTimer)
150
+ clearTimeout(abortKillTimer);
151
+ if (opts.signal)
152
+ opts.signal.removeEventListener("abort", stopForAbort);
142
153
  const timedOut = deadline.fired();
143
154
  if (timedOut) {
144
155
  // Nothing more is read once the deadline has fired: release the handles so a pipe
@@ -148,16 +159,30 @@ export function runScriptGroup(cmd, args, opts) {
148
159
  const run = { spawnedAt, settledAt: Date.now(), timeoutMs: opts.timeoutMs };
149
160
  if (timedOut)
150
161
  run.deadlineLateMs = deadline.lateMs();
151
- resolve({ ...result, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
162
+ resolve({ ...result, aborted, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
152
163
  }
164
+ // An abort signal stops the tree mid-check — the harness's own stop, so the outcome says
165
+ // the harness aborted it, distinct from a timeout and from an external killed signal. The
166
+ // listener is removed as soon as the run settles. An already-aborted signal is deliberately
167
+ // NOT acted on at spawn: the swap happens mid-run (a user abort or a shutdown fires while
168
+ // the check is running), and a signal already aborted before the check started is caught by
169
+ // the caller's `aborted` check when the run returns — a fast failing check under a
170
+ // pre-aborted signal still reaches its own verdict first (the pre-existing shutdown-test
171
+ // behavior).
172
+ if (opts.signal)
173
+ opts.signal.addEventListener("abort", stopForAbort, { once: true });
153
174
  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())
175
+ // Before a stop this is the spawn failing (npm missing from PATH). After the deadline or
176
+ // an abort it can only be a teardown signal that could not be delivered — the stop's own
177
+ // group probe or grace timer settles the run.
178
+ if (!deadline.fired() && !aborted)
158
179
  finish({ spawnError: err });
159
180
  });
160
181
  child.on("close", (code, signal) => {
182
+ // During an abort-stop the run settles on the whole group being gone (the probe or the
183
+ // grace timer), never on the leader's close alone. Otherwise the timeout rule stands.
184
+ if (aborted)
185
+ return;
161
186
  // Before the deadline the leader's close settles the run. After it, settle only once the
162
187
  // whole group is gone: something in the group may outlive the leader, and the poll
163
188
  // 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
@@ -88,10 +88,18 @@ export async function identityAlive(id) {
88
88
  const now = await pidIdentity(id.pid);
89
89
  return now !== null && now.start === id.start;
90
90
  }
91
+ /** SIGTERM → SIGKILL escalation window: how long a SIGTERM is given to take a process tree
92
+ * down before whatever survived is SIGKILLed. The one value behind every harness escalation
93
+ * — terminateChild's group kill here, the run-marker sweep's orphan kill, and the detached
94
+ * process-group runner (whose own `KILL_GRACE_MS` re-exports it, process-group.ts) — so a
95
+ * team-wide teardown, an end-of-run sweep, and a timed-out build check all grant the same
96
+ * grace and the literal cannot drift between them. 10 s is enough for a well-behaved tree to
97
+ * exit on SIGTERM; a SIGTERM-trapping straggler is SIGKILLed after it. */
98
+ export const KILL_GRACE_MS = 10_000;
91
99
  /** Terminate a detached child and everything it started: SIGTERM to the process group now,
92
- * escalating to SIGKILL after 10 s if any of it is still alive. The child is spawned detached
93
- * (its own process group leader — runPi does this for pi), so a negative PID reaches every
94
- * tool-call grandchild — which a single-PID kill leaves orphaned to launchd — while the
100
+ * escalating to SIGKILL after KILL_GRACE_MS if any of it is still alive. The child is spawned
101
+ * detached (its own process group leader — runPi does this for pi), so a negative PID reaches
102
+ * every tool-call grandchild — which a single-PID kill leaves orphaned to launchd — while the
95
103
  * harness's own group is never in the blast radius. Shared by the run-timeout,
96
104
  * quiet-watchdog, and harness-shutdown kills, and by the sweep every run gets once pi exits
97
105
  * (runPi's 'exit' handler), when the group holds only what pi's tool calls backgrounded.
@@ -106,14 +114,14 @@ export async function identityAlive(id) {
106
114
  * by it. */
107
115
  export function terminateChild(child) {
108
116
  if (signalTree(child, "SIGTERM"))
109
- setTimeout(() => signalTree(child, "SIGKILL"), 10_000).unref();
117
+ setTimeout(() => signalTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
110
118
  }
111
119
  /** Signal the child's whole process group, falling back to the child alone when the group is
112
120
  * already gone or the platform has no negative-PID kill (Windows). Returns whether the signal
113
121
  * reached a live process: false when nothing was left to receive it — ESRCH from a group that
114
122
  * died with its leader, the normal case once the child has exited. Never throws: a process
115
123
  * 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,
124
+ * teardown (src/agent/agent-run.ts) and the detached process-group runner (runScriptGroup,
117
125
  * process-group.ts) — the same "signal the tree, not just the direct child" guarantee in one
118
126
  * home, this module. */
119
127
  export function signalTree(child, signal) {
@@ -186,8 +194,8 @@ export function captureCappedOutput(child, maxBuffer, onOverflow) {
186
194
  // ── Child environments ───────────────────────────────────────────────────────────────────
187
195
  /** The child-environment policy every spawn that hands a child the harness's environment shares:
188
196
  * 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. */
197
+ * (supervisor.ts, self-reload.ts, event-notify.ts, git-run.ts, doctor-model-checks.ts) all want
198
+ * exactly this, so the spread lives here once instead of in each spawn's options. */
191
199
  export function childEnv(overrides = {}) {
192
200
  return { ...process.env, ...overrides };
193
201
  }