@sagentlab/navarch-runtime 0.1.1 → 0.1.3

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
@@ -7,7 +7,8 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
7
7
  machine.
8
8
 
9
9
  The agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
10
- selected per machine via `NAVARCH_AGENT` (default `claude-code`) — see
10
+ selected by the local machine via `--agent` or `NAVARCH_AGENT` (default
11
+ `claude-code`) — see
11
12
  "Choosing an agent (Claude Code vs. Codex)" below.
12
13
 
13
14
  See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
@@ -20,7 +21,7 @@ the API contract.
20
21
 
21
22
  ```sh
22
23
  git clone <this repo> && cd sagentlab/runtime
23
- ./install.sh # checks node/docker, npm install, npm run build
24
+ ./install.sh # checks node, npm install, npm run build
24
25
  export NAVARCH_API_BASE=https://navarch.example.com
25
26
  node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
26
27
  node bin/navarch.cjs start
@@ -48,8 +49,8 @@ availability, and registration status without starting the daemon.
48
49
 
49
50
  `connect` is the project-scoped sibling of `register` — "Connect an agent to
50
51
  a project" (self-hosted-runner style, like a GitHub Actions self-hosted
51
- runner token): a project **owner** mints a short-lived (60 minute),
52
- single-use token from the platform UI (the project settings page's "Connect
52
+ runner token): a project **owner** mints a single-use token from the platform
53
+ UI (the project settings page's "Connect
53
54
  an agent" panel, or the onboarding wizard's Agent step),
54
55
  `POST /api/projects/:id/enrollment-tokens`, and pastes you the ready-to-run
55
56
  command. Unlike `register`, no `NAVARCH_ENROLLMENT_SECRET` or
@@ -59,7 +60,7 @@ project only (it will never be dispatched work from any other project).
59
60
 
60
61
  ```sh
61
62
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
62
- --name my-agent-1 --api-base https://navarch.example.com
63
+ --name my-agent-1 --agent codex --api-base https://navarch.example.com
63
64
  node bin/navarch.cjs start
64
65
  ```
65
66
 
@@ -72,11 +73,70 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
72
73
 
73
74
  | Command | Purpose |
74
75
  |---|---|
75
- | `register --token <t> --name <n> [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]` | Registers this machine (global enrollment secret), prints the token once. |
76
- | `connect --token <t> --name <n> [--project <id>] [--capabilities a,b] [--max-sessions N] [--api-base url]` | Connects this machine to exactly one project (project-scoped enrollment token), prints the token once. |
77
- | `start` | Runs the daemon: machine heartbeat loop + claim loop, `NAVARCH_MAX_SESSIONS` concurrent task sessions. |
76
+ | `register --token <t> --name <n> [--agent claude-code\|codex] []` | Registers this machine, saves its local agent choice, and prints the token once. |
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
+ | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
78
79
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
79
80
 
81
+ ## Active-task guidance
82
+
83
+ Guidance added to a task that is already running is delivered on that
84
+ task's next lease heartbeat (every
85
+ `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS`, five minutes by default). The runtime
86
+ stops the current agent process and starts a **new agent turn** with the
87
+ original task context plus all guidance received so far. This is a turn
88
+ restart, not a new dispatch:
89
+
90
+ - The task keeps the same session and lease, and lease heartbeats continue.
91
+ - The replacement turn uses the same worktree (and the same sandbox container
92
+ in Docker mode), so committed and uncommitted changes from the interrupted
93
+ turn remain available. It should inspect those changes before continuing.
94
+ - The replacement turn is not a resumed Claude Code or Codex conversation.
95
+ The corrected prompt carries the prior task context and guidance instead.
96
+ - Transcript, token, and cost accounting is cumulative across turns: the
97
+ upload includes every turn under a separate label, and reported token and
98
+ cost totals include every turn. The lease is completed only after the
99
+ corrected turn finishes.
100
+
101
+ The daemon logs `restarting the agent turn in the same worktree` when it
102
+ delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
103
+ sooner, but keep it comfortably below the 15-minute lease TTL.
104
+
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.
112
+
113
+ Use this sequence for an upgrade:
114
+
115
+ 1. Install or build the new runtime without stopping the existing process. For
116
+ a source checkout, update the checkout and run `npm ci && npm run build`
117
+ inside `runtime/`. For an npm deployment, select the exact version in the
118
+ service command, for example
119
+ `npx --yes @sagentlab/navarch-runtime@<version> start`.
120
+ 2. In Navarch's **Fleet** view, wait until this machine shows `Sessions: 0/N`.
121
+ Check the daemon log once more for a newer `claimed task` message before
122
+ proceeding. If it claimed another task, let that session finish too.
123
+ 3. Stop the old process through its service manager, or send `SIGTERM`/press
124
+ Ctrl-C. Do not use `SIGKILL` (`kill -9`). Ensure the old process has exited
125
+ before starting its replacement so two claim loops never run for one
126
+ machine identity.
127
+ 4. Run `doctor` using the same service environment and
128
+ `NAVARCH_CONFIG_DIR`, then start the new version with that same environment.
129
+ Do **not** run `register` or `connect` again: the existing `machine.json`
130
+ contains the machine identity and token needed after the upgrade. Never
131
+ print, log, or copy the contents of `machine.json` or its token into upgrade
132
+ commands or diagnostics.
133
+ 5. Confirm the startup log reports the expected machine, agent, and API base,
134
+ then verify that Fleet shows the machine online with a fresh heartbeat.
135
+
136
+ For a supervised service, make the stop timeout long enough for step 3 to
137
+ observe a normal exit, and keep the service's environment file/config directory
138
+ unchanged across the deployment.
139
+
80
140
  ## Configuration (`NAVARCH_*` env vars)
81
141
 
82
142
  | Var | Default | Meaning |
@@ -87,27 +147,29 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
87
147
  | `NAVARCH_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
88
148
  | `NAVARCH_PROJECT_ID` | — | Alternative to `connect --project`. |
89
149
  | `NAVARCH_CONFIG_DIR` | `~/.navarch` | Where `machine.json` lives. |
90
- | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Per-session working directories (host side; mounted into each container). |
91
- | `NAVARCH_MAX_SESSIONS` | `3` | Capacity cap — see `src/capacity.cts`. |
92
- | `NAVARCH_CAPABILITIES` | `docker-sandbox,shell` | Comma list reported at heartbeat/claim time. |
150
+ | `NAVARCH_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Persistent bare repo caches plus isolated per-session worktrees. |
151
+ | `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
152
+ | `NAVARCH_CAPABILITIES` | `shell` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. |
93
153
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
94
154
  | `NAVARCH_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
95
155
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
96
156
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
97
157
  | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
98
- | `NAVARCH_SANDBOX_MODE` | `docker` | `docker` or `host` (host mode skips the container dev/debug only, no isolation). |
158
+ | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
99
159
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
100
- | `NAVARCH_AGENT` | `claude-code` | Which agent CLI runs sessions: `claude-code` or `codex`. Any other value falls back to `claude-code`. Recorded as `agent_type` on the `complete()` call so the sessions row shows which one ran. |
160
+ | `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. |
101
161
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
102
162
  | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
103
163
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
104
- | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config`/`--json` (Codex). |
164
+ | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
105
165
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
106
166
 
107
167
  ## Choosing an agent (Claude Code vs. Codex)
108
168
 
109
- Each machine runs sessions with exactly one agent CLI, set once via
110
- `NAVARCH_AGENT`:
169
+ Each machine chooses its own agent CLI. Pass `--agent` while connecting to
170
+ persist the choice in local `machine.json`, override it for one daemon start
171
+ with `start --agent`, or set `NAVARCH_AGENT` in the machine's service
172
+ environment:
111
173
 
112
174
  ```sh
113
175
  # Claude Code (default) — requires the `claude` CLI installed and
@@ -120,6 +182,9 @@ export NAVARCH_AGENT=claude-code
120
182
  export NAVARCH_AGENT=codex
121
183
  ```
122
184
 
185
+ Priority is `start --agent` → `NAVARCH_AGENT` → the locally saved choice →
186
+ `claude-code`. The control plane does not choose the adapter.
187
+
123
188
  Both adapters implement the same `AgentAdapter` interface
124
189
  (`src/adapters/types.cts`) and run either directly on the host or via
125
190
  `docker exec` in the session's sandbox container, exactly like the Claude
@@ -127,15 +192,20 @@ adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
127
192
  `selectAdapter`) at the start of each session and passes `agent_type`
128
193
  through to `complete()` unchanged by whatever happened during the run.
129
194
 
130
- **The Codex CLI invocation (`codex exec "<prompt>" --json [--mcp-config
131
- <path>]`) is unverified** there is no `codex` binary available to test
132
- against in this offline build environment, so every flag and the JSONL
133
- output shape it's assumed to produce are inferred by analogy with the Claude
134
- Code adapter's own documented `--output-format json` assumption. See
135
- `src/adapters/codex.cts`'s module doc comment and
136
- `src/exit-conditions.cts`'s `parseCodexJsonEvents` doc comment for the full
137
- list of assumptions to confirm once a real `codex` binary is available, and
138
- "What needs live verification" below.
195
+ The control plane also resolves the project's model and the task's execution
196
+ profile on every claim. The runtime passes those values as per-session CLI
197
+ overrides (`codex exec --model ... -c model_reasoning_effort=...` or
198
+ `claude -p --model ... --effort ...`) and records the effective model, profile,
199
+ and effort on completion. Machine-wide extra arguments still configure other
200
+ CLI behavior; project/task policy wins for model and effort.
201
+
202
+ The Codex CLI invocation was verified against `codex-cli 0.144.1` on
203
+ 2026-07-18. The runtime uses `codex exec "<prompt>" --json` and translates
204
+ the existing per-session MCP JSON into one-off `-c mcp_servers.*` overrides.
205
+ Machine and lease credentials are referenced through environment variables,
206
+ not placed in argv. The JSONL parser accepts the verified top-level
207
+ `item.completed` / `turn.completed` shape and retains the older `msg`
208
+ envelope as a compatibility fallback.
139
209
 
140
210
  ## Architecture
141
211
 
@@ -149,16 +219,19 @@ cli.cts
149
219
  └─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
150
220
  1. write prompt.md (prompt.cts renders the 4-layer context bundle)
151
221
  2. api.issueSecrets() → held in memory only
152
- 3. DockerSandbox.create/injectEnv/cloneRepo (sandbox.cts)
222
+ 3. fetch the project's bare repository cache and create a
223
+ unique git worktree for this session; optionally mount it
224
+ into Docker when NAVARCH_SANDBOX_MODE=docker
153
225
  4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
154
226
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
155
227
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
156
- - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json --mcp-config <path>` (ASSUMED)
228
+ - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
157
229
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
158
230
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
159
231
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
160
232
  6. api.completeLease(), reporting agent_type: config.agentType
161
- 7. sandbox.wipe() unconditionally (finally block)
233
+ 7. sandbox.wipe() when present; remove the session workspace
234
+ unconditionally (finally block)
162
235
  ```
163
236
 
164
237
  `adapter.cts` (top-level) is now a backward-compat re-export of
@@ -186,43 +259,28 @@ package talks to `fetch` directly for control-plane traffic.
186
259
  (GitHub PAT shapes, PEM private keys, generic `sk-...` tokens) as
187
260
  defense-in-depth for values the registry didn't see directly.
188
261
 
189
- ## Assumptions to confirm (control-plane contracts not yet pinned)
262
+ ## Control-plane contracts
190
263
 
191
- `schema-design.md` §7 enumerates `POST /api/dispatch/claim`, `POST
192
- /api/dispatch/:leaseId/heartbeat`, `POST /api/dispatch/:leaseId/complete`, and
193
- `POST /api/broker/issue` — those are implemented in `api.cts` exactly as
194
- documented. Three routes WP-07 also needs are **not** enumerated there and
195
- were inferred from the closest analogous shape (each is called out in
196
- `types.cts` with an "ASSUMED" doc comment and isolated to one method in
197
- `api.cts`):
264
+ `schema-design.md` §7 and the matching control-plane routes define every HTTP
265
+ contract used by `api.cts`, including:
198
266
 
199
- 1. **`POST /api/machines/register`** — machine self-registration via a
200
- short-lived enrollment token (issued out-of-band by an owner/admin). The
201
- docs describe the *behavior* ("one command, prints machine token once")
202
- but WP-01/WP-04/WP-05 own the actual admin-console/enrollment-token flow.
267
+ 1. **`POST /api/machines/register`** — global machine registration via the
268
+ operator-configured enrollment secret.
203
269
  2. **`POST /api/machines/:id/heartbeat`** — a machine-level heartbeat
204
270
  distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
205
271
  /`status` must update even when no task is claimed.
206
272
  3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
207
- Supabase Storage upload URL, per `sessions.transcript_url`'s "object
208
- storage, not in DB" note and WP-07's "transcript → Supabase Storage via a
209
- control-plane upload URL" line.
210
-
211
- Reconciling any of these against the real routes, once WP-01/WP-04/WP-05
212
- land, means editing the corresponding method in `api.cts` — no other file
213
- should need to change.
273
+ Supabase Storage upload URL for the session's redacted transcript.
214
274
 
215
- `POST /api/machines/connect` (`api.cts#connectMachine`, the `connect`
216
- command) is **not** in this ASSUMED list it was built in the same change
217
- as `app/api/machines/connect/route.ts`, so the shapes in `types.cts`
218
- (`ConnectMachineRequest`/`ConnectMachineResult`) are confirmed against the
219
- real route, not inferred.
275
+ `POST /api/machines/connect` is the project-scoped alternative: it redeems a
276
+ single-use token minted by an owner through the onboarding or Fleet UI.
220
277
 
221
278
  ## What needs live verification
222
279
 
223
- This was built and tested offline (no Docker daemon, no `claude` or `codex`
224
- binary, no live control-plane API in this environment). Unit tests cover
225
- everything that can be verified without those:
280
+ Most runtime behavior is covered offline. The Codex host adapter was also
281
+ probed against a real authenticated `codex-cli 0.144.1`; Docker and the full
282
+ production control-plane lifecycle still require live verification. Unit
283
+ tests cover:
226
284
 
227
285
  - `api.cts` — request/response shapes, error mapping, auth header handling (`tests/api.test.cts`).
228
286
  - `exit-conditions.cts` — every exit-condition → complete/fail mapping, priority order,
@@ -261,48 +319,19 @@ secrets absent from disk after exit"):
261
319
  `costUsd` fall back to 0 (session.cts's existing default) — never a thrown
262
320
  error — but cost/spend data silently goes missing until the shape is
263
321
  reconciled here.
264
- - **Everything about the Codex CLI adapter (`adapters/codex.cts`) there is
265
- no `codex` binary to test against here at all, unlike Claude Code where at
266
- least the flag name and general headless-JSON shape are documented.** Every
267
- one of the following is an unverified guess, each flagged "ASSUMED — CONFIRM
268
- AGAINST A REAL codex BINARY" at its point of use:
269
- - That `codex exec "<prompt>"` is the right non-interactive/headless
270
- invocation at all (the `claude -p` analog).
271
- - That `--json` is a real flag and that it switches output to
272
- newline-delimited JSON "event" objects (`{"id": "...", "msg": {"type":
273
- ..., ...}}`) rather than, say, a single JSON object like Claude's
274
- `--output-format json`, or no structured-output flag at all.
275
- - That the event `msg.type` values used here (`agent_message`,
276
- `token_count`, `task_complete`) exist, are spelled this way, and that
277
- `token_count` events carry cumulative (not incremental/per-turn) totals —
278
- `exit-conditions.cts#extractUsageFromCodexEvents` takes the *last* such
279
- event's numbers, which double-counts or under-counts if that guess is
280
- wrong.
281
- - That `--mcp-config <path>` is accepted at all by `codex exec` — Codex CLI
282
- documentation elsewhere describes MCP servers configured via
283
- `~/.codex/config.toml`, not a per-invocation flag, so this may need to
284
- become "write a config fragment into the sandbox first" instead.
285
- - Whether a real `codex exec` run needs an explicit non-interactive/
286
- approval-bypass flag (e.g. something like `--full-auto` or
287
- `--dangerously-bypass-approvals-and-sandbox` per published Codex CLI
288
- docs) to avoid blocking on an approval prompt inside the already-isolated
289
- Docker sandbox — deliberately **not** hardcoded, left to
290
- `NAVARCH_CODEX_EXTRA_ARGS` until confirmed, since guessing the wrong
291
- flag here could silently disable sandboxing rather than just fail loudly.
292
- - Whether the Codex CLI even authenticates/runs non-interactively the same
293
- way `claude` does (API key vs. ChatGPT-account OAuth device flow) — this
294
- adapter assumes "already authenticated on the machine" exactly like the
295
- Claude Code prerequisite, but cannot confirm that story is equivalent.
296
-
297
- If any of this is wrong, `parseCodexJsonEvents` returns an empty array and
298
- the adapter's `attachUsage()` leaves `tokensIn`/`tokensOut`/`costUsd`/
299
- `reportText` unset — `mapExitCondition` then falls back to raw stdout for
300
- the report — same never-throw degradation as the Claude path, just with
301
- more to reconcile once a real binary exists.
322
+ - A full Codex task that initializes the production MCP server, edits a
323
+ worktree, pushes a branch, opens a PR, and completes its lease. The CLI
324
+ flags, per-run MCP override keys, stdin behavior, and JSONL event/usage
325
+ shape are now verified locally; the next dogfood run covers their
326
+ production composition.
327
+ - Whether Docker-mode Codex should opt into
328
+ `--dangerously-bypass-approvals-and-sandbox`. It is intentionally not a
329
+ default: host mode is not an external sandbox, and silently disabling
330
+ Codex's protections there would be unsafe. Operators can still add the
331
+ flag explicitly through `NAVARCH_CODEX_EXTRA_ARGS` on an isolated machine.
302
332
  - The full Docker sandbox lifecycle against a real Docker daemon (rootless
303
333
  behavior, tmpfs env injection, git clone with a real PAT, `docker exec`
304
334
  timeout/kill semantics under `AbortSignal`).
305
- - The three assumed endpoints above, once they exist.
306
335
  - Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
307
336
  already-reassigned lease) — `session.cts` aborts the running adapter
308
337
  process and still attempts a best-effort `complete()` call, which the
@@ -340,13 +369,3 @@ not match, making it a clean, zero-touch way to keep this package fully
340
369
  isolated. Vite/Vitest's default esbuild transform filter also excludes
341
370
  `.cts` by default — `vitest.config.cts` overrides it (`esbuild.include`) so
342
371
  tests are actually type-stripped and run.
343
-
344
- ## Discovered scope (flagging, not editing the plan doc)
345
-
346
- - Machine registration, machine-level heartbeat, and the transcript
347
- upload-URL endpoint are not in `schema-design.md` §7's API surface list —
348
- see "Assumptions to confirm" above. Recommend WP-01/WP-04/WP-05 add these
349
- three routes explicitly to the schema doc once designed.
350
- - No admin-console UI exists yet to actually mint an `enrollment_token` for
351
- `register` to redeem — that's WP-08 (Fleet view) / WP-01 (admin console)
352
- territory; this package assumes it will exist.
@@ -37,7 +37,19 @@ async function runClaudeCodeAdapter(options) {
37
37
  args.push("--output-format", "json");
38
38
  }
39
39
  args.push(...options.extraArgs);
40
- const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
40
+ // Project/task policy is appended after machine-wide extra arguments so a
41
+ // dispatched session consistently uses the settings recorded by Navarch.
42
+ if (options.model)
43
+ args.push("--model", options.model);
44
+ if (options.reasoningEffort)
45
+ args.push("--effort", options.reasoningEffort);
46
+ const runOptions = options.reasoningEffort
47
+ ? {
48
+ ...options,
49
+ env: { ...options.env, CLAUDE_CODE_EFFORT_LEVEL: options.reasoningEffort },
50
+ }
51
+ : options;
52
+ const raw = runOptions.dockerExec ? await runViaDocker(runOptions, args) : await runOnHost(runOptions, args);
41
53
  return attachUsage(raw);
42
54
  }
43
55
  /** Parses stdout for `claude -p --output-format json` usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.codexAdapter = void 0;
4
4
  exports.runCodexAdapter = runCodexAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
+ const node_fs_1 = require("node:fs");
6
7
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
8
  /**
8
9
  * Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
@@ -13,31 +14,21 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
13
14
  * timeout/AbortSignal handling, same "attach best-effort usage onto the raw
14
15
  * result" shape — only the CLI invocation and output parsing differ.
15
16
  *
16
- * ASSUMED EVERYTHING BELOW ABOUT THE REAL `codex` BINARY MUST BE CONFIRMED
17
- * AGAINST A REAL INSTALL. There is no `codex` binary available in this
18
- * offline build environment, so (mirroring how claude.cts documents its own
19
- * `claude -p --output-format json` assumption) this adapter is a best-effort
20
- * implementation against the Codex CLI's publicly documented shape, not a
21
- * verified one:
17
+ * Verified against codex-cli 0.144.1 on 2026-07-18:
22
18
  *
23
- * codex exec "<prompt>" --json [--mcp-config <path>] [...extraArgs]
19
+ * codex exec "<prompt>" --json [-c mcp_servers.<name>.<key>=<value>] [...extraArgs]
24
20
  *
25
21
  * - `exec <prompt>` — ASSUMED to be Codex CLI's non-interactive/headless
26
22
  * subcommand (the `codex exec` "automation mode" analog of `claude -p`):
27
23
  * runs the prompt to completion without the interactive TUI and exits,
28
24
  * printing its result to stdout.
29
- * - `--json` ASSUMED to switch Codex CLI's output to newline-delimited
30
- * JSON ("JSONL") event objects rather than a single JSON result object
31
- * like Claude Code's `--output-format json`. See exit-conditions.cts's
32
- * parseCodexJsonEvents doc comment for the exact assumed event shape and
33
- * what happens when this guess is wrong (graceful degradation, never a
34
- * thrown error).
35
- * - `--mcp-config <path>` ASSUMED by analogy with the Claude adapter; there
36
- * is no confirmed Codex CLI flag of this name. Codex CLI is documented
37
- * elsewhere to configure MCP servers via a `~/.codex/config.toml`
38
- * `mcp_servers` table rather than a per-invocation flag, so this flag may
39
- * need to become "write a config.toml fragment into the sandbox before
40
- * exec" instead once a real binary is available to test against.
25
+ * - `--json` emits top-level `thread.started`, `turn.started`,
26
+ * `item.completed`, and `turn.completed` JSONL events. See
27
+ * exit-conditions.cts for the tolerant parser.
28
+ * - Codex has no `--mcp-config` flag. The runtime's existing Claude-shaped
29
+ * per-session JSON is translated into one-off `-c mcp_servers.*` overrides.
30
+ * Authentication and custom-header values are passed through environment
31
+ * variables so machine/lease credentials never appear in argv.
41
32
  * - Sandboxing/approvals: a real `codex exec` may prompt for
42
33
  * approval/sandbox-escalation on some actions by default; because this
43
34
  * runtime already isolates the session in its own Docker container (or,
@@ -55,17 +46,65 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
55
46
  */
56
47
  async function runCodexAdapter(options) {
57
48
  const args = ["exec", options.prompt];
58
- if (options.mcpConfigPath) {
59
- // ASSUMED flag/support — see module doc comment above.
60
- args.push("--mcp-config", options.mcpConfigPath);
61
- }
49
+ const env = { ...options.env };
50
+ if (options.mcpConfigPath)
51
+ args.push(...(await codexMcpArgs(options.mcpConfigPath, env)));
62
52
  if (!options.extraArgs.includes("--json")) {
63
53
  args.push("--json");
64
54
  }
65
55
  args.push(...options.extraArgs);
66
- const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
56
+ if (options.model)
57
+ args.push("--model", options.model);
58
+ if (options.reasoningEffort) {
59
+ args.push("-c", `model_reasoning_effort=${tomlString(options.reasoningEffort)}`);
60
+ }
61
+ const runOptions = { ...options, env };
62
+ const raw = runOptions.dockerExec
63
+ ? await runViaDocker(runOptions, args)
64
+ : await runOnHost(runOptions, args);
67
65
  return attachUsage(raw);
68
66
  }
67
+ /** Convert Claude's per-session MCP JSON into Codex one-off TOML overrides. */
68
+ async function codexMcpArgs(path, env) {
69
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(path, "utf8"));
70
+ const args = [];
71
+ for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
72
+ if (!server.url)
73
+ continue;
74
+ const key = tomlKey(name);
75
+ args.push("-c", `mcp_servers.${key}.url=${tomlString(server.url)}`);
76
+ args.push("-c", `mcp_servers.${key}.required=true`);
77
+ const envHeaders = {};
78
+ let headerIndex = 0;
79
+ for (const [header, value] of Object.entries(server.headers ?? {})) {
80
+ const envName = `NAVARCH_CODEX_MCP_${safeEnvSegment(name)}_${headerIndex++}`;
81
+ if (header.toLowerCase() === "authorization" && value.startsWith("Bearer ")) {
82
+ env[envName] = value.slice("Bearer ".length);
83
+ args.push("-c", `mcp_servers.${key}.bearer_token_env_var=${tomlString(envName)}`);
84
+ }
85
+ else {
86
+ env[envName] = value;
87
+ envHeaders[header] = envName;
88
+ }
89
+ }
90
+ if (Object.keys(envHeaders).length > 0) {
91
+ const table = Object.entries(envHeaders)
92
+ .map(([header, envName]) => `${tomlString(header)}=${tomlString(envName)}`)
93
+ .join(",");
94
+ args.push("-c", `mcp_servers.${key}.env_http_headers={${table}}`);
95
+ }
96
+ }
97
+ return args;
98
+ }
99
+ function tomlString(value) {
100
+ return JSON.stringify(value);
101
+ }
102
+ function tomlKey(value) {
103
+ return /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value);
104
+ }
105
+ function safeEnvSegment(value) {
106
+ return value.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
107
+ }
69
108
  /** Parses stdout for `codex exec --json` usage/final-message events and folds them onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd/reportText unset when nothing parses — see exit-conditions.cts#parseCodexJsonEvents). */
70
109
  function attachUsage(result) {
71
110
  const events = (0, exit_conditions_cjs_1.parseCodexJsonEvents)(result.stdout);
@@ -91,6 +130,10 @@ async function runOnHost(options, args) {
91
130
  cwd: options.cwd,
92
131
  env: { ...process.env, ...options.env },
93
132
  });
133
+ // `codex exec` appends piped stdin to the prompt. The spawned process gets
134
+ // a pipe by default, so close it immediately or it can wait forever for
135
+ // input even though the full prompt was supplied as an argument.
136
+ child.stdin?.end();
94
137
  const timer = setTimeout(() => {
95
138
  timedOut = true;
96
139
  child.kill("SIGKILL");
@@ -133,8 +176,13 @@ async function runViaDocker(options, args) {
133
176
  };
134
177
  options.signal?.addEventListener("abort", onAbort, { once: true });
135
178
  try {
136
- const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
179
+ // Forward values through the docker CLI process environment and put only
180
+ // variable names in argv. This keeps generated MCP bearer/header values
181
+ // out of `ps` while making the host and Docker paths equivalent.
182
+ const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
183
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], {
137
184
  timeoutMs: options.timeoutMs,
185
+ env: { ...process.env, ...options.env },
138
186
  });
139
187
  return {
140
188
  exitCode: result.code,
package/dist/api.cjs CHANGED
@@ -15,13 +15,8 @@ exports.NavarchApiError = NavarchApiError;
15
15
  /**
16
16
  * Typed client for the Navarch control-plane API surface WP-07 depends on:
17
17
  * dispatch/claim, per-lease heartbeat, complete, and broker/issue
18
- * (schema-design.md §7, quoted verbatim in the method docs below), plus the
19
- * machine-registration / machine-heartbeat / transcript-upload-url routes
20
- * this package assumes (see types.cts doc comments — flagged "ASSUMED").
21
- *
22
- * Every request path lives in exactly one method here so wiring up the real
23
- * deployment, once WP-01/WP-04/WP-05 land, is a base URL + token (and at most
24
- * a one-line path fix for the assumed routes) — not a rewrite.
18
+ * (schema-design.md §7), plus machine registration, machine heartbeat, and
19
+ * transcript upload. Every request path lives in exactly one method here.
25
20
  */
26
21
  class NavarchApiClient {
27
22
  baseUrl;
@@ -65,7 +60,7 @@ class NavarchApiClient {
65
60
  return null;
66
61
  return parsed;
67
62
  }
68
- /** ASSUMED endpoint see types.cts RegisterMachineRequest doc comment. */
63
+ /** Global machine registration using the operator-configured enrollment secret. */
69
64
  async registerMachine(req) {
70
65
  const result = await this.request("POST", "/api/machines/register", req, { auth: false });
71
66
  if (!result)
@@ -87,7 +82,7 @@ class NavarchApiClient {
87
82
  throw new Error("connectMachine: empty response from control plane.");
88
83
  return result;
89
84
  }
90
- /** ASSUMED endpoint see types.cts MachineHeartbeatRequest doc comment. */
85
+ /** Machine-level capacity heartbeat, independent of lease heartbeats. */
91
86
  async machineHeartbeat(machineId, req) {
92
87
  const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/heartbeat`, req);
93
88
  if (!result)
@@ -102,8 +97,8 @@ class NavarchApiClient {
102
97
  return result;
103
98
  }
104
99
  /** schema-design.md §7 — `POST /api/dispatch/:leaseId/heartbeat`. */
105
- async heartbeatLease(leaseId) {
106
- const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/heartbeat`, {});
100
+ async heartbeatLease(leaseId, req = {}) {
101
+ const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/heartbeat`, req);
107
102
  if (!result)
108
103
  throw new Error("heartbeatLease: empty response from control plane.");
109
104
  return result;
@@ -121,7 +116,7 @@ class NavarchApiClient {
121
116
  throw new Error("issueSecrets: empty response from control plane.");
122
117
  return result;
123
118
  }
124
- /** ASSUMED endpoint see types.cts TranscriptUploadUrlResult doc comment. */
119
+ /** Create a signed upload target for this lease's redacted transcript. */
125
120
  async getTranscriptUploadUrl(leaseId) {
126
121
  const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/transcript-upload-url`, {});
127
122
  if (!result)
@@ -19,6 +19,7 @@ class ClaimLoop {
19
19
  runSession;
20
20
  timer = null;
21
21
  stopped = false;
22
+ claimInFlight = false;
22
23
  constructor(api, config, capacity, runSession) {
23
24
  this.api = api;
24
25
  this.config = config;
@@ -37,8 +38,9 @@ class ClaimLoop {
37
38
  this.timer = null;
38
39
  }
39
40
  async tick() {
40
- if (this.stopped || !this.capacity.hasCapacity())
41
+ if (this.stopped || this.claimInFlight || !this.capacity.hasCapacity())
41
42
  return;
43
+ this.claimInFlight = true;
42
44
  try {
43
45
  // Pre-allocate the session id BEFORE claiming (author-exclusion timing):
44
46
  // the dispatcher records it on the lease and excludes it from review
@@ -47,6 +49,7 @@ class ClaimLoop {
47
49
  const claimed = await this.api.claim({
48
50
  available_capacity: this.capacity.available(),
49
51
  capabilities: this.config.capabilities,
52
+ agent_type: this.config.agentType,
50
53
  session_id: sessionId,
51
54
  });
52
55
  if (!claimed)
@@ -70,6 +73,9 @@ class ClaimLoop {
70
73
  log.warn(`claim failed: ${String(err)}`);
71
74
  }
72
75
  }
76
+ finally {
77
+ this.claimInFlight = false;
78
+ }
73
79
  }
74
80
  }
75
81
  exports.ClaimLoop = ClaimLoop;