opencode-worker 0.1.2 → 0.1.4

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
@@ -5,7 +5,8 @@ Run the official OpenCode CLI as an isolated, time-boxed worker. One package, tw
5
5
  - `opencode-worker run` — CLI, prints one JSON result
6
6
  - `opencode-worker mcp` — MCP stdio server (async jobs)
7
7
 
8
- No dependencies. Node ≥ 20. OpenCode is **not** bundled: pass `--opencode-bin`, set `OPENCODE_WORKER_BIN`, or have `opencode` on `PATH`.
8
+ No dependencies. Node ≥ 20. OpenCode is **not** bundled: pass `--opencode-bin`, set `OPENCODE_WORKER_BIN`, or have `opencode` on `PATH` (checked in that order).
9
+ A set-but-empty `--opencode-bin`/`OPENCODE_WORKER_BIN` fails with `BIN_EMPTY` instead of falling back to `PATH`; this usually means a shell variable was not expanded in your MCP config.
9
10
 
10
11
  ## Safety defaults
11
12
 
@@ -47,8 +48,9 @@ The worker's report is not proof: verify `diff` and rerun your own checks.
47
48
 
48
49
  ## MCP
49
50
 
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.
51
+ Tools: `worker_start` (returns `jobId` immediately), `worker_wait`, `worker_status`, `worker_result`, `worker_cancel`.
52
+
53
+ Recommended flow: `worker_start` → `worker_wait` (repeat while it returns `status: "running"`). `worker_wait` blocks up to `timeoutSec` (default 50 s, max 120 s) and returns the full result once the job finishes, so clients don't burn model turns polling `worker_status`. The 50 s default stays under common 60 s client tool timeouts (e.g. Codex `tool_timeout_sec`).
52
54
 
53
55
  ```json
54
56
  {
package/lib/mcp.mjs CHANGED
@@ -50,19 +50,52 @@ const runSchema = {
50
50
  required: ["dir", "prompt"],
51
51
  };
52
52
  const idSchema = { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] };
53
+ const WAIT_DEFAULT_SEC = 50; // under common 60 s client tool timeouts (e.g. Codex)
54
+ const WAIT_MAX_SEC = 120;
55
+ const waitSchema = {
56
+ type: "object",
57
+ properties: {
58
+ jobId: { type: "string" },
59
+ timeoutSec: { type: "number", description: `Max seconds to block (default ${WAIT_DEFAULT_SEC}, max ${WAIT_MAX_SEC})` },
60
+ },
61
+ required: ["jobId"],
62
+ };
53
63
 
54
64
  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 },
65
+ { name: "worker_start", description: "Start an isolated OpenCode worker job (free model by default). Returns jobId immediately. Then call worker_wait.", inputSchema: runSchema },
66
+ { 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 },
68
+ { 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 },
58
69
  { name: "worker_cancel", description: "Cancel a running job owned by this server (SIGTERM, then SIGKILL).", inputSchema: idSchema },
59
70
  ];
60
71
 
72
+ function resultOf(job) {
73
+ 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);
75
+ return textResult({ jobId: job.jobId, status: job.status, ...(job.result ?? { ok: false, errors: [job.error] }) }, !job.result?.ok);
76
+ }
77
+
78
+ async function waitFor(jobId, timeoutSec) {
79
+ const sec = Math.min(Math.max(Number(timeoutSec) || WAIT_DEFAULT_SEC, 1), WAIT_MAX_SEC);
80
+ const deadline = Date.now() + sec * 1000;
81
+ const live = jobs.get(jobId);
82
+ if (live) await Promise.race([live.done, new Promise((r) => setTimeout(r, sec * 1000).unref())]);
83
+ // Jobs owned by another server process: watch the persisted record.
84
+ let job = load(jobId);
85
+ while (job?.status === "running" && !live && Date.now() < deadline) {
86
+ await new Promise((r) => setTimeout(r, 500));
87
+ job = load(jobId);
88
+ }
89
+ if (!job) return textResult({ error: "unknown jobId" }, true);
90
+ if (job.status === "running") return textResult({ jobId, status: "running", waitedSec: sec, hint: "call worker_wait again" });
91
+ return resultOf(job);
92
+ }
93
+
61
94
  function textResult(obj, isError = false) {
62
95
  return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }], isError };
63
96
  }
64
97
 
65
- function callTool(name, args, opts) {
98
+ async function callTool(name, args, opts) {
66
99
  if (name === "worker_start") {
67
100
  const record = {
68
101
  jobId: randomUUID(), status: "running", serverPid: process.pid,
@@ -81,15 +114,12 @@ function callTool(name, args, opts) {
81
114
  jobs.set(record.jobId, { record, controller, done });
82
115
  return textResult({ jobId: record.jobId, status: "running" });
83
116
  }
117
+ if (name === "worker_wait") return waitFor(args?.jobId, args?.timeoutSec);
84
118
  const job = load(args?.jobId);
85
119
  if (!job) return textResult({ error: "unknown jobId" }, true);
86
120
  const { jobId, status, startedAt, finishedAt, dir } = job;
87
121
  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
- }
122
+ if (name === "worker_result") return resultOf(job);
93
123
  if (name === "worker_cancel") {
94
124
  const live = jobs.get(jobId);
95
125
  live?.controller.abort();
@@ -121,8 +151,10 @@ export function serveMcp(opts = {}) {
121
151
  if (method === "ping") return reply({});
122
152
  if (method === "tools/list") return reply({ tools: TOOLS });
123
153
  if (method === "tools/call") {
124
- const r = callTool(params?.name, params?.arguments ?? {}, opts);
125
- return r ? reply(r) : fail(-32602, `unknown tool: ${params?.name}`);
154
+ return callTool(params?.name, params?.arguments ?? {}, opts).then(
155
+ (r) => (r ? reply(r) : fail(-32602, `unknown tool: ${params?.name}`)),
156
+ (e) => fail(-32603, e.message),
157
+ );
126
158
  }
127
159
  fail(-32601, `method not found: ${method}`);
128
160
  });
package/lib/worker.mjs CHANGED
@@ -22,14 +22,20 @@ export class WorkerError extends Error {
22
22
  }
23
23
 
24
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}`);
25
+ const sources = [
26
+ ["--opencode-bin", explicit],
27
+ ["OPENCODE_WORKER_BIN", process.env.OPENCODE_WORKER_BIN],
28
+ ];
29
+ for (const [name, value] of sources) {
30
+ if (value === undefined || value === null) continue;
31
+ // A set-but-empty value is almost always an unexpanded shell variable; never fall back to PATH silently.
32
+ if (value.trim() === "") throw new WorkerError("BIN_EMPTY", `${name} is set but empty; give the absolute path to the opencode binary`);
33
+ if (!fs.existsSync(value)) throw new WorkerError("BIN_NOT_FOUND", `opencode binary not found at ${name}=${value}`);
34
+ return path.resolve(value);
29
35
  }
30
36
  const which = spawnSync("/usr/bin/env", ["which", "opencode"], { encoding: "utf8" });
31
37
  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)");
38
+ throw new WorkerError("BIN_NOT_FOUND", "opencode not found on PATH (set OPENCODE_WORKER_BIN or --opencode-bin)");
33
39
  }
34
40
 
35
41
  function opencodeVersion(bin) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worker",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",