Files
Jordan Humberto de Souza 720492d632 fix(opencode): support OpenCode 2.x via native tools and commands (#1213)
* fix(opencode): support OpenCode 2.x via native tools and commands

The single-file plugin (open-code-review.ts) cannot load on OpenCode 2.x:
the 2.x loader requires a default-exported { id, effect | setup } and has
no custom-tool registration API. It also fails on 1.x for most users
because nothing installs the @opencode-ai/plugin dependency the file
imports.

Ship the same ocr_review / ocr_health features as 2.x-native custom
tools plus /ocr-review /ocr-health commands, and document the
@opencode-ai/plugin dependency step for both versions.

* fix(opencode): dual V1+V2 plugin entrypoint instead of separate files

Replace the tools/+commands/ split with the documented dual plugin form:
default-export { ...Plugin.define({ id, setup }), server }. V2 registers
ocr_review/ocr_health via ctx.tool.transform and /ocr-review//ocr-health
via ctx.command.transform, reusing the same OCR logic as V1. Add
@opencode/plugin beta devDependency and a test for the default export.

* fix(opencode): review feedback: typeless V2 import, strict schemas, tests

- Import @opencode/plugin as types only and export a plain dual object,
  so OpenCode 1.x never needs the V2 beta package at runtime (verified
  in the built output: only node:*, @opencode-ai/plugin imports remain).
- V2 numeric inputs now require positive integers
  ({ type: integer, minimum: 1 }), matching the V1 zod schema.
- V2 commands keep the V1 sentence break, skip user-defined names like
  the V1 ??= guards, and resolveSessionCwd falls back to the plugin
  location when the session lookup fails.
- README: single download block, corrected 30-minute tool timeout,
  deduped project section.
- Track package-lock.json (drop the local ignore) so npm ci works.
- Move the V2 stub harness into the test suite (+6 tests, 29 passing).
- Verified live on OpenCode 1.18.30 sandbox: plugin loads with no
  errors, single init across sessions, both commands registered once.
2026-09-14 15:31:42 +08:00

547 lines
19 KiB
TypeScript

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import { spawn } from "node:child_process"
import { join } from "node:path"
import { type Plugin, tool } from "@opencode-ai/plugin"
import type { Plugin as PluginV2 } from "@opencode/plugin"
interface ReviewInput {
commit?: string
from?: string
to?: string
resume?: string
background?: string
exclude?: string
model?: string
concurrency?: number
timeoutMinutes?: number
overallTimeoutMinutes?: number
maxTools?: number
maxGitProcesses?: number
preview?: boolean
}
interface OcrInvocation {
command: string
prefixArgs: string[]
}
interface RunOptions {
cwd: string
timeoutMs?: number | null
maxOutputBytes?: number
invocation?: OcrInvocation
signal?: AbortSignal
}
interface RunResult {
stdout: string
stderr: string
exitCode: number
}
class OcrExecutionError extends Error {
readonly exitCode: number | null
readonly stderr: string
readonly stdout: string
constructor(message: string, result: {
exitCode: number | null
stderr?: string
stdout?: string
}) {
super(message)
this.name = "OcrExecutionError"
this.exitCode = result.exitCode
this.stderr = result.stderr ?? ""
this.stdout = result.stdout ?? ""
}
}
function pushValue(args: string[], flag: string, value: string | number | undefined): void {
if (value !== undefined && value !== "") {
args.push(flag, String(value))
}
}
function buildReviewArgs(input: ReviewInput, repo: string): string[] {
const hasRange = input.from !== undefined || input.to !== undefined
if (hasRange && (!input.from || !input.to)) {
throw new Error("Both 'from' and 'to' are required for a branch comparison.")
}
if (input.commit && hasRange) {
throw new Error("Use either 'commit' or a 'from'/'to' range, not both.")
}
if (input.resume && (input.commit || hasRange)) {
throw new Error("'resume' cannot be combined with 'commit' or a 'from'/'to' range.")
}
if (input.preview && input.resume) {
throw new Error("'preview' and 'resume' cannot be used together.")
}
const args = ["review", "--audience", "agent"]
if (!input.preview) {
args.push("--format", "json")
}
args.push("--repo", repo)
pushValue(args, "--commit", input.commit)
pushValue(args, "--from", input.from)
pushValue(args, "--to", input.to)
pushValue(args, "--resume", input.resume)
pushValue(args, "--background", input.background)
pushValue(args, "--exclude", input.exclude)
pushValue(args, "--model", input.model)
pushValue(args, "--concurrency", input.concurrency)
pushValue(args, "--timeout", input.timeoutMinutes)
pushValue(args, "--max-tools", input.maxTools)
pushValue(args, "--max-git-procs", input.maxGitProcesses)
if (input.preview) {
args.push("--preview")
}
return args
}
function appendChunk(
chunks: Uint8Array[],
currentBytes: number,
chunk: Uint8Array,
maxBytes: number,
): number {
const nextBytes = currentBytes + chunk.byteLength
if (nextBytes > maxBytes) {
throw new Error(`OCR output exceeded the ${maxBytes}-byte safety limit.`)
}
chunks.push(chunk)
return nextBytes
}
async function runOcr(args: string[], options: RunOptions): Promise<RunResult> {
const invocation = options.invocation ?? { command: "ocr", prefixArgs: [] }
const timeoutMs = options.timeoutMs === undefined ? 15 * 60 * 1000 : options.timeoutMs
const maxOutputBytes = options.maxOutputBytes ?? 10 * 1024 * 1024
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(
invocation.command,
[...invocation.prefixArgs, ...args],
{
cwd: options.cwd,
env: process.env,
shell: false,
detached: true,
},
)
child.stdin.end()
const stdoutChunks: Uint8Array[] = []
const stderrChunks: Uint8Array[] = []
let outputBytes = 0
let settled = false
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
let abort: (() => void) | undefined
const finish = (callback: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
if (abort) {
options.signal?.removeEventListener("abort", abort)
}
callback()
}
const killProcessGroup = (signal: NodeJS.Signals): void => {
if (closed || child.pid === undefined) return
if (process.platform === "win32") {
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" })
return
}
try {
process.kill(-child.pid, signal)
} catch {
child.kill(signal)
}
}
const terminateChild = (): void => {
if (closed) return
killProcessGroup("SIGTERM")
forceKillTimer ??= setTimeout(() => {
if (!closed) {
killProcessGroup("SIGKILL")
}
}, 3_000)
}
const failForOutputLimit = (error: Error): void => {
terminateChild()
finish(() => reject(error))
}
child.stdout.on("data", (chunk: Buffer) => {
try {
outputBytes = appendChunk(stdoutChunks, outputBytes, chunk, maxOutputBytes)
} catch (error) {
failForOutputLimit(error as Error)
}
})
child.stderr.on("data", (chunk: Buffer) => {
try {
outputBytes = appendChunk(stderrChunks, outputBytes, chunk, maxOutputBytes)
} catch (error) {
failForOutputLimit(error as Error)
}
})
child.on("error", (error) => {
const message = error.message.includes("ENOENT")
? "OpenCodeReview is not installed or 'ocr' is not on PATH. Install it with: npm install -g @alibaba-group/open-code-review"
: `Failed to start OpenCodeReview: ${error.message}`
finish(() => reject(new OcrExecutionError(message, { exitCode: null })))
})
child.on("close", (exitCode) => {
closed = true
clearTimeout(forceKillTimer)
finish(() => {
const result = {
stdout: Buffer.concat(stdoutChunks).toString("utf8").trim(),
stderr: Buffer.concat(stderrChunks).toString("utf8").trim(),
exitCode: exitCode ?? 1,
}
if (exitCode !== 0) {
reject(new OcrExecutionError(
result.stderr || result.stdout || `OpenCodeReview exited with code ${result.exitCode}.`,
result,
))
return
}
resolve(result)
})
})
abort = (): void => {
terminateChild()
finish(() => reject(new OcrExecutionError(
"OpenCodeReview was cancelled by OpenCode.",
{
exitCode: null,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
},
)))
}
options.signal?.addEventListener("abort", abort, { once: true })
if (timeoutMs !== null) {
timer = setTimeout(() => {
terminateChild()
finish(() => reject(new OcrExecutionError(
`OpenCodeReview timed out after ${Math.round(timeoutMs / 1000)} seconds.`,
{
exitCode: null,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
},
)))
}, timeoutMs)
}
if (options.signal?.aborted) {
abort()
}
})
}
function formatReviewResult(result: RunResult, preview: boolean): string {
if (preview) {
return result.stdout || "No files changed."
}
if (result.stdout === "") {
return "No changes detected; OCR produced no output."
}
try {
JSON.parse(result.stdout)
return result.stdout
} catch {
throw new OcrExecutionError("OpenCodeReview returned invalid JSON.", result)
}
}
const optionalString = (description: string) =>
tool.schema.string().optional().describe(description)
const optionalPositiveInt = (description: string) =>
tool.schema.number().int().positive().optional().describe(description)
const reviewArgs = {
commit: optionalString("Review one commit against its parent."),
from: optionalString("Base ref for a branch/range comparison. Must be paired with 'to'."),
to: optionalString("Target ref for a branch/range comparison. Must be paired with 'from'."),
resume: optionalString("Resume a previous OCR review session by ID."),
background: optionalString("Business or requirement context that the implementation should satisfy."),
exclude: optionalString("Comma-separated gitignore-style exclusion patterns."),
model: optionalString("Override the model configured in OpenCodeReview."),
concurrency: optionalPositiveInt("Maximum concurrent file reviews."),
timeoutMinutes: optionalPositiveInt("Per-file OCR timeout in minutes."),
overallTimeoutMinutes: optionalPositiveInt(
"Optional wall-clock timeout for the complete OCR process in minutes.",
),
maxTools: optionalPositiveInt("Maximum tool-call rounds per subtask; OCR enforces a minimum of 50."),
maxGitProcesses: optionalPositiveInt("Maximum concurrent Git subprocesses."),
preview: tool.schema.boolean().optional().describe(
"List the files that would be reviewed without calling an LLM.",
),
}
export const OpenCodeReviewPlugin: Plugin = async ({ client, worktree }) => {
try {
await client.app.log({
body: {
service: "open-code-review",
level: "info",
message: "OpenCodeReview tools registered",
},
})
} catch {
// Best-effort telemetry; a failed log call must not block tool registration.
}
return {
config: async (config) => {
config.command ??= {}
config.command["ocr-review"] ??= {
description: "Review code changes with OpenCodeReview",
template:
"Use the ocr_review tool to review the requested target. " +
"Treat the following text as review intent, target details, and business context: $ARGUMENTS. " +
"If no target is specified, review the current workspace changes. " +
"Report findings by severity with exact file and line references.",
}
config.command["ocr-health"] ??= {
description: "Check OpenCodeReview and its LLM connection",
template:
"Use the ocr_health tool and explain any configuration problem concisely.",
}
},
tool: {
ocr_review: tool({
description:
"Run OpenCodeReview on workspace changes, one commit, or a ref range. " +
"Returns structured line-level findings as JSON. Use preview=true to inspect scope without LLM usage.",
args: reviewArgs,
async execute(args, context) {
const input = args as ReviewInput
const cwd = context.worktree || context.directory || worktree
const defaultOverallMs = 30 * 60 * 1000
const options: RunOptions = {
cwd,
signal: context.abort,
timeoutMs: input.overallTimeoutMinutes !== undefined
? input.overallTimeoutMinutes * 60 * 1000
: defaultOverallMs,
}
const result = await runOcr(buildReviewArgs(input, cwd), options)
return formatReviewResult(result, input.preview === true)
},
}),
ocr_health: tool({
description:
"Check the installed OpenCodeReview version and verify its configured LLM connection.",
args: {},
async execute(_args, context) {
const cwd = context.worktree || context.directory || worktree
const [version, llm] = await Promise.allSettled([
runOcr(["version"], {
cwd,
timeoutMs: 30_000,
signal: context.abort,
}),
runOcr(["llm", "test"], {
cwd,
timeoutMs: 60_000,
signal: context.abort,
}),
])
const rejected = [version, llm].find(
(result): result is PromiseRejectedResult => result.status === "rejected",
)
if (context.abort?.aborted && rejected) {
throw rejected.reason
}
const parts: string[] = []
if (version.status === "fulfilled") {
parts.push(version.value.stdout)
} else {
parts.push(`Version check failed: ${version.reason?.message ?? "unknown error"}`)
}
if (llm.status === "fulfilled") {
parts.push(llm.value.stdout, llm.value.stderr)
} else {
parts.push(`LLM connection check failed: ${llm.reason?.message ?? "unknown error"}`)
}
return parts.filter(Boolean).join("\n")
},
}),
},
}
}
// ---------------------------------------------------------------------------
// OpenCode 2.x entrypoint (https://opencode.ai/v2/docs/build/plugins).
// The default export at the bottom of this file serves both versions: V2
// reads `id` + `setup`, V1 reads `server`. All OCR logic above is shared.
//
// The V2 API is imported as types only (`import type`), and the definition
// below is a plain object literal: `Plugin.define` is an identity function,
// so V1 never needs the `@opencode/plugin` package at runtime.
//
// V2 limitations (no equivalent in the V2 tool API): tool execution has no
// abort signal, so cancellation relies on the overall timeout, and the
// per-session working directory is resolved from the session location.
// ---------------------------------------------------------------------------
const OCR_REVIEW_DESCRIPTION =
"Run OpenCodeReview on workspace changes, one commit, or a ref range. " +
"Returns structured line-level findings as JSON. Use preview=true to inspect scope without LLM usage."
const OCR_HEALTH_DESCRIPTION =
"Check the installed OpenCodeReview version and verify its configured LLM connection."
const OCR_REVIEW_COMMAND_TEMPLATE =
"Use the ocr_review tool to review the requested target. " +
"Treat the following text as review intent, target details, and business context:"
const OCR_REVIEW_COMMAND_SUFFIX =
". If no target is specified, review the current workspace changes. " +
"Report findings by severity with exact file and line references."
const OCR_HEALTH_COMMAND_TEMPLATE =
"Use the ocr_health tool and explain any configuration problem concisely."
const reviewInputSchema = {
type: "object",
properties: {
commit: { type: "string", description: "Review one commit against its parent." },
from: { type: "string", description: "Base ref for a branch/range comparison. Must be paired with 'to'." },
to: { type: "string", description: "Target ref for a branch/range comparison. Must be paired with 'from'." },
resume: { type: "string", description: "Resume a previous OCR review session by ID." },
background: { type: "string", description: "Business or requirement context that the implementation should satisfy." },
exclude: { type: "string", description: "Comma-separated gitignore-style exclusion patterns." },
model: { type: "string", description: "Override the model configured in OpenCodeReview." },
concurrency: { type: "integer", minimum: 1, description: "Maximum concurrent file reviews." },
timeoutMinutes: { type: "integer", minimum: 1, description: "Per-file OCR timeout in minutes." },
overallTimeoutMinutes: { type: "integer", minimum: 1, description: "Optional wall-clock timeout for the complete OCR process in minutes." },
maxTools: { type: "integer", minimum: 1, description: "Maximum tool-call rounds per subtask; OCR enforces a minimum of 50." },
maxGitProcesses: { type: "integer", minimum: 1, description: "Maximum concurrent Git subprocesses." },
preview: { type: "boolean", description: "List the files that would be reviewed without calling an LLM." },
},
required: [],
additionalProperties: false,
}
async function resolveSessionCwd(ctx: PluginV2.Context, sessionID: string): Promise<string> {
try {
const session = await ctx.session.get({ sessionID })
const directory = session.location.directory
const subpath = session.subpath
return subpath === undefined || subpath === "" ? directory : join(directory, subpath)
} catch {
return ctx.location.directory
}
}
async function setupV2(ctx: PluginV2.Context): Promise<void> {
await ctx.tool.transform((editor) => {
editor.add({
name: "ocr_review",
description: OCR_REVIEW_DESCRIPTION,
input: reviewInputSchema,
execute: async (input, toolCtx) => {
const review = input as ReviewInput
const cwd = await resolveSessionCwd(ctx, toolCtx.sessionID)
const result = await runOcr(buildReviewArgs(review, cwd), {
cwd,
timeoutMs: review.overallTimeoutMinutes !== undefined
? review.overallTimeoutMinutes * 60 * 1000
: 30 * 60 * 1000,
})
return { content: formatReviewResult(result, review.preview === true) }
},
})
editor.add({
name: "ocr_health",
description: OCR_HEALTH_DESCRIPTION,
input: { type: "object", properties: {}, additionalProperties: false },
execute: async (_input, toolCtx) => {
const cwd = await resolveSessionCwd(ctx, toolCtx.sessionID)
const [version, llm] = await Promise.allSettled([
runOcr(["version"], { cwd, timeoutMs: 30_000 }),
runOcr(["llm", "test"], { cwd, timeoutMs: 60_000 }),
])
const parts: string[] = []
if (version.status === "fulfilled") {
parts.push(version.value.stdout)
} else {
parts.push(`Version check failed: ${version.reason?.message ?? "unknown error"}`)
}
if (llm.status === "fulfilled") {
parts.push(llm.value.stdout, llm.value.stderr)
} else {
parts.push(`LLM connection check failed: ${llm.reason?.message ?? "unknown error"}`)
}
return { content: parts.filter(Boolean).join("\n") }
},
})
})
// Like the V1 `??=` guards above, never override commands the user
// already defined under the same names.
const registeredCommands = await ctx.command.list()
const commandNames = new Set(registeredCommands.data.map((command) => command.name))
await ctx.command.transform((editor) => {
// Text-only prompts, matching the V1 $ARGUMENTS templates: spreading the
// incoming prompt attachments would violate exactOptionalPropertyTypes
// and risk stale attachment offsets after the text rewrite.
if (!commandNames.has("ocr-review")) {
editor.add({
name: "ocr-review",
description: "Review code changes with OpenCodeReview",
execute: async ({ sessionID, prompt, delivery }) => {
await ctx.session.prompt({
sessionID,
text: `${OCR_REVIEW_COMMAND_TEMPLATE}${prompt.text ?? ""}${OCR_REVIEW_COMMAND_SUFFIX}`,
delivery,
})
},
})
}
if (!commandNames.has("ocr-health")) {
editor.add({
name: "ocr-health",
description: "Check OpenCodeReview and its LLM connection",
execute: async ({ sessionID, delivery }) => {
await ctx.session.prompt({
sessionID,
text: OCR_HEALTH_COMMAND_TEMPLATE,
delivery,
})
},
})
}
})
}
export default {
id: "open-code-review",
setup: setupV2,
server: OpenCodeReviewPlugin,
}