@sagentlab/navarch-runtime 0.1.2 → 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
@@ -78,6 +78,65 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
78
78
  | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
79
79
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
80
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
+
81
140
  ## Configuration (`NAVARCH_*` env vars)
82
141
 
83
142
  | Var | Default | Meaning |
@@ -102,7 +161,7 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
102
161
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
103
162
  | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
104
163
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
105
- | `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). |
106
165
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
107
166
 
108
167
  ## Choosing an agent (Claude Code vs. Codex)
@@ -133,15 +192,20 @@ adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
133
192
  `selectAdapter`) at the start of each session and passes `agent_type`
134
193
  through to `complete()` unchanged by whatever happened during the run.
135
194
 
136
- **The Codex CLI invocation (`codex exec "<prompt>" --json [--mcp-config
137
- <path>]`) is unverified** there is no `codex` binary available to test
138
- against in this offline build environment, so every flag and the JSONL
139
- output shape it's assumed to produce are inferred by analogy with the Claude
140
- Code adapter's own documented `--output-format json` assumption. See
141
- `src/adapters/codex.cts`'s module doc comment and
142
- `src/exit-conditions.cts`'s `parseCodexJsonEvents` doc comment for the full
143
- list of assumptions to confirm once a real `codex` binary is available, and
144
- "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.
145
209
 
146
210
  ## Architecture
147
211
 
@@ -161,7 +225,7 @@ cli.cts
161
225
  4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
162
226
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
163
227
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
164
- - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json --mcp-config <path>` (ASSUMED)
228
+ - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
165
229
  heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
166
230
  a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
167
231
  5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
@@ -195,43 +259,28 @@ package talks to `fetch` directly for control-plane traffic.
195
259
  (GitHub PAT shapes, PEM private keys, generic `sk-...` tokens) as
196
260
  defense-in-depth for values the registry didn't see directly.
197
261
 
198
- ## Assumptions to confirm (control-plane contracts not yet pinned)
262
+ ## Control-plane contracts
199
263
 
200
- `schema-design.md` §7 enumerates `POST /api/dispatch/claim`, `POST
201
- /api/dispatch/:leaseId/heartbeat`, `POST /api/dispatch/:leaseId/complete`, and
202
- `POST /api/broker/issue` — those are implemented in `api.cts` exactly as
203
- documented. Three routes WP-07 also needs are **not** enumerated there and
204
- were inferred from the closest analogous shape (each is called out in
205
- `types.cts` with an "ASSUMED" doc comment and isolated to one method in
206
- `api.cts`):
264
+ `schema-design.md` §7 and the matching control-plane routes define every HTTP
265
+ contract used by `api.cts`, including:
207
266
 
208
- 1. **`POST /api/machines/register`** — machine self-registration via a
209
- short-lived enrollment token (issued out-of-band by an owner/admin). The
210
- docs describe the *behavior* ("one command, prints machine token once")
211
- 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.
212
269
  2. **`POST /api/machines/:id/heartbeat`** — a machine-level heartbeat
213
270
  distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
214
271
  /`status` must update even when no task is claimed.
215
272
  3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
216
- Supabase Storage upload URL, per `sessions.transcript_url`'s "object
217
- storage, not in DB" note and WP-07's "transcript → Supabase Storage via a
218
- control-plane upload URL" line.
219
-
220
- Reconciling any of these against the real routes, once WP-01/WP-04/WP-05
221
- land, means editing the corresponding method in `api.cts` — no other file
222
- should need to change.
273
+ Supabase Storage upload URL for the session's redacted transcript.
223
274
 
224
- `POST /api/machines/connect` (`api.cts#connectMachine`, the `connect`
225
- command) is **not** in this ASSUMED list it was built in the same change
226
- as `app/api/machines/connect/route.ts`, so the shapes in `types.cts`
227
- (`ConnectMachineRequest`/`ConnectMachineResult`) are confirmed against the
228
- 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.
229
277
 
230
278
  ## What needs live verification
231
279
 
232
- This was built and tested offline (no Docker daemon, no `claude` or `codex`
233
- binary, no live control-plane API in this environment). Unit tests cover
234
- 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:
235
284
 
236
285
  - `api.cts` — request/response shapes, error mapping, auth header handling (`tests/api.test.cts`).
237
286
  - `exit-conditions.cts` — every exit-condition → complete/fail mapping, priority order,
@@ -270,48 +319,19 @@ secrets absent from disk after exit"):
270
319
  `costUsd` fall back to 0 (session.cts's existing default) — never a thrown
271
320
  error — but cost/spend data silently goes missing until the shape is
272
321
  reconciled here.
273
- - **Everything about the Codex CLI adapter (`adapters/codex.cts`) there is
274
- no `codex` binary to test against here at all, unlike Claude Code where at
275
- least the flag name and general headless-JSON shape are documented.** Every
276
- one of the following is an unverified guess, each flagged "ASSUMED — CONFIRM
277
- AGAINST A REAL codex BINARY" at its point of use:
278
- - That `codex exec "<prompt>"` is the right non-interactive/headless
279
- invocation at all (the `claude -p` analog).
280
- - That `--json` is a real flag and that it switches output to
281
- newline-delimited JSON "event" objects (`{"id": "...", "msg": {"type":
282
- ..., ...}}`) rather than, say, a single JSON object like Claude's
283
- `--output-format json`, or no structured-output flag at all.
284
- - That the event `msg.type` values used here (`agent_message`,
285
- `token_count`, `task_complete`) exist, are spelled this way, and that
286
- `token_count` events carry cumulative (not incremental/per-turn) totals —
287
- `exit-conditions.cts#extractUsageFromCodexEvents` takes the *last* such
288
- event's numbers, which double-counts or under-counts if that guess is
289
- wrong.
290
- - That `--mcp-config <path>` is accepted at all by `codex exec` — Codex CLI
291
- documentation elsewhere describes MCP servers configured via
292
- `~/.codex/config.toml`, not a per-invocation flag, so this may need to
293
- become "write a config fragment into the sandbox first" instead.
294
- - Whether a real `codex exec` run needs an explicit non-interactive/
295
- approval-bypass flag (e.g. something like `--full-auto` or
296
- `--dangerously-bypass-approvals-and-sandbox` per published Codex CLI
297
- docs) to avoid blocking on an approval prompt inside the already-isolated
298
- Docker sandbox — deliberately **not** hardcoded, left to
299
- `NAVARCH_CODEX_EXTRA_ARGS` until confirmed, since guessing the wrong
300
- flag here could silently disable sandboxing rather than just fail loudly.
301
- - Whether the Codex CLI even authenticates/runs non-interactively the same
302
- way `claude` does (API key vs. ChatGPT-account OAuth device flow) — this
303
- adapter assumes "already authenticated on the machine" exactly like the
304
- Claude Code prerequisite, but cannot confirm that story is equivalent.
305
-
306
- If any of this is wrong, `parseCodexJsonEvents` returns an empty array and
307
- the adapter's `attachUsage()` leaves `tokensIn`/`tokensOut`/`costUsd`/
308
- `reportText` unset — `mapExitCondition` then falls back to raw stdout for
309
- the report — same never-throw degradation as the Claude path, just with
310
- 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.
311
332
  - The full Docker sandbox lifecycle against a real Docker daemon (rootless
312
333
  behavior, tmpfs env injection, git clone with a real PAT, `docker exec`
313
334
  timeout/kill semantics under `AbortSignal`).
314
- - The three assumed endpoints above, once they exist.
315
335
  - Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
316
336
  already-reassigned lease) — `session.cts` aborts the running adapter
317
337
  process and still attempts a best-effort `complete()` call, which the
@@ -349,13 +369,3 @@ not match, making it a clean, zero-touch way to keep this package fully
349
369
  isolated. Vite/Vitest's default esbuild transform filter also excludes
350
370
  `.cts` by default — `vitest.config.cts` overrides it (`esbuild.include`) so
351
371
  tests are actually type-stripped and run.
352
-
353
- ## Discovered scope (flagging, not editing the plan doc)
354
-
355
- - Machine registration, machine-level heartbeat, and the transcript
356
- upload-URL endpoint are not in `schema-design.md` §7's API surface list —
357
- see "Assumptions to confirm" above. Recommend WP-01/WP-04/WP-05 add these
358
- three routes explicitly to the schema doc once designed.
359
- - No admin-console UI exists yet to actually mint an `enrollment_token` for
360
- `register` to redeem — that's WP-08 (Fleet view) / WP-01 (admin console)
361
- 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
@@ -71,6 +73,9 @@ class ClaimLoop {
71
73
  log.warn(`claim failed: ${String(err)}`);
72
74
  }
73
75
  }
76
+ finally {
77
+ this.claimInFlight = false;
78
+ }
74
79
  }
75
80
  }
76
81
  exports.ClaimLoop = ClaimLoop;
@@ -1,17 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.extractPrUrl = extractPrUrl;
4
3
  exports.parseClaudeJsonResult = parseClaudeJsonResult;
5
4
  exports.extractUsageFromClaudeJson = extractUsageFromClaudeJson;
6
5
  exports.parseCodexJsonEvents = parseCodexJsonEvents;
7
6
  exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
8
7
  exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
9
8
  exports.mapExitCondition = mapExitCondition;
10
- const PR_URL_PATTERN = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/;
11
- function extractPrUrl(text) {
12
- const match = text.match(PR_URL_PATTERN);
13
- return match ? match[0] : null;
14
- }
15
9
  /**
16
10
  * Best-effort parse of `claude -p --output-format json` stdout into the
17
11
  * assumed result shape above. Tries the whole trimmed stdout first (the
@@ -69,10 +63,9 @@ function extractUsageFromClaudeJson(parsed) {
69
63
  }
70
64
  /**
71
65
  * Best-effort line-by-line parse of `codex exec --json` stdout into the
72
- * assumed JSONL event shape above. Silently skips any line that isn't a JSON
73
- * object carrying a `msg` field (blank lines, stray log lines, a shape that
74
- * doesn't match the guess) rather than throwing; returns an empty array (not
75
- * null) when nothing parses, since a run can legitimately emit zero events.
66
+ * verified JSONL event shape above. Silently skips any line that isn't a
67
+ * recognized JSON event (blank lines, stray log lines, unrelated objects)
68
+ * rather than throwing; returns an empty array when nothing parses.
76
69
  */
77
70
  function parseCodexJsonEvents(stdout) {
78
71
  const events = [];
@@ -83,8 +76,10 @@ function parseCodexJsonEvents(stdout) {
83
76
  for (const line of lines) {
84
77
  try {
85
78
  const parsed = JSON.parse(line);
86
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && "msg" in parsed) {
87
- events.push(parsed);
79
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
80
+ const event = parsed;
81
+ if (typeof event.type === "string" || event.msg !== undefined)
82
+ events.push(event);
88
83
  }
89
84
  }
90
85
  catch {
@@ -94,17 +89,20 @@ function parseCodexJsonEvents(stdout) {
94
89
  return events;
95
90
  }
96
91
  /**
97
- * Extracts token/cost usage from parsed `codex exec --json` events. Assumes
98
- * each `token_count` event reports the run's cumulative totals so far (like
99
- * Claude Code's running usage updates), so the *last* such event wins rather
100
- * than summing across events; if that assumption is wrong (incremental
101
- * per-turn deltas instead), this under- or over-counts until reconciled here.
92
+ * Extracts token/cost usage from parsed `codex exec --json` events. The last
93
+ * `turn.completed` event wins. Legacy `token_count` events remain supported.
102
94
  */
103
95
  function extractUsageFromCodexEvents(events) {
104
96
  let tokensIn = 0;
105
97
  let tokensOut = 0;
106
98
  let costUsd = 0;
107
99
  for (const event of events) {
100
+ if (event.type === "turn.completed" && event.usage) {
101
+ tokensIn = event.usage.input_tokens ?? 0;
102
+ tokensOut = event.usage.output_tokens ?? 0;
103
+ if (typeof event.usage.total_cost_usd === "number")
104
+ costUsd = event.usage.total_cost_usd;
105
+ }
108
106
  if (event.msg?.type === "token_count") {
109
107
  tokensIn = (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
110
108
  tokensOut = event.msg.output_tokens ?? 0;
@@ -115,9 +113,15 @@ function extractUsageFromCodexEvents(events) {
115
113
  }
116
114
  return { tokensIn, tokensOut, costUsd };
117
115
  }
118
- /** The last `agent_message` event's text — assumed to be the run's final agent-visible output, mirroring Claude JSON result's `result` field. Returns null when no such event parsed (see parseCodexJsonEvents doc comment). */
116
+ /** The last completed agent message, with legacy `msg.agent_message` fallback. */
119
117
  function extractFinalMessageFromCodexEvents(events) {
120
118
  for (let i = events.length - 1; i >= 0; i--) {
119
+ const item = events[i]?.item;
120
+ if (events[i]?.type === "item.completed" &&
121
+ item?.type === "agent_message" &&
122
+ typeof item.text === "string") {
123
+ return item.text;
124
+ }
121
125
  const msg = events[i]?.msg;
122
126
  if (msg?.type === "agent_message" && typeof msg.message === "string")
123
127
  return msg.message;
@@ -139,8 +143,11 @@ function summarize(text, maxLen = 500) {
139
143
  * exit > clean exit.
140
144
  */
141
145
  function mapExitCondition(result) {
142
- const prUrl = extractPrUrl(result.stdout) ?? extractPrUrl(result.stderr);
143
- const evidenceUrls = prUrl ? [prUrl] : [];
146
+ // Agent output is untrusted evidence, including the final completion
147
+ // message. session.cts resolves PR evidence independently through GitHub
148
+ // using this session's exact repository and worktree head branch.
149
+ const parsedJson = parseClaudeJsonResult(result.stdout);
150
+ const evidenceUrls = [];
144
151
  if (result.killedByLeaseLoss) {
145
152
  return {
146
153
  leaseOutcome: "failed",
@@ -179,9 +186,20 @@ function mapExitCondition(result) {
179
186
  // json` blob so report_summary doesn't end up being a dumped JSON object;
180
187
  // falls back to raw stdout when neither is available (see
181
188
  // parseClaudeJsonResult's doc comment).
182
- const parsedJson = parseClaudeJsonResult(result.stdout);
183
189
  const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
184
190
  const reportSummary = summarize(reportText) || "Adapter completed with no report text.";
191
+ // Headless agent CLIs normally exit 0 after producing a final response,
192
+ // including when that response says the task could not start. Treat an
193
+ // explicit leading blocked verdict as a failed lease so the dispatcher can
194
+ // retry/escalate it instead of falsely moving unfinished work to Done.
195
+ if (/^blocked(?:\s|:|$)/i.test(reportText.trim())) {
196
+ return {
197
+ leaseOutcome: "failed",
198
+ exitStatus: "failed",
199
+ reportSummary,
200
+ evidenceUrls,
201
+ };
202
+ }
185
203
  return {
186
204
  leaseOutcome: "completed",
187
205
  exitStatus: "completed",
@@ -46,15 +46,18 @@ class GitWorktree {
46
46
  await this.runGit(["--git-dir", this.repositoryPath, "remote", "set-url", "origin", this.cloneUrl], false);
47
47
  }
48
48
  }
49
- const remoteHead = await this.runGit(["ls-remote", "--symref", "origin", "HEAD"], true);
50
- const startRef = parseRemoteHead(remoteHead.stdout) ?? "HEAD";
49
+ const remoteHead = await this.runGit(["--git-dir", this.repositoryPath, "ls-remote", "--symref", "origin", "HEAD"], true);
50
+ const defaultBranch = parseRemoteHead(remoteHead.stdout);
51
+ const startRef = defaultBranch
52
+ ? defaultBranch.replace(/^refs\/heads\//, "refs/remotes/origin/")
53
+ : "HEAD";
51
54
  await this.runGit([
52
55
  "--git-dir",
53
56
  this.repositoryPath,
54
57
  "fetch",
55
58
  "--prune",
56
59
  "origin",
57
- "+refs/heads/*:refs/heads/*",
60
+ "+refs/heads/*:refs/remotes/origin/*",
58
61
  ], true);
59
62
  await this.runGit([
60
63
  "--git-dir",
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findHeadBranchPullRequestUrl = findHeadBranchPullRequestUrl;
4
+ const sandbox_cjs_1 = require("./sandbox.cjs");
5
+ async function readHostGhToken() {
6
+ // Keep the credential in memory and out of shell parsing, argv, and logs.
7
+ const result = await sandbox_cjs_1.nodeCommandRunner.run("gh", ["auth", "token", "--hostname", "github.com"], { timeoutMs: 5_000 });
8
+ if (result.code !== 0)
9
+ return undefined;
10
+ const token = result.stdout.trim();
11
+ return token && !/\s/.test(token) ? token : undefined;
12
+ }
13
+ async function resolveGitHubToken(options) {
14
+ if (options.githubToken)
15
+ return options.githubToken;
16
+ try {
17
+ // Resolution is intentionally best-effort so public-repository lookup can
18
+ // still proceed when gh is absent or has no authenticated host account.
19
+ return await (options.hostTokenProvider ?? readHostGhToken)();
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * Finds the PR opened from this session's exact same-repository head branch.
27
+ * GitHub's head filter narrows the response, and the response is checked again
28
+ * before its URL is trusted so another repository or branch can never be
29
+ * attached as completion evidence.
30
+ */
31
+ async function findHeadBranchPullRequestUrl(options) {
32
+ const [owner, name, ...extra] = options.repository.split("/");
33
+ if (!owner || !name || extra.length > 0) {
34
+ throw new Error(`Invalid GitHub repository name: ${options.repository}`);
35
+ }
36
+ const url = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
37
+ url.searchParams.set("state", "all");
38
+ url.searchParams.set("head", `${owner}:${options.headBranch}`);
39
+ url.searchParams.set("per_page", "10");
40
+ const headers = {
41
+ accept: "application/vnd.github+json",
42
+ "user-agent": "navarch-runtime",
43
+ "x-github-api-version": "2022-11-28",
44
+ };
45
+ const githubToken = await resolveGitHubToken(options);
46
+ if (githubToken)
47
+ headers.authorization = `Bearer ${githubToken}`;
48
+ const response = await (options.fetchImpl ?? fetch)(url, { headers });
49
+ if (!response.ok) {
50
+ throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
51
+ }
52
+ const body = await response.json();
53
+ if (!Array.isArray(body)) {
54
+ throw new Error("GitHub pull request lookup returned a non-array response");
55
+ }
56
+ const normalizedRepository = options.repository.toLowerCase();
57
+ for (const candidate of body) {
58
+ if (candidate.head?.ref !== options.headBranch)
59
+ continue;
60
+ const headRepository = candidate.head.repo?.full_name;
61
+ if (typeof headRepository !== "string")
62
+ continue;
63
+ if (headRepository.toLowerCase() !== normalizedRepository)
64
+ continue;
65
+ if (typeof candidate.html_url !== "string")
66
+ continue;
67
+ if (!isPullRequestUrlForRepository(candidate.html_url, normalizedRepository))
68
+ continue;
69
+ return candidate.html_url;
70
+ }
71
+ return null;
72
+ }
73
+ function isPullRequestUrlForRepository(value, repository) {
74
+ try {
75
+ const url = new URL(value);
76
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com")
77
+ return false;
78
+ const parts = url.pathname.split("/").filter(Boolean);
79
+ return (parts.length === 4 &&
80
+ `${parts[0]}/${parts[1]}`.toLowerCase() === repository &&
81
+ parts[2] === "pull" &&
82
+ /^\d+$/.test(parts[3] ?? ""));
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
package/dist/prompt.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderPrompt = renderPrompt;
4
+ exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
4
5
  /**
5
6
  * Renders the four-layer context bundle (project-plan.md §3.8: steering docs,
6
7
  * task itself, task-type playbook, depends_on reports) into the single prompt
@@ -42,3 +43,20 @@ function renderPrompt(task, bundle) {
42
43
  sections.push(bundle.playbook);
43
44
  return sections.join("\n\n");
44
45
  }
46
+ /**
47
+ * Builds a fresh agent turn after a human steers an active task. The runtime
48
+ * keeps the same worktree, so the replacement turn can inspect and continue
49
+ * partial edits instead of discarding mid-flight work.
50
+ */
51
+ function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
52
+ const corrections = guidance.map((entry) => `- **${entry.given_at}**: ${entry.content}`);
53
+ return [
54
+ originalPrompt.trim(),
55
+ "## Mid-flight human guidance",
56
+ "",
57
+ "The task was actively steered while you were working. Human guidance takes precedence over earlier context. Continue in the same worktree: inspect the current changes, correct course, and then finish the task.",
58
+ "",
59
+ ...corrections,
60
+ "",
61
+ ].join("\n");
62
+ }
package/dist/session.cjs CHANGED
@@ -15,6 +15,7 @@ const prompt_cjs_1 = require("./prompt.cjs");
15
15
  const mcp_config_cjs_1 = require("./mcp-config.cjs");
16
16
  const logger_cjs_1 = require("./logger.cjs");
17
17
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
18
+ const github_pr_cjs_1 = require("./github-pr.cjs");
18
19
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
19
20
  const MCP_CONFIG_FILENAME = "mcp-config.json";
20
21
  const log = (0, logger_cjs_1.createLogger)("session");
@@ -39,6 +40,16 @@ const log = (0, logger_cjs_1.createLogger)("session");
39
40
  async function runSession(deps, claimed, sessionId) {
40
41
  const { api, config } = deps;
41
42
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
43
+ const execution = bundle.execution ?? {
44
+ profile: task.execution_profile ?? "standard",
45
+ model: config.agentType === "codex" ? "gpt-5.6" : "best",
46
+ reasoning_effort: "medium",
47
+ };
48
+ const executionReport = {
49
+ model: execution.model,
50
+ execution_profile: execution.profile,
51
+ reasoning_effort: execution.reasoning_effort,
52
+ };
42
53
  // The session's identity is the pre-allocated session id sent at claim time
43
54
  // (recorded on the lease by the dispatcher). Lease-scoped API calls
44
55
  // (heartbeat/complete/issue/transcript) still key on leaseId.
@@ -69,28 +80,60 @@ async function runSession(deps, claimed, sessionId) {
69
80
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
70
81
  exit_status: "crashed",
71
82
  agent_type: config.agentType,
83
+ ...executionReport,
72
84
  });
73
85
  secrets = {};
74
86
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
75
87
  return;
76
88
  }
89
+ const githubToken = secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
77
90
  const gitWorktree = new git_worktree_cjs_1.GitWorktree({
78
91
  workspaceRoot: config.workspaceRoot,
79
92
  projectId: task.project_id,
80
93
  taskId: task.id,
81
94
  sessionId,
82
95
  cloneUrl,
83
- githubToken: secrets[bundle.repository?.credential_secret_name ?? "github-pat"],
96
+ githubToken,
84
97
  });
85
- const abortController = new AbortController();
98
+ const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
99
+ const deliveredGuidance = [...(bundle.guidance ?? [])];
100
+ let guidanceCursor = bundle.guidance_cursor ?? null;
101
+ let pendingGuidance = [];
102
+ let activeAbortController = null;
86
103
  let leaseLost = false;
87
- const heartbeatTimer = setInterval(() => {
88
- api.heartbeatLease(leaseId).catch((err) => {
104
+ let heartbeatInFlight = null;
105
+ const pollLease = () => {
106
+ if (heartbeatInFlight)
107
+ return heartbeatInFlight;
108
+ const request = api
109
+ .heartbeatLease(leaseId, { guidance_after: guidanceCursor })
110
+ .then((heartbeat) => {
111
+ guidanceCursor = heartbeat.guidance_cursor ?? guidanceCursor;
112
+ const fresh = (heartbeat.guidance ?? []).filter((entry) => {
113
+ if (knownGuidanceIds.has(entry.id))
114
+ return false;
115
+ knownGuidanceIds.add(entry.id);
116
+ return true;
117
+ });
118
+ if (fresh.length > 0) {
119
+ pendingGuidance.push(...fresh);
120
+ deliveredGuidance.push(...fresh);
121
+ log.info(`received ${fresh.length} guidance update${fresh.length === 1 ? "" : "s"} for ${leaseId}; restarting the agent turn in the same worktree.`);
122
+ activeAbortController?.abort();
123
+ }
124
+ })
125
+ .catch((err) => {
89
126
  log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
90
127
  leaseLost = true;
91
- abortController.abort();
128
+ activeAbortController?.abort();
129
+ })
130
+ .finally(() => {
131
+ heartbeatInFlight = null;
92
132
  });
93
- }, config.leaseHeartbeatIntervalMs);
133
+ heartbeatInFlight = request;
134
+ return request;
135
+ };
136
+ const heartbeatTimer = setInterval(() => void pollLease(), config.leaseHeartbeatIntervalMs);
94
137
  const dockerAvailable = config.sandboxMode === "docker" && (await (0, sandbox_cjs_1.isDockerAvailable)());
95
138
  if (config.sandboxMode === "docker" && !dockerAvailable) {
96
139
  clearInterval(heartbeatTimer);
@@ -104,6 +147,7 @@ async function runSession(deps, claimed, sessionId) {
104
147
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
105
148
  exit_status: "crashed",
106
149
  agent_type: config.agentType,
150
+ ...executionReport,
107
151
  })
108
152
  .catch((err) => log.warn(`complete() after docker-unavailable also failed: ${String(err)}`));
109
153
  secrets = {};
@@ -153,27 +197,78 @@ async function runSession(deps, claimed, sessionId) {
153
197
  const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
154
198
  const bin = config.agentType === "codex" ? config.codexBin : config.claudeBin;
155
199
  const extraArgs = config.agentType === "codex" ? config.codexExtraArgs : config.claudeExtraArgs;
156
- const result = await adapter.run({
157
- prompt: promptText,
158
- mcpConfigPath,
159
- bin,
160
- extraArgs,
161
- timeoutMs: config.sessionTimeoutMs,
162
- env: toEnvMap(secrets),
163
- cwd: sandbox ? undefined : gitWorktree.worktreePath,
164
- dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
165
- signal: abortController.signal,
166
- });
200
+ const attempts = [];
201
+ let result;
202
+ while (true) {
203
+ // Guidance can arrive while the worktree/sandbox is being prepared.
204
+ // It is already included in deliveredGuidance, so clear the pending
205
+ // notification and start the first turn with the corrected prompt.
206
+ pendingGuidance = [];
207
+ activeAbortController = new AbortController();
208
+ if (leaseLost)
209
+ activeAbortController.abort();
210
+ const runPrompt = deliveredGuidance.length > (bundle.guidance?.length ?? 0)
211
+ ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
212
+ : promptText;
213
+ await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
214
+ const turnResult = await adapter.run({
215
+ prompt: runPrompt,
216
+ mcpConfigPath,
217
+ bin,
218
+ extraArgs,
219
+ model: execution.model,
220
+ reasoningEffort: execution.reasoning_effort,
221
+ timeoutMs: config.sessionTimeoutMs,
222
+ env: toEnvMap(secrets),
223
+ cwd: sandbox ? undefined : gitWorktree.worktreePath,
224
+ dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
225
+ signal: activeAbortController.signal,
226
+ });
227
+ activeAbortController = null;
228
+ attempts.push(turnResult);
229
+ // Close the small race between a naturally completed turn and the next
230
+ // scheduled heartbeat. If guidance landed, run another turn before the
231
+ // lease can be completed.
232
+ await pollLease();
233
+ if (!leaseLost && pendingGuidance.length > 0)
234
+ continue;
235
+ result = {
236
+ ...turnResult,
237
+ tokensIn: attempts.reduce((sum, attempt) => sum + (attempt.tokensIn ?? 0), 0),
238
+ tokensOut: attempts.reduce((sum, attempt) => sum + (attempt.tokensOut ?? 0), 0),
239
+ costUsd: attempts.reduce((sum, attempt) => sum + (attempt.costUsd ?? 0), 0),
240
+ };
241
+ break;
242
+ }
167
243
  const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({ ...result, killedByLeaseLoss: leaseLost || result.killedByLeaseLoss });
244
+ try {
245
+ const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
246
+ repository: bundle.repository?.full_name ?? task.repo,
247
+ headBranch: gitWorktree.branch,
248
+ githubToken,
249
+ });
250
+ if (prUrl)
251
+ mapping.evidenceUrls.push(prUrl);
252
+ }
253
+ catch (err) {
254
+ // Evidence discovery is best-effort: a GitHub outage or token scope
255
+ // mismatch must not turn an otherwise valid completion into a crash.
256
+ log.warn(`head-branch PR lookup failed for ${leaseId}: ${String(err)}`);
257
+ }
168
258
  const knownSecrets = registry.list();
169
- const transcript = [
170
- "# stdout",
171
- (0, redact_cjs_1.redactText)(result.stdout, knownSecrets),
259
+ if (mapping.leaseOutcome === "failed") {
260
+ log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
261
+ }
262
+ const transcript = attempts
263
+ .flatMap((attempt, index) => [
264
+ `# agent turn ${index + 1} stdout`,
265
+ (0, redact_cjs_1.redactText)(attempt.stdout, knownSecrets),
172
266
  "",
173
- "# stderr",
174
- (0, redact_cjs_1.redactText)(result.stderr, knownSecrets),
267
+ `# agent turn ${index + 1} stderr`,
268
+ (0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
175
269
  "",
176
- ].join("\n");
270
+ ])
271
+ .join("\n");
177
272
  let transcriptUrl;
178
273
  try {
179
274
  const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
@@ -195,6 +290,7 @@ async function runSession(deps, claimed, sessionId) {
195
290
  transcript_url: transcriptUrl,
196
291
  exit_status: mapping.exitStatus,
197
292
  agent_type: config.agentType,
293
+ ...executionReport,
198
294
  });
199
295
  }
200
296
  catch (err) {
@@ -209,11 +305,13 @@ async function runSession(deps, claimed, sessionId) {
209
305
  cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
210
306
  exit_status: "crashed",
211
307
  agent_type: config.agentType,
308
+ ...executionReport,
212
309
  })
213
310
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
214
311
  }
215
312
  finally {
216
313
  clearInterval(heartbeatTimer);
314
+ activeAbortController?.abort();
217
315
  secrets = {};
218
316
  if (sandbox)
219
317
  await sandbox.stop();
package/dist/types.cjs CHANGED
@@ -6,14 +6,7 @@
6
6
  // - docs/navarch/implementation-plan.md (WP-07 behavioral contract)
7
7
  // - docs/agent-platform-project-plan.md (§3.8 dispatch/§3.9 adapter contract)
8
8
  //
9
- // The control-plane API is being built in parallel (WP-01/WP-04/WP-05) in
10
- // other worktrees this agent cannot see. Fields/endpoints marked "ASSUMED"
11
- // are not pinned down by an explicit route contract in the docs as written
12
- // and were inferred from the closest analogous shape; see the WP-07 report
13
- // for the full list of assumptions to confirm once those WPs land. Everything
14
- // else is quoted close to verbatim from schema-design.md.
15
- //
16
- // runtime/src/api.cts is the ONLY place that turns these types into HTTP
17
- // calls, so reconciling an assumption against the real contract is a
18
- // same-file edit, not a rewrite.
9
+ // These contracts are implemented by the matching app/api routes and are
10
+ // documented in schema-design.md §7. runtime/src/api.cts is the only place
11
+ // that turns them into HTTP calls.
19
12
  Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -37,10 +37,10 @@
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
39
39
  "prepublishOnly": "npm run build && npm test",
40
- "start": "node dist/cli.cjs start",
41
- "register": "node dist/cli.cjs register",
42
- "connect": "node dist/cli.cjs connect",
43
- "doctor": "node dist/cli.cjs doctor"
40
+ "start": "node bin/navarch.cjs start",
41
+ "register": "node bin/navarch.cjs register",
42
+ "connect": "node bin/navarch.cjs connect",
43
+ "doctor": "node bin/navarch.cjs doctor"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^20.14.0",