@sagentlab/navarch-runtime 0.1.11 → 0.1.12

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 agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
10
- selected by the local machine via `--agent` or `NAVARCH_AGENT` (default
11
- `claude-code`) see
12
- "Choosing an agent (Claude Code vs. Codex)" below.
9
+ The control plane selects **Claude Code**, **OpenAI Codex**, or **Google
10
+ Gemini CLI** from the project's default and any task override. Workers
11
+ advertise the adapters they can run, so tasks wait for an eligible runtime
12
+ and capability match — 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)
@@ -19,31 +19,66 @@ the API contract.
19
19
 
20
20
  ## Quick start on a fresh machine
21
21
 
22
+ For a project machine, use the command generated by **Onboard → Connect an
23
+ agent** or **Project settings → Connect an agent**. It includes a single-use,
24
+ project-scoped enrollment token and the correct control-plane origin. The
25
+ recommended npm package requires Node.js 20 or later:
26
+
22
27
  ```sh
23
- git clone <this repo> && cd sagentlab/runtime
24
- ./install.sh # checks node, npm install, npm run build
25
- export NAVARCH_API_BASE=https://navarch.example.com
26
- node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
27
- node bin/navarch.cjs supervise
28
+ # Run the two commands copied from Navarch. Their values resemble:
29
+ npx --yes @sagentlab/navarch-runtime@latest connect \
30
+ --token flmt_<single-use-token> --project <project-id> \
31
+ --name <machine-name> --agent codex \
32
+ --capabilities shell,browser-use --max-sessions 5 \
33
+ --api-base https://www.sagentlab.com --config-dir ~/.navarch/<project-slug>
34
+ npx --yes @sagentlab/navarch-runtime@latest supervise \
35
+ --config-dir ~/.navarch/<project-slug>
28
36
  ```
29
37
 
30
- Or, once dependencies are installed:
38
+ Keep the same config directory for `connect`, `supervise`, `doctor`, and later
39
+ upgrades. `connect` stores the machine identity in `<config-dir>/machine.json`
40
+ with mode `0600`; the raw machine token is not shown again. Do not copy that
41
+ file or the enrollment command into logs.
42
+
43
+ In another terminal, verify the machine without starting a second worker:
31
44
 
32
45
  ```sh
33
- npm run build
34
- NAVARCH_API_BASE=https://navarch.example.com npm run register -- --token <enrollment-token> --name my-machine-1
35
- npm run supervise
46
+ npx --yes @sagentlab/navarch-runtime@latest doctor \
47
+ --config-dir ~/.navarch/<project-slug>
36
48
  ```
37
49
 
38
- `register` is the one command that prints the machine's auth token — exactly
39
- once, to stdout. It is stored at `$NAVARCH_CONFIG_DIR/machine.json` (default
40
- `~/.navarch/machine.json`, mode `0600`) and never echoed again. For
41
- twelve-factor deployments (systemd `EnvironmentFile`, container secrets),
42
- skip `register` and set `NAVARCH_MACHINE_TOKEN` + `NAVARCH_MACHINE_ID`
43
- directly.
50
+ For the BYO-key sandbox path, follow the generated Claude/Docker command
51
+ exactly. Docker must be running, and `NAVARCH_DOCKER_IMAGE` must identify an
52
+ image containing Git, Node.js 20 or later, Claude Code 2.1.83 or later, and
53
+ the build/test tools required by the repository. The current default
54
+ `node:20-slim` image does not satisfy those requirements; a first-party image
55
+ is tracked in [issue #229](https://github.com/sagentlab/navarch/issues/229).
56
+ The complete public flow is in
57
+ [the Navarch quickstart](../docs/navarch/quickstart.md).
58
+
59
+ ### Install from source
44
60
 
45
- Run `node bin/navarch.cjs doctor` any time to print resolved config, Docker
46
- availability, and registration status without starting the daemon.
61
+ Use this path for runtime development or when validating an unreleased runtime
62
+ change:
63
+
64
+ ```sh
65
+ git clone https://github.com/sagentlab/navarch.git
66
+ cd navarch/runtime
67
+ ./install.sh
68
+ node bin/navarch.cjs connect <options copied from Navarch>
69
+ node bin/navarch.cjs supervise --config-dir ~/.navarch/<project-slug>
70
+ ```
71
+
72
+ The installer checks Node, installs the locked package dependencies, and builds
73
+ the runtime. The UI-generated npm commands remain the source of truth for the
74
+ token, project, agent, capacity, API origin, and config directory; translate
75
+ those same options to `node bin/navarch.cjs` for a source install.
76
+
77
+ The operator-only `register` command enrolls a globally managed machine. It is
78
+ not the normal public onboarding path. `register` prints the machine auth token
79
+ once and stores the same `machine.json` identity used by `connect`. For
80
+ twelve-factor deployments (systemd `EnvironmentFile`, container secrets), set
81
+ `NAVARCH_MACHINE_TOKEN` and `NAVARCH_MACHINE_ID` directly.
47
82
 
48
83
  ### Connecting an agent to one project
49
84
 
@@ -60,7 +95,7 @@ project only (it will never be dispatched work from any other project).
60
95
 
61
96
  ```sh
62
97
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
63
- --name my-agent-1 --agent codex --api-base https://navarch.example.com \
98
+ --name my-agent-1 --agent codex --api-base https://www.sagentlab.com \
64
99
  --config-dir ~/.navarch/my-agent-1
65
100
  npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/my-agent-1
66
101
  ```
@@ -74,10 +109,10 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
74
109
 
75
110
  | Command | Purpose |
76
111
  |---|---|
77
- | `register --token <t> --name <n> [--agent claude-code\|codex] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
78
- | `connect --token <t> --name <n> [--agent claude-code\|codex] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
79
- | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
80
- | `supervise [--agent claude-code\|codex]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
112
+ | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
113
+ | `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. |
114
+ | `start [--agent claude-code\|codex\|gemini]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
115
+ | `supervise [--agent claude-code\|codex\|gemini]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
81
116
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
82
117
 
83
118
  ### Running multiple agents on one machine
@@ -117,7 +152,7 @@ restart, not a new dispatch:
117
152
  - The replacement turn uses the same worktree (and the same sandbox container
118
153
  in Docker mode), so committed and uncommitted changes from the interrupted
119
154
  turn remain available. It should inspect those changes before continuing.
120
- - The replacement turn is not a resumed Claude Code or Codex conversation.
155
+ - The replacement turn is not a resumed agent CLI conversation.
121
156
  The corrected prompt carries the prior task context and guidance instead.
122
157
  - Transcript, token, and cost accounting is cumulative across turns: the
123
158
  upload includes every turn under a separate label, and reported token and
@@ -211,7 +246,7 @@ unchanged across the deployment.
211
246
  | `NAVARCH_CONFIG_DIR` | `~/.navarch` | Per-instance state root. Equivalent to `--config-dir`; use a different directory for every agent on the same host. |
212
247
  | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
213
248
  | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
214
- | `NAVARCH_CAPABILITIES` | `shell` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. |
249
+ | `NAVARCH_CAPABILITIES` | `shell,browser-use` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. Docker mode does not advertise browser use until the configured image provides it. |
215
250
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
216
251
  | `NAVARCH_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
217
252
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
@@ -219,16 +254,19 @@ unchanged across the deployment.
219
254
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
220
255
  | `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. |
221
256
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
222
- | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
223
- | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code` or `codex`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
257
+ | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. The image must contain the selected agent CLI, Git, Node.js 20+, and project build/test tools; the default is only a low-level runtime fallback and is not sufficient for BYO-key sandbox sessions ([issue #229](https://github.com/sagentlab/navarch/issues/229)). |
258
+ | `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. |
259
+ | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
224
260
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
225
261
  | `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`. |
226
262
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
227
263
  | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. |
228
264
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
229
265
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
266
+ | `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
267
+ | `NAVARCH_GEMINI_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP settings, `stream-json`, and unattended defaults (Gemini). |
230
268
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
231
- | `NAVARCH_WORKTREE_GUARD` | on | Host-mode Claude and Codex sessions get a per-session worktree boundary guard (see below). Set `off` to disable. |
269
+ | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable. |
232
270
  | `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. |
233
271
 
234
272
  ## Worktree boundary guard (host mode)
@@ -251,6 +289,12 @@ supported coding agents, using each CLI's native enforcement point:
251
289
  user or checked-in legacy `sandbox_mode` from silently disabling the
252
290
  generated profile; Codex authentication still comes from `CODEX_HOME`, and
253
291
  repository instructions such as `AGENTS.md` still load.
292
+ - **Gemini:** the runtime uses Gemini CLI's native sandbox with only the
293
+ current worktree, shared gitdir, and approved extra roots mounted. The
294
+ lease MCP config is translated to a read-only temporary settings file;
295
+ header values remain environment-variable references rather than literals.
296
+ Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting
297
+ it inside Navarch's already isolated session container.
254
298
 
255
299
  The resulting boundary is:
256
300
 
@@ -284,12 +328,11 @@ The auto-review launch paths were verified live on 2026-07-20 with Claude Code
284
328
  permission profile plus `approvals_reviewer="auto_review"`). Both completed an
285
329
  unattended smoke task successfully.
286
330
 
287
- ## Choosing an agent (Claude Code vs. Codex)
331
+ ## Choosing an agent
288
332
 
289
- Each machine chooses its own agent CLI. Pass `--agent` while connecting to
290
- persist the choice in local `machine.json`, override it for one daemon start
291
- with `start --agent`, or set `NAVARCH_AGENT` in the machine's service
292
- environment:
333
+ Each worker advertises the agent CLIs it can actually execute. Existing
334
+ single-runtime installs keep using `--agent`/`NAVARCH_AGENT`; multi-runtime
335
+ workers set `NAVARCH_RUNTIMES` to the installed and authenticated adapters:
293
336
 
294
337
  ```sh
295
338
  # Claude Code (default) — requires the `claude` CLI installed and
@@ -300,12 +343,22 @@ export NAVARCH_AGENT=claude-code
300
343
  # this machine (or NAVARCH_CODEX_BIN pointing at it), analogous to the
301
344
  # Claude Code prerequisite above.
302
345
  export NAVARCH_AGENT=codex
346
+
347
+ # Google Gemini CLI — requires an authenticated `gemini` CLI (or
348
+ # NAVARCH_GEMINI_BIN pointing at it).
349
+ export NAVARCH_AGENT=gemini
350
+
351
+ # Or let one worker serve tasks selected for any installed adapter.
352
+ export NAVARCH_RUNTIMES=claude-code,codex,gemini
303
353
  ```
304
354
 
305
- Priority is `start --agent` → `NAVARCH_AGENT` → the locally saved choice →
306
- `claude-code`. The control plane does not choose the adapter.
355
+ For the legacy single-runtime setting, priority is `start --agent` →
356
+ `NAVARCH_AGENT` the locally saved choice `claude-code`.
357
+ `NAVARCH_RUNTIMES` expands what the worker advertises; the control plane then
358
+ resolves `tasks.runtime_override` → `projects.default_runtime` and returns the
359
+ selected adapter with the claim.
307
360
 
308
- Both adapters implement the same `AgentAdapter` interface
361
+ All three adapters implement the same `AgentAdapter` interface
309
362
  (`src/adapters/types.cts`) and run either directly on the host or via
310
363
  `docker exec` in the session's sandbox container, exactly like the Claude
311
364
  adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
@@ -314,10 +367,12 @@ through to `complete()` unchanged by whatever happened during the run.
314
367
 
315
368
  The control plane also resolves the project's model and the task's execution
316
369
  profile on every claim. The runtime passes those values as per-session CLI
317
- overrides (`codex exec --model ... -c model_reasoning_effort=...` or
318
- `claude -p --model ... --effort ...`) and records the effective model, profile,
319
- and effort on completion. Machine-wide extra arguments still configure other
320
- CLI behavior; project/task policy wins for model and effort.
370
+ overrides (`codex exec --model ... -c model_reasoning_effort=...`,
371
+ `claude -p --model ... --effort ...`, or `gemini --model ...`) and records the
372
+ effective model, profile, and effort on completion. Gemini currently uses its
373
+ `auto` model default and does not expose a reasoning-effort flag. Machine-wide
374
+ extra arguments still configure other CLI behavior; dispatched model policy
375
+ wins.
321
376
 
322
377
  The Codex CLI invocation was verified against `codex-cli 0.144.1` on
323
378
  2026-07-18. The runtime uses `codex exec "<prompt>" --json` and translates
@@ -327,6 +382,12 @@ not placed in argv. The JSONL parser accepts the verified top-level
327
382
  `item.completed` / `turn.completed` shape and retains the older `msg`
328
383
  envelope as a compatibility fallback.
329
384
 
385
+ The Gemini adapter uses official headless `--prompt` and `--output-format
386
+ stream-json` flags. It resets pre-tool assistant text, retains the final
387
+ post-tool answer as the report, and reads aggregate `input_tokens` and
388
+ `output_tokens` from the terminal `result` event. Gemini does not report USD
389
+ cost, so an unknown value remains absent rather than becoming zero.
390
+
330
391
  ## Architecture
331
392
 
332
393
  ```
@@ -346,6 +407,7 @@ cli.cts
346
407
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
347
408
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
348
409
  - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
410
+ - geminiAdapter (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
349
411
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
350
412
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
351
413
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
@@ -360,7 +422,7 @@ cli.cts
360
422
 
361
423
  `adapter.cts` (top-level) is now a backward-compat re-export of
362
424
  `adapters/claude.cts` — new code should import `adapters/index.cts` (for
363
- `selectAdapter`) or `adapters/claude.cts` / `adapters/codex.cts` directly.
425
+ `selectAdapter`) or an adapter module directly.
364
426
 
365
427
  `api.cts` is the single choke point for every HTTP call; nothing else in the
366
428
  package talks to `fetch` directly for control-plane traffic.
@@ -411,8 +473,8 @@ tests cover:
411
473
  plus Claude's and Codex's usage/report parsing (`tests/exit-conditions.test.cts`).
412
474
  - `redact.cts` — exact-value and pattern-based redaction (`tests/redact.test.cts`).
413
475
  - `capacity.cts` — capacity math and acquire/release bookkeeping (`tests/capacity.test.cts`).
414
- - `config.cts` — env var parsing and defaults, including `NAVARCH_AGENT`/
415
- `NAVARCH_CODEX_BIN`/`NAVARCH_CODEX_EXTRA_ARGS` (`tests/config.test.cts`).
476
+ - `config.cts` — env var parsing and defaults for every selectable adapter
477
+ (`tests/config.test.cts`).
416
478
  - `sandbox.cts` — command construction (flags, env-via-stdin, credential-helper
417
479
  argv hygiene) against an injected fake `CommandRunner`, plus `isDockerAvailable()`
418
480
  degrading to `false` instead of throwing when Docker is absent (`tests/sandbox.test.cts`).
@@ -422,6 +484,11 @@ tests cover:
422
484
  - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
423
485
  and the docker-exec path (fake `CommandRunner`), and usage/report-text
424
486
  attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
487
+ - `adapters/gemini.cts` — host/Docker invocation, cancellation, sandbox/MCP
488
+ credential hygiene, parser drift, and normalized report/usage extraction
489
+ (`tests/adapters/gemini.test.cts`).
490
+ - `adapters/types.cts` — the shared process-result boundary is exercised for
491
+ Claude, Codex, and Gemini (`tests/adapters/conformance.test.cts`).
425
492
  - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
426
493
  config shape, native Codex permission-profile construction, and the hook's
427
494
  containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
@@ -454,6 +521,8 @@ secrets absent from disk after exit"):
454
521
  flags, per-run MCP override keys, stdin behavior, and JSONL event/usage
455
522
  shape are now verified locally; the next dogfood run covers their
456
523
  production composition.
524
+ - A full Gemini task that uses an authenticated Gemini CLI to claim, edit,
525
+ open a PR, report usage/evidence, and clean up its session sandbox.
457
526
  - Whether Docker-mode Codex should opt into
458
527
  `--dangerously-bypass-approvals-and-sandbox`. It is intentionally not a
459
528
  default: host mode is not an external sandbox, and silently disabling
@@ -12,7 +12,7 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
12
12
  * exit-conditions.cts#parseClaudeJsonResult) for exit-conditions.cts to map
13
13
  * onto a complete()/fail() call.
14
14
  *
15
- * This is one of two implementations of the AgentAdapter interface
15
+ * This is one implementation of the AgentAdapter interface
16
16
  * (adapters/types.cts) — see adapters/codex.cts for the Codex CLI sibling
17
17
  * session.cts picks between via NAVARCH_AGENT (config.cts's `agentType`).
18
18
  *
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.geminiAdapter = void 0;
4
+ exports.runGeminiAdapter = runGeminiAdapter;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_fs_1 = require("node:fs");
7
+ /**
8
+ * Headless Google Gemini CLI adapter. Gemini's `stream-json` output gives the
9
+ * same normalized boundary as the Claude and Codex adapters: raw process
10
+ * signals plus an optional final report and token counts. Gemini CLI does not
11
+ * report USD cost, so costUsd remains absent.
12
+ *
13
+ * The CLI reads MCP servers from settings rather than accepting Claude's
14
+ * `--mcp-config` flag. The adapter converts the lease-scoped config into a
15
+ * temporary system-settings file and restricts the run to those server names.
16
+ */
17
+ async function runGeminiAdapter(options) {
18
+ const args = ["--prompt", options.prompt];
19
+ const env = { ...options.env };
20
+ if (!hasArg(options.extraArgs, "--output-format", "-o")) {
21
+ args.push("--output-format", "stream-json");
22
+ }
23
+ if (!hasArg(options.extraArgs, "--skip-trust")) {
24
+ args.push("--skip-trust");
25
+ }
26
+ if (!hasExplicitApprovalMode(options.extraArgs)) {
27
+ // Navarch sessions have no interactive operator. Host sessions still use
28
+ // Gemini's sandbox below when the worktree guard is enabled.
29
+ args.push("--approval-mode", "yolo");
30
+ }
31
+ const sandboxMounts = [...(options.geminiSandboxMounts ?? [])];
32
+ if (options.mcpConfigPath) {
33
+ const mcp = await prepareGeminiMcpSettings(options.mcpConfigPath, env);
34
+ env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = mcp.settingsPath;
35
+ sandboxMounts.push(`${mcp.settingsPath}:${mcp.settingsPath}:ro`);
36
+ if (mcp.serverNames.length > 0 && !hasArg(options.extraArgs, "--allowed-mcp-server-names")) {
37
+ args.push("--allowed-mcp-server-names", mcp.serverNames.join(","));
38
+ }
39
+ }
40
+ if (!hasArg(options.extraArgs, "--sandbox", "-s")) {
41
+ if (options.geminiSandboxMounts) {
42
+ args.push("--sandbox");
43
+ env.SANDBOX_MOUNTS = sandboxMounts.join(",");
44
+ }
45
+ else {
46
+ // YOLO mode enables Gemini's own sandbox implicitly. Disable that
47
+ // nested sandbox when Navarch already supplied Docker isolation, or
48
+ // when an operator explicitly disabled the host worktree guard.
49
+ args.push("--sandbox=false");
50
+ }
51
+ }
52
+ args.push(...options.extraArgs);
53
+ if (options.model)
54
+ args.push("--model", options.model);
55
+ const runOptions = { ...options, env };
56
+ const raw = runOptions.dockerExec
57
+ ? await runViaDocker(runOptions, args)
58
+ : await runOnHost(runOptions, args);
59
+ return attachGeminiOutput(raw);
60
+ }
61
+ function hasArg(args, ...flags) {
62
+ return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
63
+ }
64
+ function hasExplicitApprovalMode(args) {
65
+ return hasArg(args, "--approval-mode", "--yolo", "-y");
66
+ }
67
+ async function prepareGeminiMcpSettings(mcpConfigPath, env) {
68
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
69
+ const mcpServers = {};
70
+ for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
71
+ const httpUrl = server.httpUrl ?? (server.type === "http" ? server.url : undefined);
72
+ if (!httpUrl && !server.url)
73
+ continue;
74
+ const headers = {};
75
+ let headerIndex = 0;
76
+ for (const [header, value] of Object.entries(server.headers ?? {})) {
77
+ const envName = `NAVARCH_GEMINI_MCP_${safeEnvSegment(name)}_${headerIndex++}`;
78
+ env[envName] = value;
79
+ headers[header] = `\${${envName}}`;
80
+ }
81
+ mcpServers[name] = {
82
+ ...(httpUrl ? { httpUrl } : { url: server.url }),
83
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
84
+ };
85
+ }
86
+ const settingsPath = `${mcpConfigPath}.gemini-settings.json`;
87
+ await node_fs_1.promises.writeFile(settingsPath, JSON.stringify({ mcpServers }, null, 2), { mode: 0o600 });
88
+ await node_fs_1.promises.chmod(settingsPath, 0o600);
89
+ return { settingsPath, serverNames: Object.keys(mcpServers) };
90
+ }
91
+ function safeEnvSegment(value) {
92
+ return value.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
93
+ }
94
+ function parseGeminiStream(stdout) {
95
+ const events = [];
96
+ for (const line of stdout.split(/\r?\n/)) {
97
+ if (!line.trim())
98
+ continue;
99
+ try {
100
+ const event = JSON.parse(line);
101
+ if (event && typeof event === "object")
102
+ events.push(event);
103
+ }
104
+ catch {
105
+ // Parser drift must not turn a successfully completed CLI run into a
106
+ // runtime crash. Unknown lines remain available in the transcript.
107
+ }
108
+ }
109
+ return events;
110
+ }
111
+ function attachGeminiOutput(result) {
112
+ const events = parseGeminiStream(result.stdout);
113
+ if (events.length === 0)
114
+ return result;
115
+ let reportText = "";
116
+ let tokensIn;
117
+ let tokensOut;
118
+ for (const event of events) {
119
+ if (event.type === "tool_use") {
120
+ // Gemini emits pre-tool assistant text as message deltas. Only retain
121
+ // the answer produced after the last tool call as the completion report.
122
+ reportText = "";
123
+ }
124
+ else if (event.type === "message" &&
125
+ event.role === "assistant" &&
126
+ typeof event.content === "string") {
127
+ reportText = event.delta === false ? event.content : reportText + event.content;
128
+ }
129
+ else if (event.type === "result" && event.stats) {
130
+ const reportedInput = nonNegativeMetric(event.stats.input_tokens);
131
+ const reportedOutput = nonNegativeMetric(event.stats.output_tokens);
132
+ if (reportedInput !== undefined)
133
+ tokensIn = reportedInput;
134
+ if (reportedOutput !== undefined)
135
+ tokensOut = reportedOutput;
136
+ }
137
+ }
138
+ return {
139
+ ...result,
140
+ ...(tokensIn !== undefined ? { tokensIn } : {}),
141
+ ...(tokensOut !== undefined ? { tokensOut } : {}),
142
+ ...(reportText.trim() ? { reportText: reportText.trim() } : {}),
143
+ };
144
+ }
145
+ function nonNegativeMetric(value) {
146
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
147
+ }
148
+ async function runOnHost(options, args) {
149
+ return new Promise((resolve) => {
150
+ let stdout = "";
151
+ let stderr = "";
152
+ let timedOut = false;
153
+ let killedByLeaseLoss = false;
154
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
155
+ cwd: options.cwd,
156
+ env: { ...process.env, ...options.env },
157
+ });
158
+ child.stdin?.end();
159
+ const timer = setTimeout(() => {
160
+ timedOut = true;
161
+ child.kill("SIGKILL");
162
+ }, options.timeoutMs);
163
+ const onAbort = () => {
164
+ killedByLeaseLoss = true;
165
+ child.kill("SIGKILL");
166
+ };
167
+ options.signal?.addEventListener("abort", onAbort, { once: true });
168
+ child.stdout.on("data", (data) => {
169
+ stdout += data.toString();
170
+ });
171
+ child.stderr.on("data", (data) => {
172
+ stderr += data.toString();
173
+ });
174
+ child.on("error", (err) => {
175
+ clearTimeout(timer);
176
+ options.signal?.removeEventListener("abort", onAbort);
177
+ stderr += `\n${String(err)}`;
178
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
179
+ });
180
+ child.on("close", (code) => {
181
+ clearTimeout(timer);
182
+ options.signal?.removeEventListener("abort", onAbort);
183
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
184
+ });
185
+ });
186
+ }
187
+ async function runViaDocker(options, args) {
188
+ const { containerName, runner } = options.dockerExec;
189
+ const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${[
190
+ options.bin,
191
+ ...args,
192
+ ].map(shellQuote).join(" ")}`;
193
+ let killedByLeaseLoss = false;
194
+ const onAbort = () => {
195
+ killedByLeaseLoss = true;
196
+ runner.run("docker", ["kill", containerName]).catch(() => undefined);
197
+ };
198
+ options.signal?.addEventListener("abort", onAbort, { once: true });
199
+ try {
200
+ const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
201
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
202
+ return {
203
+ exitCode: result.code,
204
+ timedOut: false,
205
+ killedByLeaseLoss,
206
+ stdout: result.stdout,
207
+ stderr: result.stderr,
208
+ };
209
+ }
210
+ catch (err) {
211
+ return {
212
+ exitCode: null,
213
+ timedOut: false,
214
+ killedByLeaseLoss,
215
+ stdout: "",
216
+ stderr: String(err),
217
+ };
218
+ }
219
+ finally {
220
+ options.signal?.removeEventListener("abort", onAbort);
221
+ }
222
+ }
223
+ function shellQuote(value) {
224
+ return `'${value.replace(/'/g, `'\\''`)}'`;
225
+ }
226
+ exports.geminiAdapter = {
227
+ agentType: "gemini",
228
+ run: runGeminiAdapter,
229
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
3
+ 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; } });
@@ -8,6 +8,9 @@ Object.defineProperty(exports, "runClaudeCodeAdapter", { enumerable: true, get:
8
8
  const codex_cjs_1 = require("./codex.cjs");
9
9
  Object.defineProperty(exports, "codexAdapter", { enumerable: true, get: function () { return codex_cjs_1.codexAdapter; } });
10
10
  Object.defineProperty(exports, "runCodexAdapter", { enumerable: true, get: function () { return codex_cjs_1.runCodexAdapter; } });
11
+ const gemini_cjs_1 = require("./gemini.cjs");
12
+ Object.defineProperty(exports, "geminiAdapter", { enumerable: true, get: function () { return gemini_cjs_1.geminiAdapter; } });
13
+ Object.defineProperty(exports, "runGeminiAdapter", { enumerable: true, get: function () { return gemini_cjs_1.runGeminiAdapter; } });
11
14
  /**
12
15
  * Picks the AgentAdapter (adapters/types.cts) session.cts should run a
13
16
  * session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
@@ -19,10 +22,12 @@ function selectAdapter(agentType) {
19
22
  switch (agentType) {
20
23
  case "codex":
21
24
  return codex_cjs_1.codexAdapter;
25
+ case "gemini":
26
+ return gemini_cjs_1.geminiAdapter;
22
27
  case "claude-code":
23
28
  return claude_cjs_1.claudeCodeAdapter;
24
29
  default: {
25
- // Exhaustiveness guard: config.cts only ever produces the two values
30
+ // Exhaustiveness guard: config.cts only ever produces the values
26
31
  // above, but fall back to Claude Code rather than throwing if this
27
32
  // widens in the future without every caller being updated.
28
33
  const _exhaustive = agentType;
@@ -72,6 +72,7 @@ class ClaimLoop {
72
72
  available_capacity: this.capacity.available(),
73
73
  capabilities: this.config.capabilities,
74
74
  agent_type: this.config.agentType,
75
+ runtimes: this.config.runtimes,
75
76
  session_id: sessionId,
76
77
  });
77
78
  if (this.consecutiveFailures > 0) {
package/dist/cli.cjs CHANGED
@@ -65,7 +65,7 @@ function agentFromFlag(flags) {
65
65
  if (value === undefined)
66
66
  return undefined;
67
67
  if (!(0, config_cjs_1.isRuntimeAgentType)(value)) {
68
- throw new Error("--agent must be either 'claude-code' or 'codex'.");
68
+ throw new Error("--agent must be one of 'claude-code', 'codex', or 'gemini'.");
69
69
  }
70
70
  return value;
71
71
  }
@@ -276,11 +276,11 @@ function helpText() {
276
276
 
277
277
  Usage:
278
278
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
279
- [--config-dir <path>] [--agent claude-code|codex] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
279
+ [--config-dir <path>] [--agent claude-code|codex|gemini] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
280
280
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
281
- [--config-dir <path>] [--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
282
- navarch-runtime start [--config-dir <path>] [--agent claude-code|codex]
283
- navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex]
281
+ [--config-dir <path>] [--agent claude-code|codex|gemini] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
282
+ navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini]
283
+ navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini]
284
284
  navarch-runtime doctor [--config-dir <path>]
285
285
 
286
286
  Use a different --config-dir (or NAVARCH_CONFIG_DIR) for every agent instance.
package/dist/config.cjs CHANGED
@@ -8,7 +8,7 @@ exports.loadRuntimeConfig = loadRuntimeConfig;
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const node_os_1 = __importDefault(require("node:os"));
10
10
  function isRuntimeAgentType(value) {
11
- return value === "claude-code" || value === "codex";
11
+ return value === "claude-code" || value === "codex" || value === "gemini";
12
12
  }
13
13
  function envInt(env, name, fallback) {
14
14
  const raw = env[name];
@@ -38,7 +38,9 @@ function loadRuntimeConfig(env = process.env) {
38
38
  // default. Docker isolation is an explicit operator opt-in, not a
39
39
  // prerequisite for claiming ordinary shell work.
40
40
  const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
41
- const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell"];
41
+ const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell", "browser-use"];
42
+ const agentType = isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
43
+ const runtimes = envList(env, "NAVARCH_RUNTIMES", [agentType]).filter(isRuntimeAgentType);
42
44
  return {
43
45
  apiBase: env.NAVARCH_API_BASE ?? "http://localhost:3000",
44
46
  workspaceRoot: env.NAVARCH_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
@@ -52,11 +54,14 @@ function loadRuntimeConfig(env = process.env) {
52
54
  // interval must stay comfortably under that TTL.
53
55
  leaseHeartbeatIntervalMs,
54
56
  sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
55
- agentType: isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code",
57
+ agentType,
58
+ runtimes: runtimes.length > 0 ? runtimes : [agentType],
56
59
  claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
57
60
  claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
58
61
  codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
59
62
  codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
63
+ geminiBin: env.NAVARCH_GEMINI_BIN ?? "gemini",
64
+ geminiExtraArgs: envList(env, "NAVARCH_GEMINI_EXTRA_ARGS", []),
60
65
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
61
66
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
62
67
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
@@ -48,7 +48,9 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
48
48
  token: envToken,
49
49
  name: process.env.NAVARCH_MACHINE_NAME ?? envId,
50
50
  api_base: process.env.NAVARCH_API_BASE ?? apiBaseFallback,
51
- agent_type: process.env.NAVARCH_AGENT === "codex" || process.env.NAVARCH_AGENT === "claude-code"
51
+ agent_type: process.env.NAVARCH_AGENT === "codex" ||
52
+ process.env.NAVARCH_AGENT === "claude-code" ||
53
+ process.env.NAVARCH_AGENT === "gemini"
52
54
  ? process.env.NAVARCH_AGENT
53
55
  : undefined,
54
56
  };
package/dist/session.cjs CHANGED
@@ -22,7 +22,7 @@ const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
22
22
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
23
23
  const MCP_CONFIG_FILENAME = "mcp-config.json";
24
24
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
25
- const PR_REQUIRED_COMPLETION_RETRIES = 2;
25
+ const COMPLETION_REMEDIATION_RETRIES = 2;
26
26
  const log = (0, logger_cjs_1.createLogger)("session");
27
27
  /**
28
28
  * Runs one claimed task end to end (implementation-plan.md WP-07):
@@ -30,8 +30,8 @@ const log = (0, logger_cjs_1.createLogger)("session");
30
30
  * 2. fetch secrets from the broker at session start (managed GitHub git
31
31
  * credentials are subsequently refreshed by a lease-scoped helper)
32
32
  * 3. optionally stand up a Docker sandbox when explicitly configured
33
- * 4. run the configured agent adapter (Claude Code or Codex, per
34
- * NAVARCH_AGENT — adapters/index.cts#selectAdapter), heartbeating the
33
+ * 4. run the adapter selected by the claim (Claude Code, Codex, or Gemini;
34
+ * adapters/index.cts#selectAdapter), heartbeating the
35
35
  * lease throughout
36
36
  * 5. redact + upload the transcript, map the exit condition, complete the
37
37
  * lease (recording which agent_type ran it)
@@ -46,9 +46,14 @@ const log = (0, logger_cjs_1.createLogger)("session");
46
46
  async function runSession(deps, claimed, sessionId) {
47
47
  const { api, config } = deps;
48
48
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
49
+ const runtime = claimed.runtime ?? config.agentType;
49
50
  const execution = bundle.execution ?? {
50
51
  profile: task.execution_profile ?? "standard",
51
- model: config.agentType === "codex" ? "gpt-5.6-sol" : "best",
52
+ model: runtime === "codex"
53
+ ? "gpt-5.6-sol"
54
+ : runtime === "gemini"
55
+ ? "auto"
56
+ : "best",
52
57
  reasoning_effort: "medium",
53
58
  };
54
59
  const executionReport = {
@@ -98,7 +103,7 @@ async function runSession(deps, claimed, sessionId) {
98
103
  evidence_urls: [],
99
104
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
100
105
  exit_status: "crashed",
101
- agent_type: config.agentType,
106
+ agent_type: runtime,
102
107
  ...executionReport,
103
108
  });
104
109
  secrets = {};
@@ -122,6 +127,7 @@ async function runSession(deps, claimed, sessionId) {
122
127
  let pendingGuidance = [];
123
128
  let activeAbortController = null;
124
129
  let leaseLost = false;
130
+ let leaseGone = false;
125
131
  let heartbeatInFlight = null;
126
132
  const pollLease = () => {
127
133
  if (heartbeatInFlight)
@@ -146,6 +152,7 @@ async function runSession(deps, claimed, sessionId) {
146
152
  .catch((err) => {
147
153
  log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
148
154
  leaseLost = true;
155
+ leaseGone = isTerminalLeaseHeartbeatError(err);
149
156
  activeAbortController?.abort();
150
157
  })
151
158
  .finally(() => {
@@ -167,7 +174,7 @@ async function runSession(deps, claimed, sessionId) {
167
174
  evidence_urls: [],
168
175
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
169
176
  exit_status: "crashed",
170
- agent_type: config.agentType,
177
+ agent_type: runtime,
171
178
  ...executionReport,
172
179
  })
173
180
  .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
@@ -208,11 +215,12 @@ async function runSession(deps, claimed, sessionId) {
208
215
  // machine. Host-mode Claude gets a generated PreToolUse hook; host-mode
209
216
  // Codex gets an OS-enforced native permission profile over the same roots.
210
217
  // Docker mode already has a container boundary. NAVARCH_WORKTREE_GUARD=off
211
- // opts out for either agent.
218
+ // opts out for any agent.
212
219
  let claudeSettingsPath = null;
213
220
  let codexGuardArgs;
221
+ let geminiGuardMounts;
214
222
  if (config.sandboxMode === "host" && config.worktreeGuard) {
215
- if (config.agentType === "claude-code") {
223
+ if (runtime === "claude-code") {
216
224
  if (config.claudeExtraArgs.includes("--settings")) {
217
225
  log.warn("NAVARCH_CLAUDE_EXTRA_ARGS supplies --settings; skipping the generated worktree-guard settings for this session.");
218
226
  }
@@ -227,7 +235,7 @@ async function runSession(deps, claimed, sessionId) {
227
235
  claudeSettingsPath = guard.settingsPath;
228
236
  }
229
237
  }
230
- else {
238
+ else if (runtime === "codex") {
231
239
  codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
232
240
  workDir,
233
241
  worktreePath: gitWorktree.worktreePath,
@@ -236,6 +244,15 @@ async function runSession(deps, claimed, sessionId) {
236
244
  extraRoots: config.guardExtraRoots,
237
245
  });
238
246
  }
247
+ else {
248
+ geminiGuardMounts = (0, worktree_guard_cjs_1.geminiSandboxMounts)({
249
+ workDir,
250
+ worktreePath: gitWorktree.worktreePath,
251
+ repositoryPath: gitWorktree.repositoryPath,
252
+ workspaceRoot: config.workspaceRoot,
253
+ extraRoots: config.guardExtraRoots,
254
+ });
255
+ }
239
256
  }
240
257
  try {
241
258
  await gitWorktree.prepare();
@@ -243,17 +260,16 @@ async function runSession(deps, claimed, sessionId) {
243
260
  await sandbox.create();
244
261
  await sandbox.injectEnv(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
245
262
  }
246
- // Picks the Claude Code or Codex adapter per NAVARCH_AGENT
247
- // (config.cts's `agentType`) — see adapters/index.cts#selectAdapter.
248
- // Both adapters implement the same AgentAdapter.run() shape
263
+ // The control plane resolves the project default/task override before
264
+ // claim and returns one of this worker's advertised runtimes.
265
+ // All adapters implement the same AgentAdapter.run() shape
249
266
  // (adapters/types.cts), so nothing else in this function branches on
250
267
  // which agent is running.
251
- const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
252
- const bin = config.agentType === "codex" ? config.codexBin : config.claudeBin;
253
- const extraArgs = config.agentType === "codex" ? config.codexExtraArgs : config.claudeExtraArgs;
268
+ const adapter = (0, index_cjs_1.selectAdapter)(runtime);
269
+ const { bin, extraArgs } = adapterCommand(config, runtime);
254
270
  const attempts = [];
255
271
  let nextPrompt = null;
256
- let prRequiredCompletionRetries = 0;
272
+ let completionRemediationRetries = 0;
257
273
  while (true) {
258
274
  // Guidance can arrive while the worktree/sandbox is being prepared.
259
275
  // It is already included in deliveredGuidance, so clear the pending
@@ -279,6 +295,7 @@ async function runSession(deps, claimed, sessionId) {
279
295
  env: toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail),
280
296
  settingsPath: claudeSettingsPath,
281
297
  codexGuardArgs,
298
+ geminiSandboxMounts: geminiGuardMounts,
282
299
  cwd: sandbox ? undefined : gitWorktree.worktreePath,
283
300
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
284
301
  signal: activeAbortController.signal,
@@ -287,8 +304,15 @@ async function runSession(deps, claimed, sessionId) {
287
304
  attempts.push(turnResult);
288
305
  // Close the small race between a naturally completed turn and the next
289
306
  // scheduled heartbeat. If guidance landed, run another turn before the
290
- // lease can be completed.
291
- await pollLease();
307
+ // lease can be completed. A terminal heartbeat response means the
308
+ // control plane has already disposed of the lease, so neither retry the
309
+ // heartbeat nor attempt completion against that stale lease.
310
+ if (!leaseGone)
311
+ await pollLease();
312
+ if (leaseGone) {
313
+ log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
314
+ return;
315
+ }
292
316
  if (!leaseLost && pendingGuidance.length > 0)
293
317
  continue;
294
318
  const result = {
@@ -349,7 +373,7 @@ async function runSession(deps, claimed, sessionId) {
349
373
  },
350
374
  transcript_url: transcriptUrl,
351
375
  exit_status: mapping.exitStatus,
352
- agent_type: config.agentType,
376
+ agent_type: runtime,
353
377
  ...executionReport,
354
378
  };
355
379
  try {
@@ -357,17 +381,21 @@ async function runSession(deps, claimed, sessionId) {
357
381
  break;
358
382
  }
359
383
  catch (err) {
360
- const rejection = mapping.leaseOutcome === "completed" ? prRequiredRejectionMessage(err) : null;
384
+ if (isAlreadyReleasedCompletionError(err)) {
385
+ log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
386
+ return;
387
+ }
388
+ const rejection = mapping.leaseOutcome === "completed" ? completionRemediationMessage(err) : null;
361
389
  if (!rejection)
362
390
  throw err;
363
391
  const redactedRejection = (0, redact_cjs_1.redactText)(rejection, knownSecrets);
364
- if (prRequiredCompletionRetries < PR_REQUIRED_COMPLETION_RETRIES) {
365
- prRequiredCompletionRetries += 1;
366
- log.warn(`completion for ${leaseId} requires a pull request; restarting agent turn ${prRequiredCompletionRetries}/${PR_REQUIRED_COMPLETION_RETRIES} in the same worktree.`);
392
+ if (completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
393
+ completionRemediationRetries += 1;
394
+ log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
367
395
  nextPrompt = redactedRejection;
368
396
  continue;
369
397
  }
370
- log.warn(`completion for ${leaseId} still requires a pull request after ${PR_REQUIRED_COMPLETION_RETRIES} retries; failing with the control-plane rejection.`);
398
+ log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
371
399
  await api.completeLease(leaseId, {
372
400
  ...completion,
373
401
  status: "failed",
@@ -390,7 +418,7 @@ async function runSession(deps, claimed, sessionId) {
390
418
  evidence_urls: [],
391
419
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
392
420
  exit_status: "crashed",
393
- agent_type: config.agentType,
421
+ agent_type: runtime,
394
422
  ...executionReport,
395
423
  })
396
424
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
@@ -405,6 +433,16 @@ async function runSession(deps, claimed, sessionId) {
405
433
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
406
434
  }
407
435
  }
436
+ function adapterCommand(config, runtime) {
437
+ switch (runtime) {
438
+ case "claude-code":
439
+ return { bin: config.claudeBin, extraArgs: config.claudeExtraArgs };
440
+ case "codex":
441
+ return { bin: config.codexBin, extraArgs: config.codexExtraArgs };
442
+ case "gemini":
443
+ return { bin: config.geminiBin, extraArgs: config.geminiExtraArgs };
444
+ }
445
+ }
408
446
  function sumReportedUsage(attempts, key) {
409
447
  const reported = attempts.flatMap((attempt) => {
410
448
  const value = attempt[key];
@@ -412,16 +450,28 @@ function sumReportedUsage(attempts, key) {
412
450
  });
413
451
  return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
414
452
  }
415
- function prRequiredRejectionMessage(err) {
453
+ function completionRemediationMessage(err) {
416
454
  if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
417
455
  return null;
418
456
  if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
419
457
  return null;
420
458
  const body = err.body;
421
- return body.code === "pr_required" && typeof body.error === "string"
459
+ return (body.code === "pr_required" || body.code === "pr_not_ready") &&
460
+ typeof body.error === "string"
422
461
  ? body.error
423
462
  : null;
424
463
  }
464
+ function isTerminalLeaseHeartbeatError(err) {
465
+ return err instanceof api_cjs_1.NavarchApiError && [403, 404, 410].includes(err.status);
466
+ }
467
+ function isAlreadyReleasedCompletionError(err) {
468
+ if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
469
+ return false;
470
+ if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
471
+ return false;
472
+ const body = err.body;
473
+ return body.error === "lease already released";
474
+ }
425
475
  /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
426
476
  function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
427
477
  // Keep the existing `(secrets, authorName, authorEmail)` call shape while
@@ -7,6 +7,7 @@ exports.guardHookScriptPath = guardHookScriptPath;
7
7
  exports.prepareWorktreeGuard = prepareWorktreeGuard;
8
8
  exports.codexToolReadRoots = codexToolReadRoots;
9
9
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
10
+ exports.geminiSandboxMounts = geminiSandboxMounts;
10
11
  const node_path_1 = __importDefault(require("node:path"));
11
12
  const node_fs_1 = require("node:fs");
12
13
  const node_os_1 = __importDefault(require("node:os"));
@@ -76,6 +77,10 @@ function codexToolReadRoots(env = process.env) {
76
77
  node_path_1.default.join(homeDir, ".agents", "skills"),
77
78
  node_path_1.default.join(homeDir, ".local", "bin"),
78
79
  "/opt/homebrew",
80
+ // AppKit loads this bundle even for headless Chromium. Without it,
81
+ // Playwright browsers abort before launch on macOS with
82
+ // "required built-in appearance SystemAppearance not found".
83
+ "/System/Library/CoreServices/SystemAppearance.bundle",
79
84
  node_path_1.default.join(homeDir, "Library", "Caches", "ms-playwright"),
80
85
  node_path_1.default.join(homeDir, ".cache", "ms-playwright"),
81
86
  ];
@@ -137,6 +142,21 @@ function codexWorktreeGuardArgs(options) {
137
142
  `permissions.${CODEX_GUARD_PROFILE}.network.enabled=true`,
138
143
  ];
139
144
  }
145
+ /**
146
+ * Mounts only the current session roots into Gemini CLI's native sandbox.
147
+ * Gemini mounts cwd itself; explicit same-path mounts keep the shared gitdir
148
+ * and approved external roots reachable without exposing sibling sessions.
149
+ */
150
+ function geminiSandboxMounts(options) {
151
+ const workspaceRoot = node_path_1.default.resolve(options.workspaceRoot);
152
+ const roots = [options.worktreePath, options.repositoryPath];
153
+ for (const root of options.extraRoots ?? []) {
154
+ const resolved = node_path_1.default.resolve(root);
155
+ if (!isPathInside(resolved, workspaceRoot))
156
+ roots.push(resolved);
157
+ }
158
+ return [...new Set(roots.map((root) => node_path_1.default.resolve(root)))].map((root) => `${root}:${root}:rw`);
159
+ }
140
160
  function isPathInside(candidate, root) {
141
161
  const relative = node_path_1.default.relative(root, candidate);
142
162
  return relative === "" || (!relative.startsWith("..") && !node_path_1.default.isAbsolute(relative));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.11",
4
- "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them via the Claude Code or Codex adapter, and reports results back.",
3
+ "version": "0.1.12",
4
+ "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -15,6 +15,7 @@
15
15
  "ai-agent",
16
16
  "claude-code",
17
17
  "codex",
18
+ "gemini-cli",
18
19
  "task-runner"
19
20
  ],
20
21
  "bin": {