npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sagentlab/navarch-runtime

v0.1.57

Published

Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.

Readme

navarch-runtime

The machine-side half of Navarch (WP-07): registers this machine with the control plane, then loops claim → sandbox → agent CLI → report until you stop it. Plain Node/TypeScript, no Next.js coupling, and no shared app-code coupling — this directory is a self-contained package you can npx on any fresh machine.

The machine operator selects Claude Code, OpenAI Codex, Google Gemini CLI, OpenCode, or an Agent Client Protocol (ACP) agent when connecting a worker. Any selected agent can run any project task; task eligibility depends on capabilities and project gates, not agent type — see "Choosing an agent" below.

See docs/agent-platform-project-plan.md §3.8/§3.9/§3.11 and docs/navarch/implementation-plan.md WP-07 for the design this implements, and docs/navarch/schema-design.md §7 for the API contract.

Quick start on a fresh machine

For a project machine, use the command generated by Onboard → Connect an agent or Project settings → Connect an agent. It includes a single-use, project-scoped enrollment token and the correct control-plane origin. The recommended npm package requires Node.js 20 or later.

Before generating the command, prepare the machine:

  • install Node.js 20 or later (the standard installation includes npm and npx) and Git;
  • install the coding-agent CLI selected in Navarch — claude, codex, gemini, opencode, or dsh for the default ACP integration — and complete its normal authentication flow; and
  • for Docker-backed sessions, install and start Docker and use an image that contains the selected agent CLI and the repository toolchain, with provider credentials supplied through the approved machine or project configuration.

The generated command installs/runs only the Navarch runtime. It does not install Git, Docker, or a coding-agent CLI, and it cannot sign in to the agent provider for you. Verify the host path before enrollment (substitute the CLI selected in the UI):

node --version       # 20 or later
npm --version
npx --version
git --version
codex --version      # or: claude / gemini / opencode / dsh --version

Every command above must succeed before the long-running worker starts. See Choosing an agent for runtime-specific environment variables and isolation requirements.

# Run the two commands copied from Navarch. Their values resemble:
npx --yes @sagentlab/navarch-runtime@latest connect \
  --token flmt_<single-use-token> --project <project-id> \
  --name <machine-name> --agent codex \
  --capabilities shell,browser-use --max-sessions 5 \
  --api-base https://www.sagentlab.com --config-dir ~/.navarch/<project-slug>
npx --yes @sagentlab/navarch-runtime@latest supervise \
  --config-dir ~/.navarch/<project-slug>

Keep the same config directory for connect, supervise, doctor, and later upgrades. connect stores the machine identity in <config-dir>/machine.json with mode 0600; the raw machine token is not shown again. Do not copy that file or the enrollment command into logs.

In another terminal, verify the machine without starting a second worker:

npx --yes @sagentlab/navarch-runtime@latest doctor \
  --config-dir ~/.navarch/<project-slug>

For the BYO-key sandbox path, follow the generated Claude/Docker command exactly. Docker must be running, and NAVARCH_DOCKER_IMAGE must identify an image containing Git, Node.js 20 or later, Claude Code 2.1.83 or later, and the build/test tools required by the repository. The current default node:20-slim image does not satisfy those requirements; a first-party image is tracked in issue #229. The complete public flow is in the Navarch quickstart.

Install from source

Use this path for runtime development or when validating an unreleased runtime change:

git clone https://github.com/sagentlab/navarch.git
cd navarch/runtime
./install.sh
node bin/navarch.cjs connect <options copied from Navarch>
node bin/navarch.cjs supervise --config-dir ~/.navarch/<project-slug>

The installer checks Node, installs the locked package dependencies, and builds the runtime. The UI-generated npm commands remain the source of truth for the token, project, agent, capacity, API origin, and config directory; translate those same options to node bin/navarch.cjs for a source install.

Releasing a runtime change

Publication is triggered by the version, not by the code: a push to main that changes runtime/package.json publishes that exact version of @sagentlab/navarch-runtime and rolls it out. So a pull request that edits runtime/src/** or runtime/bin/** without bumping the version merges green and never reaches a machine. Bump runtime/package.json and runtime/package-lock.json to the same new version in the pull request that carries the change; CI's Runtime job fails the pull request when that bump is missing, when the two manifests disagree, or when the version is one the base branch tip or npm already carries.

playbooks/runtime-releases.md is the full release contract — the guard's exact cases, the post-merge duplicate backstop, and the recovery steps.

The operator-only register command enrolls a globally managed machine. It is not the normal public onboarding path. register prints the machine auth token once and stores the same machine.json identity used by connect. For twelve-factor deployments (systemd EnvironmentFile, container secrets), set NAVARCH_MACHINE_TOKEN and NAVARCH_MACHINE_ID directly.

Connecting an agent to one project

connect is the project-scoped sibling of register — "Connect an agent to a project" (self-hosted-runner style, like a GitHub Actions self-hosted runner token): a project owner mints a single-use token from the platform UI (the project settings page's "Connect an agent" panel, or the onboarding wizard's Agent step), POST /api/projects/:id/enrollment-tokens, and pastes you the ready-to-run command. Unlike register, no NAVARCH_ENROLLMENT_SECRET or --owner-zone is needed — the token is already scoped to exactly one project, and the resulting machine's project_bindings is set to that project only (it will never be dispatched work from any other project).

npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
    --name my-agent-1 --agent codex --api-base https://www.sagentlab.com \
    --config-dir ~/.navarch/my-agent-1
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/my-agent-1

(@sagentlab/navarch-runtime is published to npm, so npx @sagentlab/navarch-runtime <cmd> works on a fresh machine with no clone. The from-source flow — git clone + ./install.sh + node bin/navarch.cjs <cmd> — remains available for local development.)

Commands

| Command | Purpose | |---|---| | register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] […] | Registers this machine, saves its local agent choice, and prints the token once. | | connect --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] [--project <id>] […] | Connects this machine to one project, saves its local agent choice, and prints the token once. | | start [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] | Runs the daemon. A start-time agent choice overrides the saved choice. | | supervise [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. | | doctor | Prints resolved config + Docker/registration status; no side effects. |

Running multiple agents on one machine

Every agent instance must have its own config directory. The directory contains the machine identity, update state, managed runtime versions, and (by default) session workspaces. Reusing it lets a later connect replace machine.json and causes both processes to race over update and workspace state.

Pass the same --config-dir to connect and supervise for each instance:

npx @sagentlab/navarch-runtime connect <pebble enrollment options> \
  --name pebble-agent-2816 --agent codex --config-dir ~/.navarch/pebble
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/pebble

npx @sagentlab/navarch-runtime connect <tobi enrollment options> \
  --name tobi-agent-1521 --agent codex --config-dir ~/.navarch/tobi
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/tobi

NAVARCH_CONFIG_DIR remains equivalent for service managers and environment files. A supervisor also pins its starting identity into replacement workers, so an accidental later edit to machine.json cannot change that running instance during an automatic-update handoff.

Active-task guidance

Guidance added to a task that is already running is delivered on that task's next lease heartbeat (every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS, five minutes by default). The runtime stops the current agent process and starts a new agent turn with the original task context plus all guidance received so far. This is a turn restart, not a new dispatch:

  • The task keeps the same session and lease, and lease heartbeats continue.
  • The replacement turn uses the same worktree (and the same sandbox container in Docker mode), so committed and uncommitted changes from the interrupted turn remain available. It should inspect those changes before continuing.
  • The replacement turn is not a resumed agent CLI conversation. The corrected prompt carries the prior task context and guidance instead.
  • Transcript, token, and cost accounting is cumulative across turns: the upload includes every turn under a separate label, and reported token and cost totals include every turn. The lease is completed only after the corrected turn finishes.

The daemon logs restarting the agent turn in the same worktree when it delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive sooner, but keep it comfortably below the 60-minute lease TTL.

Temporary DNS, connection, timeout, rate-limit, and server failures during a lease heartbeat are retried at the configured heartbeat interval while the last server-confirmed lease remains valid. A separate expiry timer aborts the agent at that deadline even if a renewal request is still pending. Successful renewals move the deadline; transport failures never extend it. Terminal lease rejections still abort immediately. This tolerance applies to lease heartbeats; completion and transcript upload failures retain their existing behavior.

Long-running jobs and recovery

There is no default total-duration cutoff. Successful lease heartbeats renew ownership independently of agent progress, so a healthy job can run for hours. The default watchdog instead requires 45 minutes of observed inactivity before stopping an agent. Stdout/stderr, changing CPU time, and changes in the child process tree restart that window. Silent CPU-intensive builds and tests count as active. On Unix hosts, agents use a separate process group so cancellation also stops their tools; Docker sessions stop their dedicated container.

The watchdog samples ps on the host or docker top for a container every 30 seconds. CPU activity is a liveness heuristic, not proof of useful progress: a busy loop or repeated output can keep the job alive. Quiet external waits may need a larger idle window or idle detection disabled. If process telemetry is unavailable, silence alone does not trigger an idle kill; an explicitly configured absolute limit and lease expiry still apply.

Operators can set the environment defaults above. Existing launch commands that explicitly set NAVARCH_SESSION_TIMEOUT_MS (including older generated Docker setup commands) retain that cap; remove it or set it to 0 to adopt unlimited total runtime. Task creators/editors can set max_runtime_ms and idle_timeout_ms through task creation, PATCH, or the task MCP tools. A nonnegative integer overrides the machine default, 0 disables that limit, and null inherits it. Values are read at the next claim and apply to machine-runtime sessions; the separate managed hosted executor retains its own budgets. An absolute limit covers all guidance/remediation turns within the claim, rather than resetting with each turn. These are runtime limits, not provider spending caps.

After a watchdog stop or lease loss during a turn, before worktree cleanup, the runtime attempts a private local recovery checkpoint at <workspaceRoot>/checkpoints/<project>/<task>/<session>/. It contains a Git bundle of committed history, a binary patch for dirty tracked files, untracked non-ignored files, and recovery instructions. The bundle excludes repository configuration and broker credentials. Checkpoints are not pushed or uploaded; timeout reports and runtime logs name their location. They survive session cleanup and need manual recovery and removal on that machine. Ignored files, agent conversation state, and checkpoints across machine/disk loss are not provided.

If checkpoint creation fails, the original working copy is retained instead of being force-deleted. The runtime removes session metadata and broker credentials, detaches the working copy without discarding dirty files, and writes a recovery marker. The janitor skips marked directories; a retry refuses to delete a marked working copy if detachment failed. Recover/remove it manually. Container teardown has a bounded timeout, and cancellation also stops the local Docker client, so an unresponsive daemon cannot keep the session waiting indefinitely.

Automatic and manual runtime upgrades

Run long-lived agents with navarch-runtime supervise. The worker advertises its package version and updater protocol on each machine heartbeat. When the control plane assigns an eligible release, it:

  1. Downloads the exact @sagentlab/navarch-runtime@<version> tarball with npm lifecycle scripts disabled and verifies the control-plane-recorded SHA-512 integrity.
  2. Stages it under $NAVARCH_CONFIG_DIR/versions/<version> while existing sessions continue.
  3. Stops claiming, reports zero available capacity, and waits for both an in-flight claim and every active session to finish.
  4. Atomically writes pending-update.json and exits with the supervisor-only handoff code. The supervisor starts the staged binary with the same config directory and machine identity.
  5. Commits the activation after the replacement completes its first successful heartbeat, persisting active-runtime.json so machine restarts keep using the verified release. A crash or two-minute health timeout before then restores the previous binary.

The control plane never supplies a package name, URL, path, or shell command: only an exact semver and npm SHA-512 integrity for the fixed package name are accepted. Rollouts are assigned by the machine's server-managed stable or canary channel and deterministic rollout percentage. Set NAVARCH_AUTO_UPDATE=off to keep telemetry but disable automatic activation.

Project owners can also request a same-version restart from the project Agents page. Only a worker running under navarch-runtime supervise advertises this capability. The restart command arrives on the normal machine heartbeat; the worker stops claiming, reports zero capacity, drains active sessions, and exits with a restart-only handoff code. The supervisor respawns the current verified binary without staging an update. The control plane acknowledges the one-shot command only after a heartbeat reports a different boot ID, preventing a stale command from causing a restart loop.

Plain navarch-runtime start continues to work and reports available releases, but deliberately does not activate them because no parent process would exist to perform a safe handoff or rollback.

After publishing a runtime, an operator records the immutable npm metadata in runtime_releases. Obtain the integrity with npm view @sagentlab/navarch-runtime@<version> dist.integrity, then insert the exact version, returned integrity, release channel, and desired rollout percentage. Start with channel='canary' or a small rollout_percent; raising the percentage keeps existing machines in the same deterministic cohort.

Manual fallback

SIGINT or SIGTERM now stops new claims and drains active sessions before exiting. A second signal forces an immediate exit and can interrupt work, so use it only when abandoning the active leases is intentional.

Use this sequence for an upgrade:

  1. Install or build the new runtime without stopping the existing process. For a source checkout, update the checkout and run npm ci && npm run build inside runtime/. For an npm deployment, select the exact version in the service command, for example npx --yes @sagentlab/navarch-runtime@<version> start.
  2. In Navarch's Fleet view, wait until this machine shows Sessions: 0/N. Check the daemon log once more for a newer claimed task message before proceeding. If it claimed another task, let that session finish too.
  3. Stop the old process through its service manager, or send SIGTERM/press Ctrl-C. Do not use SIGKILL (kill -9). Ensure the old process has exited before starting its replacement so two claim loops never run for one machine identity.
  4. Run doctor using the same service environment and NAVARCH_CONFIG_DIR, then start the new version with that same environment. Do not run register or connect again: the existing machine.json contains the machine identity and token needed after the upgrade. Never print, log, or copy the contents of machine.json or its token into upgrade commands or diagnostics.
  5. Confirm the startup log reports the expected machine, agent, and API base, then verify that Fleet shows the machine online with a fresh heartbeat.

For a supervised service, make the stop timeout long enough for step 3 to observe a normal exit, and keep the service's environment file/config directory unchanged across the deployment.

Configuration (NAVARCH_* env vars)

| Var | Default | Meaning | |---|---|---| | NAVARCH_API_BASE | http://localhost:3000 | Control-plane base URL. | | NAVARCH_MACHINE_TOKEN / NAVARCH_MACHINE_ID | — | Skip register/the config file; twelve-factor auth. | | NAVARCH_MACHINE_NAME | — | Used by register. | | NAVARCH_ENROLLMENT_TOKEN | — | Alternative to register --token / connect --token. | | NAVARCH_PROJECT_ID | — | Alternative to connect --project. | | NAVARCH_CONFIG_DIR | ~/.navarch | Per-instance state root. Equivalent to --config-dir; use a different directory for every agent on the same host. | | NAVARCH_WORKSPACE_ROOT | <config dir>/sandboxes | Persistent bare repo caches plus isolated per-session worktrees. | | NAVARCH_MAX_SESSIONS | 5 | Local concurrent-session capacity cap — see src/capacity.cts. | | 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. | | NAVARCH_OWNER_ZONE | sagentlab | sagentlab or customer-<slug>-premises (project-plan.md §3.11). | | NAVARCH_POLL_INTERVAL_MS | 30000 | Claim-loop poll interval. | | NAVARCH_HEARTBEAT_INTERVAL_MS | 60000 | Machine-level heartbeat interval. | | NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS | 300000 | Per-lease heartbeat interval; must stay well under the 60-minute lease TTL (schema-design.md §4). | | NAVARCH_SESSION_TIMEOUT_MS | 0 (disabled) | Optional absolute runtime budget across agent turns in a claimed session. | | NAVARCH_SESSION_IDLE_TIMEOUT_MS | 2700000 (45 min) | Stop after a continuously observed window with no stdout/stderr or process-tree CPU activity. 0 disables idle detection. | | NAVARCH_OPENCODE_STARTUP_TIMEOUT_MS | 120000 (2 min) | Stop OpenCode when it produces no stdout/stderr during startup. This catches provider auth/quota failures that leave its CLI process alive. 0 disables the startup deadline. | | NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS | 3600000 (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. | | NAVARCH_WORKTREE_STALE_AFTER_MS | 86400000 (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. | | NAVARCH_GIT_AUTHOR_NAME / NAVARCH_GIT_AUTHOR_EMAIL | sagentlab / [email protected] | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. | | NAVARCH_SANDBOX_MODE | host | host uses the resources already available to the agent process. Set docker explicitly for container isolation. | | NAVARCH_SANDBOX_PROFILE | trusted-development | Named security profile for Docker sessions (src/sandbox-profile.cts): trusted-development (image-default user, uncapped, open egress), untrusted-code (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), elevated-verification (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. | | NAVARCH_SANDBOX_EGRESS_NETWORK | (unset) | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to --network=none and records the denial. | | 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. | | NAVARCH_AGENT | saved choice, then claude-code | Local choice of agent adapter: claude-code, codex, gemini, opencode, acp, or qoder. Overrides the choice saved by connect/register; start --agent has highest priority. | | NAVARCH_RUNTIMES | resolved local agent choice | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. | | NAVARCH_UPDATE_CHANNEL | stable | Release channel advertised by the worker (stable or canary); the server-managed machine channel remains authoritative. | | 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. | | NAVARCH_CLAUDE_BIN | claude | Path/name of the Claude Code CLI binary. | | 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. The generated host-mode settings retain only the user-level apiKeyHelper needed for authentication; supply --setting-sources=<sources> here to opt into other settings deliberately. | | NAVARCH_CODEX_BIN | codex | Path/name of the Codex CLI binary. | | NAVARCH_CODEX_EXTRA_ARGS | — | Comma list of extra CLI args appended after the generated MCP -c overrides and --json (Codex). | | NAVARCH_GEMINI_BIN | gemini | Path/name of the Google Gemini CLI binary. | | NAVARCH_GEMINI_EXTRA_ARGS | — | Comma list of extra CLI args appended after the generated MCP settings, stream-json, and unattended defaults (Gemini). | | NAVARCH_OPENCODE_BIN | opencode | Path/name of the OpenCode CLI binary. | | NAVARCH_OPENCODE_EXTRA_ARGS | — | Comma list of extra CLI args appended after run, the prompt, and the generated --format json argument. Explicit --format, --model, or --variant values replace the corresponding per-session default. | | NAVARCH_ACP_BIN | dsh | Path/name of an Agent Client Protocol v1 stdio server. DeepSeek Harness is the default implementation. | | NAVARCH_ACP_EXTRA_ARGS | --profile,acp | Comma list of arguments used to start the ACP server. Override this together with NAVARCH_ACP_BIN for another ACP-compatible coding agent. | | NAVARCH_QODER_BIN | qoder | Path/name of the Qoder CLI binary. | | NAVARCH_QODER_EXTRA_ARGS | — | Comma list of extra CLI args appended after the unattended defaults (--setting-sources, --permission-mode auto, --output-format json). An explicit --output-format, --permission-mode, --settings, --model, or --reasoning-effort here replaces the per-session default. Qoder selects its own account models; pin one via --model here or connect --model to override. | | NAVARCH_MCP_CONFIG_PATH | — | Path to the platform MCP config passed as --mcp-config. | | NAVARCH_WORKTREE_GUARD | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set off to disable it for every runtime on the machine — required to run OpenCode on the host. | | 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. |

Worktree boundary guard (host mode)

A machine typically runs several sessions concurrently (NAVARCH_MAX_SESSIONS), each in its own git worktree. The runtime enforces the same boundary for all supported coding agents, using each CLI's native enforcement point:

Every adapter receives an empty, session-owned GH_CONFIG_DIR and, when the project supplies GitHub credentials, the lease-scoped GITHUB_TOKEN. This keeps gh independent of the operator's selected account and prevents tasks from reading unrelated GitHub credentials. Docker already mounts the session metadata directory; Gemini's nested host sandbox receives the gh directory as an explicit read-only mount.

  • Claude Code: the runtime uses --permission-mode auto, which sends approval decisions through Claude Code's background safety classifier. It does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated settings file (src/worktree-guard.cts, passed as --settings) also installs bin/worktree-guard-hook.cjs as a fail-closed PreToolUse boundary hook. User, project, and local settings sources are disabled by default, preventing host-only hooks and plugins from leaking into unattended sessions; the explicit generated settings file remains active and carries forward only a user-level apiKeyHelper when the machine uses one for authentication.
  • Codex: the runtime passes a one-off native permission profile with approval_policy="on-request" and approvals_reviewer="auto_review". Codex's OS sandbox grants read/write access only to the allowed roots and denies the surrounding multi-session workspace. Its network policy allows GitHub and GitHub-hosted content so authenticated gh and git operations required by the task stay inside the sandbox. Other destinations remain blocked and eligible escalations are decided by the automatic reviewer rather than waiting for human input. --ignore-user-config and an untrusted project-config override prevent a user or checked-in legacy sandbox_mode from silently disabling the generated profile; Codex authentication still comes from CODEX_HOME, and repository instructions such as AGENTS.md still load. On macOS the profile also grants read access to /Library/Developer and /Applications (Xcode and CoreSimulator, which xcrun simctl and xcodebuild load) and write access to ~/Library/Developer (simulator devices and DerivedData) and ~/Library/Caches/org.swift.swiftpm (SwiftPM repository, artifact, metadata, and manifest caches); the Claude hook gets the same write roots. Without these, iOS builds and simulator tests fail under the sandbox with access to /Library/Developer/PrivateFrameworks/CoreSimulator.framework is blocked.
  • Gemini: the runtime uses Gemini CLI's native sandbox with only the current worktree, shared gitdir, session gh config, and approved extra roots mounted. The gh config and lease MCP settings are read-only; header values remain environment-variable references rather than literals. Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting it inside Navarch's already isolated session container.
  • OpenCode: the CLI cannot currently express a host-side boundary for all shell side effects, so with the guard enabled it is resolved out of the machine's advertised runtimes at startup — and a machine that offers only OpenCode there refuses to start rather than claim tasks it can only fail. Run OpenCode in Docker with an operator-owned image that contains an authenticated opencode binary, or set NAVARCH_WORKTREE_GUARD=off only when the whole machine is already isolated (the opt-out is machine-wide: every runtime on it loses the boundary). OpenCode receives a private, per-session config, project config discovery is disabled, and lease MCP headers are referenced through child-only environment variables instead of being copied into its config file or argv.
  • ACP agents: the protocol client rejects every unattended permission escalation. The agent remains responsible for enforcing its declared file sandbox; use Navarch Docker mode when the ACP server itself does not provide a trustworthy workspace boundary.

The resulting boundary is:

  • File tools (Read/Write/Edit/Glob/Grep/...) may only touch the session worktree, the project's shared bare repo, temp dirs, and any NAVARCH_GUARD_EXTRA_ROOTS. Read-only tools may additionally read standard system prefixes (/usr, /etc, ...). Symlinks are resolved before the containment check.
  • Bash commands are screened lexically: absolute, ~/$HOME, and ..-traversal path references must land inside the allowed roots or the system prefixes.
  • The rest of the workspace root — sibling sessions' worktrees, other projects' bare repos, and the session's own metadata dir (lease-scoped MCP config, the guard files themselves) — is denied outright. The only metadata exception is the empty, read-only gh config directory described above, so an agent can neither read another agent's checkout nor rewrite its own guard policy.

The Claude hook is a strong guardrail rather than a hard security boundary because shell paths are screened lexically. Codex's permission profile is enforced by its OS sandbox. For container-grade whole-process isolation use NAVARCH_SANDBOX_MODE=docker; neither host guard is installed in Docker mode. An operator-supplied --settings in NAVARCH_CLAUDE_EXTRA_ARGS, or an explicit Codex permission/sandbox option in NAVARCH_CODEX_EXTRA_ARGS, takes precedence over the generated policy.

Claude auto mode requires Claude Code 2.1.83 or later and an eligible account, model, and first-party Anthropic API provider. If those requirements are not met, Claude Code rejects auto mode instead of silently bypassing checks.

The auto-review launch paths were verified live on 2026-07-20 with Claude Code 2.1.214 (--permission-mode auto) and Codex CLI 0.144.1 (the generated permission profile plus approvals_reviewer="auto_review"). Both completed an unattended smoke task successfully.

Choosing an agent

Each worker advertises the agent CLIs it can actually execute. Existing single-runtime installs keep using --agent/NAVARCH_AGENT; multi-runtime workers set NAVARCH_RUNTIMES to the installed and authenticated adapters:

# Claude Code (default) — requires the `claude` CLI installed and
# authenticated on this machine (or NAVARCH_CLAUDE_BIN pointing at it).
export NAVARCH_AGENT=claude-code

# OpenAI Codex — requires the `codex` CLI installed and authenticated on
# this machine (or NAVARCH_CODEX_BIN pointing at it), analogous to the
# Claude Code prerequisite above.
export NAVARCH_AGENT=codex

# Google Gemini CLI — requires an authenticated `gemini` CLI (or
# NAVARCH_GEMINI_BIN pointing at it).
export NAVARCH_AGENT=gemini

# OpenCode — requires an authenticated `opencode` CLI (or
# NAVARCH_OPENCODE_BIN pointing at it). A guarded host refuses to start with
# only this runtime; use a Docker image containing OpenCode for the normal
# isolated path, or NAVARCH_WORKTREE_GUARD=off on an isolated machine.
export NAVARCH_AGENT=opencode

# Agent Client Protocol — defaults to DeepSeek Harness `dsh --profile acp`.
# Override NAVARCH_ACP_BIN / NAVARCH_ACP_EXTRA_ARGS for another ACP v1 server.
export NAVARCH_AGENT=acp

# Qoder — requires an authenticated `qoder` CLI (or NAVARCH_QODER_BIN pointing
# at it). Model selection is account-owned; NAVARCH_QODER_EXTRA_ARGS or
# `connect --model` can pin one explicitly.
export NAVARCH_AGENT=qoder

# Advanced compatibility mode: advertise every installed adapter. On a guarded
# host, `opencode` is dropped from this list (see the boundary notes above);
# the rest are advertised as written.
export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp,qoder

For the legacy single-runtime setting, priority is start --agent → NAVARCH_AGENT → the locally saved choice → claude-code. NAVARCH_RUNTIMES expands what the worker advertises. For normal projects, the locally selected NAVARCH_AGENT handles every claimed task. Sandbox projects remain constrained to Claude Code.

All five BYO adapters implement the same AgentAdapter interface (src/adapters/types.cts) and run either directly on the host or via docker exec in the session's sandbox container, exactly like the Claude adapter always has — session.cts picks one (src/adapters/index.cts's selectAdapter) at the start of each session and passes agent_type through to complete() unchanged by whatever happened during the run.

The control plane also resolves the project's model and the task's execution profile on every claim. The runtime passes those values as per-session CLI overrides (codex exec --model ... -c model_reasoning_effort=..., claude -p --model ... --effort ..., gemini --model ..., or opencode run --model ... --variant ...) and records the effective model, profile, and effort on completion. Gemini currently uses its auto model default and does not expose a reasoning-effort flag. OpenCode accepts any provider/model reference (the platform default is opencode/x-preview-f-free, Ox Alpha Free (Unlimited) on OpenCode Zen), or default to preserve the authenticated account's selection. ACP sessions use the agent's advertised default model and set the standard reasoning_effort configuration option when it is available. Machine-wide extra arguments still configure other CLI behavior; dispatched model policy wins.

Codex defaults to gpt-6-astra; Claude Code defaults to claude-opus-5-5. Explicit project and steering/navarch.yaml model choices still win. A task's explicit execution profile wins over the repository's per-task-type profile. Otherwise, claim-time classification uses the task summary and metadata:

  • Clearly scoped typo, spelling, link, wording, or label fixes use fast / low.
  • Planning, retries, video work, tasks with at least three dependencies, production operations, and descriptions indicating architectural or sensitive changes use complex / high.
  • Ambiguous work uses standard / the project effort baseline (medium by default).

The classifier is deterministic and conservative; it never chooses critical or deep automatically. Set an explicit profile to override its assessment. Existing explicit task profiles remain explicit.

Explicitly selected Fable sessions pass --fallback-model claude-opus-5-5 for Claude Code's native availability fallback. Because native fallback excludes usage limits, a structured 429 usage-limit rejection gets one additional Opus 5.5 attempt within the original timeout. It resumes the saved conversation, or starts afresh only for a confirmed zero-turn rejection. Missing transcripts after possible work, authentication/policy errors, cancellation, and explicit fallback/session arguments do not trigger that retry. If Opus 5.5 is also limited, the usual machine cooldown applies. The runtime records the fallback in the transcript and reports Opus for the retry and subsequent turns. Native multi-model turns still report the requested primary model; their aggregate provider-reported usage includes both models. Selecting Fable requires a Claude Code version supporting Fable 5.1 (v2.1.255 or later).

Astra token cost reporting uses a standard short-context API-equivalent estimate from OpenAI pricing, not the account's subscription charge or long-context/fast-mode uplifts.

The Codex CLI invocation was verified against codex-cli 0.144.1 on 2026-07-18. The runtime uses codex exec "<prompt>" --json and translates the existing per-session MCP JSON into one-off -c mcp_servers.* overrides. Machine and lease credentials are referenced through environment variables, not placed in argv. The JSONL parser accepts the verified top-level item.completed / turn.completed shape and retains the older msg envelope as a compatibility fallback.

The Gemini adapter uses official headless --prompt and --output-format stream-json flags. It resets pre-tool assistant text, retains the final post-tool answer as the report, and reads aggregate input_tokens and output_tokens from the terminal result event. Gemini does not report USD cost, so an unknown value remains absent rather than becoming zero.

The OpenCode adapter uses opencode run "<prompt>" --format json, closes stdin immediately, and normalizes text and step_finish JSONL events into the shared report/token/cost boundary. It translates the lease MCP document to OpenCode's remote-server config with OAuth disabled and environment-backed headers. Missing accounting fields stay absent rather than becoming a fabricated zero.

OpenCode also runs with --print-logs --log-level ERROR by default (see its CLI flags), so provider errors that are missing from assistant messages still reach the runtime. A rate or usage limit in a provider error event or stream-error log stops the host process or Docker container immediately. The runtime checkpoints local work, records the error, and releases the lease with a failed session outcome. Pending guidance does not restart that session. New claims on this machine pause until the reported reset time (including relative resets such as “in 23 days”), or for 15 minutes when no reset time is available.

The ACP adapter speaks the standard Agent Client Protocol v1 JSON-RPC transport over stdio. It creates one protocol session in the task worktree, converts Navarch's HTTP/stdio MCP entries, consumes semantic message, thought, and usage updates, and fails permission requests closed because no human is attached to a worker turn. DeepSeek Harness ships that server as dsh --profile acp. This is intentionally not the REST-based Agent Communication Protocol, which is a different protocol now maintained as part of A2A.

Architecture

cli.cts
 ├─ register            → api.registerMachine()      → machine-store.cts (writes machine.json once)
 ├─ connect             → api.connectMachine()       → machine-store.cts (writes machine.json once)
 ├─ start
     ├─ MachineHeartbeatLoop   (heartbeat-loop.cts)   → api.machineHeartbeat()   [every NAVARCH_HEARTBEAT_INTERVAL_MS]
     └─ ClaimLoop              (claim-loop.cts)        → api.claim()             [every NAVARCH_POLL_INTERVAL_MS, gated by CapacityTracker]
          └─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
               1. write prompt.md (prompt.cts renders the 4-layer context bundle)
               2. api.issueSecrets()                  → held in memory only
               3. fetch the project's bare repository cache and create a
                  unique git worktree for this session; optionally mount it
                  into Docker when NAVARCH_SANDBOX_MODE=docker
               4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
                  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
                    - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
                    - codexAdapter      (adapters/codex.cts)  — `codex exec <prompt> --json -c mcp_servers.*=...`
                    - geminiAdapter     (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
                    - openCodeAdapter   (adapters/opencode.cts) — `opencode run <prompt> --format json`
                    - acpAdapter        (adapters/acp.cts) — ACP v1 JSON-RPC/stdio; defaults to `dsh --profile acp`
                    - qoderAdapter      (adapters/qoder.cts) — `qoder -p <prompt> --output-format json`
                  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
                  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
               5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
               6. api.completeLease(), reporting agent_type: config.agentType
               7. sandbox.wipe() when present; remove the session workspace
                  unconditionally (finally block)
 └─ supervise                  → supervisor.cts starts the worker with IPC
      └─ update directive      → update-installer.cts stages exact npm release
           └─ drain + exit 75  → supervisor activates candidate, waits for
                                 healthy heartbeat, or rolls back

adapter.cts (top-level) is now a backward-compat re-export of adapters/claude.cts — new code should import adapters/index.cts (for selectAdapter) or an adapter module directly.

api.cts is the single choke point for every HTTP call; nothing else in the package talks to fetch directly for control-plane traffic.

Secret handling

  • Task secrets come from POST /api/broker/issue once, at session start, and are held only in memory (session.cts) — never written to the host filesystem.
  • Inside the sandbox, DockerSandbox.injectEnv() writes KEY=value lines to a tmpfs-backed file (/tmp/session.env) over docker exec -i stdin — never a docker run -e flag (visible via docker inspect) and never a host-side file. The container's /tmp and /run are tmpfs, so nothing persists past wipe().
  • cloneRepo() wires a git credential helper that echoes $GITHUB_TOKEN — the literal token value never appears as a literal string in any argv the host's ps can see; only the variable reference does.
  • Before upload, redact.cts scrubs the transcript against every value the session's SecretRegistry actually saw, plus pattern-based fallbacks (GitHub PAT shapes, PEM private keys, generic sk-... tokens) as defense-in-depth for values the registry didn't see directly.

Control-plane contracts

schema-design.md §7 and the matching control-plane routes define every HTTP contract used by api.cts, including:

  1. POST /api/machines/register — global machine registration via the operator-configured enrollment secret.
  2. POST /api/machines/:id/heartbeat — a machine-level heartbeat distinct from the per-lease heartbeat, needed because machines.last_heartbeat_at /status must update even when no task is claimed.
  3. POST /api/dispatch/:leaseId/transcript-upload-url — a signed R2 upload URL for the session's redacted transcript. The bucket is private; the legacy public_url response field is only an attested locator returned at completion, not an anonymous read URL.

POST /api/machines/connect is the project-scoped alternative: it redeems a single-use token minted by an owner through the onboarding or Fleet UI.

Machine-authenticated routes answer a rejected identity in three distinct ways, and the runtime treats them differently:

  • 503 — the control plane could not complete the token lookup. Transient; retried like any other failure. (Before this split a failed lookup came back as 401, so one database blip looked exactly like a revoked token.)
  • 401 — the token is not recognized.
  • 410 — this agent was removed from the fleet.

Three consecutive 401/410 heartbeats end the run: the runtime stops claiming, lets active sessions finish, prints how to re-enroll, and exits 78. The supervisor does not respawn on that code — a new process cannot fix a credential the control plane no longer honors.

What needs live verification

Most runtime behavior is covered offline. The Codex host adapter was also probed against a real authenticated codex-cli 0.144.1; Docker and the full production control-plane lifecycle still require live verification. Unit tests cover:

  • api.cts — request/response shapes, error mapping, auth header handling (tests/api.test.cts).
  • exit-conditions.cts — every exit-condition → complete/fail mapping, priority order, plus Claude's and Codex's usage/report parsing (tests/exit-conditions.test.cts).
  • redact.cts — exact-value and pattern-based redaction (tests/redact.test.cts).
  • capacity.cts — capacity math and acquire/release bookkeeping (tests/capacity.test.cts).
  • config.cts — env var parsing and defaults for every selectable adapter (tests/config.test.cts).
  • sandbox.cts — command construction (flags, env-via-stdin, credential-helper argv hygiene) against an injected fake CommandRunner, plus isDockerAvailable() degrading to false instead of throwing when Docker is absent (tests/sandbox.test.cts).
  • adapters/index.cts#selectAdapter — picks the right AgentAdapter for every NAVARCH_AGENT value, including the claude-code fallback for an unrecognized one (tests/adapters/index.test.cts).
  • adapters/codex.cts — arg construction on both the host path (mocked spawn) and the docker-exec path (fake CommandRunner), and usage/report-text attachment from fixed JSONL fixtures (tests/adapters/codex.test.cts).
  • adapters/gemini.cts — host/Docker invocation, cancellation, sandbox/MCP credential hygiene, parser drift, and normalized report/usage extraction (tests/adapters/gemini.test.cts).
  • adapters/opencode.cts — real fake-binary invocation covering the prompt, isolated config and MCP header references, JSONL normalization, timeout, lease cancellation, and fail-closed host boundary (tests/adapters/opencode.test.cts).
  • adapters/types.cts — the shared process-result boundary is exercised for Claude, Codex, Gemini, and OpenCode (tests/adapters/conformance.test.cts).
  • worktree-guard.cts + bin/worktree-guard-hook.cjs — generated settings/ config shape, native Codex permission-profile construction, and the hook's containment verdicts (in-worktree vs. sibling session vs. home dir, symlink escapes, Bash path screening, the fail-closed exit-2 protocol run as a real subprocess) (tests/worktree-guard.test.cts, tests/worktree-guard-hook.test.cts).

Not exercised by unit tests, and needing a real machine per the WP-07 DoD ("on a real machine: register → claim a seeded docs task → session runs Claude Code headless → PR opens on GitHub → complete lands with evidence; secrets absent from disk after exit"):

  • Actually running claude -p headless and confirming its real --output-format json shape (WP-13). adapters/claude.cts now appends --output-format json by default and best-effort parses stdout as a single JSON result object shaped like { type, subtype, is_error, result, session_id, total_cost_usd, usage: { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens } } (see exit-conditions.cts#parseClaudeJsonResult for the exact assumption and fallback behavior). Unit tests (tests/adapter.test.cts, tests/exit-conditions.test.cts) cover the parsing and arg-construction logic against fixed JSON strings; what's untested offline is whether a real claude -p --output-format json invocation actually produces this shape. If it doesn't, parsing degrades to null and tokensIn/tokensOut/ costUsd fall back to 0 (session.cts's existing default) — never a thrown error — but cost/spend data silently goes missing until the shape is reconciled here.
  • A full Codex task that initializes the production MCP server, edits a worktree, pushes a branch, opens a PR, and completes its lease. The CLI flags, per-run MCP override keys, stdin behavior, and JSONL event/usage shape are now verified locally; the next dogfood run covers their production composition.
  • A full Gemini task that uses an authenticated Gemini CLI to claim, edit, open a PR, report usage/evidence, and clean up its session sandbox.
  • A full OpenCode task in an operator-owned Docker image that contains an authenticated OpenCode CLI, including lease MCP access, cancellation, PR creation, attribution, and cleanup.
  • Whether Docker-mode Codex should opt into --dangerously-bypass-approvals-and-sandbox. It is intentionally not a default: host mode is not an external sandbox, and silently disabling Codex's protections there would be unsafe. Operators can still add the flag explicitly through NAVARCH_CODEX_EXTRA_ARGS on an isolated machine.
  • The full Docker sandbox lifecycle against a real Docker daemon (rootless behavior, tmpfs env injection, git clone with a real PAT, docker exec timeout/kill semantics under AbortSignal).
  • Lease-loss handling against a real dispatcher (heartbeat 404/409 on an already-reassigned lease) — session.cts aborts the running adapter process and still attempts a best-effort complete() call, which the real server may reject; that path is untested against real semantics.
  • Transcript upload against a real signed R2 URL.
  • The end-to-end "Connect an agent to a project" flow against a real Supabase instance: an owner minting a token from ConnectAgentPanel (or the onboarding wizard's Agent step), navarch-runtime connect redeeming it, and the resulting machine actually claiming a task scoped to that one project. Unit-tested here: connectMachine()'s request shape (tests/api.test.cts) and the connect command's flag/env parsing (tests/cli.test.cts); not tested here: the real Postgres round trip (lib/navarch/__tests__/enrollment.test.ts and the app/api/machines/connect / app/api/projects/[id]/enrollment-tokens route tests cover that with a mocked admin client, not a live database).
  • @sagentlab/navarch-runtime is published to npm — every npx @sagentlab/navarch-runtime ... command shown above (and in ConnectAgentPanel) resolves the published package directly; the from-source flow (git clone + ./install.sh + node bin/navarch.cjs connect ...) is an equivalent local-development alternative.

A note on .cts instead of .ts

Every source and test file in this package uses the .cts extension rather than .ts. This isn't a style preference: the repo root's tsconfig.json globs **/*.ts / **/*.mts into the Next.js app's own next build type-check, and that check runs across the whole matched file set, not just files reachable from a page — confirmed empirically while building this package (a single stray .ts file under runtime/ failed the root next build). WP-07's file ownership is scoped to runtime/ only, so editing the root tsconfig.json's include/exclude was out of bounds. .cts is a first-class TypeScript/Node extension (forces CommonJS output, still allows normal import/export source syntax) that the root glob does not match, making it a clean, zero-touch way to keep this package fully isolated. Vite/Vitest's default esbuild transform filter also excludes .cts by default — vitest.config.cts overrides it (esbuild.include) so tests are actually type-stripped and run.

Inspect code dependencies

navarch-runtime code-graph --query saveInvoice --mode impact --commit HEAD returns JSON with the indexed commit, checkout HEAD, dirty-worktree warning, source paths and line numbers, and bounded dependency edges. Modes are search, callers, callees, and impact; impact accepts --depth 1 through 5. Use an exact node ID to disambiguate duplicate symbol names. A file query includes its functions and file import dependencies. --repo defaults to the current working directory. The CLI works without a Navarch login or network access.

This first implementation analyzes committed JavaScript and TypeScript with the TypeScript compiler. It resolves static imports and named functions/methods, including imported aliases. It does not execute repository code, follow symlinks, load tsconfig plugins, or upload source. Root tsconfig path aliases are supported; extended configs, dynamic dispatch, external dependencies and other languages are not fully resolved. Warnings report omissions. No graph is an assurance that a change has no other effects: inspect the actual diff and run relevant tests.

Each invocation rebuilds from Git blobs at the requested commit, avoiding stale persistent indexes. Bounds are 2,000 files, 10 MB total source, 500 KB per file, 100 returned nodes and 300 edges. A later persistent index can optimize large repositories without changing the query format.

Pinning a connection model

connect --agent codex --model gpt-6-astra saves the model alongside the local machine identity. start and supervise reuse that model, including after worker restarts. Their optional --model flag overrides the saved choice. The pin wins over the project's session model for the selected adapter only; switching agent types does not reuse another adapter's pin. Reconnect without --model to return to project-controlled model selection. Older identities without a model keep using the control-plane execution policy.