From 5cf0beb74ba02638194ba0ea375298b70c625422 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Thu, 17 Sep 2026 21:09:19 +0200 Subject: [PATCH 1/5] 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); From 94949361600ca8561bddbb77e283516f5cc8694b Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Thu, 17 Sep 2026 21:32:26 +0200 Subject: [PATCH 2/5] fix wrong herdr response parse + add labels and branch names to delegations --- docs/multi-work.md | 17 +++- scripts/test-multi-work-host.ts | 14 +++- src/herdr.ts | 27 +++++-- src/multi-work.ts | 21 +++-- src/routing.ts | 10 ++- tests/composite.test.ts | 20 +++-- tests/fixtures/native-host-plugin.ts | 8 +- tests/herdr.test.ts | 113 +++++++++++++++++++++++++-- tests/multi-work.test.ts | 99 ++++++++++++++++++++++- 9 files changed, 291 insertions(+), 38 deletions(-) diff --git a/docs/multi-work.md b/docs/multi-work.md index 23dd1a0..0b7ca54 100644 --- a/docs/multi-work.md +++ b/docs/multi-work.md @@ -63,13 +63,24 @@ subagent({ agent: "coordinate", description: "Implement API change", prompt: "Goal, scope, constraints, and acceptance checks...", - worktree: { base: "feature/source" }, + worktree: { + base: "main", + branch: "fix/login-timeout", + label: "Fix login timeout", + }, 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. +`base` identifies a locally available branch or remote-tracking branch. Optional +`branch` chooses the exact new Git branch and optional `label` sets the Herdr workspace +display label through `herdr worktree create --label`. Both must be nonempty after +trimming. Prefer concise, descriptive human-readable names. Existing branch validation +and collision checks apply, and the tool never overwrites a branch. When omitted, the +tool retains the previous behavior: a unique `multi-work/` branch and Herdr's +default workspace label. Naming fields are creation-only; resumes accept only `sessionID`. +The tool +resolves the base commit and creates the 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`. diff --git a/scripts/test-multi-work-host.ts b/scripts/test-multi-work-host.ts index 3f355cc..93f9564 100644 --- a/scripts/test-multi-work-host.ts +++ b/scripts/test-multi-work-host.ts @@ -15,6 +15,7 @@ const root = join(temp, "root"), destination = join(temp, "child"), bin = join(temp, "bin"), config = join(temp, "config"); +const herdrCreation = join(temp, "herdr-creation.txt"); 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") @@ -41,17 +42,20 @@ const inventory = (path: string, id: string) => source: { repo_key: join(root, ".git"), repo_root: root, - source_checkout_path: path, - source_workspace_id: id, + source_checkout_path: root, + source_workspace_id: "w1", }, worktrees: [ - { path, is_linked_worktree: path !== root, is_bare: false, open_workspace_id: id }, + { path: root, is_linked_worktree: false, is_bare: false, open_workspace_id: "w1" }, + ...(path === root + ? [] + : [{ path, is_linked_worktree: true, 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`, + `#!/bin/sh\ncase "$1 $2" in\n"worktree create") [ "$9" = "--label" ] && [ "\${10}" = "Fix native host" ] || exit 8; printf '%s\\n%s\\n' "$6" "\${10}" > '${herdrCreation}'; git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, ); await chmod(join(bin, "herdr"), 0o755); const built = await Bun.build({ @@ -190,6 +194,8 @@ try { .parse(JSON.parse(z.string().parse(result.output))); if (!output.content.includes("FIXTURE_COMPLETE")) throw new Error("Missing native child result"); + if ((await readFile(herdrCreation, "utf8")) !== "fix/native-host\nFix native host\n") + throw new Error("Custom Herdr branch/label mismatch"); 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"); diff --git a/src/herdr.ts b/src/herdr.ts index 60e2c5e..2ed22b0 100644 --- a/src/herdr.ts +++ b/src/herdr.ts @@ -109,7 +109,13 @@ export class Herdr { return matches.length === 1 ? matches[0] : undefined; } - async create(root: HerdrRoot, base: string, branch: string, signal: AbortSignal) { + async create( + root: HerdrRoot, + base: string, + branch: string, + signal: AbortSignal, + label?: string, + ) { 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"); @@ -150,6 +156,7 @@ export class Herdr { branch, "--base", commit, + ...(label === undefined ? [] : ["--label", label]), "--no-focus", ], root.directory, @@ -185,7 +192,7 @@ export class Herdr { throw new Error( "Created checkout does not match the requested repository and base", ); - await this.verify(directory, workspaceID, root.repo, branch, signal); + await this.verify(directory, workspaceID, root, branch, signal); } catch (error) { throw new Error( `Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`, @@ -198,7 +205,7 @@ export class Herdr { async verify( directory: string, workspaceID: string, - repo: string, + root: HerdrRoot, branch: string, signal: AbortSignal, ) { @@ -214,12 +221,20 @@ export class Herdr { const checkout = value.result.worktrees.find( (item) => item.open_workspace_id === workspaceID && item.is_linked_worktree, ); + const sourceCheckout = value.result.worktrees.find( + (item) => + item.open_workspace_id === value.result.source.source_workspace_id && + !item.is_linked_worktree && + !item.is_bare, + ); if ( !checkout || - value.result.source.source_workspace_id !== workspaceID || - (await realpath(value.result.source.source_checkout_path)) !== directory || + !sourceCheckout || + value.result.source.source_workspace_id !== root.workspaceID || + (await realpath(value.result.source.source_checkout_path)) !== root.directory || + (await realpath(sourceCheckout.path)) !== root.directory || (await realpath(checkout.path)) !== directory || - (await realpath(value.result.source.repo_key)) !== repo + (await realpath(value.result.source.repo_key)) !== root.repo ) throw new Error("Bound Herdr checkout is no longer available"); if ( diff --git a/src/multi-work.ts b/src/multi-work.ts index ab7a6a0..8445bf0 100644 --- a/src/multi-work.ts +++ b/src/multi-work.ts @@ -28,7 +28,11 @@ const inputSchema = z sessionID: z.string().optional(), background: z.boolean().optional(), worktree: z - .object({ base: z.string().trim().min(1) }) + .object({ + base: z.string().trim().min(1), + branch: z.string().trim().min(1).optional(), + label: z.string().trim().min(1).optional(), + }) .strict() .optional(), }) @@ -180,7 +184,7 @@ export const installMultiWork = ( 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."; + "Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base, branch?, label?}. Prefer descriptive human-readable branch and label values, for example {base:'main',branch:'fix/login-timeout',label:'Fix login timeout'}. Continue an owned coordinate child with sessionID and no worktree argument; naming fields are create-only. Native background and results apply."; } }).pipe(Effect.orDie), ); @@ -275,7 +279,7 @@ export const installMultiWork = ( herdr.verify( binding.directory, binding.workspaceID, - binding.root.repo, + binding.root, binding.branch, signal, ), @@ -319,7 +323,8 @@ export const installMultiWork = ( throw new Error( "Native executor returned an unexpected child", ); - const branch = `multi-work/${randomUUID()}`; + const branch = + worktree.branch ?? `multi-work/${randomUUID()}`; yield* context.storage.set(callKey, { child: id, branch, @@ -327,7 +332,13 @@ export const installMultiWork = ( }); yield* call.progress({ ...metadata, branch }); const destination = yield* Effect.tryPromise((signal) => - herdr.create(root, worktree.base, branch, signal), + herdr.create( + root, + worktree.base, + branch, + signal, + worktree.label, + ), ); const binding = { owner: call.sessionID, diff --git a/src/routing.ts b/src/routing.ts index 008ba3f..7ce4c7c 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -16,12 +16,14 @@ export const descriptions = { 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 +for work in a new Herdr worktree, supplying descriptive human-readable names such as +worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus +description and a complete prompt with scope, constraints and acceptance checks. The tool +uses the requested branch and Herdr workspace label; when omitted, it creates the prior unique +multi-work/ branch and lets Herdr choose its default label. It 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. +its agent or location; naming fields are create-only. 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 diff --git a/tests/composite.test.ts b/tests/composite.test.ts index cd47a08..1865407 100644 --- a/tests/composite.test.ts +++ b/tests/composite.test.ts @@ -59,16 +59,26 @@ const scenario = async () => { source: { repo_key: join(root, ".git"), repo_root: root, - source_checkout_path: path, - source_workspace_id: id, + source_checkout_path: root, + source_workspace_id: "w1", }, worktrees: [ { - path, - is_linked_worktree: path !== root, + path: root, + is_linked_worktree: false, is_bare: false, - open_workspace_id: id, + open_workspace_id: "w1", }, + ...(path === root + ? [] + : [ + { + path, + is_linked_worktree: true, + is_bare: false, + open_workspace_id: id, + }, + ]), ], }, }); diff --git a/tests/fixtures/native-host-plugin.ts b/tests/fixtures/native-host-plugin.ts index fd1e342..efad3e8 100644 --- a/tests/fixtures/native-host-plugin.ts +++ b/tests/fixtures/native-host-plugin.ts @@ -76,7 +76,13 @@ export default Plugin.define({ background: input.background, ...(input.child ? { sessionID: input.child } - : { worktree: { base: "main" } }), + : { + worktree: { + base: "main", + branch: "fix/native-host", + label: "Fix native host", + }, + }), }, { sessionID: Session.ID.make(input.parent), diff --git a/tests/herdr.test.ts b/tests/herdr.test.ts index e397004..f841fb1 100644 --- a/tests/herdr.test.ts +++ b/tests/herdr.test.ts @@ -38,6 +38,12 @@ async function fixture() { let linked = false; let duplicate = false; let branch = ""; + let label: string | undefined; + let sourceWorkspaceIDOverride: string | undefined; + let sourcePathOverride: string | undefined; + let childWorkspaceIDOverride: string | undefined; + let childPathOverride: string | undefined; + let repoOverride: string | undefined; const run = async (args: string[], cwd: string, signal: AbortSignal) => { calls.push(args); if (args[0] !== "herdr") return command(args, cwd, signal); @@ -52,21 +58,33 @@ async function fixture() { if (args[2] === "list") { const id = args[4]!; const path = id === "w2" ? destination : root; + const sourceWorkspaceID = sourceWorkspaceIDOverride ?? (id === "w2" ? "w1" : id); + const sourcePath = sourcePathOverride ?? root; return JSON.stringify({ result: { source: { - repo_key: join(root, ".git"), + repo_key: repoOverride ?? join(root, ".git"), repo_root: root, - source_checkout_path: path, - source_workspace_id: id, + source_checkout_path: sourcePath, + source_workspace_id: sourceWorkspaceID, }, worktrees: [ { - path, - is_linked_worktree: id === "w2" || linked, + path: sourcePath, + is_linked_worktree: linked, is_bare: false, - open_workspace_id: id, + open_workspace_id: sourceWorkspaceID, }, + ...(id === "w2" + ? [ + { + path: childPathOverride ?? path, + is_linked_worktree: true, + is_bare: false, + open_workspace_id: childWorkspaceIDOverride ?? id, + }, + ] + : []), ], }, }); @@ -75,6 +93,8 @@ async function fixture() { expect(args.slice(3, 5)).toEqual(["--cwd", root]); expect(args.at(-1)).toBe("--no-focus"); branch = args[args.indexOf("--branch") + 1]!; + const labelIndex = args.indexOf("--label"); + label = labelIndex === -1 ? undefined : args[labelIndex + 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); @@ -90,6 +110,9 @@ async function fixture() { destination, signal, calls, + get label() { + return label; + }, herdr: new Herdr(run), close() { open = false; @@ -100,10 +123,23 @@ async function fixture() { duplicate() { duplicate = true; }, + changeInventory(change: { + sourceWorkspaceID?: string | undefined; + sourcePath?: string | undefined; + childWorkspaceID?: string | undefined; + childPath?: string | undefined; + repo?: string | undefined; + }) { + sourceWorkspaceIDOverride = change.sourceWorkspaceID; + sourcePathOverride = change.sourcePath; + childWorkspaceIDOverride = change.childWorkspaceID; + childPathOverride = change.childPath; + repoOverride = change.repo; + }, }; } -test("live inventory recognizes an unannotated root and creates a unique branch from another local branch", async () => { +test("create verifies a linked workspace whose inventory source remains the parent workspace", async () => { const f = await fixture(); const root = await f.herdr.root(f.root, f.signal); expect(root?.workspaceID).toBe("w1"); @@ -114,12 +150,73 @@ test("live inventory recognizes an unannotated root and creates a unique branch await f.herdr.verify( destination.directory, destination.workspaceID, - root!.repo, + root!, destination.branch, f.signal, ); }); +test("create passes an optional workspace label to Herdr", async () => { + const f = await fixture(); + const root = (await f.herdr.root(f.root, f.signal))!; + const destination = await f.herdr.create( + root, + "main", + "fix/login-timeout", + f.signal, + "Fix login timeout", + ); + expect(destination.branch).toBe("fix/login-timeout"); + expect(f.label).toBe("Fix login timeout"); + const creation = f.calls.find((args) => args[0] === "herdr" && args[2] === "create")!; + expect(creation.slice(creation.indexOf("--label"), creation.indexOf("--label") + 2)).toEqual([ + "--label", + "Fix login timeout", + ]); +}); + +test("create rejects an existing destination branch before invoking Herdr", async () => { + const f = await fixture(); + const root = (await f.herdr.root(f.root, f.signal))!; + await expect( + f.herdr.create(root, "main", "invalid branch", f.signal, "Invalid"), + ).rejects.toThrow(); + await expect(f.herdr.create(root, "main", "other", f.signal, "Other")).rejects.toThrow( + "Destination branch already exists: other", + ); + expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0); +}); + +test("verify rejects changed parent, child and repository identities", async () => { + for (const change of [ + { sourceWorkspaceID: "w9" }, + { sourcePath: "child" }, + { childWorkspaceID: "w9" }, + { childPath: "root" }, + { repo: "child" }, + ]) { + const f = await fixture(); + const root = (await f.herdr.root(f.root, f.signal))!; + const destination = await f.herdr.create(root, "main", "multi-work/fixture", f.signal); + f.changeInventory({ + sourceWorkspaceID: change.sourceWorkspaceID, + sourcePath: change.sourcePath === "child" ? f.destination : undefined, + childWorkspaceID: change.childWorkspaceID, + childPath: change.childPath === "root" ? f.root : undefined, + repo: change.repo === "child" ? f.destination : undefined, + }); + await expect( + f.herdr.verify( + destination.directory, + destination.workspaceID, + root, + destination.branch, + f.signal, + ), + ).rejects.toThrow("Bound Herdr checkout is no longer available"); + } +}); + test("closed, linked and ambiguous workspaces fail root gating", async () => { for (const mode of ["close", "link", "duplicate"] as const) { const f = await fixture(); diff --git a/tests/multi-work.test.ts b/tests/multi-work.test.ts index a9941aa..a0043ec 100644 --- a/tests/multi-work.test.ts +++ b/tests/multi-work.test.ts @@ -18,14 +18,26 @@ class FixtureHerdr extends Herdr { verified = 0; delayCreation = false; creationAborted = false; + createdBase: string | undefined; + createdBranch: string | undefined; + createdLabel: string | undefined; 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) { + override async create( + _root: HerdrRoot, + base: string, + branch: string, + signal: AbortSignal, + label?: string, + ) { this.creations++; + this.createdBase = base; + this.createdBranch = branch; + this.createdLabel = label; if (!this.eligible) throw new Error("root closed"); if (this.delayCreation) { this.creationStarted.resolve(); @@ -295,7 +307,13 @@ const request = z.object({ agent: z.string(), description: z.string(), prompt: z.string(), - worktree: z.object({ base: z.string() }).optional(), + worktree: z + .object({ + base: z.string(), + branch: z.string().optional(), + label: z.string().optional(), + }) + .optional(), sessionID: z.string().optional(), background: z.boolean().optional(), }); @@ -348,6 +366,83 @@ test("local fast-lo uses native delegation without a worktree", async () => { ); }); +test("custom branch and workspace label pass through delegation", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + const result = yield* f.execute({ + agent: "coordinate", + description: "Fix login timeout", + prompt: "work", + worktree: { + base: "main", + branch: "fix/login-timeout", + label: "Fix login timeout", + }, + }); + expect(f.herdr.createdBase).toBe("main"); + expect(f.herdr.createdBranch).toBe("fix/login-timeout"); + expect(f.herdr.createdLabel).toBe("Fix login timeout"); + expect(result.metadata?.branch).toBe("fix/login-timeout"); + }), + ), + ); +}); + +test("omitted naming retains generated branch and no explicit label", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + yield* f.invoke(); + expect(f.herdr.createdBranch).toMatch(/^multi-work\/[0-9a-f-]{36}$/); + expect(f.herdr.createdLabel).toBeUndefined(); + }), + ), + ); +}); + +test("rejects empty naming fields and naming on resume", async () => { + const f = fixture(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* f.install; + for (const worktree of [ + { base: "main", branch: " " }, + { base: "main", label: " " }, + ]) + expect( + (yield* Effect.exit( + f.execute({ + agent: "coordinate", + description: "invalid", + prompt: "work", + worktree, + }), + ))._tag, + ).toBe("Failure"); + yield* f.invoke(); + expect( + (yield* Effect.exit( + f.execute({ + agent: "coordinate", + description: "resume", + prompt: "work", + sessionID: "ses_child1", + worktree: { base: "main", branch: "fix/other" }, + }), + ))._tag, + ).toBe("Failure"); + expect(f.herdr.creations).toBe(1); + }), + ), + ); +}); + 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(); From df61d9a282775f2af69059f480742a9dad18fbd5 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Thu, 17 Sep 2026 22:06:45 +0200 Subject: [PATCH 3/5] add quota to the main screen --- src/quota.ts | 6 +----- src/tui.tsx | 15 ++++++++++----- tests/quota.test.ts | 18 +++++++++--------- tests/tui.test.ts | 40 +++++++++++++++++++++++++++++++--------- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/quota.ts b/src/quota.ts index 3420249..cd20726 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -237,11 +237,7 @@ export class QuotaTracker { ) {} async init() { - try { - await this.resolveCredential(); - } catch { - return; - } + await this.refresh(); } private async resolveCredential(generation = this.generation) { diff --git a/src/tui.tsx b/src/tui.tsx index 479788b..d6e1b90 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -60,9 +60,9 @@ export default Plugin.define({ setRevision((value) => value + 1); }); - const Slot = (props: { sessionID: string }) => { + const Slot = (props: { instance: string }) => { const [status] = createResource( - () => `${props.sessionID}:${revision()}`, + () => `${props.instance}:${revision()}`, () => loadQuotaStatus(() => quota.status({})), ); return ( @@ -83,12 +83,17 @@ export default Plugin.define({ ); }; - const remove = context.ui.slot({ + const removeHome = context.ui.slot({ + append: "home.footer.status", + render: () => , + }); + const removeSidebar = context.ui.slot({ append: "sidebar.footer", - render: (input) => , + render: (input) => , }); return () => { - remove(); + removeHome(); + removeSidebar(); unsubscribe(); clearInterval(retry); }; diff --git a/tests/quota.test.ts b/tests/quota.test.ts index ae536d8..6bd1dab 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -329,11 +329,11 @@ test("reads old cached snapshots without session estimates and removes excluded ]); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions"); - expect(result.calls()).toBe(0); + expect(result.calls()).toBe(1); await result.tracker.dispose(); }); -test("activates only for OAuth and refreshes quota at request start", async () => { +test("activates only for OAuth and refreshes quota at initialization and request start", async () => { const key = fixture({ type: "key", key: "secret" }, [plus]); await key.tracker.init(); await key.tracker.start("root"); @@ -360,10 +360,11 @@ test("activates only for OAuth and refreshes quota at request start", async () = ], ); await oauth.tracker.init(); - await oauth.tracker.start("root"); expect(oauth.calls()).toBe(1); + expect(oauth.tracker.status()?.windows[0]?.remaining).toBe(87.5); + await oauth.tracker.start("root"); + expect(oauth.calls()).toBe(2); expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one"); - await oauth.tracker.refresh(); const status = oauth.tracker.status(); expect(status?.windows[0]?.remaining).toBe(82.5); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed"); @@ -482,10 +483,10 @@ test("request completion refreshes once across terminal and idle events", async await result.tracker.init(); await result.tracker.start("root"); await result.tracker.complete("root"); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(89); + expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); await result.tracker.complete("root"); - expect(result.calls()).toBe(2); - expect(result.tracker.status()?.windows[0]?.remaining).toBe(89); + expect(result.calls()).toBe(3); + expect(result.tracker.status()?.windows[0]?.remaining).toBe(70); await result.tracker.dispose(); }); @@ -513,8 +514,7 @@ test("an auth switch detaches an in-flight old-account response and immediately }, ], ); - await result.tracker.init(); - const old = result.tracker.start("root"); + const old = result.tracker.init(); while (result.calls() === 0) await Bun.sleep(1); result.switch({ type: "oauth", diff --git a/tests/tui.test.ts b/tests/tui.test.ts index 9d3a26f..323b62b 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -5,7 +5,8 @@ import { createSignal } from "solid-js"; import { build } from "../scripts/build.js"; import type { QuotaStatus } from "../src/quota.js"; -type SlotRender = (input: { readonly sessionID: string }) => JSX.Element; +type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element; +type HomeSlotRender = (input: Record) => JSX.Element; const status = (plan: string, remaining: number): QuotaStatus => ({ plan, @@ -30,9 +31,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an ); const { default: plugin } = tui; const pending = Promise.withResolvers(); - const responses: Array<() => Promise> = [() => pending.promise]; + const responses: Array<() => Promise> = [ + async () => status("home", 90), + () => pending.promise, + ]; let statusCalls = 0; - let slotRender: SlotRender | undefined; + let sidebarRender: SidebarSlotRender | undefined; + let homeRender: HomeSlotRender | undefined; let notifyUpdated: (() => void) | undefined; const quota = { @@ -52,8 +57,17 @@ test("built quota slot stays reactive across pending, empty, failure, update, an const contextFixture = { client: { rpc: () => quota }, ui: { - slot(claim: { render: SlotRender }) { - slotRender = claim.render; + slot(claim: { + append: "home.footer.status" | "sidebar.footer"; + render: HomeSlotRender | SidebarSlotRender; + }) { + if (claim.append === "home.footer.status") { + // SAFETY: The home slot renderer receives the empty home slot input. + homeRender = claim.render as HomeSlotRender; + } else { + // SAFETY: The only other fixture path is the session-aware sidebar slot. + sidebarRender = claim.render as SidebarSlotRender; + } return () => undefined; }, }, @@ -63,10 +77,18 @@ test("built quota slot stays reactive across pending, empty, failure, update, an // members implemented by this focused rendering fixture. const context = Object.assign({} as Plugin.Context, contextFixture); const cleanup = await plugin.setup(context); - if (!slotRender || !notifyUpdated) throw new Error("Plugin did not register its slot"); + if (!sidebarRender || !homeRender || !notifyUpdated) + throw new Error("Plugin did not register its slots"); + + const renderHome = homeRender; + const home = await testRender(() => renderHome({}), { width: 70, height: 5 }); + await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); + expect(home.captureCharFrame()).toContain("5h: 90% · due"); + expect(statusCalls).toBe(1); + home.renderer.destroy(); const [sessionID, setSessionID] = createSignal("session-one"); - const renderSlot = slotRender; + const renderSlot = sidebarRender; const setup = await testRender( () => renderSlot({ @@ -94,13 +116,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an throw new Error("disconnected"); }); notifyUpdated(); - await setup.waitFor(() => statusCalls === 3); + await setup.waitFor(() => statusCalls === 4); expect(setup.captureCharFrame()).not.toContain("ChatGPT quota"); responses.push(async () => status("pro", 42)); setSessionID("session-two"); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)")); - expect(statusCalls).toBe(4); + expect(statusCalls).toBe(5); } finally { setup.renderer.destroy(); if (cleanup) await cleanup(); From d39408afc4c33b3ddf9147a8d319093f37249316 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Fri, 18 Sep 2026 10:08:13 +0200 Subject: [PATCH 4/5] move the quota to the prompt --- src/tui.tsx | 43 +++++++++++++++++++++++++++---------------- tests/tui.test.ts | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index d6e1b90..52e3d52 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -60,32 +60,43 @@ export default Plugin.define({ setRevision((value) => value + 1); }); - const Slot = (props: { instance: string }) => { + const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => { const [status] = createResource( - () => `${props.instance}:${revision()}`, + () => (props.visible?.() === false ? false : `${props.instance}:${revision()}`), () => loadQuotaStatus(() => quota.status({})), ); + const Content = () => ( + + + ChatGPT quota ({status()!.plan}) + + + {(window) => ( + {formatQuotaWindow(window)} + )} + + + ); return ( - - - ChatGPT quota ({status()!.plan}) - - - {(window) => ( - - {formatQuotaWindow(window)} - - )} - - + }> + + + + ); }; const removeHome = context.ui.slot({ - append: "home.footer.status", - render: () => , + after: "prompt.footer", + render: () => ( + context.ui.router.current().type === "home"} + /> + ), }); const removeSidebar = context.ui.slot({ append: "sidebar.footer", diff --git a/tests/tui.test.ts b/tests/tui.test.ts index 323b62b..e9afa15 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -1,12 +1,13 @@ import { expect, test } from "bun:test"; import type { Plugin } from "@opencode/plugin/tui"; +import type { Route, SlotMap } from "@opencode/plugin/tui/context"; import { testRender, type JSX } from "@opentui/solid"; import { createSignal } from "solid-js"; import { build } from "../scripts/build.js"; import type { QuotaStatus } from "../src/quota.js"; type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element; -type HomeSlotRender = (input: Record) => JSX.Element; +type HomeSlotRender = (input: SlotMap["prompt.footer"]) => JSX.Element; const status = (plan: string, remaining: number): QuotaStatus => ({ plan, @@ -39,6 +40,7 @@ test("built quota slot stays reactive across pending, empty, failure, update, an let sidebarRender: SidebarSlotRender | undefined; let homeRender: HomeSlotRender | undefined; let notifyUpdated: (() => void) | undefined; + const [route, setRoute] = createSignal({ type: "home" }); const quota = { status() { @@ -58,11 +60,12 @@ test("built quota slot stays reactive across pending, empty, failure, update, an client: { rpc: () => quota }, ui: { slot(claim: { - append: "home.footer.status" | "sidebar.footer"; + after?: "prompt.footer"; + append?: "sidebar.footer"; render: HomeSlotRender | SidebarSlotRender; }) { - if (claim.append === "home.footer.status") { - // SAFETY: The home slot renderer receives the empty home slot input. + if (claim.after === "prompt.footer") { + // SAFETY: The prompt footer renderer receives the matching slot input. homeRender = claim.render as HomeSlotRender; } else { // SAFETY: The only other fixture path is the session-aware sidebar slot. @@ -70,6 +73,7 @@ test("built quota slot stays reactive across pending, empty, failure, update, an } return () => undefined; }, + router: { current: route }, }, theme: { text: { default: "#ffffff", muted: "#888888" } }, }; @@ -81,12 +85,32 @@ test("built quota slot stays reactive across pending, empty, failure, update, an throw new Error("Plugin did not register its slots"); const renderHome = homeRender; - const home = await testRender(() => renderHome({}), { width: 70, height: 5 }); + const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), { + width: 40, + height: 5, + }); await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); - expect(home.captureCharFrame()).toContain("5h: 90% · due"); + const homeFrame = home.captureCharFrame(); + expect(homeFrame).toContain(" ChatGPT quota (home)"); + expect(homeFrame).toContain(" 5h: 90% · due"); expect(statusCalls).toBe(1); home.renderer.destroy(); + setRoute({ type: "session", sessionID: "session-one" }); + const sessionPrompt = await testRender( + () => + renderHome({ + sessionID: "session-one", + mode: "normal", + showDetails: false, + }), + { width: 40, height: 5 }, + ); + await sessionPrompt.renderOnce(); + expect(sessionPrompt.captureCharFrame()).not.toContain("ChatGPT quota"); + expect(statusCalls).toBe(1); + sessionPrompt.renderer.destroy(); + const [sessionID, setSessionID] = createSignal("session-one"); const renderSlot = sidebarRender; const setup = await testRender( From b90720d27031c4c2f24fcbe75252e6a6eaeb3db0 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Fri, 18 Sep 2026 10:15:50 +0200 Subject: [PATCH 5/5] fix tests --- tests/composite.test.ts | 3 ++- tests/herdr.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/composite.test.ts b/tests/composite.test.ts index 1865407..7fff670 100644 --- a/tests/composite.test.ts +++ b/tests/composite.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, PubSub, Schema, Stream } from "effect"; import { Agent, Model, Provider } from "@opencode/plugin"; @@ -27,7 +28,7 @@ interface LocationInstance { admit: () => Effect.Effect; } const scenario = async () => { - const directory = await mkdtemp("/tmp/opencode/composite-"); + const directory = await mkdtemp(join(tmpdir(), "composite-")); directories.push(directory); const root = join(directory, "root"); const destination = join(directory, "child"); diff --git a/tests/herdr.test.ts b/tests/herdr.test.ts index f841fb1..5bc11fc 100644 --- a/tests/herdr.test.ts +++ b/tests/herdr.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { command, Herdr } from "../src/herdr.js"; @@ -10,7 +11,7 @@ afterEach(async () => { }); async function fixture() { - const temporary = await mkdtemp("/tmp/opencode/multi-work-"); + const temporary = await mkdtemp(join(tmpdir(), "multi-work-")); directories.push(temporary); const root = join(temporary, "root"); const destination = join(temporary, "returned checkout");