tumwater 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,27 +1,32 @@
1
- // vocabulary: plain
2
1
  /** The backend-neutral agent runner (plans/agent-backends.md \"The seam\"): everything a run
3
2
  * needs regardless of which agent it drives — the spawn, the `tumwater_run` raw-log marker,
4
- * the watchdog wiring, abort, the group kill and marker sweep, stderr capture, and result
5
- * assembly. The backend (src/agent/agent-backend.ts, with pi in src/pi/pi-backend.ts) supplies
6
- * the argv/stdin/env, the native-to-canonical stdout translator, the stderr warning matcher,
7
- * and the exit classification. `runPi` (src/pi/pi.ts) is the thin alias the existing call sites
8
- * use, so a caller and its fakes are untouched by this split. */
3
+ * the watchdog wiring, abort, and stderr capture. The backend (src/agent/agent-backend.ts, with
4
+ * pi in src/pi/pi-backend.ts) provides the argv/stdin/env, the native-to-canonical stdout
5
+ * translator, the stderr warning
6
+ * matcher, and the exit classification. This file keeps the live lifecycle — pre-spawn setup,
7
+ * `prepare`, spawn, decoders, the stream pipeline, the watchdogs and the feed — and delegates
8
+ * the post-run settlement (spawn-error result, exit orphan sweep, close-time reconcile and
9
+ * classify) to src/agent/run-settle.ts. `runPi` (src/pi/pi.ts) is the thin alias the existing
10
+ * call sites use, so a caller and its fakes are untouched by this split. */
9
11
  import { spawn } from "node:child_process";
10
12
  import fs from "node:fs";
11
13
  import os from "node:os";
12
14
  import path from "node:path";
13
15
  import { StringDecoder } from "node:string_decoder";
14
- import { ensureDir, ensureParentDir, rotateIfLarge } from "../files/files.js";
15
- import { agentBinConfigKey, agentBinSourceLabel } from "../pi/pi-bin.js";
16
+ import { ensureDir, ensureParentDir, removeTree, rotateIfLarge } from "../files/files.js";
16
17
  import { runOnAbort, terminateChild } from "../process/process.js";
17
- import { makeRunMarker, sweepRunMarker } from "../process/run-marker.js";
18
+ import { makeRunMarker } from "../process/run-marker.js";
18
19
  import { PiStreamParser } from "../pi/pi-stream.js";
19
20
  import { startPiWatchdogs } from "../pi/pi-watchdogs.js";
20
- import { DEFAULT_AGENT, agentBackend, registerAgentBackend } from "./agent-backend.js";
21
+ import { DEFAULT_BACKEND, backendById, registerAgentBackend } from "./agent-backend.js";
21
22
  import { recordSession } from "./session-ledger.js";
23
+ import { errorMessage } from "../text/text.js";
24
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
22
25
  import { piBackend } from "../pi/pi-backend.js";
23
26
  import { claudeBackend } from "./claude/claude-backend.js";
27
+ import { codexBackend } from "./codex/codex-backend.js";
24
28
  import { opencodeBackend } from "./opencode/opencode-backend.js";
29
+ import { setupFailureResult, settleClose, settleError, settleExit } from "./run-settle.js";
25
30
  import { seamImageInput } from "./image-input.js";
26
31
  import { ensureNotesShim } from "./notes-shim.js";
27
32
  import { nearestTumwaterRoot } from "../paths.js";
@@ -34,7 +39,7 @@ const STDERR_MATCH_TAIL = 512;
34
39
  * object; a line this long is already at the default whole-log cap (`logMaxBytes`, 16 MiB), so
35
40
  * continuing to buffer it can only be a malformed stream. Past it `feedCanonical` drops the
36
41
  * buffered line and skips input to the next newline, so a backend that streams bytes with no
37
- * newline — each byte resetting the quiet watchdog — cannot grow the buffer until the tick
42
+ * newline — each byte resetting the quiet watchdog — cannot grow the buffer until the run
38
43
  * timeout (4 h by default) exhausts the harness's memory. */
39
44
  export const MAX_NATIVE_LINE_CHARS = 16 * 1024 * 1024;
40
45
  // The seam's adapters. Registering here — where the lookup happens — keeps the registration
@@ -42,20 +47,7 @@ export const MAX_NATIVE_LINE_CHARS = 16 * 1024 * 1024;
42
47
  registerAgentBackend(piBackend);
43
48
  registerAgentBackend(claudeBackend);
44
49
  registerAgentBackend(opencodeBackend);
45
- /** Prefix of PiRunResult.errorMessage when the agent process never started (no session file
46
- * is created for such a run). The resolved binary follows it, so a configured agentBin
47
- * reads "failed to spawn <bin>" rather than blaming an ambient "pi". */
48
- const SPAWN_ERROR_PREFIX = "failed to spawn";
49
- /** The spawn-error message for a resolved binary: the default source keeps today's text
50
- * ("failed to spawn pi: …"); a configured source names the binary and where it came from —
51
- * the backend's own env var and config key (agentBinConfigKey) — so a wrong
52
- * `backends.claude.bin` does not read as pi's legacy `agentBin`. */
53
- function spawnErrorMessage(resolved, errMessage, backend) {
54
- const source = resolved.source === "default"
55
- ? ""
56
- : ` (resolved from ${agentBinSourceLabel(resolved.source, backend.binEnv, agentBinConfigKey(backend.id))})`;
57
- return `${SPAWN_ERROR_PREFIX} ${resolved.bin}${source}: ${errMessage}`;
58
- }
50
+ registerAgentBackend(codexBackend);
59
51
  function openAppendLog(file, maxBytes) {
60
52
  ensureParentDir(file);
61
53
  rotateIfLarge(file, maxBytes);
@@ -66,449 +58,474 @@ function openAppendLog(file, maxBytes) {
66
58
  return log;
67
59
  }
68
60
  /** Run the configured agent backend non-interactively in a worktree and distill the result.
69
- * Never throws. */
61
+ * Never throws: a setup failure (session dir, log file, scratch dir) resolves as a failed run
62
+ * whose errorMessage names it. */
70
63
  export function runAgent(opts) {
71
- return new Promise((resolve) => {
64
+ // An async executor so a backend's `prepare` step (the codex write guard resolving its trust
65
+ // by spawning app-server) can be awaited before the synchronous invocation is built. A
66
+ // `prepare` throw is caught and warned, never rejecting the run; a synchronous throw in the
67
+ // build-and-spawn stage below (e.g. claude's invocation rejecting a run with no model)
68
+ // rejects the run, matching what the Promise constructor did for a throw from a synchronous
69
+ // executor.
70
+ return new Promise(async (resolve, reject) => {
72
71
  // The `agent` selector is optional: default to pi, the historical backend. An unregistered
73
- // id also falls back to pi rather than rejecting the tick.
74
- const backend = agentBackend(opts.config.agent ?? DEFAULT_AGENT) ?? piBackend;
75
- ensureDir(opts.sessionDir);
76
- // A broken raw log (EACCES, ENOSPC) must not crash the process on an unhandled 'error'
77
- // nor hang the tick: openAppendLog marks it so finish() resolves without waiting for a
78
- // 'finish' that will never come — a lost transcript is acceptable, a stuck loop is not.
79
- const rawLog = openAppendLog(opts.rawLogFile, opts.config.logMaxBytes);
80
- // The backend-native stdout, kept beside the canonical raw log for debugging: every
81
- // complete native line is written before it is translated, so the two logs line up. A
82
- // broken native log degrades to a lost debug log, never a stuck or failed tick.
83
- const nativeLog = openAppendLog(opts.nativeLogFile, opts.config.logMaxBytes);
84
- // The marker precedes this run's first event in file order — written to the same stream
85
- // stdout lines flow through, before spawn, so ordering is exact by construction; on a
86
- // failed spawn finish() still ends the stream and flushes it. Exactly one per run: the
87
- // kind and agent are always there, the label only when the caller set one.
88
- {
89
- const marker = {
90
- type: "tumwater_run",
91
- kind: opts.kind,
92
- agent: backend.id,
93
- };
94
- if (opts.label)
95
- marker.label = opts.label;
96
- rawLog.stream.write(JSON.stringify(marker) + "\n");
72
+ // id also falls back to pi rather than rejecting the run.
73
+ const backend = backendById(opts.config.agent ?? DEFAULT_BACKEND) ?? piBackend;
74
+ let rawLog;
75
+ let nativeLog;
76
+ let runTmp;
77
+ try {
78
+ ensureDir(opts.sessionDir);
79
+ // A broken raw log (EACCES, ENOSPC) must not crash the process on an unhandled 'error'
80
+ // nor hang the run: openAppendLog marks it so finish() resolves without waiting for a
81
+ // 'finish' that will never come — a lost transcript is acceptable, a stuck runner is not.
82
+ rawLog = openAppendLog(opts.rawLogFile, opts.config.logMaxBytes);
83
+ // The backend-native stdout, kept beside the canonical raw log for debugging: every
84
+ // complete native line is written before it is translated, so the two logs line up. A
85
+ // broken native log degrades to a lost debug log, never a stuck or failed run.
86
+ nativeLog = openAppendLog(opts.nativeLogFile, opts.config.logMaxBytes);
87
+ // A private TMPDIR per run: concurrent agents default to literal /tmp names, and a shared
88
+ // per-user TMPDIR would still collide. Removed when the run settles.
89
+ runTmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-run-"));
90
+ }
91
+ catch (err) {
92
+ // Nothing is spawned yet: close what opened and fail the run instead of rejecting it.
93
+ rawLog?.stream.end();
94
+ nativeLog?.stream.end();
95
+ resolve(setupFailureResult(backend.id, `could not prepare the agent run: ${errorMessage(err)}`));
96
+ return;
97
97
  }
98
- // The first blind image read an authoring run made (plans/image-input.md "Detecting a
99
- // blind read"): the path, plus an indirection to the child so the parser's parse-time
100
- // callback (which fires during feed, after spawn) can kill it. Unset for a gate run,
101
- // which only records the read. Declared before the parser so its onBlindRead closure
102
- // (which assigns them at parse time, after spawn) can reference them.
98
+ // The pre-spawn setup below may throw synchronously (a broken marker write, parser
99
+ // construction, backend.resolveBin). An async executor's own rejection is swallowed by the
100
+ // Promise constructor, so a throw here would otherwise leave the run's promise pending
101
+ // forever and emit an unhandled rejection — a synchronous executor used to reject directly.
102
+ // The try/catch below restores that rejection. A known-recoverable failure (the notes
103
+ // shim) warns and goes on inside its own guard, so it is deliberately not covered here.
103
104
  let blindRead;
104
105
  let terminateRun;
105
- const parser = new PiStreamParser(opts.onToolCallStart, {
106
- // The seam's effective image input (src/agent/image-input.ts): a text-only seam makes
107
- // every image read blind even when pi adds no note — a claim its doctor probe proved
108
- // false — which the parser checks against its own note rule.
109
- imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
110
- onBlindRead: (path) => {
111
- // Kill an authoring run at once, before the agent goes on working from a picture it
112
- // never saw (terminateChild is what the watchdogs use). Gate runs (reviewer,
113
- // resolver) only record the read and keep going.
114
- if (opts.kind === "author") {
115
- blindRead = path;
116
- terminateRun?.();
117
- }
118
- },
119
- });
120
- // The run's cross-group attribution mark, minted before the spawn so the environment can
121
- // carry it and the exit sweep can name it.
122
- const runMarker = makeRunMarker();
123
- const resolved = backend.resolveBin(opts.config);
124
- // Install the notes shim under the team root's `.tumwater/bin` so every backend's run finds
125
- // `tumwater-notes` first on PATH (notes-shim.ts). A failure to write it logs one warning and
126
- // the run goes on without it — notes would then be unwritable by the command, but the run
127
- // itself is not blocked.
106
+ let parser;
107
+ let runMarker;
108
+ let resolved;
128
109
  let binDir;
110
+ let runSpec;
129
111
  try {
130
- // Install the shim only under a real team root — the nearest ancestor holding a
131
- // `.tumwater`. A session dir with no such ancestor (a lab script, a smoke run, a
132
- // prompt-lab replay) must never mint one: nearestTumwaterRoot is undefined then, and the
133
- // run goes on without the shim, exactly as if the install had failed — writing
134
- // `<fallback>/.tumwater` from a run created a phantom harness root in a shared directory
135
- // and broke every other suite on the host (BUGS.md 2026-10-18).
136
- const root = nearestTumwaterRoot(opts.sessionDir);
137
- if (root !== undefined)
138
- binDir = ensureNotesShim(root);
112
+ // The marker precedes this run's first event in file order — written to the same stream
113
+ // stdout lines flow through, before spawn, so ordering is exact by construction; on a
114
+ // failed spawn finish() still ends the stream and flushes it. Exactly one per run: the
115
+ // kind, agent and start time are always there, the label only when the caller set one
116
+ // (`history <role> <n> --transcript` selects a run's segments by this ts).
117
+ {
118
+ const marker = {
119
+ type: "tumwater_run",
120
+ kind: opts.kind,
121
+ agent: backend.id,
122
+ ts: Date.now(),
123
+ };
124
+ if (opts.label)
125
+ marker.label = opts.label;
126
+ rawLog.stream.write(JSON.stringify(marker) + "\n");
127
+ }
128
+ // The first blind image read an authoring run made (plans/image-input.md "Detecting a
129
+ // blind read"): the path, plus an indirection to the child so the parser's parse-time
130
+ // callback (which fires during feed, after spawn) can kill it. Unset for a gate run,
131
+ // which only records the read. Declared before the parser so its onBlindRead closure
132
+ // (which assigns them at parse time, after spawn) can reference them.
133
+ parser = new PiStreamParser(opts.onToolCallStart, {
134
+ // The seam's effective image input (src/agent/image-input.ts): a text-only seam makes
135
+ // every image read blind even when pi adds no note — a claim its doctor probe proved
136
+ // false — which the parser checks against its own note rule.
137
+ imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
138
+ onBlindRead: (path) => {
139
+ // Kill an authoring run at once, before the agent goes on working from a picture it
140
+ // never saw (terminateChild is what the watchdogs use). Gate runs (reviewer,
141
+ // resolver) only record the read and keep going.
142
+ if (opts.kind === "author") {
143
+ blindRead = path;
144
+ terminateRun?.();
145
+ }
146
+ },
147
+ });
148
+ // The run's cross-group attribution mark, minted before the spawn so the environment can
149
+ // carry it and the exit sweep can name it.
150
+ runMarker = makeRunMarker();
151
+ resolved = backend.resolveBin(opts.config);
152
+ // Install the notes shim under the team root's `.tumwater/bin` so every backend's run
153
+ // finds `tumwater-notes` first on PATH (notes-shim.ts). A failure to write it logs one
154
+ // warning and the run goes on without it — notes would then be unwritable by the command,
155
+ // but the run itself is not blocked.
156
+ try {
157
+ // Install the shim only under a real team root — the nearest ancestor holding a
158
+ // `.tumwater`. A session dir with no such ancestor (a lab script, a smoke run, a
159
+ // prompt-lab replay) must never mint one: nearestTumwaterRoot is undefined then, and
160
+ // the run goes on without the shim, exactly as if the install had failed — writing
161
+ // `<fallback>/.tumwater` from a run created a phantom harness root in a shared
162
+ // directory and broke every other suite on the host (BUGS.md 2026-10-18).
163
+ const root = nearestTumwaterRoot(opts.sessionDir);
164
+ if (root !== undefined)
165
+ binDir = ensureNotesShim(root);
166
+ }
167
+ catch {
168
+ opts.onAgentWarning?.("could not install the tumwater-notes shim; this run will not find tumwater-notes on PATH");
169
+ }
170
+ runSpec = {
171
+ cwd: opts.cwd,
172
+ prompt: opts.prompt,
173
+ config: opts.config,
174
+ sessionDir: opts.sessionDir,
175
+ sessionName: opts.sessionName,
176
+ continueSession: opts.continueSession,
177
+ kind: opts.kind,
178
+ agentBin: resolved.bin,
179
+ runMarker,
180
+ notesPath: opts.notesPath,
181
+ ...(binDir !== undefined ? { binDir } : {}),
182
+ ...(opts.maxBudgetUsd !== undefined ? { maxBudgetUsd: opts.maxBudgetUsd } : {}),
183
+ };
139
184
  }
140
- catch {
141
- opts.onAgentWarning?.("could not install the tumwater-notes shim; this run will not find tumwater-notes on PATH");
185
+ catch (err) {
186
+ // A synchronous throw in the run's pre-spawn setup (marker, parser construction,
187
+ // resolveBin) rejects the run, restoring the rejection a synchronous executor's throw
188
+ // used to get from the Promise constructor.
189
+ reject(err instanceof Error ? err : new Error(`runAgent: ${String(err)}`));
190
+ return;
142
191
  }
143
- const runSpec = {
144
- cwd: opts.cwd,
145
- prompt: opts.prompt,
146
- config: opts.config,
147
- sessionDir: opts.sessionDir,
148
- sessionName: opts.sessionName,
149
- continueSession: opts.continueSession,
150
- kind: opts.kind,
151
- agentBin: resolved.bin,
152
- runMarker,
153
- notesPath: opts.notesPath,
154
- ...(binDir !== undefined ? { binDir } : {}),
155
- ...(opts.maxBudgetUsd !== undefined ? { maxBudgetUsd: opts.maxBudgetUsd } : {}),
156
- };
157
- const invocation = backend.invocation(runSpec);
158
- // Record the run in the session ledger before its spawn, so a later resume can pick the
159
- // newest session of its own kind instead of whatever file is newest in the dir. The id is
160
- // the backend's own when it mints one ahead of the run (claude's UUID, or the resumed
161
- // record's id); pi mints none and keeps the session name.
162
- recordSession(opts.sessionDir, {
163
- agent: backend.id,
164
- id: invocation.sessionId ?? opts.sessionName,
165
- kind: opts.kind,
166
- name: opts.sessionName,
167
- cwd: opts.cwd,
168
- ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
169
- at: Date.now(),
170
- });
171
- const translator = backend.translator();
172
- // Decode stdout incrementally instead of per chunk: a raw Buffer.toString("utf8")
173
- // replaces any multi-byte character whose bytes straddle two 'data' events with U+FFFD,
174
- // corrupting that line's text (commit subjects, summaries, transcripts). StringDecoder
175
- // holds back the incomplete trailing bytes until the next chunk completes them.
176
- const decoder = new StringDecoder("utf8");
177
- // stderr gets its own incremental decoder for the same reason: the failure message and
178
- // the stderr-keyed matchers (crash, stream-severed, config) read this buffer, so a
179
- // character whose bytes straddle two 'data' events must not become U+FFFD.
180
- const stderrDecoder = new StringDecoder("utf8");
181
- let stderr = "";
182
- // The fallback-clone warning arrives at the START of a run, while `stderr` keeps only the
183
- // last 64 KiB; match each chunk (with a short carried tail for a straddle) so a chatty run
184
- // that pushes the warning out of that window still surfaces the mispriced id.
185
- let fallbackClone;
186
- let stderrTail = "";
187
- let settled = false;
188
- // The translator's native line buffer: feed the decoder's text through it and only complete
189
- // lines reach the translator, whose canonical output feeds the parser (and the raw log).
190
- // `nativeOverflow` marks a buffered line that passed MAX_NATIVE_LINE_CHARS without a newline:
191
- // its remainder is skipped to the next newline so it can never be mistaken for an event.
192
- let nativeTail = "";
193
- let nativeOverflow = false;
194
- // A private TMPDIR per run: concurrent agents default to literal /tmp names, and a shared
195
- // per-user TMPDIR would still collide. Removed when the run settles.
196
- const runTmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-run-"));
197
- const child = spawn(resolved.bin, invocation.args, {
198
- cwd: opts.cwd,
199
- // A backend that feeds the prompt on stdin (every registered one) gets a pipe the runner
200
- // writes and ends below; one that returns no stdin keeps stdio[0] "ignore".
201
- stdio: [invocation.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
202
- // pi sets its process title at startup, and so does every npm its tool calls run: on
203
- // macOS each would register with LaunchServices and leak a launchservicesd port.
204
- // Detached so pi leads its own process group: terminateChild signals the group, so a
205
- // run's tool-call grandchildren — killed or finished — die with it instead of leaking
206
- // to launchd.
207
- detached: true,
208
- // The run's mark, inherited by every tool call and its descendants: pi 0.87.1's bash
209
- // tool spawns each command detached in its OWN group, so the group sweep below cannot
210
- // reach what a tool call backgrounds (BUGS.md 2026-09-30) — the mark is the only
211
- // attribution that follows a reparented orphan. Appended to any inherited mark so a
212
- // nested harness under test keeps the outer run's.
213
- env: { ...invocation.env, TMPDIR: runTmp },
214
- });
215
- if (invocation.stdin !== undefined) {
216
- // A backend that dies before draining its stdin — an immediately-failing startup such as
217
- // an unknown flag, an unresumable session, or a not-logged-in exit — leaves this write
218
- // nothing to feed. Once the prompt exceeds the OS pipe buffer the write then fails EPIPE,
219
- // and an unhandled 'error' event on the stdin stream is thrown from the event loop and
220
- // takes the whole process (the whole team) down. The child's own exit status and stderr
221
- // already report why the run died, so swallowing the failed write adds no silence the
222
- // caller needs to break; git.ts's patchId carries the same guard for a patch-id that
223
- // dies before reading its input.
224
- child.stdin?.on("error", () => { });
225
- child.stdin?.end(invocation.stdin);
192
+ // A backend's asynchronous pre-run work (the codex write guard resolving its trust by
193
+ // spawning `app-server`, plans/codex-write-guard.md "Trust (parts 4–5)"): awaited just
194
+ // before the synchronous invocation, so trust is settled before invocation reads it. It
195
+ // never throws a run away — a throw is a warm-up failure, warned about on the run's own
196
+ // root (skipped with no root) and the run goes on; a codex trust lookup failure already
197
+ // records `off` and warns from prepare itself, so this catch is the guard for a backend
198
+ // whose prepare throws unexpectedly. The call itself is also guarded: a synchronous
199
+ // `prepare` throw would otherwise escape the async executor (leaving the run's promise
200
+ // pending and emitting an unhandled rejection), so it is warned-and-continued like an
201
+ // async rejection.
202
+ const warnRoot = nearestTumwaterRoot(opts.sessionDir);
203
+ let prepared;
204
+ try {
205
+ prepared = backend.prepare?.(runSpec);
226
206
  }
227
- // Armed now that the child exists, so the parser's parse-time onBlindRead (fires during
228
- // feed, always after spawn) can reach it.
229
- terminateRun = () => terminateChild(child);
230
- // The run's two watchdog clocks (src/pi/pi-watchdogs.ts): the tick deadline and the quiet
231
- // watchdog (quiet-kill plus the stalled-tool-call warning). The flags their timers set
232
- // (timedOut/timedOutProgressing/quietKilled/sawOutput) read live off the returned handle.
233
- const wd = startPiWatchdogs({
234
- progressCount: () => parser.progressCount,
235
- openToolCalls: () => parser.openToolCalls,
236
- tickTimeoutMs: opts.config.runTimeoutSeconds * 1000,
237
- quietMs: opts.config.quietTimeoutSeconds * 1000,
238
- stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
239
- // The run's backend id: claude never sends content updates, so the watchdog judges its
240
- // every open call by the buffered wall-clock threshold (plans/agent-backends.md).
241
- agent: backend.id,
242
- // The gate's fresh-session runs (reviewer and conflict resolver) get one deadline
243
- // extension while they are still making progress: a large diff's review otherwise
244
- // times out mid-verification and the next attempt re-reads it from scratch
245
- // (BUGS.md 2026-10-07). Authoring runs resume their session next tick, so one
246
- // deadline stays right for them.
247
- extendOnProgress: opts.kind === "gate",
248
- kill: () => terminateChild(child),
249
- onToolCallStalled: opts.onToolCallStalled,
250
- });
251
- let aborted = false;
252
- const onAbort = () => {
253
- aborted = true;
254
- terminateChild(child);
255
- };
256
- runOnAbort(opts.signal, onAbort);
257
- // Quiet watchdog and its stall warning: src/pi/pi-watchdogs.ts.
258
- const onLine = (line) => rawLog.stream.write(line + "\n");
259
- // Write one complete native line and feed its translation to the parser (which also hands
260
- // the canonical lines to onLine for the raw log).
261
- const emitNativeLine = (line) => {
262
- if (!nativeLog.broken)
263
- nativeLog.stream.write(line + "\n");
264
- for (const canonical of translator.translate(line))
265
- parser.feed(canonical + "\n", onLine);
266
- };
267
- // Split native stdout into complete lines, translate each, and feed the canonical lines to
268
- // the parser (which also hands them to onLine for the raw log). A trailing partial native
269
- // line stays buffered exactly as the parser's own buffer once held it. On the final flush
270
- // (`final`), a remaining tail is emitted as one line: a backend may close after flushing a
271
- // complete JSON event but not its newline terminator, and dropping that tail loses the
272
- // run's authoritative result (cost, final text). A genuinely truncated fragment parses as
273
- // no data (parseJsonObject → []), so this can only recover the last event.
274
- const feedCanonical = (text, final = false) => {
275
- // A buffered overflow: discard this line's remainder up to (and including) its newline, so
276
- // the stream resynchronizes on the next real event line instead of emitting a truncated one.
277
- if (nativeOverflow) {
278
- const nl = text.indexOf("\n");
279
- if (nl < 0)
280
- return;
281
- nativeOverflow = false;
282
- text = text.slice(nl + 1);
207
+ catch (error) {
208
+ if (warnRoot !== undefined) {
209
+ warnEventBestEffort(warnRoot, TUMWATER_SOURCE, `agent prepare failed, run continues: ${errorMessage(error)}`);
283
210
  }
284
- nativeTail += text;
285
- let start = 0;
286
- for (;;) {
287
- const nl = nativeTail.indexOf("\n", start);
288
- if (nl < 0)
289
- break;
290
- emitNativeLine(nativeTail.slice(start, nl));
291
- start = nl + 1;
211
+ }
212
+ // Only a backend with a `prepare` step (codex) awaits it; for every other backend the run
213
+ // spawns synchronously, preserving the pre-`prepare` timing a logical-clock watchdog test
214
+ // relies on (pi-watchdogs advances a fake clock immediately after runPi returns).
215
+ if (prepared !== undefined) {
216
+ try {
217
+ await prepared;
292
218
  }
293
- if (start > 0)
294
- nativeTail = nativeTail.slice(start);
295
- if (nativeTail.length > MAX_NATIVE_LINE_CHARS) {
296
- // No newline within the cap: drop the line and warn. On the final flush there is no
297
- // remainder to skip, so only the buffer is cleared.
298
- nativeTail = "";
299
- if (!final) {
300
- nativeOverflow = true;
301
- opts.onAgentWarning?.(`native output line exceeded ${MAX_NATIVE_LINE_CHARS / (1024 * 1024)} MiB without a newline and was discarded`);
219
+ catch (error) {
220
+ if (warnRoot !== undefined) {
221
+ warnEventBestEffort(warnRoot, TUMWATER_SOURCE, `agent prepare failed, run continues: ${errorMessage(error)}`);
302
222
  }
303
223
  }
304
- else if (final && nativeTail !== "") {
305
- emitNativeLine(nativeTail);
306
- nativeTail = "";
307
- }
308
- };
309
- child.stdout?.on("data", (chunk) => {
310
- wd.noteByte();
311
- feedCanonical(decoder.write(chunk));
312
- });
313
- child.stderr?.on("data", (chunk) => {
314
- // stderr is rare and meaningful (crash traces, warnings): treat it as progress.
315
- wd.noteStderr();
316
- const text = stderrDecoder.write(chunk);
317
- fallbackClone ??= backend.matchStderrWarning?.(text, stderrTail);
318
- stderrTail = (stderrTail + text).slice(-STDERR_MATCH_TAIL);
319
- stderr += text;
320
- if (stderr.length > 64 * 1024)
321
- stderr = stderr.slice(-64 * 1024);
322
- });
323
- const finish = (result) => {
324
- if (settled)
325
- return;
326
- settled = true;
327
- // A blind image read (plans/image-input.md "Detecting a blind read"): surface it to the
328
- // caller once, at settle, with the action its kind earned — the authoring run was killed
329
- // ("fail"), a gate run only recorded it ("logged"). The caller logs the image_blind event
330
- // with its own loop/tick context.
331
- if (parser.imageBlind) {
332
- opts.onBlindRead?.(parser.imageBlind.path, opts.kind === "author" ? "fail" : "logged");
333
- }
334
- // Best-effort: an unremovable scratch dir (read-only subdir, a straggler still writing) must
335
- // not throw out of the close handler before the run's result resolves.
336
- try {
337
- fs.rmSync(runTmp, { recursive: true, force: true, maxRetries: 3 });
338
- }
339
- catch {
340
- /* left for the OS temp sweep */
224
+ }
225
+ try {
226
+ const invocation = backend.invocation(runSpec);
227
+ // The id the pre-spawn ledger record carries: a backend that mints one ahead of the run
228
+ // (claude, a codex resume), else the session name (pi, a fresh codex run — its id is born
229
+ // in the stream). The stream-mint pass below compares against it so a codex run appends
230
+ // its streamed thread id exactly once, and only when it differs from this.
231
+ const preSpawnSessionId = invocation.sessionId ?? opts.sessionName;
232
+ // Record the run in the session ledger before its spawn, so a later resume can pick the
233
+ // newest session of its own kind instead of whatever file is newest in the dir. The id is
234
+ // the backend's own when it mints one ahead of the run (claude's UUID, or the resumed
235
+ // record's id); pi mints none and keeps the session name.
236
+ recordSession(opts.sessionDir, {
237
+ agent: backend.id,
238
+ id: preSpawnSessionId,
239
+ kind: opts.kind,
240
+ name: opts.sessionName,
241
+ cwd: opts.cwd,
242
+ ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
243
+ at: Date.now(),
244
+ });
245
+ const translator = backend.translator();
246
+ // Decode stdout incrementally instead of per chunk: a raw Buffer.toString("utf8")
247
+ // replaces any multi-byte character whose bytes straddle two 'data' events with U+FFFD,
248
+ // corrupting that line's text (commit subjects, summaries, transcripts). StringDecoder
249
+ // holds back the incomplete trailing bytes until the next chunk completes them.
250
+ const decoder = new StringDecoder("utf8");
251
+ // stderr gets its own incremental decoder for the same reason: the failure message and
252
+ // the stderr-keyed matchers (crash, stream-severed, config) read this buffer, so a
253
+ // character whose bytes straddle two 'data' events must not become U+FFFD.
254
+ const stderrDecoder = new StringDecoder("utf8");
255
+ let stderr = "";
256
+ // The fallback-clone warning arrives at the START of a run, while `stderr` keeps only the
257
+ // last 64 KiB; match each chunk (with a short carried tail for a straddle) so a chatty run
258
+ // that pushes the warning out of that window still surfaces the mispriced id.
259
+ let fallbackClone;
260
+ let stderrTail = "";
261
+ let settled = false;
262
+ // The translator's native line buffer: feed the decoder's text through it and only complete
263
+ // lines reach the translator, whose canonical output feeds the parser (and the raw log).
264
+ // `nativeOverflow` marks a buffered line that passed MAX_NATIVE_LINE_CHARS without a newline:
265
+ // its remainder is skipped to the next newline so it can never be mistaken for an event.
266
+ let nativeTail = "";
267
+ let nativeOverflow = false;
268
+ const child = spawn(resolved.bin, invocation.args, {
269
+ cwd: opts.cwd,
270
+ // A backend that feeds the prompt on stdin (every registered one) gets a pipe the runner
271
+ // writes and ends below; one that returns no stdin keeps stdio[0] "ignore".
272
+ stdio: [invocation.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
273
+ // pi sets its process title at startup, and so does every npm its tool calls run: on
274
+ // macOS each would register with LaunchServices and leak a launchservicesd port.
275
+ // Detached so pi leads its own process group: terminateChild signals the group, so a
276
+ // run's tool-call grandchildren — killed or finished — die with it instead of leaking
277
+ // to launchd.
278
+ detached: true,
279
+ // The run's mark, inherited by every tool call and its descendants: pi 0.87.1's bash
280
+ // tool spawns each command detached in its OWN group, so the group sweep below cannot
281
+ // reach what a tool call backgrounds (BUGS.md 2026-09-30) — the mark is the only
282
+ // attribution that follows a reparented orphan. Appended to any inherited mark so a
283
+ // nested harness under test keeps the outer run's.
284
+ env: { ...invocation.env, TMPDIR: runTmp },
285
+ });
286
+ if (invocation.stdin !== undefined) {
287
+ // A backend that dies before draining its stdin — an immediately-failing startup such as
288
+ // an unknown flag, an unresumable session, or a not-logged-in exit — leaves this write
289
+ // nothing to feed. Once the prompt exceeds the OS pipe buffer the write then fails EPIPE,
290
+ // and an unhandled 'error' event on the stdin stream is thrown from the event loop and
291
+ // takes the whole process (the whole team) down. The child's own exit status and stderr
292
+ // already report why the run died, so swallowing the failed write adds no silence the
293
+ // caller needs to break; git.ts's patchId carries the same guard for a patch-id that
294
+ // dies before reading its input.
295
+ child.stdin?.on("error", () => { });
296
+ child.stdin?.end(invocation.stdin);
341
297
  }
342
- wd.stop();
343
- opts.signal?.removeEventListener("abort", onAbort);
344
- // Surface the translator's per-run warnings once, after all native lines were fed.
345
- for (const warning of translator.warnings?.() ?? [])
346
- opts.onAgentWarning?.(warning);
347
- // Resolve only once the logs have flushed: writes complete on libuv's threadpool, so
348
- // resolving before 'finish' can leave the tail of <role>.pi.jsonl (or the native log)
349
- // unwritten — a tick that dies right after runAgent settles (an abort during a restart
350
- // drain, a supervisor swap) loses its last lines. end()'s callback fires on 'finish';
351
- // if a stream errors instead it never does, so settle from 'error' too — a broken log
352
- // degrades to a lost log, never a stuck tick.
353
- let resolved = false;
354
- const settle = () => {
355
- if (!resolved) {
356
- resolved = true;
357
- resolve(result);
298
+ // Armed now that the child exists, so the parser's parse-time onBlindRead (fires during
299
+ // feed, always after spawn) can reach it.
300
+ terminateRun = () => terminateChild(child);
301
+ // The run's two watchdog clocks (src/pi/pi-watchdogs.ts): the run deadline and the quiet
302
+ // watchdog (quiet-kill plus the stalled-tool-call warning). The flags their timers set
303
+ // (timedOut/timedOutProgressing/quietKilled/sawOutput) read live off the returned handle.
304
+ const wd = startPiWatchdogs({
305
+ progressCount: () => parser.progressCount,
306
+ openToolCalls: () => parser.openToolCalls,
307
+ runTimeoutMs: opts.config.runTimeoutSeconds * 1000,
308
+ quietMs: opts.config.quietTimeoutSeconds * 1000,
309
+ stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
310
+ // The run's backend id: claude never sends content updates, so the watchdog judges its
311
+ // every open call by the buffered wall-clock threshold (plans/agent-backends.md).
312
+ agent: backend.id,
313
+ // The gate's fresh-session runs (reviewer and conflict resolver) get one deadline
314
+ // extension while they are still making progress: a large diff's review otherwise
315
+ // times out mid-verification and the next attempt re-reads it from scratch
316
+ // (BUGS.md 2026-10-07). Authoring agent runs resume their session on the next one, so one
317
+ // deadline stays right for them.
318
+ extendOnProgress: opts.kind === "gate",
319
+ kill: () => terminateChild(child),
320
+ onToolCallStalled: opts.onToolCallStalled,
321
+ });
322
+ let aborted = false;
323
+ // A blind image read on an authoring run deliberately kills it, like the watchdogs do.
324
+ const wasKilled = () => aborted || wd.timedOut || wd.quietKilled || blindRead !== undefined;
325
+ const onAbort = () => {
326
+ aborted = true;
327
+ terminateChild(child);
328
+ };
329
+ runOnAbort(opts.signal, onAbort);
330
+ // Quiet watchdog and its stall warning: src/pi/pi-watchdogs.ts.
331
+ const onLine = (line) => rawLog.stream.write(line + "\n");
332
+ // The translator's session id, once the stream has named it (codex's `thread.started`).
333
+ // The pre-spawn record for a fresh codex run carries the session name; the id it actually
334
+ // ran with is born in the stream, so the first time this returns one the pre-spawn record
335
+ // lacked, append a second ledger record carrying it (same kind, name, cwd and model) — the
336
+ // record a later `exec resume` resumes (design §4 "Sessions").
337
+ let streamMintedSession = false;
338
+ const emitNativeLine = (line) => {
339
+ if (!nativeLog.broken)
340
+ nativeLog.stream.write(line + "\n");
341
+ for (const canonical of translator.translate(line))
342
+ parser.feed(canonical + "\n", onLine);
343
+ const sid = translator.sessionId?.();
344
+ if (sid !== undefined && sid !== preSpawnSessionId && !streamMintedSession) {
345
+ streamMintedSession = true;
346
+ recordSession(opts.sessionDir, {
347
+ agent: backend.id,
348
+ id: sid,
349
+ kind: opts.kind,
350
+ name: opts.sessionName,
351
+ cwd: opts.cwd,
352
+ ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
353
+ at: Date.now(),
354
+ });
358
355
  }
359
356
  };
360
- let pending = 0;
361
- const flushed = () => {
362
- pending -= 1;
363
- if (pending === 0)
364
- settle();
357
+ // Split native stdout into complete lines, translate each, and feed the canonical lines to
358
+ // the parser (which also hands them to onLine for the raw log). A trailing partial native
359
+ // line stays buffered exactly as the parser's own buffer once held it. On the final flush
360
+ // (`final`), a remaining tail is emitted as one line: a backend may close after flushing a
361
+ // complete JSON event but not its newline terminator, and dropping that tail loses the
362
+ // run's authoritative result (cost, final text). A genuinely truncated fragment parses as
363
+ // no data (parseJsonObject → []), so this can only recover the last event.
364
+ const feedCanonical = (text, final = false) => {
365
+ // A buffered overflow: discard this line's remainder up to (and including) its newline, so
366
+ // the stream resynchronizes on the next real event line instead of emitting a truncated one.
367
+ if (nativeOverflow) {
368
+ const nl = text.indexOf("\n");
369
+ if (nl < 0)
370
+ return;
371
+ nativeOverflow = false;
372
+ text = text.slice(nl + 1);
373
+ }
374
+ nativeTail += text;
375
+ let start = 0;
376
+ for (;;) {
377
+ const nl = nativeTail.indexOf("\n", start);
378
+ if (nl < 0)
379
+ break;
380
+ emitNativeLine(nativeTail.slice(start, nl));
381
+ start = nl + 1;
382
+ }
383
+ if (start > 0)
384
+ nativeTail = nativeTail.slice(start);
385
+ if (nativeTail.length > MAX_NATIVE_LINE_CHARS) {
386
+ // No newline within the cap: drop the line and warn. On the final flush there is no
387
+ // remainder to skip, so only the buffer is cleared.
388
+ nativeTail = "";
389
+ if (!final) {
390
+ nativeOverflow = true;
391
+ opts.onAgentWarning?.(`native output line exceeded ${MAX_NATIVE_LINE_CHARS / (1024 * 1024)} MiB without a newline and was discarded`);
392
+ }
393
+ }
394
+ else if (final && nativeTail !== "") {
395
+ emitNativeLine(nativeTail);
396
+ nativeTail = "";
397
+ }
365
398
  };
366
- for (const log of [rawLog, nativeLog]) {
367
- log.stream.on("error", settle);
368
- if (!log.broken) {
369
- pending += 1;
370
- log.stream.end(flushed);
399
+ child.stdout?.on("data", (chunk) => {
400
+ wd.noteByte();
401
+ feedCanonical(decoder.write(chunk));
402
+ });
403
+ child.stderr?.on("data", (chunk) => {
404
+ // stderr is rare and meaningful (crash traces, warnings): treat it as progress.
405
+ wd.noteStderr();
406
+ const text = stderrDecoder.write(chunk);
407
+ fallbackClone ??= backend.matchStderrWarning?.(text, stderrTail);
408
+ stderrTail = (stderrTail + text).slice(-STDERR_MATCH_TAIL);
409
+ stderr += text;
410
+ if (stderr.length > 64 * 1024)
411
+ stderr = stderr.slice(-64 * 1024);
412
+ });
413
+ const finish = (result) => {
414
+ if (settled)
415
+ return;
416
+ settled = true;
417
+ // A blind image read (plans/image-input.md "Detecting a blind read"): surface it to the
418
+ // caller once, at settle, with the action its kind earned — the authoring run was killed
419
+ // ("fail"), a gate run only recorded it ("logged"). The caller logs the image_blind event
420
+ // with its own role/run context.
421
+ if (parser.imageBlind) {
422
+ opts.onBlindRead?.(parser.imageBlind.path, opts.kind === "author" ? "fail" : "logged");
371
423
  }
372
- }
373
- if (pending === 0)
374
- settle();
375
- };
376
- // Every PiRunResult is built here from what the parser has seen plus how the run ended,
377
- // so adding a field to PiRunResult touches this single place. The spawn-error path passes
378
- // only an errorMessage override: at that point no output ever arrived, so the parser holds
379
- // exactly its initial values — which is precisely what a failed spawn reports.
380
- const resultFromParser = (overrides) => ({
381
- ok: false,
382
- finalText: parser.finalText,
383
- nothingToDo: parser.declaredNothingToDo,
384
- refused: parser.refused,
385
- refusedReason: parser.refusedReason || undefined,
386
- verdictText: parser.verdictText || undefined,
387
- outputTokens: parser.outputTokens,
388
- promptTokens: parser.promptTokens,
389
- cacheReadTokens: parser.cacheReadTokens,
390
- preEditPromptTokens: parser.preEditPromptTokens,
391
- firstEditTurn: parser.firstEditTurn,
392
- peakContextTokens: parser.peakContextTokens,
393
- turns: parser.turns,
394
- costUsd: parser.costUsd,
395
- stopReason: parser.stopReason,
396
- errorMessage: undefined,
397
- timedOut: false,
398
- timedOutProgressing: false,
399
- quietKilled: false,
400
- aborted,
401
- contextExceeded: parser.contextExceeded,
402
- transientServerTimeout: parser.transientServerTimeout,
403
- transientRateLimit: parser.transientRateLimit,
404
- transientBackend: parser.transientBackend,
405
- backendKind: parser.backendFailureKind,
406
- configError: parser.configError,
407
- retryAfterSeconds: parser.retryAfterSeconds,
408
- transientPiCrash: false,
409
- fallbackClone,
410
- finalMessageContentless: parser.finalMessageContentless,
411
- producedAssistantContent: parser.producedAssistantContent,
412
- compacted: parser.compacted,
413
- budgetCapped: parser.budgetCapped,
414
- resumeMissing: parser.resumeMissing,
415
- billing: parser.billing,
416
- rateWindows: parser.rateWindows.length > 0 ? parser.rateWindows : undefined,
417
- usedOverage: parser.usedOverage,
418
- ...overrides,
419
- });
420
- child.on("error", (err) => {
421
- finish(resultFromParser({
422
- errorMessage: spawnErrorMessage(resolved, err.message, backend),
423
- }));
424
- });
425
- // Sweep the run however it ended, a normal exit included, in two passes:
426
- //
427
- // 1. The group pi led: pi exits before its orphans — a tool call's `(server &)` or
428
- // `cd … && server &` is reparented to PID 1 but stays in pi's group — and the harness
429
- // cannot rely on the model's own cleanup (BUGS.md 2026-09-23: qa's unauthenticated
430
- // `gui --all-interfaces` listened on the LAN for 7.5 hours after a tick that ended
431
- // "Everything checked out"). On 'exit', not 'close': pi has just been reaped, so the
432
- // pgid still names only its group (never reissued while a member lives), and an orphan
433
- // holding pi's stdio open dies now instead of holding 'close' — and the run — open.
434
- // Synchronous, so it adds nothing to the run's resolution; on the kill paths it
435
- // re-sends a SIGTERM the group already had.
436
- //
437
- // 2. The marker sweep: pi 0.87.1's bash tool starts every command detached, in its own
438
- // process group, so whatever a tool call backgrounds sits in a group whose leader is
439
- // not pi and pass 1 never reaches it (BUGS.md 2026-09-30 — leaked servers and test
440
- // workers outlived their ticks by hours). Every process the run started carries the
441
- // run's TUMWATER_RUN mark in its environment, so the same-user scan finds them
442
- // wherever they were reparented. Fire-and-forget: one process-table scan, never
443
- // awaited — the run resolves on pi's output, and the sweep's own SIGKILL escalation
444
- // (10 s) is unref'd like signalTree's.
445
- child.on("exit", () => {
446
- terminateChild(child);
447
- void sweepRunMarker(runMarker).catch(() => { });
448
- });
449
- child.on("close", (code) => {
450
- // Flush any bytes the decoder held back at stream end and any unterminated final line
451
- // so the tail is not lost, then let the translator flush its own pending canonical
452
- // line(s) — a batching backend's unclosed last assistant message, which a killed run
453
- // never closes with a later event.
454
- feedCanonical(decoder.end(), true);
455
- for (const canonical of translator.finalize?.() ?? [])
456
- parser.feed(canonical + "\n", onLine);
457
- // A blind image read on an authoring run deliberately killed it (terminateRun above),
458
- // so its exit is a kill like the watchdogs' — the half-done work is discarded.
459
- const killed = aborted || wd.timedOut || wd.quietKilled || blindRead !== undefined;
460
- // The backend's post-run reconciliation (opencode's authoritative session export), only
461
- // when the run is not already settled (a failed spawn settles in the 'error' handler
462
- // before 'close') and was not killed — a killed run's session is half-done and no export
463
- // should spend its 10 s on it. Its lines are written and parsed like streamed ones, so
464
- // they reach the raw log and the parser's authoritative fold (run_cost/run_usage).
465
- if (!settled && !killed) {
466
- for (const canonical of backend.afterRun?.(runSpec, invocation, translator) ?? []) {
467
- parser.feed(canonical + "\n", onLine);
424
+ // Best-effort: an unremovable scratch dir (read-only subdir, a straggler still writing) must
425
+ // not throw out of the close handler before the run's result resolves.
426
+ try {
427
+ removeTree(runTmp);
468
428
  }
469
- }
470
- const failed = killed ||
471
- parser.stopReason === "error" ||
472
- (code !== 0 && !parser.finalText.trim());
473
- // The backend classifies the exit's stderr (pi: the transient JSON-parse crash, a
474
- // provider-wide backend failure, a permanent config error). The runner merges that with
475
- // the parser's own event-derived flags — one exit has one cause.
476
- const cls = backend.classifyExit({ code, stderr, killed });
477
- const stderrBackend = cls.backendKind;
478
- const configError = !killed &&
479
- cls.transientPiCrash !== true &&
480
- stderrBackend === undefined &&
481
- (parser.configError || cls.configError === true);
482
- // The blind-read failure names both the file and the model (plans/image-input.md
483
- // "Failing the tick"): take it over any generic exit text so lastError is actionable.
484
- const errorMessage = blindRead !== undefined
485
- ? `read ${blindRead}, but ${opts.config.model ?? "the model"} cannot read images`
486
- : aborted
487
- ? "stopped by tumwater shutdown"
488
- : wd.quietKilled
489
- ? `killed as hung: no agent progress for over ${opts.config.quietTimeoutSeconds}s`
490
- : wd.timedOut
491
- ? wd.timedOutProgressing
492
- ? `timed out after ${wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
493
- : `timed out after ${wd.timeoutBudgetMs / 1000}s`
494
- : (parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
495
- finish(resultFromParser({
496
- ok: !failed,
497
- transientPiCrash: cls.transientPiCrash === true,
498
- transientBackend: parser.transientBackend || cls.transientBackend === true,
499
- backendKind: parser.backendFailureKind ?? stderrBackend,
500
- configError,
501
- // Kept distinct on purpose: a hung tool call leaves its session and worktree edits
502
- // intact, so the loop resumes them (quiet_killed) instead of discarding them as an
503
- // unfulfilled timeout does. A deadline that fired on a run still making progress
504
- // gets the same treatment (BUGS.md 2026-09-29).
505
- timedOut: wd.timedOut,
506
- timedOutProgressing: wd.timedOut && wd.timedOutProgressing,
507
- quietKilled: wd.quietKilled,
508
- imageBlind: parser.imageBlind,
509
- errorMessage,
510
- }));
511
- });
429
+ catch {
430
+ /* left for the OS temp sweep */
431
+ }
432
+ wd.stop();
433
+ opts.signal?.removeEventListener("abort", onAbort);
434
+ // Surface the translator's per-run warnings once, after all native lines were fed.
435
+ for (const warning of translator.warnings?.() ?? [])
436
+ opts.onAgentWarning?.(warning);
437
+ // Resolve only once the logs have flushed: writes complete on libuv's threadpool, so
438
+ // resolving before 'finish' can leave the tail of <role>.pi.jsonl (or the native log)
439
+ // unwritten — a run that dies right after runAgent settles (an abort during a restart
440
+ // drain, a supervisor swap) loses its last lines. end()'s callback fires on 'finish';
441
+ // if a stream errors instead it never does, so settle from 'error' too — a broken log
442
+ // degrades to a lost log, never a stuck run.
443
+ let resolved = false;
444
+ const settle = () => {
445
+ if (!resolved) {
446
+ resolved = true;
447
+ resolve(result);
448
+ }
449
+ };
450
+ let pending = 0;
451
+ const flushed = () => {
452
+ pending -= 1;
453
+ if (pending === 0)
454
+ settle();
455
+ };
456
+ for (const log of [rawLog, nativeLog]) {
457
+ log.stream.on("error", settle);
458
+ if (!log.broken) {
459
+ pending += 1;
460
+ log.stream.end(flushed);
461
+ }
462
+ }
463
+ if (pending === 0)
464
+ settle();
465
+ };
466
+ // The run state the settlement-half handlers (run-settle.ts) read when their event
467
+ // fires. The getters read through to the run's still-mutating feed state, so a handler
468
+ // sees the current value rather than a snapshot taken when its listener was registered.
469
+ const state = {
470
+ child,
471
+ resolved,
472
+ backend,
473
+ runMarker,
474
+ parser,
475
+ translator,
476
+ decoder,
477
+ wd,
478
+ finish,
479
+ feedCanonical,
480
+ onLine,
481
+ runSpec,
482
+ invocation,
483
+ stderr: () => stderr,
484
+ fallbackClone: () => fallbackClone,
485
+ settled: () => settled,
486
+ blindRead: () => blindRead,
487
+ aborted: () => aborted,
488
+ wasKilled: () => wasKilled(),
489
+ kind: opts.kind,
490
+ config: opts.config,
491
+ onLeftoversSwept: opts.onLeftoversSwept,
492
+ };
493
+ child.on("error", (err) => {
494
+ settleError(state, err);
495
+ });
496
+ // Sweep the run however it ended, a normal exit included, in two passes:
497
+ //
498
+ // 1. The group pi led: pi exits before its orphans — a tool call's `(server &)` or
499
+ // `cd … && server &` is reparented to PID 1 but stays in pi's group — and the harness
500
+ // cannot rely on the model's own cleanup (BUGS.md 2026-09-23: qa's unauthenticated
501
+ // `gui --all-interfaces` listened on the LAN for 7.5 hours after a run that ended
502
+ // "Everything checked out"). On 'exit', not 'close': pi has just been reaped, so the
503
+ // pgid still names only its group (never reissued while a member lives), and an orphan
504
+ // holding pi's stdio open dies now instead of holding 'close' — and the run — open.
505
+ // Synchronous, so it adds nothing to the run's resolution; on the kill paths it
506
+ // re-sends a SIGTERM the group already had.
507
+ //
508
+ // 2. The marker sweep: pi 0.87.1's bash tool starts every command detached, in its own
509
+ // process group, so whatever a tool call backgrounds sits in a group whose leader is
510
+ // not pi and pass 1 never reaches it (BUGS.md 2026-09-30 — leaked servers and test
511
+ // workers outlived their runs by hours). Every process the run started carries the
512
+ // run's TUMWATER_RUN mark in its environment, so the same-user scan finds them
513
+ // wherever they were reparented. Fire-and-forget: one process-table scan, never
514
+ // awaited — the run resolves on pi's output, and the sweep's own SIGKILL escalation
515
+ // (10 s) is unref'd like signalTree's.
516
+ child.on("exit", () => {
517
+ settleExit(state);
518
+ });
519
+ child.on("close", (code) => {
520
+ settleClose(state, code);
521
+ });
522
+ }
523
+ catch (err) {
524
+ // A synchronous throw in the run's build-and-spawn stage (claude's invocation rejects a
525
+ // run with no resolved model) rejects the run, preserving the contract a synchronous
526
+ // executor's throw used to get from the Promise constructor.
527
+ reject(err instanceof Error ? err : new Error(`runAgent: ${String(err)}`));
528
+ }
512
529
  });
513
530
  }
514
531
  /** The historical name for the neutral runner: every existing call site, fake, and test keeps