tumwater 0.1.3 → 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 (359) hide show
  1. package/README.md +63 -28
  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-eligibility.js +332 -43
  26. package/dist/src/backlog/backlog-md.js +79 -17
  27. package/dist/src/backlog/backlog-render.js +62 -11
  28. package/dist/src/backlog/backlog-structure.js +261 -58
  29. package/dist/src/backlog/backlog-write.js +178 -441
  30. package/dist/src/backlog/backlog.js +113 -16
  31. package/dist/src/backlog/question-answer.js +339 -0
  32. package/dist/src/baseline/main-baseline.js +75 -88
  33. package/dist/src/baseline/main-red.js +32 -30
  34. package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
  35. package/dist/src/{brief.js → brief/brief.js} +33 -8
  36. package/dist/src/budget/budget.js +32 -15
  37. package/dist/src/budget/fallback-breaker.js +8 -7
  38. package/dist/src/build/build-check-detect.js +8 -7
  39. package/dist/src/build/build-check-events.js +68 -22
  40. package/dist/src/build/build-check-report.js +22 -23
  41. package/dist/src/build/build-check-scoped.js +51 -62
  42. package/dist/src/build/build-check.js +15 -14
  43. package/dist/src/build/build-info.js +2 -1
  44. package/dist/src/build/build-stage.js +23 -22
  45. package/dist/src/build/dep-install.js +22 -21
  46. package/dist/src/build/host-sleep.js +1 -1
  47. package/dist/src/change/change-data.js +13 -11
  48. package/dist/src/change/change-render.js +11 -10
  49. package/dist/src/cli/brief-command.js +9 -7
  50. package/dist/src/cli/cli-args.js +25 -5
  51. package/dist/src/cli/cli-command-args.js +43 -10
  52. package/dist/src/cli/cli-editor.js +13 -1
  53. package/dist/src/cli/cli-flag-specs.js +31 -6
  54. package/dist/src/cli/cli-marker-commands.js +13 -10
  55. package/dist/src/cli/cli-query-commands.js +439 -56
  56. package/dist/src/cli/cli-repo-gate.js +1 -1
  57. package/dist/src/cli/cli-run.js +124 -68
  58. package/dist/src/cli/completion.js +70 -12
  59. package/dist/src/cli/config-commands.js +14 -11
  60. package/dist/src/cli/help.js +264 -124
  61. package/dist/src/cli/log-commands.js +127 -21
  62. package/dist/src/cli/notify-command.js +33 -18
  63. package/dist/src/cli/question-commands.js +13 -224
  64. package/dist/src/cli.js +78 -36
  65. package/dist/src/collections.js +66 -16
  66. package/dist/src/concurrency/check-permit.js +16 -17
  67. package/dist/src/concurrency/lock.js +6 -5
  68. package/dist/src/concurrency/provider-lease.js +69 -0
  69. package/dist/src/concurrency/semaphore.js +258 -36
  70. package/dist/src/config/config-aliases.js +106 -0
  71. package/dist/src/config/config-editable-keys.js +3 -3
  72. package/dist/src/config/config-fallbacks.js +379 -0
  73. package/dist/src/config/config-field-checks.js +19 -10
  74. package/dist/src/config/config-keys.js +160 -0
  75. package/dist/src/config/config-live.js +64 -12
  76. package/dist/src/config/config-schema.js +34 -22
  77. package/dist/src/config/config-validation.js +132 -48
  78. package/dist/src/config/config-views.js +107 -325
  79. package/dist/src/config/config-write.js +138 -177
  80. package/dist/src/config/config.js +60 -49
  81. package/dist/src/config/model-selector.js +28 -2
  82. package/dist/src/doctor/doctor-backlog.js +494 -68
  83. package/dist/src/doctor/doctor-checks.js +509 -58
  84. package/dist/src/doctor/doctor-holds.js +139 -78
  85. package/dist/src/doctor/doctor-install.js +30 -0
  86. package/dist/src/doctor/doctor-launch-services.js +2 -2
  87. package/dist/src/doctor/doctor-model-checks.js +549 -157
  88. package/dist/src/doctor/doctor-notify.js +2 -3
  89. package/dist/src/doctor/doctor-orphans.js +10 -9
  90. package/dist/src/doctor/doctor-render.js +36 -9
  91. package/dist/src/doctor/doctor.js +76 -25
  92. package/dist/src/doctor/image-probe.js +244 -0
  93. package/dist/src/doctor/probe.js +64 -0
  94. package/dist/src/events/event-format.js +219 -106
  95. package/dist/src/events/event-labels.js +116 -0
  96. package/dist/src/events/event-legacy.js +140 -0
  97. package/dist/src/events/event-read.js +58 -22
  98. package/dist/src/events/event-window.js +27 -7
  99. package/dist/src/events/events.js +18 -14
  100. package/dist/src/events/notify.js +80 -30
  101. package/dist/src/failure/failure-cluster.js +12 -9
  102. package/dist/src/failure/failure-data.js +96 -48
  103. package/dist/src/failure/failure-render.js +69 -58
  104. package/dist/src/failure/failure-state-change.js +33 -26
  105. package/dist/src/failure/time-spend.js +79 -59
  106. package/dist/src/files/file-queue.js +16 -4
  107. package/dist/src/files/files.js +18 -6
  108. package/dist/src/files/json-object.js +18 -3
  109. package/dist/src/files/tail.js +1 -1
  110. package/dist/src/gates/bootstrap-gates.js +5 -4
  111. package/dist/src/gates/budget-gates.js +250 -34
  112. package/dist/src/gates/disk-gate.js +18 -18
  113. package/dist/src/gates/gate-polls.js +117 -69
  114. package/dist/src/gates/maintenance-quota.js +29 -29
  115. package/dist/src/gates/pause-gates.js +11 -11
  116. package/dist/src/gates/readiness.js +2 -1
  117. package/dist/src/gates/role-cap-gates.js +16 -21
  118. package/dist/src/gates/startup-gate.js +75 -31
  119. package/dist/src/gates/streak-gate.js +18 -16
  120. package/dist/src/git/commit-message.js +6 -5
  121. package/dist/src/git/git-diff.js +16 -16
  122. package/dist/src/git/git-run.js +8 -8
  123. package/dist/src/git/git.js +79 -59
  124. package/dist/src/git/slots-state.js +14 -5
  125. package/dist/src/git/worktree-pool.js +25 -18
  126. package/dist/src/git/worktree-use.js +15 -14
  127. package/dist/src/git/worktree.js +18 -18
  128. package/dist/src/gui/gui-args.js +10 -14
  129. package/dist/src/gui/gui-backlog-commands.js +273 -0
  130. package/dist/src/gui/gui-command.js +1 -1
  131. package/dist/src/gui/gui-config-commands.js +440 -0
  132. package/dist/src/gui/gui-endpoint-commands.js +16 -716
  133. package/dist/src/gui/gui-endpoint-common.js +49 -0
  134. package/dist/src/gui/gui-endpoints.js +105 -54
  135. package/dist/src/gui/gui-operator-commands.js +145 -0
  136. package/dist/src/gui/gui-prompt-commands.js +234 -0
  137. package/dist/src/gui/gui-server.js +25 -8
  138. package/dist/src/gui/gui-team-commands.js +164 -0
  139. package/dist/src/gui/http-body.js +17 -1
  140. package/dist/src/history/history-data.js +179 -104
  141. package/dist/src/history/history-render.js +76 -0
  142. package/dist/src/history/history.js +29 -70
  143. package/dist/src/inbox/inbox-attachments.js +66 -10
  144. package/dist/src/inbox/inbox-cancel.js +7 -5
  145. package/dist/src/inbox/inbox-edit.js +1 -1
  146. package/dist/src/inbox/inbox-submit.js +44 -4
  147. package/dist/src/inbox/inbox-top.js +83 -0
  148. package/dist/src/inbox/inbox.js +8 -2
  149. package/dist/src/inbox/pending-prompt.js +1 -1
  150. package/dist/src/inbox/prompt-commands.js +114 -73
  151. package/dist/src/init/init-templates.js +16 -14
  152. package/dist/src/init/init.js +12 -11
  153. package/dist/src/loop/leftover.js +28 -27
  154. package/dist/src/loop/loop-fallback.js +2 -2
  155. package/dist/src/loop/loop-pi.js +104 -50
  156. package/dist/src/loop/loop-recovery.js +15 -15
  157. package/dist/src/loop/loop-state.js +130 -13
  158. package/dist/src/loop/loop-tick.js +190 -88
  159. package/dist/src/loop/loop.js +68 -36
  160. package/dist/src/loop/model-fallback.js +3 -2
  161. package/dist/src/loop/revision.js +16 -7
  162. package/dist/src/merge/backlog-conflicts.js +155 -0
  163. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  164. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  165. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  166. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  167. package/dist/src/merge/merge-drain.js +212 -0
  168. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -20
  169. package/dist/src/merge/merge-pipeline.js +99 -0
  170. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  171. package/dist/src/merge/merge-queue-render.js +75 -0
  172. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  173. package/dist/src/merge/merge-slot.js +340 -0
  174. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  175. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  176. package/dist/src/merge/merge-verify.js +212 -0
  177. package/dist/src/notes-cli.js +97 -0
  178. package/dist/src/operator/operator-commands.js +113 -45
  179. package/dist/src/operator/operator-intent.js +74 -53
  180. package/dist/src/operator/operator-requests.js +52 -52
  181. package/dist/src/operator/retire.js +47 -28
  182. package/dist/src/operator/revert.js +71 -0
  183. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  184. package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
  185. package/dist/src/orchestrator/orchestrator.js +149 -96
  186. package/dist/src/orchestrator/retention.js +29 -12
  187. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  188. package/dist/src/orchestrator/startup-migrations.js +40 -0
  189. package/dist/src/paths.js +124 -38
  190. package/dist/src/pi/pi-args.js +26 -3
  191. package/dist/src/pi/pi-backend.js +15 -23
  192. package/dist/src/pi/pi-bin.js +18 -12
  193. package/dist/src/pi/pi-event-line.js +17 -1
  194. package/dist/src/pi/pi-failure.js +87 -0
  195. package/dist/src/pi/pi-models.js +126 -38
  196. package/dist/src/pi/pi-run-result.js +1 -1
  197. package/dist/src/pi/pi-settings.js +86 -0
  198. package/dist/src/pi/pi-stream.js +131 -107
  199. package/dist/src/pi/pi-watchdogs.js +4 -3
  200. package/dist/src/pi-extension/bounded-output.js +20 -5
  201. package/dist/src/pi-extension/context-budget.js +3 -1
  202. package/dist/src/pi-extension/context-shake.js +54 -21
  203. package/dist/src/pi-extension/context-window.js +37 -0
  204. package/dist/src/pi-extension/full-output.js +2 -2
  205. package/dist/src/pi-extension/strip-thinking.js +80 -0
  206. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  207. package/dist/src/process/launch-services.js +11 -10
  208. package/dist/src/process/process-table.js +1 -1
  209. package/dist/src/process/process.js +7 -7
  210. package/dist/src/process/run-marker.js +29 -6
  211. package/dist/src/process/supervisor.js +36 -25
  212. package/dist/src/prompt/dialect.js +30 -10
  213. package/dist/src/prompt/gate-prompts.js +170 -67
  214. package/dist/src/prompt/principles.js +1 -0
  215. package/dist/src/prompt/prompt-followup.js +29 -20
  216. package/dist/src/prompt/prompt.js +249 -91
  217. package/dist/src/redeploy/redeploy-policy.js +10 -10
  218. package/dist/src/redeploy/redeploy.js +6 -4
  219. package/dist/src/redeploy/redeployer.js +63 -55
  220. package/dist/src/redeploy/self-reload.js +1 -1
  221. package/dist/src/report/report-data.js +259 -254
  222. package/dist/src/report/report-fold.js +353 -0
  223. package/dist/src/report/report-render.js +235 -44
  224. package/dist/src/request-timeouts.js +1 -1
  225. package/dist/src/review/known-flakes.js +34 -14
  226. package/dist/src/review/protected-edits.js +4 -3
  227. package/dist/src/review/review-followup.js +16 -4
  228. package/dist/src/review/review-precheck.js +14 -13
  229. package/dist/src/review/review-verdict.js +14 -0
  230. package/dist/src/review/review.js +55 -34
  231. package/dist/src/review/secret-leaks.js +127 -0
  232. package/dist/src/review/suite-rerun.js +4 -3
  233. package/dist/src/roles/legacy-role-ids.js +21 -0
  234. package/dist/src/roles/legacy-role-migration.js +62 -0
  235. package/dist/src/roles/loop-ids.js +19 -2
  236. package/dist/src/roles/role-catalog.js +109 -131
  237. package/dist/src/roles/role-guidance.js +64 -31
  238. package/dist/src/roles/role-labels.js +10 -0
  239. package/dist/src/roles/role-render.js +194 -18
  240. package/dist/src/roles/role-view.js +216 -17
  241. package/dist/src/roles/roles.js +16 -14
  242. package/dist/src/scheduling/backoff.js +29 -19
  243. package/dist/src/scheduling/claims.js +15 -8
  244. package/dist/src/scheduling/once-round.js +11 -11
  245. package/dist/src/scheduling/quiet-hours.js +25 -32
  246. package/dist/src/scheduling/scheduling.js +28 -25
  247. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  248. package/dist/src/status/status-budget.js +115 -0
  249. package/dist/src/status/status-data.js +72 -184
  250. package/dist/src/status/status-inbox.js +47 -0
  251. package/dist/src/status/status-install.js +27 -0
  252. package/dist/src/status/status-polls.js +50 -39
  253. package/dist/src/status/status-roles.js +105 -0
  254. package/dist/src/{fleet → team}/error-storm.js +10 -10
  255. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  256. package/dist/src/{fleet → team}/orchestrator-info.js +84 -18
  257. package/dist/src/{fleet → team}/reclaim.js +5 -4
  258. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  259. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  260. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +39 -38
  261. package/dist/src/text/datetime.js +42 -2
  262. package/dist/src/text/format.js +10 -1
  263. package/dist/src/text/markdown.js +1 -1
  264. package/dist/src/text/phrases.js +35 -34
  265. package/dist/src/text/suggest.js +3 -2
  266. package/dist/src/text/text.js +2 -2
  267. package/dist/src/tick/qa-coverage.js +2 -1
  268. package/dist/src/tick/stage-check.js +142 -25
  269. package/dist/src/tick/telemetry-digest.js +10 -9
  270. package/dist/src/tick/tick-apply.js +101 -72
  271. package/dist/src/tick/tick-detail-data.js +23 -22
  272. package/dist/src/tick/tick-detail.js +37 -24
  273. package/dist/src/tick/tick-finalize.js +29 -22
  274. package/dist/src/tick/tick-outcome.js +2 -1
  275. package/dist/src/tick/tick-prompt.js +94 -26
  276. package/dist/src/tick/tick-resume.js +29 -13
  277. package/dist/src/tick/tick-stage.js +67 -53
  278. package/dist/src/tick/tick-timing.js +60 -59
  279. package/dist/src/tick/tick-usage.js +53 -23
  280. package/dist/src/tick/tick-verdict.js +75 -10
  281. package/dist/src/ui/badges.js +113 -69
  282. package/dist/src/ui/gui/gui-client-boot.js +202 -29
  283. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  284. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  285. package/dist/src/ui/gui/gui-client-history.js +94 -37
  286. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  287. package/dist/src/ui/gui/gui-client-loops.js +90 -47
  288. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  289. package/dist/src/ui/gui/gui-client-model.js +60 -43
  290. package/dist/src/ui/gui/gui-client-operator.js +73 -43
  291. package/dist/src/ui/gui/gui-client-pending.js +34 -31
  292. package/dist/src/ui/gui/gui-client-report.js +330 -47
  293. package/dist/src/ui/gui/gui-client-settings.js +126 -18
  294. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  295. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
  296. package/dist/src/ui/gui/gui-client.js +26 -20
  297. package/dist/src/ui/gui/gui-page.js +18 -19
  298. package/dist/src/ui/gui/gui-styles.js +12 -4
  299. package/dist/src/ui/progress-data.js +5 -3
  300. package/dist/src/ui/status-model.js +176 -102
  301. package/dist/src/ui/status-payload.js +114 -36
  302. package/dist/src/ui/status-render.js +121 -52
  303. package/dist/src/ui/team-alerts.js +341 -0
  304. package/dist/src/ui/tick-progress-model.js +14 -14
  305. package/dist/src/ui/tone.js +9 -8
  306. package/dist/src/ui/transcript-tail.js +1 -0
  307. package/dist/src/ui/transcript.js +23 -16
  308. package/dist/src/ui/tui/tui-app.js +1 -0
  309. package/dist/src/ui/tui/tui-backlog.js +85 -11
  310. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  311. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  312. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  313. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  314. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  315. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  316. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  317. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  318. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  319. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  320. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  321. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  322. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  323. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  324. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  325. package/dist/src/ui/tui/tui-editors.js +152 -0
  326. package/dist/src/ui/tui/tui-frame.js +61 -57
  327. package/dist/src/ui/tui/tui-history.js +12 -7
  328. package/dist/src/ui/tui/tui-input.js +44 -50
  329. package/dist/src/ui/tui/tui-keymap.js +1 -0
  330. package/dist/src/ui/tui/tui-keys.js +87 -1821
  331. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  332. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  333. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  334. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  335. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  336. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  337. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  338. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  339. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  340. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  341. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  342. package/dist/src/ui/tui/tui-pane.js +52 -28
  343. package/dist/src/ui/tui/tui-panes.js +294 -0
  344. package/dist/src/ui/tui/tui.js +44 -26
  345. package/dist/src/verdict/fix-claim.js +56 -33
  346. package/dist/src/verdict/no-change.js +19 -0
  347. package/dist/src/verdict/refusal.js +43 -11
  348. package/dist/src/verdict/reply-contract.js +17 -5
  349. package/dist/src/version.js +3 -2
  350. package/package.json +4 -2
  351. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  352. package/dist/src/landing/backlog-conflicts.js +0 -116
  353. package/dist/src/landing/landing-drain.js +0 -204
  354. package/dist/src/landing/landing-pipeline.js +0 -96
  355. package/dist/src/landing/landing-queue-render.js +0 -56
  356. package/dist/src/landing/landing-slot.js +0 -256
  357. package/dist/src/landing/landing-vetting.js +0 -196
  358. package/dist/src/pi-extension/role-notes.js +0 -40
  359. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,7 +1,8 @@
1
1
  import { extractRefusal, hasVerdictLine, isNegatedRefusal, isNothingToDo } from "../verdict/reply-contract.js";
2
- import { applyToolExecutionEvent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
2
+ import { applyToolExecutionEvent, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
3
3
  import { describeToolCall } from "../text/phrases.js";
4
- import { objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../files/json-object.js";
4
+ import { objectOrEmpty, nonNegativeNumber, parseJsonObject, isJsonObject } from "../files/json-object.js";
5
+ import { backendKind, isBackendFailureKind, isPermanentConfigError, TRANSIENT_BACKEND } from "./pi-failure.js";
5
6
  /** One wire number from the model server's usage block, or 0 when unusable. Only a finite,
6
7
  * non-negative number passes: a string (some OpenAI-compatible servers serialize usage
7
8
  * numbers as strings) would turn the `+=` accumulators into string concatenation and then
@@ -12,6 +13,29 @@ import { objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../files/json
12
13
  function usageNumber(value) {
13
14
  return nonNegativeNumber(value, 0);
14
15
  }
16
+ /** The text prefix of a pi `read` result that carries an image (plans/image-input.md
17
+ * "Detecting a blind read"): such a read's text block begins `Read image file [<path>…]`, and
18
+ * pi's non-vision note and its processing-failure notes sit inside that block. A text-only
19
+ * read (a code file) is not phrased this way. */
20
+ const IMAGE_READ_PREFIX = "Read image file [";
21
+ /** pi's note when the current model does not support images — the blind-read signal: the image
22
+ * is stripped from the request and the agent would answer about a picture it never saw. */
23
+ const BLIND_IMAGE_NOTE = "[Current model does not support images";
24
+ /** The `path` a read tool call's raw args name, or undefined when they carry none — the read
25
+ * tool opens a file by `path` (with optional `start`/`end` offsets), so the blind read blames
26
+ * the right file. A torn or foreign args object reads as no path (never throws). */
27
+ function readPathOf(args) {
28
+ if (!isJsonObject(args))
29
+ return undefined;
30
+ return typeof args.path === "string" && args.path ? args.path : undefined;
31
+ }
32
+ /** The text blocks of one tool-execution end's `result` (the same normalization
33
+ * messageContent applies to a message), or [] when the result is not an object. */
34
+ function resultContent(result) {
35
+ if (!isJsonObject(result))
36
+ return [];
37
+ return messageContent(result);
38
+ }
15
39
  /** The content blocks of one pi message, normalized: a `content` that is not an array (torn
16
40
  * or foreign wire JSON) reads as no blocks, and a non-object entry reads as an empty block,
17
41
  * so the `.filter`/`.some` chains below never throw on a shape the message interface does not
@@ -41,7 +65,7 @@ const CONTEXT_ERROR = /context (size|length|window)?\s*(has been |was )?exceeded
41
65
  * Module-private: only PiStreamParser.feedLine below matches against it. */
42
66
  const TRANSIENT_SERVER_TIMEOUT = /predict stream timed out/i;
43
67
  /** The provider rejecting the request with HTTP 429 — rate limiting. This is the world saying
44
- * "later", not the session failing: the fleet's single largest error source (BUGS.md 2026-09-21)
68
+ * "later", not the session failing: the team's single largest error source (BUGS.md 2026-09-21)
45
69
  * and by definition retryable. Matched against every error text feedLine sees, since pi renders
46
70
  * the provider's status and message variously ("429 \"Rate limit exceeded\"", OpenAI's
47
71
  * "Too Many Requests"). Retry-After, when the provider echoes it into the error text, is
@@ -51,90 +75,10 @@ const TRANSIENT_RATE_LIMIT = /\b429\b|too many requests|rate limit/i;
51
75
  * retrying. Tolerates the spellings error text actually uses ("Retry-After: 30",
52
76
  * "retry after 30s"). Module-private: only PiStreamParser.feedLine matches it. */
53
77
  const RETRY_AFTER = /retry[- ]after:?\s*(\d{1,4})/i;
54
- /** The provider rejecting the request with a permanent 4xx client error — a bad model id,
55
- * credential, or request shape (the observed `400 … model_not_supported` on a mistyped
56
- * provider suffix, BUGS.md 2026-10-06). Unlike a 429 or 408/5xx it can never succeed until the
57
- * configuration changes, so retrying it at suite speed loops forever (a reviewer failure is
58
- * strike-free by design). 408 (request timeout) and 429 are rate/timeouts, not config errors,
59
- * and are matched by the transient patterns above; this pattern covers 400/401/403/404 and the
60
- * provider's own permanent-error spellings. Exported so the landing path can hold a pin
61
- * instead of re-queuing it. */
62
- const PERMANENT_CONFIG_ERROR = /\b(?:400|401|403|404)\b|model_not_supported|invalid_request_error/i;
63
- /** True when an error text names a permanent provider configuration error (see
64
- * PERMANENT_CONFIG_ERROR). Pure, exported so the landing path and the stderr path classify
65
- * with one rule. */
66
- export function isPermanentConfigError(text) {
67
- return PERMANENT_CONFIG_ERROR.test(text);
68
- }
69
- /** undici's spelling of a severed HTTP stream: the provider (or its own idle timeout) cuts
70
- * the request while pi is still reading it, and undici's fetch rejects with the bare word
71
- * "terminated" — the 2026-08-22 idle-timeout diagnosis. Anchored to the END of the error text
72
- * on purpose: the real message is the bare word, optionally behind a prefix and closing
73
- * punctuation, while an unrelated error that merely contains the word ("worker terminated
74
- * with exit code 1") must not read as a backend failure. Module-private: matchBackendFailure
75
- * below, the parser, and backendKind all share the one instance. */
76
- const STREAM_SEVERED = /(^|[^a-z])terminated[.!\s]*$/i;
77
- /** The connection-class backend-failure phrases: fetch-level "Connection error.", the
78
- * refusals and resets Node surfaces. One home shared by the TRANSIENT_BACKEND gate and the
79
- * backendKind classifier, so a spelling added to one cannot go missing from the other. */
80
- const CONNECTION_ERROR = /connection error|connection refused|connection reset|econn(refused|reset)/i;
81
- /** The timeout-class backend-failure phrase (OpenAI-style "Request timed out"). Shared by the
82
- * TRANSIENT_BACKEND gate and the backendKind classifier. */
83
- const TIMEOUT_ERROR = /request timed out/i;
84
- /** The server-class backend-failure phrases: 5xx status texts, oMLX's model-server pause, and
85
- * a provider pausing itself fleet-wide with a 503 whose body carries prose rather than any
86
- * status phrase ("Provider '<name>' is currently failing for model '<id>' and has been paused
87
- * by the provider"). Shared by the TRANSIENT_BACKEND gate and the backendKind classifier. */
88
- const SERVER_ERROR = /internal server error|bad gateway|service unavailable|gateway timeout|currently failing for model|paused by the provider/i;
89
- /** The model-load spelling (oMLX's "Failed to load model"): its backendKind is the
90
- * "model-load" default, so only the gate names the pattern. */
91
- const MODEL_LOAD_ERROR = /failed to load model/i;
92
- /** The backend-failure error texts pi actually surfaces, matched against every error text
93
- * feedLine sees — the same shapes as the rate-limit regex's various renderings.
94
- * Deliberately does NOT match the 429 texts: feedLine checks TRANSIENT_RATE_LIMIT first and
95
- * only falls through here, so a rate limit stays a rate limit (with its Retry-After hint)
96
- * and never counts as a backend failure. Composed from the per-kind patterns above so each
97
- * kind's spellings — including the stream-severed one (STREAM_SEVERED) — live in exactly one
98
- * place. Only matchBackendFailure and PiStreamParser.feedLine match it; backendKind below
99
- * classifies which phrase matched. */
100
- const TRANSIENT_BACKEND = new RegExp(`${CONNECTION_ERROR.source}|${TIMEOUT_ERROR.source}|${SERVER_ERROR.source}|${MODEL_LOAD_ERROR.source}|${STREAM_SEVERED.source}`, "i");
101
- /** Which backend-failure kind an error text belongs to — pure, exported so the parser's
102
- * classification is unit-testable without a stream (pi-parser.test.ts feeds the texts
103
- * directly). Each check is the same per-kind pattern the TRANSIENT_BACKEND gate composes, so
104
- * the gate and the classifier cannot disagree; the order matches that alternation. */
105
- export function backendKind(text) {
106
- if (CONNECTION_ERROR.test(text))
107
- return "connection";
108
- if (TIMEOUT_ERROR.test(text))
109
- return "timeout";
110
- if (SERVER_ERROR.test(text))
111
- return "server";
112
- if (STREAM_SEVERED.test(text))
113
- return "stream-severed";
114
- return "model-load";
115
- }
116
- /** The backend-failure kind `text` names, or undefined when it names none — the stderr
117
- * classifier (src/pi/pi-backend.ts's classifyExit) needs "no match" distinct from
118
- * backendKind's "model-load" default. Gated by TRANSIENT_BACKEND, so a text the event path
119
- * ignores is ignored on stderr too. Pure; exported for pi-backend.ts's classifyExit. */
120
- export function matchBackendFailure(text) {
121
- return TRANSIENT_BACKEND.test(text) ? backendKind(text) : undefined;
122
- }
123
- /** The valid `backendKind` values, for validating a translator-supplied string at the wire
124
- * boundary (the same untrusted-JSON policy every other field read here applies). */
125
- const BACKEND_FAILURE_KINDS = [
126
- "connection",
127
- "timeout",
128
- "server",
129
- "model-load",
130
- "stream-severed",
131
- ];
132
- function isBackendFailureKind(value) {
133
- return typeof value === "string" && BACKEND_FAILURE_KINDS.includes(value);
134
- }
135
78
  /** Accumulates pi's JSON event stream into a PiRunResult. Exported for tests. */
136
79
  export class PiStreamParser {
137
80
  onToolCallStart;
81
+ image;
138
82
  finalText = "";
139
83
  /** True once any assistant message contains the nothing-to-do sentinel, so a
140
84
  * declaration in an intermediate turn survives later messages overwriting finalText. */
@@ -190,9 +134,10 @@ export class PiStreamParser {
190
134
  * the loop's transient retry covers it, waiting out retryAfterSeconds when present. */
191
135
  transientRateLimit = false;
192
136
  /** True when any event reports a provider-wide failure that is not rate limiting — the
193
- * connection down, a 5xx, the model failing to load (TRANSIENT_BACKEND above). Transient in
194
- * the same sense: the session is healthy and a later attempt succeeds. The fleet-wide hold
195
- * (src/fleet/fleet-hold.ts) consumes it beside the 429 flag, grouped by backendFailureKind. */
137
+ * connection down, a 5xx, the model failing to load (TRANSIENT_BACKEND, in pi-failure.ts).
138
+ * Transient in the same sense: the session is healthy and a later attempt succeeds. The
139
+ * team-wide hold (src/team/team-hold.ts) consumes it beside the 429 flag, grouped by
140
+ * backendFailureKind. */
196
141
  transientBackend = false;
197
142
  /** Which kind of backend failure the run saw (backendKind's classification of the same
198
143
  * text that set transientBackend) — carried so the hold's storm test can group by kind
@@ -203,7 +148,7 @@ export class PiStreamParser {
203
148
  retryAfterSeconds;
204
149
  /** True when any error text names a permanent provider configuration error (a 4xx other
205
150
  * than 408/429, or the provider's model_not_supported/invalid_request_error spelling): the
206
- * request can never succeed until the config changes, so the landing path holds the pin
151
+ * request can never succeed until the config changes, so the merge path holds the pin
207
152
  * rather than re-queuing it at suite speed (BUGS.md 2026-10-06). Matched only when the text
208
153
  * did not match rate-limit or a transient backend pattern first. */
209
154
  configError = false;
@@ -223,6 +168,12 @@ export class PiStreamParser {
223
168
  rateWindows = [];
224
169
  /** True when any rate-limit reading reported the account spending into overage. */
225
170
  usedOverage = false;
171
+ /** The one `read` tool call this run made on an image the model cannot see (plans/
172
+ * image-input.md "Detecting a blind read"): the image path and the seam's effective image
173
+ * input are reported at once, so the runner can kill an authoring run instead of letting
174
+ * the agent answer about a picture it never saw. The FIRST blind read wins — later ones
175
+ * add nothing. */
176
+ imageBlind;
226
177
  /** True when the run's LAST assistant message carried no text and no tool call
227
178
  * (thinking-only or empty). A compliant finish always ends with a text block (the
228
179
  * SUMMARY/sentinel line), so this signals a generation cut off mid-stream — typically
@@ -242,10 +193,9 @@ export class PiStreamParser {
242
193
  /** True when pi auto-compacted the session during (or at the end of) the run. */
243
194
  compacted = false;
244
195
  /** Incremented for every parsed event that represents real forward progress — message,
245
- * turn, and tool boundaries, retries, session events. Streaming deltas (message_update)
246
- * never count: pi's JSON protocol strips the cumulative snapshot from them, so they carry
247
- * nothing this parser acts on, and a zombie stream's content-free keepalives (message
248
- * deltas and content-free tool_execution_updates alike) must not reset the harness's hang
196
+ * turn, and tool boundaries, retries, session events, and content-bearing updates
197
+ * (message_update deltas and tool_execution_updates alike). A content-free keepalive never
198
+ * counts: a zombie stream's periodic empty updates must not reset the harness's hang
249
199
  * watchdog. */
250
200
  progressCount = 0;
251
201
  /** Tool calls started but not yet ended — pi runs one message's tool calls concurrently by
@@ -254,13 +204,20 @@ export class PiStreamParser {
254
204
  * empty-content update right after start, and a content-free keepalive must not mask a hang.
255
205
  * Feeds runPi's stall warning; entries clear at tool_execution_end. */
256
206
  openToolCalls = [];
207
+ /** Read tool calls started but not yet ended, keyed by pi's toolCallId so concurrent reads
208
+ * attribute their end to the right start (pi runs one message's tool calls concurrently, so
209
+ * several can be open at once and end in completion order — the same rule
210
+ * openToolCalls tracks). The value is the call's `args.path`, captured at start so the
211
+ * result on end has a path to blame when the read was blind. */
212
+ readPaths = new Map();
257
213
  buffer = "";
258
214
  /** `onToolCallStart` observes every tool call as it starts, with pi's tool name ("" when pi
259
215
  * omitted it) and raw args — a hook for a caller that audits what a run did (the review
260
216
  * gate's suite-rerun tripwire, src/review/suite-rerun.ts) without the parser storing every
261
217
  * call of every run. */
262
- constructor(onToolCallStart) {
218
+ constructor(onToolCallStart, image = {}) {
263
219
  this.onToolCallStart = onToolCallStart;
220
+ this.image = image;
264
221
  }
265
222
  /** Apply a translator-supplied `tumwaterKind`, trusted before the text regexes. An unknown
266
223
  * kind is ignored (the same forward-compatible policy as an unknown event type). */
@@ -326,6 +283,33 @@ export class PiStreamParser {
326
283
  return;
327
284
  this.retryAfterSeconds = Math.max(0, Math.round(value));
328
285
  }
286
+ /** Decide whether one finished `read` call returned an image the model cannot see, and if so
287
+ * record the FIRST such read and fire the `onBlindRead` hook (plans/image-input.md
288
+ * "Detecting a blind read"). An image read is a result whose content holds an `image` block,
289
+ * or a text block starting `Read image file [`; it is blind when that text carries pi's
290
+ * non-vision note, or when the runner told us the seam's effective image input is false —
291
+ * the second arm catches a claim doctor's probe proved false, where pi sends the image and
292
+ * adds no note. pi's processing-failure notes (`[Image omitted: could not be converted…]` /
293
+ * `…resized…`) are NOT blind on a vision-capable model: they name a conversion/file problem,
294
+ * not a capability gap (a non-vision model's read carries its own note, which IS blind). */
295
+ detectBlindRead(path, result) {
296
+ if (this.imageBlind)
297
+ return; // the first blind read is the one that stops the run
298
+ const blocks = resultContent(result);
299
+ const isImageRead = blocks.some((c) => c.type === "image") ||
300
+ blocks.some((c) => typeof c.text === "string" && c.text.startsWith(IMAGE_READ_PREFIX));
301
+ if (!isImageRead)
302
+ return;
303
+ const text = blocks
304
+ .filter((c) => typeof c.text === "string")
305
+ .map((c) => c.text)
306
+ .join("\n");
307
+ const blind = text.includes(BLIND_IMAGE_NOTE) || this.image.imageInput === false;
308
+ if (!blind)
309
+ return;
310
+ this.imageBlind = { path };
311
+ this.image.onBlindRead?.(path);
312
+ }
329
313
  feed(chunk, onLine) {
330
314
  this.buffer += chunk;
331
315
  // Scan with an offset instead of slicing the remainder off after every line: each old
@@ -350,12 +334,18 @@ export class PiStreamParser {
350
334
  this.buffer = this.buffer.slice(start);
351
335
  }
352
336
  feedLine(line) {
353
- // Streaming deltas are ~72% of log bytes in observed runs, and pi's JSON protocol strips
354
- // the cumulative message snapshot from them — they carry only constant-size usage plus a
355
- // small delta event, none of which this parser acts on (progress comes from the boundary
356
- // events around them). Skip even parsing them.
357
- if (piEventType(line) === "message_update")
337
+ // Streaming deltas are ~72% of log bytes in observed runs. pi's JSON protocol strips the
338
+ // cumulative message snapshot from them, so the delta event is all they carry — and a
339
+ // content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
340
+ // it must not reset the hang watchdog. A content-bearing delta is real generation, and
341
+ // counting it is what keeps a long thinking block on a slow model from being quiet-killed
342
+ // as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
343
+ // Nothing else in this parser acts on the type, so the line is done either way.
344
+ if (piEventType(line) === "message_update") {
345
+ if (messageUpdateHasContent(parseJsonObject(line)))
346
+ this.progressCount += 1;
358
347
  return;
348
+ }
359
349
  // pi's stream is one JSON object per line: a torn, non-JSON, or valid-JSON-scalar line is
360
350
  // noise, not an event — parseJsonObject reads it as no data (the same policy parseEventLine
361
351
  // applies to the harness event log and parsePiEventLine applies to pi's log lines) instead
@@ -404,10 +394,28 @@ export class PiStreamParser {
404
394
  // number). A backend that emits none keeps the summed per-message figure (pi).
405
395
  if (event.type === "run_cost")
406
396
  this.costUsd = usageNumber(event.total);
407
- // Likewise a run_usage replaces the summed output tokens (claude's per-message usage is
408
- // the stream-start snapshot, about 8x low).
409
- if (event.type === "run_usage")
410
- this.outputTokens = usageNumber(event.outputTokens);
397
+ // Likewise a run_usage replaces the summed per-message token figures (claude's per-message
398
+ // usage is the stream-start snapshot, about 8x low). OpenCode's export drives a run_usage
399
+ // that carries the run's whole token split — input, output, cacheRead, cacheWrite — and
400
+ // replaces all four summed fields, the way run_cost replaces the cost sum; a stream without
401
+ // one keeps its summed figures. A run_usage bearing only outputTokens (claude) keeps the
402
+ // historical behavior of replacing just the output sum. The split's sum is a cumulative RUN
403
+ // total, not a single request, so it never touches peakContextTokens (a per-request peak
404
+ // the stream keeps computing); the export carries no total and none is invented here.
405
+ if (event.type === "run_usage") {
406
+ if (event.input !== undefined || event.cacheRead !== undefined || event.cacheWrite !== undefined) {
407
+ const input = usageNumber(event.input);
408
+ const cacheRead = usageNumber(event.cacheRead);
409
+ const cacheWrite = usageNumber(event.cacheWrite);
410
+ const output = usageNumber(event.output);
411
+ this.outputTokens = output;
412
+ this.promptTokens = input + cacheRead + cacheWrite;
413
+ this.cacheReadTokens = cacheRead;
414
+ }
415
+ else {
416
+ this.outputTokens = usageNumber(event.outputTokens);
417
+ }
418
+ }
411
419
  // A canonical rate_limit event carries an account-window reading: collect the newest per
412
420
  // type, note any overage, and treat a rejected window as the transient rate limit it is.
413
421
  if (event.type === "rate_limit")
@@ -415,11 +423,10 @@ export class PiStreamParser {
415
423
  if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
416
424
  this.billing = event.billing;
417
425
  // Every structured event (turn/tool/message boundaries, retries, session) is real
418
- // progress — streaming deltas never are (message_update is skipped above, before
419
- // parsing), and neither is a content-free tool_execution_update: bash emits one right
420
- // after start, and a zombie stream's keepalive updates must not reset the hang
421
- // watchdog any more than a content-free message delta would (the same rule
422
- // toolUpdateHasContent applies to the stall clock).
426
+ // progress, and so is a content-bearing update — but a content-free one never is: bash
427
+ // emits an empty tool_execution_update right after start, and a zombie stream's keepalive
428
+ // updates must not reset the hang watchdog any more than a content-free message delta
429
+ // would (message_update deltas are counted above, before this point, by the same rule).
423
430
  if (!(event.type === "tool_execution_update" && !toolUpdateHasContent(event.partialResult)))
424
431
  this.progressCount += 1;
425
432
  if (event.type === "compaction_start")
@@ -443,6 +450,23 @@ export class PiStreamParser {
443
450
  describeToolCall(event.toolName ?? "", event.args) || "tool", command);
444
451
  if (event.type === "tool_execution_start")
445
452
  this.onToolCallStart?.(event.toolName ?? "", event.args);
453
+ // The blind-read detector (plans/image-input.md "Detecting a blind read"): remember
454
+ // each read call's path from its start, and on its end decide whether the image it
455
+ // returned was one the model cannot see — so the runner can kill an authoring run
456
+ // instead of letting the agent answer about a picture it never saw.
457
+ if (event.type === "tool_execution_start" && event.toolName === "read") {
458
+ const p = readPathOf(event.args);
459
+ if (p !== undefined)
460
+ this.readPaths.set(String(event.toolCallId), p);
461
+ }
462
+ else if (event.type === "tool_execution_end") {
463
+ const id = String(event.toolCallId);
464
+ const p = this.readPaths.get(id);
465
+ if (p !== undefined) {
466
+ this.readPaths.delete(id);
467
+ this.detectBlindRead(p, event.result);
468
+ }
469
+ }
446
470
  }
447
471
  if (event.type !== "message_end" || event.message?.role !== "assistant")
448
472
  return;
@@ -7,6 +7,7 @@
7
7
  * handler reads back; run-agent.ts feeds the parser live through the deps accessors, so no
8
8
  * notification path is needed when events land. */
9
9
  import { bufferedCommandStallMs, commandBuffersOutput } from "./command-shape.js";
10
+ import { streamsToolOutput } from "../agent/backend-traits.js";
10
11
  /** Start one pi run's two watchdog clocks: the tick deadline (kills the process tree when it
11
12
  * fires, recording whether the run was still progressing) and the quiet/stall interval
12
13
  * (quiet-kills a run that stopped making progress and warns on stalled tool calls). Returns
@@ -79,7 +80,7 @@ export function startPiWatchdogs(deps) {
79
80
  // only a periodic look at which open tool calls have gone silent.
80
81
  // A run that has not yet produced progress is starting, not hung: process creation and
81
82
  // model connect are legitimately slow on a loaded machine (a full test suite, a busy
82
- // fleet), and charging that startup latency as pi silence quiet-kills runs that never
83
+ // team), and charging that startup latency as pi silence quiet-kills runs that never
83
84
  // had the chance to speak (BUGS.md 2026-09-23). The one extra quiet window that fix
84
85
  // granted a zero-progress run still false-killed under merge-check load: the kill runs
85
86
  // on an interval against the wall clock, so the firing check can predate the child's
@@ -87,7 +88,7 @@ export function startPiWatchdogs(deps) {
87
88
  // but not yet drained (a firing timer phase precedes the poll phase that delivers
88
89
  // stdout, and a suspended machine resumes the same way). The 2026-09-23 fix therefore
89
90
  // gave a byte-silent run no quiet kill at all, delegating its bound to the tick timeout
90
- // — which holds only while tickTimeoutSeconds is near the quiet window's scale (BUGS.md
91
+ // — which holds only while runTimeoutSeconds is near the quiet window's scale (BUGS.md
91
92
  // 2026-09-29: the live 54000 s config left a wedged model connection 15 hours in a
92
93
  // concurrency slot). A zero-byte run now gets a finite bound of its own, independent of
93
94
  // the tick timeout: max(two quiet windows, 30 min). Startup latency under healthy load
@@ -128,7 +129,7 @@ export function startPiWatchdogs(deps) {
128
129
  // ANY of its calls — every one is judged by the buffered wall-clock threshold
129
130
  // (plans/agent-backends.md "Watchdogs and progress"); pi keeps its per-command
130
131
  // classification, where only a piped or redirected command is buffered.
131
- if (deps.agent === "claude" || commandBuffersOutput(call.command || call.label)) {
132
+ if (!streamsToolOutput(deps.agent) || commandBuffersOutput(call.command || call.label)) {
132
133
  const openMs = Date.now() - call.lastActivityAt;
133
134
  const bufferedStallMs = bufferedCommandStallMs(deps.stallMs);
134
135
  if (openMs >= bufferedStallMs) {
@@ -13,13 +13,26 @@
13
13
  *
14
14
  * Limits are constants on purpose (opinionated defaults over configuration). The read
15
15
  * limit (~12k chars ≈ 300 lines) mirrors the prompt's CONTEXT_BUDGET_RULE; the bash limit
16
- * is looser because test logs and build output legitimately need more tail.
16
+ * is looser because test logs and build output legitimately need more tail. Both were tuned on
17
+ * the 128k local fallback, so a model that declares a smaller window gets them scaled down in
18
+ * proportion (`outputLimits`): a 12k-char result is twice the share of a 64k window.
17
19
  */
20
+ import { windowScale } from "./context-window.js";
18
21
  import { writeFullOutput } from "./full-output.js";
19
22
  /** Char budget for a `read` tool result (~300 lines, matching CONTEXT_BUDGET_RULE). */
20
23
  export const READ_LIMIT_CHARS = 12_000;
21
24
  /** Char budget for a `bash` tool result — looser, so test/build tails survive. */
22
25
  export const BASH_LIMIT_CHARS = 16_000;
26
+ /** The smallest share of each tuned limit a small window scales it to: a quarter keeps a test
27
+ * log's tail readable (4,000 chars of bash) even at a 32k window. */
28
+ const MIN_LIMIT_SHARE = 0.25;
29
+ /** The budgets for a model's declared context window: READ_LIMIT_CHARS and BASH_LIMIT_CHARS at
30
+ * or above the 128k window they were tuned on (and when the window is unknown), scaled down in
31
+ * proportion below it — 6,000 and 8,000 chars at 64k — and never under a quarter of each. */
32
+ export function outputLimits(contextWindow) {
33
+ const scale = Math.max(MIN_LIMIT_SHARE, windowScale(contextWindow));
34
+ return { read: Math.round(READ_LIMIT_CHARS * scale), bash: Math.round(BASH_LIMIT_CHARS * scale) };
35
+ }
23
36
  /** Extra code points reserved when snapping cut points to line boundaries, so the
24
37
  * snapped head+tail plus marker still fit inside the limit even after rounding. */
25
38
  const SNAP_SLACK = 240;
@@ -136,9 +149,10 @@ export function boundBashResult(text, details, writeFullOutput, limitChars = BAS
136
149
  const fullPath = details?.fullOutputPath ?? writeFullOutput?.(text) ?? null;
137
150
  return boundText(text, limitChars, fullPath);
138
151
  }
139
- /** The pi extension entry point: bound oversized read and bash results in place. */
152
+ /** The pi extension entry point: bound oversized read and bash results in place, at the budgets
153
+ * for the current model's declared window (read per result, so a model change is honored). */
140
154
  export default function boundedOutput(pi) {
141
- pi.on("tool_result", (event) => {
155
+ pi.on("tool_result", (event, ctx) => {
142
156
  if (event.toolName !== "read" && event.toolName !== "bash")
143
157
  return undefined;
144
158
  const blocks = event.content;
@@ -148,9 +162,10 @@ export default function boundedOutput(pi) {
148
162
  const textBlock = blocks.find((b) => b?.type === "text" && typeof b.text === "string");
149
163
  if (!textBlock || typeof textBlock.text !== "string")
150
164
  return undefined;
165
+ const limits = outputLimits(ctx?.model?.contextWindow);
151
166
  const bounded = event.toolName === "read"
152
- ? boundReadResult(textBlock.text, event.input)
153
- : boundBashResult(textBlock.text, event.details, (text) => writeFullOutput(text, event.toolCallId));
167
+ ? boundReadResult(textBlock.text, event.input, limits.read)
168
+ : boundBashResult(textBlock.text, event.details, (text) => writeFullOutput(text, event.toolCallId), limits.bash);
154
169
  if (bounded === textBlock.text)
155
170
  return undefined;
156
171
  return { content: [{ type: "text", text: bounded }] };
@@ -9,7 +9,9 @@
9
9
  * lossy summary; past it, a run that keeps reading hits the window and lands nothing. context-shake
10
10
  * reclaims the oldest bulky tool results first, so the notes now sit above that reclaim line: the
11
11
  * 70% note names the crossing, context-shake elides what it can in the same turn_end, and at 85%
12
- * the note is the stop-reading backstop for a run whose remaining results were not reclaimable. The
12
+ * the note is the stop-reading backstop for a run whose remaining results were not reclaimable. (On
13
+ * a window under 128k context-shake crosses earlier, under pi's compaction line — see its
14
+ * `shakeLimits` — while these notes keep their percentages.) The
13
15
  * failure mode the numbers exist for is the fallback's local model (Qwen3.8-27B): its longest ticks
14
16
  * peaked at 96–110k tokens (coverage, steward, telemetry, plan; 2026-09-29..10-01) while the
15
17
  * prompt's reading budget could only say "your window is finite". On a large-window model the
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Bundled pi extension: when a run crosses 70% of its context window, reclaim the space held
2
+ * Bundled pi extension: when a run crosses 70% of its context window (less on a window under
3
+ * 128k, where pi compacts sooner — see `shakeLimits`), reclaim the space held
3
4
  * by old, bulky `read` and `bash` tool results by replacing each with a short pointer the model
4
5
  * can follow back to the full text. Loaded on every pi run via `-e <this file>` from
5
6
  * src/pi/pi-args.ts, after bounded-output and before context-budget.
@@ -10,7 +11,7 @@
10
11
  * old tool results with recoverable references and keep the recent window intact. pi 1.0.0 can
11
12
  * do the same from an extension — a `turn_end` handler appends persisted `context_edit` entries
12
13
  * (pi docs, extensions.md "Events and concurrency"; session-format.md "ContextEditEntry"). On a
13
- * large-window primary (1M) the 70% line sits far above the largest fleet tick, so the
14
+ * large-window primary (1M) the 70% line sits far above the largest team tick, so the
14
15
  * extension plans nothing and costs nothing there.
15
16
  *
16
17
  * The selection logic is a pure, exported function (`shakePlan`) so every rule is unit-testable
@@ -24,18 +25,45 @@ import { compactTokens } from "../text/format.js";
24
25
  import { writeFullOutput } from "./full-output.js";
25
26
  import { appendToolResultNote } from "./tool-result-content.js";
26
27
  import { readContextUsage } from "./context-usage.js";
27
- /** The fill percentage at which the first shake pass runs. */
28
+ import { compactionPercent, declaredWindow, windowScale } from "./context-window.js";
29
+ /** The fill percentage at which the first shake pass runs, on the 128k window it was tuned on
30
+ * (see `shakeLimits` for a smaller one). */
28
31
  export const SHAKE_PERCENT = 70;
29
32
  /** The fill percentage at which a second pass runs, only when the first did not bring the run
30
33
  * back under `SHAKE_PERCENT` (the note-heavy path climbs here again). */
31
34
  export const SHAKE_MAX_PERCENT = 85;
32
35
  /** The newest slice of context, in estimated tokens, that is never elided — the model still
33
- * needs what it was just reading. */
36
+ * needs what it was just reading. Tuned on the 128k window. */
34
37
  export const SHAKE_PROTECTED_TOKENS = 20_000;
35
38
  /** A tool result shorter than this is left alone: its pointer would save little. */
36
39
  export const SHAKE_MIN_CHARS = 2_000;
37
- /** A pass that would reclaim fewer than this many estimated tokens is not worth the edit. */
40
+ /** A pass that would reclaim fewer than this many estimated tokens is not worth the edit. Tuned
41
+ * on the 128k window. */
38
42
  export const SHAKE_MIN_RECLAIM_TOKENS = 10_000;
43
+ /** How far, in percentage points, the second pass stays under pi's own compaction line: 2.5
44
+ * puts it at exactly SHAKE_MAX_PERCENT on the 128k window. */
45
+ const COMPACTION_MARGIN_PERCENT = 2.5;
46
+ /** The shake limits for a declared context window. At or above the 128k window they were tuned
47
+ * on — and when the window is unknown — they are the SHAKE_* constants. Below it:
48
+ * - the protected slice and the reclaim floor scale in proportion (10k and 5k tokens at 64k);
49
+ * - both passes move under pi's compaction line (window − 16,384), which on a small window
50
+ * falls below 85% — at 64k it is 75%, so an 85% pass would never run before pi replaced the
51
+ * run's reads with a lossy summary. The second pass sits COMPACTION_MARGIN_PERCENT under
52
+ * that line and the first keeps its 15-point lead (57.5% and 72.5% at 64k).
53
+ * Pure. */
54
+ export function shakeLimits(contextWindow) {
55
+ const window = declaredWindow(contextWindow);
56
+ const scale = windowScale(window);
57
+ const maxPercent = window === null
58
+ ? SHAKE_MAX_PERCENT
59
+ : Math.max(0, Math.min(SHAKE_MAX_PERCENT, compactionPercent(window) - COMPACTION_MARGIN_PERCENT));
60
+ return {
61
+ percent: Math.max(0, Math.min(SHAKE_PERCENT, maxPercent - (SHAKE_MAX_PERCENT - SHAKE_PERCENT))),
62
+ maxPercent,
63
+ protectedTokens: Math.round(SHAKE_PROTECTED_TOKENS * scale),
64
+ minReclaimTokens: Math.round(SHAKE_MIN_RECLAIM_TOKENS * scale),
65
+ };
66
+ }
39
67
  /** The fixed prefix of every elision pointer, used to recognize a result that was already
40
68
  * elided (and must never be elided again). */
41
69
  export const ELIDED_PREFIX = "[elided by tumwater:";
@@ -52,7 +80,7 @@ function percentOf(usage) {
52
80
  }
53
81
  // Both ratio inputs are read through nonNegativeNumber: a NaN/Infinity/negative/string wire
54
82
  // value is as unusable as a missing one, and a NaN ratio would slip past shakePlan's
55
- // `percent < SHAKE_PERCENT` guard (every comparison against NaN is false) and plan a pass on
83
+ // `percent < limits.percent` guard (every comparison against NaN is false) and plan a pass on
56
84
  // an unknown fill. A non-positive window has no ratio either.
57
85
  const tokens = nonNegativeNumber(usage.tokens, null);
58
86
  const window = nonNegativeNumber(usage.contextWindow, null);
@@ -85,8 +113,8 @@ function replacementFor(msg, fullOutputPath) {
85
113
  }
86
114
  /** Whether one message is even a candidate, ignoring the reclaim floor: a bulky, non-error,
87
115
  * not-already-elided read or bash result that sits outside the protected newest slice. */
88
- function isCandidate(msg, newerTokens) {
89
- if (newerTokens < SHAKE_PROTECTED_TOKENS)
116
+ function isCandidate(msg, newerTokens, protectedTokens) {
117
+ if (newerTokens < protectedTokens)
90
118
  return false;
91
119
  if (msg.role !== "toolResult")
92
120
  return false;
@@ -104,14 +132,16 @@ function isCandidate(msg, newerTokens) {
104
132
  }
105
133
  /**
106
134
  * Plan a shake pass: the `context_edit` replacements that reclaim the most space from old
107
- * bulky tool results. Empty unless the run is at or above `SHAKE_PERCENT`, something outside
108
- * the newest `SHAKE_PROTECTED_TOKENS` is reclaimable, and the pass clears
109
- * `SHAKE_MIN_RECLAIM_TOKENS` together. Deterministic and filesystem-free except the injected
135
+ * bulky tool results. Empty unless the run is at or above the first pass's percent, something
136
+ * outside the newest protected slice is reclaimable, and the pass clears the reclaim floor
137
+ * together — all three from `shakeLimits` for the usage's window. Deterministic and
138
+ * filesystem-free except the injected
110
139
  * `writeFullOutput`, which is only called for a bash result pi has not already persisted.
111
140
  */
112
141
  export function shakePlan(messages, usage, writeFullOutput) {
113
142
  const percent = percentOf(usage);
114
- if (percent === null || percent < SHAKE_PERCENT)
143
+ const limits = shakeLimits(usage.contextWindow);
144
+ if (percent === null || percent < limits.percent)
115
145
  return EMPTY_PLAN;
116
146
  // Tokens newer than each message, so a candidate is one the model has already moved past.
117
147
  const newerTokens = new Array(messages.length).fill(0);
@@ -120,11 +150,11 @@ export function shakePlan(messages, usage, writeFullOutput) {
120
150
  newerTokens[i] = running;
121
151
  running += estimateTokens(messages[i]?.text ?? "");
122
152
  }
123
- const candidates = messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0));
153
+ const candidates = messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0, limits.protectedTokens));
124
154
  // The pointer is far shorter than the result it replaces, so the full text's tokens are a
125
155
  // safe upper bound on the reclaim; bail before touching the writer when even that is short.
126
156
  const upperBound = candidates.reduce((sum, msg) => sum + estimateTokens(msg.text), 0);
127
- if (upperBound < SHAKE_MIN_RECLAIM_TOKENS)
157
+ if (upperBound < limits.minReclaimTokens)
128
158
  return EMPTY_PLAN;
129
159
  const edits = [];
130
160
  let reclaimedTokens = 0;
@@ -141,7 +171,7 @@ export function shakePlan(messages, usage, writeFullOutput) {
141
171
  edits.push({ targetId: msg.entryId, toolName: msg.toolName, replacement, reclaimedTokens: reclaimed });
142
172
  reclaimedTokens += reclaimed;
143
173
  }
144
- if (reclaimedTokens < SHAKE_MIN_RECLAIM_TOKENS)
174
+ if (reclaimedTokens < limits.minReclaimTokens)
145
175
  return EMPTY_PLAN;
146
176
  return { edits, reclaimedTokens, elidedCount: edits.length };
147
177
  }
@@ -200,7 +230,8 @@ function textOf(msg) {
200
230
  .join("\n");
201
231
  }
202
232
  /** The pi extension entry point: shake at 70% (and again at 85% when the first pass did not
203
- * hold), leaving each pointer in the context and the one-line summary on the next result. */
233
+ * hold) — on a window under 128k, at `shakeLimits`'s lower crossings — leaving each pointer in
234
+ * the context and the one-line summary on the next result. */
204
235
  export default function contextShake(pi) {
205
236
  let lastShake = 0;
206
237
  let pendingNote = null;
@@ -211,12 +242,14 @@ export default function contextShake(pi) {
211
242
  const percent = percentOf(usage);
212
243
  if (percent === null)
213
244
  return undefined;
214
- // One pass per crossing: 70% first, then 85% only if the run climbed back here.
245
+ // One pass per crossing: the first pass's percent, then the second's only if the run
246
+ // climbed back there.
215
247
  let threshold = 0;
216
- if (lastShake < SHAKE_PERCENT && percent >= SHAKE_PERCENT)
217
- threshold = SHAKE_PERCENT;
218
- else if (lastShake < SHAKE_MAX_PERCENT && percent >= SHAKE_MAX_PERCENT)
219
- threshold = SHAKE_MAX_PERCENT;
248
+ const limits = shakeLimits(usage.contextWindow);
249
+ if (lastShake < limits.percent && percent >= limits.percent)
250
+ threshold = limits.percent;
251
+ else if (lastShake < limits.maxPercent && percent >= limits.maxPercent)
252
+ threshold = limits.maxPercent;
220
253
  if (threshold === 0)
221
254
  return undefined;
222
255
  lastShake = threshold;