opencode-worker 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # opencode-worker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run the official OpenCode CLI as an isolated, time-boxed worker. One package, two entry points:
4
+
5
+ - `opencode-worker run` — CLI, prints one JSON result
6
+ - `opencode-worker mcp` — MCP stdio server (async jobs)
7
+
8
+ No dependencies. Node ≥ 20. OpenCode is **not** bundled: pass `--opencode-bin`, set `OPENCODE_WORKER_BIN`, or have `opencode` on `PATH`.
9
+
10
+ ## Safety defaults
11
+
12
+ - Child env is rebuilt from scratch: only `PATH`, `LANG`, `LC_ALL`, `TMPDIR` pass through. No API keys or credentials.
13
+ - `HOME` and `XDG_{CONFIG,DATA,CACHE,STATE}_HOME` point into `<dir>/.opencode-worker/`.
14
+ - Generated config: `model` = `small_model` = requested model, `enabled_providers: ["opencode"]`, `permission.external_directory: "deny"`.
15
+ - Models outside the `opencode` provider are rejected (`PROVIDER_NOT_ALLOWED`); there is no fallback.
16
+ - Timeout (default 180 s): SIGTERM, then SIGKILL after 5 s.
17
+ - Fixed flags: `run --pure --auto --format json --agent <agent> --model <model> --variant <variant>`.
18
+
19
+ Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build`.
20
+
21
+ ## CLI
22
+
23
+ ```sh
24
+ opencode-worker run --dir ./task --timeout 180 "Fix add() in calculator.py and run the tests"
25
+ opencode-worker run --dir ./task -f prompt.md --variant high
26
+ ```
27
+
28
+ Exit codes: `0` ok, `1` worker failed (timeout, nonzero exit, error event, model mismatch), `2` usage/setup error.
29
+
30
+ ## Result (schemaVersion 1)
31
+
32
+ | field | meaning |
33
+ |---|---|
34
+ | `ok` | exit 0 and no errors |
35
+ | `exit`, `signal`, `durationSec` | process outcome |
36
+ | `opencode` | `{bin, version}` |
37
+ | `requested` | `{model, variant, agent, timeoutSec}` |
38
+ | `actual` | provider/model/variant recorded in the isolated session DB (needs `sqlite3`; `null` if unavailable) |
39
+ | `text` | worker's final text |
40
+ | `tools[]` | `{tool, status, input, exit, output, error}` per tool call; `exit` is the bash exit code (else `null`), `output` capped at 4000 chars |
41
+ | `cost`, `tokens` | summed from `step_finish` events |
42
+ | `diff` | `diff -ruN` of `<dir>` before vs after (excludes `.opencode-worker/`) |
43
+ | `errors[]` | `timeout`, `cancelled`, `nonzero_exit`, `event_error`, `model_mismatch`, `diff_failed`, `unparsed_line` |
44
+ | `artifacts` | raw `events.jsonl` and `stderr.txt` under `<dir>/.opencode-worker/runs/<id>/` |
45
+
46
+ The worker's report is not proof: verify `diff` and rerun your own checks.
47
+
48
+ ## MCP
49
+
50
+ Tools: `worker_start` (returns `jobId` immediately), `worker_status`, `worker_result`, `worker_cancel`.
51
+ Async by design so long runs never hit client request timeouts.
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "opencode-worker": {
57
+ "command": "npx",
58
+ "args": ["-y", "opencode-worker", "mcp"],
59
+ "env": { "OPENCODE_WORKER_BIN": "/path/to/opencode" }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Client setup
66
+
67
+ Claude Code (tools must be allowed in `-p` mode; put the prompt right after `-p`, because `--allowedTools` and `--mcp-config` are variadic and swallow trailing arguments):
68
+
69
+ ```sh
70
+ claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode -- npx -y opencode-worker mcp
71
+ claude -p "…" --allowedTools "mcp__opencode-worker__worker_start,mcp__opencode-worker__worker_status,mcp__opencode-worker__worker_result"
72
+ ```
73
+
74
+ Codex (`~/.codex/config.toml`); without the approval mode, `codex exec` rejects MCP calls under `approval: never`:
75
+
76
+ ```toml
77
+ [mcp_servers.opencode-worker]
78
+ command = "npx"
79
+ args = ["-y", "opencode-worker", "mcp"]
80
+ env = { OPENCODE_WORKER_BIN = "/path/to/opencode" }
81
+ default_tools_approval_mode = "approve"
82
+ ```
83
+
84
+ OMP (`.omp/mcp.json` or `~/.omp/agent/mcp.json`): the JSON above with `"type": "stdio"`. Non-interactive: `omp -p --auto-approve "…"`.
85
+
86
+ Job records are written to `$OPENCODE_WORKER_STATE_DIR` (default `$XDG_STATE_HOME/opencode-worker/jobs`, else `~/.local/state/opencode-worker/jobs`), so `worker_status`/`worker_result` work after a server or client restart.
87
+ Closing stdin or SIGINT/SIGTERM/SIGHUP cancels running jobs and records them as `cancelled`. If the server is killed outright, its unfinished jobs report `lost`; raw logs remain in `<dir>/.opencode-worker/runs/`.
88
+
89
+ ## Notes
90
+
91
+ - `<dir>/.opencode-worker/` holds state, snapshots and logs per run; delete it when done.
92
+ - The before-snapshot copies the whole directory: use small, task-scoped directories.
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import fs from "node:fs";
4
+ import { runWorker, WorkerError, DEFAULTS } from "../lib/worker.mjs";
5
+ import { serveMcp } from "../lib/mcp.mjs";
6
+
7
+ const USAGE = `Usage:
8
+ opencode-worker run --dir <dir> [options] <prompt | -f file>
9
+ opencode-worker mcp [--opencode-bin <path>]
10
+
11
+ Options (run):
12
+ --dir <dir> Working directory; the worker is confined to it (required)
13
+ --model <id> default ${DEFAULTS.model} (provider must be one of ${DEFAULTS.providers.join(",")})
14
+ --variant <v> default ${DEFAULTS.variant}
15
+ --agent <name> default ${DEFAULTS.agent}
16
+ --timeout <sec> default ${DEFAULTS.timeoutSec}
17
+ --opencode-bin <path> default $OPENCODE_WORKER_BIN, then PATH
18
+ -f, --prompt-file <f> Read prompt from file
19
+
20
+ Prints one JSON result object. Exit: 0 ok, 1 worker failed, 2 usage/setup error.`;
21
+
22
+ const [cmd, ...rest] = process.argv.slice(2);
23
+ try {
24
+ if (cmd === "run") {
25
+ const { values, positionals } = parseArgs({
26
+ args: rest,
27
+ allowPositionals: true,
28
+ options: {
29
+ dir: { type: "string" }, model: { type: "string" }, variant: { type: "string" },
30
+ agent: { type: "string" }, timeout: { type: "string" }, "opencode-bin": { type: "string" },
31
+ "prompt-file": { type: "string", short: "f" },
32
+ },
33
+ });
34
+ const prompt = values["prompt-file"] ? fs.readFileSync(values["prompt-file"], "utf8") : positionals.join(" ");
35
+ if (!values.dir || !prompt.trim()) throw new WorkerError("BAD_ARGS", "--dir and a prompt are required\n\n" + USAGE);
36
+ const timeoutSec = values.timeout === undefined ? undefined : Number(values.timeout);
37
+ if (timeoutSec !== undefined && !(timeoutSec > 0)) throw new WorkerError("BAD_ARGS", "--timeout must be a positive number");
38
+ const result = await runWorker({
39
+ dir: values.dir, prompt, model: values.model, variant: values.variant, agent: values.agent,
40
+ timeoutSec, opencodeBin: values["opencode-bin"],
41
+ });
42
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
43
+ process.exit(result.ok ? 0 : 1);
44
+ } else if (cmd === "mcp") {
45
+ const { values } = parseArgs({ args: rest, options: { "opencode-bin": { type: "string" } } });
46
+ serveMcp({ opencodeBin: values["opencode-bin"] });
47
+ } else {
48
+ process.stderr.write(USAGE + "\n");
49
+ process.exit(cmd === "-h" || cmd === "--help" ? 0 : 2);
50
+ }
51
+ } catch (e) {
52
+ process.stdout.write(JSON.stringify({ ok: false, errors: [{ type: e.code ?? "SETUP_ERROR", detail: e.message }] }, null, 2) + "\n");
53
+ process.exit(2);
54
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // Minimal MCP stdio server (newline-delimited JSON-RPC 2.0), no dependencies.
2
+ // Async job model so long worker runs never hit client request timeouts.
3
+ import readline from "node:readline";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { runWorker, DEFAULTS } from "./worker.mjs";
9
+
10
+ const PROTOCOL = "2025-06-18";
11
+ const jobs = new Map(); // live jobs of this server: id -> { record, controller, done }
12
+ const JOBS_DIR = process.env.OPENCODE_WORKER_STATE_DIR
13
+ ?? path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local/state"), "opencode-worker", "jobs");
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
+ const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
+
17
+ // Job records are persisted so results survive server/client restarts.
18
+ function save(record) {
19
+ fs.mkdirSync(JOBS_DIR, { recursive: true });
20
+ const file = path.join(JOBS_DIR, `${record.jobId}.json`);
21
+ fs.writeFileSync(file + ".tmp", JSON.stringify(record, null, 2));
22
+ fs.renameSync(file + ".tmp", file);
23
+ }
24
+
25
+ function alive(pid) {
26
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
27
+ }
28
+
29
+ function load(jobId) {
30
+ if (!UUID.test(jobId ?? "")) return null;
31
+ const live = jobs.get(jobId);
32
+ if (live) return live.record;
33
+ let record;
34
+ try { record = JSON.parse(fs.readFileSync(path.join(JOBS_DIR, `${jobId}.json`), "utf8")); } catch { return null; }
35
+ // Owned by a server that died mid-run: the outcome is unknown.
36
+ if (record.status === "running" && !alive(record.serverPid)) record.status = "lost";
37
+ return record;
38
+ }
39
+
40
+ const runSchema = {
41
+ type: "object",
42
+ properties: {
43
+ dir: { type: "string", description: "Absolute working directory; the worker is confined to it" },
44
+ prompt: { type: "string", description: "Task for the OpenCode worker" },
45
+ model: { type: "string", description: `default ${DEFAULTS.model}` },
46
+ variant: { type: "string", description: `default ${DEFAULTS.variant}` },
47
+ agent: { type: "string", description: `default ${DEFAULTS.agent}` },
48
+ timeoutSec: { type: "number", description: `default ${DEFAULTS.timeoutSec}` },
49
+ },
50
+ required: ["dir", "prompt"],
51
+ };
52
+ const idSchema = { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] };
53
+
54
+ const TOOLS = [
55
+ { name: "worker_start", description: "Start an isolated OpenCode worker job (free model by default). Returns jobId immediately.", inputSchema: runSchema },
56
+ { name: "worker_status", description: "Get job status: running | done | failed | cancelled | lost (server died mid-run). Works across server restarts.", inputSchema: idSchema },
57
+ { name: "worker_result", description: "Get the JSON result (diff, tests text, cost, tokens, actual model) of a finished job. Works across server restarts.", inputSchema: idSchema },
58
+ { name: "worker_cancel", description: "Cancel a running job owned by this server (SIGTERM, then SIGKILL).", inputSchema: idSchema },
59
+ ];
60
+
61
+ function textResult(obj, isError = false) {
62
+ return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }], isError };
63
+ }
64
+
65
+ function callTool(name, args, opts) {
66
+ if (name === "worker_start") {
67
+ const record = {
68
+ jobId: randomUUID(), status: "running", serverPid: process.pid,
69
+ dir: args.dir, startedAt: new Date().toISOString(), finishedAt: null, result: null, error: null,
70
+ };
71
+ const controller = new AbortController();
72
+ save(record);
73
+ const done = runWorker({ ...args, opencodeBin: opts.opencodeBin, signal: controller.signal }).then(
74
+ (r) => { record.result = r; record.status = r.errors.some((e) => e.type === "cancelled") ? "cancelled" : r.ok ? "done" : "failed"; },
75
+ (e) => { record.error = { type: e.code ?? "SETUP_ERROR", detail: e.message }; record.status = "failed"; },
76
+ ).then(() => {
77
+ record.finishedAt = new Date().toISOString();
78
+ save(record);
79
+ jobs.delete(record.jobId);
80
+ });
81
+ jobs.set(record.jobId, { record, controller, done });
82
+ return textResult({ jobId: record.jobId, status: "running" });
83
+ }
84
+ const job = load(args?.jobId);
85
+ if (!job) return textResult({ error: "unknown jobId" }, true);
86
+ const { jobId, status, startedAt, finishedAt, dir } = job;
87
+ if (name === "worker_status") return textResult({ jobId, status, startedAt, finishedAt, dir });
88
+ if (name === "worker_result") {
89
+ if (status === "running") return textResult({ jobId, status }, true);
90
+ if (status === "lost") return textResult({ ok: false, errors: [{ type: "lost", detail: "server exited before the job finished; inspect <dir>/.opencode-worker/runs" }] }, true);
91
+ return textResult(job.result ?? { ok: false, errors: [job.error] }, !job.result?.ok);
92
+ }
93
+ if (name === "worker_cancel") {
94
+ const live = jobs.get(jobId);
95
+ live?.controller.abort();
96
+ return textResult({ jobId, cancelRequested: Boolean(live), status });
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function shutdown(code) {
102
+ const live = [...jobs.values()];
103
+ for (const j of live) j.controller.abort();
104
+ await Promise.allSettled(live.map((j) => j.done));
105
+ process.exit(code);
106
+ }
107
+
108
+ export function serveMcp(opts = {}) {
109
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
110
+ const rl = readline.createInterface({ input: process.stdin });
111
+ rl.on("line", (line) => {
112
+ if (!line.trim()) return;
113
+ let msg;
114
+ try { msg = JSON.parse(line); } catch { return send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); }
115
+ const { id, method, params } = msg;
116
+ if (id === undefined) return; // notification
117
+ const reply = (result) => send({ jsonrpc: "2.0", id, result });
118
+ const fail = (code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
119
+ if (method === "initialize")
120
+ return reply({ protocolVersion: params?.protocolVersion ?? PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "opencode-worker", version: VERSION } });
121
+ if (method === "ping") return reply({});
122
+ if (method === "tools/list") return reply({ tools: TOOLS });
123
+ if (method === "tools/call") {
124
+ const r = callTool(params?.name, params?.arguments ?? {}, opts);
125
+ return r ? reply(r) : fail(-32602, `unknown tool: ${params?.name}`);
126
+ }
127
+ fail(-32601, `method not found: ${method}`);
128
+ });
129
+ rl.on("close", () => shutdown(0));
130
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => shutdown(0));
131
+ }
package/lib/worker.mjs ADDED
@@ -0,0 +1,217 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+ export const DEFAULTS = {
7
+ model: "opencode/deepseek-v4.1-flash-free",
8
+ variant: "low",
9
+ agent: "build",
10
+ timeoutSec: 180,
11
+ providers: ["opencode"],
12
+ };
13
+ const STATE_DIR = ".opencode-worker";
14
+ const HARD_DEADLINE_WRAPPER = ["/usr/bin/perl", "/bin/perl"].find((p) => fs.existsSync(p)) ?? null;
15
+ const TOOL_OUTPUT_MAX = 4000;
16
+
17
+ export class WorkerError extends Error {
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ export function resolveOpencodeBin(explicit) {
25
+ const candidates = [explicit, process.env.OPENCODE_WORKER_BIN].filter(Boolean);
26
+ for (const c of candidates) {
27
+ if (fs.existsSync(c)) return path.resolve(c);
28
+ throw new WorkerError("BIN_NOT_FOUND", `opencode binary not found: ${c}`);
29
+ }
30
+ const which = spawnSync("/usr/bin/env", ["which", "opencode"], { encoding: "utf8" });
31
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
32
+ throw new WorkerError("BIN_NOT_FOUND", "opencode not found (use --opencode-bin or OPENCODE_WORKER_BIN)");
33
+ }
34
+
35
+ function opencodeVersion(bin) {
36
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000, env: { PATH: process.env.PATH } });
37
+ if (r.status !== 0) throw new WorkerError("BIN_BROKEN", `opencode --version failed: ${r.stderr || r.error}`);
38
+ return r.stdout.trim();
39
+ }
40
+
41
+ /** Isolated environment: nothing from the parent except PATH and locale. No credentials or API keys. */
42
+ function isolatedEnv(stateRoot, configPath) {
43
+ const env = {
44
+ PATH: process.env.PATH || "/usr/bin:/bin:/usr/sbin:/sbin",
45
+ HOME: path.join(stateRoot, "home"),
46
+ XDG_CONFIG_HOME: path.join(stateRoot, "xdg/config"),
47
+ XDG_DATA_HOME: path.join(stateRoot, "xdg/data"),
48
+ XDG_CACHE_HOME: path.join(stateRoot, "xdg/cache"),
49
+ XDG_STATE_HOME: path.join(stateRoot, "xdg/state"),
50
+ OPENCODE_CONFIG: configPath,
51
+ TERM: "dumb",
52
+ };
53
+ for (const k of ["LANG", "LC_ALL", "TMPDIR"]) if (process.env[k]) env[k] = process.env[k];
54
+ for (const k of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"])
55
+ fs.mkdirSync(env[k], { recursive: true });
56
+ return env;
57
+ }
58
+
59
+ function snapshot(dir, dest) {
60
+ fs.mkdirSync(dest, { recursive: true });
61
+ for (const name of fs.readdirSync(dir)) {
62
+ if (name === STATE_DIR) continue;
63
+ fs.cpSync(path.join(dir, name), path.join(dest, name), { recursive: true, verbatimSymlinks: true });
64
+ }
65
+ }
66
+
67
+ function unifiedDiff(before, dir) {
68
+ const r = spawnSync("diff", ["-ruN", `--exclude=${STATE_DIR}`, "before", dir], {
69
+ cwd: path.dirname(before),
70
+ encoding: "utf8",
71
+ maxBuffer: 32 * 1024 * 1024,
72
+ });
73
+ // diff exit: 0 same, 1 different, 2 trouble
74
+ if (r.status === 2) return { diff: null, error: r.stderr.trim() };
75
+ return { diff: r.stdout.replaceAll(dir + "/", "after/"), error: null };
76
+ }
77
+
78
+ function parseEvents(stdout) {
79
+ const out = { text: "", tools: [], cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, errors: [], sessionID: null };
80
+ for (const line of stdout.split("\n")) {
81
+ if (!line.trim()) continue;
82
+ let ev;
83
+ try { ev = JSON.parse(line); } catch { out.errors.push({ type: "unparsed_line", detail: line.slice(0, 300) }); continue; }
84
+ out.sessionID ??= ev.sessionID ?? null;
85
+ const p = ev.part ?? {};
86
+ if (ev.type === "text") out.text += p.text ?? "";
87
+ else if (ev.type === "tool_use") {
88
+ const s = p.state ?? {};
89
+ const output = typeof s.output === "string" ? s.output : s.output == null ? null : JSON.stringify(s.output);
90
+ out.tools.push({
91
+ tool: p.tool,
92
+ status: s.status,
93
+ input: s.input,
94
+ exit: s.metadata?.exit ?? null,
95
+ output: output && output.length > TOOL_OUTPUT_MAX ? output.slice(0, TOOL_OUTPUT_MAX) + `…[truncated ${output.length - TOOL_OUTPUT_MAX} chars]` : output,
96
+ error: s.error ?? null,
97
+ });
98
+ }
99
+ else if (ev.type === "step_finish") {
100
+ out.cost += p.cost ?? 0;
101
+ const t = p.tokens ?? {};
102
+ out.tokens.input += t.input ?? 0;
103
+ out.tokens.output += t.output ?? 0;
104
+ out.tokens.reasoning += t.reasoning ?? 0;
105
+ out.tokens.cacheRead += t.cache?.read ?? 0;
106
+ out.tokens.cacheWrite += t.cache?.write ?? 0;
107
+ } else if (ev.type === "error") out.errors.push({ type: "event_error", detail: ev.error ?? p });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** Actual model/variant recorded by OpenCode, read from the isolated session DB. */
113
+ function actualModels(stateRoot, sessionID) {
114
+ const db = path.join(stateRoot, "xdg/data/opencode/opencode.db");
115
+ if (!fs.existsSync(db) || !sessionID || !/^[\w-]+$/.test(sessionID)) return null;
116
+ const q = `select distinct json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), json_extract(data,'$.variant') from message where session_id='${sessionID}' and json_extract(data,'$.role')='assistant';`;
117
+ const r = spawnSync("sqlite3", ["-separator", "\t", db, q], { encoding: "utf8" });
118
+ if (r.status !== 0) {
119
+ const r2 = spawnSync("sqlite3", ["-separator", "\t", db, q.replace(` session_id='${sessionID}' and`, "")], { encoding: "utf8" });
120
+ if (r2.status !== 0) return null;
121
+ r.stdout = r2.stdout;
122
+ }
123
+ return r.stdout.trim().split("\n").filter(Boolean).map((l) => {
124
+ const [provider, model, variant] = l.split("\t");
125
+ return { provider, model, variant: variant || null };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Run one OpenCode worker task.
131
+ * @param {{dir:string,prompt:string,model?:string,variant?:string,agent?:string,timeoutSec?:number,opencodeBin?:string,signal?:AbortSignal}} o
132
+ */
133
+ export async function runWorker(o) {
134
+ if (!o.dir || !o.prompt) throw new WorkerError("BAD_ARGS", "dir and prompt are required");
135
+ const dir = fs.realpathSync(path.resolve(o.dir));
136
+ if (!fs.statSync(dir).isDirectory()) throw new WorkerError("BAD_ARGS", `not a directory: ${dir}`);
137
+ const model = o.model ?? DEFAULTS.model;
138
+ const variant = o.variant ?? DEFAULTS.variant;
139
+ const agent = o.agent ?? DEFAULTS.agent;
140
+ const timeoutSec = o.timeoutSec ?? DEFAULTS.timeoutSec;
141
+ const provider = model.split("/")[0];
142
+ if (!DEFAULTS.providers.includes(provider))
143
+ throw new WorkerError("PROVIDER_NOT_ALLOWED", `provider '${provider}' not in ${JSON.stringify(DEFAULTS.providers)}`);
144
+
145
+ const bin = resolveOpencodeBin(o.opencodeBin);
146
+ const version = opencodeVersion(bin);
147
+
148
+ const runId = new Date().toISOString().replace(/[:.]/g, "-");
149
+ const stateRoot = path.join(dir, STATE_DIR);
150
+ const runDir = path.join(stateRoot, "runs", runId);
151
+ fs.mkdirSync(runDir, { recursive: true });
152
+ const configPath = path.join(stateRoot, "opencode.json");
153
+ fs.writeFileSync(configPath, JSON.stringify({
154
+ $schema: "https://opencode.ai/config.json",
155
+ model,
156
+ small_model: model,
157
+ enabled_providers: DEFAULTS.providers,
158
+ permission: { external_directory: "deny" },
159
+ }, null, 2));
160
+ const env = isolatedEnv(stateRoot, configPath);
161
+ const before = path.join(runDir, "before");
162
+ snapshot(dir, before);
163
+
164
+ const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, o.prompt];
165
+ const started = Date.now();
166
+ const { exit, signal, timedOut, cancelled, stdout, stderr } = await new Promise((resolve) => {
167
+ // Hard deadline that survives our own death: alarm() is inherited across exec,
168
+ // so the worker gets SIGALRM even if this process is SIGKILLed.
169
+ const hard = HARD_DEADLINE_WRAPPER
170
+ ? spawn(HARD_DEADLINE_WRAPPER, ["-e", "alarm shift; exec { $ARGV[0] } @ARGV or die $!", String(Math.ceil(timeoutSec) + 10), bin, ...args], { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] })
171
+ : null;
172
+ const child = hard ?? spawn(bin, args, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] });
173
+ let stdout = "", stderr = "", timedOut = false, cancelled = false;
174
+ child.stdout.on("data", (d) => (stdout += d));
175
+ child.stderr.on("data", (d) => (stderr += d));
176
+ const kill = () => {
177
+ child.kill("SIGTERM");
178
+ setTimeout(() => child.exitCode === null && child.kill("SIGKILL"), 5000).unref();
179
+ };
180
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
181
+ o.signal?.addEventListener("abort", () => { cancelled = true; kill(); }, { once: true });
182
+ child.on("error", (e) => { stderr += String(e); });
183
+ child.on("close", (exit, signal) => { clearTimeout(timer); resolve({ exit, signal, timedOut, cancelled, stdout, stderr }); });
184
+ });
185
+ const durationSec = (Date.now() - started) / 1000;
186
+ fs.writeFileSync(path.join(runDir, "events.jsonl"), stdout);
187
+ fs.writeFileSync(path.join(runDir, "stderr.txt"), stderr);
188
+
189
+ const ev = parseEvents(stdout);
190
+ const { diff, error: diffError } = unifiedDiff(before, dir);
191
+ const actual = actualModels(stateRoot, ev.sessionID);
192
+ const errors = [...ev.errors];
193
+ if (timedOut) errors.push({ type: "timeout", detail: `exceeded ${timeoutSec}s` });
194
+ if (cancelled) errors.push({ type: "cancelled" });
195
+ if (exit !== 0 && !timedOut && !cancelled) errors.push({ type: "nonzero_exit", detail: stderr.trim().slice(-2000) || `exit ${exit} signal ${signal}` });
196
+ if (diffError) errors.push({ type: "diff_failed", detail: diffError });
197
+ const modelId = model.split("/").slice(1).join("/");
198
+ if (actual && actual.some((a) => a.provider !== provider || a.model !== modelId))
199
+ errors.push({ type: "model_mismatch", detail: actual });
200
+
201
+ return {
202
+ schemaVersion: SCHEMA_VERSION,
203
+ ok: exit === 0 && errors.length === 0,
204
+ exit, signal, durationSec,
205
+ opencode: { bin, version },
206
+ requested: { model, variant, agent, timeoutSec },
207
+ actual,
208
+ sessionID: ev.sessionID,
209
+ text: ev.text,
210
+ tools: ev.tools,
211
+ cost: ev.cost,
212
+ tokens: ev.tokens,
213
+ diff,
214
+ errors,
215
+ artifacts: { runDir, events: path.join(runDir, "events.jsonl"), stderr: path.join(runDir, "stderr.txt") },
216
+ };
217
+ }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "opencode-worker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Run the official OpenCode CLI as an isolated, time-boxed worker. CLI + MCP stdio server.",
5
+ "type": "module",
6
+ "bin": { "opencode-worker": "bin/opencode-worker.mjs" },
7
+ "files": ["bin", "lib", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "license": "MIT"
10
+ }