From c48fe99b96bb237fed38a959b5868299a6d24de8 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Sat, 12 Sep 2026 10:29:24 +0200 Subject: [PATCH] chore: update opencode api to v2 --- README.md | 22 +++- bun.lock | 19 +-- install-local.sh | 79 +++++++++++++ package.json | 11 +- scripts/build.ts | 28 +++++ src/index.ts | 8 +- src/quota.ts | 97 +++++++++++++--- src/tui.tsx | 2 +- tests/install-local.test.ts | 224 ++++++++++++++++++++++++++++++++++++ tests/plugin.test.ts | 30 ++++- tests/quota.test.ts | 151 ++++++++++++++++++++++++ tests/tui.test.ts | 104 +++++++++++++++++ tsconfig.json | 2 +- 13 files changed, 740 insertions(+), 37 deletions(-) create mode 100755 install-local.sh create mode 100644 scripts/build.ts create mode 100644 tests/install-local.test.ts create mode 100644 tests/tui.test.ts diff --git a/README.md b/README.md index 149d5bb..f6eb7e7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OpenCode V2 plugin providing a `coordinate` agent and fast/good/deep worker pair ## Install the latest release -Requires **OpenCode V2** (`opencode2`), **Bun 1.3.14+**, Bash, curl, jq, and `sha256sum` (Linux/WSL). The native plugin API is pinned to `@opencode/plugin@0.0.0-beta-19378`; use the matching OpenCode beta. +Requires **OpenCode V2 2.0.2** (`opencode2`), **Bun 1.3.14+**, Bash, curl, jq, and `sha256sum` (Linux/WSL). The native plugin API is pinned to `@opencode/plugin@2.0.2`; use the matching stable OpenCode release. Run the installer without sudo: @@ -26,6 +26,24 @@ The installer atomically replaces the package directory at `plugins/stanp-openco Restart `opencode2` after installation (or `opencode2 service restart` for an existing service). Connect your provider, then select `coordinate` in the agent picker. The default configuration uses OpenAI GPT models; agents are only enabled when the required models are available. +## Install from a local checkout + +To build and install the current checkout instead of downloading a release, install Bash, jq, and **Bun 1.3.14+**, then run: + +```sh +bash /path/to/stanp-opencode/install-local.sh +``` + +The script may be invoked from any working directory. It runs `bun install --frozen-lockfile` (which may access the package registry) and `bun run build` in the checkout containing the script on every run. It validates the native V2 and TUI bundles, copies them into the same installed package layout as the release installer, and preserves existing plugin options, unrelated `opencode.json` settings, and `opencode.jsonc`. Dependency installation, build, validation, and configuration parsing failures leave the currently installed plugin and configuration untouched. + +The configuration directory defaults to `~/.config/opencode`, respects `XDG_CONFIG_HOME`, and can be overridden explicitly: + +```sh +OPENCODE_CONFIG_DIR=/path/to/opencode-config bash /path/to/stanp-opencode/install-local.sh +``` + +Restart `opencode2` after installation (or `opencode2 service restart`). + ## Configure See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). When using the installer, point the example's plugin package entry at the installed package directory rather than the unpublished npm package name. Configured local plugin paths must be directories; pointing at `plugins/stanp-opencode.js` causes OpenCode to reject that configured entry and auto-load the file without its options: @@ -47,7 +65,7 @@ See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignmen This relative path assumes the configuration file is in the selected global config directory. Use an absolute path in project configuration. Remove any old V1 `plugin` entry when migrating; V2 uses `plugins`. `lunaPriority` requests priority service where supported and can increase cost; set it to `false` to use the provider default. -The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them. +The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In OpenCode V2 2.0.2, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them. When an MCP server named `codegraph` is already configured, its tools are exposed directly to `coordinate` and retain the built-in `build` agent's matching allow, ask, and deny rules. The plugin does not configure or start a missing server, and no other MCP server is exposed this way. diff --git a/bun.lock b/bun.lock index 0e13465..a62e11f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,9 @@ "zod": "4.1.8", }, "devDependencies": { - "@opencode/client": "0.0.0-beta-19378", - "@opencode/plugin": "0.0.0-beta-19378", + "@opencode/client": "2.0.2", + "@opencode/plugin": "2.0.2", + "@opencode/schema": "2.0.2", "@opentui/core": "0.5.10", "@opentui/solid": "0.5.10", "@oxlint/plugins": "1.82.0", @@ -20,7 +21,7 @@ "typescript": "7.0.2", }, "peerDependencies": { - "@opencode/plugin": "0.0.0-beta-19378", + "@opencode/plugin": "2.0.2", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0", @@ -200,17 +201,17 @@ "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], - "@opencode/ai": ["@opencode/ai@0.0.0-beta-19378", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "0.0.0-beta-19378", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-mk20W9+HH82jT8VLowhu9weV6Qkch+ydbfPRp2FJFh53RP9up1YaHLDu1o913biSJQNa36Q/9shln7YIOJ2PNA=="], + "@opencode/ai": ["@opencode/ai@2.0.2", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.2", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-Byq9c3kRdIl+AfGidPkd3JUC6DnjaNXGem77sAFRSxYwA0AMJMImPm+ua6ibnRqJ+6HlEq9JSh+Z1nQ8s8xzrQ=="], - "@opencode/client": ["@opencode/client@0.0.0-beta-19378", "", { "dependencies": { "@opencode/protocol": "0.0.0-beta-19378", "@opencode/schema": "0.0.0-beta-19378" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-tYhOVbCt4RFIxw3DYvX9NREomExR4QCE/K/mFoUxkU8dlhQNCDDT8ztztDkLyLycYwwvAJ+wKt4La6vH0/s8Pg=="], + "@opencode/client": ["@opencode/client@2.0.2", "", { "dependencies": { "@opencode/protocol": "2.0.2", "@opencode/schema": "2.0.2" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-w8rrSsxY9qnuFrsQAHdLDE6mrEqUKQeo7fNsw2i/tFtimgspT53/8/3ckEVRuRUM5k6v68KiU2deG0W2gEIVDQ=="], - "@opencode/plugin": ["@opencode/plugin@0.0.0-beta-19378", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "0.0.0-beta-19378", "@opencode/client": "0.0.0-beta-19378", "@opencode/protocol": "0.0.0-beta-19378", "@opencode/schema": "0.0.0-beta-19378", "@opencode/util": "0.0.0-beta-19378", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "0.0.0-beta-19378", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-Kzlp3gpem5QSzmEkrUOZPABnFy6a+m5wRGskXqt1KrJbXMIjrW9xnluFn81iHpiHEWRkQZOVZssw6VKPgyXQtA=="], + "@opencode/plugin": ["@opencode/plugin@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.2", "@opencode/client": "2.0.2", "@opencode/protocol": "2.0.2", "@opencode/schema": "2.0.2", "@opencode/util": "2.0.2", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.2", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-lFXcsyg1fhzCpzuMqBhb2yNddMeynXZwXL3aHs1T9ftbFu4buFAdtE9ox09Q+CL/r7yGbbX4mdtjKYdnt0dn5Q=="], - "@opencode/protocol": ["@opencode/protocol@0.0.0-beta-19378", "", { "dependencies": { "@opencode/schema": "0.0.0-beta-19378", "effect": "4.0.0-rc.112" } }, "sha512-BXh+ygV/xah3LYD12i0eVH69JW6m+7vVK7MiSUnKm+T88G8DT0ytJmqFcLYtoa1EcDNnujcmHMrLtHheTn4b4g=="], + "@opencode/protocol": ["@opencode/protocol@2.0.2", "", { "dependencies": { "@opencode/schema": "2.0.2", "effect": "4.0.0-rc.112" } }, "sha512-FxTxdHPNz46NkG8ZBB2S60QzXbZDlVEyZ6KeYTt2G7hcWB5JlMYBh8jeqHlsLbquKinvMKA2iuEsA1NaccN+YQ=="], - "@opencode/schema": ["@opencode/schema@0.0.0-beta-19378", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-aBilB58tyzWEE44gNmZXZpu5Xw2W3mNV3vrdKCX19z2VRoaZGY8BRhjDwFNGGBoZ9U5KXp9e0C0GKydUuKxT/w=="], + "@opencode/schema": ["@opencode/schema@2.0.2", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-jF23H41j6zM2Az1pYLD6NFfzpoQ79CX0AxRkfNxTZFbPfjGGc+EPSOzVQP/r4Y/5bkSSqVga0Vn7MyoP8QhwDQ=="], - "@opencode/util": ["@opencode/util@0.0.0-beta-19378", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "pacote": "21.5.1" } }, "sha512-YNAWKFt348p9UZWQJ5wyazTq8TOPrStaLnd7bA8m5E17CaULtKnxeiKBIcG095cpeqIZ+/JCQBurcqOZ7UbDrA=="], + "@opencode/util": ["@opencode/util@2.0.2", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "pacote": "21.5.1" } }, "sha512-AKnFr44s1Q7/Ud+Pl4QMCEJoIp33xyDiTi693BofXn+HBDHdJmKrwq87YpG+Kwt9t48gVv6Cx1glmqJ4lVJpHg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], diff --git a/install-local.sh b/install-local.sh new file mode 100755 index 0000000..f9888fd --- /dev/null +++ b/install-local.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command in bun jq; do + command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; } +done + +checkout=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +config="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" +mkdir -p "$config" +config=$(cd "$config" && pwd -P) +mkdir -p "$config/plugins" +install=$(mktemp -d "$config/install.stanp-opencode.XXXXXXXX") +staged="" +backup="" +cleanup() { + [[ -z "$install" ]] || rm -rf "$install" + [[ -z "$staged" ]] || rm -rf "$staged" +} +trap cleanup EXIT + +(cd "$checkout" && bun install --frozen-lockfile && bun run build) + +bun -e ' + if (!await Bun.file(process.argv[1]).exists()) { + throw new Error("Local build lacks the OpenCode V2 directory entrypoint."); + } + const { default: plugin } = await import(process.argv[1]); + if (!plugin || plugin.id !== "stanp-opencode" || typeof plugin.setup !== "function") { + throw new Error("Local build is not a native OpenCode V2 plugin."); + } + const tuiFile = Bun.file(process.argv[2]); + const manifest = await Bun.file(process.argv[3]).json(); + const exportsTui = manifest?.exports?.["./tui"]; + if (!await tuiFile.exists() || !new Bun.Transpiler({ loader: "js" }).scan(await tuiFile.text()).exports.includes("default") || (typeof exportsTui !== "string" && typeof exportsTui?.import !== "string")) { + throw new Error("Local build lacks the ChatGPT quota TUI plugin."); + } +' "$checkout/dist/index.js" "$checkout/dist/tui.js" "$checkout/package.json" + +config_file="$config/opencode.json" +config_source="$config_file" +if [[ ! -e "$config_source" ]]; then + config_source="$install/empty-config.json" + printf '{}\n' > "$config_source" +fi +jq -e ' + if type != "object" then error("opencode.json must contain an object") else . end + | if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end + | .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json") + | .plugins = (.plugins // []) + | if any(.plugins[]?; .package == "./plugins/stanp-opencode") then . else + .plugins += [{ + package: "./plugins/stanp-opencode", + options: { mode: "gpt", provider: "openai", lunaPriority: true } + }] + end +' "$config_source" > "$install/opencode.json" + +target="$config/plugins/stanp-opencode" +staged="$config/plugins/.stanp-opencode.new.$$" +backup="$config/plugins/.stanp-opencode.old.$$" +mkdir "$staged" +cp "$checkout/dist/index.js" "$staged/index.js" +cp "$checkout/dist/tui.js" "$staged/tui.js" +cat > "$staged/package.json" <<'EOF' +{"name":"stanp-opencode","type":"module","main":"./index.js","exports":{".":"./index.js","./tui":"./tui.js"}} +EOF +if [[ -e "$target" || -L "$target" ]]; then mv "$target" "$backup"; fi +if ! mv "$staged" "$target"; then + if [[ -e "$backup" || -L "$backup" ]]; then mv "$backup" "$target"; fi + printf 'Could not activate plugin; installation unchanged\n' >&2 + exit 1 +fi +staged="" +rm -rf "$backup" +backup="" +rm -f "$config/plugins/stanp-opencode.js" +mv "$install/opencode.json" "$config_file" +printf 'Built and installed stanp-opencode from %s into %s/plugins/stanp-opencode and updated %s\nRestart opencode2 to load it.\n' "$checkout" "$config" "$config_file" diff --git a/package.json b/package.json index 35a7f32..cf58c0c 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,13 @@ "lint": "oxlint --deny-warnings .", "format": "oxfmt --write .", "format:check": "oxfmt --check .", - "test": "bun test", - "build": "bun build src/index.ts src/quota-rpc.ts --outdir dist --target bun --format esm && bun build src/tui.tsx --outdir dist --target bun --format esm --external @opencode/plugin/tui --external @opentui/solid --external solid-js && tsc --project tsconfig.build.json --emitDeclarationOnly" + "test": "bun test --conditions=browser", + "build": "bun scripts/build.ts && tsc --project tsconfig.build.json --emitDeclarationOnly" }, "devDependencies": { - "@opencode/client": "0.0.0-beta-19378", - "@opencode/plugin": "0.0.0-beta-19378", + "@opencode/client": "2.0.2", + "@opencode/plugin": "2.0.2", + "@opencode/schema": "2.0.2", "@opentui/core": "0.5.10", "@opentui/solid": "0.5.10", "@oxlint/plugins": "1.82.0", @@ -49,7 +50,7 @@ "typescript": "7.0.2" }, "peerDependencies": { - "@opencode/plugin": "0.0.0-beta-19378", + "@opencode/plugin": "2.0.2", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..4adeda0 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,28 @@ +import solidPlugin from "@opentui/solid/bun-plugin"; + +export async function build() { + const results = await Promise.all([ + Bun.build({ + entrypoints: ["src/index.ts", "src/quota-rpc.ts"], + outdir: "dist", + target: "bun", + format: "esm", + }), + Bun.build({ + entrypoints: ["src/tui.tsx"], + outdir: "dist", + target: "bun", + format: "esm", + external: ["@opencode/plugin/tui", "@opentui/solid", "solid-js"], + plugins: [solidPlugin], + }), + ]); + + for (const result of results) { + if (result.success) continue; + for (const log of result.logs) console.error(log); + throw new Error("Build failed"); + } +} + +if (import.meta.main) await build(); diff --git a/src/index.ts b/src/index.ts index 1e53a34..408293f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,12 @@ export default Plugin.define({ // asynchronous setup is running. const updates = (async () => { for await (const event of context.event.subscribe({ signal: controller.signal })) { + if ( + event.location && + (event.location.directory !== context.location.directory || + event.location.workspaceID !== context.location.workspaceID) + ) + continue; if (event.type === "catalog.updated") { models = await discover(context, selected.provider); await context.agent.reload(); @@ -223,7 +229,7 @@ export default Plugin.define({ )?.effect ?? "ask"; if (effect !== "allow") throw new Error( - `${action} requires an allow permission: OpenCode beta's public plugin API cannot request custom-tool permission approval (${effect}).`, + `${action} requires an allow permission: OpenCode's public plugin API cannot request custom-tool permission approval (${effect}).`, ); } await context.tool.transform((editor) => { diff --git a/src/quota.ts b/src/quota.ts index 445c46c..9eeea3b 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -9,6 +9,34 @@ const POLL_MS = 60_000; const TIMEOUT_MS = 5_000; const STORAGE_KEY = "chatgpt-quota"; +function compactLimitIdentity(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function isSparkLimit(meteredFeature?: string, limitName?: string | null) { + return ( + (meteredFeature !== undefined && + compactLimitIdentity(meteredFeature) === "codexbengalfox") || + (limitName != null && compactLimitIdentity(limitName) === "gpt53codexspark") + ); +} + +function isReserveLimit(meteredFeature?: string, limitName?: string | null) { + const hasReserveToken = (value: string) => /(^|[^a-z0-9])reserve($|[^a-z0-9])/i.test(value); + return ( + (meteredFeature !== undefined && hasReserveToken(meteredFeature)) || + (limitName != null && hasReserveToken(limitName)) + ); +} + +function isExcludedLimit(meteredFeature?: string, limitName?: string | null) { + return isSparkLimit(meteredFeature, limitName) || isReserveLimit(meteredFeature, limitName); +} + +function isExcludedWindow(window: Pick) { + return isExcludedLimit(window.id.split(":", 1)[0], window.label); +} + export interface QuotaWindow { id: string; label: string | null; @@ -56,6 +84,24 @@ const storedState = z.object({ }); type StoredState = z.infer; +function withoutExcludedState(state: StoredState): StoredState { + const windows = state.latest?.windows.filter((window) => !isExcludedWindow(window)); + return { + ...state, + latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined, + sessions: Object.fromEntries( + Object.entries(state.sessions).map(([session, tracked]) => [ + session, + Object.fromEntries( + Object.entries(tracked).filter( + ([id]) => !isExcludedLimit(id.split(":", 1)[0], undefined), + ), + ), + ]), + ), + }; +} + type QuotaContext = Pick; type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -64,6 +110,10 @@ const quotaWindow = z.object({ used_percent: z.number().finite(), reset_at: z.number().finite(), }); +const additionalRateLimitIdentity = z.object({ + limit_name: z.string().optional(), + metered_feature: z.string().optional(), +}); const quotaPayload = z.object({ plan_type: z.string(), account_id: z.string().optional(), @@ -74,21 +124,33 @@ const quotaPayload = z.object({ secondary_window: quotaWindow.nullish(), }) .nullish(), - additional_rate_limits: z - .array( - z.object({ - limit_name: z.string(), - metered_feature: z.string(), - rate_limit: z - .object({ - allowed: z.boolean().optional(), - primary_window: quotaWindow.nullish(), - secondary_window: quotaWindow.nullish(), - }) - .nullish(), - }), - ) - .nullish(), + additional_rate_limits: z.preprocess( + (value) => + Array.isArray(value) + ? value.filter((item) => { + const identity = additionalRateLimitIdentity.safeParse(item); + return ( + !identity.success || + !isExcludedLimit(identity.data.metered_feature, identity.data.limit_name) + ); + }) + : value, + z + .array( + z.object({ + limit_name: z.string(), + metered_feature: z.string(), + rate_limit: z + .object({ + allowed: z.boolean().optional(), + primary_window: quotaWindow.nullish(), + secondary_window: quotaWindow.nullish(), + }) + .nullish(), + }), + ) + .nullish(), + ), }); function normalizeWindow( @@ -218,7 +280,10 @@ export class QuotaTracker { if (generation !== this.generation) return; const stored = parsed.success ? parsed.data : undefined; if (generation !== this.generation) return; - this.state = stored?.account === account ? stored : { account, sessions: {} }; + this.state = + stored?.account === account + ? withoutExcludedState(stored) + : { account, sessions: {} }; } if (generation !== this.generation) return; if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs); diff --git a/src/tui.tsx b/src/tui.tsx index a18caad..7870ac5 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -67,7 +67,7 @@ export default Plugin.define({ const remove = context.ui.slot({ append: "sidebar.footer", - render: ({ sessionID }) => , + render: (input) => , }); return () => { remove(); diff --git a/tests/install-local.test.ts b/tests/install-local.test.ts new file mode 100644 index 0000000..83a70cc --- /dev/null +++ b/tests/install-local.test.ts @@ -0,0 +1,224 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const installer = fileURLToPath(new URL("../install-local.sh", import.meta.url)); +const directories: string[] = []; +const jsonc = '{\n // This file must remain untouched.\n "theme": "dark"\n}\n'; +const oldPlugin = 'export default { id: "old", setup() { return "old"; } };\n'; +type FixtureConfig = + | { permissions: { shell: string }; plugins: { package: string }[] } + | { + theme: string; + plugins: { + package: string; + options: { mode: string; coordinate: string; lunaPriority: boolean }; + }[]; + } + | { plugins: string }; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) { + const child = Bun.spawn(command, { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +async function fixture(configValue?: FixtureConfig) { + const directory = await mkdtemp(join(tmpdir(), "stanp-local-install-")); + directories.push(directory); + const checkout = join(directory, "checkout with spaces"); + const cwd = join(directory, "different cwd"); + const config = join(directory, "config with spaces"); + const bin = join(directory, "bin"); + const installed = join(config, "plugins", "stanp-opencode"); + await Promise.all([ + mkdir(checkout), + mkdir(cwd), + mkdir(bin), + mkdir(installed, { recursive: true }), + ]); + await Promise.all([ + Bun.write(join(checkout, "install-local.sh"), Bun.file(installer)), + writeFile( + join(checkout, "package.json"), + JSON.stringify({ + name: "stanp-opencode", + version: "1.0.0", + type: "module", + exports: { ".": "./dist/index.js", "./tui": { import: "./dist/tui.js" } }, + }), + ), + writeFile(join(checkout, "bun.lock"), ""), + writeFile(join(config, "opencode.jsonc"), jsonc), + writeFile( + join(config, "opencode.json"), + JSON.stringify( + configValue ?? { + permissions: { shell: "ask" }, + plugins: [{ package: "existing-plugin" }], + }, + ), + ), + writeFile(join(installed, "index.js"), oldPlugin), + writeFile( + join(installed, "package.json"), + '{"name":"stanp-opencode","type":"module","main":"./index.js"}\n', + ), + writeFile(join(config, "plugins", "stanp-opencode.js"), "legacy\n"), + writeFile( + join(bin, "bun"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "install" ]]; then + [[ "\${2:-}" == "--frozen-lockfile" ]] + printf '%s\n' "$PWD" >> "$CALLS" + [[ "$MODE" != "dependency" ]] + exit +fi +if [[ "\${1:-}" == "run" && "\${2:-}" == "build" ]]; then + printf '%s\n' "$PWD" >> "$CALLS" + [[ "$MODE" != "build" ]] + mkdir -p dist + if [[ "$MODE" == "validation" ]]; then + printf '%s\n' 'export default { id: "stanp-opencode", server() {} };' > dist/index.js + else + printf 'export default { id: "stanp-opencode", setup() { return "%s"; } };\n' "$GENERATION" > dist/index.js + fi + printf '%s\n' 'export default { id: "stanp-opencode.quota-tui", setup() {} };' > dist/tui.js + exit +fi +exec "$REAL_BUN" "$@" +`, + ), + writeFile( + join(bin, "curl"), + `#!/usr/bin/env bash +touch "$FETCH_MARKER" +printf 'release fetching is forbidden\n' >&2 +exit 99 +`, + ), + ]); + await Promise.all([chmod(join(bin, "bun"), 0o755), chmod(join(bin, "curl"), 0o755)]); + const env = { + ...Bun.env, + PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH ?? "/usr/bin:/bin"}`, + HOME: join(directory, "home"), + XDG_CONFIG_HOME: join(directory, "ignored-xdg"), + OPENCODE_CONFIG_DIR: config, + REAL_BUN: process.execPath, + MODE: "success", + GENERATION: "first", + CALLS: join(directory, "calls"), + FETCH_MARKER: join(directory, "release-fetch-attempted"), + }; + return { directory, checkout, cwd, config, installed, env }; +} + +test("builds from its checkout and installs defaults when invoked elsewhere", async () => { + const setup = await fixture(); + const result = await run( + ["bash", join(setup.checkout, "install-local.sh")], + setup.cwd, + setup.env, + ); + expect(result, result.stderr).toMatchObject({ exitCode: 0 }); + expect(await readFile(setup.env.CALLS, "utf8")).toBe(`${setup.checkout}\n${setup.checkout}\n`); + expect(await Bun.file(setup.env.FETCH_MARKER).exists()).toBe(false); + expect(await readFile(join(setup.config, "opencode.jsonc"), "utf8")).toBe(jsonc); + expect(JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"))).toEqual({ + $schema: "https://opencode.ai/config.json", + permissions: { shell: "ask" }, + plugins: [ + { package: "existing-plugin" }, + { + package: "./plugins/stanp-opencode", + options: { mode: "gpt", provider: "openai", lunaPriority: true }, + }, + ], + }); + const imported = await run( + [ + process.execPath, + "-e", + "const { default: plugin } = await import(process.argv[1]); console.log(plugin.setup());", + setup.installed, + ], + setup.cwd, + setup.env, + ); + expect(imported, imported.stderr).toMatchObject({ exitCode: 0, stdout: "first\n" }); + expect((await readdir(setup.installed)).sort()).toEqual(["index.js", "package.json", "tui.js"]); + expect(await Bun.file(join(setup.config, "plugins", "stanp-opencode.js")).exists()).toBe(false); +}); + +test("reinstall replaces bundles while preserving existing settings and plugin options", async () => { + const configured = { + theme: "dark", + plugins: [ + { + package: "./plugins/stanp-opencode", + options: { mode: "custom", coordinate: "provider/model", lunaPriority: false }, + }, + ], + }; + const setup = await fixture(configured); + const first = await run( + ["bash", join(setup.checkout, "install-local.sh")], + setup.cwd, + setup.env, + ); + expect(first, first.stderr).toMatchObject({ exitCode: 0 }); + const second = await run(["bash", join(setup.checkout, "install-local.sh")], setup.cwd, { + ...setup.env, + GENERATION: "second", + }); + expect(second, second.stderr).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"))).toEqual({ + $schema: "https://opencode.ai/config.json", + ...configured, + }); + expect(await readFile(join(setup.installed, "index.js"), "utf8")).toContain('return "second"'); +}); + +for (const mode of ["dependency", "build", "validation", "config"] as const) { + test(`${mode} failure leaves the previous installation and configuration untouched`, async () => { + const invalidConfig = mode === "config" ? { plugins: "not-an-array" } : undefined; + const setup = await fixture(invalidConfig); + const beforeConfig = await readFile(join(setup.config, "opencode.json"), "utf8"); + const result = await run(["bash", join(setup.checkout, "install-local.sh")], setup.cwd, { + ...setup.env, + MODE: mode === "config" ? "success" : mode, + }); + expect(result.exitCode, result.stderr).not.toBe(0); + expect(await readFile(join(setup.installed, "index.js"), "utf8")).toBe(oldPlugin); + expect(await readFile(join(setup.config, "opencode.json"), "utf8")).toBe(beforeConfig); + expect(await readFile(join(setup.config, "opencode.jsonc"), "utf8")).toBe(jsonc); + expect(await readFile(join(setup.config, "plugins", "stanp-opencode.js"), "utf8")).toBe( + "legacy\n", + ); + expect((await readdir(join(setup.config, "plugins"))).sort()).toEqual([ + "stanp-opencode", + "stanp-opencode.js", + ]); + expect(await Bun.file(setup.env.FETCH_MARKER).exists()).toBe(false); + }); +} diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index f37efef..af03bdd 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -46,8 +46,14 @@ async function host( const registration = { async dispose() {} }; let transform!: (editor: AgentEditor) => void; let reloaded = Promise.withResolvers(); - let events!: ReadableStreamDefaultController<{ type: string }>; - const stream = new ReadableStream<{ type: string }>({ + let events!: ReadableStreamDefaultController<{ + type: string; + location?: { directory: string; workspaceID?: string }; + }>; + const stream = new ReadableStream<{ + type: string; + location?: { directory: string; workspaceID?: string }; + }>({ start(controller) { events = controller; }, @@ -101,6 +107,7 @@ async function host( // SAFETY: This test host implements the native APIs used by setup; unrelated context // services are deliberately absent so unexpected new dependencies fail the test. const context = { + location: { directory: "/test" }, options, catalog: { model: { @@ -227,6 +234,10 @@ async function host( events.enqueue({ type: "agent.updated" }); await reloaded.promise; }, + changeBuildPermissionsElsewhere(permissions: Agent.Info["permissions"]) { + build.permissions = [...permissions]; + events.enqueue({ type: "agent.updated", location: { directory: "/other" } }); + }, async changeCodegraph( server: | { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean } @@ -477,6 +488,21 @@ test("late build permission overrides remain effective for workers and coordinat ).toBe("deny"); }); +test("events from another location do not reload this location's agents", async () => { + const result = await host(); + result.changeBuildPermissionsElsewhere([ + ...result.build.permissions, + { action: "read", resource: "*.env", effect: "deny" }, + ]); + await result.refresh(allModels); + expect( + result.agents + .get("coordinate") + ?.permissions.filter((rule) => rule.action === "read" && rule.resource === "*.env") + .at(-1)?.effect, + ).toBe("ask"); +}); + test("todo lists persist within one session without leaking to another", async () => { const result = await host(); const todos = [{ id: "1", content: "Implement", status: "in_progress", priority: "high" }]; diff --git a/tests/quota.test.ts b/tests/quota.test.ts index aacc3ed..3714d2e 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -95,6 +95,67 @@ test("normalizes additional named limits with stable IDs distinct from equal dur ).toEqual(["codex_luna:primary"]); }); +test("ignores Spark and representative reserve limits while preserving other quotas", () => { + const normalized = normalizeQuota({ + ...plus, + additional_rate_limits: [ + { + limit_name: "gpt 5_3 codex spark", + metered_feature: "CODEX-BENGALFOX", + rate_limit: { + primary_window: { used_percent: "invalid" }, + }, + }, + { + limit_name: "Luna Reserve", + metered_feature: "codex_luna", + rate_limit: { primary_window: { used_percent: "invalid" } }, + }, + { + limit_name: "Luna overflow", + metered_feature: "codex_luna_reserve", + rate_limit: { primary_window: { used_percent: "invalid" } }, + }, + { + limit_name: "GPT-5.6 Luna", + metered_feature: "codex_luna", + rate_limit: { primary_window: plus.rate_limit.primary_window }, + }, + { + limit_name: "Preserved capacity", + metered_feature: "codex_preserved", + rate_limit: { primary_window: plus.rate_limit.primary_window }, + }, + ], + }); + + expect(normalized?.windows.map((window) => window.id)).toEqual([ + "codex:primary", + "codex:secondary", + "codex_luna:primary", + "codex_preserved:primary", + ]); + expect(normalized?.windows.slice(2).map((window) => window.label)).toEqual([ + "GPT-5.6 Luna", + "Preserved capacity", + ]); +}); + +test("returns no quota for a Spark-only payload", () => { + expect( + normalizeQuota({ + plan_type: "pro", + additional_rate_limits: [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { primary_window: plus.rate_limit.primary_window }, + }, + ], + }), + ).toBeUndefined(); +}); + function fixture( credential: | { @@ -190,6 +251,96 @@ function fixture( }; } +async function accountFingerprint(id: string) { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`account:${id}`)); + return Buffer.from(bytes).toString("hex"); +} + +test("removes cached Spark and representative reserve windows and consumption while offline", async () => { + const cached = { + account: await accountFingerprint("account-one"), + latest: { + plan: "pro", + fetchedAt: 123, + windows: [ + { + id: "codex:primary", + label: null, + seconds: 18_000, + used: 12.5, + resetsAt: 2_000_000_000_000, + }, + { + id: "CODEX-BENGALFOX:primary", + label: "GPT 5.3 Codex Spark", + seconds: 18_000, + used: 80, + resetsAt: 2_000_000_000_000, + }, + { + id: "codex_luna_reserve:primary", + label: "Luna Reserve", + seconds: 18_000, + used: 60, + resetsAt: 2_000_000_000_000, + }, + { + id: "codex_luna:primary", + label: "GPT-5.6 Luna", + seconds: 18_000, + used: 20, + resetsAt: 2_000_000_000_000, + }, + ], + }, + sessions: { + root: { + "codex:primary": { + resetsAt: 2_000_000_000_000, + lastUsed: 12.5, + consumed: 2, + }, + "CODEX-BENGALFOX:primary": { + resetsAt: 2_000_000_000_000, + lastUsed: 80, + consumed: 40, + }, + "codex_luna_reserve:primary": { + resetsAt: 2_000_000_000_000, + lastUsed: 60, + consumed: 30, + }, + "codex_luna:primary": { + resetsAt: 2_000_000_000_000, + lastUsed: 20, + consumed: 3, + }, + }, + }, + }; + const result = fixture( + { + type: "oauth", + access: "opaque", + refresh: "refresh", + metadata: { accountID: "account-one" }, + }, + [new Error("offline")], + 1_000_000, + [Promise.resolve(cached)], + ); + + await result.tracker.init(); + const status = await result.tracker.status("root"); + expect(status?.windows.map((window) => window.id)).toEqual([ + "codex:primary", + "codex_luna:primary", + ]); + expect(status?.windows[0]?.sessionConsumed).toBe(2); + expect(status?.windows[1]?.sessionConsumed).toBe(3); + await result.tracker.dispose(); +}); + test("activates only for OAuth and establishes an awaited root baseline shared by children", async () => { const key = fixture({ type: "key", key: "secret" }, [plus]); await key.tracker.init(); diff --git a/tests/tui.test.ts b/tests/tui.test.ts new file mode 100644 index 0000000..e2f35a9 --- /dev/null +++ b/tests/tui.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import type { Plugin } from "@opencode/plugin/tui"; +import { testRender, type JSX } from "@opentui/solid"; +import { createSignal } from "solid-js"; +import { build } from "../scripts/build.js"; +import type { QuotaStatus } from "../src/quota.js"; + +type SlotRender = (input: { readonly sessionID: string }) => JSX.Element; + +const status = (plan: string, remaining: number): QuotaStatus => ({ + plan, + fetchedAt: 1, + windows: [ + { + id: "codex:primary", + label: null, + seconds: 18_000, + used: 100 - remaining, + resetsAt: 2, + remaining, + sessionConsumed: 1.5, + }, + ], +}); + +test("built quota slot stays reactive across pending, empty, failure, update, and session states", async () => { + await build(); + const { default: plugin } = await import("../dist/tui.js"); + const pending = Promise.withResolvers(); + const responses: Array<() => Promise> = [() => pending.promise]; + const sessions: string[] = []; + let slotRender: SlotRender | undefined; + let notifyUpdated: (() => void) | undefined; + + const quota = { + status(input: { sessionID: string }) { + sessions.push(input.sessionID); + const response = responses.shift(); + if (!response) throw new Error("Missing test response"); + return response(); + }, + events: { + on(_event: "updated", callback: () => void) { + notifyUpdated = callback; + return () => undefined; + }, + }, + }; + const contextFixture = { + client: { rpc: () => quota }, + ui: { + slot(claim: { render: SlotRender }) { + slotRender = claim.render; + return () => undefined; + }, + }, + theme: { text: { default: "#ffffff", muted: "#888888" } }, + }; + // SAFETY: The slot setup only reads the client RPC, slot registration, and theme + // members implemented by this focused rendering fixture. + const context = Object.assign({} as Plugin.Context, contextFixture); + const cleanup = await plugin.setup(context); + if (!slotRender || !notifyUpdated) throw new Error("Plugin did not register its slot"); + + const [sessionID, setSessionID] = createSignal("session-one"); + const renderSlot = slotRender; + const setup = await testRender( + () => + renderSlot({ + get sessionID() { + return sessionID(); + }, + }), + { width: 70, height: 5 }, + ); + + try { + await setup.renderOnce(); + expect(setup.captureCharFrame()).not.toContain("ChatGPT quota"); + + pending.resolve(status("plus", 75)); + await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)")); + expect(setup.captureCharFrame()).toContain("5h: 75% left ยท ~1.5pp this session"); + + responses.push(async () => null); + notifyUpdated(); + await setup.waitForFrame((frame) => !frame.includes("ChatGPT quota")); + + responses.push(async () => { + throw new Error("disconnected"); + }); + notifyUpdated(); + await setup.waitFor(() => sessions.length === 3); + expect(setup.captureCharFrame()).not.toContain("ChatGPT quota"); + + responses.push(async () => status("pro", 42)); + setSessionID("session-two"); + await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)")); + expect(sessions).toEqual(["session-one", "session-one", "session-one", "session-two"]); + } finally { + setup.renderer.destroy(); + if (cleanup) await cleanup(); + } +}); diff --git a/tsconfig.json b/tsconfig.json index f9bf5d7..d7eba60 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "rootDir": ".", "outDir": "dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "tests/**/*.ts"] }