@sagentlab/navarch-runtime 0.1.51 → 0.1.53

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
@@ -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\|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. |
155
+ | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] […]` | 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\|qoder] [--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\|qoder]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
158
+ | `supervise [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder]` | 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
@@ -368,8 +368,8 @@ unchanged across the deployment.
368
368
  | `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. |
369
369
  | `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. |
370
370
  | `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. |
371
- | `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. |
372
- | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
371
+ | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent adapter: `claude-code`, `codex`, `gemini`, `opencode`, `acp`, or `qoder`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
372
+ | `NAVARCH_RUNTIMES` | resolved local agent choice | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
373
373
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
374
374
  | `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`. |
375
375
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
@@ -382,6 +382,8 @@ unchanged across the deployment.
382
382
  | `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. |
383
383
  | `NAVARCH_ACP_BIN` | `dsh` | Path/name of an Agent Client Protocol v1 stdio server. DeepSeek Harness is the default implementation. |
384
384
  | `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. |
385
+ | `NAVARCH_QODER_BIN` | `qoder` | Path/name of the Qoder CLI binary. |
386
+ | `NAVARCH_QODER_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the unattended defaults (`--setting-sources`, `--permission-mode auto`, `--output-format json`). An explicit `--output-format`, `--permission-mode`, `--settings`, `--model`, or `--reasoning-effort` here replaces the per-session default. Qoder selects its own account models; pin one via `--model` here or `connect --model` to override. |
385
387
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
386
388
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable it for every runtime on the machine — required to run OpenCode on the host. |
387
389
  | `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. |
@@ -514,10 +516,15 @@ export NAVARCH_AGENT=opencode
514
516
  # Override NAVARCH_ACP_BIN / NAVARCH_ACP_EXTRA_ARGS for another ACP v1 server.
515
517
  export NAVARCH_AGENT=acp
516
518
 
519
+ # Qoder — requires an authenticated `qoder` CLI (or NAVARCH_QODER_BIN pointing
520
+ # at it). Model selection is account-owned; NAVARCH_QODER_EXTRA_ARGS or
521
+ # `connect --model` can pin one explicitly.
522
+ export NAVARCH_AGENT=qoder
523
+
517
524
  # Advanced compatibility mode: advertise every installed adapter. On a guarded
518
525
  # host, `opencode` is dropped from this list (see the boundary notes above);
519
526
  # the rest are advertised as written.
520
- export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp
527
+ export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp,qoder
521
528
  ```
522
529
 
523
530
  For the legacy single-runtime setting, priority is `start --agent` →
@@ -601,6 +608,16 @@ to OpenCode's remote-server config with OAuth disabled and environment-backed
601
608
  headers. Missing accounting fields stay absent rather than becoming a
602
609
  fabricated zero.
603
610
 
611
+ OpenCode also runs with `--print-logs --log-level ERROR` by default (see its
612
+ [CLI flags](https://opencode.ai/docs/cli/#global-flags)), so provider errors
613
+ that are missing from assistant messages still reach the runtime. A rate or
614
+ usage limit in a provider error event or stream-error log stops the host
615
+ process or Docker container immediately. The runtime checkpoints local work,
616
+ records the error, and releases the lease with a failed session outcome.
617
+ Pending guidance does not restart that session. New claims on this machine
618
+ pause until the reported reset time (including relative resets such as
619
+ “in 23 days”), or for 15 minutes when no reset time is available.
620
+
604
621
  The ACP adapter speaks the standard [Agent Client Protocol](https://agentclientprotocol.com)
605
622
  v1 JSON-RPC transport over stdio. It creates one protocol session in the task
606
623
  worktree, converts Navarch's HTTP/stdio MCP entries, consumes semantic message,
@@ -632,6 +649,7 @@ cli.cts
632
649
  - geminiAdapter (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
633
650
  - openCodeAdapter (adapters/opencode.cts) — `opencode run <prompt> --format json`
634
651
  - acpAdapter (adapters/acp.cts) — ACP v1 JSON-RPC/stdio; defaults to `dsh --profile acp`
652
+ - qoderAdapter (adapters/qoder.cts) — `qoder -p <prompt> --output-format json`
635
653
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
636
654
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
637
655
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
@@ -680,7 +698,7 @@ contract used by `api.cts`, including:
680
698
  distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
681
699
  /`status` must update even when no task is claimed.
682
700
  3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
683
- Supabase Storage upload URL for the session's redacted transcript. The
701
+ R2 upload URL for the session's redacted transcript. The
684
702
  bucket is private; the legacy `public_url` response field is only an
685
703
  attested locator returned at completion, not an anonymous read URL.
686
704
 
@@ -782,7 +800,7 @@ secrets absent from disk after exit"):
782
800
  already-reassigned lease) — `session.cts` aborts the running adapter
783
801
  process and still attempts a best-effort `complete()` call, which the
784
802
  real server may reject; that path is untested against real semantics.
785
- - Transcript upload against a real signed Supabase Storage URL.
803
+ - Transcript upload against a real signed R2 URL.
786
804
  - The end-to-end "Connect an agent to a project" flow against a real
787
805
  Supabase instance: an owner minting a token from `ConnectAgentPanel`
788
806
  (or the onboarding wizard's Agent step), `navarch-runtime connect`
@@ -14,9 +14,9 @@ const MAX_RESET_SEARCH_MINUTES = 48 * 60;
14
14
  function detectAdapterCapacityLimit(result, nowMs = Date.now()) {
15
15
  const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout) ??
16
16
  (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stderr);
17
- if (parsed?.api_error_status !== 429)
17
+ if (!result.capacityError && parsed?.api_error_status !== 429)
18
18
  return null;
19
- const detail = parsed.result ?? "";
19
+ const detail = result.capacityError ?? parsed?.result ?? "";
20
20
  if (!/\b(?:session|usage|rate)\s+limit\b|\btoo many requests\b/i.test(detail)) {
21
21
  return null;
22
22
  }
@@ -32,6 +32,13 @@ function detectAdapterCapacityLimit(result, nowMs = Date.now()) {
32
32
  * the platform's IANA timezone database.
33
33
  */
34
34
  function parseResetTime(detail, nowMs) {
35
+ const relative = detail.match(/\bresets?\s+in\s+(\d+(?:\.\d+)?)\s*(seconds?|minutes?|hours?|days?)\b/i);
36
+ if (relative) {
37
+ const units = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 };
38
+ const duration = Number(relative[1]) * units[relative[2].toLowerCase().replace(/s$/, "")];
39
+ if (Number.isFinite(duration) && duration > 0)
40
+ return nowMs + duration + RESET_GRACE_MS;
41
+ }
35
42
  const match = detail.match(/\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i);
36
43
  if (!match)
37
44
  return null;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
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;
3
+ exports.runQoderAdapter = exports.qoderAdapter = 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; } });
@@ -20,6 +20,9 @@ Object.defineProperty(exports, "runPiAdapter", { enumerable: true, get: function
20
20
  const acp_cjs_1 = require("./acp.cjs");
21
21
  Object.defineProperty(exports, "acpAdapter", { enumerable: true, get: function () { return acp_cjs_1.acpAdapter; } });
22
22
  Object.defineProperty(exports, "runAcpAdapter", { enumerable: true, get: function () { return acp_cjs_1.runAcpAdapter; } });
23
+ const qoder_cjs_1 = require("./qoder.cjs");
24
+ Object.defineProperty(exports, "qoderAdapter", { enumerable: true, get: function () { return qoder_cjs_1.qoderAdapter; } });
25
+ Object.defineProperty(exports, "runQoderAdapter", { enumerable: true, get: function () { return qoder_cjs_1.runQoderAdapter; } });
23
26
  /**
24
27
  * Picks the AgentAdapter (adapters/types.cts) session.cts should run a
25
28
  * session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
@@ -37,6 +40,8 @@ function selectAdapter(agentType) {
37
40
  return opencode_cjs_1.openCodeAdapter;
38
41
  case "acp":
39
42
  return acp_cjs_1.acpAdapter;
43
+ case "qoder":
44
+ return qoder_cjs_1.qoderAdapter;
40
45
  case "claude-code":
41
46
  return claude_cjs_1.claudeCodeAdapter;
42
47
  case "pi":
@@ -7,10 +7,10 @@ const node_fs_1 = require("node:fs");
7
7
  const config_cjs_1 = require("../config.cjs");
8
8
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
9
  /**
10
- * Headless OpenCode adapter, fixture-pinned to the OpenCode 1.18 JSON/config
11
- * contract:
10
+ * Headless OpenCode adapter, fixture-pinned to the OpenCode JSON/config
11
+ * contract shared by V1 and V2 (verified against 2.0.6):
12
12
  *
13
- * opencode run "<prompt>" --format json [--model provider/model]
13
+ * opencode run "<prompt>" --format json [--model provider/model[#variant]]
14
14
  *
15
15
  * OpenCode merges configuration from several machine and project locations,
16
16
  * so each Navarch session gets an isolated XDG/config directory, disables
@@ -32,14 +32,21 @@ async function runOpenCodeAdapter(options) {
32
32
  const env = { ...options.env };
33
33
  await prepareOpenCodeConfig(options.mcpConfigPath, env);
34
34
  const args = ["run", options.prompt];
35
+ // Provider failures may only appear in logs, absent from assistant events.
36
+ if (!hasArg(options.extraArgs, "--print-logs"))
37
+ args.push("--print-logs");
38
+ if (!hasArg(options.extraArgs, "--log-level"))
39
+ args.push("--log-level", "ERROR");
35
40
  if (!hasArg(options.extraArgs, "--format"))
36
41
  args.push("--format", "json");
37
42
  args.push(...options.extraArgs);
38
43
  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);
44
+ // OpenCode V2 folds the reasoning variant into the model reference
45
+ // (`provider/model#variant`) and hard-fails on a variant the model does
46
+ // not advertise. The claim route clamps the effort to the model's
47
+ // advertised variants, so a present value is always valid; absent means
48
+ // use the model's own default.
49
+ args.push("--model", options.reasoningEffort ? `${options.model}#${options.reasoningEffort}` : options.model);
43
50
  }
44
51
  const runOptions = { ...options, env };
45
52
  const raw = runOptions.dockerExec
@@ -80,23 +87,22 @@ async function prepareOpenCodeConfig(mcpConfigPath, env) {
80
87
  mcp[name] = {
81
88
  type: "remote",
82
89
  url,
83
- enabled: true,
90
+ disabled: false,
84
91
  oauth: false,
85
92
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
86
93
  };
87
94
  }
88
95
  const config = {
89
96
  $schema: "https://opencode.ai/config.json",
90
- autoupdate: false,
97
+ update: "disable",
91
98
  share: "disabled",
92
- plugin: [],
93
- mcp,
94
- permission: {
95
- "*": "allow",
96
- external_directory: "deny",
97
- question: "deny",
98
- doom_loop: "deny",
99
- },
99
+ plugins: [],
100
+ mcp: { servers: mcp },
101
+ permissions: [
102
+ { action: "*", resource: "*", effect: "allow" },
103
+ { action: "external_directory", resource: "*", effect: "deny" },
104
+ { action: "question", resource: "*", effect: "deny" },
105
+ ],
100
106
  };
101
107
  const configPath = `${mcpConfigPath}.opencode.json`;
102
108
  const configDir = `${mcpConfigPath}.opencode-config`;
@@ -186,6 +192,42 @@ function parseOpenCodeEvents(stdout) {
186
192
  function nonNegativeMetric(value) {
187
193
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
188
194
  }
195
+ /** Inspect only provider error envelopes/logs, never tool output or assistant text. */
196
+ function capacityMonitor(onLimit) {
197
+ const pending = { stdout: "", stderr: "" };
198
+ const inspect = (line, stream) => {
199
+ let detail = "";
200
+ try {
201
+ const event = JSON.parse(line);
202
+ if (event.type === "error" || event.type === "session.error") {
203
+ const error = event.error ?? event.properties?.error;
204
+ detail = typeof error === "string" ? error : error?.data?.message ?? error?.message ?? "";
205
+ }
206
+ }
207
+ catch {
208
+ // OpenCode's log format varies by version. Require a stream-error log
209
+ // to avoid confusing a tool's own HTTP 429 with provider exhaustion.
210
+ if (stream === "stderr" && /\bERROR\b/.test(line) && /stream error/i.test(line)) {
211
+ detail = line;
212
+ }
213
+ }
214
+ if (typeof detail === "string" && /\b(?:rate|usage|session)\s+limit\b|\btoo many requests\b/i.test(detail)) {
215
+ onLimit(detail);
216
+ }
217
+ };
218
+ return {
219
+ push(stream, chunk) {
220
+ const lines = (pending[stream] + chunk).split(/\r?\n/);
221
+ pending[stream] = lines.pop() ?? "";
222
+ for (const line of lines)
223
+ inspect(line, stream);
224
+ },
225
+ flush() {
226
+ inspect(pending.stdout, "stdout");
227
+ inspect(pending.stderr, "stderr");
228
+ },
229
+ };
230
+ }
189
231
  async function runOnHost(options, args) {
190
232
  return new Promise((resolve) => {
191
233
  let stdout = "";
@@ -197,6 +239,11 @@ async function runOnHost(options, args) {
197
239
  cwd: options.cwd,
198
240
  env: { ...process.env, ...options.env },
199
241
  });
242
+ let capacityError;
243
+ const monitor = capacityMonitor((detail) => {
244
+ capacityError ??= detail;
245
+ (0, kill_agent_cjs_1.killAgent)(child);
246
+ });
200
247
  child.stdin?.end();
201
248
  options.onProcessStarted?.(child.pid);
202
249
  const timer = options.timeoutMs > 0 ? setTimeout(() => {
@@ -213,21 +260,25 @@ async function runOnHost(options, args) {
213
260
  child.stdout.on("data", (data) => {
214
261
  options.onActivity?.();
215
262
  stdout += data.toString();
263
+ monitor.push("stdout", data.toString());
216
264
  });
217
265
  child.stderr.on("data", (data) => {
218
266
  options.onActivity?.();
219
267
  stderr += data.toString();
268
+ monitor.push("stderr", data.toString());
220
269
  });
221
270
  child.on("error", (error) => {
222
271
  clearTimeout(timer);
223
272
  options.signal?.removeEventListener("abort", onAbort);
224
273
  stderr += `\n${String(error)}`;
225
- resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
274
+ monitor.flush();
275
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr, capacityError });
226
276
  });
227
277
  child.on("close", (code) => {
228
278
  clearTimeout(timer);
229
279
  options.signal?.removeEventListener("abort", onAbort);
230
- resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
280
+ monitor.flush();
281
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr, capacityError });
231
282
  });
232
283
  });
233
284
  }
@@ -236,8 +287,19 @@ async function runViaDocker(options, args) {
236
287
  const quoted = [options.bin, ...args].map(shellQuote).join(" ");
237
288
  const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
238
289
  let killedByLeaseLoss = false;
290
+ let capacityError;
291
+ const controller = new AbortController();
292
+ const streamed = { stdout: false, stderr: false };
293
+ const monitor = capacityMonitor((detail) => {
294
+ if (capacityError)
295
+ return;
296
+ capacityError = detail;
297
+ controller.abort();
298
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
299
+ });
239
300
  const onAbort = () => {
240
301
  killedByLeaseLoss = true;
302
+ controller.abort();
241
303
  runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
242
304
  };
243
305
  options.signal?.addEventListener("abort", onAbort, { once: true });
@@ -247,10 +309,19 @@ async function runViaDocker(options, args) {
247
309
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
248
310
  const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs,
249
311
  onActivity: options.onActivity,
250
- signal: options.signal,
312
+ signal: controller.signal,
313
+ onStdout: (chunk) => { streamed.stdout = true; monitor.push("stdout", chunk); },
314
+ onStderr: (chunk) => { streamed.stderr = true; monitor.push("stderr", chunk); },
251
315
  env: { ...process.env, ...options.env },
252
316
  });
317
+ // Also inspect runners that only return buffered output.
318
+ if (!streamed.stdout)
319
+ monitor.push("stdout", result.stdout);
320
+ if (!streamed.stderr)
321
+ monitor.push("stderr", result.stderr);
322
+ monitor.flush();
253
323
  return {
324
+ capacityError,
254
325
  exitCode: result.code,
255
326
  timedOut: false,
256
327
  killedByLeaseLoss,
@@ -260,6 +331,7 @@ async function runViaDocker(options, args) {
260
331
  }
261
332
  catch (error) {
262
333
  return {
334
+ capacityError,
263
335
  exitCode: null,
264
336
  timedOut: false,
265
337
  killedByLeaseLoss,
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.qoderAdapter = void 0;
4
+ exports.runQoderAdapter = runQoderAdapter;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
+ function hasFlag(args, flag) {
9
+ return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
10
+ }
11
+ /**
12
+ * Headless Qoder CLI adapter: `qoder -p "<prompt>" --output-format json`.
13
+ * Qoder's print mode emits the same single-JSON result shape as Claude Code
14
+ * (verified against qodercli 1.1.56), so usage/cost parsing reuses
15
+ * parseClaudeJsonResult and its result text lands in the ordinary
16
+ * exit-conditions path.
17
+ *
18
+ * Model selection is account-owned (`qoder --list-models`), so the claim-time
19
+ * `"default"` sentinel (execution-policy.ts / session.cts) is passed through
20
+ * as no `--model` flag; an explicit pin from `--model` at enroll or
21
+ * NAVARCH_QODER_EXTRA_ARGS still reaches the CLI.
22
+ */
23
+ async function runQoderAdapter(options) {
24
+ if (options.signal?.aborted) {
25
+ return { exitCode: null, timedOut: false, killedByLeaseLoss: true, stdout: "", stderr: "" };
26
+ }
27
+ const args = ["-p", options.prompt];
28
+ // Runtime sessions must not inherit an operator's personal or project-local
29
+ // Qoder hooks, for the same reason as the Claude adapter: they make
30
+ // execution machine-dependent and can fail otherwise-good sessions. The
31
+ // empty source list still permits an explicit --settings file below.
32
+ if (!hasFlag(options.extraArgs, "--setting-sources")) {
33
+ args.push("--setting-sources", "");
34
+ }
35
+ // Unattended sessions route permission decisions through Qoder's native
36
+ // auto classifier rather than prompting or bypassing checks.
37
+ if (!["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => hasFlag(options.extraArgs, flag))) {
38
+ args.push("--permission-mode", "auto");
39
+ }
40
+ if (options.settingsPath && !hasFlag(options.extraArgs, "--settings")) {
41
+ args.push("--settings", options.settingsPath);
42
+ }
43
+ if (options.mcpConfigPath) {
44
+ args.push("--mcp-config", options.mcpConfigPath);
45
+ }
46
+ if (!hasFlag(options.extraArgs, "--output-format")) {
47
+ args.push("--output-format", "json");
48
+ }
49
+ args.push(...options.extraArgs);
50
+ if (options.model && options.model !== "default" && !hasFlag(options.extraArgs, "--model")) {
51
+ args.push("--model", options.model);
52
+ }
53
+ if (options.reasoningEffort && !hasFlag(options.extraArgs, "--reasoning-effort")) {
54
+ args.push("--reasoning-effort", options.reasoningEffort);
55
+ }
56
+ const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
57
+ return attachUsage(raw);
58
+ }
59
+ /** Parses stdout for `qoder -p --output-format json` usage and folds it onto the raw result (best-effort). */
60
+ function attachUsage(result) {
61
+ const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
62
+ if (!parsed)
63
+ return result;
64
+ const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
65
+ return {
66
+ ...result,
67
+ tokensIn: usage.tokensIn,
68
+ tokensOut: usage.tokensOut,
69
+ cacheHitTokensIn: usage.cacheHitTokensIn,
70
+ costUsd: usage.costUsd,
71
+ };
72
+ }
73
+ async function runOnHost(options, args) {
74
+ return new Promise((resolve) => {
75
+ let stdout = "";
76
+ let stderr = "";
77
+ let timedOut = false;
78
+ let killedByLeaseLoss = false;
79
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
80
+ detached: process.platform !== "win32",
81
+ cwd: options.cwd,
82
+ env: { ...process.env, ...options.env },
83
+ });
84
+ // `qoder -p` checks stdin for a piped prompt; close it so the child does
85
+ // not wait when the complete prompt was supplied as an argument.
86
+ child.stdin?.end();
87
+ options.onProcessStarted?.(child.pid);
88
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
89
+ timedOut = true;
90
+ (0, kill_agent_cjs_1.killAgent)(child);
91
+ }, options.timeoutMs) : undefined;
92
+ const onAbort = () => {
93
+ killedByLeaseLoss = true;
94
+ (0, kill_agent_cjs_1.killAgent)(child);
95
+ };
96
+ options.signal?.addEventListener("abort", onAbort, { once: true });
97
+ if (options.signal?.aborted)
98
+ onAbort();
99
+ child.stdout.on("data", (d) => {
100
+ options.onActivity?.();
101
+ stdout += d.toString();
102
+ });
103
+ child.stderr.on("data", (d) => {
104
+ options.onActivity?.();
105
+ stderr += d.toString();
106
+ });
107
+ child.on("error", (err) => {
108
+ clearTimeout(timer);
109
+ options.signal?.removeEventListener("abort", onAbort);
110
+ stderr += `\n${String(err)}`;
111
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
112
+ });
113
+ child.on("close", (code) => {
114
+ clearTimeout(timer);
115
+ options.signal?.removeEventListener("abort", onAbort);
116
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
117
+ });
118
+ });
119
+ }
120
+ async function runViaDocker(options, args) {
121
+ const { containerName, runner } = options.dockerExec;
122
+ const quoted = [options.bin, ...args].map(shellQuote).join(" ");
123
+ const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
124
+ let killedByLeaseLoss = false;
125
+ const onAbort = () => {
126
+ killedByLeaseLoss = true;
127
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
128
+ };
129
+ options.signal?.addEventListener("abort", onAbort, { once: true });
130
+ if (options.signal?.aborted)
131
+ onAbort();
132
+ try {
133
+ const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
134
+ timeoutMs: options.timeoutMs,
135
+ onActivity: options.onActivity,
136
+ signal: options.signal,
137
+ });
138
+ return {
139
+ exitCode: result.code,
140
+ timedOut: false,
141
+ killedByLeaseLoss,
142
+ stdout: result.stdout,
143
+ stderr: result.stderr,
144
+ };
145
+ }
146
+ catch (err) {
147
+ return {
148
+ exitCode: null,
149
+ timedOut: false,
150
+ killedByLeaseLoss,
151
+ stdout: "",
152
+ stderr: String(err),
153
+ };
154
+ }
155
+ finally {
156
+ options.signal?.removeEventListener("abort", onAbort);
157
+ }
158
+ }
159
+ function shellQuote(value) {
160
+ return `'${value.replace(/'/g, `'\\''`)}'`;
161
+ }
162
+ /** The AgentAdapter (adapters/types.cts) wrapper session.cts selects via NAVARCH_AGENT=qoder. */
163
+ exports.qoderAdapter = {
164
+ agentType: "qoder",
165
+ run: runQoderAdapter,
166
+ };
package/dist/cli.cjs CHANGED
@@ -61,18 +61,24 @@ function superviseInvocation(configDir) {
61
61
  return `${invocation("supervise")} --config-dir ${configDir}`;
62
62
  }
63
63
  /** Resolve CLI-local configuration without mutating the parent shell environment. */
64
- function configFromFlags(flags) {
65
- const configDir = flags["config-dir"];
66
- return (0, config_cjs_1.loadRuntimeConfig)(configDir
67
- ? { ...process.env, NAVARCH_CONFIG_DIR: node_path_1.default.resolve(configDir) }
68
- : process.env);
64
+ function configFromFlags(flags, identity) {
65
+ const env = { ...process.env };
66
+ if (flags["config-dir"])
67
+ env.NAVARCH_CONFIG_DIR = node_path_1.default.resolve(flags["config-dir"]);
68
+ // Resolve the preferred adapter before deriving the advertised runtimes and
69
+ // applying host guards. Keep an explicit NAVARCH_RUNTIMES list intact.
70
+ const agentType = agentFromFlag(flags) ?? (env.NAVARCH_AGENT || identity?.agent_type);
71
+ if (agentType)
72
+ env.NAVARCH_AGENT = agentType;
73
+ const config = (0, config_cjs_1.loadRuntimeConfig)(env);
74
+ return { ...config, model: modelFromFlags(flags, config.agentType, identity) };
69
75
  }
70
76
  function agentFromFlag(flags) {
71
77
  const value = flags.agent;
72
78
  if (value === undefined)
73
79
  return undefined;
74
80
  if (!(0, config_cjs_1.isRuntimeAgentType)(value)) {
75
- throw new Error("--agent must be one of 'claude-code', 'codex', 'gemini', 'opencode', or 'acp'.");
81
+ throw new Error("--agent must be one of 'claude-code', 'codex', 'gemini', 'opencode', 'acp', or 'qoder'.");
76
82
  }
77
83
  return value;
78
84
  }
@@ -202,12 +208,7 @@ async function connectCommand(flags) {
202
208
  async function startCommand(flags) {
203
209
  const baseConfig = configFromFlags(flags);
204
210
  const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
205
- // Adapter selection belongs to the local machine: an explicit start flag
206
- // wins, followed by NAVARCH_AGENT, the choice saved at connect/register
207
- // time, and finally the backwards-compatible Claude Code default.
208
- const agentType = agentFromFlag(flags) ??
209
- (process.env.NAVARCH_AGENT ? baseConfig.agentType : identity.agent_type ?? baseConfig.agentType);
210
- const config = { ...baseConfig, agentType, model: modelFromFlags(flags, agentType, identity) };
211
+ const config = configFromFlags(flags, identity);
211
212
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
212
213
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
213
214
  const runtimeShutdown = new AbortController();
@@ -283,19 +284,17 @@ async function startCommand(flags) {
283
284
  process.on("SIGTERM", shutdown);
284
285
  }
285
286
  async function superviseCommand(flags) {
286
- const config = configFromFlags(flags);
287
- const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
287
+ const baseConfig = configFromFlags(flags);
288
+ const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
289
+ const config = configFromFlags(flags, identity);
288
290
  // Resolve and pin the adapter before spawning the worker. The supervisor
289
291
  // passes machine credentials through the environment, so the child no
290
292
  // longer reads machine.json for identity fields (including agent_type).
291
293
  // Without an explicit worker argument, any saved non-Claude selection
292
294
  // would therefore fall back to Claude Code.
293
- const agentType = agentFromFlag(flags) ??
294
- (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
295
- const workerArgs = ["--agent", agentType];
296
- const model = modelFromFlags(flags, agentType, identity);
297
- if (model)
298
- workerArgs.push("--model", model);
295
+ const workerArgs = ["--agent", config.agentType];
296
+ if (config.model)
297
+ workerArgs.push("--model", config.model);
299
298
  // Pin the identity for this supervisor's lifetime. Without this snapshot, a
300
299
  // replacement worker rereads machine.json after an automatic update and can
301
300
  // silently become a different machine if another terminal reused the same
@@ -315,8 +314,17 @@ async function doctorCommand(flags) {
315
314
  // start (config.cts). Report that as the diagnosis instead of rethrowing it
316
315
  // as a bare CLI error.
317
316
  let config;
317
+ let identity;
318
318
  try {
319
319
  config = configFromFlags(flags);
320
+ try {
321
+ identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
322
+ }
323
+ catch {
324
+ // Unregistered machines can still inspect their environment configuration.
325
+ }
326
+ if (identity)
327
+ config = configFromFlags(flags, identity);
320
328
  }
321
329
  catch (err) {
322
330
  console.log(`config: UNUSABLE — ${err instanceof Error ? err.message : String(err)}`);
@@ -333,18 +341,12 @@ async function doctorCommand(flags) {
333
341
  console.log(`worktree_guard: ${config.worktreeGuard ? "on" : "off"}`);
334
342
  console.log(`runtimes: ${config.runtimes?.join(", ") ?? config.agentType}`);
335
343
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
336
- let identity;
337
- try {
338
- identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
339
- }
340
- catch {
344
+ if (!identity) {
341
345
  console.log(`machine: not registered — run \`${invocation("register")}\``);
342
346
  return;
343
347
  }
344
- const agentType = agentFromFlag(flags) ??
345
- (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
346
348
  console.log(`machine: ${identity.name} (${identity.machine_id})`);
347
- console.log(`agent: ${agentType}`);
349
+ console.log(`agent: ${config.agentType}`);
348
350
  }
349
351
  function helpText() {
350
352
  return `navarch-runtime — Navarch machine-side session manager
package/dist/config.cjs CHANGED
@@ -17,7 +17,8 @@ function isRuntimeAgentType(value) {
17
17
  value === "codex" ||
18
18
  value === "gemini" ||
19
19
  value === "opencode" ||
20
- value === "acp");
20
+ value === "acp" ||
21
+ value === "qoder");
21
22
  }
22
23
  function envInt(env, name, fallback) {
23
24
  const raw = env[name];
@@ -123,6 +124,8 @@ function loadRuntimeConfig(env = process.env) {
123
124
  opencodeExtraArgs: envList(env, "NAVARCH_OPENCODE_EXTRA_ARGS", []),
124
125
  acpBin: env.NAVARCH_ACP_BIN ?? "dsh",
125
126
  acpExtraArgs: envList(env, "NAVARCH_ACP_EXTRA_ARGS", ["--profile", "acp"]),
127
+ qoderBin: env.NAVARCH_QODER_BIN ?? "qoder",
128
+ qoderExtraArgs: envList(env, "NAVARCH_QODER_EXTRA_ARGS", []),
126
129
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
127
130
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
128
131
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
@@ -229,6 +229,16 @@ function mapExitCondition(result) {
229
229
  evidenceUrls,
230
230
  };
231
231
  }
232
+ if (result.capacityError) {
233
+ return {
234
+ leaseOutcome: "failed",
235
+ exitStatus: "failed",
236
+ reportSummary: summarize(`Session stopped: provider capacity exhausted. ${result.capacityError}`) +
237
+ (result.checkpointPath ? ` Local recovery checkpoint: ${result.checkpointPath}` : "") +
238
+ (result.retainedWorktreePath ? ` Recovery worktree retained: ${result.retainedWorktreePath}` : ""),
239
+ evidenceUrls,
240
+ };
241
+ }
232
242
  if (result.timedOut) {
233
243
  return {
234
244
  leaseOutcome: "failed",
@@ -52,7 +52,8 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
52
52
  process.env.NAVARCH_AGENT === "claude-code" ||
53
53
  process.env.NAVARCH_AGENT === "gemini" ||
54
54
  process.env.NAVARCH_AGENT === "opencode" ||
55
- process.env.NAVARCH_AGENT === "acp"
55
+ process.env.NAVARCH_AGENT === "acp" ||
56
+ process.env.NAVARCH_AGENT === "qoder"
56
57
  ? process.env.NAVARCH_AGENT
57
58
  : undefined,
58
59
  };
package/dist/sandbox.cjs CHANGED
@@ -40,10 +40,12 @@ exports.nodeCommandRunner = {
40
40
  child.stdout?.on("data", (d) => {
41
41
  opts.onActivity?.();
42
42
  stdout += d.toString();
43
+ opts.onStdout?.(d.toString());
43
44
  });
44
45
  child.stderr?.on("data", (d) => {
45
46
  opts.onActivity?.();
46
47
  stderr += d.toString();
48
+ opts.onStderr?.(d.toString());
47
49
  });
48
50
  child.on("error", (err) => {
49
51
  if (settled)
package/dist/session.cjs CHANGED
@@ -44,7 +44,7 @@ function resolveSessionExecution(config, claimed) {
44
44
  profile: claimed.task.execution_profile ?? "standard",
45
45
  model: runtime === "codex" ? "gpt-6-astra"
46
46
  : runtime === "gemini" ? "auto"
47
- : runtime === "opencode" || runtime === "acp" ? "default"
47
+ : runtime === "opencode" || runtime === "acp" || runtime === "qoder" ? "default"
48
48
  : "claude-opus-5",
49
49
  reasoning_effort: "medium",
50
50
  };
@@ -229,7 +229,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
229
229
  return;
230
230
  try {
231
231
  result.checkpointPath = await gitWorktree.saveCheckpoint();
232
- log.warn(`session ${leaseId} stopped for ${leaseLost ? "lease loss" : result.timeoutReason ?? "duration"}; recovery checkpoint: ${result.checkpointPath}`);
232
+ log.warn(`session ${leaseId} stopped for ${leaseLost ? "lease loss" : result.capacityError ? "provider capacity" : result.timeoutReason ?? "duration"}; recovery checkpoint: ${result.checkpointPath}`);
233
233
  }
234
234
  catch (error) {
235
235
  retainWorktree = true;
@@ -481,6 +481,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
481
481
  attempts.push(turnResult);
482
482
  const capacityLimit = (0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(turnResult);
483
483
  if (capacityLimit) {
484
+ await checkpointForRecovery(turnResult);
484
485
  sessionOutcome.claimCooldownUntil = Math.max(sessionOutcome.claimCooldownUntil ?? 0, capacityLimit.retryAtMs);
485
486
  }
486
487
  // Close the small race between a naturally completed turn and the next
@@ -495,7 +496,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
495
496
  log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
496
497
  return sessionOutcome;
497
498
  }
498
- if (!leaseLost && !turnResult.timedOut && pendingGuidance.length > 0)
499
+ if (!capacityLimit && !leaseLost && !turnResult.timedOut && pendingGuidance.length > 0)
499
500
  continue;
500
501
  const result = {
501
502
  ...turnResult,
@@ -762,6 +763,8 @@ function adapterCommand(config, runtime) {
762
763
  return { bin: config.opencodeBin, extraArgs: config.opencodeExtraArgs };
763
764
  case "acp":
764
765
  return { bin: config.acpBin, extraArgs: config.acpExtraArgs };
766
+ case "qoder":
767
+ return { bin: config.qoderBin, extraArgs: config.qoderExtraArgs };
765
768
  }
766
769
  }
767
770
  /**
@@ -806,6 +809,8 @@ function verificationFailureReport(taskType, report) {
806
809
  }
807
810
  /** Rejection codes another agent turn in the same worktree can plausibly fix. */
808
811
  const REMEDIABLE_REJECTION_CODES = new Set([
812
+ "review_fix_required",
813
+ "review_fix_unavailable",
809
814
  "pr_required",
810
815
  // A video-deliverable build that completed without registering its render:
811
816
  // the file usually already exists in the worktree, so the remediation turn
package/dist/upload.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.uploadTranscript = uploadTranscript;
4
4
  /**
5
- * PUTs an already-redacted transcript to the signed Supabase Storage URL the
5
+ * PUTs an already-redacted transcript to the presigned R2 URL the
6
6
  * control plane hands back (schema-design.md §4 sessions.transcript_url;
7
7
  * implementation-plan.md WP-07). Kept as its own module so the storage
8
8
  * mechanism is a one-function swap if it ever changes.
@@ -16,9 +16,8 @@ async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
16
16
  body: content,
17
17
  });
18
18
  if (!response.ok) {
19
- // Supabase Storage answers with {statusCode, error, message} here, which is
20
- // the only thing distinguishing an RLS rejection from a bad/expired token
21
- // from a bucket misconfiguration. Without it a 403 is unattributable.
19
+ // Include the bounded storage response to distinguish expired signatures
20
+ // from bucket misconfiguration.
22
21
  throw new Error(`Transcript upload failed with ${response.status}: ${await readErrorBody(response)}`);
23
22
  }
24
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
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",