tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -0,0 +1,37 @@
1
+ // vocabulary: plain
2
+ /** Minting an OpenCode session id (plans/opencode-backend.md "Sessions", finding 3) — a pure
3
+ * reproduction of OpenCode's `Identifier.descending()` (packages/schema/src/identifier.ts), so
4
+ * a fresh run's `--session` id is accepted by the free-tier gate. A `ses_…` id in any other
5
+ * shape is refused with a 403 (finding 3), so this must match OpenCode exactly.
6
+ *
7
+ * The id is `ses_` + 12 hex digits + 14 base-62 characters. The hex is the low 48 bits of the
8
+ * bitwise NOT of (Unix milliseconds × 4096 + a per-millisecond counter), big-endian; the 14
9
+ * characters are drawn from `0-9A-Za-z` one random byte each. */
10
+ import crypto from "node:crypto";
11
+ /** The 62-character alphabet OpenCode draws a session id's trailing characters from. */
12
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
13
+ /** Per-process minting state, mirroring OpenCode's per-millisecond counter: the counter
14
+ * restarts at 1 for each new millisecond, and increments within the same millisecond so two
15
+ * ids minted in one tick still differ in their hex. */
16
+ let lastMintMs = -1;
17
+ let counter = 0;
18
+ /** Mint an OpenCode session id. `now` is a Unix-millisecond epoch (injected for tests);
19
+ * `randomBytes` supplies the 14 base-62 characters (defaults to `crypto.randomBytes`, so the
20
+ * trailing part is random, never guessed from the visible prefix). */
21
+ export function mintOpencodeSessionId(now = Date.now(), randomBytes = (n) => crypto.randomBytes(n)) {
22
+ if (now === lastMintMs)
23
+ counter += 1;
24
+ else {
25
+ lastMintMs = now;
26
+ counter = 1;
27
+ }
28
+ // `Identifier.descending()`: current = now × 4096 + counter, then the low 48 bits of ~current.
29
+ const current = BigInt(now) * 0x1000n + BigInt(counter);
30
+ const hex = ((~current) & 0xffffffffffffn).toString(16).padStart(12, "0");
31
+ const bytes = randomBytes(14);
32
+ let suffix = "";
33
+ for (let i = 0; i < 14; i += 1) {
34
+ suffix += ALPHABET[bytes[i] % 62];
35
+ }
36
+ return `ses_${hex}${suffix}`;
37
+ }
@@ -0,0 +1,278 @@
1
+ /** The opencode adapter's native-to-canonical stream translator (plans/opencode-backend.md
2
+ * §6, "The stream translator"). `opencode run --format json` writes one JSON object per line
3
+ * whose vocabulary differs from pi's in every dimension the readers care about: each step is
4
+ * opened by `step_start` and closed by `step_finish` carrying the step's cost and tokens; a
5
+ * tool is reported as a `tool_use` event only ONCE it has completed or errored (never while
6
+ * it runs); and errors arrive as their own `{type: "error", error: {type, message,
7
+ * status?, response?}}` line rather than as prose in a message. Rather than teach ~fifteen
8
+ * readers a second dialect, every native line is translated here into pi's vocabulary — the
9
+ * canonical stream — so `PiStreamParser`, the
10
+ * raw log, the transcript, progress, and the GUI all stay unchanged.
11
+ *
12
+ * Pure translation: no spawn, no backend registration, no config. The factory keeps per-run
13
+ * state (the open step buffer, the queued tool calls, the unmapped native types seen, whether
14
+ * the run was refused by the free tier), so call it once per run. Unmapped native types and a
15
+ * malformed (non-JSON) line are recorded once each on the seam's optional `warnings()`, and
16
+ * `opencode_free_tier_refused` is added there once when any step was refused. `--thinking` is
17
+ * never passed, so no `reasoning` event appears; the mapping below still recognizes one so a
18
+ * future `--thinking` run degrades gracefully. */
19
+ import { canonicalUsageBlock, isJsonObject, nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
20
+ /** opencode's tool names → the canonical pi names the readers already know. `shell`→`bash` and
21
+ * `patch`→`edit` (pi has one edit tool); anything else is lower-cased so a future tool still
22
+ * reads as a name rather than as `undefined`. */
23
+ const TOOL_NAMES = {
24
+ shell: "bash",
25
+ patch: "edit",
26
+ };
27
+ /** A Retry-After hint spelled out in a rate-limit error's message text ("retry after 30s" /
28
+ * "retry in 30 seconds"). */
29
+ const RETRY_AFTER = /retry (?:after|in) (\d+)\s*s(?:econds?)?\b/i;
30
+ /** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
31
+ * backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
32
+ * `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
33
+ * unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
34
+ * its classification table). */
35
+ function classifyError(err) {
36
+ const message = typeof err.message === "string" ? err.message : "";
37
+ const type = typeof err.type === "string" ? err.type : "";
38
+ const status = err.status;
39
+ // The free tier refuses intermittently (a provider failure, not a config error): three
40
+ // refusals in a row move the tier to its fallback through the existing model-failure
41
+ // episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
42
+ // text naming the free tier. A `provider.auth` with any other status (a 401 bad
43
+ // credential) is not a refusal and falls through to the config-error row below.
44
+ if ((type === "provider.auth" && status === 403) || /free\s?tier|FreeTierError/i.test(message))
45
+ return { kind: "transientBackend", backendKind: "server", freeTier: true };
46
+ // Rate limiting — 429 or rate-limit text — is the world saying "later", inherently
47
+ // retryable; carry the echoed Retry-After hint when the message spells it.
48
+ if (status === 429 || /\b429\b|rate limit|too many requests/i.test(message)) {
49
+ const retry = RETRY_AFTER.exec(message);
50
+ return { kind: "transientRateLimit", ...(retry ? { retryAfterSeconds: Number(retry[1]) } : {}) };
51
+ }
52
+ // An unknown model or variant, and the malformed `ses_` id, are permanent config errors
53
+ // (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`, or
54
+ // the model-not-found / 401 text.
55
+ if (type === "provider.no-route" ||
56
+ type === "unknown" ||
57
+ status === 401 ||
58
+ /unknown model|unknown provider|model not found|provider not found|unauthorized|status\s*401|\b401\b/i.test(message))
59
+ return { kind: "configError" };
60
+ // Context overflow that compaction could not fix. Matched STRICTLY (the same shape pi's
61
+ // own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
62
+ // in passing — a plugin error, say — is never misread as an overflow that would then be
63
+ // trusted by PiStreamParser over its own tighter regex.
64
+ if (/context (size|length|window)?\s*(has been |was )?exceeded|exceeds? (the )?context|too (long|large) for .*context/i.test(message))
65
+ return { kind: "contextExceeded" };
66
+ // Transient server, connection and timeout failures.
67
+ if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
68
+ return { kind: "transientBackend", backendKind: "server" };
69
+ if (/connection|econn|refused|reset|terminated/i.test(message))
70
+ return { kind: "transientBackend", backendKind: "connection" };
71
+ if (/timed out|timeout/i.test(message))
72
+ return { kind: "transientBackend", backendKind: "timeout" };
73
+ return {};
74
+ }
75
+ /** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
76
+ * as `totalTokens`, and `cost.total` = the step's USD cost (plans/opencode-backend.md §6).
77
+ * `totalTokens` feeds `peakContextTokens`; `cost.total` accumulates into `costUsd`. */
78
+ function canonicalUsage(tokens, cost) {
79
+ const cache = objectOrEmpty(tokens.cache);
80
+ return canonicalUsageBlock(nonNegativeNumber(tokens.input, 0), nonNegativeNumber(cache.read, 0), nonNegativeNumber(cache.write, 0), nonNegativeNumber(tokens.output, 0), cost);
81
+ }
82
+ /** Build a fresh per-run opencode translator. */
83
+ export function opencodeTranslator(opts = {}) {
84
+ /** True once the run's session has been announced (the first event with a non-empty
85
+ * `sessionID` emits `session` then `agent_start`, once). */
86
+ let sessionStarted = false;
87
+ /** The open step: its buffered content blocks and the tool calls reported so far. OpenCode
88
+ * reports each call only once it has ended, so the calls are queued and emitted AFTER their
89
+ * step's `message_end`, in order — the assistant message, then its tool executions, the
90
+ * order every reader of the raw log assumes. */
91
+ let step;
92
+ const seen = new Set();
93
+ const warnings = new Set();
94
+ let sawFreeTier = false;
95
+ const record = (kind) => {
96
+ if (seen.has(kind))
97
+ return;
98
+ seen.add(kind);
99
+ warnings.add(kind);
100
+ };
101
+ /** Emit the open step (if any) as ONE canonical `message_end` (role assistant) carrying
102
+ * its buffered blocks and, when `withUsage`, its usage; then one `tool_execution_start`
103
+ * followed by a `tool_execution_end` for each queued call in order. A cut-off step (stream
104
+ * ended or an error arrived before `step_finish`) flushes without usage. */
105
+ const flush = (withUsage) => {
106
+ const s = step;
107
+ if (!s)
108
+ return [];
109
+ step = undefined;
110
+ const message = { role: "assistant", content: s.content };
111
+ if (withUsage)
112
+ message.usage = s.usage;
113
+ const out = [JSON.stringify({ type: "message_end", message })];
114
+ for (const call of s.calls) {
115
+ out.push(JSON.stringify({ type: "tool_execution_start", toolCallId: call.id, toolName: call.name, args: call.args }));
116
+ out.push(JSON.stringify({ type: "tool_execution_end", toolCallId: call.id, isError: call.isError }));
117
+ }
118
+ return out;
119
+ };
120
+ const translate = (line) => {
121
+ const parsed = parseJsonObject(line);
122
+ if (!parsed) {
123
+ // A malformed / non-JSON line on stdout is skipped with one warning for the run.
124
+ record("malformed");
125
+ return [];
126
+ }
127
+ const event = parsed;
128
+ // The first event with a non-empty `sessionID` announces the session and the agent start.
129
+ // An error raised before a session exists has `sessionID: ""` (or none) and emits neither.
130
+ let out = [];
131
+ if (!sessionStarted) {
132
+ const sid = event.sessionID;
133
+ if (typeof sid === "string" && sid !== "") {
134
+ sessionStarted = true;
135
+ out.push(JSON.stringify({
136
+ type: "session",
137
+ id: sid,
138
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
139
+ }));
140
+ out.push(JSON.stringify({ type: "agent_start" }));
141
+ }
142
+ }
143
+ const type = event.type;
144
+ switch (type) {
145
+ case "step_start":
146
+ step = { content: [], calls: [] };
147
+ break;
148
+ case "text": {
149
+ // A content block with no open buffer opens one: `step_start` is sometimes missing, and
150
+ // edit-run.jsonl's final `text` lands after its last `step_finish`, so a block dropped
151
+ // for want of a buffer would lose the run's last turn.
152
+ step ??= { content: [], calls: [] };
153
+ step.content.push({ type: "text", text: objectOrEmpty(event.part).text ?? "" });
154
+ break;
155
+ }
156
+ case "reasoning": {
157
+ step ??= { content: [], calls: [] };
158
+ const thinking = objectOrEmpty(event.part).text ?? "";
159
+ step.content.push({ type: "thinking", thinking: typeof thinking === "string" ? thinking : "" });
160
+ break;
161
+ }
162
+ case "tool_use": {
163
+ // As above, a tool reported with no open buffer opens one.
164
+ step ??= { content: [], calls: [] };
165
+ const partValue = objectOrEmpty(event.part);
166
+ const state = objectOrEmpty(partValue.state);
167
+ const input = objectOrEmpty(state.input);
168
+ // `arguments` is the tool's `input` plus a `path` alias of `filePath` —
169
+ // describeToolCall reads either.
170
+ const args = { ...input };
171
+ if (typeof input.filePath === "string")
172
+ args.path = input.filePath;
173
+ // V2 2.0.32 emits the native tool name in `part.tool` (not `part.name`); the live
174
+ // captures agree.
175
+ const name = typeof partValue.tool === "string" ? partValue.tool : "";
176
+ // Own-property test, not a bare index: a tool name like `constructor` or
177
+ // `__proto__` would otherwise inherit a function from the table's prototype chain and
178
+ // bypass the lower-cased fallback (the same guard claude-stream.ts applies).
179
+ const canonicalName = Object.hasOwn(TOOL_NAMES, name) ? (TOOL_NAMES[name] ?? name.toLowerCase()) : name.toLowerCase();
180
+ const id = typeof partValue.id === "string" ? partValue.id : "";
181
+ step.content.push({ type: "toolCall", id, name: canonicalName, arguments: args });
182
+ step.calls.push({ id, name: canonicalName, args, isError: state.status === "error" });
183
+ break;
184
+ }
185
+ case "step_finish": {
186
+ if (!step)
187
+ break;
188
+ const part = objectOrEmpty(event.part);
189
+ const tokens = objectOrEmpty(part.tokens);
190
+ const cache = objectOrEmpty(tokens.cache);
191
+ step.usage = canonicalUsage({
192
+ input: tokens.input,
193
+ output: tokens.output,
194
+ cache: { read: cache.read, write: cache.write },
195
+ }, nonNegativeNumber(part.cost, 0));
196
+ out.push(...flush(true));
197
+ break;
198
+ }
199
+ case "error": {
200
+ // Errors carry their payload in `event.error` (`{type, message, status?, response?}`,
201
+ // plan finding 8); classification reads type first, message second.
202
+ const err = objectOrEmpty(event.error);
203
+ const cls = classifyError(err);
204
+ if (cls.freeTier)
205
+ sawFreeTier = true;
206
+ const message = typeof err.message === "string" ? err.message : "";
207
+ // The open step (if any) is cut off by the error and flushed without usage first.
208
+ out.push(...flush(false));
209
+ const m = { role: "assistant", content: [], stopReason: "error" };
210
+ if (message !== "")
211
+ m.errorMessage = message;
212
+ // tumwaterKind/backendKind ride the canonical EVENT (not its message): the parser
213
+ // reads event.tumwaterKind and trusts it before its text regexes.
214
+ out.push(JSON.stringify({
215
+ type: "message_end",
216
+ message: m,
217
+ ...(cls.kind !== undefined ? { tumwaterKind: cls.kind } : {}),
218
+ ...(cls.backendKind !== undefined ? { backendKind: cls.backendKind } : {}),
219
+ ...(cls.retryAfterSeconds !== undefined ? { retryAfterSeconds: cls.retryAfterSeconds } : {}),
220
+ }));
221
+ break;
222
+ }
223
+ default:
224
+ // Record the unknown type ("" for a type-less line), once, in first-seen order.
225
+ record(typeof type === "string" ? type : "unknown");
226
+ break;
227
+ }
228
+ return out;
229
+ };
230
+ return {
231
+ translate,
232
+ finalize() {
233
+ // The native stream ended with a step still open (a killed run, or a stream that just
234
+ // went idle): flush it as a message_end without usage so the last turn is not lost.
235
+ return flush(false);
236
+ },
237
+ noteOpencodeUsagePartial() {
238
+ warnings.add("opencode_usage_partial");
239
+ },
240
+ warnings() {
241
+ // The free-tier refusal is recorded lazily so a run reports it exactly once.
242
+ if (sawFreeTier)
243
+ warnings.add("opencode_free_tier_refused");
244
+ return [...warnings];
245
+ },
246
+ };
247
+ }
248
+ /** Turn an `opencode session export` object into the run's two authoritative canonical lines,
249
+ * `run_cost` and `run_usage`, read from the export's `info`. The live stream drops the final
250
+ * step's usage in most runs (finding 5), so the export — not the sum of the streamed steps — is
251
+ * the run's real cost and token totals. Anything malformed returns `[]`; part 5/7 logs
252
+ * `opencode_usage_partial` when this is empty. */
253
+ export function opencodeExportLines(exportJson) {
254
+ if (typeof exportJson !== "object" || exportJson === null || Array.isArray(exportJson))
255
+ return [];
256
+ const info = exportJson.info;
257
+ if (typeof info !== "object" || info === null)
258
+ return [];
259
+ const cost = nonNegativeNumber(info.cost, undefined);
260
+ if (cost === undefined)
261
+ return [];
262
+ // `info.tokens` must itself be a JSON object: a missing, scalar, or array (torn) section
263
+ // returns [] — never a zeroed run_usage — so part 5/7's `opencode_usage_partial` trigger
264
+ // fires. isJsonObject rejects the array a bare `typeof === "object"` lets through.
265
+ const rawTokens = info.tokens;
266
+ if (!isJsonObject(rawTokens))
267
+ return [];
268
+ const tokens = rawTokens;
269
+ const input = nonNegativeNumber(tokens.input, 0);
270
+ const output = nonNegativeNumber(tokens.output, 0);
271
+ const cache = objectOrEmpty(tokens.cache);
272
+ const cacheRead = nonNegativeNumber(cache.read, 0);
273
+ const cacheWrite = nonNegativeNumber(cache.write, 0);
274
+ return [
275
+ JSON.stringify({ type: "run_cost", total: cost }),
276
+ JSON.stringify({ type: "run_usage", input, output, cacheRead, cacheWrite }),
277
+ ];
278
+ }
@@ -1,34 +1,18 @@
1
+ // vocabulary: plain
1
2
  /**
2
- * The one write path for a role's notebook, shared by the two agent-facing surfaces: the
3
- * bundled pi extension (src/pi-extension/role-notes.ts) and claude's stdio MCP server
4
- * (src/agent/mcp/role-notes-server.ts, plans/agent-backends.md "What the pi extensions did,
5
- * per backend"). Kept backend-neutral — no pi or claude types — so both registers can quote
6
- * the SAME 4 KB cap, the SAME parameter schema, and the SAME validate-then-atomic-write
7
- * execution, and cannot drift on what a valid note is or what a rejected call does.
3
+ * The one write path for a role's notebook, shared by every surface that touches it: the
4
+ * `tumwater-notes` command (src/notes-cli.ts), `tumwater role --notes-file` /
5
+ * `--clear-notes` (cli-query-commands.ts), the dashboard drawer (gui-operator-commands.ts)
6
+ * and the TUI editors (tui-editor-notes.ts, tui-pane-transcript.ts). Kept backend-neutral —
7
+ * no pi, claude or opencode types — so every surface quotes the SAME 4 KB cap, the SAME
8
+ * validation, and the SAME validate-then-atomic-write execution, and cannot drift on what
9
+ * a valid note is or what a rejected write does.
8
10
  */
9
11
  import { writeTextAtomic } from "../files/files.js";
10
12
  import { gotSuffix } from "../text/text.js";
11
13
  /** The cap on a role's note, in UTF-8 bytes. Small enough to cost little on every turn, large
12
14
  * enough for the codebase facts a role wants to keep. */
13
15
  export const ROLE_NOTES_MAX_BYTES = 4096;
14
- /** The tool's parameter schema (plain JSON Schema), shared by the pi extension and the MCP
15
- * server so both declare the same `{ text }` contract. */
16
- export const ROLE_NOTES_PARAMETERS = {
17
- type: "object",
18
- properties: {
19
- text: {
20
- type: "string",
21
- description: `The full replacement note, at most ${ROLE_NOTES_MAX_BYTES} bytes. Empty text clears it.`,
22
- },
23
- },
24
- required: ["text"],
25
- additionalProperties: false,
26
- };
27
- /** The human-facing description both surfaces carry: what the note is for, its cap, and the
28
- * replacement/clear semantics. */
29
- export const ROLE_NOTES_DESCRIPTION = `Replace this role's notebook: the short note the role's own earlier ticks left for the ` +
30
- `next tick (where things live, what was ruled out, what to look at next; at most ` +
31
- `${ROLE_NOTES_MAX_BYTES} bytes). Pass the full replacement text; empty text clears it.`;
32
16
  /** Validate note text against the byte cap. Returns null when the text is acceptable, or an
33
17
  * error naming the byte count and the fix — the same "shorten it to at most" wording
34
18
  * text/text.ts's tooLongMessage gives every other bounded text, so the model that overran
@@ -47,19 +31,19 @@ export function validateRoleNote(text) {
47
31
  export function writeRoleNote(notesPath, text) {
48
32
  writeTextAtomic(notesPath, text);
49
33
  }
50
- /** Validate and write one `role_notes` call's text, returning the surface's success message.
51
- * `text` is `unknown` because neither pi nor MCP guarantees the model honored the parameter
52
- * schema: coercing a malformed `text` to "" cleared the notebook, the destructive reading of
53
- * a typo the model never sees, so a bad call throws instead and the caller renders it as a
54
- * tool error. Shared so the two surfaces reject the same inputs with the same wording and the
55
- * same no-write-on-failure behavior. */
34
+ /** Validate and write one note's text, returning the success message surfaces show. `text` is
35
+ * `unknown` because a caller may hand it a malformed value (a malformed heredoc, a stray
36
+ * argument) without having enforced the string contract: coercing a malformed `text` to ""
37
+ * would clear the notebook, the destructive reading of a typo the writer never sees, so a bad
38
+ * value throws instead and the caller renders it as an error. Shared so every surface
39
+ * rejects the same inputs with the same wording and the same no-write-on-failure behavior. */
56
40
  export function saveRoleNote(notesPath, text) {
57
41
  if (typeof text !== "string") {
58
- throw new Error(`role_notes requires a string "text"${text === undefined ? "" : gotSuffix(text)} — pass the full replacement note, or "" to clear it`);
42
+ throw new Error(`a note must be a string, not ${typeof text}${text === undefined ? "" : gotSuffix(text)} — pass the full replacement note, or "" to clear it`);
59
43
  }
60
44
  const error = validateRoleNote(text);
61
45
  if (error)
62
46
  throw new Error(error);
63
47
  writeRoleNote(notesPath, text);
64
- return text === "" ? "notebook cleared" : `notebook saved (${Buffer.byteLength(text, "utf8")} bytes)`;
48
+ return text === "" ? "notes cleared" : `notes saved (${Buffer.byteLength(text, "utf8")} bytes)`;
65
49
  }
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** The backend-neutral agent runner (plans/agent-backends.md \"The seam\"): everything a run
2
3
  * needs regardless of which agent it drives — the spawn, the `tumwater_run` raw-log marker,
3
4
  * the watchdog wiring, abort, the group kill and marker sweep, stderr capture, and result
@@ -20,6 +21,11 @@ import { DEFAULT_AGENT, agentBackend, registerAgentBackend } from "./agent-backe
20
21
  import { recordSession } from "./session-ledger.js";
21
22
  import { piBackend } from "../pi/pi-backend.js";
22
23
  import { claudeBackend } from "./claude/claude-backend.js";
24
+ import { opencodeBackend } from "./opencode/opencode-backend.js";
25
+ import { seamImageInput } from "./image-input.js";
26
+ import { ensureNotesShim } from "./notes-shim.js";
27
+ import { nearestTumwaterRoot } from "../paths.js";
28
+ import { piModelsPath, readPiProviders } from "../pi/pi-models.js";
23
29
  /** How many trailing stderr characters are carried into the next chunk's warning match, so a
24
30
  * warning split across two `data` events is still seen. The warnings are short lines, so a few
25
31
  * hundred characters always cover a straddle. */
@@ -35,6 +41,7 @@ export const MAX_NATIVE_LINE_CHARS = 16 * 1024 * 1024;
35
41
  // explicit and lets a test register a fake under its own id.
36
42
  registerAgentBackend(piBackend);
37
43
  registerAgentBackend(claudeBackend);
44
+ registerAgentBackend(opencodeBackend);
38
45
  /** Prefix of PiRunResult.errorMessage when the agent process never started (no session file
39
46
  * is created for such a run). The resolved binary follows it, so a configured agentBin
40
47
  * reads "failed to spawn <bin>" rather than blaming an ambient "pi". */
@@ -42,7 +49,7 @@ const SPAWN_ERROR_PREFIX = "failed to spawn";
42
49
  /** The spawn-error message for a resolved binary: the default source keeps today's text
43
50
  * ("failed to spawn pi: …"); a configured source names the binary and where it came from —
44
51
  * the backend's own env var and config key (agentBinConfigKey) — so a wrong
45
- * `agents.claude.bin` does not read as pi's legacy `agentBin`. */
52
+ * `backends.claude.bin` does not read as pi's legacy `agentBin`. */
46
53
  function spawnErrorMessage(resolved, errMessage, backend) {
47
54
  const source = resolved.source === "default"
48
55
  ? ""
@@ -88,12 +95,52 @@ export function runAgent(opts) {
88
95
  marker.label = opts.label;
89
96
  rawLog.stream.write(JSON.stringify(marker) + "\n");
90
97
  }
91
- const parser = new PiStreamParser(opts.onToolCallStart);
98
+ // The first blind image read an authoring run made (plans/image-input.md "Detecting a
99
+ // blind read"): the path, plus an indirection to the child so the parser's parse-time
100
+ // callback (which fires during feed, after spawn) can kill it. Unset for a gate run,
101
+ // which only records the read. Declared before the parser so its onBlindRead closure
102
+ // (which assigns them at parse time, after spawn) can reference them.
103
+ let blindRead;
104
+ let terminateRun;
105
+ const parser = new PiStreamParser(opts.onToolCallStart, {
106
+ // The seam's effective image input (src/agent/image-input.ts): a text-only seam makes
107
+ // every image read blind even when pi adds no note — a claim its doctor probe proved
108
+ // false — which the parser checks against its own note rule.
109
+ imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
110
+ onBlindRead: (path) => {
111
+ // Kill an authoring run at once, before the agent goes on working from a picture it
112
+ // never saw (terminateChild is what the watchdogs use). Gate runs (reviewer,
113
+ // resolver) only record the read and keep going.
114
+ if (opts.kind === "author") {
115
+ blindRead = path;
116
+ terminateRun?.();
117
+ }
118
+ },
119
+ });
92
120
  // The run's cross-group attribution mark, minted before the spawn so the environment can
93
121
  // carry it and the exit sweep can name it.
94
122
  const runMarker = makeRunMarker();
95
123
  const resolved = backend.resolveBin(opts.config);
96
- const invocation = backend.invocation({
124
+ // Install the notes shim under the team root's `.tumwater/bin` so every backend's run finds
125
+ // `tumwater-notes` first on PATH (notes-shim.ts). A failure to write it logs one warning and
126
+ // the run goes on without it — notes would then be unwritable by the command, but the run
127
+ // itself is not blocked.
128
+ let binDir;
129
+ try {
130
+ // Install the shim only under a real team root — the nearest ancestor holding a
131
+ // `.tumwater`. A session dir with no such ancestor (a lab script, a smoke run, a
132
+ // prompt-lab replay) must never mint one: nearestTumwaterRoot is undefined then, and the
133
+ // run goes on without the shim, exactly as if the install had failed — writing
134
+ // `<fallback>/.tumwater` from a run created a phantom harness root in a shared directory
135
+ // and broke every other suite on the host (BUGS.md 2026-10-18).
136
+ const root = nearestTumwaterRoot(opts.sessionDir);
137
+ if (root !== undefined)
138
+ binDir = ensureNotesShim(root);
139
+ }
140
+ catch {
141
+ opts.onAgentWarning?.("could not install the tumwater-notes shim; this run will not find tumwater-notes on PATH");
142
+ }
143
+ const runSpec = {
97
144
  cwd: opts.cwd,
98
145
  prompt: opts.prompt,
99
146
  config: opts.config,
@@ -104,8 +151,10 @@ export function runAgent(opts) {
104
151
  agentBin: resolved.bin,
105
152
  runMarker,
106
153
  notesPath: opts.notesPath,
154
+ ...(binDir !== undefined ? { binDir } : {}),
107
155
  ...(opts.maxBudgetUsd !== undefined ? { maxBudgetUsd: opts.maxBudgetUsd } : {}),
108
- });
156
+ };
157
+ const invocation = backend.invocation(runSpec);
109
158
  // Record the run in the session ledger before its spawn, so a later resume can pick the
110
159
  // newest session of its own kind instead of whatever file is newest in the dir. The id is
111
160
  // the backend's own when it mints one ahead of the run (claude's UUID, or the resumed
@@ -147,8 +196,8 @@ export function runAgent(opts) {
147
196
  const runTmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-run-"));
148
197
  const child = spawn(resolved.bin, invocation.args, {
149
198
  cwd: opts.cwd,
150
- // The prompt-argv backends (pi) deliver no stdin and keep stdio[0] "ignore"; a backend
151
- // that feeds the prompt on stdin gets a pipe the runner writes and ends below.
199
+ // A backend that feeds the prompt on stdin (every registered one) gets a pipe the runner
200
+ // writes and ends below; one that returns no stdin keeps stdio[0] "ignore".
152
201
  stdio: [invocation.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
153
202
  // pi sets its process title at startup, and so does every npm its tool calls run: on
154
203
  // macOS each would register with LaunchServices and leak a launchservicesd port.
@@ -168,20 +217,23 @@ export function runAgent(opts) {
168
217
  // an unknown flag, an unresumable session, or a not-logged-in exit — leaves this write
169
218
  // nothing to feed. Once the prompt exceeds the OS pipe buffer the write then fails EPIPE,
170
219
  // and an unhandled 'error' event on the stdin stream is thrown from the event loop and
171
- // takes the whole process (the whole fleet) down. The child's own exit status and stderr
220
+ // takes the whole process (the whole team) down. The child's own exit status and stderr
172
221
  // already report why the run died, so swallowing the failed write adds no silence the
173
222
  // caller needs to break; git.ts's patchId carries the same guard for a patch-id that
174
223
  // dies before reading its input.
175
224
  child.stdin?.on("error", () => { });
176
225
  child.stdin?.end(invocation.stdin);
177
226
  }
227
+ // Armed now that the child exists, so the parser's parse-time onBlindRead (fires during
228
+ // feed, always after spawn) can reach it.
229
+ terminateRun = () => terminateChild(child);
178
230
  // The run's two watchdog clocks (src/pi/pi-watchdogs.ts): the tick deadline and the quiet
179
231
  // watchdog (quiet-kill plus the stalled-tool-call warning). The flags their timers set
180
232
  // (timedOut/timedOutProgressing/quietKilled/sawOutput) read live off the returned handle.
181
233
  const wd = startPiWatchdogs({
182
234
  progressCount: () => parser.progressCount,
183
235
  openToolCalls: () => parser.openToolCalls,
184
- tickTimeoutMs: opts.config.tickTimeoutSeconds * 1000,
236
+ tickTimeoutMs: opts.config.runTimeoutSeconds * 1000,
185
237
  quietMs: opts.config.quietTimeoutSeconds * 1000,
186
238
  stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
187
239
  // The run's backend id: claude never sends content updates, so the watchdog judges its
@@ -272,6 +324,13 @@ export function runAgent(opts) {
272
324
  if (settled)
273
325
  return;
274
326
  settled = true;
327
+ // A blind image read (plans/image-input.md "Detecting a blind read"): surface it to the
328
+ // caller once, at settle, with the action its kind earned — the authoring run was killed
329
+ // ("fail"), a gate run only recorded it ("logged"). The caller logs the image_blind event
330
+ // with its own loop/tick context.
331
+ if (parser.imageBlind) {
332
+ opts.onBlindRead?.(parser.imageBlind.path, opts.kind === "author" ? "fail" : "logged");
333
+ }
275
334
  // Best-effort: an unremovable scratch dir (read-only subdir, a straggler still writing) must
276
335
  // not throw out of the close handler before the run's result resolves.
277
336
  try {
@@ -395,7 +454,19 @@ export function runAgent(opts) {
395
454
  feedCanonical(decoder.end(), true);
396
455
  for (const canonical of translator.finalize?.() ?? [])
397
456
  parser.feed(canonical + "\n", onLine);
398
- const killed = aborted || wd.timedOut || wd.quietKilled;
457
+ // A blind image read on an authoring run deliberately killed it (terminateRun above),
458
+ // so its exit is a kill like the watchdogs' — the half-done work is discarded.
459
+ const killed = aborted || wd.timedOut || wd.quietKilled || blindRead !== undefined;
460
+ // The backend's post-run reconciliation (opencode's authoritative session export), only
461
+ // when the run is not already settled (a failed spawn settles in the 'error' handler
462
+ // before 'close') and was not killed — a killed run's session is half-done and no export
463
+ // should spend its 10 s on it. Its lines are written and parsed like streamed ones, so
464
+ // they reach the raw log and the parser's authoritative fold (run_cost/run_usage).
465
+ if (!settled && !killed) {
466
+ for (const canonical of backend.afterRun?.(runSpec, invocation, translator) ?? []) {
467
+ parser.feed(canonical + "\n", onLine);
468
+ }
469
+ }
399
470
  const failed = killed ||
400
471
  parser.stopReason === "error" ||
401
472
  (code !== 0 && !parser.finalText.trim());
@@ -408,21 +479,25 @@ export function runAgent(opts) {
408
479
  cls.transientPiCrash !== true &&
409
480
  stderrBackend === undefined &&
410
481
  (parser.configError || cls.configError === true);
482
+ // The blind-read failure names both the file and the model (plans/image-input.md
483
+ // "Failing the tick"): take it over any generic exit text so lastError is actionable.
484
+ const errorMessage = blindRead !== undefined
485
+ ? `read ${blindRead}, but ${opts.config.model ?? "the model"} cannot read images`
486
+ : aborted
487
+ ? "stopped by tumwater shutdown"
488
+ : wd.quietKilled
489
+ ? `killed as hung: no agent progress for over ${opts.config.quietTimeoutSeconds}s`
490
+ : wd.timedOut
491
+ ? wd.timedOutProgressing
492
+ ? `timed out after ${wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
493
+ : `timed out after ${wd.timeoutBudgetMs / 1000}s`
494
+ : (parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
411
495
  finish(resultFromParser({
412
496
  ok: !failed,
413
497
  transientPiCrash: cls.transientPiCrash === true,
414
498
  transientBackend: parser.transientBackend || cls.transientBackend === true,
415
499
  backendKind: parser.backendFailureKind ?? stderrBackend,
416
500
  configError,
417
- errorMessage: aborted
418
- ? "aborted by harness shutdown"
419
- : wd.quietKilled
420
- ? `killed as hung: no pi progress for over ${opts.config.quietTimeoutSeconds}s`
421
- : wd.timedOut
422
- ? wd.timedOutProgressing
423
- ? `timed out after ${wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
424
- : `timed out after ${wd.timeoutBudgetMs / 1000}s`
425
- : (parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `pi exited ${code}` : undefined)),
426
501
  // Kept distinct on purpose: a hung tool call leaves its session and worktree edits
427
502
  // intact, so the loop resumes them (quiet_killed) instead of discarding them as an
428
503
  // unfulfilled timeout does. A deadline that fired on a run still making progress
@@ -430,6 +505,8 @@ export function runAgent(opts) {
430
505
  timedOut: wd.timedOut,
431
506
  timedOutProgressing: wd.timedOut && wd.timedOutProgressing,
432
507
  quietKilled: wd.quietKilled,
508
+ imageBlind: parser.imageBlind,
509
+ errorMessage,
433
510
  }));
434
511
  });
435
512
  });