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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The opencode adapter for the agent seam (plans/opencode-backend.md "The invocation",
|
|
3
|
+
* "Sessions", §6). It shapes one run's argv/stdin/env, translates opencode's `run --format
|
|
4
|
+
* json` into the canonical stream, runs the authoritative session export after the process
|
|
5
|
+
* exits, and classifies the exit's stderr — everything backend-specific. src/agent/run-agent.ts
|
|
6
|
+
* owns the spawn and records this run in the session ledger; opencode mints its own session id
|
|
7
|
+
* (a `ses_…` id, or the resumed record's id), which `invocation` returns so the ledger can
|
|
8
|
+
* carry it.
|
|
9
|
+
*
|
|
10
|
+
* Sessions resume by id from the cwd the session started in, out of the run's shared database
|
|
11
|
+
* (opencodeDbPath). A continue takes the ledger's newest record of this run's kind and resumes
|
|
12
|
+
* only when it is opencode's, has this cwd, and `opencode session export <id> --standalone`
|
|
13
|
+
* actually finds it — `--session` silently creates a missing session, so the existence check is
|
|
14
|
+
* the only thing that stops a follow-up running against an empty session. Anything else mints a
|
|
15
|
+
* fresh id (design §4).
|
|
16
|
+
*
|
|
17
|
+
* Registration lives in src/agent/run-agent.ts beside the pi and claude adapters; a test selects
|
|
18
|
+
* opencode by setting `config.agent = "opencode"` on a run. A run carrying a notebook gets the
|
|
19
|
+
* notes path in its environment (the `tumwater-notes` command on PATH reads it), as the pi and
|
|
20
|
+
* claude adapters do. */
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { loadConfigSafe } from "../../config/config.js";
|
|
25
|
+
import { resolvedAgentConfig } from "../../config/config-views.js";
|
|
26
|
+
import { agentBaseEnv, withNotesPath } from "../../process/run-marker.js";
|
|
27
|
+
import { resolveBackendBin } from "../../pi/pi-bin.js";
|
|
28
|
+
import { opencodeDbPath, teamRootOf } from "../../paths.js";
|
|
29
|
+
import { resumableSession } from "../session-ledger.js";
|
|
30
|
+
import { opencodeArgs } from "./opencode-args.js";
|
|
31
|
+
import { opencodeConfigContent, operatorMcpNames } from "./opencode-config.js";
|
|
32
|
+
import { outputLimits } from "../../pi-extension/bounded-output.js";
|
|
33
|
+
import { seamContextWindow } from "../../pi/pi-models.js";
|
|
34
|
+
import { opencodeModelKey, readOpencodeCache } from "./opencode-catalog.js";
|
|
35
|
+
import { opencodeEnv } from "./opencode-env.js";
|
|
36
|
+
import { mintOpencodeSessionId } from "./opencode-session-id.js";
|
|
37
|
+
import { opencodeExportLines, opencodeTranslator } from "./opencode-stream.js";
|
|
38
|
+
/** How long a post-run `opencode session export` may take before it gives up (design §6): the
|
|
39
|
+
* export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. Used for
|
|
40
|
+
* both the resume-existence check and the reconciling export. */
|
|
41
|
+
const EXPORT_TIMEOUT_MS = 10_000;
|
|
42
|
+
/** The minimum OpenCode CLI version this harness accepts (plans/opencode-backend.md "Which
|
|
43
|
+
* OpenCode", part 7/7): 2.0.32, the version H1 verified live. V1 is maintenance-only and has
|
|
44
|
+
* the git-repo hang (issue 55037), so doctor fails an installed CLI below this and names
|
|
45
|
+
* `brew install opencode`. */
|
|
46
|
+
export const OPENCODE_MIN_VERSION = "2.0.32";
|
|
47
|
+
/** The repository's root (initial) commit hash, or undefined when git cannot answer (not a
|
|
48
|
+
* repo, or git missing). `git rev-list --max-parents=0 HEAD` is the same read OpenCode makes
|
|
49
|
+
* to key its shell spill by the tree's root commit. Read-only. */
|
|
50
|
+
function rootCommitOf(root) {
|
|
51
|
+
try {
|
|
52
|
+
const res = spawnSync("git", ["-C", root, "rev-list", "--max-parents=0", "HEAD"], {
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
timeout: 5_000,
|
|
55
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
});
|
|
57
|
+
const line = res.stdout?.trim();
|
|
58
|
+
return line !== undefined && /^[0-9a-f]{40}$/i.test(line) ? line : undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The tree's shell spill dir OpenCode writes (finding 17, part 7/7):
|
|
65
|
+
* `~/.local/share/opencode/shell/<root commit>/`, keyed by the repository's root commit hash and
|
|
66
|
+
* OUTSIDE `.tumwater/`, so only a `session delete` or an explicit spill prune reaches it. Empty
|
|
67
|
+
* string when the root commit cannot be read — callers then skip spill work (doctor reports no
|
|
68
|
+
* spill dir; retention prunes no spill files). */
|
|
69
|
+
export function opencodeSpillDir(root) {
|
|
70
|
+
const commit = rootCommitOf(root);
|
|
71
|
+
if (commit === undefined)
|
|
72
|
+
return "";
|
|
73
|
+
return path.join(os.homedir(), ".local", "share", "opencode", "shell", commit);
|
|
74
|
+
}
|
|
75
|
+
/** The catalog cache key for a run's resolved model id (part 6d/7): a bare gateway id gets the
|
|
76
|
+
* `opencode/` provider prefix, a provider-qualified id passes as is — the same rule
|
|
77
|
+
* opencodeArgs uses to build the `--model` arg and opencode-catalog.ts uses to key the cache,
|
|
78
|
+
* so an invocation's lookup always matches the key a run is priced and billed by. */
|
|
79
|
+
function catalogModelKey(model) {
|
|
80
|
+
return opencodeModelKey(model);
|
|
81
|
+
}
|
|
82
|
+
/** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
|
|
83
|
+
* 10 s) and return whether it reported success — the existence check that decides a resume.
|
|
84
|
+
* Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
|
|
85
|
+
* direction: `--session` would otherwise silently create a missing session. */
|
|
86
|
+
function opencodeSessionExists(id, env, bin, cwd) {
|
|
87
|
+
try {
|
|
88
|
+
const res = spawnSync(bin, ["session", "export", id, "--standalone"], {
|
|
89
|
+
env,
|
|
90
|
+
cwd,
|
|
91
|
+
timeout: EXPORT_TIMEOUT_MS,
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
});
|
|
94
|
+
return res.status === 0;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Run the reconciling export after a successful run and return its two canonical lines
|
|
101
|
+
* (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
|
|
102
|
+
* timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
|
|
103
|
+
* (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
|
|
104
|
+
function opencodeExport(result) {
|
|
105
|
+
let res;
|
|
106
|
+
try {
|
|
107
|
+
res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
|
|
108
|
+
env: result.env,
|
|
109
|
+
cwd: result.cwd,
|
|
110
|
+
timeout: EXPORT_TIMEOUT_MS,
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
if (res.status !== 0 || !res.stdout)
|
|
118
|
+
return [];
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(res.stdout);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return opencodeExportLines(parsed);
|
|
127
|
+
}
|
|
128
|
+
/** The session id this run uses: for a continue, the ledger's newest record of this kind when it
|
|
129
|
+
* is opencode's, has this cwd, and the export finds the session; otherwise a freshly minted id.
|
|
130
|
+
* Returns the id; the runner records it in the ledger. */
|
|
131
|
+
function resolveOpencodeSession(run, env) {
|
|
132
|
+
if (run.continueSession) {
|
|
133
|
+
const record = resumableSession(run.sessionDir, {
|
|
134
|
+
kind: run.kind,
|
|
135
|
+
agent: "opencode",
|
|
136
|
+
cwd: run.cwd,
|
|
137
|
+
});
|
|
138
|
+
if (record !== null && opencodeSessionExists(record.id, env, run.agentBin, run.cwd)) {
|
|
139
|
+
return { sessionId: record.id };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { sessionId: mintOpencodeSessionId() };
|
|
143
|
+
}
|
|
144
|
+
/** Classify opencode's stderr: on a nonzero exit, a free-tier refusal is a provider failure
|
|
145
|
+
* (moves the job to its fallback) and an unknown model or variant is a permanent config error.
|
|
146
|
+
* A run the harness killed has its own cause and is never classified here. The structured
|
|
147
|
+
* signals (the stream's `error` lines) are classified by the translator; this is the stderr
|
|
148
|
+
* half for a CLI that died before emitting one. */
|
|
149
|
+
function classifyOpencodeExit(info) {
|
|
150
|
+
if (info.killed)
|
|
151
|
+
return {};
|
|
152
|
+
if (info.code !== 0 && /FreeTierError/i.test(info.stderr)) {
|
|
153
|
+
return { transientBackend: true, backendKind: "server" };
|
|
154
|
+
}
|
|
155
|
+
if (info.code !== 0 && /Model unavailable|Variant unavailable/i.test(info.stderr)) {
|
|
156
|
+
return { configError: true };
|
|
157
|
+
}
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
/** The agent-backend seam's opencode adapter. */
|
|
161
|
+
export const opencodeBackend = {
|
|
162
|
+
id: "opencode",
|
|
163
|
+
label: "OpenCode",
|
|
164
|
+
defaultBin: "opencode",
|
|
165
|
+
binEnv: "TUMWATER_OPENCODE_BIN",
|
|
166
|
+
resolveBin(config) {
|
|
167
|
+
return resolveBackendBin(config, this);
|
|
168
|
+
},
|
|
169
|
+
invocation(run) {
|
|
170
|
+
// The shared database and injected config are per-root, built the same way for every run of
|
|
171
|
+
// this team so a resume's export check reads the same OPENCODE_DB the original run wrote.
|
|
172
|
+
const root = teamRootOf(run.sessionDir);
|
|
173
|
+
const dbPath = opencodeDbPath(root);
|
|
174
|
+
// Window-scaled tool-output caps (part 6f/7): OpenCode's `tool_output` default is about
|
|
175
|
+
// three times pi's tuned budgets, so every turn would re-send far more tool output than a
|
|
176
|
+
// pi run — critical on the 200K free model's window. Cap it at pi's `outputLimits(window)
|
|
177
|
+
// .bash` (16,000 at 128k and above, scaled down below), with the ~40 chars/line line cap
|
|
178
|
+
// pi's "12k chars ≈ 300 lines" read budget implies. An unknown window (no catalog entry)
|
|
179
|
+
// keeps the 128k values. The window is part 6e/7's `seamContextWindow` for this run's
|
|
180
|
+
// model.
|
|
181
|
+
const maxBytes = outputLimits(seamContextWindow({ agent: "opencode", model: run.config.model }, root)).bash;
|
|
182
|
+
const configContent = opencodeConfigContent({
|
|
183
|
+
cwd: run.cwd,
|
|
184
|
+
operatorMcpNames: operatorMcpNames(process.env, os.homedir()),
|
|
185
|
+
toolOutput: { maxLines: Math.round(maxBytes / 40), maxBytes },
|
|
186
|
+
});
|
|
187
|
+
// The notebook path rides beside the run marker, as the pi and claude adapters' envs do
|
|
188
|
+
// (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes` command
|
|
189
|
+
// on PATH, so OpenCode configures no tumwater MCP server for notes.
|
|
190
|
+
const base = withNotesPath(agentBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
|
|
191
|
+
const env = opencodeEnv(base, { dbPath, configContent });
|
|
192
|
+
const { sessionId } = resolveOpencodeSession(run, env);
|
|
193
|
+
// The thinking variant (part 6d/7): ride the catalog's effort levels for the resolved
|
|
194
|
+
// model as a `#level` suffix, and drop a level the catalog does not list — an unlisted
|
|
195
|
+
// variant would otherwise be a permanent config error from OpenCode (finding 9).
|
|
196
|
+
const model = run.config.model ?? "";
|
|
197
|
+
const thinking = run.config.thinking;
|
|
198
|
+
const entry = readOpencodeCache(root).models[catalogModelKey(model)];
|
|
199
|
+
const variant = thinking !== undefined && thinking !== "" && entry?.efforts?.includes(thinking) ? thinking : undefined;
|
|
200
|
+
const { args, stdin } = opencodeArgs({
|
|
201
|
+
model,
|
|
202
|
+
...(variant !== undefined ? { variant } : {}),
|
|
203
|
+
sessionId,
|
|
204
|
+
title: run.sessionName,
|
|
205
|
+
args: resolvedAgentConfig(run.config, "opencode").args,
|
|
206
|
+
prompt: run.prompt,
|
|
207
|
+
});
|
|
208
|
+
return { args, stdin, env, sessionId };
|
|
209
|
+
},
|
|
210
|
+
translator() {
|
|
211
|
+
return opencodeTranslator();
|
|
212
|
+
},
|
|
213
|
+
imageInput(model) {
|
|
214
|
+
// Answer from the catalog cache (part 6d/7): a model reads images when its catalog entry
|
|
215
|
+
// says so; a model missing from the cache answers false — the safe direction, so a blind
|
|
216
|
+
// image read is never silently assumed readable. The cache is per-root, found by walking up
|
|
217
|
+
// from the process's working directory to its `.tumwater`, the same root the runs' shared
|
|
218
|
+
// database and cache live under.
|
|
219
|
+
if (model === undefined)
|
|
220
|
+
return false;
|
|
221
|
+
return readOpencodeCache(teamRootOf(process.cwd())).models[catalogModelKey(model)]?.images === true;
|
|
222
|
+
},
|
|
223
|
+
resumable(dir, opts) {
|
|
224
|
+
const record = resumableSession(dir, { kind: opts.kind, agent: "opencode", cwd: opts.cwd });
|
|
225
|
+
if (record === null)
|
|
226
|
+
return false;
|
|
227
|
+
const root = teamRootOf(dir);
|
|
228
|
+
const dbPath = opencodeDbPath(root);
|
|
229
|
+
const env = { ...process.env, OPENCODE_DB: dbPath };
|
|
230
|
+
// Resolve the binary exactly as the run's invocation does — same env → backends.opencode.bin
|
|
231
|
+
// → default precedence — so a configured custom binary's resume guard agrees with the
|
|
232
|
+
// `run.agentBin` the invocation runs (they must not disagree, or a follow-up is silently
|
|
233
|
+
// skipped). The team's config is loaded from the root `dir` lives under.
|
|
234
|
+
const { config } = loadConfigSafe(root);
|
|
235
|
+
const bin = config !== undefined ? opencodeBackend.resolveBin(config).bin : "opencode";
|
|
236
|
+
return opencodeSessionExists(record.id, env, bin, opts.cwd ?? path.dirname(dir));
|
|
237
|
+
},
|
|
238
|
+
afterRun(run, invocation, translator) {
|
|
239
|
+
if (invocation.sessionId === undefined)
|
|
240
|
+
return [];
|
|
241
|
+
const lines = opencodeExport({
|
|
242
|
+
id: invocation.sessionId,
|
|
243
|
+
env: invocation.env,
|
|
244
|
+
bin: run.agentBin,
|
|
245
|
+
cwd: run.cwd,
|
|
246
|
+
});
|
|
247
|
+
if (lines.length === 0) {
|
|
248
|
+
// The export is authoritative, so its loss is worth one warning: the run keeps its
|
|
249
|
+
// streamed numbers and the caller surfaces `opencode_usage_partial` in the event feed.
|
|
250
|
+
translator.noteOpencodeUsagePartial();
|
|
251
|
+
}
|
|
252
|
+
return lines;
|
|
253
|
+
},
|
|
254
|
+
classifyExit: classifyOpencodeExit,
|
|
255
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The data half of OpenCode free-ness (plans/opencode-backend.md §7; part 6a/7): a bounded
|
|
3
|
+
* fetch of models.dev's `api.json` — the catalog OpenCode itself caches (finding 10) — a parse
|
|
4
|
+
* that keeps only the providers the config routes through OpenCode, a cache at
|
|
5
|
+
* `.tumwater/state/opencode-models.json` that survives a failed fetch, and
|
|
6
|
+
* `opencodeModelFree` as the one predicate part 6b/7's seam reads. No seam is wired here: that
|
|
7
|
+
* is part 6b/7 (the budget gate), part 6c/7 (a billedAt demotion on an observed cost) and
|
|
8
|
+
* part 7/7 (a startup-cache seed and doctor), so this module's only callers today are its own
|
|
9
|
+
* tests. */
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
|
|
12
|
+
import { opencodeModelsPath } from "../../paths.js";
|
|
13
|
+
import { logEventBestEffort } from "../../events/events.js";
|
|
14
|
+
import { opencodeQualifiedModel } from "./opencode-args.js";
|
|
15
|
+
/** models.dev's catalog, the data OpenCode caches in its database `kv` table (finding 10). */
|
|
16
|
+
const CATALOG_URL = "https://models.dev/api.json";
|
|
17
|
+
/** The fetch bound (finding 10; the plan's 30 s): OpenCode prices from the same endpoint at
|
|
18
|
+
* most once a day, so a slow or wedged network must not hold the team up. */
|
|
19
|
+
const CATALOG_FETCH_TIMEOUT_MS = 30_000;
|
|
20
|
+
/** How long a fetched cache is trusted before it is refreshed. */
|
|
21
|
+
const CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const defaultFetch = async (url, timeoutMs) => {
|
|
23
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
throw new Error(`models.dev catalog ${res.status} ${res.statusText}`);
|
|
26
|
+
return res.json();
|
|
27
|
+
};
|
|
28
|
+
/** GET models.dev's catalog, bounded at 30 s. Returns the parsed JSON body, or the injected
|
|
29
|
+
* (or default) fetch throws on a failure. So a spare `opencode` binary is never needed just to
|
|
30
|
+
* cable this up; the version check that forces a refresh lives in refreshOpencodeCatalog. */
|
|
31
|
+
export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
|
|
32
|
+
return fetchFn(CATALOG_URL, CATALOG_FETCH_TIMEOUT_MS);
|
|
33
|
+
}
|
|
34
|
+
/** Parse models.dev's `api.json` into the models the config routes through OpenCode: always the
|
|
35
|
+
* `opencode` provider, plus every provider named in `providers` (the config's
|
|
36
|
+
* `opencode/<provider>/…` routes — `opencode-go` and so on). The live `GET /api.json` returns
|
|
37
|
+
* the provider map at the top level; OpenCode's cached copy wraps that same map under a `body`
|
|
38
|
+
* key (the 2.0.32 capture in test/fixtures), so both shapes are accepted. Each kept model is
|
|
39
|
+
* keyed `<provider>/<model>`. A model never present yields no entry, so an unknown model is
|
|
40
|
+
* automatically "not free" to opencodeModelFree. */
|
|
41
|
+
export function parseOpencodeCatalog(body, providers) {
|
|
42
|
+
const out = {};
|
|
43
|
+
const root = body;
|
|
44
|
+
// models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
|
|
45
|
+
const wrapped = root?.body;
|
|
46
|
+
const catalog = typeof wrapped === "object" && wrapped !== null && !Array.isArray(wrapped)
|
|
47
|
+
? wrapped
|
|
48
|
+
: (body ?? {});
|
|
49
|
+
for (const [provider, providerRaw] of Object.entries(catalog)) {
|
|
50
|
+
if (provider !== "opencode" && !providers.includes(provider))
|
|
51
|
+
continue;
|
|
52
|
+
const providerInfo = providerRaw;
|
|
53
|
+
if (providerInfo?.models === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
for (const [id, raw] of Object.entries(providerInfo.models)) {
|
|
56
|
+
const m = raw;
|
|
57
|
+
// Only a present cost block marks a model free: a missing cost block is unknown price data,
|
|
58
|
+
// and treating it as $0 is the unsafe direction for a budget gate.
|
|
59
|
+
const cost = m?.cost;
|
|
60
|
+
const input = cost?.input ?? 0;
|
|
61
|
+
const output = cost?.output ?? 0;
|
|
62
|
+
const model = { free: cost !== undefined && input === 0 && output === 0 };
|
|
63
|
+
if (cost !== undefined) {
|
|
64
|
+
model.inputPerM = input;
|
|
65
|
+
model.outputPerM = output;
|
|
66
|
+
}
|
|
67
|
+
if (m?.limit?.context !== undefined)
|
|
68
|
+
model.context = m.limit.context;
|
|
69
|
+
if (m?.modalities?.input !== undefined)
|
|
70
|
+
model.images = m.modalities.input.includes("image");
|
|
71
|
+
const efforts = m?.reasoning_options
|
|
72
|
+
?.filter((r) => r.type === "effort")
|
|
73
|
+
.flatMap((r) => r.values ?? []);
|
|
74
|
+
if (efforts !== undefined && efforts.length > 0)
|
|
75
|
+
model.efforts = efforts;
|
|
76
|
+
out[`${provider}/${id}`] = model;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** True when the catalog says the model is free and no run has marked it billed (part 6c/7).
|
|
82
|
+
* A model missing from the cache, a priced one, and a free one with `billedAt` set are all not
|
|
83
|
+
* free — the safe direction for a budget gate. */
|
|
84
|
+
export function opencodeModelFree(cache, model) {
|
|
85
|
+
const entry = cache.models[model];
|
|
86
|
+
return entry !== undefined && entry.free && entry.billedAt === undefined;
|
|
87
|
+
}
|
|
88
|
+
/** The catalog cache key for an opencode run's resolved selector: the `<provider>/<model>`
|
|
89
|
+
* form part 6a/7 keys the cache by. Delegates the bare-gateway-prefix rule to
|
|
90
|
+
* opencode-args.ts's opencodeQualifiedModel (the single home), so the key a run is priced by
|
|
91
|
+
* always matches the id a run is invoked by. */
|
|
92
|
+
export function opencodeModelKey(selector) {
|
|
93
|
+
return opencodeQualifiedModel(selector);
|
|
94
|
+
}
|
|
95
|
+
/** Demote a catalog-free model the moment a run bills it (part 6c/7): when a run reports a
|
|
96
|
+
* nonzero cost on a model the cache calls free, mark that entry priced (`billedAt`) in the
|
|
97
|
+
* cache and log `opencode_free_model_billed` {model, costUsd} once. A subsequent priced run on
|
|
98
|
+
* the same model sees `billedAt` already set and stays silent, and the next
|
|
99
|
+
* `opencodeModelFree` answer for it is false. A run reporting $0, a priced model, or an
|
|
100
|
+
* unknown model leaves the cache untouched — a $0 run never demotes (missing and priced
|
|
101
|
+
* models are already "not free" to opencodeModelFree). Best-effort like observeClaudeAccount:
|
|
102
|
+
* the caller wraps the write/log in try/catch so an unwritable cache or feed never fails the
|
|
103
|
+
* run that observed it. Returns the cache after the observation, or null when nothing changed. */
|
|
104
|
+
export function observeOpencodeRun(root, run, opts) {
|
|
105
|
+
const cache = readOpencodeCache(root);
|
|
106
|
+
const entry = cache.models[run.model];
|
|
107
|
+
if (entry === undefined || !entry.free || entry.billedAt !== undefined || run.costUsd <= 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
entry.billedAt = opts.now !== undefined ? opts.now() : Date.now();
|
|
111
|
+
writeOpencodeCache(root, cache);
|
|
112
|
+
const log = opts.log !== undefined ? opts.log : (e) => logEventBestEffort(root, e);
|
|
113
|
+
log({ source: opts.source, type: "opencode_free_model_billed", model: run.model, costUsd: run.costUsd });
|
|
114
|
+
return cache;
|
|
115
|
+
}
|
|
116
|
+
/** Read the cached catalog, or an empty one (fetchedAt 0, no models) when the file is missing
|
|
117
|
+
* or malformed — the harness's no-data policy, so a torn write reads as "needs a refresh".
|
|
118
|
+
* Exported so the opencode backend's adapter (part 6d/7) and doctor can read the same cache
|
|
119
|
+
* without triggering a network fetch. The synchronous read the sync poll surfaces
|
|
120
|
+
* (pollBudgetGate, collectBudget) use to thread the catalog-cache into their free-ness reads
|
|
121
|
+
* (part 6b/7); the async refresh (which runs once per process at the startup gate) is what
|
|
122
|
+
* fills the file in. */
|
|
123
|
+
export function readOpencodeCache(root) {
|
|
124
|
+
const existing = readJsonFile(opencodeModelsPath(root));
|
|
125
|
+
if (existing !== null && typeof existing.fetchedAt === "number" && existing.models !== undefined && typeof existing.models === "object" && !Array.isArray(existing.models)) {
|
|
126
|
+
return {
|
|
127
|
+
fetchedAt: existing.fetchedAt,
|
|
128
|
+
cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
|
|
129
|
+
models: existing.models,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { fetchedAt: 0, models: {} };
|
|
133
|
+
}
|
|
134
|
+
/** Persist the catalog cache atomically under `.tumwater/state/`. */
|
|
135
|
+
export function writeOpencodeCache(root, cache) {
|
|
136
|
+
writeJsonAtomic(opencodeModelsPath(root), cache);
|
|
137
|
+
}
|
|
138
|
+
/** Read the installed opencode CLI's version, undefined when it cannot be read (no binary, a
|
|
139
|
+
* timeout, or a spawn failure). A changed version forces a catalog refresh: OpenCode serves from
|
|
140
|
+
* the same models.dev data, so a new CLI may route to providers the old cache does not know.
|
|
141
|
+
* `bin` is the configured opencode binary (default `opencode` on PATH), so the seed probes the
|
|
142
|
+
* same binary the runs spawn. */
|
|
143
|
+
function readCliVersion(bin = "opencode") {
|
|
144
|
+
try {
|
|
145
|
+
const res = spawnSync(bin, ["--version"], { stdio: "pipe", encoding: "utf8", timeout: 5_000 });
|
|
146
|
+
const version = res.stdout?.trim();
|
|
147
|
+
return version === undefined || version === "" ? undefined : version;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Return a cache fresh enough to price by, fetching and persisting when the current one is
|
|
154
|
+
* missing, stale (older than 24 h) or fetched by a different `opencode --version`. A failed
|
|
155
|
+
* fetch keeps the old cache (logging one warning) so the team still prices by the last good
|
|
156
|
+
* data; `billedAt` from the old cache survives a successful refresh (part 6c/7). */
|
|
157
|
+
export async function refreshOpencodeCatalog(root, opts) {
|
|
158
|
+
const now = opts.now !== undefined ? opts.now() : Date.now();
|
|
159
|
+
const version = opts.cliVersion !== undefined ? opts.cliVersion : readCliVersion(opts.cliBin);
|
|
160
|
+
const current = readOpencodeCache(root);
|
|
161
|
+
const fresh = current.fetchedAt !== 0 &&
|
|
162
|
+
now - current.fetchedAt < CATALOG_CACHE_TTL_MS &&
|
|
163
|
+
(version === undefined || current.cliVersion === version);
|
|
164
|
+
if (fresh)
|
|
165
|
+
return current;
|
|
166
|
+
let body;
|
|
167
|
+
try {
|
|
168
|
+
body = await fetchOpencodeCatalog(opts.fetch);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
opts.warn?.(`open code model catalog fetch failed; keeping the cached copy`);
|
|
172
|
+
return current.fetchedAt === 0 ? { fetchedAt: 0, cliVersion: version, models: {} } : current;
|
|
173
|
+
}
|
|
174
|
+
const parsed = parseOpencodeCatalog(body, opts.providers);
|
|
175
|
+
for (const key of Object.keys(parsed)) {
|
|
176
|
+
const fresh = parsed[key];
|
|
177
|
+
const old = current.models[key];
|
|
178
|
+
if (fresh !== undefined && old?.billedAt !== undefined)
|
|
179
|
+
fresh.billedAt = old.billedAt;
|
|
180
|
+
}
|
|
181
|
+
const cache = { fetchedAt: now, cliVersion: version, models: parsed };
|
|
182
|
+
writeOpencodeCache(root, cache);
|
|
183
|
+
return cache;
|
|
184
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** Building one OpenCode run's injected config (`OPENCODE_CONFIG_CONTENT`) (plans/opencode-
|
|
3
|
+
* backend.md "The invocation") — pure JSON construction, with no subprocess. The config
|
|
4
|
+
* applies after the operator's global and project files, so only managed config wins over it:
|
|
5
|
+
* the gate-safe isolation switches (snapshots off, updates off, six denials), `enabled →
|
|
6
|
+
* disabled: true` for every operator MCP server (config merges, so disabling is the only way
|
|
7
|
+
* to remove one), and an `instructions` entry mirroring claude's AGENTS.md handling. No
|
|
8
|
+
* tool is disabled beyond the six gate-safe denials — denying `read` trips the free-tier gate
|
|
9
|
+
* (finding 11). Part 5/7 wires this string into the run's environment.
|
|
10
|
+
*
|
|
11
|
+
* From part 3/4 tumwater no longer adds an MCP server of its own: run notes ride the
|
|
12
|
+
* `tumwater-notes` command on PATH, so every operator MCP server is disabled alike — an
|
|
13
|
+
* operator server named `tumwater` is disabled like any other (a later config replaces a
|
|
14
|
+
* server object whole, design decision 8 and plans/opencode-backend.md "The invocation"). No
|
|
15
|
+
* XDG/config files are read here except the operator's global opencode config, and that only
|
|
16
|
+
* to enumerate its MCP server names (operatorMcpNames): a broken or missing file yields `[]`
|
|
17
|
+
* here, and doctor reports it in part 7/7. */
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
/** The operator's global opencode config lives in `$XDG_CONFIG_HOME/opencode/` (or
|
|
21
|
+
* `~/.config/opencode/`), in `config.json`, `opencode.json` or `opencode.jsonc`. */
|
|
22
|
+
const OPERATOR_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"];
|
|
23
|
+
/** The six gate-safe denials (plans/opencode-backend.md "The invocation", findings 11–12).
|
|
24
|
+
* `read` is never denied — it trips the free-tier gate. `shell`, `glob`, `grep`, `edit` and
|
|
25
|
+
* `write` stay allowed for parity with pi. */
|
|
26
|
+
const DENIED_PERMISSIONS = [
|
|
27
|
+
{ action: "execute", resource: "*", effect: "deny" },
|
|
28
|
+
{ action: "subagent", resource: "*", effect: "deny" },
|
|
29
|
+
{ action: "webfetch", resource: "*", effect: "deny" },
|
|
30
|
+
{ action: "websearch", resource: "*", effect: "deny" },
|
|
31
|
+
{ action: "question", resource: "*", effect: "deny" },
|
|
32
|
+
{ action: "skill", resource: "*", effect: "deny" },
|
|
33
|
+
];
|
|
34
|
+
/** True at `i` when a `//` line comment or a `/*`-style block comment starts there. */
|
|
35
|
+
function isCommentStart(src, i) {
|
|
36
|
+
return src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*");
|
|
37
|
+
}
|
|
38
|
+
/** Advance `j` past any whitespace and comments, for a position that is never inside a string.
|
|
39
|
+
* Returns the index of the next significant (non-comment, non-whitespace) character, or
|
|
40
|
+
* `src.length` when the input ends in comments/whitespace. */
|
|
41
|
+
function skipWhitespaceAndComments(src, j) {
|
|
42
|
+
while (j < src.length) {
|
|
43
|
+
const c = src[j];
|
|
44
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
45
|
+
j += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (c === "/" && src[j + 1] === "/") {
|
|
49
|
+
while (j < src.length && src[j] !== "\n")
|
|
50
|
+
j += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === "/" && src[j + 1] === "*") {
|
|
54
|
+
j += 2;
|
|
55
|
+
while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
|
|
56
|
+
j += 1;
|
|
57
|
+
j += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
return j;
|
|
63
|
+
}
|
|
64
|
+
/** Strip comments and trailing commas from a JSONC string so a strict JSON parse succeeds.
|
|
65
|
+
* Walks the string so a `//` or a block-comment opener, and `,}` / `,]`, inside a normal
|
|
66
|
+
* double-quoted string value survive (escape sequences included); comments and trailing
|
|
67
|
+
* commas are removed only outside strings, where they are legal JSONC but not JSON. A
|
|
68
|
+
* trailing comma is dropped when the next significant character — past any whitespace AND
|
|
69
|
+
* any comment — is a closing brace or bracket, so a comma then a comment then `}` strips
|
|
70
|
+
* correctly. */
|
|
71
|
+
function stripJsonc(src) {
|
|
72
|
+
let out = "";
|
|
73
|
+
let inString = false;
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < src.length) {
|
|
76
|
+
const c = src[i];
|
|
77
|
+
const next = src[i + 1];
|
|
78
|
+
if (inString) {
|
|
79
|
+
out += c;
|
|
80
|
+
if (c === "\\" && next !== undefined) {
|
|
81
|
+
out += next;
|
|
82
|
+
i += 2;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (c === '"')
|
|
86
|
+
inString = false;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (c === '"') {
|
|
92
|
+
inString = true;
|
|
93
|
+
out += c;
|
|
94
|
+
i += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (isCommentStart(src, i)) {
|
|
98
|
+
i = skipWhitespaceAndComments(src, i);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === ",") {
|
|
102
|
+
// A trailing comma may be followed by whitespace and comments before the closing
|
|
103
|
+
// brace/bracket (`,\n // c\n }`); look past all of it to decide, without touching a
|
|
104
|
+
// comma inside a string value.
|
|
105
|
+
const j = skipWhitespaceAndComments(src, i + 1);
|
|
106
|
+
if (src[j] === "}" || src[j] === "]") {
|
|
107
|
+
i += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
out += c;
|
|
112
|
+
i += 1;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** Read the MCP server names in the operator's global opencode config. Each name is a server
|
|
117
|
+
* the managed config must explicitly disable (config merges, so it cannot be removed). Reads
|
|
118
|
+
* both config shapes: the keys of V2's `mcp.servers` plus V1-shaped `mcp.<name>` keys other
|
|
119
|
+
* than `servers`. Returns `[]` for a missing file, an unreadable file, or one that does not
|
|
120
|
+
* parse; doctor reports a broken one in part 7/7. `homeDir` is the operator's home directory;
|
|
121
|
+
* the process's own `XDG_CONFIG_HOME` env, when set, wins over `~/.config` (the XDG default). */
|
|
122
|
+
export function operatorMcpNames(env, homeDir) {
|
|
123
|
+
const configHome = env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
|
|
124
|
+
const dir = path.join(configHome, "opencode");
|
|
125
|
+
for (const file of OPERATOR_CONFIG_FILES) {
|
|
126
|
+
let text;
|
|
127
|
+
try {
|
|
128
|
+
text = fs.readFileSync(path.join(dir, file), "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(file.endsWith(".jsonc") ? stripJsonc(text) : text);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const mcp = parsed?.mcp;
|
|
141
|
+
if (mcp === undefined || mcp === null)
|
|
142
|
+
return [];
|
|
143
|
+
if (typeof mcp !== "object" || Array.isArray(mcp))
|
|
144
|
+
return [];
|
|
145
|
+
const names = new Set();
|
|
146
|
+
const servers = mcp.servers;
|
|
147
|
+
if (typeof servers === "object" && servers !== null && !Array.isArray(servers)) {
|
|
148
|
+
for (const key of Object.keys(servers))
|
|
149
|
+
names.add(key);
|
|
150
|
+
}
|
|
151
|
+
// V1-shaped `mcp.<name>` keys, other than the V2 `servers` grouping.
|
|
152
|
+
for (const key of Object.keys(mcp)) {
|
|
153
|
+
if (key !== "servers")
|
|
154
|
+
names.add(key);
|
|
155
|
+
}
|
|
156
|
+
return [...names];
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
/** True when the worktree has a `CLAUDE.md` and no `AGENTS.md`: OpenCode reads `AGENTS.md`
|
|
161
|
+
* only, so the managed config injects `CLAUDE.md` — but only when the repo has no `AGENTS.md`,
|
|
162
|
+
* exactly the mirror of claude's `shouldAppendAgents`. */
|
|
163
|
+
function shouldInjectClaudeMd(cwd) {
|
|
164
|
+
return fs.existsSync(path.join(cwd, "CLAUDE.md")) && !fs.existsSync(path.join(cwd, "AGENTS.md"));
|
|
165
|
+
}
|
|
166
|
+
/** Build the injected opencode config JSON string. Never reads the worktree beyond the
|
|
167
|
+
* CLAUDE.md/AGENTS.md probe. */
|
|
168
|
+
export function opencodeConfigContent(opts) {
|
|
169
|
+
const config = {
|
|
170
|
+
snapshots: false,
|
|
171
|
+
update: "disable",
|
|
172
|
+
permissions: DENIED_PERMISSIONS,
|
|
173
|
+
};
|
|
174
|
+
if (opts.toolOutput !== undefined) {
|
|
175
|
+
config.tool_output = { max_lines: opts.toolOutput.maxLines, max_bytes: opts.toolOutput.maxBytes };
|
|
176
|
+
}
|
|
177
|
+
const servers = {};
|
|
178
|
+
for (const name of opts.operatorMcpNames) {
|
|
179
|
+
// Every operator server is disabled alike: an operator server named `tumwater` gets no
|
|
180
|
+
// special treatment now that tumwater ships no MCP server of its own (part 3/4).
|
|
181
|
+
servers[name] = { disabled: true };
|
|
182
|
+
}
|
|
183
|
+
if (Object.keys(servers).length > 0)
|
|
184
|
+
config.mcp = { servers };
|
|
185
|
+
if (shouldInjectClaudeMd(opts.cwd))
|
|
186
|
+
config.instructions = ["CLAUDE.md"];
|
|
187
|
+
return JSON.stringify(config);
|
|
188
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** Building the environment for one opencode run (plans/opencode-backend.md "The invocation")
|
|
3
|
+
* — pure scrub-and-set over a base environment, with no subprocess. The caller layers the
|
|
4
|
+
* run-marker and the macOS preload on first (claude's adapter shows the pie pattern), then
|
|
5
|
+
* hands the result here alongside the injected config and the session database path.
|
|
6
|
+
*
|
|
7
|
+
* Inherited OpenCode session variables are scrubbed (every `OPENCODE_*` except the operator's
|
|
8
|
+
* `OPENCODE_API_KEY`, plus the bare `OPENCODE` and `AI_AGENT`): a run spawned from inside an
|
|
9
|
+
* OpenCode session must not attach to that session's server, config or database, and `AI_AGENT`
|
|
10
|
+
* must not leak so the shell tool sets its own (finding 16). The set half disables Claude Code
|
|
11
|
+
* interop and its skills, auto-update and the file watcher, and isolates the run to its own
|
|
12
|
+
* database and injected config.
|
|
13
|
+
*
|
|
14
|
+
* No shell-timeout variable exists in 2.0.32 (finding 16), so none is set; the dialect tells
|
|
15
|
+
* the model to pass `timeout` for the check (§5). `XDG_CONFIG_HOME` and its siblings are NOT
|
|
16
|
+
* set: OpenCode's shell-tool children inherit the environment, and pointing XDG at a private
|
|
17
|
+
* dir would stop `git` reading `~/.config/git/config`, losing commits' author identity (§3). */
|
|
18
|
+
/** Build the opencode process environment from `base`. Never mutates `base`. */
|
|
19
|
+
export function opencodeEnv(base, opts) {
|
|
20
|
+
const env = {};
|
|
21
|
+
for (const [key, value] of Object.entries(base)) {
|
|
22
|
+
// Everything an inherited OpenCode session set is scrubbed — server, config, database,
|
|
23
|
+
// client and session id — except the operator's API key, which is operator intent and must
|
|
24
|
+
// survive. `AI_AGENT` goes too, so the shell tool sets its own (finding 16).
|
|
25
|
+
if (key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT")
|
|
26
|
+
continue;
|
|
27
|
+
env[key] = value;
|
|
28
|
+
}
|
|
29
|
+
env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
|
|
30
|
+
env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
|
|
31
|
+
env.OPENCODE_DISABLE_AUTOUPDATE = "1";
|
|
32
|
+
env.OPENCODE_DISABLE_FILEWATCHER = "1";
|
|
33
|
+
env.OPENCODE_DB = opts.dbPath;
|
|
34
|
+
env.OPENCODE_CONFIG_CONTENT = opts.configContent;
|
|
35
|
+
return env;
|
|
36
|
+
}
|