Skip to content
Closed
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
89 changes: 82 additions & 7 deletions src/adapters/opencode/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

import { dirname, resolve, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";

import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
import { extractEvents, extractUserEvents, parseOpencodeUsage, buildAgentUsageEvent } from "../../session/extract.js";
Expand Down Expand Up @@ -833,8 +833,47 @@ async function createContextModePlugin(ctx: PluginContext) {
// session-attribution path in `tool.execute.after` should be exercised
// against a live OpenCode 2 agent turn before release.

/** Register context-mode's ctx_* tools through the V2 tool-transform API. */
async function registerNativeToolsV2(ctx: any, projectDir: string): Promise<void> {
/**
* Register context-mode's ctx_* tools through the V2 tool-transform API.
*
* Two V2-specific details, both observed on OpenCode 2.0.7:
* - Tools added via `editor.add` default to `codemode: true`, which hides them
* from the model's direct tool list: they are only reachable by writing code
* against the `execute` tool's catalog. Routing enforcement tells the model
* to *call* these tools directly, so they are registered with
* `codemode: false`.
* - Their model-visible names must equal `toolNamer(name)` — the names the
* routing block and the `execute.before` redirect messages use — so the
* guidance the model receives always names a tool that exists. OpenCode 2
* composes the visible name as `<namespace>_<name>`, so a namer of the form
* `<prefix>_<tool>` maps onto its native `namespace` option (see
* `v2ToolIdentity`); any other namer shape is used verbatim as the name.
*/
/**
* Split a routed tool name into OpenCode 2's `{ name, namespace }` so the
* host-composed `<namespace>_<name>` equals exactly what the router tells the
* model to call. Exported for tests.
*/
export function v2ToolIdentity(
bareTool: string,
toolNamer: (bareTool: string) => string,
): { name: string; namespace?: string } {
const routed = toolNamer(bareTool);
const suffix = `_${bareTool}`;
if (routed.endsWith(suffix)) {
const namespace = routed.slice(0, -suffix.length);
// Only a clean `<prefix>_<tool>` shape; anything else (e.g. `mcp__x__tool`)
// is registered verbatim so the host never sees an odd namespace.
if (namespace && !namespace.endsWith("_")) return { name: bareTool, namespace };
}
return { name: routed };
}

async function registerNativeToolsV2(
ctx: any,
projectDir: string,
toolNamer: (bareTool: string) => string,
): Promise<void> {
const mod = await loadCtxToolRegistry();
const zod4 = await import("zod/v4");

Expand All @@ -851,10 +890,14 @@ async function registerNativeToolsV2(ctx: any, projectDir: string): Promise<void
const v4Shape = zod3ShapeToV4(shape) as Record<string, InstanceType<typeof zod4.ZodType>>;
const jsonSchema = zod4.toJSONSchema(zod4.object(v4Shape));

const identity = v2ToolIdentity(registered.name, toolNamer);
editor.add({
name: registered.name,
name: identity.name,
description: String(config.description ?? ""),
input: jsonSchema,
options: identity.namespace
? { namespace: identity.namespace, codemode: false }
: { codemode: false },
async execute(input: Record<string, unknown>) {
let parsedArgs: Record<string, unknown> = input ?? {};
if (typeof inputSchema?.parse === "function") {
Expand Down Expand Up @@ -891,15 +934,44 @@ async function registerNativeToolsV2(ctx: any, projectDir: string): Promise<void
});
}

/**
* Mark this process as a ready context-mode tool provider.
*
* Routing (hooks/core/routing.mjs) redirects curl/wget and large-output
* commands only when `isMCPReady()` finds a live readiness sentinel. The
* stdio MCP server writes one from its main(); with native V2 tools there is
* no MCP server, so without this the redirects silently depend on some
* unrelated context-mode MCP process (another client's) being alive. Mirrors
* the server's sentinel: this process's PID, refreshed every 30s (the reader's
* freshness window is 90s), removed on dispose.
*/
async function startReadinessSentinel(): Promise<() => void> {
const buildDir = dirname(fileURLToPath(import.meta.url));
const mcpReadyPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "mcp-ready.mjs");
const { sentinelPathForPid } = await import(pathToFileURL(mcpReadyPath).href);
const sentinel: string = sentinelPathForPid(process.pid);
const write = () => {
try { writeFileSync(sentinel, String(process.pid)); } catch { /* best effort */ }
};
write();
const refresh = setInterval(write, 30_000);
refresh.unref();
return () => {
clearInterval(refresh);
try { unlinkSync(sentinel); } catch { /* best effort */ }
};
}

/** V2 `Plugin.define({ id, setup })` body. `ctx` is the OpenCode 2 plugin context. */
async function setupContextModePluginV2(ctx: any): Promise<() => void> {
const directory = ctx?.location?.directory ?? process.cwd();
const { platform, routing, autoInjectionMod, routingBlock, projectDir, db } =
const { platform, routing, autoInjectionMod, toolNamer, routingBlock, projectDir, db } =
await createContextModeRuntime(directory);

const captureAgentsMd = makeAgentsMdCapture(projectDir, db);

await registerNativeToolsV2(ctx, projectDir);
await registerNativeToolsV2(ctx, projectDir, toolNamer);
const stopReadinessSentinel = await startReadinessSentinel();

// ── tool.execute.before → routing enforcement ──────────
await ctx.tool.hook("execute.before", (event: any) => {
Expand Down Expand Up @@ -1072,7 +1144,10 @@ async function setupContextModePluginV2(ctx: any): Promise<() => void> {
}
});

return () => usageController.abort();
return () => {
usageController.abort();
stopReadinessSentinel();
};
}

// ── Exports ──────────────────────────────────────────────
Expand Down
118 changes: 118 additions & 0 deletions tests/opencode-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,124 @@ describe("ContextModePlugin", () => {
const mod = await import("../src/adapters/opencode/plugin.js");
expect(typeof (mod.default as any).setup).toBe("function");
});

// Runs the real V2 setup() against a minimal fake OpenCode 2 context and
// records what it registers. Two things only a live host exposed:
// - `editor.add` defaults to `codemode: true`, which keeps a tool out of the
// model's direct tool list (reachable only through the `execute` tool's
// catalog), while routing enforcement tells the model to call ctx_* tools
// directly;
// - the redirect messages and routing block name tools via
// `createToolNamer(platform)`, so registration must use the same names or
// the model is pointed at tools that do not exist.
it("V2 setup() registers ctx_* tools as direct tools under the routed names", async () => {
await import("@opencode/plugin");
const mod = await import("../src/adapters/opencode/plugin.js");
const { createToolNamer } = await import("../hooks/core/tool-naming.mjs");
const namer = createToolNamer("opencode");

type Added = { name: string; options?: { codemode?: boolean; namespace?: string } };
const added: Added[] = [];
const controller = new AbortController();
const ctx = {
location: { directory: tempDir },
tool: {
transform: async (fn: (editor: unknown) => unknown) => {
await fn({ add: (tool: Added) => added.push(tool) });
},
hook: async () => {},
},
session: { hook: async () => {} },
event: {
subscribe: () => ({
async *[Symbol.asyncIterator]() {
await new Promise((r) => controller.signal.addEventListener("abort", r, { once: true }));
},
}),
},
};

const dispose = await (mod.default as any).setup(ctx);
try {
expect(added.length).toBeGreaterThan(0);
for (const tool of added) {
expect(tool.options?.codemode).toBe(false);
}
// OpenCode 2 shows `<namespace>_<name>` to the model: that visible name
// must be exactly the routed name for every registered tool.
for (const tool of added) {
const visible = tool.options?.namespace ? `${tool.options.namespace}_${tool.name}` : tool.name;
expect(tool.name.startsWith("ctx_")).toBe(true);
expect(visible).toBe(namer(tool.name));
}
expect(added.map((t) => t.name)).toContain("ctx_execute");
} finally {
controller.abort();
if (typeof dispose === "function") await dispose();
}
});
});

// Routing (hooks/core/routing.mjs) only redirects curl/wget/large output when
// isMCPReady() finds a live readiness sentinel -- written by the stdio MCP
// server's main(). With native V2 tools there is no MCP server, so unless the
// plugin marks itself ready, routing silently depends on some unrelated
// context-mode MCP process (e.g. another client's) happening to be alive.
describe("OpenCode 2 readiness sentinel", () => {
it("V2 setup() writes a readiness sentinel for its own process and removes it on dispose", async () => {
const sentinelDir = mkdtempSync(join(tmpdir(), "cm-v2-sentinel-"));
const prev = process.env.CONTEXT_MODE_MCP_SENTINEL_DIR;
process.env.CONTEXT_MODE_MCP_SENTINEL_DIR = sentinelDir;
const sentinel = join(sentinelDir, `context-mode-mcp-ready-${process.pid}`);
const controller = new AbortController();
try {
await import("@opencode/plugin");
const mod = await import("../src/adapters/opencode/plugin.js");
const ctx = {
location: { directory: tempDir },
tool: { transform: async (fn: (e: unknown) => unknown) => { await fn({ add: () => {} }); }, hook: async () => {} },
session: { hook: async () => {} },
event: {
subscribe: () => ({
async *[Symbol.asyncIterator]() {
await new Promise((r) => controller.signal.addEventListener("abort", r, { once: true }));
},
}),
},
};
expect(existsSync(sentinel)).toBe(false);
const dispose = await (mod.default as any).setup(ctx);
expect(existsSync(sentinel)).toBe(true);
const { isMCPReady } = await import("../hooks/core/mcp-ready.mjs");
expect(isMCPReady()).toBe(true);
controller.abort();
await dispose();
expect(existsSync(sentinel)).toBe(false);
} finally {
controller.abort();
if (prev === undefined) delete process.env.CONTEXT_MODE_MCP_SENTINEL_DIR;
else process.env.CONTEXT_MODE_MCP_SENTINEL_DIR = prev;
rmSync(sentinelDir, { recursive: true, force: true });
}
});
});

describe("v2ToolIdentity", () => {
it("maps a <prefix>_<tool> namer onto OpenCode 2's namespace option", async () => {
const { v2ToolIdentity } = await import("../src/adapters/opencode/plugin.js");
expect(v2ToolIdentity("ctx_execute", (t) => `context-mode_${t}`)).toEqual({
name: "ctx_execute",
namespace: "context-mode",
});
});

it("uses any other namer shape verbatim, with no namespace", async () => {
const { v2ToolIdentity } = await import("../src/adapters/opencode/plugin.js");
expect(v2ToolIdentity("ctx_execute", (t) => t)).toEqual({ name: "ctx_execute" });
expect(v2ToolIdentity("ctx_execute", (t) => `mcp__context-mode__${t}`)).toEqual({
name: "mcp__context-mode__ctx_execute",
});
});
});

// ── Factory ───────────────────────────────────────────
Expand Down