This commit is contained in:
@@ -4,7 +4,7 @@ OpenCode V2 plugin providing a `coordinate` agent and fast/good/deep worker pair
|
|||||||
|
|
||||||
## Install the latest release
|
## 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:
|
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.
|
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
|
## 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:
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode/client": "0.0.0-beta-19378",
|
"@opencode/client": "2.0.2",
|
||||||
"@opencode/plugin": "0.0.0-beta-19378",
|
"@opencode/plugin": "2.0.2",
|
||||||
|
"@opencode/schema": "2.0.2",
|
||||||
"@opentui/core": "0.5.10",
|
"@opentui/core": "0.5.10",
|
||||||
"@opentui/solid": "0.5.10",
|
"@opentui/solid": "0.5.10",
|
||||||
"@oxlint/plugins": "1.82.0",
|
"@oxlint/plugins": "1.82.0",
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode/plugin": "0.0.0-beta-19378",
|
"@opencode/plugin": "2.0.2",
|
||||||
"@opentui/core": ">=0.5.10",
|
"@opentui/core": ">=0.5.10",
|
||||||
"@opentui/solid": ">=0.5.10",
|
"@opentui/solid": ">=0.5.10",
|
||||||
"solid-js": ">=1.9.0",
|
"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=="],
|
"@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=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
|
|||||||
Executable
+79
@@ -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"
|
||||||
+6
-5
@@ -33,12 +33,13 @@
|
|||||||
"lint": "oxlint --deny-warnings .",
|
"lint": "oxlint --deny-warnings .",
|
||||||
"format": "oxfmt --write .",
|
"format": "oxfmt --write .",
|
||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"test": "bun test",
|
"test": "bun test --conditions=browser",
|
||||||
"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"
|
"build": "bun scripts/build.ts && tsc --project tsconfig.build.json --emitDeclarationOnly"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode/client": "0.0.0-beta-19378",
|
"@opencode/client": "2.0.2",
|
||||||
"@opencode/plugin": "0.0.0-beta-19378",
|
"@opencode/plugin": "2.0.2",
|
||||||
|
"@opencode/schema": "2.0.2",
|
||||||
"@opentui/core": "0.5.10",
|
"@opentui/core": "0.5.10",
|
||||||
"@opentui/solid": "0.5.10",
|
"@opentui/solid": "0.5.10",
|
||||||
"@oxlint/plugins": "1.82.0",
|
"@oxlint/plugins": "1.82.0",
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode/plugin": "0.0.0-beta-19378",
|
"@opencode/plugin": "2.0.2",
|
||||||
"@opentui/core": ">=0.5.10",
|
"@opentui/core": ">=0.5.10",
|
||||||
"@opentui/solid": ">=0.5.10",
|
"@opentui/solid": ">=0.5.10",
|
||||||
"solid-js": ">=1.9.0"
|
"solid-js": ">=1.9.0"
|
||||||
|
|||||||
@@ -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();
|
||||||
+7
-1
@@ -65,6 +65,12 @@ export default Plugin.define({
|
|||||||
// asynchronous setup is running.
|
// asynchronous setup is running.
|
||||||
const updates = (async () => {
|
const updates = (async () => {
|
||||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
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") {
|
if (event.type === "catalog.updated") {
|
||||||
models = await discover(context, selected.provider);
|
models = await discover(context, selected.provider);
|
||||||
await context.agent.reload();
|
await context.agent.reload();
|
||||||
@@ -223,7 +229,7 @@ export default Plugin.define({
|
|||||||
)?.effect ?? "ask";
|
)?.effect ?? "ask";
|
||||||
if (effect !== "allow")
|
if (effect !== "allow")
|
||||||
throw new Error(
|
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) => {
|
await context.tool.transform((editor) => {
|
||||||
|
|||||||
+67
-2
@@ -9,6 +9,34 @@ const POLL_MS = 60_000;
|
|||||||
const TIMEOUT_MS = 5_000;
|
const TIMEOUT_MS = 5_000;
|
||||||
const STORAGE_KEY = "chatgpt-quota";
|
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<QuotaWindow, "id" | "label">) {
|
||||||
|
return isExcludedLimit(window.id.split(":", 1)[0], window.label);
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuotaWindow {
|
export interface QuotaWindow {
|
||||||
id: string;
|
id: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
@@ -56,6 +84,24 @@ const storedState = z.object({
|
|||||||
});
|
});
|
||||||
type StoredState = z.infer<typeof storedState>;
|
type StoredState = z.infer<typeof storedState>;
|
||||||
|
|
||||||
|
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<Plugin.Context, "integration" | "session" | "storage">;
|
type QuotaContext = Pick<Plugin.Context, "integration" | "session" | "storage">;
|
||||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
@@ -64,6 +110,10 @@ const quotaWindow = z.object({
|
|||||||
used_percent: z.number().finite(),
|
used_percent: z.number().finite(),
|
||||||
reset_at: 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({
|
const quotaPayload = z.object({
|
||||||
plan_type: z.string(),
|
plan_type: z.string(),
|
||||||
account_id: z.string().optional(),
|
account_id: z.string().optional(),
|
||||||
@@ -74,7 +124,18 @@ const quotaPayload = z.object({
|
|||||||
secondary_window: quotaWindow.nullish(),
|
secondary_window: quotaWindow.nullish(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
additional_rate_limits: z
|
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(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
limit_name: z.string(),
|
limit_name: z.string(),
|
||||||
@@ -89,6 +150,7 @@ const quotaPayload = z.object({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.nullish(),
|
.nullish(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeWindow(
|
function normalizeWindow(
|
||||||
@@ -218,7 +280,10 @@ export class QuotaTracker {
|
|||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
const stored = parsed.success ? parsed.data : undefined;
|
const stored = parsed.success ? parsed.data : undefined;
|
||||||
if (generation !== this.generation) return;
|
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 (generation !== this.generation) return;
|
||||||
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ export default Plugin.define({
|
|||||||
|
|
||||||
const remove = context.ui.slot({
|
const remove = context.ui.slot({
|
||||||
append: "sidebar.footer",
|
append: "sidebar.footer",
|
||||||
render: ({ sessionID }) => <Slot sessionID={sessionID} />,
|
render: (input) => <Slot sessionID={input.sessionID} />,
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
remove();
|
remove();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+28
-2
@@ -46,8 +46,14 @@ async function host(
|
|||||||
const registration = { async dispose() {} };
|
const registration = { async dispose() {} };
|
||||||
let transform!: (editor: AgentEditor) => void;
|
let transform!: (editor: AgentEditor) => void;
|
||||||
let reloaded = Promise.withResolvers<void>();
|
let reloaded = Promise.withResolvers<void>();
|
||||||
let events!: ReadableStreamDefaultController<{ type: string }>;
|
let events!: ReadableStreamDefaultController<{
|
||||||
const stream = new ReadableStream<{ type: string }>({
|
type: string;
|
||||||
|
location?: { directory: string; workspaceID?: string };
|
||||||
|
}>;
|
||||||
|
const stream = new ReadableStream<{
|
||||||
|
type: string;
|
||||||
|
location?: { directory: string; workspaceID?: string };
|
||||||
|
}>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
events = controller;
|
events = controller;
|
||||||
},
|
},
|
||||||
@@ -101,6 +107,7 @@ async function host(
|
|||||||
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
||||||
// services are deliberately absent so unexpected new dependencies fail the test.
|
// services are deliberately absent so unexpected new dependencies fail the test.
|
||||||
const context = {
|
const context = {
|
||||||
|
location: { directory: "/test" },
|
||||||
options,
|
options,
|
||||||
catalog: {
|
catalog: {
|
||||||
model: {
|
model: {
|
||||||
@@ -227,6 +234,10 @@ async function host(
|
|||||||
events.enqueue({ type: "agent.updated" });
|
events.enqueue({ type: "agent.updated" });
|
||||||
await reloaded.promise;
|
await reloaded.promise;
|
||||||
},
|
},
|
||||||
|
changeBuildPermissionsElsewhere(permissions: Agent.Info["permissions"]) {
|
||||||
|
build.permissions = [...permissions];
|
||||||
|
events.enqueue({ type: "agent.updated", location: { directory: "/other" } });
|
||||||
|
},
|
||||||
async changeCodegraph(
|
async changeCodegraph(
|
||||||
server:
|
server:
|
||||||
| { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
| { 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");
|
).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 () => {
|
test("todo lists persist within one session without leaking to another", async () => {
|
||||||
const result = await host();
|
const result = await host();
|
||||||
const todos = [{ id: "1", content: "Implement", status: "in_progress", priority: "high" }];
|
const todos = [{ id: "1", content: "Implement", status: "in_progress", priority: "high" }];
|
||||||
|
|||||||
@@ -95,6 +95,67 @@ test("normalizes additional named limits with stable IDs distinct from equal dur
|
|||||||
).toEqual(["codex_luna:primary"]);
|
).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(
|
function fixture(
|
||||||
credential:
|
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 () => {
|
test("activates only for OAuth and establishes an awaited root baseline shared by children", async () => {
|
||||||
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
||||||
await key.tracker.init();
|
await key.tracker.init();
|
||||||
|
|||||||
@@ -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<QuotaStatus | null>();
|
||||||
|
const responses: Array<() => Promise<QuotaStatus | null>> = [() => 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
+1
-1
@@ -19,5 +19,5 @@
|
|||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "tests/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user