diff --git a/README.md b/README.md index 1beba14..f49c491 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ To check your configured providers: - `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base `, `--wait`, `--background`. - `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Accepts custom focus text. -- `/opencode:rescue` -- Delegates a task to OpenCode via the `opencode:opencode-rescue` subagent. Supports `--model`, `--agent`, `--resume`, `--fresh`, `--background`. +- `/opencode:rescue` -- Delegates a task to OpenCode via the `opencode:opencode-rescue` subagent. Supports `--model`, `--variant`, `--file` (repeatable, like `opencode run -f`), `--agent`, `--resume`, `--fresh`, `--background`. - `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo. - `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming. - `/opencode:cancel` -- Cancels an active background OpenCode job. diff --git a/plugins/opencode/agents/opencode-rescue.md b/plugins/opencode/agents/opencode-rescue.md index 850b0da..554cfa5 100644 --- a/plugins/opencode/agents/opencode-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -51,7 +51,8 @@ Command selection: - Use exactly one `task` invocation per rescue handoff (followed by poll and result calls). - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. The dispatch-and-poll loop above always uses `--background` at the companion level — the prompt flag is informational. -- If the forwarded request includes `--model`, pass it through to `task`. +- If the forwarded request includes `--model` or `--variant`, pass them through to `task`. +- If the forwarded request includes `--file ` (repeatable), pass each through to `task` unchanged. - If the forwarded request includes `--agent`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. diff --git a/plugins/opencode/commands/rescue.md b/plugins/opencode/commands/rescue.md index bb7d676..463b486 100644 --- a/plugins/opencode/commands/rescue.md +++ b/plugins/opencode/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the OpenCode rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--agent ] [what OpenCode should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--variant ] [--file ]... [--agent ] [what OpenCode should investigate, solve, or continue]" context: fork allowed-tools: Bash(node:*) --- @@ -17,7 +17,7 @@ Execution mode: - If the request includes `--wait`, run the `opencode:opencode-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. -- `--model` and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--model`, `--variant`, `--file`, and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. `--file` may be repeated and takes a path relative to the repo root. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting OpenCode, check for a resumable rescue session from this Claude session by running: diff --git a/plugins/opencode/scripts/lib/args.mjs b/plugins/opencode/scripts/lib/args.mjs index 7e5413b..3451ab9 100644 --- a/plugins/opencode/scripts/lib/args.mjs +++ b/plugins/opencode/scripts/lib/args.mjs @@ -2,13 +2,16 @@ /** * Parse CLI arguments into options and positional args. + * `arrayOptions` are repeatable value flags (e.g. `--file a --file b`) + * collected into an array; `valueOptions` take a single (last-wins) value. * @param {string[]} argv - * @param {{ valueOptions?: string[], booleanOptions?: string[] }} schema - * @returns {{ options: Record, positional: string[] }} + * @param {{ valueOptions?: string[], booleanOptions?: string[], arrayOptions?: string[] }} schema + * @returns {{ options: Record, positional: string[] }} */ export function parseArgs(argv, schema = {}) { const valueSet = new Set(schema.valueOptions ?? []); const boolSet = new Set(schema.booleanOptions ?? []); + const arraySet = new Set(schema.arrayOptions ?? []); const options = {}; const positional = []; @@ -21,6 +24,8 @@ export function parseArgs(argv, schema = {}) { const key = arg.slice(2); if (valueSet.has(key)) { options[key] = argv[++i] ?? ""; + } else if (arraySet.has(key)) { + (options[key] ??= []).push(argv[++i] ?? ""); } else if (boolSet.has(key) || !valueSet.has(key)) { options[key] = true; } diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 41968cc..f14ac5f 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -3,6 +3,9 @@ // OpenCode exposes a REST API + SSE. This module wraps that API. import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; // Re-export for spec-compliance / discoverability: probeSessionTerminal lives // in auto-heal.mjs because it is tightly coupled to heal-decision logic, but @@ -32,6 +35,70 @@ const IDLE_TIMEOUT_MS = Number(process.env.OPENCODE_IDLE_TIMEOUT_MS) || 3_600_00 // processes for N polls in a row, declare stuck. 3 × 5s = 15s grace. const PGREP_MISS_THRESHOLD = Number(process.env.OPENCODE_PGREP_MISS_THRESHOLD) || 3; +/** + * Build the request body shared by sendPrompt and sendPromptAsync. + * `model` may be given as a `provider/model` string (like `opencode run -m`) + * or as the API's `{ providerID, modelID }` object; `variant` mirrors the + * `opencode run --variant` flag (provider-specific reasoning effort). + */ +export function buildPromptBody(promptText, opts = {}) { + const body = { + // Attachments come first, mirroring `opencode run`'s `parts: [...files, text]`. + parts: [ + ...(Array.isArray(opts.attachments) ? opts.attachments : []), + { type: "text", text: promptText }, + ], + }; + if (opts.agent) body.agent = opts.agent; + if (opts.model) { + if (typeof opts.model === "string") { + const sep = opts.model.indexOf("/"); + if (sep <= 0 || sep === opts.model.length - 1) { + throw new Error( + `Invalid model "${opts.model}": expected the provider/model format` + ); + } + body.model = { + providerID: opts.model.slice(0, sep), + modelID: opts.model.slice(sep + 1), + }; + } else { + body.model = opts.model; + } + } + if (opts.variant) body.variant = opts.variant; + if (opts.system) body.system = opts.system; + return body; +} + +/** + * Build a file part for the prompt body, mirroring `opencode run -f` against + * a local server: a `file://` URL plus mime. The server runs its Read tool on + * text/plain parts (inlining content, and producing image attachments for + * media) and lists application/x-directory parts, so no encoding is needed + * here and local-file semantics stay identical to the CLI. + * @param {string} filePath - path to attach (absolute, or relative to the workspace) + * @returns {{ type: "file", url: string, filename: string, mime: string }} + */ +export function buildFilePart(filePath) { + const resolved = path.resolve(filePath); + let stat; + try { + stat = fs.statSync(resolved); + } catch { + throw new Error(`File not found: ${filePath}`); + } + if (!stat.isFile() && !stat.isDirectory()) { + throw new Error(`Cannot attach special file: ${filePath}`); + } + return { + type: "file", + url: pathToFileURL(resolved).href, + filename: path.basename(resolved), + mime: stat.isDirectory() ? "application/x-directory" : "text/plain", + }; +} + /** * Find the PID of `opencode serve` listening on `port`, if we can. * Returns null on Windows or any detection failure (caller degrades gracefully). @@ -232,12 +299,7 @@ export function createClient(baseUrl, opts = {}) { * we abort the hanging fetch and synthesize the response from the poll. */ sendPrompt: async (sessionId, promptText, opts = {}) => { - const body = { - parts: [{ type: "text", text: promptText }], - }; - if (opts.agent) body.agent = opts.agent; - if (opts.model) body.model = opts.model; - if (opts.system) body.system = opts.system; + const body = buildPromptBody(promptText, opts); const ac = new AbortController(); const timeoutId = setTimeout(() => ac.abort(new Error("prompt timeout")), PROMPT_TIMEOUT_MS); @@ -430,11 +492,7 @@ export function createClient(baseUrl, opts = {}) { * Send a prompt asynchronously (returns immediately). */ sendPromptAsync: (sessionId, promptText, opts = {}) => { - const body = { - parts: [{ type: "text", text: promptText }], - }; - if (opts.agent) body.agent = opts.agent; - if (opts.model) body.model = opts.model; + const body = buildPromptBody(promptText, opts); return request("POST", `/session/${sessionId}/prompt_async`, body); }, diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 71417ff..059e57c 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -10,7 +10,9 @@ import fs from "node:fs"; import { parseArgs, extractTaskText } from "./lib/args.mjs"; import { isOpencodeInstalled, getOpencodeVersion, spawnDetached } from "./lib/process.mjs"; -import { isServerRunning, ensureServer, createClient, connect } from "./lib/opencode-server.mjs"; +import { + isServerRunning, ensureServer, createClient, connect, buildFilePart, +} from "./lib/opencode-server.mjs"; import { resolveWorkspace } from "./lib/workspace.mjs"; import { loadState, updateState, upsertJob, generateJobId, jobDataPath, jobLogPath } from "./lib/state.mjs"; import { buildStatusSnapshot, resolveResultJob, resolveCancelableJob, enrichJob, matchJobReference } from "./lib/job-control.mjs"; @@ -131,7 +133,7 @@ async function handleSetup(argv) { async function handleReview(argv) { const { options } = parseArgs(argv, { - valueOptions: ["base", "scope"], + valueOptions: ["base", "scope", "model", "variant"], booleanOptions: ["wait", "background"], }); @@ -157,6 +159,8 @@ async function handleReview(argv) { const response = await client.sendPrompt(session.id, prompt, { agent: "plan", // read-only agent for reviews + model: options.model, + variant: options.variant, }); report("finalizing", "Processing review output..."); @@ -181,7 +185,7 @@ async function handleReview(argv) { async function handleAdversarialReview(argv) { const { options, positional } = parseArgs(argv, { - valueOptions: ["base", "scope"], + valueOptions: ["base", "scope", "model", "variant"], booleanOptions: ["wait", "background"], }); @@ -212,6 +216,8 @@ async function handleAdversarialReview(argv) { const response = await client.sendPrompt(session.id, prompt, { agent: "plan", + model: options.model, + variant: options.variant, }); report("finalizing", "Processing review output..."); @@ -239,11 +245,12 @@ async function handleAdversarialReview(argv) { async function handleTask(argv) { const { options, positional } = parseArgs(argv, { - valueOptions: ["model", "agent"], + valueOptions: ["model", "agent", "variant"], + arrayOptions: ["file"], booleanOptions: ["write", "background", "wait", "resume-last", "fresh"], }); - const taskText = extractTaskText(argv, ["model", "agent"], [ + const taskText = extractTaskText(argv, ["model", "agent", "variant", "file"], [ "write", "background", "wait", "resume-last", "fresh", ]); @@ -256,6 +263,13 @@ async function handleTask(argv) { const isWrite = options.write !== undefined ? options.write : true; const agentName = options.agent ?? (isWrite ? "build" : "plan"); + // Resolve attachments up front so a bad path fails here, before any + // session is created or a background worker is spawned. Paths are + // workspace-relative (matching where the OpenCode server runs) and are + // forwarded to the worker as absolute paths. + const filePaths = (options.file ?? []).map((f) => path.resolve(workspace, f)); + const attachments = filePaths.map((f) => buildFilePart(f)); + // Check for resume let resumeSessionId = null; if (options["resume-last"]) { @@ -291,6 +305,8 @@ async function handleTask(argv) { isWrite, resumeSessionId, model: options.model, + variant: options.variant, + files: filePaths, }, }); @@ -305,6 +321,8 @@ async function handleTask(argv) { if (isWrite) workerArgs.push("--write"); if (resumeSessionId) workerArgs.push("--resume-session", resumeSessionId); if (options.model) workerArgs.push("--model", options.model); + if (options.variant) workerArgs.push("--variant", options.variant); + for (const f of filePaths) workerArgs.push("--file", f); const child = spawnDetached("node", workerArgs, { cwd: workspace, logFile }); upsertJob(workspace, { id: job.id, pid: child.pid }); @@ -333,10 +351,19 @@ async function handleTask(argv) { const prompt = buildTaskPrompt(taskText, { write: isWrite }); report("investigating", "Sending task to OpenCode..."); - log(`Agent: ${agentName}, Write: ${isWrite}, Prompt: ${prompt.length} chars`); + log( + `Agent: ${agentName}, Write: ${isWrite},` + + ` Model: ${options.model || "(default)"},` + + ` Variant: ${options.variant || "(default)"},` + + ` Files: ${filePaths.length ? filePaths.join(", ") : "(none)"},` + + ` Prompt: ${prompt.length} chars` + ); const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: options.model, + variant: options.variant, + attachments, }); report("finalizing", "Processing task output..."); @@ -373,7 +400,10 @@ async function handleTask(argv) { async function handleTaskWorker(argv) { const { options } = parseArgs(argv, { - valueOptions: ["job-id", "workspace", "task-text", "agent", "model", "resume-session"], + valueOptions: [ + "job-id", "workspace", "task-text", "agent", "model", "variant", "resume-session", + ], + arrayOptions: ["file"], booleanOptions: ["write"], }); @@ -405,10 +435,14 @@ async function handleTaskWorker(argv) { upsertJob(workspace, { id: jobId, opencodeSessionId: sessionId }); const prompt = buildTaskPrompt(taskText, { write: isWrite }); + const attachments = (options.file ?? []).map((f) => buildFilePart(f)); report("investigating", "Running task..."); const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: options.model, + variant: options.variant, + attachments, }); const text = extractResponseText(response); diff --git a/plugins/opencode/skills/opencode-runtime/SKILL.md b/plugins/opencode/skills/opencode-runtime/SKILL.md index 1833b59..c7e0184 100644 --- a/plugins/opencode/skills/opencode-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-runtime/SKILL.md @@ -29,12 +29,15 @@ Execution rules: - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. - Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). - Leave model unset by default. Add `--model` only when the user explicitly asks for one. +- Same for `--variant` (provider-specific reasoning effort, e.g. `high`, `max`, `minimal`): only pass it when the user explicitly asks. +- `--file ` (repeatable) attaches a file or directory to the OpenCode prompt, like `opencode run -f`. Only pass it when the user explicitly asks. Paths are resolved against the repo root. Command selection: - Use exactly one `task` invocation per rescue handoff. Follow it with status polls and one final `result` call. - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`. The dispatch-and-poll loop always uses `--background` at the companion level internally. -- If the forwarded request includes `--model`, pass it through to `task`. +- If the forwarded request includes `--model` or `--variant`, pass them through to `task`. +- If the forwarded request includes `--file `, pass every occurrence through to `task`. - If the forwarded request includes `--agent`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. diff --git a/tests/args.test.mjs b/tests/args.test.mjs index 6a78af4..8024705 100644 --- a/tests/args.test.mjs +++ b/tests/args.test.mjs @@ -26,6 +26,24 @@ describe("parseArgs", () => { assert.deepEqual(positional, ["hello", "world"]); }); + it("collects repeatable array options in order", () => { + const { options, positional } = parseArgs( + ["attach", "--file", "a.png", "--file", "b.log", "these", "notes"], + { arrayOptions: ["file"] } + ); + assert.deepEqual(options.file, ["a.png", "b.log"]); + assert.deepEqual(positional, ["attach", "these", "notes"]); + }); + + it("array options are separate from value options", () => { + const { options } = parseArgs(["--model", "x/y", "--file", "a", "--file", "b"], { + valueOptions: ["model"], + arrayOptions: ["file"], + }); + assert.equal(options.model, "x/y"); + assert.deepEqual(options.file, ["a", "b"]); + }); + it("handles mixed args", () => { const { options, positional } = parseArgs( ["fix", "--model", "claude-sonnet", "--write", "the", "bug"], @@ -47,6 +65,15 @@ describe("extractTaskText", () => { assert.equal(text, "fix the bug"); }); + it("strips repeated value flags", () => { + const text = extractTaskText( + ["--file", "a.png", "review", "--file", "b.log", "this"], + ["file"], + [] + ); + assert.equal(text, "review this"); + }); + it("returns empty for flags-only input", () => { const text = extractTaskText(["--wait", "--model", "gpt"], ["model"], ["wait"]); assert.equal(text, ""); diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs new file mode 100644 index 0000000..e18f385 --- /dev/null +++ b/tests/opencode-server.test.mjs @@ -0,0 +1,112 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + buildPromptBody, + buildFilePart, +} from "../plugins/opencode/scripts/lib/opencode-server.mjs"; + +describe("buildFilePart", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "oc-file-part-")); + const filePath = path.join(tmp, "notes.md"); + const dirPath = path.join(tmp, "sub dir"); + fs.writeFileSync(filePath, "hello"); + fs.mkdirSync(dirPath); + + it("builds a text/plain file:// part for a file", () => { + const part = buildFilePart(filePath); + assert.equal(part.type, "file"); + assert.equal(part.mime, "text/plain"); + assert.equal(part.filename, "notes.md"); + assert.equal(part.url, pathToFileURL(filePath).href); + }); + + it("escapes special characters in the file URL", () => { + const part = buildFilePart(dirPath); + assert.equal(part.url, pathToFileURL(dirPath).href); + assert.ok(part.url.includes("%20"), "spaces should be percent-encoded"); + }); + + it("marks directories as application/x-directory", () => { + const part = buildFilePart(dirPath); + assert.equal(part.mime, "application/x-directory"); + assert.equal(part.filename, "sub dir"); + }); + + it("throws for missing paths", () => { + assert.throws(() => buildFilePart(path.join(tmp, "nope.txt")), /File not found/); + }); +}); + +describe("buildPromptBody", () => { + it("wraps prompt text and passes agent through", () => { + const body = buildPromptBody("fix the bug", { agent: "build" }); + assert.deepEqual(body, { + parts: [{ type: "text", text: "fix the bug" }], + agent: "build", + }); + }); + + it("converts a provider/model string to the API ModelRef object", () => { + const body = buildPromptBody("hi", { model: "anthropic/claude-sonnet-4-5" }); + assert.deepEqual(body.model, { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }); + }); + + it("keeps model IDs containing slashes intact", () => { + const body = buildPromptBody("hi", { model: "lmstudio/google/gemma-3" }); + assert.deepEqual(body.model, { + providerID: "lmstudio", + modelID: "google/gemma-3", + }); + }); + + it("rejects model strings without a provider", () => { + assert.throws(() => buildPromptBody("hi", { model: "claude-sonnet" }), /provider\/model/); + assert.throws(() => buildPromptBody("hi", { model: "anthropic/" }), /provider\/model/); + assert.throws(() => buildPromptBody("hi", { model: "/claude" }), /provider\/model/); + }); + + it("passes a ModelRef object through unchanged", () => { + const ref = { providerID: "openai", modelID: "gpt-5.2" }; + const body = buildPromptBody("hi", { model: ref }); + assert.equal(body.model, ref); + }); + + it("forwards variant like `opencode run --variant`", () => { + const body = buildPromptBody("hi", { variant: "high" }); + assert.equal(body.variant, "high"); + }); + + it("supports model, variant, agent, and system together", () => { + const body = buildPromptBody("hi", { + agent: "plan", + model: "opencode/gpt-5.1-codex", + variant: "max", + system: "extra", + }); + assert.deepEqual(body, { + parts: [{ type: "text", text: "hi" }], + agent: "plan", + model: { providerID: "opencode", modelID: "gpt-5.1-codex" }, + variant: "max", + system: "extra", + }); + }); + + it("places attachments before the text part, like `opencode run`", () => { + const file = { type: "file", url: "file:///tmp/a.png", filename: "a.png", mime: "text/plain" }; + const body = buildPromptBody("hi", { attachments: [file] }); + assert.deepEqual(body.parts, [file, { type: "text", text: "hi" }]); + }); + + it("omits unset options", () => { + const body = buildPromptBody("hi", {}); + assert.deepEqual(body, { parts: [{ type: "text", text: "hi" }] }); + }); +});