opencode-worker 0.1.3 → 0.1.5
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 +13 -9
- package/bin/opencode-worker.mjs +1 -1
- package/lib/mcp.mjs +44 -12
- package/lib/worker.mjs +13 -2
- package/package.json +1 -1
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"]`, `permission.external_directory: "deny"`.
|
|
16
16
|
- Models outside the `opencode` provider are rejected (`PROVIDER_NOT_ALLOWED`); there is no fallback.
|
|
17
|
-
-
|
|
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.
|
|
18
18
|
- Fixed flags: `run --pure --auto --format json --agent <agent> --model <model> --variant <variant>`.
|
|
19
19
|
|
|
20
20
|
Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build`.
|
|
@@ -22,8 +22,9 @@ Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build
|
|
|
22
22
|
## CLI
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
opencode-worker run --dir ./task
|
|
26
|
-
opencode-worker run --dir ./task -f prompt.md --variant high
|
|
25
|
+
opencode-worker run --dir ./task "Fix add() in calculator.py and run the tests"
|
|
26
|
+
opencode-worker run --dir ./task --timeout 1800 -f prompt.md --variant high
|
|
27
|
+
OPENCODE_WORKER_TIMEOUT_SEC=1800 opencode-worker run --dir ./task "…"
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
Exit codes: `0` ok, `1` worker failed (timeout, nonzero exit, error event, model mismatch), `2` usage/setup error.
|
|
@@ -48,8 +49,9 @@ The worker's report is not proof: verify `diff` and rerun your own checks.
|
|
|
48
49
|
|
|
49
50
|
## MCP
|
|
50
51
|
|
|
51
|
-
Tools: `worker_start` (returns `jobId` immediately), `worker_status`, `worker_result`, `worker_cancel`.
|
|
52
|
-
|
|
52
|
+
Tools: `worker_start` (returns `jobId` immediately), `worker_wait`, `worker_status`, `worker_result`, `worker_cancel`.
|
|
53
|
+
|
|
54
|
+
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`).
|
|
53
55
|
|
|
54
56
|
```json
|
|
55
57
|
{
|
|
@@ -57,19 +59,21 @@ Async by design so long runs never hit client request timeouts.
|
|
|
57
59
|
"opencode-worker": {
|
|
58
60
|
"command": "npx",
|
|
59
61
|
"args": ["-y", "opencode-worker", "mcp"],
|
|
60
|
-
"env": { "OPENCODE_WORKER_BIN": "/path/to/opencode" }
|
|
62
|
+
"env": { "OPENCODE_WORKER_BIN": "/path/to/opencode", "OPENCODE_WORKER_TIMEOUT_SEC": "1800" }
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
`OPENCODE_WORKER_TIMEOUT_SEC` is optional (default 900); it is the job limit, independent of the per-call `worker_wait` limit.
|
|
69
|
+
|
|
66
70
|
### Client setup
|
|
67
71
|
|
|
68
72
|
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):
|
|
69
73
|
|
|
70
74
|
```sh
|
|
71
|
-
claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode -- npx -y opencode-worker mcp
|
|
72
|
-
claude -p "…" --allowedTools "mcp__opencode-worker__worker_start,mcp__opencode-worker__worker_status,mcp__opencode-worker__worker_result"
|
|
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 -p "…" --allowedTools "mcp__opencode-worker__worker_start,mcp__opencode-worker__worker_wait,mcp__opencode-worker__worker_status,mcp__opencode-worker__worker_result"
|
|
73
77
|
```
|
|
74
78
|
|
|
75
79
|
Codex (`~/.codex/config.toml`); without the approval mode, `codex exec` rejects MCP calls under `approval: never`:
|
|
@@ -78,7 +82,7 @@ Codex (`~/.codex/config.toml`); without the approval mode, `codex exec` rejects
|
|
|
78
82
|
[mcp_servers.opencode-worker]
|
|
79
83
|
command = "npx"
|
|
80
84
|
args = ["-y", "opencode-worker", "mcp"]
|
|
81
|
-
env = { OPENCODE_WORKER_BIN = "/path/to/opencode" }
|
|
85
|
+
env = { OPENCODE_WORKER_BIN = "/path/to/opencode", OPENCODE_WORKER_TIMEOUT_SEC = "1800" }
|
|
82
86
|
default_tools_approval_mode = "approve"
|
|
83
87
|
```
|
|
84
88
|
|
package/bin/opencode-worker.mjs
CHANGED
|
@@ -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 ${DEFAULTS.timeoutSec}
|
|
16
|
+
--timeout <sec> default $OPENCODE_WORKER_TIMEOUT_SEC, else ${DEFAULTS.timeoutSec}
|
|
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
|
@@ -45,24 +45,57 @@ const runSchema = {
|
|
|
45
45
|
model: { type: "string", description: `default ${DEFAULTS.model}` },
|
|
46
46
|
variant: { type: "string", description: `default ${DEFAULTS.variant}` },
|
|
47
47
|
agent: { type: "string", description: `default ${DEFAULTS.agent}` },
|
|
48
|
-
timeoutSec: { type: "number", description: `default ${DEFAULTS.timeoutSec}` },
|
|
48
|
+
timeoutSec: { type: "number", description: `Job time limit in seconds; omit to use the server default ($OPENCODE_WORKER_TIMEOUT_SEC, else ${DEFAULTS.timeoutSec})` },
|
|
49
49
|
},
|
|
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: "
|
|
57
|
-
{ name: "
|
|
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
|
-
|
|
125
|
-
|
|
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
|
@@ -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:
|
|
10
|
+
timeoutSec: 900,
|
|
11
11
|
providers: ["opencode"],
|
|
12
12
|
};
|
|
13
13
|
const STATE_DIR = ".opencode-worker";
|
|
@@ -132,6 +132,16 @@ function actualModels(stateRoot, sessionID) {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Job timeout default: OPENCODE_WORKER_TIMEOUT_SEC if set, else DEFAULTS.timeoutSec. */
|
|
136
|
+
export function defaultTimeoutSec() {
|
|
137
|
+
const raw = process.env.OPENCODE_WORKER_TIMEOUT_SEC;
|
|
138
|
+
if (raw === undefined) return DEFAULTS.timeoutSec;
|
|
139
|
+
const n = Number(raw);
|
|
140
|
+
if (raw.trim() === "" || !(Number.isFinite(n) && n > 0))
|
|
141
|
+
throw new WorkerError("BAD_CONFIG", `OPENCODE_WORKER_TIMEOUT_SEC must be a positive number of seconds, got '${raw}'`);
|
|
142
|
+
return n;
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
/**
|
|
136
146
|
* Run one OpenCode worker task.
|
|
137
147
|
* @param {{dir:string,prompt:string,model?:string,variant?:string,agent?:string,timeoutSec?:number,opencodeBin?:string,signal?:AbortSignal}} o
|
|
@@ -143,7 +153,8 @@ export async function runWorker(o) {
|
|
|
143
153
|
const model = o.model ?? DEFAULTS.model;
|
|
144
154
|
const variant = o.variant ?? DEFAULTS.variant;
|
|
145
155
|
const agent = o.agent ?? DEFAULTS.agent;
|
|
146
|
-
const timeoutSec = o.timeoutSec ??
|
|
156
|
+
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}`);
|
|
147
158
|
const provider = model.split("/")[0];
|
|
148
159
|
if (!DEFAULTS.providers.includes(provider))
|
|
149
160
|
throw new WorkerError("PROVIDER_NOT_ALLOWED", `provider '${provider}' not in ${JSON.stringify(DEFAULTS.providers)}`);
|