Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ To check your configured providers:

- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--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.
Expand Down
3 changes: 2 additions & 1 deletion plugins/opencode/agents/opencode-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (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`.
Expand Down
4 changes: 2 additions & 2 deletions plugins/opencode/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -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 <provider/model>] [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <provider/model>] [--variant <high|max|minimal>] [--file <path>]... [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*)
---
Expand All @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions plugins/opencode/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string|boolean>, positional: string[] }}
* @param {{ valueOptions?: string[], booleanOptions?: string[], arrayOptions?: string[] }} schema
* @returns {{ options: Record<string, string|boolean|string[]>, 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 = [];

Expand All @@ -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;
}
Expand Down
80 changes: 69 additions & 11 deletions plugins/opencode/scripts/lib/opencode-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
},

Expand Down
48 changes: 41 additions & 7 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
});

Expand All @@ -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...");
Expand All @@ -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"],
});

Expand Down Expand Up @@ -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...");
Expand Down Expand Up @@ -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",
]);

Expand All @@ -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"]) {
Expand Down Expand Up @@ -291,6 +305,8 @@ async function handleTask(argv) {
isWrite,
resumeSessionId,
model: options.model,
variant: options.variant,
files: filePaths,
},
});

Expand All @@ -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 });
Expand Down Expand Up @@ -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...");
Expand Down Expand Up @@ -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"],
});

Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion plugins/opencode/skills/opencode-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (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 <path>`, 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`.
Expand Down
27 changes: 27 additions & 0 deletions tests/args.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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, "");
Expand Down
Loading