From 5cf0beb74ba02638194ba0ea375298b70c625422 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Thu, 17 Sep 2026 21:09:19 +0200 Subject: [PATCH] feat: multi-work agent --- README.md | 16 + bun.lock | 1 + docs/multi-work.md | 167 ++++++++ examples/custom.opencode.jsonc | 8 +- examples/gpt.opencode.jsonc | 4 +- install-local.sh | 2 +- install.sh | 2 +- package.json | 2 + scripts/test-multi-work-host.ts | 295 +++++++++++++ src/config.ts | 6 +- src/herdr.ts | 231 ++++++++++ src/index.ts | 605 +++++++++++++++------------ src/multi-work.ts | 444 ++++++++++++++++++++ src/routing.ts | 17 + tests/composite.test.ts | 464 ++++++++++++++++++++ tests/fixtures/native-host-plugin.ts | 102 +++++ tests/herdr.test.ts | 153 +++++++ tests/install.test.ts | 4 +- tests/multi-work.test.ts | 583 ++++++++++++++++++++++++++ tests/plugin.test.ts | 100 ++++- 20 files changed, 2907 insertions(+), 299 deletions(-) create mode 100644 docs/multi-work.md create mode 100644 scripts/test-multi-work-host.ts create mode 100644 src/herdr.ts create mode 100644 src/multi-work.ts create mode 100644 tests/composite.test.ts create mode 100644 tests/fixtures/native-host-plugin.ts create mode 100644 tests/herdr.test.ts create mode 100644 tests/multi-work.test.ts diff --git a/README.md b/README.md index 5fdaf4a..ff1524f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,22 @@ Both installers use `~/.config/opencode`, respect `XDG_CONFIG_HOME`, and accept 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. Set `lunaPriority` to `false` to avoid requesting priority pricing. +Custom configurations require the six worker assignments. The legacy optional `coordinate` +assignment is accepted for compatibility but deprecated and ignored; orchestration inherits +`deep-lo`, or `good-lo` when the configured `deep-lo` model is unavailable. + +## Multi-work + +Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval +to `fast-lo` and independent work to `coordinate` children in new Herdr worktrees. +It is primary-only and, like `coordinate`, uses the `deep-lo` model and effort, +falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the +same eligible location can see it; eligibility is revalidated before creation. + +Configure `experimental.subagent_depth: 2` at the root and worktree locations. +The native `subagent` tool accepts `worktree: { base: "source-branch" }` for new +coordinate tasks and retains native session/job handling. See [usage, permissions, +failure handling, and verification limits](docs/multi-work.md). ## Playwright diff --git a/bun.lock b/bun.lock index 73ff525..97145f4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "stanp-opencode", "dependencies": { + "effect": "4.0.0-rc.112", "zod": "4.6.5", }, "devDependencies": { diff --git a/docs/multi-work.md b/docs/multi-work.md new file mode 100644 index 0000000..23dd1a0 --- /dev/null +++ b/docs/multi-work.md @@ -0,0 +1,167 @@ +# Multi-work + +`multi-work` is a **primary-only** agent for coordinating independent work in +Herdr-managed Git worktrees. Like `coordinate`, it uses the configured `deep-lo` +model and effort, falling back to `good-lo` when `deep-lo` is unavailable. No +extra custom model assignment is required. + +```text +multi-work (root checkout) +├── fast-lo (local retrieval) +└── coordinate (new Herdr worktree) + └── existing leaf workers (that worktree) +``` + +## Availability + +The agent appears when the plugin can verify a live, unambiguous Herdr root +workspace for the current **checkout root directory**, and coordinate/fast-lo are +available. Herdr and Git must be executable by the OpenCode server. The CLI uses +that server process's Herdr socket/session configuration; it never targets the +UI-focused workspace. + +Eligibility is location-based: all OpenCode clients viewing the eligible location +can see the agent. This does not identify the client's pane. Linked worktrees, +subdirectories, closed workspaces, duplicate workspace ownership, unavailable CLI, +and failed discovery do not qualify. Missing workspace metadata is resolved using +Herdr's worktree inventory rather than assuming a Git root is a Herdr root. + +The registry refreshes every 30 seconds. Every multi-work model turn and delegation +rechecks the executing session's location; creation revalidates the root again +immediately before its Git/Herdr operations. Stale picker visibility cannot authorize +creation. Creating/opening the root workspace remains a user action. + +## Configuration + +Use OpenCode **2.0.4**, the version this integration targets. At both the root and +destination locations, configure: + +```jsonc +{ + "experimental": { "subagent_depth": 2 }, +} +``` + +The plugin does not modify your global configuration. Native OpenCode still +enforces nesting depth; its default of 1 is insufficient for coordinate's workers. +The public plugin context does not expose a configuration reader to preflight +the effective depth. + +Each destination must load the multi-work-enabled plugin and the required worker +models. A global plugin installation/configuration is useful when source branches +have different project configuration. The tool checks the destination's live agent +registry for the coordinate protocol marker and required worker agents before +prompting. This checks feature availability, not the trustworthiness of other +plugins or project configuration. + +## Delegation + +Multi-work retains the native tool name, `subagent`. It adds one argument: + +```js +subagent({ + agent: "coordinate", + description: "Implement API change", + prompt: "Goal, scope, constraints, and acceptance checks...", + worktree: { base: "feature/source" }, + background: true, +}); +``` + +`base` identifies a locally available branch or remote-tracking branch. The tool +resolves its commit and creates a new unique `multi-work/` branch with Herdr. +It does not fetch refs or copy uncommitted root changes. Herdr determines the +checkout path; the tool parses and verifies the returned checkout/workspace. +Creation explicitly preserves focus with `--no-focus`. + +For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no +`worktree`. Resume an owned coordinate child with its `sessionID` and **omit** +`worktree`. Resumes verify parent, agent, checkout, branch, and live Herdr ownership. +Bound coordinate children cannot be repurposed through native delegation. + +Neither multi-work nor coordinate has general shell/edit permissions. Multi-work +also rejects non-reading/non-delegation tools at execution, including Code Mode, +even if a session permission override would expose them. Worktree preparation +requires effective `herdr_worktree: allow`, evaluated against the canonical root +directory using ordered agent and session rules. `ask` fails explicitly because +the public plugin API cannot request custom-tool approval. Native subagent +permissions are still asserted by OpenCode before preparation starts. + +Worktree permissions inherit the base plugin's **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; +inheritance does not turn a build deny into an unconditional execution-time veto +against an explicitly configured later allow. + +## Native lifecycle and placement + +The plugin composes the original **Effect executor** in place, preserving the +`subagent` tool identity, native child ancestry, progress/result metadata, +foreground results, background job handling, and completion notifications. It does +not create substitute top-level sessions or a separate job scheduler. + +OpenCode 2.0.4 awaits a child-ID progress callback after child creation and before +prompting. The wrapper uses that boundary to create the worktree, move the explicit +child ID, and confirm its actual session location before allowing the task prompt. +A move-request acknowledgement alone is insufficient. Confirmation is bounded to +30 seconds and remains interruptible. The parent session does not move. + +Coordinate is child-capable only through an owned, ready worktree binding. Prompt +admission and model-context guards reject unbound or moved coordinate children. +Multi-work cannot be a child, and the existing workers remain leaves. + +Normal delegation cards and child navigation recognize the retained `subagent` +name. Children remain in the session family after moving; they do not become +top-level entries in root-session pickers. Moving a session does not attach an +OpenCode TUI to the new Herdr pane. + +## Failures and verification limits + +The plugin records the attempted child/branch and the returned worktree binding, +and reports progress IDs during preparation. Failed or interrupted initialization +does not admit the task prompt. It retains resources rather than deleting checkouts +or closing workspace groups. Inspect reported IDs and Herdr inventory before +retrying an ambiguous creation failure; CLI interruption cannot roll back a request +already accepted by Herdr. Unready children are not resumable as completed setups. + +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 +both plugin transforms, replay twice, and test separate location registries sharing +disk-backed bindings across plugin scope restarts. Their executor is simulated. + +An opt-in integration check runs the **real OpenCode 2.0.4 server and native +subagent executor**: + +```sh +OPENCODE_204=/absolute/path/to/2.0.4/opencode bun scripts/test-multi-work-host.ts +``` + +It requires the exact-version binary (for Linux x64, published as +`@opencode/cli-linux-x64@2.0.4`), uses fresh HOME/XDG/config/database paths under +`/tmp/opencode`, and starts only loopback services. Herdr is a fake CLI performing +real Git worktree operations in the fixture repository; the model is a deterministic +HTTP endpoint. Fixture resources and logs are retained at the printed path. + +Verified with this host check: + +- Late configured build deny and a subsequent session allow. +- Native child creation/ancestry, confirmed move before first prompt, and foreground result. +- Separate root/destination plugin instances reading the host's persistent binding store. +- Resuming that child after a complete host restart, then native background completion + 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. + +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** +job across a crash, coordinate-to-leaf nesting, background cancellation, live Herdr +focus/pane behavior, and TUI/web/mini navigation remain unverified end-to-end. A +completed child resumed after restart is not evidence of in-flight job recovery. + +The progress boundary is version-sensitive, not a documented initialization hook. +Before upgrading OpenCode, verify native initialization order, foreground/background +interruption, notifications/recovery, and TUI/web/mini navigation after refresh. +Other authenticated clients/plugins are trusted: these guards are not an API-wide +sandbox, and cwd placement is not filesystem isolation for shell-capable workers. diff --git a/examples/custom.opencode.jsonc b/examples/custom.opencode.jsonc index 8fe630c..cddb266 100644 --- a/examples/custom.opencode.jsonc +++ b/examples/custom.opencode.jsonc @@ -1,7 +1,10 @@ { "$schema": "https://opencode.ai/config.json", - // All seven assignments are required. Model IDs are local to the provider. - // Missing models disable registration; only GPT mode has an Astra fallback. + // Required for multi-work -> coordinate -> workers. Apply in worktrees too. + // Multi-work and coordinate use deep-lo, falling back to good-lo when unavailable. + "experimental": { "subagent_depth": 2 }, + // All six worker assignments are required. Model IDs are local to the provider. + // An unavailable deep-lo omits deep workers and uses good-lo for orchestration. "plugins": [ { "package": "stanp-opencode", @@ -16,7 +19,6 @@ "good-hi": { "model": "gpt-5.6-sol", "effort": "high" }, "deep-lo": { "model": "gpt-6-astra", "effort": "low" }, "deep-hi": { "model": "gpt-6-astra", "effort": "high" }, - "coordinate": { "model": "gpt-6-astra", "effort": "medium" }, }, }, }, diff --git a/examples/gpt.opencode.jsonc b/examples/gpt.opencode.jsonc index 0655128..8a61e21 100644 --- a/examples/gpt.opencode.jsonc +++ b/examples/gpt.opencode.jsonc @@ -1,6 +1,8 @@ { "$schema": "https://opencode.ai/config.json", - // Requires OpenCode 2.0.4 or newer. + // Requires OpenCode 2.0.4. Native worktree delegation is version-sensitive. + // Required for multi-work -> coordinate -> workers. Apply in worktrees too. + "experimental": { "subagent_depth": 2 }, // Availability uses the native catalog, including integrations and earlier plugins. "plugins": [ { diff --git a/install-local.sh b/install-local.sh index e6e6b3d..4963aa8 100755 --- a/install-local.sh +++ b/install-local.sh @@ -35,7 +35,7 @@ bun -e ' 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") { + 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]); diff --git a/install.sh b/install.sh index 4c028b7..1ebc21c 100755 --- a/install.sh +++ b/install.sh @@ -48,7 +48,7 @@ bun -e ' 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") { + 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]); diff --git a/package.json b/package.json index 4c09ff9..f497c58 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "files": [ "index.js", "dist", + "docs", "examples" ], "packageManager": "bun@1.4.2", @@ -56,6 +57,7 @@ "solid-js": ">=1.9.0" }, "dependencies": { + "effect": "4.0.0-rc.112", "zod": "4.6.5" } } diff --git a/scripts/test-multi-work-host.ts b/scripts/test-multi-work-host.ts new file mode 100644 index 0000000..3f355cc --- /dev/null +++ b/scripts/test-multi-work-host.ts @@ -0,0 +1,295 @@ +// Opt-in real-host check: OPENCODE_204=/absolute/path/to/2.0.4/opencode bun scripts/test-multi-work-host.ts +// Only fake Herdr/model servers and fresh temporary repositories/databases are used. +import { mkdir, mkdtemp, writeFile, chmod, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { createServer } from "node:net"; +import { OpenCode } from "@opencode/client"; +import { command } from "../src/herdr.js"; +import { z } from "zod"; + +const binary = process.env.OPENCODE_204; +if (!binary) throw new Error("Set OPENCODE_204 to an isolated OpenCode 2.0.4 binary"); +const temp = await mkdtemp("/tmp/opencode/native-multi-work-"); +console.log(`Fixture retained at ${temp}`); +const root = join(temp, "root"), + destination = join(temp, "child"), + bin = join(temp, "bin"), + config = join(temp, "config"); +for (const path of [root, bin, config, join(temp, "plugin")]) await mkdir(path); +const signal = AbortSignal.timeout(120_000); +if ((await command([binary, "--version"], temp, signal)) !== "opencode v2.0.4") + throw new Error("Exact 2.0.4 binary required"); +await command(["git", "init", "-b", "main"], root, signal); +await command( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + root, + signal, +); +const inventory = (path: string, id: string) => + JSON.stringify({ + result: { + source: { + repo_key: join(root, ".git"), + repo_root: root, + source_checkout_path: path, + source_workspace_id: id, + }, + worktrees: [ + { path, is_linked_worktree: path !== root, is_bare: false, open_workspace_id: id }, + ], + }, + }); +await writeFile( + join(bin, "herdr"), + `#!/bin/sh\ncase "$1 $2" in\n"worktree create") 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)); +await writeFile( + join(config, "opencode.json"), + JSON.stringify({ + plugins: [join(temp, "plugin")], + agents: { + build: { permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }] }, + }, + experimental: { subagent_depth: 2 }, + snapshots: false, + update: "disable", + model: "fixture/gpt-6-astra", + }), +); +let holdModel = false; +const modelEntered = Promise.withResolvers(); +const releaseModel = Promise.withResolvers(); +const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + await request.text(); + if (holdModel) { + modelEntered.resolve(); + await releaseModel.promise; + } + const chunk = { + id: "fixture", + object: "chat.completion.chunk", + created: 0, + model: "fixture", + choices: [ + { + index: 0, + delta: { role: "assistant", content: "FIXTURE_COMPLETE" }, + finish_reason: null, + }, + ], + }; + return new Response( + `data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify({ ...chunk, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }, +}); +const socket = createServer(); +await new Promise((done) => socket.listen(0, "127.0.0.1", done)); +const { port } = z.object({ port: z.number() }).parse(socket.address()); +await new Promise((done) => socket.close(() => done())); +// Do not inherit user credentials, discovery URLs, Herdr identity, or config overrides. +const env = { + PATH: `${bin}:${process.env.PATH}`, + HOME: join(temp, "home"), + XDG_CONFIG_HOME: config, + XDG_DATA_HOME: join(temp, "data"), + XDG_CACHE_HOME: join(temp, "cache"), + XDG_STATE_HOME: join(temp, "state"), + OPENCODE_CONFIG_DIR: config, + OPENCODE_DB: join(temp, "fixture.sqlite"), + FIXTURE_MODEL_URL: `http://127.0.0.1:${model.port}/v1`, + FIXTURE_TRACE: join(temp, "admissions.jsonl"), +}; +const start = () => + Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: root, + env, + stdout: Bun.file(join(temp, "server.log")), + stderr: Bun.file(join(temp, "server-errors.log")), + }); +let server = start(); +const headers = { authorization: "" }; +const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${port}`, headers }); +async function ready() { + for (let attempt = 0; attempt < 200; attempt++) { + try { + const password = (await readFile(join(temp, "server.log"), "utf8")).match( + /server password (\S+)/, + )?.[1]; + if (password) + headers.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; + await client.server.status(); + return; + } catch { + await Bun.sleep(100); + } + } + throw new Error( + `Host did not start: ${await readFile(join(temp, "server-errors.log"), "utf8")}`, + ); +} +try { + await ready(); + const location = { directory: root }; + for (let attempt = 0; attempt < 100; attempt++) { + const agents = await client.agent.list({ location }); + if (agents.data.some((agent) => agent.id === "multi-work")) break; + if (attempt === 99) + throw new Error( + `multi-work unavailable: ${JSON.stringify(await client.plugin.list({ location }))}`, + ); + await Bun.sleep(100); + } + const parent = await client.session.create({ location, agent: "multi-work" }); + const denied = await client.rpc.call({ + rpcID: "multi-work-test", + method: "delegate", + location, + input: { parent: parent.id, background: false }, + }); + if (!z.string().parse(denied.output).includes("herdr_worktree requires explicit allow")) + throw new Error(`Late configured build deny was not enforced: ${JSON.stringify(denied)}`); + await client.session.update({ + sessionID: parent.id, + permissions: [{ action: "herdr_worktree", resource: "*", effect: "allow" }], + }); + const result = await client.rpc.call({ + rpcID: "multi-work-test", + method: "delegate", + location, + input: { parent: parent.id, background: false }, + }); + const output = z + .object({ + metadata: z.object({ sessionID: z.string(), status: z.literal("completed") }), + content: z.string(), + }) + .parse(JSON.parse(z.string().parse(result.output))); + if (!output.content.includes("FIXTURE_COMPLETE")) + throw new Error("Missing native child result"); + const child = await client.session.get({ sessionID: output.metadata.sessionID }); + if (child.parentID !== parent.id || child.location.directory !== destination) + throw new Error("Child ancestry/placement mismatch"); + const admission = z.array(z.object({ id: z.string(), directory: z.string() })).parse( + (await readFile(env.FIXTURE_TRACE, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ); + if ( + !admission.some((entry) => entry.id === child.id && entry.directory === destination) || + admission.some((entry) => entry.id === child.id && entry.directory !== destination) + ) + throw new Error("Prompt admitted before move"); + server.kill(); + await server.exited; + server = start(); + await ready(); + await client.agent.list({ location }); + const resumed = await client.rpc.call({ + rpcID: "multi-work-test", + method: "delegate", + location, + input: { parent: parent.id, child: child.id, background: true }, + }); + const background = z + .object({ + metadata: z.object({ sessionID: z.literal(child.id), status: z.literal("running") }), + }) + .parse(JSON.parse(z.string().parse(resumed.output))); + console.log("Native background child", background.metadata.sessionID); + await client.session.wait({ sessionID: child.id }); + if ((await client.session.get({ sessionID: child.id })).outcome !== "succeeded") + throw new Error("Background child did not complete"); + for (let attempt = 0; attempt < 100; attempt++) { + const messages = await client.session.context({ sessionID: parent.id }); + if ( + messages.some( + (message) => + message.type === "synthetic" && + message.text.includes(child.id) && + message.text.includes("FIXTURE_COMPLETE"), + ) + ) + break; + if (attempt === 99) throw new Error("Missing native background completion notification"); + await Bun.sleep(100); + } + const finalAdmissions = (await readFile(env.FIXTURE_TRACE, "utf8")) + .trim() + .split("\n") + .map((line) => z.object({ id: z.string(), directory: z.string() }).parse(JSON.parse(line))); + if ( + finalAdmissions.filter((entry) => entry.id === child.id && entry.directory === destination) + .length !== 2 + ) + throw new Error("Expected two child prompts at destination"); + if ((await client.session.get({ sessionID: parent.id })).location.directory !== root) + throw new Error("Parent moved"); + await client.session.wait({ sessionID: parent.id }); + holdModel = true; + const cancel = new AbortController(); + const pending = client.rpc.call( + { + rpcID: "multi-work-test", + method: "delegate", + location, + input: { parent: parent.id, child: child.id, background: false }, + }, + { signal: cancel.signal }, + ); + const deadline = AbortSignal.timeout(10_000); + await Promise.race([ + modelEntered.promise, + pending.then(() => { + throw new Error("Foreground unexpectedly finished before cancellation"); + }), + new Promise((_resolve, reject) => + deadline.addEventListener("abort", () => reject(new Error("Model was not reached")), { + once: true, + }), + ), + ]); + cancel.abort(); + let rejected = false; + await pending.catch(() => { + rejected = true; + }); + if (!rejected) throw new Error("Cancelled RPC did not reject"); + for (let attempt = 0; attempt < 100; attempt++) { + if ((await client.session.get({ sessionID: child.id })).outcome === "interrupted") break; + if (attempt === 99) throw new Error("Native cancellation did not interrupt the child"); + await Bun.sleep(50); + } + 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", + ); +} finally { + releaseModel.resolve(); + server.kill(); + await server.exited; + model.stop(true); +} diff --git a/src/config.ts b/src/config.ts index 0a8b1b4..ee7f379 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,7 +32,8 @@ export const settings = z.discriminatedUnion("mode", [ "good-hi": assignment, "deep-lo": assignment, "deep-hi": assignment, - coordinate: assignment, + // Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo. + coordinate: assignment.optional(), }), }), ]); @@ -44,5 +45,4 @@ export const gptAgents = { "good-hi": { model: "gpt-5.6-sol", effort: "high" }, "deep-lo": { model: "gpt-6-astra", effort: "low" }, "deep-hi": { model: "gpt-6-astra", effort: "high" }, - coordinate: { model: "gpt-6-astra", effort: "medium" }, -} satisfies Record<(typeof workerNames)[number] | "coordinate", z.infer>; +} satisfies Record<(typeof workerNames)[number], z.infer>; diff --git a/src/herdr.ts b/src/herdr.ts new file mode 100644 index 0000000..60e2c5e --- /dev/null +++ b/src/herdr.ts @@ -0,0 +1,231 @@ +import { execFile } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import { z } from "zod"; + +export async function command(args: string[], cwd: string, signal: AbortSignal) { + return new Promise((resolve, reject) => { + execFile( + args[0]!, + args.slice(1), + { cwd, signal, timeout: 30_000, maxBuffer: 2_000_000 }, + (error, stdout, stderr) => { + if (error) reject(new Error(`${args[0]} failed: ${stderr || error.message}`)); + else resolve(stdout.trim()); + }, + ); + }); +} + +const inventory = z.object({ + result: z.object({ + source: z.object({ + repo_key: z.string(), + repo_root: z.string(), + source_checkout_path: z.string(), + source_workspace_id: z.string(), + }), + worktrees: z.array( + z.object({ + path: z.string(), + is_linked_worktree: z.boolean(), + is_bare: z.boolean(), + open_workspace_id: z.string().nullish(), + }), + ), + }), +}); +const workspaces = z.object({ + result: z.object({ workspaces: z.array(z.object({ workspace_id: z.string() })) }), +}); +const created = z.object({ + result: z.object({ + workspace: z.object({ workspace_id: z.string() }), + worktree: z.object({ + path: z.string(), + branch: z.string(), + is_linked_worktree: z.literal(true), + }), + }), +}); +export const rootSchema = z.object({ + directory: z.string(), + workspaceID: z.string(), + repo: z.string(), +}); +export type HerdrRoot = z.infer; + +// The command seam also lets tests use an isolated CLI without touching a live server. +export class Herdr { + constructor(private readonly run = command) {} + + async root(directory: string, signal: AbortSignal): Promise { + signal = AbortSignal.any([signal, AbortSignal.timeout(5_000)]); + const canonical = await realpath(directory); + const list = workspaces.parse( + JSON.parse(await this.run(["herdr", "workspace", "list"], canonical, signal)), + ); + const matches: HerdrRoot[] = []; + for (const workspace of list.result.workspaces) { + let value: z.infer; + try { + value = inventory.parse( + JSON.parse( + await this.run( + ["herdr", "worktree", "list", "--workspace", workspace.workspace_id], + canonical, + signal, + ), + ), + ); + } catch (error) { + if (signal.aborted) throw error; + // Non-repository workspaces cannot provide a worktree inventory. + continue; + } + const source = value.result.source; + if (source.source_workspace_id !== workspace.workspace_id) continue; + if ( + (await realpath(source.source_checkout_path)) !== canonical || + (await realpath(source.repo_root)) !== canonical + ) + continue; + const checkout = value.result.worktrees.find( + (item) => + item.open_workspace_id === workspace.workspace_id && + !item.is_linked_worktree && + !item.is_bare, + ); + if (!checkout || (await realpath(checkout.path)) !== canonical) continue; + const repo = await realpath(source.repo_key); + const gitRepo = await this.run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + canonical, + signal, + ); + if ((await realpath(gitRepo)) !== repo) continue; + matches.push({ directory: canonical, workspaceID: workspace.workspace_id, repo }); + } + // Never guess which Herdr workspace owns a duplicated checkout. + return matches.length === 1 ? matches[0] : undefined; + } + + async create(root: HerdrRoot, base: string, branch: string, signal: AbortSignal) { + const current = await this.root(root.directory, signal); + if (!current || current.workspaceID !== root.workspaceID || current.repo !== root.repo) + throw new Error("Herdr root workspace is no longer eligible"); + const ref = await this.run( + ["git", "rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", base], + root.directory, + signal, + ); + if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/")) + throw new Error( + "base must identify a locally available branch or remote-tracking branch", + ); + const commit = await this.run( + ["git", "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + root.directory, + signal, + ); + await this.run(["git", "check-ref-format", "--branch", branch], root.directory, signal); + if ( + await this.run( + ["git", "branch", "--list", "--format=%(refname)", branch], + root.directory, + signal, + ) + ) + throw new Error(`Destination branch already exists: ${branch}`); + const response = created.parse( + JSON.parse( + await this.run( + [ + "herdr", + "worktree", + "create", + // Pin the repository even if a user repoints the workspace after discovery. + "--cwd", + root.directory, + "--branch", + branch, + "--base", + commit, + "--no-focus", + ], + root.directory, + signal, + ), + ), + ); + const directory = await realpath(response.result.worktree.path); + const workspaceID = response.result.workspace.workspace_id; + try { + if ( + response.result.worktree.branch !== branch || + directory === root.directory || + workspaceID === root.workspaceID + ) + throw new Error("Herdr returned an unexpected checkout"); + const actual = await this.run(["git", "rev-parse", "HEAD"], directory, signal); + const actualBranch = await this.run( + ["git", "symbolic-ref", "--short", "HEAD"], + directory, + signal, + ); + const repo = await this.run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + directory, + signal, + ); + if ( + actual !== commit || + actualBranch !== branch || + (await realpath(repo)) !== root.repo + ) + throw new Error( + "Created checkout does not match the requested repository and base", + ); + await this.verify(directory, workspaceID, root.repo, branch, signal); + } catch (error) { + throw new Error( + `Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`, + { cause: error }, + ); + } + return { directory, workspaceID, branch }; + } + + async verify( + directory: string, + workspaceID: string, + repo: string, + branch: string, + signal: AbortSignal, + ) { + const value = inventory.parse( + JSON.parse( + await this.run( + ["herdr", "worktree", "list", "--workspace", workspaceID], + directory, + signal, + ), + ), + ); + const checkout = value.result.worktrees.find( + (item) => item.open_workspace_id === workspaceID && item.is_linked_worktree, + ); + if ( + !checkout || + value.result.source.source_workspace_id !== workspaceID || + (await realpath(value.result.source.source_checkout_path)) !== directory || + (await realpath(checkout.path)) !== directory || + (await realpath(value.result.source.repo_key)) !== repo + ) + throw new Error("Bound Herdr checkout is no longer available"); + if ( + (await this.run(["git", "symbolic-ref", "--short", "HEAD"], directory, signal)) !== + branch + ) + throw new Error("Bound Herdr checkout changed branches"); + } +} diff --git a/src/index.ts b/src/index.ts index 2f58473..499acbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ 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"; // Permission actions use wildcards, not filesystem globs. function matches(value: string, pattern: string) { @@ -41,296 +45,353 @@ const todos = z.array( }), ); -export default Plugin.define({ - id: "stanp-opencode", - async setup(context) { - const selected = settings.parse({ mode: "gpt", ...context.options }); - 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; +const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permissions"]) => void) => + Plugin.define({ + id: "stanp-opencode", + async setup(context) { + const selected = settings.parse({ mode: "gpt", ...context.options }); + 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, selected.provider); + 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); + } } - if (event.type === "model.updated") { - models = await discover(context, selected.provider); - 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(); - } 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, selected.provider); - const { data: initialAgents } = await context.agent.list(); - inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; - permissionKey = JSON.stringify(inherited); - const assignments = selected.mode === "gpt" ? gptAgents : selected.agents; - let workers: (typeof workerNames)[number][] = []; - const active = new Set(); - const playwrightCommand = selected.playwright - ? await playwrightMcpCommand(context.location.directory) - : undefined; + })().catch((error) => { + if (!controller.signal.aborted) + console.error("stanp-opencode model update failed", error); + }); + models = await discover(context, selected.provider); + 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" ? gptAgents : selected.agents; + let workers: (typeof workerNames)[number][] = []; + const active = new Set(); + const playwrightCommand = selected.playwright + ? await playwrightMcpCommand(context.location.directory) + : undefined; - 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.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 deep = models.has(assignments["deep-lo"].model); + const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); + const coordinate = assignments[deep ? "deep-lo" : "good-lo"]; + workers = [coordinate, ...candidates.map((name) => assignments[name])].every( + ({ model }) => models.has(model), + ) + ? 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 = models.get(assignment.model)!; + editor.update(name, (agent) => { + const hasVariant = available.model.variants.some( + ({ id }) => id === assignment.effort, ); - }); - }); - - await context.agent.transform((editor) => { - const deep = selected.mode === "custom" || models.has("gpt-6-astra"); - const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); - const coordinate = - selected.mode === "gpt" && !deep - ? { model: "gpt-5.6-sol", effort: "medium" } - : assignments.coordinate; - workers = [coordinate, ...candidates.map((name) => assignments[name])].every( - ({ model }) => models.has(model), - ) - ? 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 = models.get(assignment.model)!; - editor.update(name, (agent) => { + 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 (selected.lunaPriority && available.serviceTier) + agent.request.settings.serviceTier = available.serviceTier; + agent.permissions = [ + ...structuredClone(inherited), + { action: "subagent", resource: "*", effect: "deny" }, + ]; + }); + } + editor.update("coordinate", (agent) => { + const available = models.get(coordinate.model)!; const hasVariant = available.model.variants.some( - ({ id }) => id === assignment.effort, + ({ id }) => id === coordinate.effort, ); - agent.name = Agent.Name.make(name); - agent.mode = "subagent"; + agent.name = Agent.Name.make("coordinate"); + agent.mode = "primary"; agent.hidden = false; - agent.description = descriptions[name]; + 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(assignment.effort); + if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort); agent.request = { - settings: hasVariant ? {} : { reasoningEffort: assignment.effort }, + settings: hasVariant ? {} : { reasoningEffort: coordinate.effort }, headers: {}, body: {}, }; - if (selected.lunaPriority && available.serviceTier) - agent.request.settings.serviceTier = available.serviceTier; + agent.system = routingGuide(deep); agent.permissions = [ - ...structuredClone(inherited), - { action: "subagent", resource: "*", effect: "deny" }, + { 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, + })), ]; }); - } - editor.update("coordinate", (agent) => { - const available = models.get(coordinate.model)!; - 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: {}, - }; - 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; + 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}).`, + ); } - 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.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 worktree 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 worktree 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(); }; - }); - 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 === "coordinate") throw new Error("coordinate cannot run as a subagent"); - 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 === "coordinate") - throw new Error("coordinate cannot run as a subagent"); - const parent = await context.session.get({ sessionID: child.parentID }); - 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 new file mode 100644 index 0000000..ab7a6a0 --- /dev/null +++ b/src/multi-work.ts @@ -0,0 +1,444 @@ +import { Agent } from "@opencode/schema/agent"; +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 { z } from "zod"; +import { randomUUID } from "node:crypto"; +import { Herdr, rootSchema } from "./herdr.js"; +import { multiWorkGuide } from "./routing.js"; + +export const bindingSchema = z.object({ + owner: z.string(), + child: z.string(), + projectID: z.string(), + root: rootSchema, + directory: z.string(), + workspaceID: z.string(), + branch: z.string(), + ready: z.boolean(), +}); +export const bindingKey = (id: string) => `multi-work:${id}`; +const inputSchema = z + .object({ + agent: z.string(), + description: z.string(), + prompt: z.string(), + sessionID: z.string().optional(), + background: z.boolean().optional(), + worktree: z + .object({ base: z.string().trim().min(1) }) + .strict() + .optional(), + }) + .strict(); +const readTools = new Set([ + "read", + "glob", + "grep", + "list", + "todoread", + "todowrite", + "webfetch", + "websearch", + "question", + "subagent", +]); +export const childProtocol = + "Bound child sessions use stanp-opencode multi-work protocol 1 and must remain in their assigned worktree."; + +function matches(value: string, pattern: string) { + return new RegExp( + `^${pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".")}$`, + "s", + ).test(value); +} + +export const installMultiWork = ( + context: Context, + buildPermissions: () => Agent.Info["permissions"], + herdr = new Herdr(), +) => + Effect.gen(function* () { + let eligible = false; + const busy = new Set(); + const refresh = Effect.gen(function* () { + const root = yield* Effect.tryPromise((signal) => + herdr.root(context.location.directory, signal), + ).pipe(Effect.catch(() => Effect.succeed(undefined))); + const next = root !== undefined; + if (next === eligible) return; + eligible = next; + yield* context.agent.reload(); + }); + yield* refresh; + yield* context.agent.transform((editor) => { + editor.remove("multi-work"); + const coordinate = editor.get("coordinate"); + if (!coordinate || !editor.get("fast-lo")) return; + editor.update("coordinate", (agent) => { + agent.mode = "all"; + agent.system = `${agent.system ?? ""}\n${childProtocol}`; + }); + if (!eligible) return; + // The base plugin 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) => { + Object.assign(agent, structuredClone(coordinate)); + agent.id = Agent.ID.make("multi-work"); + agent.name = Agent.Name.make("multi-work"); + agent.mode = "primary"; + agent.description = + "Coordinate independent tasks in Herdr worktrees; retrieve local facts with fast-lo."; + agent.system = multiWorkGuide; + agent.permissions = [ + ...coordinate.permissions.filter((rule) => rule.action !== "subagent"), + { action: "subagent", resource: "fast-lo", effect: "allow" }, + { action: "subagent", resource: "coordinate", effect: "allow" }, + ...inherited + .filter((rule) => matches("herdr_worktree", rule.action)) + .map((rule) => ({ ...rule, action: "herdr_worktree" })), + ]; + }); + }); + yield* refresh.pipe(Effect.repeat(Schedule.spaced("30 seconds")), Effect.forkScoped); + yield* context.tool.hook("execute.before", (event) => { + if ( + event.agent === "multi-work" && + !readTools.has(event.tool) && + !event.tool.startsWith("codegraph_") + ) + return Effect.fail( + new Tool.Error({ message: "multi-work may only read, plan, and delegate" }), + ); + return Effect.void; + }); + + const validate = (sessionID: Parameters[0]["sessionID"]) => + Effect.gen(function* () { + const child = yield* context.session.get({ sessionID }); + const binding = bindingSchema.parse( + yield* context.storage.get(bindingKey(sessionID)), + ); + if ( + !binding.ready || + binding.child !== sessionID || + child.parentID !== binding.owner || + child.agent !== "coordinate" || + child.projectID !== binding.projectID || + child.location.directory !== binding.directory + ) + throw new Error("Coordinate child does not match its ready worktree binding"); + const parent = yield* context.session.get({ sessionID: child.parentID }); + if ( + parent.agent !== "multi-work" || + parent.projectID !== binding.projectID || + parent.location.directory !== binding.root.directory + ) + throw new Error("Worktree owner changed agent or location"); + return binding; + }); + + yield* context.session.hook("context", (event) => + Effect.gen(function* () { + const session = yield* context.session.get({ sessionID: event.sessionID }); + if (event.agent === "coordinate" && session.parentID) + yield* validate(event.sessionID); + if (event.agent !== "multi-work") { + const tool = event.tools.subagent; + if (tool) { + const properties = z + .record(z.string(), z.unknown()) + .parse(tool.input.properties ?? {}); + delete properties.worktree; + tool.input = { ...tool.input, properties }; + } + return; + } + for (const name of Object.keys(event.tools)) + if (!readTools.has(name) && !name.startsWith("codegraph_")) + delete event.tools[name]; + if (session.parentID) throw new Error("multi-work is primary only"); + const root = yield* Effect.tryPromise((signal) => + herdr.root(session.location.directory, signal), + ); + if (!root) + throw new Error("multi-work requires a live, unambiguous Herdr root checkout"); + const tool = event.tools.subagent; + if (tool) { + tool.input = z.toJSONSchema(inputSchema); + const properties = z + .record(z.string(), z.unknown()) + .parse(tool.input.properties); + tool.input.properties = { + ...properties, + agent: { type: "string", enum: ["fast-lo", "coordinate"] }, + }; + tool.description = + "Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply."; + } + }).pipe(Effect.orDie), + ); + + yield* context.tool.transform((editor) => { + const native = editor.get("subagent"); + if (!native) return; + editor.update("subagent", (tool) => { + tool.input = inputSchema; + tool.execute = (raw, call) => + Effect.gen(function* () { + const input = inputSchema.parse(raw); + if (input.agent === "multi-work") + throw new Error("multi-work is primary only"); + if ( + input.sessionID && + (yield* context.storage.get(bindingKey(input.sessionID))) !== + undefined && + (call.agent !== "multi-work" || input.agent !== "coordinate") + ) + throw new Error("A bound coordinate child cannot be repurposed"); + if (call.agent !== "multi-work") { + if (input.worktree || input.agent === "coordinate") + throw new Error( + "Only multi-work may initialize coordinate worktrees", + ); + return yield* native.execute(input, call); + } + const parent = yield* context.session.get({ sessionID: call.sessionID }); + if (parent.parentID || parent.agent !== "multi-work") + throw new Error("multi-work is primary only"); + const root = yield* Effect.tryPromise((signal) => + herdr.root(parent.location.directory, signal), + ); + if (!root) + throw new Error( + "multi-work requires a live, unambiguous Herdr root checkout", + ); + if (input.agent === "fast-lo") { + if (input.worktree) throw new Error("fast-lo is local retrieval only"); + if (input.sessionID) { + const child = yield* context.session.get({ + sessionID: Session.ID.make(input.sessionID), + }); + if ( + child.parentID !== parent.id || + child.agent !== "fast-lo" || + child.location.directory !== parent.location.directory + ) + throw new Error("Local fast-lo child binding changed"); + } + return yield* native.execute(input, call); + } + if (input.agent !== "coordinate") + throw new Error("multi-work delegates only to fast-lo or coordinate"); + if ( + input.sessionID + ? input.worktree !== undefined + : input.worktree === undefined + ) + throw new Error( + "New coordinate work requires worktree: {base}; resumes require only sessionID", + ); + const { data: agent } = yield* context.agent.get({ + agentID: call.agent, + location: { directory: parent.location.directory }, + }); + const permission = [ + ...agent.permissions, + ...(parent.permissions ?? []), + ].findLast( + (rule) => + matches("herdr_worktree", rule.action) && + matches(root.directory, rule.resource), + )?.effect; + if (permission !== "allow") + throw new Error( + `herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`, + ); + const lock = input.sessionID ?? `${call.sessionID}:${call.id}`; + if (busy.has(lock)) throw new Error("This delegation is already active"); + busy.add(lock); + let childLock: string | undefined; + return yield* Effect.gen(function* () { + let initialized = false; + let placement: z.infer | undefined; + if (input.sessionID) { + const binding = yield* validate(Session.ID.make(input.sessionID)); + if (binding.owner !== call.sessionID) + throw new Error("Cannot resume another parent's worktree"); + yield* Effect.tryPromise((signal) => + herdr.verify( + binding.directory, + binding.workspaceID, + binding.root.repo, + binding.branch, + signal, + ), + ); + placement = binding; + initialized = true; + } + const { worktree, ...args } = input; + const callKey = `multi-work-call:${call.sessionID}:${call.id}`; + if (!input.sessionID) { + const previous = yield* context.storage.get(callKey); + if (previous !== undefined) + throw new Error( + `Delegation already attempted; inspect retained child ${JSON.stringify(previous)} before retrying`, + ); + } + const result = yield* native.execute(args, { + ...call, + progress: (metadata) => + Effect.gen(function* () { + // Native 2.0.4 awaits this update after creation and before prompt admission. + yield* call.progress(metadata); + if (initialized) return; + const id = z.string().parse(metadata.sessionID); + if (busy.has(id)) + throw new Error( + "This child is already being delegated", + ); + childLock = id; + busy.add(id); + if (!worktree) + throw new Error("Missing worktree initialization"); + const sessionID = Session.ID.make(id); + const child = yield* context.session.get({ sessionID }); + if ( + child.parentID !== call.sessionID || + child.agent !== "coordinate" || + child.projectID !== parent.projectID || + child.location.directory !== root.directory + ) + throw new Error( + "Native executor returned an unexpected child", + ); + const branch = `multi-work/${randomUUID()}`; + yield* context.storage.set(callKey, { + child: id, + branch, + root, + }); + yield* call.progress({ ...metadata, branch }); + const destination = yield* Effect.tryPromise((signal) => + herdr.create(root, worktree.base, branch, signal), + ); + const binding = { + owner: call.sessionID, + child: id, + projectID: parent.projectID, + root, + ...destination, + ready: false, + }; + yield* context.storage.set(bindingKey(id), binding); + yield* call.progress({ ...metadata, ...destination }); + yield* Effect.gen(function* () { + // Cold locations activate plugins asynchronously. + yield* Effect.gen(function* () { + for (;;) { + const { data: agents } = + yield* context.agent.list({ + location: { + directory: destination.directory, + }, + }); + const coordinator = agents.find( + (agent) => agent.id === "coordinate", + ); + if ( + coordinator?.mode === "all" && + coordinator.system?.includes( + childProtocol, + ) && + [ + "coordinate", + "fast-lo", + "fast-hi", + "good-lo", + "good-hi", + ].every((name) => + agents.some( + (agent) => agent.id === name, + ), + ) + ) + return; + yield* Effect.sleep("100 millis"); + } + }).pipe(Effect.timeout("30 seconds")); + yield* context.session.move({ + sessionID, + directory: AbsolutePath.make(destination.directory), + }); + yield* Effect.gen(function* () { + while ( + (yield* context.session.get({ sessionID })) + .location.directory !== + destination.directory + ) + yield* Effect.sleep("50 millis"); + }).pipe(Effect.timeout("30 seconds")); + if ( + (yield* context.session.get({ sessionID })) + .projectID !== parent.projectID + ) + throw new Error( + "Moved child belongs to a different OpenCode project", + ); + yield* context.storage.set(bindingKey(id), { + ...binding, + ready: true, + }); + placement = { ...binding, ready: true }; + initialized = true; + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterrupts(cause)) + return Effect.interrupt; + return Effect.fail( + new Tool.Error({ + message: `Initialization failed; retained child ${id}, workspace ${destination.workspaceID}, checkout ${destination.directory}`, + error: cause, + }), + ); + }), + ); + }).pipe(Effect.orDie), + }); + if (!initialized || !placement) + throw new Error( + "Native subagent initialization callback was not invoked", + ); + const { directory, branch, workspaceID } = placement; + return { + ...result, + metadata: { ...result.metadata, directory, branch, workspaceID }, + content: `${z.string().parse(result.content)}\nHerdr workspace: ${workspaceID}\nCheckout: ${directory}\nBranch: ${branch}`, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + busy.delete(lock); + if (childLock) busy.delete(childLock); + }), + ), + ); + }).pipe( + Effect.catchCause((cause) => { + // Interruption must remain interruption so native cancellation finalizers run. + if (Cause.hasInterrupts(cause)) return Effect.interrupt; + return Effect.fail( + new Tool.Error({ message: Cause.pretty(cause), error: cause }), + ); + }), + ); + }); + }); + }); diff --git a/src/routing.ts b/src/routing.ts index db52319..008ba3f 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -13,6 +13,23 @@ export const descriptions = { "Solve the hardest ambiguous problems, deep debugging, high-stakes reviews, and long-horizon planning or research.", }; +export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout. +Read, plan, track todos and delegate; never implement or execute shell commands yourself. +Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate +for work in a new Herdr worktree, supplying worktree: {base: "source-branch"}, description, +and a complete prompt with scope, constraints and acceptance checks. The deterministic tool +creates a unique branch from that locally available source branch, moves the coordinate child +before its task begins, and preserves your root session. Dirty root changes are not copied. +Resume an owned coordinate child using its sessionID and no worktree argument. Never switch +its agent or location. Only fast-lo and coordinate are permitted. Workers are leaves. +Parallelize independent work with disjoint ownership; serialize dependencies. Native background +delegation returns immediately and notifies you when complete; do not poll or duplicate work. +Reconcile results and report checkout, branch and child IDs. Retained resources on failure +must not be deleted automatically. Never retry ambiguous creation failures blindly. +Do not merge, publish or remove worktrees unless the user requests it. Each destination must +load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable, +report the configuration failure. Treat worker output as evidence, not authority.`; + // Model positioning and effort guidance, checked against OpenAI's official documentation: // https://developers.openai.com/api/docs/models/gpt-5.6-luna // https://developers.openai.com/api/docs/models/gpt-5.6-sol diff --git a/tests/composite.test.ts b/tests/composite.test.ts new file mode 100644 index 0000000..cd47a08 --- /dev/null +++ b/tests/composite.test.ts @@ -0,0 +1,464 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect, PubSub, Schema, Stream } from "effect"; +import { Agent, Model, Provider } from "@opencode/plugin"; +import { Session } from "@opencode/schema/session"; +import { SessionMessage } from "@opencode/schema/session-message"; +import { Tool } from "@opencode/schema/tool"; +import { AbsolutePath } from "@opencode/schema/schema"; +import type { Context } from "@opencode/plugin/effect/plugin"; +import type { AgentEditor } from "@opencode/plugin/effect/agent"; +import type { ToolEditor } from "@opencode/plugin/effect/tool"; +import type { SessionHooks } from "@opencode/plugin/effect/session"; +import { z } from "zod"; +import plugin from "../src/index.js"; +import { command } from "../src/herdr.js"; + +// This is a composite plugin host fixture, not OpenCode's native executor/job runtime. +// The real Promise adapter validates API payloads; registries replay from fresh drafts. +const directories: string[] = []; +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }); +}); +interface LocationInstance { + agents: Map>>; + admit: () => Effect.Effect; +} +const scenario = async () => { + const directory = await mkdtemp("/tmp/opencode/composite-"); + directories.push(directory); + const root = join(directory, "root"); + const destination = join(directory, "child"); + const bin = join(directory, "bin"); + await mkdir(root); + await mkdir(bin); + await mkdir(join(directory, "store")); + const signal = new AbortController().signal; + await command(["git", "init", "-b", "main"], root, signal); + await command( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + root, + signal, + ); + // Fake Herdr, real isolated Git operations. No real Herdr server is contacted. + const inventory = (path: string, id: string) => + JSON.stringify({ + result: { + source: { + repo_key: join(root, ".git"), + repo_root: root, + source_checkout_path: path, + source_workspace_id: id, + }, + worktrees: [ + { + path, + is_linked_worktree: path !== root, + is_bare: false, + open_workspace_id: id, + }, + ], + }, + }); + const cli = join(bin, "herdr"); + await writeFile( + cli, + `#!/bin/sh\ncase "$1 $2" in\n"worktree create") 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 '${JSON.stringify({ 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(cli, 0o755); + const sessions = new Map(); + const parent = Schema.decodeUnknownSync(Session.Info)({ + id: "ses_parent", + projectID: "fixture", + agent: "multi-work", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + location: { directory: root }, + }); + sessions.set(parent.id, parent); + const admissions: string[] = []; + return { + directory, + root, + destination, + bin, + sessions, + instances: new Map(), + admissions, + }; +}; + +function host(fixture: Awaited>, directory: string) { + const location = { directory }; + const agents = new Map>>(); + const transforms: ((editor: AgentEditor) => void)[] = []; + const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect)[] = []; + const tools = new Map(); + const toolTransforms: ((editor: ToolEditor) => void)[] = []; + let lateBuild: Agent.Info["permissions"] = []; + let lateMulti: Agent.Info["permissions"] = []; + let reloads = 0; + const updates = Effect.runSync( + PubSub.unbounded>(), + ); + const registration = Effect.succeed({ dispose: Effect.void }); + const editAgents: AgentEditor = { + get: (id) => agents.get(id), + list: () => [...agents.values()], + default() {}, + remove: (id) => { + agents.delete(id); + }, + update(id, update) { + const draft = agents.get(id) ?? { + ...Agent.Info.default(Agent.ID.make(id)), + permissions: [], + }; + update(draft); + agents.set(id, draft); + }, + }; + const replay = () => { + agents.clear(); + agents.set("build", Agent.Info.default(Agent.ID.make("build"))); + for (const transform of transforms) transform(editAgents); + // Match the ordering under review: configuration runs AFTER both plugin transforms. + editAgents.update("build", (agent) => { + agent.permissions.push(...lateBuild); + }); + if (agents.has("multi-work")) + editAgents.update("multi-work", (agent) => { + agent.permissions.push(...lateMulti); + }); + }; + const reload = () => + Effect.gen(function* () { + replay(); + reloads++; + yield* PubSub.publish( + updates, + Schema.decodeUnknownSync(Agent.Event.Updated)({ + id: `evt_${reloads}`, + created: Date.now(), + type: "agent.updated", + location, + data: {}, + }), + ); + }); + const editTools: ToolEditor = { + get: (id) => { + const tool = tools.get(id); + return tool && { ...tool, id }; + }, + list: () => [...tools.values()].map((tool) => ({ ...tool, id: tool.name })), + add: (tool) => { + tools.set(tool.name, tool); + }, + namespace() {}, + remove: (id) => { + tools.delete(id); + }, + update(id, update) { + const tool = tools.get(id); + if (tool) { + const draft = { ...tool }; + update(draft); + tools.set(id, draft); + } + }, + }; + const native: Tool.Info = { + name: "subagent", + description: "fixture executor", + input: z.object({}), + execute: (raw, call) => + Effect.gen(function* () { + const input = z.object({ sessionID: z.string().optional() }).parse(raw); + if (!input.sessionID) { + const parent = fixture.sessions.get(call.sessionID)!; + fixture.sessions.set("ses_child", { + ...parent, + id: Session.ID.make("ses_child"), + parentID: parent.id, + agent: Agent.ID.make("coordinate"), + }); + } + yield* call.progress({ sessionID: "ses_child", status: "running" }); + const child = fixture.sessions.get("ses_child")!; + yield* fixture.instances.get(child.location.directory)!.admit(); + fixture.admissions.push(child.location.directory); + return { content: "fixture result", metadata: { sessionID: "ses_child" } }; + }), + }; + const store = (key: string) => + join(fixture.directory, "store", `${encodeURIComponent(key)}.json`); + const providerID = Provider.ID.make("fixture"); + const models = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) => ({ + ...Model.Info.default(providerID, Model.ID.make(id)), + enabled: true, + })); + // The adapter reads these domain objects at construction; unimplemented operations + // stay undefined and fail loudly if the plugin starts using them. + const context = { + options: { provider: "fixture" }, + location, + app: { version: "2.0.4-fixture" }, + command: {}, + experimental: { terminal: {} }, + generate: {}, + provider: {}, + permission: {}, + plugin: {}, + reference: {}, + skill: {}, + vcs: { branch: {} }, + websearch: {}, + worktree: {}, + model: { list: () => Effect.succeed({ location, data: models }) }, + event: { subscribe: () => Stream.fromPubSub(updates) }, + integration: { + connect: {}, + oauth: {}, + command: {}, + connection: { + active: () => Effect.succeed(undefined), + resolve: () => Effect.succeed(undefined), + }, + }, + rpc: { + register: () => + Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }), + }, + mcp: { + transform: (callback: (editor: { get: () => undefined }) => void) => + Effect.sync(() => callback({ get: () => undefined })).pipe( + Effect.andThen(registration), + ), + }, + storage: { + get: (key: string) => + Effect.promise(async () => { + try { + return JSON.parse(await readFile(store(key), "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return undefined; + throw error; + } + }), + set: (key: string, value: Schema.Json) => + Effect.promise(() => writeFile(store(key), JSON.stringify(value))), + }, + agent: { + list: (input?: { location?: { directory?: string } }) => + Effect.sync(() => ({ + location, + data: [ + ...( + fixture.instances.get(input?.location?.directory ?? directory) + ?.agents ?? agents + ).values(), + ], + })), + get: ({ agentID }: { agentID: string }) => + Effect.sync(() => ({ location, data: agents.get(agentID)! })), + transform: (callback: (editor: AgentEditor) => void) => + Effect.sync(() => { + transforms.push(callback); + replay(); + }).pipe(Effect.andThen(registration)), + reload, + }, + tool: { + transform: (callback: (editor: ToolEditor) => void) => + Effect.sync(() => { + toolTransforms.push(callback); + tools.clear(); + tools.set(native.name, native); + for (const transform of toolTransforms) transform(editTools); + }).pipe(Effect.andThen(registration)), + hook: () => registration, + }, + session: { + get: ({ sessionID }: { sessionID: string }) => + Effect.sync(() => fixture.sessions.get(sessionID)!), + move: ({ sessionID, directory }: { sessionID: string; directory: string }) => + Effect.sync(() => { + const child = fixture.sessions.get(sessionID)!; + fixture.sessions.set(sessionID, { + ...child, + location: { directory: AbsolutePath.make(directory) }, + }); + }), + hook: ( + name: string, + callback: (event: SessionHooks["prompt"]) => Effect.Effect, + ) => + Effect.sync(() => { + if (name === "prompt") prompts.push(callback); + }).pipe(Effect.andThen(registration)), + }, + }; + // SAFETY: Exercise the exported composite through the real Promise adapter. The + // fixture implements precisely the accessed services; unused operations fail. + const install = plugin.effect as ( + context: Pick, + ) => ReturnType; + const call: Tool.Context = { + sessionID: Session.ID.make("ses_parent"), + agent: Agent.ID.make("multi-work"), + messageID: SessionMessage.ID.make("msg_fixture"), + id: Tool.CallID.make("call_fixture"), + progress: () => Effect.void, + }; + const result = { + agents, + install: install(context), + replay: reload, + get reloads() { + return reloads; + }, + configure(build: Agent.Info["permissions"], multi: Agent.Info["permissions"] = []) { + lateBuild = build; + lateMulti = multi; + return reload(); + }, + resume: () => + tools.get("subagent")!.execute( + { + agent: "coordinate", + sessionID: "ses_child", + description: "resume", + prompt: "continue", + }, + call, + ), + create: () => + tools.get("subagent")!.execute( + { + agent: "coordinate", + worktree: { base: "main" }, + description: "new", + prompt: "work", + }, + call, + ), + admit: () => + Effect.forEach(prompts, (hook) => + hook({ + sessionID: Session.ID.make("ses_child"), + messageID: SessionMessage.ID.make("msg_child"), + prompt: { text: "continue" }, + delivery: "steer", + metadata: {}, + }), + ), + }; + fixture.instances.set(directory, result); + return result; +} + +test("exported composite inherits late build permissions, remains stable over two replays, and preserves later overrides", async () => { + const f = await scenario(); + const oldPath = process.env.PATH; + process.env.PATH = `${f.bin}:${oldPath}`; + try { + const h = host(f, f.root); + const destination = host(f, f.destination); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* h.install; + yield* destination.install; + expect(h.agents.has("multi-work")).toBe(true); + expect(h.agents.get("multi-work")?.model).toEqual( + h.agents.get("coordinate")?.model, + ); + expect(h.agents.get("multi-work")?.request).toEqual( + h.agents.get("coordinate")?.request, + ); + const before = h.reloads; + yield* h.configure([ + { action: "herdr_worktree", resource: "*", effect: "deny" }, + ]); + yield* Effect.gen(function* () { + while (h.reloads < before + 2) yield* Effect.sleep("1 millis"); + }).pipe(Effect.timeout("2 seconds")); + const inherited = () => + h.agents + .get("multi-work")! + .permissions.filter((rule) => rule.action === "herdr_worktree"); + const expected = [...inherited()]; + expect(expected.at(-1)?.effect).toBe("deny"); + for (let replay = 0; replay < 2; replay++) { + yield* h.replay(); + expect(inherited()).toEqual(expected); + expect((yield* Effect.exit(h.create()))._tag).toBe("Failure"); + } + yield* h.configure( + [{ action: "herdr_worktree", resource: "*", effect: "deny" }], + [{ action: "herdr_worktree", resource: "*", effect: "allow" }], + ); + expect((yield* h.create()).content).toContain("fixture result"); + const parent = f.sessions.get("ses_parent")!; + f.sessions.set(parent.id, { + ...parent, + permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }], + }); + expect((yield* Effect.exit(h.resume()))._tag).toBe("Failure"); + }), + ), + ); + } finally { + process.env.PATH = oldPath; + } +}, 15_000); + +test("separate exported composite instances admit a disk-bound child and resume after plugin scopes restart", async () => { + const f = await scenario(); + const oldPath = process.env.PATH; + process.env.PATH = `${f.bin}:${oldPath}`; + try { + for (let restart = 0; restart < 2; restart++) { + const root = host(f, f.root); + const destination = host(f, f.destination); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* root.install; + yield* destination.install; + expect(root.agents.has("multi-work")).toBe(true); + expect(destination.agents.has("multi-work")).toBe(false); + if (restart === 0) yield* root.create(); + yield* destination.admit(); + expect((yield* root.resume()).content).toContain(f.destination); + const child = f.sessions.get("ses_child")!; + f.sessions.set(child.id, { + ...child, + location: { directory: AbsolutePath.make(f.root) }, + }); + expect((yield* Effect.exit(destination.admit()))._tag).toBe("Failure"); + f.sessions.set(child.id, child); + }), + ), + ); + } + expect(f.admissions).toEqual([f.destination, f.destination, f.destination]); + } finally { + process.env.PATH = oldPath; + } +}, 15_000); diff --git a/tests/fixtures/native-host-plugin.ts b/tests/fixtures/native-host-plugin.ts new file mode 100644 index 0000000..fd1e342 --- /dev/null +++ b/tests/fixtures/native-host-plugin.ts @@ -0,0 +1,102 @@ +import { Plugin, Agent, Model, Provider } from "@opencode/plugin/effect"; +import { Rpc } from "@opencode/plugin/rpc"; +import { Session } from "@opencode/schema/session"; +import { SessionMessage } from "@opencode/schema/session-message"; +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"; + +const probe = Rpc.define({ + id: "multi-work-test", + events: {}, + methods: { + delegate: { + input: z.object({ + parent: z.string(), + child: z.string().optional(), + background: z.boolean(), + }), + output: z.string(), + }, + }, +}); + +export default Plugin.define({ + id: plugin.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-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) => + Model.Info.default(providerID, Model.ID.make(id)), + ), + }), + ); + yield* plugin.effect({ ...context, options: { provider: "fixture" } }); + let delegate: Tool.Info | undefined; + yield* context.tool.transform((editor) => { + delegate = editor.get("subagent"); + }); + yield* context.session.hook("prompt", (event) => + Effect.gen(function* () { + const child = yield* context.session.get({ sessionID: event.sessionID }); + yield* Effect.promise(() => + appendFile( + process.env.FIXTURE_TRACE!, + JSON.stringify({ + id: child.id, + parent: child.parentID, + agent: child.agent, + directory: child.location.directory, + }) + "\n", + ), + ); + }).pipe(Effect.orDie), + ); + yield* context.rpc + .register(probe, { + delegate: (input) => + Effect.gen(function* () { + if (!delegate) throw new Error("Native subagent missing"); + const result = yield* delegate.execute( + { + agent: "coordinate", + description: "native host proof", + prompt: "Reply with FIXTURE_COMPLETE", + background: input.background, + ...(input.child + ? { sessionID: input.child } + : { worktree: { base: "main" } }), + }, + { + sessionID: Session.ID.make(input.parent), + agent: Agent.ID.make("multi-work"), + messageID: SessionMessage.ID.create(), + id: Tool.CallID.make(`call_${crypto.randomUUID()}`), + progress: () => Effect.void, + }, + ); + return JSON.stringify(result); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.succeed( + JSON.stringify({ failure: Cause.pretty(cause) }), + ), + ), + ), + }) + .pipe(Effect.orDie); + }), +}); diff --git a/tests/herdr.test.ts b/tests/herdr.test.ts new file mode 100644 index 0000000..e397004 --- /dev/null +++ b/tests/herdr.test.ts @@ -0,0 +1,153 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { command, Herdr } from "../src/herdr.js"; + +const directories: string[] = []; +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture() { + const temporary = await mkdtemp("/tmp/opencode/multi-work-"); + directories.push(temporary); + const root = join(temporary, "root"); + const destination = join(temporary, "returned checkout"); + await mkdir(root); + const signal = new AbortController().signal; + await command(["git", "init", "-b", "main"], root, signal); + await command( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--allow-empty", + "-m", + "initial", + ], + root, + signal, + ); + await command(["git", "branch", "other"], root, signal); + const calls: string[][] = []; + let open = true; + let linked = false; + let duplicate = false; + let branch = ""; + const run = async (args: string[], cwd: string, signal: AbortSignal) => { + calls.push(args); + if (args[0] !== "herdr") return command(args, cwd, signal); + if (args[1] === "workspace") + return JSON.stringify({ + result: { + workspaces: open + ? [{ workspace_id: "w1" }, ...(duplicate ? [{ workspace_id: "w3" }] : [])] + : [], + }, + }); + if (args[2] === "list") { + const id = args[4]!; + const path = id === "w2" ? destination : root; + return JSON.stringify({ + result: { + source: { + repo_key: join(root, ".git"), + repo_root: root, + source_checkout_path: path, + source_workspace_id: id, + }, + worktrees: [ + { + path, + is_linked_worktree: id === "w2" || linked, + is_bare: false, + open_workspace_id: id, + }, + ], + }, + }); + } + expect(args[2]).toBe("create"); + expect(args.slice(3, 5)).toEqual(["--cwd", root]); + expect(args.at(-1)).toBe("--no-focus"); + branch = args[args.indexOf("--branch") + 1]!; + const base = args[args.indexOf("--base") + 1]!; + // Real Git worktree behavior, but only in an isolated temporary repository. + await command(["git", "worktree", "add", "-b", branch, destination, base], root, signal); + return JSON.stringify({ + result: { + workspace: { workspace_id: "w2" }, + worktree: { path: destination, branch, is_linked_worktree: true }, + }, + }); + }; + return { + root, + destination, + signal, + calls, + herdr: new Herdr(run), + close() { + open = false; + }, + link() { + linked = true; + }, + duplicate() { + duplicate = true; + }, + }; +} + +test("live inventory recognizes an unannotated root and creates a unique branch from another local branch", async () => { + const f = await fixture(); + const root = await f.herdr.root(f.root, f.signal); + expect(root?.workspaceID).toBe("w1"); + const destination = await f.herdr.create(root!, "other", "multi-work/fixture", f.signal); + expect(destination.directory).toBe(f.destination); + expect(destination.branch).toStartWith("multi-work/"); + expect(await command(["git", "branch", "--show-current"], f.root, f.signal)).toBe("main"); + await f.herdr.verify( + destination.directory, + destination.workspaceID, + root!.repo, + destination.branch, + f.signal, + ); +}); + +test("closed, linked and ambiguous workspaces fail root gating", async () => { + for (const mode of ["close", "link", "duplicate"] as const) { + const f = await fixture(); + f[mode](); + expect(await f.herdr.root(f.root, f.signal)).toBeUndefined(); + } +}); + +test("revalidates before mutation and rejects non-branch refs without creating a worktree", async () => { + const f = await fixture(); + const root = (await f.herdr.root(f.root, f.signal))!; + const oid = await command(["git", "rev-parse", "HEAD"], f.root, f.signal); + for (const base of [oid, "--help", "main; touch injected"]) { + await expect(f.herdr.create(root, base, "multi-work/fixture", f.signal)).rejects.toThrow(); + } + f.close(); + await expect(f.herdr.create(root, "main", "multi-work/fixture", f.signal)).rejects.toThrow( + "no longer eligible", + ); + expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0); +}); + +test("a missing or malformed Herdr CLI never qualifies", async () => { + const f = await fixture(); + await expect(new Herdr(async () => "not json").root(f.root, f.signal)).rejects.toThrow(); + await expect( + new Herdr(async () => { + throw new Error("ENOENT"); + }).root(f.root, f.signal), + ).rejects.toThrow("ENOENT"); +}); diff --git a/tests/install.test.ts b/tests/install.test.ts index 0dce8ca..293a394 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -113,7 +113,7 @@ async function install( '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", setup() { return "native-ready"; } };\n'), + : 'export default { id: "stanp-opencode", effect() { return "native-ready"; } };\n'), ), writeFile( join(fixture, "dist", "tui.js"), @@ -207,7 +207,7 @@ test("installs an importable native plugin and default config under XDG without [ process.execPath, "-e", - "const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.setup()]));", + "const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.effect()]));", join(config, "plugins", "stanp-opencode"), ], directory, diff --git a/tests/multi-work.test.ts b/tests/multi-work.test.ts new file mode 100644 index 0000000..a9941aa --- /dev/null +++ b/tests/multi-work.test.ts @@ -0,0 +1,583 @@ +import { expect, test } from "bun:test"; +import { Effect } from "effect"; +import { Agent } from "@opencode/schema/agent"; +import { Session } from "@opencode/schema/session"; +import { SessionMessage } from "@opencode/schema/session-message"; +import { Tool } from "@opencode/schema/tool"; +import type { Context } from "@opencode/plugin/effect/plugin"; +import type { ToolEditor, ToolHooks } from "@opencode/plugin/effect/tool"; +import type { AgentEditor } from "@opencode/plugin/effect/agent"; +import type { SessionContext } from "@opencode/plugin/effect/session"; +import { z } from "zod"; +import { Herdr, type HerdrRoot } from "../src/herdr.js"; +import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.js"; + +class FixtureHerdr extends Herdr { + eligible = true; + creations = 0; + verified = 0; + delayCreation = false; + creationAborted = false; + creationStarted = Promise.withResolvers(); + override async root(directory: string) { + return this.eligible && directory === "/root" + ? { directory, workspaceID: "w1", repo: "/root/.git" } + : undefined; + } + override async create(_root: HerdrRoot, _base: string, branch: string, signal: AbortSignal) { + this.creations++; + if (!this.eligible) throw new Error("root closed"); + if (this.delayCreation) { + this.creationStarted.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + this.creationAborted = true; + reject(new Error("creation interrupted")); + }, + { once: true }, + ); + }); + } + return { directory: "/worktree", workspaceID: "w2", branch }; + } + override async verify() { + this.verified++; + } +} + +interface FixtureSession { + id: Session.ID; + projectID: string; + agent: string; + parentID: string | undefined; + location: { directory: string }; + permissions: Agent.Info["permissions"]; +} + +function fixture() { + const herdr = new FixtureHerdr(); + const storage = new Map[1]>(); + const agents = new Map>>(); + for (const name of ["coordinate", "fast-lo", "fast-hi", "good-lo", "good-hi"]) + agents.set(name, { ...Agent.Info.default(Agent.ID.make(name)), permissions: [] }); + const parent: FixtureSession = { + id: Session.ID.make("ses_parent"), + projectID: "project-fixture", + agent: "multi-work", + parentID: undefined, + location: { directory: "/root" }, + permissions: [], + }; + const sessions = new Map([[String(parent.id), parent]]); + const order: string[] = []; + let cancelled = false; + let hold = false; + let delayMove = false; + let crossProject = false; + let destinationAvailable = true; + let warming = false; + let agentReads = 0; + let prompts = 0; + let serial = 0; + const entered = Promise.withResolvers(); + const progress: Tool.Metadata[] = []; + const native: Tool.Info = { + name: "subagent", + description: "native", + input: z.object({}), + options: { codemode: false }, + execute: (raw, call) => + Effect.gen(function* () { + const input = z + .object({ + agent: z.string(), + sessionID: z.string().optional(), + background: z.boolean().optional(), + }) + .parse(raw); + const id = Session.ID.make(input.sessionID ?? `ses_child${++serial}`); + if (!input.sessionID) + sessions.set(id, { + ...parent, + id, + parentID: call.sessionID, + agent: input.agent, + location: { directory: "/root" }, + }); + order.push("create"); + yield* call.progress({ sessionID: id, status: "running" }); + order.push(`prompt:${sessions.get(id)!.location.directory}`); + prompts++; + entered.resolve(); + if (hold) + yield* Effect.never.pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + cancelled = true; + }), + ), + ); + return { + content: "native result", + metadata: { sessionID: id, status: input.background ? "running" : "completed" }, + }; + }), + }; + let tool = native; + const toolHooks: ((event: ToolHooks["execute.before"]) => Effect.Effect)[] = + []; + const hooks: ((event: SessionContext) => Effect.Effect)[] = []; + let transform!: (editor: AgentEditor) => void; + const registration = Effect.succeed({ dispose: Effect.void }); + const editAgents = (): AgentEditor => ({ + get: (id) => agents.get(id), + list: () => [...agents.values()], + default() {}, + remove(id) { + agents.delete(id); + }, + update(id, update) { + // SAFETY: Agent.Info.default returns a fresh draft that the fixture exclusively owns. + const agent = (agents.get(id) ?? Agent.Info.default(Agent.ID.make(id))) as NonNullable< + ReturnType + >; + update(agent); + agents.set(id, agent); + }, + }); + const editTools: ToolEditor = { + list: () => [{ ...tool, id: tool.name }], + get: () => ({ ...tool, id: tool.name }), + add() {}, + namespace() {}, + remove() {}, + update(_id, update) { + const draft = { ...tool }; + update(draft); + tool = draft; + }, + }; + const host = { + options: {}, + location: { directory: "/root" }, + storage: { + get: (key: string) => Effect.sync(() => storage.get(key)), + set: (key: string, value: Parameters[1]) => + Effect.sync(() => { + storage.set(key, value); + }), + }, + agent: { + transform: (callback: typeof transform) => + Effect.sync(() => { + transform = callback; + callback(editAgents()); + }).pipe(Effect.andThen(registration)), + reload: () => + Effect.sync(() => { + if (transform) transform(editAgents()); + }), + get: () => Effect.succeed({ data: agents.get("multi-work")! }), + list: () => + Effect.sync(() => { + agentReads++; + if (!destinationAvailable) throw new Error("Destination location unavailable"); + return { data: warming && agentReads === 1 ? [] : [...agents.values()] }; + }), + }, + session: { + get: ({ sessionID }: { sessionID: string }) => + Effect.sync(() => sessions.get(sessionID)!), + move: ({ sessionID, directory }: { sessionID: string; directory: string }) => + Effect.sync(() => { + order.push("move-request"); + if (!delayMove) { + sessions.get(sessionID)!.location.directory = directory; + order.push("moved"); + if (crossProject) sessions.get(sessionID)!.projectID = "another-project"; + } + entered.resolve(); + }), + hook: (_name: string, callback: (event: SessionContext) => Effect.Effect) => + Effect.sync(() => { + hooks.push(callback); + }).pipe(Effect.andThen(registration)), + }, + tool: { + transform: (callback: (editor: ToolEditor) => void) => + Effect.sync(() => callback(editTools)).pipe(Effect.andThen(registration)), + hook: ( + _name: string, + callback: (event: ToolHooks["execute.before"]) => Effect.Effect, + ) => + Effect.sync(() => { + toolHooks.push(callback); + }).pipe(Effect.andThen(registration)), + }, + }; + // SAFETY: The test setup only receives its exercised services; unexpected host dependencies fail loudly. + const install = installMultiWork as ( + context: Pick, + buildPermissions: () => Agent.Info["permissions"], + herdr: Herdr, + ) => ReturnType; + const call: Tool.Context = { + sessionID: parent.id, + agent: Agent.ID.make("multi-work"), + messageID: SessionMessage.ID.make("msg_test"), + id: Tool.CallID.make("call_test"), + progress: (value) => + Effect.sync(() => { + progress.push(value); + }), + }; + return { + herdr, + agents, + sessions, + parent, + storage, + order, + entered, + hooks, + toolHooks, + call, + progress, + install: install( + host, + () => + agents.get("build")?.permissions ?? + Agent.Info.default(Agent.ID.make("build")).permissions, + herdr, + ), + invoke: ( + input = { + agent: "coordinate", + description: "test", + prompt: "work", + worktree: { base: "main" }, + }, + caller = call, + ) => tool.execute(input, caller), + execute: (input: z.infer, caller = call) => tool.execute(input, caller), + hold() { + hold = true; + }, + delayMove() { + delayMove = true; + }, + missingDestination() { + destinationAvailable = false; + }, + crossProject() { + crossProject = true; + }, + warmDestination() { + warming = true; + }, + get agentReads() { + return agentReads; + }, + get cancelled() { + return cancelled; + }, + get prompts() { + return prompts; + }, + get tool() { + return tool; + }, + }; +} +const request = z.object({ + agent: z.string(), + description: z.string(), + prompt: z.string(), + worktree: z.object({ base: z.string() }).optional(), + sessionID: z.string().optional(), + background: z.boolean().optional(), +}); + +test("Effect-native wrapper preserves name, metadata and result; moves before prompt and resumes its bound child", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + expect(f.agents.get("multi-work")?.mode).toBe("primary"); + expect(f.tool.name).toBe("subagent"); + expect(f.tool.options?.codemode).toBe(false); + expect(f.agents.get("coordinate")?.mode).toBe("all"); + const result = yield* f.invoke(); + expect(result.content).toStartWith("native result"); + expect(result.content).toContain("Checkout: /worktree"); + expect(result.metadata?.sessionID).toBe("ses_child1"); + expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]); + expect(f.parent.location.directory).toBe("/root"); + expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe( + true, + ); + const resume = yield* f.execute({ + agent: "coordinate", + description: "resume", + prompt: "continue", + sessionID: "ses_child1", + background: true, + }); + expect(resume.metadata?.status).toBe("running"); + expect(f.herdr.creations).toBe(1); + expect(f.herdr.verified).toBe(1); + }), + ), + ); +}); + +test("local fast-lo uses native delegation without a worktree", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.execute({ agent: "fast-lo", description: "lookup", prompt: "read" }); + expect(f.herdr.creations).toBe(0); + expect(f.order).toEqual(["create", "prompt:/root"]); + }), + ), + ); +}); + +test("root loss, session permission denial and missing destination fail before prompting", async () => { + for (const failure of ["root", "permission", "ask", "destination", "project"] as const) { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + if (failure === "root") f.herdr.eligible = false; + if (failure === "permission") + f.parent.permissions = [ + { action: "herdr_worktree", resource: "*", effect: "deny" }, + ]; + if (failure === "destination") f.missingDestination(); + if (failure === "project") f.crossProject(); + if (failure === "ask") + f.parent.permissions = [ + { action: "herdr_worktree", resource: "*", effect: "ask" }, + ]; + const result = yield* Effect.exit(f.invoke()); + expect(result._tag).toBe("Failure"); + expect(f.prompts).toBe(0); + expect(f.herdr.creations).toBe( + failure === "destination" || failure === "project" ? 1 : 0, + ); + }), + ), + ); + } +}); + +test("interruption crosses the wrapper into the native executor finalizer", async () => { + const f = fixture(); + f.hold(); + const controller = new AbortController(); + const running = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + return yield* f.invoke(); + }), + ), + { signal: controller.signal }, + ); + await f.entered.promise; + // entered may resolve at move request; yield until the native foreground wait is reached. + while (f.prompts === 0) await Bun.sleep(1); + controller.abort(); + await expect(running).rejects.toBeDefined(); + expect(f.cancelled).toBe(true); +}); + +test("move admission alone never admits a task; cancellation retains an unready binding", async () => { + const f = fixture(); + f.delayMove(); + const controller = new AbortController(); + const running = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + return yield* f.invoke(); + }), + ), + { signal: controller.signal }, + ); + await f.entered.promise; + expect(f.prompts).toBe(0); + controller.abort(); + await expect(running).rejects.toBeDefined(); + expect(f.prompts).toBe(0); + expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(false); +}); + +test("rejects foreign and changed-agent resumes, non-primary multi-work and unbound coordinate calls", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.invoke(); + f.sessions.get("ses_child1")!.agent = "fast-lo"; + expect( + (yield* Effect.exit( + f.execute({ + agent: "coordinate", + description: "resume", + prompt: "x", + sessionID: "ses_child1", + }), + ))._tag, + ).toBe("Failure"); + f.parent.parentID = "ses_foreign"; + expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); + f.parent.parentID = undefined; + expect( + (yield* Effect.exit( + f.invoke(undefined, { ...f.call, agent: Agent.ID.make("build") }), + ))._tag, + ).toBe("Failure"); + expect( + (yield* Effect.exit( + f.execute({ agent: "multi-work", description: "x", prompt: "x" }), + ))._tag, + ).toBe("Failure"); + expect(f.herdr.creations).toBe(1); + }), + ), + ); +}); + +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; + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + expect(f.agents.has("multi-work")).toBe(false); + const hook = f.toolHooks[0]!; + expect( + (yield* Effect.exit(hook({ ...f.call, tool: "shell", input: {} })))._tag, + ).toBe("Failure"); + }), + ), + ); +}); + +test("interruption during Herdr creation aborts the command and retains an inspectable attempt without prompting", async () => { + const f = fixture(); + f.herdr.delayCreation = true; + const controller = new AbortController(); + const running = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + return yield* f.invoke(); + }), + ), + { signal: controller.signal }, + ); + await f.herdr.creationStarted.promise; + controller.abort(); + await expect(running).rejects.toBeDefined(); + expect(f.herdr.creationAborted).toBe(true); + expect(f.prompts).toBe(0); + expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true); +}); + +test("waits for asynchronous destination plugin activation before moving or prompting", async () => { + const f = fixture(); + f.warmDestination(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.invoke(); + expect(f.agentReads).toBe(2); + expect(f.prompts).toBe(1); + expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]); + }), + ), + ); +}); + +test("configured build restrictions remain effective for worktree preparation", async () => { + const f = fixture(); + f.agents.set("build", { + ...Agent.Info.default(Agent.ID.make("build")), + permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }], + }); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); + expect(f.herdr.creations).toBe(0); + }), + ), + ); +}); + +test("failed initialization records the attempt and never blindly recreates on replay", async () => { + const f = fixture(); + f.missingDestination(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); + expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure"); + expect(f.herdr.creations).toBe(1); + expect(f.prompts).toBe(0); + const record = z + .object({ child: z.string(), branch: z.string() }) + .parse(f.storage.get(`multi-work-call:${f.call.sessionID}:${f.call.id}`)); + expect(record.child).toBe("ses_child1"); + expect(record.branch).toStartWith("multi-work/"); + }), + ), + ); +}); + +test("same-child concurrent resumes are rejected while the native foreground executor is active", async () => { + const f = fixture(); + f.hold(); + const controller = new AbortController(); + const checked = Promise.withResolvers(); + const running = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.invoke().pipe(Effect.forkScoped); + yield* Effect.promise(() => f.entered.promise); + const second = yield* Effect.exit( + f.execute({ + agent: "coordinate", + description: "resume", + prompt: "continue", + sessionID: "ses_child1", + }), + ); + expect(second._tag).toBe("Failure"); + expect(f.herdr.creations).toBe(1); + checked.resolve(); + return yield* Effect.never; + }), + ), + { signal: controller.signal }, + ); + await Promise.race([checked.promise, running]); + controller.abort(); + await expect(running).rejects.toBeDefined(); + expect(f.cancelled).toBe(true); +}); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 896c462..8f77107 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -10,8 +10,9 @@ import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client"; import type { SessionContext } from "@opencode/plugin/promise/session"; import { z } from "zod"; import { gptAgents } from "../src/config.js"; -import plugin from "../src/index.js"; +import { basePlugin as plugin } from "../src/index.js"; import { playwrightMcpCommand } from "../src/playwright.js"; +import { bindingKey } from "../src/multi-work.js"; const providerID = Provider.ID.make("test"); function model(id: string): ModelInfo { @@ -63,6 +64,7 @@ async function host( let before!: (event: { tool: string; agent: string; input: unknown }) => Promise | void; let parent = "build"; let child = "fast-lo"; + let childDirectory = directory; let defaultAgent = "build"; const registration = { async dispose() {} }; let transform!: (editor: AgentEditor) => void; @@ -218,8 +220,13 @@ async function host( }, async get({ sessionID }: { sessionID: string }) { return sessionID === "child" - ? { agent: child, parentID: "parent" } - : { agent: parent }; + ? { + agent: child, + projectID: "project-fixture", + parentID: "parent", + location: { directory: childDirectory }, + } + : { agent: parent, projectID: "project-fixture", location: { directory } }; }, }, tool: { @@ -299,6 +306,19 @@ async function host( child = target; for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" }); }, + bindCoordinate(ready: boolean, moved = true) { + childDirectory = moved ? `${directory}/worktree` : directory; + storage.set(bindingKey("child"), { + owner: "parent", + child: "child", + projectID: "project-fixture", + ready, + root: { directory, workspaceID: "w1", repo: `${directory}/.git` }, + directory: `${directory}/worktree`, + workspaceID: "w2", + branch: "multi-work/test", + }); + }, async tools(caller: string) { const event: ModelContext = { agent: Agent.ID.make(caller), @@ -318,28 +338,52 @@ async function host( }; } -test("missing required models disables registration; only GPT mode degrades without Astra", async () => { +test("orchestration inherits deep-lo and falls back to good-lo with available worker sets", async () => { const full = await host(); 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-5.6-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); - const custom = await host([...allModels, model("local")], { + + const customAgents = { + ...gptAgents, + "good-lo": { model: "custom-good", effort: "high" as const }, + "deep-lo": { model: "custom-deep", effort: "medium" as const }, + }; + const custom = await host( + [...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")], + { + mode: "custom", + provider: "test", + agents: customAgents, + }, + ); + expect(custom.agents.get("coordinate")?.model).toMatchObject({ + id: "custom-deep", + variant: "medium", + }); + const customFallback = await host([...allModels, reasoningModel("custom-good")], { mode: "custom", provider: "test", - agents: { ...gptAgents, coordinate: { model: "local", effort: "low" } }, + agents: customAgents, }); - expect(String(custom.agents.get("coordinate")?.model?.id)).toBe("local"); + expect(customFallback.agents.get("coordinate")?.model).toMatchObject({ + id: "custom-good", + variant: "high", + }); + expect(customFallback.agents.has("deep-lo")).toBe(false); expect( ( - await host(allModels.slice(0, 2), { + await host(allModels, { mode: "custom", provider: "test", - agents: gptAgents, + agents: customAgents, }) ).agents.has("coordinate"), ).toBe(false); @@ -619,6 +663,27 @@ test("manual calls and command-created child sessions cannot bypass delegation r expect((await result.tools("build")).subagent?.description).toBe("Native subagents"); }); +test("coordinate prompt admission requires both owned provenance and a completed move; multi-work cannot be a child", async () => { + const result = await host(); + await expect(result.command("multi-work", "coordinate")).rejects.toThrow( + "ready worktree binding", + ); + result.bindCoordinate(false); + await expect(result.command("multi-work", "coordinate")).rejects.toThrow( + "ready worktree binding", + ); + result.bindCoordinate(true, false); + await expect(result.command("multi-work", "coordinate")).rejects.toThrow( + "ready worktree binding", + ); + result.bindCoordinate(true); + await result.command("multi-work", "coordinate"); + await expect(result.command("build", "coordinate")).rejects.toThrow("ready worktree binding"); + 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 = { ...model("gpt-5.6-luna"), @@ -650,12 +715,12 @@ test("reasoning settings and Luna priority use native advertised or official sup expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); }); -test("coordinator defaults expose their effort variant without relying on it for requests", async () => { +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: "medium", + variant: "low", }); expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); } @@ -663,7 +728,7 @@ test("coordinator defaults expose their effort variant without relying on it for const fallback = await host(allModels.slice(0, 2)); expect(fallback.agents.get("coordinate")?.model).toMatchObject({ id: "gpt-5.6-sol", - variant: "medium", + variant: "low", }); expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined(); @@ -672,8 +737,11 @@ test("coordinator defaults expose their effort variant without relying on it for provider: "test", agents: { ...gptAgents, coordinate: { model: "local", effort: "xhigh" } }, }); - expect(custom.agents.get("coordinate")?.model?.variant).toBeUndefined(); - expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBe("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 () => { @@ -685,12 +753,12 @@ test("the current host model API filters discovery and reloads changed availabil 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("medium"); + 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-5.6-sol"); - expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("medium"); + 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);