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.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
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";
|
|
3
11
|
import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
|
|
4
|
-
import { configForRole,
|
|
5
|
-
import {
|
|
12
|
+
import { configForRole, resolvedAgentConfig } from "../config/config-views.js";
|
|
13
|
+
import { fallbackPair, resolveTierFallbacks, seamsInUse, tierChainSteps } from "../config/config-fallbacks.js";
|
|
14
|
+
import { cacheReadUnpriced, fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
|
|
6
15
|
import { MODEL_TIERS } from "../config/config-schema.js";
|
|
7
|
-
import { isAgentId
|
|
8
|
-
import {
|
|
16
|
+
import { isAgentId } from "../config/model-selector.js";
|
|
17
|
+
import { imageReroute, seamImageInput } from "../agent/image-input.js";
|
|
18
|
+
import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
|
|
19
|
+
import { agentBackend } from "../agent/agent-backend.js";
|
|
20
|
+
import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
|
|
21
|
+
import { claudeBackend, claudeConfigDir } from "../agent/claude/claude-backend.js";
|
|
9
22
|
import { claudeAuthUsesSubscription, parseClaudeAuthStatus, readClaudeAccount } from "../agent/claude/claude-account.js";
|
|
10
23
|
import { claudeEnv } from "../agent/claude/claude-env.js";
|
|
11
24
|
import { parseJsonObject } from "../files/json-object.js";
|
|
12
25
|
import { envPath } from "../files/files.js";
|
|
26
|
+
import { modelPairName, pairDailyCost } from "../budget/budget.js";
|
|
27
|
+
import { usd, usdCap } from "../text/format.js";
|
|
28
|
+
import { loadLoopStates } from "./doctor-holds.js";
|
|
13
29
|
import { resolveAgentBin } from "../pi/pi-bin.js";
|
|
14
30
|
import { agree } from "../text/phrases.js";
|
|
15
|
-
import { formatTime } from "../text/datetime.js";
|
|
31
|
+
import { formatDate, formatTime } from "../text/datetime.js";
|
|
16
32
|
import { belowNodeFloor } from "../version.js";
|
|
17
33
|
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
18
34
|
/** The doctor's model-readiness checks, split out of doctor-checks.ts (whose environment and
|
|
@@ -27,16 +43,18 @@ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
|
27
43
|
* checkInit already fails on a broken config, so a model check only reports that it could not
|
|
28
44
|
* run. One home for the guard and its wording, shared by checkFallbackModel and
|
|
29
45
|
* checkTierModels (the same shape doctor-backlog.ts's readDocChecked uses for unreadable
|
|
30
|
-
* files).
|
|
46
|
+
* files). The config or the warn outcome, so each check's one-line guard — `const config =
|
|
47
|
+
* loadConfigForCheck(root); if ("level" in config) return config;` — is all a check needs to
|
|
48
|
+
* read its config instead of repeating a load-and-unwrap prologue. */
|
|
31
49
|
function loadConfigForCheck(root) {
|
|
32
50
|
const { config, error } = loadConfigSafe(root);
|
|
33
51
|
if (config === undefined)
|
|
34
|
-
return
|
|
35
|
-
return
|
|
52
|
+
return warnOutcome(`cannot check — ${error}`);
|
|
53
|
+
return config;
|
|
36
54
|
}
|
|
37
55
|
/** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
|
|
38
56
|
* `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
|
|
39
|
-
*
|
|
57
|
+
* roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
|
|
40
58
|
* behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
|
|
41
59
|
* after the day's budget is already spent — so doctor checks it up front, before the operator
|
|
42
60
|
* needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
|
|
@@ -45,16 +63,15 @@ function loadConfigForCheck(root) {
|
|
|
45
63
|
* verified" — unless the running orchestrator has published that its breaker demoted the pair
|
|
46
64
|
* (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
|
|
47
65
|
* which warns with the failure count and the next probe time. No fallbackModel configured is
|
|
48
|
-
* informational, not a warning — plenty of
|
|
66
|
+
* informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
|
|
49
67
|
* injectable so tests need no real pi install. */
|
|
50
68
|
export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
|
|
51
|
-
const
|
|
52
|
-
if ("
|
|
53
|
-
return
|
|
54
|
-
const { config } = loaded;
|
|
69
|
+
const config = loadConfigForCheck(root);
|
|
70
|
+
if ("level" in config)
|
|
71
|
+
return config;
|
|
55
72
|
const declared = fallbackPair(config);
|
|
56
73
|
if (!declared)
|
|
57
|
-
return okOutcome("none configured —
|
|
74
|
+
return okOutcome("none configured — roles pause at the cap");
|
|
58
75
|
// A pair missing either half would fall through to pi's own (unverified) default, so it can
|
|
59
76
|
// never be free; naming that case beats rendering a bare "?/model". The free branch names
|
|
60
77
|
// the price-resolved pair (a map with no `default` entry borrows another tier's), while the
|
|
@@ -64,124 +81,134 @@ export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted =
|
|
|
64
81
|
if (free) {
|
|
65
82
|
const name = nameOf(free);
|
|
66
83
|
if (demoted)
|
|
67
|
-
return warnOutcome(`${name} is priced at zero but not serving — the running
|
|
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))}`);
|
|
68
85
|
return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
|
|
69
86
|
}
|
|
70
|
-
return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap
|
|
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);
|
|
71
157
|
}
|
|
72
158
|
/** How long the credential probe may take before it counts as unknown — a doctor run is a
|
|
73
159
|
* pre-flight, not a place to wait out a hung backend. */
|
|
74
|
-
const AUTH_CHECK_TIMEOUT_MS = 15_000;
|
|
160
|
+
export const AUTH_CHECK_TIMEOUT_MS = 15_000;
|
|
75
161
|
/** Cap on the probe's captured stdout. The auth reply is a tiny JSON object; a binary whose
|
|
76
162
|
* output is a runaway log (or a pipe held open with no EOF) must not grow `out` without bound.
|
|
77
163
|
* Sibling probes share EXEC_MAX_BUFFER, but 1 MiB is already orders of magnitude past any real
|
|
78
164
|
* reply, so it is the tighter bound here. Over it the probe kills the child and reads as
|
|
79
165
|
* unknown. */
|
|
80
|
-
const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
81
|
-
/** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"):
|
|
82
|
-
* the
|
|
83
|
-
*
|
|
166
|
+
export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
167
|
+
/** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"): the
|
|
168
|
+
* distinct pairs of the one seamsInUse list — every enabled role, the review gate, the
|
|
169
|
+
* resolver, the director, and each tier's budget/model-failure fallback — so a claude or pi
|
|
170
|
+
* pair that exists only as a fallback is checked exactly like a primary one. Deduplicated by
|
|
84
171
|
* backend/provider/model, so a model reached through several surfaces is checked once. */
|
|
85
172
|
function seamPairs(config) {
|
|
86
173
|
const pairs = new Map();
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
return;
|
|
90
|
-
pairs.set(`${pair.agent ?? ""}\0${pair.provider ?? ""}\0${pair.model}`, pair);
|
|
91
|
-
};
|
|
92
|
-
for (const tier of MODEL_TIERS) {
|
|
93
|
-
const sel = tierModel(config, tier);
|
|
94
|
-
if (!sel)
|
|
174
|
+
for (const seam of seamsInUse(config)) {
|
|
175
|
+
if (seam.model === undefined)
|
|
95
176
|
continue;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
thinking: sel.thinking,
|
|
177
|
+
pairs.set(`${seam.agent ?? ""}\0${seam.provider ?? ""}\0${seam.model}`, {
|
|
178
|
+
agent: seam.agent,
|
|
179
|
+
provider: seam.provider,
|
|
180
|
+
model: seam.model,
|
|
181
|
+
thinking: seam.thinking,
|
|
102
182
|
});
|
|
103
183
|
}
|
|
104
|
-
for (const role of enabledRoleIds(config)) {
|
|
105
|
-
const rc = configForRole(config, role);
|
|
106
|
-
add({ agent: rc.agent, provider: rc.provider, model: rc.model, thinking: rc.thinking });
|
|
107
|
-
}
|
|
108
|
-
if (config.review.enabled) {
|
|
109
|
-
const rv = reviewConfig(config);
|
|
110
|
-
add({ agent: rv.agent, provider: rv.provider, model: rv.model, thinking: rv.thinking });
|
|
111
|
-
}
|
|
112
184
|
return [...pairs.values()];
|
|
113
185
|
}
|
|
114
186
|
/** Whether any seam runs on the claude backend — the doctor's claude checks stand down when
|
|
115
|
-
* none does (a pi-only
|
|
187
|
+
* none does (a pi-only team reads no `~/.claude` state and spawns no claude). */
|
|
116
188
|
export function claudeSeamsInUse(config) {
|
|
117
189
|
return seamPairs(config).some((pair) => pair.agent === "claude");
|
|
118
190
|
}
|
|
119
|
-
/**
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
cwd: process.cwd(),
|
|
132
|
-
// Detached so the deadline's signalTree reaches a grandchild too; stderr is ignored
|
|
133
|
-
// rather than piped, since nothing reads it and a full stderr pipe would block the
|
|
134
|
-
// child until the deadline.
|
|
135
|
-
detached: true,
|
|
136
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
137
|
-
env,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
resolve(null);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
let out = "";
|
|
145
|
-
let bytes = 0;
|
|
146
|
-
let settled = false;
|
|
147
|
-
let timer;
|
|
148
|
-
const settle = (result) => {
|
|
149
|
-
if (settled)
|
|
150
|
-
return;
|
|
151
|
-
settled = true;
|
|
152
|
-
if (timer !== undefined)
|
|
153
|
-
clearTimeout(timer);
|
|
154
|
-
// Destroy the captured stdout and unref the process handle (releaseChildHandles): a
|
|
155
|
-
// grandchild that escaped the group and holds the pipe's write end would otherwise keep
|
|
156
|
-
// the harness's event loop alive though the probe already has its answer.
|
|
157
|
-
releaseChildHandles(child);
|
|
158
|
-
resolve(result);
|
|
159
|
-
};
|
|
160
|
-
// Deadline or runaway output: kill the whole group, tear down the handles, and settle —
|
|
161
|
-
// do not wait for a `'close'` the group may never deliver.
|
|
162
|
-
const abort = () => {
|
|
163
|
-
signalTree(child, "SIGKILL");
|
|
164
|
-
settle(null);
|
|
165
|
-
};
|
|
166
|
-
timer = setTimeout(abort, timeoutMs);
|
|
167
|
-
child.stdout?.on("data", (chunk) => {
|
|
168
|
-
out += chunk.toString("utf8");
|
|
169
|
-
bytes += chunk.length;
|
|
170
|
-
if (bytes > AUTH_CHECK_MAX_OUTPUT_BYTES)
|
|
171
|
-
abort();
|
|
172
|
-
});
|
|
173
|
-
child.on("error", () => settle(null));
|
|
174
|
-
child.on("close", (code) => settle({ code, stdout: out }));
|
|
175
|
-
});
|
|
191
|
+
/** Whether any seam routes to opencode — the doctor's opencode checks stand down when none
|
|
192
|
+
* does (a pi-only team spawns no opencode and reads no opencode state). */
|
|
193
|
+
export function opencodeSeamsInUse(config) {
|
|
194
|
+
return seamPairs(config).some((pair) => pair.agent === "opencode");
|
|
195
|
+
}
|
|
196
|
+
/** Whether any seam routes to the pi backend — the doctor's pi-extension row stands down when
|
|
197
|
+
* none does (an all-claude or all-opencode team reads no `~/.pi` state). Unlike the model-based
|
|
198
|
+
* seam consult, this scans the routed seams directly (not seamPairs' model-filtered view) so a
|
|
199
|
+
* default pi team — whose seams route to pi with pi's own default model and no explicit
|
|
200
|
+
* selector — still gets the row: that is the feature's main scenario. */
|
|
201
|
+
export function piSeamsInUse(config) {
|
|
202
|
+
return seamsInUse(config).some((s) => s.agent === "pi");
|
|
176
203
|
}
|
|
177
|
-
/** The default credential probe: run the resolved agent binary's own auth check for one
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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. */
|
|
182
209
|
export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
183
210
|
const resolved = resolveAgentBin(config);
|
|
184
|
-
const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs);
|
|
211
|
+
const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
185
212
|
if (result === null)
|
|
186
213
|
return "unknown";
|
|
187
214
|
if (result.code !== 0)
|
|
@@ -203,8 +230,18 @@ export async function piProviderAuth(config, provider, pathEnv = envPath(), time
|
|
|
203
230
|
* so neither probes a different binary or environment than the other. */
|
|
204
231
|
function claudeProbe(config, args, pathEnv, timeoutMs) {
|
|
205
232
|
const resolved = claudeBackend.resolveBin(config);
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
|
208
245
|
}
|
|
209
246
|
/** Run `claude auth status` under the same scrubbed environment a real run gets (claudeEnv),
|
|
210
247
|
* or null when the probe could not run or its reply was unreadable. */
|
|
@@ -220,14 +257,13 @@ async function claudeAuthProbe(config, pathEnv, timeoutMs) {
|
|
|
220
257
|
* names the billing the reply implies — the subscription type, or an API key — so an operator
|
|
221
258
|
* telling subscription spend from API spend does not have to run the command themselves. A
|
|
222
259
|
* probe that could not run or a reply this cannot read warns rather than guesses. Stands down
|
|
223
|
-
* (ok) when no seam routes to claude, so a pi-only
|
|
260
|
+
* (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
|
|
224
261
|
export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
225
|
-
const
|
|
226
|
-
if ("
|
|
227
|
-
return
|
|
228
|
-
const { config } = loaded;
|
|
262
|
+
const config = loadConfigForCheck(root);
|
|
263
|
+
if ("level" in config)
|
|
264
|
+
return config;
|
|
229
265
|
if (!claudeSeamsInUse(config))
|
|
230
|
-
return okOutcome("no
|
|
266
|
+
return okOutcome("no model jobs run on claude");
|
|
231
267
|
const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
|
|
232
268
|
if (auth === null)
|
|
233
269
|
return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
|
|
@@ -239,25 +275,48 @@ export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUT
|
|
|
239
275
|
: "API key";
|
|
240
276
|
return okOutcome(`logged in — billing: ${billing}`);
|
|
241
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
|
+
}
|
|
242
302
|
/** The dotted version in `claude --version`'s output, or undefined when it names none. */
|
|
243
303
|
function parseClaudeCliVersion(output) {
|
|
244
304
|
return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
|
|
245
305
|
}
|
|
246
306
|
/** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
|
|
247
|
-
* configured model's minimum fails or misprices that model, and
|
|
307
|
+
* configured model's minimum fails or misprices that model, and team runs disable the
|
|
248
308
|
* auto-updater, so the version an operator has is the version ticks run. Fails below the
|
|
249
309
|
* highest floor among the claude-routed models, naming which model needs it; an ok detail
|
|
250
310
|
* prints the installed version and the floor it satisfied (or no floor when none applies).
|
|
251
311
|
* The `off`/`minimal` thinking clamp is noted here too, since this check already has the
|
|
252
312
|
* claude seams in hand. Stands down when no seam routes to claude. */
|
|
253
313
|
export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
254
|
-
const
|
|
255
|
-
if ("
|
|
256
|
-
return
|
|
257
|
-
const { config } = loaded;
|
|
314
|
+
const config = loadConfigForCheck(root);
|
|
315
|
+
if ("level" in config)
|
|
316
|
+
return config;
|
|
258
317
|
const seams = seamPairs(config).filter((pair) => pair.agent === "claude" && pair.model !== undefined);
|
|
259
318
|
if (seams.length === 0)
|
|
260
|
-
return okOutcome("no
|
|
319
|
+
return okOutcome("no model jobs run on claude");
|
|
261
320
|
// The floor each configured model needs; a model the adapter does not gate contributes none.
|
|
262
321
|
const floors = new Map();
|
|
263
322
|
for (const seam of seams) {
|
|
@@ -289,39 +348,41 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
|
|
|
289
348
|
return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
|
|
290
349
|
return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
|
|
291
350
|
}
|
|
292
|
-
/** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* every
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* `
|
|
308
|
-
*
|
|
309
|
-
* same job (`model.small` / `model.strong`). `auth` is
|
|
310
|
-
* injectable so tests need no pi on PATH; `env` so the PI_* branches stay hermetic. */
|
|
351
|
+
/** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
|
|
352
|
+
* put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
|
|
353
|
+
* agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
|
|
354
|
+
* every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
|
|
355
|
+
* collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
|
|
356
|
+
* default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
|
|
357
|
+
* resolve through configForRole), and the reviewer's while review is on. A pair with no model is
|
|
358
|
+
* pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
|
|
359
|
+
* prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
|
|
360
|
+
* (credentials are fixable without a config edit), a probe that cannot run warns rather than
|
|
361
|
+
* guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
|
|
362
|
+
* undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
|
|
363
|
+
* set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
|
|
364
|
+
* 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 agentBaseEnv) — pointing at the tier
|
|
366
|
+
* keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests need no
|
|
367
|
+
* pi on PATH; `env` so the PI_* branches stay hermetic. */
|
|
311
368
|
export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env) {
|
|
312
|
-
const
|
|
313
|
-
if ("
|
|
314
|
-
return
|
|
315
|
-
const { config } = loaded;
|
|
369
|
+
const config = loadConfigForCheck(root);
|
|
370
|
+
if ("level" in config)
|
|
371
|
+
return config;
|
|
316
372
|
const pairs = new Map();
|
|
317
|
-
|
|
373
|
+
// Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
|
|
374
|
+
// each backend correctly; opencode is not claude.
|
|
375
|
+
const routedModels = new Map();
|
|
318
376
|
const add = (agent, provider, model) => {
|
|
319
377
|
if (model === undefined)
|
|
320
378
|
return;
|
|
321
379
|
// A non-pi backend's models are not in pi's definitions, so they are not swept for
|
|
322
|
-
// resolution or auth here — the
|
|
380
|
+
// resolution or auth here — the backend's own checks cover them instead.
|
|
323
381
|
if (agent !== undefined && agent !== "pi") {
|
|
324
|
-
|
|
382
|
+
let models = routedModels.get(agent);
|
|
383
|
+
if (models === undefined)
|
|
384
|
+
routedModels.set(agent, (models = new Set()));
|
|
385
|
+
models.add(model);
|
|
325
386
|
return;
|
|
326
387
|
}
|
|
327
388
|
pairs.set(`${provider ?? ""}/${model}`, { provider, model });
|
|
@@ -332,8 +393,8 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
332
393
|
const ompNote = ompVars.length === 0
|
|
333
394
|
? ""
|
|
334
395
|
: ` — ${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`;
|
|
335
|
-
if (pairs.size === 0 &&
|
|
336
|
-
return (ompVars.length === 0 ? okOutcome : warnOutcome)(`no models declared — every
|
|
396
|
+
if (pairs.size === 0 && routedModels.size === 0)
|
|
397
|
+
return (ompVars.length === 0 ? okOutcome : warnOutcome)(`no models declared — every model job uses pi's own default${ompNote}`);
|
|
337
398
|
const providers = readPiProviders(modelsPath);
|
|
338
399
|
const fails = [];
|
|
339
400
|
const warns = [];
|
|
@@ -342,7 +403,7 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
342
403
|
if (providers) {
|
|
343
404
|
const reserved = [...providers.keys()].filter(isAgentId);
|
|
344
405
|
if (reserved.length > 0)
|
|
345
|
-
warns.push(`pi's definitions (${modelsPath}) declare provider ${reserved.map((p) => `"${p}"`).join(", ")} — the name is reserved for an agent backend, so
|
|
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`);
|
|
346
407
|
}
|
|
347
408
|
if (pairs.size > 0 && !providers)
|
|
348
409
|
return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}${ompNote}`);
|
|
@@ -353,6 +414,14 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
353
414
|
warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
|
|
354
415
|
continue;
|
|
355
416
|
}
|
|
417
|
+
// A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
|
|
418
|
+
// to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
|
|
419
|
+
// third-party-harness change a Claude plan's limits cover only first-party tools, so it is
|
|
420
|
+
// billed per token even under a Claude subscription login — the bare `claude-…` form on the
|
|
421
|
+
// claude CLI uses the plan's included usage instead.
|
|
422
|
+
if (model?.startsWith("claude-")) {
|
|
423
|
+
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
|
+
}
|
|
356
425
|
const defs = providers?.get(provider);
|
|
357
426
|
if (!defs) {
|
|
358
427
|
fails.push(`provider ${provider} is not in pi's definitions (${modelsPath})`);
|
|
@@ -384,13 +453,336 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
384
453
|
}
|
|
385
454
|
const found = [...fails, ...warns].join("; ");
|
|
386
455
|
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` : ""}`;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
456
|
+
// One routing note per non-pi backend in use, so an opencode-routed model is never labeled
|
|
457
|
+
// 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.
|
|
459
|
+
const routedNotes = [];
|
|
460
|
+
for (const [agent, models] of routedModels) {
|
|
461
|
+
const n = models.size;
|
|
462
|
+
routedNotes.push(agent === "opencode"
|
|
463
|
+
? `${n} opencode-routed ${agree(n, "model", "models")} — \`opencode/…\` ids run on the OpenCode CLI, and pi's built-in \`opencode\` provider is not reachable from tumwater`
|
|
464
|
+
: `${n} claude-routed ${agree(n, "model", "models")} — resolved by the claude backend, not pi's definitions`);
|
|
465
|
+
}
|
|
466
|
+
const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
|
|
390
467
|
const base = fails.length === 0 && warns.length === 0
|
|
391
|
-
? [pairs.size > 0 ? resolvedPi : "",
|
|
468
|
+
? [pairs.size > 0 ? resolvedPi : "", routedText].filter(Boolean).join("; ")
|
|
392
469
|
: found;
|
|
393
470
|
if (fails.length > 0)
|
|
394
471
|
return failOutcome(`${base}${ompNote}`);
|
|
395
472
|
return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
|
|
396
473
|
}
|
|
474
|
+
/** 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 loop can
|
|
476
|
+
* reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
|
|
477
|
+
* 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) every claimed
|
|
479
|
+
* model is re-probed; with "never" (the TUI Health tab and the GUI doctor endpoint) nothing is
|
|
480
|
+
* spawned and only cached verdicts are read, so a refresh never spends a model call. claude
|
|
481
|
+
* seams are never probed — every Claude model reads images, and a probe would spend subscription
|
|
482
|
+
* window to confirm it.
|
|
483
|
+
*
|
|
484
|
+
* Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
|
|
485
|
+
* reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
|
|
486
|
+
* text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
|
|
487
|
+
* failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
|
|
488
|
+
* reroute sweep never sends a text-only loop to a model that lost its probe. `providers`,
|
|
489
|
+
* `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
|
|
490
|
+
export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
|
|
491
|
+
const config = loadConfigForCheck(root);
|
|
492
|
+
if ("level" in config)
|
|
493
|
+
return config;
|
|
494
|
+
// `tier models` already fails on a missing/unknown pair, so this check names only what it can
|
|
495
|
+
// answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
|
|
496
|
+
// this check when a seam actually needs it — an all-claude team reads images without it.
|
|
497
|
+
const accountNow = account === undefined ? readClaudeAccount(root) : account;
|
|
498
|
+
const pairs = seamPairs(config);
|
|
499
|
+
const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
|
|
500
|
+
if (providers === null && needsDefinitions)
|
|
501
|
+
return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}`);
|
|
502
|
+
const label = modelLabel;
|
|
503
|
+
// The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
|
|
504
|
+
// model whose earlier probe failed (it must still be named and re-probed by --reprobe).
|
|
505
|
+
const claimed = new Map();
|
|
506
|
+
for (const pair of pairs) {
|
|
507
|
+
if (pair.model === undefined)
|
|
508
|
+
continue;
|
|
509
|
+
if (seamImageInput(pair, providers) === true)
|
|
510
|
+
claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
|
|
511
|
+
agent: pair.agent,
|
|
512
|
+
provider: pair.provider,
|
|
513
|
+
model: pair.model,
|
|
514
|
+
thinking: pair.thinking,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
// The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
|
|
518
|
+
// 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.provider, p.model, accountNow));
|
|
520
|
+
for (const tier of MODEL_TIERS) {
|
|
521
|
+
const pair = resolved[tier].pair;
|
|
522
|
+
if (pair?.model === undefined)
|
|
523
|
+
continue;
|
|
524
|
+
// Fallback pairs are always pi (FallbackModelConfig has no agent), and the readers
|
|
525
|
+
// (seamImageInput, imageReroute) resolve a pi seam to the "pi" agent, so the cache key
|
|
526
|
+
// must carry "pi" — keying it `undefined` would write a verdict the readers never read.
|
|
527
|
+
const agent = "pi";
|
|
528
|
+
if (seamImageInput(pair, providers) === true)
|
|
529
|
+
claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
|
|
530
|
+
agent,
|
|
531
|
+
provider: pair.provider,
|
|
532
|
+
model: pair.model,
|
|
533
|
+
thinking: pair.thinking,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const probes = readImageProbeCache(root);
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
const env = childEnv({ PATH: pathEnv });
|
|
539
|
+
const readsItems = [];
|
|
540
|
+
const failed = [];
|
|
541
|
+
const couldNot = [];
|
|
542
|
+
let probedAny = false;
|
|
543
|
+
// Settle one claimed seam: probe when asked and uncached, read a fresh cached verdict
|
|
544
|
+
// otherwise, or report "not verified yet". Only pass/fail are cached; could-not-verify is
|
|
545
|
+
// reported but never cached.
|
|
546
|
+
for (const seam of claimed.values()) {
|
|
547
|
+
const agent = seam.agent;
|
|
548
|
+
// A backend that registers an imageInput hook trusts the hook (claude today, which reads
|
|
549
|
+
// every model); a backend without one is probed through its model catalog.
|
|
550
|
+
if (agent !== undefined && agentBackend(agent)?.imageInput !== undefined) {
|
|
551
|
+
readsItems.push(label(agent, seam.provider, seam.model));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const key = imageProbeKey(agent, seam.provider, seam.model);
|
|
555
|
+
const cached = probes[key];
|
|
556
|
+
const valid = imageProbeEntryFresh(cached, now);
|
|
557
|
+
const wantProbe = probeImages !== "never" && (probeImages === "all" || !valid);
|
|
558
|
+
if (wantProbe) {
|
|
559
|
+
probedAny = true;
|
|
560
|
+
const result = await probe(config, { provider: seam.provider, model: seam.model, thinking: seam.thinking }, env);
|
|
561
|
+
if (result.verdict === "pass") {
|
|
562
|
+
probes[key] = { verdict: "pass", detail: result.detail, at: now, claim: true };
|
|
563
|
+
readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(now))})`);
|
|
564
|
+
}
|
|
565
|
+
else if (result.verdict === "fail") {
|
|
566
|
+
probes[key] = { verdict: "fail", detail: result.detail, at: now, claim: true };
|
|
567
|
+
failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${result.detail}`);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
couldNot.push(`${label(agent, seam.provider, seam.model)} could not be verified — ${result.detail}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else if (cached && imageProbeEntryFresh(cached, now)) {
|
|
574
|
+
if (cached.verdict === "pass") {
|
|
575
|
+
readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(cached.at))})`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${cached.detail}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (probedAny)
|
|
586
|
+
writeImageProbeCache(root, probes);
|
|
587
|
+
// Text-only enabled loops: which can reroute to a tier that reads images (honoring failed
|
|
588
|
+
// probes), and which cannot.
|
|
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;
|
|
691
|
+
}
|
|
692
|
+
for (const key of ["plugin", "agent", "instructions"]) {
|
|
693
|
+
if (jsonKeyPresent(text, key))
|
|
694
|
+
parts.push(`a ${key} entry`);
|
|
695
|
+
}
|
|
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
|
+
}
|
|
703
|
+
const agentsMd = path.join(dir, "AGENTS.md");
|
|
704
|
+
if (statOrNull(agentsMd)?.isFile() === true)
|
|
705
|
+
warns.push(`${agentsMd} still loads into team runs`);
|
|
706
|
+
return warns;
|
|
707
|
+
}
|
|
708
|
+
/** Total bytes of every regular file under `dir` (a shallow recursive walk), 0 for a missing
|
|
709
|
+
* or unreadable dir. */
|
|
710
|
+
function opencodeDirBytes(dir) {
|
|
711
|
+
try {
|
|
712
|
+
let total = 0;
|
|
713
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
714
|
+
const p = path.join(dir, entry.name);
|
|
715
|
+
if (entry.isDirectory())
|
|
716
|
+
total += opencodeDirBytes(p);
|
|
717
|
+
else if (entry.isFile())
|
|
718
|
+
total += fs.statSync(p).size;
|
|
719
|
+
}
|
|
720
|
+
return total;
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** A byte count for display: MB, KB, or bytes. */
|
|
727
|
+
function opencodeBytes(n) {
|
|
728
|
+
if (n >= 1024 * 1024)
|
|
729
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
730
|
+
if (n >= 1024)
|
|
731
|
+
return `${Math.round(n / 1024)} KB`;
|
|
732
|
+
return `${n} B`;
|
|
733
|
+
}
|
|
734
|
+
/** OpenCode model, credential and leak readiness (plans/opencode-backend.md §7, part 7/7):
|
|
735
|
+
* every model routed to opencode must be in the catalog cache — an unknown model fails, a
|
|
736
|
+
* cached one reads "free" or its per-M prices and context window, and a thinking level with no
|
|
737
|
+
* catalog effort is noted as dropped; a non-gateway provider (`opencode/<provider>/…`) must
|
|
738
|
+
* have a credential `opencode auth list` lists, else warns; and the operator's global opencode
|
|
739
|
+
* config and AGENTS.md must not leak an MCP server or a plugin/agent/instructions entry into
|
|
740
|
+
* team runs, else warns naming the file and key. Reports the team's opencode database and spill
|
|
741
|
+
* sizes as information. Stands down (ok) when no seam routes to opencode, so a pi-only team
|
|
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) {
|
|
746
|
+
const config = loadConfigForCheck(root);
|
|
747
|
+
if ("level" in config)
|
|
748
|
+
return config;
|
|
749
|
+
if (!opencodeSeamsInUse(config))
|
|
750
|
+
return okOutcome("no model jobs run on opencode");
|
|
751
|
+
const models = seamPairs(config).filter((p) => p.agent === "opencode" && p.model !== undefined);
|
|
752
|
+
const cache = readOpencodeCache(root);
|
|
753
|
+
const fails = [];
|
|
754
|
+
const warns = [];
|
|
755
|
+
const notes = [];
|
|
756
|
+
for (const pair of models) {
|
|
757
|
+
const key = opencodeModelKey(pair.model);
|
|
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);
|
|
788
|
+
}
|