CLI contracts
What Leg knows about each coding-agent CLI, where every fact came from, and
which facts were observed live on the build machine versus read from --help
or docs. Two contracts per CLI: the headless argv a pipeline leg spawns
(§ per CLI below), and the interactive tap leg <agent> reads
(§ Interactive taps). Evidence:
fixtures/help/<cli>.txt (raw --help), fixtures/live/<cli>/ (one real tiny
task per CLI, run through src/runner.mjs launch on 2026-09-11 UTC, the
evening of 2026-09-10 locally; paths under the local home directory are
replaced with ~), and the interactive checks run on 2026-09-11.
Every fact line ends with (source: …). observed-live means the build machine
did it; docs-only means the CLI's own --help or documentation says so and
Leg has not seen it happen.
Probe task (identical for every CLI, scripts/probe.mjs): "Create a file named
hello-
| CLI | version | probe result | adapter |
|---|---|---|---|
| claude | 2.1.268 | exit 0, file yes, DONE yes, 20 s | src/adapters/claude.mjs |
| codex | codex-cli 0.153.4 | exit 0, file yes, DONE yes, 28 s | src/adapters/codex.mjs |
| agy | 1.2.0 | attempt 1 exit 0 but wrote to its scratch workspace; attempt 2 (--add-dir) exit 0, file yes, DONE yes, 41 s |
src/adapters/agy.mjs |
| grok | 0.2.51 | exit 0, stopReason: Cancelled, no file: not logged in (device-code prompt) |
src/adapters/grok.mjs exists, NOT registered |
The auth-source check: the build shell carried ANTHROPIC_API_KEY and
OPENAI_API_KEY (injected by the shell profile). Every adapter's env() deletes
them (src/env.mjs). No fixture contains "another auth source"
(grep -ril "another auth source" fixtures/live is empty), claude's and agy's
stderr are 0 bytes, and codex's is the one stdin notice. observed-live.
claude
Version 2.1.268 (source:
claude --version, fixtures/help/claude.version.txt).Binary:
~/.local/bin/claude.exe(native exe, 211 MB); Leg resolves it there first, elseclaudeon PATH;LEG_CLAUDE_BINoverrides (source:ls ~/.local/bin/claude.exe; src/adapters/claude.mjsresolve()).Headless argv (exact, from fixtures/live/claude/cmd.txt):
claude.exe -p --output-format json --permission-mode acceptEditswith the prompt on stdin (stdin:pipe). Optional:--max-turns N,--resume <session-id>,--model <alias|name>,--allowedTools a,b(source:claude --helplines for each flag; observed-live for the base argv).Output: ONE JSON object on stdout, printed only when the session ends (test/lessons.test.mjs
json-only-at-end; observed-live: 20 s of silence then the object). Top-level keys observed:type:"result",subtype:"success",is_error,stop_reason:"end_turn",terminal_reason:"completed",api_error_status,session_id,num_turns,result(final text),permission_denials(array),usage{input_tokens,output_tokens,cache_read_input_tokens,…},modelUsage,total_cost_usd,duration_ms(source: fixtures/live/claude/out.log). Leg readssession_id,result,stop_reason,subtype,is_error,terminal_reason,api_error_status,permission_denials.length.Model: with no
--model, the account's default model answered (modelUsagenames claude-fable-5-1 and claude-haiku-4-5). A chain entry should namemodelto control spend; Leg passes it through as--model(source: fixtures/live/claude/out.logmodelUsage;claude --help --model).Exit codes:
exit meaning source 0 completed, JSON printed observed-live, fixtures/live/claude/run.json non-zero launch or fatal error; JSON may be absent docs-only ( claude --helpdoes not enumerate codes; phase 4 records the fixture cases)Permission modes (
--permission-modechoices in 2.1.268):acceptEdits,auto,bypassPermissions,manual,dontAsk,plan(source:claude --help). Leg: defaultacceptEdits; allowedacceptEdits,auto,plan,manual,dontAsk; forbiddenbypassPermissions,--dangerously-skip-permissions,--allow-dangerously-skip-permissions.acceptEditsallowed the file write headless (observed-live). The Agent tool is never disallowed (test/lessons.test.mjsagent-tool-never-disallowed).--permission-prompts noneexists ("anything that would prompt is denied automatically") and is not passed; headless-phas no host to answer prompts anyway (source:claude --help).Resume:
--resume <session-id>(docs-only until phase 5 exercises it);--fork-sessioncreates a new id on resume (source:claude --help).Child-process rules:
ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,CLAUDECODE,CLAUDE_CODE_*,CLAUDE_EFFORT,CLAUDE_PLUGIN_DATAdeleted;CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0set (source: src/env.mjs; the "another auth source" check above, observed-live).Limit signals: see § Limit signals (phase 4).
codex
Version codex-cli 0.153.4 (source:
codex --version).Binary: npm package
@openai/codexwhosebin/codex.jsonly spawns the platform package's native exe. Leg spawns that exe directly:~/AppData/Roaming/npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe(one process to kill); falls back tonode <bin/codex.js>viaresolveNpmCliEntry, thencodexon PATH;LEG_CODEX_BINoverrides (source:bin/codex.jsread 2026-09-10; fixtures/live/codex/cmd.txt).Headless argv (exact, from cmd.txt):
codex.exe exec --json -s workspace-write -C <worktree> -c sandbox_workspace_write.network_access=false -o <run>/last.md "<prompt>". Prompt is the last positional argument; stdin isignore:codex execreads stdin whenever it is not a TTY and hangs on an open pipe ("Reading additional input from stdin..."; plan-time probe hung 170 s with a pipe, finished in 20 s with stdin closed; observed-live).-owrites the final agent message to a file (observed-live: fixtures/live/codex/last.md).Output: JSONL events on stdout (source: fixtures/live/codex/out.log):
thread.started{thread_id},turn.started,item.started/item.completed{item{type: agent_message|command_execution|error, text|command|message}},turn.completed{usage{input_tokens,cached_input_tokens,output_tokens,reasoning_output_tokens}}. Anitem.type:"error"is a non-fatal notice (observed: "Skill descriptions were shortened…"). Leg readsthread_idas the session id, the lastagent_messagetext,turn.completedas the stop reason, and the error messages.Exit codes:
exit meaning source 0 turn completed observed-live, fixtures/live/codex/run.json (none) hangs with an open stdin pipe until it is killed observed at plan time; no artifact kept, the behaviour is pinned by test/lessons.test.mjs codex-stdin-ignorednon-zero launch/auth/fatal docs-only Sandbox modes (
-s):read-only,workspace-write,danger-full-access(source:codex exec --help). Leg: defaultworkspace-write; allowedread-only,workspace-write; forbiddendanger-full-access,--dangerously-bypass-approvals-and-sandbox,--dangerously-bypass-hook-trust,--approve-for-me(routes approvals through automatic review),--full-auto(not present in 0.153.4exec --help; kept on the forbidden list). Blast radius is-C <worktree>; network off unless the chain entry setsnetwork: true(PROTOCOL sandbox ladder).workspace-writeallowed the write headless (observed-live).Resume:
codex exec resume [SESSION_ID] [PROMPT]with--lastfor the newest (source:codex exec resume --help). Leg's adapter emitsexec … resume <thread_id> <prompt>; docs-only until phase 5 exercises it.Limit signals: see § Limit signals (phase 4).
agy
Version 1.2.0 (source:
agy --version).Binary:
~/AppData/Local/agy/bin/agy.exe(native);LEG_AGY_BINoverrides (source:where agy; src/adapters/agy.mjs).Headless argv (exact, from cmd.txt):
agy.exe -p "<working-directory preamble + prompt>" --output-format json --mode accept-edits --add-dir <worktree> --print-timeout 90m; stdinignore.- agy does not work in the process cwd. Attempt 1 (no
--add-dir) exited 0 and wrotehello-agy.txtunder~/.gemini/antigravity-cli/scratch/(observed-live, fixtures/live/agy/attempt-1-scratch-workspace.out.log). Attempt 2 adds--add-dir <worktree>and prefixes the prompt with "Working directory:"; it wrote both files in the repo (observed-live). --print-timeoutdefaults to 5m0s; Leg sets it from the kill timer in Go duration syntax so the supervisor decides what a runaway is (source:agy --help).
- agy does not work in the process cwd. Attempt 1 (no
Output: one JSON object:
conversation_id,status:"SUCCESS",response,duration_seconds,num_turns,usage{input_tokens,output_tokens,thinking_tokens,cache_read_tokens,total_tokens}(observed-live, fixtures/live/agy/out.log). Leg readsconversation_idas the session id,response,status.Exit codes:
exit meaning source 0 status: SUCCESSobserved-live (both attempts; attempt 1 was a success in the wrong directory) non-zero fatal/auth docs-only Modes (
--mode):accept-edits,plan(source:agy --help). Leg: defaultaccept-edits; forbidden--dangerously-skip-permissions.accept-editsallowed the write headless (observed-live).Resume:
--conversation <id>(by id) or--continue(most recent) (source:agy --help; docs-only until phase 5).Limit signals: see § Limit signals (phase 4).
grok
Version grok 0.2.51 (f4f85a649) [stable] (source:
grok --version).Binary:
~/.grok/bin/grok.exe(native; also an npm shim on PATH);LEG_GROK_BINoverrides (source:where grok).Headless argv (from cmd.txt):
grok.exe -p "<prompt>" --output-format json --permission-mode acceptEdits; stdinignore.--prompt-file <path>also exists (source:grok --help).Output: one JSON object
{text, stopReason, sessionId, requestId, thought}(observed-live, fixtures/live/grok/out.log).Exit codes:
exit meaning source 0 printed JSON with stopReason:"Cancelled"and did no work: the CLI was not logged in, printed a device-code prompt on stderr (https://accounts.x.ai/oauth2/device?user_code=…, "Waiting for authorization...") and gave up after ~58 sobserved-live, fixtures/live/grok/err.log A zero exit with no DONE marker and no diff is exactly the
no_progressclass the completion contract exists for.Permission modes:
default,acceptEdits,auto,dontAsk,bypassPermissions,plan(source:grok --help). Leg: defaultacceptEdits; forbiddenbypassPermissions,--always-approve.Login:
grok login(source:grok --helpCommands).Verdict: not verified, no adapter registered.
src/adapters/grok.mjsis built from--helpand unit-tested for shape and forbidden flags, but stays out ofsrc/adapters/index.mjsuntilgrok loginhas been completed on the machine andnode scripts/probe.mjs --adapter grok --repo <toy>passes.
Interactive taps
What leg claude|codex|agy reads while the real interactive CLI runs. Same
tagging rule: observed-live 2026-09-11 means the build machine did it;
docs-only means the CLI's own source or documentation says so and Leg has
not seen it happen. Machine: Claude Code 2.1.268, codex-cli 0.153.4, agy 1.2.0.
Shared: the agent is spawned with stdio inherited and the user's arguments
passed through (src/attach.mjs spawnSpec); the child environment is
sanitizeEnv(process.env, { interactive: true }) plus the account's config-dir
variable and LEG_SESSION (source: src/attach.mjs, src/env.mjs).
claude tap
- Attach:
claude <args> --settings <LEG_HOME>/sessions/<id>/claude-settings.json(source: src/attach.mjsspawnSpec; src/taps/claude.mjswriteSettings). Hooks in a--settingsfile merge with the user's rather than replacing them;statusLineis the one key that replaces, so Leg runs the user's own command first (source: code.claude.com/docs/en/settings; src/taps/claude.mjsuserStatusLine). observed-live 2026-09-11: a Leg session ran with every user hook still firing. - Hooks written (source: src/taps/claude.mjs
settingsFor):SessionStart,UserPromptSubmit,PostToolUsewith matcherEdit|Write|MultiEdit|NotebookEdit,Stop,StopFailure,SessionEnd, eachnode <src>/hook.mjs claude-hook --session <id>with a 20 s timeout. observed-live 2026-09-11 (hook.log in the session directory). autoContinueAtUsageLimit: falsein the same file, because Leg owns the hand-off (source: src/taps/claude.mjssettingsFor).- Usage:
GET https://api.anthropic.com/api/oauth/usage(LEG_CLAUDE_USAGE_URLoverrides) withAuthorization: Bearer <accessToken>from<CLAUDE_CONFIG_DIR>/.credentials.jsonkeyclaudeAiOauth, and headeranthropic-beta: oauth-2025-04-20; response fieldsfive_hourandseven_day, each{ utilization, resets_at }; polled everyLEG_USAGE_POLL_MSms, default 60000 (source: src/taps/claude-usage.mjs). observed-live 2026-09-11: real windows came back and were written to<LEG_HOME>/usage/claude--default.json; a seven_day window at 93 % raised the amber warning. - The wall:
StopFailurehook witherror: rate_limit(source: https://code.claude.com/docs/en/hooks#stopfailure). observed-live 2026-09-11 : a realStopFailurewitherror: rate_limit(a 429rate_limit_error) arrived at 07:46:37Z and is kept, scrubbed, asfixtures/live/claude/limit-rate_limit.json(src/live-capture.mjs). The path can also be run live withleg sessions simulate-limit(the same payload throughsrc/hook.mjs, markedleg_simulated, never kept as evidence). - Status line, not usable on 2.1.268: a custom
statusLinecommand passed through--settings, and again through a project.claude/settings.local.json, was not run at all when it was tried; anechocommand at both levels left the built-in status line in place while hooks from the same--settingsfile fired (no artifact kept; note in the src/taps/claude-usage.mjs header, 2026-09-11). Leg still writes thestatusLineentry, which records the samerate_limits.five_hour/seven_dayfields (https://code.claude.com/docs/en/statusline), so the endpoint poll becomes a fallback if a later build honours it.
codex tap
- Attach: nothing injected.
codex <args>runs as it always does, and Leg finds the session's rollout file afterwards (source: src/attach.mjsspawnSpec). Reason: injecting a hook makes codex show its hooks-review prompt on every Leg session. - Which file:
<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*.jsonl, matched onsession_meta.payload.cwdequal to the session's cwd with a birth time or mtime at or after the spawn time, five seconds of slack (source: src/taps/codex.mjsfindRollout). observed-live 2026-09-11: the right rollout was found for a real hand-off. The file's mtime is not a content clock on Windows, six rollouts from that day had mtimes 7 s to 9 min behind their last line, so nothing depends on the two matching. - Usage: read-only app-server
account/rateLimits/read, polled every 60 seconds by the board and active attach. Leg maps windows by duration, including 300 minutes to 5h and 10080 minutes to 7d, rather than assumingprimaryorsecondarynames. It sends no model turn and does not hardcode a quota; only explicit backend availability clears a previous wall (sources:src/taps/codex.mjs,src/attach.mjs,src/server.mjs,src/usage.mjs). - The wall:
event_msg.task_complete.errorwithcodex_error_info: "usage_limit_exceeded"and message "You've hit your usage limit … try again at <date>" (source: github.com/openai/codexcodex-rs/protocol/src/error.rsUsageLimitReachedError). The event shape was read from local rollouts; the error itself is observed-live 2026-09-15 . Atask_complete.errorwithcodex_error_info: usage_limit_exceededwalled a codex leg once, but no scrubbed payload was kept, sofixtures/live/codex/limit-usage_limit_exceeded.jsonis still the slot for the first one. - Transcript:
response_item.messagewithrole: userandcontent[].type: input_textfor prompts;output_textandtask_complete.last_agent_messagefor assistant text (source: src/taps/codex.mjs; observed-live 2026-09-11). - Edited files: the
*** Add File:/*** Update File:/*** Delete File:lines of anapply_patchpayload (source: src/taps/codex.mjs; observed-live 2026-09-11).
agy tap
- Attach:
agy <args> --log-file <LEG_HOME>/sessions/<id>/agy.log(source: src/attach.mjsspawnSpec). agy 1.2.0 is a closed Go binary with no hook surface. - Usage: none. No percentage is written to any file agy owns; its own status line fetches a quota summary from the backend and stores nothing (source: src/taps/agy.mjs header).
- The wall:
RESOURCE_EXHAUSTED, "it resets in %s", "out of quota", and "quota exhausted/exceeded" in the log, plus a relative reset parsed out of "resets in <n><s|m|h|d>" (source: strings present inagy.exe; src/taps/agy.mjsscanLog). docs-only :RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in 71h19m42s.appeared in a session'sagy.logat 08:02:42Z,scanLogread the relative reset, and the agent was walled. No payload was kept, the capture call insrc/attach.mjswas added while that session's runner was already running, sofixtures/live/agy/limit-agy-resource-exhausted.jsonis still the slot for the next one. - Prompts and conversation id:
~/.gemini/antigravity-cli/history.jsonl, one{ display, timestamp, workspace, conversationId }per prompt (source: src/taps/agy.mjshistoryFile; shape observed on disk, last written 2026-09-09, the 2026-09-11 agy leg produced no history entry). - One account only: agy 1.2.0 has no config-directory override, so
LAYOUT.agy.envisnullandaccounts add agyis refused (source: src/accounts.mjs).
Usage store and the chooser
<LEG_HOME>/usage/<agent>--<account>.json:{ five_hour: {pct, resets_at}, seven_day: {…}, limited_until, limited_reason, source, updated_at }(source: src/usage.mjs).- Warning threshold
WARN_PCT, default 85, fromLEG_WARN_PCT; the warning is an amber card, an event, and one terminal bell (source: src/usage.mjs, src/attach.mjs). markLimited()takes the reset the CLI reported; failing that the soonest known window reset; failing that now + 5 h (source: src/usage.mjsDEFAULT_LIMIT_S).candidates()yields the other accounts of the same agent first, then the remaining agents in the order claude, codex, agy, wrapping, so every option is tried once.chooseNext()skips any option still walled and returns the walled ones sorted by reset (source: src/usage.mjs).
Session store
<LEG_HOME>/sessions/<id>/ holds session.json, events.jsonl,
hook.log, and, per agent, claude-settings.json or agy.log;
control.json appears only once the board has asked for a hand-off or an
end (source: src/sessions.mjs header, src/attach.mjs). Statuses and event
types are listed
in VOCABULARY.md. Board routes: GET /api/sessions,
GET /api/sessions/:id, POST /api/sessions/:id/handoff,
POST /api/sessions/:id/end, DELETE /api/sessions/:id, with the list pushed
as the SSE sessions event (source: src/server.mjs, src/board/sessions.js).
Limit signals
Recorded in fixtures/limits/<group>/<id>.json and classified by
src/limits.mjs (classify()); the runner writes the outcome into
run.json.outcome with the matched signal. Every limit row below is
still docs-only: its text comes from the CLI's own documentation or
source, cited in "produced by". Real limits from all three agents were seen
on 2026-09-11, but only the claude payload was kept as evidence
(fixtures/live/claude/limit-rate_limit.json); the codex and agy ones went
to the session ledger, not to fixtures/limits/. The observed-live rows are
the non-limit cases the probes actually hit and the detector must never
mistake for a limit.
Outcome precedence (src/limits.mjs): spawn error → launch_failed; stderr
"another auth source is set" or an auth fixture → auth_failed (wins over
any limit text, even with exit 0); killed from the board → killed; kill timer
→ stalled; exit 0 + .leg/DONE → completed; adapter-specific then
generic limit fixture → limit; launch fixture → launch_failed; exit 0 +
changes, no DONE → incomplete; exit 0, nothing changed → no_progress;
non-zero exit → failed. Every outcome except completed, auth_failed and
killed asks the chain to hand off.
Generated by node scripts/limits-table.mjs from 21 fixtures (4 observed-live, 17 docs-only). Classification limit hands the card to the next agent as a usage limit; auth is a failed launch (never a limit); launch is a failed launch that the next agent may still try; budget is a turn or spend cap set by Leg itself; info must never classify as a limit.
| id | adapter | class | where | source | text (excerpt) | produced by |
|---|---|---|---|---|---|---|
| agy-resource-exhausted | agy | limit | any | docs-only | resource-exhausted | agy changelog ("Fixed personal accounts hitting a resource-exhausted error at startup"); agy --help documents no limit wording; falls back to the generic matchers |
| claude-budget-limit | claude | budget | any | docs-only | Budget limit reached | https://code.claude.com/docs/en/cli-reference (--max-budget-usd) |
| claude-max-turns | claude | budget | stdout | observed-live | {"type":"result","subtype":"error_max_turns","is_error":true,"stop_reason":"tool_use","ter | fixtures/real-run/leg1/out.log: claude -p --max-turns 2 on the real run, 2026-09-10 (docs: https://code.claude.com/docs/en/cli-reference --max-turns "Exits with an error when the limit is reached") |
| claude-model-limit | claude | limit | any | docs-only | You’ve hit your Opus limit | https://code.claude.com/docs/en/costs ("You’ve hit your Opus limit" / "You’ve hit your Sonnet limit") |
| claude-session-limit | claude | limit | any | docs-only | You’ve hit your session limit | https://code.claude.com/docs/en/costs (section: When a developer asks about a limit) |
| claude-weekly-limit | claude | limit | any | docs-only | You’ve hit your weekly limit | https://code.claude.com/docs/en/costs (section: When a developer asks about a limit) |
| codex-quota-exceeded | codex | limit | any | docs-only | quota exceeded | github.com/openai/codex codex-rs/response-debug-context/src/lib.rs (ApiError::QuotaExceeded => "quota exceeded") |
| codex-rate-limit-exceeded | codex | limit | any | docs-only | rate limit exceeded: | github.com/openai/codex codex-rs/codex-api/src/error.rs (#[error("rate limit exceeded: {message}")]) |
| codex-skills-notice | codex | info | stdout | observed-live | Skill descriptions were shortened to fit the skills context budget. Codex can still see ev | fixtures/live/codex/out.log (item.completed, item.type=error) from scripts/probe.mjs --adapter codex, 2026-09-10 |
| codex-usage-limit-reached | codex | limit | any | docs-only | Usage limit reached. You've reached your usage limit. Increase your limits to continue | github.com/openai/codex codex-rs/tui/src/chatwidget/turn_runtime.rs (WorkspaceOwnerUsageLimitReached) |
| codex-usage-limit | codex | limit | any | docs-only | You’ve hit your usage limit for {limit_name}. Switch to another model now, | github.com/openai/codex codex-rs/protocol/src/error.rs (gh search code "usage limit" --repo openai/codex, 2026-09-10) |
| generic-429 | * | limit | any | docs-only | 429 Too Many Requests | generic HTTP matcher (429 Too Many Requests); lowest priority |
| generic-overloaded | * | limit | any | docs-only | overloaded_error | generic matcher (Anthropic API 529 overloaded_error); lowest priority |
| generic-quota | * | limit | any | docs-only | quota | generic matcher; lowest priority |
| generic-rate-limit | * | limit | any | docs-only | rate limit | generic matcher; lowest priority |
| generic-resource-exhausted | * | limit | any | docs-only | RESOURCE_EXHAUSTED | generic matcher (gRPC RESOURCE_EXHAUSTED); lowest priority |
| generic-usage-limit | * | limit | any | docs-only | usage limit | generic matcher; lowest priority |
| grok-not-logged-in | grok | auth | stderr | observed-live | To sign in, open this URL in your browser: https://accounts.x.ai/oauth2/device?user_cod | fixtures/live/grok/err.log from scripts/probe.mjs --adapter grok, 2026-09-10 (stdout JSON stopReason: Cancelled, exit 0) |
| auth-source-set | * | auth | stderr | docs-only | another auth source is set | project brief (Wes, 2026-09-10): stderr saying "another auth source is set" counts as a failed launch; wording not yet observed live |
| compile-error | * | info | stderr | docs-only | SyntaxError: Unexpected token ) at compileSourceTextModule (node:internal/modules/esm/ | synthetic negative fixture (a crashed agent is not a limit) |
| empty-stdout-exit-0 | * | info | stdout | observed-live | fixtures/live/grok (exit 0, no work): silence is not a limit |
Runner facts that apply to every CLI (observed-live in phase 2/3 tests)
- The supervisor is detached (
detached: true, windowsHide: true) and survives the launcher;run.jsonmoveslaunching → running → exited|killed|failed. - Kill is
taskkill /PID <pid> /T /Fon Windows with one verify-retry (LEG_TIMERS_MS=notify,kill,verify, default 30 min / 90 min / 30 s). - No adapter ever sets
shell: true; every spawn isspawn(bin, argv)with a native exe ornode <entry.js>(test/lessons.test.mjsno-shell-spawn). - A chain entry naming a forbidden mode or flag makes
argv()throwforbidden flag: …; the supervisor records the refusal and exits 13 without spawning (test/adapters.test.mjs, test/runner.test.mjs).
Leg is commercial, source-available software by Wes Sander. The source you run ships in the npm package. Questions or a refund: legcli@practicalsystems.io.