opencode-worker 0.1.7 → 0.1.9

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,7 @@ 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"]`.
16
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: only `read`, `list`, `glob`, `grep`, `edit` (incl. write/patch), `bash`, `todowrite`, `todoread` are allowed; `external_directory` is denied. No web fetch, no sub-agents.
17
+ - Tools are default-deny and set by `role` (below); `external_directory` is always denied. No web fetch, no sub-agents.
18
18
  - Models outside the `opencode` provider are rejected (`PROVIDER_NOT_ALLOWED`); there is no fallback.
19
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.
20
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).
@@ -36,6 +36,20 @@ Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build
36
36
  Unknown values (e.g. `medium`, which this model does not define) fail with `BAD_VARIANT` instead of silently using the provider default.
37
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
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
+
39
53
  ## CLI
40
54
 
41
55
  ```sh
@@ -58,7 +72,7 @@ Exit codes: `0` ok, `1` worker failed (timeout, nonzero exit, error event, model
58
72
  | `text` | worker's final text |
59
73
  | `tools[]` | `{tool, status, input, exit, output, error}` per tool call; `exit` is the bash exit code (else `null`), `output` capped at 4000 chars |
60
74
  | `cost`, `tokens` | summed from `step_finish` events |
61
- | `diff` | `diff -ruN` of `<dir>` before vs after (excludes `.opencode-worker/`) |
75
+ | `diff` | unified diff of `<dir>` before vs after. Excludes `.opencode-worker/` and tool caches (`__pycache__`, `*.pyc`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.cache`); the worker also runs with `PYTHONDONTWRITEBYTECODE=1`, so test runs don't show up as changes |
62
76
  | `errors[]` | `timeout`, `cancelled`, `nonzero_exit`, `event_error`, `model_mismatch`, `diff_failed`, `unparsed_line` |
63
77
  | `artifacts` | raw `events.jsonl` and `stderr.txt` under `<dir>/.opencode-worker/runs/<id>/` |
64
78
 
@@ -103,6 +117,8 @@ env = { OPENCODE_WORKER_BIN = "/path/to/opencode" }
103
117
  default_tools_approval_mode = "approve"
104
118
  ```
105
119
 
120
+ Codex does not read a project-level `.codex/config.toml`. To enable the worker only when needed, put the same block in a profile file `~/.codex/ocw.config.toml` and start Codex with `codex -p ocw`.
121
+
106
122
  OMP (`.omp/mcp.json` or `~/.omp/agent/mcp.json`): the JSON above with `"type": "stdio"`. Non-interactive: `omp -p --auto-approve "…"`.
107
123
 
108
124
  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.
@@ -113,7 +129,7 @@ Closing stdin or SIGINT/SIGTERM/SIGHUP cancels running jobs and records them as
113
129
  - `<dir>/.opencode-worker/` holds state, snapshots and logs per run; delete it when done.
114
130
  - The before-snapshot copies the whole directory: use small, task-scoped directories.
115
131
  - 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.
116
- - Two jobs writing the same `dir` at once will overwrite each other's files and snapshots; give each job its own directory.
132
+ - Give each parallel implementer its own `dir` (e.g. a git worktree per task); same-dir starts get `DIR_BUSY`.
117
133
 
118
134
  ## Releasing
119
135
 
@@ -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
  };
@@ -59,6 +60,8 @@ function isolatedEnv(stateRoot, configPath) {
59
60
  OPENCODE_CONFIG_DIR: path.join(stateRoot, "xdg/config/opencode"),
60
61
  OPENCODE_DISABLE_PROJECT_CONFIG: "1",
61
62
  TERM: "dumb",
63
+ // Test runs must not show up as file changes in the diff.
64
+ PYTHONDONTWRITEBYTECODE: "1",
62
65
  };
63
66
  for (const k of ["LANG", "LC_ALL", "TMPDIR"]) if (process.env[k]) env[k] = process.env[k];
64
67
  for (const k of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "OPENCODE_CONFIG_DIR"])
@@ -94,15 +97,20 @@ function snapshot(dir, dest) {
94
97
  }
95
98
  }
96
99
 
100
+ // Tool caches that commands write as a side effect; never meaningful in a worker diff.
101
+ export const DIFF_EXCLUDES = [STATE_DIR, "__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache"];
102
+
97
103
  function unifiedDiff(before, dir) {
98
- const r = spawnSync("diff", ["-ruN", `--exclude=${STATE_DIR}`, "before", dir], {
104
+ const r = spawnSync("diff", ["-ruN", ...DIFF_EXCLUDES.map((p) => `--exclude=${p}`), "before", dir], {
99
105
  cwd: path.dirname(before),
100
106
  encoding: "utf8",
101
107
  maxBuffer: 32 * 1024 * 1024,
102
108
  });
103
109
  // diff exit: 0 same, 1 different, 2 trouble
104
110
  if (r.status === 2) return { diff: null, error: r.stderr.trim() };
105
- return { diff: r.stdout.replaceAll(dir + "/", "after/"), error: null };
111
+ // Drop the per-file "diff -ruN --exclude=…" command lines; ---/+++ headers already name each file.
112
+ const body = r.stdout.split("\n").filter((l) => !l.startsWith("diff -ruN ")).join("\n");
113
+ return { diff: body.replaceAll(dir + "/", "after/"), error: null };
106
114
  }
107
115
 
108
116
  function parseEvents(stdout) {
@@ -199,9 +207,52 @@ export function killTree(root, graceMs = 5000) {
199
207
  }, graceMs).unref());
200
208
  }
201
209
 
210
+ export function alive(pid) {
211
+ if (!pid) return false;
212
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
213
+ }
214
+
215
+ const READ_TOOLS = ["read", "list", "glob", "grep", "todowrite", "todoread"];
216
+ /**
217
+ * Roles = tool allowlist + a one-line brief prepended to the prompt.
218
+ * `exclusive` roles may change files (tester via bash), so they need the dir to themselves.
219
+ */
220
+ export const ROLES = {
221
+ implementer: { tools: ALLOWED_TOOLS, exclusive: true, brief: null },
222
+ 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." },
223
+ 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." },
224
+ };
225
+
226
+ /**
227
+ * One exclusive job per dir, or any number of reviewers. A lock is live while its
228
+ * owner process or its worker is alive, so an orphaned worker still holds the dir.
229
+ */
230
+ function acquireDirLock(stateRoot, runId, role) {
231
+ const locksDir = path.join(stateRoot, "locks");
232
+ fs.mkdirSync(locksDir, { recursive: true });
233
+ const live = [];
234
+ for (const name of fs.readdirSync(locksDir)) {
235
+ const file = path.join(locksDir, name);
236
+ let lock;
237
+ try { lock = JSON.parse(fs.readFileSync(file, "utf8")); } catch { continue; }
238
+ if (alive(lock.pid) || alive(lock.workerPid)) live.push(lock);
239
+ else fs.rmSync(file, { force: true });
240
+ }
241
+ const conflict = ROLES[role].exclusive ? live : live.filter((l) => ROLES[l.role]?.exclusive !== false);
242
+ if (conflict.length)
243
+ 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`);
244
+ const file = path.join(locksDir, `${runId}.json`);
245
+ const lock = { runId, role, pid: process.pid, workerPid: null };
246
+ fs.writeFileSync(file, JSON.stringify(lock));
247
+ return {
248
+ setWorker(pid) { lock.workerPid = pid; fs.writeFileSync(file, JSON.stringify(lock)); },
249
+ release() { fs.rmSync(file, { force: true }); },
250
+ };
251
+ }
252
+
202
253
  /**
203
254
  * Run one OpenCode worker task.
204
- * @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
255
+ * @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
205
256
  */
206
257
  export async function runWorker(o) {
207
258
  if (!o.dir || !o.prompt) throw new WorkerError("BAD_ARGS", "dir and prompt are required");
@@ -210,6 +261,8 @@ export async function runWorker(o) {
210
261
  const model = o.model ?? DEFAULTS.model;
211
262
  const variant = o.variant ?? DEFAULTS.variant;
212
263
  const agent = o.agent ?? DEFAULTS.agent;
264
+ const role = o.role ?? DEFAULTS.role;
265
+ if (!Object.hasOwn(ROLES, role)) throw new WorkerError("BAD_ROLE", `role must be one of ${Object.keys(ROLES).join(", ")}, got '${role}'`);
213
266
  const timeoutSec = o.timeoutSec ?? defaultTimeoutSec();
214
267
  if (timeoutSec !== null && !(Number.isFinite(timeoutSec) && timeoutSec > 0))
215
268
  throw new WorkerError("BAD_ARGS", `timeoutSec must be a positive number, got ${o.timeoutSec}`);
@@ -220,13 +273,13 @@ export async function runWorker(o) {
220
273
  const bin = resolveOpencodeBin(o.opencodeBin);
221
274
  const version = opencodeVersion(bin);
222
275
 
223
- const runId = new Date().toISOString().replace(/[:.]/g, "-");
276
+ const runId = new Date().toISOString().replace(/[:.]/g, "-") + "-" + Math.random().toString(36).slice(2, 6);
224
277
  const stateRoot = path.join(dir, STATE_DIR);
225
278
  const runDir = path.join(stateRoot, "runs", runId);
226
279
  fs.mkdirSync(runDir, { recursive: true });
227
- const configPath = path.join(stateRoot, "opencode.json");
280
+ const configPath = path.join(runDir, "opencode.json"); // per run: concurrent reviewers may differ in variant
228
281
  const modelId = model.split("/").slice(1).join("/");
229
- const permission = Object.fromEntries([["*", "deny"], ...ALLOWED_TOOLS.map((t) => [t, "allow"]), ["external_directory", "deny"]]);
282
+ const permission = Object.fromEntries([["*", "deny"], ...ROLES[role].tools.map((t) => [t, "allow"]), ["external_directory", "deny"]]);
230
283
  const config = {
231
284
  $schema: "https://opencode.ai/config.json",
232
285
  model,
@@ -241,10 +294,21 @@ export async function runWorker(o) {
241
294
  const variants = modelVariants(bin, env, model);
242
295
  if (!variants.includes(variant))
243
296
  throw new WorkerError("BAD_VARIANT", `variant '${variant}' is not defined for ${model}; available: ${variants.join(", ")}`);
297
+ const lock = acquireDirLock(stateRoot, runId, role);
298
+ try {
299
+ return await collect({ o, dir, stateRoot, runDir, role, agent, model, modelId, provider, variant, timeoutSec, env, bin, version, lock });
300
+ } finally {
301
+ lock.release();
302
+ }
303
+ }
304
+
305
+ /** Snapshot, spawn opencode, gather events and diff. Runs while the dir lock is held. */
306
+ async function collect({ o, dir, stateRoot, runDir, role, agent, model, modelId, provider, variant, timeoutSec, env, bin, version, lock }) {
244
307
  const before = path.join(runDir, "before");
245
308
  snapshot(dir, before);
246
309
 
247
- const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, o.prompt];
310
+ const prompt = ROLES[role].brief ? `${ROLES[role].brief}\n\n${o.prompt}` : o.prompt;
311
+ const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, prompt];
248
312
  const started = Date.now();
249
313
  const eventsPath = path.join(runDir, "events.jsonl");
250
314
  const stderrPath = path.join(runDir, "stderr.txt");
@@ -259,7 +323,10 @@ export async function runWorker(o) {
259
323
  // Streamed to disk so a run stays inspectable even if this process dies mid-run.
260
324
  child.stdout.on("data", (d) => { stdout += d; fs.appendFileSync(eventsPath, d); });
261
325
  child.stderr.on("data", (d) => { stderr += d; fs.appendFileSync(stderrPath, d); });
262
- if (child.pid) o.onSpawn?.({ pid: child.pid, bin, runDir });
326
+ if (child.pid) {
327
+ lock.setWorker(child.pid);
328
+ o.onSpawn?.({ pid: child.pid, bin, runDir });
329
+ }
263
330
  const kill = () => killTree(child.pid);
264
331
  const timer = timeoutSec === null ? null : setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
265
332
  o.signal?.addEventListener("abort", () => { cancelled = true; kill(); }, { once: true });
@@ -286,7 +353,7 @@ export async function runWorker(o) {
286
353
  ok: exit === 0 && errors.length === 0,
287
354
  exit, signal, durationSec,
288
355
  opencode: { bin, version },
289
- requested: { model, variant, agent, timeoutSec },
356
+ requested: { model, variant, agent, role, timeoutSec },
290
357
  actual,
291
358
  sessionID: ev.sessionID,
292
359
  text: ev.text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worker",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",