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,87 @@
1
+ /** The backend-failure classifier for the pi seam: given provider/pi error text, decide
2
+ * whether it names a permanent configuration error or a transient backend failure, and which
3
+ * `BackendFailureKind` it is. This vocabulary is cross-cutting — the settle/team hold groups a
4
+ * storm by kind (src/team/team-hold.ts), the merge path pins a permanent config error, the pi
5
+ * adapter's stderr path (src/pi/pi-backend.ts) and the stream parser both classify — so it
6
+ * lives in its own module instead of hiding inside the stream parser (which used to be its
7
+ * only home: pi-backend.ts had to import a parser to get a classifier). Keep the per-kind
8
+ * regexes here so the transient gate and the kind classifier cannot disagree: they compose one
9
+ * `TRANSIENT_BACKEND` alternation and `backendKind` orders its checks the same way.
10
+ *
11
+ * The parser-local error heuristics that feed PiStreamParser's own flags — the context-overflow,
12
+ * predict-stream-timeout, and rate-limit+Retry-After patterns — stay in src/pi/pi-stream.ts:
13
+ * they are stream-parsing concerns (they drive `contextExceeded`/`transientServerTimeout`/
14
+ * `transientRateLimit`), not the cross-module "what kind of failure is this" vocabulary. */
15
+ const PERMANENT_CONFIG_ERROR = /\b(?:400|401|403|404)\b|model_not_supported|invalid_request_error/i;
16
+ /** True when an error text names a permanent provider configuration error (see
17
+ * PERMANENT_CONFIG_ERROR). Pure, exported so the merge path and the stderr path classify
18
+ * with one rule. */
19
+ export function isPermanentConfigError(text) {
20
+ return PERMANENT_CONFIG_ERROR.test(text);
21
+ }
22
+ /** undici's spelling of a severed HTTP stream: the provider (or its own idle timeout) cuts
23
+ * the request while pi is still reading it, and undici's fetch rejects with the bare word
24
+ * "terminated" — the 2026-08-22 idle-timeout diagnosis. Anchored to the END of the error text
25
+ * on purpose: the real message is the bare word, optionally behind a prefix and closing
26
+ * punctuation, while an unrelated error that merely contains the word ("worker terminated
27
+ * with exit code 1") must not read as a backend failure. Module-private: matchBackendFailure
28
+ * below, the parser, and backendKind all share the one instance. */
29
+ const STREAM_SEVERED = /(^|[^a-z])terminated[.!\s]*$/i;
30
+ /** The connection-class backend-failure phrases: fetch-level "Connection error.", the
31
+ * refusals and resets Node surfaces. One home shared by the TRANSIENT_BACKEND gate and the
32
+ * backendKind classifier, so a spelling added to one cannot go missing from the other. */
33
+ const CONNECTION_ERROR = /connection error|connection refused|connection reset|econn(refused|reset)/i;
34
+ /** The timeout-class backend-failure phrase (OpenAI-style "Request timed out"). Shared by the
35
+ * TRANSIENT_BACKEND gate and the backendKind classifier. */
36
+ const TIMEOUT_ERROR = /request timed out/i;
37
+ /** The server-class backend-failure phrases: 5xx status texts, oMLX's model-server pause, and
38
+ * a provider pausing itself team-wide with a 503 whose body carries prose rather than any
39
+ * status phrase ("Provider '<name>' is currently failing for model '<id>' and has been paused
40
+ * by the provider"). Shared by the TRANSIENT_BACKEND gate and the backendKind classifier. */
41
+ const SERVER_ERROR = /internal server error|bad gateway|service unavailable|gateway timeout|currently failing for model|paused by the provider/i;
42
+ /** The model-load spelling (oMLX's "Failed to load model"): its backendKind is the
43
+ * "model-load" default, so only the gate names the pattern. */
44
+ const MODEL_LOAD_ERROR = /failed to load model/i;
45
+ /** The backend-failure error texts pi actually surfaces, matched against every error text
46
+ * feedLine sees — the same shapes as the rate-limit regex's various renderings.
47
+ * Deliberately does NOT match the 429 texts: feedLine checks TRANSIENT_RATE_LIMIT first and
48
+ * only falls through here, so a rate limit stays a rate limit (with its Retry-After hint)
49
+ * and never counts as a backend failure. Composed from the per-kind patterns above so each
50
+ * kind's spellings — including the stream-severed one (STREAM_SEVERED) — live in exactly one
51
+ * place. Only matchBackendFailure and PiStreamParser.feedLine match it; backendKind below
52
+ * classifies which phrase matched. */
53
+ export const TRANSIENT_BACKEND = new RegExp(`${CONNECTION_ERROR.source}|${TIMEOUT_ERROR.source}|${SERVER_ERROR.source}|${MODEL_LOAD_ERROR.source}|${STREAM_SEVERED.source}`, "i");
54
+ /** Which backend-failure kind an error text belongs to — pure, exported so the parser's
55
+ * classification is unit-testable without a stream (pi-parser.test.ts feeds the texts
56
+ * directly). Each check is the same per-kind pattern the TRANSIENT_BACKEND gate composes, so
57
+ * the gate and the classifier cannot disagree; the order matches that alternation. */
58
+ export function backendKind(text) {
59
+ if (CONNECTION_ERROR.test(text))
60
+ return "connection";
61
+ if (TIMEOUT_ERROR.test(text))
62
+ return "timeout";
63
+ if (SERVER_ERROR.test(text))
64
+ return "server";
65
+ if (STREAM_SEVERED.test(text))
66
+ return "stream-severed";
67
+ return "model-load";
68
+ }
69
+ /** The backend-failure kind `text` names, or undefined when it names none — the stderr
70
+ * classifier (src/pi/pi-backend.ts's classifyExit) needs "no match" distinct from
71
+ * backendKind's "model-load" default. Gated by TRANSIENT_BACKEND, so a text the event path
72
+ * ignores is ignored on stderr too. Pure; exported for pi-backend.ts's classifyExit. */
73
+ export function matchBackendFailure(text) {
74
+ return TRANSIENT_BACKEND.test(text) ? backendKind(text) : undefined;
75
+ }
76
+ /** The valid `backendKind` values, for validating a translator-supplied string at the wire
77
+ * boundary (the same untrusted-JSON policy every other field read here applies). */
78
+ const BACKEND_FAILURE_KINDS = [
79
+ "connection",
80
+ "timeout",
81
+ "server",
82
+ "model-load",
83
+ "stream-severed",
84
+ ];
85
+ export function isBackendFailureKind(value) {
86
+ return typeof value === "string" && BACKEND_FAILURE_KINDS.includes(value);
87
+ }
@@ -1,17 +1,30 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { enabledRoleIds } from "../config/config.js";
4
- import { configForRole, fallbackPair, resolverConfig, reviewConfig } from "../config/config-views.js";
5
- import { isAgentId } from "../config/model-selector.js";
4
+ import { configForRole, resolverConfig, reviewConfig } from "../config/config-views.js";
5
+ import { fallbackPair } from "../config/config-fallbacks.js";
6
+ import { seamAgent } from "../config/model-selector.js";
6
7
  import { cachedByStat } from "../files/stat-cache.js";
7
8
  import { isJsonObject } from "../files/json-object.js";
8
9
  import { readJsonFile } from "../files/json-files.js";
10
+ import { expandHomeTilde } from "../files/files.js";
11
+ import { declaredWindow } from "../pi-extension/context-window.js";
12
+ import { piSettingsPath, readPiDefaultModel } from "./pi-settings.js";
13
+ import { opencodeModelFree, opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
9
14
  /** pi's model definitions — the custom providers and models they serve, with each model's
10
- * declared cost. This is where a local (free) fleet differs from an API one: unpriced or
15
+ * declared cost. This is where a local (free) team differs from an API one: unpriced or
11
16
  * zero-cost models never accumulate spend against the daily budget cap, so the badge can
12
17
  * read n/a instead of a dollar figure that can never move. */
13
- export function piModelsPath() {
14
- return path.join(os.homedir(), ".pi", "agent", "models.json");
18
+ export function piModelsPath(env = process.env, homeDir = os.homedir()) {
19
+ return path.join(piAgentDir(env, homeDir), "models.json");
20
+ }
21
+ /** pi's config directory: `$PI_CODING_AGENT_DIR` when non-empty (a leading `~` expands against
22
+ * the home dir), else `~/.pi/agent`. This mirrors pi's `getAgentDir`. */
23
+ export function piAgentDir(env = process.env, homeDir = os.homedir()) {
24
+ const dir = env.PI_CODING_AGENT_DIR;
25
+ if (!dir)
26
+ return path.join(homeDir, ".pi", "agent");
27
+ return expandHomeTilde(dir, homeDir);
15
28
  }
16
29
  /** The cost components pi's models.json may declare. */
17
30
  const COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
@@ -33,7 +46,7 @@ function costIsFree(cost) {
33
46
  });
34
47
  }
35
48
  /** Per-poll cache of the parsed definitions, keyed by models path: both dashboards poll
36
- * fleetModelsFree every second while models.json changes only when a user edits it — and it grows
49
+ * teamModelsFree every second while models.json changes only when a user edits it — and it grows
37
50
  * with the model catalog (every added provider/model entry), so an unchanged file costs one stat
38
51
  * per poll instead of a re-read plus JSON.parse of the whole catalog. Any write invalidates via
39
52
  * dev/ino/mtime/size (stat-cache.cachedByStat, same freshness check as the other polled files in
@@ -45,6 +58,17 @@ const providersCache = new Map();
45
58
  * Exported for budget-gates.ts's per-tier resolution, which prices several candidate pairs
46
59
  * against the same snapshot instead of re-entering this cache per pair.
47
60
  */
61
+ /** A provider/model pair's claimed image input from pi's definitions: `true` when the model
62
+ * declares `"image"` in its `input`, `false` when the pair resolves but declares no image
63
+ * support (input absent, or present without `"image"` — pi's own text-only default for the
64
+ * custom model), and `null` when the pair cannot be resolved at all (see resolveModelDef). The
65
+ * null case is left to the doctor's `tier models` check, which already fails such pairs. */
66
+ export function piModelImageInput(providers, provider, model) {
67
+ const def = resolveModelDef(providers, provider, model);
68
+ if (!def)
69
+ return null;
70
+ return Array.isArray(def.input) ? def.input.includes("image") : false;
71
+ }
48
72
  export function readPiProviders(modelsPath) {
49
73
  return cachedByStat(providersCache, modelsPath, // Keyed by path so distinct roots and test files never collide.
50
74
  modelsPath, () => {
@@ -70,6 +94,47 @@ export function readPiProviders(modelsPath) {
70
94
  // A copy: callers may treat the result as their own.
71
95
  (providers) => new Map([...providers].map(([name, models]) => [name, [...models]])));
72
96
  }
97
+ /** The context window a run's model declares, or null when it cannot be known: an unresolvable
98
+ * pair, a missing or unusable window, or a backend that is neither pi nor opencode (its catalog
99
+ * is elsewhere). A pi seam names its window from pi's models.json, running on pi's saved default
100
+ * (readPiDefaultModel) when it names no provider/model — exactly the model pi will start on. An
101
+ * opencode seam names its window from the models.dev catalog (part 6e/7): the same cache entry
102
+ * and key the free-ness check reads, so the window and the price always come from one model.
103
+ * A missing cache, an unknown model, or an unusable `context` reads null, as an unknown pi
104
+ * model does. */
105
+ export function seamContextWindow(seam, projectRoot, modelsPath = piModelsPath()) {
106
+ const agent = seamAgent(seam);
107
+ switch (agent) {
108
+ case "pi":
109
+ let { provider, model } = seam;
110
+ if (!provider || !model) {
111
+ const piDefault = readPiDefaultModel(piSettingsPath(modelsPath), projectRoot);
112
+ provider = piDefault?.provider;
113
+ model = piDefault?.model;
114
+ }
115
+ return declaredWindow(resolveModelDef(readPiProviders(modelsPath), provider, model)?.contextWindow);
116
+ case "opencode":
117
+ // The models.dev cache (readOpencodeCache, an empty cache when the file is missing) holds
118
+ // the window part 6a/7 parsed from `limit.context`, keyed by the gateway-qualified id the
119
+ // run actually invokes (opencodeModelKey) — the same key and entry the free-ness check
120
+ // prices by, so a seam's window never disagrees with the model it runs.
121
+ if (seam.model === undefined)
122
+ return null;
123
+ return declaredWindow(readOpencodeCache(projectRoot).models[opencodeModelKey(seam.model)]?.context);
124
+ default:
125
+ return null;
126
+ }
127
+ }
128
+ /** A provider's `baseUrl` from pi's models.json, or null when the file, the provider or the
129
+ * field is missing — what doctor probes to tell a local server that is down from one that is up. */
130
+ export function piProviderBaseUrl(modelsPath, provider) {
131
+ const doc = readJsonFile(modelsPath);
132
+ const providers = doc?.providers;
133
+ if (!isJsonObject(providers) || !Object.hasOwn(providers, provider))
134
+ return null;
135
+ const pdef = providers[provider];
136
+ return isJsonObject(pdef) && typeof pdef.baseUrl === "string" ? pdef.baseUrl : null;
137
+ }
73
138
  /** Resolve a `provider`/`model` pair against pi's definitions — the shared first step of
74
139
  * pairFree and cacheReadUnpriced. Undefined when the pair cannot be resolved at all: an
75
140
  * omitted provider or model (pi's own default), a missing or malformed definitions file, an
@@ -118,50 +183,72 @@ function accountUsesOverage(account) {
118
183
  return account !== null && Object.values(account.windows).some((w) => w.isUsingOverage === true);
119
184
  }
120
185
  /** Whether a seam may be called free: a subscription claude seam is free money (its marginal
121
- * cost is zero, plans/agent-backends.md "Free-ness"), and a pi seam is free when pi's own
122
- * models.json prices it at zero. A subscription that is spending into overage is NOT free: that
123
- * overflow is API-priced real money, exactly as `runCostIsReal` treats a run that saw
124
- * `isUsingOverage`. Everything else is NOT free — an API-billed claude seam, an overage
125
- * subscription claude seam, an unresolvable or priced pi pair, and any other backend — so the
186
+ * cost is zero, plans/agent-backends.md "Free-ness"), a pi seam is free when pi's own
187
+ * models.json prices it at zero, and an opencode seam is free when the models.dev catalog
188
+ * prices its model at zero and no run has billed it (part 6b/7). A subscription that is
189
+ * spending into overage is NOT free: that overflow is API-priced real money, exactly as
190
+ * `runCostIsReal` treats a run that saw `isUsingOverage`. Everything else is NOT free — an
191
+ * API-billed claude seam, an overage subscription claude seam, an unresolvable or priced pi
192
+ * pair, an opencode model the catalog does not price at zero, and any other backend — so the
126
193
  * badge never reads n/a while spend it cannot see could still reach the cap. The one central
127
194
  * rule: the budget gate, the fallback view, and the status free-ness read all judge seams
128
- * through it (plans/agent-backends.md "Free-ness" and "Every seam, checked"). */
129
- export function seamFree(providers, seam, account = null) {
130
- const agent = seam.agent ?? (isAgentId(seam.provider) ? seam.provider : "pi");
131
- if (agent === "claude")
132
- return account?.billing === "subscription" && !accountUsesOverage(account);
133
- if (agent !== "pi")
134
- return false;
135
- return pairFree(providers, seam.provider, seam.model);
195
+ * through it (plans/agent-backends.md "Free-ness" and "Every seam, checked"). The opencode
196
+ * `catalog` is threaded the way the claude `account` is: an absent catalog means "no cache to
197
+ * read", so an opencode seam reads NOT free, the safe direction for a budget gate. */
198
+ export function seamFree(providers, seam, account = null, catalog = null) {
199
+ const agent = seamAgent(seam);
200
+ // One free-ness rule per backend: pi's pair price, claude's subscription, opencode's catalog,
201
+ // and nothing for any other backend (its spend could still reach the cap, so the badge must
202
+ // not read n/a). The opencode arm reads the gateway-qualified id (part 6b/7) and changes the
203
+ // pi and claude arms nothing.
204
+ switch (agent) {
205
+ case "pi":
206
+ return pairFree(providers, seam.provider, seam.model);
207
+ case "claude":
208
+ return account?.billing === "subscription" && !accountUsesOverage(account);
209
+ case "opencode":
210
+ // A null catalog — no cache to read — and an unknown or billed model must all read NOT
211
+ // free (opencodeModelFree's safe direction): the badge never reads n/a while spend it
212
+ // cannot see could still reach the cap (plans/opencode-backend.md §7 "Free-ness").
213
+ // opencodeModelKey is the models.dev cache key for the run's resolved selector — the
214
+ // same `<provider>/<model>` join opencodeArgs invokes — so the seam's free-ness lookup
215
+ // never disagrees with the model it actually runs, and the free-ness key is the one
216
+ // billing key (part 6b/7).
217
+ return catalog !== null && seam.model !== undefined && opencodeModelFree(catalog, opencodeModelKey(seam.model));
218
+ default:
219
+ return false;
220
+ }
136
221
  }
137
222
  /** Whether a fallback-config pair may be called free: a `claude/<id>` pair is free while the
138
- * account bills a subscription and is not using overage, and a pi provider/model is free when
139
- * models.json prices it at zero. A non-claude agent namespace, or an unresolvable pi pair, is
140
- * refused — the badge must not read n/a while a backend whose billing it cannot see runs.
141
- * Exported so the budget gate (src/gates/budget-gates.ts) and the status pause set
142
- * (src/status/status-data.ts) judge fallback pairs with the same predicate the free-ness view
143
- * does — neither may engage a backend their own badge would not call free. */
144
- export function piPairFree(providers, provider, model, account = null) {
145
- return seamFree(providers, { provider, model }, account);
146
- }
147
- /** The free fallback pair the budget gate would engage for the fleet, or null when none is
223
+ * account bills a subscription and is not using overage, a pi provider/model is free when
224
+ * models.json prices it at zero, and an opencode pair is free when the catalog prices it at
225
+ * zero (part 6b/7). Any other agent namespace, or an unresolvable pair, is refused — the badge
226
+ * must not read n/a while a backend whose billing it cannot see runs. Exported so the budget
227
+ * gate (src/gates/budget-gates.ts), the status pause set (src/status/status-data.ts) and the
228
+ * free-ness view judge fallback pairs with the same predicate — neither may engage a backend
229
+ * their own badge would not call free. `catalog` is the opencode cache, threaded like `account`;
230
+ * absent, an opencode pair reads NOT free. */
231
+ export function piPairFree(providers, provider, model, account = null, catalog = null) {
232
+ return seamFree(providers, { provider, model }, account, catalog);
233
+ }
234
+ /** The free fallback pair the budget gate would engage for the team, or null when none is
148
235
  * usable: the price-aware resolution of the default tier (fallbackPair over
149
236
  * resolveTierFallbacks' borrow order), so a map whose `default` entry is absent or priced
150
237
  * still names the free pair the tier borrows. Null for a missing, unresolvable, or priced
151
238
  * pair — the gate's refusal, and the pair the badge/doctor must not advertise. */
152
- export function fallbackModelPair(config, modelsPath = piModelsPath(), account = null) {
239
+ export function fallbackModelPair(config, modelsPath = piModelsPath(), account = null, catalog = null) {
153
240
  const providers = readPiProviders(modelsPath);
154
- return fallbackPair(config, (p) => piPairFree(providers, p.provider, p.model, account));
241
+ return fallbackPair(config, (p) => piPairFree(providers, p.provider, p.model, account, catalog));
155
242
  }
156
243
  /** True when the configured fallback model (plans/fallback-model.md) is one the budget gate may
157
244
  * actually engage: configured at all, resolvable to a provider/model pair, and priced at zero in
158
245
  * pi's definitions. False for every other case — no fallback, a half-named pair that would fall
159
246
  * through to pi's own default, an unknown id, or a priced one — and the gate then pauses role
160
247
  * loops exactly as it did before this feature. */
161
- export function fallbackModelFree(config, modelsPath = piModelsPath(), account = null) {
162
- return fallbackModelPair(config, modelsPath, account) !== null;
248
+ export function fallbackModelFree(config, modelsPath = piModelsPath(), account = null, catalog = null) {
249
+ return fallbackModelPair(config, modelsPath, account, catalog) !== null;
163
250
  }
164
- /** True when every model the fleet could use is free: each enabled role's effective
251
+ /** True when every model the team could use is free: each enabled role's effective
165
252
  * provider/model (configForRole — the director included, it is a catalog role, resolved at
166
253
  * its tier per plans/model-tiers.md) plus the reviewer's while review is on, plus the
167
254
  * `strong` tier's own model whenever any role is enabled — the conflict resolver (part 4/8)
@@ -169,10 +256,11 @@ export function fallbackModelFree(config, modelsPath = piModelsPath(), account =
169
256
  * Every pair is judged by `seamFree` above: a subscription claude seam is free, a pi seam is
170
257
  * free when models.json prices it at zero, and everything else counts as NOT free — the badge
171
258
  * must never read n/a while spend it is tracking could still reach the cap. With no enabled
172
- * roles and review off there are no pairs at all, so the fleet cannot spend and the answer is
259
+ * roles and review off there are no pairs at all, so the team cannot spend and the answer is
173
260
  * true. `account` is the persisted claude billing answer (6a/7's reader), absent meaning "no
174
- * subscription". */
175
- export function fleetModelsFree(config, modelsPath = piModelsPath(), account = null) {
261
+ * subscription", and `catalog` the opencode models cache (absent, an opencode seam reads not
262
+ * free). */
263
+ export function teamModelsFree(config, modelsPath = piModelsPath(), account = null, catalog = null) {
176
264
  const pairs = [];
177
265
  for (const role of enabledRoleIds(config)) {
178
266
  const rc = configForRole(config, role);
@@ -193,5 +281,5 @@ export function fleetModelsFree(config, modelsPath = piModelsPath(), account = n
193
281
  if (pairs.length === 0)
194
282
  return true;
195
283
  const providers = readPiProviders(modelsPath);
196
- return pairs.every((seam) => seamFree(providers, seam, account));
284
+ return pairs.every((seam) => seamFree(providers, seam, account, catalog));
197
285
  }
@@ -1,7 +1,7 @@
1
1
  /** Whether a run's cost is real money that charges the daily cap (plans/agent-backends.md
2
2
  * part 6a): an API-billed run, or a subscription run that saw overage. A plain subscription
3
3
  * run's cost is notional — API-equivalent spend that never moves the cap. The one predicate
4
- * both the tick-usage fold and the landing accumulator split on, so the two cannot drift. */
4
+ * both the tick-usage fold and the merge accumulator split on, so the two cannot drift. */
5
5
  export function runCostIsReal(run) {
6
6
  return run.billing !== "subscription" || run.usedOverage === true;
7
7
  }
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { cachedByStat } from "../files/stat-cache.js";
3
+ import { isJsonObject } from "../files/json-object.js";
4
+ import { readJsonFile } from "../files/json-files.js";
5
+ /** pi's own saved-default model, read from pi's `settings.json` beside the models catalog —
6
+ * the name a loop row shows when `tumwater.json` resolves no model and pi starts on its own
7
+ * default. This mirrors step 3 of pi 1.2.0's start-up model choice, "saved default from
8
+ * settings" (`findInitialModel` in pi's `dist/core/model-resolver.js`), for a run tumwater
9
+ * starts without `--provider`/`--model`. The reader does NOT model pi's last step: when the
10
+ * default pair is missing from pi's catalog or has no auth, pi picks the first available
11
+ * model — tumwater cannot resolve that pattern (see `enabledModels` below), so it shows
12
+ * nothing rather than a name pi may not run. */
13
+ /** pi's `settings.json` path, derived from the models catalog path so the two files share one
14
+ * seam: `snapshot` and `rolePayload` already take `modelsPath`, and the default
15
+ * `piModelsPath()` follows `$PI_CODING_AGENT_DIR` through `piAgentDir`. The secret-leak gate
16
+ * derives `auth.json` the same way. */
17
+ export function piSettingsPath(modelsPath) {
18
+ return path.join(path.dirname(modelsPath), "settings.json");
19
+ }
20
+ /** Per-poll cache of the parsed default, keyed by settings path: the snapshot runs on every
21
+ * poll while settings.json changes only when a user edits it, so an unchanged file costs one
22
+ * stat per poll instead of a re-read plus JSON.parse. Any write invalidates via
23
+ * dev/ino/mtime/size (stat-cache.cachedByStat, same freshness check as the other polled files);
24
+ * a missing or malformed file yields null and is not cached, so a mid-edit broken file recovers
25
+ * on the next poll exactly like before. */
26
+ const settingsCache = new Map();
27
+ /** The keys of pi's settings.json that decide the saved default (findInitialModel's inputs):
28
+ * the default pair, its preferred thinking, and the two scope patterns — `enabledModels`
29
+ * (pi starts on the first scoped model instead) and the project-local override file's own
30
+ * settings. */
31
+ const DEFAULT_KEYS = [
32
+ "defaultProvider",
33
+ "defaultModel",
34
+ "defaultThinkingLevel",
35
+ "enabledModels",
36
+ "modelThinkingLevels",
37
+ ];
38
+ /** The model pi starts on when tumwater names none, or null when it cannot be known. Mirrors
39
+ * step 3 of pi 1.2.0's start-up model choice ("saved default from settings") for a run started
40
+ * without `--provider`/`--model`. Null when the file is missing, unreadable, or not a JSON
41
+ * object; when `defaultProvider` and `defaultModel` are not both non-empty strings (pi uses a
42
+ * saved default only as a pair); when `enabledModels` is a non-empty array (pi then starts on
43
+ * the first scoped model, a pattern tumwater does not resolve); or when
44
+ * `<projectRoot>/.pi/settings.json` exists and names any of the default keys — pi merges that
45
+ * file over the global one only for a project it trusts, and tumwater cannot tell whether it
46
+ * does, so showing nothing is better than naming a model pi may not run. Otherwise returns
47
+ * `{ provider, model }` with `thinking` from `modelThinkingLevels["<provider>/<model>"]` when
48
+ * that is a string, else from `defaultThinkingLevel` when that is a string — pi's precedence. */
49
+ export function readPiDefaultModel(settingsPath, projectRoot) {
50
+ return cachedByStat(settingsCache, settingsPath, // Keyed by path so distinct roots and test files never collide.
51
+ settingsPath, () => readPiDefaultModelUncached(settingsPath, projectRoot), (defaultModel) => ({ ...defaultModel }));
52
+ }
53
+ function readPiDefaultModelUncached(settingsPath, projectRoot) {
54
+ // readJsonFile is the shared "missing or torn reads as no data" policy for a JSON object
55
+ // file; the nested shapes get the same isJsonObject guard.
56
+ const doc = readJsonFile(settingsPath);
57
+ if (!doc)
58
+ return null;
59
+ const { defaultProvider, defaultModel } = doc;
60
+ if (typeof defaultProvider !== "string" || defaultProvider === "")
61
+ return null;
62
+ if (typeof defaultModel !== "string" || defaultModel === "")
63
+ return null;
64
+ if (Array.isArray(doc.enabledModels) && doc.enabledModels.length > 0)
65
+ return null;
66
+ // A project-local override that names any default key means pi's final choice depends on
67
+ // whether it trusts the project — unknowable to tumwater, so show nothing.
68
+ const proj = readJsonFile(path.join(projectRoot, ".pi", "settings.json"));
69
+ if (proj) {
70
+ for (const key of DEFAULT_KEYS) {
71
+ if (proj[key] !== undefined)
72
+ return null;
73
+ }
74
+ }
75
+ const key = `${defaultProvider}/${defaultModel}`;
76
+ const levels = doc.modelThinkingLevels;
77
+ const level = isJsonObject(levels) ? levels[key] : undefined;
78
+ let thinking;
79
+ if (typeof level === "string")
80
+ thinking = level;
81
+ else if (typeof doc.defaultThinkingLevel === "string")
82
+ thinking = doc.defaultThinkingLevel;
83
+ return thinking !== undefined
84
+ ? { provider: defaultProvider, model: defaultModel, thinking }
85
+ : { provider: defaultProvider, model: defaultModel };
86
+ }