From f527778b290c399ce0a409e0c348b08a3c744891 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Sun, 4 Oct 2026 12:49:41 +0200 Subject: [PATCH] feat: split plugins and cleanup --- README.md | 41 +- docs/multi-work.md | 13 +- examples/custom.opencode.jsonc | 4 +- examples/gpt.opencode.jsonc | 4 +- index.js | 1 - install-local.sh | 91 +- install.sh | 71 +- package.json | 21 +- scripts/build.ts | 16 +- scripts/install.ts | 197 ++++ scripts/test-multi-work-host.ts | 103 +- src/agents-rpc.ts | 20 + src/agents.ts | 303 ++++++ src/config.ts | 6 - src/index.ts | 449 --------- src/multi-work.ts | 69 +- src/playwright.ts | 137 --- src/quota-plugin.ts | 54 + src/quota.ts | 15 +- tests/composite.test.ts | 44 +- tests/fixtures/native-agents-plugin.ts | 27 + tests/fixtures/native-host-plugin.ts | 23 +- tests/install-local.test.ts | 236 ----- tests/install.test.ts | 706 +++++++++----- tests/multi-work.test.ts | 49 +- tests/package.test.ts | 49 + tests/plugin.test.ts | 1246 ++++++------------------ tests/plugins.test.ts | 84 ++ tests/quota.test.ts | 27 +- 29 files changed, 1793 insertions(+), 2313 deletions(-) delete mode 100644 index.js create mode 100644 scripts/install.ts create mode 100644 src/agents-rpc.ts create mode 100644 src/agents.ts delete mode 100644 src/index.ts delete mode 100644 src/playwright.ts create mode 100644 src/quota-plugin.ts create mode 100644 tests/fixtures/native-agents-plugin.ts delete mode 100644 tests/install-local.test.ts create mode 100644 tests/package.test.ts create mode 100644 tests/plugins.test.ts diff --git a/README.md b/README.md index 65f9cd3..1561a8c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # stanp-opencode -stanp-opencode is an OpenCode V2 coordinator with specialist workers for fast, good, and deep tasks. +One package with three independently loadable OpenCode V2 plugins: + +- **Agents** (`stanp-opencode.agents`): coordinate, specialist workers, routing, inherited permissions, native delegation, and the anti-overengineering skill. Existing user skill definitions are preserved. +- **Multi-work** (`stanp-opencode.multi-work`): Herdr delegation and authenticated child bindings. Requires Agents **loaded first**, including at destination locations. +- **Quota** (`stanp-opencode.quota`): independent ChatGPT quota tracking and its TUI (`stanp-opencode.quota-tui`). The RPC remains `stanp-opencode.quota`. + +There is no Personal plugin. Todos use native OpenCode tools. Plugins never register or alter MCP servers; user MCP configuration remains yours. ## Install the latest release @@ -9,38 +15,37 @@ Requires OpenCode 2.0.15, Bun 1.4.2+, Bash, curl, jq, and `sha256sum` on Linux o Run the installer without sudo: ```sh -curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash +curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash -s -- --plugins agents,multi-work,quota ``` -Pass `--playwright` to let the plugin automatically configure a Playwright MCP server: - -```sh -curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash -s -- --playwright -``` - -Rerun the command to upgrade to the latest published release. +Use any nonempty subset; Multi-work requires Agents. Explicit `--plugins multi-work` fails rather than silently adding Agents. When run from a terminal without the flag, the installer asks for a selection and offers Agents if Multi-work is selected alone. Noninteractive fresh installs require `--plugins`. Upgrades without a flag retain the existing selection; the old monolith upgrades to all three. Use an explicit flag to change that selection. ## Install from a local checkout -This requires Bash, jq, and Bun 1.4.2+: +This requires Bash and Bun 1.4.2+: ```sh -bash /path/to/stanp-opencode/install-local.sh +bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota ``` -Add `--playwright` to this command to enable automatic Playwright MCP configuration. - The script installs dependencies and builds the checkout before installing it. After either installation, restart `opencode` or run `opencode service restart` for an existing service. Connect OpenAI and select `coordinate`. The required models must be available for the workers to run. Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept `OPENCODE_CONFIG_DIR` as an override. +They update only owned registrations in `opencode.json` and the owned installation directory; unrelated settings, plugins, MCP configuration, and `opencode.jsonc` remain unchanged. Quota's TUI is installed only when Quota is selected. + +## Clean-break upgrade + +The monolith, root package entrypoint, and `basePlugin` export are deliberately retired. Use the `./agents`, `./multi-work`, and `./quota` package exports, or the installed directory paths below. Replace manually configured legacy registrations too; do not load the old monolith alongside the new plugins. + +Distinct plugin identities intentionally start fresh storage: **quota tracking resets and existing Multi-work bindings cannot resume**. Start new delegated sessions. Old host storage is not migrated or edited. Existing Git worktrees and Herdr workspaces are never deleted or modified by this upgrade. New bindings remain resumable across restarts within the new Multi-work identity. ## Configuration See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). -For an installed plugin, use `./plugins/stanp-opencode` as the plugin package path. In GPT mode with the `openai` provider, set top-level `lunaPriority` to `false` to avoid requesting priority pricing. Other providers do not accept this option. Custom top-level and per-agent `provider` and `lunaPriority` fields are invalid. +Installed package paths are `./plugins/stanp-opencode/agents`, `./plugins/stanp-opencode/multi-work`, and `./plugins/stanp-opencode/quota`, in that order. Model options belong only to Agents. In GPT mode with the `openai` provider, set `lunaPriority` to `false` to avoid requesting priority pricing. Other providers do not accept this option. Custom top-level and per-agent `provider` and `lunaPriority` fields are invalid. Custom configurations require the four `fast-*` and `good-*` assignments; `deep-lo` and `deep-hi` are optional. Assignments use `provider/model` model names and require both `model` and `effort` when supplied. If either deep assignment is omitted, both deep workers are disabled and orchestration uses `good-lo`. The legacy optional `coordinate` assignment is accepted for compatibility but ignored. Orchestration inherits `deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable. @@ -65,12 +70,6 @@ For new coordinate tasks, omit `worktree` to share the current root checkout, or session/job handling. See [usage, permissions, failure handling, and verification limits](docs/multi-work.md). -## Playwright - -When installed with `--playwright`, the plugin prefers a local Playwright MCP installation. If ordinary Playwright is installed, it can download and run the MCP package with `npx`; otherwise Docker is required for the automatic fallback. Automatically configured servers allow five minutes for startup because the first launch may need to download a package or container image. Docker reuses its cached image on later launches. Existing `playwright` configuration, including its timeout, is preserved. - -If `opencode mcp list` reports `MCP error -32001: Request timed out`, prefetch the Docker fallback with `docker pull mcr.microsoft.com/playwright/mcp`, or install `@playwright/mcp` in the project so the plugin can launch it directly. Then reconnect the server from `/mcps` or restart OpenCode. A custom `playwright` entry under `mcp.servers` can also set its own `timeout.startup` in milliseconds. - ## Uninstall -Remove `plugins/stanp-opencode` from the selected configuration directory and remove its configuration entry, then restart OpenCode. +Remove `plugins/stanp-opencode` from the selected configuration directory and its three owned configuration entries, then restart OpenCode. This does not remove worktrees or host storage. diff --git a/docs/multi-work.md b/docs/multi-work.md index b8cd437..928dfab 100644 --- a/docs/multi-work.md +++ b/docs/multi-work.md @@ -35,6 +35,10 @@ creation. Creating/opening the root workspace remains a user action. ## Configuration +Load **Agents before Multi-work**, at both root and destination locations. Multi-work fails closed without the native Agents RPC registration; there is no shared module-state registry. Its own storage identity is `stanp-opencode.multi-work`. + +Upgrading from the old `stanp-opencode` monolith is a clean break: existing bindings cannot resume, and quota tracking resets. Start new delegated sessions. Existing worktrees, workspaces, and old host storage remain untouched. New bindings persist normally under the new identity. + Use OpenCode **2.0.15**, the version this integration targets. The package retains Effect `4.0.0-rc.112` because OpenCode 2.0.15's client requires that exact peer version. At both the root and destination locations, configure: @@ -123,10 +127,9 @@ starts. Root-local delegation performs no Herdr creation and therefore does not require `herdr_worktree`; worker edit/shell permissions remain inherited and enforced normally. -Worktree permissions inherit the base plugin's **effective build-permission +Worktree permissions inherit Agents' **effective build-permission snapshot**, including configuration applied after plugin transforms. Its existing -`agent.updated` listener refreshes that snapshot and replays the registry when it -changes. Later `multi-work` agent rules and session rules retain their precedence; +`agent.updated` listener refreshes that snapshot. Multi-work reads it through the native Agents RPC and replays when it changes. Later `multi-work` agent rules and session rules retain their precedence; inheritance does not turn a build deny into an unconditional execution-time veto against an explicitly configured later allow. @@ -163,7 +166,7 @@ already accepted by Herdr. Unready children are not resumable as completed setup The ordinary suite uses isolated Git repositories and simulated Herdr responses. It covers gating, source refs, ordering, failures, and interruption. Composite tests -run the exported plugin through the real Promise adapter, apply configuration after +load Agents through the real Promise adapter followed by Multi-work, apply configuration after both plugin transforms, replay twice, and test separate location registries sharing disk-backed bindings across plugin scope restarts. Their executor is simulated. @@ -189,6 +192,8 @@ Verified with this host check: and its synthetic notification to the parent. - Interrupting a foreground RPC while the child awaits its model response causes the native executor to interrupt the child; the parent remains at the root. +- Manual configurations with missing or later-loaded Agents fail Multi-work activation. +- Quota alone activates without Agents or Multi-work, and its stable RPC returns null without credentials. The fixture RPC invokes the actual wrapped executor; it does not exercise the parent model's tool-call dispatch or rendered tool cards. Recovery of an **in-flight** diff --git a/examples/custom.opencode.jsonc b/examples/custom.opencode.jsonc index 04a11ae..3182102 100644 --- a/examples/custom.opencode.jsonc +++ b/examples/custom.opencode.jsonc @@ -8,7 +8,7 @@ // Models use provider/model names; each assignment needs model and effort. "plugins": [ { - "package": "stanp-opencode", + "package": "./plugins/stanp-opencode/agents", "options": { "mode": "custom", "agents": { @@ -21,5 +21,7 @@ }, }, }, + { "package": "./plugins/stanp-opencode/multi-work" }, + { "package": "./plugins/stanp-opencode/quota" }, ], } diff --git a/examples/gpt.opencode.jsonc b/examples/gpt.opencode.jsonc index eb5b632..f9786c6 100644 --- a/examples/gpt.opencode.jsonc +++ b/examples/gpt.opencode.jsonc @@ -6,7 +6,7 @@ // Availability uses the native catalog, including integrations and earlier plugins. "plugins": [ { - "package": "stanp-opencode", + "package": "./plugins/stanp-opencode/agents", "options": { "mode": "gpt", "provider": "openai", @@ -15,6 +15,8 @@ "lunaPriority": true, }, }, + { "package": "./plugins/stanp-opencode/multi-work" }, + { "package": "./plugins/stanp-opencode/quota" }, ], // Select coordinate in the agent picker. Build and the default stay unchanged. } diff --git a/index.js b/index.js deleted file mode 100644 index ab8219c..0000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./dist/index.js"; diff --git a/install-local.sh b/install-local.sh index 4963aa8..a022e69 100755 --- a/install-local.sh +++ b/install-local.sh @@ -1,92 +1,21 @@ #!/usr/bin/env bash set -euo pipefail - -playwright=false +selection=() if (($#)); then - if [[ "$#" -ne 1 || "$1" != "--playwright" ]]; then - printf 'Usage: %s [--playwright]\n' "$0" >&2 + if [[ "$#" -ne 2 || "$1" != "--plugins" || ! "$2" =~ ^(agents|multi-work|quota)(,(agents|multi-work|quota))*$ ]]; then + printf 'Usage: %s [--plugins agents,multi-work,quota]\n' "$0" >&2 exit 2 fi - playwright=true + if [[ ",$2," == *,multi-work,* && ",$2," != *,agents,* ]]; then + printf 'Multi-work requires Agents\n' >&2 + exit 2 + fi + selection=("$2") fi - -for command in bun jq; do - command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; } -done - +command -v bun >/dev/null || { printf 'Required command not found: bun\n' >&2; exit 1; } 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" && typeof plugin.effect !== "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 --argjson playwright "$playwright" ' - 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 - if $playwright then - .plugins |= map(if .package == "./plugins/stanp-opencode" then .options = ((.options // {}) + { playwright: true }) else . end) - else . end - else - .plugins += [{ - package: "./plugins/stanp-opencode", - options: ({ mode: "gpt", provider: "openai", lunaPriority: true } + if $playwright then { playwright: true } else {} end) - }] - 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 opencode to load it.\n' "$checkout" "$config" "$config_file" +bun "$checkout/dist/install.js" "$checkout" "$config" "${selection[@]}" diff --git a/install.sh b/install.sh index 1ebc21c..921e616 100755 --- a/install.sh +++ b/install.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -playwright=false +selection=() if (($#)); then - if [[ "$#" -ne 1 || "$1" != "--playwright" ]]; then - printf 'Usage: %s [--playwright]\n' "$0" >&2 + if [[ "$#" -ne 2 || "$1" != "--plugins" || ! "$2" =~ ^(agents|multi-work|quota)(,(agents|multi-work|quota))*$ ]]; then + printf 'Usage: %s [--plugins agents,multi-work,quota]\n' "$0" >&2 exit 2 fi - playwright=true + if [[ ",$2," == *,multi-work,* && ",$2," != *,agents,* ]]; then + printf 'Multi-work requires Agents\n' >&2 + exit 2 + fi + selection=("$2") fi for command in bun curl jq sha256sum tar; do @@ -43,61 +47,4 @@ if [[ ! "$actual" =~ ^[a-fA-F0-9]{64}$ || "$actual" != "$checksum" ]]; then fi tar -xzf "$install/stanp-opencode.tgz" -C "$install" -bun -e ' - if (!await Bun.file(process.argv[1]).exists()) { - throw new Error("Latest release lacks the OpenCode V2 directory entrypoint. Publish a V2-compatible release first."); - } - const { default: plugin } = await import(process.argv[1]); - if (!plugin || plugin.id !== "stanp-opencode" || (typeof plugin.setup !== "function" && typeof plugin.effect !== "function")) { - throw new Error("Latest release is not a native OpenCode V2 plugin. Publish a V2-compatible release first."); - } - 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("Latest release lacks the ChatGPT quota TUI plugin."); - } -' "$install/package/dist/index.js" "$install/package/dist/tui.js" "$install/package/package.json" - -tag=$(jq -r '.tag_name' "$install/release.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 --argjson playwright "$playwright" ' - 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 - if $playwright then - .plugins |= map(if .package == "./plugins/stanp-opencode" then .options = ((.options // {}) + { playwright: true }) else . end) - else . end - else - .plugins += [{ - package: "./plugins/stanp-opencode", - options: ({ mode: "gpt", provider: "openai", lunaPriority: true } + if $playwright then { playwright: true } else {} end) - }] - 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" -mv "$install/package/dist/index.js" "$staged/index.js" -mv "$install/package/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 -rm -rf "$backup" -rm -f "$config/plugins/stanp-opencode.js" -mv "$install/opencode.json" "$config_file" -printf 'Installed stanp-opencode %s into %s/plugins/stanp-opencode and updated %s\nRestart opencode to load it.\n' "$tag" "$config" "$config_file" +bun "$install/package/dist/install.js" "$install/package" "$config" "${selection[@]}" diff --git a/package.json b/package.json index 08c34dc..169ef16 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,33 @@ "version": "1.0.0", "description": "Stanp agents for OpenCode", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "./agents": { + "types": "./dist/agents.d.ts", + "import": "./dist/agents.js" + }, + "./multi-work": { + "types": "./dist/multi-work.d.ts", + "import": "./dist/multi-work.js" + }, + "./quota": { + "types": "./dist/quota-plugin.d.ts", + "import": "./dist/quota-plugin.js" }, "./tui": { "types": "./dist/tui.d.ts", "import": "./dist/tui.js" }, + "./quota/tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js" + }, "./quota-rpc": { "types": "./dist/quota-rpc.d.ts", "import": "./dist/quota-rpc.js" } }, "files": [ - "index.js", "dist", "docs", "examples" diff --git a/scripts/build.ts b/scripts/build.ts index 4adeda0..0ee3381 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,9 +1,23 @@ import solidPlugin from "@opentui/solid/bun-plugin"; +import { rm } from "node:fs/promises"; export async function build() { + for (const file of ["index.js", "index.d.ts", "playwright.d.ts"]) + await rm(`dist/${file}`, { force: true }); const results = await Promise.all([ Bun.build({ - entrypoints: ["src/index.ts", "src/quota-rpc.ts"], + entrypoints: [ + "src/agents.ts", + "src/multi-work.ts", + "src/quota-plugin.ts", + "src/quota-rpc.ts", + ], + outdir: "dist", + target: "bun", + format: "esm", + }), + Bun.build({ + entrypoints: ["scripts/install.ts"], outdir: "dist", target: "bun", format: "esm", diff --git a/scripts/install.ts b/scripts/install.ts new file mode 100644 index 0000000..fddb68b --- /dev/null +++ b/scripts/install.ts @@ -0,0 +1,197 @@ +import { mkdir, rename, rm, copyFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { z } from "zod"; + +const names = ["agents", "multi-work", "quota"] as const; +const base = "./plugins/stanp-opencode"; +const entrySchema = z + .object({ package: z.string(), options: z.record(z.string(), z.json()).optional() }) + .catchall(z.json()); +type Entry = z.infer; +const pluginSchema = z.union([z.string(), entrySchema]); +const registrationSchema = z + .union([z.string().transform((packageName) => ({ package: packageName })), entrySchema]) + .pipe(entrySchema); +const configSchema = z + .object({ plugins: z.array(pluginSchema).optional(), $schema: z.string().optional() }) + .catchall(z.json()); +const nativePlugin = z.union([ + z.object({ id: z.string(), setup: z.function() }), + z.object({ id: z.string(), effect: z.function() }), +]); +type DirectoryExports = { ".": string; "./tui"?: string }; + +export async function install(source: string, config: string, selection?: string) { + const absoluteBase = join(config, "plugins", "stanp-opencode"); + const monolith = (target: string) => + target === base || target === absoluteBase || /^stanp-opencode(?:@[^/]+)?$/.test(target); + const owned = new Set( + names.flatMap((name) => [ + `${base}/${name}`, + `${absoluteBase}/${name}`, + `stanp-opencode/${name}`, + ]), + ); + const isOwned = (target: string) => monolith(target) || owned.has(target); + const configFile = join(config, "opencode.json"); + const current = configSchema.parse( + (await Bun.file(configFile).exists()) ? await Bun.file(configFile).json() : {}, + ); + const entries = current.plugins ?? []; + // Normalize only the lookup view; unrelated entries retain their original representation. + const configured = entries.map((entry) => registrationSchema.parse(entry)); + const previous = configured.filter((entry) => isOwned(entry.package)); + const oldMonolith = previous.some((entry) => monolith(entry.package)); + const retained = oldMonolith + ? names + : names.filter((name) => + previous.some( + (entry) => + entry.package === `${base}/${name}` || + entry.package === `${absoluteBase}/${name}` || + entry.package === `stanp-opencode/${name}`, + ), + ); + let chosen: string[]; + if (selection !== undefined) chosen = selection.split(","); + else if (process.stdin.isTTY) { + const input = createInterface({ input: process.stdin, output: process.stdout }); + try { + const defaults = retained.length ? retained : names; + chosen = ( + ( + await input.question( + `Plugins (agents,multi-work,quota) [${defaults.join(",")}]: `, + ) + ).trim() || defaults.join(",") + ).split(","); + if (chosen.includes("multi-work") && !chosen.includes("agents")) { + if ( + /^(y|yes)?$/i.test( + ( + await input.question("Multi-work requires Agents. Add Agents? [Y/n]: ") + ).trim(), + ) + ) + chosen.push("agents"); + } + } finally { + input.close(); + } + } else { + if (!retained.length) + throw new Error( + "Noninteractive installation requires --plugins agents,multi-work,quota (or a valid subset)", + ); + chosen = [...retained]; + } + if ( + !z.array(z.enum(names)).min(1).safeParse(chosen).success || + new Set(chosen).size !== chosen.length + ) + throw new Error("Invalid --plugins selection; use unique names: agents,multi-work,quota"); + if (chosen.includes("multi-work") && !chosen.includes("agents")) + throw new Error("Multi-work requires Agents; select agents,multi-work"); + const selected = names.filter((name) => chosen.includes(name)); + const registrations: Entry[] = selected.map((name) => { + const prior = previous.find( + (entry) => + entry.package === `${base}/${name}` || + entry.package === `${absoluteBase}/${name}` || + entry.package === `stanp-opencode/${name}` || + (name === "agents" && monolith(entry.package)), + ); + const options: NonNullable = {}; + if (name === "agents" && !prior?.options) + Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true }); + Object.assign(options, prior?.options); + delete options.playwright; + const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` }); + delete entry.options; + if (Object.keys(options).length) entry.options = options; + return entry; + }); + const first = configured.findIndex((entry) => isOwned(entry.package)); + const next = entries.filter((_entry, index) => !isOwned(configured[index]!.package)); + next.splice(first < 0 ? next.length : first, 0, ...registrations); + const updated = { + ...current, + $schema: current.$schema ?? "https://opencode.ai/config.json", + plugins: next, + }; + const parent = join(config, "plugins"); + await mkdir(parent, { recursive: true }); + const staged = join(parent, `.stanp-opencode.new.${process.pid}`); + const backup = join(parent, `.stanp-opencode.old.${process.pid}`); + const target = join(parent, "stanp-opencode"); + const configStage = join(config, `.stanp-opencode.config.${process.pid}`); + let saved = false; + let activated = false; + try { + await mkdir(staged); + for (const name of selected) { + const file = name === "quota" ? "quota-plugin" : name; + const entrypoint = join(source, "dist", `${file}.js`); + const { default: definition } = await import(entrypoint); + const plugin = nativePlugin.parse(definition); + if (plugin.id !== `stanp-opencode.${name}`) + throw new Error(`Invalid native OpenCode plugin: ${name}`); + const directory = join(staged, name); + await mkdir(directory); + await copyFile(entrypoint, join(directory, "index.js")); + const exports: DirectoryExports = { ".": "./index.js" }; + if (name === "quota") { + const tui = join(source, "dist", "tui.js"); + if ( + !new Bun.Transpiler({ loader: "js" }) + .scan(await Bun.file(tui).text()) + .exports.includes("default") + ) + throw new Error("Quota lacks its TUI entrypoint"); + await copyFile(tui, join(directory, "tui.js")); + exports["./tui"] = "./tui.js"; + } + await Bun.write( + join(directory, "package.json"), + JSON.stringify({ + name: `stanp-opencode-${name}`, + type: "module", + main: "./index.js", + exports, + }), + ); + } + await Bun.write(configStage, JSON.stringify(updated, null, 4) + "\n"); + try { + await rename(target, backup); + saved = true; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) + throw error; + } + await rename(staged, target); + activated = true; + await rename(configStage, configFile); + } catch (error) { + if (activated) await rm(target, { recursive: true, force: true }); + if (saved) await rename(backup, target); + throw error; + } finally { + await rm(staged, { recursive: true, force: true }); + await rm(configStage, { force: true }); + } + if (saved) await rm(backup, { recursive: true, force: true }); + await rm(join(parent, "stanp-opencode.js"), { force: true }); + console.log(`Installed ${selected.join(", ")}. Restart OpenCode.`); + if (oldMonolith) + console.log( + "Clean break: quota tracking resets; old Multi-work bindings cannot resume. Existing worktrees are untouched.", + ); +} + +if (import.meta.main) { + const [source, config, selection] = process.argv.slice(2); + if (!source || !config) throw new Error("Expected package directory and config directory"); + await install(source, config, selection); +} diff --git a/scripts/test-multi-work-host.ts b/scripts/test-multi-work-host.ts index bed1715..a223ca0 100644 --- a/scripts/test-multi-work-host.ts +++ b/scripts/test-multi-work-host.ts @@ -58,18 +58,26 @@ await writeFile( `#!/bin/sh\ncase "$1 $2" in\n"worktree create") [ "$9" = "--label" ] && [ "\${10}" = "Fix native host" ] || exit 8; printf '%s\\n%s\\n' "$6" "\${10}" > '${herdrCreation}'; git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, ); await chmod(join(bin, "herdr"), 0o755); -const built = await Bun.build({ - entrypoints: [resolve("tests/fixtures/native-host-plugin.ts")], - outdir: join(temp, "plugin"), - naming: "index.js", - target: "bun", - format: "esm", -}); -if (!built.success) throw new Error(String(built.logs)); +for (const [name, fixture] of [ + ["agents", "native-agents-plugin"], + ["multi-work", "native-host-plugin"], +] as const) { + const built = await Bun.build({ + entrypoints: [resolve(`tests/fixtures/${fixture}.ts`)], + outdir: join(temp, "plugin", name), + naming: "index.js", + target: "bun", + format: "esm", + }); + if (!built.success) throw new Error(String(built.logs)); +} await writeFile( join(config, "opencode.json"), JSON.stringify({ - plugins: [join(temp, "plugin")], + plugins: [ + { package: join(temp, "plugin", "agents") }, + { package: join(temp, "plugin", "multi-work") }, + ], agents: { build: { permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }] }, }, @@ -290,8 +298,83 @@ try { if (attempt === 99) throw new Error("Native cancellation did not interrupt the child"); await Bun.sleep(50); } + releaseModel.resolve(); + holdModel = false; + for (const plugins of [ + [{ package: join(temp, "plugin", "multi-work") }], + [ + { package: join(temp, "plugin", "multi-work") }, + { package: join(temp, "plugin", "agents") }, + ], + ]) { + server.kill(); + await server.exited; + await writeFile( + join(config, "opencode.json"), + JSON.stringify({ plugins, update: "disable" }), + ); + server = start(); + await ready(); + for (let attempt = 0; attempt < 100; attempt++) { + const { data } = await client.plugin.list({ location }); + const multi = data.find((plugin) => plugin.id === "stanp-opencode.multi-work"); + if (multi?.state.status === "failed") break; + if (attempt === 99 || multi?.state.status === "active") + throw new Error( + `Missing or late Agents did not fail Multi-work activation: ${JSON.stringify(data)}`, + ); + await Bun.sleep(100); + } + } + server.kill(); + await server.exited; + const quotaBuild = await Bun.build({ + entrypoints: [resolve("src/quota-plugin.ts")], + outdir: join(temp, "plugin", "quota"), + naming: "index.js", + target: "bun", + format: "esm", + }); + if (!quotaBuild.success) throw new Error(String(quotaBuild.logs)); + await writeFile( + join(config, "opencode.json"), + JSON.stringify({ + plugins: [{ package: join(temp, "plugin", "quota") }], + update: "disable", + }), + ); + server = start(); + await ready(); + for (let attempt = 0; attempt < 100; attempt++) { + const independent = await client.plugin.list({ location }); + if ( + independent.data.some( + (plugin) => + plugin.id === "stanp-opencode.agents" || + plugin.id === "stanp-opencode.multi-work", + ) + ) + throw new Error("Quota-only configuration loaded Agents or Multi-work"); + if ( + independent.data.some( + (plugin) => + plugin.id === "stanp-opencode.quota" && plugin.state.status === "active", + ) + ) + break; + if (attempt === 99) throw new Error("Quota did not activate independently"); + await Bun.sleep(100); + } + const quotaStatus = await client.rpc.call({ + rpcID: "stanp-opencode.quota", + method: "status", + location, + input: {}, + }); + if (quotaStatus.output !== null) + throw new Error("Unauthenticated Quota RPC should return null"); console.log( - "PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption", + "PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption, missing/late Agents fail closed, independent Quota and stable RPC", ); } finally { releaseModel.resolve(); diff --git a/src/agents-rpc.ts b/src/agents-rpc.ts new file mode 100644 index 0000000..3beb138 --- /dev/null +++ b/src/agents-rpc.ts @@ -0,0 +1,20 @@ +import { Rpc } from "@opencode/plugin/rpc"; +import { z } from "zod"; + +// A native host boundary: Multi-work must load after Agents and use its final build permissions. +export const AgentsRpc = Rpc.define({ + id: "stanp-opencode.agents", + methods: { + permissions: { + input: z.object({}), + output: z.array( + z.object({ + action: z.string(), + resource: z.string(), + effect: z.enum(["allow", "ask", "deny"]), + }), + ), + }, + }, + events: {}, +}); diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..28260bb --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,303 @@ +import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; +import type { ModelInfo } from "@opencode/client"; +import { z } from "zod"; +import { gptAgents, settings, workerNames } from "./config.js"; +import { discover, modelKey } from "./provider.js"; +import { descriptions, routingGuide } from "./routing.js"; +import { Skill } from "@opencode/schema/skill"; +import { AbsolutePath } from "@opencode/schema/schema"; +import { fileURLToPath } from "node:url"; +import antiOverengineering from "./anti-overengineering.md" with { type: "text" }; +import { AgentsRpc } from "./agents-rpc.js"; + +// Permission actions use wildcards, not filesystem globs. +function matches(value: string, pattern: string) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "s").test(value); +} + +const reading = [ + "read", + "glob", + "grep", + "list", + "todoread", + "todowrite", + "webfetch", + "websearch", + "question", + "skill", + "external_directory", + "doom_loop", +]; +const delegation = z.object({ agent: z.string() }); +export default Plugin.define({ + id: "stanp-opencode.agents", + async setup(context) { + const selected = settings.parse({ mode: "gpt", ...context.options }); + const lunaPriority = + selected.mode === "gpt" && + selected.provider === "openai" && + selected.lunaPriority === true; + let models = new Map(); + let inherited: Agent.Info["permissions"] = Agent.Info.default( + Agent.ID.make("build"), + ).permissions; + let permissionKey = JSON.stringify(inherited); + let codegraphTools: string[] = []; + let agentTransformReady = false; + const controller = new AbortController(); + // Subscribe before the first read: configuration can publish its models while + // asynchronous setup is running. + const updates = (async () => { + for await (const event of context.event.subscribe({ signal: controller.signal })) { + if (event.location) { + const workspaceID = + "workspaceID" in event.location ? event.location.workspaceID : undefined; + if ( + event.location.directory !== context.location.directory || + workspaceID !== context.location.workspaceID + ) + continue; + } + if (event.type === "model.updated") { + models = await discover(context, lunaPriority); + await context.agent.reload(); + } else if (event.type === "agent.updated") { + // The configured build transform may run after ours. Read the final + // registry, then replay only when its effective permissions change. + const { data } = await context.agent.list(); + const permissions = + data.find((agent) => agent.id === "build")?.permissions ?? + Agent.Info.default(Agent.ID.make("build")).permissions; + const key = JSON.stringify(permissions); + if (key === permissionKey) continue; + inherited = permissions; + permissionKey = key; + await context.agent.reload(); + } + } + })().catch((error) => { + if (!controller.signal.aborted) + console.error("stanp-opencode model update failed", error); + }); + models = await discover(context, lunaPriority); + const { data: initialAgents } = await context.agent.list(); + inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; + permissionKey = JSON.stringify(inherited); + const registration = await context.rpc.register(AgentsRpc, { + permissions: async () => inherited.map((rule) => ({ ...rule })), + }); + const assignments = + selected.mode === "gpt" + ? Object.fromEntries( + workerNames.map((name) => [ + name, + { + ...gptAgents[name], + provider: selected.provider, + }, + ]), + ) + : selected.agents; + let workers: (typeof workerNames)[number][] = []; + const active = new Set(); + + await context.skill.transform((editor) => { + if (editor.get("anti-overengineering")) return; + editor.add({ + id: Skill.ID.make("anti-overengineering"), + name: Skill.Name.make("anti-overengineering"), + description: "Build the smallest correct solution without sacrificing correctness.", + path: AbsolutePath.make(fileURLToPath(import.meta.url)), + content: antiOverengineering, + }); + }); + + await context.tool.transform((editor) => { + const next = editor + .list() + .map((tool) => tool.id) + .filter((id) => id.startsWith("codegraph_")) + .sort(); + if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return; + codegraphTools = next; + if (agentTransformReady) + queueMicrotask(() => { + void context.agent + .reload() + .catch((error) => + console.error("stanp-opencode agent reload failed", error), + ); + }); + }); + + await context.agent.transform((editor) => { + const coordinates = (assignment: { model: string }) => { + if (selected.mode === "gpt") + return { provider: selected.provider, model: assignment.model }; + const slash = assignment.model.indexOf("/"); + return { + provider: assignment.model.slice(0, slash), + model: assignment.model.slice(slash + 1), + }; + }; + const availableFor = (assignment: { model: string }) => { + const { provider, model } = coordinates(assignment); + return models.get(modelKey(provider, model)); + }; + const deepLo = assignments["deep-lo"]; + const deep = + deepLo !== undefined && + assignments["deep-hi"] !== undefined && + availableFor(deepLo) !== undefined; + const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); + const coordinate = deep ? deepLo : assignments["good-lo"]; + workers = [coordinate, ...candidates.map((name) => assignments[name])].every( + (assignment) => assignment !== undefined && availableFor(assignment) !== undefined, + ) + ? candidates + : []; + active.clear(); + for (const name of workers) active.add(name); + if (workers.length === 0) return; + for (const name of workers) { + const assignment = assignments[name]!; + const available = availableFor(assignment)!; + editor.update(name, (agent) => { + const hasVariant = available.model.variants.some( + ({ id }) => id === assignment.effort, + ); + agent.name = Agent.Name.make(name); + agent.mode = "subagent"; + agent.hidden = false; + agent.description = descriptions[name]; + agent.model = { + providerID: Provider.ID.make(available.model.providerID), + id: Model.ID.make(available.model.id), + }; + if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort); + agent.request = { + settings: hasVariant ? {} : { reasoningEffort: assignment.effort }, + headers: {}, + body: {}, + }; + if (lunaPriority && available.serviceTier) + agent.request.settings.serviceTier = available.serviceTier; + agent.permissions = [ + ...structuredClone(inherited), + { action: "subagent", resource: "*", effect: "deny" }, + ]; + }); + } + editor.update("coordinate", (agent) => { + const available = availableFor(coordinate)!; + const hasVariant = available.model.variants.some( + ({ id }) => id === coordinate.effort, + ); + agent.name = Agent.Name.make("coordinate"); + agent.mode = "primary"; + agent.hidden = false; + agent.description = + "Read, plan, and coordinate specialist workers; never implement directly."; + agent.model = { + providerID: Provider.ID.make(available.model.providerID), + id: Model.ID.make(available.model.id), + }; + if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort); + agent.request = { + settings: hasVariant ? {} : { reasoningEffort: coordinate.effort }, + headers: {}, + body: {}, + }; + if (lunaPriority && available.serviceTier) + agent.request.settings.serviceTier = available.serviceTier; + agent.system = routingGuide(deep); + agent.permissions = [ + { action: "*", resource: "*", effect: "deny" }, + ...reading.flatMap((action) => + inherited + .filter((rule) => matches(action, rule.action)) + .map((rule) => ({ ...rule, action })), + ), + ...codegraphTools.flatMap((action) => + inherited + .filter((rule) => matches(action, rule.action)) + .map((rule) => ({ ...rule, action })), + ), + ...workers.map((resource) => ({ + action: "subagent", + resource, + effect: "allow" as const, + })), + ]; + }); + }); + agentTransformReady = true; + async function assertMultiWork() { + const { data } = await context.plugin.list(); + if ( + !data.some( + (plugin) => + plugin.id === "stanp-opencode.multi-work" && + plugin.state.status === "active", + ) + ) + throw new Error("coordinate cannot run as a subagent without Multi-work"); + } + await context.session.hook("context", (event) => { + if (workerNames.some((name) => name === event.agent)) { + delete event.tools.subagent; + return; + } + const tool = event.tools.subagent; + if (event.agent === "coordinate" && workers.length === 0) { + delete event.tools.subagent; + return; + } + if (event.agent !== "coordinate" || !tool) return; + tool.description = `Delegate a self-contained assignment to one of these workers:\n${workers.map((name) => `- ${name}: ${descriptions[name]}`).join("\n")}`; + // The executable definition remains native; constrain only this request's schema. + const properties = z.record(z.string(), z.unknown()).parse(tool.input.properties ?? {}); + tool.input = { + ...tool.input, + properties: { ...properties, agent: { type: "string", enum: workers } }, + }; + }); + await context.tool.hook("execute.before", async (event) => { + if (event.tool !== "subagent") return; + const { agent } = delegation.parse(event.input); + if (agent === "coordinate" && event.agent !== "multi-work") + throw new Error("coordinate cannot run as a subagent without Multi-work"); + if (agent === "coordinate") await assertMultiWork(); + if (workerNames.some((name) => name === event.agent)) + throw new Error("Workers cannot delegate tasks"); + if (event.agent === "coordinate" && !active.has(agent)) + throw new Error("coordinate may delegate only to its available workers"); + }); + // Commands can create child sessions without going through the subagent tool. + await context.session.hook("prompt", async (event) => { + const child = await context.session.get({ sessionID: event.sessionID }); + if (!child.parentID) return; + const parent = await context.session.get({ sessionID: child.parentID }); + if (child.agent === "coordinate" && parent.agent !== "multi-work") + throw new Error("coordinate cannot run as a subagent without Multi-work"); + if (child.agent === "coordinate") await assertMultiWork(); + if (workerNames.some((name) => name === parent.agent)) + throw new Error("Workers cannot delegate tasks"); + if ( + parent.agent === "coordinate" && + (child.agent === undefined || !active.has(child.agent)) + ) + throw new Error("coordinate may delegate only to its available workers"); + }); + return async () => { + controller.abort(); + await updates; + await registration.dispose(); + }; + }, +}); diff --git a/src/config.ts b/src/config.ts index 09fd709..825a195 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,14 +24,9 @@ const customAssignment = gptAssignment.extend({ }, "model must use the provider/model convention"), }); -const common = { - playwright: z.boolean().default(false), -}; - export const settings = z.discriminatedUnion("mode", [ z .strictObject({ - ...common, mode: z.literal("gpt"), provider: z.string().trim().min(1).default("openai"), lunaPriority: z.boolean().optional(), @@ -50,7 +45,6 @@ export const settings = z.discriminatedUnion("mode", [ : value; }), z.strictObject({ - ...common, mode: z.literal("custom"), agents: z.strictObject({ "fast-lo": customAssignment, diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index ea13340..0000000 --- a/src/index.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; -import type { RpcRegistration } from "@opencode/plugin/promise/rpc"; -import type { ModelInfo } from "@opencode/client"; -import { z } from "zod"; -import { gptAgents, settings, workerNames } from "./config.js"; -import { discover, modelKey } from "./provider.js"; -import { descriptions, routingGuide } from "./routing.js"; -import { QuotaTracker } from "./quota.js"; -import { QuotaRpc } from "./quota-rpc.js"; -import { playwrightMcpCommand } from "./playwright.js"; -import { Plugin as EffectPlugin } from "@opencode/plugin/effect"; -import { fromPromise } from "@opencode/plugin/promise/adapter"; -import { Effect } from "effect"; -import { bindingKey, bindingSchema, installMultiWork } from "./multi-work.js"; -import { Skill } from "@opencode/schema/skill"; -import { AbsolutePath } from "@opencode/schema/schema"; -import { fileURLToPath } from "node:url"; -import antiOverengineering from "./anti-overengineering.md" with { type: "text" }; - -// Permission actions use wildcards, not filesystem globs. -function matches(value: string, pattern: string) { - const escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${escaped}$`, "s").test(value); -} - -const reading = [ - "read", - "glob", - "grep", - "list", - "todoread", - "todowrite", - "webfetch", - "websearch", - "question", - "skill", - "external_directory", - "doom_loop", -]; -const delegation = z.object({ agent: z.string() }); -const todos = z.array( - z.object({ - id: z.string(), - content: z.string(), - status: z.enum(["pending", "in_progress", "completed", "cancelled"]), - priority: z.enum(["high", "medium", "low"]), - }), -); - -const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permissions"]) => void) => - Plugin.define({ - id: "stanp-opencode", - async setup(context) { - const selected = settings.parse({ mode: "gpt", ...context.options }); - const lunaPriority = - selected.mode === "gpt" && - selected.provider === "openai" && - selected.lunaPriority === true; - let models = new Map(); - let inherited: Agent.Info["permissions"] = Agent.Info.default( - Agent.ID.make("build"), - ).permissions; - let permissionKey = JSON.stringify(inherited); - let hasCodegraph = false; - let codegraphTools: string[] = []; - let agentTransformReady = false; - const controller = new AbortController(); - let quotaRegistration: RpcRegistration | undefined; - const quota = new QuotaTracker(context, async () => { - await quotaRegistration?.events.emit("updated", {}); - }); - quotaRegistration = await context.rpc.register(QuotaRpc, { - status: async () => quota.status() ?? null, - }); - await quota.init(); - // Subscribe before the first read: configuration can publish its models while - // asynchronous setup is running. - const updates = (async () => { - for await (const event of context.event.subscribe({ signal: controller.signal })) { - if (event.location) { - const workspaceID = - "workspaceID" in event.location - ? event.location.workspaceID - : undefined; - if ( - event.location.directory !== context.location.directory || - workspaceID !== context.location.workspaceID - ) - continue; - } - if (event.type === "model.updated") { - models = await discover(context, lunaPriority); - await context.agent.reload(); - } else if (event.type === "agent.updated") { - // The configured build transform may run after ours. Read the final - // registry, then replay only when its effective permissions change. - const { data } = await context.agent.list(); - const permissions = - data.find((agent) => agent.id === "build")?.permissions ?? - Agent.Info.default(Agent.ID.make("build")).permissions; - const key = JSON.stringify(permissions); - if (key === permissionKey) continue; - inherited = permissions; - permissionsChanged(inherited); - permissionKey = key; - await context.agent.reload(); - } else if ( - event.type === "session.execution.succeeded" || - event.type === "session.execution.failed" || - event.type === "session.execution.interrupted" || - event.type === "session.idle" - ) { - await quota.complete(event.data.sessionID); - } else if (event.type === "credential.updated") { - await quota.authChanged(); - } else if ( - event.type === "credential.switched" && - String(event.data.integrationID) === "openai" - ) { - await quota.authChanged(true); - } - } - })().catch((error) => { - if (!controller.signal.aborted) - console.error("stanp-opencode model update failed", error); - }); - models = await discover(context, lunaPriority); - const { data: initialAgents } = await context.agent.list(); - inherited = - initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; - permissionsChanged(inherited); - permissionKey = JSON.stringify(inherited); - const assignments = - selected.mode === "gpt" - ? Object.fromEntries( - workerNames.map((name) => [ - name, - { - ...gptAgents[name], - provider: selected.provider, - }, - ]), - ) - : selected.agents; - let workers: (typeof workerNames)[number][] = []; - const active = new Set(); - const playwrightCommand = selected.playwright - ? await playwrightMcpCommand(context.location.directory) - : undefined; - - await context.skill.transform((editor) => { - if (editor.get("anti-overengineering")) return; - editor.add({ - id: Skill.ID.make("anti-overengineering"), - name: Skill.Name.make("anti-overengineering"), - description: - "Build the smallest correct solution without sacrificing correctness.", - path: AbsolutePath.make(fileURLToPath(import.meta.url)), - content: antiOverengineering, - }); - }); - - await context.mcp.transform((editor) => { - if (playwrightCommand && !editor.get("playwright")) - editor.set("playwright", { - type: "local", - command: playwrightCommand, - timeout: { startup: 300_000 }, - }); - hasCodegraph = editor.get("codegraph") !== undefined; - if (hasCodegraph) - editor.update("codegraph", (server) => { - server.codemode = false; - }); - }); - await context.tool.transform((editor) => { - const next = editor - .list() - .map((tool) => tool.id) - .filter((id) => id.startsWith("codegraph_")) - .sort(); - if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return; - codegraphTools = next; - if (agentTransformReady) - queueMicrotask(() => { - void context.agent - .reload() - .catch((error) => - console.error("stanp-opencode agent reload failed", error), - ); - }); - }); - - await context.agent.transform((editor) => { - const coordinates = (assignment: { model: string }) => { - if (selected.mode === "gpt") - return { provider: selected.provider, model: assignment.model }; - const slash = assignment.model.indexOf("/"); - return { - provider: assignment.model.slice(0, slash), - model: assignment.model.slice(slash + 1), - }; - }; - const availableFor = (assignment: { model: string }) => { - const { provider, model } = coordinates(assignment); - return models.get(modelKey(provider, model)); - }; - const deepLo = assignments["deep-lo"]; - const deep = - deepLo !== undefined && - assignments["deep-hi"] !== undefined && - availableFor(deepLo) !== undefined; - const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); - const coordinate = deep ? deepLo : assignments["good-lo"]; - workers = [coordinate, ...candidates.map((name) => assignments[name])].every( - (assignment) => - assignment !== undefined && availableFor(assignment) !== undefined, - ) - ? candidates - : []; - active.clear(); - for (const name of workers) active.add(name); - if (workers.length === 0) return; - for (const name of workers) { - const assignment = assignments[name]!; - const available = availableFor(assignment)!; - editor.update(name, (agent) => { - const hasVariant = available.model.variants.some( - ({ id }) => id === assignment.effort, - ); - agent.name = Agent.Name.make(name); - agent.mode = "subagent"; - agent.hidden = false; - agent.description = descriptions[name]; - agent.model = { - providerID: Provider.ID.make(available.model.providerID), - id: Model.ID.make(available.model.id), - }; - if (hasVariant) - agent.model.variant = Model.VariantID.make(assignment.effort); - agent.request = { - settings: hasVariant ? {} : { reasoningEffort: assignment.effort }, - headers: {}, - body: {}, - }; - if (lunaPriority && available.serviceTier) - agent.request.settings.serviceTier = available.serviceTier; - agent.permissions = [ - ...structuredClone(inherited), - { action: "subagent", resource: "*", effect: "deny" }, - ]; - }); - } - editor.update("coordinate", (agent) => { - const available = availableFor(coordinate)!; - const hasVariant = available.model.variants.some( - ({ id }) => id === coordinate.effort, - ); - agent.name = Agent.Name.make("coordinate"); - agent.mode = "primary"; - agent.hidden = false; - agent.description = - "Read, plan, and coordinate specialist workers; never implement directly."; - agent.model = { - providerID: Provider.ID.make(available.model.providerID), - id: Model.ID.make(available.model.id), - }; - if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort); - agent.request = { - settings: hasVariant ? {} : { reasoningEffort: coordinate.effort }, - headers: {}, - body: {}, - }; - if (lunaPriority && available.serviceTier) - agent.request.settings.serviceTier = available.serviceTier; - agent.system = routingGuide(deep); - agent.permissions = [ - { action: "*", resource: "*", effect: "deny" }, - ...reading.flatMap((action) => - inherited - .filter((rule) => matches(action, rule.action)) - .map((rule) => ({ ...rule, action })), - ), - ...(hasCodegraph - ? codegraphTools.flatMap((action) => - inherited - .filter((rule) => matches(action, rule.action)) - .map((rule) => ({ ...rule, action })), - ) - : []), - ...workers.map((resource) => ({ - action: "subagent", - resource, - effect: "allow" as const, - })), - ]; - }); - }); - agentTransformReady = true; - async function assertTodo(agentID: Agent.ID, action: string) { - const { data: agent } = await context.agent.get({ agentID }); - const effect = - agent.permissions.findLast( - (rule) => matches(action, rule.action) && matches("*", rule.resource), - )?.effect ?? "ask"; - if (effect !== "allow") - throw new Error( - `${action} requires an allow permission: OpenCode's public plugin API cannot request custom-tool permission approval (${effect}).`, - ); - } - await context.tool.transform((editor) => { - if (!editor.get("todowrite")) - editor.add({ - name: "todowrite", - description: - "Replace this session's todo list to track the work and its progress.", - input: z.object({ todos }), - options: { codemode: false }, - async execute(input, tool) { - await assertTodo(tool.agent, "todowrite"); - await context.storage.set(`todos:${tool.sessionID}`, input.todos); - return { content: JSON.stringify(input.todos) }; - }, - }); - if (!editor.get("todoread")) - editor.add({ - name: "todoread", - description: "Read this session's todo list.", - input: z.object({}), - options: { codemode: false }, - async execute(_input, tool) { - await assertTodo(tool.agent, "todoread"); - const stored = await context.storage.get(`todos:${tool.sessionID}`); - return { - content: JSON.stringify( - stored === undefined ? [] : todos.parse(stored), - ), - }; - }, - }); - }); - await context.session.hook("context", (event) => { - if (workerNames.some((name) => name === event.agent)) { - delete event.tools.subagent; - return; - } - const tool = event.tools.subagent; - if (event.agent === "coordinate" && workers.length === 0) { - delete event.tools.subagent; - return; - } - if (event.agent !== "coordinate" || !tool) return; - tool.description = `Delegate a self-contained assignment to one of these workers:\n${workers.map((name) => `- ${name}: ${descriptions[name]}`).join("\n")}`; - // The executable definition remains native; constrain only this request's schema. - const properties = z - .record(z.string(), z.unknown()) - .parse(tool.input.properties ?? {}); - tool.input = { - ...tool.input, - properties: { ...properties, agent: { type: "string", enum: workers } }, - }; - }); - await context.session.hook("model.request", async (event) => - quota.start(event.sessionID), - ); - await context.tool.hook("execute.before", (event) => { - if (event.tool !== "subagent") return; - const { agent } = delegation.parse(event.input); - if (agent === "multi-work") throw new Error("multi-work is primary only"); - if (agent === "coordinate" && event.agent !== "multi-work") - throw new Error( - "coordinate cannot run as a subagent without a ready multi-work binding", - ); - if (workerNames.some((name) => name === event.agent)) - throw new Error("Workers cannot delegate tasks"); - if (event.agent === "coordinate" && !active.has(agent)) - throw new Error("coordinate may delegate only to its available workers"); - }); - // Commands can create child sessions without going through the subagent tool. - await context.session.hook("prompt", async (event) => { - const child = await context.session.get({ sessionID: event.sessionID }); - if (!child.parentID) return; - if (child.agent === "multi-work") throw new Error("multi-work is primary only"); - const parent = await context.session.get({ sessionID: child.parentID }); - if (child.agent === "coordinate") { - const binding = bindingSchema.safeParse( - await context.storage.get(bindingKey(event.sessionID)), - ); - if ( - !binding.success || - !binding.data.ready || - binding.data.child !== event.sessionID || - binding.data.owner !== child.parentID || - binding.data.projectID !== child.projectID || - binding.data.projectID !== parent.projectID || - parent.agent !== "multi-work" || - child.location.directory !== binding.data.directory || - parent.location.directory !== binding.data.root.directory - ) - throw new Error( - "coordinate cannot run as a subagent without a ready multi-work binding", - ); - } - if ( - parent.agent === "multi-work" && - child.agent !== "coordinate" && - (child.agent !== "fast-lo" || - child.location.directory !== parent.location.directory) - ) - throw new Error( - "multi-work may delegate only to local fast-lo or a bound coordinate child", - ); - if (workerNames.some((name) => name === parent.agent)) - throw new Error("Workers cannot delegate tasks"); - if ( - parent.agent === "coordinate" && - (child.agent === undefined || !active.has(child.agent)) - ) - throw new Error("coordinate may delegate only to its available workers"); - }); - return async () => { - controller.abort(); - await updates; - await quota.dispose(); - await quotaRegistration?.dispose(); - }; - }, - }); - -export const basePlugin = makeBasePlugin(() => {}); - -export default EffectPlugin.define({ - id: "stanp-opencode", - effect: (context) => - Effect.gen(function* () { - let inherited: Agent.Info["permissions"] = Agent.Info.default( - Agent.ID.make("build"), - ).permissions; - yield* fromPromise( - makeBasePlugin((permissions) => { - inherited = permissions; - }), - ).effect(context); - yield* installMultiWork(context, () => inherited); - }), -}); diff --git a/src/multi-work.ts b/src/multi-work.ts index 4658611..59f22c1 100644 --- a/src/multi-work.ts +++ b/src/multi-work.ts @@ -3,7 +3,9 @@ import { Tool } from "@opencode/schema/tool"; import { Session } from "@opencode/schema/session"; import { AbsolutePath } from "@opencode/schema/schema"; import type { Context } from "@opencode/plugin/effect/plugin"; -import { Cause, Effect, Schedule } from "effect"; +import { Plugin } from "@opencode/plugin/effect"; +import { AgentsRpc } from "./agents-rpc.js"; +import { Cause, Effect, Schedule, Stream } from "effect"; import { z } from "zod"; import { randomUUID } from "node:crypto"; import { Herdr, rootSchema } from "./herdr.js"; @@ -97,7 +99,7 @@ export const installMultiWork = ( agent.system = `${agent.system ?? ""}\n${childProtocol}`; }); if (!eligible) return; - // The base plugin captures the final registry after late configuration + // Agents captures the final registry after late configuration // transforms and reloads when it changes. Editor.build is too early. const inherited = buildPermissions(); editor.update("multi-work", (agent) => { @@ -156,6 +158,34 @@ export const installMultiWork = ( return binding; }); + // Commands can create children without going through the subagent tool. + yield* context.session.hook("prompt", (event) => + Effect.gen(function* () { + const child = yield* context.session.get({ sessionID: event.sessionID }); + if (!child.parentID) return; + if (child.agent === "multi-work") throw new Error("multi-work is primary only"); + const parent = yield* context.session.get({ sessionID: child.parentID }); + if (child.agent === "coordinate") yield* validate(event.sessionID); + if ( + parent.agent === "multi-work" && + child.agent !== "coordinate" && + (child.agent !== "fast-lo" || + child.location.directory !== parent.location.directory) + ) + throw new Error( + "multi-work may delegate only to local fast-lo or a bound coordinate child", + ); + }).pipe(Effect.orDie), + ); + yield* context.tool.hook("execute.before", (event) => { + if ( + event.tool === "subagent" && + z.object({ agent: z.string() }).parse(event.input).agent === "multi-work" + ) + return Effect.fail(new Tool.Error({ message: "multi-work is primary only" })); + return Effect.void; + }); + yield* context.session.hook("context", (event) => Effect.gen(function* () { const session = yield* context.session.get({ sessionID: event.sessionID }); @@ -489,3 +519,38 @@ export const installMultiWork = ( }); }); }); + +export default Plugin.define({ + id: "stanp-opencode.multi-work", + effect: (context) => + Effect.gen(function* () { + const agents = context.rpc(AgentsRpc); + let inherited = yield* agents + .permissions({}) + .pipe( + Effect.catch(() => + Effect.die(new Error("Multi-work requires Agents loaded before it")), + ), + ); + yield* context.event.subscribe().pipe( + Stream.filter( + (event) => + event.type === "agent.updated" && + (!event.location || + (event.location.directory === context.location.directory && + event.location.workspaceID === context.location.workspaceID)), + ), + Stream.runForEach(() => + Effect.gen(function* () { + const next = yield* agents.permissions({}); + if (JSON.stringify(next) === JSON.stringify(inherited)) return; + inherited = next; + yield* context.agent.reload(); + }), + ), + Effect.orDie, + Effect.forkScoped, + ); + yield* installMultiWork(context, () => inherited); + }).pipe(Effect.orDie), +}); diff --git a/src/playwright.ts b/src/playwright.ts deleted file mode 100644 index 2a7beed..0000000 --- a/src/playwright.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { access, readFile, realpath } from "node:fs/promises"; -import { constants } from "node:fs"; -import { dirname, join, parse, resolve } from "node:path"; -import { z } from "zod"; - -const dockerCommand = ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"]; -const directPackageManifest = z.object({ - name: z.literal("@playwright/mcp"), - bin: z.string(), -}); -const namedPackageManifest = z.object({ - name: z.literal("@playwright/mcp"), - bin: z.object({ "playwright-mcp": z.string() }), -}); -const playwrightPackageManifest = z.object({ - name: z.literal("playwright"), - bin: z.object({ playwright: z.string() }), -}); -const playwrightTestPackageManifest = z.object({ - name: z.literal("@playwright/test"), - bin: z.object({ playwright: z.string() }), - dependencies: z.object({ playwright: z.string() }), -}); - -async function packageExecutable(packageRoot: string) { - try { - const input = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); - const direct = directPackageManifest.safeParse(input); - const bin = direct.success - ? direct.data.bin - : namedPackageManifest.parse(input).bin["playwright-mcp"]; - const executable = resolve(packageRoot, bin); - await access(executable, constants.X_OK); - return executable; - } catch { - return; - } -} - -async function ordinaryPackageExecutable(packageRoot: string) { - try { - const manifest = playwrightPackageManifest.parse( - JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")), - ); - const executable = resolve(packageRoot, manifest.bin.playwright); - await access(executable, constants.X_OK); - return executable; - } catch { - return; - } -} - -async function playwrightDependency(packageRoot: string) { - let current = await realpath(packageRoot); - const root = parse(current).root; - while (true) { - const executable = await ordinaryPackageExecutable( - join(current, "node_modules/playwright"), - ); - if (executable) return executable; - if (current === root) return; - current = dirname(current); - } -} - -async function testPackageExecutable(packageRoot: string) { - try { - const manifest = playwrightTestPackageManifest.parse( - JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")), - ); - if (!(await playwrightDependency(packageRoot))) return; - const executable = resolve(packageRoot, manifest.bin.playwright); - await access(executable, constants.X_OK); - return executable; - } catch { - return; - } -} - -async function verifiedPathExecutable(candidate: string, kind: "mcp" | "playwright") { - try { - await access(candidate, constants.X_OK); - const target = await realpath(candidate); - let directory = dirname(target); - const root = parse(directory).root; - while (true) { - const executable = - kind === "mcp" - ? await packageExecutable(directory) - : ((await ordinaryPackageExecutable(directory)) ?? - (await testPackageExecutable(directory))); - if (executable && (await realpath(executable)) === target) return candidate; - if (directory === root) return; - directory = dirname(directory); - } - } catch { - return; - } -} - -export async function playwrightMcpCommand( - directory: string, - path = process.env.PATH ?? "", -): Promise { - let current = resolve(directory); - const root = parse(current).root; - while (true) { - const executable = await packageExecutable(join(current, "node_modules/@playwright/mcp")); - if (executable) return [executable]; - if (current === root) break; - current = dirname(current); - } - - for (const entry of path.split(process.platform === "win32" ? ";" : ":")) { - if (!entry) continue; - const executable = await verifiedPathExecutable(join(entry, "playwright-mcp"), "mcp"); - if (executable) return [executable]; - } - - current = resolve(directory); - while (true) { - const playwright = await ordinaryPackageExecutable( - join(current, "node_modules/playwright"), - ); - const test = await testPackageExecutable(join(current, "node_modules/@playwright/test")); - if (playwright || test) return ["npx", "-y", "@playwright/mcp@latest"]; - if (current === root) break; - current = dirname(current); - } - - for (const entry of path.split(process.platform === "win32" ? ";" : ":")) { - if (!entry) continue; - const executable = await verifiedPathExecutable(join(entry, "playwright"), "playwright"); - if (executable) return ["npx", "-y", "@playwright/mcp@latest"]; - } - return [...dockerCommand]; -} diff --git a/src/quota-plugin.ts b/src/quota-plugin.ts new file mode 100644 index 0000000..c44b9da --- /dev/null +++ b/src/quota-plugin.ts @@ -0,0 +1,54 @@ +import { Plugin } from "@opencode/plugin"; +import { QuotaTracker } from "./quota.js"; +import { QuotaRpc } from "./quota-rpc.js"; + +export default Plugin.define({ + id: "stanp-opencode.quota", + async setup(context) { + const controller = new AbortController(); + const quota = new QuotaTracker(context, async () => { + await registration.events.emit("updated", {}); + }); + const registration = await context.rpc.register(QuotaRpc, { + status: async () => quota.status() ?? null, + }); + await quota.init(); + const updates = (async () => { + for await (const event of context.event.subscribe({ signal: controller.signal })) { + if ( + event.location && + (event.location.directory !== context.location.directory || + ("workspaceID" in event.location + ? event.location.workspaceID + : undefined) !== context.location.workspaceID) + ) + continue; + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" || + event.type === "session.idle" + ) { + await quota.complete(event.data.sessionID); + } else if (event.type === "credential.updated") { + await quota.authChanged(); + } else if ( + event.type === "credential.switched" && + String(event.data.integrationID) === "openai" + ) { + await quota.authChanged(true); + } + } + })().catch((error) => { + if (!controller.signal.aborted) + console.error("stanp-opencode quota update failed", error); + }); + await context.session.hook("model.request", async (event) => quota.start(event.sessionID)); + return async () => { + controller.abort(); + await updates; + await quota.dispose(); + await registration.dispose(); + }; + }, +}); diff --git a/src/quota.ts b/src/quota.ts index cd20726..a7c1491 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -75,7 +75,6 @@ const storedState = z.object({ }) .optional(), }); -const legacyStoredState = storedState.extend({ sessions: z.unknown() }); type StoredState = z.infer; function withoutExcludedState(state: StoredState): StoredState { @@ -223,7 +222,6 @@ export class QuotaTracker { private timer: ReturnType | undefined; private request: Promise | undefined; private readonly requests = new Set>(); - private readonly migrations = new Set>(); private readonly active = new Set(); private controller: AbortController | undefined; private generation = 0; @@ -263,16 +261,6 @@ export class QuotaTracker { const stored = parsed.success ? parsed.data : undefined; if (generation !== this.generation) return; this.state = stored?.account === account ? withoutExcludedState(stored) : { account }; - const legacy = legacyStoredState.safeParse(value); - if (stored?.account === account && legacy.success) { - const migration = this.persist(); - this.migrations.add(migration); - try { - await migration; - } finally { - this.migrations.delete(migration); - } - } } if (generation !== this.generation) return; if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs); @@ -395,7 +383,6 @@ export class QuotaTracker { this.controller = undefined; this.request = undefined; this.active.clear(); - await Promise.all(this.migrations); if (this.state) await this.clear(); } await this.refresh(); @@ -407,6 +394,6 @@ export class QuotaTracker { this.controller?.abort(); if (this.timer) clearInterval(this.timer); this.timer = undefined; - await Promise.all([...this.requests, ...this.migrations]); + await Promise.all(this.requests); } } diff --git a/tests/composite.test.ts b/tests/composite.test.ts index d2ff56b..aba3d5f 100644 --- a/tests/composite.test.ts +++ b/tests/composite.test.ts @@ -14,7 +14,18 @@ import type { ToolEditor } from "@opencode/plugin/effect/tool"; import type { SkillEditor } from "@opencode/plugin/effect/skill"; import type { SessionHooks } from "@opencode/plugin/effect/session"; import { z } from "zod"; -import plugin from "../src/index.js"; +import agentsPlugin from "../src/agents.js"; +import multiWorkPlugin from "../src/multi-work.js"; +import { fromPromise } from "@opencode/plugin/promise/adapter"; +import { AgentsRpc } from "../src/agents-rpc.js"; + +const plugin = { + effect: (context: Context) => + Effect.gen(function* () { + yield* fromPromise(agentsPlugin).effect(context); + yield* multiWorkPlugin.effect(context); + }), +}; import { command } from "../src/herdr.js"; // This is a composite plugin host fixture, not OpenCode's native executor/job runtime. @@ -127,6 +138,7 @@ function host(fixture: Awaited>, directory: string) PubSub.unbounded>(), ); const registration = Effect.succeed({ dispose: Effect.void }); + let permissions: (() => Effect.Effect) | undefined; const editAgents: AgentEditor = { get: (id) => agents.get(id), list: () => [...agents.values()], @@ -234,7 +246,20 @@ function host(fixture: Awaited>, directory: string) generate: {}, provider: {}, permission: {}, - plugin: {}, + plugin: { + list: () => + Effect.succeed({ + location, + data: [ + { + id: "stanp-opencode.multi-work", + source: { type: "builtin" }, + state: { status: "active" }, + features: { server: true }, + }, + ], + }), + }, reference: {}, skill: { transform: (callback: (editor: SkillEditor) => void) => @@ -270,10 +295,17 @@ function host(fixture: Awaited>, directory: string) resolve: () => Effect.succeed(undefined), }, }, - rpc: { - register: () => - Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }), - }, + rpc: Object.assign(() => ({ permissions: () => permissions!() }), { + register: ( + definition: typeof AgentsRpc, + handlers: { permissions: () => Effect.Effect }, + ) => + Effect.sync(() => { + expect(definition.id).toBe(AgentsRpc.id); + permissions = handlers.permissions; + return { dispose: Effect.void, events: { emit: () => Effect.void } }; + }), + }), mcp: { transform: (callback: (editor: { get: () => undefined }) => void) => Effect.sync(() => callback({ get: () => undefined })).pipe( diff --git a/tests/fixtures/native-agents-plugin.ts b/tests/fixtures/native-agents-plugin.ts new file mode 100644 index 0000000..16093e2 --- /dev/null +++ b/tests/fixtures/native-agents-plugin.ts @@ -0,0 +1,27 @@ +import { Plugin, Model, Provider } from "@opencode/plugin/effect"; +import { fromPromise } from "@opencode/plugin/promise/adapter"; +import { Effect } from "effect"; +import agents from "../../src/agents.js"; + +export default Plugin.define({ + id: agents.id, + effect: (context) => + Effect.gen(function* () { + const providerID = Provider.ID.make("fixture"); + yield* context.provider.transform((editor) => + editor.add({ + info: { + ...Provider.Info.empty(providerID), + name: "Isolated fixture", + activation: "enabled", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: process.env.FIXTURE_MODEL_URL! }, + }, + models: ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => + Model.Info.default(providerID, Model.ID.make(id)), + ), + }), + ); + yield* fromPromise(agents).effect({ ...context, options: { provider: "fixture" } }); + }), +}); diff --git a/tests/fixtures/native-host-plugin.ts b/tests/fixtures/native-host-plugin.ts index 4dd8436..5ff8877 100644 --- a/tests/fixtures/native-host-plugin.ts +++ b/tests/fixtures/native-host-plugin.ts @@ -1,4 +1,4 @@ -import { Plugin, Agent, Model, Provider } from "@opencode/plugin/effect"; +import { Plugin, Agent } from "@opencode/plugin/effect"; import { Rpc } from "@opencode/plugin/rpc"; import { Session } from "@opencode/schema/session"; import { SessionMessage } from "@opencode/schema/session-message"; @@ -6,7 +6,7 @@ import { Tool } from "@opencode/schema/tool"; import { Cause, Effect } from "effect"; import { z } from "zod"; import { appendFile } from "node:fs/promises"; -import plugin from "../../src/index.js"; +import multiWork from "../../src/multi-work.js"; const probe = Rpc.define({ id: "multi-work-test", @@ -24,25 +24,10 @@ const probe = Rpc.define({ }); export default Plugin.define({ - id: plugin.id, + id: multiWork.id, effect: (context) => Effect.gen(function* () { - const providerID = Provider.ID.make("fixture"); - yield* context.provider.transform((editor) => - editor.add({ - info: { - ...Provider.Info.empty(providerID), - name: "Isolated fixture", - activation: "enabled", - package: "@opencode/ai/providers/openai-compatible", - settings: { baseURL: process.env.FIXTURE_MODEL_URL! }, - }, - models: ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => - Model.Info.default(providerID, Model.ID.make(id)), - ), - }), - ); - yield* plugin.effect({ ...context, options: { provider: "fixture" } }); + yield* multiWork.effect(context); let delegate: Tool.Info | undefined; yield* context.tool.transform((editor) => { delegate = editor.get("subagent"); diff --git a/tests/install-local.test.ts b/tests/install-local.test.ts deleted file mode 100644 index 6be6993..0000000 --- a/tests/install-local.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -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"'); -}); - -test("enables Playwright only when requested", async () => { - const setup = await fixture(); - const result = await run( - ["bash", join(setup.checkout, "install-local.sh"), "--playwright"], - setup.cwd, - setup.env, - ); - expect(result, result.stderr).toMatchObject({ exitCode: 0 }); - const installed = JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8")); - expect(installed.plugins.at(-1).options.playwright).toBe(true); -}); - -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/install.test.ts b/tests/install.test.ts index 293a394..3e5906a 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,296 +1,480 @@ import { afterEach, expect, test } from "bun:test"; -import { createHash } from "node:crypto"; import { chmod, mkdir, mkdtemp, readFile, readdir, - readlink, rm, + stat, symlink, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { install } from "../scripts/install.js"; -const installer = fileURLToPath(new URL("../install.sh", import.meta.url)); const directories: string[] = []; -const configContents = '{\n // Keep user settings and comments.\n "theme": "dark"\n}\n'; -const jsonConfig = { permissions: { shell: "ask" }, plugins: [{ package: "existing-plugin" }] }; -const previousPlugin = 'export default { id: "previous", setup() { return "preserved"; } };\n'; -const previousTarget = "../stanp-opencode/install.previous"; - afterEach(async () => { - await Promise.all( - directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); + for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true }); }); - -async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) { - const child = Bun.spawn(command, { +const unrelated = { + theme: "dark", + permissions: { shell: "ask" }, + mcp: { + servers: { + codegraph: { type: "local", command: ["user-codegraph"], codemode: true }, + browser: { type: "local", command: ["user-browser"] }, + }, + }, +}; +const names = ["agents", "multi-work", "quota"]; +const helper = new URL("../scripts/install.ts", import.meta.url); +async function run( + command: string[], + cwd: string, + env: NodeJS.ProcessEnv = Bun.env, + input?: string, +) { + const process = Bun.spawn(command, { cwd, env, + stdin: input === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe", - timeout: 20_000, + timeout: 30_000, }); + if (input !== undefined && process.stdin) { + process.stdin.write(input); + process.stdin.end(); + } const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), ]); return { exitCode, stdout, stderr }; } - -async function install( - mode: "native" | "checksum" | "legacy", - configured = false, - playwright = false, -) { - const directory = await mkdtemp(join(tmpdir(), "stanp-install-")); +async function fixture() { + const directory = await mkdtemp("/tmp/opencode/plugin-install-"); directories.push(directory); - const config = - mode === "native" - ? join(directory, "xdg", "opencode") - : join(directory, "custom config with spaces"); - const bin = join(directory, "bin"); - const fixture = join(directory, "package"); - await Promise.all([ - mkdir(bin), - mkdir(join(fixture, "dist"), { recursive: true }), - mkdir(join(config, "plugins"), { recursive: true }), - mkdir(join(config, "stanp-opencode", "install.previous"), { recursive: true }), - ]); - await Promise.all([ - writeFile(join(config, "opencode.jsonc"), configContents), - writeFile( - join(config, "opencode.json"), - JSON.stringify( - configured - ? { - ...jsonConfig, - plugins: [ - ...jsonConfig.plugins, - { - package: "./plugins/stanp-opencode", - options: { - mode: "gpt", - provider: "existing-provider", - lunaPriority: false, - }, - }, - ], - } - : jsonConfig, - ), - ), - symlink(previousTarget, join(config, "plugins", "stanp-opencode")), - writeFile(join(config, "stanp-opencode", "install.previous", "index.js"), previousPlugin), - writeFile( - join(config, "stanp-opencode", "install.previous", "package.json"), - '{"name":"stanp-opencode","type":"module","main":"./index.js"}\n', - ), - writeFile(join(fixture, "index.js"), 'export { default } from "./dist/index.js";\n'), - writeFile( - join(fixture, "package.json"), - JSON.stringify({ - name: "stanp-opencode", - version: "1.0.0", - type: "module", - main: "./dist/index.js", - exports: { ".": "./dist/index.js", "./tui": "./dist/tui.js" }, - scripts: { postinstall: "bun ./postinstall.js" }, - }), - ), - writeFile( - join(fixture, "postinstall.js"), - 'await Bun.write(process.env.POSTINSTALL_MARKER, "executed");\n', - ), - writeFile( - join(fixture, "dist", "index.js"), - 'await Bun.write(process.env.IMPORT_MARKER, "imported");\n' + - (mode === "legacy" - ? 'export default { id: "stanp-opencode", async server() { return {}; } };\n' - : 'export default { id: "stanp-opencode", effect() { return "native-ready"; } };\n'), - ), - writeFile( - join(fixture, "dist", "tui.js"), - 'export default { id: "stanp-opencode.quota-tui", setup() { return "tui-ready"; } };\n', - ), - ]); - const env = { - ...Bun.env, - PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH ?? "/usr/bin:/bin"}`, - HOME: directory, - XDG_CONFIG_HOME: join(directory, "xdg"), - OPENCODE_CONFIG_DIR: mode === "native" ? "" : config, - FIXTURE_DIR: directory, - POSTINSTALL_MARKER: join(directory, "postinstall-ran"), - IMPORT_MARKER: join(directory, "import-ran"), - }; - const packed = await run(["tar", "-czf", "stanp-opencode.tgz", "package"], directory, env); - expect(packed, packed.stderr).toMatchObject({ exitCode: 0 }); - const digest = - mode === "checksum" - ? "0".repeat(64) - : createHash("sha256") - .update(await readFile(join(directory, "stanp-opencode.tgz"))) - .digest("hex"); - await Promise.all([ - writeFile(join(directory, "stanp-opencode.tgz.sha256"), `${digest} stanp-opencode.tgz\n`), - writeFile( - join(directory, "release.json"), - JSON.stringify({ - tag_name: "v1.0.0", - assets: ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"].map((name, id) => ({ - id: id + 1, - name, - browser_download_url: `https://fixtures.invalid/${name}`, - })), - }), - ), - writeFile( - join(bin, "curl"), - `#!/usr/bin/env bash -set -euo pipefail -url="" -output="" -while (($#)); do - case "$1" in - -o|--output) output="$2"; shift 2 ;; - https://*) url="$1"; shift ;; - *) shift ;; - esac -done -case "$url" in - https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest) source=release.json ;; - https://fixtures.invalid/stanp-opencode.tgz?asset_id=1) source=stanp-opencode.tgz ;; - https://fixtures.invalid/stanp-opencode.tgz.sha256?asset_id=2) source=stanp-opencode.tgz.sha256 ;; - *) printf 'Unexpected fixture URL: %s\\n' "$url" >&2; exit 1 ;; -esac -if [[ -n "$output" ]]; then - cp "$FIXTURE_DIR/$source" "$output" -else - cat "$FIXTURE_DIR/$source" -fi -`, - ), - writeFile( - join(bin, "sha256sum"), - `#!/usr/bin/env bash -if [[ "\${1:-}" == -* ]]; then - printf 'usage: sha256sum [-bctwz] [files ...]\n' >&2 - exit 1 -fi -PATH="\${PATH#*:}" -export PATH -exec sha256sum "$@" -`, - ), - ]); - await Promise.all([chmod(join(bin, "curl"), 0o755), chmod(join(bin, "sha256sum"), 0o755)]); - const result = await run( - ["bash", installer, ...(playwright ? ["--playwright"] : [])], - directory, - env, + const source = join(directory, "package with spaces"); + const config = join(directory, "config with spaces"); + await mkdir(join(source, "dist"), { recursive: true }); + await mkdir(join(source, "scripts")); + await mkdir(config); + for (const name of names) + await writeFile( + join(source, "dist", `${name === "quota" ? "quota-plugin" : name}.js`), + `export default { id: "stanp-opencode.${name}", setup() {} };`, + ); + await writeFile( + join(source, "dist", "tui.js"), + 'export default { id: "stanp-opencode.quota-tui", setup() {} };', ); - return { directory, config, env, result }; + const built = await Bun.build({ entrypoints: [helper.pathname], target: "bun", format: "esm" }); + expect(built.success).toBe(true); + await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!); + await writeFile(join(source, "package.json"), '{"type":"module"}'); + await writeFile( + join(config, "opencode.json"), + JSON.stringify({ ...unrelated, plugins: [{ package: "before", options: { keep: true } }] }), + ); + await writeFile(join(config, "opencode.jsonc"), "// untouched user config\n{}\n"); + return { directory, source, config }; +} +async function contents(config: string) { + return JSON.parse(await readFile(join(config, "opencode.json"), "utf8")); } -test("installs an importable native plugin and default config under XDG without running scripts", async () => { - const { directory, config, env, result } = await install("native"); - expect(result, result.stderr).toMatchObject({ exitCode: 0 }); - // The generated package path is only known at runtime; exercise its module-loading boundary. - const imported = await run( - [ - process.execPath, - "-e", - "const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.effect()]));", - join(config, "plugins", "stanp-opencode"), - ], - directory, - env, - ); - expect(imported, imported.stderr).toMatchObject({ - exitCode: 0, - stdout: '["stanp-opencode","native-ready"]\n', +for (const selection of [ + "agents", + "quota", + "agents,quota", + "agents,multi-work", + "agents,multi-work,quota", +]) { + test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => { + const f = await fixture(); + await install(f.source, f.config, selection); + const result = await contents(f.config); + expect(result).toMatchObject(unrelated); + expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([ + "before", + ...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`), + ]); + const target = join(f.config, "plugins", "stanp-opencode"); + expect((await readdir(target)).sort()).toEqual(selection.split(",").sort()); + for (const name of selection.split(",")) { + const manifest = await Bun.file(join(target, name, "package.json")).json(); + expect(manifest.exports["./tui"] !== undefined).toBe(name === "quota"); + expect(await Bun.file(join(target, name, "tui.js")).exists()).toBe(name === "quota"); + } + expect(await readFile(join(f.config, "opencode.jsonc"), "utf8")).toBe( + "// untouched user config\n{}\n", + ); }); - expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents); - expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual({ - $schema: "https://opencode.ai/config.json", - plugins: [ - { package: "existing-plugin" }, +} + +test("upgrades retain selection and options; explicit selection replaces only owned registrations", async () => { + const f = await fixture(); + await install(f.source, f.config, "agents,quota"); + const current = await contents(f.config); + current.plugins[1].options = { mode: "gpt", provider: "openai", lunaPriority: false }; + current.plugins.push({ package: "after", options: { untouched: 1 } }); + await writeFile(join(f.config, "opencode.json"), JSON.stringify(current)); + await install(f.source, f.config); + expect((await contents(f.config)).plugins).toEqual(current.plugins); + await install(f.source, f.config, "quota"); + expect((await contents(f.config)).plugins).toEqual([ + current.plugins[0], + { package: "./plugins/stanp-opencode/quota" }, + current.plugins[3], + ]); +}); + +test("mixed string/object registrations upgrade without flags and preserve unrelated entries verbatim", async () => { + const f = await fixture(); + const before = "unrelated-plugin"; + const after = { package: "unrelated-object", options: { keep: [1, true, "value"] } }; + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ + ...unrelated, + plugins: [before, { package: "./plugins/stanp-opencode/quota" }, after], + }), + ); + await install(f.source, f.config); + expect((await contents(f.config)).plugins).toEqual([ + before, + { package: "./plugins/stanp-opencode/quota" }, + after, + ]); + await install(f.source, f.config, "agents,quota"); + const updated = await contents(f.config); + expect(updated.plugins).toEqual([ + before, + { + package: "./plugins/stanp-opencode/agents", + options: { mode: "gpt", provider: "openai", lunaPriority: true }, + }, + { package: "./plugins/stanp-opencode/quota" }, + after, + ]); + expect(updated).toMatchObject(unrelated); +}); + +test("owned string registrations retain their selection and explicit selection removes mixed duplicates", async () => { + const f = await fixture(); + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ + plugins: [ + "before", + "./plugins/stanp-opencode/agents", + "./plugins/stanp-opencode/multi-work", + "./plugins/stanp-opencode/quota", + { package: "stanp-opencode/quota" }, + "after", + ], + }), + ); + await install(f.source, f.config); + const retained = await contents(f.config); + expect(retained.plugins).toEqual([ + "before", + { + package: "./plugins/stanp-opencode/agents", + options: { mode: "gpt", provider: "openai", lunaPriority: true }, + }, + { package: "./plugins/stanp-opencode/multi-work" }, + { package: "./plugins/stanp-opencode/quota" }, + "after", + ]); + await install(f.source, f.config, "quota"); + expect((await contents(f.config)).plugins).toEqual([ + "before", + { package: "./plugins/stanp-opencode/quota" }, + "after", + ]); +}); + +for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absolute"]) { + test(`owned monolith string ${target} upgrades without a selection`, async () => { + const f = await fixture(); + const packageName = + target === "absolute" ? join(f.config, "plugins", "stanp-opencode") : target; + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ plugins: ["before", packageName, "after"] }), + ); + await install(f.source, f.config); + expect((await contents(f.config)).plugins).toEqual([ + "before", { - package: "./plugins/stanp-opencode", + package: "./plugins/stanp-opencode/agents", options: { mode: "gpt", provider: "openai", lunaPriority: true }, }, - ], - permissions: { shell: "ask" }, + { package: "./plugins/stanp-opencode/multi-work" }, + { package: "./plugins/stanp-opencode/quota" }, + "after", + ]); }); - expect(await Bun.file(join(config, "plugins", "stanp-opencode", "package.json")).exists()).toBe( - true, - ); - expect(await Bun.file(join(config, "plugins", "stanp-opencode", "tui.js")).exists()).toBe(true); - expect( - await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"), - ).toBe(previousPlugin); - expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false); -}, 60_000); - -test("preserves an existing stanp-opencode configuration", async () => { - const { config, result } = await install("native", true); - expect(result, result.stderr).toMatchObject({ exitCode: 0 }); - const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8")); - expect(installed.plugins).toEqual([ - { package: "existing-plugin" }, - { - package: "./plugins/stanp-opencode", - options: { - mode: "gpt", - provider: "existing-provider", - lunaPriority: false, - }, - }, - ]); - expect(installed.permissions).toEqual(jsonConfig.permissions); -}, 60_000); - -test("enables Playwright only when requested", async () => { - const { config, result } = await install("native", false, true); - expect(result, result.stderr).toMatchObject({ exitCode: 0 }); - const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8")); - expect(installed.plugins.at(-1).options.playwright).toBe(true); -}, 60_000); - -for (const mode of ["checksum", "legacy"] as const) { - test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => { - const { directory, config, env, result } = await install(mode); - expect(result.exitCode, result.stderr).not.toBe(0); - expect(await readlink(join(config, "plugins", "stanp-opencode"))).toBe(previousTarget); - expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents); - expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual( - jsonConfig, - ); - expect( - await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"), - ).toBe(previousPlugin); - expect(await readdir(join(config, "stanp-opencode"))).toEqual(["install.previous"]); - expect(await readdir(join(config, "plugins"))).toEqual(["stanp-opencode"]); - // Load the preserved package from this test's runtime-selected configuration directory. - const imported = await run( - [ - process.execPath, - "-e", - "const { default: plugin } = await import(process.argv[1]); console.log(plugin.setup());", - join(config, "plugins", "stanp-opencode"), - ], - directory, - env, - ); - expect(imported, imported.stderr).toMatchObject({ exitCode: 0, stdout: "preserved\n" }); - expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false); - expect(await Bun.file(env.IMPORT_MARKER).exists()).toBe(mode === "legacy"); - }, 60_000); } + +test("local installer retains executable permissions and supports direct invocation", async () => { + const f = await fixture(); + const path = new URL("../install-local.sh", import.meta.url).pathname; + expect((await stat(path)).mode & 0o777).toBe(0o755); + const result = await run([path, "--plugins", "multi-work"], f.directory); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Multi-work requires Agents"); +}); + +test("replaces the legacy monolith without duplicates and retains Agents options", async () => { + const f = await fixture(); + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ + ...unrelated, + plugins: [ + { package: "before" }, + { + package: "./plugins/stanp-opencode", + options: { mode: "gpt", provider: "openai", lunaPriority: false }, + }, + { package: "stanp-opencode" }, + { package: "after" }, + ], + }), + ); + await mkdir(join(f.config, "plugins")); + await writeFile(join(f.config, "plugins", "stanp-opencode.js"), "legacy"); + await install(f.source, f.config); + const result = await contents(f.config); + expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([ + "before", + ...names.map((name) => `./plugins/stanp-opencode/${name}`), + "after", + ]); + expect(result.plugins[1].options.lunaPriority).toBe(false); + expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false); + expect(result).toMatchObject(unrelated); +}); + +test("noninteractive fresh installs require a selection; invalid selections leave config unchanged", async () => { + const f = await fixture(); + const original = await readFile(join(f.config, "opencode.json"), "utf8"); + await expect(install(f.source, f.config)).rejects.toThrow("requires --plugins"); + for (const selection of [ + "multi-work", + "multi-work,quota", + "", + "agents,agents", + "personal", + "agents,", + ]) + await expect(install(f.source, f.config, selection)).rejects.toThrow(); + expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original); +}); + +test("interactive Multi-work-only selection offers Agents", async () => { + const f = await fixture(); + const command = `${process.execPath} '${helper.pathname}' '${f.source}' '${f.config}'`; + const child = Bun.spawn(["script", "-qec", command, "/dev/null"], { + cwd: f.directory, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + let output = ""; + let selected = false; + let confirmed = false; + const reader = child.stdout.getReader(); + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + output += new TextDecoder().decode(chunk.value); + if (!selected && output.includes("Plugins (")) { + child.stdin.write("multi-work\n"); + selected = true; + } + if (!confirmed && output.includes("Add Agents?")) { + child.stdin.write("y\n"); + confirmed = true; + } + } + expect(await child.exited, output).toBe(0); + child.stdin.end(); + expect(output).toContain("Add Agents?"); + expect( + (await contents(f.config)).plugins + .slice(1) + .map((entry: { package: string }) => entry.package), + ).toEqual(["./plugins/stanp-opencode/agents", "./plugins/stanp-opencode/multi-work"]); +}); + +test("artifact validation failure leaves prior installation and config intact", async () => { + const f = await fixture(); + await install(f.source, f.config, "quota"); + const original = await readFile(join(f.config, "opencode.json"), "utf8"); + await writeFile( + join(f.source, "dist", "agents.js"), + 'export default { id: "wrong", server() {} };', + ); + // Import in a new process to avoid the runtime's module cache from prior fixture validation. + const result = await run( + [process.execPath, helper.pathname, f.source, f.config, "agents"], + f.directory, + ); + expect(result.exitCode).not.toBe(0); + expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original); + expect( + await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(), + ).toBe(true); + expect(await readdir(join(f.config, "plugins"))).toEqual(["stanp-opencode"]); +}); + +test("replaces an owned installation symlink without touching its target or unrelated plugin directories", async () => { + const f = await fixture(); + const external = join(f.directory, "old installation"); + const sibling = join(f.config, "plugins", "unrelated"); + await mkdir(external); + await mkdir(sibling, { recursive: true }); + await writeFile(join(external, "keep.txt"), "old work is untouched"); + await writeFile(join(sibling, "index.js"), "unrelated plugin"); + await symlink(external, join(f.config, "plugins", "stanp-opencode")); + await install(f.source, f.config, "quota"); + expect(await readFile(join(external, "keep.txt"), "utf8")).toBe("old work is untouched"); + expect(await readFile(join(sibling, "index.js"), "utf8")).toBe("unrelated plugin"); + expect( + await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(), + ).toBe(true); +}); + +test("both shell installers reject explicit Multi-work-only selection before doing work", async () => { + const f = await fixture(); + for (const file of ["install.sh", "install-local.sh"]) { + const result = await run( + ["bash", new URL(`../${file}`, import.meta.url).pathname, "--plugins", "multi-work"], + f.directory, + ); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Multi-work requires Agents"); + } + expect((await contents(f.config)).plugins).toHaveLength(1); +}); + +test("local shell installer builds its checkout and upgrades mixed entries without flags", async () => { + const f = await fixture(); + const after = { package: "unrelated-object", options: { keep: true } }; + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ + plugins: ["unrelated-plugin", { package: "./plugins/stanp-opencode/quota" }, after], + }), + ); + await Bun.write( + join(f.source, "install-local.sh"), + Bun.file(new URL("../install-local.sh", import.meta.url)), + ); + const bin = join(f.directory, "bin"); + await mkdir(bin); + await writeFile( + join(bin, "bun"), + `#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == install || "$1" == run ]]; then printf '%s\\n' "$PWD:$*" >> "$CALLS"; exit; fi\nexec "$REAL_BUN" "$@"\n`, + ); + await chmod(join(bin, "bun"), 0o755); + const env = { + ...Bun.env, + PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH}`, + REAL_BUN: process.execPath, + CALLS: join(f.directory, "calls"), + OPENCODE_CONFIG_DIR: f.config, + }; + const result = await run(["bash", join(f.source, "install-local.sh")], f.directory, env); + expect(result, result.stderr).toMatchObject({ exitCode: 0 }); + expect(await readFile(env.CALLS, "utf8")).toBe( + `${f.source}:install --frozen-lockfile\n${f.source}:run build\n`, + ); + expect((await contents(f.config)).plugins).toEqual([ + "unrelated-plugin", + { package: "./plugins/stanp-opencode/quota" }, + after, + ]); +}); + +for (const validChecksum of [true, false]) + test(`release shell installer verifies packaged checksum (${validChecksum})`, async () => { + const f = await fixture(); + if (validChecksum) + await writeFile( + join(f.config, "opencode.json"), + JSON.stringify({ plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"] }), + ); + const archiveRoot = join(f.directory, "archive"); + await mkdir(archiveRoot); + const packageRoot = join(archiveRoot, "package"); + await mkdir(packageRoot); + // Build the release fixture under the actual archive package prefix. + await run(["cp", "-R", `${f.source}/.`, packageRoot], f.directory); + const archive = join(f.directory, "stanp-opencode.tgz"); + expect( + (await run(["tar", "-czf", archive, "-C", archiveRoot, "package"], f.directory)) + .exitCode, + ).toBe(0); + const hash = createHash("sha256") + .update(new Uint8Array(await Bun.file(archive).arrayBuffer())) + .digest("hex"); + const checksum = join(f.directory, "checksum"); + await writeFile(checksum, `${validChecksum ? hash : "0".repeat(64)} stanp-opencode.tgz\n`); + const release = join(f.directory, "release.json"); + await writeFile( + release, + JSON.stringify({ + tag_name: "v2", + assets: [ + { + id: 1, + name: "stanp-opencode.tgz", + browser_download_url: "https://fixture/archive", + }, + { + id: 2, + name: "stanp-opencode.tgz.sha256", + browser_download_url: "https://fixture/checksum", + }, + ], + }), + ); + const bin = join(f.directory, "bin"); + await mkdir(bin); + await writeFile( + join(bin, "curl"), + `#!/usr/bin/env bash\nset -euo pipefail\nfor ((i=1;i<=$#;i++)); do if [[ "\${!i}" == -o ]]; then j=$((i+1)); output="\${!j}"; fi; done\ncase "$*" in *https://fixture/archive*) cp "$ARCHIVE" "$output";; *https://fixture/checksum*) cp "$CHECKSUM" "$output";; *) cp "$RELEASE" "$output";; esac\n`, + ); + await chmod(join(bin, "curl"), 0o755); + const result = await run( + ["bash", new URL("../install.sh", import.meta.url).pathname], + f.directory, + { + ...Bun.env, + PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH}`, + ARCHIVE: archive, + CHECKSUM: checksum, + RELEASE: release, + OPENCODE_CONFIG_DIR: f.config, + }, + ); + expect(result.exitCode, result.stderr).toBe(validChecksum ? 0 : 1); + if (validChecksum) + expect((await contents(f.config)).plugins).toEqual([ + "unrelated-plugin", + { package: "./plugins/stanp-opencode/quota" }, + ]); + else { + expect(result.stderr).toContain("checksum mismatch"); + expect((await contents(f.config)).plugins).toHaveLength(1); + } + }); diff --git a/tests/multi-work.test.ts b/tests/multi-work.test.ts index 51cbe6c..3f78dfe 100644 --- a/tests/multi-work.test.ts +++ b/tests/multi-work.test.ts @@ -143,6 +143,7 @@ function fixture() { const toolHooks: ((event: ToolHooks["execute.before"]) => Effect.Effect)[] = []; const hooks: ((event: SessionContext) => Effect.Effect)[] = []; + let prompt!: (event: Pick) => Effect.Effect; let transform!: (editor: AgentEditor) => void; const registration = Effect.succeed({ dispose: Effect.void }); const editAgents = (): AgentEditor => ({ @@ -214,9 +215,13 @@ function fixture() { } entered.resolve(); }), - hook: (_name: string, callback: (event: SessionContext) => Effect.Effect) => + hook: (name: string, callback: (event: SessionContext) => Effect.Effect) => Effect.sync(() => { - hooks.push(callback); + if (name === "context") hooks.push(callback); + if (name === "prompt") { + // SAFETY: The prompt admission hook reads only the native session ID. + prompt = callback as typeof prompt; + } }).pipe(Effect.andThen(registration)), }, tool: { @@ -256,6 +261,7 @@ function fixture() { order, entered, hooks, + prompt: (event: Pick) => prompt(event), toolHooks, call, progress, @@ -676,6 +682,45 @@ test("rejects foreign and changed-agent resumes, non-primary multi-work and unbo ); }); +test("command-created children require ready owned bindings and cannot bypass placement guards", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.invoke(); + const child = f.sessions.get("ses_child1")!; + const binding = bindingSchema.parse(f.storage.get(bindingKey(child.id))); + const admit = () => f.prompt({ sessionID: Session.ID.make(child.id) }); + yield* admit(); + f.storage.set(bindingKey(child.id), { ...binding, ready: false }); + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + f.storage.set(bindingKey(child.id), binding); + child.location.directory = "/relocated"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + child.location.directory = binding.directory; + child.projectID = "foreign"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + child.projectID = binding.projectID; + f.parent.location.directory = "/elsewhere"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + f.parent.location.directory = binding.root.directory; + f.parent.agent = "build"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + f.parent.agent = "multi-work"; + child.agent = "multi-work"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + child.agent = "fast-hi"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + child.agent = "fast-lo"; + expect((yield* Effect.exit(admit()))._tag).toBe("Failure"); + child.location.directory = f.parent.location.directory; + yield* admit(); + }), + ), + ); +}); + test("ineligible roots do not register multi-work; multi-work cannot execute shell even with session overrides", async () => { const f = fixture(); f.herdr.eligible = false; diff --git a/tests/package.test.ts b/tests/package.test.ts new file mode 100644 index 0000000..474fcd1 --- /dev/null +++ b/tests/package.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { Host } from "@opencode/plugin/host"; +import { build } from "../scripts/build.js"; +import { install } from "../scripts/install.js"; + +test("packed package has independent native plugins and quota-only TUI discovery", async () => { + const directory = await mkdtemp("/tmp/opencode/plugin-package-"); + try { + await build(); + const archive = join(directory, "stanp-opencode.tgz"); + const pack = Bun.spawn([process.execPath, "pm", "pack", "--filename", archive], { + stdout: "pipe", + stderr: "pipe", + }); + const output = new Response(pack.stderr).text(); + expect(await pack.exited, await output).toBe(0); + const unpack = Bun.spawn(["tar", "-xzf", archive, "-C", directory], { + stdout: "ignore", + stderr: "pipe", + }); + expect(await unpack.exited).toBe(0); + const source = join(directory, "package"); + const manifest = await Bun.file(join(source, "package.json")).json(); + expect(manifest.exports["."]).toBeUndefined(); + expect(await Bun.file(join(source, "index.js")).exists()).toBe(false); + expect(await Bun.file(join(source, "dist", "index.js")).exists()).toBe(false); + for (const name of ["agents", "multi-work", "quota"]) { + const entrypoint = join(source, manifest.exports[`./${name}`].import); + const { default: plugin } = await import(entrypoint); + expect(plugin.id).toBe(`stanp-opencode.${name}`); + } + const config = join(directory, "config"); + await mkdir(config); + await install(source, config, "agents,multi-work,quota"); + for (const name of ["agents", "multi-work", "quota"]) { + const entries = Host.resolve({ + directory: join(config, "plugins", "stanp-opencode", name), + }); + expect(entries.server).toBeDefined(); + expect(entries.tui !== undefined).toBe(name === "quota"); + } + const { QuotaRpc } = await import(join(source, manifest.exports["./quota-rpc"].import)); + expect(QuotaRpc.id).toBe("stanp-opencode.quota"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 30_000); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 837749c..37f3457 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -1,284 +1,151 @@ import { afterEach, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; +import { Agent, Model, Provider, type Plugin } from "@opencode/plugin"; import type { AgentEditor } from "@opencode/plugin/promise/agent"; -import type { DeepMutable } from "@opencode/plugin/promise/types"; -import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool"; -import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client"; +import type { ToolEditor } from "@opencode/plugin/promise/tool"; import type { SessionContext } from "@opencode/plugin/promise/session"; import type { SkillEditor } from "@opencode/plugin/promise/skill"; +import type { DeepMutable } from "@opencode/plugin/promise/types"; import type { Skill } from "@opencode/schema/skill"; +import type { ModelInfo } from "@opencode/client"; +import plugin from "../src/agents.js"; +import { settings, gptAgents, workerNames } from "../src/config.js"; import { z } from "zod"; -import { gptAgents, settings, workerNames } from "../src/config.js"; -import { basePlugin as plugin } from "../src/index.js"; -import { playwrightMcpCommand } from "../src/playwright.js"; -import { bindingKey } from "../src/multi-work.js"; +import { AgentsRpc } from "../src/agents-rpc.js"; -const providerID = Provider.ID.make("test"); -function model(id: string): ModelInfo { - return Model.Info.default(providerID, Model.ID.make(id)); -} -function reasoningModel(id: string): ModelInfo { - return { - ...model(id), - variants: ["low", "medium", "high"].map((effort) => ({ - id: Model.VariantID.make(effort), - settings: { reasoningEffort: effort }, - })), - }; -} -function providerModel(provider: string, id: string): ModelInfo { - return { - ...reasoningModel(id), - providerID: Provider.ID.make(provider), - }; -} -const allModels = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map(reasoningModel); -type CustomAssignment = { - model: string; - effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; -}; -const customAssignments = (provider = "test") => { - const assignment = (name: (typeof workerNames)[number]): CustomAssignment => ({ - ...gptAgents[name], - model: `${provider}/${gptAgents[name].model}`, - }); - return { - "fast-lo": assignment("fast-lo"), - "fast-hi": assignment("fast-hi"), - "good-lo": assignment("good-lo"), - "good-hi": assignment("good-hi"), - "deep-lo": assignment("deep-lo"), - "deep-hi": assignment("deep-hi"), - }; -}; const cleanups: Plugin.Cleanup[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); -type ModelContext = Pick; -type HookEvent = ModelContext | Pick; -type FixtureSetup = ( - context: Pick, -) => Plugin.Cleanup | void | Promise; - +const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({ + ...Model.Info.default(Provider.ID.make("test"), Model.ID.make(id)), + variants: ["low", "high"].map((effort) => ({ + id: Model.VariantID.make(effort), + settings: { reasoningEffort: effort }, + })), +})); async function host( - models: ModelInfo[] = allModels, + catalog: ModelInfo[] = models, options: Plugin.Context["options"] = { provider: "test" }, - configuredMcp: Record< - string, - { - type: "local"; - command: string[]; - codemode?: boolean; - disabled?: boolean; - timeout?: { startup?: number; catalog?: number; execution?: number }; - } - > = {}, - configuredMcpTools: string[] = [], - directory = "/test", - workspaceID?: string, + existingSkill?: Skill.Info, ) { const agents = new Map>(); - const build: DeepMutable = Agent.Info.default(Agent.ID.make("build")); - build.permissions.push({ action: "shell", resource: "git push *", effect: "ask" }); - agents.set("build", build); - const originalBuild = structuredClone(build); - const sessionHooks: Record Promise | void)[]> = {}; - let before!: (event: { tool: string; agent: string; input: unknown }) => Promise | void; + let build: DeepMutable = Agent.Info.default(Agent.ID.make("build")); + const skills = new Map( + existingSkill ? [[existingSkill.id, existingSkill]] : [], + ); + let transform: (editor: AgentEditor) => void; + let contextHook: (event: Pick) => Promise | void; + let promptHook: (event: { sessionID: string }) => Promise | void; + let before: (event: { tool: string; agent: string; input: unknown }) => Promise | void; let parent = "build"; let child = "fast-lo"; - let childDirectory = directory; - let defaultAgent = "build"; + let tools: string[] = []; + let toolTransform: (editor: ToolEditor) => void; + let permissions: () => Promise; + let controller: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(value) { + controller = value; + }, + }); const registration = { async dispose() {} }; - let transform!: (editor: AgentEditor) => void; - let reloaded = Promise.withResolvers(); - 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; - }, - }); - const storage = new Map(); - const tools = new Map(); - const skills = new Map(); - const mcpTools = new Set(configuredMcpTools); - const mcpServers = new Map(Object.entries(structuredClone(configuredMcp))); - let mcpTransform!: (editor: { - get(name: string): (typeof configuredMcp)[string] | undefined; - set(name: string, server: (typeof configuredMcp)[string]): void; - update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void; - }) => void; - const toolTransforms: ((editor: ToolEditor) => void)[] = []; - const editMcp = () => ({ - get: (name: string) => mcpServers.get(name), - set(name: string, server: (typeof configuredMcp)[string]) { - mcpServers.set(name, structuredClone(server)); - }, - update(name: string, update: (server: (typeof configuredMcp)[string]) => void) { - const server = mcpServers.get(name); - if (server) update(server); - }, - }); - const editTools = (): ToolEditor => ({ - get(id) { - const tool = tools.get(id); - return tool ? { ...tool, id } : undefined; - }, - list: () => [ - ...[...tools].map(([id, tool]) => ({ ...tool, id })), - ...[...mcpTools].map((id) => ({ - id, - name: id, - description: id, - input: z.object({}), - options: { codemode: false }, - async execute() { - return { content: "" }; - }, - })), - ], - namespace() {}, - add(tool) { - tools.set(tool.name, tool); - }, - update(id, update) { - const tool = tools.get(id); - if (tool) update(tool); - }, - remove(id) { - tools.delete(id); - }, - }); - // 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 replay = () => { + agents.clear(); + agents.set("build", structuredClone(build)); + transform?.({ + get: (id) => agents.get(id), + list: () => [...agents.values()], + default() {}, + remove: (id) => { + agents.delete(id); + }, + update(id, update) { + const draft = + agents.get(id) ?? structuredClone(Agent.Info.default(Agent.ID.make(id))); + update(draft); + agents.set(id, draft); + }, + }); + }; + const replayTools = () => + toolTransform?.({ + list: () => + tools.map((id) => ({ + id, + name: id, + description: id, + input: z.object({}), + async execute() { + return { content: "" }; + }, + })), + get: () => undefined, + namespace() {}, + add() { + throw new Error("Agents must not register custom tools"); + }, + update() {}, + remove() {}, + }); const context = { - location: { directory, workspaceID }, + location: { directory: "/fixture" }, options, + plugin: { + async list() { + return { data: [] }; + }, + }, model: { async list() { - return { data: models }; - }, - }, - event: { - subscribe({ signal }: { signal: AbortSignal }) { - signal.addEventListener("abort", () => events.close(), { once: true }); - return stream; - }, - }, - storage: { - async get(key: string) { - return storage.get(key); - }, - async set(key: string, value: JsonValue) { - storage.set(key, value); - }, - async remove(key: string) { - storage.delete(key); - }, - }, - integration: { - connection: { - async active() { - return undefined; - }, - async resolve() { - return undefined; - }, - }, - }, - rpc: { - async register() { - return { ...registration, events: { async emit() {} } }; - }, - }, - mcp: { - async transform(callback: typeof mcpTransform) { - mcpTransform = callback; - callback(editMcp()); - return registration; - }, - }, - skill: { - async transform(callback: (editor: SkillEditor) => void) { - callback({ - list: () => [...skills.values()], - get: (id) => skills.get(id), - add: (skill) => { - skills.set(skill.id, skill); - }, - update(id, update) { - const skill = skills.get(id); - if (skill) update(skill); - }, - remove: (id) => { - skills.delete(id); - }, - }); - return registration; + return { data: catalog }; }, }, agent: { async list() { return { data: [...agents.values()] }; }, - async get({ agentID }: { agentID: string }) { - return { data: agents.get(agentID) }; + async reload() { + replay(); }, - async transform(callback: (editor: AgentEditor) => void) { + async transform(callback: typeof transform) { transform = callback; + replay(); + return registration; + }, + }, + skill: { + async transform(callback: (editor: SkillEditor) => void) { callback({ - get: (id) => agents.get(id), - list: () => [...agents.values()], - default(id) { - defaultAgent = id ?? "build"; - }, - remove(id) { - agents.delete(id); - }, - update(id, update) { - const agent = agents.get(id) ?? Agent.Info.default(Agent.ID.make(id)); - update(agent); - agents.set(id, agent); + get: (id) => skills.get(id), + add: (skill) => { + skills.set(skill.id, skill); }, + list: () => [...skills.values()], + update() {}, + remove() {}, }); return registration; }, - async reload() { - agents.clear(); - agents.set("build", build); - await this.transform(transform); - reloaded.resolve(); - }, }, - session: { - async hook(name: string, callback: (event: HookEvent) => Promise | void) { - (sessionHooks[name] ??= []).push(callback); + rpc: { + async register( + _definition: typeof AgentsRpc, + handlers: { permissions: typeof permissions }, + ) { + permissions = handlers.permissions; return registration; }, - async get({ sessionID }: { sessionID: string }) { - return sessionID === "child" - ? { - agent: child, - projectID: "project-fixture", - parentID: "parent", - location: { directory: childDirectory }, - } - : { agent: parent, projectID: "project-fixture", location: { directory } }; + }, + event: { + subscribe({ signal }: { signal: AbortSignal }) { + signal.addEventListener("abort", () => controller.close(), { once: true }); + return stream; }, }, tool: { - async transform(callback: (editor: ToolEditor) => void) { - toolTransforms.push(callback); - callback(editTools()); + async transform(callback: typeof toolTransform) { + toolTransform = callback; + replayTools(); return registration; }, async hook(_name: string, callback: typeof before) { @@ -286,787 +153,232 @@ async function host( return registration; }, }, + session: { + async hook(name: string, callback: typeof contextHook & typeof promptHook) { + if (name === "context") contextHook = callback; + if (name === "prompt") promptHook = callback; + return registration; + }, + async get({ sessionID }: { sessionID: string }) { + return sessionID === "child" + ? { agent: child, parentID: "parent" } + : { agent: parent }; + }, + }, }; - // SAFETY: Exercise the real setup with the native operations implemented by this - // partial host; absent services intentionally fail instead of becoming inert mocks. - const setup = plugin.setup as FixtureSetup; + // SAFETY: The fixture provides precisely the native domains used by Agents; + // accessing storage, MCP registration, or quota dependencies fails loudly. + const setup = plugin.setup as ( + context: Pick, + ) => ReturnType; + replay(); const cleanup = await setup(context); if (cleanup) cleanups.push(cleanup); + const flush = async () => { + await Bun.sleep(10); + }; return { agents, skills, - build, - mcpServers, - defaultAgent, - originalBuild, - async refresh(next: ModelInfo[]) { - models = next; - reloaded = Promise.withResolvers(); - events.enqueue({ type: "model.updated" }); - await reloaded.promise; + permissions: () => permissions(), + async configure(rules: Agent.Info["permissions"]) { + build = { ...build, permissions: rules.map((rule) => ({ ...rule })) }; + replay(); + controller.enqueue({ type: "agent.updated" }); + await flush(); }, - publishModels(next: ModelInfo[], location?: { directory: string; workspaceID?: string }) { - models = next; - if (location) events.enqueue({ type: "model.updated", location }); - else events.enqueue({ type: "model.updated" }); + async models(next: ModelInfo[], location = { directory: "/fixture" }) { + catalog = next; + controller.enqueue({ type: "model.updated", location }); + await flush(); }, - async waitForReload() { - reloaded = Promise.withResolvers(); - await reloaded.promise; + async availableTools(next: string[]) { + tools = next; + replayTools(); + await flush(); }, - async changeBuildPermissions(permissions: Agent.Info["permissions"]) { - build.permissions = [...permissions]; - reloaded = Promise.withResolvers(); - events.enqueue({ type: "agent.updated" }); - await reloaded.promise; + async delegate(agent: string, target: string) { + await before({ tool: "subagent", agent, input: { agent: target } }); }, - 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 } - | undefined, - toolNames: string[], - ) { - if (server) mcpServers.set("codegraph", server); - else mcpServers.delete("codegraph"); - mcpTools.clear(); - for (const name of toolNames) mcpTools.add(name); - reloaded = Promise.withResolvers(); - mcpTransform(editMcp()); - for (const callback of toolTransforms) callback(editTools()); - await reloaded.promise; - }, - async todo(name: string, sessionID: string, input: JsonValue = {}) { - // SAFETY: Todo executors use only sessionID and agent from the native context. - return tools - .get(name)! - .execute(input, { sessionID, agent: Agent.ID.make("build") } as ToolContext); - }, - async delegate(caller: string, target: string) { - await before({ tool: "subagent", agent: caller, input: { agent: target } }); - }, - async command(caller: string, target: string) { - parent = caller; + async command(owner: string, target: string) { + parent = owner; child = target; - for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" }); + await promptHook({ sessionID: "child" }); }, - bindCoordinate(ready: boolean, moved = true, local = false) { - childDirectory = local ? directory : moved ? `${directory}/worktree` : directory; - storage.set(bindingKey("child"), { - owner: "parent", - child: "child", - projectID: "project-fixture", - ready, - root: { directory, workspaceID: "w1", repo: `${directory}/.git` }, - directory: local ? directory : `${directory}/worktree`, - workspaceID: local ? "w1" : "w2", - branch: local ? "root" : "multi-work/test", - }); - }, - async tools(caller: string) { - const event: ModelContext = { - agent: Agent.ID.make(caller), + async request(agent: string) { + const event: Pick = { + agent: Agent.ID.make(agent), tools: { subagent: { - description: "Native subagents", - input: { - type: "object", - properties: { agent: { type: "string" }, prompt: { type: "string" } }, - }, + description: "native", + input: { type: "object", properties: { agent: { type: "string" } } }, }, }, }; - for (const hook of sessionHooks.context ?? []) await hook(event); + await contextHook(event); return event.tools; }, }; } -test("orchestration inherits deep-lo and falls back to good-lo with available worker sets", async () => { - const full = await host(); - expect(full.agents.get("good-lo")?.model).toMatchObject({ - id: "gpt-6.1-sol", - variant: "low", - }); - expect(full.agents.get("good-hi")?.model).toMatchObject({ - id: "gpt-6.1-sol", - variant: "high", - }); - expect(full.agents.has("deep-hi")).toBe(true); - expect(String(full.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); - expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low"); - const reduced = await host(allModels.slice(0, 2)); - expect(reduced.agents.has("deep-hi")).toBe(false); - expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); - expect(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low"); - expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable"); - expect((await host([])).agents.has("coordinate")).toBe(false); - expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false); +test("Agents is independently loadable with no quota, storage, MCP, or custom tools", async () => { + expect(plugin.id).toBe("stanp-opencode.agents"); + const h = await host(); + expect(h.agents.has("coordinate")).toBe(true); + expect(h.agents.has("multi-work")).toBe(false); + expect(h.agents.get("build")?.mode).toBe("primary"); + expect(h.skills.get("anti-overengineering")?.content).toContain("correctness"); +}); - const customAgents = { - ...customAssignments(), - "good-lo": { - model: "test/custom-good", - effort: "high" as const, - }, - "deep-lo": { - model: "test/custom-deep", - effort: "medium" as const, - }, - }; - const custom = await host( - [...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")], - { - mode: "custom", - agents: customAgents, - }, +test("preserves an existing user anti-overengineering skill", async () => { + const first = await host(); + const original = { ...first.skills.get("anti-overengineering")!, content: "user definition" }; + const h = await host(models, { provider: "test" }, original); + expect(h.skills.get("anti-overengineering")).toEqual(original); +}); + +test("worker availability, deep fallback, and model refresh remain provider aware", async () => { + const h = await host(); + expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); + await h.models(models.slice(0, 2)); + expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); + expect(h.agents.has("deep-lo")).toBe(false); + await h.models(models.slice(0, 1)); + expect(h.agents.has("coordinate")).toBe(false); + expect(await h.request("coordinate")).toEqual({}); + await h.models(models); + expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); +}); + +test("custom models use provider-qualified names and optional deep assignments", async () => { + const assignments = Object.fromEntries( + workerNames + .filter((name) => !name.startsWith("deep-")) + .map((name) => [name, { ...gptAgents[name], model: `test/${gptAgents[name].model}` }]), + ); + const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") })); + const h = await host([...duplicate, ...models], { mode: "custom", agents: assignments }); + expect(String(h.agents.get("coordinate")?.model?.providerID)).toBe("test"); + expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); + expect(h.agents.has("deep-hi")).toBe(false); + expect(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).toBe( + false, ); - expect(custom.agents.get("coordinate")?.model).toMatchObject({ - id: "custom-deep", - variant: "medium", - }); - const customFallback = await host([...allModels, reasoningModel("custom-good")], { - mode: "custom", - agents: customAgents, - }); - expect(customFallback.agents.get("coordinate")?.model).toMatchObject({ - id: "custom-good", - variant: "high", - }); - expect(customFallback.agents.has("deep-lo")).toBe(false); expect( - ( - await host(allModels, { - mode: "custom", - agents: customAgents, - }) - ).agents.has("coordinate"), + settings.safeParse({ + mode: "custom", + agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } }, + }).success, ).toBe(false); }); -test("custom deep assignments are optional and a partial deep tier falls back to good-lo", async () => { - for (const omitted of [["deep-lo", "deep-hi"], ["deep-lo"], ["deep-hi"]] as const) { - const agents: Partial> = customAssignments(); - for (const name of omitted) delete agents[name]; - const result = await host(allModels, { mode: "custom", agents }); - expect(result.agents.get("coordinate")?.model).toMatchObject({ - id: "gpt-6.1-sol", - variant: "low", - }); - expect(result.agents.get("coordinate")?.system).toContain("Deep agents are unavailable"); - for (const name of ["fast-lo", "fast-hi", "good-lo", "good-hi"]) - expect(result.agents.has(name)).toBe(true); - expect(result.agents.has("deep-lo")).toBe(false); - expect(result.agents.has("deep-hi")).toBe(false); - expect((await result.tools("coordinate")).subagent?.input.properties).toMatchObject({ - agent: { type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }, - }); - } -}); - -test("registers the embedded anti-overengineering skill and makes it available to coordinate", async () => { - const result = await host(); - const skill = result.skills.get("anti-overengineering")!; - expect(String(skill.name)).toBe("anti-overengineering"); - expect(skill.content).toContain("## Correctness floor"); - expect(skill.content).toContain("## Final subtraction pass"); - expect(await Bun.file(skill.path).exists()).toBe(true); - const coordinate = result.agents.get("coordinate")!; - expect(coordinate.permissions).toContainEqual({ - action: "skill", - resource: "*", - effect: "allow", - }); - expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); - for (const models of [allModels, allModels.slice(0, 2)]) { - const prompt = (await host(models)).agents.get("coordinate")?.system; - expect(prompt).toContain("Audit subagent review"); - expect(prompt).toContain("reject overkill or"); - expect(prompt).toContain("BEFORE delegating it"); - expect(prompt).toContain("Ordinary in-scope bugfixes need no extra approval"); - expect(prompt).toContain("Never waive correctness, security or data integrity"); - } -}); - -test("custom assignments resolve duplicate model IDs by provider and refresh provider-aware fallback", async () => { - const agents = customAssignments("alpha"); - agents["fast-lo"] = { ...agents["fast-lo"], model: "beta/shared" }; - agents["fast-hi"] = { ...agents["fast-hi"], model: "alpha/shared" }; - agents["deep-lo"] = { ...agents["deep-lo"], model: "beta/deep" }; - agents["deep-hi"] = { ...agents["deep-hi"], model: "beta/deep" }; - const base = [ - providerModel("alpha", "shared"), - providerModel("beta", "shared"), - providerModel("alpha", "gpt-6.1-sol"), - providerModel("alpha", "gpt-6-astra"), +test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => { + const h = await host(); + const rules = [ + { action: "*", resource: "*", effect: "allow" as const }, + { action: "read", resource: "secret", effect: "deny" as const }, ]; - const result = await host(base, { mode: "custom", agents }); - expect(String(result.agents.get("fast-lo")?.model?.providerID)).toBe("beta"); - expect(String(result.agents.get("fast-hi")?.model?.providerID)).toBe("alpha"); - expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); - expect(result.agents.has("deep-lo")).toBe(false); - - await result.refresh([...base, providerModel("beta", "deep")]); - expect(result.agents.get("coordinate")?.model).toMatchObject({ - providerID: "beta", - id: "deep", - }); - expect(result.agents.has("deep-lo")).toBe(true); - - const nested = customAssignments("alpha"); - nested["fast-lo"] = { ...nested["fast-lo"], model: "alpha/group/shared" }; - const nestedResult = await host( - [...base, providerModel("alpha", "gpt-6-luna"), providerModel("alpha", "group/shared")], - { mode: "custom", agents: nested }, + await h.configure(rules); + expect(await h.permissions()).toEqual(rules); + expect(h.agents.get("fast-lo")?.permissions).toEqual([ + ...rules, + { action: "subagent", resource: "*", effect: "deny" }, + ]); + expect(h.agents.get("coordinate")?.permissions).toContainEqual(rules[1]); + expect(h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "shell")).toBe( + false, ); - expect(String(nestedResult.agents.get("fast-lo")?.model?.id)).toBe("group/shared"); }); -test("custom configuration requires qualified models and rejects obsolete fields", () => { - expect(() => settings.parse({ mode: "custom", agents: customAssignments() })).not.toThrow(); - for (const omitted of [["deep-lo", "deep-hi"], ["deep-lo"], ["deep-hi"]] as const) { - const agents: Partial> = customAssignments(); - for (const name of omitted) delete agents[name]; - expect(() => settings.parse({ mode: "custom", agents })).not.toThrow(); - } - for (const name of ["fast-lo", "fast-hi", "good-lo", "good-hi"] as const) { - const agents: Partial> = customAssignments(); - delete agents[name]; - expect(() => settings.parse({ mode: "custom", agents })).toThrow(); - } - for (const name of ["deep-lo", "deep-hi"] as const) - expect(() => - settings.parse({ - mode: "custom", - agents: { ...customAssignments(), [name]: { model: "test/model" } }, - }), - ).toThrow(); - for (const model of ["bare", "/model", "provider/"]) - expect(() => - settings.parse({ - mode: "custom", - agents: { - ...customAssignments(), - "fast-lo": { model, effort: "low" }, - }, - }), - ).toThrow(); - const nested = customAssignments(); - nested["fast-lo"] = { model: "provider/group/model", effort: "low" }; - expect(() => settings.parse({ mode: "custom", agents: nested })).not.toThrow(); - expect(() => - settings.parse({ mode: "custom", provider: "test", agents: customAssignments() }), - ).toThrow(); - expect(() => - settings.parse({ mode: "custom", lunaPriority: false, agents: customAssignments() }), - ).toThrow(); - expect(() => - settings.parse({ - mode: "custom", - agents: { - ...customAssignments(), - "fast-lo": { ...customAssignments()["fast-lo"], provider: "test" }, - }, - }), - ).toThrow(); - expect(() => - settings.parse({ - mode: "custom", - agents: { - ...customAssignments(), - "fast-lo": { ...customAssignments()["fast-lo"], lunaPriority: true }, - }, - }), - ).toThrow(); - expect(() => - settings.parse({ - mode: "custom", - agents: { ...customAssignments(), coordinate: customAssignments()["deep-lo"] }, - }), - ).not.toThrow(); - for (const obsolete of [{ provider: "test" }, { lunaPriority: true }]) - expect(() => - settings.parse({ - mode: "custom", - agents: { - ...customAssignments(), - coordinate: { - ...customAssignments()["deep-lo"], - ...obsolete, - }, - }, - }), - ).toThrow(); -}); - -test("worker inheritance and coordinator isolation preserve build and default", async () => { - const result = await host(); - expect(result.defaultAgent).toBe("build"); - expect(result.agents.get("build")).toEqual(result.originalBuild); - const worker = result.agents.get("fast-lo")!; - expect(worker.permissions).toContainEqual({ - action: "shell", - resource: "git push *", - effect: "ask", - }); - expect(worker.permissions.at(-1)).toEqual({ - action: "subagent", +test("available Codegraph tools inherit permission rules without changing MCP configuration", async () => { + const h = await host(); + await h.availableTools(["codegraph_query", "docs_search"]); + await h.configure([ + { action: "*", resource: "*", effect: "allow" }, + { action: "code*", resource: "*", effect: "ask" }, + ]); + expect(h.agents.get("coordinate")?.permissions).toContainEqual({ + action: "codegraph_query", resource: "*", - effect: "deny", - }); - const coordinate = result.agents.get("coordinate")!; - expect(coordinate.permissions[0]).toEqual({ action: "*", resource: "*", effect: "deny" }); - expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); - expect(coordinate.permissions).toContainEqual({ - action: "read", - resource: "*.env", effect: "ask", }); - expect(coordinate.permissions).toContainEqual({ - action: "subagent", - resource: "fast-lo", - effect: "allow", - }); -}); - -test("coordinator inherits only codegraph MCP permissions when that server exists", async () => { - const absent = await host(); expect( - absent.agents + h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "docs_search"), + ).toBe(false); + await h.availableTools([]); + expect( + h.agents .get("coordinate") ?.permissions.some((rule) => rule.action.startsWith("codegraph_")), ).toBe(false); - expect(absent.mcpServers.has("codegraph")).toBe(false); - - const result = await host( - allModels, - { provider: "test" }, - { - codegraph: { type: "local", command: ["codegraph"], codemode: true }, - docs: { type: "local", command: ["docs"], codemode: true }, - }, - ["codegraph_codegraph_search", "codegraph_future_query", "docs_search"], - ); - expect(result.mcpServers.get("codegraph")?.codemode).toBe(false); - expect(result.mcpServers.get("docs")?.codemode).toBe(true); - let coordinate = result.agents.get("coordinate")!; - expect(coordinate.permissions).toContainEqual({ - action: "codegraph_codegraph_search", - resource: "*", - effect: "allow", - }); - expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false); - expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); - expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false); - - await result.changeBuildPermissions([ - { action: "*", resource: "*", effect: "allow" }, - { action: "code*", resource: "*", effect: "ask" }, - { action: "*search", resource: "*", effect: "deny" }, - { action: "docs_*", resource: "*", effect: "allow" }, - { action: "shell", resource: "*", effect: "allow" }, - ]); - coordinate = result.agents.get("coordinate")!; - expect(coordinate.permissions).toContainEqual({ - action: "codegraph_future_query", - resource: "*", - effect: "ask", - }); - expect(coordinate.permissions).toContainEqual({ - action: "codegraph_codegraph_search", - resource: "*", - effect: "deny", - }); - expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false); - expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); - - await result.changeCodegraph(undefined, []); - coordinate = result.agents.get("coordinate")!; - expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false); - await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [ - "codegraph_added_later", - ]); - expect(result.mcpServers.get("codegraph")?.codemode).toBe(false); - expect(result.agents.get("coordinate")?.permissions).toContainEqual({ - action: "codegraph_added_later", - resource: "*", - effect: "ask", - }); }); -test("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves fallback behavior", async () => { - const originalPath = process.env.PATH; - const root = await mkdtemp(join(tmpdir(), "stanp-playwright-")); - try { - process.env.PATH = ""; - const packageRoot = join(root, "local", "node_modules", "@playwright", "mcp"); - const executable = join(packageRoot, "cli.js"); - const localProject = join(root, "local", "project"); - const globalProject = join(root, "global-project"); - await mkdir(localProject, { recursive: true }); - await mkdir(globalProject); - await mkdir(packageRoot, { recursive: true }); - await writeFile( - join(packageRoot, "package.json"), - JSON.stringify({ name: "@playwright/mcp", bin: { "playwright-mcp": "cli.js" } }), - ); - await writeFile(executable, "#!/usr/bin/env node\n"); - await chmod(executable, 0o755); - - const local = await host( - allModels, - { provider: "test", playwright: true }, - {}, - [], - localProject, - ); - expect(local.mcpServers.get("playwright")).toEqual({ - type: "local", - command: [executable], - timeout: { startup: 300_000 }, - }); - - const globalPackage = join(root, "global", "@playwright", "mcp"); - const globalExecutable = join(globalPackage, "cli.js"); - const globalBin = join(root, "bin"); - const pathExecutable = join(globalBin, "playwright-mcp"); - await mkdir(globalPackage, { recursive: true }); - await mkdir(globalBin, { recursive: true }); - await writeFile( - join(globalPackage, "package.json"), - JSON.stringify({ name: "@playwright/mcp", bin: { "playwright-mcp": "cli.js" } }), - ); - await writeFile(globalExecutable, "#!/usr/bin/env node\n"); - await chmod(globalExecutable, 0o755); - await symlink(globalExecutable, pathExecutable); - expect(await playwrightMcpCommand(globalProject, globalBin)).toEqual([pathExecutable]); - - const ordinaryPackage = join(root, "ordinary-project", "node_modules", "playwright"); - const ordinaryProject = join(root, "ordinary-project", "project"); - const ordinaryExecutable = join(ordinaryPackage, "cli.js"); - await mkdir(ordinaryPackage, { recursive: true }); - await mkdir(ordinaryProject); - await writeFile( - join(ordinaryPackage, "package.json"), - JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }), - ); - await writeFile(ordinaryExecutable, "#!/usr/bin/env node\n"); - await chmod(ordinaryExecutable, 0o755); - const ordinary = await host( - allModels, - { provider: "test", playwright: true }, - {}, - [], - ordinaryProject, - ); - expect(ordinary.mcpServers.get("playwright")?.command).toEqual([ - "npx", - "-y", - "@playwright/mcp@latest", - ]); - - const testPackage = join(root, "test-project", "node_modules", "@playwright", "test"); - const testDependency = join(testPackage, "node_modules", "playwright"); - const testProject = join(root, "test-project", "project"); - await mkdir(testDependency, { recursive: true }); - await mkdir(testProject); - await writeFile( - join(testPackage, "package.json"), - JSON.stringify({ - name: "@playwright/test", - bin: { playwright: "cli.js" }, - dependencies: { playwright: "1.0.0" }, - }), - ); - await writeFile(join(testPackage, "cli.js"), "#!/usr/bin/env node\n"); - await chmod(join(testPackage, "cli.js"), 0o755); - await writeFile( - join(testDependency, "package.json"), - JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }), - ); - await writeFile(join(testDependency, "cli.js"), "#!/usr/bin/env node\n"); - await chmod(join(testDependency, "cli.js"), 0o755); - expect(await playwrightMcpCommand(testProject, "")).toEqual([ - "npx", - "-y", - "@playwright/mcp@latest", - ]); - - const pathPackage = join(root, "path-playwright", "playwright"); - const pathBin = join(root, "playwright-bin"); - const pathPlaywright = join(pathBin, "playwright"); - await mkdir(pathPackage, { recursive: true }); - await mkdir(pathBin); - await writeFile( - join(pathPackage, "package.json"), - JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }), - ); - await writeFile(join(pathPackage, "cli.js"), "#!/usr/bin/env node\n"); - await chmod(join(pathPackage, "cli.js"), 0o755); - await symlink(join(pathPackage, "cli.js"), pathPlaywright); - expect(await playwrightMcpCommand(globalProject, pathBin)).toEqual([ - "npx", - "-y", - "@playwright/mcp@latest", - ]); - - const ordinaryBin = join(root, "ordinary-bin"); - const fakePlaywright = join(ordinaryBin, "playwright"); - await mkdir(ordinaryBin); - await writeFile(fakePlaywright, "#!/usr/bin/env node\n"); - await chmod(fakePlaywright, 0o755); - expect((await playwrightMcpCommand(globalProject, ordinaryBin))[0]).toBe("docker"); - - const fallback = await host(allModels, { provider: "test", playwright: true }); - expect(fallback.mcpServers.get("playwright")).toEqual({ - type: "local", - command: ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"], - timeout: { startup: 300_000 }, - }); - - const existing = await host( - allModels, - { provider: "test" }, - { - playwright: { - type: "local", - command: ["custom-playwright-mcp"], - disabled: true, - timeout: { startup: 12_345 }, - }, - docs: { type: "local", command: ["docs"], codemode: true }, - }, - ); - expect(existing.mcpServers.get("playwright")).toEqual({ - type: "local", - command: ["custom-playwright-mcp"], - disabled: true, - timeout: { startup: 12_345 }, - }); - expect(existing.mcpServers.get("docs")?.codemode).toBe(true); - - const disabled = await host(); - expect(disabled.mcpServers.has("playwright")).toBe(false); - } finally { - if (originalPath === undefined) delete process.env.PATH; - else process.env.PATH = originalPath; - await rm(root, { recursive: true, force: true }); - } -}); - -test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => { - const result = await host(allModels.slice(0, 2)); - for (const invoke of [result.delegate, result.command]) { +test("native tool and command paths retain core delegation restrictions", async () => { + const h = await host(models.slice(0, 2)); + for (const invoke of [h.delegate, h.command]) { await expect(invoke("build", "coordinate")).rejects.toThrow("cannot run as a subagent"); + await expect(invoke("multi-work", "coordinate")).rejects.toThrow("without Multi-work"); await expect(invoke("fast-hi", "good-lo")).rejects.toThrow("Workers cannot delegate"); await expect(invoke("coordinate", "deep-lo")).rejects.toThrow( "only to its available workers", ); await invoke("coordinate", "good-hi"); } - const tools = await result.tools("coordinate"); - expect(tools.subagent?.input.properties).toEqual({ - agent: { type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }, - prompt: { type: "string" }, + expect(await h.request("fast-lo")).toEqual({}); + expect( + z + .object({ agent: z.object({ type: z.string(), enum: z.array(z.string()) }) }) + .parse((await h.request("coordinate")).subagent?.input.properties).agent, + ).toEqual({ type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }); +}); + +test("foreign-location model events do not refresh this plugin instance", async () => { + const h = await host(); + await h.models([], { directory: "/elsewhere" }); + expect(h.agents.has("coordinate")).toBe(true); +}); + +test("reasoning uses advertised variants and falls back to request settings", async () => { + const h = await host(); + expect(String(h.agents.get("fast-lo")?.model?.variant)).toBe("low"); + expect(h.agents.get("fast-lo")?.request?.settings).toEqual({}); + const noVariants = models.map((model) => ({ ...model, variants: [] })); + const fallback = await host(noVariants); + expect(fallback.agents.get("fast-lo")?.model?.variant).toBeUndefined(); + expect(fallback.agents.get("fast-lo")?.request?.settings).toEqual({ reasoningEffort: "low" }); + expect(fallback.agents.get("coordinate")?.request?.settings).toEqual({ + reasoningEffort: "low", }); - expect(await result.tools("fast-lo")).toEqual({}); - expect((await result.tools("build")).subagent?.description).toBe("Native subagents"); }); -test("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => { - const result = await host(); - await expect(result.command("multi-work", "coordinate")).rejects.toThrow( - "ready multi-work binding", - ); - result.bindCoordinate(false); - await expect(result.command("multi-work", "coordinate")).rejects.toThrow( - "ready multi-work binding", - ); - result.bindCoordinate(true, false); - await expect(result.command("multi-work", "coordinate")).rejects.toThrow( - "ready multi-work binding", - ); - result.bindCoordinate(true); - await result.command("multi-work", "coordinate"); - result.bindCoordinate(true, true, true); - await result.command("multi-work", "coordinate"); - await expect(result.command("build", "coordinate")).rejects.toThrow("ready multi-work binding"); - result.bindCoordinate(true); - await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo"); - for (const invoke of [result.delegate, result.command]) - await expect(invoke("build", "multi-work")).rejects.toThrow("primary only"); -}); - -test("reasoning settings and Luna priority use native advertised or official support", async () => { - const luna = { - ...providerModel("openai", "gpt-6-luna"), +test("Luna priority requires official or advertised support and can be disabled", async () => { + const openai = models.map((model) => ({ + ...model, + providerID: Provider.ID.make("openai"), package: "@opencode/ai/providers/openai", - settings: { baseURL: "https://api.openai.com/v1" }, - }; - const openaiModels = [ - luna, - providerModel("openai", "gpt-6.1-sol"), - providerModel("openai", "gpt-6-astra"), - ]; - const ordinary = await host([providerModel("openai", "gpt-6-luna"), ...openaiModels.slice(1)], { - provider: "openai", - }); - expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({}); - expect(String(ordinary.agents.get("fast-lo")?.model?.variant)).toBe("low"); - const official = await host(openaiModels, { provider: "openai" }); - expect(official.agents.get("fast-hi")?.request.settings).toEqual({ serviceTier: "priority" }); - const proxy = await host( - [{ ...luna, settings: { baseURL: "https://proxy.example/v1" } }, ...openaiModels.slice(1)], - { provider: "openai" }, - ); - expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({}); + })); + const official = await host(openai, { provider: "openai" }); + expect(official.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("priority"); + expect(official.agents.get("good-lo")?.request?.settings.serviceTier).toBeUndefined(); + const disabled = await host(openai, { provider: "openai", lunaPriority: false }); + expect(disabled.agents.get("fast-hi")?.request?.settings.serviceTier).toBeUndefined(); + const proxy = openai.map((model) => ({ + ...model, + settings: { baseURL: "https://proxy.example/v1" }, + })); + const unadvertised = await host(proxy, { provider: "openai" }); + expect(unadvertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBeUndefined(); const advertised = await host( - [ - { ...luna, package: "custom", settings: { serviceTier: "fast" } }, - ...openaiModels.slice(1), - ], + proxy.map((model) => ({ ...model, settings: { ...model.settings, serviceTier: "fast" } })), { provider: "openai" }, ); - expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast"); - const sdkAlias = await host( - [{ ...luna, package: "@ai-sdk/openai" }, ...openaiModels.slice(1)], - { provider: "openai" }, + expect(advertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("fast"); +}); + +test("disabled catalog entries do not authorize worker availability", async () => { + const h = await host( + models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })), ); - expect(sdkAlias.agents.get("fast-lo")?.request.settings.serviceTier).toBe("priority"); - const disabled = await host(openaiModels, { - provider: "openai", - lunaPriority: false, - }); - expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({}); - - const custom = customAssignments("openai"); - const customResult = await host(openaiModels, { - mode: "custom", - agents: custom, - }); - expect(customResult.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined(); - expect(customResult.agents.get("fast-hi")?.request.settings.serviceTier).toBeUndefined(); - - const other = await host(allModels, { provider: "test" }); - expect(other.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined(); - expect(() => settings.parse({ mode: "gpt", provider: "test", lunaPriority: true })).toThrow(); -}); - -test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => { - for (const options of [{ provider: "test" }, { mode: "gpt", provider: "test" }] as const) { - const result = await host(allModels, options); - expect(result.agents.get("coordinate")?.model).toMatchObject({ - id: "gpt-6-astra", - variant: "low", - }); - expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); - } - - const fallback = await host(allModels.slice(0, 2)); - expect(fallback.agents.get("coordinate")?.model).toMatchObject({ - id: "gpt-6.1-sol", - variant: "low", - }); - expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); - - const custom = await host([...allModels, model("local")], { - mode: "custom", - agents: { - ...customAssignments(), - coordinate: { model: "test/local", effort: "xhigh" }, - }, - }); - expect(custom.agents.get("coordinate")?.model).toMatchObject({ - id: "gpt-6-astra", - variant: "low", - }); - expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); -}); - -test("the current host model API filters discovery and reloads changed availability", async () => { - const disabled = { ...reasoningModel("gpt-6-luna"), enabled: false }; - const other = { ...reasoningModel("gpt-6.1-sol"), providerID: Provider.ID.make("other") }; - expect((await host([disabled, other, ...allModels.slice(1)])).agents.has("coordinate")).toBe( - false, - ); - const result = await host([]); - await result.refresh(allModels); - expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); - expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low"); - expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); - await result.refresh(allModels.slice(0, 2)); - expect(result.agents.has("deep-lo")).toBe(false); - expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); - expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low"); - expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); - await result.refresh([]); - expect(result.agents.has("coordinate")).toBe(false); -}); - -test("late build permission overrides remain effective for workers and coordinator", async () => { - const result = await host(); - await result.changeBuildPermissions([ - ...result.build.permissions, - { action: "read", resource: "*.env", effect: "deny" }, - { action: "shell", resource: "git push *", effect: "deny" }, - ]); - expect(result.agents.get("fast-lo")?.permissions).toContainEqual({ - action: "shell", - resource: "git push *", - effect: "deny", - }); - expect( - result.agents - .get("coordinate") - ?.permissions.filter((rule) => rule.action === "read" && rule.resource === "*.env") - .at(-1)?.effect, - ).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("model updates require an exact workspace match within the same directory", async () => { - const result = await host([], { provider: "test" }, {}, [], "/test", "workspace-a"); - - result.publishModels(allModels, { directory: "/test" }); - await Bun.sleep(0); - expect(result.agents.has("coordinate")).toBe(false); - - result.publishModels(allModels, { directory: "/test", workspaceID: "workspace-b" }); - await Bun.sleep(0); - expect(result.agents.has("coordinate")).toBe(false); - - const reloaded = result.waitForReload(); - result.publishModels(allModels, { directory: "/test", workspaceID: "workspace-a" }); - await reloaded; - expect(result.agents.has("coordinate")).toBe(true); -}); - -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" }]; - await result.todo("todowrite", "one", { todos }); - expect((await result.todo("todoread", "one")).content).toBe(JSON.stringify(todos)); - expect((await result.todo("todoread", "two")).content).toBe("[]"); - result.build.permissions.push({ action: "todowrite", resource: "*", effect: "ask" }); - await expect(result.todo("todowrite", "one", { todos: [] })).rejects.toThrow("cannot request"); - expect((await result.todo("todoread", "one")).content).toBe(JSON.stringify(todos)); - result.build.permissions.push({ action: "todoread", resource: "*", effect: "deny" }); - await expect(result.todo("todoread", "one")).rejects.toThrow("(deny)"); + expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); + expect(h.agents.has("deep-lo")).toBe(false); }); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts new file mode 100644 index 0000000..4b4e9db --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import type { Plugin } from "@opencode/plugin"; +import type { Context } from "@opencode/plugin/effect/plugin"; +import { Effect } from "effect"; +import quota from "../src/quota-plugin.js"; +import multiWork from "../src/multi-work.js"; +import { QuotaRpc } from "../src/quota-rpc.js"; +import { AgentsRpc } from "../src/agents-rpc.js"; + +test("Quota loads independently and retains its RPC ID and nullable status", async () => { + let controller: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(value) { + controller = value; + }, + }); + let status: (() => Promise) | undefined; + let disposed = false; + let modelHook = false; + const context = { + options: {}, + location: { directory: "/fixture" }, + integration: { + connection: { + async active() { + return undefined; + }, + }, + }, + event: { + subscribe({ signal }: { signal: AbortSignal }) { + signal.addEventListener("abort", () => controller.close(), { once: true }); + return stream; + }, + }, + rpc: { + async register(definition: typeof QuotaRpc, handlers: { status: () => Promise }) { + expect(definition.id).toBe("stanp-opencode.quota"); + status = handlers.status; + return { + async dispose() { + disposed = true; + }, + events: { async emit() {} }, + }; + }, + }, + session: { + async hook(name: string) { + expect(name).toBe("model.request"); + modelHook = true; + return { async dispose() {} }; + }, + }, + }; + // SAFETY: Quota with no credentials uses only these native host services. + // Absent agent/model/MCP APIs fail if independence regresses. + const setup = quota.setup as ( + context: Pick, + ) => ReturnType; + const cleanup = await setup(context); + expect(quota.id).toBe("stanp-opencode.quota"); + expect(await status!()).toBeNull(); + expect(modelHook).toBe(true); + if (cleanup) await cleanup(); + expect(disposed).toBe(true); +}); + +test("Multi-work rejects missing Agents before any Herdr operations or registrations", async () => { + const context = { + rpc: (definition: typeof AgentsRpc) => { + expect(definition.id).toBe("stanp-opencode.agents"); + return { permissions: () => Effect.fail(new Error("RPC not registered")) }; + }, + }; + // SAFETY: Missing Agents fails at the first RPC call; no other host service is accessed. + const activate = multiWork.effect as ( + context: Pick, + ) => ReturnType; + const result = await Effect.runPromiseExit( + Effect.scoped(activate({ ...context, options: {} })), + ); + expect(String(result)).toContain("Multi-work requires Agents loaded before it"); +}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 6bd1dab..adfef4a 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -247,7 +247,7 @@ async function accountFingerprint(id: string) { return Buffer.from(bytes).toString("hex"); } -test("reads old cached snapshots without session estimates and removes excluded windows", async () => { +test("reads cached snapshots and removes excluded windows", async () => { const cached = { account: await accountFingerprint("account-one"), latest: { @@ -284,30 +284,6 @@ test("reads old cached snapshots without session estimates and removes excluded }, ], }, - 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( { @@ -328,7 +304,6 @@ test("reads old cached snapshots without session estimates and removes excluded "codex_luna:primary", ]); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); - expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions"); expect(result.calls()).toBe(1); await result.tracker.dispose(); });