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
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ jobs:
packages/editor/App.archiveReadOnly.test.tsx
packages/editor/actionsLabelMode.test.ts

opencode-v2:
name: OpenCode 2 installed package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Install OpenCode 2
run: npm install --global @opencode-ai/cli@0.0.0-next-16775

- name: Build OpenCode plugin assets
run: bun run build:review && bun run build:hook && bun run build:opencode

- name: Pack OpenCode plugin
working-directory: apps/opencode-plugin
run: npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP"

- name: Run installed-package smoke
run: >-
bun run --cwd apps/opencode-plugin smoke:v2 --
"$(command -v opencode2)"
"$RUNNER_TEMP/plannotator-opencode-0.25.1.tgz"

uninstall-windows:
# Run the filesystem and path logic under real Windows path semantics.
# The tests inject temporary homes and process boundaries, so this job
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ plannotator/
│ │ └── dist/ # Built single-file apps (index.html, review.html)
│ ├── opencode-plugin/ # OpenCode plugin
│ │ ├── commands/ # Slash command stubs (review, annotate, last — plugin intercepts execution)
│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers
│ │ ├── index.ts # OpenCode 1 entry with submit_plan tool + review/annotate event handlers
│ │ ├── server.ts # OpenCode 2 adapter (experimental V2 plugin API)
│ │ ├── plannotator.html # Built plan review app
│ │ └── review-editor.html # Built code review app
│ ├── amp-plugin/ # Amp plugin
Expand Down
34 changes: 32 additions & 2 deletions apps/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ Obsidian users can auto-save approved plans to Obsidian as well. [See details](#

## Install

### OpenCode 2 beta

Install OpenCode 2 from npm's `next` tag, then add Plannotator to the V2 `plugins` field:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "@plannotator/opencode@latest",
"options": {
"workflow": "plan-agent",
"planningAgents": ["plan"]
}
}
]
}
```

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 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.

### OpenCode 1

Add to your `opencode.json`:

```json
Expand All @@ -30,15 +60,15 @@ Add to your `opencode.json`:

Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's `plan` agent, not to `build` or other primary agents.

> **Slash commands:** Run the install script to get `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last`:
> **OpenCode 1 slash commands:** Run the install script to get `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last`:
> ```bash
> curl -fsSL https://plannotator.ai/install.sh | bash
> ```
> This also clears any cached plugin versions.

## Workflow Modes

Plannotator supports four OpenCode workflows:
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.

- **`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
2 changes: 1 addition & 1 deletion apps/opencode-plugin/cli-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ export async function runCliPlanReview(input: {
planContent: string;
cwd?: string;
timeoutSeconds: number | null;
abortSignal: AbortSignal;
abortSignal?: AbortSignal;
bridge?: OpenCodeBridgeContext;
}): Promise<OpenCodePlanReviewResult> {
const result = await runPlannotatorCli({
Expand Down
4 changes: 2 additions & 2 deletions apps/opencode-plugin/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface EmbeddedPlanReviewInput {
pasteApiUrl?: string;
htmlContent: string;
timeoutSeconds: number | null;
abortSignal: AbortSignal;
abortSignal?: AbortSignal;
logReady: (url: string, isRemote: boolean, port: number) => void;
}

Expand Down Expand Up @@ -69,7 +69,7 @@ export async function deliverEmbeddedAnnotateMessagePrompt(input: {
export async function runEmbeddedPlanReview(
input: EmbeddedPlanReviewInput,
): Promise<EmbeddedPlanReviewResult> {
input.abortSignal.throwIfAborted();
input.abortSignal?.throwIfAborted();
const { startPlannotatorServer, handleServerReady } = await loadPlanServer();
const server = await startPlannotatorServer({
plan: input.planContent,
Expand Down
174 changes: 174 additions & 0 deletions apps/opencode-plugin/fixtures/v2-installed-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createServer } from "node:net";

const [opencodeBin, pluginTarball] = Bun.argv.slice(2);
if (!opencodeBin || !pluginTarball) {
throw new Error("Usage: bun fixtures/v2-installed-smoke.ts <opencode2-bin> <packed-plugin.tgz>");
}

const packageJson = JSON.parse(readFileSync(path.join(import.meta.dir, "..", "package.json"), "utf-8")) as {
name: string;
version: string;
[key: string]: unknown;
};
const root = mkdtempSync(path.join(tmpdir(), "plannotator-opencode-v2-smoke-"));
const port = await getFreePort();
const registryPort = await getFreePort();
const url = `http://127.0.0.1:${port}`;
const registryUrl = `http://127.0.0.1:${registryPort}`;
mkdirSync(path.join(root, "config"), { recursive: true });
mkdirSync(path.join(root, "data"), { recursive: true });
mkdirSync(path.join(root, "cache"), { recursive: true });

const env = {
...process.env,
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_CONFIG_CONTENT: JSON.stringify({
plugins: [{ package: `${packageJson.name}@${packageJson.version}`, options: { workflow: "plan-agent" } }],
}),
OPENCODE_LOG_LEVEL: "DEBUG",
OPENCODE_PASSWORD: "plannotator-smoke",
OPENCODE_SERVER_PASSWORD: "plannotator-smoke",
NPM_CONFIG_REGISTRY: registryUrl,
npm_config_registry: registryUrl,
};

const registry = Bun.serve({
hostname: "127.0.0.1",
port: registryPort,
async fetch(request) {
const requestUrl = new URL(request.url);
const packageName = decodeURIComponent(requestUrl.pathname.slice(1));
if (packageName === packageJson.name) {
return Response.json({
name: packageJson.name,
"dist-tags": { latest: packageJson.version },
versions: {
[packageJson.version]: {
...packageJson,
dist: { tarball: `${registryUrl}/plannotator-opencode.tgz` },
},
},
});
}
if (requestUrl.pathname === "/plannotator-opencode.tgz") {
return new Response(Bun.file(path.resolve(pluginTarball)));
}

const upstream = await fetch(`https://registry.npmjs.org${requestUrl.pathname}${requestUrl.search}`);
const headers = new Headers(upstream.headers);
headers.delete("content-encoding");
headers.delete("content-length");
return new Response(upstream.body, {
status: upstream.status,
headers,
});
},
});

const server = Bun.spawn([
opencodeBin,
"serve",
"--hostname",
"127.0.0.1",
"--port",
String(port),
], {
cwd: process.cwd(),
env,
stdout: "pipe",
stderr: "pipe",
});
const serverStdout = new Response(server.stdout).text();
const serverStderr = new Response(server.stderr).text();
let failed = false;

try {
await waitForHealthyServer(url);
const plugins = await waitForPlugin(url);
console.log(JSON.stringify(plugins));
} catch (error) {
failed = true;
throw error;
} finally {
server.kill();
await server.exited;
await registry.stop();
const stdout = await serverStdout;
const stderr = await serverStderr;
if (failed) {
console.error(stdout);
console.error(stderr);
}
if (process.env.PLANNOTATOR_KEEP_SMOKE === "1") {
console.error(`Smoke artifacts: ${root}`);
} else {
rmSync(root, { recursive: true, force: true });
}
}

async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Could not allocate a smoke-test port."));
return;
}
server.close((error) => error ? reject(error) : resolve(address.port));
});
});
}

async function waitForHealthyServer(url: string): Promise<void> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`${url}/api/health`, {
headers: authHeaders(),
});
if (response.ok) return;
} catch {
// The server has not bound yet.
}
await Bun.sleep(50);
}
throw new Error("OpenCode 2 smoke server did not become healthy.");
}

async function waitForPlugin(url: string): Promise<unknown> {
const deadline = Date.now() + 20_000;
let lastOutput = "";
while (Date.now() < deadline) {
const httpResponse = await fetch(`${url}/api/plugin`, {
headers: {
...authHeaders(),
"x-opencode-directory": encodeURIComponent(process.cwd()),
},
});
lastOutput = await httpResponse.text();
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) =>
typeof plugin === "string" ? plugin === "plannotator" : plugin.id === "plannotator"
)) return response;
await Bun.sleep(50);
}
throw new Error(`Plannotator did not activate in OpenCode 2. Last response: ${lastOutput}`);
}

function authHeaders(): Record<string, string> {
return {
Authorization: `Basic ${Buffer.from("opencode:plannotator-smoke").toString("base64")}`,
};
}
Loading