Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ Install OpenCode 2 from npm's `next` tag, then add Plannotator to the V2 `plugin

Restart OpenCode 2 and verify that `plannotator` appears in `opencode2 plugin list`.

OpenCode 2 support is experimental while its plugin API is in beta. The core `submit_plan` review flow works, but the current API has these limitations:
OpenCode 2 support is experimental while its plugin API is in beta. The core `submit_plan` review flow works everywhere. Two newer capabilities depend on which plugin API your OpenCode build ships with, and Plannotator detects both at runtime rather than requiring a particular channel:

- OpenCode 2 does not expose a native slash-command execution hook. Its command definitions expand to model prompts, so `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last` remain OpenCode 1-only instead of silently becoming model-mediated commands.
- V2 tool execution does not expose an abort signal. Cancelling a turn cannot yet stop a running review server or CLI child immediately.
- The V2 plugin context cannot switch the active session agent. Agent switching selected in the review UI is ignored with a server-log warning; switch to `build` manually after approval before implementation.
- The V2 plugin context has no TUI toast/log API, so remote session URLs are written to the server output rather than shown as a toast.
- **Slash commands.** Native command execution landed upstream in `@opencode-ai/plugin` (anomalyco/opencode issue #2185, PR #44765) and currently ships on the `beta` and `dev` dist-tags; the `next` and `latest` tags still carry the older API. Capability is detected from the command draft OpenCode hands the plugin, not from the plugin API's shape: `ctx.command.transform` exists on both generations, and only the newer draft has `add`. On a host that has it, Plannotator registers `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last` itself and runs the same machinery OpenCode 1 uses, so your raw arguments reach the CLI unchanged and nothing is routed through the model. On an older host it registers nothing and the commands run from their markdown definitions, which ask the agent to run the `plannotator` CLI and relay its output; that path works but costs a model turn and depends on the agent following the instruction.
- **Command precedence.** OpenCode activates its own config-command loader after package plugins, and the last definition to claim a name wins, so the markdown stubs the installer writes to `~/.config/opencode/commands` would otherwise shadow the native definitions on every normal install. Plannotator re-registers the three names shortly after startup so its own definitions are the ones that run. If that reclaim cannot run, the stubs keep the names and the commands still work through the model-mediated fallback.
- **Agent switching.** `ctx.session.switchAgent` arrived with the same plugin API generation. On a host that exposes it, an agent switch chosen in the review UI is applied to the session. On an older host the plan is still approved and a warning is written to the server log; switch to `build` manually before implementation.
- **Abort signal.** V2 tool execution still exposes no abort signal. Cancelling a turn cannot stop a running review server or CLI child immediately.
- **TUI toasts.** OpenCode 2 has a TUI plugin entry point, but it is separate from the server plugin Plannotator registers, so session URLs are written to the server output rather than shown as a toast. Remote sessions should read the URL from the OpenCode log.

### OpenCode 1

Expand All @@ -68,7 +69,7 @@ Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's

## Workflow Modes

The examples below use the OpenCode 1 config shape. OpenCode 2 places the same option keys under the plugin entry's `options` object shown above. In V2, `manual` intentionally registers no tool and native slash-command handlers are unavailable, so it currently leaves the integration inactive.
The examples below use the OpenCode 1 config shape. OpenCode 2 places the same option keys under the plugin entry's `options` object shown above. In V2, `manual` registers no tool, so it leaves only the slash commands: useful on a host with native command execution, inactive on one without it.

- **`plan-agent`** (default): `submit_plan` is available to OpenCode's built-in `plan` agent plus any extra agents listed in `planningAgents`. This keeps Plannotator integrated with OpenCode plan mode without nudging `build` to call it.
- **`manual`**: `submit_plan` is not registered. Use `/plannotator-last`, `/plannotator-annotate`, and `/plannotator-review` when you want Plannotator.
Expand Down
43 changes: 43 additions & 0 deletions apps/opencode-plugin/agent-switch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { supportsSwitchAgent, type V2ContextLike } from "./v2-client";

export interface OpenCodeAgentLike {
name?: string;
}
Expand Down Expand Up @@ -71,3 +73,44 @@ export async function resolveValidatedTargetAgent(input: {
warnAgentUnavailable(input.client, targetAgent, input.delivery ?? "feedback");
return undefined;
}

/**
* OpenCode 2 agent switch.
*
* `ctx.session.switchAgent` arrived with the same plugin-API generation as
* native command execution, so it is duck-typed rather than imported: on a host
* without it the plan is still approved and the caller is told the switch was
* skipped. Returns the agent actually switched to, or undefined when the
* session's agent was left alone.
*/
export async function switchV2SessionAgent(input: {
ctx: V2ContextLike;
sessionID: string;
requestedAgent?: string;
getAgents: () => Promise<OpenCodeAgentLike[]>;
warn?: (message: string) => void;
}): Promise<string | undefined> {
const warn = input.warn ?? ((message: string) => console.error(message));
const targetAgent = resolveTargetAgent(input.requestedAgent);
if (!targetAgent) return undefined;

const available = (await input.getAgents()).some((agent) => agent.name === targetAgent);
if (!available) {
warn(`[Plannotator] Configured OpenCode agent "${targetAgent}" is not available; approving the plan without switching agents.`);
return undefined;
}

if (!supportsSwitchAgent(input.ctx)) {
warn("[Plannotator] This OpenCode 2 host does not expose agent switching to plugins; approving the plan without switching agents.");
return undefined;
}

try {
await input.ctx.session!.switchAgent!({ sessionID: input.sessionID, agent: targetAgent });
} catch (error) {
warn(`[Plannotator] Could not switch the OpenCode session to "${targetAgent}": ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}

return targetAgent;
}
91 changes: 91 additions & 0 deletions apps/opencode-plugin/command-interception.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createTestEnvironment } from "../../tests/helpers/environment";
import PlannotatorPlugin from "./index";

/**
* OpenCode 1 slash-command interception.
*
* The V1 plugin clears `output.parts` IN PLACE before anything reaches the
* model. Now that the shared markdown stubs carry real instructions ("run the
* plannotator CLI and relay stdout", for OpenCode 2 hosts on the stale
* channels), a regression here would leak those instructions to the OpenCode 1
* model and re-open the #713 class: OpenCode resolves prompt parts over
* "<body> <arguments>" and auto-attaches any file path it finds, which on a
* large file blows the context before the annotation UI even opens.
*
* Interception lives on the always-built plugin object; `shouldRegisterSubmitPlan`
* only gates `plugin.tool`, so `workflow: "manual"` must intercept too.
*/

const envKeys = ["PLANNOTATOR_BIN", "PLANNOTATOR_DATA_DIR"] as const;
const environment = createTestEnvironment(envKeys, "plannotator-oc1-intercept-");

afterEach(() => environment.restore());

const COMMANDS = ["plannotator-review", "plannotator-annotate", "plannotator-last"] as const;

function makeClient() {
return {
app: {
log: async () => ({}),
agents: async () => ({ data: [] }),
},
config: { get: async () => ({ data: {} }) },
session: {
messages: async () => ({ data: [] }),
prompt: async () => ({}),
},
};
}

async function interceptionHandler(options: Record<string, unknown>) {
const plugin = await PlannotatorPlugin(
{ client: makeClient(), directory: "/project" } as never,
// "cli" keeps the embedded server out of the test; the CLI spawn then fails
// fast against the bogus PLANNOTATOR_BIN below and is swallowed by
// handleCliCommand's own catch.
{ runtime: "cli", ...options } as never,
);
return (plugin as Record<string, any>)["command.execute.before"] as (
input: Record<string, unknown>,
output: { parts: unknown[] },
) => Promise<void>;
}

describe("OpenCode 1 command interception", () => {
for (const workflow of ["plan-agent", "manual"] as const) {
for (const command of COMMANDS) {
test(`${workflow}: /${command} empties output.parts before the model sees it`, async () => {
environment.reset();
process.env.PLANNOTATOR_BIN = "/nonexistent/plannotator-interception-test";
process.env.PLANNOTATOR_DATA_DIR = environment.makeTempDir();

const handler = await interceptionHandler({ workflow });
const parts = [{ type: "text", text: "run the plannotator CLI and relay stdout" }];
const output = { parts };

await handler(
{ command, sessionID: "session-1", arguments: "" },
output,
);

expect(parts.length).toBe(0);
// Mutated in place, never reassigned: the caller holds this exact array
// and ignores anything assigned to output.parts.
expect(output.parts).toBe(parts);
});
}
}

test("an unrelated command keeps its parts untouched", async () => {
environment.reset();
process.env.PLANNOTATOR_BIN = "/nonexistent/plannotator-interception-test";
process.env.PLANNOTATOR_DATA_DIR = environment.makeTempDir();

const handler = await interceptionHandler({ workflow: "plan-agent" });
const output = { parts: [{ type: "text", text: "someone else's command" }] };
await handler({ command: "other-command", sessionID: "session-1", arguments: "" }, output);

expect(output.parts.length).toBe(1);
});
});
6 changes: 6 additions & 0 deletions apps/opencode-plugin/commands/plannotator-annotate.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
description: Open interactive annotation UI for a file, folder, or URL
---

Run `plannotator annotate $ARGUMENTS` with Bash, in the foreground, and wait for it to finish.

Relay its stdout to the user. If annotations come back, address them now. If the command reports that the arguments could not be resolved to a file, URL, or folder, work out which target the user meant and re-run it with that concrete path.

Do not ask the user to run the command themselves.
6 changes: 6 additions & 0 deletions apps/opencode-plugin/commands/plannotator-last.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
description: Annotate the last assistant message
---

Run `plannotator last $ARGUMENTS` with Bash, in the foreground, and wait for it to finish. Send no message before running it: the command targets the latest rendered assistant response, so a preamble becomes the thing being annotated.

Relay its stdout to the user and carry any returned feedback into your next response. An approval can still carry notes; treat those as guidance, not a change request.

Do not ask the user to run the command themselves.
6 changes: 6 additions & 0 deletions apps/opencode-plugin/commands/plannotator-review.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
description: Open interactive code review for current changes or a PR URL; pass --git or --gitbutler to force that provider
---

Run `plannotator review $ARGUMENTS` with Bash, in the foreground, and wait for it to finish.

Relay its stdout to the user. If it returns feedback or annotations, address them now. If it returns an approval, say the review passed and continue.

Do not ask the user to run the command themselves.
92 changes: 88 additions & 4 deletions apps/opencode-plugin/fixtures/v2-installed-smoke.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createServer } from "node:net";
import { NATIVE_COMMANDS } from "../native-commands";

const [opencodeBin, pluginTarball] = Bun.argv.slice(2);
if (!opencodeBin || !pluginTarball) {
Expand Down Expand Up @@ -36,6 +37,22 @@ mkdirSync(path.join(root, "config"), { recursive: true });
mkdirSync(path.join(root, "data"), { recursive: true });
mkdirSync(path.join(root, "cache"), { recursive: true });

// Reproduce a NORMAL install: scripts/install.sh (and the package postinstall)
// write the three markdown stubs into ~/.config/opencode/commands, which is
// exactly where OpenCode 2's own ConfigCommandPlugin scans. Without them the
// sandbox is a shape no user has, and the contest between the config-loaded
// stubs and the plugin's native definitions never happens here.
const stubsDir = path.join(root, "config", "opencode", "commands");
mkdirSync(stubsDir, { recursive: true });
const stubSource = path.join(import.meta.dir, "..", "commands");
for (const entry of readdirSync(stubSource)) {
if (entry.endsWith(".md")) copyFileSync(path.join(stubSource, entry), path.join(stubsDir, entry));
}

// Set when the host is known to ship the post-#44765 command API (the `beta` /
// `dev` channels). CI runs a `next` build, where the stubs legitimately win.
const expectNativeCommands = process.env.PLANNOTATOR_SMOKE_EXPECT_NATIVE === "1";

const env = {
...process.env,
XDG_CONFIG_HOME: path.join(root, "config"),
Expand Down Expand Up @@ -111,6 +128,7 @@ let failed = false;
try {
await waitForHealthyServer(url);
const plugins = await waitForPlugin(url);
await checkCommands(url);
console.log(JSON.stringify(plugins));
} catch (error) {
failed = true;
Expand Down Expand Up @@ -203,10 +221,32 @@ async function waitForPlugin(url: string): Promise<unknown> {
if (!httpResponse.ok) {
throw new Error(`OpenCode plugin API returned ${httpResponse.status}: ${lastOutput}`);
}
const response = JSON.parse(lastOutput) as { data?: Array<{ id?: string } | string> };
if (response.data?.some((plugin) =>
const response = JSON.parse(lastOutput) as {
data?: Array<
| {
id?: string;
status?: string;
error?: string;
state?: { status?: string; error?: string };
}
| string
>;
};
const entry = response.data?.find((plugin) =>
typeof plugin === "string" ? plugin === "plannotator" : plugin.id === "plannotator"
)) {
);
if (entry) {
// A plugin whose setup threw still LISTS here. Without this check the
// smoke passed on a plugin that took the whole command registration down
// with it, which is the exact failure a wrong capability probe produces.
// Plugin.Info carries status/error at the TOP level; `state` is read as a
// fallback only, so this keeps working whichever shape the host serves.
const info = typeof entry === "string" ? undefined : entry;
const status = info?.status ?? info?.state?.status;
if (status === "failed") {
const error = info?.error ?? info?.state?.error ?? "no error reported";
throw new Error(`Plannotator activated as failed in OpenCode 2: ${error}`);
}
console.error(`plannotator activated after ${elapsed()}`);
return response;
}
Expand All @@ -225,6 +265,50 @@ async function waitForPlugin(url: string): Promise<unknown> {
);
}

/**
* Assert the three slash commands resolve, and report WHICH definition owns
* each name.
*
* Ownership is readable from the description: the plugin's native definitions
* and the markdown stubs' frontmatter deliberately differ (pinned by
* native-commands.test.ts), so a stub description means the config-loaded
* command won the name. On a `next` host that is correct and expected; on a
* host with the post-#44765 command API it is the shadowing bug, which is what
* PLANNOTATOR_SMOKE_EXPECT_NATIVE makes fatal.
*/
async function checkCommands(url: string): Promise<void> {
const response = await fetch(`${url}/api/command`, {
headers: { ...authHeaders(), "x-opencode-directory": encodeURIComponent(process.cwd()) },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = await response.text();
if (!response.ok) throw new Error(`OpenCode command API returned ${response.status}: ${body}`);

const commands = (JSON.parse(body) as { data?: Array<{ name?: string; description?: string }> }).data ?? [];
const missing: string[] = [];
const shadowed: string[] = [];
for (const command of NATIVE_COMMANDS) {
const found = commands.find((entry) => entry.name === command.name);
if (!found) {
missing.push(command.name);
continue;
}
const native = found.description === command.description;
if (!native) shadowed.push(command.name);
console.error(`/${command.name}: ${native ? "plugin definition" : "markdown stub"}`);
}

if (missing.length > 0) {
throw new Error(`OpenCode 2 resolved no command for: ${missing.join(", ")}. Full list: ${body.slice(0, 800)}`);
}
if (expectNativeCommands && shadowed.length > 0) {
throw new Error(
`The markdown stubs shadowed the plugin's native definitions for: ${shadowed.join(", ")}. ` +
"The command names must be reclaimed after OpenCode's ConfigCommandPlugin activates.",
);
}
}

// A stuck teardown used to turn a failing smoke into a multi-minute CI wall-clock burn that
// hid the diagnostics behind the job timeout. Every step here is bounded and escalates.
async function shutdown(): Promise<void> {
Expand Down
22 changes: 19 additions & 3 deletions apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ function readBundledHtml(filename: string): string {
return readFileSync(resolveBundledHtmlPath(filename), "utf-8");
}

/** Best-effort warm of the sync cache. Never throws, on any failure. */
function preloadBundledHtml(filename: string, assign: (html: string) => void): void {
try {
readFile(resolveBundledHtmlPath(filename), "utf-8").then(assign).catch(() => {});
} catch {
// The asset is not on disk. The lazy getters raise a clear error if and
// when a code path actually needs it.
}
}

function getPlanHtml(): string {
if (!_planHtml) _planHtml = readBundledHtml("plannotator.html");
return _planHtml;
Expand Down Expand Up @@ -234,9 +244,15 @@ async function runPlanReview(input: {
const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpenCodeOptions) => {
const workflowOptions = normalizeWorkflowOptions(rawOptions);

// Preload HTML in background — populates the sync cache before first use
readFile(resolveBundledHtmlPath("plannotator.html"), "utf-8").then(h => { _planHtml = h; }).catch(() => {});
readFile(resolveBundledHtmlPath("review-editor.html"), "utf-8").then(h => { _reviewHtml = h; }).catch(() => {});
// Preload HTML in background: populates the sync cache before first use.
// `resolveBundledHtmlPath` THROWS when the asset is absent, and it runs
// synchronously here, outside the .catch that was meant to absorb exactly
// that. An unbuilt checkout (or a partial install) therefore took down plugin
// construction itself, before any code path that needs the HTML. A missing
// asset must only fail the feature that reads it, which is what the lazy
// getters already do.
preloadBundledHtml("plannotator.html", (html) => { _planHtml = html; });
preloadBundledHtml("review-editor.html", (html) => { _reviewHtml = html; });

let cachedAgents: any[] | null = null;

Expand Down
Loading