tumwater 0.2.0 → 0.4.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.
- package/README.md +27 -13
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +8 -9
- package/dist/src/agent/agent-backend.js +8 -6
- package/dist/src/agent/backend-traits.js +55 -25
- package/dist/src/agent/claude/claude-account.js +71 -204
- package/dist/src/agent/claude/claude-args.js +9 -6
- package/dist/src/agent/claude/claude-backend.js +10 -10
- package/dist/src/agent/claude/claude-env.js +1 -1
- package/dist/src/agent/claude/claude-stream.js +6 -4
- package/dist/src/agent/claude/claude-write-guard.js +49 -0
- package/dist/src/agent/claude/write-guard-hook.js +13 -0
- package/dist/src/agent/codex/codex-account.js +119 -0
- package/dist/src/agent/codex/codex-args.js +181 -0
- package/dist/src/agent/codex/codex-backend.js +279 -0
- package/dist/src/agent/codex/codex-catalog.js +97 -0
- package/dist/src/agent/codex/codex-env.js +18 -0
- package/dist/src/agent/codex/codex-hook-trust.js +246 -0
- package/dist/src/agent/codex/codex-patch-paths.js +36 -0
- package/dist/src/agent/codex/codex-rollout.js +179 -0
- package/dist/src/agent/codex/codex-stream.js +404 -0
- package/dist/src/agent/codex/codex-write-guard.js +86 -0
- package/dist/src/agent/codex/write-guard-hook.js +12 -0
- package/dist/src/agent/image-input.js +29 -24
- package/dist/src/agent/notes-shim.js +11 -12
- package/dist/src/agent/opencode/opencode-args.js +0 -1
- package/dist/src/agent/opencode/opencode-backend.js +12 -9
- package/dist/src/agent/opencode/opencode-catalog.js +55 -29
- package/dist/src/agent/opencode/opencode-config.js +19 -14
- package/dist/src/agent/opencode/opencode-env.js +0 -1
- package/dist/src/agent/opencode/opencode-session-id.js +1 -2
- package/dist/src/agent/opencode/opencode-stream.js +1 -1
- package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
- package/dist/src/agent/role-notes-write.js +5 -6
- package/dist/src/agent/run-agent.js +465 -448
- package/dist/src/agent/run-settle.js +214 -0
- package/dist/src/agent/session-ledger.js +86 -16
- package/dist/src/agent/subscription-account.js +220 -0
- package/dist/src/agent/write-guard.js +148 -0
- package/dist/src/backlog/backlog-archive.js +281 -55
- package/dist/src/backlog/backlog-cli-view.js +255 -0
- package/dist/src/backlog/backlog-cli.js +91 -335
- package/dist/src/backlog/backlog-commit.js +20 -13
- package/dist/src/backlog/backlog-eligibility.js +193 -242
- package/dist/src/backlog/backlog-index.js +149 -0
- package/dist/src/backlog/backlog-md.js +54 -6
- package/dist/src/backlog/backlog-parts.js +264 -0
- package/dist/src/backlog/backlog-release.js +134 -0
- package/dist/src/backlog/backlog-render.js +27 -0
- package/dist/src/backlog/backlog-structure.js +99 -156
- package/dist/src/backlog/backlog-write.js +40 -127
- package/dist/src/backlog/backlog.js +21 -6
- package/dist/src/backlog/question-answer.js +21 -15
- package/dist/src/baseline/main-baseline.js +89 -20
- package/dist/src/baseline/main-guardrails.js +136 -0
- package/dist/src/baseline/main-red.js +153 -36
- package/dist/src/baseline/main-watch.js +232 -0
- package/dist/src/brief/brief-edit.js +12 -12
- package/dist/src/brief/brief.js +31 -51
- package/dist/src/budget/budget-fallback-view.js +288 -0
- package/dist/src/budget/budget-wiring.js +158 -0
- package/dist/src/budget/budget.js +31 -20
- package/dist/src/budget/fallback-breaker.js +26 -27
- package/dist/src/build/build-check-detect.js +23 -15
- package/dist/src/build/build-check-events.js +25 -6
- package/dist/src/build/build-check-report.js +49 -9
- package/dist/src/build/build-check-scoped.js +18 -11
- package/dist/src/build/build-check.js +2 -1
- package/dist/src/build/build-info.js +2 -3
- package/dist/src/build/build-stage.js +9 -14
- package/dist/src/build/dep-install.js +65 -3
- package/dist/src/change/change-data.js +123 -31
- package/dist/src/change/change-render.js +46 -5
- package/dist/src/cli/brief-command.js +3 -4
- package/dist/src/cli/cli-args.js +181 -33
- package/dist/src/cli/cli-command-args.js +46 -18
- package/dist/src/cli/cli-editor.js +2 -1
- package/dist/src/cli/cli-flag-specs.js +183 -45
- package/dist/src/cli/cli-init.js +51 -0
- package/dist/src/cli/cli-marker-commands.js +4 -4
- package/dist/src/cli/cli-query-commands.js +291 -598
- package/dist/src/cli/cli-repo-gate.js +11 -1
- package/dist/src/cli/cli-review-commands.js +113 -0
- package/dist/src/cli/cli-role-command.js +525 -0
- package/dist/src/cli/cli-start-summary.js +167 -0
- package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
- package/dist/src/cli/completion.js +169 -22
- package/dist/src/cli/config-commands.js +58 -26
- package/dist/src/cli/follow-view.js +75 -0
- package/dist/src/cli/help.js +258 -82
- package/dist/src/cli/log-commands.js +164 -85
- package/dist/src/cli/question-commands.js +211 -10
- package/dist/src/cli.js +173 -45
- package/dist/src/collections.js +56 -13
- package/dist/src/concurrency/check-permit.js +20 -3
- package/dist/src/concurrency/lock.js +17 -13
- package/dist/src/concurrency/provider-lease.js +44 -8
- package/dist/src/concurrency/semaphore.js +102 -26
- package/dist/src/config/config-aliases.js +0 -1
- package/dist/src/config/config-editable-keys.js +16 -1
- package/dist/src/config/config-fallbacks.js +94 -56
- package/dist/src/config/config-field-checks.js +40 -10
- package/dist/src/config/config-keys.js +30 -8
- package/dist/src/config/config-live.js +63 -24
- package/dist/src/config/config-request.js +137 -0
- package/dist/src/config/config-schema.js +31 -24
- package/dist/src/config/config-selector-checks.js +123 -0
- package/dist/src/config/config-validation-agents.js +80 -0
- package/dist/src/config/config-validation-model.js +116 -0
- package/dist/src/config/config-validation.js +169 -222
- package/dist/src/config/config-views.js +136 -54
- package/dist/src/config/config-write-core.js +135 -0
- package/dist/src/config/config-write.js +164 -265
- package/dist/src/config/config.js +53 -38
- package/dist/src/config/model-selector.js +34 -34
- package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
- package/dist/src/doctor/doctor-backlog-plans.js +398 -0
- package/dist/src/doctor/doctor-backlog.js +249 -512
- package/dist/src/doctor/doctor-check-names.js +85 -0
- package/dist/src/doctor/doctor-checks.js +61 -593
- package/dist/src/doctor/doctor-claude-checks.js +142 -0
- package/dist/src/doctor/doctor-codex-checks.js +276 -0
- package/dist/src/doctor/doctor-contention-checks.js +506 -0
- package/dist/src/doctor/doctor-fallback-checks.js +130 -0
- package/dist/src/doctor/doctor-holds.js +80 -66
- package/dist/src/doctor/doctor-install.js +2 -1
- package/dist/src/doctor/doctor-model-checks.js +216 -537
- package/dist/src/doctor/doctor-notify.js +9 -5
- package/dist/src/doctor/doctor-opencode-checks.js +164 -0
- package/dist/src/doctor/doctor-orphans.js +182 -63
- package/dist/src/doctor/doctor-pi-checks.js +62 -0
- package/dist/src/doctor/doctor-project-checks.js +397 -0
- package/dist/src/doctor/doctor.js +126 -77
- package/dist/src/doctor/image-probe.js +21 -8
- package/dist/src/doctor/outcome.js +3 -2
- package/dist/src/doctor/probe.js +21 -12
- package/dist/src/events/event-format.js +185 -89
- package/dist/src/events/event-labels.js +11 -5
- package/dist/src/events/event-legacy.js +39 -15
- package/dist/src/events/event-read.js +52 -39
- package/dist/src/events/event-tally.js +16 -0
- package/dist/src/events/event-window.js +80 -26
- package/dist/src/events/events.js +30 -13
- package/dist/src/events/notify.js +7 -7
- package/dist/src/failure/failure-cluster.js +12 -12
- package/dist/src/failure/failure-data.js +65 -60
- package/dist/src/failure/failure-render.js +29 -21
- package/dist/src/failure/failure-state-change.js +17 -12
- package/dist/src/failure/time-spend.js +35 -35
- package/dist/src/files/file-queue.js +17 -0
- package/dist/src/files/files.js +111 -35
- package/dist/src/files/json-files.js +3 -3
- package/dist/src/files/json-object.js +28 -7
- package/dist/src/files/stat-cache.js +2 -2
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +4 -5
- package/dist/src/gates/budget-gates.js +88 -225
- package/dist/src/gates/disk-gate.js +1 -1
- package/dist/src/gates/gate-polls.js +101 -143
- package/dist/src/gates/maintenance-quota.js +37 -30
- package/dist/src/gates/pause-gates.js +28 -10
- package/dist/src/gates/readiness.js +7 -3
- package/dist/src/gates/role-cap-gates.js +8 -8
- package/dist/src/gates/startup-gate.js +94 -32
- package/dist/src/gates/streak-gate.js +54 -30
- package/dist/src/git/commit-message.js +89 -46
- package/dist/src/git/git-diff.js +16 -10
- package/dist/src/git/git-run.js +79 -38
- package/dist/src/git/git.js +54 -34
- package/dist/src/git/slot-salvage.js +253 -0
- package/dist/src/git/slots-state.js +6 -6
- package/dist/src/git/worktree-pool.js +132 -38
- package/dist/src/git/worktree-use.js +4 -11
- package/dist/src/git/worktree.js +94 -37
- package/dist/src/gui/gui-admin-endpoints.js +96 -0
- package/dist/src/gui/gui-args.js +79 -8
- package/dist/src/gui/gui-backlog-commands.js +43 -19
- package/dist/src/gui/gui-backlog-endpoints.js +156 -0
- package/dist/src/gui/gui-command.js +26 -13
- package/dist/src/gui/gui-config-commands.js +168 -80
- package/dist/src/gui/gui-endpoint-commands.js +5 -4
- package/dist/src/gui/gui-endpoint-common.js +4 -4
- package/dist/src/gui/gui-endpoints.js +11 -380
- package/dist/src/gui/gui-history-endpoints.js +301 -0
- package/dist/src/gui/gui-operator-commands.js +235 -13
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +97 -0
- package/dist/src/gui/gui-server.js +26 -5
- package/dist/src/gui/gui-team-commands.js +7 -7
- package/dist/src/gui/http-body.js +1 -1
- package/dist/src/history/history-data.js +122 -234
- package/dist/src/history/history-queued.js +155 -0
- package/dist/src/history/history-render.js +42 -22
- package/dist/src/history/history.js +190 -49
- package/dist/src/inbox/inbox-attachments.js +29 -30
- package/dist/src/inbox/inbox-cancel.js +10 -11
- package/dist/src/inbox/inbox-edit.js +9 -9
- package/dist/src/inbox/inbox-submit.js +18 -18
- package/dist/src/inbox/inbox-top.js +28 -17
- package/dist/src/inbox/inbox.js +42 -43
- package/dist/src/inbox/pending-prompt.js +26 -26
- package/dist/src/inbox/prompt-commands.js +62 -49
- package/dist/src/inbox/prompt-not-before.js +2 -2
- package/dist/src/init/init-templates.js +1 -2
- package/dist/src/init/init.js +28 -23
- package/dist/src/merge/backlog-conflicts.js +215 -35
- package/dist/src/merge/merge-batch.js +14 -8
- package/dist/src/merge/merge-cancel.js +53 -0
- package/dist/src/merge/merge-check-failures.js +44 -22
- package/dist/src/merge/merge-core.js +58 -32
- package/dist/src/merge/merge-diff.js +5 -4
- package/dist/src/merge/merge-drain.js +45 -19
- package/dist/src/merge/merge-git.js +177 -38
- package/dist/src/merge/merge-pipeline.js +15 -3
- package/dist/src/merge/merge-post-review.js +214 -0
- package/dist/src/merge/merge-queue-render.js +48 -11
- package/dist/src/merge/merge-queue.js +52 -15
- package/dist/src/merge/merge-requeue.js +103 -0
- package/dist/src/merge/merge-slot.js +61 -16
- package/dist/src/merge/merge-stack.js +27 -11
- package/dist/src/merge/merge-to-main.js +77 -26
- package/dist/src/merge/merge-top.js +83 -0
- package/dist/src/merge/merge-verify.js +75 -18
- package/dist/src/notes-cli.js +35 -15
- package/dist/src/operator/operator-commands.js +192 -81
- package/dist/src/operator/operator-intent.js +69 -42
- package/dist/src/operator/operator-requests.js +35 -40
- package/dist/src/operator/retire.js +5 -6
- package/dist/src/operator/revert.js +142 -34
- package/dist/src/orchestrator/orchestrator-launch.js +60 -52
- package/dist/src/orchestrator/orchestrator-poll.js +352 -0
- package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
- package/dist/src/orchestrator/orchestrator.js +129 -369
- package/dist/src/orchestrator/retention.js +42 -8
- package/dist/src/orchestrator/scheduling-admit.js +116 -0
- package/dist/src/orchestrator/scheduling-scope.js +245 -0
- package/dist/src/orchestrator/scheduling-skip.js +195 -0
- package/dist/src/orchestrator/startup-migration-steps.js +8 -7
- package/dist/src/orchestrator/startup-migrations.js +11 -7
- package/dist/src/paths.js +126 -49
- package/dist/src/pi/command-shape.js +12 -1
- package/dist/src/pi/pi-args.js +6 -4
- package/dist/src/pi/pi-backend.js +6 -6
- package/dist/src/pi/pi-bin.js +54 -63
- package/dist/src/pi/pi-listed-models.js +53 -0
- package/dist/src/pi/pi-models.js +63 -30
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +1 -1
- package/dist/src/pi/pi-stream.js +6 -6
- package/dist/src/pi/pi-watchdogs.js +18 -19
- package/dist/src/pi-extension/bounded-output.js +5 -3
- package/dist/src/pi-extension/context-budget.js +2 -2
- package/dist/src/pi-extension/context-shake.js +1 -1
- package/dist/src/pi-extension/context-window.js +0 -1
- package/dist/src/pi-extension/full-output.js +3 -10
- package/dist/src/pi-extension/strip-thinking.js +0 -1
- package/dist/src/pi-extension/trim-system-prompt.js +3 -4
- package/dist/src/pi-extension/write-guard.js +37 -0
- package/dist/src/process/launch-services.js +1 -2
- package/dist/src/process/node-bin.js +16 -0
- package/dist/src/process/process-group.js +8 -18
- package/dist/src/process/process.js +41 -2
- package/dist/src/process/run-marker.js +26 -6
- package/dist/src/process/supervisor.js +22 -21
- package/dist/src/prompt/dialect.js +17 -2
- package/dist/src/prompt/gate-notes.js +203 -0
- package/dist/src/prompt/gate-prompts.js +150 -217
- package/dist/src/prompt/notes-backlog.js +184 -0
- package/dist/src/prompt/principles.js +1 -2
- package/dist/src/prompt/prompt-followup.js +19 -14
- package/dist/src/prompt/prompt.js +131 -204
- package/dist/src/redeploy/redeploy-policy.js +24 -19
- package/dist/src/redeploy/redeploy-probes.js +1 -1
- package/dist/src/redeploy/redeploy.js +2 -3
- package/dist/src/redeploy/redeployer.js +20 -20
- package/dist/src/redeploy/self-reload.js +2 -1
- package/dist/src/report/report-data.js +188 -124
- package/dist/src/report/report-fold.js +130 -54
- package/dist/src/report/report-main-red.js +55 -0
- package/dist/src/report/report-render.js +136 -65
- package/dist/src/report/report-types.js +1 -0
- package/dist/src/report/report.js +23 -9
- package/dist/src/request-timeouts.js +2 -2
- package/dist/src/review/diff-rules.js +53 -0
- package/dist/src/review/known-flakes.js +112 -1
- package/dist/src/review/post-merge-review-accept.js +51 -0
- package/dist/src/review/post-merge-review-render.js +112 -0
- package/dist/src/review/post-merge-review.js +239 -0
- package/dist/src/review/protected-edits.js +24 -10
- package/dist/src/review/quarantine-render.js +65 -0
- package/dist/src/review/review-followup.js +8 -8
- package/dist/src/review/review-precheck.js +66 -23
- package/dist/src/review/review-run.js +395 -0
- package/dist/src/review/review-verdict.js +2 -2
- package/dist/src/review/review.js +180 -401
- package/dist/src/review/secret-leaks.js +36 -16
- package/dist/src/review/suite-rerun.js +0 -1
- package/dist/src/roles/legacy-role-migration.js +80 -7
- package/dist/src/roles/role-catalog.js +127 -12
- package/dist/src/roles/role-guidance.js +65 -20
- package/dist/src/roles/role-labels.js +7 -5
- package/dist/src/roles/role-render.js +108 -56
- package/dist/src/roles/role-view.js +95 -56
- package/dist/src/roles/roles.js +33 -33
- package/dist/src/roles/runner-ids.js +97 -0
- package/dist/src/run/acceptance-greps.js +119 -0
- package/dist/src/run/ci-parity.js +121 -0
- package/dist/src/run/feedback-filings.js +51 -0
- package/dist/src/run/qa-coverage.js +125 -0
- package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
- package/dist/src/run/run-detail-data.js +175 -0
- package/dist/src/run/run-detail-render.js +91 -0
- package/dist/src/run/run-detail.js +168 -0
- package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
- package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
- package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
- package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
- package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
- package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
- package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
- package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
- package/dist/src/run/selfcheck-digest.js +40 -0
- package/dist/src/run/stage-backlog-checks.js +249 -0
- package/dist/src/run/stage-check.js +178 -0
- package/dist/src/run/stage-checks-shared.js +65 -0
- package/dist/src/run/stage-name-checks.js +204 -0
- package/dist/src/run/stage-path-checks.js +231 -0
- package/dist/src/run/stage-prose-checks.js +261 -0
- package/dist/src/runner/leftover.js +216 -0
- package/dist/src/{loop → runner}/model-fallback.js +12 -12
- package/dist/src/runner/revision.js +191 -0
- package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
- package/dist/src/runner/runner-outcome.js +109 -0
- package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
- package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
- package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
- package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
- package/dist/src/runner/runner-state.js +103 -0
- package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
- package/dist/src/scheduling/backoff.js +38 -38
- package/dist/src/scheduling/claim-entries.js +49 -0
- package/dist/src/scheduling/claims.js +35 -22
- package/dist/src/scheduling/once-round.js +22 -22
- package/dist/src/scheduling/quiet-hours.js +5 -6
- package/dist/src/scheduling/scheduling.js +74 -64
- package/dist/src/scheduling/work-merged-cache.js +2 -2
- package/dist/src/status/status-budget.js +55 -47
- package/dist/src/status/status-data.js +49 -17
- package/dist/src/status/status-inbox.js +2 -2
- package/dist/src/status/status-install.js +0 -1
- package/dist/src/status/status-polls.js +43 -28
- package/dist/src/status/status-roles.js +50 -13
- package/dist/src/team/error-storm.js +8 -8
- package/dist/src/team/failure-spread.js +2 -2
- package/dist/src/team/orchestrator-info.js +15 -9
- package/dist/src/team/reclaim.js +7 -8
- package/dist/src/team/team-hold.js +7 -7
- package/dist/src/team/team-polls.js +91 -50
- package/dist/src/team/team-state.js +8 -2
- package/dist/src/text/datetime.js +65 -18
- package/dist/src/text/format.js +48 -11
- package/dist/src/text/markdown.js +8 -5
- package/dist/src/text/phrases.js +60 -23
- package/dist/src/text/suggest.js +1 -1
- package/dist/src/text/text.js +78 -13
- package/dist/src/ui/badges.js +99 -38
- package/dist/src/ui/gui/gui-client-backlog.js +314 -0
- package/dist/src/ui/gui/gui-client-boot.js +89 -61
- package/dist/src/ui/gui/gui-client-composer.js +8 -9
- package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
- package/dist/src/ui/gui/gui-client-drawer.js +155 -289
- package/dist/src/ui/gui/gui-client-feed.js +271 -0
- package/dist/src/ui/gui/gui-client-history.js +437 -68
- package/dist/src/ui/gui/gui-client-labels.js +6 -3
- package/dist/src/ui/gui/gui-client-markdown.js +1 -2
- package/dist/src/ui/gui/gui-client-model.js +27 -36
- package/dist/src/ui/gui/gui-client-operator.js +60 -52
- package/dist/src/ui/gui/gui-client-pending.js +405 -24
- package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
- package/dist/src/ui/gui/gui-client-report.js +105 -430
- package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
- package/dist/src/ui/gui/gui-client-settings.js +163 -5
- package/dist/src/ui/gui/gui-client-team.js +36 -465
- package/dist/src/ui/gui/gui-client.js +38 -26
- package/dist/src/ui/gui/gui-icons.js +0 -2
- package/dist/src/ui/gui/gui-page.js +10 -7
- package/dist/src/ui/gui/gui-styles.js +38 -34
- package/dist/src/ui/progress-data.js +9 -11
- package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
- package/dist/src/ui/status-model.js +133 -125
- package/dist/src/ui/status-payload.js +364 -49
- package/dist/src/ui/status-render.js +109 -82
- package/dist/src/ui/team-alerts.js +728 -62
- package/dist/src/ui/tone.js +12 -6
- package/dist/src/ui/transcript-run.js +58 -0
- package/dist/src/ui/transcript-tail.js +28 -20
- package/dist/src/ui/transcript.js +63 -23
- package/dist/src/ui/tui/tui-app.js +0 -1
- package/dist/src/ui/tui/tui-backlog.js +53 -14
- package/dist/src/ui/tui/tui-editor-answer.js +27 -31
- package/dist/src/ui/tui/tui-editor-attach.js +4 -12
- package/dist/src/ui/tui/tui-editor-backends.js +127 -0
- package/dist/src/ui/tui/tui-editor-brief.js +14 -36
- package/dist/src/ui/tui/tui-editor-budget.js +36 -39
- package/dist/src/ui/tui/tui-editor-check.js +29 -0
- package/dist/src/ui/tui/tui-editor-checks.js +28 -0
- package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
- package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
- package/dist/src/ui/tui/tui-editor-config.js +99 -0
- package/dist/src/ui/tui/tui-editor-entry.js +39 -64
- package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
- package/dist/src/ui/tui/tui-editor-instances.js +46 -0
- package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
- package/dist/src/ui/tui/tui-editor-interval.js +29 -16
- package/dist/src/ui/tui/tui-editor-model.js +29 -0
- package/dist/src/ui/tui/tui-editor-notes.js +9 -29
- package/dist/src/ui/tui/tui-editor-notify.js +25 -0
- package/dist/src/ui/tui/tui-editor-pause.js +48 -59
- package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
- package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
- package/dist/src/ui/tui/tui-editor-queued.js +5 -9
- package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
- package/dist/src/ui/tui/tui-editor-retire.js +5 -13
- package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
- package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
- package/dist/src/ui/tui/tui-editor-search.js +45 -33
- package/dist/src/ui/tui/tui-editor-session.js +101 -34
- package/dist/src/ui/tui/tui-editor-slots.js +24 -0
- package/dist/src/ui/tui/tui-editors.js +44 -2
- package/dist/src/ui/tui/tui-frame.js +68 -11
- package/dist/src/ui/tui/tui-history.js +8 -9
- package/dist/src/ui/tui/tui-input.js +13 -11
- package/dist/src/ui/tui/tui-keymap.js +0 -1
- package/dist/src/ui/tui/tui-keys.js +33 -9
- package/dist/src/ui/tui/tui-pane-activity.js +35 -19
- package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
- package/dist/src/ui/tui/tui-pane-brief.js +0 -1
- package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
- package/dist/src/ui/tui/tui-pane-health.js +168 -8
- package/dist/src/ui/tui/tui-pane-history.js +446 -82
- package/dist/src/ui/tui/tui-pane-pending.js +539 -15
- package/dist/src/ui/tui/tui-pane-queued.js +78 -57
- package/dist/src/ui/tui/tui-pane-report.js +54 -16
- package/dist/src/ui/tui/tui-pane-session.js +34 -6
- package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
- package/dist/src/ui/tui/tui-pane.js +91 -28
- package/dist/src/ui/tui/tui-panes.js +153 -34
- package/dist/src/ui/tui/tui.js +18 -15
- package/dist/src/verdict/fix-claim.js +129 -33
- package/dist/src/verdict/no-change.js +4 -4
- package/dist/src/verdict/refusal.js +40 -16
- package/dist/src/verdict/reply-contract.js +77 -31
- package/dist/src/version.js +0 -1
- package/package.json +2 -1
- package/dist/src/loop/leftover.js +0 -130
- package/dist/src/loop/revision.js +0 -70
- package/dist/src/roles/loop-ids.js +0 -77
- package/dist/src/tick/qa-coverage.js +0 -108
- package/dist/src/tick/stage-check.js +0 -419
- package/dist/src/tick/telemetry-digest.js +0 -25
- package/dist/src/tick/tick-detail-data.js +0 -108
- package/dist/src/tick/tick-detail.js +0 -163
- package/dist/src/ui/gui/gui-client-sound.js +0 -53
|
@@ -1,175 +1,69 @@
|
|
|
1
|
-
// vocabulary: plain
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
1
|
import { childEnv } from "../process/process.js";
|
|
6
|
-
import { boundedProbe } from "./probe.js";
|
|
7
|
-
import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
|
|
8
|
-
import { opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
|
|
9
|
-
import { opencodeDbPath } from "../paths.js";
|
|
10
|
-
import { readTextOrNull, statOrNull } from "../files/files.js";
|
|
2
|
+
import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
|
|
11
3
|
import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
|
|
12
|
-
import { configForRole
|
|
13
|
-
import {
|
|
14
|
-
import { cacheReadUnpriced,
|
|
4
|
+
import { configForRole } from "../config/config-views.js";
|
|
5
|
+
import { resolveTierFallbacks, seamsInUse } from "../config/config-fallbacks.js";
|
|
6
|
+
import { cacheReadUnpriced, piModelsPath, piPairFree, readPiProviders } from "../pi/pi-models.js";
|
|
15
7
|
import { MODEL_TIERS } from "../config/config-schema.js";
|
|
16
|
-
import { isAgentId } from "../config/model-selector.js";
|
|
17
8
|
import { imageReroute, seamImageInput } from "../agent/image-input.js";
|
|
18
9
|
import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
|
|
19
|
-
import {
|
|
10
|
+
import { backendById } from "../agent/agent-backend.js";
|
|
20
11
|
import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
|
|
21
|
-
import {
|
|
22
|
-
import { claudeAuthUsesSubscription, parseClaudeAuthStatus, readClaudeAccount } from "../agent/claude/claude-account.js";
|
|
23
|
-
import { claudeEnv } from "../agent/claude/claude-env.js";
|
|
24
|
-
import { parseJsonObject } from "../files/json-object.js";
|
|
12
|
+
import { readClaudeAccount } from "../agent/claude/claude-account.js";
|
|
25
13
|
import { envPath } from "../files/files.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { loadLoopStates } from "./doctor-holds.js";
|
|
29
|
-
import { resolveAgentBin } from "../pi/pi-bin.js";
|
|
14
|
+
import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
|
|
15
|
+
import { readPiListedModels } from "../pi/pi-listed-models.js";
|
|
30
16
|
import { agree } from "../text/phrases.js";
|
|
31
|
-
import { formatDate
|
|
32
|
-
import {
|
|
17
|
+
import { formatDate } from "../text/datetime.js";
|
|
18
|
+
import { errorMessage, joinParts } from "../text/text.js";
|
|
33
19
|
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
20
|
+
import { belowNodeFloor } from "../version.js";
|
|
21
|
+
/** The one phrase a configured-models check uses when the config declares no models at all:
|
|
22
|
+
* informational (capability falls to pi's own defaults), not a warning. Shared by
|
|
23
|
+
* checkTierModels and checkImageInput so the two checks word the same "nothing configured"
|
|
24
|
+
* stance identically. */
|
|
25
|
+
const PHRASE_NO_MODELS_DECLARED = "no models declared — every model job uses pi's own default";
|
|
26
|
+
/** The one phrase a configured-models check uses when pi's definitions file could not be read
|
|
27
|
+
* at all: the check cannot answer what it was asked, so it warns instead of guessing. Shared by
|
|
28
|
+
* checkTierModels and checkImageInput so the two checks word the "cannot check" stance
|
|
29
|
+
* identically; each appends its own modelsPath after the shared stem, and checkTierModels
|
|
30
|
+
* also appends its ompNote. */
|
|
31
|
+
const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model definitions at ";
|
|
34
32
|
/** The doctor's model-readiness checks, split out of doctor-checks.ts (whose environment and
|
|
35
|
-
* repo checks stay there
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* and
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
33
|
+
* repo-setup checks stay there, and whose state/disk/build checks live in
|
|
34
|
+
* doctor-project-checks.ts), and split apart again so one theme owns one module: this file holds
|
|
35
|
+
* the shared config guard, the seam framework (seamPairs and the per-backend seam predicates),
|
|
36
|
+
* and the declared-tier and image-input checks (checkTierModels, checkImageInput). The cap
|
|
37
|
+
* fallback price and fallback-chain checks (checkFallbackModel, checkFallbackChain) live in
|
|
38
|
+
* doctor-fallback-checks.ts, importing this file's loadConfigForCheck just as the per-backend
|
|
39
|
+
* modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
|
|
40
|
+
* modules (doctor-claude-checks.ts, doctor-opencode-checks.ts, doctor-codex-checks.ts,
|
|
41
|
+
* doctor-pi-checks.ts), each importing this file's shared seam framework and the AUTH_CHECK_*
|
|
42
|
+
* probe budgets from probe.ts rather than duplicating them. The shared report contract
|
|
43
|
+
* (CheckOutcome) is imported from outcome.ts, exactly as the other doctor-* modules do. Together
|
|
44
|
+
* these are the doctor's only checks that read pi's model definitions and, for the probes, spawn
|
|
45
|
+
* an agent binary — a distinct concern from the filesystem/git checks, kept together so a change
|
|
46
|
+
* to how doctor reads models does not touch the environment checks. */
|
|
42
47
|
/** The config a readiness check reads, or the warn outcome a broken tumwater.json earns:
|
|
43
48
|
* checkInit already fails on a broken config, so a model check only reports that it could not
|
|
44
|
-
* run. One home for the guard and its wording, shared by
|
|
45
|
-
*
|
|
46
|
-
* files). The config or the warn outcome, so each check's
|
|
47
|
-
* loadConfigForCheck(root); if ("level" in config) return
|
|
48
|
-
* read its config instead of repeating a
|
|
49
|
-
|
|
49
|
+
* run. One home for the guard and its wording, shared by checkTierModels here and by
|
|
50
|
+
* doctor-fallback-checks.ts and the per-backend modules (the same shape doctor-backlog.ts's
|
|
51
|
+
* readDocChecked uses for unreadable files). The config or the warn outcome, so each check's
|
|
52
|
+
* one-line guard — `const config = loadConfigForCheck(root); if ("level" in config) return
|
|
53
|
+
* config;` — is all a check needs to read its config instead of repeating a
|
|
54
|
+
* load-and-unwrap prologue. */
|
|
55
|
+
export function loadConfigForCheck(root) {
|
|
50
56
|
const { config, error } = loadConfigSafe(root);
|
|
51
57
|
if (config === undefined)
|
|
52
58
|
return warnOutcome(`cannot check — ${error}`);
|
|
53
59
|
return config;
|
|
54
60
|
}
|
|
55
|
-
/** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
|
|
56
|
-
* `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
|
|
57
|
-
* roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
|
|
58
|
-
* behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
|
|
59
|
-
* after the day's budget is already spent — so doctor checks it up front, before the operator
|
|
60
|
-
* needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
|
|
61
|
-
* readiness, though: whether the backend can SERVE is known only from the fallback's own ticks
|
|
62
|
-
* (src/budget/fallback-breaker.ts's breaker, BUGS.md 2026-09-20), so a free pair reads "serving not
|
|
63
|
-
* verified" — unless the running orchestrator has published that its breaker demoted the pair
|
|
64
|
-
* (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
|
|
65
|
-
* which warns with the failure count and the next probe time. No fallbackModel configured is
|
|
66
|
-
* informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
|
|
67
|
-
* injectable so tests need no real pi install. */
|
|
68
|
-
export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
|
|
69
|
-
const config = loadConfigForCheck(root);
|
|
70
|
-
if ("level" in config)
|
|
71
|
-
return config;
|
|
72
|
-
const declared = fallbackPair(config);
|
|
73
|
-
if (!declared)
|
|
74
|
-
return okOutcome("none configured — roles pause at the cap");
|
|
75
|
-
// A pair missing either half would fall through to pi's own (unverified) default, so it can
|
|
76
|
-
// never be free; naming that case beats rendering a bare "?/model". The free branch names
|
|
77
|
-
// the price-resolved pair (a map with no `default` entry borrows another tier's), while the
|
|
78
|
-
// refusal names the pair the operator declared.
|
|
79
|
-
const nameOf = (pair) => pair.provider && pair.model ? `${pair.provider}/${pair.model}` : "a half-resolved pair";
|
|
80
|
-
const free = fallbackModelPair(config, modelsPath, readClaudeAccount(root));
|
|
81
|
-
if (free) {
|
|
82
|
-
const name = nameOf(free);
|
|
83
|
-
if (demoted)
|
|
84
|
-
return warnOutcome(`${name} is priced at zero but not serving — the running team demoted it after ${demoted.failures} consecutive failed runs, so roles pause at the cap; one probe run retries it from ${formatTime(new Date(demoted.probeAt))}`);
|
|
85
|
-
return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
|
|
86
|
-
}
|
|
87
|
-
return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
|
|
88
|
-
}
|
|
89
|
-
/** Whether a local server answers `GET <url>`: node's built-in fetch with a short timeout, false
|
|
90
|
-
* on any failure — doctor is a pre-flight, not a place to wait out a hung server. */
|
|
91
|
-
async function probeModels(url) {
|
|
92
|
-
try {
|
|
93
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
94
|
-
return res.ok;
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const isLocalUrl = (url) => {
|
|
101
|
-
try {
|
|
102
|
-
const host = new URL(url).hostname;
|
|
103
|
-
return host === "127.0.0.1" || host === "localhost";
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
/** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
|
|
110
|
-
* step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
|
|
111
|
-
* spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
|
|
112
|
-
* models.json and the loop states; only a pi step on 127.0.0.1/localhost makes a network
|
|
113
|
-
* request. `probe` is injectable so tests need no real server. */
|
|
114
|
-
export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
|
|
115
|
-
const config = loadConfigForCheck(root);
|
|
116
|
-
if ("level" in config)
|
|
117
|
-
return config;
|
|
118
|
-
const steps = tierChainSteps(config);
|
|
119
|
-
if (steps.length === 0)
|
|
120
|
-
return okOutcome("none configured");
|
|
121
|
-
const states = loadLoopStates(root, config);
|
|
122
|
-
const providers = readPiProviders(modelsPath);
|
|
123
|
-
const account = readClaudeAccount(root);
|
|
124
|
-
const now = Date.now();
|
|
125
|
-
let down = false;
|
|
126
|
-
const fragments = [];
|
|
127
|
-
for (const { tier, step, pair } of steps) {
|
|
128
|
-
// A provider that names a backend other than pi (claude today; opencode from part 2) never
|
|
129
|
-
// asks pi for a base URL or runs pi's price probe — only a pi step may.
|
|
130
|
-
const nonPiBackend = isAgentId(pair.provider) && pair.provider !== "pi";
|
|
131
|
-
const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
|
|
132
|
-
const local = base !== null && isLocalUrl(base);
|
|
133
|
-
const up = local ? await probe(`${base}/models`) : null;
|
|
134
|
-
const cap = pair.maxDailyCostUsd;
|
|
135
|
-
const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
|
|
136
|
-
let verdict;
|
|
137
|
-
if (up === false) {
|
|
138
|
-
down = true;
|
|
139
|
-
verdict = `not running (GET ${base}/models failed)`;
|
|
140
|
-
}
|
|
141
|
-
else if (cap !== undefined && spent >= cap) {
|
|
142
|
-
verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
|
|
143
|
-
}
|
|
144
|
-
else if (nonPiBackend) {
|
|
145
|
-
verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
|
|
146
|
-
}
|
|
147
|
-
else if (piPairFree(providers, pair.provider, pair.model, account)) {
|
|
148
|
-
verdict = up ? "free · server up" : "free";
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
|
|
152
|
-
}
|
|
153
|
-
fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
|
|
154
|
-
}
|
|
155
|
-
const detail = fragments.join("; ");
|
|
156
|
-
return down ? warnOutcome(detail) : okOutcome(detail);
|
|
157
|
-
}
|
|
158
|
-
/** How long the credential probe may take before it counts as unknown — a doctor run is a
|
|
159
|
-
* pre-flight, not a place to wait out a hung backend. */
|
|
160
|
-
export const AUTH_CHECK_TIMEOUT_MS = 15_000;
|
|
161
|
-
/** Cap on the probe's captured stdout. The auth reply is a tiny JSON object; a binary whose
|
|
162
|
-
* output is a runaway log (or a pipe held open with no EOF) must not grow `out` without bound.
|
|
163
|
-
* Sibling probes share EXEC_MAX_BUFFER, but 1 MiB is already orders of magnitude past any real
|
|
164
|
-
* reply, so it is the tighter bound here. Over it the probe kills the child and reads as
|
|
165
|
-
* unknown. */
|
|
166
|
-
export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
167
61
|
/** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"): the
|
|
168
62
|
* distinct pairs of the one seamsInUse list — every enabled role, the review gate, the
|
|
169
63
|
* resolver, the director, and each tier's budget/model-failure fallback — so a claude or pi
|
|
170
64
|
* pair that exists only as a fallback is checked exactly like a primary one. Deduplicated by
|
|
171
65
|
* backend/provider/model, so a model reached through several surfaces is checked once. */
|
|
172
|
-
function seamPairs(config) {
|
|
66
|
+
export function seamPairs(config) {
|
|
173
67
|
const pairs = new Map();
|
|
174
68
|
for (const seam of seamsInUse(config)) {
|
|
175
69
|
if (seam.model === undefined)
|
|
@@ -183,6 +77,46 @@ function seamPairs(config) {
|
|
|
183
77
|
}
|
|
184
78
|
return [...pairs.values()];
|
|
185
79
|
}
|
|
80
|
+
/** The model-carrying seam pairs for one backend — `seamPairs` with model guaranteed present
|
|
81
|
+
* (seamPairs never emits a pair without a model, so the guard only narrows the type) and
|
|
82
|
+
* narrowed to `agent`'s pairs. The backend readiness checks (claude, codex, opencode) each
|
|
83
|
+
* walk exactly this filtered list, so they share one filter rather than three copies. */
|
|
84
|
+
export function seamedModels(config, agent) {
|
|
85
|
+
return seamPairs(config).filter((p) => p.agent === agent && p.model !== undefined);
|
|
86
|
+
}
|
|
87
|
+
/** The dotted version one of the backend CLIs prints in its `--version` output (`2.0.32`),
|
|
88
|
+
* or undefined when it names none. claude and codex print a bare version; opencode prints it
|
|
89
|
+
* prefixed with `v` (`opencode v2.0.32`), which the caller opts into with `requireVPrefix` so
|
|
90
|
+
* its check cannot mistake a version-like string missing the `v`. One home for the parser so
|
|
91
|
+
* the claude, codex and opencode version checks cannot drift on the version shape they accept.
|
|
92
|
+
*/
|
|
93
|
+
function parseCliVersion(output, requireVPrefix = false) {
|
|
94
|
+
return (requireVPrefix ? /v(\d+\.\d+\.\d+)/ : /(\d+\.\d+\.\d+)/).exec(output)?.[1];
|
|
95
|
+
}
|
|
96
|
+
/** The dashed version verdict of one backend `--version` probe: the parsed dotted version
|
|
97
|
+
* when the probe ran and exited 0, or undefined when it could not run, errored, or named no
|
|
98
|
+
* version. The one home of the `code === 0 ? parseCliVersion : undefined` step the claude,
|
|
99
|
+
* codex and opencode version checks each repeated, so they cannot drift on how a null or
|
|
100
|
+
* non-zero probe reads (codex had already drifted to the `?.` form). `requireVPrefix` is
|
|
101
|
+
* parseCliVersion's, passed through for opencode's `v`-prefixed output. */
|
|
102
|
+
export function versionFromProbe(result, requireVPrefix = false) {
|
|
103
|
+
return result !== null && result.code === 0
|
|
104
|
+
? parseCliVersion(result.stdout, requireVPrefix)
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
/** The warn/fail/ok verdict for a backend CLI's `--version` check against one flat minimum
|
|
108
|
+
* version (codex's checkCodexVersion and opencode's checkOpencodeVersion both require a
|
|
109
|
+
* single floor for the whole backend, unlike claude's per-model floors). `found` is the
|
|
110
|
+
* parsed dotted version, or undefined when the probe could not run or its output was
|
|
111
|
+
* unreadable. Centralizing the three messages and the belowNodeFloor comparison keeps the
|
|
112
|
+
* two backends' version verdicts from drifting. */
|
|
113
|
+
export function minVersionOutcome(name, found, min, upgradeHint) {
|
|
114
|
+
if (found === undefined)
|
|
115
|
+
return warnOutcome(`could not check — \`${name} --version\` did not run or its output was unreadable`);
|
|
116
|
+
if (belowNodeFloor(found, `>=${min}`))
|
|
117
|
+
return failOutcome(`${name} ${found} is older than ${min} — ${upgradeHint}`);
|
|
118
|
+
return okOutcome(`${name} ${found} (>= ${min})`);
|
|
119
|
+
}
|
|
186
120
|
/** Whether any seam runs on the claude backend — the doctor's claude checks stand down when
|
|
187
121
|
* none does (a pi-only team reads no `~/.claude` state and spawns no claude). */
|
|
188
122
|
export function claudeSeamsInUse(config) {
|
|
@@ -193,6 +127,11 @@ export function claudeSeamsInUse(config) {
|
|
|
193
127
|
export function opencodeSeamsInUse(config) {
|
|
194
128
|
return seamPairs(config).some((pair) => pair.agent === "opencode");
|
|
195
129
|
}
|
|
130
|
+
/** Whether any seam routes to codex — the doctor's codex checks stand down when none does
|
|
131
|
+
* (a pi-only team spawns no codex and reads no codex home state). */
|
|
132
|
+
export function codexSeamsInUse(config) {
|
|
133
|
+
return seamPairs(config).some((pair) => pair.agent === "codex");
|
|
134
|
+
}
|
|
196
135
|
/** Whether any seam routes to the pi backend — the doctor's pi-extension row stands down when
|
|
197
136
|
* none does (an all-claude or all-opencode team reads no `~/.pi` state). Unlike the model-based
|
|
198
137
|
* seam consult, this scans the routed seams directly (not seamPairs' model-filtered view) so a
|
|
@@ -201,153 +140,6 @@ export function opencodeSeamsInUse(config) {
|
|
|
201
140
|
export function piSeamsInUse(config) {
|
|
202
141
|
return seamsInUse(config).some((s) => s.agent === "pi");
|
|
203
142
|
}
|
|
204
|
-
/** The default credential probe: run the resolved agent binary's own auth check for one provider
|
|
205
|
-
* and read `ready` out of its JSON. The binary resolves exactly as the spawn does (resolveAgentBin
|
|
206
|
-
* — the same source a real tick would launch), so doctor tests the auth of the pi it would actually
|
|
207
|
-
* run. Never throws; every failure mode ends on "unknown". The bounded subprocess is the shared
|
|
208
|
-
* boundedProbe (probe.ts), also used by the claude probes. */
|
|
209
|
-
export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
210
|
-
const resolved = resolveAgentBin(config);
|
|
211
|
-
const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
212
|
-
if (result === null)
|
|
213
|
-
return "unknown";
|
|
214
|
-
if (result.code !== 0)
|
|
215
|
-
return "unknown";
|
|
216
|
-
// An unreadable reply is unknown, never not-ready (which would blame the credentials).
|
|
217
|
-
const doc = parseJsonObject(result.stdout);
|
|
218
|
-
if (doc === null)
|
|
219
|
-
return "unknown";
|
|
220
|
-
if (doc.ready === true)
|
|
221
|
-
return "ready";
|
|
222
|
-
if (doc.ready === false)
|
|
223
|
-
return "not-ready";
|
|
224
|
-
return "unknown";
|
|
225
|
-
}
|
|
226
|
-
/** One bounded probe of the resolved `claude` binary under the scrubbed run environment a real
|
|
227
|
-
* tick gets: resolve the binary through the claude adapter that would spawn it and run `args`
|
|
228
|
-
* with a short deadline. Null when the probe could not run, the child errored, or the deadline
|
|
229
|
-
* passed. The one home of the resolveBin + claudeEnv pairing the auth and version checks share,
|
|
230
|
-
* so neither probes a different binary or environment than the other. */
|
|
231
|
-
function claudeProbe(config, args, pathEnv, timeoutMs) {
|
|
232
|
-
const resolved = claudeBackend.resolveBin(config);
|
|
233
|
-
// The configured team config dir must reach the probe's env the way a real run's does, or
|
|
234
|
-
// the auth/version rows would look in claude's default dir (usually ~/.claude) while a run
|
|
235
|
-
// with backends.claude.configDir writes and reads elsewhere — the very mismatch doctor exists
|
|
236
|
-
// to surface. Mirror backends.run's expansion: only claude accepts configDir, and unset leaves
|
|
237
|
-
// today's env byte-identical.
|
|
238
|
-
const configDir = resolvedAgentConfig(config, "claude").configDir;
|
|
239
|
-
const expandedConfigDir = configDir !== undefined ? claudeConfigDir(process.env, os.homedir(), configDir) : undefined;
|
|
240
|
-
const env = claudeEnv(childEnv({ PATH: pathEnv }), {
|
|
241
|
-
quietTimeoutSeconds: 0,
|
|
242
|
-
...(expandedConfigDir !== undefined ? { configDir: expandedConfigDir } : {}),
|
|
243
|
-
});
|
|
244
|
-
return boundedProbe(resolved.bin, args, env, timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
245
|
-
}
|
|
246
|
-
/** Run `claude auth status` under the same scrubbed environment a real run gets (claudeEnv),
|
|
247
|
-
* or null when the probe could not run or its reply was unreadable. */
|
|
248
|
-
async function claudeAuthProbe(config, pathEnv, timeoutMs) {
|
|
249
|
-
const result = await claudeProbe(config, ["auth", "status"], pathEnv, timeoutMs);
|
|
250
|
-
if (result?.code !== 0)
|
|
251
|
-
return null;
|
|
252
|
-
return parseClaudeAuthStatus(result.stdout);
|
|
253
|
-
}
|
|
254
|
-
/** claude credential readiness (plans/agent-backends.md "Every seam, checked"): a claude seam
|
|
255
|
-
* whose login is gone fails every tick before it can work, so doctor runs `claude auth status`
|
|
256
|
-
* under the scrubbed run environment and fails when it reports `loggedIn: false`. An ok detail
|
|
257
|
-
* names the billing the reply implies — the subscription type, or an API key — so an operator
|
|
258
|
-
* telling subscription spend from API spend does not have to run the command themselves. A
|
|
259
|
-
* probe that could not run or a reply this cannot read warns rather than guesses. Stands down
|
|
260
|
-
* (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
|
|
261
|
-
export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
262
|
-
const config = loadConfigForCheck(root);
|
|
263
|
-
if ("level" in config)
|
|
264
|
-
return config;
|
|
265
|
-
if (!claudeSeamsInUse(config))
|
|
266
|
-
return okOutcome("no model jobs run on claude");
|
|
267
|
-
const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
|
|
268
|
-
if (auth === null)
|
|
269
|
-
return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
|
|
270
|
-
if (!auth.loggedIn)
|
|
271
|
-
return failOutcome("claude reports logged in: false — run `claude auth login`");
|
|
272
|
-
const subscription = claudeAuthUsesSubscription(auth);
|
|
273
|
-
const billing = subscription
|
|
274
|
-
? `subscription${auth.subscriptionType !== undefined ? ` (${auth.subscriptionType})` : ""}`
|
|
275
|
-
: "API key";
|
|
276
|
-
return okOutcome(`logged in — billing: ${billing}`);
|
|
277
|
-
}
|
|
278
|
-
/** The subscription-vs-API billing trap (plans/provider-concurrency.md §9 gap 6): an operator
|
|
279
|
-
* who exports `ANTHROPIC_API_KEY` for pi's `anthropic` provider will silently move every claude
|
|
280
|
-
* seam to API billing — claude prefers the key over a claude.ai login — and subscription seams
|
|
281
|
-
* stop counting as free. Doctor warns when the key is set, some seam routes to claude, and
|
|
282
|
-
* `claude auth status` reports a claude.ai login (the subscription the key would override). It
|
|
283
|
-
* stays silent (ok) when no seam runs on claude, when the key is unset, or when claude reports
|
|
284
|
-
* an API-key login (then there is no subscription to lose). `env` is injectable so the check
|
|
285
|
-
* stays hermetic; a probe that cannot run warns rather than guesses like checkClaudeAuth. */
|
|
286
|
-
export async function checkAnthroApiKey(root, env = process.env, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
287
|
-
const config = loadConfigForCheck(root);
|
|
288
|
-
if ("level" in config)
|
|
289
|
-
return config;
|
|
290
|
-
if (!claudeSeamsInUse(config))
|
|
291
|
-
return okOutcome("no model jobs run on claude");
|
|
292
|
-
const apiKey = env.ANTHROPIC_API_KEY;
|
|
293
|
-
if (apiKey === undefined || apiKey === "")
|
|
294
|
-
return okOutcome("no ANTHROPIC_API_KEY set");
|
|
295
|
-
const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
|
|
296
|
-
if (auth === null)
|
|
297
|
-
return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
|
|
298
|
-
if (!auth.loggedIn || !claudeAuthUsesSubscription(auth))
|
|
299
|
-
return okOutcome("ANTHROPIC_API_KEY is set, but claude reports no claude.ai login");
|
|
300
|
-
return warnOutcome("ANTHROPIC_API_KEY is set while claude reports a claude.ai login — model jobs on claude will bill the API key, not the subscription; unset it or route every anthropic model job on pi explicitly");
|
|
301
|
-
}
|
|
302
|
-
/** The dotted version in `claude --version`'s output, or undefined when it names none. */
|
|
303
|
-
function parseClaudeCliVersion(output) {
|
|
304
|
-
return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
|
|
305
|
-
}
|
|
306
|
-
/** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
|
|
307
|
-
* configured model's minimum fails or misprices that model, and team runs disable the
|
|
308
|
-
* auto-updater, so the version an operator has is the version ticks run. Fails below the
|
|
309
|
-
* highest floor among the claude-routed models, naming which model needs it; an ok detail
|
|
310
|
-
* prints the installed version and the floor it satisfied (or no floor when none applies).
|
|
311
|
-
* The `off`/`minimal` thinking clamp is noted here too, since this check already has the
|
|
312
|
-
* claude seams in hand. Stands down when no seam routes to claude. */
|
|
313
|
-
export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
314
|
-
const config = loadConfigForCheck(root);
|
|
315
|
-
if ("level" in config)
|
|
316
|
-
return config;
|
|
317
|
-
const seams = seamPairs(config).filter((pair) => pair.agent === "claude" && pair.model !== undefined);
|
|
318
|
-
if (seams.length === 0)
|
|
319
|
-
return okOutcome("no model jobs run on claude");
|
|
320
|
-
// The floor each configured model needs; a model the adapter does not gate contributes none.
|
|
321
|
-
const floors = new Map();
|
|
322
|
-
for (const seam of seams) {
|
|
323
|
-
const min = claudeBackend.minVersion?.(seam.model);
|
|
324
|
-
if (min !== undefined)
|
|
325
|
-
floors.set(seam.model, min);
|
|
326
|
-
}
|
|
327
|
-
const result = await claudeProbe(config, ["--version"], pathEnv, timeoutMs);
|
|
328
|
-
const found = result !== null && result.code === 0 ? parseClaudeCliVersion(result.stdout) : undefined;
|
|
329
|
-
if (found === undefined)
|
|
330
|
-
return warnOutcome("could not check — `claude --version` did not run or its output was unreadable");
|
|
331
|
-
const clamped = seams.some((seam) => seam.thinking === "off" || seam.thinking === "minimal");
|
|
332
|
-
const clampNote = clamped
|
|
333
|
-
? "; `off`/`minimal` thinking clamps to `--effort low` (claude has no lower setting)"
|
|
334
|
-
: "";
|
|
335
|
-
if (floors.size === 0)
|
|
336
|
-
return okOutcome(`claude ${found}${clampNote}`);
|
|
337
|
-
// The highest floor decides: whichever model needs the newest CLI. Below it, fail and name
|
|
338
|
-
// that model; at or above, print the version against the floor.
|
|
339
|
-
let required = "";
|
|
340
|
-
let neededBy = "";
|
|
341
|
-
for (const [model, min] of floors) {
|
|
342
|
-
if (required === "" || belowNodeFloor(required, `>=${min}`)) {
|
|
343
|
-
required = min;
|
|
344
|
-
neededBy = model;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (belowNodeFloor(found, `>=${required}`))
|
|
348
|
-
return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
|
|
349
|
-
return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
|
|
350
|
-
}
|
|
351
143
|
/** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
|
|
352
144
|
* put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
|
|
353
145
|
* agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
|
|
@@ -362,13 +154,16 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
|
|
|
362
154
|
* undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
|
|
363
155
|
* set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
|
|
364
156
|
* ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
|
|
365
|
-
* (src/pi/pi-backend.ts, built on src/process/run-marker.ts's
|
|
366
|
-
* keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
|
|
367
|
-
* pi on PATH; `env` so the PI_* branches stay hermetic. */
|
|
368
|
-
export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env) {
|
|
157
|
+
* (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
|
|
158
|
+
* tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
|
|
159
|
+
* need no pi on PATH; `env` so the PI_* branches stay hermetic. */
|
|
160
|
+
export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
|
|
369
161
|
const config = loadConfigForCheck(root);
|
|
370
162
|
if ("level" in config)
|
|
371
163
|
return config;
|
|
164
|
+
// Asked lazily and at most once: only a pair models.json does not declare needs the listing.
|
|
165
|
+
let listing;
|
|
166
|
+
const listed = () => (listing ??= listModels());
|
|
372
167
|
const pairs = new Map();
|
|
373
168
|
// Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
|
|
374
169
|
// each backend correctly; opencode is not claude.
|
|
@@ -394,19 +189,14 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
394
189
|
? ""
|
|
395
190
|
: ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`;
|
|
396
191
|
if (pairs.size === 0 && routedModels.size === 0)
|
|
397
|
-
return (ompVars.length === 0 ? okOutcome : warnOutcome)(
|
|
398
|
-
|
|
192
|
+
return (ompVars.length === 0 ? okOutcome : warnOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
|
|
193
|
+
let providers = readPiProviders(modelsPath);
|
|
194
|
+
if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
|
|
195
|
+
providers = new Map();
|
|
399
196
|
const fails = [];
|
|
400
197
|
const warns = [];
|
|
401
|
-
// Reserved agent ids: a pi provider whose name is an agent backend's id can never reach pi
|
|
402
|
-
// (the provider segment wins the routing), so its definitions are unreachable — warn.
|
|
403
|
-
if (providers) {
|
|
404
|
-
const reserved = [...providers.keys()].filter(isAgentId);
|
|
405
|
-
if (reserved.length > 0)
|
|
406
|
-
warns.push(`pi's definitions (${modelsPath}) declare provider ${reserved.map((p) => `"${p}"`).join(", ")} — the name is reserved for an agent backend, so model ids under it never reach pi`);
|
|
407
|
-
}
|
|
408
198
|
if (pairs.size > 0 && !providers)
|
|
409
|
-
return warnOutcome(
|
|
199
|
+
return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
|
|
410
200
|
const readyProviders = new Set();
|
|
411
201
|
const checkedProviders = new Set();
|
|
412
202
|
for (const { provider, model } of pairs.values()) {
|
|
@@ -423,19 +213,29 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
423
213
|
warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
|
|
424
214
|
}
|
|
425
215
|
const defs = providers?.get(provider);
|
|
426
|
-
|
|
427
|
-
fails.push(`provider ${provider} is not in pi's definitions (${modelsPath})`);
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
const def = defs.find((d) => d.id === model);
|
|
216
|
+
const def = defs?.find((d) => d.id === model);
|
|
431
217
|
if (!def) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
218
|
+
const listedModels = await listed();
|
|
219
|
+
if (!listedModels?.has(`${provider}/${model}`)) {
|
|
220
|
+
const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
|
|
221
|
+
if (!defs && !providerListed) {
|
|
222
|
+
fails.push(listedModels === null
|
|
223
|
+
? `provider ${provider} is not in pi's definitions (${modelsPath})`
|
|
224
|
+
: `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`);
|
|
225
|
+
}
|
|
226
|
+
else if (listedModels === null) {
|
|
227
|
+
// No exact entry: pi clones the provider's default model for the id, inheriting that
|
|
228
|
+
// default's price and context window (BUGS.md 2026-10-06) — name that consequence
|
|
229
|
+
// instead of only the failed lookup.
|
|
230
|
+
fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
437
237
|
}
|
|
438
|
-
if (cacheReadUnpriced(providers, provider, model)) {
|
|
238
|
+
if (def && providers && cacheReadUnpriced(providers, provider, model)) {
|
|
439
239
|
// A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
|
|
440
240
|
// the daily budget undercounts the real spend (BUGS.md 2026-10-06).
|
|
441
241
|
warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
|
|
@@ -455,37 +255,44 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
455
255
|
const resolvedPi = `${pairs.size} declared ${agree(pairs.size, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
|
|
456
256
|
// One routing note per non-pi backend in use, so an opencode-routed model is never labeled
|
|
457
257
|
// as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
|
|
458
|
-
// id always means the OpenCode CLI, never pi's built-in `opencode` gateway.
|
|
258
|
+
// id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
|
|
259
|
+
// with the same `<n> <agent>-routed model[s] — ` frame — one phrase in one place (routedNote)
|
|
260
|
+
// so the branches cannot drift on it; the count prints beside the label, so the number form
|
|
261
|
+
// needs agree (not plural, which prints the count).
|
|
262
|
+
const routedNote = (agent, n, tail) => `${n} ${agent}-routed ${agree(n, "model", "models")} — ${tail}`;
|
|
459
263
|
const routedNotes = [];
|
|
460
264
|
for (const [agent, models] of routedModels) {
|
|
461
265
|
const n = models.size;
|
|
462
266
|
routedNotes.push(agent === "opencode"
|
|
463
|
-
?
|
|
464
|
-
:
|
|
267
|
+
? routedNote(agent, n, "`opencode/…` ids run on the OpenCode CLI, and pi's built-in `opencode` provider is not reachable from tumwater")
|
|
268
|
+
: agent === "codex"
|
|
269
|
+
? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
|
|
270
|
+
: routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
|
|
465
271
|
}
|
|
466
272
|
const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
|
|
467
273
|
const base = fails.length === 0 && warns.length === 0
|
|
468
|
-
? [pairs.size > 0 ? resolvedPi : "", routedText]
|
|
274
|
+
? joinParts([pairs.size > 0 ? resolvedPi : "", routedText])
|
|
469
275
|
: found;
|
|
470
276
|
if (fails.length > 0)
|
|
471
277
|
return failOutcome(`${base}${ompNote}`);
|
|
472
278
|
return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
|
|
473
279
|
}
|
|
474
280
|
/** The doctor's image-input capability check (plans/image-input.md "Doctor", parts 1-2/5):
|
|
475
|
-
* which of the team's seams claim to read images, and whether every text-only enabled
|
|
281
|
+
* which of the team's seams claim to read images, and whether every text-only enabled role can
|
|
476
282
|
* reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
|
|
477
283
|
* terminal `tumwater doctor` default) each claimed pi model with no fresh cached verdict is
|
|
478
|
-
* shown a real image and the answer is checked; with "all" (the `--reprobe` flag
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
284
|
+
* shown a real image and the answer is checked; with "all" (the `--reprobe` flag, the
|
|
285
|
+
* dashboard's Re-probe models button and the TUI Health tab's Ctrl+P) every claimed model is
|
|
286
|
+
* re-probed; with "never" (the TUI Health tab and GUI doctor endpoint on entry and refresh)
|
|
287
|
+
* nothing is spawned and only cached verdicts are read, so a refresh never spends a model call.
|
|
288
|
+
* claude seams are never probed — every Claude model reads images, and a probe would spend
|
|
289
|
+
* subscription window to confirm it.
|
|
483
290
|
*
|
|
484
291
|
* Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
|
|
485
292
|
* reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
|
|
486
293
|
* text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
|
|
487
294
|
* failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
|
|
488
|
-
* reroute sweep never sends a text-only
|
|
295
|
+
* reroute sweep never sends a text-only role to a model that lost its probe. `providers`,
|
|
489
296
|
* `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
|
|
490
297
|
export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
|
|
491
298
|
const config = loadConfigForCheck(root);
|
|
@@ -495,10 +302,13 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
|
|
|
495
302
|
// answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
|
|
496
303
|
// this check when a seam actually needs it — an all-claude team reads images without it.
|
|
497
304
|
const accountNow = account === undefined ? readClaudeAccount(root) : account;
|
|
305
|
+
// The free-ness lookup reads the image-input check's one claude account (part 2a/9).
|
|
306
|
+
const accounts = (b) => (b === "claude" ? accountNow : null);
|
|
307
|
+
const listed = readPiListedModels(root);
|
|
498
308
|
const pairs = seamPairs(config);
|
|
499
309
|
const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
|
|
500
310
|
if (providers === null && needsDefinitions)
|
|
501
|
-
return warnOutcome(
|
|
311
|
+
return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}`);
|
|
502
312
|
const label = modelLabel;
|
|
503
313
|
// The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
|
|
504
314
|
// model whose earlier probe failed (it must still be named and re-probed by --reprobe).
|
|
@@ -506,7 +316,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
|
|
|
506
316
|
for (const pair of pairs) {
|
|
507
317
|
if (pair.model === undefined)
|
|
508
318
|
continue;
|
|
509
|
-
if (seamImageInput(pair, providers) === true)
|
|
319
|
+
if (seamImageInput(pair, providers, undefined, listed) === true)
|
|
510
320
|
claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
|
|
511
321
|
agent: pair.agent,
|
|
512
322
|
provider: pair.provider,
|
|
@@ -516,16 +326,16 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
|
|
|
516
326
|
}
|
|
517
327
|
// The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
|
|
518
328
|
// fallback regime picks among these, so a text-only fallback team is a coverage gap here.
|
|
519
|
-
const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p
|
|
329
|
+
const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p, accounts));
|
|
520
330
|
for (const tier of MODEL_TIERS) {
|
|
521
331
|
const pair = resolved[tier].pair;
|
|
522
332
|
if (pair?.model === undefined)
|
|
523
333
|
continue;
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
const agent = "pi";
|
|
528
|
-
if (seamImageInput(pair, providers) === true)
|
|
334
|
+
// The readers (seamImageInput, imageReroute) resolve a pair with no backend to the "pi"
|
|
335
|
+
// agent, so the cache key must carry "pi" — keying it `undefined` would write a verdict the
|
|
336
|
+
// readers never read.
|
|
337
|
+
const agent = pair.backend ?? "pi";
|
|
338
|
+
if (seamImageInput({ ...pair, agent }, providers, undefined, listed) === true)
|
|
529
339
|
claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
|
|
530
340
|
agent,
|
|
531
341
|
provider: pair.provider,
|
|
@@ -547,7 +357,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
|
|
|
547
357
|
const agent = seam.agent;
|
|
548
358
|
// A backend that registers an imageInput hook trusts the hook (claude today, which reads
|
|
549
359
|
// every model); a backend without one is probed through its model catalog.
|
|
550
|
-
if (agent !== undefined &&
|
|
360
|
+
if (agent !== undefined && backendById(agent)?.imageInput !== undefined) {
|
|
551
361
|
readsItems.push(label(agent, seam.provider, seam.model));
|
|
552
362
|
continue;
|
|
553
363
|
}
|
|
@@ -582,207 +392,76 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
|
|
|
582
392
|
readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
|
|
583
393
|
}
|
|
584
394
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const reroutes = [];
|
|
590
|
-
const noTarget = [];
|
|
591
|
-
for (const role of enabledRoleIds(config)) {
|
|
592
|
-
const rc = configForRole(config, role);
|
|
593
|
-
if (rc.model === undefined)
|
|
594
|
-
continue;
|
|
595
|
-
if (seamImageInput(rc, providers, probes) !== false)
|
|
596
|
-
continue; // reads images, or unknown (to tier models)
|
|
597
|
-
const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes);
|
|
598
|
-
if (rr && seamImageInput(rr.config, providers, probes) === true)
|
|
599
|
-
reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
|
|
600
|
-
else
|
|
601
|
-
noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
|
|
602
|
-
}
|
|
603
|
-
// A team that declared no models at all leaves capability to pi's own defaults — the same
|
|
604
|
-
// "nothing configured" stance as tier models, informational rather than a warning.
|
|
605
|
-
const declared = pairs.some((p) => p.model !== undefined) ||
|
|
606
|
-
MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
|
|
607
|
-
if (!declared)
|
|
608
|
-
return okOutcome("no models declared — every model job uses pi's own default");
|
|
609
|
-
const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
|
|
610
|
-
const details = [readsLine, ...reroutes].filter(Boolean).join("; ");
|
|
611
|
-
if (failed.length > 0)
|
|
612
|
-
return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
|
|
613
|
-
if (couldNot.length > 0)
|
|
614
|
-
return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
|
|
615
|
-
if (noTarget.length > 0)
|
|
616
|
-
return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
|
|
617
|
-
if (readsItems.length === 0)
|
|
618
|
-
return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
|
|
619
|
-
return okOutcome(details);
|
|
620
|
-
}
|
|
621
|
-
/** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
|
|
622
|
-
* undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
|
|
623
|
-
* own gateway with no credential). A model id with more than the `opencode/` namespace prefix
|
|
624
|
-
* is provider-qualified and needs a credential OpenCode lists. */
|
|
625
|
-
function opencodeProviderOf(model) {
|
|
626
|
-
const parts = model.split("/");
|
|
627
|
-
if (parts[0] === "opencode" && parts.length >= 3)
|
|
628
|
-
return parts[1];
|
|
629
|
-
if (parts[0] !== "opencode" && parts.length >= 2)
|
|
630
|
-
return parts[0];
|
|
631
|
-
return undefined;
|
|
632
|
-
}
|
|
633
|
-
/** Whether `opencode auth list --format json` (bounded) lists `provider` exactly. A probe that
|
|
634
|
-
* cannot run, exits nonzero, or omits the provider reads as NOT listed — the safe direction,
|
|
635
|
-
* since doctor is warning the operator to add the credential, never confirming one it did not
|
|
636
|
-
* see. The list is matched EXACTLY, not as a raw substring: when the output parses as an array
|
|
637
|
-
* (of names or of per-credential objects), a credential listed as `anthropic` must never vouch
|
|
638
|
-
* for `anthropic-foo` twice over; when it cannot be parsed as JSON (a shape the CLI may change),
|
|
639
|
-
* the text is matched word-bounded so a truncated or prose line still reads. */
|
|
640
|
-
async function opencodeAuthLists(config, provider, pathEnv, timeoutMs) {
|
|
641
|
-
const resolved = opencodeBackend.resolveBin(config);
|
|
642
|
-
const result = await boundedProbe(resolved.bin, ["auth", "list", "--format", "json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
643
|
-
if (result?.code !== 0)
|
|
644
|
-
return false;
|
|
645
|
-
const text = result.stdout;
|
|
646
|
-
try {
|
|
647
|
-
const parsed = JSON.parse(text);
|
|
648
|
-
if (Array.isArray(parsed)) {
|
|
649
|
-
return parsed.some((item) => {
|
|
650
|
-
if (typeof item === "string")
|
|
651
|
-
return item === provider;
|
|
652
|
-
return (item !== null &&
|
|
653
|
-
typeof item === "object" &&
|
|
654
|
-
Object.values(item).some((v) => v === provider));
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
catch {
|
|
659
|
-
// Not an array-shaped JSON payload — fall through to the word-bounded text match.
|
|
660
|
-
}
|
|
661
|
-
const esc = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
662
|
-
return new RegExp(`(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|$)`).test(text);
|
|
663
|
-
}
|
|
664
|
-
/** Whether a JSON-ish key (optionally quoted, followed by a colon) appears in a config file's
|
|
665
|
-
* text — a loose match that tolerates JSONC comments without parsing them. */
|
|
666
|
-
function jsonKeyPresent(text, key) {
|
|
667
|
-
return new RegExp(`["']?${key}["']?\\s*:`).test(text);
|
|
668
|
-
}
|
|
669
|
-
/** The operator's global opencode config files and AGENTS.md (plans/opencode-backend.md §7
|
|
670
|
-
* "Leaks", part 7/7): the operator's own config and instructions still load into team runs,
|
|
671
|
-
* so each MCP server (disabled for team runs) or plugin/agent/instructions entry or the
|
|
672
|
-
* AGENTS.md file itself produces one warning naming the file and what leaks in: an MCP server
|
|
673
|
-
* is switched off by the harness, while a plugin/agent/instructions entry or AGENTS.md is NOT
|
|
674
|
-
* switched off and still loads. `configHome` is the config root to scan — the XDG config home
|
|
675
|
-
* (when set) or `~/.config`, the SAME folder opencode's own operatorMcpNames reads, so the leak
|
|
676
|
-
* scan matches the config a run actually loads. Config files
|
|
677
|
-
* are JSON/C, so the keys are matched loosely (a word followed by a colon) rather than fully
|
|
678
|
-
* parsed — enough to name what still loads before a run inherits it. */
|
|
679
|
-
function opencodeConfigLeaks(configHome) {
|
|
680
|
-
const dir = path.join(configHome, "opencode");
|
|
681
|
-
const warns = [];
|
|
682
|
-
for (const file of ["config.json", "opencode.json", "opencode.jsonc"]) {
|
|
683
|
-
const text = readTextOrNull(path.join(dir, file));
|
|
684
|
-
if (text === null)
|
|
685
|
-
continue;
|
|
686
|
-
const parts = [];
|
|
687
|
-
let mcp = false;
|
|
688
|
-
if (jsonKeyPresent(text, "mcp") && jsonKeyPresent(text, "servers")) {
|
|
689
|
-
parts.push("an MCP server");
|
|
690
|
-
mcp = true;
|
|
395
|
+
let saveProblem = null;
|
|
396
|
+
if (probedAny) {
|
|
397
|
+
try {
|
|
398
|
+
writeImageProbeCache(root, probes);
|
|
691
399
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
parts.push(`a ${key} entry`);
|
|
400
|
+
catch (err) {
|
|
401
|
+
saveProblem = errorMessage(err);
|
|
695
402
|
}
|
|
696
|
-
if (parts.length === 0)
|
|
697
|
-
continue;
|
|
698
|
-
const joined = parts.join(" and ");
|
|
699
|
-
// Only the MCP server is disabled for team runs; everything else still loads.
|
|
700
|
-
const verdict = mcp && parts.length === 1 ? " — disabled for team runs" : " — still loads into team runs";
|
|
701
|
-
warns.push(`${path.join(dir, file)} carries ${joined}${verdict}`);
|
|
702
403
|
}
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
if (
|
|
716
|
-
|
|
717
|
-
else
|
|
718
|
-
|
|
404
|
+
const verdict = () => {
|
|
405
|
+
// Text-only enabled roles: which can reroute to a tier that reads images (honoring failed
|
|
406
|
+
// probes), and which cannot.
|
|
407
|
+
const reroutes = [];
|
|
408
|
+
const noTarget = [];
|
|
409
|
+
for (const role of enabledRoleIds(config)) {
|
|
410
|
+
const rc = configForRole(config, role);
|
|
411
|
+
if (rc.model === undefined)
|
|
412
|
+
continue;
|
|
413
|
+
if (seamImageInput(rc, providers, probes, listed) !== false)
|
|
414
|
+
continue; // reads images, or unknown (to tier models)
|
|
415
|
+
const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes, null, listed);
|
|
416
|
+
if (rr && seamImageInput(rr.config, providers, probes, listed) === true)
|
|
417
|
+
reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
|
|
418
|
+
else
|
|
419
|
+
noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
|
|
719
420
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
421
|
+
// A team that declared no models at all leaves capability to pi's own defaults — the same
|
|
422
|
+
// "nothing configured" stance as tier models, informational rather than a warning.
|
|
423
|
+
const declared = pairs.some((p) => p.model !== undefined) ||
|
|
424
|
+
MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
|
|
425
|
+
if (!declared)
|
|
426
|
+
return okOutcome(PHRASE_NO_MODELS_DECLARED);
|
|
427
|
+
const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
|
|
428
|
+
const details = joinParts([readsLine, ...reroutes]);
|
|
429
|
+
if (failed.length > 0)
|
|
430
|
+
return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
|
|
431
|
+
if (couldNot.length > 0)
|
|
432
|
+
return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
|
|
433
|
+
if (noTarget.length > 0)
|
|
434
|
+
return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
|
|
435
|
+
if (readsItems.length === 0)
|
|
436
|
+
return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
|
|
437
|
+
return okOutcome(details);
|
|
438
|
+
};
|
|
439
|
+
const outcome = verdict();
|
|
440
|
+
if (saveProblem === null)
|
|
441
|
+
return outcome;
|
|
442
|
+
return {
|
|
443
|
+
level: outcome.level === "ok" ? "warn" : outcome.level,
|
|
444
|
+
detail: `${outcome.detail} — could not save the image-probe cache (${saveProblem}), so the next doctor probes again`,
|
|
445
|
+
};
|
|
733
446
|
}
|
|
734
|
-
/**
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
|
|
742
|
-
* spawns nothing and reads nothing. `pathEnv` and `home` are injectable so tests need no real
|
|
743
|
-
* PATH or HOME. The version half of the opencode pre-flight is the sibling checkOpencodeVersion
|
|
744
|
-
* in doctor-checks.ts. */
|
|
745
|
-
export async function checkOpencode(root, pathEnv = envPath(), home = os.homedir(), xdgConfigHome = process.env.XDG_CONFIG_HOME ?? "", timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
447
|
+
/** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
|
|
448
|
+
* the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
|
|
449
|
+
* resolves and `gh auth status` exits 0 before a run tries to file — the codex-login pattern
|
|
450
|
+
* (plans/codex-backend.md part 9/9). Shown only when the role is enabled: on a default install
|
|
451
|
+
* nothing files upstream, so there is nothing to check and the row is omitted (null). `probe`
|
|
452
|
+
* is injectable so tests use a fake gh (test/fakes/fake-gh.ts); the real probe runs `gh` under
|
|
453
|
+
* the scrubbed run environment with a bounded deadline. */
|
|
454
|
+
export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (args) => boundedProbe("gh", args, childEnv({ PATH: pathEnv }), AUTH_CHECK_TIMEOUT_MS, AUTH_CHECK_MAX_OUTPUT_BYTES)) {
|
|
746
455
|
const config = loadConfigForCheck(root);
|
|
747
456
|
if ("level" in config)
|
|
748
457
|
return config;
|
|
749
|
-
if (!
|
|
750
|
-
return
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const entry = cache.models[key];
|
|
759
|
-
if (entry === undefined) {
|
|
760
|
-
fails.push(`${pair.model} is not in the opencode catalog cache`);
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
const price = entry.free ? "free" : `$${entry.inputPerM ?? 0}/$${entry.outputPerM ?? 0} per M`;
|
|
764
|
-
const window = entry.context !== undefined ? `, ${entry.context.toLocaleString("en-US")}-token window` : "";
|
|
765
|
-
notes.push(`${pair.model} — ${price}${window}`);
|
|
766
|
-
// A model with no effort list at all supports no thinking level, so a requested level is
|
|
767
|
-
// dropped just the same as one the list omits.
|
|
768
|
-
if (pair.thinking !== undefined &&
|
|
769
|
-
pair.thinking !== "" &&
|
|
770
|
-
!entry.efforts?.includes(pair.thinking)) {
|
|
771
|
-
warns.push(`${pair.model} has no ${pair.thinking} effort — that thinking level is dropped`);
|
|
772
|
-
}
|
|
773
|
-
const provider = opencodeProviderOf(pair.model);
|
|
774
|
-
if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
|
|
775
|
-
warns.push(`${pair.model} is a non-gateway provider opencode does not list a credential for`);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
warns.push(...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")));
|
|
779
|
-
const db = statOrNull(opencodeDbPath(root));
|
|
780
|
-
const spillDir = opencodeSpillDir(root);
|
|
781
|
-
const spill = spillDir !== "" && statOrNull(spillDir)?.isDirectory() === true ? opencodeDirBytes(spillDir) : null;
|
|
782
|
-
const sizeNotes = `db ${opencodeBytes(db?.size ?? 0)}${spill !== null ? `, spill ${opencodeBytes(spill)}` : ", no spill dir"}`;
|
|
783
|
-
const found = [...fails, ...warns];
|
|
784
|
-
const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
|
|
785
|
-
if (fails.length > 0)
|
|
786
|
-
return failOutcome(base);
|
|
787
|
-
return (warns.length > 0 ? warnOutcome : okOutcome)(base);
|
|
458
|
+
if (!config.roles.feedback?.enabled)
|
|
459
|
+
return null;
|
|
460
|
+
const version = await probe(["--version"]);
|
|
461
|
+
if (version?.code !== 0)
|
|
462
|
+
return failOutcome("gh is missing or unusable — install GitHub CLI (https://cli.github.com) so the feedback role can file issues as your login");
|
|
463
|
+
const auth = await probe(["auth", "status"]);
|
|
464
|
+
if (auth?.code !== 0)
|
|
465
|
+
return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
|
|
466
|
+
return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
|
|
788
467
|
}
|