kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
491 lines (459 loc) • 25.8 kB
JavaScript
#!/usr/bin/env node
/**
* # bin/kestrel — the node-safe launcher (bead: kestrel-mkn)
*
* Owner directive (2026-07-14): bun is not widely deployed and the site examples are npx-only, so
* `npx kestrel.markets run/day/agent` must work under plain node. This launcher is the published
* bin entry; it imports ONLY node builtins before the light/heavy re-exec decision — never
* Bun-only code.
*
* ## Contract
* - LIGHT verbs (`version help parse validate print frame percept sim prove replay verify`), any `-h/--help/-V/--version`,
* or an argv with NO leading verb at all → load `dist/cli.js` in-process under the current runtime,
* exactly as the pre-mkn bin did. Light stays node-runnable with zero bun anywhere. An UNRECOGNIZED
* leading verb FAILS CLOSED to heavy (kestrel-jnd7): a verb the router grew but these sets have not
* must re-exec onto bun, never silently skip it and die under node with a misleading exit-4.
* - HEAVY verbs (`run day paper runs lineage leaderboard agent`) need `bun:sqlite`/`Bun.CryptoHasher`.
* Already under bun (`process.versions.bun`) → continue in-process. Under node → RE-EXEC
* `bun dist/cli.js <identical argv>` with stdio inherited, propagating the child's exit code
* (a signalled child → 128+signo, so SIGINT/SIGTERM/SIGSEGV surface as 130/143/139, not a
* flattened 1). Certified paths stay pinned-Bun by construction: the re-exec IS bun.
*
* ## Discovery — ONE gate, and it is behavioural
* A candidate is accepted iff `bun --version` exits 0 with a semver at or above {@link BUN_FLOOR}
* ({@link probeBun}). NOTHING else gates: no size sniff, no name heuristic. A file-size heuristic was
* tried and REVERTED — it rejected legitimate small wrappers (asdf/mise shims, a pnpm `.bin` script
* entry, a 42-byte `exec …/bun "$@"`) while adding nothing the probe does not already do: bun's own
* ~450-byte "postinstall did not run" placeholder exits 1 with no semver, so the probe rejects it
* anyway. The probe is also the only SOUND test available — on POSIX `execvp` hands an ENOEXEC file to
* `/bin/sh`, so a wrong-arch binary or a stray script would "launch" and exit nonzero, indistinguish-
* able from a genuine command failure, if we trusted the exec alone. The version floor matters because
* an old `bun` earlier on PATH would otherwise outrank the pinned bundled one and die deep inside the
* registry with a misleading `no such table` instead of a loud refusal here.
*
* Order:
* 1. `$KESTREL_BUN` — an EXPLICIT operator pin. If it does not probe clean the launcher fails LOUD
* (exit 4) and NEVER substitutes another bun: silently re-execing onto a runtime other than the
* one an operator pinned would void exactly the pinned-Bun certification this re-exec preserves.
* 2. Every `bun` on `$PATH`, in order.
* 3. The SHARED `$KESTREL_HOME/.kestrel/runtime` cache (else `~/.kestrel/runtime`) — one per-USER
* bun that all worktrees on a shared dev box reuse, mirroring the `$KESTREL_HOME/.kestrel` idiom
* `credentials.ts` uses. The first working bun this launcher resolves is MATERIALIZED here once by
* copying its BYTES (a hardlink when same-filesystem, a real copy on EXDEV — NEVER a symlink), so
* the cache is INDEPENDENT of any install's `node_modules`: it survives that tree being deleted
* (worktree cleanup) or absent (`--omit=optional`), the two scenarios it exists for. Best-effort and
* fail-quiet; subsequent runs — in this or any sibling worktree — find it before re-probing (or
* re-installing) a per-worktree copy (bead kestrel-2e2e).
* 4. The bundled `bun` optionalDependency — its postinstall-materialized `bun/bin/bun.exe` FIRST,
* then the `@oven/bun-*` packages filtered by platform+arch+libc (non-baseline before baseline),
* because npm installs every wrong-libc/baseline variant and bun's postinstall MOVES the correct
* one out of `@oven`.
* A candidate that fails the probe (or the exec) is SKIPPED and the next tried. `KESTREL_NO_BUNDLED_BUN=1`
* excludes the bundled PACKAGE entirely — by REALPATH, so its `.bin/bun` symlink (which npx puts first
* on PATH) cannot smuggle it back in. The flag targets that per-worktree package; the shared cache is a
* separate per-USER tier holding INDEPENDENT bytes (a hardlink/copy, not a symlink into the package), so
* a bun materialized there is its own artifact, no longer the bundled package the flag excludes.
*
* Only when EVERY candidate fails does the launcher fall through in-process: the heavy verb then faults
* through `loadHeavy` into the existing loud exit-4 RUNTIME_UNAVAILABLE refusal, rendered in the
* caller's mode — never a raw stack.
*/
"use strict";
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
// TEST-ISOLATION BUNDLE OVERRIDE (bead kestrel-viz2). By default the launcher loads/re-execs the shipped
// repo-root `dist/cli.js`. The spawn-driver e2e suites drive THIS launcher but each rebuilds that one
// shared bundle, so a peer's rebuild races another's `node bin/kestrel.cjs <verb>` → a non-deterministic
// exit-1 that looks like net-new breakage. `KESTREL_CLI` lets such a suite point the launcher at a PRIVATE
// bundle it built (via `KESTREL_CLI_OUTDIR`), so no concurrent repo-root rebuild can prune it out from
// under the spawn. Opt-in and absent from every production/npx path, so the default is unchanged.
const CLI = process.env.KESTREL_CLI ? path.resolve(process.env.KESTREL_CLI) : path.join(__dirname, "..", "dist", "cli.js");
/** The floor `package.json#engines.bun` promises. An older bun ships an older `bun:sqlite`. */
const BUN_FLOOR = [1, 1, 0];
// `sim` is LIGHT by construction (kestrel-585): it drives the hosted funnel over fetch only
// (no bun:sqlite/chdb), so the site's hero snippet runs under plain node via npx.
// `prove`/`replay`/`verify` (kestrel-markets-n04e.1) are LIGHT like `sim`: they drive the hosted
// funnel over fetch only (verify adds node:crypto — still a node built-in), so the zero-credential
// front door runs under plain node via npx with no bun anywhere.
// `certify` (kestrel-8kvs, gate G10) is LIGHT too: it fetches the evidence bundle over fetch and
// re-projects the Blotter with pure `project`/`serialize` + a node:crypto-free sha256 — no bun/chdb,
// no engine drive — so open recomputation runs under plain node via npx.
// `whoami`/`refresh` (kestrel-p7th) are LIGHT: `whoami` is a pure-local credential read (node
// built-ins + fs only, zero network); `refresh` renews the durable capability over fetch against the
// credential's own host (no bun/chdb) — so credential self-inspection + renewal run under plain node.
// `secrets` (kestrel-jh9w.2) is LIGHT: the operator/BYOK secret store is a node built-ins + fs
// read/write of `~/.kestrel/.env` with ZERO network and no bun/chdb — so credential residency is
// manageable under plain node via npx, like the rest of the credential surface.
const LIGHT_VERBS = new Set(["version", "help", "parse", "validate", "print", "frame", "percept", "sim", "register", "whoami", "refresh", "secrets", "card", "prove", "replay", "verify", "certify"]);
// `paper` (kestrel-7o2.12) is HEAVY: it dynamic-imports the same `src/cli/heavy.ts` barrel as
// `run`/`day` (bun:sqlite registry + ledger) to drive a live-feed session against the venue's
// paper gate. Under node it would otherwise fail open to LIGHT and hit the exit-4 refusal.
// `mcp` (kestrel-0f7i) is classified HEAVY like `agent`, for the same reason: its `--local` arm
// dynamic-imports the in-process engine (Bun.CryptoHasher et al.), so when a bun exists the re-exec
// serves BOTH transports. Its DEFAULT (remote) transport is pure fetch: on a genuinely bun-less host
// the launcher's fall-through runs it in-process under node and the stdio drop-in still works, while
// `--local` there faults loud (exit 4) through loadHeavy — never a raw Bun fault on the MCP wire.
const HEAVY_VERBS = new Set(["run", "day", "paper", "runs", "lineage", "leaderboard", "agent", "mcp"]);
/** Global flags that CONSUME the next token — so `--api run` can never be misread as the `run` verb. */
const VALUE_FLAGS = new Set(["--format", "--color", "--api"]);
/**
* Scan argv in order; the FIRST known verb decides. Help/version flags anywhere force LIGHT (the
* router's meta fast-path serves them without any heavy import), so `run --help` needs no bun. A value
* flag swallows its value; other dashed tokens are skipped. The first non-flag token IS the command: a
* LIGHT verb goes in-process, ANYTHING else (a HEAVY verb, or a verb these sets do not know) FAILS CLOSED
* to heavy. An argv with no leading verb at all stays LIGHT (the in-process router renders the usage
* error). The two sets are pinned set-equal to the router's verb inventory by tests/cli/heavy.test.ts,
* so a known verb classifies correctly; unknown→heavy is the authoring-time backstop behind that pin.
*/
function isHeavyInvocation(args) {
for (let i = 0; i < args.length; i += 1) {
const tok = args[i];
if (VALUE_FLAGS.has(tok)) {
i += 1; // a flag VALUE is never a flag
continue;
}
if (tok === "-h" || tok === "--help" || tok === "-V" || tok === "--version") return false;
}
for (let i = 0; i < args.length; i += 1) {
const tok = args[i];
if (VALUE_FLAGS.has(tok)) {
i += 1; // consume the flag's VALUE — never classify it as a verb
continue;
}
if (tok.startsWith("-")) continue;
if (LIGHT_VERBS.has(tok)) return false;
// The first non-flag token IS the command. A LIGHT verb went home above; anything else — a HEAVY
// verb OR a verb the launcher does not know (present in the router, absent from these sets) — FAILS
// CLOSED to heavy. Unknown→heavy is the SAFE direction: it only ever costs a bun re-exec (which the
// heavy path certifies anyway), whereas unknown→light silently skips the re-exec and dies under node
// with a misleading exit-4 RUNTIME_UNAVAILABLE. The drift pin (tests/cli/heavy.test.ts) still couples
// the sets to the router; this is the authoring-time backstop so a new verb can never fail open.
return true;
}
return false;
}
/**
* The render mode the CLI itself will resolve, by the SAME precedence as `src/cli/context.ts`:
* explicit `--json`/`--format` > `--agent`/env > piped/CI → text, TTY → human. The launcher must agree
* with it: in `json`/`text` mode stderr is a machine channel an agent parses, so a bare advisory line
* there would corrupt it.
*/
function resolveMode(args, env, stdoutTTY) {
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "--json") return "json";
if (args[i] === "--format") {
const v = args[i + 1];
if (v === "json" || v === "text" || v === "human") return v;
}
if (VALUE_FLAGS.has(args[i])) i += 1;
}
const agent = args.includes("--agent") || env.KESTREL_AGENT === "1" || env.AGENT === "1";
const isCI = !!(env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.GITLAB_CI);
if (agent) return "text";
return !stdoutTTY || isCI ? "text" : "human";
}
/** Render a launcher-level failure in the caller's mode, mirroring `src/cli/errors.ts#fail`, and exit 4. */
function failRuntime(mode, message, hint) {
if (mode === "json") {
process.stderr.write(JSON.stringify({ error: { code: "RUNTIME_UNAVAILABLE", message, hint } }) + "\n");
} else if (mode === "text") {
process.stderr.write(`error\tcode=RUNTIME_UNAVAILABLE\tmessage=${message}\thint=${hint}\n`);
} else {
process.stderr.write(`error: ${message}\nhint: ${hint}\n`);
}
process.exit(4);
}
/**
* The ONE gate: does this path BEHAVE like a bun at or above the floor? `bun --version` must exit 0 and
* print a semver. Costs one ~20ms spawn on a verb that is about to run a whole graded session.
*/
function probeBun(p) {
const res = spawnSync(p, ["--version"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
if (res.error !== undefined && res.error !== null) return false;
if (res.status !== 0) return false;
const m = /^(\d+)\.(\d+)\.(\d+)/.exec((res.stdout ?? "").trim());
if (m === null) return false;
const v = [Number(m[1]), Number(m[2]), Number(m[3])];
for (let i = 0; i < 3; i += 1) {
if (v[i] > BUN_FLOOR[i]) return true;
if (v[i] < BUN_FLOOR[i]) return false;
}
return true; // exactly the floor
}
/** True on a musl libc (Alpine): node reports no glibc runtime version there. */
function isMusl() {
try {
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : undefined;
const header = report !== undefined && report !== null ? report.header : undefined;
if (header !== undefined && header !== null && typeof header === "object") {
return header.glibcVersionRuntime === undefined;
}
} catch {
/* fall through to the filesystem probe */
}
return fs.existsSync("/etc/alpine-release");
}
/**
* The `@oven/bun-*` package names that can actually RUN here, best first. `@oven/bun-linux-*` declares
* no `libc` field, so npm installs EVERY linux variant for the arch (a Debian tree really does carry
* both `bun-linux-aarch64` and `bun-linux-aarch64-musl`) and bun's postinstall MOVES the correct one
* into `bun/bin/`, leaving the wrong-libc siblings behind. An unfiltered readdir therefore hands back
* an unrunnable binary on Linux, and coin-flips onto the slower `-baseline` build on x64.
*/
function ovenPackageNames() {
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x64" : null;
if (arch === null) return [];
const { platform } = process;
if (platform === "darwin") {
return arch === "aarch64" ? ["bun-darwin-aarch64"] : ["bun-darwin-x64", "bun-darwin-x64-baseline"];
}
if (platform === "win32") {
return arch === "aarch64" ? ["bun-windows-aarch64"] : ["bun-windows-x64", "bun-windows-x64-baseline"];
}
if (platform === "linux") {
const libc = isMusl() ? "-musl" : "";
return arch === "aarch64" ? [`bun-linux-aarch64${libc}`] : [`bun-linux-x64${libc}`, `bun-linux-x64${libc}-baseline`];
}
return [];
}
function exists(p) {
try {
return fs.statSync(p).isFile(); // follows symlinks (.bin entries are symlinks)
} catch {
return false;
}
}
/** The installed `bun` package dir, or null when the optional dependency is absent. */
function bunPackageDir() {
try {
return path.dirname(require.resolve("bun/package.json", { paths: [__dirname] }));
} catch {
return null; // installed with --omit=optional, or this platform has no bun build
}
}
/** Ranked bun binaries inside the bun package dir: the materialized bin, THEN the @oven fallbacks. */
function bundledCandidates(pkgDir) {
const out = [];
// 1) The postinstall-materialized bin — bun MOVED the right platform binary here, so it is the one
// location guaranteed correct for this libc/arch. Try it BEFORE any @oven scan.
for (const bin of ["bun.exe", "bun"]) {
const p = path.join(pkgDir, "bin", bin);
if (exists(p) && !out.includes(p)) out.push(p);
}
// 2) The @oven platform packages — real even when install scripts were skipped (--ignore-scripts,
// pnpm), but only the ones whose platform+arch+libc match this host.
for (const root of [path.join(pkgDir, "..", "@oven"), path.join(pkgDir, "node_modules", "@oven")]) {
for (const name of ovenPackageNames()) {
for (const bin of ["bun", "bun.exe"]) {
const p = path.join(root, name, "bin", bin);
if (exists(p) && !out.includes(p)) out.push(p);
}
}
}
return out;
}
function realpathOrSelf(p) {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
/** Every `bun` on PATH, in order. Names only — {@link probeBun} is the gate. */
function bunFromPath() {
const names = process.platform === "win32" ? ["bun.exe", "bun.cmd", "bun"] : ["bun"];
const out = [];
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
if (dir === "") continue;
for (const name of names) {
const p = path.join(dir, name);
if (exists(p) && !out.includes(p)) out.push(p);
}
}
return out;
}
/**
* The SHARED runtime-cache home — `$KESTREL_HOME/.kestrel/runtime` (else `~/.kestrel/runtime`),
* mirroring the `$KESTREL_HOME/.kestrel` idiom `src/cli/credentials.ts` established for the credential
* store. This is the ONE per-USER (not per-worktree, not per-install) location a bun is materialized
* into and reused from — the machine-health fix for a shared dev box where every worktree's
* `node_modules` would otherwise carry its own ~60MB bundled bun (bead kestrel-2e2e). An empty
* `KESTREL_HOME` is treated as unset (a bare relative `.kestrel` would be worse than useless).
*/
function sharedRuntimeDir() {
const home = process.env.KESTREL_HOME && process.env.KESTREL_HOME !== "" ? process.env.KESTREL_HOME : os.homedir();
return path.join(home, ".kestrel", "runtime");
}
/** The canonical shared-cache bun path this platform materializes/reads (`…/runtime/bin/bun[.exe]`). */
function sharedRuntimeBunPath() {
return path.join(sharedRuntimeDir(), "bin", process.platform === "win32" ? "bun.exe" : "bun");
}
/** The shared-cache bun binaries that actually exist right now (empty until first promotion). */
function sharedRuntimeCandidates() {
const out = [];
const dir = path.join(sharedRuntimeDir(), "bin");
for (const bin of ["bun.exe", "bun"]) {
const p = path.join(dir, bin);
if (exists(p) && !out.includes(p)) out.push(p);
}
return out;
}
/**
* A per-PROCESS counter that, with {@link process.pid}, names the temp file each promotion writes before
* its atomic rename. Uniqueness is derived from pid + this incrementing counter — NEVER `Math.random`/
* `Date.now` on the launcher path (they are banned on the runtime path, and pid+counter is already unique
* within and across the concurrent agents that share this box).
*/
let promoteCounter = 0;
/**
* Materialize a just-discovered bun into the shared cache, ONCE, so every OTHER worktree finds it here
* first and never re-probes (or re-installs) its own copy. It COPIES THE ACTUAL BYTES so the cache is
* INDEPENDENT of any `node_modules`: a HARDLINK (same-filesystem — instant, shares the inode, survives
* deletion of the source path as long as this cache link exists), falling back to a real COPY on EXDEV
* (cross-device). NEVER a symlink — a symlink into some install's `node_modules` DANGLES the moment that
* tree is deleted (worktree cleanup — the exact disk-churn this cache targets) or absent
* (`npm install --omit=optional` — the exact survival case), making the shared cache useless in precisely
* the two scenarios it exists for (bead kestrel-2e2e).
*
* ATOMICITY (50-100 concurrent agents may promote at once): the bytes land in a UNIQUE temp name in the
* SAME directory as `dest`, then a single {@link fs.renameSync} publishes it — rename is atomic on POSIX,
* so a concurrent reader NEVER observes a half-materialized binary. Benign races are swallowed: if `dest`
* already exists a peer won — clean up the temp and return; an `EEXIST` on the rename (Windows, where
* rename does not replace) is the same peer-won case.
*
* Strictly BEST-EFFORT and fail-quiet: a read-only home, a sandbox, a cross-device copy that runs out of
* disk, or a race with a peer must NEVER turn caching a runtime into a failed session (fail-closed:
* caching is an optimization, never a gate). Any temp file is cleaned up on every failure path, and the
* run continues on the bun it already resolved.
*/
function promoteToSharedRuntime(bun) {
let tmp = null;
try {
const dest = sharedRuntimeBunPath();
if (exists(dest)) return; // already materialized (this very bun, or a peer's) — reuse, don't churn
const src = realpathOrSelf(bun);
const destDir = path.dirname(dest);
fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
// A UNIQUE temp in the SAME dir as dest, so publishing is a same-directory (atomic) rename.
promoteCounter += 1;
tmp = dest + "." + process.pid + "." + promoteCounter + ".tmp";
// Copy the BYTES: a hardlink when same-fs, a real copy on EXDEV. Either way the cache no longer
// depends on the source path surviving — that is the whole fix.
try {
fs.linkSync(src, tmp);
} catch (e) {
if (e && e.code === "EXDEV") fs.copyFileSync(src, tmp);
else throw e;
}
fs.chmodSync(tmp, 0o755); // executable — before the rename, so the published entry is ready to run
try {
fs.renameSync(tmp, dest); // atomic publish — a reader sees the whole binary or nothing
tmp = null; // renamed into place; nothing left to clean up
} catch (e) {
if (e && e.code === "EEXIST") return; // a peer worktree won the race (Windows) — cache is populated
throw e; // any other rename failure falls to the fail-quiet catch; the temp is cleaned in finally
}
} catch {
/* best-effort ONLY — never let caching a runtime break the run */
} finally {
if (tmp !== null) {
try {
fs.unlinkSync(tmp); // clean up the temp on every failure/peer-won path
} catch {
/* nothing to clean — already gone */
}
}
}
}
/**
* The ranked candidate list for the IMPLICIT tiers: PATH, then the SHARED `~/.kestrel/runtime` cache,
* then the per-worktree bundled package. The shared cache outranks the bundled copy so a machine that
* has materialized bun once reuses it instead of every worktree re-probing its own. `KESTREL_NO_BUNDLED_BUN=1`
* drops the bundled package by REALPATH — `node_modules/.bin/bun` is a symlink INTO that package and
* npx puts it first on PATH, so a name-only exclusion would smuggle the bundled bun straight back in
* and make the escape hatch a lie. The realpath filter still drops any PATH entry that resolves into the
* bundled package; it does NOT reach the shared cache, because the cache now holds independent bytes (a
* hardlink/copy), not a symlink back into the package — a materialized cache entry is its own artifact.
*/
function bunCandidates() {
const pkgDir = bunPackageDir();
const excludeBundled = process.env.KESTREL_NO_BUNDLED_BUN === "1" && pkgDir !== null;
const bundledRoot = pkgDir === null ? null : realpathOrSelf(pkgDir);
const ovenRoot = pkgDir === null ? null : realpathOrSelf(path.join(pkgDir, "..", "@oven"));
const fromBundle = pkgDir === null || excludeBundled ? [] : bundledCandidates(pkgDir);
const out = [];
for (const p of [...bunFromPath(), ...sharedRuntimeCandidates(), ...fromBundle]) {
if (excludeBundled) {
const real = realpathOrSelf(p);
if (bundledRoot !== null && real.startsWith(bundledRoot + path.sep)) continue;
if (ovenRoot !== null && real.startsWith(ovenRoot + path.sep)) continue;
}
if (!out.includes(p)) out.push(p);
}
return out;
}
/** Load the dist entry under the CURRENT runtime; it runs `main(process.argv.slice(2))` and exits. */
function runInProcess() {
import(pathToFileURL(CLI).href).catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: failed to load ${CLI}: ${msg}\n`);
process.exit(1);
});
}
/** Re-exec the CLI under `bun`, propagating the child's exit code. Returns only if it did NOT launch. */
function reExec(bun, argv) {
const res = spawnSync(bun, [CLI, ...argv], { stdio: "inherit" });
if (res.error !== undefined && res.error !== null) return;
if (res.signal !== null && res.signal !== undefined) {
const signo = os.constants.signals[res.signal];
process.exit(128 + (typeof signo === "number" ? signo : 1)); // SIGINT→130, SIGTERM→143, SIGSEGV→139
}
process.exit(res.status ?? 1);
}
function launch(argv) {
if (!isHeavyInvocation(argv) || process.versions.bun !== undefined) {
runInProcess();
return;
}
// Tier 1 — the EXPLICIT pin. An operator who names a bun gets THAT bun or a loud refusal; quietly
// falling back to another runtime would void the pinned-Bun guarantee the re-exec exists to keep.
const pin = process.env.KESTREL_BUN;
if (pin !== undefined && pin !== "") {
if (probeBun(pin)) reExec(pin, argv);
failRuntime(
resolveMode(argv, process.env, process.stdout.isTTY === true),
`KESTREL_BUN=${pin} is not a working Bun >= ${BUN_FLOOR.join(".")}`,
"point KESTREL_BUN at a Bun binary, or unset it to use PATH / the bundled runtime",
);
return; // unreachable (failRuntime exits) — belt and braces
}
// Tiers 2-4 — PATH, then the shared `~/.kestrel/runtime` cache, then the bundled dependency. A
// candidate that fails the probe or the exec is skipped for the next one. The FIRST working bun is
// promoted into the shared cache (best-effort) so every OTHER worktree resolves it there first.
for (const bun of bunCandidates()) {
if (!probeBun(bun)) continue;
promoteToSharedRuntime(bun); // materialize once, reuse across worktrees (fail-quiet)
reExec(bun, argv); // exits, unless the exec itself failed
}
// Nothing anywhere → fall through to the loud exit-4 RUNTIME_UNAVAILABLE refusal.
runInProcess();
}
if (require.main === module) launch(process.argv.slice(2));
module.exports = {
BUN_FLOOR,
LIGHT_VERBS,
HEAVY_VERBS,
isHeavyInvocation,
resolveMode,
probeBun,
ovenPackageNames,
bundledCandidates,
bunCandidates,
sharedRuntimeDir,
sharedRuntimeBunPath,
sharedRuntimeCandidates,
promoteToSharedRuntime,
};