opencode-worker 0.1.6 → 0.1.8

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
@@ -12,7 +12,9 @@ A set-but-empty `--opencode-bin`/`OPENCODE_WORKER_BIN` fails with `BIN_EMPTY` in
12
12
 
13
13
  - Child env is rebuilt from scratch: only `PATH`, `LANG`, `LC_ALL`, `TMPDIR` pass through. No API keys or credentials.
14
14
  - `HOME` and `XDG_{CONFIG,DATA,CACHE,STATE}_HOME` point into `<dir>/.opencode-worker/`.
15
- - Generated config: `model` = `small_model` = requested model, `enabled_providers: ["opencode"]`, `permission.external_directory: "deny"`.
15
+ - Generated config: `model` = `small_model` = requested model, `enabled_providers: ["opencode"]`.
16
+ - Project config is ignored: `opencode.json`, `.opencode/` agents, plugins and MCP in the task dir are not loaded (`OPENCODE_DISABLE_PROJECT_CONFIG=1`, private `OPENCODE_CONFIG_DIR`). `AGENTS.md` still applies.
17
+ - Tools are default-deny and set by `role` (below); `external_directory` is always denied. No web fetch, no sub-agents.
16
18
  - Models outside the `opencode` provider are rejected (`PROVIDER_NOT_ALLOWED`); there is no fallback.
17
19
  - 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
20
  - 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).
@@ -20,6 +22,34 @@ A set-but-empty `--opencode-bin`/`OPENCODE_WORKER_BIN` fails with `BIN_EMPTY` in
20
22
 
21
23
  Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build`.
22
24
 
25
+ ## Reasoning
26
+
27
+ `--variant` / `worker_start.variant` selects reasoning effort. Valid values are the model's own variants (read from `opencode models`) plus `none`:
28
+
29
+ | variant | effect |
30
+ |---|---|
31
+ | `none` | reasoning off (added by opencode-worker as `reasoningEffort: "none"`) |
32
+ | `low` (default) | light reasoning |
33
+ | `high` | more reasoning |
34
+ | `max` | most reasoning |
35
+
36
+ Unknown values (e.g. `medium`, which this model does not define) fail with `BAD_VARIANT` instead of silently using the provider default.
37
+ On short tasks the difference is small: in our runs `low`/`high`/`max` averaged roughly 30/45/70 reasoning tokens and `none` produced 0, with the same answers.
38
+
39
+ ## Roles
40
+
41
+ `--role` / `worker_start.role`:
42
+
43
+ | role | allowed tools | dir sharing |
44
+ |---|---|---|
45
+ | `implementer` (default) | read, list, glob, grep, edit (incl. write/patch), bash, todo | exclusive |
46
+ | `tester` | read, list, glob, grep, bash, todo — no edit tool | exclusive |
47
+ | `reviewer` | read, list, glob, grep, todo — no edit, no bash | shared with other reviewers |
48
+
49
+ `tester` and `reviewer` also get a one-line role brief prepended to the prompt. A tester can still write files through `bash`; this is a tool policy, not a sandbox — check `diff`.
50
+
51
+ One `implementer`/`tester` job per `dir` at a time; any number of reviewers may share a dir, but not with an exclusive job. A conflicting start fails immediately with `DIR_BUSY` (MCP `worker_start` returns it directly). An orphaned worker keeps holding its dir until `worker_cancel` stops it.
52
+
23
53
  ## CLI
24
54
 
25
55
  ```sh
@@ -96,6 +126,8 @@ Closing stdin or SIGINT/SIGTERM/SIGHUP cancels running jobs and records them as
96
126
 
97
127
  - `<dir>/.opencode-worker/` holds state, snapshots and logs per run; delete it when done.
98
128
  - The before-snapshot copies the whole directory: use small, task-scoped directories.
129
+ - Parallel jobs are allowed but share the free endpoint and your CPU: with 6 concurrent jobs a ~10 s task took up to ~30 s. Run a few at a time.
130
+ - Give each parallel implementer its own `dir` (e.g. a git worktree per task); same-dir starts get `DIR_BUSY`.
99
131
 
100
132
  ## Releasing
101
133
 
@@ -11,7 +11,8 @@ const USAGE = `Usage:
11
11
  Options (run):
12
12
  --dir <dir> Working directory; the worker is confined to it (required)
13
13
  --model <id> default ${DEFAULTS.model} (provider must be one of ${DEFAULTS.providers.join(",")})
14
- --variant <v> default ${DEFAULTS.variant}
14
+ --variant <v> reasoning: none | low | high | max (default ${DEFAULTS.variant})
15
+ --role <role> implementer (default) | tester | reviewer
15
16
  --agent <name> default ${DEFAULTS.agent}
16
17
  --timeout <sec> optional job time limit (default $OPENCODE_WORKER_TIMEOUT_SEC, else none)
17
18
  --opencode-bin <path> default $OPENCODE_WORKER_BIN, then PATH
@@ -27,7 +28,7 @@ try {
27
28
  allowPositionals: true,
28
29
  options: {
29
30
  dir: { type: "string" }, model: { type: "string" }, variant: { type: "string" },
30
- agent: { type: "string" }, timeout: { type: "string" }, "opencode-bin": { type: "string" },
31
+ agent: { type: "string" }, role: { type: "string" }, timeout: { type: "string" }, "opencode-bin": { type: "string" },
31
32
  "prompt-file": { type: "string", short: "f" },
32
33
  },
33
34
  });
@@ -36,7 +37,7 @@ try {
36
37
  const timeoutSec = values.timeout === undefined ? undefined : Number(values.timeout);
37
38
  if (timeoutSec !== undefined && !(timeoutSec > 0)) throw new WorkerError("BAD_ARGS", "--timeout must be a positive number");
38
39
  const result = await runWorker({
39
- dir: values.dir, prompt, model: values.model, variant: values.variant, agent: values.agent,
40
+ dir: values.dir, prompt, model: values.model, variant: values.variant, agent: values.agent, role: values.role,
40
41
  timeoutSec, opencodeBin: values["opencode-bin"],
41
42
  });
42
43
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
package/lib/mcp.mjs CHANGED
@@ -6,7 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { randomUUID } from "node:crypto";
9
- import { runWorker, killTree, DEFAULTS } from "./worker.mjs";
9
+ import { runWorker, killTree, alive, ROLES, DEFAULTS } from "./worker.mjs";
10
10
 
11
11
  const PROTOCOL = "2025-06-18";
12
12
  const jobs = new Map(); // live jobs of this server: id -> { record, controller, done }
@@ -23,10 +23,6 @@ function save(record) {
23
23
  fs.renameSync(file + ".tmp", file);
24
24
  }
25
25
 
26
- function alive(pid) {
27
- try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
28
- }
29
-
30
26
  // The recorded pid is ours only if it is still that opencode binary (guards against pid reuse).
31
27
  function isOurWorker(record) {
32
28
  if (!record.workerPid || !record.workerBin || !alive(record.workerPid)) return false;
@@ -61,7 +57,12 @@ const runSchema = {
61
57
  dir: { type: "string", description: "Absolute working directory; the worker is confined to it" },
62
58
  prompt: { type: "string", description: "Task for the OpenCode worker" },
63
59
  model: { type: "string", description: `default ${DEFAULTS.model}` },
64
- variant: { type: "string", description: `default ${DEFAULTS.variant}` },
60
+ variant: { type: "string", description: "Reasoning effort: none | low | high | max (model-defined set; default low)" },
61
+ role: {
62
+ type: "string",
63
+ enum: Object.keys(ROLES),
64
+ description: "implementer (default): edit + bash. tester: bash, no edits. reviewer: read/search only; several reviewers may share a dir. Only one implementer/tester job per dir at a time.",
65
+ },
65
66
  agent: { type: "string", description: `default ${DEFAULTS.agent}` },
66
67
  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)." },
67
68
  },
@@ -122,7 +123,9 @@ async function callTool(name, args, opts) {
122
123
  };
123
124
  const controller = new AbortController();
124
125
  save(record);
125
- const onSpawn = ({ pid, bin, runDir }) => { Object.assign(record, { workerPid: pid, workerBin: bin, runDir }); save(record); };
126
+ let spawned;
127
+ const started = new Promise((r) => { spawned = r; });
128
+ const onSpawn = ({ pid, bin, runDir }) => { Object.assign(record, { workerPid: pid, workerBin: bin, runDir }); save(record); spawned(); };
126
129
  const done = runWorker({ ...args, opencodeBin: opts.opencodeBin, signal: controller.signal, onSpawn }).then(
127
130
  (r) => { record.result = r; record.status = r.errors.some((e) => e.type === "cancelled") ? "cancelled" : r.ok ? "done" : "failed"; },
128
131
  (e) => { record.error = { type: e.code ?? "SETUP_ERROR", detail: e.message }; record.status = "failed"; },
@@ -132,6 +135,10 @@ async function callTool(name, args, opts) {
132
135
  jobs.delete(record.jobId);
133
136
  });
134
137
  jobs.set(record.jobId, { record, controller, done });
138
+ // Setup failures (bad variant, DIR_BUSY, missing binary) surface here instead of on the first wait.
139
+ await Promise.race([started, done]);
140
+ if (record.status === "failed" && record.error)
141
+ return textResult({ jobId: record.jobId, status: "failed", ok: false, errors: [record.error] }, true);
135
142
  return textResult({ jobId: record.jobId, status: "running" });
136
143
  }
137
144
  if (name === "worker_wait") return waitFor(args?.jobId, args?.timeoutSec);
package/lib/worker.mjs CHANGED
@@ -7,6 +7,7 @@ export const DEFAULTS = {
7
7
  model: "opencode/deepseek-v4.1-flash-free",
8
8
  variant: "low",
9
9
  agent: "build",
10
+ role: "implementer",
10
11
  timeoutSec: null, // no job time limit by default; stop via cancel or session close
11
12
  providers: ["opencode"],
12
13
  };
@@ -54,14 +55,38 @@ function isolatedEnv(stateRoot, configPath) {
54
55
  XDG_CACHE_HOME: path.join(stateRoot, "xdg/cache"),
55
56
  XDG_STATE_HOME: path.join(stateRoot, "xdg/state"),
56
57
  OPENCODE_CONFIG: configPath,
58
+ // Without these, an opencode.json / .opencode/ in the task dir is merged in
59
+ // (agents, providers, MCP, plugins) and can override our free-only config.
60
+ OPENCODE_CONFIG_DIR: path.join(stateRoot, "xdg/config/opencode"),
61
+ OPENCODE_DISABLE_PROJECT_CONFIG: "1",
57
62
  TERM: "dumb",
58
63
  };
59
64
  for (const k of ["LANG", "LC_ALL", "TMPDIR"]) if (process.env[k]) env[k] = process.env[k];
60
- for (const k of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"])
65
+ for (const k of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "OPENCODE_CONFIG_DIR"])
61
66
  fs.mkdirSync(env[k], { recursive: true });
62
67
  return env;
63
68
  }
64
69
 
70
+ // Default-deny: tools added later by OpenCode, plugins or MCP never become available implicitly.
71
+ export const ALLOWED_TOOLS = ["read", "list", "glob", "grep", "edit", "bash", "todowrite", "todoread"];
72
+ const NO_REASONING_VARIANT = "none";
73
+ const variantCache = new Map();
74
+
75
+ /** Variants OpenCode defines for the model, plus our "none" (reasoning off). */
76
+ function modelVariants(bin, env, model) {
77
+ if (variantCache.has(model)) return variantCache.get(model);
78
+ const [provider] = model.split("/");
79
+ const r = spawnSync(bin, ["models", provider, "--verbose"], { encoding: "utf8", env, timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
80
+ const at = r.stdout?.indexOf(`${model}\n{`) ?? -1;
81
+ if (r.status !== 0 || at < 0) throw new WorkerError("MODEL_NOT_FOUND", `model ${model} is not listed by 'opencode models ${provider}'`);
82
+ // The JSON object for this model ends at the first line that is exactly "}".
83
+ const body = r.stdout.slice(at + model.length + 1);
84
+ const meta = JSON.parse(body.slice(0, body.indexOf("\n}") + 2));
85
+ const variants = [...Object.keys(meta.variants ?? {}), NO_REASONING_VARIANT];
86
+ variantCache.set(model, variants);
87
+ return variants;
88
+ }
89
+
65
90
  function snapshot(dir, dest) {
66
91
  fs.mkdirSync(dest, { recursive: true });
67
92
  for (const name of fs.readdirSync(dir)) {
@@ -175,9 +200,52 @@ export function killTree(root, graceMs = 5000) {
175
200
  }, graceMs).unref());
176
201
  }
177
202
 
203
+ export function alive(pid) {
204
+ if (!pid) return false;
205
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
206
+ }
207
+
208
+ const READ_TOOLS = ["read", "list", "glob", "grep", "todowrite", "todoread"];
209
+ /**
210
+ * Roles = tool allowlist + a one-line brief prepended to the prompt.
211
+ * `exclusive` roles may change files (tester via bash), so they need the dir to themselves.
212
+ */
213
+ export const ROLES = {
214
+ implementer: { tools: ALLOWED_TOOLS, exclusive: true, brief: null },
215
+ tester: { tools: [...READ_TOOLS, "bash"], exclusive: true, brief: "Role: tester. Run commands to test and verify. Do not modify source files; report what passed and what failed." },
216
+ reviewer: { tools: READ_TOOLS, exclusive: false, brief: "Role: reviewer. You can only read and search; you cannot edit files or run commands. Report findings with file and line references." },
217
+ };
218
+
219
+ /**
220
+ * One exclusive job per dir, or any number of reviewers. A lock is live while its
221
+ * owner process or its worker is alive, so an orphaned worker still holds the dir.
222
+ */
223
+ function acquireDirLock(stateRoot, runId, role) {
224
+ const locksDir = path.join(stateRoot, "locks");
225
+ fs.mkdirSync(locksDir, { recursive: true });
226
+ const live = [];
227
+ for (const name of fs.readdirSync(locksDir)) {
228
+ const file = path.join(locksDir, name);
229
+ let lock;
230
+ try { lock = JSON.parse(fs.readFileSync(file, "utf8")); } catch { continue; }
231
+ if (alive(lock.pid) || alive(lock.workerPid)) live.push(lock);
232
+ else fs.rmSync(file, { force: true });
233
+ }
234
+ const conflict = ROLES[role].exclusive ? live : live.filter((l) => ROLES[l.role]?.exclusive !== false);
235
+ if (conflict.length)
236
+ throw new WorkerError("DIR_BUSY", `dir already has a running ${conflict.map((l) => l.role).join(", ")} job (run ${conflict.map((l) => l.runId).join(", ")}); wait for it, cancel it, or use another dir`);
237
+ const file = path.join(locksDir, `${runId}.json`);
238
+ const lock = { runId, role, pid: process.pid, workerPid: null };
239
+ fs.writeFileSync(file, JSON.stringify(lock));
240
+ return {
241
+ setWorker(pid) { lock.workerPid = pid; fs.writeFileSync(file, JSON.stringify(lock)); },
242
+ release() { fs.rmSync(file, { force: true }); },
243
+ };
244
+ }
245
+
178
246
  /**
179
247
  * Run one OpenCode worker task.
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
248
+ * @param {{dir:string,prompt:string,role?:"implementer"|"tester"|"reviewer",model?:string,variant?:string,agent?:string,timeoutSec?:number|null,opencodeBin?:string,signal?:AbortSignal,onSpawn?:(info:{pid:number,bin:string,runDir:string})=>void}} o
181
249
  */
182
250
  export async function runWorker(o) {
183
251
  if (!o.dir || !o.prompt) throw new WorkerError("BAD_ARGS", "dir and prompt are required");
@@ -186,6 +254,8 @@ export async function runWorker(o) {
186
254
  const model = o.model ?? DEFAULTS.model;
187
255
  const variant = o.variant ?? DEFAULTS.variant;
188
256
  const agent = o.agent ?? DEFAULTS.agent;
257
+ const role = o.role ?? DEFAULTS.role;
258
+ if (!Object.hasOwn(ROLES, role)) throw new WorkerError("BAD_ROLE", `role must be one of ${Object.keys(ROLES).join(", ")}, got '${role}'`);
189
259
  const timeoutSec = o.timeoutSec ?? defaultTimeoutSec();
190
260
  if (timeoutSec !== null && !(Number.isFinite(timeoutSec) && timeoutSec > 0))
191
261
  throw new WorkerError("BAD_ARGS", `timeoutSec must be a positive number, got ${o.timeoutSec}`);
@@ -196,23 +266,42 @@ export async function runWorker(o) {
196
266
  const bin = resolveOpencodeBin(o.opencodeBin);
197
267
  const version = opencodeVersion(bin);
198
268
 
199
- const runId = new Date().toISOString().replace(/[:.]/g, "-");
269
+ const runId = new Date().toISOString().replace(/[:.]/g, "-") + "-" + Math.random().toString(36).slice(2, 6);
200
270
  const stateRoot = path.join(dir, STATE_DIR);
201
271
  const runDir = path.join(stateRoot, "runs", runId);
202
272
  fs.mkdirSync(runDir, { recursive: true });
203
- const configPath = path.join(stateRoot, "opencode.json");
204
- fs.writeFileSync(configPath, JSON.stringify({
273
+ const configPath = path.join(runDir, "opencode.json"); // per run: concurrent reviewers may differ in variant
274
+ const modelId = model.split("/").slice(1).join("/");
275
+ const permission = Object.fromEntries([["*", "deny"], ...ROLES[role].tools.map((t) => [t, "allow"]), ["external_directory", "deny"]]);
276
+ const config = {
205
277
  $schema: "https://opencode.ai/config.json",
206
278
  model,
207
279
  small_model: model,
208
280
  enabled_providers: DEFAULTS.providers,
209
- permission: { external_directory: "deny" },
210
- }, null, 2));
281
+ permission,
282
+ };
283
+ if (variant === NO_REASONING_VARIANT)
284
+ config.provider = { [provider]: { models: { [modelId]: { variants: { [NO_REASONING_VARIANT]: { reasoningEffort: "none" } } } } } };
285
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
211
286
  const env = isolatedEnv(stateRoot, configPath);
287
+ const variants = modelVariants(bin, env, model);
288
+ if (!variants.includes(variant))
289
+ throw new WorkerError("BAD_VARIANT", `variant '${variant}' is not defined for ${model}; available: ${variants.join(", ")}`);
290
+ const lock = acquireDirLock(stateRoot, runId, role);
291
+ try {
292
+ return await collect({ o, dir, stateRoot, runDir, role, agent, model, modelId, provider, variant, timeoutSec, env, bin, version, lock });
293
+ } finally {
294
+ lock.release();
295
+ }
296
+ }
297
+
298
+ /** Snapshot, spawn opencode, gather events and diff. Runs while the dir lock is held. */
299
+ async function collect({ o, dir, stateRoot, runDir, role, agent, model, modelId, provider, variant, timeoutSec, env, bin, version, lock }) {
212
300
  const before = path.join(runDir, "before");
213
301
  snapshot(dir, before);
214
302
 
215
- const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, o.prompt];
303
+ const prompt = ROLES[role].brief ? `${ROLES[role].brief}\n\n${o.prompt}` : o.prompt;
304
+ const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, prompt];
216
305
  const started = Date.now();
217
306
  const eventsPath = path.join(runDir, "events.jsonl");
218
307
  const stderrPath = path.join(runDir, "stderr.txt");
@@ -227,7 +316,10 @@ export async function runWorker(o) {
227
316
  // Streamed to disk so a run stays inspectable even if this process dies mid-run.
228
317
  child.stdout.on("data", (d) => { stdout += d; fs.appendFileSync(eventsPath, d); });
229
318
  child.stderr.on("data", (d) => { stderr += d; fs.appendFileSync(stderrPath, d); });
230
- if (child.pid) o.onSpawn?.({ pid: child.pid, bin, runDir });
319
+ if (child.pid) {
320
+ lock.setWorker(child.pid);
321
+ o.onSpawn?.({ pid: child.pid, bin, runDir });
322
+ }
231
323
  const kill = () => killTree(child.pid);
232
324
  const timer = timeoutSec === null ? null : setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
233
325
  o.signal?.addEventListener("abort", () => { cancelled = true; kill(); }, { once: true });
@@ -246,7 +338,6 @@ export async function runWorker(o) {
246
338
  if (cancelled) errors.push({ type: "cancelled" });
247
339
  if (exit !== 0 && !timedOut && !cancelled) errors.push({ type: "nonzero_exit", detail: stderr.trim().slice(-2000) || `exit ${exit} signal ${signal}` });
248
340
  if (diffError) errors.push({ type: "diff_failed", detail: diffError });
249
- const modelId = model.split("/").slice(1).join("/");
250
341
  if (actual && actual.some((a) => a.provider !== provider || a.model !== modelId))
251
342
  errors.push({ type: "model_mismatch", detail: actual });
252
343
 
@@ -255,7 +346,7 @@ export async function runWorker(o) {
255
346
  ok: exit === 0 && errors.length === 0,
256
347
  exit, signal, durationSec,
257
348
  opencode: { bin, version },
258
- requested: { model, variant, agent, timeoutSec },
349
+ requested: { model, variant, agent, role, timeoutSec },
259
350
  actual,
260
351
  sessionID: ev.sessionID,
261
352
  text: ev.text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worker",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",