@sagentlab/navarch-runtime 0.1.0

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 ADDED
@@ -0,0 +1,351 @@
1
+ # navarch-runtime
2
+
3
+ The machine-side half of Navarch (WP-07): registers this machine with the
4
+ control plane, then loops claim → sandbox → agent CLI → report until you stop
5
+ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
6
+ — this directory is a self-contained package you can `npx` on any fresh
7
+ machine.
8
+
9
+ The agent CLI a session runs is either **Claude Code** or **OpenAI Codex**,
10
+ selected per machine via `FLOTILLA_AGENT` (default `claude-code`) — see
11
+ "Choosing an agent (Claude Code vs. Codex)" below.
12
+
13
+ See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
14
+ §3.8/§3.9/§3.11 and [`docs/flotilla/implementation-plan.md`](../docs/flotilla/implementation-plan.md)
15
+ WP-07 for the design this implements, and
16
+ [`docs/flotilla/schema-design.md`](../docs/flotilla/schema-design.md) §7 for
17
+ the API contract.
18
+
19
+ ## Quick start on a fresh machine
20
+
21
+ ```sh
22
+ git clone <this repo> && cd sagentlab/runtime
23
+ ./install.sh # checks node/docker, npm install, npm run build
24
+ export FLOTILLA_API_BASE=https://flotilla.example.com
25
+ node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
26
+ node bin/navarch.cjs start
27
+ ```
28
+
29
+ Or, once dependencies are installed:
30
+
31
+ ```sh
32
+ npm run build
33
+ FLOTILLA_API_BASE=https://flotilla.example.com npm run register -- --token <enrollment-token> --name my-machine-1
34
+ npm start
35
+ ```
36
+
37
+ `register` is the one command that prints the machine's auth token — exactly
38
+ once, to stdout. It is stored at `$FLOTILLA_CONFIG_DIR/machine.json` (default
39
+ `~/.flotilla/machine.json`, mode `0600`) and never echoed again. For
40
+ twelve-factor deployments (systemd `EnvironmentFile`, container secrets),
41
+ skip `register` and set `FLOTILLA_MACHINE_TOKEN` + `FLOTILLA_MACHINE_ID`
42
+ directly.
43
+
44
+ Run `node bin/navarch.cjs doctor` any time to print resolved config, Docker
45
+ availability, and registration status without starting the daemon.
46
+
47
+ ### Connecting an agent to one project
48
+
49
+ `connect` is the project-scoped sibling of `register` — "Connect an agent to
50
+ a project" (self-hosted-runner style, like a GitHub Actions self-hosted
51
+ runner token): a project **owner** mints a short-lived (60 minute),
52
+ single-use token from the platform UI (the project settings page's "Connect
53
+ an agent" panel, or the onboarding wizard's Agent step),
54
+ `POST /api/projects/:id/enrollment-tokens`, and pastes you the ready-to-run
55
+ command. Unlike `register`, no `FLOTILLA_ENROLLMENT_SECRET` or
56
+ `--owner-zone` is needed — the token is already scoped to exactly one
57
+ project, and the resulting machine's `project_bindings` is set to that
58
+ project only (it will never be dispatched work from any other project).
59
+
60
+ ```sh
61
+ npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
62
+ --name my-agent-1 --api-base https://flotilla.example.com
63
+ node bin/navarch.cjs start
64
+ ```
65
+
66
+ (`@sagentlab/navarch-runtime` is not yet published to npm — see "What needs
67
+ live verification" below for the from-source equivalent, i.e. the
68
+ `git clone` + `./install.sh` + `node bin/navarch.cjs connect ...` flow
69
+ above.)
70
+
71
+ ## Commands
72
+
73
+ | Command | Purpose |
74
+ |---|---|
75
+ | `register --token <t> --name <n> [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]` | Registers this machine (global enrollment secret), prints the token once. |
76
+ | `connect --token <t> --name <n> [--project <id>] [--capabilities a,b] [--max-sessions N] [--api-base url]` | Connects this machine to exactly one project (project-scoped enrollment token), prints the token once. |
77
+ | `start` | Runs the daemon: machine heartbeat loop + claim loop, `FLOTILLA_MAX_SESSIONS` concurrent task sessions. |
78
+ | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
79
+
80
+ ## Configuration (`FLOTILLA_*` env vars)
81
+
82
+ | Var | Default | Meaning |
83
+ |---|---|---|
84
+ | `FLOTILLA_API_BASE` | `http://localhost:3000` | Control-plane base URL. |
85
+ | `FLOTILLA_MACHINE_TOKEN` / `FLOTILLA_MACHINE_ID` | — | Skip `register`/the config file; twelve-factor auth. |
86
+ | `FLOTILLA_MACHINE_NAME` | — | Used by `register`. |
87
+ | `FLOTILLA_ENROLLMENT_TOKEN` | — | Alternative to `register --token` / `connect --token`. |
88
+ | `FLOTILLA_PROJECT_ID` | — | Alternative to `connect --project`. |
89
+ | `FLOTILLA_CONFIG_DIR` | `~/.flotilla` | Where `machine.json` lives. |
90
+ | `FLOTILLA_WORKSPACE_ROOT` | `<config dir>/sandboxes` | Per-session working directories (host side; mounted into each container). |
91
+ | `FLOTILLA_MAX_SESSIONS` | `3` | Capacity cap — see `src/capacity.cts`. |
92
+ | `FLOTILLA_CAPABILITIES` | `docker-sandbox,shell` | Comma list reported at heartbeat/claim time. |
93
+ | `FLOTILLA_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
94
+ | `FLOTILLA_POLL_INTERVAL_MS` | `5000` | Claim-loop poll interval. |
95
+ | `FLOTILLA_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
96
+ | `FLOTILLA_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
97
+ | `FLOTILLA_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
98
+ | `FLOTILLA_SANDBOX_MODE` | `docker` | `docker` or `host` (host mode skips the container — dev/debug only, no isolation). |
99
+ | `FLOTILLA_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
100
+ | `FLOTILLA_AGENT` | `claude-code` | Which agent CLI runs sessions: `claude-code` or `codex`. Any other value falls back to `claude-code`. Recorded as `agent_type` on the `complete()` call so the sessions row shows which one ran. |
101
+ | `FLOTILLA_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
102
+ | `FLOTILLA_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
103
+ | `FLOTILLA_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
104
+ | `FLOTILLA_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config`/`--json` (Codex). |
105
+ | `FLOTILLA_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
106
+
107
+ ## Choosing an agent (Claude Code vs. Codex)
108
+
109
+ Each machine runs sessions with exactly one agent CLI, set once via
110
+ `FLOTILLA_AGENT`:
111
+
112
+ ```sh
113
+ # Claude Code (default) — requires the `claude` CLI installed and
114
+ # authenticated on this machine (or FLOTILLA_CLAUDE_BIN pointing at it).
115
+ export FLOTILLA_AGENT=claude-code
116
+
117
+ # OpenAI Codex — requires the `codex` CLI installed and authenticated on
118
+ # this machine (or FLOTILLA_CODEX_BIN pointing at it), analogous to the
119
+ # Claude Code prerequisite above.
120
+ export FLOTILLA_AGENT=codex
121
+ ```
122
+
123
+ Both adapters implement the same `AgentAdapter` interface
124
+ (`src/adapters/types.cts`) and run either directly on the host or via
125
+ `docker exec` in the session's sandbox container, exactly like the Claude
126
+ adapter always has — `session.cts` picks one (`src/adapters/index.cts`'s
127
+ `selectAdapter`) at the start of each session and passes `agent_type`
128
+ through to `complete()` unchanged by whatever happened during the run.
129
+
130
+ **The Codex CLI invocation (`codex exec "<prompt>" --json [--mcp-config
131
+ <path>]`) is unverified** — there is no `codex` binary available to test
132
+ against in this offline build environment, so every flag and the JSONL
133
+ output shape it's assumed to produce are inferred by analogy with the Claude
134
+ Code adapter's own documented `--output-format json` assumption. See
135
+ `src/adapters/codex.cts`'s module doc comment and
136
+ `src/exit-conditions.cts`'s `parseCodexJsonEvents` doc comment for the full
137
+ list of assumptions to confirm once a real `codex` binary is available, and
138
+ "What needs live verification" below.
139
+
140
+ ## Architecture
141
+
142
+ ```
143
+ cli.cts
144
+ ├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
145
+ ├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
146
+ └─ start
147
+ ├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every FLOTILLA_HEARTBEAT_INTERVAL_MS]
148
+ └─ ClaimLoop (claim-loop.cts) → api.claim() [every FLOTILLA_POLL_INTERVAL_MS, gated by CapacityTracker]
149
+ └─ runSession (session.cts), one per claimed lease, run concurrently up to FLOTILLA_MAX_SESSIONS:
150
+ 1. write prompt.md (prompt.cts renders the 4-layer context bundle)
151
+ 2. api.issueSecrets() → held in memory only
152
+ 3. DockerSandbox.create/injectEnv/cloneRepo (sandbox.cts)
153
+ 4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
154
+ (adapters/types.cts) by FLOTILLA_AGENT, then .run(...):
155
+ - claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
156
+ - codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json --mcp-config <path>` (ASSUMED)
157
+ heartbeating the lease every FLOTILLA_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
158
+ a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
159
+ 5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
160
+ 6. api.completeLease(), reporting agent_type: config.agentType
161
+ 7. sandbox.wipe() unconditionally (finally block)
162
+ ```
163
+
164
+ `adapter.cts` (top-level) is now a backward-compat re-export of
165
+ `adapters/claude.cts` — new code should import `adapters/index.cts` (for
166
+ `selectAdapter`) or `adapters/claude.cts` / `adapters/codex.cts` directly.
167
+
168
+ `api.cts` is the single choke point for every HTTP call; nothing else in the
169
+ package talks to `fetch` directly for control-plane traffic.
170
+
171
+ ## Secret handling
172
+
173
+ - Task secrets come from `POST /api/broker/issue` **once**, at session start,
174
+ and are held only in memory (`session.cts`) — never written to the host
175
+ filesystem.
176
+ - Inside the sandbox, `DockerSandbox.injectEnv()` writes `KEY=value` lines to
177
+ a **tmpfs-backed** file (`/tmp/session.env`) over `docker exec -i` **stdin**
178
+ — never a `docker run -e` flag (visible via `docker inspect`) and never a
179
+ host-side file. The container's `/tmp` and `/run` are tmpfs, so nothing
180
+ persists past `wipe()`.
181
+ - `cloneRepo()` wires a git credential helper that echoes `$GITHUB_TOKEN` —
182
+ the literal token value never appears as a literal string in any argv the
183
+ host's `ps` can see; only the variable *reference* does.
184
+ - Before upload, `redact.cts` scrubs the transcript against every value the
185
+ session's `SecretRegistry` actually saw, plus pattern-based fallbacks
186
+ (GitHub PAT shapes, PEM private keys, generic `sk-...` tokens) as
187
+ defense-in-depth for values the registry didn't see directly.
188
+
189
+ ## Assumptions to confirm (control-plane contracts not yet pinned)
190
+
191
+ `schema-design.md` §7 enumerates `POST /api/dispatch/claim`, `POST
192
+ /api/dispatch/:leaseId/heartbeat`, `POST /api/dispatch/:leaseId/complete`, and
193
+ `POST /api/broker/issue` — those are implemented in `api.cts` exactly as
194
+ documented. Three routes WP-07 also needs are **not** enumerated there and
195
+ were inferred from the closest analogous shape (each is called out in
196
+ `types.cts` with an "ASSUMED" doc comment and isolated to one method in
197
+ `api.cts`):
198
+
199
+ 1. **`POST /api/machines/register`** — machine self-registration via a
200
+ short-lived enrollment token (issued out-of-band by an owner/admin). The
201
+ docs describe the *behavior* ("one command, prints machine token once")
202
+ but WP-01/WP-04/WP-05 own the actual admin-console/enrollment-token flow.
203
+ 2. **`POST /api/machines/:id/heartbeat`** — a machine-level heartbeat
204
+ distinct from the per-lease heartbeat, needed because `machines.last_heartbeat_at`
205
+ /`status` must update even when no task is claimed.
206
+ 3. **`POST /api/dispatch/:leaseId/transcript-upload-url`** — a signed
207
+ Supabase Storage upload URL, per `sessions.transcript_url`'s "object
208
+ storage, not in DB" note and WP-07's "transcript → Supabase Storage via a
209
+ control-plane upload URL" line.
210
+
211
+ Reconciling any of these against the real routes, once WP-01/WP-04/WP-05
212
+ land, means editing the corresponding method in `api.cts` — no other file
213
+ should need to change.
214
+
215
+ `POST /api/machines/connect` (`api.cts#connectMachine`, the `connect`
216
+ command) is **not** in this ASSUMED list — it was built in the same change
217
+ as `app/api/machines/connect/route.ts`, so the shapes in `types.cts`
218
+ (`ConnectMachineRequest`/`ConnectMachineResult`) are confirmed against the
219
+ real route, not inferred.
220
+
221
+ ## What needs live verification
222
+
223
+ This was built and tested offline (no Docker daemon, no `claude` or `codex`
224
+ binary, no live control-plane API in this environment). Unit tests cover
225
+ everything that can be verified without those:
226
+
227
+ - `api.cts` — request/response shapes, error mapping, auth header handling (`tests/api.test.cts`).
228
+ - `exit-conditions.cts` — every exit-condition → complete/fail mapping, priority order,
229
+ plus Claude's and Codex's usage/report parsing (`tests/exit-conditions.test.cts`).
230
+ - `redact.cts` — exact-value and pattern-based redaction (`tests/redact.test.cts`).
231
+ - `capacity.cts` — capacity math and acquire/release bookkeeping (`tests/capacity.test.cts`).
232
+ - `config.cts` — env var parsing and defaults, including `FLOTILLA_AGENT`/
233
+ `FLOTILLA_CODEX_BIN`/`FLOTILLA_CODEX_EXTRA_ARGS` (`tests/config.test.cts`).
234
+ - `sandbox.cts` — command construction (flags, env-via-stdin, credential-helper
235
+ argv hygiene) against an injected fake `CommandRunner`, plus `isDockerAvailable()`
236
+ degrading to `false` instead of throwing when Docker is absent (`tests/sandbox.test.cts`).
237
+ - `adapters/index.cts#selectAdapter` — picks the right `AgentAdapter` for every
238
+ `FLOTILLA_AGENT` value, including the claude-code fallback for an unrecognized
239
+ one (`tests/adapters/index.test.cts`).
240
+ - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
241
+ and the docker-exec path (fake `CommandRunner`), and usage/report-text
242
+ attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
243
+
244
+ Not exercised by unit tests, and needing a real machine per the WP-07 DoD
245
+ ("on a real machine: register → claim a seeded docs task → session runs
246
+ Claude Code headless → PR opens on GitHub → complete lands with evidence;
247
+ secrets absent from disk after exit"):
248
+
249
+ - Actually running `claude -p` headless and confirming its real
250
+ `--output-format json` shape (WP-13). `adapters/claude.cts` now appends
251
+ `--output-format json` by default and best-effort parses stdout as a single
252
+ JSON result object shaped like `{ type, subtype, is_error, result,
253
+ session_id, total_cost_usd, usage: { input_tokens, output_tokens,
254
+ cache_creation_input_tokens, cache_read_input_tokens } }` (see
255
+ `exit-conditions.cts#parseClaudeJsonResult` for the exact assumption and
256
+ fallback behavior). Unit tests (`tests/adapter.test.cts`,
257
+ `tests/exit-conditions.test.cts`) cover the parsing and arg-construction
258
+ logic against fixed JSON strings; what's untested offline is whether a real
259
+ `claude -p --output-format json` invocation actually produces this shape.
260
+ If it doesn't, parsing degrades to `null` and `tokensIn`/`tokensOut`/
261
+ `costUsd` fall back to 0 (session.cts's existing default) — never a thrown
262
+ error — but cost/spend data silently goes missing until the shape is
263
+ reconciled here.
264
+ - **Everything about the Codex CLI adapter (`adapters/codex.cts`) — there is
265
+ no `codex` binary to test against here at all, unlike Claude Code where at
266
+ least the flag name and general headless-JSON shape are documented.** Every
267
+ one of the following is an unverified guess, each flagged "ASSUMED — CONFIRM
268
+ AGAINST A REAL codex BINARY" at its point of use:
269
+ - That `codex exec "<prompt>"` is the right non-interactive/headless
270
+ invocation at all (the `claude -p` analog).
271
+ - That `--json` is a real flag and that it switches output to
272
+ newline-delimited JSON "event" objects (`{"id": "...", "msg": {"type":
273
+ ..., ...}}`) rather than, say, a single JSON object like Claude's
274
+ `--output-format json`, or no structured-output flag at all.
275
+ - That the event `msg.type` values used here (`agent_message`,
276
+ `token_count`, `task_complete`) exist, are spelled this way, and that
277
+ `token_count` events carry cumulative (not incremental/per-turn) totals —
278
+ `exit-conditions.cts#extractUsageFromCodexEvents` takes the *last* such
279
+ event's numbers, which double-counts or under-counts if that guess is
280
+ wrong.
281
+ - That `--mcp-config <path>` is accepted at all by `codex exec` — Codex CLI
282
+ documentation elsewhere describes MCP servers configured via
283
+ `~/.codex/config.toml`, not a per-invocation flag, so this may need to
284
+ become "write a config fragment into the sandbox first" instead.
285
+ - Whether a real `codex exec` run needs an explicit non-interactive/
286
+ approval-bypass flag (e.g. something like `--full-auto` or
287
+ `--dangerously-bypass-approvals-and-sandbox` per published Codex CLI
288
+ docs) to avoid blocking on an approval prompt inside the already-isolated
289
+ Docker sandbox — deliberately **not** hardcoded, left to
290
+ `FLOTILLA_CODEX_EXTRA_ARGS` until confirmed, since guessing the wrong
291
+ flag here could silently disable sandboxing rather than just fail loudly.
292
+ - Whether the Codex CLI even authenticates/runs non-interactively the same
293
+ way `claude` does (API key vs. ChatGPT-account OAuth device flow) — this
294
+ adapter assumes "already authenticated on the machine" exactly like the
295
+ Claude Code prerequisite, but cannot confirm that story is equivalent.
296
+
297
+ If any of this is wrong, `parseCodexJsonEvents` returns an empty array and
298
+ the adapter's `attachUsage()` leaves `tokensIn`/`tokensOut`/`costUsd`/
299
+ `reportText` unset — `mapExitCondition` then falls back to raw stdout for
300
+ the report — same never-throw degradation as the Claude path, just with
301
+ more to reconcile once a real binary exists.
302
+ - The full Docker sandbox lifecycle against a real Docker daemon (rootless
303
+ behavior, tmpfs env injection, git clone with a real PAT, `docker exec`
304
+ timeout/kill semantics under `AbortSignal`).
305
+ - The three assumed endpoints above, once they exist.
306
+ - Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
307
+ already-reassigned lease) — `session.cts` aborts the running adapter
308
+ process and still attempts a best-effort `complete()` call, which the
309
+ real server may reject; that path is untested against real semantics.
310
+ - Transcript upload against a real signed Supabase Storage URL.
311
+ - The end-to-end "Connect an agent to a project" flow against a real
312
+ Supabase instance: an owner minting a token from `ConnectAgentPanel`
313
+ (or the onboarding wizard's Agent step), `navarch-runtime connect`
314
+ redeeming it, and the resulting machine actually claiming a task scoped to
315
+ that one project. Unit-tested here: `connectMachine()`'s request shape
316
+ (`tests/api.test.cts`) and the `connect` command's flag/env parsing
317
+ (`tests/cli.test.cts`); not tested here: the real Postgres round trip
318
+ (`lib/flotilla/__tests__/enrollment.test.ts` and the
319
+ `app/api/machines/connect` / `app/api/projects/[id]/enrollment-tokens`
320
+ route tests cover that with a mocked admin client, not a live database).
321
+ - `@sagentlab/navarch-runtime` is not yet published to npm — every `npx
322
+ @sagentlab/navarch-runtime ...` command shown above (and in
323
+ `ConnectAgentPanel`) currently requires the from-source flow instead
324
+ (`git clone` + `./install.sh` + `node bin/navarch.cjs connect ...`).
325
+
326
+ ## A note on `.cts` instead of `.ts`
327
+
328
+ Every source and test file in this package uses the `.cts` extension rather
329
+ than `.ts`. This isn't a style preference: the repo root's `tsconfig.json`
330
+ globs `**/*.ts` / `**/*.mts` into the Next.js app's own `next build`
331
+ type-check, and that check runs across the whole matched file set, not just
332
+ files reachable from a page — confirmed empirically while building this
333
+ package (a single stray `.ts` file under `runtime/` failed the root
334
+ `next build`). WP-07's file ownership is scoped to `runtime/` only, so
335
+ editing the root `tsconfig.json`'s `include`/`exclude` was out of bounds.
336
+ `.cts` is a first-class TypeScript/Node extension (forces CommonJS output,
337
+ still allows normal `import`/`export` source syntax) that the root glob does
338
+ not match, making it a clean, zero-touch way to keep this package fully
339
+ isolated. Vite/Vitest's default esbuild transform filter also excludes
340
+ `.cts` by default — `vitest.config.cts` overrides it (`esbuild.include`) so
341
+ tests are actually type-stripped and run.
342
+
343
+ ## Discovered scope (flagging, not editing the plan doc)
344
+
345
+ - Machine registration, machine-level heartbeat, and the transcript
346
+ upload-URL endpoint are not in `schema-design.md` §7's API surface list —
347
+ see "Assumptions to confirm" above. Recommend WP-01/WP-04/WP-05 add these
348
+ three routes explicitly to the schema doc once designed.
349
+ - No admin-console UI exists yet to actually mint an `enrollment_token` for
350
+ `register` to redeem — that's WP-08 (Fleet view) / WP-01 (admin console)
351
+ territory; this package assumes it will exist.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Thin npx-able entry point. The real CLI lives in src/cli.cts, compiled to
5
+ // dist/cli.cjs by `npm run build`. Kept deliberately tiny (no logic) so there
6
+ // is nothing here to type-check or unit-test beyond "does it call main()".
7
+ require("../dist/cli.cjs")
8
+ .main()
9
+ .catch((err) => {
10
+ console.error(err);
11
+ process.exitCode = 1;
12
+ });
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeCodeAdapter = exports.runClaudeCodeAdapter = void 0;
4
+ /**
5
+ * Backward-compat re-export shim.
6
+ *
7
+ * The Claude Code adapter that used to live entirely in this file now lives
8
+ * in adapters/claude.cts, implementing the generalized `AgentAdapter`
9
+ * interface (adapters/types.cts) alongside adapters/codex.cts's Codex CLI
10
+ * sibling — session.cts picks between the two via FLOTILLA_AGENT
11
+ * (config.cts's `agentType`) through adapters/index.cts#selectAdapter.
12
+ *
13
+ * This file is kept, unchanged in its exported names, so any existing
14
+ * `import { runClaudeCodeAdapter } from "./adapter.cjs"` (or
15
+ * `import type { RunClaudeCodeOptions } from "./adapter.cjs"`) continues to
16
+ * resolve without a call-site change. New code should import from
17
+ * "./adapters/claude.cjs" (or "./adapters/index.cjs" for the
18
+ * agent-agnostic selection helper) directly instead.
19
+ */
20
+ var claude_cjs_1 = require("./adapters/claude.cjs");
21
+ Object.defineProperty(exports, "runClaudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.runClaudeCodeAdapter; } });
22
+ Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeCodeAdapter = void 0;
4
+ exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
+ /**
8
+ * Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
9
+ * WP-07): `claude -p "<context bundle>" --mcp-config platform-mcp.json
10
+ * --output-format json`. Returns raw stdout/stderr/exit signals (plus
11
+ * best-effort tokensIn/tokensOut/costUsd parsed from the JSON result -- see
12
+ * exit-conditions.cts#parseClaudeJsonResult) for exit-conditions.cts to map
13
+ * onto a complete()/fail() call.
14
+ *
15
+ * This is one of two implementations of the AgentAdapter interface
16
+ * (adapters/types.cts) — see adapters/codex.cts for the Codex CLI sibling
17
+ * session.cts picks between via FLOTILLA_AGENT (config.cts's `agentType`).
18
+ *
19
+ * NEEDS LIVE VERIFICATION (WP-13): `--output-format json` is assumed to make
20
+ * `claude -p` print a single JSON result object with a `usage` block, per
21
+ * Claude Code's documented headless-mode output. This cannot be confirmed
22
+ * without a real `claude` binary in this offline build environment. If the
23
+ * real shape differs, parseClaudeJsonResult() returns null and
24
+ * tokensIn/tokensOut/costUsd fall back to 0 (session.cts's existing
25
+ * default) rather than throwing -- see runtime/README.md.
26
+ */
27
+ async function runClaudeCodeAdapter(options) {
28
+ const args = ["-p", options.prompt];
29
+ if (options.mcpConfigPath) {
30
+ args.push("--mcp-config", options.mcpConfigPath);
31
+ }
32
+ // Only append the default when the caller hasn't already asked for a
33
+ // specific --output-format (extraArgs wins so an operator can opt back
34
+ // into plain-text output, e.g. while diagnosing a live-verification
35
+ // mismatch, without an adapter code change).
36
+ if (!options.extraArgs.includes("--output-format")) {
37
+ args.push("--output-format", "json");
38
+ }
39
+ args.push(...options.extraArgs);
40
+ const raw = options.dockerExec ? await runViaDocker(options, args) : await runOnHost(options, args);
41
+ return attachUsage(raw);
42
+ }
43
+ /** 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). */
44
+ function attachUsage(result) {
45
+ const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
46
+ if (!parsed)
47
+ return result;
48
+ const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
49
+ return { ...result, tokensIn: usage.tokensIn, tokensOut: usage.tokensOut, costUsd: usage.costUsd };
50
+ }
51
+ async function runOnHost(options, args) {
52
+ return new Promise((resolve) => {
53
+ let stdout = "";
54
+ let stderr = "";
55
+ let timedOut = false;
56
+ let killedByLeaseLoss = false;
57
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
58
+ cwd: options.cwd,
59
+ env: { ...process.env, ...options.env },
60
+ });
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ child.kill("SIGKILL");
64
+ }, options.timeoutMs);
65
+ const onAbort = () => {
66
+ killedByLeaseLoss = true;
67
+ child.kill("SIGKILL");
68
+ };
69
+ options.signal?.addEventListener("abort", onAbort, { once: true });
70
+ child.stdout.on("data", (d) => {
71
+ stdout += d.toString();
72
+ });
73
+ child.stderr.on("data", (d) => {
74
+ stderr += d.toString();
75
+ });
76
+ child.on("error", (err) => {
77
+ clearTimeout(timer);
78
+ options.signal?.removeEventListener("abort", onAbort);
79
+ stderr += `\n${String(err)}`;
80
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
81
+ });
82
+ child.on("close", (code) => {
83
+ clearTimeout(timer);
84
+ options.signal?.removeEventListener("abort", onAbort);
85
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
86
+ });
87
+ });
88
+ }
89
+ async function runViaDocker(options, args) {
90
+ const { containerName, runner } = options.dockerExec;
91
+ const quoted = [options.bin, ...args].map(shellQuote).join(" ");
92
+ const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ${quoted}`;
93
+ let killedByLeaseLoss = false;
94
+ const onAbort = () => {
95
+ killedByLeaseLoss = true;
96
+ // Best-effort: kill the exec'd process inside the container. The runner
97
+ // call below still resolves once `docker exec` itself is torn down by
98
+ // its own timeout/kill; a true "kill this docker exec now" requires
99
+ // process-group tracking that nodeCommandRunner does not yet expose.
100
+ runner.run("docker", ["kill", containerName]).catch(() => undefined);
101
+ };
102
+ options.signal?.addEventListener("abort", onAbort, { once: true });
103
+ try {
104
+ const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
105
+ timeoutMs: options.timeoutMs,
106
+ });
107
+ return {
108
+ exitCode: result.code,
109
+ timedOut: false,
110
+ killedByLeaseLoss,
111
+ stdout: result.stdout,
112
+ stderr: result.stderr,
113
+ };
114
+ }
115
+ catch (err) {
116
+ return {
117
+ exitCode: null,
118
+ timedOut: false,
119
+ killedByLeaseLoss,
120
+ stdout: "",
121
+ stderr: String(err),
122
+ };
123
+ }
124
+ finally {
125
+ options.signal?.removeEventListener("abort", onAbort);
126
+ }
127
+ }
128
+ function shellQuote(value) {
129
+ return `'${value.replace(/'/g, `'\\''`)}'`;
130
+ }
131
+ /** The AgentAdapter (adapters/types.cts) wrapper session.cts selects via FLOTILLA_AGENT=claude-code (the default). */
132
+ exports.claudeCodeAdapter = {
133
+ agentType: "claude-code",
134
+ run: runClaudeCodeAdapter,
135
+ };