opencode-worker 0.1.5 → 0.1.6

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
@@ -14,7 +14,8 @@ A set-but-empty `--opencode-bin`/`OPENCODE_WORKER_BIN` fails with `BIN_EMPTY` in
14
14
  - `HOME` and `XDG_{CONFIG,DATA,CACHE,STATE}_HOME` point into `<dir>/.opencode-worker/`.
15
15
  - Generated config: `model` = `small_model` = requested model, `enabled_providers: ["opencode"]`, `permission.external_directory: "deny"`.
16
16
  - Models outside the `opencode` provider are rejected (`PROVIDER_NOT_ALLOWED`); there is no fallback.
17
- - Job timeout: default 900 s (15 min). Set it once per install with `OPENCODE_WORKER_TIMEOUT_SEC` (CLI and MCP server), or per job with `--timeout` / `worker_start.timeoutSec`. On expiry: SIGTERM, then SIGKILL after 5 s; an `alarm()` 10 s later also kills the worker if this process itself died.
17
+ - No job time limit by default: a job runs until it finishes, `worker_cancel`, or the MCP session closes — like a subagent. Opt in to a limit with `OPENCODE_WORKER_TIMEOUT_SEC` (per install) or `--timeout` / `worker_start.timeoutSec` (per job); on expiry: SIGTERM, then SIGKILL after 5 s, plus an `alarm()` 10 s later that fires even if this process died.
18
+ - Cancel and timeout stop the whole process tree: opencode plus every command it spawned (tool shells run in their own process groups, so the tree is walked by parent pid).
18
19
  - Fixed flags: `run --pure --auto --format json --agent <agent> --model <model> --variant <variant>`.
19
20
 
20
21
  Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build`.
@@ -59,20 +60,20 @@ Recommended flow: `worker_start` → `worker_wait` (repeat while it returns `sta
59
60
  "opencode-worker": {
60
61
  "command": "npx",
61
62
  "args": ["-y", "opencode-worker", "mcp"],
62
- "env": { "OPENCODE_WORKER_BIN": "/path/to/opencode", "OPENCODE_WORKER_TIMEOUT_SEC": "1800" }
63
+ "env": { "OPENCODE_WORKER_BIN": "/path/to/opencode" }
63
64
  }
64
65
  }
65
66
  }
66
67
  ```
67
68
 
68
- `OPENCODE_WORKER_TIMEOUT_SEC` is optional (default 900); it is the job limit, independent of the per-call `worker_wait` limit.
69
+ Optional: `"OPENCODE_WORKER_TIMEOUT_SEC": "1800"` in `env` caps every job; omit it for no limit. It is independent of the per-call `worker_wait` limit.
69
70
 
70
71
  ### Client setup
71
72
 
72
73
  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):
73
74
 
74
75
  ```sh
75
- claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode -e OPENCODE_WORKER_TIMEOUT_SEC=1800 -- npx -y opencode-worker mcp
76
+ claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode -- npx -y opencode-worker mcp
76
77
  claude -p "…" --allowedTools "mcp__opencode-worker__worker_start,mcp__opencode-worker__worker_wait,mcp__opencode-worker__worker_status,mcp__opencode-worker__worker_result"
77
78
  ```
78
79
 
@@ -82,14 +83,14 @@ Codex (`~/.codex/config.toml`); without the approval mode, `codex exec` rejects
82
83
  [mcp_servers.opencode-worker]
83
84
  command = "npx"
84
85
  args = ["-y", "opencode-worker", "mcp"]
85
- env = { OPENCODE_WORKER_BIN = "/path/to/opencode", OPENCODE_WORKER_TIMEOUT_SEC = "1800" }
86
+ env = { OPENCODE_WORKER_BIN = "/path/to/opencode" }
86
87
  default_tools_approval_mode = "approve"
87
88
  ```
88
89
 
89
90
  OMP (`.omp/mcp.json` or `~/.omp/agent/mcp.json`): the JSON above with `"type": "stdio"`. Non-interactive: `omp -p --auto-approve "…"`.
90
91
 
91
92
  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.
92
- 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/`.
93
+ Closing stdin or SIGINT/SIGTERM/SIGHUP cancels running jobs and records them as `cancelled`. If the server is killed outright (crash, SIGKILL), a restarted server reports its unfinished jobs as `orphaned` when the recorded worker pid is still that opencode binary (stop it with `worker_cancel`), else `lost`. Events and stderr are streamed to `<dir>/.opencode-worker/runs/<id>/` while the job runs, so partial logs survive either way.
93
94
 
94
95
  ## Notes
95
96
 
@@ -13,7 +13,7 @@ Options (run):
13
13
  --model <id> default ${DEFAULTS.model} (provider must be one of ${DEFAULTS.providers.join(",")})
14
14
  --variant <v> default ${DEFAULTS.variant}
15
15
  --agent <name> default ${DEFAULTS.agent}
16
- --timeout <sec> default $OPENCODE_WORKER_TIMEOUT_SEC, else ${DEFAULTS.timeoutSec}
16
+ --timeout <sec> optional job time limit (default $OPENCODE_WORKER_TIMEOUT_SEC, else none)
17
17
  --opencode-bin <path> default $OPENCODE_WORKER_BIN, then PATH
18
18
  -f, --prompt-file <f> Read prompt from file
19
19
 
package/lib/mcp.mjs CHANGED
@@ -4,8 +4,9 @@ import readline from "node:readline";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { randomUUID } from "node:crypto";
8
- import { runWorker, DEFAULTS } from "./worker.mjs";
9
+ import { runWorker, killTree, DEFAULTS } from "./worker.mjs";
9
10
 
10
11
  const PROTOCOL = "2025-06-18";
11
12
  const jobs = new Map(); // live jobs of this server: id -> { record, controller, done }
@@ -26,17 +27,34 @@ function alive(pid) {
26
27
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
27
28
  }
28
29
 
30
+ // The recorded pid is ours only if it is still that opencode binary (guards against pid reuse).
31
+ function isOurWorker(record) {
32
+ if (!record.workerPid || !record.workerBin || !alive(record.workerPid)) return false;
33
+ const ps = spawnSync("ps", ["-o", "command=", "-p", String(record.workerPid)], { encoding: "utf8" });
34
+ return ps.status === 0 && ps.stdout.includes(record.workerBin);
35
+ }
36
+
29
37
  function load(jobId) {
30
38
  if (!UUID.test(jobId ?? "")) return null;
31
39
  const live = jobs.get(jobId);
32
40
  if (live) return live.record;
33
41
  let record;
34
42
  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";
43
+ // Owner server died mid-run: the worker either still runs unattended (orphaned) or is gone (lost).
44
+ if (record.status === "running" && !alive(record.serverPid)) record.status = isOurWorker(record) ? "orphaned" : "lost";
37
45
  return record;
38
46
  }
39
47
 
48
+ async function killOrphan(record) {
49
+ await killTree(record.workerPid);
50
+ Object.assign(record, {
51
+ status: "cancelled",
52
+ finishedAt: new Date().toISOString(),
53
+ error: { type: "orphan_killed", detail: `worker pid ${record.workerPid} outlived its server and was stopped; partial logs in ${record.runDir}` },
54
+ });
55
+ save(record);
56
+ }
57
+
40
58
  const runSchema = {
41
59
  type: "object",
42
60
  properties: {
@@ -45,7 +63,7 @@ const runSchema = {
45
63
  model: { type: "string", description: `default ${DEFAULTS.model}` },
46
64
  variant: { type: "string", description: `default ${DEFAULTS.variant}` },
47
65
  agent: { type: "string", description: `default ${DEFAULTS.agent}` },
48
- timeoutSec: { type: "number", description: `Job time limit in seconds; omit to use the server default ($OPENCODE_WORKER_TIMEOUT_SEC, else ${DEFAULTS.timeoutSec})` },
66
+ timeoutSec: { type: "number", description: "Optional job time limit in seconds. Default: $OPENCODE_WORKER_TIMEOUT_SEC if set, else no limit (runs until done or worker_cancel)." },
49
67
  },
50
68
  required: ["dir", "prompt"],
51
69
  };
@@ -64,14 +82,15 @@ const waitSchema = {
64
82
  const TOOLS = [
65
83
  { name: "worker_start", description: "Start an isolated OpenCode worker job (free model by default). Returns jobId immediately. Then call worker_wait.", inputSchema: runSchema },
66
84
  { name: "worker_wait", description: "Block until the job finishes or timeoutSec elapses. Finished: returns the full result (same as worker_result). Still running: returns {status: \"running\"}; call worker_wait again. Prefer this over polling worker_status.", inputSchema: waitSchema },
67
- { name: "worker_status", description: "Get job status without waiting: running | done | failed | cancelled | lost (server died mid-run). Works across server restarts.", inputSchema: idSchema },
85
+ { name: "worker_status", description: "Get job status without waiting: running | done | failed | cancelled | orphaned (its server died but the worker still runs; stop it with worker_cancel) | lost (server and worker both gone). Works across server restarts.", inputSchema: idSchema },
68
86
  { name: "worker_result", description: "Get the JSON result (diff, tool outputs, cost, tokens, actual model) of a finished job. Works across server restarts.", inputSchema: idSchema },
69
- { name: "worker_cancel", description: "Cancel a running job owned by this server (SIGTERM, then SIGKILL).", inputSchema: idSchema },
87
+ { name: "worker_cancel", description: "Stop a running job (SIGTERM, then SIGKILL, to the worker's whole process group). Also stops orphaned jobs left by a crashed server.", inputSchema: idSchema },
70
88
  ];
71
89
 
72
90
  function resultOf(job) {
73
91
  if (job.status === "running") return textResult({ jobId: job.jobId, status: "running" }, true);
74
- if (job.status === "lost") return textResult({ ok: false, errors: [{ type: "lost", detail: "server exited before the job finished; inspect <dir>/.opencode-worker/runs" }] }, true);
92
+ if (job.status === "lost") return textResult({ jobId: job.jobId, status: "lost", ok: false, errors: [{ type: "lost", detail: `server and worker exited before the job finished; partial logs in ${job.runDir ?? "<dir>/.opencode-worker/runs"}` }] }, true);
93
+ if (job.status === "orphaned") return textResult({ jobId: job.jobId, status: "orphaned", ok: false, workerPid: job.workerPid, errors: [{ type: "orphaned", detail: `server died; worker pid ${job.workerPid} still runs but its result can no longer be collected. Call worker_cancel to stop it. Partial logs in ${job.runDir}` }] }, true);
75
94
  return textResult({ jobId: job.jobId, status: job.status, ...(job.result ?? { ok: false, errors: [job.error] }) }, !job.result?.ok);
76
95
  }
77
96
 
@@ -103,7 +122,8 @@ async function callTool(name, args, opts) {
103
122
  };
104
123
  const controller = new AbortController();
105
124
  save(record);
106
- const done = runWorker({ ...args, opencodeBin: opts.opencodeBin, signal: controller.signal }).then(
125
+ const onSpawn = ({ pid, bin, runDir }) => { Object.assign(record, { workerPid: pid, workerBin: bin, runDir }); save(record); };
126
+ const done = runWorker({ ...args, opencodeBin: opts.opencodeBin, signal: controller.signal, onSpawn }).then(
107
127
  (r) => { record.result = r; record.status = r.errors.some((e) => e.type === "cancelled") ? "cancelled" : r.ok ? "done" : "failed"; },
108
128
  (e) => { record.error = { type: e.code ?? "SETUP_ERROR", detail: e.message }; record.status = "failed"; },
109
129
  ).then(() => {
@@ -117,13 +137,20 @@ async function callTool(name, args, opts) {
117
137
  if (name === "worker_wait") return waitFor(args?.jobId, args?.timeoutSec);
118
138
  const job = load(args?.jobId);
119
139
  if (!job) return textResult({ error: "unknown jobId" }, true);
120
- const { jobId, status, startedAt, finishedAt, dir } = job;
121
- if (name === "worker_status") return textResult({ jobId, status, startedAt, finishedAt, dir });
140
+ const { jobId, status, startedAt, finishedAt, dir, workerPid } = job;
141
+ if (name === "worker_status") return textResult({ jobId, status, startedAt, finishedAt, dir, workerPid });
122
142
  if (name === "worker_result") return resultOf(job);
123
143
  if (name === "worker_cancel") {
124
144
  const live = jobs.get(jobId);
125
- live?.controller.abort();
126
- return textResult({ jobId, cancelRequested: Boolean(live), status });
145
+ if (live) {
146
+ live.controller.abort();
147
+ return textResult({ jobId, cancelRequested: true, status });
148
+ }
149
+ if (status === "orphaned") {
150
+ await killOrphan(job);
151
+ return textResult({ jobId, cancelRequested: true, status: job.status, killedPid: workerPid });
152
+ }
153
+ return textResult({ jobId, cancelRequested: false, status });
127
154
  }
128
155
  return null;
129
156
  }
package/lib/worker.mjs CHANGED
@@ -7,7 +7,7 @@ export const DEFAULTS = {
7
7
  model: "opencode/deepseek-v4.1-flash-free",
8
8
  variant: "low",
9
9
  agent: "build",
10
- timeoutSec: 900,
10
+ timeoutSec: null, // no job time limit by default; stop via cancel or session close
11
11
  providers: ["opencode"],
12
12
  };
13
13
  const STATE_DIR = ".opencode-worker";
@@ -132,7 +132,7 @@ function actualModels(stateRoot, sessionID) {
132
132
  });
133
133
  }
134
134
 
135
- /** Job timeout default: OPENCODE_WORKER_TIMEOUT_SEC if set, else DEFAULTS.timeoutSec. */
135
+ /** Job time limit: OPENCODE_WORKER_TIMEOUT_SEC if set, else none (run until done or cancelled). */
136
136
  export function defaultTimeoutSec() {
137
137
  const raw = process.env.OPENCODE_WORKER_TIMEOUT_SEC;
138
138
  if (raw === undefined) return DEFAULTS.timeoutSec;
@@ -142,9 +142,42 @@ export function defaultTimeoutSec() {
142
142
  return n;
143
143
  }
144
144
 
145
+ /** pid plus all descendants. opencode runs tool commands in their own process groups, so a group kill misses them. */
146
+ export function treePids(root) {
147
+ const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
148
+ const children = new Map();
149
+ for (const line of ps.stdout.split("\n")) {
150
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
151
+ if (pid) children.set(ppid, [...(children.get(ppid) ?? []), pid]);
152
+ }
153
+ const out = [root];
154
+ for (let i = 0; i < out.length; i++) out.push(...(children.get(out[i]) ?? []));
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Stop a worker and every command it spawned: SIGTERM, then SIGKILL after graceMs.
160
+ * The tree is captured before signalling because children reparent to init once opencode exits.
161
+ */
162
+ export function killTree(root, graceMs = 5000) {
163
+ const pids = new Set(treePids(root));
164
+ const signalAll = (sig) => {
165
+ for (const pid of pids) {
166
+ try { process.kill(-pid, sig); } catch {} // tool shells lead their own groups
167
+ try { process.kill(pid, sig); } catch {}
168
+ }
169
+ };
170
+ signalAll("SIGTERM");
171
+ return new Promise((resolve) => setTimeout(() => {
172
+ for (const pid of treePids(root)) pids.add(pid);
173
+ signalAll("SIGKILL");
174
+ resolve([...pids]);
175
+ }, graceMs).unref());
176
+ }
177
+
145
178
  /**
146
179
  * Run one OpenCode worker task.
147
- * @param {{dir:string,prompt:string,model?:string,variant?:string,agent?:string,timeoutSec?:number,opencodeBin?:string,signal?:AbortSignal}} o
180
+ * @param {{dir:string,prompt:string,model?:string,variant?:string,agent?:string,timeoutSec?:number|null,opencodeBin?:string,signal?:AbortSignal,onSpawn?:(info:{pid:number,bin:string,runDir:string})=>void}} o
148
181
  */
149
182
  export async function runWorker(o) {
150
183
  if (!o.dir || !o.prompt) throw new WorkerError("BAD_ARGS", "dir and prompt are required");
@@ -154,7 +187,8 @@ export async function runWorker(o) {
154
187
  const variant = o.variant ?? DEFAULTS.variant;
155
188
  const agent = o.agent ?? DEFAULTS.agent;
156
189
  const timeoutSec = o.timeoutSec ?? defaultTimeoutSec();
157
- if (!(Number.isFinite(timeoutSec) && timeoutSec > 0)) throw new WorkerError("BAD_ARGS", `timeoutSec must be a positive number, got ${o.timeoutSec}`);
190
+ if (timeoutSec !== null && !(Number.isFinite(timeoutSec) && timeoutSec > 0))
191
+ throw new WorkerError("BAD_ARGS", `timeoutSec must be a positive number, got ${o.timeoutSec}`);
158
192
  const provider = model.split("/")[0];
159
193
  if (!DEFAULTS.providers.includes(provider))
160
194
  throw new WorkerError("PROVIDER_NOT_ALLOWED", `provider '${provider}' not in ${JSON.stringify(DEFAULTS.providers)}`);
@@ -180,28 +214,29 @@ export async function runWorker(o) {
180
214
 
181
215
  const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, o.prompt];
182
216
  const started = Date.now();
217
+ const eventsPath = path.join(runDir, "events.jsonl");
218
+ const stderrPath = path.join(runDir, "stderr.txt");
183
219
  const { exit, signal, timedOut, cancelled, stdout, stderr } = await new Promise((resolve) => {
184
- // Hard deadline that survives our own death: alarm() is inherited across exec,
185
- // so the worker gets SIGALRM even if this process is SIGKILLed.
186
- const hard = HARD_DEADLINE_WRAPPER
187
- ? 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"] })
188
- : null;
189
- const child = hard ?? spawn(bin, args, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] });
220
+ // Not detached: terminal Ctrl+C on the CLI still reaches opencode.
221
+ const spawnOpts = { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] };
222
+ // Optional hard deadline that survives our own death: alarm() is inherited across exec.
223
+ const child = timeoutSec !== null && HARD_DEADLINE_WRAPPER
224
+ ? spawn(HARD_DEADLINE_WRAPPER, ["-e", "alarm shift; exec { $ARGV[0] } @ARGV or die $!", String(Math.ceil(timeoutSec) + 10), bin, ...args], spawnOpts)
225
+ : spawn(bin, args, spawnOpts);
190
226
  let stdout = "", stderr = "", timedOut = false, cancelled = false;
191
- child.stdout.on("data", (d) => (stdout += d));
192
- child.stderr.on("data", (d) => (stderr += d));
193
- const kill = () => {
194
- child.kill("SIGTERM");
195
- setTimeout(() => child.exitCode === null && child.kill("SIGKILL"), 5000).unref();
196
- };
197
- const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
227
+ // Streamed to disk so a run stays inspectable even if this process dies mid-run.
228
+ child.stdout.on("data", (d) => { stdout += d; fs.appendFileSync(eventsPath, d); });
229
+ child.stderr.on("data", (d) => { stderr += d; fs.appendFileSync(stderrPath, d); });
230
+ if (child.pid) o.onSpawn?.({ pid: child.pid, bin, runDir });
231
+ const kill = () => killTree(child.pid);
232
+ const timer = timeoutSec === null ? null : setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
198
233
  o.signal?.addEventListener("abort", () => { cancelled = true; kill(); }, { once: true });
199
234
  child.on("error", (e) => { stderr += String(e); });
200
235
  child.on("close", (exit, signal) => { clearTimeout(timer); resolve({ exit, signal, timedOut, cancelled, stdout, stderr }); });
201
236
  });
202
237
  const durationSec = (Date.now() - started) / 1000;
203
- fs.writeFileSync(path.join(runDir, "events.jsonl"), stdout);
204
- fs.writeFileSync(path.join(runDir, "stderr.txt"), stderr);
238
+ fs.writeFileSync(eventsPath, stdout);
239
+ fs.writeFileSync(stderrPath, stderr);
205
240
 
206
241
  const ev = parseEvents(stdout);
207
242
  const { diff, error: diffError } = unifiedDiff(before, dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worker",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Run the official OpenCode CLI as an isolated, time-boxed worker. CLI + MCP stdio server.",
5
5
  "repository": {
6
6
  "type": "git",