From 7ee4044fccae9e83ac329e839d50ca7e3847313d Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:31:35 +1000 Subject: [PATCH 1/2] feat(companion): add --variant flag, mirror of `opencode run --variant` task, task-worker, review, and adversarial-review now accept --variant (provider-specific reasoning effort, e.g. high, max, minimal) and pass it through to the OpenCode HTTP API's prompt body. For background tasks it is recorded in job state, logged on dispatch, and forwarded to the worker. Also fix --model, which was parsed and recorded but never sent with the prompt: buildPromptBody now converts a provider/model string to the {providerID, modelID} object the API's ModelRef schema requires, instead of sending a raw string that would be rejected. Update rescue command/agent/skill docs and README, and add unit tests for buildPromptBody. --- README.md | 2 +- plugins/opencode/agents/opencode-rescue.md | 2 +- plugins/opencode/commands/rescue.md | 4 +- .../opencode/scripts/lib/opencode-server.mjs | 45 ++++++++++--- .../opencode/scripts/opencode-companion.mjs | 29 ++++++-- .../opencode/skills/opencode-runtime/SKILL.md | 3 +- tests/opencode-server.test.mjs | 67 +++++++++++++++++++ 7 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 tests/opencode-server.test.mjs diff --git a/README.md b/README.md index 1beba14..2b0dab9 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`, `--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..c669563 100644 --- a/plugins/opencode/agents/opencode-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -51,7 +51,7 @@ 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 `--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..aa4aa14 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 ] [--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`, 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. - 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/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 41968cc..401752f 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -32,6 +32,38 @@ 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 = { + parts: [{ 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; +} + /** * 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 +264,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 +457,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..e9cb6cf 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -131,7 +131,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 +157,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 +183,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 +214,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 +243,11 @@ async function handleAdversarialReview(argv) { async function handleTask(argv) { const { options, positional } = parseArgs(argv, { - valueOptions: ["model", "agent"], + valueOptions: ["model", "agent", "variant"], booleanOptions: ["write", "background", "wait", "resume-last", "fresh"], }); - const taskText = extractTaskText(argv, ["model", "agent"], [ + const taskText = extractTaskText(argv, ["model", "agent", "variant"], [ "write", "background", "wait", "resume-last", "fresh", ]); @@ -291,6 +295,7 @@ async function handleTask(argv) { isWrite, resumeSessionId, model: options.model, + variant: options.variant, }, }); @@ -305,6 +310,7 @@ 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); const child = spawnDetached("node", workerArgs, { cwd: workspace, logFile }); upsertJob(workspace, { id: job.id, pid: child.pid }); @@ -333,10 +339,17 @@ 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)"},` + + ` Prompt: ${prompt.length} chars` + ); const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: options.model, + variant: options.variant, }); report("finalizing", "Processing task output..."); @@ -373,7 +386,9 @@ 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", + ], booleanOptions: ["write"], }); @@ -409,6 +424,8 @@ async function handleTaskWorker(argv) { const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: options.model, + variant: options.variant, }); const text = extractResponseText(response); diff --git a/plugins/opencode/skills/opencode-runtime/SKILL.md b/plugins/opencode/skills/opencode-runtime/SKILL.md index 1833b59..90d88d3 100644 --- a/plugins/opencode/skills/opencode-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-runtime/SKILL.md @@ -29,12 +29,13 @@ 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. 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 `--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/opencode-server.test.mjs b/tests/opencode-server.test.mjs new file mode 100644 index 0000000..3923984 --- /dev/null +++ b/tests/opencode-server.test.mjs @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildPromptBody } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; + +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("omits unset options", () => { + const body = buildPromptBody("hi", {}); + assert.deepEqual(body, { parts: [{ type: "text", text: "hi" }] }); + }); +}); From 62d8a8070fa4b2a9409ffc80551c6426723ba40c Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:42:56 +1000 Subject: [PATCH 2/2] feat(companion): add repeatable --file flag, like `opencode run -f` task and task-worker now accept --file (repeatable) and attach each path to the OpenCode prompt as a file part. Mirroring the CLI's local-run behavior, parts carry a file:// URL with text/plain (or application/x-directory) mime, and the server inlines content via its Read tool -- no base64 encoding or size bloat. Paths resolve against the workspace root, are validated before a session is created or a worker is spawned, and are recorded in job state and the dispatch log. parseArgs grows an arrayOptions schema for repeatable value flags, and buildPromptBody places attachments before the text part to match the CLI's parts ordering. Docs and tests updated accordingly. --- README.md | 2 +- plugins/opencode/agents/opencode-rescue.md | 1 + plugins/opencode/commands/rescue.md | 4 +- plugins/opencode/scripts/lib/args.mjs | 9 +++- .../opencode/scripts/lib/opencode-server.mjs | 37 ++++++++++++++- .../opencode/scripts/opencode-companion.mjs | 21 ++++++++- .../opencode/skills/opencode-runtime/SKILL.md | 2 + tests/args.test.mjs | 27 +++++++++++ tests/opencode-server.test.mjs | 47 ++++++++++++++++++- 9 files changed, 141 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2b0dab9..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`, `--variant`, `--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 c669563..554cfa5 100644 --- a/plugins/opencode/agents/opencode-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -52,6 +52,7 @@ 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` 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 aa4aa14..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 ] [--variant ] [--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`, `--variant`, 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 401752f..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 @@ -40,7 +43,11 @@ const PGREP_MISS_THRESHOLD = Number(process.env.OPENCODE_PGREP_MISS_THRESHOLD) | */ export function buildPromptBody(promptText, opts = {}) { const body = { - parts: [{ type: "text", text: promptText }], + // 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) { @@ -64,6 +71,34 @@ export function buildPromptBody(promptText, opts = {}) { 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). diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index e9cb6cf..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"; @@ -244,10 +246,11 @@ async function handleAdversarialReview(argv) { async function handleTask(argv) { const { options, positional } = parseArgs(argv, { valueOptions: ["model", "agent", "variant"], + arrayOptions: ["file"], booleanOptions: ["write", "background", "wait", "resume-last", "fresh"], }); - const taskText = extractTaskText(argv, ["model", "agent", "variant"], [ + const taskText = extractTaskText(argv, ["model", "agent", "variant", "file"], [ "write", "background", "wait", "resume-last", "fresh", ]); @@ -260,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"]) { @@ -296,6 +306,7 @@ async function handleTask(argv) { resumeSessionId, model: options.model, variant: options.variant, + files: filePaths, }, }); @@ -311,6 +322,7 @@ async function handleTask(argv) { 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 }); @@ -343,6 +355,7 @@ async function handleTask(argv) { `Agent: ${agentName}, Write: ${isWrite},` + ` Model: ${options.model || "(default)"},` + ` Variant: ${options.variant || "(default)"},` + + ` Files: ${filePaths.length ? filePaths.join(", ") : "(none)"},` + ` Prompt: ${prompt.length} chars` ); @@ -350,6 +363,7 @@ async function handleTask(argv) { agent: agentName, model: options.model, variant: options.variant, + attachments, }); report("finalizing", "Processing task output..."); @@ -389,6 +403,7 @@ async function handleTaskWorker(argv) { valueOptions: [ "job-id", "workspace", "task-text", "agent", "model", "variant", "resume-session", ], + arrayOptions: ["file"], booleanOptions: ["write"], }); @@ -420,12 +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 90d88d3..c7e0184 100644 --- a/plugins/opencode/skills/opencode-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-runtime/SKILL.md @@ -30,12 +30,14 @@ Execution rules: - 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` 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 index 3923984..e18f385 100644 --- a/tests/opencode-server.test.mjs +++ b/tests/opencode-server.test.mjs @@ -1,6 +1,45 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildPromptBody } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; +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", () => { @@ -60,6 +99,12 @@ describe("buildPromptBody", () => { }); }); + 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" }] });