@kilogent/runner-dev 0.1.4 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +30 -4
  2. package/dist/cli.js +1360 -516
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,9 +4,9 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/daemon.ts
7
- import fs9 from "node:fs";
7
+ import fs14 from "node:fs";
8
8
  import os5 from "node:os";
9
- import path11 from "node:path";
9
+ import path15 from "node:path";
10
10
  import {
11
11
  collection as collection8,
12
12
  deleteField as deleteField2,
@@ -242,7 +242,18 @@ var ASSISTANT_FROZEN_ARGS = Object.freeze({
242
242
  // ../shared/dist/assistantConfig.js
243
243
  var CONFIG_DOCS = {
244
244
  /** `crewConfig/assistant` — the workspace assistant's prompt, model, budget and prices. */
245
- assistant: "assistant"
245
+ assistant: "assistant",
246
+ /**
247
+ * `crewConfig/engines` — which version of each engine's CLI the fleet believes works, and which
248
+ * versions a runner proved broken (PRD §15.82).
249
+ *
250
+ * Read by every RUNNER with the client SDK, written by nobody but the Admin SDK, exactly like
251
+ * the assistant contract beside it. The shipped defaults live in the runner
252
+ * (`engines/install/spec.ts`) rather than here on purpose: the runner is the only thing that
253
+ * installs, and keeping them out of a package the app also reads keeps a surface a captain can
254
+ * edit from ever naming a version.
255
+ */
256
+ engines: "engines"
246
257
  };
247
258
  var DEFAULT_ASSISTANT_MODEL = "gemini-3.1-flash-lite";
248
259
  var DEFAULT_DAILY_CAP_USD = 0.1;
@@ -377,10 +388,10 @@ function rates(value) {
377
388
  }
378
389
  return Object.keys(out).length > 0 ? out : void 0;
379
390
  }
380
- function resolveAssistantConfig(raw, engine) {
391
+ function resolveAssistantConfig(raw, engine2) {
381
392
  const doc13 = raw ?? {};
382
393
  const shared = text(doc13.contract) ?? DEFAULT_ASSISTANT_CONTRACT;
383
- const perEngine = engine === "free" ? text(doc13.freeContract) ?? DEFAULT_FREE_CONTRACT : text(doc13.agenticContract) ?? DEFAULT_AGENTIC_CONTRACT;
394
+ const perEngine = engine2 === "free" ? text(doc13.freeContract) ?? DEFAULT_FREE_CONTRACT : text(doc13.agenticContract) ?? DEFAULT_AGENTIC_CONTRACT;
384
395
  return {
385
396
  contract: `${shared}
386
397
 
@@ -806,11 +817,11 @@ function machineLoginPending(c, now) {
806
817
  return now - c.login.requestedAt < MACHINE_LOGIN_TTL_MS ? "start" : "expire";
807
818
  }
808
819
  function resolveAgentCredential(agent, credentials, defaultEngine) {
809
- const engine = agent.engine ?? defaultEngine;
820
+ const engine2 = agent.engine ?? defaultEngine;
810
821
  if (agent.credentialId) {
811
822
  return credentials.find((c) => c.id === agent.credentialId) ?? null;
812
823
  }
813
- const forEngine = credentials.filter((c) => c.engine === engine && isCredentialReady(c));
824
+ const forEngine = credentials.filter((c) => c.engine === engine2 && isCredentialReady(c));
814
825
  const oldest = [...forEngine].sort((a, b) => a.createdAt - b.createdAt)[0];
815
826
  return oldest ?? void 0;
816
827
  }
@@ -887,9 +898,9 @@ function str(input, key) {
887
898
  const value = input[key];
888
899
  return typeof value === "string" && value.trim() ? value : void 0;
889
900
  }
890
- function basename(path13) {
891
- const parts = path13.split(/[\\/]/).filter(Boolean);
892
- return parts[parts.length - 1] ?? path13;
901
+ function basename(path17) {
902
+ const parts = path17.split(/[\\/]/).filter(Boolean);
903
+ return parts[parts.length - 1] ?? path17;
893
904
  }
894
905
  function hostOf(url) {
895
906
  try {
@@ -977,8 +988,8 @@ function builtinDetail(tool, input) {
977
988
  case "Write":
978
989
  case "Edit":
979
990
  case "MultiEdit": {
980
- const path13 = str(input, "file_path");
981
- return path13 ? basename(path13) : void 0;
991
+ const path17 = str(input, "file_path");
992
+ return path17 ? basename(path17) : void 0;
982
993
  }
983
994
  case "Glob":
984
995
  case "Grep":
@@ -1246,7 +1257,7 @@ import os from "node:os";
1246
1257
  import path from "node:path";
1247
1258
 
1248
1259
  // src/version.ts
1249
- var RUNNER_VERSION = true ? "0.1.4" : "0.0.0-dev";
1260
+ var RUNNER_VERSION = true ? "0.1.6" : "0.0.0-dev";
1250
1261
  var RUNNER_PACKAGE = true ? "@kilogent/runner-dev" : "@kilogent/runner-dev";
1251
1262
  var RUNNER_BIN = true ? "kilogent-runner-dev" : "kilogent-runner-dev";
1252
1263
 
@@ -1358,11 +1369,15 @@ function allowsLocalMcp(config2, shipId) {
1358
1369
  var TOGGLE_DEFAULTS = {
1359
1370
  notifications: false,
1360
1371
  keepAwake: true,
1361
- autoUpdate: true
1372
+ autoUpdate: true,
1373
+ autoInstallEngines: true
1362
1374
  };
1363
1375
  function notificationsEnabled(config2) {
1364
1376
  return config2?.notifications ?? TOGGLE_DEFAULTS.notifications;
1365
1377
  }
1378
+ function engineInstallAllowed(config2) {
1379
+ return config2?.autoInstallEngines ?? TOGGLE_DEFAULTS.autoInstallEngines;
1380
+ }
1366
1381
  function mcpUrl(config2) {
1367
1382
  return process.env.CREW_MCP_URL || config2.mcpUrl || (config2.projectId === DEFAULT_PROJECT_ID ? DEFAULT_MCP_URL : `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`);
1368
1383
  }
@@ -2480,12 +2495,12 @@ async function resolveAssistantCredential(db, shipId, machine) {
2480
2495
  const credentialId = settings?.assistant?.credentialId ?? settings?.defaultCredentialId;
2481
2496
  const credentials = await loadShipCredentials(db, shipId).catch(() => []);
2482
2497
  const credential = credentialId ? credentials.find((c) => c.id === credentialId) : void 0;
2483
- const engine = credential?.engine ?? DEFAULT_ENGINE_ID;
2498
+ const engine2 = credential?.engine ?? DEFAULT_ENGINE_ID;
2484
2499
  const secrets = await resolveJobSecrets({
2485
2500
  db,
2486
2501
  shipId,
2487
2502
  // The synthetic agent: two fields, both of which this module has just decided.
2488
- agent: { engine, credentialId },
2503
+ agent: { engine: engine2, credentialId },
2489
2504
  credentials,
2490
2505
  machine
2491
2506
  });
@@ -2494,22 +2509,220 @@ async function resolveAssistantCredential(db, shipId, machine) {
2494
2509
  // A MODEL, ALWAYS. `engines/claude.ts` puts this straight onto `--model`, and an empty string
2495
2510
  // is an empty flag — a failure that reads as the model misbehaving rather than as a missing
2496
2511
  // value. The engine's own default is the right answer when nothing has been chosen.
2497
- model: getEngine(engine)?.defaultModelId ?? ""
2512
+ model: getEngine(engine2)?.defaultModelId ?? ""
2498
2513
  };
2499
2514
  }
2500
2515
 
2501
2516
  // src/engines/claude.ts
2502
2517
  import { spawn as spawn3 } from "node:child_process";
2503
- import fs3 from "node:fs";
2518
+ import fs5 from "node:fs";
2504
2519
  import os2 from "node:os";
2520
+ import path6 from "node:path";
2521
+
2522
+ // src/engines/binary.ts
2523
+ import fs4 from "node:fs";
2524
+
2525
+ // src/engines/install/spec.ts
2505
2526
  import path3 from "node:path";
2527
+ var ENGINE_PACKAGES = {
2528
+ codex: { npmPackage: "@openai/codex", binName: "codex" },
2529
+ claude: { npmPackage: "@anthropic-ai/claude-code", binName: "claude" }
2530
+ };
2531
+ function enginePackageFor(engineId) {
2532
+ return Object.prototype.hasOwnProperty.call(ENGINE_PACKAGES, engineId) ? ENGINE_PACKAGES[engineId] : null;
2533
+ }
2534
+ function installableEngineIds() {
2535
+ return Object.keys(ENGINE_PACKAGES);
2536
+ }
2537
+ var EXACT_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[A-Za-z0-9.-]+)?$/;
2538
+ function isExactVersion(version) {
2539
+ return typeof version === "string" && version.length <= 64 && EXACT_VERSION.test(version);
2540
+ }
2541
+ var SEGMENT = /^[A-Za-z0-9_-]+$/;
2542
+ function assertPathSegment(segment) {
2543
+ if (!SEGMENT.test(segment)) throw new Error(`not a valid path segment: ${JSON.stringify(segment)}`);
2544
+ }
2545
+ function enginePrefixDir(root, engineId) {
2546
+ assertPathSegment(engineId);
2547
+ return path3.join(root, "tools", engineId);
2548
+ }
2549
+ function engineBinPath(root, engineId, binName, platform = process.platform) {
2550
+ assertPathSegment(engineId);
2551
+ assertPathSegment(binName);
2552
+ const prefix = enginePrefixDir(root, engineId);
2553
+ return platform === "win32" ? path3.join(prefix, binName) : path3.join(prefix, "bin", binName);
2554
+ }
2555
+ function engineMarkerPath(root, engineId) {
2556
+ return path3.join(enginePrefixDir(root, engineId), "installed.json");
2557
+ }
2558
+ var DEFAULT_ENGINE_POLICIES = {
2559
+ codex: { knownGood: "0.152.0", blocked: [], minAgeHours: 24 },
2560
+ claude: { knownGood: "2.1.258", blocked: [], minAgeHours: 24 }
2561
+ };
2562
+ var MAX_BLOCKED_VERSIONS = 20;
2563
+ var ENGINE_FIRST_CHECK_MS = 6e4;
2564
+ var ENGINE_CHECK_MS = 6 * 36e5;
2565
+ function resolveEnginePolicy(engineId, doc13) {
2566
+ const shipped = DEFAULT_ENGINE_POLICIES[engineId] ?? { knownGood: "", blocked: [], minAgeHours: 24 };
2567
+ const raw = doc13?.[engineId];
2568
+ if (!raw || typeof raw !== "object") return shipped;
2569
+ const entry = raw;
2570
+ const blocked = Array.isArray(entry.blocked) ? entry.blocked.filter(isExactVersion) : shipped.blocked;
2571
+ const age = typeof entry.minAgeHours === "number" && Number.isFinite(entry.minAgeHours) && entry.minAgeHours >= 0 ? Math.min(entry.minAgeHours, 24 * 30) : shipped.minAgeHours;
2572
+ return {
2573
+ knownGood: isExactVersion(entry.knownGood) ? entry.knownGood : shipped.knownGood,
2574
+ blocked: blocked.slice(-MAX_BLOCKED_VERSIONS),
2575
+ minAgeHours: age
2576
+ };
2577
+ }
2578
+ function chooseEngineVersion(input) {
2579
+ const { policy, latest, installed, firstSeen, now } = input;
2580
+ const blocked = new Set(policy.blocked);
2581
+ if (installed && blocked.has(installed)) {
2582
+ return isExactVersion(policy.knownGood) && !blocked.has(policy.knownGood) ? { version: policy.knownGood, reason: "known-good" } : { version: null, reason: "no-policy" };
2583
+ }
2584
+ if (!installed) {
2585
+ if (!isExactVersion(policy.knownGood) || blocked.has(policy.knownGood)) {
2586
+ return { version: null, reason: "no-policy" };
2587
+ }
2588
+ return { version: policy.knownGood, reason: "known-good" };
2589
+ }
2590
+ if (!latest || !isExactVersion(latest)) return { version: null, reason: "no-registry" };
2591
+ if (latest === installed) return { version: null, reason: "up-to-date" };
2592
+ if (blocked.has(latest)) return { version: null, reason: "blocked-latest" };
2593
+ if (!firstSeen || firstSeen.version !== latest) return { version: null, reason: "soaking" };
2594
+ if (now - firstSeen.at < policy.minAgeHours * 36e5) return { version: null, reason: "soaking" };
2595
+ return { version: latest, reason: "upgrade" };
2596
+ }
2597
+ var MAX_ENGINE_INSTALL_ATTEMPTS = 3;
2598
+ var ENGINE_BACKOFF_MS = [15 * 6e4, 2 * 36e5, 12 * 36e5];
2599
+ function decideEngineInstall(facts) {
2600
+ if (!enginePackageFor(facts.engineId)) return { install: false, reason: "unknown-engine" };
2601
+ if (!facts.autoInstall) return { install: false, reason: "disabled" };
2602
+ if (facts.platform === "win32") return { install: false, reason: "windows" };
2603
+ if (!isExactVersion(facts.target)) return { install: false, reason: "unknown-engine" };
2604
+ const pkg = enginePackageFor(facts.engineId);
2605
+ if (facts.marker && facts.marker.npmPackage === pkg.npmPackage && facts.marker.version === facts.target) {
2606
+ return { install: false, reason: "already-installed" };
2607
+ }
2608
+ if (!facts.npmPresent()) return { install: false, reason: "no-npm" };
2609
+ if (facts.locked) return { install: false, reason: "locked" };
2610
+ const record = facts.record;
2611
+ if (record && record.target === facts.target && record.outcome === "failed") {
2612
+ if (record.attempts >= MAX_ENGINE_INSTALL_ATTEMPTS) return { install: false, reason: "exhausted" };
2613
+ const wait = ENGINE_BACKOFF_MS[Math.min(record.attempts - 1, ENGINE_BACKOFF_MS.length - 1)] ?? 0;
2614
+ if (facts.now - record.at < wait) return { install: false, reason: "backoff" };
2615
+ }
2616
+ return { install: true, target: facts.target };
2617
+ }
2618
+ function describeEngineInstallRefusal(reason, engineId, runnerBin) {
2619
+ switch (reason) {
2620
+ case "unknown-engine":
2621
+ return `This runner has no installer for "${engineId}" \u2014 update it, or install that CLI yourself and point ${engineBinEnvHint(engineId)} at it.`;
2622
+ case "disabled":
2623
+ return `Installing engine CLIs is turned off on this machine (\`${runnerBin} config set autoInstallEngines on\` to allow it).`;
2624
+ case "windows":
2625
+ return `This runner does not install engine CLIs on Windows yet \u2014 install "${engineId}" yourself and point ${engineBinEnvHint(engineId)} at it.`;
2626
+ case "no-npm":
2627
+ return "`npm` is not on this machine's PATH, so there is nothing to install with.";
2628
+ case "already-installed":
2629
+ return `"${engineId}" is already installed at the version this machine wants.`;
2630
+ case "locked":
2631
+ return `Another install of "${engineId}" is already running on this machine.`;
2632
+ case "backoff":
2633
+ return `Waiting before trying "${engineId}" again \u2014 the last install failed (\`${runnerBin} engine install ${engineId}\` to try now).`;
2634
+ case "exhausted":
2635
+ return `Installing "${engineId}" failed ${MAX_ENGINE_INSTALL_ATTEMPTS} times on this machine \u2014 see \`${runnerBin} engine list\`.`;
2636
+ }
2637
+ }
2638
+ function engineBinEnvHint(engineId) {
2639
+ return `CREW_${engineId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`;
2640
+ }
2641
+
2642
+ // src/engines/install/state.ts
2643
+ import fs3 from "node:fs";
2644
+ import path4 from "node:path";
2645
+ var ENGINE_STATE_FILE = "engines.json";
2646
+ function engineStatePath(root) {
2647
+ return path4.join(root, ENGINE_STATE_FILE);
2648
+ }
2649
+ function readEngineState(root) {
2650
+ try {
2651
+ const parsed = JSON.parse(fs3.readFileSync(engineStatePath(root), "utf8"));
2652
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
2653
+ return parsed;
2654
+ } catch {
2655
+ return {};
2656
+ }
2657
+ }
2658
+ function recordEngineInstall(root, engineId, patch) {
2659
+ try {
2660
+ const state = readEngineState(root);
2661
+ const previous = state[engineId];
2662
+ state[engineId] = { ...previous ?? { target: "", npmPackage: "", attempts: 0, at: 0, outcome: "failed" }, ...patch };
2663
+ fs3.mkdirSync(root, { recursive: true, mode: 448 });
2664
+ fs3.writeFileSync(engineStatePath(root), `${JSON.stringify(state, null, 2)}
2665
+ `, { mode: 384 });
2666
+ return true;
2667
+ } catch {
2668
+ return false;
2669
+ }
2670
+ }
2671
+ function readEngineMarker(root, engineId) {
2672
+ try {
2673
+ const parsed = JSON.parse(fs3.readFileSync(engineMarkerPath(root, engineId), "utf8"));
2674
+ if (!parsed || typeof parsed !== "object") return null;
2675
+ const marker = parsed;
2676
+ if (typeof marker.npmPackage !== "string" || typeof marker.version !== "string") return null;
2677
+ if (typeof marker.bin !== "string" || !marker.bin) return null;
2678
+ return { npmPackage: marker.npmPackage, version: marker.version, bin: marker.bin, at: typeof marker.at === "number" ? marker.at : 0 };
2679
+ } catch {
2680
+ return null;
2681
+ }
2682
+ }
2683
+ function writeEngineMarker(root, engineId, marker) {
2684
+ try {
2685
+ fs3.writeFileSync(engineMarkerPath(root, engineId), `${JSON.stringify(marker, null, 2)}
2686
+ `, { mode: 384 });
2687
+ return true;
2688
+ } catch {
2689
+ return false;
2690
+ }
2691
+ }
2692
+ function clearEngineMarker(root, engineId) {
2693
+ try {
2694
+ fs3.rmSync(engineMarkerPath(root, engineId), { force: true });
2695
+ } catch {
2696
+ }
2697
+ }
2506
2698
 
2507
2699
  // src/engines/binary.ts
2508
2700
  function engineBinaryEnvVar(engineId) {
2509
2701
  return `CREW_${engineId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`;
2510
2702
  }
2511
- function engineBinary(engineId, defaultName) {
2512
- return process.env[engineBinaryEnvVar(engineId)] || defaultName;
2703
+ function managedEngineBin(engineId, options = {}) {
2704
+ const pkg = enginePackageFor(engineId);
2705
+ if (!pkg) return null;
2706
+ let root;
2707
+ try {
2708
+ root = options.root ?? configDir();
2709
+ } catch {
2710
+ return null;
2711
+ }
2712
+ const marker = readEngineMarker(root, engineId);
2713
+ if (!marker || marker.npmPackage !== pkg.npmPackage) return null;
2714
+ try {
2715
+ fs4.accessSync(marker.bin, fs4.constants.X_OK);
2716
+ } catch {
2717
+ return null;
2718
+ }
2719
+ return marker.bin;
2720
+ }
2721
+ function engineBinary(engineId, defaultName, options = {}) {
2722
+ const env = options.env ?? process.env;
2723
+ const override = env[engineBinaryEnvVar(engineId)];
2724
+ if (override) return override;
2725
+ return managedEngineBin(engineId, options) ?? defaultName;
2513
2726
  }
2514
2727
 
2515
2728
  // src/engines/claudeEvents.ts
@@ -2576,6 +2789,30 @@ function mcpCallsFromClaudeEvent(event, pairing) {
2576
2789
  return calls;
2577
2790
  }
2578
2791
 
2792
+ // src/engines/engineFault.ts
2793
+ var ARGV_REJECTED = [
2794
+ /unexpected argument/i,
2795
+ /unrecognized (?:subcommand|option)/i,
2796
+ /unknown (?:option|argument|command)/i,
2797
+ /invalid value .* for/i,
2798
+ /error: unexpected/i
2799
+ ];
2800
+ function detectEngineFault(input) {
2801
+ if (input.explained) return void 0;
2802
+ if (input.exitCode === 0) return void 0;
2803
+ const stderr = input.stderr.slice(-4e3);
2804
+ for (const pattern of ARGV_REJECTED) {
2805
+ if (pattern.test(stderr)) {
2806
+ const line = stderr.split("\n").find((l) => pattern.test(l))?.trim() ?? "";
2807
+ return `The CLI rejected the command line this runner builds: ${line.slice(0, 200)}`;
2808
+ }
2809
+ }
2810
+ if (input.events === 0 && stderr.trim().length === 0) {
2811
+ return `The CLI produced no output and exited ${input.exitCode ?? "on a signal"}.`;
2812
+ }
2813
+ return void 0;
2814
+ }
2815
+
2579
2816
  // src/engines/githubEnv.ts
2580
2817
  function githubSessionEnv(token) {
2581
2818
  if (!token) return {};
@@ -2588,6 +2825,15 @@ function githubSessionEnv(token) {
2588
2825
  };
2589
2826
  }
2590
2827
 
2828
+ // src/engines/spawnEnv.ts
2829
+ import path5 from "node:path";
2830
+ function withNodeOnPath(env = process.env) {
2831
+ const nodeDir = path5.dirname(process.execPath);
2832
+ const current = env.PATH ?? "";
2833
+ if (current === nodeDir || current.startsWith(nodeDir + path5.delimiter)) return { ...env };
2834
+ return { ...env, PATH: current ? `${nodeDir}${path5.delimiter}${current}` : nodeDir };
2835
+ }
2836
+
2591
2837
  // src/engines/limitWindow.ts
2592
2838
  var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
2593
2839
  function saneResetAt(reported, now, maxMs = MAX_LIMIT_MS) {
@@ -2713,9 +2959,9 @@ function allowedTools(agent, extraServers = []) {
2713
2959
  return [...new Set(tools)];
2714
2960
  }
2715
2961
  function createSessionDirs(jobId) {
2716
- const workdir = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-job-${jobId}-`));
2717
- const configDir2 = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-cfg-${jobId}-`));
2718
- return { workdir, configDir: configDir2, mcpConfigPath: path3.join(configDir2, "mcp.json") };
2962
+ const workdir = fs5.mkdtempSync(path6.join(os2.tmpdir(), `crew-job-${jobId}-`));
2963
+ const configDir2 = fs5.mkdtempSync(path6.join(os2.tmpdir(), `crew-cfg-${jobId}-`));
2964
+ return { workdir, configDir: configDir2, mcpConfigPath: path6.join(configDir2, "mcp.json") };
2719
2965
  }
2720
2966
  function buildMcpConfig(input) {
2721
2967
  const mcpServers = {
@@ -2762,11 +3008,11 @@ function buildMcpConfig(input) {
2762
3008
  return { mcpServers };
2763
3009
  }
2764
3010
  function writeMcpConfig(dirs, input) {
2765
- fs3.writeFileSync(dirs.mcpConfigPath, JSON.stringify(buildMcpConfig(input)), { mode: 384 });
3011
+ fs5.writeFileSync(dirs.mcpConfigPath, JSON.stringify(buildMcpConfig(input)), { mode: 384 });
2766
3012
  }
2767
3013
  function cleanupSessionDirs(dirs) {
2768
- fs3.rmSync(dirs.workdir, { recursive: true, force: true });
2769
- fs3.rmSync(dirs.configDir, { recursive: true, force: true });
3014
+ fs5.rmSync(dirs.workdir, { recursive: true, force: true });
3015
+ fs5.rmSync(dirs.configDir, { recursive: true, force: true });
2770
3016
  }
2771
3017
  async function runClaudeSession(input) {
2772
3018
  const bin = claudeBinary();
@@ -2803,7 +3049,9 @@ async function runSession(input, bin, dirs) {
2803
3049
  ];
2804
3050
  const claudeToken = engineSecret(input.secrets, CLAUDE_SECRET_KEY);
2805
3051
  const env = {
2806
- ...process.env,
3052
+ // Claude ships a native launcher and does not need this today; every engine gets it anyway,
3053
+ // because which engines do is the vendor's packaging and changes in a release (spawnEnv.ts).
3054
+ ...withNodeOnPath(),
2807
3055
  // THE INIT EVENT MUST BE TRUTHFUL, and one inherited variable is enough to make it lie.
2808
3056
  //
2809
3057
  // `MCP_CONNECTION_NONBLOCKING` lets the CLI start before its MCP servers have settled. The init
@@ -2921,6 +3169,12 @@ ${stderrLines.slice(-20).join("\n")}`,
2921
3169
  Date.now(),
2922
3170
  engineUsageWindows(CLAUDE_DRIVER_ID)?.fallbackMs ?? 5 * 60 * 60 * 1e3
2923
3171
  );
3172
+ const engineFault = ok2 ? void 0 : detectEngineFault({
3173
+ exitCode,
3174
+ stderr: stderrLines.join("\n"),
3175
+ events: lines.length,
3176
+ explained: Boolean(limit4 || mcpProblem || resultEvent)
3177
+ });
2924
3178
  return {
2925
3179
  ok: ok2,
2926
3180
  transcript: `${lines.join("\n")}
@@ -2928,7 +3182,8 @@ ${stderrLines.slice(-20).join("\n")}`,
2928
3182
  usage,
2929
3183
  resultText: resultText3,
2930
3184
  ...limit4 ? { limit: limit4 } : {},
2931
- ...mcpFailed.length ? { mcpFailed } : {}
3185
+ ...mcpFailed.length ? { mcpFailed } : {},
3186
+ ...engineFault ? { engineFault } : {}
2932
3187
  };
2933
3188
  }
2934
3189
  var CLAUDE_DRIVER_ID = "claude";
@@ -2948,8 +3203,8 @@ function emptyUsage() {
2948
3203
  durationS: 0
2949
3204
  };
2950
3205
  }
2951
- async function claudeHealthCheck() {
2952
- const bin = claudeBinary();
3206
+ async function claudeHealthCheck(binOverride) {
3207
+ const bin = binOverride ?? claudeBinary();
2953
3208
  return new Promise((resolve) => {
2954
3209
  let settled = false;
2955
3210
  const done = (health) => {
@@ -2958,7 +3213,7 @@ async function claudeHealthCheck() {
2958
3213
  resolve({ ...health, binary: bin });
2959
3214
  };
2960
3215
  let stdout = "";
2961
- const child = spawn3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
3216
+ const child = spawn3(bin, ["--version"], { env: withNodeOnPath(), stdio: ["ignore", "pipe", "ignore"] });
2962
3217
  const timer = setTimeout(() => {
2963
3218
  child.kill("SIGKILL");
2964
3219
  done({ ok: false, detail: `\`${bin} --version\` timed out`, fix: "Check the Claude CLI install." });
@@ -3001,9 +3256,9 @@ var claudeDriver = {
3001
3256
 
3002
3257
  // src/engines/codex.ts
3003
3258
  import { spawn as spawn4 } from "node:child_process";
3004
- import fs4 from "node:fs";
3259
+ import fs6 from "node:fs";
3005
3260
  import os3 from "node:os";
3006
- import path4 from "node:path";
3261
+ import path7 from "node:path";
3007
3262
 
3008
3263
  // src/engines/ansi.ts
3009
3264
  var ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
@@ -3266,7 +3521,7 @@ var codexLogin = {
3266
3521
  logoutArgs: () => ["logout"],
3267
3522
  statusArgs: () => ["login", "status"],
3268
3523
  env: (home) => ({ CODEX_HOME: home }),
3269
- isSignedIn: (home) => fs4.existsSync(path4.join(home, AUTH_FILE)),
3524
+ isSignedIn: (home) => fs6.existsSync(path7.join(home, AUTH_FILE)),
3270
3525
  parseGuidance(text2) {
3271
3526
  const clean = stripAnsi(text2);
3272
3527
  const url = GUIDANCE_URL_RE.exec(clean)?.[0];
@@ -3282,7 +3537,7 @@ var codexLogin = {
3282
3537
  readAccount(home) {
3283
3538
  let parsed;
3284
3539
  try {
3285
- parsed = JSON.parse(fs4.readFileSync(path4.join(home, AUTH_FILE), "utf8"));
3540
+ parsed = JSON.parse(fs6.readFileSync(path7.join(home, AUTH_FILE), "utf8"));
3286
3541
  } catch {
3287
3542
  return null;
3288
3543
  }
@@ -3330,17 +3585,18 @@ async function runCodexSession(input) {
3330
3585
  { credentialLost: "The sign-in directory on this machine no longer holds a login." }
3331
3586
  );
3332
3587
  }
3333
- const workdir = fs4.mkdtempSync(path4.join(os3.tmpdir(), `crew-job-${input.job.id}-`));
3588
+ const workdir = fs6.mkdtempSync(path7.join(os3.tmpdir(), `crew-job-${input.job.id}-`));
3334
3589
  try {
3335
3590
  return await runSession2(input, bin, home, workdir);
3336
3591
  } finally {
3337
- fs4.rmSync(workdir, { recursive: true, force: true });
3592
+ fs6.rmSync(workdir, { recursive: true, force: true });
3338
3593
  }
3339
3594
  }
3340
3595
  async function runSession2(input, bin, home, workdir) {
3341
3596
  const args = codexArgs({ ...input, extraServers: input.extraMcpServers, workdir });
3342
3597
  const env = {
3343
- ...process.env,
3598
+ // The CLI's command is a `#!/usr/bin/env node` script — see engines/spawnEnv.ts.
3599
+ ...withNodeOnPath(),
3344
3600
  ...codexLogin.env(home),
3345
3601
  [WORKSPACE_TOKEN_ENV]: input.idToken,
3346
3602
  ...githubSessionEnv(input.githubToken)
@@ -3456,6 +3712,12 @@ async function runSession2(input, bin, home, workdir) {
3456
3712
  const limit4 = !ok2 && windows ? detectCodexLimit(failureText, Date.now(), windows.fallbackMs) : void 0;
3457
3713
  const credentialLost = !ok2 && !limit4 ? detectLoginLost(stderr) : void 0;
3458
3714
  const resultText3 = ok2 ? seen.lastMessage : failedMessage || stderr.trim().split("\n").slice(-3).join("\n") || `Codex exited ${exitCode}`;
3715
+ const engineFault = ok2 ? void 0 : detectEngineFault({
3716
+ exitCode,
3717
+ stderr,
3718
+ events: lines.length,
3719
+ explained: Boolean(limit4 || credentialLost || mcpProblem || failedMessage)
3720
+ });
3459
3721
  return {
3460
3722
  ok: ok2,
3461
3723
  transcript,
@@ -3463,11 +3725,12 @@ async function runSession2(input, bin, home, workdir) {
3463
3725
  resultText: resultText3.slice(0, 2e4),
3464
3726
  ...limit4 ? { limit: limit4 } : {},
3465
3727
  ...credentialLost ? { credentialLost } : {},
3466
- ...mcpFailed.length ? { mcpFailed } : {}
3728
+ ...mcpFailed.length ? { mcpFailed } : {},
3729
+ ...engineFault ? { engineFault } : {}
3467
3730
  };
3468
3731
  }
3469
- async function codexHealthCheck() {
3470
- const bin = codexBinary();
3732
+ async function codexHealthCheck(binOverride) {
3733
+ const bin = binOverride ?? codexBinary();
3471
3734
  return new Promise((resolve) => {
3472
3735
  let settled = false;
3473
3736
  const done = (health) => {
@@ -3476,7 +3739,7 @@ async function codexHealthCheck() {
3476
3739
  resolve({ ...health, binary: bin });
3477
3740
  };
3478
3741
  let stdout = "";
3479
- const child = spawn4(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
3742
+ const child = spawn4(bin, ["--version"], { env: withNodeOnPath(), stdio: ["ignore", "pipe", "ignore"] });
3480
3743
  const timer = setTimeout(() => {
3481
3744
  child.kill("SIGKILL");
3482
3745
  done({ ok: false, detail: `\`${bin} --version\` timed out`, fix: "Check the Codex CLI install." });
@@ -3885,19 +4148,16 @@ function credentialGate(agent, credentials, runnerId, busyCredentialIds, default
3885
4148
  }
3886
4149
 
3887
4150
  // src/jobs/engineHome.ts
3888
- import path5 from "node:path";
3889
- var SEGMENT = /^[A-Za-z0-9_-]+$/;
4151
+ import path8 from "node:path";
3890
4152
  function engineCredentialHome(root, engineId, shipId, credentialId) {
3891
- for (const segment of [engineId, shipId, credentialId]) {
3892
- if (!SEGMENT.test(segment)) throw new Error(`not a valid path segment: ${JSON.stringify(segment)}`);
3893
- }
3894
- return path5.join(root, "engines", engineId, shipId, credentialId);
4153
+ for (const segment of [engineId, shipId, credentialId]) assertPathSegment(segment);
4154
+ return path8.join(root, "engines", engineId, shipId, credentialId);
3895
4155
  }
3896
4156
 
3897
4157
  // src/jobs/engineLogin.ts
3898
4158
  import { spawn as nodeSpawn } from "node:child_process";
3899
- import fs5 from "node:fs";
3900
- import path6 from "node:path";
4159
+ import fs7 from "node:fs";
4160
+ import path9 from "node:path";
3901
4161
  var DEVICE_CODE_TTL_MS = 15 * 60 * 1e3;
3902
4162
  function machineLoginTimeoutMs() {
3903
4163
  const override = Number(process.env.CREW_MACHINE_LOGIN_TIMEOUT_MS);
@@ -3920,39 +4180,39 @@ function credentialsToForget(previouslyHeld, current) {
3920
4180
  return [...previouslyHeld].filter((id) => !live.has(id));
3921
4181
  }
3922
4182
  function orphanHomes(root, shipId, liveIds) {
3923
- const enginesDir = path6.join(root, "engines");
4183
+ const enginesDir = path9.join(root, "engines");
3924
4184
  let engines = [];
3925
4185
  try {
3926
- engines = fs5.readdirSync(enginesDir);
4186
+ engines = fs7.readdirSync(enginesDir);
3927
4187
  } catch {
3928
4188
  return [];
3929
4189
  }
3930
4190
  const out = [];
3931
4191
  for (const engineId of engines) {
3932
- const shipDir = path6.join(enginesDir, engineId, shipId);
4192
+ const shipDir = path9.join(enginesDir, engineId, shipId);
3933
4193
  let creds = [];
3934
4194
  try {
3935
- creds = fs5.readdirSync(shipDir);
4195
+ creds = fs7.readdirSync(shipDir);
3936
4196
  } catch {
3937
4197
  continue;
3938
4198
  }
3939
- for (const credId of creds) if (!liveIds.has(credId)) out.push(path6.join(shipDir, credId));
4199
+ for (const credId of creds) if (!liveIds.has(credId)) out.push(path9.join(shipDir, credId));
3940
4200
  }
3941
4201
  return out;
3942
4202
  }
3943
4203
  async function runMachineLogin(input) {
3944
- const spawn8 = input.spawn ?? nodeSpawn;
4204
+ const spawn9 = input.spawn ?? nodeSpawn;
3945
4205
  if (input.signal?.aborted) return { phase: "failed", error: "The runner was stopping." };
3946
4206
  try {
3947
- fs5.mkdirSync(input.home, { recursive: true, mode: 448 });
4207
+ fs7.mkdirSync(input.home, { recursive: true, mode: 448 });
3948
4208
  } catch (e) {
3949
4209
  return { phase: "failed", error: `Could not create the sign-in directory: ${message2(e)}` };
3950
4210
  }
3951
4211
  return new Promise((resolve) => {
3952
4212
  let child;
3953
4213
  try {
3954
- child = spawn8(input.bin, input.driver.spawnArgs(), {
3955
- env: { ...process.env, ...input.driver.env(input.home) },
4214
+ child = spawn9(input.bin, input.driver.spawnArgs(), {
4215
+ env: { ...withNodeOnPath(), ...input.driver.env(input.home) },
3956
4216
  stdio: ["ignore", "pipe", "pipe"]
3957
4217
  });
3958
4218
  } catch (e) {
@@ -4036,13 +4296,13 @@ function notInstalled(engineId, bin, detail) {
4036
4296
  return `\`${bin}\` is not installed on this machine (${detail}). Install the engine's CLI, or set ${engineBinaryEnvVar(engineId)} to its path.`;
4037
4297
  }
4038
4298
  async function logoutAndRemove(input) {
4039
- const spawn8 = input.spawn ?? nodeSpawn;
4040
- if (fs5.existsSync(input.home)) {
4299
+ const spawn9 = input.spawn ?? nodeSpawn;
4300
+ if (fs7.existsSync(input.home)) {
4041
4301
  await new Promise((resolve) => {
4042
4302
  let child;
4043
4303
  try {
4044
- child = spawn8(input.bin, input.driver.logoutArgs(), {
4045
- env: { ...process.env, ...input.driver.env(input.home) },
4304
+ child = spawn9(input.bin, input.driver.logoutArgs(), {
4305
+ env: { ...withNodeOnPath(), ...input.driver.env(input.home) },
4046
4306
  stdio: "ignore"
4047
4307
  });
4048
4308
  } catch {
@@ -4064,7 +4324,7 @@ async function logoutAndRemove(input) {
4064
4324
  });
4065
4325
  }
4066
4326
  try {
4067
- fs5.rmSync(input.home, { recursive: true, force: true });
4327
+ fs7.rmSync(input.home, { recursive: true, force: true });
4068
4328
  } catch (e) {
4069
4329
  input.log(`Could not remove the sign-in directory ${input.home}: ${message2(e)}`);
4070
4330
  }
@@ -4095,6 +4355,13 @@ async function machineLoginAndReport(input) {
4095
4355
  await say2({ phase: "failed", error: message2(e) });
4096
4356
  return;
4097
4357
  }
4358
+ if (input.ensureEngine) {
4359
+ const ready = await input.ensureEngine(credential.engine);
4360
+ if (!ready.ok) {
4361
+ await say2({ phase: "failed", error: ready.detail });
4362
+ return;
4363
+ }
4364
+ }
4098
4365
  input.log(`Signing this machine in for "${credential.label}" (${credential.engine}) \u2014 a captain asked.`);
4099
4366
  const outcome = await runMachineLogin({
4100
4367
  driver: driver.login,
@@ -4128,7 +4395,7 @@ async function machineLoginAndReport(input) {
4128
4395
  if (outcome.phase === "expired") {
4129
4396
  input.log(`The sign-in for "${credential.label}" expired before it was approved.`);
4130
4397
  try {
4131
- fs5.rmSync(home, { recursive: true, force: true });
4398
+ fs7.rmSync(home, { recursive: true, force: true });
4132
4399
  } catch {
4133
4400
  }
4134
4401
  await say2({ phase: "expired" });
@@ -4136,7 +4403,7 @@ async function machineLoginAndReport(input) {
4136
4403
  }
4137
4404
  input.log(`The sign-in for "${credential.label}" failed: ${outcome.error}`);
4138
4405
  try {
4139
- fs5.rmSync(home, { recursive: true, force: true });
4406
+ fs7.rmSync(home, { recursive: true, force: true });
4140
4407
  } catch {
4141
4408
  }
4142
4409
  await say2({ phase: "failed", error: outcome.error });
@@ -4303,11 +4570,11 @@ function redactTranscript(transcript, knownSecrets, patterns = []) {
4303
4570
  return out;
4304
4571
  }
4305
4572
  async function uploadTranscript(storage, shipId, jobId, redacted) {
4306
- const path13 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
4307
- await uploadBytes(storageRef(storage, path13), new TextEncoder().encode(redacted), {
4573
+ const path17 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
4574
+ await uploadBytes(storageRef(storage, path17), new TextEncoder().encode(redacted), {
4308
4575
  contentType: "application/x-ndjson"
4309
4576
  });
4310
- return path13;
4577
+ return path17;
4311
4578
  }
4312
4579
  function utcDay(millis) {
4313
4580
  return new Date(millis).toISOString().slice(0, 10);
@@ -4665,118 +4932,658 @@ function createProgressWriter(deps) {
4665
4932
  };
4666
4933
  }
4667
4934
 
4668
- // src/service.ts
4669
- import { spawnSync } from "node:child_process";
4670
- import fs6 from "node:fs";
4671
- import os4 from "node:os";
4672
- import path7 from "node:path";
4673
- import { fileURLToPath } from "node:url";
4674
- var SERVICE_LABEL = `com.kilogent.${RUNNER_BIN.replace(/^kilogent-/, "")}`;
4675
- var LINUX_UNIT = `${RUNNER_BIN}.service`;
4676
- var WINDOWS_TASK = RUNNER_BIN.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
4677
- var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
4678
- var LEGACY_LINUX_UNIT = "crew-runner.service";
4679
- var LEGACY_WINDOWS_TASK = "CrewRunner";
4680
- var RETIRED_SERVICES = [
4681
- { label: LEGACY_SERVICE_LABEL, unit: LEGACY_LINUX_UNIT, task: LEGACY_WINDOWS_TASK },
4682
- { label: "com.lumi.runner", unit: "lumi-runner.service", task: "LumiRunner" }
4683
- ];
4684
- var ServiceError = class extends Error {
4685
- };
4686
- function run(command, args) {
4687
- const result = spawnSync(command, args, { encoding: "utf8" });
4688
- return {
4689
- ok: result.status === 0,
4690
- out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
4691
- };
4935
+ // src/engines/install/index.ts
4936
+ import fs10 from "node:fs";
4937
+
4938
+ // src/engines/install/lock.ts
4939
+ import fs9 from "node:fs";
4940
+ import path10 from "node:path";
4941
+
4942
+ // src/engines/install/npm.ts
4943
+ import { spawn as spawn6 } from "node:child_process";
4944
+ import fs8 from "node:fs";
4945
+ var ENGINE_INSTALL_TIMEOUT_MS = 5 * 6e4;
4946
+ var STDERR_KEEP = 600;
4947
+ function engineInstallArgs(pkg, version, prefix) {
4948
+ if (!isExactVersion(version)) throw new Error(`refusing to install a version that is not exact: ${JSON.stringify(version)}`);
4949
+ if (enginePackageFor_reverse(pkg.npmPackage) === null) {
4950
+ throw new Error(`refusing to install a package that is not on the allowlist: ${JSON.stringify(pkg.npmPackage)}`);
4951
+ }
4952
+ return ["install", "--global", "--prefix", prefix, "--no-fund", "--no-audit", `${pkg.npmPackage}@${version}`];
4692
4953
  }
4693
- function cliPath() {
4694
- return fileURLToPath(import.meta.url);
4954
+ function enginePackageFor_reverse(npmPackage) {
4955
+ for (const id of ["codex", "claude"]) {
4956
+ if (enginePackageFor(id)?.npmPackage === npmPackage) return id;
4957
+ }
4958
+ return null;
4695
4959
  }
4696
- function uid() {
4697
- return String(process.getuid?.() ?? 0);
4960
+ function clearEnginePrefix(root, engineId) {
4961
+ fs8.rmSync(enginePrefixDir(root, engineId), { recursive: true, force: true });
4698
4962
  }
4699
- function launchAgentPath() {
4700
- return path7.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
4963
+ async function installEnginePackage(pkg, version, prefix, options = {}) {
4964
+ const args = engineInstallArgs(pkg, version, prefix);
4965
+ return new Promise((resolve) => {
4966
+ let child;
4967
+ try {
4968
+ child = spawn6("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
4969
+ } catch (e) {
4970
+ resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
4971
+ return;
4972
+ }
4973
+ let stderr = "";
4974
+ let settled = false;
4975
+ const finish = (result) => {
4976
+ if (settled) return;
4977
+ settled = true;
4978
+ clearTimeout(deadline);
4979
+ resolve(result);
4980
+ };
4981
+ const deadline = setTimeout(() => {
4982
+ child.kill("SIGTERM");
4983
+ setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
4984
+ finish({ ok: false, detail: `npm install timed out after ${Math.round((options.timeoutMs ?? ENGINE_INSTALL_TIMEOUT_MS) / 6e4)} min` });
4985
+ }, options.timeoutMs ?? ENGINE_INSTALL_TIMEOUT_MS);
4986
+ deadline.unref();
4987
+ child.stderr?.on("data", (chunk) => {
4988
+ stderr = (stderr + chunk.toString("utf8")).slice(-STDERR_KEEP);
4989
+ });
4990
+ child.on("error", (e) => finish({ ok: false, detail: e.message }));
4991
+ child.on("close", (code) => {
4992
+ finish(code === 0 ? { ok: true, detail: "" } : { ok: false, detail: stderr.trim() || `npm exited ${code}` });
4993
+ });
4994
+ });
4701
4995
  }
4702
- function systemdUnitPath() {
4703
- return path7.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
4996
+
4997
+ // src/engines/install/lock.ts
4998
+ var OWNER_FILE = "owner.json";
4999
+ var STALE_AFTER_MS = 2 * ENGINE_INSTALL_TIMEOUT_MS;
5000
+ function lockDir(root, engineId) {
5001
+ return `${enginePrefixDir(root, engineId)}.lock`;
4704
5002
  }
4705
- function serviceEnv() {
4706
- const env = { PATH: process.env.PATH ?? "" };
4707
- if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
4708
- env.LUMI_RUNNER_HOME = configDir();
5003
+ function readOwner(dir) {
5004
+ try {
5005
+ const parsed = JSON.parse(fs9.readFileSync(path10.join(dir, OWNER_FILE), "utf8"));
5006
+ const owner = parsed;
5007
+ if (typeof owner?.pid !== "number" || typeof owner?.at !== "number") return null;
5008
+ return { pid: owner.pid, at: owner.at };
5009
+ } catch {
5010
+ return null;
4709
5011
  }
4710
- return env;
4711
5012
  }
4712
- function serviceEnvDrift(current, installed) {
4713
- if (!installed) return [];
4714
- return Object.keys(current).filter((name) => current[name] !== installed[name]).sort();
5013
+ function alive(pid) {
5014
+ try {
5015
+ process.kill(pid, 0);
5016
+ return true;
5017
+ } catch (e) {
5018
+ return e.code === "EPERM";
5019
+ }
4715
5020
  }
4716
- function removeLegacyService() {
4717
- const removed = [];
4718
- for (const old of RETIRED_SERVICES) {
4719
- if (old.label === SERVICE_LABEL) continue;
4720
- if (process.platform === "darwin") {
4721
- const unitPath = path7.join(os4.homedir(), "Library/LaunchAgents", `${old.label}.plist`);
4722
- if (fs6.existsSync(unitPath)) {
4723
- run("launchctl", ["bootout", `gui/${uid()}/${old.label}`]);
4724
- fs6.rmSync(unitPath, { force: true });
4725
- removed.push(unitPath);
4726
- }
4727
- continue;
4728
- }
4729
- if (process.platform === "linux") {
4730
- const unitPath = path7.join(os4.homedir(), ".config/systemd/user", old.unit);
4731
- if (fs6.existsSync(unitPath)) {
4732
- run("systemctl", ["--user", "disable", "--now", old.unit]);
4733
- fs6.rmSync(unitPath, { force: true });
4734
- run("systemctl", ["--user", "daemon-reload"]);
4735
- removed.push(unitPath);
4736
- }
4737
- continue;
5021
+ function isStale(owner, now) {
5022
+ if (!owner) return true;
5023
+ return !alive(owner.pid) || now - owner.at > STALE_AFTER_MS;
5024
+ }
5025
+ async function withEngineInstallLock(root, engineId, fn, now = Date.now()) {
5026
+ const dir = lockDir(root, engineId);
5027
+ fs9.mkdirSync(path10.dirname(dir), { recursive: true, mode: 448 });
5028
+ let held = false;
5029
+ try {
5030
+ fs9.mkdirSync(dir);
5031
+ held = true;
5032
+ } catch {
5033
+ if (!isStale(readOwner(dir), now)) return { ran: false };
5034
+ try {
5035
+ fs9.rmSync(dir, { recursive: true, force: true });
5036
+ fs9.mkdirSync(dir);
5037
+ held = true;
5038
+ } catch {
5039
+ return { ran: false };
4738
5040
  }
4739
- if (process.platform === "win32") {
4740
- if (run("schtasks", ["/Query", "/TN", old.task]).ok) {
4741
- run("schtasks", ["/Delete", "/TN", old.task, "/F"]);
4742
- removed.push(old.task);
5041
+ }
5042
+ try {
5043
+ fs9.writeFileSync(path10.join(dir, OWNER_FILE), `${JSON.stringify({ pid: process.pid, at: now })}
5044
+ `, { mode: 384 });
5045
+ } catch {
5046
+ }
5047
+ try {
5048
+ return { ran: true, value: await fn() };
5049
+ } finally {
5050
+ if (held) {
5051
+ try {
5052
+ fs9.rmSync(dir, { recursive: true, force: true });
5053
+ } catch {
4743
5054
  }
4744
5055
  }
4745
5056
  }
4746
- return removed;
4747
5057
  }
4748
- var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
4749
- function isTransientBootstrapError(out) {
4750
- const text2 = out.toLowerCase();
4751
- return (
4752
- // The observed one: `Bootstrap failed: 5: Input/output error`.
4753
- text2.includes("input/output error") || // Same cause, different report: the old generation is still registered.
4754
- text2.includes("service already loaded") || text2.includes("service is already loaded") || text2.includes("eexist") || // launchd is mid-teardown and says so.
4755
- text2.includes("operation already in progress") || text2.includes("operation now in progress")
5058
+
5059
+ // src/engines/install/index.ts
5060
+ async function installEngine(input) {
5061
+ const { root, engineId, version, log: log2 } = input;
5062
+ const now = input.now ?? Date.now();
5063
+ const pkg = enginePackageFor(engineId);
5064
+ if (!pkg) return { ok: false, ran: false, detail: `no installer for "${engineId}"` };
5065
+ if (!isExactVersion(version)) return { ok: false, ran: false, detail: `not an exact version: ${version}` };
5066
+ const held = await withEngineInstallLock(
5067
+ root,
5068
+ engineId,
5069
+ async () => {
5070
+ const record = readEngineState(root)[engineId];
5071
+ const spent = record && record.target === version ? record.attempts : 0;
5072
+ recordEngineInstall(root, engineId, {
5073
+ target: version,
5074
+ npmPackage: pkg.npmPackage,
5075
+ attempts: spent + 1,
5076
+ at: now,
5077
+ outcome: "failed",
5078
+ detail: "install started"
5079
+ });
5080
+ clearEngineMarker(root, engineId);
5081
+ clearEnginePrefix(root, engineId);
5082
+ const prefix = enginePrefixDir(root, engineId);
5083
+ fs10.mkdirSync(prefix, { recursive: true, mode: 448 });
5084
+ log2(`Installing ${pkg.npmPackage}@${version} for "${engineId}" (this is a few hundred MB).`);
5085
+ const installed = await installEnginePackage(pkg, version, prefix, { timeoutMs: input.timeoutMs ?? ENGINE_INSTALL_TIMEOUT_MS });
5086
+ if (!installed.ok) {
5087
+ recordEngineInstall(root, engineId, { outcome: "failed", at: Date.now(), detail: installed.detail.slice(0, 400) });
5088
+ return { ok: false, ran: true, detail: installed.detail };
5089
+ }
5090
+ const bin = engineBinPath(root, engineId, pkg.binName);
5091
+ const health = await input.healthCheck(bin);
5092
+ if (!health.ok || health.binary !== bin) {
5093
+ const detail = health.ok ? `npm reported success, but the check ran ${health.binary ?? "something else"} rather than ${bin} \u2014 the install did not land where this runner looks.` : health.detail;
5094
+ recordEngineInstall(root, engineId, { outcome: "failed", at: Date.now(), detail: detail.slice(0, 400) });
5095
+ return { ok: false, ran: true, detail };
5096
+ }
5097
+ writeEngineMarker(root, engineId, { npmPackage: pkg.npmPackage, version, bin, at: Date.now() });
5098
+ recordEngineInstall(root, engineId, { outcome: "installed", at: Date.now(), attempts: 0, detail: void 0 });
5099
+ log2(`"${engineId}" is ready \u2014 ${pkg.npmPackage}@${version}.`);
5100
+ return { ok: true, ran: true, detail: "", bin };
5101
+ },
5102
+ now
4756
5103
  );
5104
+ return held.ran ? held.value : { ok: false, ran: false, detail: "another install of this engine is already running" };
4757
5105
  }
4758
- function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
4759
- let result = attempt();
4760
- for (const delay of delays) {
4761
- if (result.ok || !isTransientBootstrapError(result.out)) return result;
4762
- wait(delay);
4763
- result = attempt();
4764
- }
4765
- return result;
4766
- }
4767
- function sleepSync(ms) {
4768
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
4769
- }
4770
- function plistXml() {
4771
- const env = serviceEnv();
4772
- const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
4773
- <string>${escapeXml(v)}</string>`).join("\n");
4774
- return `<?xml version="1.0" encoding="UTF-8"?>
4775
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4776
- <plist version="1.0">
4777
- <dict>
4778
- <key>Label</key>
4779
- <string>${SERVICE_LABEL}</string>
5106
+
5107
+ // src/update/install.ts
5108
+ import { spawn as spawn7, spawnSync } from "node:child_process";
5109
+ import fs11 from "node:fs";
5110
+ import path12 from "node:path";
5111
+
5112
+ // src/update/plan.ts
5113
+ import path11 from "node:path";
5114
+
5115
+ // src/update/semver.ts
5116
+ var DEV_VERSION = "0.0.0-dev";
5117
+ var NUMERIC = /^(0|[1-9]\d*)$/;
5118
+ function parseVersion(raw) {
5119
+ if (typeof raw !== "string") return null;
5120
+ const trimmed = raw.trim();
5121
+ if (!trimmed) return null;
5122
+ const [withoutBuild] = trimmed.split("+", 1);
5123
+ const dash = withoutBuild.indexOf("-");
5124
+ const core = dash < 0 ? withoutBuild : withoutBuild.slice(0, dash);
5125
+ const pre = dash < 0 ? "" : withoutBuild.slice(dash + 1);
5126
+ const parts = core.split(".");
5127
+ if (parts.length !== 3) return null;
5128
+ if (!parts.every((p) => NUMERIC.test(p))) return null;
5129
+ const [major, minor, patch] = parts.map(Number);
5130
+ if (dash >= 0 && pre === "") return null;
5131
+ const prerelease = pre === "" ? [] : pre.split(".");
5132
+ if (prerelease.some((id) => id === "")) return null;
5133
+ return {
5134
+ major,
5135
+ minor,
5136
+ patch,
5137
+ prerelease: prerelease.map((id) => NUMERIC.test(id) ? Number(id) : id)
5138
+ };
5139
+ }
5140
+ function comparePrerelease(a, b) {
5141
+ if (a.length === 0) return b.length === 0 ? 0 : 1;
5142
+ if (b.length === 0) return -1;
5143
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
5144
+ const x = a[i];
5145
+ const y = b[i];
5146
+ if (x === y) continue;
5147
+ const xNum = typeof x === "number";
5148
+ const yNum = typeof y === "number";
5149
+ if (xNum !== yNum) return xNum ? -1 : 1;
5150
+ if (xNum && yNum) return x < y ? -1 : 1;
5151
+ return x < y ? -1 : 1;
5152
+ }
5153
+ if (a.length === b.length) return 0;
5154
+ return a.length < b.length ? -1 : 1;
5155
+ }
5156
+ function compareVersions(a, b) {
5157
+ for (const key of ["major", "minor", "patch"]) {
5158
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
5159
+ }
5160
+ return comparePrerelease(a.prerelease, b.prerelease);
5161
+ }
5162
+ function isNewer(candidate, current) {
5163
+ if (current === DEV_VERSION) return false;
5164
+ const a = parseVersion(candidate);
5165
+ const b = parseVersion(current);
5166
+ if (!a || !b) return false;
5167
+ return compareVersions(a, b) === 1;
5168
+ }
5169
+
5170
+ // src/update/plan.ts
5171
+ var PACKAGE_NAME = RUNNER_PACKAGE;
5172
+ var UPDATE_DIST_TAG = "latest";
5173
+ var UPDATE_CHECK_MS = 30 * 6e4;
5174
+ var UPDATE_FIRST_CHECK_MS = 2 * 6e4;
5175
+ var UPDATE_JITTER_MS = 10 * 6e4;
5176
+ var CHECK_MINUTES_MIN = 5;
5177
+ var CHECK_MINUTES_MAX = 24 * 60;
5178
+ var UPDATE_DRAIN_MAX_MS = 30 * 6e4;
5179
+ var MAX_UPDATE_ATTEMPTS = 3;
5180
+ var UPDATE_BACKOFF_MS = [15 * 6e4, 2 * 36e5, 12 * 36e5];
5181
+ function checkIntervalMs(config2) {
5182
+ const raw = config2.updateCheckMinutes;
5183
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return UPDATE_CHECK_MS;
5184
+ const clamped2 = Math.min(CHECK_MINUTES_MAX, Math.max(CHECK_MINUTES_MIN, Math.floor(raw)));
5185
+ return clamped2 * 6e4;
5186
+ }
5187
+ function nextAttemptAllowed(state, target, now) {
5188
+ if (!state || typeof state !== "object") return { allowed: true };
5189
+ if (state.target !== target) return { allowed: true };
5190
+ const attempts = typeof state.attempts === "number" && state.attempts > 0 ? state.attempts : 0;
5191
+ if (attempts === 0) return { allowed: true };
5192
+ if (attempts >= MAX_UPDATE_ATTEMPTS) return { allowed: false, reason: "exhausted" };
5193
+ if (typeof state.at !== "number" || !Number.isFinite(state.at)) return { allowed: true };
5194
+ const wait = UPDATE_BACKOFF_MS[Math.min(attempts - 1, UPDATE_BACKOFF_MS.length - 1)];
5195
+ return now - state.at >= wait ? { allowed: true } : { allowed: false, reason: "backoff" };
5196
+ }
5197
+ function reconcile(state, current) {
5198
+ if (!state || typeof state.target !== "string") return { action: "none" };
5199
+ if (state.target === current) return { action: "clear", target: state.target };
5200
+ return { action: "keep", state };
5201
+ }
5202
+ function packageRootFrom(cliPath2) {
5203
+ return path11.dirname(path11.dirname(cliPath2));
5204
+ }
5205
+ function globalPrefixFrom(cliPath2) {
5206
+ const root = packageRootFrom(cliPath2);
5207
+ const parts = root.split(path11.sep);
5208
+ const i = parts.lastIndexOf("node_modules");
5209
+ if (i < 1) return null;
5210
+ const before = parts.slice(0, i);
5211
+ if (before[before.length - 1] === "lib") before.pop();
5212
+ const prefix = before.join(path11.sep);
5213
+ return prefix || null;
5214
+ }
5215
+ function decideUpdate(f) {
5216
+ if (f.platform === "win32") return { update: false, reason: "windows" };
5217
+ if (f.current === DEV_VERSION) return { update: false, reason: "dev-build" };
5218
+ if (!f.autoUpdate) return { update: false, reason: "disabled" };
5219
+ const service2 = f.service();
5220
+ if (service2.state !== "running") return { update: false, reason: "service-not-running" };
5221
+ if (!service2.execPath || service2.execPath !== f.cliPath) {
5222
+ return { update: false, reason: "foreign-unit" };
5223
+ }
5224
+ if (!f.npmPresent()) return { update: false, reason: "no-npm" };
5225
+ if (f.distTags === null) return { update: false, reason: "registry-unreachable" };
5226
+ const target = f.distTags[UPDATE_DIST_TAG];
5227
+ if (!target) return { update: false, reason: "no-such-tag" };
5228
+ if (!isNewer(target, f.current)) return { update: false, reason: "up-to-date" };
5229
+ const attempt = nextAttemptAllowed(f.state, target, f.now);
5230
+ if (!attempt.allowed) return { update: false, reason: attempt.reason };
5231
+ return { update: true, target };
5232
+ }
5233
+ function describeRefusal(reason, f) {
5234
+ switch (reason) {
5235
+ case "windows":
5236
+ return `Auto-update is not available on Windows \u2014 run \`${RUNNER_BIN} update\` to upgrade.`;
5237
+ case "dev-build":
5238
+ return "Development build \u2014 auto-update is off.";
5239
+ case "disabled":
5240
+ return `Auto-update is off (\`${RUNNER_BIN} config set autoUpdate on\` to enable).`;
5241
+ case "service-not-running":
5242
+ return `Auto-update needs the background service (this daemon would have nothing to restart it). Run \`${RUNNER_BIN} service install\`, or \`${RUNNER_BIN} update\` by hand.`;
5243
+ case "foreign-unit":
5244
+ return `This process is not the one the installed service runs, so auto-update is off for it. Run \`${RUNNER_BIN} update\` if you meant to upgrade the installed copy.`;
5245
+ case "no-npm":
5246
+ return "Auto-update needs `npm` on the service PATH and could not find it.";
5247
+ case "registry-unreachable":
5248
+ return "Could not reach the npm registry to check for updates \u2014 will retry.";
5249
+ case "no-such-tag":
5250
+ return `The npm registry has no release of ${PACKAGE_NAME} yet.`;
5251
+ case "up-to-date":
5252
+ return `Up to date (${f.current}).`;
5253
+ case "backoff":
5254
+ return "A recent update attempt did not take \u2014 waiting before trying again.";
5255
+ case "exhausted":
5256
+ return `Gave up updating after ${MAX_UPDATE_ATTEMPTS} attempts. Still on ${f.current} \u2014 run \`${RUNNER_BIN} update\` to see why, or wait for the next release.`;
5257
+ }
5258
+ }
5259
+
5260
+ // src/update/install.ts
5261
+ var INSTALL_TIMEOUT_MS = 5 * 6e4;
5262
+ var STDERR_KEEP2 = 400;
5263
+ function npmPresent() {
5264
+ const probe2 = process.platform === "win32" ? "where" : "which";
5265
+ try {
5266
+ return spawnSync(probe2, ["npm"], { encoding: "utf8" }).status === 0;
5267
+ } catch {
5268
+ return false;
5269
+ }
5270
+ }
5271
+ function installArgs(target, cliPath2) {
5272
+ const args = ["install", "--global", "--no-fund", "--no-audit", `${PACKAGE_NAME}@${target}`];
5273
+ const prefix = globalPrefixFrom(cliPath2);
5274
+ if (prefix) args.push("--prefix", prefix);
5275
+ return args;
5276
+ }
5277
+ async function installGlobal(target, cliPath2, options = {}) {
5278
+ const args = installArgs(target, cliPath2);
5279
+ return new Promise((resolve) => {
5280
+ let child;
5281
+ try {
5282
+ child = spawn7("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
5283
+ } catch (e) {
5284
+ resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
5285
+ return;
5286
+ }
5287
+ let stderr = "";
5288
+ child.stderr?.on("data", (chunk) => {
5289
+ stderr = `${stderr}${chunk.toString()}`.slice(-STDERR_KEEP2);
5290
+ });
5291
+ let settled = false;
5292
+ const finish = (result) => {
5293
+ if (settled) return;
5294
+ settled = true;
5295
+ clearTimeout(timer);
5296
+ resolve(result);
5297
+ };
5298
+ const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
5299
+ const timer = setTimeout(() => {
5300
+ child.kill("SIGTERM");
5301
+ setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
5302
+ finish({ ok: false, detail: `npm install timed out after ${timeoutMs / 6e4} minutes.` });
5303
+ }, timeoutMs);
5304
+ timer.unref();
5305
+ child.on("error", (e) => finish({ ok: false, detail: e.message }));
5306
+ child.on(
5307
+ "close",
5308
+ (code) => finish({ ok: code === 0, detail: stderr.trim() || `npm exited ${code}` })
5309
+ );
5310
+ });
5311
+ }
5312
+ function installedVersionAt(cliPath2) {
5313
+ try {
5314
+ const pkg = JSON.parse(
5315
+ fs11.readFileSync(path12.join(packageRootFrom(cliPath2), "package.json"), "utf8")
5316
+ );
5317
+ const version = pkg?.version;
5318
+ return typeof version === "string" ? version : null;
5319
+ } catch {
5320
+ return null;
5321
+ }
5322
+ }
5323
+
5324
+ // src/update/registry.ts
5325
+ var DEFAULT_REGISTRY = "https://registry.npmjs.org";
5326
+ var REGISTRY_TIMEOUT_MS = 1e4;
5327
+ function registryBase(env = process.env) {
5328
+ return (env.LUMI_RUNNER_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, "");
5329
+ }
5330
+ async function fetchDistTags(options = {}) {
5331
+ const base = options.registry ?? registryBase();
5332
+ const doFetch = options.fetchImpl ?? fetch;
5333
+ const url = `${base}/${encodeURIComponent(options.packageName ?? PACKAGE_NAME)}`;
5334
+ const controller = new AbortController();
5335
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? REGISTRY_TIMEOUT_MS);
5336
+ try {
5337
+ const response = await doFetch(url, {
5338
+ headers: { accept: "application/vnd.npm.install-v1+json" },
5339
+ signal: controller.signal
5340
+ });
5341
+ if (!response.ok) return null;
5342
+ const body = await response.json();
5343
+ const tags = body?.["dist-tags"];
5344
+ if (!tags || typeof tags !== "object" || Array.isArray(tags)) return null;
5345
+ const out = {};
5346
+ for (const [tag, version] of Object.entries(tags)) {
5347
+ if (typeof version === "string") out[tag] = version;
5348
+ }
5349
+ return out;
5350
+ } catch {
5351
+ return null;
5352
+ } finally {
5353
+ clearTimeout(timer);
5354
+ }
5355
+ }
5356
+
5357
+ // src/jobs/engineTools.ts
5358
+ async function refreshEngineTools(input) {
5359
+ const now = input.now ?? Date.now();
5360
+ const out = [];
5361
+ const seen = /* @__PURE__ */ new Set();
5362
+ for (const engineId of input.engineIds) {
5363
+ if (seen.has(engineId)) continue;
5364
+ seen.add(engineId);
5365
+ out.push(await refreshOne(engineId, input, now));
5366
+ }
5367
+ return out;
5368
+ }
5369
+ async function refreshOne(engineId, input, now) {
5370
+ const { root, log: log2 } = input;
5371
+ const pkg = enginePackageFor(engineId);
5372
+ if (!pkg) {
5373
+ return { engineId, state: "missing", detail: describeEngineInstallRefusal("unknown-engine", engineId, input.runnerBin) };
5374
+ }
5375
+ const policy = resolveEnginePolicy(engineId, input.policyDoc);
5376
+ const marker = readEngineMarker(root, engineId);
5377
+ const installed = managedEngineBin(engineId, { root }) ? marker?.version ?? null : null;
5378
+ let latest = null;
5379
+ if (installed) {
5380
+ const ask = input.fetchLatest ?? (async (name) => (await fetchDistTags({ packageName: name }))?.latest ?? null);
5381
+ latest = await ask(pkg.npmPackage).catch(() => null);
5382
+ if (latest) rememberFirstSeen(root, engineId, latest, now);
5383
+ }
5384
+ const record = readEngineState(root)[engineId];
5385
+ const choice = chooseEngineVersion({ policy, latest, installed, firstSeen: record?.firstSeen, now });
5386
+ if (!choice.version) return describeCurrent(engineId, input, marker, record, choice.reason);
5387
+ const decision = decideEngineInstall({
5388
+ engineId,
5389
+ target: choice.version,
5390
+ platform: process.platform,
5391
+ autoInstall: input.autoInstall,
5392
+ npmPresent: npmPresentOnce(),
5393
+ marker,
5394
+ locked: false,
5395
+ record: record ?? null,
5396
+ now
5397
+ });
5398
+ if (!decision.install) {
5399
+ if (decision.reason === "already-installed") return { engineId, state: "ready", version: marker?.version };
5400
+ return describeRefused(engineId, input, marker, decision.reason);
5401
+ }
5402
+ input.onState?.({ engineId, state: "installing", version: choice.version });
5403
+ const outcome = await installEngine({
5404
+ root,
5405
+ engineId,
5406
+ version: choice.version,
5407
+ healthCheck: (bin) => getDriver(engineId).healthCheck(bin),
5408
+ log: log2,
5409
+ now
5410
+ });
5411
+ if (outcome.ok) return { engineId, state: "ready", version: choice.version };
5412
+ log2(`Could not install "${engineId}": ${outcome.detail.slice(0, 200)}`);
5413
+ const fallback = await unmanagedVersion(engineId);
5414
+ if (fallback && !policy.blocked.includes(fallback)) {
5415
+ return { engineId, state: "unmanaged", version: fallback, detail: "Using a copy already on this machine \u2014 this runner cannot update or roll it back." };
5416
+ }
5417
+ return { engineId, state: "failed", detail: outcome.detail.slice(0, 300) };
5418
+ }
5419
+ async function ensureEngineReady(engineId, input) {
5420
+ const [state] = await refreshEngineTools({ ...input, engineIds: [engineId] });
5421
+ if (!state) return { ok: false, detail: `This runner does not know the engine "${engineId}".` };
5422
+ if (state.state === "ready" || state.state === "unmanaged") return { ok: true, detail: state.detail ?? "" };
5423
+ return { ok: false, detail: state.detail ?? `The "${engineId}" CLI is not installed on this machine.` };
5424
+ }
5425
+ function describeCurrent(engineId, input, marker, record, reason) {
5426
+ if (marker && managedEngineBin(engineId, { root: input.root })) {
5427
+ return { engineId, state: "ready", version: marker.version };
5428
+ }
5429
+ if (reason === "no-policy") {
5430
+ return { engineId, state: "missing", detail: `This runner has no version recorded for "${engineId}".` };
5431
+ }
5432
+ if (record?.outcome === "failed") return { engineId, state: "failed", detail: record.detail };
5433
+ return { engineId, state: "missing" };
5434
+ }
5435
+ function describeRefused(engineId, input, marker, reason) {
5436
+ const detail = describeEngineInstallRefusal(reason, engineId, input.runnerBin);
5437
+ if (marker && managedEngineBin(engineId, { root: input.root })) {
5438
+ return { engineId, state: "ready", version: marker.version, detail };
5439
+ }
5440
+ return { engineId, state: "missing", detail };
5441
+ }
5442
+ function rememberFirstSeen(root, engineId, version, now) {
5443
+ const record = readEngineState(root)[engineId];
5444
+ if (record?.firstSeen?.version === version) return;
5445
+ recordEngineInstall(root, engineId, { firstSeen: { version, at: now } });
5446
+ }
5447
+ function npmPresentOnce() {
5448
+ let answer = null;
5449
+ return () => {
5450
+ if (answer === null) answer = npmPresent();
5451
+ return answer;
5452
+ };
5453
+ }
5454
+ async function unmanagedVersion(engineId) {
5455
+ try {
5456
+ const health = await getDriver(engineId).healthCheck();
5457
+ if (!health.ok) return null;
5458
+ const match = /(\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)/.exec(health.detail ?? "");
5459
+ return match?.[1] ?? null;
5460
+ } catch {
5461
+ return null;
5462
+ }
5463
+ }
5464
+
5465
+ // src/jobs/shipEngines.ts
5466
+ function enginesForAgents(agents) {
5467
+ return engineSet(agents.map((agent) => agentEngine(agent)));
5468
+ }
5469
+ function engineSet(engineIds) {
5470
+ const engines = new Set(engineIds);
5471
+ if (engines.size === 0) engines.add(DEFAULT_ENGINE_ID);
5472
+ return engines;
5473
+ }
5474
+
5475
+ // src/service.ts
5476
+ import { spawnSync as spawnSync2 } from "node:child_process";
5477
+ import fs12 from "node:fs";
5478
+ import os4 from "node:os";
5479
+ import path13 from "node:path";
5480
+ import { fileURLToPath } from "node:url";
5481
+ var SERVICE_LABEL = `com.kilogent.${RUNNER_BIN.replace(/^kilogent-/, "")}`;
5482
+ var LINUX_UNIT = `${RUNNER_BIN}.service`;
5483
+ var WINDOWS_TASK = RUNNER_BIN.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
5484
+ var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
5485
+ var LEGACY_LINUX_UNIT = "crew-runner.service";
5486
+ var LEGACY_WINDOWS_TASK = "CrewRunner";
5487
+ var RETIRED_SERVICES = [
5488
+ { label: LEGACY_SERVICE_LABEL, unit: LEGACY_LINUX_UNIT, task: LEGACY_WINDOWS_TASK },
5489
+ { label: "com.lumi.runner", unit: "lumi-runner.service", task: "LumiRunner" }
5490
+ ];
5491
+ var ServiceError = class extends Error {
5492
+ };
5493
+ function run(command, args) {
5494
+ const result = spawnSync2(command, args, { encoding: "utf8" });
5495
+ return {
5496
+ ok: result.status === 0,
5497
+ out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
5498
+ };
5499
+ }
5500
+ function cliPath() {
5501
+ return fileURLToPath(import.meta.url);
5502
+ }
5503
+ function uid() {
5504
+ return String(process.getuid?.() ?? 0);
5505
+ }
5506
+ function launchAgentPath() {
5507
+ return path13.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
5508
+ }
5509
+ function systemdUnitPath() {
5510
+ return path13.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
5511
+ }
5512
+ function serviceEnv() {
5513
+ const env = { PATH: process.env.PATH ?? "" };
5514
+ if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
5515
+ env.LUMI_RUNNER_HOME = configDir();
5516
+ }
5517
+ return env;
5518
+ }
5519
+ function serviceEnvDrift(current, installed) {
5520
+ if (!installed) return [];
5521
+ return Object.keys(current).filter((name) => current[name] !== installed[name]).sort();
5522
+ }
5523
+ function removeLegacyService() {
5524
+ const removed = [];
5525
+ for (const old of RETIRED_SERVICES) {
5526
+ if (old.label === SERVICE_LABEL) continue;
5527
+ if (process.platform === "darwin") {
5528
+ const unitPath = path13.join(os4.homedir(), "Library/LaunchAgents", `${old.label}.plist`);
5529
+ if (fs12.existsSync(unitPath)) {
5530
+ run("launchctl", ["bootout", `gui/${uid()}/${old.label}`]);
5531
+ fs12.rmSync(unitPath, { force: true });
5532
+ removed.push(unitPath);
5533
+ }
5534
+ continue;
5535
+ }
5536
+ if (process.platform === "linux") {
5537
+ const unitPath = path13.join(os4.homedir(), ".config/systemd/user", old.unit);
5538
+ if (fs12.existsSync(unitPath)) {
5539
+ run("systemctl", ["--user", "disable", "--now", old.unit]);
5540
+ fs12.rmSync(unitPath, { force: true });
5541
+ run("systemctl", ["--user", "daemon-reload"]);
5542
+ removed.push(unitPath);
5543
+ }
5544
+ continue;
5545
+ }
5546
+ if (process.platform === "win32") {
5547
+ if (run("schtasks", ["/Query", "/TN", old.task]).ok) {
5548
+ run("schtasks", ["/Delete", "/TN", old.task, "/F"]);
5549
+ removed.push(old.task);
5550
+ }
5551
+ }
5552
+ }
5553
+ return removed;
5554
+ }
5555
+ var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
5556
+ function isTransientBootstrapError(out) {
5557
+ const text2 = out.toLowerCase();
5558
+ return (
5559
+ // The observed one: `Bootstrap failed: 5: Input/output error`.
5560
+ text2.includes("input/output error") || // Same cause, different report: the old generation is still registered.
5561
+ text2.includes("service already loaded") || text2.includes("service is already loaded") || text2.includes("eexist") || // launchd is mid-teardown and says so.
5562
+ text2.includes("operation already in progress") || text2.includes("operation now in progress")
5563
+ );
5564
+ }
5565
+ function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
5566
+ let result = attempt();
5567
+ for (const delay of delays) {
5568
+ if (result.ok || !isTransientBootstrapError(result.out)) return result;
5569
+ wait(delay);
5570
+ result = attempt();
5571
+ }
5572
+ return result;
5573
+ }
5574
+ function sleepSync(ms) {
5575
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
5576
+ }
5577
+ function plistXml() {
5578
+ const env = serviceEnv();
5579
+ const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
5580
+ <string>${escapeXml(v)}</string>`).join("\n");
5581
+ return `<?xml version="1.0" encoding="UTF-8"?>
5582
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5583
+ <plist version="1.0">
5584
+ <dict>
5585
+ <key>Label</key>
5586
+ <string>${SERVICE_LABEL}</string>
4780
5587
  <key>ProgramArguments</key>
4781
5588
  <array>
4782
5589
  <string>${escapeXml(process.execPath)}</string>
@@ -4794,9 +5601,9 @@ function plistXml() {
4794
5601
  ${envEntries}
4795
5602
  </dict>
4796
5603
  <key>StandardOutPath</key>
4797
- <string>${escapeXml(path7.join(logDir(), "service.out.log"))}</string>
5604
+ <string>${escapeXml(path13.join(logDir(), "service.out.log"))}</string>
4798
5605
  <key>StandardErrorPath</key>
4799
- <string>${escapeXml(path7.join(logDir(), "service.err.log"))}</string>
5606
+ <string>${escapeXml(path13.join(logDir(), "service.err.log"))}</string>
4800
5607
  </dict>
4801
5608
  </plist>
4802
5609
  `;
@@ -4842,14 +5649,14 @@ function parseSystemdCliPath(unit) {
4842
5649
  function execFacts(unitPath, parse, parseEnv) {
4843
5650
  let text2;
4844
5651
  try {
4845
- text2 = fs6.readFileSync(unitPath, "utf8");
5652
+ text2 = fs12.readFileSync(unitPath, "utf8");
4846
5653
  } catch {
4847
5654
  return {};
4848
5655
  }
4849
5656
  const unitEnv = parseEnv(text2);
4850
5657
  const execPath = parse(text2);
4851
- if (!execPath || !path7.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
4852
- return { execPath, execMissing: !fs6.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
5658
+ if (!execPath || !path13.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
5659
+ return { execPath, execMissing: !fs12.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
4853
5660
  }
4854
5661
  function systemdUnit() {
4855
5662
  const env = serviceEnv();
@@ -4875,7 +5682,7 @@ WantedBy=default.target
4875
5682
  function serviceStatus() {
4876
5683
  if (process.platform === "darwin") {
4877
5684
  const unitPath = launchAgentPath();
4878
- if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
5685
+ if (!fs12.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
4879
5686
  const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
4880
5687
  const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
4881
5688
  if (!printed.ok) {
@@ -4891,7 +5698,7 @@ function serviceStatus() {
4891
5698
  }
4892
5699
  if (process.platform === "linux") {
4893
5700
  const unitPath = systemdUnitPath();
4894
- if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
5701
+ if (!fs12.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
4895
5702
  const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
4896
5703
  return {
4897
5704
  state: active.out === "active" ? "running" : "installed",
@@ -4913,14 +5720,14 @@ function serviceStatus() {
4913
5720
  }
4914
5721
  function installService() {
4915
5722
  const notes = [];
4916
- fs6.mkdirSync(logDir(), { recursive: true, mode: 448 });
5723
+ fs12.mkdirSync(logDir(), { recursive: true, mode: 448 });
4917
5724
  for (const unit of removeLegacyService()) {
4918
5725
  notes.push(`Removed the previous crew-runner service (${unit}).`);
4919
5726
  }
4920
5727
  if (process.platform === "darwin") {
4921
5728
  const unitPath = launchAgentPath();
4922
- fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
4923
- fs6.writeFileSync(unitPath, plistXml());
5729
+ fs12.mkdirSync(path13.dirname(unitPath), { recursive: true });
5730
+ fs12.writeFileSync(unitPath, plistXml());
4924
5731
  run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
4925
5732
  const boot = retryWhileTransient(
4926
5733
  () => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
@@ -4931,272 +5738,91 @@ function installService() {
4931
5738
  }
4932
5739
  if (process.platform === "linux") {
4933
5740
  const unitPath = systemdUnitPath();
4934
- fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
4935
- fs6.writeFileSync(unitPath, systemdUnit());
5741
+ fs12.mkdirSync(path13.dirname(unitPath), { recursive: true });
5742
+ fs12.writeFileSync(unitPath, systemdUnit());
4936
5743
  const reload = run("systemctl", ["--user", "daemon-reload"]);
4937
5744
  if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
4938
5745
  const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
4939
5746
  if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
4940
5747
  const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
4941
5748
  if (!linger.out.includes("Linger=yes")) {
4942
- notes.push(
4943
- `Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
4944
- );
4945
- }
4946
- return { unitPath, notes };
4947
- }
4948
- if (process.platform === "win32") {
4949
- const command = `"${process.execPath}" "${cliPath()}" start`;
4950
- const create = run("schtasks", [
4951
- "/Create",
4952
- "/TN",
4953
- WINDOWS_TASK,
4954
- "/TR",
4955
- command,
4956
- "/SC",
4957
- "ONLOGON",
4958
- "/RL",
4959
- "LIMITED",
4960
- "/F"
4961
- ]);
4962
- if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
4963
- notes.push(
4964
- "Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
4965
- );
4966
- return { unitPath: WINDOWS_TASK, notes };
4967
- }
4968
- throw new ServiceError(`Service install is not supported on ${process.platform}.`);
4969
- }
4970
- function uninstallService() {
4971
- removeLegacyService();
4972
- if (process.platform === "darwin") {
4973
- run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
4974
- fs6.rmSync(launchAgentPath(), { force: true });
4975
- return;
4976
- }
4977
- if (process.platform === "linux") {
4978
- run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
4979
- fs6.rmSync(systemdUnitPath(), { force: true });
4980
- run("systemctl", ["--user", "daemon-reload"]);
4981
- return;
4982
- }
4983
- if (process.platform === "win32") {
4984
- run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
4985
- return;
4986
- }
4987
- throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
4988
- }
4989
- function restartService() {
4990
- if (process.platform === "darwin") {
4991
- const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
4992
- if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
4993
- return;
4994
- }
4995
- if (process.platform === "linux") {
4996
- const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
4997
- if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
4998
- return;
4999
- }
5000
- if (process.platform === "win32") {
5001
- run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
5002
- const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
5003
- if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
5004
- return;
5005
- }
5006
- throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
5007
- }
5008
-
5009
- // src/update/plan.ts
5010
- import path8 from "node:path";
5011
-
5012
- // src/update/semver.ts
5013
- var DEV_VERSION = "0.0.0-dev";
5014
- var NUMERIC = /^(0|[1-9]\d*)$/;
5015
- function parseVersion(raw) {
5016
- if (typeof raw !== "string") return null;
5017
- const trimmed = raw.trim();
5018
- if (!trimmed) return null;
5019
- const [withoutBuild] = trimmed.split("+", 1);
5020
- const dash = withoutBuild.indexOf("-");
5021
- const core = dash < 0 ? withoutBuild : withoutBuild.slice(0, dash);
5022
- const pre = dash < 0 ? "" : withoutBuild.slice(dash + 1);
5023
- const parts = core.split(".");
5024
- if (parts.length !== 3) return null;
5025
- if (!parts.every((p) => NUMERIC.test(p))) return null;
5026
- const [major, minor, patch] = parts.map(Number);
5027
- if (dash >= 0 && pre === "") return null;
5028
- const prerelease = pre === "" ? [] : pre.split(".");
5029
- if (prerelease.some((id) => id === "")) return null;
5030
- return {
5031
- major,
5032
- minor,
5033
- patch,
5034
- prerelease: prerelease.map((id) => NUMERIC.test(id) ? Number(id) : id)
5035
- };
5036
- }
5037
- function comparePrerelease(a, b) {
5038
- if (a.length === 0) return b.length === 0 ? 0 : 1;
5039
- if (b.length === 0) return -1;
5040
- for (let i = 0; i < Math.min(a.length, b.length); i++) {
5041
- const x = a[i];
5042
- const y = b[i];
5043
- if (x === y) continue;
5044
- const xNum = typeof x === "number";
5045
- const yNum = typeof y === "number";
5046
- if (xNum !== yNum) return xNum ? -1 : 1;
5047
- if (xNum && yNum) return x < y ? -1 : 1;
5048
- return x < y ? -1 : 1;
5049
- }
5050
- if (a.length === b.length) return 0;
5051
- return a.length < b.length ? -1 : 1;
5052
- }
5053
- function compareVersions(a, b) {
5054
- for (const key of ["major", "minor", "patch"]) {
5055
- if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
5056
- }
5057
- return comparePrerelease(a.prerelease, b.prerelease);
5058
- }
5059
- function isNewer(candidate, current) {
5060
- if (current === DEV_VERSION) return false;
5061
- const a = parseVersion(candidate);
5062
- const b = parseVersion(current);
5063
- if (!a || !b) return false;
5064
- return compareVersions(a, b) === 1;
5065
- }
5066
-
5067
- // src/update/plan.ts
5068
- var PACKAGE_NAME = RUNNER_PACKAGE;
5069
- var UPDATE_DIST_TAG = "latest";
5070
- var UPDATE_CHECK_MS = 30 * 6e4;
5071
- var UPDATE_FIRST_CHECK_MS = 2 * 6e4;
5072
- var UPDATE_JITTER_MS = 10 * 6e4;
5073
- var CHECK_MINUTES_MIN = 5;
5074
- var CHECK_MINUTES_MAX = 24 * 60;
5075
- var UPDATE_DRAIN_MAX_MS = 30 * 6e4;
5076
- var MAX_UPDATE_ATTEMPTS = 3;
5077
- var UPDATE_BACKOFF_MS = [15 * 6e4, 2 * 36e5, 12 * 36e5];
5078
- function checkIntervalMs(config2) {
5079
- const raw = config2.updateCheckMinutes;
5080
- if (typeof raw !== "number" || !Number.isFinite(raw)) return UPDATE_CHECK_MS;
5081
- const clamped2 = Math.min(CHECK_MINUTES_MAX, Math.max(CHECK_MINUTES_MIN, Math.floor(raw)));
5082
- return clamped2 * 6e4;
5083
- }
5084
- function nextAttemptAllowed(state, target, now) {
5085
- if (!state || typeof state !== "object") return { allowed: true };
5086
- if (state.target !== target) return { allowed: true };
5087
- const attempts = typeof state.attempts === "number" && state.attempts > 0 ? state.attempts : 0;
5088
- if (attempts === 0) return { allowed: true };
5089
- if (attempts >= MAX_UPDATE_ATTEMPTS) return { allowed: false, reason: "exhausted" };
5090
- if (typeof state.at !== "number" || !Number.isFinite(state.at)) return { allowed: true };
5091
- const wait = UPDATE_BACKOFF_MS[Math.min(attempts - 1, UPDATE_BACKOFF_MS.length - 1)];
5092
- return now - state.at >= wait ? { allowed: true } : { allowed: false, reason: "backoff" };
5093
- }
5094
- function reconcile(state, current) {
5095
- if (!state || typeof state.target !== "string") return { action: "none" };
5096
- if (state.target === current) return { action: "clear", target: state.target };
5097
- return { action: "keep", state };
5098
- }
5099
- function packageRootFrom(cliPath2) {
5100
- return path8.dirname(path8.dirname(cliPath2));
5101
- }
5102
- function globalPrefixFrom(cliPath2) {
5103
- const root = packageRootFrom(cliPath2);
5104
- const parts = root.split(path8.sep);
5105
- const i = parts.lastIndexOf("node_modules");
5106
- if (i < 1) return null;
5107
- const before = parts.slice(0, i);
5108
- if (before[before.length - 1] === "lib") before.pop();
5109
- const prefix = before.join(path8.sep);
5110
- return prefix || null;
5111
- }
5112
- function decideUpdate(f) {
5113
- if (f.platform === "win32") return { update: false, reason: "windows" };
5114
- if (f.current === DEV_VERSION) return { update: false, reason: "dev-build" };
5115
- if (!f.autoUpdate) return { update: false, reason: "disabled" };
5116
- const service2 = f.service();
5117
- if (service2.state !== "running") return { update: false, reason: "service-not-running" };
5118
- if (!service2.execPath || service2.execPath !== f.cliPath) {
5119
- return { update: false, reason: "foreign-unit" };
5749
+ notes.push(
5750
+ `Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
5751
+ );
5752
+ }
5753
+ return { unitPath, notes };
5120
5754
  }
5121
- if (!f.npmPresent()) return { update: false, reason: "no-npm" };
5122
- if (f.distTags === null) return { update: false, reason: "registry-unreachable" };
5123
- const target = f.distTags[UPDATE_DIST_TAG];
5124
- if (!target) return { update: false, reason: "no-such-tag" };
5125
- if (!isNewer(target, f.current)) return { update: false, reason: "up-to-date" };
5126
- const attempt = nextAttemptAllowed(f.state, target, f.now);
5127
- if (!attempt.allowed) return { update: false, reason: attempt.reason };
5128
- return { update: true, target };
5129
- }
5130
- function describeRefusal(reason, f) {
5131
- switch (reason) {
5132
- case "windows":
5133
- return `Auto-update is not available on Windows \u2014 run \`${RUNNER_BIN} update\` to upgrade.`;
5134
- case "dev-build":
5135
- return "Development build \u2014 auto-update is off.";
5136
- case "disabled":
5137
- return `Auto-update is off (\`${RUNNER_BIN} config set autoUpdate on\` to enable).`;
5138
- case "service-not-running":
5139
- return `Auto-update needs the background service (this daemon would have nothing to restart it). Run \`${RUNNER_BIN} service install\`, or \`${RUNNER_BIN} update\` by hand.`;
5140
- case "foreign-unit":
5141
- return `This process is not the one the installed service runs, so auto-update is off for it. Run \`${RUNNER_BIN} update\` if you meant to upgrade the installed copy.`;
5142
- case "no-npm":
5143
- return "Auto-update needs `npm` on the service PATH and could not find it.";
5144
- case "registry-unreachable":
5145
- return "Could not reach the npm registry to check for updates \u2014 will retry.";
5146
- case "no-such-tag":
5147
- return `The npm registry has no release of ${PACKAGE_NAME} yet.`;
5148
- case "up-to-date":
5149
- return `Up to date (${f.current}).`;
5150
- case "backoff":
5151
- return "A recent update attempt did not take \u2014 waiting before trying again.";
5152
- case "exhausted":
5153
- return `Gave up updating after ${MAX_UPDATE_ATTEMPTS} attempts. Still on ${f.current} \u2014 run \`${RUNNER_BIN} update\` to see why, or wait for the next release.`;
5755
+ if (process.platform === "win32") {
5756
+ const command = `"${process.execPath}" "${cliPath()}" start`;
5757
+ const create = run("schtasks", [
5758
+ "/Create",
5759
+ "/TN",
5760
+ WINDOWS_TASK,
5761
+ "/TR",
5762
+ command,
5763
+ "/SC",
5764
+ "ONLOGON",
5765
+ "/RL",
5766
+ "LIMITED",
5767
+ "/F"
5768
+ ]);
5769
+ if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
5770
+ notes.push(
5771
+ "Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
5772
+ );
5773
+ return { unitPath: WINDOWS_TASK, notes };
5154
5774
  }
5775
+ throw new ServiceError(`Service install is not supported on ${process.platform}.`);
5155
5776
  }
5156
-
5157
- // src/update/registry.ts
5158
- var DEFAULT_REGISTRY = "https://registry.npmjs.org";
5159
- var REGISTRY_TIMEOUT_MS = 1e4;
5160
- function registryBase(env = process.env) {
5161
- return (env.LUMI_RUNNER_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, "");
5777
+ function uninstallService() {
5778
+ removeLegacyService();
5779
+ if (process.platform === "darwin") {
5780
+ run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
5781
+ fs12.rmSync(launchAgentPath(), { force: true });
5782
+ return;
5783
+ }
5784
+ if (process.platform === "linux") {
5785
+ run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
5786
+ fs12.rmSync(systemdUnitPath(), { force: true });
5787
+ run("systemctl", ["--user", "daemon-reload"]);
5788
+ return;
5789
+ }
5790
+ if (process.platform === "win32") {
5791
+ run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
5792
+ return;
5793
+ }
5794
+ throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
5162
5795
  }
5163
- async function fetchDistTags(options = {}) {
5164
- const base = options.registry ?? registryBase();
5165
- const doFetch = options.fetchImpl ?? fetch;
5166
- const url = `${base}/${encodeURIComponent(PACKAGE_NAME)}`;
5167
- const controller = new AbortController();
5168
- const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? REGISTRY_TIMEOUT_MS);
5169
- try {
5170
- const response = await doFetch(url, {
5171
- headers: { accept: "application/vnd.npm.install-v1+json" },
5172
- signal: controller.signal
5173
- });
5174
- if (!response.ok) return null;
5175
- const body = await response.json();
5176
- const tags = body?.["dist-tags"];
5177
- if (!tags || typeof tags !== "object" || Array.isArray(tags)) return null;
5178
- const out = {};
5179
- for (const [tag, version] of Object.entries(tags)) {
5180
- if (typeof version === "string") out[tag] = version;
5181
- }
5182
- return out;
5183
- } catch {
5184
- return null;
5185
- } finally {
5186
- clearTimeout(timer);
5796
+ function restartService() {
5797
+ if (process.platform === "darwin") {
5798
+ const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
5799
+ if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
5800
+ return;
5801
+ }
5802
+ if (process.platform === "linux") {
5803
+ const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
5804
+ if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
5805
+ return;
5806
+ }
5807
+ if (process.platform === "win32") {
5808
+ run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
5809
+ const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
5810
+ if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
5811
+ return;
5187
5812
  }
5813
+ throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
5188
5814
  }
5189
5815
 
5190
5816
  // src/update/state.ts
5191
- import fs7 from "node:fs";
5192
- import path9 from "node:path";
5817
+ import fs13 from "node:fs";
5818
+ import path14 from "node:path";
5193
5819
  var UPDATE_STATE_FILE = "update.json";
5194
5820
  function updateStatePath() {
5195
- return path9.join(configDir(), UPDATE_STATE_FILE);
5821
+ return path14.join(configDir(), UPDATE_STATE_FILE);
5196
5822
  }
5197
5823
  function readUpdateState() {
5198
5824
  try {
5199
- const parsed = JSON.parse(fs7.readFileSync(updateStatePath(), "utf8"));
5825
+ const parsed = JSON.parse(fs13.readFileSync(updateStatePath(), "utf8"));
5200
5826
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
5201
5827
  return parsed;
5202
5828
  } catch {
@@ -5205,8 +5831,8 @@ function readUpdateState() {
5205
5831
  }
5206
5832
  function writeUpdateState(state) {
5207
5833
  try {
5208
- fs7.mkdirSync(configDir(), { recursive: true, mode: 448 });
5209
- fs7.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
5834
+ fs13.mkdirSync(configDir(), { recursive: true, mode: 448 });
5835
+ fs13.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
5210
5836
  `, { mode: 384 });
5211
5837
  return true;
5212
5838
  } catch {
@@ -5215,75 +5841,8 @@ function writeUpdateState(state) {
5215
5841
  }
5216
5842
  function clearUpdateState() {
5217
5843
  try {
5218
- fs7.rmSync(updateStatePath(), { force: true });
5219
- } catch {
5220
- }
5221
- }
5222
-
5223
- // src/update/install.ts
5224
- import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
5225
- import fs8 from "node:fs";
5226
- import path10 from "node:path";
5227
- var INSTALL_TIMEOUT_MS = 5 * 6e4;
5228
- var STDERR_KEEP = 400;
5229
- function npmPresent() {
5230
- const probe2 = process.platform === "win32" ? "where" : "which";
5231
- try {
5232
- return spawnSync2(probe2, ["npm"], { encoding: "utf8" }).status === 0;
5233
- } catch {
5234
- return false;
5235
- }
5236
- }
5237
- function installArgs(target, cliPath2) {
5238
- const args = ["install", "--global", "--no-fund", "--no-audit", `${PACKAGE_NAME}@${target}`];
5239
- const prefix = globalPrefixFrom(cliPath2);
5240
- if (prefix) args.push("--prefix", prefix);
5241
- return args;
5242
- }
5243
- async function installGlobal(target, cliPath2, options = {}) {
5244
- const args = installArgs(target, cliPath2);
5245
- return new Promise((resolve) => {
5246
- let child;
5247
- try {
5248
- child = spawn6("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
5249
- } catch (e) {
5250
- resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
5251
- return;
5252
- }
5253
- let stderr = "";
5254
- child.stderr?.on("data", (chunk) => {
5255
- stderr = `${stderr}${chunk.toString()}`.slice(-STDERR_KEEP);
5256
- });
5257
- let settled = false;
5258
- const finish = (result) => {
5259
- if (settled) return;
5260
- settled = true;
5261
- clearTimeout(timer);
5262
- resolve(result);
5263
- };
5264
- const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
5265
- const timer = setTimeout(() => {
5266
- child.kill("SIGTERM");
5267
- setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
5268
- finish({ ok: false, detail: `npm install timed out after ${timeoutMs / 6e4} minutes.` });
5269
- }, timeoutMs);
5270
- timer.unref();
5271
- child.on("error", (e) => finish({ ok: false, detail: e.message }));
5272
- child.on(
5273
- "close",
5274
- (code) => finish({ ok: code === 0, detail: stderr.trim() || `npm exited ${code}` })
5275
- );
5276
- });
5277
- }
5278
- function installedVersionAt(cliPath2) {
5279
- try {
5280
- const pkg = JSON.parse(
5281
- fs8.readFileSync(path10.join(packageRootFrom(cliPath2), "package.json"), "utf8")
5282
- );
5283
- const version = pkg?.version;
5284
- return typeof version === "string" ? version : null;
5844
+ fs13.rmSync(updateStatePath(), { force: true });
5285
5845
  } catch {
5286
- return null;
5287
5846
  }
5288
5847
  }
5289
5848
 
@@ -5406,7 +5965,12 @@ async function startDaemon() {
5406
5965
  // published: `hasOnly()` fails the whole write for one unlisted key, which would
5407
5966
  // take every heartbeat down, not just the badge.
5408
5967
  updateAvailable: updateAvailableVersion,
5409
- updatePhase
5968
+ updatePhase,
5969
+ // §15.82, and the same rule the paragraph above states: `engineTools` must be in
5970
+ // `liveFields()` in the DEPLOYED rules before this version is published. It shipped
5971
+ // in its own change ahead of this one for exactly that reason. `null` when the
5972
+ // machine has nothing to say, so a badge clears itself.
5973
+ engineTools: engineToolStates.size > 0 ? [...engineToolStates.values()] : null
5410
5974
  },
5411
5975
  { merge: true }
5412
5976
  );
@@ -5578,8 +6142,8 @@ async function startDaemon() {
5578
6142
  held.delete(id);
5579
6143
  loginsInFlight.get(id)?.abort();
5580
6144
  for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
5581
- if (!dir.endsWith(`${path11.sep}${id}`)) continue;
5582
- const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
6145
+ if (!dir.endsWith(`${path15.sep}${id}`)) continue;
6146
+ const engineId = path15.basename(path15.dirname(path15.dirname(dir)));
5583
6147
  const driver = getDriver(engineId);
5584
6148
  if (!driver.login) continue;
5585
6149
  void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
@@ -5589,7 +6153,7 @@ async function startDaemon() {
5589
6153
  }
5590
6154
  if (!heldCredentialIds.has(shipId)) {
5591
6155
  for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
5592
- const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
6156
+ const engineId = path15.basename(path15.dirname(path15.dirname(dir)));
5593
6157
  const driver = getDriver(engineId);
5594
6158
  if (!driver.login) continue;
5595
6159
  void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
@@ -5623,7 +6187,9 @@ async function startDaemon() {
5623
6187
  credential,
5624
6188
  root: configDir(),
5625
6189
  signal: abort.signal,
5626
- log: log2
6190
+ log: log2,
6191
+ // §15.82 — a captain asking a machine to sign in is also asking it to have the CLI.
6192
+ ensureEngine: (engineId) => ensureEngineForCaller(engineId)
5627
6193
  }).catch((e) => log2(`Sign-in for ${credential.id} ended abnormally: ${e instanceof Error ? e.message : e}`)).finally(() => loginsInFlight.delete(credential.id));
5628
6194
  }
5629
6195
  poke();
@@ -5678,12 +6244,12 @@ async function startDaemon() {
5678
6244
  heldCredentialIds.delete(shipId);
5679
6245
  for (const engineId of (() => {
5680
6246
  try {
5681
- return fs9.readdirSync(path11.join(configDir(), "engines"));
6247
+ return fs14.readdirSync(path15.join(configDir(), "engines"));
5682
6248
  } catch {
5683
6249
  return [];
5684
6250
  }
5685
6251
  })()) {
5686
- fs9.rmSync(path11.join(configDir(), "engines", engineId, shipId), { recursive: true, force: true });
6252
+ fs14.rmSync(path15.join(configDir(), "engines", engineId, shipId), { recursive: true, force: true });
5687
6253
  }
5688
6254
  approved.delete(shipId);
5689
6255
  warnedUnapproved.delete(shipId);
@@ -5722,6 +6288,11 @@ async function startDaemon() {
5722
6288
  DEFAULT_ENGINE_ID
5723
6289
  );
5724
6290
  };
6291
+ function engineUsable(engineId) {
6292
+ const state = engineToolStates.get(engineId);
6293
+ if (!state) return true;
6294
+ return state.state !== "installing";
6295
+ }
5725
6296
  let dispatching = false;
5726
6297
  let pokeRequested = false;
5727
6298
  let shuttingDown = false;
@@ -5730,6 +6301,10 @@ async function startDaemon() {
5730
6301
  let nextUpdateCheckAt = Date.now() + UPDATE_FIRST_CHECK_MS + Math.floor(Math.random() * UPDATE_JITTER_MS);
5731
6302
  let updateAvailableVersion = null;
5732
6303
  let updatePhase = null;
6304
+ const engineToolStates = /* @__PURE__ */ new Map();
6305
+ let checkingEngines = false;
6306
+ let nextEngineCheckAt = Date.now() + ENGINE_FIRST_CHECK_MS + Math.floor(Math.random() * UPDATE_JITTER_MS);
6307
+ const loggedEngineRefusals = /* @__PURE__ */ new Set();
5733
6308
  function poke() {
5734
6309
  if (dispatching || shuttingDown || updating) {
5735
6310
  pokeRequested = !shuttingDown && !updating;
@@ -5798,7 +6373,13 @@ async function startDaemon() {
5798
6373
  // `pending`, which is what lets a reset resume them with only a poke.
5799
6374
  eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // §15.81: a job on a machine-held credential is that machine's job. Skipped entries
5800
6375
  // STAY in `pending`; the credentials listener pokes when the sign-in lands.
5801
- credentialVerdict(p.shipId, p.job) === "ok" && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
6376
+ credentialVerdict(p.shipId, p.job) === "ok" && // §15.82: a machine that cannot run this engine should not take work it will fail.
6377
+ // Skipped entries STAY in `pending` and the engine check pokes when an install lands
6378
+ // — but only while the install is still going to happen. Once it is refused for good
6379
+ // the job IS claimed and fails with the driver's own message, because a job that never
6380
+ // runs and never fails is invisible, while a failed one at least names the missing CLI
6381
+ // (`E10`, `A21`).
6382
+ engineUsable(engineForJob(p.shipId, p.job)) && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
5802
6383
  // skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
5803
6384
  // with a poke. Without this the `transient` release below re-claims what it just put
5804
6385
  // down, every three seconds, forever (jobs/claimBackoff.ts).
@@ -6087,7 +6668,7 @@ async function startDaemon() {
6087
6668
  // `mcpSecretsToRedact` for why one of them is not enough.
6088
6669
  ...mcpSecretsToRedact(extraMcpServers)
6089
6670
  ];
6090
- const session = await getDriver(engineId).run({
6671
+ const runSession3 = () => getDriver(engineId).run({
6091
6672
  prompt,
6092
6673
  agent,
6093
6674
  job,
@@ -6105,6 +6686,21 @@ async function startDaemon() {
6105
6686
  // swallows everything.
6106
6687
  onStep: (step2) => progress.push(step2)
6107
6688
  });
6689
+ let session = await runSession3();
6690
+ if (session.engineFault && !slot.abort.signal.aborted) {
6691
+ const rolledBackTo = await rollBackEngine(engineId, session.engineFault, log2);
6692
+ if (rolledBackTo) {
6693
+ log2(`Re-running this job on ${engineId} ${rolledBackTo.goodVersion} to find out whether ${rolledBackTo.badVersion} was the cause.`);
6694
+ const second = await runSession3();
6695
+ if (second.ok) {
6696
+ log2(`It was: ${engineId} ${rolledBackTo.badVersion} is broken, and this machine is reporting it.`);
6697
+ void reportBrokenEngineVersion(shipId, engineId, rolledBackTo, job.id);
6698
+ } else {
6699
+ log2(`It was not the version \u2014 ${engineId} ${rolledBackTo.badVersion} is left alone.`);
6700
+ }
6701
+ session = second;
6702
+ }
6703
+ }
6108
6704
  transcript = redactTranscript(
6109
6705
  session.transcript,
6110
6706
  knownSecrets,
@@ -6303,6 +6899,7 @@ async function startDaemon() {
6303
6899
  void heartbeat();
6304
6900
  sweepLimits();
6305
6901
  void maybeCheckForUpdate();
6902
+ void maybeCheckEngines();
6306
6903
  }, HEARTBEAT_MS);
6307
6904
  let stopping = false;
6308
6905
  const shutdown = async (reason) => {
@@ -6403,6 +7000,104 @@ async function startDaemon() {
6403
7000
  nextUpdateCheckAt = Date.now() + checkIntervalMs(config2);
6404
7001
  }
6405
7002
  }
7003
+ async function ensureEngineForCaller(engineId) {
7004
+ const shipIds = [...byShip.keys()];
7005
+ const policySnap = shipIds.length ? await getDoc8(doc9(sess(shipIds[0]).fb.db, COLLECTIONS.crewConfig, CONFIG_DOCS.engines)).catch(() => null) : null;
7006
+ const result = await ensureEngineReady(engineId, {
7007
+ root: configDir(),
7008
+ runnerBin: RUNNER_BIN,
7009
+ autoInstall: engineInstallAllowed(config2),
7010
+ policyDoc: policySnap?.exists() ? policySnap.data() : null,
7011
+ log: (line) => log2(line),
7012
+ onState: (state) => {
7013
+ engineToolStates.set(state.engineId, state);
7014
+ void heartbeat();
7015
+ }
7016
+ });
7017
+ void maybeCheckEnginesSoon();
7018
+ return result;
7019
+ }
7020
+ function maybeCheckEnginesSoon() {
7021
+ nextEngineCheckAt = 0;
7022
+ }
7023
+ async function rollBackEngine(engineId, fault, say2) {
7024
+ const root = configDir();
7025
+ const marker = readEngineMarker(root, engineId);
7026
+ if (!marker || !managedEngineBin(engineId, { root })) return null;
7027
+ const shipIds = [...byShip.keys()];
7028
+ const policySnap = shipIds.length ? await getDoc8(doc9(sess(shipIds[0]).fb.db, COLLECTIONS.crewConfig, CONFIG_DOCS.engines)).catch(() => null) : null;
7029
+ const policy = resolveEnginePolicy(engineId, policySnap?.exists() ? policySnap.data() : null);
7030
+ if (!policy.knownGood || policy.knownGood === marker.version) return null;
7031
+ say2(`A ${engineId} session failed before the model did anything: ${fault}`);
7032
+ say2(`Putting ${engineId} ${policy.knownGood} back to check whether ${marker.version} is at fault.`);
7033
+ const outcome = await installEngine({
7034
+ root,
7035
+ engineId,
7036
+ version: policy.knownGood,
7037
+ healthCheck: (bin) => getDriver(engineId).healthCheck(bin),
7038
+ log: say2
7039
+ });
7040
+ if (!outcome.ok) {
7041
+ say2(`Could not put ${engineId} ${policy.knownGood} back: ${outcome.detail.slice(0, 200)}`);
7042
+ return null;
7043
+ }
7044
+ maybeCheckEnginesSoon();
7045
+ return { badVersion: marker.version, goodVersion: policy.knownGood };
7046
+ }
7047
+ async function reportBrokenEngineVersion(shipId, engineId, versions, jobId) {
7048
+ try {
7049
+ const idToken = await sess(shipId).user.getIdToken();
7050
+ await callFunction(functionsBaseUrl(config2), idToken, "crewShips", "reportEngineVersion", {
7051
+ shipId,
7052
+ engineId,
7053
+ badVersion: versions.badVersion,
7054
+ goodVersion: versions.goodVersion,
7055
+ jobId
7056
+ });
7057
+ } catch (e) {
7058
+ log2(`Could not report ${engineId} ${versions.badVersion} as broken: ${e instanceof Error ? e.message : e}`);
7059
+ }
7060
+ }
7061
+ async function maybeCheckEngines() {
7062
+ if (checkingEngines || shuttingDown || Date.now() < nextEngineCheckAt) return;
7063
+ const shipIds = [...byShip.keys()];
7064
+ if (shipIds.length === 0) return;
7065
+ checkingEngines = true;
7066
+ try {
7067
+ const engineIds = engineSet(agentEngines.values());
7068
+ const firstShip = shipIds[0];
7069
+ const policySnap = await getDoc8(
7070
+ doc9(sess(firstShip).fb.db, COLLECTIONS.crewConfig, CONFIG_DOCS.engines)
7071
+ ).catch(() => null);
7072
+ const states = await refreshEngineTools({
7073
+ engineIds,
7074
+ root: configDir(),
7075
+ runnerBin: RUNNER_BIN,
7076
+ autoInstall: engineInstallAllowed(config2),
7077
+ policyDoc: policySnap?.exists() ? policySnap.data() : null,
7078
+ log: (line) => log2(line),
7079
+ onState: (state) => {
7080
+ engineToolStates.set(state.engineId, state);
7081
+ void heartbeat();
7082
+ }
7083
+ });
7084
+ for (const state of states) {
7085
+ const key = `${state.engineId}:${state.state}:${state.detail ?? ""}`;
7086
+ if ((state.state === "missing" || state.state === "failed") && !loggedEngineRefusals.has(key)) {
7087
+ loggedEngineRefusals.add(key);
7088
+ log2(`Engine "${state.engineId}": ${state.detail ?? "not installed on this machine"}`);
7089
+ }
7090
+ if (state.state === "ready") loggedEngineRefusals.delete(key);
7091
+ engineToolStates.set(state.engineId, state);
7092
+ }
7093
+ poke();
7094
+ } catch (e) {
7095
+ log2(`Engine check failed: ${e instanceof Error ? e.message : e}`);
7096
+ } finally {
7097
+ checkingEngines = false;
7098
+ nextEngineCheckAt = Date.now() + ENGINE_CHECK_MS;
7099
+ }
7100
+ }
6406
7101
  function beginUpdate(target) {
6407
7102
  updating = { target };
6408
7103
  updateAvailableVersion = target;
@@ -6629,7 +7324,8 @@ function glyph(level) {
6629
7324
  var TOGGLES = {
6630
7325
  notifications: "Desktop notifications when a job starts, finishes or fails (off by default)",
6631
7326
  keepAwake: "Keep this machine awake while a job is running",
6632
- autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
7327
+ autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)",
7328
+ autoInstallEngines: "Install the engine CLIs this machine's crews need \u2014 a few hundred MB each (PRD \xA715.82)"
6633
7329
  };
6634
7330
  var PARALLEL = "parallel";
6635
7331
  var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
@@ -6821,8 +7517,8 @@ function setParallel(config2, value, ship2) {
6821
7517
 
6822
7518
  // src/cli/commands/doctor.ts
6823
7519
  import { spawnSync as spawnSync3 } from "node:child_process";
6824
- import fs10 from "node:fs";
6825
- import path12 from "node:path";
7520
+ import fs15 from "node:fs";
7521
+ import path16 from "node:path";
6826
7522
  import { collection as collection9, doc as doc10, getDoc as getDoc9, getDocs as getDocs8 } from "firebase/firestore";
6827
7523
 
6828
7524
  // src/cli/session.ts
@@ -6898,7 +7594,7 @@ function serviceBinaryCheckFrom(input) {
6898
7594
  if (status.state === "not-installed" || status.state === "unsupported") return null;
6899
7595
  const pathValue = status.unitEnv?.PATH;
6900
7596
  if (pathValue === void 0) return null;
6901
- const relative = [...new Set(binaries)].filter((b) => !path12.isAbsolute(b));
7597
+ const relative = [...new Set(binaries)].filter((b) => !path16.isAbsolute(b));
6902
7598
  if (relative.length === 0) return null;
6903
7599
  const missing = relative.filter((b) => !resolves(b, pathValue));
6904
7600
  const id = "service:path";
@@ -6914,10 +7610,10 @@ function serviceBinaryCheckFrom(input) {
6914
7610
  );
6915
7611
  }
6916
7612
  function resolvesOnPath(binary, pathValue) {
6917
- for (const dir of pathValue.split(path12.delimiter)) {
7613
+ for (const dir of pathValue.split(path16.delimiter)) {
6918
7614
  if (!dir) continue;
6919
7615
  try {
6920
- fs10.accessSync(path12.join(dir, binary), fs10.constants.X_OK);
7616
+ fs15.accessSync(path16.join(dir, binary), fs15.constants.X_OK);
6921
7617
  return true;
6922
7618
  } catch {
6923
7619
  }
@@ -7037,8 +7733,7 @@ async function checkShips(config2) {
7037
7733
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
7038
7734
  } catch {
7039
7735
  }
7040
- const shipEngines = new Set(agents.map((agent) => agentEngine(agent)));
7041
- if (shipEngines.size === 0) shipEngines.add(DEFAULT_ENGINE_ID);
7736
+ const shipEngines = enginesForAgents(agents);
7042
7737
  for (const id of shipEngines) engines.add(id);
7043
7738
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
7044
7739
  try {
@@ -7173,9 +7868,20 @@ async function runDoctor() {
7173
7868
  for (const engineId of engines) {
7174
7869
  const health = await getDriver(engineId).healthCheck();
7175
7870
  if (health.binary) jobBinaries.push(health.binary);
7871
+ const managed = managedEngineBin(engineId, {});
7872
+ const version = managed ? readEngineMarker(configDir(), engineId)?.version : null;
7873
+ const origin = managed ? ` \u2014 installed by this runner${version ? ` (${version})` : ""}` : health.binary && health.binary !== engineId ? " \u2014 from CREW_*_BIN" : " \u2014 already on this machine";
7176
7874
  checks.push(
7177
- health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, health.detail) : fail(`engine:${engineId}`, `Engine "${engineId}"`, health.detail, health.fix)
7875
+ health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, `${health.detail}${origin}`) : fail(
7876
+ `engine:${engineId}`,
7877
+ `Engine "${engineId}"`,
7878
+ health.detail,
7879
+ // A way forward the operator can type, ahead of the vendor's download page: this
7880
+ // runner installs it itself, and the old message never said so.
7881
+ enginePackageFor(engineId) ? `Run \`${RUNNER_BIN} engine install ${engineId}\` (a few hundred MB), or ${health.fix ?? "install the CLI yourself"}` : health.fix
7882
+ )
7178
7883
  );
7884
+ if (managed) jobBinaries.push("node");
7179
7885
  }
7180
7886
  if (needsGithub) {
7181
7887
  for (const binary of ["git", "gh"]) {
@@ -7231,8 +7937,142 @@ function report2(checks) {
7231
7937
  return failed.length === 0 ? 0 : 1;
7232
7938
  }
7233
7939
 
7940
+ // src/cli/commands/engine.ts
7941
+ import fs16 from "node:fs";
7942
+ function originOf(engineId) {
7943
+ const override = process.env[`CREW_${engineId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`];
7944
+ if (override) return { origin: "override", bin: override, version: null };
7945
+ const managed = managedEngineBin(engineId, {});
7946
+ if (managed) return { origin: "runner", bin: managed, version: readEngineMarker(configDir(), engineId)?.version ?? null };
7947
+ return { origin: "none", bin: null, version: null };
7948
+ }
7949
+ async function runEngineList() {
7950
+ const root = configDir();
7951
+ const state = readEngineState(root);
7952
+ const rows = [];
7953
+ for (const engineId of installableEngineIds()) {
7954
+ const pkg = enginePackageFor(engineId);
7955
+ const { origin, bin, version } = originOf(engineId);
7956
+ const health = await getDriver(engineId).healthCheck();
7957
+ const record = state[engineId];
7958
+ rows.push({
7959
+ engine: engineId,
7960
+ label: getEngine(engineId)?.label ?? engineId,
7961
+ package: pkg?.npmPackage ?? null,
7962
+ shipped: DEFAULT_ENGINE_POLICIES[engineId]?.knownGood ?? null,
7963
+ installed: version,
7964
+ origin: origin === "none" && health.ok ? "path" : origin,
7965
+ binary: bin ?? health.binary ?? null,
7966
+ ok: health.ok,
7967
+ detail: health.ok ? void 0 : health.detail,
7968
+ lastAttempt: record ? { target: record.target, outcome: record.outcome, attempts: record.attempts, detail: record.detail } : null,
7969
+ diskBytes: dirSize(enginePrefixDir(root, engineId))
7970
+ });
7971
+ }
7972
+ if (isJson()) {
7973
+ emitJson({ autoInstall: engineInstallAllowed(loadConfig()), engines: rows });
7974
+ return 0;
7975
+ }
7976
+ say.line(`Engine CLIs (${engineInstallAllowed(loadConfig()) ? "installed automatically" : "automatic installs are OFF"})`);
7977
+ for (const row of rows) {
7978
+ const where6 = row.origin === "runner" ? "installed by this runner" : row.origin === "override" ? "from CREW_*_BIN" : row.origin === "path" ? "already on this machine \u2014 this runner cannot update or roll it back" : "not installed";
7979
+ say.line("");
7980
+ say.line(` ${row.label} (${row.engine})`);
7981
+ say.line(` ${row.ok ? "\u2713" : "\u2717"} ${row.binary ?? row.package ?? row.engine} \u2014 ${where6}`);
7982
+ if (row.installed) say.line(` version ${row.installed}${row.diskBytes ? ` \xB7 ${mb(row.diskBytes)}` : ""}`);
7983
+ if (!row.ok && row.detail) say.line(` ${row.detail}`);
7984
+ if (row.lastAttempt?.outcome === "failed" && row.lastAttempt.detail) {
7985
+ say.line(` last attempt (${row.lastAttempt.target}, ${row.lastAttempt.attempts}x): ${row.lastAttempt.detail}`);
7986
+ }
7987
+ }
7988
+ say.line("");
7989
+ say.line(` A CLI is a few hundred MB. \`${RUNNER_BIN} config set autoInstallEngines off\` declines them.`);
7990
+ return 0;
7991
+ }
7992
+ async function runEngineInstall(engineIds, options = {}) {
7993
+ const root = configDir();
7994
+ const wanted = engineIds.length > 0 ? engineIds : installableEngineIds();
7995
+ const results = [];
7996
+ for (const engineId of wanted) {
7997
+ const pkg = enginePackageFor(engineId);
7998
+ if (!pkg) throw new CliError(`This runner has no installer for "${engineId}". Known: ${installableEngineIds().join(", ")}.`);
7999
+ const version = DEFAULT_ENGINE_POLICIES[engineId]?.knownGood;
8000
+ if (!version) throw new CliError(`This runner has no version recorded for "${engineId}".`);
8001
+ const marker = readEngineMarker(root, engineId);
8002
+ const already = Boolean(marker && marker.npmPackage === pkg.npmPackage && marker.version === version && managedEngineBin(engineId, {}));
8003
+ if (options.check || options.dryRun) {
8004
+ results.push({
8005
+ engine: engineId,
8006
+ package: pkg.npmPackage,
8007
+ version,
8008
+ installed: false,
8009
+ alreadyInstalled: already,
8010
+ command: `npm ${engineInstallArgs(pkg, version, enginePrefixDir(root, engineId)).join(" ")}`
8011
+ });
8012
+ continue;
8013
+ }
8014
+ if (already && !options.force) {
8015
+ say.info(`${engineId} is already at ${version} \u2014 pass --force to reinstall.`);
8016
+ results.push({ engine: engineId, package: pkg.npmPackage, version, installed: false, alreadyInstalled: true });
8017
+ continue;
8018
+ }
8019
+ const outcome = await installEngine({
8020
+ root,
8021
+ engineId,
8022
+ version,
8023
+ healthCheck: (bin) => getDriver(engineId).healthCheck(bin),
8024
+ // An install takes minutes, so it narrates — but `--json` promises that stdout is ONE
8025
+ // document, and a caller piping this into a parser must not have to strip progress out of
8026
+ // it. Under `--json` the same lines go to stderr, where a person still sees them.
8027
+ log: (line) => isJson() ? console.error(line) : say.line(` ${line}`)
8028
+ });
8029
+ if (!outcome.ok) throw new CliError(`Could not install ${pkg.npmPackage}@${version}: ${outcome.detail}`);
8030
+ results.push({ engine: engineId, package: pkg.npmPackage, version, installed: true, alreadyInstalled: false, binary: outcome.bin });
8031
+ }
8032
+ if (isJson()) {
8033
+ emitJson({ engines: results });
8034
+ return 0;
8035
+ }
8036
+ for (const r of results) {
8037
+ if (r.command) say.info(`Would run: ${r.command}`);
8038
+ else if (r.installed) say.line(`\u2713 ${r.engine} \u2014 ${r.package}@${r.version}`);
8039
+ }
8040
+ return 0;
8041
+ }
8042
+ async function runEngineUninstall(engineId) {
8043
+ if (!enginePackageFor(engineId)) throw new CliError(`This runner has no installer for "${engineId}".`);
8044
+ const dir = enginePrefixDir(configDir(), engineId);
8045
+ const existed = fs16.existsSync(dir);
8046
+ fs16.rmSync(dir, { recursive: true, force: true });
8047
+ if (isJson()) {
8048
+ emitJson({ engine: engineId, removed: existed, directory: dir });
8049
+ return 0;
8050
+ }
8051
+ say.line(existed ? `Removed this runner's copy of ${engineId} (${dir}).` : `This runner had no copy of ${engineId}.`);
8052
+ say.line("A copy you installed yourself is untouched.");
8053
+ return 0;
8054
+ }
8055
+ function dirSize(dir) {
8056
+ try {
8057
+ let total = 0;
8058
+ for (const entry of fs16.readdirSync(dir, { recursive: true, withFileTypes: true })) {
8059
+ if (!entry.isFile()) continue;
8060
+ try {
8061
+ total += fs16.statSync(`${entry.parentPath ?? entry.path}/${entry.name}`).size;
8062
+ } catch {
8063
+ }
8064
+ }
8065
+ return total || null;
8066
+ } catch {
8067
+ return null;
8068
+ }
8069
+ }
8070
+ function mb(bytes) {
8071
+ return `${Math.round(bytes / 1e6)} MB`;
8072
+ }
8073
+
7234
8074
  // src/cli/commands/login.ts
7235
- import { spawn as spawn7 } from "node:child_process";
8075
+ import { spawn as spawn8 } from "node:child_process";
7236
8076
  import os6 from "node:os";
7237
8077
  import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
7238
8078
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
@@ -7274,7 +8114,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
7274
8114
  function openBrowser(url) {
7275
8115
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
7276
8116
  try {
7277
- const child = spawn7(command, args, { stdio: "ignore", detached: true });
8117
+ const child = spawn8(command, args, { stdio: "ignore", detached: true });
7278
8118
  child.on("error", () => {
7279
8119
  });
7280
8120
  child.unref();
@@ -7542,20 +8382,20 @@ async function runServiceStatus() {
7542
8382
  }
7543
8383
 
7544
8384
  // src/cli/commands/uninstall.ts
7545
- import fs11 from "node:fs";
8385
+ import fs17 from "node:fs";
7546
8386
  async function runUninstall(options) {
7547
8387
  const before = serviceStatus();
7548
8388
  const dir = configDir();
7549
8389
  const hadService = before.state !== "not-installed" && before.state !== "unsupported";
7550
8390
  if (hadService) uninstallService();
7551
8391
  let purged = false;
7552
- if (options.purge && fs11.existsSync(dir)) {
8392
+ if (options.purge && fs17.existsSync(dir)) {
7553
8393
  const confirmed = await promptConfirm({
7554
8394
  message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
7555
8395
  initialValue: false
7556
8396
  });
7557
8397
  if (confirmed) {
7558
- fs11.rmSync(dir, { recursive: true, force: true });
8398
+ fs17.rmSync(dir, { recursive: true, force: true });
7559
8399
  purged = true;
7560
8400
  }
7561
8401
  }
@@ -8022,6 +8862,10 @@ program.command("start").description("Run the daemon in the foreground").action(
8022
8862
  return 0;
8023
8863
  })
8024
8864
  );
8865
+ var engine = program.command("engine").description("The engine CLIs this machine runs agents on");
8866
+ engine.command("list").description("What each engine needs, what this machine has, and where it came from").action(action(runEngineList));
8867
+ engine.command("install [engineIds...]").description("Install an engine's CLI into this runner's own folder (a few hundred MB each)").option("--check", "report what would be installed, and install nothing").option("--dry-run", "print the npm command that would run").option("--force", "reinstall even when this runner already has that version").action(action((engineIds, options) => runEngineInstall(engineIds ?? [], options)));
8868
+ engine.command("uninstall <engineId>").description("Remove this runner's own copy \u2014 never one you installed yourself").action(action((engineId) => runEngineUninstall(engineId)));
8025
8869
  program.command("doctor").description("Check whether this machine can run jobs").action(action(runDoctor));
8026
8870
  program.command("status").description("Show what this machine is doing").action(action(runStatus));
8027
8871
  program.command("update").description("Update this machine to the latest published version").option("--check", "report whether a newer version exists, and install nothing").option("--dry-run", "print the npm command that would run").action(action((options) => runUpdate(options)));