opencode-worker 0.1.5 → 0.1.7
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 +26 -7
- package/bin/opencode-worker.mjs +1 -1
- package/lib/mcp.mjs +39 -12
- package/lib/worker.mjs +90 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,13 +12,30 @@ 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"]
|
|
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: only `read`, `list`, `glob`, `grep`, `edit` (incl. write/patch), `bash`, `todowrite`, `todoread` are allowed; `external_directory` is 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.
|
|
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).
|
|
18
21
|
- Fixed flags: `run --pure --auto --format json --agent <agent> --model <model> --variant <variant>`.
|
|
19
22
|
|
|
20
23
|
Defaults: model `opencode/deepseek-v4.1-flash-free`, variant `low`, agent `build`.
|
|
21
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
|
+
|
|
22
39
|
## CLI
|
|
23
40
|
|
|
24
41
|
```sh
|
|
@@ -59,20 +76,20 @@ Recommended flow: `worker_start` → `worker_wait` (repeat while it returns `sta
|
|
|
59
76
|
"opencode-worker": {
|
|
60
77
|
"command": "npx",
|
|
61
78
|
"args": ["-y", "opencode-worker", "mcp"],
|
|
62
|
-
"env": { "OPENCODE_WORKER_BIN": "/path/to/opencode"
|
|
79
|
+
"env": { "OPENCODE_WORKER_BIN": "/path/to/opencode" }
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
```
|
|
67
84
|
|
|
68
|
-
`OPENCODE_WORKER_TIMEOUT_SEC`
|
|
85
|
+
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
86
|
|
|
70
87
|
### Client setup
|
|
71
88
|
|
|
72
89
|
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
90
|
|
|
74
91
|
```sh
|
|
75
|
-
claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode
|
|
92
|
+
claude mcp add opencode-worker -e OPENCODE_WORKER_BIN=/path/to/opencode -- npx -y opencode-worker mcp
|
|
76
93
|
claude -p "…" --allowedTools "mcp__opencode-worker__worker_start,mcp__opencode-worker__worker_wait,mcp__opencode-worker__worker_status,mcp__opencode-worker__worker_result"
|
|
77
94
|
```
|
|
78
95
|
|
|
@@ -82,19 +99,21 @@ Codex (`~/.codex/config.toml`); without the approval mode, `codex exec` rejects
|
|
|
82
99
|
[mcp_servers.opencode-worker]
|
|
83
100
|
command = "npx"
|
|
84
101
|
args = ["-y", "opencode-worker", "mcp"]
|
|
85
|
-
env = { OPENCODE_WORKER_BIN = "/path/to/opencode"
|
|
102
|
+
env = { OPENCODE_WORKER_BIN = "/path/to/opencode" }
|
|
86
103
|
default_tools_approval_mode = "approve"
|
|
87
104
|
```
|
|
88
105
|
|
|
89
106
|
OMP (`.omp/mcp.json` or `~/.omp/agent/mcp.json`): the JSON above with `"type": "stdio"`. Non-interactive: `omp -p --auto-approve "…"`.
|
|
90
107
|
|
|
91
108
|
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
|
|
109
|
+
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
110
|
|
|
94
111
|
## Notes
|
|
95
112
|
|
|
96
113
|
- `<dir>/.opencode-worker/` holds state, snapshots and logs per run; delete it when done.
|
|
97
114
|
- The before-snapshot copies the whole directory: use small, task-scoped directories.
|
|
115
|
+
- 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.
|
|
98
117
|
|
|
99
118
|
## Releasing
|
|
100
119
|
|
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 $OPENCODE_WORKER_TIMEOUT_SEC, else
|
|
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
|
-
//
|
|
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:
|
|
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 |
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
126
|
-
|
|
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:
|
|
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";
|
|
@@ -54,14 +54,38 @@ function isolatedEnv(stateRoot, configPath) {
|
|
|
54
54
|
XDG_CACHE_HOME: path.join(stateRoot, "xdg/cache"),
|
|
55
55
|
XDG_STATE_HOME: path.join(stateRoot, "xdg/state"),
|
|
56
56
|
OPENCODE_CONFIG: configPath,
|
|
57
|
+
// Without these, an opencode.json / .opencode/ in the task dir is merged in
|
|
58
|
+
// (agents, providers, MCP, plugins) and can override our free-only config.
|
|
59
|
+
OPENCODE_CONFIG_DIR: path.join(stateRoot, "xdg/config/opencode"),
|
|
60
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
57
61
|
TERM: "dumb",
|
|
58
62
|
};
|
|
59
63
|
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"])
|
|
64
|
+
for (const k of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "OPENCODE_CONFIG_DIR"])
|
|
61
65
|
fs.mkdirSync(env[k], { recursive: true });
|
|
62
66
|
return env;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
// Default-deny: tools added later by OpenCode, plugins or MCP never become available implicitly.
|
|
70
|
+
export const ALLOWED_TOOLS = ["read", "list", "glob", "grep", "edit", "bash", "todowrite", "todoread"];
|
|
71
|
+
const NO_REASONING_VARIANT = "none";
|
|
72
|
+
const variantCache = new Map();
|
|
73
|
+
|
|
74
|
+
/** Variants OpenCode defines for the model, plus our "none" (reasoning off). */
|
|
75
|
+
function modelVariants(bin, env, model) {
|
|
76
|
+
if (variantCache.has(model)) return variantCache.get(model);
|
|
77
|
+
const [provider] = model.split("/");
|
|
78
|
+
const r = spawnSync(bin, ["models", provider, "--verbose"], { encoding: "utf8", env, timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
|
|
79
|
+
const at = r.stdout?.indexOf(`${model}\n{`) ?? -1;
|
|
80
|
+
if (r.status !== 0 || at < 0) throw new WorkerError("MODEL_NOT_FOUND", `model ${model} is not listed by 'opencode models ${provider}'`);
|
|
81
|
+
// The JSON object for this model ends at the first line that is exactly "}".
|
|
82
|
+
const body = r.stdout.slice(at + model.length + 1);
|
|
83
|
+
const meta = JSON.parse(body.slice(0, body.indexOf("\n}") + 2));
|
|
84
|
+
const variants = [...Object.keys(meta.variants ?? {}), NO_REASONING_VARIANT];
|
|
85
|
+
variantCache.set(model, variants);
|
|
86
|
+
return variants;
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
function snapshot(dir, dest) {
|
|
66
90
|
fs.mkdirSync(dest, { recursive: true });
|
|
67
91
|
for (const name of fs.readdirSync(dir)) {
|
|
@@ -132,7 +156,7 @@ function actualModels(stateRoot, sessionID) {
|
|
|
132
156
|
});
|
|
133
157
|
}
|
|
134
158
|
|
|
135
|
-
/** Job
|
|
159
|
+
/** Job time limit: OPENCODE_WORKER_TIMEOUT_SEC if set, else none (run until done or cancelled). */
|
|
136
160
|
export function defaultTimeoutSec() {
|
|
137
161
|
const raw = process.env.OPENCODE_WORKER_TIMEOUT_SEC;
|
|
138
162
|
if (raw === undefined) return DEFAULTS.timeoutSec;
|
|
@@ -142,9 +166,42 @@ export function defaultTimeoutSec() {
|
|
|
142
166
|
return n;
|
|
143
167
|
}
|
|
144
168
|
|
|
169
|
+
/** pid plus all descendants. opencode runs tool commands in their own process groups, so a group kill misses them. */
|
|
170
|
+
export function treePids(root) {
|
|
171
|
+
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
|
172
|
+
const children = new Map();
|
|
173
|
+
for (const line of ps.stdout.split("\n")) {
|
|
174
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
175
|
+
if (pid) children.set(ppid, [...(children.get(ppid) ?? []), pid]);
|
|
176
|
+
}
|
|
177
|
+
const out = [root];
|
|
178
|
+
for (let i = 0; i < out.length; i++) out.push(...(children.get(out[i]) ?? []));
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stop a worker and every command it spawned: SIGTERM, then SIGKILL after graceMs.
|
|
184
|
+
* The tree is captured before signalling because children reparent to init once opencode exits.
|
|
185
|
+
*/
|
|
186
|
+
export function killTree(root, graceMs = 5000) {
|
|
187
|
+
const pids = new Set(treePids(root));
|
|
188
|
+
const signalAll = (sig) => {
|
|
189
|
+
for (const pid of pids) {
|
|
190
|
+
try { process.kill(-pid, sig); } catch {} // tool shells lead their own groups
|
|
191
|
+
try { process.kill(pid, sig); } catch {}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
signalAll("SIGTERM");
|
|
195
|
+
return new Promise((resolve) => setTimeout(() => {
|
|
196
|
+
for (const pid of treePids(root)) pids.add(pid);
|
|
197
|
+
signalAll("SIGKILL");
|
|
198
|
+
resolve([...pids]);
|
|
199
|
+
}, graceMs).unref());
|
|
200
|
+
}
|
|
201
|
+
|
|
145
202
|
/**
|
|
146
203
|
* Run one OpenCode worker task.
|
|
147
|
-
* @param {{dir:string,prompt:string,model?:string,variant?:string,agent?:string,timeoutSec?:number,opencodeBin?:string,signal?:AbortSignal}} o
|
|
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
|
|
148
205
|
*/
|
|
149
206
|
export async function runWorker(o) {
|
|
150
207
|
if (!o.dir || !o.prompt) throw new WorkerError("BAD_ARGS", "dir and prompt are required");
|
|
@@ -154,7 +211,8 @@ export async function runWorker(o) {
|
|
|
154
211
|
const variant = o.variant ?? DEFAULTS.variant;
|
|
155
212
|
const agent = o.agent ?? DEFAULTS.agent;
|
|
156
213
|
const timeoutSec = o.timeoutSec ?? defaultTimeoutSec();
|
|
157
|
-
if (!(Number.isFinite(timeoutSec) && timeoutSec > 0))
|
|
214
|
+
if (timeoutSec !== null && !(Number.isFinite(timeoutSec) && timeoutSec > 0))
|
|
215
|
+
throw new WorkerError("BAD_ARGS", `timeoutSec must be a positive number, got ${o.timeoutSec}`);
|
|
158
216
|
const provider = model.split("/")[0];
|
|
159
217
|
if (!DEFAULTS.providers.includes(provider))
|
|
160
218
|
throw new WorkerError("PROVIDER_NOT_ALLOWED", `provider '${provider}' not in ${JSON.stringify(DEFAULTS.providers)}`);
|
|
@@ -167,41 +225,50 @@ export async function runWorker(o) {
|
|
|
167
225
|
const runDir = path.join(stateRoot, "runs", runId);
|
|
168
226
|
fs.mkdirSync(runDir, { recursive: true });
|
|
169
227
|
const configPath = path.join(stateRoot, "opencode.json");
|
|
170
|
-
|
|
228
|
+
const modelId = model.split("/").slice(1).join("/");
|
|
229
|
+
const permission = Object.fromEntries([["*", "deny"], ...ALLOWED_TOOLS.map((t) => [t, "allow"]), ["external_directory", "deny"]]);
|
|
230
|
+
const config = {
|
|
171
231
|
$schema: "https://opencode.ai/config.json",
|
|
172
232
|
model,
|
|
173
233
|
small_model: model,
|
|
174
234
|
enabled_providers: DEFAULTS.providers,
|
|
175
|
-
permission
|
|
176
|
-
}
|
|
235
|
+
permission,
|
|
236
|
+
};
|
|
237
|
+
if (variant === NO_REASONING_VARIANT)
|
|
238
|
+
config.provider = { [provider]: { models: { [modelId]: { variants: { [NO_REASONING_VARIANT]: { reasoningEffort: "none" } } } } } };
|
|
239
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
177
240
|
const env = isolatedEnv(stateRoot, configPath);
|
|
241
|
+
const variants = modelVariants(bin, env, model);
|
|
242
|
+
if (!variants.includes(variant))
|
|
243
|
+
throw new WorkerError("BAD_VARIANT", `variant '${variant}' is not defined for ${model}; available: ${variants.join(", ")}`);
|
|
178
244
|
const before = path.join(runDir, "before");
|
|
179
245
|
snapshot(dir, before);
|
|
180
246
|
|
|
181
247
|
const args = ["run", "--pure", "--auto", "--format", "json", "--agent", agent, "--model", model, "--variant", variant, o.prompt];
|
|
182
248
|
const started = Date.now();
|
|
249
|
+
const eventsPath = path.join(runDir, "events.jsonl");
|
|
250
|
+
const stderrPath = path.join(runDir, "stderr.txt");
|
|
183
251
|
const { exit, signal, timedOut, cancelled, stdout, stderr } = await new Promise((resolve) => {
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
252
|
+
// Not detached: terminal Ctrl+C on the CLI still reaches opencode.
|
|
253
|
+
const spawnOpts = { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] };
|
|
254
|
+
// Optional hard deadline that survives our own death: alarm() is inherited across exec.
|
|
255
|
+
const child = timeoutSec !== null && HARD_DEADLINE_WRAPPER
|
|
256
|
+
? spawn(HARD_DEADLINE_WRAPPER, ["-e", "alarm shift; exec { $ARGV[0] } @ARGV or die $!", String(Math.ceil(timeoutSec) + 10), bin, ...args], spawnOpts)
|
|
257
|
+
: spawn(bin, args, spawnOpts);
|
|
190
258
|
let stdout = "", stderr = "", timedOut = false, cancelled = false;
|
|
191
|
-
|
|
192
|
-
child.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
};
|
|
197
|
-
const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
|
|
259
|
+
// Streamed to disk so a run stays inspectable even if this process dies mid-run.
|
|
260
|
+
child.stdout.on("data", (d) => { stdout += d; fs.appendFileSync(eventsPath, d); });
|
|
261
|
+
child.stderr.on("data", (d) => { stderr += d; fs.appendFileSync(stderrPath, d); });
|
|
262
|
+
if (child.pid) o.onSpawn?.({ pid: child.pid, bin, runDir });
|
|
263
|
+
const kill = () => killTree(child.pid);
|
|
264
|
+
const timer = timeoutSec === null ? null : setTimeout(() => { timedOut = true; kill(); }, timeoutSec * 1000);
|
|
198
265
|
o.signal?.addEventListener("abort", () => { cancelled = true; kill(); }, { once: true });
|
|
199
266
|
child.on("error", (e) => { stderr += String(e); });
|
|
200
267
|
child.on("close", (exit, signal) => { clearTimeout(timer); resolve({ exit, signal, timedOut, cancelled, stdout, stderr }); });
|
|
201
268
|
});
|
|
202
269
|
const durationSec = (Date.now() - started) / 1000;
|
|
203
|
-
fs.writeFileSync(
|
|
204
|
-
fs.writeFileSync(
|
|
270
|
+
fs.writeFileSync(eventsPath, stdout);
|
|
271
|
+
fs.writeFileSync(stderrPath, stderr);
|
|
205
272
|
|
|
206
273
|
const ev = parseEvents(stdout);
|
|
207
274
|
const { diff, error: diffError } = unifiedDiff(before, dir);
|
|
@@ -211,7 +278,6 @@ export async function runWorker(o) {
|
|
|
211
278
|
if (cancelled) errors.push({ type: "cancelled" });
|
|
212
279
|
if (exit !== 0 && !timedOut && !cancelled) errors.push({ type: "nonzero_exit", detail: stderr.trim().slice(-2000) || `exit ${exit} signal ${signal}` });
|
|
213
280
|
if (diffError) errors.push({ type: "diff_failed", detail: diffError });
|
|
214
|
-
const modelId = model.split("/").slice(1).join("/");
|
|
215
281
|
if (actual && actual.some((a) => a.provider !== provider || a.model !== modelId))
|
|
216
282
|
errors.push({ type: "model_mismatch", detail: actual });
|
|
217
283
|
|