From 3158d42b690ee6d6ab6c7144bba84397d2d7a057 Mon Sep 17 00:00:00 2001 From: Shadrous Date: Sat, 3 Oct 2026 18:25:28 +0300 Subject: [PATCH 1/3] Port OpenCode plugin entrypoint to V2 API (@opencode/plugin 2.0.22) - Replace V1 function export with Plugin.define({id, setup}) - Register annotate_plan via ctx.tool.transform with JSON Schema input - Inject plan instructions via ctx.session.hook(context) - Resolve current agent via ctx.session.get + ctx.agent.list - Handoff via ctx.session.switchAgent; cleanup via setup return - Preserve bridge/config/server UI logic unchanged --- opencode/index.js | 313 ++++++++++++++++++++++------------------- opencode/index.test.ts | 130 +++++++++++++---- package.json | 2 +- 3 files changed, 274 insertions(+), 171 deletions(-) diff --git a/opencode/index.js b/opencode/index.js index 3dd33b5..2fd9f31 100644 --- a/opencode/index.js +++ b/opencode/index.js @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { Plugin } from "@opencode/plugin"; import { runPlanReview } from "./bridge.js"; import { resolveImplementationHandoff } from "./config.js"; @@ -63,13 +63,8 @@ Tool guard before calling \`annotate_plan\`: - assert \`plan_status != approved || explicit_replan == true\` - if an assertion fails, continue execution without submitting a new plan.`; -const IMPLEMENTATION_PROMPT = [ - "Plan review status: plan_status=approved.", - "State transition: next_state=EXECUTION.", - "Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan.", - "Execute the approved plan directly now — write code, create files, and make changes.", - "Do not call `annotate_plan` again unless the user explicitly requests re-planning.", -].join(" "); +const IMPLEMENTATION_AGENT_FALLBACK_NOTE = + "Plan review status: plan_status=approved. Execute the approved plan directly now."; function getErrorMessage(error) { if (error instanceof Error && error.message) { @@ -78,171 +73,199 @@ function getErrorMessage(error) { return "unknown error"; } -/** @type {import("@opencode-ai/plugin").Plugin} */ -export const OpenPlanAnnotatorPlugin = async (ctx) => { - const implementationHandoff = await resolveImplementationHandoff(ctx.directory); - const implementationAgent = implementationHandoff.enabled ? implementationHandoff.agent : undefined; +function getAgentList(agentsResponse) { + if (Array.isArray(agentsResponse)) { + return agentsResponse; + } + if (agentsResponse && Array.isArray(agentsResponse.data)) { + return agentsResponse.data; + } + return undefined; +} + +async function getCurrentSessionAgent(ctx, sessionID) { + if (!sessionID) { + return undefined; + } - async function getCurrentUserAgent(sessionID) { - if (!sessionID) { - return undefined; + try { + const session = await ctx.session.get({ sessionID }); + return session?.agent; + } catch { + return undefined; + } +} + +async function shouldInjectPlanReviewInstructions(ctx, implementationAgent, sessionID) { + if (!sessionID) { + return true; + } + + try { + const currentAgent = await getCurrentSessionAgent(ctx, sessionID); + if (!currentAgent) { + return true; } - const response = await ctx.client.session.messages({ - path: { id: sessionID }, - }); + if (implementationAgent && currentAgent === implementationAgent) { + return false; + } - const messages = response?.data; - if (!Array.isArray(messages)) { - return undefined; + const agentsResponse = await ctx.agent.list(); + const agents = getAgentList(agentsResponse); + if (!Array.isArray(agents)) { + return true; } - for (let i = messages.length - 1; i >= 0; i -= 1) { - const message = messages[i]; - if (message?.info?.role === "user") { - return message?.info?.agent; - } + const agent = agents.find((candidate) => candidate?.name === currentAgent || candidate?.id === currentAgent); + if (agent?.mode === "subagent") { + return false; } + } catch { + return true; + } - return undefined; + return true; +} + +async function handoffToImplementationAgent(ctx, implementationAgent, sessionID) { + if (!implementationAgent) { + return null; } - async function shouldInjectPlanReviewInstructions(sessionID) { - if (!sessionID) { - return true; - } + if (!sessionID) { + return { + agent: implementationAgent, + warning: "Could not auto-switch because the current session ID was unavailable.", + }; + } - try { - const currentAgent = await getCurrentUserAgent(sessionID); - if (!currentAgent) { - return true; - } + try { + await ctx.session.switchAgent({ sessionID, agent: implementationAgent }); + return { agent: implementationAgent }; + } catch (error) { + return { + agent: implementationAgent, + warning: `Could not auto-switch to \`${implementationAgent}\`: ${getErrorMessage(error)}`, + }; + } +} - if (implementationAgent && currentAgent === implementationAgent) { - return false; +function getSystemText(system) { + if (!Array.isArray(system)) { + return ""; + } + return system + .map((part) => { + if (typeof part === "string") { + return part; } - - const response = await ctx.client.app.agents({ - query: { directory: ctx.directory }, - }); - const agents = response?.data; - if (!Array.isArray(agents)) { - return true; + if (part && typeof part.text === "string") { + return part.text; } + return ""; + }) + .join("\n") + .toLowerCase(); +} - const agent = agents.find((candidate) => candidate.name === currentAgent); - if (agent?.mode === "subagent") { - return false; - } - } catch { - return true; - } +/** + * Execute the annotate_plan tool and return plain-text instructions for the agent. + * Exported for tests. + */ +export async function executeAnnotatePlan(ctx, implementationAgent, args, toolContext) { + const result = await runPlanReview({ + plan: args.plan, + sessionId: toolContext?.sessionID, + cwd: ctx.location.directory, + }); - return true; - } + const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested."); - async function handoffToImplementationAgent(sessionID) { - if (!implementationAgent) { - return null; + if (result.approved) { + const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; + + if (args.summary) { + lines.push(`Summary: ${args.summary}`); } - if (!sessionID) { - return { - agent: implementationAgent, - warning: "Could not auto-switch because the current session ID was unavailable.", - }; + const handoffResult = await handoffToImplementationAgent(ctx, implementationAgent, toolContext?.sessionID); + if (handoffResult) { + if (handoffResult.warning) { + lines.push(`Auto-switch warning: ${handoffResult.warning}`); + } else { + lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`); + } + } else if (!implementationAgent) { + lines.push(IMPLEMENTATION_AGENT_FALLBACK_NOTE); } - try { - await ctx.client.session.prompt({ - path: { id: sessionID }, - body: { - agent: implementationAgent, - noReply: true, - parts: [{ type: "text", text: IMPLEMENTATION_PROMPT }], - }, - }); + lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan."); + lines.push("Execute the approved plan directly now — write code, create files, and make changes."); + lines.push("Do not call `annotate_plan` again unless the user explicitly requests re-planning."); - return { agent: implementationAgent }; - } catch (error) { - return { - agent: implementationAgent, - warning: `Could not auto-switch to \`${implementationAgent}\`: ${getErrorMessage(error)}`, - }; - } + return lines.join("\n\n"); } - return { - "experimental.chat.system.transform": async (input, output) => { - const currentSystem = output.system.join("\n").toLowerCase(); - if (currentSystem.includes("title generator") || currentSystem.includes("generate a concise title")) { - return; - } + return [ + "Plan review status: plan_status=rejected.", + "State transition: next_state=PLAN_DRAFT.", + "", + "## User feedback", + "", + feedback, + "", + "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", + ].join("\n"); +} - if (!currentSystem.includes("annotate_plan")) { - const shouldInject = await shouldInjectPlanReviewInstructions(input?.sessionID); - if (shouldInject) { - output.system.push(PLAN_REVIEW_INSTRUCTIONS); - } - } - }, +export { PLAN_REVIEW_INSTRUCTIONS }; - tool: { - annotate_plan: tool({ +export default Plugin.define({ + id: "open-plan-annotator", + async setup(ctx) { + const implementationHandoff = await resolveImplementationHandoff(ctx.location.directory); + const implementationAgent = implementationHandoff.enabled ? implementationHandoff.agent : undefined; + + await ctx.tool.transform((editor) => { + editor.add({ + name: "annotate_plan", description: "Submit a markdown plan for interactive user review. Returns plain-text execution or revision instructions for the agent.", - - args: { - plan: tool.schema.string().describe("The complete implementation plan in markdown format"), - summary: tool.schema.string().optional().describe("Optional one-line plan summary"), + input: { + type: "object", + properties: { + plan: { + type: "string", + description: "The complete implementation plan in markdown format", + }, + summary: { + type: "string", + description: "Optional one-line plan summary", + }, + }, + required: ["plan"], + additionalProperties: false, }, - - async execute(args, context) { - const result = await runPlanReview({ - plan: args.plan, - sessionId: context.sessionID, - cwd: ctx.directory, - }); - - const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested."); - - if (result.approved) { - const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; - - if (args.summary) { - lines.push(`Summary: ${args.summary}`); - } - - const handoffResult = await handoffToImplementationAgent(context.sessionID); - if (handoffResult) { - if (handoffResult.warning) { - lines.push(`Auto-switch warning: ${handoffResult.warning}`); - } else { - lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`); - } - } - - lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan."); - lines.push("Execute the approved plan directly now — write code, create files, and make changes."); - lines.push("Do not call `annotate_plan` again unless the user explicitly requests re-planning."); - - return lines.join("\n\n"); - } - - return [ - "Plan review status: plan_status=rejected.", - "State transition: next_state=PLAN_DRAFT.", - "", - "## User feedback", - "", - feedback, - "", - "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", - ].join("\n"); + async execute(input, toolContext) { + const text = await executeAnnotatePlan(ctx, implementationAgent, input, toolContext); + return { content: text }; }, - }), - }, - }; -}; + }); + }); + + await ctx.session.hook("context", async (event) => { + if (getSystemText(event.system).includes("annotate_plan")) { + return; + } + const shouldInject = await shouldInjectPlanReviewInstructions(ctx, implementationAgent, event.sessionID); + if (shouldInject) { + event.system.push({ type: "text", text: PLAN_REVIEW_INSTRUCTIONS }); + } + }); -export default OpenPlanAnnotatorPlugin; + // runPlanReviewBinary spawns a short-lived detached binary per tool call + // and unrefs it once hook output is parsed; nothing persistent to clean up. + return () => {}; + }, +}); diff --git a/opencode/index.test.ts b/opencode/index.test.ts index 22063b0..daeda4b 100644 --- a/opencode/index.test.ts +++ b/opencode/index.test.ts @@ -4,51 +4,131 @@ afterEach(() => { mock.restore(); }); +function createMockEditor() { + const tools = new Map(); + return { + tools, + add(tool) { + tools.set(tool.name, tool); + }, + update() {}, + remove() {}, + }; +} + function createPluginContext() { + const editor = createMockEditor(); + const hooks = new Map(); return { - directory: process.cwd(), - client: { + ctx: { + location: { directory: process.cwd() }, + options: {}, + tool: { + transform: async (callback) => { + callback(editor); + return { dispose: async () => {} }; + }, + list: async () => [], + reload: async () => {}, + }, session: { - messages: async () => ({ data: [] }), - prompt: async () => ({ data: null }), + get: async () => ({ id: "session-1", agent: "plan" }), + switchAgent: async () => {}, + prompt: async () => ({ id: "inbox-1" }), + hook: async (name, callback) => { + hooks.set(name, callback); + return { dispose: async () => {} }; + }, }, - app: { - agents: async () => ({ data: [] }), + agent: { + list: async () => ({ data: [] }), + get: async () => ({ data: undefined }), }, }, + editor, + hooks, }; } -describe("annotate_plan tool output", () => { - test("returns plain text execution instructions after approval", async () => { +async function loadPluginWithBridge(runPlanReviewImpl, suffix) { + mock.module("./bridge.js", () => ({ + runPlanReview: runPlanReviewImpl, + })); + + const mod = await import(`./index.js?${suffix}-${Date.now()}`); + return mod; +} + +describe("annotate_plan tool output (V2)", () => { + test("default export is a V2 plugin definition object", async () => { mock.module("./bridge.js", () => ({ runPlanReview: async () => ({ approved: true }), })); + const mod = await import(`./index.js?shape-${Date.now()}`); + expect(typeof mod.default).toBe("object"); + expect(mod.default.id).toBe("open-plan-annotator"); + expect(typeof mod.default.setup).toBe("function"); + }); - const { OpenPlanAnnotatorPlugin } = await import(`./index.js?approved-${Date.now()}`); - const plugin = await OpenPlanAnnotatorPlugin(createPluginContext()); + test("returns plain text execution instructions after approval", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "approved"); + const { ctx, editor } = createPluginContext(); + const cleanup = await mod.default.setup(ctx); + expect(typeof cleanup === "function" || cleanup === undefined).toBe(true); - const result = await plugin.tool.annotate_plan.execute({ plan: "# Plan" }, { sessionID: "session-1" }); + const tool = editor.tools.get("annotate_plan"); + expect(tool).toBeDefined(); + const result = await tool.execute({ plan: "# Plan" }, { sessionID: "session-1" }); - expect(typeof result).toBe("string"); - expect(result).toContain("plan_status=approved"); - expect(result).toContain("next_state=EXECUTION"); - expect(result).toContain("Do not call `annotate_plan` again"); + const content = typeof result === "string" ? result : (result.content ?? ""); + expect(content).toContain("plan_status=approved"); + expect(content).toContain("next_state=EXECUTION"); + expect(content).toContain("Do not call `annotate_plan` again"); }); test("returns plain text revision instructions after rejection", async () => { - mock.module("./bridge.js", () => ({ - runPlanReview: async () => ({ approved: false, feedback: "Need rollback steps." }), - })); + const mod = await loadPluginWithBridge( + async () => ({ approved: false, feedback: "Need rollback steps." }), + "rejected", + ); + const { ctx, editor } = createPluginContext(); + await mod.default.setup(ctx); + + const tool = editor.tools.get("annotate_plan"); + const result = await tool.execute({ plan: "# Plan" }, { sessionID: "session-2" }); - const { OpenPlanAnnotatorPlugin } = await import(`./index.js?rejected-${Date.now()}`); - const plugin = await OpenPlanAnnotatorPlugin(createPluginContext()); + const content = typeof result === "string" ? result : (result.content ?? ""); + expect(content).toContain("plan_status=rejected"); + expect(content).toContain("next_state=PLAN_DRAFT"); + expect(content).toContain("Need rollback steps."); + }); + + test("injects plan review instructions via session context hook", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "hook"); + const { ctx, hooks } = createPluginContext(); + await mod.default.setup(ctx); + + const hook = hooks.get("context"); + expect(hook).toBeDefined(); + const event = { sessionID: "session-1", system: [] }; + await hook(event); + expect(event.system.length).toBe(1); + expect(event.system[0].text).toContain("annotate_plan"); + + // does not duplicate when already present + await hook(event); + expect(event.system.length).toBe(1); + }); - const result = await plugin.tool.annotate_plan.execute({ plan: "# Plan" }, { sessionID: "session-2" }); + test("skips injection for the implementation agent", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "skip-handoff"); + const { ctx, hooks } = createPluginContext(); + ctx.session.get = async () => ({ id: "s", agent: "build" }); + await mod.default.setup(ctx); - expect(typeof result).toBe("string"); - expect(result).toContain("plan_status=rejected"); - expect(result).toContain("next_state=PLAN_DRAFT"); - expect(result).toContain("Need rollback steps."); + const hook = hooks.get("context"); + const event = { sessionID: "s", system: [] }; + await hook(event); + expect(event.system.length).toBe(0); }); }); diff --git a/package.json b/package.json index c4e13d2..6d57e20 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260511.1" }, "dependencies": { - "@opencode-ai/plugin": "^1.14.48", + "@opencode/plugin": "2.0.22", "typebox": "^1.1.38" }, "optionalDependencies": { From ca5b5e0712a57d6472207af80ee0a642643c1438 Mon Sep 17 00:00:00 2001 From: Shadrous Date: Sat, 3 Oct 2026 22:15:53 +0300 Subject: [PATCH 2/3] Add root index.js re-export for V2 directory loading --- index.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 index.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..07cae12 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +export { default } from "./opencode/index.js"; From 24df8de831668ebc10604e00c21d37cde1a04173 Mon Sep 17 00:00:00 2001 From: ndom91 Date: Sat, 17 Oct 2026 12:52:49 +0100 Subject: [PATCH 3/3] Complete OpenCode v2 plugin support --- README.md | 1 + index.js | 2 +- opencode/index.js | 233 +++++++++++++++++++++++++------- opencode/index.test.ts | 48 +++++-- package.json | 4 +- scripts/check-package-files.mjs | 2 + server.js | 1 + 7 files changed, 227 insertions(+), 64 deletions(-) create mode 100644 server.js diff --git a/README.md b/README.md index 752e4da..03a8ecd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ What you get: - optional implementation-agent handoff after approval Restart OpenCode after installing or updating so it reloads the package-managed runtime. +OpenCode 1.x and 2.x are both supported. ### Pi diff --git a/index.js b/index.js index 07cae12..e59e2f2 100644 --- a/index.js +++ b/index.js @@ -1 +1 @@ -export { default } from "./opencode/index.js"; +export { OpenPlanAnnotatorPluginV2 as default } from "./opencode/index.js"; diff --git a/opencode/index.js b/opencode/index.js index 2fd9f31..6dbebb6 100644 --- a/opencode/index.js +++ b/opencode/index.js @@ -1,4 +1,4 @@ -import { Plugin } from "@opencode/plugin"; +import { tool } from "@opencode-ai/plugin"; import { runPlanReview } from "./bridge.js"; import { resolveImplementationHandoff } from "./config.js"; @@ -63,9 +63,15 @@ Tool guard before calling \`annotate_plan\`: - assert \`plan_status != approved || explicit_replan == true\` - if an assertion fails, continue execution without submitting a new plan.`; -const IMPLEMENTATION_AGENT_FALLBACK_NOTE = - "Plan review status: plan_status=approved. Execute the approved plan directly now."; +const IMPLEMENTATION_PROMPT = [ + "Plan review status: plan_status=approved.", + "State transition: next_state=EXECUTION.", + "Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan.", + "Execute the approved plan directly now — write code, create files, and make changes.", + "Do not call `annotate_plan` again unless the user explicitly requests re-planning.", +].join(" "); +/** @param {unknown} error */ function getErrorMessage(error) { if (error instanceof Error && error.message) { return error.message; @@ -73,51 +79,24 @@ function getErrorMessage(error) { return "unknown error"; } -function getAgentList(agentsResponse) { - if (Array.isArray(agentsResponse)) { - return agentsResponse; - } - if (agentsResponse && Array.isArray(agentsResponse.data)) { - return agentsResponse.data; - } - return undefined; -} - -async function getCurrentSessionAgent(ctx, sessionID) { - if (!sessionID) { - return undefined; - } - - try { - const session = await ctx.session.get({ sessionID }); - return session?.agent; - } catch { - return undefined; - } -} - -async function shouldInjectPlanReviewInstructions(ctx, implementationAgent, sessionID) { - if (!sessionID) { +/** + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {{ agent?: string }} event + */ +async function shouldInjectPlanReviewInstructions(ctx, implementationAgent, event) { + const currentAgent = event.agent; + if (!currentAgent) { return true; } try { - const currentAgent = await getCurrentSessionAgent(ctx, sessionID); - if (!currentAgent) { - return true; - } - if (implementationAgent && currentAgent === implementationAgent) { return false; } const agentsResponse = await ctx.agent.list(); - const agents = getAgentList(agentsResponse); - if (!Array.isArray(agents)) { - return true; - } - - const agent = agents.find((candidate) => candidate?.name === currentAgent || candidate?.id === currentAgent); + const agent = agentsResponse.data.find((candidate) => candidate.name === currentAgent); if (agent?.mode === "subagent") { return false; } @@ -128,6 +107,11 @@ async function shouldInjectPlanReviewInstructions(ctx, implementationAgent, sess return true; } +/** + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {string | undefined} sessionID + */ async function handoffToImplementationAgent(ctx, implementationAgent, sessionID) { if (!implementationAgent) { return null; @@ -151,6 +135,7 @@ async function handoffToImplementationAgent(ctx, implementationAgent, sessionID) } } +/** @param {unknown} system */ function getSystemText(system) { if (!Array.isArray(system)) { return ""; @@ -170,8 +155,11 @@ function getSystemText(system) { } /** - * Execute the annotate_plan tool and return plain-text instructions for the agent. - * Exported for tests. + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {{ plan: string, summary?: string }} args + * @param {{ sessionID?: string } | undefined} toolContext + * @returns {Promise} */ export async function executeAnnotatePlan(ctx, implementationAgent, args, toolContext) { const result = await runPlanReview({ @@ -196,10 +184,7 @@ export async function executeAnnotatePlan(ctx, implementationAgent, args, toolCo } else { lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`); } - } else if (!implementationAgent) { - lines.push(IMPLEMENTATION_AGENT_FALLBACK_NOTE); } - lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan."); lines.push("Execute the approved plan directly now — write code, create files, and make changes."); lines.push("Do not call `annotate_plan` again unless the user explicitly requests re-planning."); @@ -221,7 +206,8 @@ export async function executeAnnotatePlan(ctx, implementationAgent, args, toolCo export { PLAN_REVIEW_INSTRUCTIONS }; -export default Plugin.define({ +/** @type {import("@opencode/plugin").Plugin.Plugin} */ +export const OpenPlanAnnotatorPluginV2 = { id: "open-plan-annotator", async setup(ctx) { const implementationHandoff = await resolveImplementationHandoff(ctx.location.directory); @@ -258,14 +244,163 @@ export default Plugin.define({ if (getSystemText(event.system).includes("annotate_plan")) { return; } - const shouldInject = await shouldInjectPlanReviewInstructions(ctx, implementationAgent, event.sessionID); + const shouldInject = await shouldInjectPlanReviewInstructions(ctx, implementationAgent, event); if (shouldInject) { event.system.push({ type: "text", text: PLAN_REVIEW_INSTRUCTIONS }); } }); - // runPlanReviewBinary spawns a short-lived detached binary per tool call - // and unrefs it once hook output is parsed; nothing persistent to clean up. return () => {}; }, -}); +}; + +/** @type {import("@opencode-ai/plugin").Plugin} */ +export const OpenPlanAnnotatorPlugin = async (ctx) => { + const implementationHandoff = await resolveImplementationHandoff(ctx.directory); + const implementationAgent = implementationHandoff.enabled ? implementationHandoff.agent : undefined; + + async function getCurrentUserAgent(sessionID) { + if (!sessionID) { + return undefined; + } + + const response = await ctx.client.session.messages({ + path: { id: sessionID }, + }); + + const messages = response?.data; + if (!Array.isArray(messages)) { + return undefined; + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.info?.role === "user") { + return message.info.agent; + } + } + + return undefined; + } + + async function shouldInjectInstructions(sessionID) { + if (!sessionID) { + return true; + } + + try { + const currentAgent = await getCurrentUserAgent(sessionID); + if (!currentAgent) { + return true; + } + + if (implementationAgent && currentAgent === implementationAgent) { + return false; + } + + const response = await ctx.client.app.agents({ + query: { directory: ctx.directory }, + }); + const agent = response?.data?.find((candidate) => candidate.name === currentAgent); + return agent?.mode !== "subagent"; + } catch { + return true; + } + } + + async function handoff(sessionID) { + if (!implementationAgent) { + return null; + } + + if (!sessionID) { + return { + agent: implementationAgent, + warning: "Could not auto-switch because the current session ID was unavailable.", + }; + } + + try { + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + agent: implementationAgent, + noReply: true, + parts: [{ type: "text", text: IMPLEMENTATION_PROMPT }], + }, + }); + return { agent: implementationAgent }; + } catch (error) { + return { + agent: implementationAgent, + warning: `Could not auto-switch to \`${implementationAgent}\`: ${getErrorMessage(error)}`, + }; + } + } + + return { + "experimental.chat.system.transform": async (input, output) => { + const currentSystem = output.system.join("\n").toLowerCase(); + if (currentSystem.includes("title generator") || currentSystem.includes("generate a concise title")) { + return; + } + + if (!currentSystem.includes("annotate_plan") && (await shouldInjectInstructions(input?.sessionID))) { + output.system.push(PLAN_REVIEW_INSTRUCTIONS); + } + }, + tool: { + annotate_plan: tool({ + description: + "Submit a markdown plan for interactive user review. Returns plain-text execution or revision instructions for the agent.", + args: { + plan: tool.schema.string().describe("The complete implementation plan in markdown format"), + summary: tool.schema.string().optional().describe("Optional one-line plan summary"), + }, + async execute(args, context) { + const result = await runPlanReview({ + plan: args.plan, + sessionId: context.sessionID, + cwd: ctx.directory, + }); + const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested."); + + if (!result.approved) { + return [ + "Plan review status: plan_status=rejected.", + "State transition: next_state=PLAN_DRAFT.", + "", + "## User feedback", + "", + feedback, + "", + "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", + ].join("\n"); + } + + const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; + if (args.summary) { + lines.push(`Summary: ${args.summary}`); + } + + const handoffResult = await handoff(context.sessionID); + if (handoffResult) { + lines.push( + handoffResult.warning + ? `Auto-switch warning: ${handoffResult.warning}` + : `Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`, + ); + } + lines.push( + "Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan.", + "Execute the approved plan directly now — write code, create files, and make changes.", + "Do not call `annotate_plan` again unless the user explicitly requests re-planning.", + ); + return lines.join("\n\n"); + }, + }), + }, + }; +}; + +export default OpenPlanAnnotatorPlugin; diff --git a/opencode/index.test.ts b/opencode/index.test.ts index daeda4b..abe4da4 100644 --- a/opencode/index.test.ts +++ b/opencode/index.test.ts @@ -32,7 +32,6 @@ function createPluginContext() { reload: async () => {}, }, session: { - get: async () => ({ id: "session-1", agent: "plan" }), switchAgent: async () => {}, prompt: async () => ({ id: "inbox-1" }), hook: async (name, callback) => { @@ -60,20 +59,44 @@ async function loadPluginWithBridge(runPlanReviewImpl, suffix) { } describe("annotate_plan tool output (V2)", () => { - test("default export is a V2 plugin definition object", async () => { + test("V2 entrypoints default to a plugin definition object", async () => { mock.module("./bridge.js", () => ({ runPlanReview: async () => ({ approved: true }), })); - const mod = await import(`./index.js?shape-${Date.now()}`); - expect(typeof mod.default).toBe("object"); - expect(mod.default.id).toBe("open-plan-annotator"); - expect(typeof mod.default.setup).toBe("function"); + const suffix = `shape-${Date.now()}`; + const modules = await Promise.all([import(`../index.js?${suffix}`), import(`../server.js?${suffix}`)]); + for (const mod of modules) { + expect(typeof mod.default).toBe("object"); + expect(mod.default.id).toBe("open-plan-annotator"); + expect(typeof mod.default.setup).toBe("function"); + } + }); + + test("legacy entrypoint retains the V1 plugin function", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "v1-shape"); + expect(typeof mod.default).toBe("function"); + expect(mod.OpenPlanAnnotatorPlugin).toBe(mod.default); + + const plugin = await mod.default({ + directory: process.cwd(), + client: { + session: { + messages: async () => ({ data: [] }), + prompt: async () => ({ data: null }), + }, + app: { + agents: async () => ({ data: [] }), + }, + }, + }); + const result = await plugin.tool.annotate_plan.execute({ plan: "# Plan" }, { sessionID: "session-1" }); + expect(result).toContain("plan_status=approved"); }); test("returns plain text execution instructions after approval", async () => { const mod = await loadPluginWithBridge(async () => ({ approved: true }), "approved"); const { ctx, editor } = createPluginContext(); - const cleanup = await mod.default.setup(ctx); + const cleanup = await mod.OpenPlanAnnotatorPluginV2.setup(ctx); expect(typeof cleanup === "function" || cleanup === undefined).toBe(true); const tool = editor.tools.get("annotate_plan"); @@ -92,7 +115,7 @@ describe("annotate_plan tool output (V2)", () => { "rejected", ); const { ctx, editor } = createPluginContext(); - await mod.default.setup(ctx); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); const tool = editor.tools.get("annotate_plan"); const result = await tool.execute({ plan: "# Plan" }, { sessionID: "session-2" }); @@ -106,11 +129,11 @@ describe("annotate_plan tool output (V2)", () => { test("injects plan review instructions via session context hook", async () => { const mod = await loadPluginWithBridge(async () => ({ approved: true }), "hook"); const { ctx, hooks } = createPluginContext(); - await mod.default.setup(ctx); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); const hook = hooks.get("context"); expect(hook).toBeDefined(); - const event = { sessionID: "session-1", system: [] }; + const event = { agent: "plan", sessionID: "session-1", system: [] }; await hook(event); expect(event.system.length).toBe(1); expect(event.system[0].text).toContain("annotate_plan"); @@ -123,11 +146,10 @@ describe("annotate_plan tool output (V2)", () => { test("skips injection for the implementation agent", async () => { const mod = await loadPluginWithBridge(async () => ({ approved: true }), "skip-handoff"); const { ctx, hooks } = createPluginContext(); - ctx.session.get = async () => ({ id: "s", agent: "build" }); - await mod.default.setup(ctx); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); const hook = hooks.get("context"); - const event = { sessionID: "s", system: [] }; + const event = { agent: "build", sessionID: "s", system: [] }; await hook(event); expect(event.system.length).toBe(0); }); diff --git a/package.json b/package.json index 6d57e20..7f455c2 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "shared/versionInfo.mjs", "shared/planReview.mjs", "shared/piExtension.mjs", + "index.js", + "server.js", ".claude-plugin/", "opencode/bridge.js", "opencode/config.js", @@ -74,7 +76,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260511.1" }, "dependencies": { - "@opencode/plugin": "2.0.22", + "@opencode-ai/plugin": "^1.14.48", "typebox": "^1.1.38" }, "optionalDependencies": { diff --git a/scripts/check-package-files.mjs b/scripts/check-package-files.mjs index 8291fe9..3efbb5c 100644 --- a/scripts/check-package-files.mjs +++ b/scripts/check-package-files.mjs @@ -18,6 +18,8 @@ const packages = [ "shared/cliMode.mjs", "shared/planReview.mjs", "shared/piExtension.mjs", + "index.js", + "server.js", ], }, { diff --git a/server.js b/server.js new file mode 100644 index 0000000..e59e2f2 --- /dev/null +++ b/server.js @@ -0,0 +1 @@ +export { OpenPlanAnnotatorPluginV2 as default } from "./opencode/index.js";