opencode-worker 0.1.6 → 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 +19 -1
- package/lib/worker.mjs +36 -5
- package/package.json +1 -1
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"]
|
|
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.
|
|
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,20 @@ 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
|
+
|
|
23
39
|
## CLI
|
|
24
40
|
|
|
25
41
|
```sh
|
|
@@ -96,6 +112,8 @@ Closing stdin or SIGINT/SIGTERM/SIGHUP cancels running jobs and records them as
|
|
|
96
112
|
|
|
97
113
|
- `<dir>/.opencode-worker/` holds state, snapshots and logs per run; delete it when done.
|
|
98
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.
|
|
99
117
|
|
|
100
118
|
## Releasing
|
|
101
119
|
|
package/lib/worker.mjs
CHANGED
|
@@ -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)) {
|
|
@@ -201,14 +225,22 @@ export async function runWorker(o) {
|
|
|
201
225
|
const runDir = path.join(stateRoot, "runs", runId);
|
|
202
226
|
fs.mkdirSync(runDir, { recursive: true });
|
|
203
227
|
const configPath = path.join(stateRoot, "opencode.json");
|
|
204
|
-
|
|
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 = {
|
|
205
231
|
$schema: "https://opencode.ai/config.json",
|
|
206
232
|
model,
|
|
207
233
|
small_model: model,
|
|
208
234
|
enabled_providers: DEFAULTS.providers,
|
|
209
|
-
permission
|
|
210
|
-
}
|
|
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));
|
|
211
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(", ")}`);
|
|
212
244
|
const before = path.join(runDir, "before");
|
|
213
245
|
snapshot(dir, before);
|
|
214
246
|
|
|
@@ -246,7 +278,6 @@ export async function runWorker(o) {
|
|
|
246
278
|
if (cancelled) errors.push({ type: "cancelled" });
|
|
247
279
|
if (exit !== 0 && !timedOut && !cancelled) errors.push({ type: "nonzero_exit", detail: stderr.trim().slice(-2000) || `exit ${exit} signal ${signal}` });
|
|
248
280
|
if (diffError) errors.push({ type: "diff_failed", detail: diffError });
|
|
249
|
-
const modelId = model.split("/").slice(1).join("/");
|
|
250
281
|
if (actual && actual.some((a) => a.provider !== provider || a.model !== modelId))
|
|
251
282
|
errors.push({ type: "model_mismatch", detail: actual });
|
|
252
283
|
|