tumwater 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -28
- 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-eligibility.js +332 -43
- package/dist/src/backlog/backlog-md.js +79 -17
- 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 +178 -441
- 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 +32 -30
- package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
- package/dist/src/{brief.js → brief/brief.js} +33 -8
- 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 +13 -11
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +9 -7
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +43 -10
- package/dist/src/cli/cli-editor.js +13 -1
- package/dist/src/cli/cli-flag-specs.js +31 -6
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-query-commands.js +439 -56
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +124 -68
- package/dist/src/cli/completion.js +70 -12
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +264 -124
- 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 +13 -224
- package/dist/src/cli.js +78 -36
- 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 +107 -325
- 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 +494 -68
- package/dist/src/doctor/doctor-checks.js +509 -58
- package/dist/src/doctor/doctor-holds.js +139 -78
- 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 +2 -3
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +76 -25
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +219 -106
- 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 +80 -30
- 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 +6 -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 +79 -59
- 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 -716
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +105 -54
- 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 +25 -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 +179 -104
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +29 -70
- package/dist/src/inbox/inbox-attachments.js +66 -10
- 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 +114 -73
- 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 +104 -50
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +130 -13
- package/dist/src/loop/loop-tick.js +190 -88
- package/dist/src/loop/loop.js +68 -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 -20
- 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 +113 -45
- package/dist/src/operator/operator-intent.js +74 -53
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +47 -28
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
- package/dist/src/orchestrator/orchestrator.js +149 -96
- 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 +124 -38
- 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 +4 -3
- 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 +36 -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 +29 -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 +259 -254
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +235 -44
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +4 -3
- 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 +55 -34
- 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 +109 -131
- 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 +16 -14
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +15 -8
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +28 -25
- 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 +72 -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 +50 -39
- 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 +84 -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} +39 -38
- 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 +142 -25
- 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 +23 -22
- package/dist/src/tick/tick-detail.js +37 -24
- 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 +67 -53
- 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 +75 -10
- package/dist/src/ui/badges.js +113 -69
- package/dist/src/ui/gui/gui-client-boot.js +202 -29
- 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 +94 -37
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +90 -47
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +60 -43
- package/dist/src/ui/gui/gui-client-operator.js +73 -43
- package/dist/src/ui/gui/gui-client-pending.js +34 -31
- package/dist/src/ui/gui/gui-client-report.js +330 -47
- package/dist/src/ui/gui/gui-client-settings.js +126 -18
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +18 -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 +176 -102
- package/dist/src/ui/status-payload.js +114 -36
- package/dist/src/ui/status-render.js +121 -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 +9 -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 +61 -57
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +44 -50
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +87 -1821
- 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 +52 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +44 -26
- package/dist/src/verdict/fix-claim.js +56 -33
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +43 -11
- 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 -56
- package/dist/src/landing/landing-slot.js +0 -256
- 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,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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** Minting an OpenCode session id (plans/opencode-backend.md "Sessions", finding 3) — a pure
|
|
3
|
+
* reproduction of OpenCode's `Identifier.descending()` (packages/schema/src/identifier.ts), so
|
|
4
|
+
* a fresh run's `--session` id is accepted by the free-tier gate. A `ses_…` id in any other
|
|
5
|
+
* shape is refused with a 403 (finding 3), so this must match OpenCode exactly.
|
|
6
|
+
*
|
|
7
|
+
* The id is `ses_` + 12 hex digits + 14 base-62 characters. The hex is the low 48 bits of the
|
|
8
|
+
* bitwise NOT of (Unix milliseconds × 4096 + a per-millisecond counter), big-endian; the 14
|
|
9
|
+
* characters are drawn from `0-9A-Za-z` one random byte each. */
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
/** The 62-character alphabet OpenCode draws a session id's trailing characters from. */
|
|
12
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
13
|
+
/** Per-process minting state, mirroring OpenCode's per-millisecond counter: the counter
|
|
14
|
+
* restarts at 1 for each new millisecond, and increments within the same millisecond so two
|
|
15
|
+
* ids minted in one tick still differ in their hex. */
|
|
16
|
+
let lastMintMs = -1;
|
|
17
|
+
let counter = 0;
|
|
18
|
+
/** Mint an OpenCode session id. `now` is a Unix-millisecond epoch (injected for tests);
|
|
19
|
+
* `randomBytes` supplies the 14 base-62 characters (defaults to `crypto.randomBytes`, so the
|
|
20
|
+
* trailing part is random, never guessed from the visible prefix). */
|
|
21
|
+
export function mintOpencodeSessionId(now = Date.now(), randomBytes = (n) => crypto.randomBytes(n)) {
|
|
22
|
+
if (now === lastMintMs)
|
|
23
|
+
counter += 1;
|
|
24
|
+
else {
|
|
25
|
+
lastMintMs = now;
|
|
26
|
+
counter = 1;
|
|
27
|
+
}
|
|
28
|
+
// `Identifier.descending()`: current = now × 4096 + counter, then the low 48 bits of ~current.
|
|
29
|
+
const current = BigInt(now) * 0x1000n + BigInt(counter);
|
|
30
|
+
const hex = ((~current) & 0xffffffffffffn).toString(16).padStart(12, "0");
|
|
31
|
+
const bytes = randomBytes(14);
|
|
32
|
+
let suffix = "";
|
|
33
|
+
for (let i = 0; i < 14; i += 1) {
|
|
34
|
+
suffix += ALPHABET[bytes[i] % 62];
|
|
35
|
+
}
|
|
36
|
+
return `ses_${hex}${suffix}`;
|
|
37
|
+
}
|