@sagentlab/navarch-runtime 0.1.40 → 0.1.42

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
@@ -2,12 +2,12 @@
2
2
 
3
3
  The machine-side half of Navarch (WP-07): registers this machine with the
4
4
  control plane, then loops claim → sandbox → agent CLI → report until you stop
5
- it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
5
+ it. Plain Node/TypeScript, no Next.js coupling, and no shared app-code coupling
6
6
  — this directory is a self-contained package you can `npx` on any fresh
7
7
  machine.
8
8
 
9
9
  The machine operator selects **Claude Code**, **OpenAI Codex**, **Google
10
- Gemini CLI**, or **OpenCode** when connecting a worker. Any selected agent can
10
+ Gemini CLI**, **OpenCode**, or an **Agent Client Protocol (ACP)** agent when connecting a worker. Any selected agent can
11
11
  run any project task; task eligibility depends on capabilities and project
12
12
  gates, not agent type — see "Choosing an agent" below.
13
13
 
@@ -29,7 +29,7 @@ Before generating the command, prepare the machine:
29
29
  - install Node.js 20 or later (the standard installation includes `npm` and
30
30
  `npx`) and Git;
31
31
  - install the coding-agent CLI selected in Navarch — `claude`, `codex`,
32
- `gemini`, or `opencode` — and complete its normal authentication flow; and
32
+ `gemini`, `opencode`, or `dsh` for the default ACP integration — and complete its normal authentication flow; and
33
33
  - for Docker-backed sessions, install and start Docker and use an image that
34
34
  contains the selected agent CLI and the repository toolchain, with provider
35
35
  credentials supplied through the approved machine or project configuration.
@@ -44,7 +44,7 @@ node --version # 20 or later
44
44
  npm --version
45
45
  npx --version
46
46
  git --version
47
- codex --version # or: claude --version / gemini --version / opencode --version
47
+ codex --version # or: claude / gemini / opencode / dsh --version
48
48
  ```
49
49
 
50
50
  Every command above must succeed before the long-running worker starts. See
@@ -152,10 +152,10 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
152
152
 
153
153
  | Command | Purpose |
154
154
  |---|---|
155
- | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
156
- | `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. |
157
- | `start [--agent claude-code\|codex\|gemini\|opencode]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
158
- | `supervise [--agent claude-code\|codex\|gemini\|opencode]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
155
+ | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
156
+ | `connect --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
157
+ | `start [--agent claude-code\|codex\|gemini\|opencode\|acp]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
158
+ | `supervise [--agent claude-code\|codex\|gemini\|opencode\|acp]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
159
159
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
160
160
 
161
161
  ### Running multiple agents on one machine
@@ -311,7 +311,7 @@ unchanged across the deployment.
311
311
  | `NAVARCH_SANDBOX_PROFILE` | `trusted-development` | Named security profile for Docker sessions (`src/sandbox-profile.cts`): `trusted-development` (image-default user, uncapped, open egress), `untrusted-code` (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), `elevated-verification` (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
312
312
  | `NAVARCH_SANDBOX_EGRESS_NETWORK` | _(unset)_ | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to `--network=none` and records the denial. |
313
313
  | `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. |
314
- | `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. |
314
+ | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent adapter: `claude-code`, `codex`, `gemini`, `opencode`, or `acp`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
315
315
  | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
316
316
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
317
317
  | `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`. |
@@ -323,6 +323,8 @@ unchanged across the deployment.
323
323
  | `NAVARCH_GEMINI_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP settings, `stream-json`, and unattended defaults (Gemini). |
324
324
  | `NAVARCH_OPENCODE_BIN` | `opencode` | Path/name of the OpenCode CLI binary. |
325
325
  | `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. |
326
+ | `NAVARCH_ACP_BIN` | `dsh` | Path/name of an Agent Client Protocol v1 stdio server. DeepSeek Harness is the default implementation. |
327
+ | `NAVARCH_ACP_EXTRA_ARGS` | `--profile,acp` | Comma list of arguments used to start the ACP server. Override this together with `NAVARCH_ACP_BIN` for another ACP-compatible coding agent. |
326
328
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
327
329
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable. |
328
330
  | `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. |
@@ -383,6 +385,10 @@ as an explicit read-only mount.
383
385
  OpenCode receives a private, per-session config, project config discovery is
384
386
  disabled, and lease MCP headers are referenced through child-only environment
385
387
  variables instead of being copied into its config file or argv.
388
+ - **ACP agents:** the protocol client rejects every unattended permission
389
+ escalation. The agent remains responsible for enforcing its declared file
390
+ sandbox; use Navarch Docker mode when the ACP server itself does not provide
391
+ a trustworthy workspace boundary.
386
392
 
387
393
  The resulting boundary is:
388
394
 
@@ -443,8 +449,12 @@ export NAVARCH_AGENT=gemini
443
449
  # use a Docker image containing OpenCode for the normal isolated path.
444
450
  export NAVARCH_AGENT=opencode
445
451
 
452
+ # Agent Client Protocol — defaults to DeepSeek Harness `dsh --profile acp`.
453
+ # Override NAVARCH_ACP_BIN / NAVARCH_ACP_EXTRA_ARGS for another ACP v1 server.
454
+ export NAVARCH_AGENT=acp
455
+
446
456
  # Advanced compatibility mode: advertise every installed adapter.
447
- export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode
457
+ export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp
448
458
  ```
449
459
 
450
460
  For the legacy single-runtime setting, priority is `start --agent` →
@@ -453,7 +463,7 @@ For the legacy single-runtime setting, priority is `start --agent` →
453
463
  the locally selected `NAVARCH_AGENT` handles every claimed task. Sandbox
454
464
  projects remain constrained to Claude Code.
455
465
 
456
- All four BYO adapters implement the same `AgentAdapter` interface
466
+ All five BYO adapters implement the same `AgentAdapter` interface
457
467
  (`src/adapters/types.cts`) and run either directly on the host or via
458
468
  `docker exec` in the session's sandbox container, exactly like the Claude
459
469
  adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
@@ -469,7 +479,8 @@ effort on completion. Gemini currently uses its `auto` model default and does
469
479
  not expose a reasoning-effort flag. OpenCode accepts any `provider/model`
470
480
  reference (the platform default is `opencode/x-preview-f-free`, Ox Alpha Free
471
481
  (Unlimited) on OpenCode Zen), or `default` to preserve the authenticated
472
- account's selection.
482
+ account's selection. ACP sessions use the agent's advertised default model and
483
+ set the standard `reasoning_effort` configuration option when it is available.
473
484
  Machine-wide extra arguments still configure other CLI behavior; dispatched
474
485
  model policy wins.
475
486
 
@@ -527,6 +538,15 @@ to OpenCode's remote-server config with OAuth disabled and environment-backed
527
538
  headers. Missing accounting fields stay absent rather than becoming a
528
539
  fabricated zero.
529
540
 
541
+ The ACP adapter speaks the standard [Agent Client Protocol](https://agentclientprotocol.com)
542
+ v1 JSON-RPC transport over stdio. It creates one protocol session in the task
543
+ worktree, converts Navarch's HTTP/stdio MCP entries, consumes semantic message,
544
+ thought, and usage updates, and fails permission requests closed because no
545
+ human is attached to a worker turn. [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
546
+ ships that server as `dsh --profile acp`. This is intentionally not the
547
+ REST-based [Agent Communication Protocol](https://agentcommunicationprotocol.dev),
548
+ which is a different protocol now maintained as part of A2A.
549
+
530
550
  ## Architecture
531
551
 
532
552
  ```
@@ -547,6 +567,8 @@ cli.cts
547
567
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
548
568
  - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
549
569
  - geminiAdapter (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
570
+ - openCodeAdapter (adapters/opencode.cts) — `opencode run <prompt> --format json`
571
+ - acpAdapter (adapters/acp.cts) — ACP v1 JSON-RPC/stdio; defaults to `dsh --profile acp`
550
572
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
551
573
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
552
574
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.acpAdapter = void 0;
4
+ exports.runAcpAdapter = runAcpAdapter;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_fs_1 = require("node:fs");
7
+ const node_stream_1 = require("node:stream");
8
+ /**
9
+ * How long a cancelled agent may take to observe `session/cancel` and settle
10
+ * the in-flight `session/prompt` before the subprocess is terminated. The
11
+ * notification is only written to the child's stdin; without this window the
12
+ * SIGTERM in `terminateChild` can land before the agent's event loop ever
13
+ * reads the line, so the protocol cancellation is silently lost and the agent
14
+ * gets no chance to release what it is holding.
15
+ */
16
+ const CANCEL_GRACE_MS = 2_000;
17
+ /**
18
+ * Generic Agent Client Protocol v1 adapter. DeepSeek Harness is the default
19
+ * implementation (`dsh --profile acp`), while NAVARCH_ACP_BIN and
20
+ * NAVARCH_ACP_EXTRA_ARGS let an operator substitute any ACP-compatible coding
21
+ * agent without adding another Navarch adapter.
22
+ *
23
+ * The similarly named Agent Communication Protocol at
24
+ * agentcommunicationprotocol.dev is a different, REST-based agent-to-agent
25
+ * protocol. Coding-agent subprocesses such as dsh expose Agent Client Protocol
26
+ * over newline-delimited JSON-RPC on stdio, which is the boundary implemented
27
+ * here.
28
+ */
29
+ async function runAcpAdapter(options) {
30
+ const acp = await import("@agentclientprotocol/sdk");
31
+ const mcpServers = await readMcpServers(options.mcpConfigPath, options.env);
32
+ const child = spawnAcpAgent(options);
33
+ const rawStdout = [];
34
+ const stderr = [];
35
+ const protocolInput = new node_stream_1.PassThrough();
36
+ const messages = [];
37
+ const thoughts = [];
38
+ let costUsd;
39
+ let timedOut = false;
40
+ let killedByLeaseLoss = false;
41
+ let sessionId;
42
+ let stopReason;
43
+ // Resolved once the in-flight prompt request settles, however it settles, so
44
+ // cancellation can wait for the agent instead of racing the kill.
45
+ let settlePrompt = () => undefined;
46
+ const promptSettled = new Promise((resolve) => {
47
+ settlePrompt = resolve;
48
+ });
49
+ child.stdout.on("data", (chunk) => {
50
+ rawStdout.push(chunk);
51
+ protocolInput.write(chunk);
52
+ });
53
+ child.stdout.on("end", () => protocolInput.end());
54
+ child.stderr.setEncoding("utf8");
55
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
56
+ child.on("error", (error) => {
57
+ stderr.push(`\n${describeError(error)}`);
58
+ protocolInput.end();
59
+ });
60
+ const stream = acp.ndJsonStream(node_stream_1.Writable.toWeb(child.stdin), node_stream_1.Readable.toWeb(protocolInput));
61
+ const client = acp.client({ name: "navarch-runtime" })
62
+ .onNotification(acp.methods.client.session.update, ({ params }) => {
63
+ collectUpdate(params, messages, thoughts, (value) => {
64
+ costUsd = value;
65
+ });
66
+ })
67
+ // Navarch sessions are unattended. Permission escalation therefore fails
68
+ // closed instead of inventing human consent on the worker's behalf.
69
+ .onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
70
+ const rejection = params.options.find((option) => option.kind === "reject_always")
71
+ ?? params.options.find((option) => option.kind === "reject_once");
72
+ return rejection
73
+ ? { outcome: { outcome: "selected", optionId: rejection.optionId } }
74
+ : { outcome: { outcome: "cancelled" } };
75
+ });
76
+ const connection = client.connect(stream);
77
+ const controller = new AbortController();
78
+ let abortProtocol = Promise.resolve();
79
+ controller.signal.addEventListener("abort", () => {
80
+ abortProtocol = (async () => {
81
+ if (sessionId) {
82
+ await connection.agent.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => undefined);
83
+ await Promise.race([
84
+ promptSettled,
85
+ new Promise((resolve) => setTimeout(resolve, CANCEL_GRACE_MS)),
86
+ ]);
87
+ }
88
+ connection.close();
89
+ })();
90
+ }, { once: true });
91
+ const timer = setTimeout(() => {
92
+ timedOut = true;
93
+ controller.abort();
94
+ }, options.timeoutMs);
95
+ const onAbort = () => {
96
+ killedByLeaseLoss = true;
97
+ controller.abort();
98
+ };
99
+ options.signal?.addEventListener("abort", onAbort, { once: true });
100
+ if (options.signal?.aborted)
101
+ onAbort();
102
+ try {
103
+ const context = connection.agent;
104
+ await context.request(acp.methods.agent.initialize, {
105
+ protocolVersion: acp.PROTOCOL_VERSION,
106
+ clientCapabilities: {},
107
+ });
108
+ const created = await context.request(acp.methods.agent.session.new, {
109
+ cwd: options.cwd ?? process.cwd(),
110
+ mcpServers,
111
+ });
112
+ sessionId = created.sessionId;
113
+ let configOptions = created.configOptions ?? [];
114
+ configOptions = await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "model", options.model === "default" ? undefined : options.model);
115
+ await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "reasoning_effort", options.reasoningEffort);
116
+ let response;
117
+ try {
118
+ response = await context.request(acp.methods.agent.session.prompt, {
119
+ sessionId,
120
+ prompt: [{ type: "text", text: options.prompt }],
121
+ });
122
+ }
123
+ finally {
124
+ settlePrompt();
125
+ }
126
+ stopReason = response.stopReason;
127
+ await Promise.race([
128
+ context.request(acp.methods.agent.session.close, { sessionId }).catch(() => undefined),
129
+ new Promise((resolve) => setTimeout(resolve, Math.min(5_000, options.timeoutMs))),
130
+ ]);
131
+ }
132
+ catch (error) {
133
+ if (!controller.signal.aborted)
134
+ stderr.push(`\n${describeError(error)}`);
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ options.signal?.removeEventListener("abort", onAbort);
139
+ await abortProtocol;
140
+ connection.close();
141
+ await terminateChild(child, options, controller.signal.aborted);
142
+ }
143
+ if (thoughts.length > 0)
144
+ stderr.push(`\n# ACP agent thoughts\n${thoughts.join("\n")}`);
145
+ const reportText = messages.join("").trim();
146
+ const successfulStop = stopReason === "end_turn" || stopReason === "max_tokens" || stopReason === "max_turn_requests";
147
+ return {
148
+ exitCode: successfulStop ? 0 : timedOut || killedByLeaseLoss ? null : 1,
149
+ timedOut,
150
+ killedByLeaseLoss,
151
+ stdout: Buffer.concat(rawStdout).toString("utf8"),
152
+ stderr: stderr.join(""),
153
+ ...(reportText ? { reportText } : {}),
154
+ ...(costUsd !== undefined ? { costUsd } : {}),
155
+ };
156
+ }
157
+ function collectUpdate(notification, messages, thoughts, setCostUsd) {
158
+ const update = notification.update;
159
+ if ((update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk")
160
+ && update.content.type === "text") {
161
+ (update.sessionUpdate === "agent_message_chunk" ? messages : thoughts).push(update.content.text);
162
+ }
163
+ if (update.sessionUpdate === "usage_update"
164
+ && update.cost?.currency.toUpperCase() === "USD"
165
+ && Number.isFinite(update.cost.amount)
166
+ && update.cost.amount >= 0) {
167
+ setCostUsd(update.cost.amount);
168
+ }
169
+ }
170
+ async function setConfigOption(context, method, sessionId, options, configId, desired) {
171
+ if (!desired)
172
+ return options;
173
+ const expectedCategory = configId === "model" ? "model" : "thought_level";
174
+ const option = options.find((candidate) => ((candidate.id === configId || candidate.category === expectedCategory)
175
+ && candidate.type === "select"));
176
+ if (!option || option.type !== "select")
177
+ return options;
178
+ const choices = option.options.flatMap((candidate) => "group" in candidate ? candidate.options : [candidate]);
179
+ const selected = choices.find((candidate) => candidate.value === desired || candidate.name === desired);
180
+ if (!selected || selected.value === option.currentValue)
181
+ return options;
182
+ const response = await context.request(method, { sessionId, configId, value: selected.value });
183
+ return response.configOptions ?? options;
184
+ }
185
+ async function readMcpServers(configPath, env) {
186
+ if (!configPath)
187
+ return [];
188
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(configPath, "utf8"));
189
+ return Object.entries(parsed.mcpServers ?? {}).flatMap(([name, server]) => {
190
+ const url = server.httpUrl ?? server.url;
191
+ if (url) {
192
+ return [{
193
+ type: "http",
194
+ name,
195
+ url,
196
+ headers: Object.entries(server.headers ?? {}).map(([header, value]) => ({
197
+ name: header,
198
+ value: resolveEnvReference(value, env),
199
+ })),
200
+ }];
201
+ }
202
+ if (!server.command)
203
+ return [];
204
+ return [{
205
+ name,
206
+ command: server.command,
207
+ args: server.args ?? [],
208
+ env: Object.entries(server.env ?? {}).map(([key, value]) => ({
209
+ name: key,
210
+ value: resolveEnvReference(value, env),
211
+ })),
212
+ }];
213
+ });
214
+ }
215
+ function resolveEnvReference(value, env) {
216
+ const name = value.match(/^\{env:([A-Z_][A-Z0-9_]*)\}$/)?.[1];
217
+ if (!name)
218
+ return value;
219
+ const resolved = env[name] ?? process.env[name];
220
+ if (resolved === undefined)
221
+ throw new Error(`ACP MCP configuration references missing environment variable ${name}.`);
222
+ return resolved;
223
+ }
224
+ function spawnAcpAgent(options) {
225
+ const env = { ...process.env, ...options.env };
226
+ if (!options.dockerExec) {
227
+ return (0, node_child_process_1.spawn)(options.bin, options.extraArgs, {
228
+ cwd: options.cwd,
229
+ env,
230
+ stdio: ["pipe", "pipe", "pipe"],
231
+ });
232
+ }
233
+ const { containerName } = options.dockerExec;
234
+ const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
235
+ return (0, node_child_process_1.spawn)("docker", [
236
+ "exec",
237
+ "-i",
238
+ ...forwardedEnv,
239
+ containerName,
240
+ "sh",
241
+ "-c",
242
+ '[ -f /tmp/session.env ] && . /tmp/session.env; exec "$@"',
243
+ "sh",
244
+ options.bin,
245
+ ...options.extraArgs,
246
+ ], { env, stdio: ["pipe", "pipe", "pipe"] });
247
+ }
248
+ async function terminateChild(child, options, forceContainerKill) {
249
+ if (child.exitCode === null && child.signalCode === null) {
250
+ child.kill("SIGTERM");
251
+ await Promise.race([
252
+ new Promise((resolve) => child.once("close", () => resolve())),
253
+ new Promise((resolve) => setTimeout(resolve, 1_000)),
254
+ ]);
255
+ if (child.exitCode === null && child.signalCode === null)
256
+ child.kill("SIGKILL");
257
+ }
258
+ if (options.dockerExec && forceContainerKill) {
259
+ await options.dockerExec.runner.run("docker", ["kill", options.dockerExec.containerName]).catch(() => undefined);
260
+ }
261
+ }
262
+ function describeError(error) {
263
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
264
+ }
265
+ exports.acpAdapter = {
266
+ agentType: "acp",
267
+ run: runAcpAdapter,
268
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runPiAdapter = exports.piAdapter = exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
3
+ exports.runAcpAdapter = exports.acpAdapter = exports.runPiAdapter = exports.piAdapter = 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; } });
@@ -17,6 +17,9 @@ Object.defineProperty(exports, "runOpenCodeAdapter", { enumerable: true, get: fu
17
17
  const pi_cjs_1 = require("./pi.cjs");
18
18
  Object.defineProperty(exports, "piAdapter", { enumerable: true, get: function () { return pi_cjs_1.piAdapter; } });
19
19
  Object.defineProperty(exports, "runPiAdapter", { enumerable: true, get: function () { return pi_cjs_1.runPiAdapter; } });
20
+ const acp_cjs_1 = require("./acp.cjs");
21
+ Object.defineProperty(exports, "acpAdapter", { enumerable: true, get: function () { return acp_cjs_1.acpAdapter; } });
22
+ Object.defineProperty(exports, "runAcpAdapter", { enumerable: true, get: function () { return acp_cjs_1.runAcpAdapter; } });
20
23
  /**
21
24
  * Picks the AgentAdapter (adapters/types.cts) session.cts should run a
22
25
  * session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
@@ -32,6 +35,8 @@ function selectAdapter(agentType) {
32
35
  return gemini_cjs_1.geminiAdapter;
33
36
  case "opencode":
34
37
  return opencode_cjs_1.openCodeAdapter;
38
+ case "acp":
39
+ return acp_cjs_1.acpAdapter;
35
40
  case "claude-code":
36
41
  return claude_cjs_1.claudeCodeAdapter;
37
42
  case "pi":
@@ -4,6 +4,7 @@ exports.openCodeAdapter = void 0;
4
4
  exports.runOpenCodeAdapter = runOpenCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
+ const config_cjs_1 = require("../config.cjs");
7
8
  /**
8
9
  * Headless OpenCode adapter, fixture-pinned to the OpenCode 1.18 JSON/config
9
10
  * contract:
@@ -24,9 +25,7 @@ async function runOpenCodeAdapter(options) {
24
25
  timedOut: false,
25
26
  killedByLeaseLoss: false,
26
27
  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.",
28
+ stderr: config_cjs_1.OPENCODE_HOST_GUARD_MESSAGE,
30
29
  };
31
30
  }
32
31
  const env = { ...options.env };
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', 'gemini', or 'opencode'.");
69
+ throw new Error("--agent must be one of 'claude-code', 'codex', 'gemini', 'opencode', or 'acp'.");
70
70
  }
71
71
  return value;
72
72
  }
@@ -274,7 +274,19 @@ async function superviseCommand(flags) {
274
274
  process.exitCode = exitCode;
275
275
  }
276
276
  async function doctorCommand(flags) {
277
- const config = configFromFlags(flags);
277
+ // doctor is the command an operator reaches for precisely when the config
278
+ // will not load — an OpenCode-only machine on a guarded host refuses to
279
+ // start (config.cts). Report that as the diagnosis instead of rethrowing it
280
+ // as a bare CLI error.
281
+ let config;
282
+ try {
283
+ config = configFromFlags(flags);
284
+ }
285
+ catch (err) {
286
+ console.log(`config: UNUSABLE — ${err instanceof Error ? err.message : String(err)}`);
287
+ process.exitCode = 1;
288
+ return;
289
+ }
278
290
  const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
279
291
  console.log(`api_base: ${config.apiBase}`);
280
292
  console.log(`config_dir: ${config.configDir}`);
@@ -282,6 +294,8 @@ async function doctorCommand(flags) {
282
294
  console.log(`max_sessions: ${config.maxSessions}`);
283
295
  console.log(`capabilities: ${config.capabilities.join(", ")}`);
284
296
  console.log(`sandbox_mode: ${config.sandboxMode}`);
297
+ console.log(`worktree_guard: ${config.worktreeGuard ? "on" : "off"}`);
298
+ console.log(`runtimes: ${config.runtimes?.join(", ") ?? config.agentType}`);
285
299
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
286
300
  let identity;
287
301
  try {
@@ -301,11 +315,11 @@ function helpText() {
301
315
 
302
316
  Usage:
303
317
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
304
- [--config-dir <path>] [--agent claude-code|codex|gemini|opencode] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
318
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
305
319
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
306
- [--config-dir <path>] [--agent claude-code|codex|gemini|opencode] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
307
- navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini|opencode]
308
- navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini|opencode]
320
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
321
+ navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp]
322
+ navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp]
309
323
  navarch-runtime doctor [--config-dir <path>]
310
324
  navarch-runtime code-graph --query <symbol-or-file> [--mode search|callers|callees|impact] [--commit HEAD] [--depth 1..5] [--repo <path>]
311
325
 
package/dist/config.cjs CHANGED
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DEFAULT_SANDBOX_IMAGE = void 0;
6
+ exports.OPENCODE_HOST_GUARD_MESSAGE = exports.DEFAULT_SANDBOX_IMAGE = void 0;
7
7
  exports.isRuntimeAgentType = isRuntimeAgentType;
8
+ exports.opencodeBlockedOnHost = opencodeBlockedOnHost;
8
9
  exports.loadRuntimeConfig = loadRuntimeConfig;
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const node_os_1 = __importDefault(require("node:os"));
@@ -15,7 +16,8 @@ function isRuntimeAgentType(value) {
15
16
  return (value === "claude-code" ||
16
17
  value === "codex" ||
17
18
  value === "gemini" ||
18
- value === "opencode");
19
+ value === "opencode" ||
20
+ value === "acp");
19
21
  }
20
22
  function envInt(env, name, fallback) {
21
23
  const raw = env[name];
@@ -43,6 +45,18 @@ function envList(env, name, fallback) {
43
45
  .map((s) => s.trim())
44
46
  .filter(Boolean);
45
47
  }
48
+ /**
49
+ * OpenCode is the one runtime Navarch cannot fence into a per-session worktree
50
+ * on the host: its CLI has no equivalent of Claude's PreToolUse hook or
51
+ * Codex's native permission profile. Docker mode supplies the boundary
52
+ * instead, and NAVARCH_WORKTREE_GUARD=off is the explicit opt-out.
53
+ */
54
+ function opencodeBlockedOnHost(sandboxMode, worktreeGuard) {
55
+ return sandboxMode === "host" && worktreeGuard;
56
+ }
57
+ exports.OPENCODE_HOST_GUARD_MESSAGE = "OpenCode cannot enforce Navarch's host worktree boundary for shell side effects. " +
58
+ "Use NAVARCH_SANDBOX_MODE=docker, or explicitly set NAVARCH_WORKTREE_GUARD=off " +
59
+ "only on an otherwise isolated machine.";
46
60
  /**
47
61
  * Loads runtime config from NAVARCH_* env vars, with sane defaults for a
48
62
  * fresh machine. Accepts an explicit env map (defaulting to process.env) so
@@ -56,8 +70,23 @@ function loadRuntimeConfig(env = process.env) {
56
70
  // prerequisite for claiming ordinary shell work.
57
71
  const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
58
72
  const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell", "browser-use"];
59
- const agentType = isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
60
- const runtimes = envList(env, "NAVARCH_RUNTIMES", [agentType]).filter(isRuntimeAgentType);
73
+ const requestedAgentType = isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
74
+ // Multiple sessions share one machine; keeping each agent inside its own
75
+ // worktree is the safe default, so disabling is the explicit opt-out.
76
+ const worktreeGuard = !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? "");
77
+ const requestedRuntimes = envList(env, "NAVARCH_RUNTIMES", [requestedAgentType]).filter(isRuntimeAgentType);
78
+ // OpenCode's CLI cannot express the host worktree boundary, so its adapter
79
+ // fails closed there (adapters/opencode.cts). Advertising it anyway meant
80
+ // the machine claimed OpenCode tasks it could only fail, one lease at a
81
+ // time; keep it out of the claim instead of discovering this per session.
82
+ const runnableRuntimes = requestedRuntimes.filter((runtime) => runtime !== "opencode" || !opencodeBlockedOnHost(sandboxMode, worktreeGuard));
83
+ if (requestedRuntimes.includes("opencode") && runnableRuntimes.length === 0) {
84
+ throw new Error(exports.OPENCODE_HOST_GUARD_MESSAGE);
85
+ }
86
+ const agentType = runnableRuntimes.includes(requestedAgentType) || runnableRuntimes.length === 0
87
+ ? requestedAgentType
88
+ : (runnableRuntimes[0] ?? requestedAgentType);
89
+ const runtimes = runnableRuntimes;
61
90
  return {
62
91
  apiBase: env.NAVARCH_API_BASE ?? "http://localhost:3000",
63
92
  workspaceRoot: env.NAVARCH_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
@@ -83,6 +112,8 @@ function loadRuntimeConfig(env = process.env) {
83
112
  geminiExtraArgs: envList(env, "NAVARCH_GEMINI_EXTRA_ARGS", []),
84
113
  opencodeBin: env.NAVARCH_OPENCODE_BIN ?? "opencode",
85
114
  opencodeExtraArgs: envList(env, "NAVARCH_OPENCODE_EXTRA_ARGS", []),
115
+ acpBin: env.NAVARCH_ACP_BIN ?? "dsh",
116
+ acpExtraArgs: envList(env, "NAVARCH_ACP_EXTRA_ARGS", ["--profile", "acp"]),
86
117
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
87
118
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
88
119
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
@@ -95,9 +126,7 @@ function loadRuntimeConfig(env = process.env) {
95
126
  : sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID,
96
127
  sandboxEgressNetwork: env.NAVARCH_SANDBOX_EGRESS_NETWORK?.trim() || null,
97
128
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
98
- // Multiple sessions share one machine; keeping each agent inside its own
99
- // worktree is the safe default, so disabling is the explicit opt-out.
100
- worktreeGuard: !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? ""),
129
+ worktreeGuard,
101
130
  guardExtraRoots: (env.NAVARCH_GUARD_EXTRA_ROOTS ?? "")
102
131
  .split(node_path_1.default.delimiter)
103
132
  .map((s) => s.trim())
@@ -50,7 +50,9 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
50
50
  api_base: process.env.NAVARCH_API_BASE ?? apiBaseFallback,
51
51
  agent_type: process.env.NAVARCH_AGENT === "codex" ||
52
52
  process.env.NAVARCH_AGENT === "claude-code" ||
53
- process.env.NAVARCH_AGENT === "gemini"
53
+ process.env.NAVARCH_AGENT === "gemini" ||
54
+ process.env.NAVARCH_AGENT === "opencode" ||
55
+ process.env.NAVARCH_AGENT === "acp"
54
56
  ? process.env.NAVARCH_AGENT
55
57
  : undefined,
56
58
  };
package/dist/session.cjs CHANGED
@@ -67,7 +67,7 @@ async function runSession(deps, claimed, sessionId) {
67
67
  ? "gpt-6-astra"
68
68
  : runtime === "gemini"
69
69
  ? "auto"
70
- : runtime === "opencode"
70
+ : runtime === "opencode" || runtime === "acp"
71
71
  ? "default"
72
72
  : "claude-fable-5-1",
73
73
  reasoning_effort: "medium",
@@ -105,7 +105,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
105
105
  ? "gpt-6-astra"
106
106
  : runtime === "gemini"
107
107
  ? "auto"
108
- : runtime === "opencode"
108
+ : runtime === "opencode" || runtime === "acp"
109
109
  ? "default"
110
110
  : "claude-fable-5-1",
111
111
  reasoning_effort: "medium",
@@ -392,7 +392,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
392
392
  codexGuardArgs,
393
393
  geminiSandboxMounts: geminiGuardMounts,
394
394
  opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
395
- cwd: sandbox ? undefined : gitWorktree.worktreePath,
395
+ // ACP requires an absolute session cwd. Docker mounts the worktree at
396
+ // this same absolute path, while the other Docker adapters ignore cwd.
397
+ cwd: gitWorktree.worktreePath,
396
398
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
397
399
  signal: activeAbortController.signal,
398
400
  });
@@ -616,6 +618,8 @@ function adapterCommand(config, runtime) {
616
618
  return { bin: config.geminiBin, extraArgs: config.geminiExtraArgs };
617
619
  case "opencode":
618
620
  return { bin: config.opencodeBin, extraArgs: config.opencodeExtraArgs };
621
+ case "acp":
622
+ return { bin: config.acpBin, extraArgs: config.acpExtraArgs };
619
623
  }
620
624
  }
621
625
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.40",
4
- "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, Gemini, or OpenCode.",
3
+ "version": "0.1.42",
4
+ "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -17,6 +17,8 @@
17
17
  "codex",
18
18
  "gemini-cli",
19
19
  "opencode",
20
+ "agent-client-protocol",
21
+ "deepseek-harness",
20
22
  "task-runner"
21
23
  ],
22
24
  "bin": {
@@ -50,6 +52,7 @@
50
52
  "vitest": "^4.1.10"
51
53
  },
52
54
  "dependencies": {
55
+ "@agentclientprotocol/sdk": "^1.4.0",
53
56
  "typescript": "^5.7.2"
54
57
  }
55
58
  }