@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 +97 -87
- package/dist/adapters/claude.cjs +13 -1
- package/dist/adapters/codex.cjs +73 -25
- package/dist/api.cjs +7 -12
- package/dist/claim-loop.cjs +6 -1
- package/dist/exit-conditions.cjs +39 -21
- package/dist/git-worktree.cjs +6 -3
- package/dist/github-pr.cjs +87 -0
- package/dist/prompt.cjs +18 -0
- package/dist/session.cjs +121 -23
- package/dist/types.cjs +3 -10
- package/package.json +5 -5
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 `--
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
##
|
|
262
|
+
## Control-plane contracts
|
|
199
263
|
|
|
200
|
-
`schema-design.md` §7
|
|
201
|
-
|
|
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
|
|
209
|
-
|
|
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
|
|
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`
|
|
225
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -37,7 +37,19 @@ async function runClaudeCodeAdapter(options) {
|
|
|
37
37
|
args.push("--output-format", "json");
|
|
38
38
|
}
|
|
39
39
|
args.push(...options.extraArgs);
|
|
40
|
-
|
|
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). */
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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
|
-
*
|
|
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 [
|
|
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`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
args.push(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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)
|
package/dist/claim-loop.cjs
CHANGED
|
@@ -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;
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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)
|
|
87
|
-
|
|
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.
|
|
98
|
-
*
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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",
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -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
|
|
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/
|
|
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
|
|
96
|
+
githubToken,
|
|
84
97
|
});
|
|
85
|
-
const
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
128
|
+
activeAbortController?.abort();
|
|
129
|
+
})
|
|
130
|
+
.finally(() => {
|
|
131
|
+
heartbeatInFlight = null;
|
|
92
132
|
});
|
|
93
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
(0, redact_cjs_1.redactText)(
|
|
267
|
+
`# agent turn ${index + 1} stderr`,
|
|
268
|
+
(0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
|
|
175
269
|
"",
|
|
176
|
-
]
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
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
|
|
41
|
-
"register": "node
|
|
42
|
-
"connect": "node
|
|
43
|
-
"doctor": "node
|
|
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",
|