@sagentlab/navarch-runtime 0.1.6 → 0.1.8

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
@@ -24,7 +24,7 @@ git clone <this repo> && cd sagentlab/runtime
24
24
  ./install.sh # checks node, npm install, npm run build
25
25
  export NAVARCH_API_BASE=https://navarch.example.com
26
26
  node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
27
- node bin/navarch.cjs start
27
+ node bin/navarch.cjs supervise
28
28
  ```
29
29
 
30
30
  Or, once dependencies are installed:
@@ -32,7 +32,7 @@ Or, once dependencies are installed:
32
32
  ```sh
33
33
  npm run build
34
34
  NAVARCH_API_BASE=https://navarch.example.com npm run register -- --token <enrollment-token> --name my-machine-1
35
- npm start
35
+ npm run supervise
36
36
  ```
37
37
 
38
38
  `register` is the one command that prints the machine's auth token — exactly
@@ -61,7 +61,7 @@ project only (it will never be dispatched work from any other project).
61
61
  ```sh
62
62
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
63
63
  --name my-agent-1 --agent codex --api-base https://navarch.example.com
64
- node bin/navarch.cjs start
64
+ npx @sagentlab/navarch-runtime supervise
65
65
  ```
66
66
 
67
67
  (`@sagentlab/navarch-runtime` is published to npm, so `npx
@@ -76,6 +76,7 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
76
76
  | `register --token <t> --name <n> [--agent claude-code\|codex] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
77
77
  | `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. |
78
78
  | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
79
+ | `supervise [--agent claude-code\|codex]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
79
80
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
80
81
 
81
82
  ## Active-task guidance
@@ -102,13 +103,49 @@ The daemon logs `restarting the agent turn in the same worktree` when it
102
103
  delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
103
104
  sooner, but keep it comfortably below the 15-minute lease TTL.
104
105
 
105
- ## Safely upgrading and restarting the daemon
106
-
107
- The daemon does not currently drain sessions during shutdown: `SIGINT` or
108
- `SIGTERM` stops new claims and heartbeats, then exits immediately. Stopping it
109
- with an active session can interrupt the agent before it reports completion;
110
- the control plane must then wait for the lease to expire and requeue the task.
111
- Do not use an ordinary daemon restart as a way to deliver guidance.
106
+ ## Automatic and manual runtime upgrades
107
+
108
+ Run long-lived agents with `navarch-runtime supervise`. The worker advertises
109
+ its package version and updater protocol on each machine heartbeat. When the
110
+ control plane assigns an eligible release, it:
111
+
112
+ 1. Downloads the exact `@sagentlab/navarch-runtime@<version>` tarball with npm
113
+ lifecycle scripts disabled and verifies the control-plane-recorded SHA-512
114
+ integrity.
115
+ 2. Stages it under `$NAVARCH_CONFIG_DIR/versions/<version>` while existing
116
+ sessions continue.
117
+ 3. Stops claiming, reports zero available capacity, and waits for both an
118
+ in-flight claim and every active session to finish.
119
+ 4. Atomically writes `pending-update.json` and exits with the supervisor-only
120
+ handoff code. The supervisor starts the staged binary with the same config
121
+ directory and machine identity.
122
+ 5. Commits the activation after the replacement completes its first successful
123
+ heartbeat, persisting `active-runtime.json` so machine restarts keep using
124
+ the verified release. A crash or two-minute health timeout before then
125
+ restores the previous binary.
126
+
127
+ The control plane never supplies a package name, URL, path, or shell command:
128
+ only an exact semver and npm SHA-512 integrity for the fixed package name are
129
+ accepted. Rollouts are assigned by the machine's server-managed `stable` or
130
+ `canary` channel and deterministic rollout percentage. Set
131
+ `NAVARCH_AUTO_UPDATE=off` to keep telemetry but disable automatic activation.
132
+
133
+ Plain `navarch-runtime start` continues to work and reports available releases,
134
+ but deliberately does not activate them because no parent process would exist
135
+ to perform a safe handoff or rollback.
136
+
137
+ After publishing a runtime, an operator records the immutable npm metadata in
138
+ `runtime_releases`. Obtain the integrity with
139
+ `npm view @sagentlab/navarch-runtime@<version> dist.integrity`, then insert the
140
+ exact version, returned integrity, release channel, and desired rollout
141
+ percentage. Start with `channel='canary'` or a small `rollout_percent`; raising
142
+ the percentage keeps existing machines in the same deterministic cohort.
143
+
144
+ ### Manual fallback
145
+
146
+ `SIGINT` or `SIGTERM` now stops new claims and drains active sessions before
147
+ exiting. A second signal forces an immediate exit and can interrupt work, so use
148
+ it only when abandoning the active leases is intentional.
112
149
 
113
150
  Use this sequence for an upgrade:
114
151
 
@@ -158,8 +195,10 @@ unchanged across the deployment.
158
195
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
159
196
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
160
197
  | `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. |
198
+ | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
199
+ | `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`. |
161
200
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
162
- | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). Supplying an explicit permission flag such as `--allowedTools` or `--permission-mode` replaces the unattended session's default `--dangerously-skip-permissions`. |
201
+ | `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`. |
163
202
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
164
203
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
165
204
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
@@ -172,13 +211,16 @@ A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
172
211
  each in its own git worktree. The runtime enforces the same boundary for both
173
212
  supported coding agents, using each CLI's native enforcement point:
174
213
 
175
- - **Claude Code:** permission bypass skips prompts but not hooks, so the
176
- runtime generates a Claude settings file (`src/worktree-guard.cts`, passed
177
- as `--settings`) that installs `bin/worktree-guard-hook.cjs` as a fail-closed
178
- `PreToolUse` hook.
214
+ - **Claude Code:** the runtime uses `--permission-mode auto`, which sends
215
+ approval decisions through Claude Code's background safety classifier. It
216
+ does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
217
+ settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
218
+ `bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
179
219
  - **Codex:** the runtime passes a one-off native permission profile with
180
- `--ask-for-approval never`. Codex's OS sandbox grants read/write access only
181
- to the allowed roots and denies the surrounding multi-session workspace.
220
+ `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
221
+ Codex's OS sandbox grants read/write access only to the allowed roots and
222
+ denies the surrounding multi-session workspace, while eligible escalations
223
+ are decided by the automatic reviewer rather than waiting for human input.
182
224
  `--ignore-user-config` and an untrusted project-config override prevent a
183
225
  user or checked-in legacy `sandbox_mode` from silently disabling the
184
226
  generated profile; Codex authentication still comes from `CODEX_HOME`, and
@@ -207,12 +249,14 @@ An operator-supplied `--settings` in `NAVARCH_CLAUDE_EXTRA_ARGS`, or an
207
249
  explicit Codex permission/sandbox option in `NAVARCH_CODEX_EXTRA_ARGS`, takes
208
250
  precedence over the generated policy.
209
251
 
210
- The full mechanism was verified live on 2026-07-19 against a real
211
- `claude -p --dangerously-skip-permissions --settings <generated file>` run:
212
- the `PreToolUse` hook fires under permission bypass, an in-worktree Read and
213
- `git --version` succeed, and a cross-session Read and a Bash redirect into a
214
- sibling session are both blocked with the guard's message (and nothing lands
215
- on disk).
252
+ Claude auto mode requires Claude Code 2.1.83 or later and an eligible account,
253
+ model, and first-party Anthropic API provider. If those requirements are not
254
+ met, Claude Code rejects auto mode instead of silently bypassing checks.
255
+
256
+ The auto-review launch paths were verified live on 2026-07-20 with Claude Code
257
+ 2.1.214 (`--permission-mode auto`) and Codex CLI 0.144.1 (the generated
258
+ permission profile plus `approvals_reviewer="auto_review"`). Both completed an
259
+ unattended smoke task successfully.
216
260
 
217
261
  ## Choosing an agent (Claude Code vs. Codex)
218
262
 
@@ -263,7 +307,7 @@ envelope as a compatibility fallback.
263
307
  cli.cts
264
308
  ├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
265
309
  ├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
266
- └─ start
310
+ ├─ start
267
311
  ├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every NAVARCH_HEARTBEAT_INTERVAL_MS]
268
312
  └─ ClaimLoop (claim-loop.cts) → api.claim() [every NAVARCH_POLL_INTERVAL_MS, gated by CapacityTracker]
269
313
  └─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
@@ -282,6 +326,10 @@ cli.cts
282
326
  6. api.completeLease(), reporting agent_type: config.agentType
283
327
  7. sandbox.wipe() when present; remove the session workspace
284
328
  unconditionally (finally block)
329
+ └─ supervise → supervisor.cts starts the worker with IPC
330
+ └─ update directive → update-installer.cts stages exact npm release
331
+ └─ drain + exit 75 → supervisor activates candidate, waits for
332
+ healthy heartbeat, or rolls back
285
333
  ```
286
334
 
287
335
  `adapter.cts` (top-level) is now a backward-compat re-export of
@@ -8,12 +8,11 @@
8
8
  * Multiple sessions run concurrently on one machine (claim-loop.cts), each
9
9
  * confined by convention to its own git worktree
10
10
  * (`<workspaceRoot>/sessions/<sessionId>/repo` — git-worktree.cts). The
11
- * adapter launches Claude with --dangerously-skip-permissions (adapters/
12
- * claude.cts), which bypasses permission prompts but NOT hooks: Claude Code
13
- * runs PreToolUse hooks in every permission mode, so this script is the
14
- * enforcement point that keeps an agent from reading or writing another
15
- * session's worktree, the operator's home directory, or anything else
16
- * unrelated to its task.
11
+ * adapter launches Claude in auto permission mode (adapters/claude.cts).
12
+ * Claude Code runs PreToolUse hooks in every permission mode, so this script
13
+ * is the enforcement point that keeps an agent from reading or writing
14
+ * another session's worktree, the operator's home directory, or anything
15
+ * else unrelated to its task.
17
16
  *
18
17
  * Protocol (Claude Code hooks): the hook receives {tool_name, tool_input,
19
18
  * cwd, ...} as JSON on stdin. Exit 0 allows the tool call; exit 2 blocks it
@@ -4,17 +4,6 @@ exports.claudeCodeAdapter = void 0;
4
4
  exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
- const NAVARCH_MCP_ALLOWED_TOOLS = [
8
- "mcp__navarch__task_comment",
9
- "mcp__navarch__record_decision",
10
- "mcp__navarch__request_approval",
11
- "mcp__navarch__register_resource",
12
- "mcp__navarch__create_task",
13
- "mcp__navarch__list_triage_tasks",
14
- "mcp__navarch__groom_triage_task",
15
- "mcp__navarch__defer_triage_task",
16
- "mcp__navarch__file_pr_review",
17
- ];
18
7
  /**
19
8
  * Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
20
9
  * WP-07): `claude -p "<context bundle>" --mcp-config platform-mcp.json
@@ -37,28 +26,18 @@ const NAVARCH_MCP_ALLOWED_TOOLS = [
37
26
  */
38
27
  async function runClaudeCodeAdapter(options) {
39
28
  const args = ["-p", options.prompt];
40
- const hasExplicitPermissionPolicy = options.extraArgs.some((arg) => [
41
- "--allowedTools",
42
- "--allowed-tools",
43
- "--disallowedTools",
44
- "--disallowed-tools",
45
- "--permission-mode",
46
- "--permission-prompt-tool",
47
- "--dangerously-skip-permissions",
48
- ].includes(arg));
49
- // Navarch sessions are unattended: a permission prompt can never be
50
- // answered and leaves Claude unable to edit the task's isolated worktree.
51
- // The runtime owns that worktree (and, in Docker mode, the surrounding
52
- // container), so make the non-interactive session capable of completing
53
- // coding tasks by default. Operators can replace this with a narrower
54
- // Claude permission policy through NAVARCH_CLAUDE_EXTRA_ARGS.
55
- if (!hasExplicitPermissionPolicy) {
56
- args.push("--dangerously-skip-permissions");
29
+ const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
30
+ // Navarch sessions are unattended, so route permission decisions through
31
+ // Claude Code's native auto-mode classifier instead of prompting a human or
32
+ // bypassing checks. Operators can replace this with a different permission
33
+ // policy through NAVARCH_CLAUDE_EXTRA_ARGS.
34
+ if (!hasExplicitPermissionMode) {
35
+ args.push("--permission-mode", "auto");
57
36
  }
58
37
  // The worktree-guard settings file (worktree-guard.cts) installs the
59
- // PreToolUse boundary hook. Hooks run even under permission bypass, so this
60
- // is what keeps an unattended session inside its own worktree on a machine
61
- // running several agents. An operator-supplied --settings (via
38
+ // PreToolUse boundary hook. Hooks run in auto mode, so this remains a
39
+ // fail-closed boundary around each worktree on a machine running several
40
+ // agents. An operator-supplied --settings (via
62
41
  // NAVARCH_CLAUDE_EXTRA_ARGS) wins — two --settings flags on one invocation
63
42
  // would be ambiguous, and session.cts logs the guard as skipped.
64
43
  if (options.settingsPath && !options.extraArgs.includes("--settings")) {
@@ -66,14 +45,9 @@ async function runClaudeCodeAdapter(options) {
66
45
  }
67
46
  if (options.mcpConfigPath) {
68
47
  args.push("--mcp-config", options.mcpConfigPath);
69
- // The Navarch server scopes every call to the authenticated lease and
70
- // enforces task/project authorization. Keep its tools explicit for
71
- // operator-supplied policies and for compatibility with Claude versions
72
- // that still inspect allowedTools while permission bypass is active.
73
- if (!options.extraArgs.includes("--allowedTools") &&
74
- !options.extraArgs.includes("--allowed-tools")) {
75
- args.push("--allowedTools", NAVARCH_MCP_ALLOWED_TOOLS.join(","));
76
- }
48
+ // Do not blanket-preapprove Navarch MCP tools: auto mode should review
49
+ // their side effects too. Operators can still add an explicit
50
+ // --allowedTools policy through NAVARCH_CLAUDE_EXTRA_ARGS.
77
51
  }
78
52
  // Only append the default when the caller hasn't already asked for a
79
53
  // specific --output-format (extraArgs wins so an operator can opt back
@@ -32,10 +32,11 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
32
32
  * variables so machine/lease credentials never appear in argv.
33
33
  * - Host-mode sessions use a generated native Codex permission profile that
34
34
  * denies reads and writes outside the session worktree, shared Git dir,
35
- * temp dirs, and operator-approved extra roots. It also uses `--ask-for-
36
- * approval never`, so a boundary violation fails back to the unattended
37
- * agent instead of waiting for input. Explicit permission-policy arguments
38
- * in NAVARCH_CODEX_EXTRA_ARGS replace that generated profile.
35
+ * temp dirs, and operator-approved extra roots. Interactive approval
36
+ * requests use `approval_policy="on-request"` with the native automatic
37
+ * reviewer, so unattended sessions retain safety review without waiting for
38
+ * human input. Explicit permission-policy arguments in
39
+ * NAVARCH_CODEX_EXTRA_ARGS replace that generated profile.
39
40
  * - `NAVARCH_CODEX_EXTRA_ARGS` (`extraArgs`) wins over the default `--json`
40
41
  * exactly like the Claude adapter's `--output-format` opt-out, so an
41
42
  * operator can fall back to plain-text output (or add the real
package/dist/capacity.cjs CHANGED
@@ -14,6 +14,7 @@ function computeAvailableCapacity(maxSessions, activeSessions) {
14
14
  class CapacityTracker {
15
15
  maxSessions;
16
16
  active = new Set();
17
+ idleWaiters = new Set();
17
18
  constructor(maxSessions) {
18
19
  this.maxSessions = maxSessions;
19
20
  }
@@ -36,6 +37,17 @@ class CapacityTracker {
36
37
  }
37
38
  release(sessionId) {
38
39
  this.active.delete(sessionId);
40
+ if (this.active.size === 0) {
41
+ for (const resolve of this.idleWaiters)
42
+ resolve();
43
+ this.idleWaiters.clear();
44
+ }
45
+ }
46
+ /** Resolves once every acquired lease has finished and released its slot. */
47
+ waitForIdle() {
48
+ if (this.active.size === 0)
49
+ return Promise.resolve();
50
+ return new Promise((resolve) => this.idleWaiters.add(resolve));
39
51
  }
40
52
  }
41
53
  exports.CapacityTracker = CapacityTracker;
@@ -20,6 +20,7 @@ class ClaimLoop {
20
20
  timer = null;
21
21
  stopped = false;
22
22
  claimInFlight = false;
23
+ quiescenceWaiters = new Set();
23
24
  constructor(api, config, capacity, runSession) {
24
25
  this.api = api;
25
26
  this.config = config;
@@ -29,6 +30,7 @@ class ClaimLoop {
29
30
  start() {
30
31
  if (this.timer)
31
32
  return;
33
+ this.stopped = false;
32
34
  this.timer = setInterval(() => void this.tick(), this.config.pollIntervalMs);
33
35
  }
34
36
  stop() {
@@ -36,6 +38,18 @@ class ClaimLoop {
36
38
  if (this.timer)
37
39
  clearInterval(this.timer);
38
40
  this.timer = null;
41
+ this.resolveQuiescenceWaiters();
42
+ }
43
+ /**
44
+ * Stops new polls and waits for a claim request already on the wire to
45
+ * settle. If that request returns a lease, the session is still run; callers
46
+ * must then wait for CapacityTracker.waitForIdle() before restarting.
47
+ */
48
+ async drain() {
49
+ this.stop();
50
+ if (!this.claimInFlight)
51
+ return;
52
+ await new Promise((resolve) => this.quiescenceWaiters.add(resolve));
39
53
  }
40
54
  async tick() {
41
55
  if (this.stopped || this.claimInFlight || !this.capacity.hasCapacity())
@@ -75,7 +89,15 @@ class ClaimLoop {
75
89
  }
76
90
  finally {
77
91
  this.claimInFlight = false;
92
+ this.resolveQuiescenceWaiters();
78
93
  }
79
94
  }
95
+ resolveQuiescenceWaiters() {
96
+ if (this.claimInFlight)
97
+ return;
98
+ for (const resolve of this.quiescenceWaiters)
99
+ resolve();
100
+ this.quiescenceWaiters.clear();
101
+ }
80
102
  }
81
103
  exports.ClaimLoop = ClaimLoop;
package/dist/cli.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.main = main;
4
4
  const config_cjs_1 = require("./config.cjs");
5
+ const node_crypto_1 = require("node:crypto");
5
6
  const machine_store_cjs_1 = require("./machine-store.cjs");
6
7
  const api_cjs_1 = require("./api.cjs");
7
8
  const capacity_cjs_1 = require("./capacity.cjs");
@@ -10,6 +11,8 @@ const claim_loop_cjs_1 = require("./claim-loop.cjs");
10
11
  const session_cjs_1 = require("./session.cjs");
11
12
  const sandbox_cjs_1 = require("./sandbox.cjs");
12
13
  const logger_cjs_1 = require("./logger.cjs");
14
+ const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
15
+ const supervisor_cjs_1 = require("./supervisor.cjs");
13
16
  const log = (0, logger_cjs_1.createLogger)("cli");
14
17
  const PACKAGE_NAME = "@sagentlab/navarch-runtime";
15
18
  /**
@@ -99,7 +102,7 @@ async function registerCommand(flags) {
99
102
  console.log("Machine registered.");
100
103
  console.log(` machine_id: ${result.machine_id}`);
101
104
  console.log(` token: ${result.token}`);
102
- console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("start")}\` to begin serving tasks.`);
105
+ console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
103
106
  }
104
107
  /**
105
108
  * `navarch-runtime connect` — "Connect an agent to a project"
@@ -147,7 +150,7 @@ async function connectCommand(flags) {
147
150
  console.log("Machine connected.");
148
151
  console.log(` machine_id: ${result.machine_id}`);
149
152
  console.log(` token: ${result.token}`);
150
- console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("start")}\` to begin serving tasks.`);
153
+ console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
151
154
  }
152
155
  async function startCommand(flags) {
153
156
  const baseConfig = (0, config_cjs_1.loadRuntimeConfig)();
@@ -160,20 +163,52 @@ async function startCommand(flags) {
160
163
  const config = { ...baseConfig, agentType };
161
164
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
162
165
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
163
- const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity);
164
166
  const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
167
+ const bootId = (0, node_crypto_1.randomUUID)();
168
+ const updateCoordinatorRef = {};
169
+ let readySent = false;
170
+ const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity, bootId, (result) => updateCoordinatorRef.current?.consider(result.update), () => {
171
+ if (!readySent && process.send) {
172
+ readySent = true;
173
+ process.send({ type: "navarch-ready", boot_id: bootId });
174
+ }
175
+ });
176
+ updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
177
+ config,
178
+ claimLoop,
179
+ capacity,
180
+ heartbeat,
181
+ });
165
182
  heartbeat.start();
166
183
  claimLoop.start();
167
184
  log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
185
+ let shuttingDown = false;
168
186
  const shutdown = () => {
169
- log.info("shutting down...");
170
- heartbeat.stop();
171
- claimLoop.stop();
172
- process.exit(0);
187
+ if (shuttingDown) {
188
+ log.warn("second shutdown signal received; forcing exit with active work");
189
+ process.exit(1);
190
+ }
191
+ shuttingDown = true;
192
+ void (async () => {
193
+ log.info("shutting down: draining active sessions...");
194
+ heartbeat.setUpdateState("draining");
195
+ await claimLoop.drain();
196
+ await capacity.waitForIdle();
197
+ heartbeat.stop();
198
+ log.info("shutdown drain complete");
199
+ process.exit(0);
200
+ })();
173
201
  };
174
202
  process.on("SIGINT", shutdown);
175
203
  process.on("SIGTERM", shutdown);
176
204
  }
205
+ async function superviseCommand(flags) {
206
+ const config = (0, config_cjs_1.loadRuntimeConfig)();
207
+ const agentType = agentFromFlag(flags);
208
+ const workerArgs = agentType ? ["--agent", agentType] : [];
209
+ const exitCode = await (0, supervisor_cjs_1.superviseRuntime)(config.configDir, process.argv[1] ?? "", workerArgs);
210
+ process.exitCode = exitCode;
211
+ }
177
212
  async function doctorCommand(flags) {
178
213
  const config = (0, config_cjs_1.loadRuntimeConfig)();
179
214
  const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
@@ -206,6 +241,7 @@ Usage:
206
241
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
207
242
  [--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
208
243
  navarch-runtime start [--agent claude-code|codex]
244
+ navarch-runtime supervise [--agent claude-code|codex]
209
245
  navarch-runtime doctor
210
246
 
211
247
  Configuration is via NAVARCH_* environment variables; see runtime/README.md.
@@ -224,6 +260,9 @@ async function main(argv = process.argv.slice(2)) {
224
260
  case "start":
225
261
  await startCommand(flags);
226
262
  break;
263
+ case "supervise":
264
+ await superviseCommand(flags);
265
+ break;
227
266
  case "doctor":
228
267
  await doctorCommand(flags);
229
268
  break;
package/dist/config.cjs CHANGED
@@ -67,5 +67,8 @@ function loadRuntimeConfig(env = process.env) {
67
67
  .split(node_path_1.default.delimiter)
68
68
  .map((s) => s.trim())
69
69
  .filter(Boolean),
70
+ updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
71
+ autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
72
+ !["off", "false", "0"].includes(env.NAVARCH_AUTO_UPDATE ?? ""),
70
73
  };
71
74
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GitWorktree = void 0;
7
+ exports.branchSlug = branchSlug;
7
8
  const node_path_1 = __importDefault(require("node:path"));
8
9
  const node_fs_1 = require("node:fs");
9
10
  const sandbox_cjs_1 = require("./sandbox.cjs");
@@ -28,7 +29,7 @@ class GitWorktree {
28
29
  this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
29
30
  this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
30
31
  this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
31
- this.branch = `navarch/${safePathSegment(options.taskId).slice(0, 32)}-${sessionKey.slice(0, 12)}`;
32
+ this.branch = `navarch/${branchSlug(options)}-${sessionKey.toLowerCase().slice(0, 8)}`;
32
33
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
33
34
  this.cloneUrl = options.cloneUrl;
34
35
  this.githubToken = options.githubToken;
@@ -87,6 +88,8 @@ class GitWorktree {
87
88
  async runGit(args, authenticated) {
88
89
  const credentialArgs = authenticated && this.githubToken
89
90
  ? [
91
+ "-c",
92
+ "credential.helper=",
90
93
  "-c",
91
94
  'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f',
92
95
  ]
@@ -130,6 +133,32 @@ async function pathExists(value) {
130
133
  return false;
131
134
  }
132
135
  }
136
+ /**
137
+ * Kebab-case slug for the session branch, derived from the task summary so
138
+ * branch names read like `navarch/fix-login-redirect-a1b2c3d4` instead of a
139
+ * UUID mash. Falls back to the task type, then a task-id prefix, when the
140
+ * summary yields nothing. Output contains only [a-z0-9-] with no leading or
141
+ * trailing dash, so it is always a valid git ref component (no `..`, no
142
+ * trailing `.lock`).
143
+ */
144
+ function branchSlug(options) {
145
+ for (const candidate of [options.taskSummary, options.taskType]) {
146
+ const slug = (candidate ?? "")
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9]+/g, "-")
149
+ .replace(/^-+/, "")
150
+ .slice(0, 40)
151
+ .replace(/-+$/, "");
152
+ if (slug)
153
+ return slug;
154
+ }
155
+ const idSlug = safePathSegment(options.taskId)
156
+ .toLowerCase()
157
+ .replace(/[^a-z0-9]+/g, "-")
158
+ .slice(0, 8)
159
+ .replace(/^-+|-+$/g, "");
160
+ return idSlug || "task";
161
+ }
133
162
  function safePathSegment(value) {
134
163
  const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
135
164
  if (!safe)
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MachineHeartbeatLoop = void 0;
4
+ const version_cjs_1 = require("./version.cjs");
4
5
  const logger_cjs_1 = require("./logger.cjs");
5
6
  const log = (0, logger_cjs_1.createLogger)("heartbeat");
6
7
  /**
@@ -14,12 +15,21 @@ class MachineHeartbeatLoop {
14
15
  machineId;
15
16
  config;
16
17
  capacity;
18
+ bootId;
19
+ onResult;
20
+ onHealthy;
17
21
  timer = null;
18
- constructor(api, machineId, config, capacity) {
22
+ updateState = "idle";
23
+ lastUpdateError;
24
+ draining = false;
25
+ constructor(api, machineId, config, capacity, bootId, onResult, onHealthy) {
19
26
  this.api = api;
20
27
  this.machineId = machineId;
21
28
  this.config = config;
22
29
  this.capacity = capacity;
30
+ this.bootId = bootId;
31
+ this.onResult = onResult;
32
+ this.onHealthy = onHealthy;
23
33
  }
24
34
  start() {
25
35
  if (this.timer)
@@ -32,12 +42,28 @@ class MachineHeartbeatLoop {
32
42
  clearInterval(this.timer);
33
43
  this.timer = null;
34
44
  }
45
+ setUpdateState(state, error) {
46
+ this.updateState = state;
47
+ this.lastUpdateError = error;
48
+ this.draining = state === "draining";
49
+ void this.tick();
50
+ }
35
51
  async tick() {
36
52
  try {
37
- await this.api.machineHeartbeat(this.machineId, {
38
- available_capacity: this.capacity.available(),
53
+ const result = await this.api.machineHeartbeat(this.machineId, {
54
+ available_capacity: this.draining ? 0 : this.capacity.available(),
39
55
  capabilities: this.config.capabilities,
56
+ runtime: {
57
+ version: version_cjs_1.RUNTIME_VERSION,
58
+ updater_protocol: version_cjs_1.UPDATER_PROTOCOL_VERSION,
59
+ channel: this.config.updateChannel,
60
+ state: this.updateState,
61
+ boot_id: this.bootId,
62
+ ...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
63
+ },
40
64
  });
65
+ this.onHealthy?.();
66
+ this.onResult?.(result);
41
67
  }
42
68
  catch (err) {
43
69
  log.warn(`machine heartbeat failed: ${String(err)}`);
package/dist/session.cjs CHANGED
@@ -92,6 +92,8 @@ async function runSession(deps, claimed, sessionId) {
92
92
  workspaceRoot: config.workspaceRoot,
93
93
  projectId: task.project_id,
94
94
  taskId: task.id,
95
+ taskSummary: task.summary,
96
+ taskType: task.task_type,
95
97
  sessionId,
96
98
  cloneUrl,
97
99
  githubToken,
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.superviseRuntime = superviseRuntime;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const logger_cjs_1 = require("./logger.cjs");
6
+ const update_installer_cjs_1 = require("./update-installer.cjs");
7
+ const log = (0, logger_cjs_1.createLogger)("supervisor");
8
+ const UPDATE_RESTART_EXIT_CODE = 75;
9
+ function runWorker(binPath, args, healthTimeoutMs, setCurrentChild, onHealthy) {
10
+ return new Promise((resolve) => {
11
+ const child = (0, node_child_process_1.spawn)(process.execPath, [binPath, "start", ...args], {
12
+ env: { ...process.env, NAVARCH_SUPERVISED: "1" },
13
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
14
+ });
15
+ setCurrentChild(child);
16
+ let healthy = false;
17
+ let healthSignalReceived = false;
18
+ let healthTimedOut = false;
19
+ let healthCommit = Promise.resolve();
20
+ let healthTimer = null;
21
+ let forceKillTimer = null;
22
+ let settled = false;
23
+ const finish = (code) => {
24
+ if (settled)
25
+ return;
26
+ settled = true;
27
+ if (healthTimer)
28
+ clearTimeout(healthTimer);
29
+ if (forceKillTimer)
30
+ clearTimeout(forceKillTimer);
31
+ setCurrentChild(null);
32
+ void healthCommit.finally(() => resolve({ code, healthy }));
33
+ };
34
+ if (healthTimeoutMs !== null) {
35
+ healthTimer = setTimeout(() => {
36
+ if (!healthy) {
37
+ healthTimedOut = true;
38
+ log.error("updated runtime missed its startup health deadline; rolling back");
39
+ child.kill("SIGTERM");
40
+ forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 10_000);
41
+ }
42
+ }, healthTimeoutMs);
43
+ }
44
+ child.on("message", (message) => {
45
+ if (!healthSignalReceived &&
46
+ !healthTimedOut &&
47
+ typeof message === "object" &&
48
+ message !== null &&
49
+ message.type === "navarch-ready") {
50
+ healthSignalReceived = true;
51
+ healthCommit = onHealthy()
52
+ .then(() => {
53
+ healthy = true;
54
+ if (healthTimer)
55
+ clearTimeout(healthTimer);
56
+ if (forceKillTimer)
57
+ clearTimeout(forceKillTimer);
58
+ })
59
+ .catch((error) => {
60
+ log.error(`could not commit runtime activation: ${String(error)}`);
61
+ child.kill("SIGTERM");
62
+ });
63
+ }
64
+ });
65
+ child.once("error", (error) => {
66
+ log.error(`could not start runtime worker: ${String(error)}`);
67
+ finish(1);
68
+ });
69
+ child.once("exit", (code) => finish(code ?? 1));
70
+ });
71
+ }
72
+ /** Runs the worker and owns update activation, health checking, and rollback. */
73
+ async function superviseRuntime(configDir, initialBinPath, workerArgs = [], healthTimeoutMs = 120_000) {
74
+ let currentBin = initialBinPath;
75
+ let rollbackBin = null;
76
+ let candidatePending = null;
77
+ let awaitingCandidateHealth = false;
78
+ let currentChild = null;
79
+ let stopping = false;
80
+ const forwardSignal = (signal) => {
81
+ stopping = true;
82
+ currentChild?.kill(signal);
83
+ };
84
+ const onSigint = () => forwardSignal("SIGINT");
85
+ const onSigterm = () => forwardSignal("SIGTERM");
86
+ process.on("SIGINT", onSigint);
87
+ process.on("SIGTERM", onSigterm);
88
+ try {
89
+ const active = await (0, update_installer_cjs_1.readActiveRuntime)(configDir);
90
+ if (active) {
91
+ try {
92
+ const verified = await (0, update_installer_cjs_1.verifyManagedRuntime)(configDir, active);
93
+ currentBin = verified.bin_path;
94
+ log.info(`starting active managed runtime ${active.version}`);
95
+ }
96
+ catch (error) {
97
+ log.warn(`ignoring invalid active runtime pointer: ${String(error)}`);
98
+ }
99
+ }
100
+ while (!stopping) {
101
+ const candidateThisRun = awaitingCandidateHealth;
102
+ const result = await runWorker(currentBin, workerArgs, candidateThisRun ? healthTimeoutMs : null, (child) => {
103
+ currentChild = child;
104
+ }, async () => {
105
+ if (candidateThisRun) {
106
+ if (!candidatePending)
107
+ throw new Error("candidate has no pending release metadata");
108
+ await (0, update_installer_cjs_1.writeActiveRuntime)(configDir, candidatePending);
109
+ await (0, update_installer_cjs_1.clearPendingUpdate)(configDir);
110
+ awaitingCandidateHealth = false;
111
+ rollbackBin = null;
112
+ candidatePending = null;
113
+ log.info("updated runtime reported healthy; activation committed");
114
+ }
115
+ });
116
+ if (stopping)
117
+ return result.code;
118
+ if (candidateThisRun && !result.healthy) {
119
+ if (!rollbackBin)
120
+ return 1;
121
+ log.warn("updated runtime failed before becoming healthy; restoring previous version");
122
+ currentBin = rollbackBin;
123
+ rollbackBin = null;
124
+ awaitingCandidateHealth = false;
125
+ candidatePending = null;
126
+ await (0, update_installer_cjs_1.clearPendingUpdate)(configDir);
127
+ continue;
128
+ }
129
+ if (result.code !== UPDATE_RESTART_EXIT_CODE)
130
+ return result.code;
131
+ const pending = await (0, update_installer_cjs_1.readPendingUpdate)(configDir);
132
+ if (!pending) {
133
+ log.error("worker requested an update restart without a pending update");
134
+ return 1;
135
+ }
136
+ const verified = await (0, update_installer_cjs_1.verifyManagedRuntime)(configDir, pending);
137
+ rollbackBin = currentBin;
138
+ currentBin = verified.bin_path;
139
+ candidatePending = pending;
140
+ awaitingCandidateHealth = true;
141
+ log.info(`activating staged runtime ${pending.version}`);
142
+ }
143
+ return 0;
144
+ }
145
+ finally {
146
+ process.off("SIGINT", onSigint);
147
+ process.off("SIGTERM", onSigterm);
148
+ }
149
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RuntimeUpdateCoordinator = void 0;
4
+ const logger_cjs_1 = require("./logger.cjs");
5
+ const update_installer_cjs_1 = require("./update-installer.cjs");
6
+ const version_cjs_1 = require("./version.cjs");
7
+ const log = (0, logger_cjs_1.createLogger)("update");
8
+ class RuntimeUpdateCoordinator {
9
+ options;
10
+ targetInProgress = null;
11
+ warnedUnsupervised = false;
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ consider(directive) {
16
+ if (!directive || directive.target_version === version_cjs_1.RUNTIME_VERSION)
17
+ return;
18
+ if (!this.options.config.autoUpdate) {
19
+ if (!this.warnedUnsupervised) {
20
+ log.warn(`runtime ${directive.target_version} is available; run under \`navarch-runtime supervise\` to activate updates automatically`);
21
+ this.warnedUnsupervised = true;
22
+ }
23
+ return;
24
+ }
25
+ if (this.targetInProgress)
26
+ return;
27
+ this.targetInProgress = directive.target_version;
28
+ void this.apply(directive);
29
+ }
30
+ async apply(directive) {
31
+ const { config, heartbeat, claimLoop, capacity } = this.options;
32
+ try {
33
+ heartbeat.setUpdateState("staging");
34
+ log.info(`staging runtime ${directive.target_version}`);
35
+ const staged = await (this.options.stage ?? update_installer_cjs_1.stageRuntimeUpdate)(config.configDir, directive);
36
+ heartbeat.setUpdateState("draining");
37
+ log.info("runtime update staged; draining active sessions before restart");
38
+ await claimLoop.drain();
39
+ await capacity.waitForIdle();
40
+ await (0, update_installer_cjs_1.writePendingUpdate)(config.configDir, staged, directive.rollout_id);
41
+ log.info(`runtime ${directive.target_version} ready; handing off to supervisor`);
42
+ (this.options.requestRestart ?? ((code) => process.exit(code)))(75);
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ log.error(`runtime update failed: ${message}`);
47
+ heartbeat.setUpdateState("failed", message.slice(0, 500));
48
+ claimLoop.start();
49
+ this.targetInProgress = null;
50
+ }
51
+ }
52
+ }
53
+ exports.RuntimeUpdateCoordinator = RuntimeUpdateCoordinator;
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.validateUpdateDirective = validateUpdateDirective;
7
+ exports.stageRuntimeUpdate = stageRuntimeUpdate;
8
+ exports.writePendingUpdate = writePendingUpdate;
9
+ exports.readPendingUpdate = readPendingUpdate;
10
+ exports.clearPendingUpdate = clearPendingUpdate;
11
+ exports.writeActiveRuntime = writeActiveRuntime;
12
+ exports.readActiveRuntime = readActiveRuntime;
13
+ exports.assertSafePendingUpdate = assertSafePendingUpdate;
14
+ exports.verifyManagedRuntime = verifyManagedRuntime;
15
+ const node_child_process_1 = require("node:child_process");
16
+ const node_crypto_1 = require("node:crypto");
17
+ const node_fs_1 = require("node:fs");
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ const node_util_1 = require("node:util");
20
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
21
+ const PACKAGE_NAME = "@sagentlab/navarch-runtime";
22
+ const EXACT_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
23
+ const SHA512_INTEGRITY = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
24
+ const defaultRunner = async (file, args) => {
25
+ const result = await execFileAsync(file, args, { maxBuffer: 10 * 1024 * 1024 });
26
+ return { stdout: result.stdout, stderr: result.stderr };
27
+ };
28
+ function validateUpdateDirective(directive) {
29
+ if (!EXACT_SEMVER.test(directive.target_version)) {
30
+ throw new Error(`Invalid runtime target version: ${directive.target_version}`);
31
+ }
32
+ if (!SHA512_INTEGRITY.test(directive.integrity)) {
33
+ throw new Error("Runtime update is missing a valid SHA-512 integrity value.");
34
+ }
35
+ if (!directive.rollout_id || directive.rollout_id.length > 128) {
36
+ throw new Error("Runtime update has an invalid rollout id.");
37
+ }
38
+ }
39
+ async function verifyInstalledPackage(packageDir, version, integrity) {
40
+ const manifest = JSON.parse(await node_fs_1.promises.readFile(node_path_1.default.join(packageDir, "package.json"), "utf8"));
41
+ if (manifest.name !== PACKAGE_NAME || manifest.version !== version) {
42
+ throw new Error("Staged runtime package identity did not match the requested release.");
43
+ }
44
+ const recordedIntegrity = (await node_fs_1.promises.readFile(node_path_1.default.join(packageDir, ".navarch-integrity"), "utf8")).trim();
45
+ if (recordedIntegrity !== integrity) {
46
+ throw new Error("Staged runtime integrity did not match the release directive.");
47
+ }
48
+ const binPath = node_path_1.default.join(packageDir, "bin", "navarch.cjs");
49
+ await node_fs_1.promises.access(binPath);
50
+ return { version, integrity, bin_path: binPath };
51
+ }
52
+ /** Downloads and verifies an exact immutable npm release without running package scripts. */
53
+ async function stageRuntimeUpdate(configDir, directive, runner = defaultRunner) {
54
+ validateUpdateDirective(directive);
55
+ const versionsDir = node_path_1.default.join(configDir, "versions");
56
+ const targetDir = node_path_1.default.join(versionsDir, directive.target_version);
57
+ await node_fs_1.promises.mkdir(versionsDir, { recursive: true, mode: 0o700 });
58
+ try {
59
+ const staged = await verifyInstalledPackage(targetDir, directive.target_version, directive.integrity);
60
+ await runner(process.execPath, [staged.bin_path, "--help"]);
61
+ return staged;
62
+ }
63
+ catch (error) {
64
+ if (error.code !== "ENOENT")
65
+ throw error;
66
+ await node_fs_1.promises.rm(targetDir, { recursive: true, force: true });
67
+ }
68
+ const stagingDir = node_path_1.default.join(versionsDir, `.staging-${(0, node_crypto_1.randomUUID)()}`);
69
+ const installDir = node_path_1.default.join(stagingDir, "install");
70
+ await node_fs_1.promises.mkdir(installDir, { recursive: true, mode: 0o700 });
71
+ try {
72
+ const packed = await runner("npm", [
73
+ "pack",
74
+ `${PACKAGE_NAME}@${directive.target_version}`,
75
+ "--json",
76
+ "--ignore-scripts",
77
+ "--pack-destination",
78
+ stagingDir,
79
+ ]);
80
+ const packResult = JSON.parse(packed.stdout);
81
+ const artifact = packResult[0];
82
+ if (!artifact ||
83
+ typeof artifact.filename !== "string" ||
84
+ artifact.integrity !== directive.integrity) {
85
+ throw new Error("Downloaded runtime tarball failed its release integrity check.");
86
+ }
87
+ const tarballPath = node_path_1.default.join(stagingDir, node_path_1.default.basename(artifact.filename));
88
+ await runner("npm", [
89
+ "install",
90
+ "--prefix",
91
+ installDir,
92
+ "--ignore-scripts",
93
+ "--omit=dev",
94
+ "--no-audit",
95
+ "--no-fund",
96
+ "--package-lock=false",
97
+ tarballPath,
98
+ ]);
99
+ const installedPackage = node_path_1.default.join(installDir, "node_modules", "@sagentlab", "navarch-runtime");
100
+ await node_fs_1.promises.writeFile(node_path_1.default.join(installedPackage, ".navarch-integrity"), `${directive.integrity}\n`, { mode: 0o600 });
101
+ await node_fs_1.promises.rename(installedPackage, targetDir);
102
+ const staged = await verifyInstalledPackage(targetDir, directive.target_version, directive.integrity);
103
+ await runner(process.execPath, [staged.bin_path, "--help"]);
104
+ return staged;
105
+ }
106
+ finally {
107
+ await node_fs_1.promises.rm(stagingDir, { recursive: true, force: true });
108
+ }
109
+ }
110
+ function pendingPath(configDir) {
111
+ return node_path_1.default.join(configDir, "pending-update.json");
112
+ }
113
+ function activePath(configDir) {
114
+ return node_path_1.default.join(configDir, "active-runtime.json");
115
+ }
116
+ async function writePendingUpdate(configDir, staged, rolloutId) {
117
+ const pending = { ...staged, rollout_id: rolloutId };
118
+ const tempPath = `${pendingPath(configDir)}.${(0, node_crypto_1.randomUUID)()}.tmp`;
119
+ await node_fs_1.promises.writeFile(tempPath, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
120
+ await node_fs_1.promises.rename(tempPath, pendingPath(configDir));
121
+ }
122
+ async function readPendingUpdate(configDir) {
123
+ try {
124
+ return JSON.parse(await node_fs_1.promises.readFile(pendingPath(configDir), "utf8"));
125
+ }
126
+ catch (error) {
127
+ if (error.code === "ENOENT")
128
+ return null;
129
+ throw error;
130
+ }
131
+ }
132
+ async function clearPendingUpdate(configDir) {
133
+ await node_fs_1.promises.rm(pendingPath(configDir), { force: true });
134
+ }
135
+ async function writeActiveRuntime(configDir, pending) {
136
+ const active = { ...pending, activated_at: new Date().toISOString() };
137
+ const tempPath = `${activePath(configDir)}.${(0, node_crypto_1.randomUUID)()}.tmp`;
138
+ await node_fs_1.promises.writeFile(tempPath, `${JSON.stringify(active, null, 2)}\n`, { mode: 0o600 });
139
+ await node_fs_1.promises.rename(tempPath, activePath(configDir));
140
+ }
141
+ async function readActiveRuntime(configDir) {
142
+ try {
143
+ return JSON.parse(await node_fs_1.promises.readFile(activePath(configDir), "utf8"));
144
+ }
145
+ catch (error) {
146
+ if (error.code === "ENOENT")
147
+ return null;
148
+ throw error;
149
+ }
150
+ }
151
+ function assertSafePendingUpdate(configDir, pending) {
152
+ if (!EXACT_SEMVER.test(pending.version))
153
+ throw new Error("Pending update has an invalid version.");
154
+ const expectedBin = node_path_1.default.resolve(configDir, "versions", pending.version, "bin", "navarch.cjs");
155
+ if (node_path_1.default.resolve(pending.bin_path) !== expectedBin) {
156
+ throw new Error("Pending update executable is outside the managed versions directory.");
157
+ }
158
+ }
159
+ /** Re-verifies a persisted managed pointer before the supervisor executes it. */
160
+ async function verifyManagedRuntime(configDir, runtime) {
161
+ assertSafePendingUpdate(configDir, runtime);
162
+ const packageDir = node_path_1.default.resolve(configDir, "versions", runtime.version);
163
+ return verifyInstalledPackage(packageDir, runtime.version, runtime.integrity);
164
+ }
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UPDATER_PROTOCOL_VERSION = exports.RUNTIME_VERSION = void 0;
4
+ // package.json is always included in an npm package, even though the runtime's
5
+ // explicit `files` allowlist only names dist/bin/README.
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ const packageJson = require("../package.json");
8
+ exports.RUNTIME_VERSION = typeof packageJson.version === "string" ? packageJson.version : "0.0.0-unknown";
9
+ exports.UPDATER_PROTOCOL_VERSION = 1;
@@ -88,8 +88,12 @@ function codexWorktreeGuardArgs(options) {
88
88
  }
89
89
  return [
90
90
  "--ignore-user-config",
91
- "--ask-for-approval",
92
- "never",
91
+ // Keep approvals interactive at the policy layer, but route them to
92
+ // Codex's automatic reviewer because `codex exec` has no human available.
93
+ "-c",
94
+ 'approval_policy="on-request"',
95
+ "-c",
96
+ 'approvals_reviewer="auto_review"',
93
97
  "-c",
94
98
  `projects.${tomlString(node_path_1.default.resolve(options.worktreePath))}.trust_level="untrusted"`,
95
99
  "-c",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
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.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "test:watch": "vitest",
39
39
  "prepublishOnly": "npm run build && npm test",
40
40
  "start": "node bin/navarch.cjs start",
41
+ "supervise": "node bin/navarch.cjs supervise",
41
42
  "register": "node bin/navarch.cjs register",
42
43
  "connect": "node bin/navarch.cjs connect",
43
44
  "doctor": "node bin/navarch.cjs doctor"