tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -1,62 +1,41 @@
1
1
  /** The backend-neutral agent runner (plans/agent-backends.md \"The seam\"): everything a run
2
- * needs regardless of which agent it drives — the spawn, the `tumwater_run` raw-log marker,
3
- * the watchdog wiring, abort, and stderr capture. The backend (src/agent/agent-backend.ts, with
2
+ * needs regardless of which agent it drives — the watchdog wiring, abort, and stderr capture.
3
+ * The backend (src/agent/agent-backend.ts, with
4
4
  * pi in src/pi/pi-backend.ts) provides the argv/stdin/env, the native-to-canonical stdout
5
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
6
+ * matcher, and the exit classification. This file keeps the coordinator — setup, the watchdog
7
+ * wiring, abort, the child's stream-attachment and the settlement-state assembly — and
8
+ * delegates the build-and-spawn stage (the marker,
9
+ * parser, bin resolution, `prepare`, invocation and spawn) to src/agent/run-spawn.ts, the
10
+ * streaming pipeline (native-to-canonical stdout split, the stderr window, the stream-minted
11
+ * session record and the settle/flush) to src/agent/run-stream.ts, and the post-run settlement
12
+ * (spawn-error result, exit orphan sweep, close-time reconcile and
9
13
  * classify) to src/agent/run-settle.ts. `runPi` (src/pi/pi.ts) is the thin alias the existing
10
14
  * call sites use, so a caller and its fakes are untouched by this split. */
11
- import { spawn } from "node:child_process";
12
15
  import fs from "node:fs";
13
16
  import os from "node:os";
14
17
  import path from "node:path";
15
- import { StringDecoder } from "node:string_decoder";
16
- import { ensureDir, ensureParentDir, removeTree, rotateIfLarge } from "../files/files.js";
18
+ import { ensureDir, removeTree } from "../files/files.js";
17
19
  import { runOnAbort, terminateChild } from "../process/process.js";
18
- import { makeRunMarker } from "../process/run-marker.js";
19
- import { PiStreamParser } from "../pi/pi-stream.js";
20
20
  import { startPiWatchdogs } from "../pi/pi-watchdogs.js";
21
21
  import { DEFAULT_BACKEND, backendById, registerAgentBackend } from "./agent-backend.js";
22
- import { recordSession } from "./session-ledger.js";
23
22
  import { errorMessage } from "../text/text.js";
24
- import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
25
23
  import { piBackend } from "../pi/pi-backend.js";
26
24
  import { claudeBackend } from "./claude/claude-backend.js";
27
25
  import { codexBackend } from "./codex/codex-backend.js";
28
26
  import { opencodeBackend } from "./opencode/opencode-backend.js";
29
27
  import { setupFailureResult, settleClose, settleError, settleExit } from "./run-settle.js";
30
- import { seamImageInput } from "./image-input.js";
31
- import { ensureNotesShim } from "./notes-shim.js";
32
- import { nearestTumwaterRoot } from "../paths.js";
33
- import { piModelsPath, readPiProviders } from "../pi/pi-models.js";
34
- /** How many trailing stderr characters are carried into the next chunk's warning match, so a
35
- * warning split across two `data` events is still seen. The warnings are short lines, so a few
36
- * hundred characters always cover a straddle. */
37
- const STDERR_MATCH_TAIL = 512;
38
- /** The largest native stdout line the runner will buffer. A complete native event is one JSON
39
- * object; a line this long is already at the default whole-log cap (`logMaxBytes`, 16 MiB), so
40
- * continuing to buffer it can only be a malformed stream. Past it `feedCanonical` drops the
41
- * buffered line and skips input to the next newline, so a backend that streams bytes with no
42
- * newline — each byte resetting the quiet watchdog — cannot grow the buffer until the run
43
- * timeout (4 h by default) exhausts the harness's memory. */
44
- export const MAX_NATIVE_LINE_CHARS = 16 * 1024 * 1024;
28
+ import { openAppendLog, spawnPiRun } from "./run-spawn.js";
29
+ import { createAgentStream } from "./run-stream.js";
30
+ // The streaming pipeline's over-long-native-line cap stays reachable from here, the historical
31
+ // home the test suite still imports it from, even though the value now lives in run-stream.ts.
32
+ export { MAX_NATIVE_LINE_CHARS } from "./run-stream.js";
45
33
  // The seam's adapters. Registering here — where the lookup happens — keeps the registration
46
34
  // explicit and lets a test register a fake under its own id.
47
35
  registerAgentBackend(piBackend);
48
36
  registerAgentBackend(claudeBackend);
49
37
  registerAgentBackend(opencodeBackend);
50
38
  registerAgentBackend(codexBackend);
51
- function openAppendLog(file, maxBytes) {
52
- ensureParentDir(file);
53
- rotateIfLarge(file, maxBytes);
54
- const log = { stream: fs.createWriteStream(file, { flags: "a" }), broken: false };
55
- log.stream.on("error", () => {
56
- log.broken = true;
57
- });
58
- return log;
59
- }
60
39
  /** Run the configured agent backend non-interactively in a worktree and distill the result.
61
40
  * Never throws: a setup failure (session dir, log file, scratch dir) resolves as a failed run
62
41
  * whose errorMessage names it. */
@@ -95,209 +74,33 @@ export function runAgent(opts) {
95
74
  resolve(setupFailureResult(backend.id, `could not prepare the agent run: ${errorMessage(err)}`));
96
75
  return;
97
76
  }
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.
104
- let blindRead;
105
- let terminateRun;
106
- let parser;
107
- let runMarker;
108
- let resolved;
109
- let binDir;
110
- let runSpec;
111
77
  try {
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
- };
184
- }
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;
191
- }
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);
206
- }
207
- catch (error) {
208
- if (warnRoot !== undefined) {
209
- warnEventBestEffort(warnRoot, TUMWATER_SOURCE, `agent prepare failed, run continues: ${errorMessage(error)}`);
210
- }
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;
218
- }
219
- catch (error) {
220
- if (warnRoot !== undefined) {
221
- warnEventBestEffort(warnRoot, TUMWATER_SOURCE, `agent prepare failed, run continues: ${errorMessage(error)}`);
222
- }
223
- }
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);
297
- }
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);
78
+ // The whole build-and-spawn stage — the marker write, parser construction, run marker,
79
+ // backend-bin resolution, notes shim, run spec, the backend's optional `prepare`, the
80
+ // session-ledger record, translator/decoders, and the child spawn — now lives in
81
+ // spawnPiRun (src/agent/run-spawn.ts) and returns the SpawnedRun bundle. A synchronous
82
+ // throw anywhere in it (a broken marker write, parser construction, backend.resolveBin,
83
+ // claude's invocation rejecting a run with no model) propagates out of the await and is
84
+ // rejected by the catch below, matching what a synchronous executor's throw used to get
85
+ // from the Promise constructor.
86
+ // A backend without a `prepare` step (pi, claude, opencode) builds and spawns synchronously
87
+ // and hands the bundle straight back, so the `instanceof` below takes the sync leg and the
88
+ // watchdogs (a setInterval registration) start in the same synchronous step the logical-clock
89
+ // watchdog tests pin (they advance the fake clock immediately after runPi returns). A backend
90
+ // WITH a `prepare` (codex) returns a promise that resolves after its trust is settled.
91
+ const spawnedResult = spawnPiRun(backend, { raw: rawLog }, runTmp, opts);
92
+ const spawned = spawnedResult instanceof Promise ? await spawnedResult : spawnedResult;
93
+ // Bind the coordinator's objects from the bundle, then hand the stream-side objects (parser,
94
+ // translator, decoders) to createAgentStream below, which owns them with the pipeline.
95
+ const child = spawned.child;
96
+ const resolved = spawned.resolved;
97
+ const runSpec = spawned.runSpec;
98
+ const invocation = spawned.invocation;
99
+ const parser = spawned.parser;
100
+ const translator = spawned.translator;
101
+ const decoder = spawned.decoder;
102
+ const runMarker = spawned.runMarker;
103
+ const blindRead = spawned.blindRead;
301
104
  // The run's two watchdog clocks (src/pi/pi-watchdogs.ts): the run deadline and the quiet
302
105
  // watchdog (quiet-kill plus the stalled-tool-call warning). The flags their timers set
303
106
  // (timedOut/timedOutProgressing/quietKilled/sawOutput) read live off the returned handle.
@@ -321,148 +124,30 @@ export function runAgent(opts) {
321
124
  });
322
125
  let aborted = false;
323
126
  // 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;
127
+ const wasKilled = () => aborted || wd.timedOut || wd.quietKilled || blindRead() !== undefined;
325
128
  const onAbort = () => {
326
129
  aborted = true;
327
130
  terminateChild(child);
328
131
  };
329
132
  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
- });
355
- }
356
- };
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
- }
398
- };
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);
133
+ // The streaming pipeline — native-to-canonical stdout split with the over-long-line
134
+ // discard, the stderr window and its fallback-clone warning matcher, the stream-minted
135
+ // session's second ledger record, and the settle/flush that resolves the run — now lives
136
+ // in src/agent/run-stream.ts behind the AgentStream interface. run-agent only wires the
137
+ // child's `data` events to it and binds its getters into the settlement state below.
138
+ const stream = createAgentStream({
139
+ opts,
140
+ backend,
141
+ spawned,
142
+ wd,
143
+ onAbort,
144
+ rawLog,
145
+ nativeLog,
146
+ runTmp,
147
+ resolve,
412
148
  });
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");
423
- }
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);
428
- }
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
- };
149
+ child.stdout?.on("data", (chunk) => stream.feedStdout(chunk));
150
+ child.stderr?.on("data", (chunk) => stream.feedStderr(chunk));
466
151
  // The run state the settlement-half handlers (run-settle.ts) read when their event
467
152
  // fires. The getters read through to the run's still-mutating feed state, so a handler
468
153
  // sees the current value rather than a snapshot taken when its listener was registered.
@@ -475,15 +160,15 @@ export function runAgent(opts) {
475
160
  translator,
476
161
  decoder,
477
162
  wd,
478
- finish,
479
- feedCanonical,
480
- onLine,
163
+ finish: stream.finish,
164
+ feedCanonical: stream.feedCanonical,
165
+ onLine: stream.onLine,
481
166
  runSpec,
482
167
  invocation,
483
- stderr: () => stderr,
484
- fallbackClone: () => fallbackClone,
485
- settled: () => settled,
486
- blindRead: () => blindRead,
168
+ stderr: stream.stderr,
169
+ fallbackClone: stream.fallbackClone,
170
+ settled: stream.settled,
171
+ blindRead: () => blindRead(),
487
172
  aborted: () => aborted,
488
173
  wasKilled: () => wasKilled(),
489
174
  kind: opts.kind,
@@ -523,7 +208,16 @@ export function runAgent(opts) {
523
208
  catch (err) {
524
209
  // A synchronous throw in the run's build-and-spawn stage (claude's invocation rejects a
525
210
  // run with no resolved model) rejects the run, preserving the contract a synchronous
526
- // executor's throw used to get from the Promise constructor.
211
+ // executor's throw used to get from the Promise constructor. No child was spawned, so no
212
+ // `error`/`close` ever fires and the settle path that removes the private TMPDIR
213
+ // (run-stream.ts's finish) never runs — remove it here, best-effort like finish does, so
214
+ // a broken run does not leave a `tumwater-run-*` temp dir behind.
215
+ try {
216
+ removeTree(runTmp);
217
+ }
218
+ catch {
219
+ /* left for the OS temp sweep */
220
+ }
527
221
  reject(err instanceof Error ? err : new Error(`runAgent: ${String(err)}`));
528
222
  }
529
223
  });
@@ -2,8 +2,9 @@
2
2
  * same seam the runner-run → runner-outcome split used): everything that happens after the
3
3
  * run's live feed — the spawn-`error` result, the `exit` orphan sweep, and the `close`-time
4
4
  * flush/reconcile/classify that builds the `PiRunResult`. run-agent.ts keeps the lifecycle
5
- * (pre-spawn setup, `prepare`, spawn, decoders, stream pipeline, watchdogs, the feed) and
6
- * delegates its `error`/`exit`/`close` listeners here, so the streaming half stays readable.
5
+ * (setup, watchdogs, the feed), delegates its build-and-spawn stage to src/agent/run-spawn.ts,
6
+ * its streaming pipeline to src/agent/run-stream.ts and its `error`/`exit`/`close` listeners
7
+ * here, so the streaming half stays readable.
7
8
  *
8
9
  * Each handler takes the run's live state (`RunSettleState`, defined in run-agent.ts and
9
10
  * imported here only as a type) and reads what it closes over at the moment its event fires —
@@ -143,6 +144,27 @@ export function settleExit(state) {
143
144
  })
144
145
  .catch(() => { });
145
146
  }
147
+ /** Settle the run's `lastError` text in priority order: a failed blind image read names both
148
+ * the file and the model (plans/image-input.md "Failing the run"), then the abort, quiet-kill
149
+ * and timeout causes the watchdogs and signal path set, then the parser's own event-derived
150
+ * error, else the raw stderr tail. Undefined for a run that simply ended with a message. */
151
+ function settleErrorMessage(state, blindRead, failed, stderr, code) {
152
+ if (blindRead !== undefined) {
153
+ return `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`;
154
+ }
155
+ if (state.aborted())
156
+ return "stopped by tumwater shutdown";
157
+ if (state.wd.quietKilled) {
158
+ return `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`;
159
+ }
160
+ if (state.wd.timedOut) {
161
+ return state.wd.timedOutProgressing
162
+ ? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
163
+ : `timed out after ${state.wd.timeoutBudgetMs / 1000}s`;
164
+ }
165
+ return (state.parser.errorMessage ??
166
+ (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
167
+ }
146
168
  /** The run's `close` event: the process died and all its streams closed. Flush what the
147
169
  * decoders held back, reconcile the backend's post-run work, classify the exit cause, and
148
170
  * settle the run's result. */
@@ -181,20 +203,8 @@ export function settleClose(state, code) {
181
203
  cls.transientPiCrash !== true &&
182
204
  stderrBackend === undefined &&
183
205
  (state.parser.configError || cls.configError === true);
184
- // The blind-read failure names both the file and the model (plans/image-input.md
185
- // "Failing the run"): take it over any generic exit text so lastError is actionable.
186
206
  const blindRead = state.blindRead();
187
- const errorMessage = blindRead !== undefined
188
- ? `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`
189
- : state.aborted()
190
- ? "stopped by tumwater shutdown"
191
- : state.wd.quietKilled
192
- ? `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`
193
- : state.wd.timedOut
194
- ? state.wd.timedOutProgressing
195
- ? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
196
- : `timed out after ${state.wd.timeoutBudgetMs / 1000}s`
197
- : (state.parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
207
+ const errorMessage = settleErrorMessage(state, blindRead, failed, stderr, code);
198
208
  state.finish(resultFromParser(state, {
199
209
  ok: !failed,
200
210
  transientPiCrash: cls.transientPiCrash === true,