@sagentlab/navarch-runtime 0.1.24 → 0.1.26

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
@@ -6,10 +6,10 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
6
6
  — this directory is a self-contained package you can `npx` on any fresh
7
7
  machine.
8
8
 
9
- The machine operator selects **Claude Code**, **OpenAI Codex**, or **Google
10
- Gemini CLI** when connecting a worker. Any selected agent can run any project
11
- task; task eligibility depends on capabilities and project gates, not agent
12
- type — see "Choosing an agent" below.
9
+ The machine operator selects **Claude Code**, **OpenAI Codex**, **Google
10
+ Gemini CLI**, or **OpenCode** when connecting a worker. Any selected agent can
11
+ run any project task; task eligibility depends on capabilities and project
12
+ gates, not agent type — see "Choosing an agent" below.
13
13
 
14
14
  See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
15
15
  §3.8/§3.9/§3.11 and [`docs/navarch/implementation-plan.md`](../docs/navarch/implementation-plan.md)
@@ -125,10 +125,10 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
125
125
 
126
126
  | Command | Purpose |
127
127
  |---|---|
128
- | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
129
- | `connect --token <t> --name <n> [--agent claude-code\|codex\|gemini] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
130
- | `start [--agent claude-code\|codex\|gemini]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
131
- | `supervise [--agent claude-code\|codex\|gemini]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
128
+ | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
129
+ | `connect --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
130
+ | `start [--agent claude-code\|codex\|gemini\|opencode]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
131
+ | `supervise [--agent claude-code\|codex\|gemini\|opencode]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
132
132
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
133
133
 
134
134
  ### Running multiple agents on one machine
@@ -273,7 +273,7 @@ unchanged across the deployment.
273
273
  | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
274
274
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
275
275
  | `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
276
- | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, or `gemini`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
276
+ | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, `gemini`, or `opencode`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
277
277
  | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
278
278
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
279
279
  | `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
@@ -283,6 +283,8 @@ unchanged across the deployment.
283
283
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
284
284
  | `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
285
285
  | `NAVARCH_GEMINI_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP settings, `stream-json`, and unattended defaults (Gemini). |
286
+ | `NAVARCH_OPENCODE_BIN` | `opencode` | Path/name of the OpenCode CLI binary. |
287
+ | `NAVARCH_OPENCODE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `run`, the prompt, and the generated `--format json` argument. Explicit `--format`, `--model`, or `--variant` values replace the corresponding per-session default. |
286
288
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
287
289
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable. |
288
290
  | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
@@ -327,6 +329,14 @@ as an explicit read-only mount.
327
329
  values remain environment-variable references rather than literals.
328
330
  Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting
329
331
  it inside Navarch's already isolated session container.
332
+ - **OpenCode:** the CLI cannot currently express a host-side boundary for all
333
+ shell side effects. With the guard enabled, host launches therefore fail
334
+ closed before the CLI starts. Run OpenCode in Docker with an operator-owned
335
+ image that contains an authenticated `opencode` binary, or set
336
+ `NAVARCH_WORKTREE_GUARD=off` only when the whole machine is already isolated.
337
+ OpenCode receives a private, per-session config, project config discovery is
338
+ disabled, and lease MCP headers are referenced through child-only environment
339
+ variables instead of being copied into its config file or argv.
330
340
 
331
341
  The resulting boundary is:
332
342
 
@@ -382,8 +392,13 @@ export NAVARCH_AGENT=codex
382
392
  # NAVARCH_GEMINI_BIN pointing at it).
383
393
  export NAVARCH_AGENT=gemini
384
394
 
395
+ # OpenCode — requires an authenticated `opencode` CLI (or
396
+ # NAVARCH_OPENCODE_BIN pointing at it). The guarded host path fails closed;
397
+ # use a Docker image containing OpenCode for the normal isolated path.
398
+ export NAVARCH_AGENT=opencode
399
+
385
400
  # Advanced compatibility mode: advertise every installed adapter.
386
- export NAVARCH_RUNTIMES=claude-code,codex,gemini
401
+ export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode
387
402
  ```
388
403
 
389
404
  For the legacy single-runtime setting, priority is `start --agent` →
@@ -392,7 +407,7 @@ For the legacy single-runtime setting, priority is `start --agent` →
392
407
  the locally selected `NAVARCH_AGENT` handles every claimed task. Sandbox
393
408
  projects remain constrained to Claude Code.
394
409
 
395
- All three adapters implement the same `AgentAdapter` interface
410
+ All four BYO adapters implement the same `AgentAdapter` interface
396
411
  (`src/adapters/types.cts`) and run either directly on the host or via
397
412
  `docker exec` in the session's sandbox container, exactly like the Claude
398
413
  adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
@@ -402,11 +417,13 @@ through to `complete()` unchanged by whatever happened during the run.
402
417
  The control plane also resolves the project's model and the task's execution
403
418
  profile on every claim. The runtime passes those values as per-session CLI
404
419
  overrides (`codex exec --model ... -c model_reasoning_effort=...`,
405
- `claude -p --model ... --effort ...`, or `gemini --model ...`) and records the
406
- effective model, profile, and effort on completion. Gemini currently uses its
407
- `auto` model default and does not expose a reasoning-effort flag. Machine-wide
408
- extra arguments still configure other CLI behavior; dispatched model policy
409
- wins.
420
+ `claude -p --model ... --effort ...`, `gemini --model ...`, or `opencode run
421
+ --model ... --variant ...`) and records the effective model, profile, and
422
+ effort on completion. Gemini currently uses its `auto` model default and does
423
+ not expose a reasoning-effort flag. OpenCode accepts `default` to preserve the
424
+ authenticated account's selection, or any `provider/model` reference.
425
+ Machine-wide extra arguments still configure other CLI behavior; dispatched
426
+ model policy wins.
410
427
 
411
428
  The Codex CLI invocation was verified against `codex-cli 0.144.1` on
412
429
  2026-07-18. The runtime uses `codex exec "<prompt>" --json` and translates
@@ -422,6 +439,13 @@ post-tool answer as the report, and reads aggregate `input_tokens` and
422
439
  `output_tokens` from the terminal `result` event. Gemini does not report USD
423
440
  cost, so an unknown value remains absent rather than becoming zero.
424
441
 
442
+ The OpenCode adapter uses `opencode run "<prompt>" --format json`, closes
443
+ stdin immediately, and normalizes `text` and `step_finish` JSONL events into
444
+ the shared report/token/cost boundary. It translates the lease MCP document
445
+ to OpenCode's remote-server config with OAuth disabled and environment-backed
446
+ headers. Missing accounting fields stay absent rather than becoming a
447
+ fabricated zero.
448
+
425
449
  ## Architecture
426
450
 
427
451
  ```
@@ -521,8 +545,12 @@ tests cover:
521
545
  - `adapters/gemini.cts` — host/Docker invocation, cancellation, sandbox/MCP
522
546
  credential hygiene, parser drift, and normalized report/usage extraction
523
547
  (`tests/adapters/gemini.test.cts`).
548
+ - `adapters/opencode.cts` — real fake-binary invocation covering the prompt,
549
+ isolated config and MCP header references, JSONL normalization, timeout,
550
+ lease cancellation, and fail-closed host boundary
551
+ (`tests/adapters/opencode.test.cts`).
524
552
  - `adapters/types.cts` — the shared process-result boundary is exercised for
525
- Claude, Codex, and Gemini (`tests/adapters/conformance.test.cts`).
553
+ Claude, Codex, Gemini, and OpenCode (`tests/adapters/conformance.test.cts`).
526
554
  - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
527
555
  config shape, native Codex permission-profile construction, and the hook's
528
556
  containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
@@ -557,6 +585,9 @@ secrets absent from disk after exit"):
557
585
  production composition.
558
586
  - A full Gemini task that uses an authenticated Gemini CLI to claim, edit,
559
587
  open a PR, report usage/evidence, and clean up its session sandbox.
588
+ - A full OpenCode task in an operator-owned Docker image that contains an
589
+ authenticated OpenCode CLI, including lease MCP access, cancellation, PR
590
+ creation, attribution, and cleanup.
560
591
  - Whether Docker-mode Codex should opt into
561
592
  `--dangerously-bypass-approvals-and-sandbox`. It is intentionally not a
562
593
  default: host mode is not an external sandbox, and silently disabling
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
3
+ exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
4
4
  exports.selectAdapter = selectAdapter;
5
5
  const claude_cjs_1 = require("./claude.cjs");
6
6
  Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
@@ -11,6 +11,9 @@ Object.defineProperty(exports, "runCodexAdapter", { enumerable: true, get: funct
11
11
  const gemini_cjs_1 = require("./gemini.cjs");
12
12
  Object.defineProperty(exports, "geminiAdapter", { enumerable: true, get: function () { return gemini_cjs_1.geminiAdapter; } });
13
13
  Object.defineProperty(exports, "runGeminiAdapter", { enumerable: true, get: function () { return gemini_cjs_1.runGeminiAdapter; } });
14
+ const opencode_cjs_1 = require("./opencode.cjs");
15
+ Object.defineProperty(exports, "openCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.openCodeAdapter; } });
16
+ Object.defineProperty(exports, "runOpenCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.runOpenCodeAdapter; } });
14
17
  /**
15
18
  * Picks the AgentAdapter (adapters/types.cts) session.cts should run a
16
19
  * session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
@@ -24,6 +27,8 @@ function selectAdapter(agentType) {
24
27
  return codex_cjs_1.codexAdapter;
25
28
  case "gemini":
26
29
  return gemini_cjs_1.geminiAdapter;
30
+ case "opencode":
31
+ return opencode_cjs_1.openCodeAdapter;
27
32
  case "claude-code":
28
33
  return claude_cjs_1.claudeCodeAdapter;
29
34
  default: {
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openCodeAdapter = void 0;
4
+ exports.runOpenCodeAdapter = runOpenCodeAdapter;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_fs_1 = require("node:fs");
7
+ /**
8
+ * Headless OpenCode adapter, fixture-pinned to the OpenCode 1.18 JSON/config
9
+ * contract:
10
+ *
11
+ * opencode run "<prompt>" --format json [--model provider/model]
12
+ *
13
+ * OpenCode merges configuration from several machine and project locations,
14
+ * so each Navarch session gets an isolated XDG/config directory, disables
15
+ * project discovery, and supplies its complete policy as both a private file
16
+ * and the highest-precedence inline config. Provider credentials remain in
17
+ * OpenCode's data store or the child environment; only MCP env references are
18
+ * written to disk.
19
+ */
20
+ async function runOpenCodeAdapter(options) {
21
+ if (options.opencodeHostGuard && !options.dockerExec) {
22
+ return {
23
+ exitCode: 1,
24
+ timedOut: false,
25
+ killedByLeaseLoss: false,
26
+ stdout: "",
27
+ stderr: "OpenCode cannot enforce Navarch's host worktree boundary for shell side effects. " +
28
+ "Use NAVARCH_SANDBOX_MODE=docker, or explicitly set NAVARCH_WORKTREE_GUARD=off " +
29
+ "only on an otherwise isolated machine.",
30
+ };
31
+ }
32
+ const env = { ...options.env };
33
+ await prepareOpenCodeConfig(options.mcpConfigPath, env);
34
+ const args = ["run", options.prompt];
35
+ if (!hasArg(options.extraArgs, "--format"))
36
+ args.push("--format", "json");
37
+ args.push(...options.extraArgs);
38
+ if (options.model && options.model !== "default" && !hasArg(options.extraArgs, "--model", "-m")) {
39
+ args.push("--model", options.model);
40
+ }
41
+ if (options.reasoningEffort && !hasArg(options.extraArgs, "--variant")) {
42
+ args.push("--variant", options.reasoningEffort);
43
+ }
44
+ const runOptions = { ...options, env };
45
+ const raw = runOptions.dockerExec
46
+ ? await runViaDocker(runOptions, args)
47
+ : await runOnHost(runOptions, args);
48
+ return attachOpenCodeOutput(raw);
49
+ }
50
+ function hasArg(args, ...flags) {
51
+ return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
52
+ }
53
+ async function prepareOpenCodeConfig(mcpConfigPath, env) {
54
+ if (!mcpConfigPath) {
55
+ throw new Error("OpenCode requires a per-session MCP config path for isolated execution.");
56
+ }
57
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
58
+ const mcp = {};
59
+ for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
60
+ const url = server.httpUrl ?? server.url;
61
+ if (!url)
62
+ continue;
63
+ const headers = {};
64
+ let headerIndex = 0;
65
+ for (const [header, value] of Object.entries(server.headers ?? {})) {
66
+ const existingEnv = envReference(value);
67
+ if (existingEnv) {
68
+ const referencedValue = env[existingEnv] ?? process.env[existingEnv];
69
+ if (referencedValue === undefined) {
70
+ throw new Error(`OpenCode MCP header references missing environment variable ${existingEnv}.`);
71
+ }
72
+ env[existingEnv] = referencedValue;
73
+ headers[header] = value;
74
+ continue;
75
+ }
76
+ const envName = `NAVARCH_OPENCODE_MCP_${safeEnvSegment(name)}_${headerIndex++}`;
77
+ env[envName] = value;
78
+ headers[header] = `{env:${envName}}`;
79
+ }
80
+ mcp[name] = {
81
+ type: "remote",
82
+ url,
83
+ enabled: true,
84
+ oauth: false,
85
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
86
+ };
87
+ }
88
+ const config = {
89
+ $schema: "https://opencode.ai/config.json",
90
+ autoupdate: false,
91
+ share: "disabled",
92
+ plugin: [],
93
+ mcp,
94
+ permission: {
95
+ "*": "allow",
96
+ external_directory: "deny",
97
+ question: "deny",
98
+ doom_loop: "deny",
99
+ },
100
+ };
101
+ const configPath = `${mcpConfigPath}.opencode.json`;
102
+ const configDir = `${mcpConfigPath}.opencode-config`;
103
+ const xdgConfigHome = `${mcpConfigPath}.opencode-xdg`;
104
+ await node_fs_1.promises.mkdir(configDir, { recursive: true, mode: 0o700 });
105
+ await node_fs_1.promises.mkdir(xdgConfigHome, { recursive: true, mode: 0o700 });
106
+ await node_fs_1.promises.writeFile(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
107
+ await node_fs_1.promises.chmod(configPath, 0o600);
108
+ env.OPENCODE_CONFIG = configPath;
109
+ env.OPENCODE_CONFIG_DIR = configDir;
110
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config);
111
+ env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
112
+ env.XDG_CONFIG_HOME = xdgConfigHome;
113
+ }
114
+ function safeEnvSegment(value) {
115
+ return value.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
116
+ }
117
+ function envReference(value) {
118
+ return value.match(/^\{env:([A-Z_][A-Z0-9_]*)\}$/)?.[1] ?? null;
119
+ }
120
+ function attachOpenCodeOutput(result) {
121
+ const events = parseOpenCodeEvents(result.stdout);
122
+ if (events.length === 0)
123
+ return result;
124
+ let tokensIn;
125
+ let tokensOut;
126
+ let costUsd;
127
+ let finalMessageId;
128
+ const textByMessage = new Map();
129
+ for (const event of events) {
130
+ const part = event.part;
131
+ if (event.type === "text" && part?.type === "text" && typeof part.text === "string") {
132
+ const messageId = part.messageID ?? "unknown";
133
+ const parts = textByMessage.get(messageId) ?? [];
134
+ parts.push(part.text);
135
+ textByMessage.set(messageId, parts);
136
+ finalMessageId = messageId;
137
+ }
138
+ if (event.type !== "step_finish" || part?.type !== "step-finish")
139
+ continue;
140
+ const input = nonNegativeMetric(part.tokens?.input);
141
+ const cacheRead = nonNegativeMetric(part.tokens?.cache?.read);
142
+ const cacheWrite = nonNegativeMetric(part.tokens?.cache?.write);
143
+ const output = nonNegativeMetric(part.tokens?.output);
144
+ const cost = nonNegativeMetric(part.cost);
145
+ if (input !== undefined || cacheRead !== undefined || cacheWrite !== undefined) {
146
+ tokensIn = (tokensIn ?? 0) + (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
147
+ }
148
+ if (output !== undefined)
149
+ tokensOut = (tokensOut ?? 0) + output;
150
+ if (cost !== undefined)
151
+ costUsd = (costUsd ?? 0) + cost;
152
+ }
153
+ const reportText = finalMessageId
154
+ ? textByMessage.get(finalMessageId)?.join("\n").trim()
155
+ : undefined;
156
+ return {
157
+ ...result,
158
+ ...(tokensIn !== undefined ? { tokensIn } : {}),
159
+ ...(tokensOut !== undefined ? { tokensOut } : {}),
160
+ ...(costUsd !== undefined ? { costUsd } : {}),
161
+ ...(reportText ? { reportText } : {}),
162
+ };
163
+ }
164
+ function parseOpenCodeEvents(stdout) {
165
+ const events = [];
166
+ for (const line of stdout.split(/\r?\n/)) {
167
+ if (!line.trim().startsWith("{"))
168
+ continue;
169
+ try {
170
+ const event = JSON.parse(line);
171
+ if (event && typeof event === "object" && typeof event.type === "string")
172
+ events.push(event);
173
+ }
174
+ catch {
175
+ // Keep unknown lines in the transcript and degrade to raw output.
176
+ }
177
+ }
178
+ return events;
179
+ }
180
+ function nonNegativeMetric(value) {
181
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
182
+ }
183
+ async function runOnHost(options, args) {
184
+ return new Promise((resolve) => {
185
+ let stdout = "";
186
+ let stderr = "";
187
+ let timedOut = false;
188
+ let killedByLeaseLoss = false;
189
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
190
+ cwd: options.cwd,
191
+ env: { ...process.env, ...options.env },
192
+ });
193
+ child.stdin?.end();
194
+ const timer = setTimeout(() => {
195
+ timedOut = true;
196
+ child.kill("SIGKILL");
197
+ }, options.timeoutMs);
198
+ const onAbort = () => {
199
+ killedByLeaseLoss = true;
200
+ child.kill("SIGKILL");
201
+ };
202
+ options.signal?.addEventListener("abort", onAbort, { once: true });
203
+ child.stdout.on("data", (data) => {
204
+ stdout += data.toString();
205
+ });
206
+ child.stderr.on("data", (data) => {
207
+ stderr += data.toString();
208
+ });
209
+ child.on("error", (error) => {
210
+ clearTimeout(timer);
211
+ options.signal?.removeEventListener("abort", onAbort);
212
+ stderr += `\n${String(error)}`;
213
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
214
+ });
215
+ child.on("close", (code) => {
216
+ clearTimeout(timer);
217
+ options.signal?.removeEventListener("abort", onAbort);
218
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
219
+ });
220
+ });
221
+ }
222
+ async function runViaDocker(options, args) {
223
+ const { containerName, runner } = options.dockerExec;
224
+ const quoted = [options.bin, ...args].map(shellQuote).join(" ");
225
+ const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
226
+ let killedByLeaseLoss = false;
227
+ const onAbort = () => {
228
+ killedByLeaseLoss = true;
229
+ runner.run("docker", ["kill", containerName]).catch(() => undefined);
230
+ };
231
+ options.signal?.addEventListener("abort", onAbort, { once: true });
232
+ try {
233
+ const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
234
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
235
+ return {
236
+ exitCode: result.code,
237
+ timedOut: false,
238
+ killedByLeaseLoss,
239
+ stdout: result.stdout,
240
+ stderr: result.stderr,
241
+ };
242
+ }
243
+ catch (error) {
244
+ return {
245
+ exitCode: null,
246
+ timedOut: false,
247
+ killedByLeaseLoss,
248
+ stdout: "",
249
+ stderr: String(error),
250
+ };
251
+ }
252
+ finally {
253
+ options.signal?.removeEventListener("abort", onAbort);
254
+ }
255
+ }
256
+ function shellQuote(value) {
257
+ return `'${value.replace(/'/g, `'\\''`)}'`;
258
+ }
259
+ exports.openCodeAdapter = {
260
+ agentType: "opencode",
261
+ run: runOpenCodeAdapter,
262
+ };
package/dist/cli.cjs CHANGED
@@ -66,7 +66,7 @@ function agentFromFlag(flags) {
66
66
  if (value === undefined)
67
67
  return undefined;
68
68
  if (!(0, config_cjs_1.isRuntimeAgentType)(value)) {
69
- throw new Error("--agent must be one of 'claude-code', 'codex', or 'gemini'.");
69
+ throw new Error("--agent must be one of 'claude-code', 'codex', 'gemini', or 'opencode'.");
70
70
  }
71
71
  return value;
72
72
  }
@@ -250,8 +250,8 @@ async function superviseCommand(flags) {
250
250
  // Resolve and pin the adapter before spawning the worker. The supervisor
251
251
  // passes machine credentials through the environment, so the child no
252
252
  // longer reads machine.json for identity fields (including agent_type).
253
- // Without an explicit worker argument, a saved Codex/Gemini selection
254
- // therefore falls back to Claude Code.
253
+ // Without an explicit worker argument, any saved non-Claude selection
254
+ // would therefore fall back to Claude Code.
255
255
  const agentType = agentFromFlag(flags) ??
256
256
  (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
257
257
  const workerArgs = ["--agent", agentType];
@@ -296,11 +296,11 @@ function helpText() {
296
296
 
297
297
  Usage:
298
298
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
299
- [--config-dir <path>] [--agent claude-code|codex|gemini] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
299
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
300
300
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
301
- [--config-dir <path>] [--agent claude-code|codex|gemini] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
302
- navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini]
303
- navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini]
301
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
302
+ navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini|opencode]
303
+ navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini|opencode]
304
304
  navarch-runtime doctor [--config-dir <path>]
305
305
 
306
306
  Use a different --config-dir (or NAVARCH_CONFIG_DIR) for every agent instance.
package/dist/config.cjs CHANGED
@@ -11,7 +11,10 @@ const node_os_1 = __importDefault(require("node:os"));
11
11
  /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
12
12
  exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
13
13
  function isRuntimeAgentType(value) {
14
- return value === "claude-code" || value === "codex" || value === "gemini";
14
+ return (value === "claude-code" ||
15
+ value === "codex" ||
16
+ value === "gemini" ||
17
+ value === "opencode");
15
18
  }
16
19
  function envInt(env, name, fallback) {
17
20
  const raw = env[name];
@@ -77,6 +80,8 @@ function loadRuntimeConfig(env = process.env) {
77
80
  codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
78
81
  geminiBin: env.NAVARCH_GEMINI_BIN ?? "gemini",
79
82
  geminiExtraArgs: envList(env, "NAVARCH_GEMINI_EXTRA_ARGS", []),
83
+ opencodeBin: env.NAVARCH_OPENCODE_BIN ?? "opencode",
84
+ opencodeExtraArgs: envList(env, "NAVARCH_OPENCODE_EXTRA_ARGS", []),
80
85
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
81
86
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
82
87
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
@@ -15,10 +15,13 @@
15
15
  // Pure and side-effect free (no filesystem access) so it's trivially unit
16
16
  // testable; session.cts is the only caller that writes the result to disk.
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.NAVARCH_LEASE_HEADER = void 0;
18
+ exports.NAVARCH_MCP_LEASE_ENV = exports.NAVARCH_MCP_AUTH_ENV = exports.NAVARCH_LEASE_HEADER = void 0;
19
19
  exports.buildNavarchMcpConfig = buildNavarchMcpConfig;
20
+ exports.buildEnvReferencedNavarchMcpConfig = buildEnvReferencedNavarchMcpConfig;
20
21
  /** The header app/api/mcp/route.ts's lib/navarch/mcp/context.ts reads to identify which lease is calling. */
21
22
  exports.NAVARCH_LEASE_HEADER = "X-Navarch-Lease-Id";
23
+ exports.NAVARCH_MCP_AUTH_ENV = "NAVARCH_OPENCODE_MCP_AUTHORIZATION";
24
+ exports.NAVARCH_MCP_LEASE_ENV = "NAVARCH_OPENCODE_MCP_LEASE_ID";
22
25
  /**
23
26
  * Builds the `.mcp.json`-shaped config object Claude Code's `--mcp-config`
24
27
  * flag expects: a remote "http" (streamable HTTP) server entry with the
@@ -39,3 +42,29 @@ function buildNavarchMcpConfig(opts) {
39
42
  },
40
43
  };
41
44
  }
45
+ /**
46
+ * OpenCode reads `{env:NAME}` substitutions natively. Build its staging MCP
47
+ * document without bearer values so even the short-lived source JSON is safe
48
+ * to write in session metadata before the adapter converts it.
49
+ */
50
+ function buildEnvReferencedNavarchMcpConfig(opts) {
51
+ const serverName = opts.serverName ?? "navarch";
52
+ return {
53
+ config: {
54
+ mcpServers: {
55
+ [serverName]: {
56
+ type: "http",
57
+ url: `${opts.apiBase.replace(/\/+$/, "")}/api/mcp`,
58
+ headers: {
59
+ Authorization: `{env:${exports.NAVARCH_MCP_AUTH_ENV}}`,
60
+ [exports.NAVARCH_LEASE_HEADER]: `{env:${exports.NAVARCH_MCP_LEASE_ENV}}`,
61
+ },
62
+ },
63
+ },
64
+ },
65
+ env: {
66
+ [exports.NAVARCH_MCP_AUTH_ENV]: `Bearer ${opts.machineToken}`,
67
+ [exports.NAVARCH_MCP_LEASE_ENV]: opts.leaseId,
68
+ },
69
+ };
70
+ }
package/dist/session.cjs CHANGED
@@ -32,7 +32,7 @@ const log = (0, logger_cjs_1.createLogger)("session");
32
32
  * 2. fetch secrets from the broker at session start (managed GitHub git
33
33
  * credentials are subsequently refreshed by a lease-scoped helper)
34
34
  * 3. optionally stand up a Docker sandbox when explicitly configured
35
- * 4. run the adapter selected by the claim (Claude Code, Codex, or Gemini;
35
+ * 4. run the adapter selected by the claim (Claude Code, Codex, Gemini, or OpenCode;
36
36
  * adapters/index.cts#selectAdapter), heartbeating the
37
37
  * lease throughout
38
38
  * 5. redact + upload the transcript, map the exit condition, complete the
@@ -55,7 +55,9 @@ async function runSession(deps, claimed, sessionId) {
55
55
  ? "gpt-5.6-sol"
56
56
  : runtime === "gemini"
57
57
  ? "auto"
58
- : "best",
58
+ : runtime === "opencode"
59
+ ? "default"
60
+ : "best",
59
61
  reasoning_effort: "medium",
60
62
  };
61
63
  const executionReport = {
@@ -215,17 +217,23 @@ async function runSession(deps, claimed, sessionId) {
215
217
  // path valid in whichever environment it actually runs.
216
218
  let mcpConfigPath = config.mcpConfigPath;
217
219
  if (!mcpConfigPath) {
218
- const mcpConfig = (0, mcp_config_cjs_1.buildNavarchMcpConfig)({
220
+ const mcpOptions = {
219
221
  apiBase: api.getBaseUrl(),
220
222
  machineToken: api.getToken() ?? "",
221
223
  leaseId,
222
- });
223
- await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, MCP_CONFIG_FILENAME), JSON.stringify(mcpConfig, null, 2), "utf8");
224
+ };
225
+ const mcpConfig = runtime === "opencode"
226
+ ? (0, mcp_config_cjs_1.buildEnvReferencedNavarchMcpConfig)(mcpOptions)
227
+ : { config: (0, mcp_config_cjs_1.buildNavarchMcpConfig)(mcpOptions), env: {} };
228
+ Object.assign(sessionEnv, mcpConfig.env);
229
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, MCP_CONFIG_FILENAME), JSON.stringify(mcpConfig.config, null, 2), "utf8");
224
230
  mcpConfigPath = node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
225
231
  }
226
232
  // Worktree boundary guard (worktree-guard.cts): several sessions share this
227
233
  // machine. Host-mode Claude gets a generated PreToolUse hook; host-mode
228
- // Codex gets an OS-enforced native permission profile over the same roots.
234
+ // Codex gets an OS-enforced native permission profile over the same roots;
235
+ // Gemini gets a native sandbox mount list; OpenCode fails closed on the
236
+ // guarded host path because its CLI cannot express the full boundary.
229
237
  // Docker mode already has a container boundary. NAVARCH_WORKTREE_GUARD=off
230
238
  // opts out for any agent.
231
239
  let claudeSettingsPath = null;
@@ -256,7 +264,7 @@ async function runSession(deps, claimed, sessionId) {
256
264
  extraRoots: config.guardExtraRoots,
257
265
  });
258
266
  }
259
- else {
267
+ else if (runtime === "gemini") {
260
268
  geminiGuardMounts = (0, worktree_guard_cjs_1.geminiSandboxMounts)({
261
269
  workDir,
262
270
  worktreePath: gitWorktree.worktreePath,
@@ -311,6 +319,7 @@ async function runSession(deps, claimed, sessionId) {
311
319
  settingsPath: claudeSettingsPath,
312
320
  codexGuardArgs,
313
321
  geminiSandboxMounts: geminiGuardMounts,
322
+ opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
314
323
  cwd: sandbox ? undefined : gitWorktree.worktreePath,
315
324
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
316
325
  signal: activeAbortController.signal,
@@ -526,6 +535,8 @@ function adapterCommand(config, runtime) {
526
535
  return { bin: config.codexBin, extraArgs: config.codexExtraArgs };
527
536
  case "gemini":
528
537
  return { bin: config.geminiBin, extraArgs: config.geminiExtraArgs };
538
+ case "opencode":
539
+ return { bin: config.opencodeBin, extraArgs: config.opencodeExtraArgs };
529
540
  }
530
541
  }
531
542
  function sumReportedUsage(attempts, key) {
@@ -556,6 +567,10 @@ function verificationFailureReport(taskType, report) {
556
567
  /** Rejection codes another agent turn in the same worktree can plausibly fix. */
557
568
  const REMEDIABLE_REJECTION_CODES = new Set([
558
569
  "pr_required",
570
+ // A video-deliverable build that completed without registering its render:
571
+ // the file usually already exists in the worktree, so the remediation turn
572
+ // only has to call register_artifact and PUT the upload.
573
+ "artifact_required",
559
574
  "pr_not_ready",
560
575
  "review_attestation_required",
561
576
  "review_attestation_invalid",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.24",
4
- "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
3
+ "version": "0.1.26",
4
+ "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, Gemini, or OpenCode.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -16,6 +16,7 @@
16
16
  "claude-code",
17
17
  "codex",
18
18
  "gemini-cli",
19
+ "opencode",
19
20
  "task-runner"
20
21
  ],
21
22
  "bin": {