@sagentlab/navarch-runtime 0.1.11 → 0.1.13

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` | `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. |
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
- | `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`. |
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`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts; supply `--setting-sources=<sources>` here to opt in deliberately. |
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)
@@ -242,6 +280,9 @@ supported coding agents, using each CLI's native enforcement point:
242
280
  does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
243
281
  settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
244
282
  `bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
283
+ User, project, and local settings sources are disabled by default, preventing
284
+ host-only hooks and plugins from leaking into unattended sessions; the
285
+ explicit generated settings file remains active.
245
286
  - **Codex:** the runtime passes a one-off native permission profile with
246
287
  `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
247
288
  Codex's OS sandbox grants read/write access only to the allowed roots and
@@ -251,6 +292,12 @@ supported coding agents, using each CLI's native enforcement point:
251
292
  user or checked-in legacy `sandbox_mode` from silently disabling the
252
293
  generated profile; Codex authentication still comes from `CODEX_HOME`, and
253
294
  repository instructions such as `AGENTS.md` still load.
295
+ - **Gemini:** the runtime uses Gemini CLI's native sandbox with only the
296
+ current worktree, shared gitdir, and approved extra roots mounted. The
297
+ lease MCP config is translated to a read-only temporary settings file;
298
+ header values remain environment-variable references rather than literals.
299
+ Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting
300
+ it inside Navarch's already isolated session container.
254
301
 
255
302
  The resulting boundary is:
256
303
 
@@ -284,12 +331,11 @@ The auto-review launch paths were verified live on 2026-07-20 with Claude Code
284
331
  permission profile plus `approvals_reviewer="auto_review"`). Both completed an
285
332
  unattended smoke task successfully.
286
333
 
287
- ## Choosing an agent (Claude Code vs. Codex)
334
+ ## Choosing an agent
288
335
 
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:
336
+ Each worker advertises the agent CLIs it can actually execute. Existing
337
+ single-runtime installs keep using `--agent`/`NAVARCH_AGENT`; multi-runtime
338
+ workers set `NAVARCH_RUNTIMES` to the installed and authenticated adapters:
293
339
 
294
340
  ```sh
295
341
  # Claude Code (default) — requires the `claude` CLI installed and
@@ -300,12 +346,22 @@ export NAVARCH_AGENT=claude-code
300
346
  # this machine (or NAVARCH_CODEX_BIN pointing at it), analogous to the
301
347
  # Claude Code prerequisite above.
302
348
  export NAVARCH_AGENT=codex
349
+
350
+ # Google Gemini CLI — requires an authenticated `gemini` CLI (or
351
+ # NAVARCH_GEMINI_BIN pointing at it).
352
+ export NAVARCH_AGENT=gemini
353
+
354
+ # Or let one worker serve tasks selected for any installed adapter.
355
+ export NAVARCH_RUNTIMES=claude-code,codex,gemini
303
356
  ```
304
357
 
305
- Priority is `start --agent` → `NAVARCH_AGENT` → the locally saved choice →
306
- `claude-code`. The control plane does not choose the adapter.
358
+ For the legacy single-runtime setting, priority is `start --agent` →
359
+ `NAVARCH_AGENT` the locally saved choice `claude-code`.
360
+ `NAVARCH_RUNTIMES` expands what the worker advertises; the control plane then
361
+ resolves `tasks.runtime_override` → `projects.default_runtime` and returns the
362
+ selected adapter with the claim.
307
363
 
308
- Both adapters implement the same `AgentAdapter` interface
364
+ All three adapters implement the same `AgentAdapter` interface
309
365
  (`src/adapters/types.cts`) and run either directly on the host or via
310
366
  `docker exec` in the session's sandbox container, exactly like the Claude
311
367
  adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
@@ -314,10 +370,12 @@ through to `complete()` unchanged by whatever happened during the run.
314
370
 
315
371
  The control plane also resolves the project's model and the task's execution
316
372
  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.
373
+ overrides (`codex exec --model ... -c model_reasoning_effort=...`,
374
+ `claude -p --model ... --effort ...`, or `gemini --model ...`) and records the
375
+ effective model, profile, and effort on completion. Gemini currently uses its
376
+ `auto` model default and does not expose a reasoning-effort flag. Machine-wide
377
+ extra arguments still configure other CLI behavior; dispatched model policy
378
+ wins.
321
379
 
322
380
  The Codex CLI invocation was verified against `codex-cli 0.144.1` on
323
381
  2026-07-18. The runtime uses `codex exec "<prompt>" --json` and translates
@@ -327,6 +385,12 @@ not placed in argv. The JSONL parser accepts the verified top-level
327
385
  `item.completed` / `turn.completed` shape and retains the older `msg`
328
386
  envelope as a compatibility fallback.
329
387
 
388
+ The Gemini adapter uses official headless `--prompt` and `--output-format
389
+ stream-json` flags. It resets pre-tool assistant text, retains the final
390
+ post-tool answer as the report, and reads aggregate `input_tokens` and
391
+ `output_tokens` from the terminal `result` event. Gemini does not report USD
392
+ cost, so an unknown value remains absent rather than becoming zero.
393
+
330
394
  ## Architecture
331
395
 
332
396
  ```
@@ -346,6 +410,7 @@ cli.cts
346
410
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
347
411
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
348
412
  - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
413
+ - geminiAdapter (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
349
414
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
350
415
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
351
416
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
@@ -360,7 +425,7 @@ cli.cts
360
425
 
361
426
  `adapter.cts` (top-level) is now a backward-compat re-export of
362
427
  `adapters/claude.cts` — new code should import `adapters/index.cts` (for
363
- `selectAdapter`) or `adapters/claude.cts` / `adapters/codex.cts` directly.
428
+ `selectAdapter`) or an adapter module directly.
364
429
 
365
430
  `api.cts` is the single choke point for every HTTP call; nothing else in the
366
431
  package talks to `fetch` directly for control-plane traffic.
@@ -411,8 +476,8 @@ tests cover:
411
476
  plus Claude's and Codex's usage/report parsing (`tests/exit-conditions.test.cts`).
412
477
  - `redact.cts` — exact-value and pattern-based redaction (`tests/redact.test.cts`).
413
478
  - `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`).
479
+ - `config.cts` — env var parsing and defaults for every selectable adapter
480
+ (`tests/config.test.cts`).
416
481
  - `sandbox.cts` — command construction (flags, env-via-stdin, credential-helper
417
482
  argv hygiene) against an injected fake `CommandRunner`, plus `isDockerAvailable()`
418
483
  degrading to `false` instead of throwing when Docker is absent (`tests/sandbox.test.cts`).
@@ -422,6 +487,11 @@ tests cover:
422
487
  - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
423
488
  and the docker-exec path (fake `CommandRunner`), and usage/report-text
424
489
  attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
490
+ - `adapters/gemini.cts` — host/Docker invocation, cancellation, sandbox/MCP
491
+ credential hygiene, parser drift, and normalized report/usage extraction
492
+ (`tests/adapters/gemini.test.cts`).
493
+ - `adapters/types.cts` — the shared process-result boundary is exercised for
494
+ Claude, Codex, and Gemini (`tests/adapters/conformance.test.cts`).
425
495
  - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
426
496
  config shape, native Codex permission-profile construction, and the hook's
427
497
  containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
@@ -454,6 +524,8 @@ secrets absent from disk after exit"):
454
524
  flags, per-run MCP override keys, stdin behavior, and JSONL event/usage
455
525
  shape are now verified locally; the next dogfood run covers their
456
526
  production composition.
527
+ - A full Gemini task that uses an authenticated Gemini CLI to claim, edit,
528
+ open a PR, report usage/evidence, and clean up its session sandbox.
457
529
  - Whether Docker-mode Codex should opt into
458
530
  `--dangerously-bypass-approvals-and-sandbox`. It is intentionally not a
459
531
  default: host mode is not an external sandbox, and silently disabling
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectAdapterCapacityLimit = detectAdapterCapacityLimit;
4
+ const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
5
+ const DEFAULT_CAPACITY_COOLDOWN_MS = 15 * 60 * 1000;
6
+ const RESET_GRACE_MS = 30 * 1000;
7
+ const MAX_RESET_SEARCH_MINUTES = 48 * 60;
8
+ /**
9
+ * Detects a provider-side capacity response that applies beyond one task
10
+ * lease. Claude Code reports account session exhaustion as a successful CLI
11
+ * process containing a structured 429 result, so process exit status alone
12
+ * cannot distinguish it from an ordinary failed task.
13
+ */
14
+ function detectAdapterCapacityLimit(result, nowMs = Date.now()) {
15
+ const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout) ??
16
+ (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stderr);
17
+ if (parsed?.api_error_status !== 429)
18
+ return null;
19
+ const detail = parsed.result ?? "";
20
+ if (!/\b(?:session|usage|rate)\s+limit\b|\btoo many requests\b/i.test(detail)) {
21
+ return null;
22
+ }
23
+ return {
24
+ retryAtMs: parseResetTime(detail, nowMs) ??
25
+ nowMs + DEFAULT_CAPACITY_COOLDOWN_MS,
26
+ };
27
+ }
28
+ /**
29
+ * Resolves messages such as "resets 9:50pm (America/New_York)" without
30
+ * assuming the runtime host uses the provider's timezone. Searching minute
31
+ * boundaries also handles UTC offsets and daylight-saving transitions using
32
+ * the platform's IANA timezone database.
33
+ */
34
+ function parseResetTime(detail, nowMs) {
35
+ const match = detail.match(/\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i);
36
+ if (!match)
37
+ return null;
38
+ const hour12 = Number(match[1]);
39
+ const minute = Number(match[2] ?? "0");
40
+ const meridiem = match[3]?.toLowerCase();
41
+ const timeZone = match[4]?.trim();
42
+ if (!Number.isInteger(hour12) ||
43
+ hour12 < 1 ||
44
+ hour12 > 12 ||
45
+ !Number.isInteger(minute) ||
46
+ minute < 0 ||
47
+ minute > 59 ||
48
+ !timeZone) {
49
+ return null;
50
+ }
51
+ const targetHour = hour12 % 12 + (meridiem === "pm" ? 12 : 0);
52
+ let formatter;
53
+ try {
54
+ formatter = new Intl.DateTimeFormat("en-US", {
55
+ timeZone,
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ hourCycle: "h23",
59
+ });
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ const firstMinuteMs = Math.ceil((nowMs + 1) / 60_000) * 60_000;
65
+ for (let offset = 0; offset < MAX_RESET_SEARCH_MINUTES; offset += 1) {
66
+ const candidateMs = firstMinuteMs + offset * 60_000;
67
+ const parts = formatter.formatToParts(new Date(candidateMs));
68
+ const hour = Number(parts.find((part) => part.type === "hour")?.value);
69
+ const candidateMinute = Number(parts.find((part) => part.type === "minute")?.value);
70
+ if (hour === targetHour && candidateMinute === minute) {
71
+ return candidateMs + RESET_GRACE_MS;
72
+ }
73
+ }
74
+ return null;
75
+ }
@@ -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
  *
@@ -26,7 +26,22 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
26
26
  */
27
27
  async function runClaudeCodeAdapter(options) {
28
28
  const args = ["-p", options.prompt];
29
+ const hasSettingSources = options.extraArgs.some((arg) => arg === "--setting-sources" || arg.startsWith("--setting-sources="));
29
30
  const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
31
+ // Runtime sessions must not inherit an operator's personal or project-local
32
+ // Claude hooks. Apart from making execution machine-dependent, those hooks
33
+ // commonly reference helper files through CLAUDE_PROJECT_DIR; that variable
34
+ // points at the temporary session checkout, where a host-only helper does
35
+ // not exist, and a failing SessionEnd hook turns an otherwise successful
36
+ // task into an adapter failure.
37
+ //
38
+ // An empty source list disables user/project/local settings while preserving
39
+ // the explicit --settings file below ("flagSettings" in Claude Code), so the
40
+ // generated worktree-guard hook remains active. Operators can deliberately
41
+ // opt sources back in through NAVARCH_CLAUDE_EXTRA_ARGS.
42
+ if (!hasSettingSources) {
43
+ args.push("--setting-sources", "");
44
+ }
30
45
  // Navarch sessions are unattended, so route permission decisions through
31
46
  // Claude Code's native auto-mode classifier instead of prompting a human or
32
47
  // bypassing checks. Operators can replace this with a different permission
@@ -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;
@@ -23,6 +23,7 @@ class ClaimLoop {
23
23
  claimInFlight = false;
24
24
  consecutiveFailures = 0;
25
25
  nextClaimAt = 0;
26
+ capacityCooldownUntil = 0;
26
27
  quiescenceWaiters = new Set();
27
28
  constructor(api, config, capacity, runSession) {
28
29
  this.api = api;
@@ -60,7 +61,7 @@ class ClaimLoop {
60
61
  if (this.stopped ||
61
62
  this.claimInFlight ||
62
63
  !this.capacity.hasCapacity() ||
63
- Date.now() < this.nextClaimAt)
64
+ Date.now() < Math.max(this.nextClaimAt, this.capacityCooldownUntil))
64
65
  return;
65
66
  this.claimInFlight = true;
66
67
  try {
@@ -72,6 +73,7 @@ class ClaimLoop {
72
73
  available_capacity: this.capacity.available(),
73
74
  capabilities: this.config.capabilities,
74
75
  agent_type: this.config.agentType,
76
+ runtimes: this.config.runtimes,
75
77
  session_id: sessionId,
76
78
  });
77
79
  if (this.consecutiveFailures > 0) {
@@ -84,6 +86,13 @@ class ClaimLoop {
84
86
  this.capacity.acquire(claimed.lease_id);
85
87
  log.info(`claimed task ${claimed.task.id} (${claimed.task.task_type}) as lease ${claimed.lease_id}, session ${sessionId}`);
86
88
  this.runSession(claimed, sessionId)
89
+ .then((outcome) => {
90
+ const cooldownUntil = outcome?.claimCooldownUntil;
91
+ if (!cooldownUntil || cooldownUntil <= Date.now())
92
+ return;
93
+ this.capacityCooldownUntil = Math.max(this.capacityCooldownUntil, cooldownUntil);
94
+ log.warn(`adapter capacity exhausted; pausing new claims until ${new Date(this.capacityCooldownUntil).toISOString()}`);
95
+ })
87
96
  .catch((err) => log.error(`session ${sessionId} failed: ${String(err)}`))
88
97
  .finally(() => this.capacity.release(claimed.lease_id));
89
98
  }
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
@@ -3,12 +3,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_SANDBOX_IMAGE = void 0;
6
7
  exports.isRuntimeAgentType = isRuntimeAgentType;
7
8
  exports.loadRuntimeConfig = loadRuntimeConfig;
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const node_os_1 = __importDefault(require("node:os"));
11
+ /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
12
+ exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
10
13
  function isRuntimeAgentType(value) {
11
- return value === "claude-code" || value === "codex";
14
+ return value === "claude-code" || value === "codex" || value === "gemini";
12
15
  }
13
16
  function envInt(env, name, fallback) {
14
17
  const raw = env[name];
@@ -38,7 +41,9 @@ function loadRuntimeConfig(env = process.env) {
38
41
  // default. Docker isolation is an explicit operator opt-in, not a
39
42
  // prerequisite for claiming ordinary shell work.
40
43
  const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
41
- const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell"];
44
+ const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell", "browser-use"];
45
+ const agentType = isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
46
+ const runtimes = envList(env, "NAVARCH_RUNTIMES", [agentType]).filter(isRuntimeAgentType);
42
47
  return {
43
48
  apiBase: env.NAVARCH_API_BASE ?? "http://localhost:3000",
44
49
  workspaceRoot: env.NAVARCH_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
@@ -52,16 +57,19 @@ function loadRuntimeConfig(env = process.env) {
52
57
  // interval must stay comfortably under that TTL.
53
58
  leaseHeartbeatIntervalMs,
54
59
  sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
55
- agentType: isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code",
60
+ agentType,
61
+ runtimes: runtimes.length > 0 ? runtimes : [agentType],
56
62
  claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
57
63
  claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
58
64
  codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
59
65
  codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
66
+ geminiBin: env.NAVARCH_GEMINI_BIN ?? "gemini",
67
+ geminiExtraArgs: envList(env, "NAVARCH_GEMINI_EXTRA_ARGS", []),
60
68
  gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
61
69
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
62
70
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
63
71
  sandboxMode,
64
- dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
72
+ dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
65
73
  // Multiple sessions share one machine; keeping each agent inside its own
66
74
  // worktree is the safe default, so disabling is the explicit opt-out.
67
75
  worktreeGuard: !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? ""),
@@ -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
@@ -19,10 +19,11 @@ const logger_cjs_1 = require("./logger.cjs");
19
19
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
20
20
  const github_pr_cjs_1 = require("./github-pr.cjs");
21
21
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
22
+ const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
22
23
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
23
24
  const MCP_CONFIG_FILENAME = "mcp-config.json";
24
25
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
25
- const PR_REQUIRED_COMPLETION_RETRIES = 2;
26
+ const COMPLETION_REMEDIATION_RETRIES = 2;
26
27
  const log = (0, logger_cjs_1.createLogger)("session");
27
28
  /**
28
29
  * Runs one claimed task end to end (implementation-plan.md WP-07):
@@ -30,8 +31,8 @@ const log = (0, logger_cjs_1.createLogger)("session");
30
31
  * 2. fetch secrets from the broker at session start (managed GitHub git
31
32
  * credentials are subsequently refreshed by a lease-scoped helper)
32
33
  * 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
34
+ * 4. run the adapter selected by the claim (Claude Code, Codex, or Gemini;
35
+ * adapters/index.cts#selectAdapter), heartbeating the
35
36
  * lease throughout
36
37
  * 5. redact + upload the transcript, map the exit condition, complete the
37
38
  * lease (recording which agent_type ran it)
@@ -46,9 +47,14 @@ const log = (0, logger_cjs_1.createLogger)("session");
46
47
  async function runSession(deps, claimed, sessionId) {
47
48
  const { api, config } = deps;
48
49
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
50
+ const runtime = claimed.runtime ?? config.agentType;
49
51
  const execution = bundle.execution ?? {
50
52
  profile: task.execution_profile ?? "standard",
51
- model: config.agentType === "codex" ? "gpt-5.6-sol" : "best",
53
+ model: runtime === "codex"
54
+ ? "gpt-5.6-sol"
55
+ : runtime === "gemini"
56
+ ? "auto"
57
+ : "best",
52
58
  reasoning_effort: "medium",
53
59
  };
54
60
  const executionReport = {
@@ -98,7 +104,7 @@ async function runSession(deps, claimed, sessionId) {
98
104
  evidence_urls: [],
99
105
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
100
106
  exit_status: "crashed",
101
- agent_type: config.agentType,
107
+ agent_type: runtime,
102
108
  ...executionReport,
103
109
  });
104
110
  secrets = {};
@@ -122,7 +128,9 @@ async function runSession(deps, claimed, sessionId) {
122
128
  let pendingGuidance = [];
123
129
  let activeAbortController = null;
124
130
  let leaseLost = false;
131
+ let leaseGone = false;
125
132
  let heartbeatInFlight = null;
133
+ const sessionOutcome = {};
126
134
  const pollLease = () => {
127
135
  if (heartbeatInFlight)
128
136
  return heartbeatInFlight;
@@ -146,6 +154,7 @@ async function runSession(deps, claimed, sessionId) {
146
154
  .catch((err) => {
147
155
  log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
148
156
  leaseLost = true;
157
+ leaseGone = isTerminalLeaseHeartbeatError(err);
149
158
  activeAbortController?.abort();
150
159
  })
151
160
  .finally(() => {
@@ -167,7 +176,7 @@ async function runSession(deps, claimed, sessionId) {
167
176
  evidence_urls: [],
168
177
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
169
178
  exit_status: "crashed",
170
- agent_type: config.agentType,
179
+ agent_type: runtime,
171
180
  ...executionReport,
172
181
  })
173
182
  .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
@@ -208,11 +217,12 @@ async function runSession(deps, claimed, sessionId) {
208
217
  // machine. Host-mode Claude gets a generated PreToolUse hook; host-mode
209
218
  // Codex gets an OS-enforced native permission profile over the same roots.
210
219
  // Docker mode already has a container boundary. NAVARCH_WORKTREE_GUARD=off
211
- // opts out for either agent.
220
+ // opts out for any agent.
212
221
  let claudeSettingsPath = null;
213
222
  let codexGuardArgs;
223
+ let geminiGuardMounts;
214
224
  if (config.sandboxMode === "host" && config.worktreeGuard) {
215
- if (config.agentType === "claude-code") {
225
+ if (runtime === "claude-code") {
216
226
  if (config.claudeExtraArgs.includes("--settings")) {
217
227
  log.warn("NAVARCH_CLAUDE_EXTRA_ARGS supplies --settings; skipping the generated worktree-guard settings for this session.");
218
228
  }
@@ -227,7 +237,7 @@ async function runSession(deps, claimed, sessionId) {
227
237
  claudeSettingsPath = guard.settingsPath;
228
238
  }
229
239
  }
230
- else {
240
+ else if (runtime === "codex") {
231
241
  codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
232
242
  workDir,
233
243
  worktreePath: gitWorktree.worktreePath,
@@ -236,6 +246,15 @@ async function runSession(deps, claimed, sessionId) {
236
246
  extraRoots: config.guardExtraRoots,
237
247
  });
238
248
  }
249
+ else {
250
+ geminiGuardMounts = (0, worktree_guard_cjs_1.geminiSandboxMounts)({
251
+ workDir,
252
+ worktreePath: gitWorktree.worktreePath,
253
+ repositoryPath: gitWorktree.repositoryPath,
254
+ workspaceRoot: config.workspaceRoot,
255
+ extraRoots: config.guardExtraRoots,
256
+ });
257
+ }
239
258
  }
240
259
  try {
241
260
  await gitWorktree.prepare();
@@ -243,17 +262,16 @@ async function runSession(deps, claimed, sessionId) {
243
262
  await sandbox.create();
244
263
  await sandbox.injectEnv(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
245
264
  }
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
265
+ // The control plane resolves the project default/task override before
266
+ // claim and returns one of this worker's advertised runtimes.
267
+ // All adapters implement the same AgentAdapter.run() shape
249
268
  // (adapters/types.cts), so nothing else in this function branches on
250
269
  // 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;
270
+ const adapter = (0, index_cjs_1.selectAdapter)(runtime);
271
+ const { bin, extraArgs } = adapterCommand(config, runtime);
254
272
  const attempts = [];
255
273
  let nextPrompt = null;
256
- let prRequiredCompletionRetries = 0;
274
+ let completionRemediationRetries = 0;
257
275
  while (true) {
258
276
  // Guidance can arrive while the worktree/sandbox is being prepared.
259
277
  // It is already included in deliveredGuidance, so clear the pending
@@ -279,16 +297,28 @@ async function runSession(deps, claimed, sessionId) {
279
297
  env: toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail),
280
298
  settingsPath: claudeSettingsPath,
281
299
  codexGuardArgs,
300
+ geminiSandboxMounts: geminiGuardMounts,
282
301
  cwd: sandbox ? undefined : gitWorktree.worktreePath,
283
302
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
284
303
  signal: activeAbortController.signal,
285
304
  });
286
305
  activeAbortController = null;
287
306
  attempts.push(turnResult);
307
+ const capacityLimit = (0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(turnResult);
308
+ if (capacityLimit) {
309
+ sessionOutcome.claimCooldownUntil = Math.max(sessionOutcome.claimCooldownUntil ?? 0, capacityLimit.retryAtMs);
310
+ }
288
311
  // Close the small race between a naturally completed turn and the next
289
312
  // scheduled heartbeat. If guidance landed, run another turn before the
290
- // lease can be completed.
291
- await pollLease();
313
+ // lease can be completed. A terminal heartbeat response means the
314
+ // control plane has already disposed of the lease, so neither retry the
315
+ // heartbeat nor attempt completion against that stale lease.
316
+ if (!leaseGone)
317
+ await pollLease();
318
+ if (leaseGone) {
319
+ log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
320
+ return sessionOutcome;
321
+ }
292
322
  if (!leaseLost && pendingGuidance.length > 0)
293
323
  continue;
294
324
  const result = {
@@ -349,7 +379,7 @@ async function runSession(deps, claimed, sessionId) {
349
379
  },
350
380
  transcript_url: transcriptUrl,
351
381
  exit_status: mapping.exitStatus,
352
- agent_type: config.agentType,
382
+ agent_type: runtime,
353
383
  ...executionReport,
354
384
  };
355
385
  try {
@@ -357,17 +387,21 @@ async function runSession(deps, claimed, sessionId) {
357
387
  break;
358
388
  }
359
389
  catch (err) {
360
- const rejection = mapping.leaseOutcome === "completed" ? prRequiredRejectionMessage(err) : null;
390
+ if (isAlreadyReleasedCompletionError(err)) {
391
+ log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
392
+ return sessionOutcome;
393
+ }
394
+ const rejection = mapping.leaseOutcome === "completed" ? completionRemediationMessage(err) : null;
361
395
  if (!rejection)
362
396
  throw err;
363
397
  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.`);
398
+ if (completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
399
+ completionRemediationRetries += 1;
400
+ log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
367
401
  nextPrompt = redactedRejection;
368
402
  continue;
369
403
  }
370
- log.warn(`completion for ${leaseId} still requires a pull request after ${PR_REQUIRED_COMPLETION_RETRIES} retries; failing with the control-plane rejection.`);
404
+ log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
371
405
  await api.completeLease(leaseId, {
372
406
  ...completion,
373
407
  status: "failed",
@@ -390,7 +424,7 @@ async function runSession(deps, claimed, sessionId) {
390
424
  evidence_urls: [],
391
425
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
392
426
  exit_status: "crashed",
393
- agent_type: config.agentType,
427
+ agent_type: runtime,
394
428
  ...executionReport,
395
429
  })
396
430
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
@@ -404,6 +438,17 @@ async function runSession(deps, claimed, sessionId) {
404
438
  await gitWorktree.cleanup();
405
439
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
406
440
  }
441
+ return sessionOutcome;
442
+ }
443
+ function adapterCommand(config, runtime) {
444
+ switch (runtime) {
445
+ case "claude-code":
446
+ return { bin: config.claudeBin, extraArgs: config.claudeExtraArgs };
447
+ case "codex":
448
+ return { bin: config.codexBin, extraArgs: config.codexExtraArgs };
449
+ case "gemini":
450
+ return { bin: config.geminiBin, extraArgs: config.geminiExtraArgs };
451
+ }
407
452
  }
408
453
  function sumReportedUsage(attempts, key) {
409
454
  const reported = attempts.flatMap((attempt) => {
@@ -412,16 +457,28 @@ function sumReportedUsage(attempts, key) {
412
457
  });
413
458
  return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
414
459
  }
415
- function prRequiredRejectionMessage(err) {
460
+ function completionRemediationMessage(err) {
416
461
  if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
417
462
  return null;
418
463
  if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
419
464
  return null;
420
465
  const body = err.body;
421
- return body.code === "pr_required" && typeof body.error === "string"
466
+ return (body.code === "pr_required" || body.code === "pr_not_ready") &&
467
+ typeof body.error === "string"
422
468
  ? body.error
423
469
  : null;
424
470
  }
471
+ function isTerminalLeaseHeartbeatError(err) {
472
+ return err instanceof api_cjs_1.NavarchApiError && [403, 404, 410].includes(err.status);
473
+ }
474
+ function isAlreadyReleasedCompletionError(err) {
475
+ if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
476
+ return false;
477
+ if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
478
+ return false;
479
+ const body = err.body;
480
+ return body.error === "lease already released";
481
+ }
425
482
  /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
426
483
  function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
427
484
  // 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.13",
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": {