tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,12 +1,14 @@
1
1
  /** The seam's answer to "who in this team can read images" (plans/image-input.md
2
- * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives, so the
3
- * answer is never reached two ways. It lives at runtime level because it needs the backend
4
- * registry: a claude seam is always capable through the AgentBackend.imageInput hook, a pi
5
- * seam reads the model's `input` in models.json, and any other backend answers null unless it
6
- * declares a hook. */
7
- import { configForRole } from "../config/config-views.js";
8
- import { seamAgent } from "../config/model-selector.js";
9
- import { piModelImageInput, piModelsPath, readPiProviders } from "../pi/pi-models.js";
2
+ * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives; the reroute
3
+ * target (imageReroute) and the per-role surfaces (roleImageInfo) both read it, so the answer
4
+ * is never reached two ways. It lives at runtime level because it needs the backend registry:
5
+ * a claude seam is always capable through the AgentBackend.imageInput hook, a pi seam reads the
6
+ * model's `input` in models.json, and any other backend answers null unless it declares a hook. */
7
+ import { configForRole, roleSeamTier, withModelOverrides } from "../config/config-views.js";
8
+ import { BORROW_ORDER, resolveTierFallbacks } from "../config/config-fallbacks.js";
9
+ import { seamAgent } from "../config/config-model-selector.js";
10
+ import { modelLabel } from "./backend-traits.js";
11
+ import { piModelImageInput, piPairFree, piModelsPath, readPiProviders } from "../pi/pi-models.js";
10
12
  import { piListedImageInput } from "../pi/pi-listed-models.js";
11
13
  import { backendById } from "./agent-backend.js";
12
14
  /** One seam's image-input capability (plans/image-input.md "Capability"): `true` when the
@@ -32,12 +34,67 @@ export function seamImageInput(seam, providers, listed = null) {
32
34
  return null;
33
35
  return piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
34
36
  }
37
+ /** The image-capable reroute target for `role`, or null when no peer tier can read images
38
+ * (plans/image-input.md "Reroute target"). Walks the role's seam tier through BORROW_ORDER,
39
+ * skipping the tier itself — small → default → strong, default → strong → small, strong →
40
+ * default (a strong-tier seam never drops to small, the same rule as the budget fallback) —
41
+ * and takes the first tier whose model reads images. `regime` picks the pair set (see
42
+ * RerouteRegime); `modelsPath` and `account` are injectable so tests need no real pi install
43
+ * or account. */
44
+ export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, providers = null, listed = null) {
45
+ const tier = roleSeamTier(config, role);
46
+ const candidates = BORROW_ORDER[tier].filter((t) => t !== tier);
47
+ if (candidates.length === 0)
48
+ return null;
49
+ const providerMap = providers ?? readPiProviders(modelsPath);
50
+ const capable = (rc) => seamImageInput(rc, providerMap, listed) === true;
51
+ if (regime === "primary") {
52
+ for (const t of candidates) {
53
+ const rc = withModelOverrides(config, t, {});
54
+ if (capable(rc))
55
+ return { tier: t, config: rc };
56
+ }
57
+ return null;
58
+ }
59
+ // Fallback regime: only the tiers' free fallback pairs qualify (resolveTierFallbacks over the
60
+ // price check), never a priced primary model — a reroute must not spend past the cap.
61
+ const resolved = resolveTierFallbacks(config, (p) =>
62
+ // The free-ness lookup reads the one claude account held here (part 2a/9).
63
+ piPairFree(providerMap, p, (b) => (b === "claude" ? account : null)));
64
+ for (const t of candidates) {
65
+ const pair = resolved[t].pair;
66
+ if (!pair)
67
+ continue;
68
+ const rc = withModelOverrides(config, t, {
69
+ provider: pair.provider,
70
+ model: pair.model,
71
+ thinking: pair.thinking,
72
+ });
73
+ if (capable(rc))
74
+ return { tier: t, config: rc };
75
+ }
76
+ return null;
77
+ }
78
+ /** The config of imageReroute, for callers that want only the target model. */
79
+ export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, providers = null, listed = null) {
80
+ return imageReroute(config, role, regime, modelsPath, account, providers, listed)?.config ?? null;
81
+ }
82
+ /** A seam's model as one readable token, the same label the doctor's `image input` check used
83
+ * (`claude/<id>` for a claude seam, `provider/model` for pi), delegated to backend-traits'
84
+ * modelLabel. Used by roleImageInfo alone. */
85
+ function seamImageLabel(backend, provider, model) {
86
+ return modelLabel(backend, provider, model);
87
+ }
35
88
  /** Compute RoleImageInfo for one role. `modelsPath` and `listed` are injectable; the providers
36
- * catalog is read (stat-cached) here. The reroute field is a literal null — the peer-tier image
37
- * reroute was subtracted (part 5g/6), so no role names a reroute target; 5h removes the field. */
89
+ * catalog is read (stat-cached) here and also handed to imageReroute so a poll that asks many
90
+ * roles still parses the catalog once. */
38
91
  export function roleImageInfo(config, role, modelsPath = piModelsPath(), listed = null) {
39
92
  const providers = readPiProviders(modelsPath);
40
93
  const rc = configForRole(config, role);
41
94
  const imageInput = seamImageInput(rc, providers, listed);
42
- return { imageInput, imageReroute: null };
95
+ const rr = imageReroute(config, role, "primary", modelsPath, null, providers, listed);
96
+ const imageRerouteStr = rr
97
+ ? `${rr.tier} (${seamImageLabel(rr.config.agent, rr.config.provider, rr.config.model)})`
98
+ : null;
99
+ return { imageInput, imageReroute: imageRerouteStr };
43
100
  }
@@ -1,5 +1,5 @@
1
1
  /** Installing the per-team `tumwater-notes` shim every backend's run finds on its PATH
2
- * (plans/notes-command.md "Reaching it from a run"): run-spawn calls `ensureNotesShim(root)`
2
+ * (plans/notes-command.md "Reaching it from a run"): agent-spawn calls `ensureNotesShim(root)`
3
3
  * before building each invocation, and the backend prepends the returned dir to the run's
4
4
  * PATH (backendBaseEnv's `binDir`, src/process/run-marker.ts). The shim is a two-line `sh`
5
5
  * script that execs the compiled notes CLI, so a model's shell — pi's, Claude Code's or
@@ -20,7 +20,7 @@ export const NOTES_SHIM_NAME = "tumwater-notes";
20
20
  /** Install the `tumwater-notes` shim under `<root>/.tumwater/bin` and return that directory.
21
21
  * The shim is written atomically with mode 0755, only when its content differs from what is
22
22
  * already there (a redeploy that changes `<dist>` rewrites it). Never throws: a failure to
23
- * write is the caller's to absorb — run-spawn logs one warning and runs without the shim. */
23
+ * write is the caller's to absorb — agent-spawn logs one warning and runs without the shim. */
24
24
  export function ensureNotesShim(root, execPath = process.execPath) {
25
25
  const dir = notesShimDir(root);
26
26
  const target = path.join(dir, NOTES_SHIM_NAME);
@@ -1,7 +1,7 @@
1
1
  /** The opencode adapter for the agent seam (plans/opencode-backend.md "The invocation",
2
2
  * "Sessions", §6). It shapes one run's argv/stdin/env, translates opencode's `run --format
3
3
  * json` into the canonical stream, runs the authoritative session export after the process
4
- * exits, and classifies the exit's stderr — everything backend-specific. src/agent/run-spawn.ts
4
+ * exits, and classifies the exit's stderr — everything backend-specific. src/agent/agent-spawn.ts
5
5
  * spawns the run and records this run in the session ledger; opencode mints its own session id
6
6
  * (a `ses_…` id, or the resumed record's id), which `invocation` returns so the ledger can
7
7
  * carry it.
@@ -13,7 +13,7 @@
13
13
  * the only thing that stops a follow-up running against an empty session. Anything else mints a
14
14
  * fresh id (design §4).
15
15
  *
16
- * Registration lives in src/agent/run-agent.ts beside the pi and claude adapters; a test selects
16
+ * Registration lives in src/agent/agent-run.ts beside the pi and claude adapters; a test selects
17
17
  * opencode by setting `config.agent = "opencode"` on a run. A run carrying a note gets the
18
18
  * notes path in its environment (the `tumwater-notes` command on PATH reads it), as the pi and
19
19
  * claude adapters do. */
@@ -79,42 +79,44 @@ export function opencodeSpillDir(root) {
79
79
  function catalogModelKey(model) {
80
80
  return opencodeModelKey(model);
81
81
  }
82
- /** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
83
- * 10 s) and return whether it reported success — the existence check that decides a resume.
84
- * Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
85
- * direction: `--session` would otherwise silently create a missing session. */
86
- function opencodeSessionExists(id, env, bin, cwd) {
82
+ /** Run the bounded `opencode session export <id> --standalone` spawn that both the resume
83
+ * existence check and the reconciling export issue, under the same env/cwd and the shared
84
+ * 10 s bound. `capture` is false for the existence check, which reads only the exit status:
85
+ * `stdio: "ignore"` keeps the export — which carries every message of the session — out of
86
+ * memory and below the default 1 MiB `maxBuffer`, whose `ENOBUFS` kill would otherwise make a
87
+ * large but real session read as absent and mint a fresh one. Returns the spawn result, or null
88
+ * when the spawn itself threw — a missing binary or an invalid option, resolved the same way
89
+ * both callers treated a throw. The command line, env, cwd and timeout live here once, so the
90
+ * existence check and the reconciling export cannot drift on what they run. */
91
+ function runSessionExport(id, env, bin, cwd, capture = true) {
87
92
  try {
88
- const res = spawnSync(bin, ["session", "export", id, "--standalone"], {
93
+ return spawnSync(bin, ["session", "export", id, "--standalone"], {
89
94
  env,
90
95
  cwd,
91
96
  timeout: EXPORT_TIMEOUT_MS,
92
- stdio: "ignore",
97
+ encoding: "utf8",
98
+ stdio: capture ? "pipe" : "ignore",
93
99
  });
94
- return res.status === 0;
95
100
  }
96
101
  catch {
97
- return false;
102
+ return null;
98
103
  }
99
104
  }
105
+ /** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
106
+ * 10 s) and return whether it reported success — the existence check that decides a resume.
107
+ * Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
108
+ * direction: `--session` would otherwise silently create a missing session. */
109
+ function opencodeSessionExists(id, env, bin, cwd) {
110
+ const res = runSessionExport(id, env, bin, cwd, false);
111
+ return res?.status === 0;
112
+ }
100
113
  /** Run the reconciling export after a successful run and return its two canonical lines
101
114
  * (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
102
115
  * timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
103
116
  * (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
104
117
  function opencodeExport(result) {
105
- let res;
106
- try {
107
- res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
108
- env: result.env,
109
- cwd: result.cwd,
110
- timeout: EXPORT_TIMEOUT_MS,
111
- encoding: "utf8",
112
- });
113
- }
114
- catch {
115
- return [];
116
- }
117
- if (res.status !== 0 || !res.stdout)
118
+ const res = runSessionExport(result.id, result.env, result.bin, result.cwd);
119
+ if (res?.status !== 0 || !res.stdout)
118
120
  return [];
119
121
  let parsed;
120
122
  try {
@@ -9,6 +9,7 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { isJsonObject, validEntries } from "../../files/json-object.js";
11
11
  import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
12
+ import { cachedByStat } from "../../files/stat-cache.js";
12
13
  import { errorMessage } from "../../text/text.js";
13
14
  import { DAY_MS } from "../../text/datetime.js";
14
15
  import { opencodeModelsPath } from "../../paths.js";
@@ -21,6 +22,25 @@ const CATALOG_URL = "https://models.dev/api.json";
21
22
  const CATALOG_FETCH_TIMEOUT_MS = 30_000;
22
23
  /** How long a fetched cache is trusted before it is refreshed. */
23
24
  const CATALOG_CACHE_TTL_MS = DAY_MS;
25
+ /** Per-catalog cache of the parsed catalog, keyed by the models.json path: the status
26
+ * snapshot and the budget gate both poll it every second while the file changes only when a
27
+ * refresh re-fetches models.dev — and it grows with the models.dev catalog — so an
28
+ * unchanged file costs one stat per poll instead of a re-read plus a JSON.parse and a
29
+ * validation of the whole file (the same stat-keyed contract readOpencodeCache's siblings
30
+ * readCodexCatalog, readPiProviders and readMergeQueue already use). Any write invalidates
31
+ * via dev/ino/mtime/size (stat-cache.cachedByStat's freshness check). */
32
+ const catalogCache = new Map();
33
+ /** A structural copy of a parsed catalog, so a caller that mutates its own result (e.g.
34
+ * observeOpencodeRun sets `billedAt` on an entry) never reaches the shared cache entry; the
35
+ * next poll re-derives from the file. The entries are plain data (`efforts` arrays included)
36
+ * so a shallow model-object spread suffices. */
37
+ function cloneCache(cache) {
38
+ const models = {};
39
+ for (const [key, entry] of Object.entries(cache.models)) {
40
+ models[key] = entry.efforts !== undefined ? { ...entry, efforts: [...entry.efforts] } : { ...entry };
41
+ }
42
+ return { fetchedAt: cache.fetchedAt, cliVersion: cache.cliVersion, models };
43
+ }
24
44
  const defaultFetch = async (url, timeoutMs) => {
25
45
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
26
46
  if (!res.ok)
@@ -33,6 +53,53 @@ const defaultFetch = async (url, timeoutMs) => {
33
53
  export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
34
54
  return fetchFn(CATALOG_URL, CATALOG_FETCH_TIMEOUT_MS);
35
55
  }
56
+ /** The catalog root `parseOpencodeCatalog` iterates: models.dev's live api.json puts the provider
57
+ * map at the top level, OpenCode's cached copy wraps that same map under a `body` key. */
58
+ function catalogRoot(body) {
59
+ const root = isJsonObject(body) ? body : {};
60
+ // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
61
+ const wrapped = root.body;
62
+ return isJsonObject(wrapped) ? wrapped : root;
63
+ }
64
+ /** The free/priced reads from a per-model cost block: whether it is present and yields two zero
65
+ * per-M prices. A missing cost block is unknown price data, and treating it as $0 is the unsafe
66
+ * direction for a budget gate. */
67
+ function modelPricing(m) {
68
+ const cost = isJsonObject(m.cost) ? m.cost : undefined;
69
+ const input = cost?.input ?? 0;
70
+ const output = cost?.output ?? 0;
71
+ return {
72
+ costed: cost !== undefined,
73
+ free: cost !== undefined && input === 0 && output === 0,
74
+ inputPerM: input,
75
+ outputPerM: output,
76
+ };
77
+ }
78
+ /** The effort levels from a per-model object: the `values` of the `reasoning_options` entries
79
+ * whose `type` is `"effort"`. */
80
+ function modelEfforts(m) {
81
+ if (!Array.isArray(m.reasoning_options))
82
+ return [];
83
+ return m.reasoning_options.flatMap((r) => isJsonObject(r) && r.type === "effort" && Array.isArray(r.values) ? r.values : []);
84
+ }
85
+ /** Build one catalog model from an untrusted per-model object, null when it is malformed. */
86
+ function catalogModelFrom(m) {
87
+ const pricing = modelPricing(m);
88
+ const model = { free: pricing.free };
89
+ if (pricing.costed) {
90
+ model.inputPerM = pricing.inputPerM;
91
+ model.outputPerM = pricing.outputPerM;
92
+ }
93
+ if (isJsonObject(m.limit) && m.limit.context !== undefined)
94
+ model.context = m.limit.context;
95
+ if (isJsonObject(m.modalities) && Array.isArray(m.modalities.input)) {
96
+ model.images = m.modalities.input.includes("image");
97
+ }
98
+ const efforts = modelEfforts(m);
99
+ if (efforts.length > 0)
100
+ model.efforts = efforts;
101
+ return isOpencodeCatalogModel(model) ? model : null;
102
+ }
36
103
  /** Parse models.dev's `api.json` into the models the config routes through OpenCode: always the
37
104
  * `opencode` provider, plus every provider named in `providers` (the config's
38
105
  * `opencode/<provider>/…` routes — `opencode-go` and so on). The live `GET /api.json` returns
@@ -43,11 +110,7 @@ export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
43
110
  * provider or model is skipped, never thrown on. */
44
111
  export function parseOpencodeCatalog(body, providers) {
45
112
  const out = {};
46
- const root = isJsonObject(body) ? body : {};
47
- // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
48
- const wrapped = root.body;
49
- const catalog = isJsonObject(wrapped) ? wrapped : root;
50
- for (const [provider, providerInfo] of Object.entries(catalog)) {
113
+ for (const [provider, providerInfo] of Object.entries(catalogRoot(body))) {
51
114
  if (provider !== "opencode" && !providers.includes(provider))
52
115
  continue;
53
116
  if (!isJsonObject(providerInfo) || !isJsonObject(providerInfo.models))
@@ -55,28 +118,8 @@ export function parseOpencodeCatalog(body, providers) {
55
118
  for (const [id, m] of Object.entries(providerInfo.models)) {
56
119
  if (!isJsonObject(m))
57
120
  continue;
58
- // Only a present cost block marks a model free: a missing cost block is unknown price data,
59
- // and treating it as $0 is the unsafe direction for a budget gate.
60
- const cost = isJsonObject(m.cost) ? m.cost : undefined;
61
- const input = cost?.input ?? 0;
62
- const output = cost?.output ?? 0;
63
- const model = { free: cost !== undefined && input === 0 && output === 0 };
64
- if (cost !== undefined) {
65
- model.inputPerM = input;
66
- model.outputPerM = output;
67
- }
68
- if (isJsonObject(m.limit) && m.limit.context !== undefined)
69
- model.context = m.limit.context;
70
- if (isJsonObject(m.modalities) && Array.isArray(m.modalities.input)) {
71
- model.images = m.modalities.input.includes("image");
72
- }
73
- if (Array.isArray(m.reasoning_options)) {
74
- const efforts = m.reasoning_options
75
- .flatMap((r) => isJsonObject(r) && r.type === "effort" && Array.isArray(r.values) ? r.values : []);
76
- if (efforts.length > 0)
77
- model.efforts = efforts;
78
- }
79
- if (isOpencodeCatalogModel(model))
121
+ const model = catalogModelFrom(m);
122
+ if (model !== null)
80
123
  out[`${provider}/${id}`] = model;
81
124
  }
82
125
  }
@@ -137,15 +180,21 @@ function isOpencodeCatalogModel(value) {
137
180
  * fills the file in. Entries that are not well-formed catalog models are dropped, so a foreign
138
181
  * entry reads as a model missing from the cache — not free. */
139
182
  export function readOpencodeCache(root) {
140
- const existing = readJsonFile(opencodeModelsPath(root));
141
- if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
142
- return {
143
- fetchedAt: existing.fetchedAt,
144
- cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
145
- models: validEntries(existing.models, isOpencodeCatalogModel),
146
- };
147
- }
148
- return { fetchedAt: 0, models: {} };
183
+ const file = opencodeModelsPath(root);
184
+ const cached = cachedByStat(catalogCache, file, file, () => {
185
+ // readJsonFile is the shared "missing or torn reads as no data" policy; a torn write
186
+ // or a malformed file reads as an empty cache (fetchedAt 0), exactly as before.
187
+ const existing = readJsonFile(file);
188
+ if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
189
+ return {
190
+ fetchedAt: existing.fetchedAt,
191
+ cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
192
+ models: validEntries(existing.models, isOpencodeCatalogModel),
193
+ };
194
+ }
195
+ return { fetchedAt: 0, models: {} };
196
+ }, cloneCache);
197
+ return cached ?? { fetchedAt: 0, models: {} };
149
198
  }
150
199
  /** Persist the catalog cache atomically under `.tumwater/state/`. */
151
200
  export function writeOpencodeCache(root, cache) {
@@ -38,29 +38,47 @@ const DENIED_PERMISSIONS = [
38
38
  function isCommentStart(src, i) {
39
39
  return src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*");
40
40
  }
41
+ /** True when `c` is a JSONC whitespace character. */
42
+ function isWhitespace(c) {
43
+ return c === " " || c === "\t" || c === "\n" || c === "\r";
44
+ }
45
+ /** Advance `j` past a `//` line comment (a caller-supplied position where `src[j] === "/"`
46
+ * and `src[j + 1] === "/"`), not past the terminating newline. */
47
+ function skipLineComment(src, j) {
48
+ while (j < src.length && src[j] !== "\n")
49
+ j += 1;
50
+ return j;
51
+ }
52
+ /** Advance `j` past a `/*`-style block comment (a caller-supplied position where `src[j] ===
53
+ * "/"` and `src[j + 1] === "*"`), ending after its closing two characters. An unterminated
54
+ * comment leaves `j` at `src.length + 2`, overshooting past the end — today's behaviour, kept. */
55
+ function skipBlockComment(src, j) {
56
+ j += 2;
57
+ while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
58
+ j += 1;
59
+ j += 2;
60
+ return j;
61
+ }
62
+ /** Advance `j` past one comment, dispatching on the second character: `//` to the line-comment
63
+ * scan, `/*` to the block-comment scan (a caller-supplied position where `isCommentStart`
64
+ * holds). */
65
+ function skipComment(src, j) {
66
+ return src[j + 1] === "/" ? skipLineComment(src, j) : skipBlockComment(src, j);
67
+ }
41
68
  /** Advance `j` past any whitespace and comments, for a position that is never inside a string.
42
69
  * Returns the index of the next significant (non-comment, non-whitespace) character, or
43
70
  * `src.length` when the input ends in comments/whitespace. */
44
71
  function skipWhitespaceAndComments(src, j) {
45
72
  while (j < src.length) {
46
- const c = src[j];
47
- if (c === " " || c === "\t" || c === "\n" || c === "\r") {
73
+ if (isWhitespace(src[j])) {
48
74
  j += 1;
49
- continue;
50
75
  }
51
- if (c === "/" && src[j + 1] === "/") {
52
- while (j < src.length && src[j] !== "\n")
53
- j += 1;
54
- continue;
76
+ else if (isCommentStart(src, j)) {
77
+ j = skipComment(src, j);
55
78
  }
56
- if (c === "/" && src[j + 1] === "*") {
57
- j += 2;
58
- while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
59
- j += 1;
60
- j += 2;
61
- continue;
79
+ else {
80
+ break;
62
81
  }
63
- break;
64
82
  }
65
83
  return j;
66
84
  }
@@ -14,22 +14,21 @@
14
14
  * the model to pass `timeout` for the check (§5). `XDG_CONFIG_HOME` and its siblings are NOT
15
15
  * set: OpenCode's shell-tool children inherit the environment, and pointing XDG at a private
16
16
  * dir would stop `git` reading `~/.config/git/config`, losing commits' author identity (§3). */
17
+ import { scrubEnv } from "../env-scrub.js";
17
18
  /** Build the opencode process environment from `base`. Never mutates `base`. */
18
19
  export function opencodeEnv(base, opts) {
19
- const env = {};
20
- for (const [key, value] of Object.entries(base)) {
20
+ return scrubEnv(base, {
21
21
  // Everything an inherited OpenCode session set is scrubbed — server, config, database,
22
22
  // client and session id — except the operator's API key, which is operator intent and must
23
23
  // survive. `AI_AGENT` goes too, so the shell tool sets its own (finding 16).
24
- if (key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT")
25
- continue;
26
- env[key] = value;
27
- }
28
- env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
29
- env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
30
- env.OPENCODE_DISABLE_AUTOUPDATE = "1";
31
- env.OPENCODE_DISABLE_FILEWATCHER = "1";
32
- env.OPENCODE_DB = opts.dbPath;
33
- env.OPENCODE_CONFIG_CONTENT = opts.configContent;
34
- return env;
24
+ keep: (key) => !(key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT"),
25
+ then: (env) => {
26
+ env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
27
+ env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
28
+ env.OPENCODE_DISABLE_AUTOUPDATE = "1";
29
+ env.OPENCODE_DISABLE_FILEWATCHER = "1";
30
+ env.OPENCODE_DB = opts.dbPath;
31
+ env.OPENCODE_CONFIG_CONTENT = opts.configContent;
32
+ },
33
+ });
35
34
  }
@@ -28,49 +28,71 @@ const TOOL_NAMES = {
28
28
  /** A Retry-After hint spelled out in a rate-limit error's message text ("retry after 30s" /
29
29
  * "retry in 30 seconds"). */
30
30
  const RETRY_AFTER = /retry (?:after|in) (\d+)\s*s(?:econds?)?\b/i;
31
- /** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
32
- * backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
33
- * `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
34
- * unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
35
- * its classification table). */
36
- function classifyError(err) {
37
- const message = typeof err.message === "string" ? err.message : "";
38
- const type = typeof err.type === "string" ? err.type : "";
39
- const status = err.status;
40
- // The free tier refuses intermittently (a provider failure, not a config error): three
41
- // refusals in a row move the tier to its fallback through the existing model-failure
42
- // episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
43
- // text naming the free tier. A `provider.auth` with any other status (a 401 bad
44
- // credential) is not a refusal and falls through to the config-error row below.
31
+ ;
32
+ /** Is this the free tier's intermittent refusal (a provider failure, not a config error)?
33
+ * Three refusals in a row move the tier to its fallback through the existing model-failure
34
+ * episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
35
+ * text naming the free tier. A `provider.auth` with any other status (a 401 bad
36
+ * credential) is not a refusal and falls through to the config-error row. */
37
+ function freeTierClassification(type, status, message) {
45
38
  if ((type === "provider.auth" && status === 403) || /free\s?tier|FreeTierError/i.test(message))
46
39
  return { kind: "transientBackend", backendKind: "server", freeTier: true };
47
- // Rate limiting — 429 or rate-limit text — is the world saying "later", inherently
48
- // retryable; carry the echoed Retry-After hint when the message spells it.
40
+ return undefined;
41
+ }
42
+ /** Is this a rate limit (429 or rate-limit text) — the world saying "later", inherently
43
+ * retryable? Carry the echoed Retry-After hint when the message spells it. */
44
+ function rateLimitClassification(status, message) {
49
45
  if (status === 429 || /\b429\b|rate limit|too many requests/i.test(message)) {
50
46
  const retry = RETRY_AFTER.exec(message);
51
47
  return { kind: "transientRateLimit", ...(retry ? { retryAfterSeconds: Number(retry[1]) } : {}) };
52
48
  }
53
- // An unknown model or variant, and the malformed `ses_` id, are permanent config errors
54
- // (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`, or
55
- // the model-not-found / 401 text.
49
+ return undefined;
50
+ }
51
+ /** Is this a permanent config error — an unknown model or variant, or the malformed `ses_`
52
+ * id (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`,
53
+ * or the model-not-found / 401 text? */
54
+ function configErrorClassification(type, status, message) {
56
55
  if (type === "provider.no-route" ||
57
56
  type === "unknown" ||
58
57
  status === 401 ||
59
58
  /unknown model|unknown provider|model not found|provider not found|unauthorized|status\s*401|\b401\b/i.test(message))
60
59
  return { kind: "configError" };
61
- // Context overflow that compaction could not fix. Matched STRICTLY (the same shape pi's
62
- // own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
63
- // in passing — a plugin error, say — is never misread as an overflow that would then be
64
- // trusted by PiStreamParser over its own tighter regex.
60
+ return undefined;
61
+ }
62
+ /** Did context overflow in a way compaction could not fix? Matched STRICTLY (the same shape
63
+ * pi's own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
64
+ * in passing — a plugin error, say — is never misread as an overflow that would then be
65
+ * trusted by PiStreamParser over its own tighter regex. */
66
+ function contextOverflowClassification(message) {
65
67
  if (/context (size|length|window)?\s*(has been |was )?exceeded|exceeds? (the )?context|too (long|large) for .*context/i.test(message))
66
68
  return { kind: "contextExceeded" };
67
- // Transient server, connection and timeout failures.
69
+ return undefined;
70
+ }
71
+ /** Is this a transient server, connection or timeout failure? The server statuses and text
72
+ * first, then the shared connection/timeout prose vocabulary (see `transientProseKind`). */
73
+ function transientClassification(message) {
68
74
  if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
69
75
  return { kind: "transientBackend", backendKind: "server" };
70
76
  const prose = transientProseKind(message);
71
77
  if (prose)
72
78
  return prose;
73
- return {};
79
+ return undefined;
80
+ }
81
+ /** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
82
+ * backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
83
+ * `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
84
+ * unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
85
+ * its classification table). */
86
+ function classifyError(err) {
87
+ const message = typeof err.message === "string" ? err.message : "";
88
+ const type = typeof err.type === "string" ? err.type : "";
89
+ const status = err.status;
90
+ return (freeTierClassification(type, status, message) ??
91
+ rateLimitClassification(status, message) ??
92
+ configErrorClassification(type, status, message) ??
93
+ contextOverflowClassification(message) ??
94
+ transientClassification(message) ??
95
+ {});
74
96
  }
75
97
  /** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
76
98
  * as `totalTokens`, and `cost.total` = the step's USD cost (plans/opencode-backend.md §6).
@@ -39,7 +39,7 @@ export function recordSession(dir, record) {
39
39
  }
40
40
  }
41
41
  /** The one run's ledger record, minus the id the two recording sites mint differently — the
42
- * pre-spawn id in run-spawn.ts, or the stream-minted session id in run-stream.ts. One place
42
+ * pre-spawn id in agent-spawn.ts, or the stream-minted session id in agent-stream.ts. One place
43
43
  * for the record's non-id shape (agent, kind, name, cwd, the optional model and the stamp), so
44
44
  * the two appends cannot drift on the model spread or the required fields. `agent` is the
45
45
  * owning backend's id; the run options supply the rest. */
@@ -191,6 +191,24 @@ export function pruneSessionLedgers(sessionsRoot, days) {
191
191
  /** How long one `opencode session delete <id> --standalone` may take before it gives up: the
192
192
  * session export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. */
193
193
  const SESSION_DELETE_TIMEOUT_MS = 10_000;
194
+ /** Run one bounded headless CLI session delete (`bin args…`) under `env`, best-effort: a
195
+ * spawn that throws (a missing binary, an unwritable env) returns false without throwing, so
196
+ * a failed delete never fails the prune. Shared by the OpenCode and Codex session purges below
197
+ * so neither drift on the 10 s bound, the status-0 success rule, or the swallow policy. */
198
+ function sessionDeleteSucceeds(bin, args, env) {
199
+ try {
200
+ const res = spawnSync(bin, args, {
201
+ env,
202
+ timeout: SESSION_DELETE_TIMEOUT_MS,
203
+ stdio: "ignore",
204
+ });
205
+ return res.status === 0;
206
+ }
207
+ catch {
208
+ // Best effort: an unwritable env or a missing binary must not fail the prune.
209
+ return false;
210
+ }
211
+ }
194
212
  /** Delete each OpenCode session the ledger records as expired, with one
195
213
  * `opencode session delete <id> --standalone` each (bounded), plus the spill files older than
196
214
  * the window in the team's shell spill dir. The session's spill files and server state live
@@ -209,18 +227,8 @@ export function pruneOpencodeSessions(sessionsRoot, days, opts) {
209
227
  for (const record of readSessions(path.dirname(file))) {
210
228
  if (record.agent !== "opencode" || record.at >= cutoff)
211
229
  continue;
212
- try {
213
- const res = spawnSync(opts.bin, ["session", "delete", record.id, "--standalone"], {
214
- env: opts.env,
215
- timeout: SESSION_DELETE_TIMEOUT_MS,
216
- stdio: "ignore",
217
- });
218
- if (res.status === 0)
219
- deleted += 1;
220
- }
221
- catch {
222
- // Best effort: an unwritable env or a missing binary must not fail the prune.
223
- }
230
+ if (sessionDeleteSucceeds(opts.bin, ["session", "delete", record.id, "--standalone"], opts.env))
231
+ deleted += 1;
224
232
  }
225
233
  });
226
234
  }
@@ -276,20 +284,8 @@ export function pruneCodexSessions(sessionsRoot, days, opts) {
276
284
  for (const id of expired) {
277
285
  if (live.has(id))
278
286
  continue;
279
- if (opts.bin !== undefined) {
280
- try {
281
- const res = spawnSync(opts.bin, ["delete", "--force", id], {
282
- env: opts.env,
283
- timeout: SESSION_DELETE_TIMEOUT_MS,
284
- stdio: "ignore",
285
- });
286
- if (res.status === 0)
287
- deleted += 1;
288
- }
289
- catch {
290
- // Best effort: an unwritable env or a missing binary must not fail the prune.
291
- }
292
- }
287
+ if (opts.bin !== undefined && sessionDeleteSucceeds(opts.bin, ["delete", "--force", id], opts.env))
288
+ deleted += 1;
293
289
  // Fallback: unlink the home's rollout for this session so neither the ledger line nor
294
290
  // its file outlives the window. A real `codex delete` removes the rollout itself, so a
295
291
  // successful delete has nothing left to unlink; a failed or skipped delete does, and