@cabane/companion 0.6.89 → 0.6.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -120,7 +120,7 @@ function parsePrepareOutput(stdout) {
120
120
  return { cwd: last };
121
121
  }
122
122
  var runPrepareHook = (hook, input) => {
123
- return new Promise((resolve, reject) => {
123
+ return new Promise((resolve2, reject) => {
124
124
  const timeoutMs = hook.timeoutMs ?? DEFAULT_TIMEOUT_MS;
125
125
  let settled = false;
126
126
  const finish = (fn) => {
@@ -194,11 +194,11 @@ var runPrepareHook = (hook, input) => {
194
194
  return;
195
195
  }
196
196
  if (input.prepared) {
197
- resolve(input.prepared);
197
+ resolve2(input.prepared);
198
198
  return;
199
199
  }
200
200
  try {
201
- resolve(parsePrepareOutput(stdout));
201
+ resolve2(parsePrepareOutput(stdout));
202
202
  } catch (err) {
203
203
  reject(err instanceof PrepareHookError ? err : new PrepareHookError(String(err)));
204
204
  }
@@ -313,9 +313,10 @@ var companionConfigSchema = z2.object({
313
313
  // The change this task exists for: Claude Code used to be the one harness a
314
314
  // device exposed with no configuration — a `claude --version` exit-0 was taken as
315
315
  // consent — and it is now the third config-driven harness, opted into exactly
316
- // like codex. `claude` on PATH is still required (you can't run what isn't
317
- // installed), but presence alone no longer exposes anything: the manifest
318
- // advertises claude-code only when this block says so AND the binary is there.
316
+ // like codex. CT1379: the binary that must be there is the Agent SDK's own
317
+ // bundled executable the file a turn actually launches — not the user's
318
+ // `claude` on PATH, which the SDK never runs. The manifest advertises
319
+ // claude-code only when this block says so AND that binary resolves.
319
320
  //
320
321
  // The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
321
322
  // Absent means the config predates this task — a device whose user was never
@@ -335,9 +336,11 @@ var companionConfigSchema = z2.object({
335
336
  // not a server address. The operator installs Codex + logs in (`codex login`, or
336
337
  // `CODEX_API_KEY` — Cabane never sees the key) and sets `{ "codex": { "enabled":
337
338
  // true } }`. Presence of the block enables it (an explicit `enabled: false`
338
- // keeps the block but turns it off). Enabling makes the device advertise the
339
- // `codex` runtime on its heartbeat manifest AND registers the codex adapter in
340
- // the dispatcher. Absent the device doesn't offer codex, exactly as before.
339
+ // keeps the block but turns it off). Absent the device doesn't offer codex.
340
+ // CT1379: enabling is now half the answer, not all of it — the manifest and the
341
+ // adapter registry additionally require the SDK's vendored `codex` binary to
342
+ // resolve, because the enable flag alone advertised a runtime a device with a
343
+ // half-installed platform package could not start.
341
344
  codex: z2.object({
342
345
  enabled: z2.boolean().optional()
343
346
  }).strict().optional()
@@ -426,15 +429,15 @@ function requireConfig() {
426
429
  }
427
430
 
428
431
  // src/dashboard/server.ts
429
- import { dirname as dirname3, join as join4 } from "path";
430
- import { fileURLToPath } from "url";
432
+ import { dirname as dirname4, join as join5 } from "path";
433
+ import { fileURLToPath as fileURLToPath2 } from "url";
431
434
  import { serve } from "@hono/node-server";
432
435
  import { Hono } from "hono";
433
436
 
434
437
  // src/dashboard/routes.ts
435
438
  import { openSync, readSync, closeSync, fstatSync, existsSync as existsSync2 } from "fs";
436
439
  import { readFile } from "fs/promises";
437
- import { extname, join as join3, normalize } from "path";
440
+ import { extname, join as join4, normalize } from "path";
438
441
  import { streamSSE } from "hono/streaming";
439
442
 
440
443
  // src/harness-check.ts
@@ -450,14 +453,14 @@ function parseVersionToken(raw) {
450
453
  return m ? m[0] : null;
451
454
  }
452
455
  function probeCliPresence(command, spawnImpl = spawn2) {
453
- return new Promise((resolve) => {
456
+ return new Promise((resolve2) => {
454
457
  let settled = false;
455
458
  let timer = null;
456
459
  const done = (result) => {
457
460
  if (!settled) {
458
461
  settled = true;
459
462
  if (timer) clearTimeout(timer);
460
- resolve(result);
463
+ resolve2(result);
461
464
  }
462
465
  };
463
466
  let child;
@@ -539,6 +542,203 @@ async function safe(fn) {
539
542
  }
540
543
  }
541
544
 
545
+ // src/bundled-binaries.ts
546
+ import { realpathSync, statSync } from "fs";
547
+ import { createRequire } from "module";
548
+ import { dirname as dirname2, join as join2 } from "path";
549
+ import { fileURLToPath } from "url";
550
+ function nodeResolveSelf(specifier) {
551
+ const here = fileURLToPath(import.meta.url);
552
+ if (typeof import.meta.resolve === "function") {
553
+ try {
554
+ return fileURLToPath(import.meta.resolve(specifier));
555
+ } catch {
556
+ }
557
+ }
558
+ try {
559
+ return createRequire(here).resolve(specifier);
560
+ } catch {
561
+ }
562
+ return walkNodeModules(specifier, here);
563
+ }
564
+ function walkNodeModules(pkg2, fromPath) {
565
+ let dir2 = dirname2(fromPath);
566
+ for (; ; ) {
567
+ const manifest = join2(dir2, "node_modules", pkg2, "package.json");
568
+ if (nodeIsFile(manifest)) {
569
+ try {
570
+ return realpathSync(manifest);
571
+ } catch {
572
+ return manifest;
573
+ }
574
+ }
575
+ const parent = dirname2(dir2);
576
+ if (parent === dir2) return null;
577
+ dir2 = parent;
578
+ }
579
+ }
580
+ function nodeResolve(specifier, fromPath) {
581
+ try {
582
+ return createRequire(fromPath).resolve(specifier);
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function nodeIsFile(path) {
588
+ try {
589
+ return statSync(path).isFile();
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+ function detectPreferMusl(platform = process.platform) {
595
+ if (platform !== "linux") return false;
596
+ const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
597
+ const header = report?.header;
598
+ return report != null && header?.glibcVersionRuntime === void 0;
599
+ }
600
+ function defaultResolveEnv() {
601
+ return {
602
+ platform: process.platform,
603
+ arch: process.arch,
604
+ preferMusl: detectPreferMusl(),
605
+ // Both resolutions start from this module, which tsup bundles into
606
+ // `dist/cli.js` and `dist/postinstall.js` — both at the package's `dist/`
607
+ // root — so one environment serves the runtime checks and the install
608
+ // verifier alike.
609
+ resolveSelf: nodeResolveSelf,
610
+ resolve: nodeResolve,
611
+ isFile: nodeIsFile
612
+ };
613
+ }
614
+ var CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
615
+ function claudePlatformPackages(platform, arch, preferMusl) {
616
+ if (platform === "android") return [`${CLAUDE_SDK}-linux-${arch}-android`];
617
+ if (platform === "linux") {
618
+ return preferMusl ? [`${CLAUDE_SDK}-linux-${arch}-musl`, `${CLAUDE_SDK}-linux-${arch}`] : [`${CLAUDE_SDK}-linux-${arch}`, `${CLAUDE_SDK}-linux-${arch}-musl`];
619
+ }
620
+ return [`${CLAUDE_SDK}-${platform}-${arch}`];
621
+ }
622
+ function resolveClaude(env) {
623
+ const candidates = claudePlatformPackages(env.platform, env.arch, env.preferMusl);
624
+ const base = {
625
+ runtime: "claude-code",
626
+ packageName: candidates[0] ?? null,
627
+ platform: env.platform,
628
+ arch: env.arch
629
+ };
630
+ const sdkEntry = env.resolveSelf(CLAUDE_SDK);
631
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
632
+ let sawPackage = null;
633
+ for (const pkg2 of candidates) {
634
+ const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
635
+ if (!manifest) continue;
636
+ sawPackage ??= pkg2;
637
+ const exe = join2(dirname2(manifest), env.platform === "win32" ? "claude.exe" : "claude");
638
+ if (env.isFile(exe)) {
639
+ return { ...base, packageName: pkg2, status: "present", path: exe };
640
+ }
641
+ }
642
+ return sawPackage ? { ...base, packageName: sawPackage, status: "executable_missing", path: null } : { ...base, status: "platform_package_missing", path: null };
643
+ }
644
+ var CODEX_SDK = "@openai/codex-sdk";
645
+ var CODEX_CLI = "@openai/codex";
646
+ function codexTargetTriple(platform, arch) {
647
+ if (platform === "linux" || platform === "android") {
648
+ if (arch === "x64") return "x86_64-unknown-linux-musl";
649
+ if (arch === "arm64") return "aarch64-unknown-linux-musl";
650
+ return null;
651
+ }
652
+ if (platform === "darwin") {
653
+ if (arch === "x64") return "x86_64-apple-darwin";
654
+ if (arch === "arm64") return "aarch64-apple-darwin";
655
+ return null;
656
+ }
657
+ if (platform === "win32") {
658
+ if (arch === "x64") return "x86_64-pc-windows-msvc";
659
+ if (arch === "arm64") return "aarch64-pc-windows-msvc";
660
+ return null;
661
+ }
662
+ return null;
663
+ }
664
+ var CODEX_PACKAGE_BY_TRIPLE = {
665
+ "x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
666
+ "aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
667
+ "x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
668
+ "aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
669
+ "x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
670
+ "aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
671
+ };
672
+ function resolveCodex(env) {
673
+ const triple = codexTargetTriple(env.platform, env.arch);
674
+ const pkg2 = triple ? CODEX_PACKAGE_BY_TRIPLE[triple] : void 0;
675
+ const base = {
676
+ runtime: "codex",
677
+ packageName: pkg2 ?? null,
678
+ platform: env.platform,
679
+ arch: env.arch
680
+ };
681
+ if (!triple || !pkg2) return { ...base, status: "platform_package_missing", path: null };
682
+ const sdkEntry = env.resolveSelf(CODEX_SDK);
683
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
684
+ const cliManifest = env.resolve(`${CODEX_CLI}/package.json`, sdkEntry);
685
+ if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
686
+ const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
687
+ if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
688
+ const packageRoot = join2(dirname2(platformManifest), "vendor", triple);
689
+ const exeName = env.platform === "win32" ? "codex.exe" : "codex";
690
+ const current = join2(packageRoot, "bin", exeName);
691
+ if (env.isFile(current) && env.isFile(join2(packageRoot, "codex-package.json"))) {
692
+ return { ...base, status: "present", path: current };
693
+ }
694
+ const legacy = join2(packageRoot, "codex", exeName);
695
+ if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
696
+ return { ...base, status: "executable_missing", path: null };
697
+ }
698
+ function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
699
+ try {
700
+ return runtime === "codex" ? resolveCodex(env) : resolveClaude(env);
701
+ } catch {
702
+ return {
703
+ runtime,
704
+ status: "platform_package_missing",
705
+ path: null,
706
+ packageName: null,
707
+ platform: env.platform,
708
+ arch: env.arch
709
+ };
710
+ }
711
+ }
712
+ var HARNESS_LABEL = {
713
+ "claude-code": "Claude Code",
714
+ codex: "Codex"
715
+ };
716
+ var HARNESS_INTENT_WORD = {
717
+ "claude-code": "connected",
718
+ codex: "enabled"
719
+ };
720
+ function missingNoun(status) {
721
+ return status === "executable_missing" ? "executable" : "platform package";
722
+ }
723
+ function repairCommands() {
724
+ return ["npm i -g @cabane/companion --include=optional"];
725
+ }
726
+ function repairAction() {
727
+ return `Reinstall with \`${repairCommands()[0]}\`.`;
728
+ }
729
+ function startupWarning(r) {
730
+ const label = HARNESS_LABEL[r.runtime];
731
+ return `warning: ${label} is ${HARNESS_INTENT_WORD[r.runtime]}, but @cabane/companion's bundled ${label} ${missingNoun(r.status)} is missing. It will not be offered to Cabane. ${repairAction()} Then restart the companion.`;
732
+ }
733
+ function harnessIssueNote(runtime, status) {
734
+ const intent = HARNESS_INTENT_WORD[runtime];
735
+ const capitalized = `${intent[0].toUpperCase()}${intent.slice(1)}`;
736
+ return `${capitalized}, but @cabane/companion's bundled ${HARNESS_LABEL[runtime]} ${missingNoun(status)} is missing. ${repairAction()} Then restart the companion.`;
737
+ }
738
+ function turnIncompleteCopy(runtime) {
739
+ return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
740
+ }
741
+
542
742
  // src/manifest.ts
543
743
  var DEVICE_MANIFEST = {
544
744
  runtimes: [{ name: "claude-code", version: null }],
@@ -550,7 +750,20 @@ function buildCompanionManifest(opts) {
550
750
  if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
551
751
  if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
552
752
  if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
553
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
753
+ return {
754
+ runtimes,
755
+ capabilities: { ...DEVICE_MANIFEST.capabilities },
756
+ runtimeIssues: opts.runtimeIssues ?? []
757
+ };
758
+ }
759
+ function runtimeIssuesFor(gates) {
760
+ const issues = [];
761
+ for (const name of ["claude-code", "codex"]) {
762
+ const gate = name === "codex" ? gates.codex : gates.claudeCode;
763
+ if (!gate.intended || gate.bundled === "present") continue;
764
+ issues.push({ name, reason: gate.bundled });
765
+ }
766
+ return issues;
554
767
  }
555
768
 
556
769
  // src/harness-status.ts
@@ -564,11 +777,12 @@ var DEFAULT_OPENCODE_SERVER_URL = "http://127.0.0.1:4096";
564
777
  function deriveHarnessSnapshot(signals) {
565
778
  const advertised = new Set(
566
779
  buildCompanionManifest({
567
- // CT1082: connected AND installed the manifest's own rule, restated here
568
- // through the same function rather than re-decided.
569
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
780
+ // CT1082 + CT1379: connected AND the bundled binary the SDK launches is
781
+ // there — the manifest's own rule, restated here through the same function
782
+ // rather than re-decided. PATH is deliberately absent from both gates now.
783
+ claudeCode: signals.claudeCodeConnected && signals.claudeBundled === "present",
570
784
  opencode: signals.opencodeConfigured,
571
- codex: signals.codexEnabled
785
+ codex: signals.codexEnabled && signals.codexBundled === "present"
572
786
  }).runtimes.map((r) => r.name)
573
787
  );
574
788
  const harnesses = [
@@ -594,7 +808,7 @@ function deriveClaudeCode(signals, manifestHas) {
594
808
  ...base,
595
809
  state: "needs_attention",
596
810
  version: null,
597
- detail: "Connected, but the `claude` CLI isn\u2019t on your PATH. Install it (`npm i -g @anthropic-ai/claude-code`) and sign in, or disconnect it.",
811
+ detail: harnessIssueNote("claude-code", signals.claudeBundled),
598
812
  enable: null
599
813
  };
600
814
  }
@@ -603,7 +817,11 @@ function deriveClaudeCode(signals, manifestHas) {
603
817
  ...base,
604
818
  state: "detected_not_exposed",
605
819
  version: signals.claudeVersion,
606
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
820
+ // CT1379: connecting records INTENT; it is not the same as becoming
821
+ // runnable. With the bundled binary missing, a connect succeeds and the
822
+ // device still advertises nothing — so this says so up front rather than
823
+ // promising routing it can't deliver.
824
+ detail: signals.claudeBundled === "present" ? "Claude Code is installed here but not connected yet. Connect it to offer Claude Code from this device." : `Claude Code is installed here but not connected yet \u2014 and @cabane/companion's bundled Claude Code ${missingNoun(signals.claudeBundled)} is missing, so connecting alone won't make it runnable. ${repairAction()}`,
607
825
  enable: "claude-code"
608
826
  };
609
827
  }
@@ -618,20 +836,20 @@ function deriveClaudeCode(signals, manifestHas) {
618
836
  function deriveCodex(signals, manifestHas) {
619
837
  const base = { runtime: "codex", label: LABELS.codex };
620
838
  if (manifestHas) {
621
- if (signals.codexOnPath) {
622
- return {
623
- ...base,
624
- state: "exposed",
625
- version: signals.codexVersion,
626
- detail: "Codex is enabled and exposed to Cabane.",
627
- enable: null
628
- };
629
- }
839
+ return {
840
+ ...base,
841
+ state: "exposed",
842
+ version: signals.codexVersion,
843
+ detail: "Codex is enabled and exposed to Cabane.",
844
+ enable: null
845
+ };
846
+ }
847
+ if (signals.codexEnabled) {
630
848
  return {
631
849
  ...base,
632
850
  state: "needs_attention",
633
851
  version: null,
634
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
852
+ detail: harnessIssueNote("codex", signals.codexBundled),
635
853
  enable: null
636
854
  };
637
855
  }
@@ -640,7 +858,9 @@ function deriveCodex(signals, manifestHas) {
640
858
  ...base,
641
859
  state: "detected_not_exposed",
642
860
  version: signals.codexVersion,
643
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
861
+ // CT1379: same distinction as Claude Code enabling is intent, not
862
+ // launchability.
863
+ detail: signals.codexBundled === "present" ? "Codex is installed but not enabled yet. Turn it on to offer Codex from this device." : `Codex is installed but not enabled yet \u2014 and @cabane/companion's bundled Codex ${missingNoun(signals.codexBundled)} is missing, so enabling alone won't make it runnable. ${repairAction()}`,
644
864
  enable: "codex"
645
865
  };
646
866
  }
@@ -707,6 +927,7 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
707
927
  async function probeHarnessSignals(cfg, deps = {}) {
708
928
  const probePresence = deps.probePresence ?? probeHarnessPresence;
709
929
  const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
930
+ const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
710
931
  const configuredServerUrl = cfg.opencode?.serverUrl;
711
932
  const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
712
933
  const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
@@ -722,6 +943,11 @@ async function probeHarnessSignals(cfg, deps = {}) {
722
943
  // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
723
944
  // the manifest gate and the probe above is only a suggestion.
724
945
  claudeCodeConnected: isClaudeCodeConnected(cfg),
946
+ // CT1379: the executable a Claude turn actually launches. Resolved every time
947
+ // the signals are, so a binary that disappears between beats is caught on the
948
+ // next one rather than at turn time.
949
+ claudeBundled: resolveBundled("claude-code").status,
950
+ codexBundled: resolveBundled("codex").status,
725
951
  // A parseable `codex --version` is our presence signal (presence alone never
726
952
  // exposes codex; its config flag is the manifest gate either way).
727
953
  codexOnPath: codexVersion !== null,
@@ -735,12 +961,12 @@ async function probeHarnessSignals(cfg, deps = {}) {
735
961
  };
736
962
  }
737
963
  function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
738
- return new Promise((resolve) => {
964
+ return new Promise((resolve2) => {
739
965
  let settled = false;
740
966
  const done = (v) => {
741
967
  if (!settled) {
742
968
  settled = true;
743
- resolve(v);
969
+ resolve2(v);
744
970
  }
745
971
  };
746
972
  const timer = setTimeout(() => done(fallback), timeoutMs);
@@ -818,19 +1044,19 @@ function looksUnsupported(output) {
818
1044
  );
819
1045
  }
820
1046
  function runBounded(command, args) {
821
- return new Promise((resolve) => {
1047
+ return new Promise((resolve2) => {
822
1048
  let settled = false;
823
1049
  const done = (result) => {
824
1050
  if (settled) return;
825
1051
  settled = true;
826
1052
  clearTimeout(timer);
827
- resolve(result);
1053
+ resolve2(result);
828
1054
  };
829
1055
  let child;
830
1056
  try {
831
1057
  child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
832
1058
  } catch {
833
- resolve({ code: null, output: "", error: "spawn" });
1059
+ resolve2({ code: null, output: "", error: "spawn" });
834
1060
  return;
835
1061
  }
836
1062
  let out = "";
@@ -851,11 +1077,11 @@ function runBounded(command, args) {
851
1077
 
852
1078
  // src/logger.ts
853
1079
  import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
854
- import { dirname as dirname2, join as join2 } from "path";
1080
+ import { dirname as dirname3, join as join3 } from "path";
855
1081
  import pino from "pino";
856
1082
  import pretty from "pino-pretty";
857
1083
  function companionLogPath() {
858
- return join2(cabaneDir(), "companion.log");
1084
+ return join3(cabaneDir(), "companion.log");
859
1085
  }
860
1086
  var CONSOLE_IGNORE = [
861
1087
  "pid",
@@ -880,7 +1106,7 @@ var cached = null;
880
1106
  var consoleLogging = true;
881
1107
  function createLogger(destinations = {}) {
882
1108
  const path = companionLogPath();
883
- if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
1109
+ if (!destinations.file) mkdirSync2(dirname3(path), { recursive: true });
884
1110
  const streams = [];
885
1111
  if (process.env.CABANE_COMPANION_DAEMON !== "1") {
886
1112
  const consoleStream = pretty({
@@ -1156,13 +1382,13 @@ var CONTENT_TYPES = {
1156
1382
  function registerRoutes(app, deps) {
1157
1383
  const { supervisor, hub, staticDir } = deps;
1158
1384
  app.get("/", async (c) => {
1159
- const html = await readFile(join3(staticDir, "index.html"), "utf8");
1385
+ const html = await readFile(join4(staticDir, "index.html"), "utf8");
1160
1386
  return c.html(html);
1161
1387
  });
1162
1388
  app.get("/static/:file", async (c) => {
1163
1389
  const file = c.req.param("file");
1164
1390
  const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1165
- const full = join3(staticDir, safe3);
1391
+ const full = join4(staticDir, safe3);
1166
1392
  if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
1167
1393
  const body = await readFile(full);
1168
1394
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
@@ -1262,8 +1488,8 @@ function registerRoutes(app, deps) {
1262
1488
  while (!stream.aborted) {
1263
1489
  if (queue.length === 0) {
1264
1490
  await Promise.race([
1265
- new Promise((resolve) => {
1266
- wake = resolve;
1491
+ new Promise((resolve2) => {
1492
+ wake = resolve2;
1267
1493
  }),
1268
1494
  stream.sleep(25e3)
1269
1495
  ]);
@@ -1347,8 +1573,8 @@ async function startDashboard(opts) {
1347
1573
  return {
1348
1574
  url,
1349
1575
  port,
1350
- close: () => new Promise((resolve) => {
1351
- server.close(() => resolve());
1576
+ close: () => new Promise((resolve2) => {
1577
+ server.close(() => resolve2());
1352
1578
  server.closeAllConnections?.();
1353
1579
  })
1354
1580
  };
@@ -1365,12 +1591,12 @@ async function startDashboard(opts) {
1365
1591
  );
1366
1592
  }
1367
1593
  function listen(app, port) {
1368
- return new Promise((resolve, reject) => {
1594
+ return new Promise((resolve2, reject) => {
1369
1595
  let settled = false;
1370
1596
  const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
1371
1597
  if (!settled) {
1372
1598
  settled = true;
1373
- resolve(server);
1599
+ resolve2(server);
1374
1600
  }
1375
1601
  });
1376
1602
  server.on("error", (err) => {
@@ -1385,14 +1611,14 @@ function isAddrInUse(err) {
1385
1611
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
1386
1612
  }
1387
1613
  function resolveStaticDir() {
1388
- return join4(dirname3(fileURLToPath(import.meta.url)), "static");
1614
+ return join5(dirname4(fileURLToPath2(import.meta.url)), "static");
1389
1615
  }
1390
1616
 
1391
1617
  // src/control-socket.ts
1392
1618
  import { createHash } from "crypto";
1393
1619
  import { chmodSync as chmodSync2, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
1394
1620
  import { createServer, connect } from "net";
1395
- import { join as join5 } from "path";
1621
+ import { join as join6 } from "path";
1396
1622
  var CONTROL_TIMEOUT_MS = 1e3;
1397
1623
  var MAX_UNIX_SOCKET_PATH_BYTES = 103;
1398
1624
  function controlSocketPath() {
@@ -1401,11 +1627,11 @@ function controlSocketPath() {
1401
1627
  if (process.platform === "win32") {
1402
1628
  return `\\\\.\\pipe\\cabane-companion-${key}`;
1403
1629
  }
1404
- const besideRuntime = join5(dir2, "companion.sock");
1630
+ const besideRuntime = join6(dir2, "companion.sock");
1405
1631
  if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
1406
1632
  const filename = `cabane-companion-${key}.sock`;
1407
- const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync3(process.env.XDG_RUNTIME_DIR) ? join5(process.env.XDG_RUNTIME_DIR, filename) : null;
1408
- return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join5("/tmp", filename);
1633
+ const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync3(process.env.XDG_RUNTIME_DIR) ? join6(process.env.XDG_RUNTIME_DIR, filename) : null;
1634
+ return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join6("/tmp", filename);
1409
1635
  }
1410
1636
  async function startControlServer(handlers) {
1411
1637
  const path = controlSocketPath();
@@ -1419,18 +1645,18 @@ async function startControlServer(handlers) {
1419
1645
  void serveConnection(socket, handlers);
1420
1646
  });
1421
1647
  server.unref();
1422
- await new Promise((resolve, reject) => {
1648
+ await new Promise((resolve2, reject) => {
1423
1649
  server.once("error", reject);
1424
1650
  server.listen(path, () => {
1425
1651
  server.removeListener("error", reject);
1426
- resolve();
1652
+ resolve2();
1427
1653
  });
1428
1654
  });
1429
1655
  if (process.platform !== "win32") {
1430
1656
  try {
1431
1657
  chmodSync2(path, 384);
1432
1658
  } catch (err) {
1433
- await new Promise((resolve) => server.close(() => resolve()));
1659
+ await new Promise((resolve2) => server.close(() => resolve2()));
1434
1660
  try {
1435
1661
  rmSync2(path, { force: true });
1436
1662
  } catch {
@@ -1442,10 +1668,10 @@ async function startControlServer(handlers) {
1442
1668
  });
1443
1669
  return {
1444
1670
  path,
1445
- close: () => new Promise((resolve) => {
1671
+ close: () => new Promise((resolve2) => {
1446
1672
  server.close(() => {
1447
1673
  if (process.platform !== "win32") rmSync2(path, { force: true });
1448
- resolve();
1674
+ resolve2();
1449
1675
  });
1450
1676
  })
1451
1677
  };
@@ -1495,12 +1721,12 @@ function reply(socket, body) {
1495
1721
  async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1496
1722
  const socket = connect(path);
1497
1723
  try {
1498
- await new Promise((resolve, reject) => {
1724
+ await new Promise((resolve2, reject) => {
1499
1725
  const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1500
1726
  timer.unref?.();
1501
1727
  socket.once("connect", () => {
1502
1728
  clearTimeout(timer);
1503
- resolve();
1729
+ resolve2();
1504
1730
  });
1505
1731
  socket.once("error", (err) => {
1506
1732
  clearTimeout(timer);
@@ -1527,7 +1753,7 @@ function isNotListening(err) {
1527
1753
  return code === "ENOENT" || code === "ECONNREFUSED";
1528
1754
  }
1529
1755
  function readLine(socket, timeoutMs) {
1530
- return new Promise((resolve) => {
1756
+ return new Promise((resolve2) => {
1531
1757
  let buf = "";
1532
1758
  let settled = false;
1533
1759
  const done = (v) => {
@@ -1535,7 +1761,7 @@ function readLine(socket, timeoutMs) {
1535
1761
  settled = true;
1536
1762
  clearTimeout(timer);
1537
1763
  socket.removeListener("data", onData);
1538
- resolve(v);
1764
+ resolve2(v);
1539
1765
  };
1540
1766
  const timer = setTimeout(() => done(null), timeoutMs);
1541
1767
  timer.unref?.();
@@ -1581,6 +1807,7 @@ async function requireStartConfig(deps = {}) {
1581
1807
  async function warnAboutHarnessReadiness(cfg, deps = {}) {
1582
1808
  const probeClaude = deps.probeClaude ?? claudeOnPath;
1583
1809
  const probeCodex = deps.probeCodex ?? codexOnPath;
1810
+ const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
1584
1811
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
1585
1812
  `));
1586
1813
  const connected = [
@@ -1589,25 +1816,42 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1589
1816
  ...cfg.opencode ? ["opencode"] : []
1590
1817
  ];
1591
1818
  if (connected.length > 0) {
1592
- if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
1593
- warn(
1594
- "warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
1595
- );
1819
+ for (const runtime of ["claude-code", "codex"]) {
1820
+ const intended = runtime === "codex" ? isCodexEnabled(cfg) : isClaudeCodeConnected(cfg);
1821
+ if (!intended) continue;
1822
+ const resolution = resolveBundled(runtime);
1823
+ if (resolution.status !== "present") warn(startupWarning(resolution));
1596
1824
  }
1597
1825
  return;
1598
1826
  }
1599
1827
  const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1600
- const installed = [
1601
- ...claudeInstalled ? ["Claude Code"] : [],
1602
- ...codexInstalled ? ["Codex"] : []
1603
- ];
1604
- const connectCommands = [
1605
- ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
1606
- ...codexInstalled ? ["`cabane-companion connect codex`"] : []
1828
+ const found = [
1829
+ ...claudeInstalled ? [
1830
+ {
1831
+ label: "Claude Code",
1832
+ command: "`cabane-companion connect claude-code`",
1833
+ bundled: resolveBundled("claude-code").status
1834
+ }
1835
+ ] : [],
1836
+ ...codexInstalled ? [
1837
+ {
1838
+ label: "Codex",
1839
+ command: "`cabane-companion connect codex`",
1840
+ bundled: resolveBundled("codex").status
1841
+ }
1842
+ ] : []
1607
1843
  ];
1608
- warn(
1609
- "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
1610
- );
1844
+ const runnable = found.filter((h) => h.bundled === "present");
1845
+ const incomplete = found.filter((h) => h.bundled !== "present");
1846
+ const incompleteNote = incomplete.map(
1847
+ (h) => `${h.label} is installed here, but @cabane/companion's bundled ${h.label} ${missingNoun(h.bundled)} is missing, so connecting it alone won't make it runnable.`
1848
+ ).join(" ");
1849
+ const guidance = runnable.length > 0 ? `We found ${runnable.map((h) => h.label).join(" and ")} on this machine \u2014 connect ${runnable.length > 1 ? "one with " : "it with "}${runnable.map((h) => h.command).join(" or ")} and turns start routing here.${incompleteNote ? ` ${incompleteNote} ${repairAction()}` : ""}` : incomplete.length > 0 ? (
1850
+ // Their machine is fine; this companion's install is not. Repair that,
1851
+ // then connect — no "install a harness" they already have.
1852
+ `${incompleteNote} ${repairAction()} Then connect it with ${incomplete.map((h) => h.command).join(" or ")}.`
1853
+ ) : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).";
1854
+ warn(`No harness is connected on this device yet, so no agent turn can run here. ${guidance}`);
1611
1855
  }
1612
1856
 
1613
1857
  // src/runtime-file.ts
@@ -1620,10 +1864,10 @@ import {
1620
1864
  openSync as openSync2,
1621
1865
  closeSync as closeSync2
1622
1866
  } from "fs";
1623
- import { join as join6 } from "path";
1867
+ import { join as join7 } from "path";
1624
1868
  var PROBE_TIMEOUT_MS2 = 1e3;
1625
1869
  function runtimePath() {
1626
- return join6(cabaneDir(), "runtime.json");
1870
+ return join7(cabaneDir(), "runtime.json");
1627
1871
  }
1628
1872
  function serialize(state) {
1629
1873
  return JSON.stringify(state, null, 2) + "\n";
@@ -2173,15 +2417,15 @@ function isAbortError(err) {
2173
2417
  return err instanceof Error && err.name === "AbortError";
2174
2418
  }
2175
2419
  function sleep(ms, signal) {
2176
- return new Promise((resolve) => {
2177
- if (signal?.aborted) return resolve();
2420
+ return new Promise((resolve2) => {
2421
+ if (signal?.aborted) return resolve2();
2178
2422
  const timer = setTimeout(() => {
2179
2423
  signal?.removeEventListener("abort", onAbort);
2180
- resolve();
2424
+ resolve2();
2181
2425
  }, ms);
2182
2426
  const onAbort = () => {
2183
2427
  clearTimeout(timer);
2184
- resolve();
2428
+ resolve2();
2185
2429
  };
2186
2430
  signal?.addEventListener("abort", onAbort, { once: true });
2187
2431
  });
@@ -2272,10 +2516,10 @@ import {
2272
2516
  rmSync as rmSync4,
2273
2517
  writeFileSync as writeFileSync3
2274
2518
  } from "fs";
2275
- import { dirname as dirname4, join as join7 } from "path";
2519
+ import { dirname as dirname5, join as join8 } from "path";
2276
2520
  import { z as z3 } from "zod";
2277
2521
  function credentialsPath() {
2278
- return join7(cabaneDir(), "credentials.json");
2522
+ return join8(cabaneDir(), "credentials.json");
2279
2523
  }
2280
2524
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
2281
2525
  function load() {
@@ -2297,7 +2541,7 @@ function load() {
2297
2541
  }
2298
2542
  function save(map) {
2299
2543
  const path = credentialsPath();
2300
- mkdirSync5(dirname4(path), { recursive: true });
2544
+ mkdirSync5(dirname5(path), { recursive: true });
2301
2545
  try {
2302
2546
  chmodSync3(cabaneDir(), 448);
2303
2547
  } catch {
@@ -2343,9 +2587,9 @@ function pruneCredentials(keepAgentIds) {
2343
2587
 
2344
2588
  // src/cursor.ts
2345
2589
  import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2346
- import { join as join8 } from "path";
2590
+ import { join as join9 } from "path";
2347
2591
  function pathFor(workspaceId) {
2348
- return join8(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2592
+ return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2349
2593
  }
2350
2594
  function readCursor(workspaceId) {
2351
2595
  const path = pathFor(workspaceId);
@@ -2355,7 +2599,7 @@ function readCursor(workspaceId) {
2355
2599
  }
2356
2600
  function writeCursor(workspaceId, eventId) {
2357
2601
  const path = pathFor(workspaceId);
2358
- mkdirSync6(join8(cabaneDir(), "cursors"), { recursive: true });
2602
+ mkdirSync6(join9(cabaneDir(), "cursors"), { recursive: true });
2359
2603
  writeFileSync4(path, eventId + "\n", "utf8");
2360
2604
  }
2361
2605
 
@@ -2400,13 +2644,13 @@ var CursorTracker = class {
2400
2644
 
2401
2645
  // src/dispatch-dedupe.ts
2402
2646
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2403
- import { join as join9 } from "path";
2647
+ import { join as join10 } from "path";
2404
2648
  var MAX_IDS = 256;
2405
2649
  function dir(log) {
2406
- return join9(cabaneDir(), log);
2650
+ return join10(cabaneDir(), log);
2407
2651
  }
2408
2652
  function pathFor2(log, workspaceId) {
2409
- return join9(dir(log), encodeURIComponent(workspaceId));
2653
+ return join10(dir(log), encodeURIComponent(workspaceId));
2410
2654
  }
2411
2655
  function readIds(log, workspaceId) {
2412
2656
  const path = pathFor2(log, workspaceId);
@@ -2443,10 +2687,10 @@ function markCompleted(workspaceId, eventId) {
2443
2687
  }
2444
2688
  var MAX_RESUME_ATTEMPTS = 3;
2445
2689
  function resumeDir() {
2446
- return join9(cabaneDir(), "resume-attempts");
2690
+ return join10(cabaneDir(), "resume-attempts");
2447
2691
  }
2448
2692
  function resumePathFor(workspaceId) {
2449
- return join9(resumeDir(), encodeURIComponent(workspaceId));
2693
+ return join10(resumeDir(), encodeURIComponent(workspaceId));
2450
2694
  }
2451
2695
  function readResumeCounts(workspaceId) {
2452
2696
  const out = /* @__PURE__ */ new Map();
@@ -2482,10 +2726,10 @@ function bumpResumeAttempt(workspaceId, eventId) {
2482
2726
  return next;
2483
2727
  }
2484
2728
  function turnDir() {
2485
- return join9(cabaneDir(), "turns");
2729
+ return join10(cabaneDir(), "turns");
2486
2730
  }
2487
2731
  function turnPathFor(workspaceId) {
2488
- return join9(turnDir(), encodeURIComponent(workspaceId));
2732
+ return join10(turnDir(), encodeURIComponent(workspaceId));
2489
2733
  }
2490
2734
  var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2491
2735
  function readTurnIds(workspaceId) {
@@ -4860,7 +5104,7 @@ var serverTurnTails = /* @__PURE__ */ new Map();
4860
5104
  async function acquireServerTurnLock(url) {
4861
5105
  const prev = serverTurnTails.get(url) ?? Promise.resolve();
4862
5106
  let release;
4863
- const done = new Promise((resolve) => release = resolve);
5107
+ const done = new Promise((resolve2) => release = resolve2);
4864
5108
  serverTurnTails.set(url, done);
4865
5109
  await prev.catch(() => {
4866
5110
  });
@@ -7099,8 +7343,8 @@ var ConnectorHealthStore = class {
7099
7343
  };
7100
7344
 
7101
7345
  // src/dispatcher.ts
7102
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
7103
- import { join as join14 } from "path";
7346
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7347
+ import { join as join15 } from "path";
7104
7348
 
7105
7349
  // src/turn-execution.ts
7106
7350
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
@@ -7462,11 +7706,11 @@ function trimSlash2(s) {
7462
7706
  // src/codex-instructions.ts
7463
7707
  import { mkdtemp, rm, writeFile } from "fs/promises";
7464
7708
  import { tmpdir } from "os";
7465
- import { join as join10 } from "path";
7709
+ import { join as join11 } from "path";
7466
7710
  var PREFIX = "cabane-codex-instructions-";
7467
7711
  async function writeCodexInstructionsFile(contents) {
7468
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
7469
- const path = join10(dir2, "instructions.md");
7712
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
7713
+ const path = join11(dir2, "instructions.md");
7470
7714
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
7471
7715
  return {
7472
7716
  path,
@@ -7476,6 +7720,42 @@ async function writeCodexInstructionsFile(contents) {
7476
7720
  };
7477
7721
  }
7478
7722
 
7723
+ // src/api-error-shape.ts
7724
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7725
+ "dispatch_not_admitted",
7726
+ "turn_already_ended",
7727
+ "turn_belongs_elsewhere"
7728
+ ]);
7729
+ function apiErrorCode(err) {
7730
+ if (!(err instanceof ApiError)) return null;
7731
+ const body = err.body;
7732
+ return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7733
+ }
7734
+ function leaseRefusal(err) {
7735
+ const code = apiErrorCode(err);
7736
+ if (code && LEASE_REFUSALS.has(code)) return code;
7737
+ return null;
7738
+ }
7739
+ function isWriteFenceRefusal(err) {
7740
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
7741
+ }
7742
+ var ERROR_BODY_LOG_CAP = 2e3;
7743
+ function describeErrorBody(body) {
7744
+ if (body === void 0 || body === null) return void 0;
7745
+ let text;
7746
+ if (typeof body === "string") {
7747
+ text = body;
7748
+ } else {
7749
+ try {
7750
+ text = JSON.stringify(body);
7751
+ } catch {
7752
+ text = String(body);
7753
+ }
7754
+ }
7755
+ if (text.length === 0) return void 0;
7756
+ return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
7757
+ }
7758
+
7479
7759
  // src/turn-seq-floor.ts
7480
7760
  var SeqFloorUnavailable = class extends Error {
7481
7761
  constructor(detail) {
@@ -7511,15 +7791,15 @@ function readOutboxFloor(read, turnId, log) {
7511
7791
 
7512
7792
  // src/prepared.ts
7513
7793
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7514
- import { join as join11 } from "path";
7794
+ import { join as join12 } from "path";
7515
7795
  function dirFor(workspaceId) {
7516
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7796
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7517
7797
  }
7518
7798
  function conversationDir(workspaceId, conversationId) {
7519
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
7799
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
7520
7800
  }
7521
7801
  function pathFor3(workspaceId, conversationId, agentId) {
7522
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7802
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7523
7803
  }
7524
7804
  function readPrepared(workspaceId, conversationId, agentId) {
7525
7805
  const path = pathFor3(workspaceId, conversationId, agentId);
@@ -7551,11 +7831,11 @@ function clearPrepared(workspaceId, conversationId, agentId) {
7551
7831
 
7552
7832
  // src/secrets.ts
7553
7833
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
7554
- import { join as join12 } from "path";
7834
+ import { join as join13 } from "path";
7555
7835
  import { z as z14 } from "zod";
7556
7836
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
7557
7837
  function secretsPath() {
7558
- return join12(cabaneDir(), "secrets.json");
7838
+ return join13(cabaneDir(), "secrets.json");
7559
7839
  }
7560
7840
  var secretStoreSchema = z14.record(z14.string(), z14.string());
7561
7841
  function loadSecretStore() {
@@ -7642,9 +7922,9 @@ function resolveMcpSecrets(mcpServers, store) {
7642
7922
 
7643
7923
  // src/transcript-writer.ts
7644
7924
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
7645
- import { basename, dirname as dirname5, join as join13 } from "path";
7925
+ import { basename, dirname as dirname6, join as join14 } from "path";
7646
7926
  function transcriptsDir() {
7647
- return join13(cabaneDir(), "transcripts");
7927
+ return join14(cabaneDir(), "transcripts");
7648
7928
  }
7649
7929
  var RETAIN = 200;
7650
7930
  var ANOMALY_RETAIN = 50;
@@ -7654,7 +7934,7 @@ var TranscriptWriter = class {
7654
7934
  onWarn;
7655
7935
  constructor(dir2, meta, onWarn) {
7656
7936
  this.onWarn = onWarn;
7657
- this.path = join13(dir2, fileName(meta));
7937
+ this.path = join14(dir2, fileName(meta));
7658
7938
  try {
7659
7939
  mkdirSync9(dir2, { recursive: true });
7660
7940
  try {
@@ -7683,10 +7963,10 @@ var TranscriptWriter = class {
7683
7963
  preserveAnomaly() {
7684
7964
  if (this.broken) return;
7685
7965
  try {
7686
- const dir2 = join13(dirname5(this.path), "anomalies");
7966
+ const dir2 = join14(dirname6(this.path), "anomalies");
7687
7967
  mkdirSync9(dir2, { recursive: true, mode: 448 });
7688
7968
  chmodSync4(dir2, 448);
7689
- const target = join13(dir2, basename(this.path));
7969
+ const target = join14(dir2, basename(this.path));
7690
7970
  copyFileSync(this.path, target);
7691
7971
  chmodSync4(target, 384);
7692
7972
  pruneOld(dir2, ANOMALY_RETAIN);
@@ -7732,7 +8012,7 @@ function pruneOld(dir2, retain) {
7732
8012
  const drop = files.sort().slice(0, files.length - retain);
7733
8013
  for (const f of drop) {
7734
8014
  try {
7735
- rmSync6(join13(dir2, f), { force: true });
8015
+ rmSync6(join14(dir2, f), { force: true });
7736
8016
  } catch {
7737
8017
  }
7738
8018
  }
@@ -7879,6 +8159,84 @@ var TurnCommitter = class {
7879
8159
  }
7880
8160
  };
7881
8161
 
8162
+ // src/turn-runtime-integrity.ts
8163
+ function bundledHarnessFor(runtime) {
8164
+ return runtime === "claude-code" || runtime === "codex" ? runtime : null;
8165
+ }
8166
+ function resolve(ctx, harness) {
8167
+ return (ctx.resolveBundled ?? resolveBundledBinary)(harness);
8168
+ }
8169
+ async function postIncompleteNotice(ctx, harness, resolution) {
8170
+ try {
8171
+ await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
8172
+ body: turnIncompleteCopy(harness),
8173
+ kind: "final",
8174
+ turnId: ctx.turnId,
8175
+ parentMessageId: ctx.parentMessageId
8176
+ });
8177
+ return true;
8178
+ } catch (err) {
8179
+ ctx.log.warn(
8180
+ { err: err instanceof Error ? err.message : String(err) },
8181
+ "dispatcher: runtime-incomplete notice post failed"
8182
+ );
8183
+ return false;
8184
+ }
8185
+ }
8186
+ async function checkBundledBinary(ctx) {
8187
+ const harness = bundledHarnessFor(ctx.runtime);
8188
+ if (!harness) return null;
8189
+ const resolution = resolve(ctx, harness);
8190
+ if (resolution.status === "present") return null;
8191
+ ctx.log.error(
8192
+ {
8193
+ runtime: harness,
8194
+ status: resolution.status,
8195
+ platform: resolution.platform,
8196
+ arch: resolution.arch
8197
+ },
8198
+ "dispatcher: bundled harness binary missing at dispatch"
8199
+ );
8200
+ return {
8201
+ harness,
8202
+ reason: `runtime_incomplete:${harness}`,
8203
+ posted: await postIncompleteNotice(ctx, harness, resolution)
8204
+ };
8205
+ }
8206
+ async function reclassifyThrow(ctx, opts) {
8207
+ const harness = bundledHarnessFor(ctx.runtime);
8208
+ if (!harness || opts.aborted) return null;
8209
+ const resolution = resolve(ctx, harness);
8210
+ if (resolution.status === "present") return null;
8211
+ ctx.log.error(
8212
+ {
8213
+ runtime: harness,
8214
+ status: resolution.status,
8215
+ platform: resolution.platform,
8216
+ arch: resolution.arch,
8217
+ originalReason: opts.originalReason
8218
+ },
8219
+ "dispatcher: adapter threw and the bundled harness binary is missing \u2014 reporting the incomplete runtime"
8220
+ );
8221
+ return {
8222
+ harness,
8223
+ reason: `runtime_incomplete:${harness}`,
8224
+ posted: await postIncompleteNotice(ctx, harness, resolution)
8225
+ };
8226
+ }
8227
+ async function guardBundledBinary(ctx, concluded, outcome) {
8228
+ const incomplete = await checkBundledBinary(ctx);
8229
+ if (!incomplete) return;
8230
+ outcome.runtimeIncomplete = incomplete.posted;
8231
+ throw incomplete.posted ? concluded(incomplete.reason) : concluded(incomplete.reason, incomplete.reason);
8232
+ }
8233
+ async function absorbBundledLoss(ctx, aborted, outcome) {
8234
+ const lost = await reclassifyThrow(ctx, { aborted, originalReason: outcome.resultReason });
8235
+ if (!lost) return false;
8236
+ outcome.resultReason = lost.reason;
8237
+ return lost.posted;
8238
+ }
8239
+
7882
8240
  // src/turn-execution.ts
7883
8241
  var PREPARING_TOOL_NAME = "preparing";
7884
8242
  var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
@@ -7891,40 +8249,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7891
8249
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7892
8250
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7893
8251
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
7894
- var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7895
- "dispatch_not_admitted",
7896
- "turn_already_ended",
7897
- "turn_belongs_elsewhere"
7898
- ]);
7899
- function apiErrorCode(err) {
7900
- if (!(err instanceof ApiError)) return null;
7901
- const body = err.body;
7902
- return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7903
- }
7904
- function leaseRefusal(err) {
7905
- const code = apiErrorCode(err);
7906
- if (code && LEASE_REFUSALS.has(code)) return code;
7907
- return null;
7908
- }
7909
- function isWriteFenceRefusal(err) {
7910
- return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
7911
- }
7912
- var ERROR_BODY_LOG_CAP = 2e3;
7913
- function describeErrorBody(body) {
7914
- if (body === void 0 || body === null) return void 0;
7915
- let text;
7916
- if (typeof body === "string") {
7917
- text = body;
7918
- } else {
7919
- try {
7920
- text = JSON.stringify(body);
7921
- } catch {
7922
- text = String(body);
7923
- }
7924
- }
7925
- if (text.length === 0) return void 0;
7926
- return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
7927
- }
7928
8252
  function initialOutcome() {
7929
8253
  return {
7930
8254
  sessionWritten: false,
@@ -7943,7 +8267,8 @@ function initialOutcome() {
7943
8267
  settledDiagnostics: null,
7944
8268
  silentMarkerEmitted: false,
7945
8269
  timeoutReason: null,
7946
- leaseLost: false
8270
+ leaseLost: false,
8271
+ runtimeIncomplete: false
7947
8272
  };
7948
8273
  }
7949
8274
  var TurnConcluded = class {
@@ -8094,7 +8419,9 @@ var TurnExecution = class {
8094
8419
  activeRunStartedAt: null,
8095
8420
  turnId,
8096
8421
  settledMessageId: payload.messageId,
8097
- outcome: errorReason ? "failed" : "settled"
8422
+ // CT1379: `runtimeIncomplete` is a real failure that deliberately carries
8423
+ // no `errorReason`, so the inference alone would read it as settled.
8424
+ outcome: errorReason || this.outcome.runtimeIncomplete ? "failed" : "settled"
8098
8425
  };
8099
8426
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
8100
8427
  try {
@@ -8434,8 +8761,21 @@ ${reason}`,
8434
8761
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
8435
8762
  });
8436
8763
  }
8764
+ integrityContext() {
8765
+ return {
8766
+ runtime: this.turnContext.runtime,
8767
+ workspaceId: this.workspaceId,
8768
+ conversationId: this.payload.conversationId,
8769
+ turnId: this.turnId,
8770
+ parentMessageId: this.payload.messageId,
8771
+ log: this.turnLog,
8772
+ postTurnMessage: (workspaceId, conversationId, body) => this.opts.api.postTurnMessage(workspaceId, conversationId, body),
8773
+ ...this.opts.resolveBundled ? { resolveBundled: this.opts.resolveBundled } : {}
8774
+ };
8775
+ }
8437
8776
  async selectAdapter() {
8438
8777
  const { payload, workspaceId, turnId, turnLog } = this;
8778
+ await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
8439
8779
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
8440
8780
  const adapters = [];
8441
8781
  if (this.opts.claudeCodeAvailable?.() ?? true) {
@@ -8444,7 +8784,7 @@ ${reason}`,
8444
8784
  if (this.opts.opencodeServerUrl) {
8445
8785
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
8446
8786
  }
8447
- if (this.opts.codexEnabled) {
8787
+ if (this.opts.codexAvailable?.() ?? false) {
8448
8788
  adapters.push(
8449
8789
  createCodexAdapter({
8450
8790
  enabled: true,
@@ -8728,6 +9068,11 @@ ${reason}`,
8728
9068
  o.okResult = false;
8729
9069
  o.resultReason = err instanceof Error ? err.message : String(err);
8730
9070
  turnLog.error({ err: o.resultReason }, "dispatcher: SDK query threw");
9071
+ o.runtimeIncomplete = await absorbBundledLoss(
9072
+ this.integrityContext(),
9073
+ abortController.signal.aborted,
9074
+ o
9075
+ );
8731
9076
  } finally {
8732
9077
  this.disarmWatchdogs();
8733
9078
  if (o.leaseLost) {
@@ -8776,13 +9121,13 @@ ${reason}`,
8776
9121
  if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
8777
9122
  body.resolvedConfig = o.turnResolvedConfig;
8778
9123
  }
8779
- if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !this.skipState.skipped) {
9124
+ if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !o.runtimeIncomplete && !this.skipState.skipped) {
8780
9125
  body.errorReason = o.resultReason.slice(0, 200);
8781
9126
  }
8782
9127
  if (o.okResult) {
8783
9128
  body.lastSeenMessageId = payload.messageId;
8784
9129
  }
8785
- body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
9130
+ body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
8786
9131
  if (o.sessionDegraded) {
8787
9132
  body.degraded = true;
8788
9133
  }
@@ -8907,15 +9252,15 @@ function checkoutState(cwd) {
8907
9252
  if (entries.length === 0) {
8908
9253
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
8909
9254
  }
8910
- const gitPath = join14(cwd, ".git");
9255
+ const gitPath = join15(cwd, ".git");
8911
9256
  if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
8912
9257
  let stat;
8913
9258
  try {
8914
- stat = statSync(gitPath);
9259
+ stat = statSync2(gitPath);
8915
9260
  } catch (error) {
8916
9261
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
8917
9262
  }
8918
- if (stat.isDirectory() && !existsSync11(join14(gitPath, "HEAD")))
9263
+ if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
8919
9264
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
8920
9265
  return { ok: true, reason: "usable" };
8921
9266
  }
@@ -9050,7 +9395,7 @@ import {
9050
9395
  rmSync as rmSync7,
9051
9396
  writeFileSync as writeFileSync7
9052
9397
  } from "fs";
9053
- import { join as join15 } from "path";
9398
+ import { join as join16 } from "path";
9054
9399
  var MAX_ENTRIES = 2e3;
9055
9400
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
9056
9401
  var Outbox = class {
@@ -9063,10 +9408,10 @@ var Outbox = class {
9063
9408
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
9064
9409
  // cases route writes at the right tmpdir.
9065
9410
  dir() {
9066
- return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9411
+ return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9067
9412
  }
9068
9413
  fileFor(turnId, seq) {
9069
- return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9414
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9070
9415
  }
9071
9416
  // Persist a commit for later draining. Atomic (temp file + rename) so a
9072
9417
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -9108,7 +9453,7 @@ var Outbox = class {
9108
9453
  const entries = [];
9109
9454
  for (const name of names) {
9110
9455
  if (!name.endsWith(".json")) continue;
9111
- const full = join15(dir2, name);
9456
+ const full = join16(dir2, name);
9112
9457
  try {
9113
9458
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
9114
9459
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -9360,12 +9705,12 @@ var SseSubscriber = class {
9360
9705
  }
9361
9706
  };
9362
9707
  function sleep2(ms) {
9363
- return new Promise((resolve) => setTimeout(resolve, ms));
9708
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
9364
9709
  }
9365
9710
 
9366
9711
  // src/version.ts
9367
- import { createRequire } from "module";
9368
- var pkg = createRequire(import.meta.url)("../package.json");
9712
+ import { createRequire as createRequire2 } from "module";
9713
+ var pkg = createRequire2(import.meta.url)("../package.json");
9369
9714
  var COMPANION_VERSION = pkg.version;
9370
9715
 
9371
9716
  // src/supervisor.ts
@@ -9387,10 +9732,15 @@ var CompanionSupervisor = class {
9387
9732
  config;
9388
9733
  log;
9389
9734
  hub;
9390
- // CT309: gates the claude-code runtime in the heartbeat manifest the boot
9391
- // probe (exit-0 `claude --version`), used as the fallback until CT586's
9392
- // per-heartbeat re-probe lands fresh presence in `harnessSignals`.
9393
- claudeCode;
9735
+ // CT1379: the bundled-executable resolver the execution-presence half of both
9736
+ // routing gates. Synchronous and cheap (a module resolve plus a stat), so unlike
9737
+ // the CLI probes it needs no boot-frozen fallback: a caller that asks before the
9738
+ // first harness refresh simply resolves now.
9739
+ resolveBundled;
9740
+ // CT1379: the last bundled status LOGGED per harness, so a missing binary is
9741
+ // reported at startup and on each healthy↔missing transition rather than on
9742
+ // every 30s beat (the CT484 latch pattern, per runtime).
9743
+ loggedBundledStatus = /* @__PURE__ */ new Map();
9394
9744
  // CT571: the probed per-harness versions, reported on each heartbeat manifest.
9395
9745
  // CT586: no longer boot-frozen — the per-heartbeat harness re-probe refreshes
9396
9746
  // it, so a harness updated mid-run reports its new version without a restart.
@@ -9434,13 +9784,14 @@ var CompanionSupervisor = class {
9434
9784
  this.config = opts.config;
9435
9785
  this.log = opts.log;
9436
9786
  this.hub = opts.hub;
9437
- this.claudeCode = opts.claudeCode ?? true;
9787
+ this.resolveBundled = opts.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
9438
9788
  this.probePresence = opts.probePresence ?? probeHarnessPresence;
9439
9789
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
9440
9790
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
9441
9791
  this.reexecFn = opts.reexec ?? defaultReexec;
9442
9792
  this.dispatcherFactory = opts.dispatcherFactory;
9443
9793
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
9794
+ this.latchBootBundledState();
9444
9795
  }
9445
9796
  // Stand up the data plane: pair check, initial assignments pull, then the
9446
9797
  // heartbeat + poll loops. A companion with no device token (logged out) does
@@ -9523,19 +9874,25 @@ var CompanionSupervisor = class {
9523
9874
  const res = await this.deviceApi.heartbeat({
9524
9875
  version: COMPANION_VERSION,
9525
9876
  exposedSecretNames: store.names(),
9526
- // Report each runtime only when this device can actually run it: CT309
9527
- // claude-code when `claude` is on PATH, CT270 opencode when the operator
9877
+ // Report each runtime only when this device can actually run it:
9878
+ // CT1379 claude-code and codex when the SDK's own bundled binary is there
9879
+ // and the user asked for the harness, CT270 opencode when the operator
9528
9880
  // configured an `opencode serve`.
9529
9881
  manifest: buildCompanionManifest({
9530
- // CT1082: connected AND installed. Presence is the live re-probe (CT586),
9531
- // falling back to the boot probe until the first one lands; consent is the
9532
- // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
9882
+ // CT1082/CT1379: connected AND launchable. Execution presence is the
9883
+ // live bundled-binary resolution (refreshed on this beat), consent is
9884
+ // the user's `claudeCode.enabled`. Claude Code rides neither alone.
9533
9885
  claudeCode: this.claudeCodeOffered(),
9534
9886
  opencode: !!this.config.opencode?.serverUrl,
9535
- // CT481: advertise codex when the operator enabled it (config-gated,
9536
- // like opencode the CLI's presence is the operator's responsibility;
9537
- // a misconfigured device fails the turn loudly, never silently).
9538
- codex: isCodexEnabled(this.config),
9887
+ // CT481/CT1379: advertise codex when the operator enabled it AND its
9888
+ // vendored binary is there. The enable flag alone used to be enough,
9889
+ // which is how a device with a half-installed 331 MB platform package
9890
+ // advertised a runtime that could not start.
9891
+ codex: this.codexOffered(),
9892
+ // CT1379: and say WHY when an asked-for harness is absent from the list
9893
+ // above. Diagnostic only — the server never routes on it — and always
9894
+ // sent, so an empty array clears a previously reported issue.
9895
+ runtimeIssues: this.runtimeIssues(),
9539
9896
  // CT571/CT586: each runtime's `version` from the latest harness probe
9540
9897
  // (fail-soft to null). Informational only — the server matches on name.
9541
9898
  versions: this.harnessVersions
@@ -9756,18 +10113,103 @@ var CompanionSupervisor = class {
9756
10113
  "companion: stopped agent (unassigned)"
9757
10114
  );
9758
10115
  }
9759
- // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
9760
- // falling back to the boot probe until the first one lands. Presence ONLY —
9761
- // CT1082 split presence from exposure, so nothing routes on this directly.
9762
- claudeCodePresent() {
9763
- return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
9764
- }
9765
- // CT1082: does this device OFFER claude-code — connected by its user and actually
9766
- // installed? The ONE signal both the heartbeat manifest and the dispatcher's
9767
- // adapter registry read, so what the device advertises and what it can select
9768
- // can't disagree (the CT833 invariant, now with consent in front of it).
10116
+ // CT1379: can this machine LAUNCH the harness is the SDK's own bundled
10117
+ // executable there? Live (the per-beat re-resolve), falling back to resolving
10118
+ // right now before the first refresh lands. Execution presence ONLY; consent is
10119
+ // the caller's half.
10120
+ bundledStatus(runtime) {
10121
+ const cached2 = runtime === "codex" ? this.harnessSignals?.codexBundled : this.harnessSignals?.claudeBundled;
10122
+ return cached2 ?? this.resolveBundled(runtime).status;
10123
+ }
10124
+ // The full resolution rather than just its status the transition log needs the
10125
+ // platform/architecture and the repair the copy is built from, and re-resolving
10126
+ // is a module lookup plus a stat.
10127
+ bundledResolution(runtime) {
10128
+ return this.resolveBundled(runtime);
10129
+ }
10130
+ // CT1082/CT1379: does this device OFFER claude-code — connected by its user, and
10131
+ // able to start the binary a turn runs? The ONE signal both the heartbeat
10132
+ // manifest and the dispatcher's adapter registry read, so what the device
10133
+ // advertises and what it can select can't disagree (the CT833 invariant, with
10134
+ // consent in front of it and the real executable underneath it).
9769
10135
  claudeCodeOffered() {
9770
- return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
10136
+ return isClaudeCodeConnected(this.config) && this.bundledStatus("claude-code") === "present";
10137
+ }
10138
+ // CT1379: and the same question for codex, which until now was the enable flag
10139
+ // alone — so an enabled device with no vendored binary advertised a runtime it
10140
+ // could not start. Same shape as Claude Code, same predicate on both sides.
10141
+ codexOffered() {
10142
+ return isCodexEnabled(this.config) && this.bundledStatus("codex") === "present";
10143
+ }
10144
+ // CT1379: the manifest's diagnostic half, derived from the same two facts as the
10145
+ // two predicates above so an issue and an advertisement can never both be made
10146
+ // for one harness.
10147
+ runtimeIssues() {
10148
+ return runtimeIssuesFor({
10149
+ claudeCode: {
10150
+ intended: isClaudeCodeConnected(this.config),
10151
+ bundled: this.bundledStatus("claude-code")
10152
+ },
10153
+ codex: { intended: isCodexEnabled(this.config), bundled: this.bundledStatus("codex") }
10154
+ });
10155
+ }
10156
+ // CT1379: a missing binary is reported ONCE, and again only when the answer
10157
+ // CHANGES — with the runtime, the platform/architecture and the resolution
10158
+ // result, which is enough to find the occurrence in `companion.log` without a
10159
+ // repro and carries nothing that could be a credential or a vendor's output.
10160
+ //
10161
+ // Two things this deliberately does NOT do (Codo's review, blocking finding #2):
10162
+ //
10163
+ // - It does not re-announce the state the device booted in. `warnAboutHarnessReadiness`
10164
+ // already said that on stderr at startup, in the specified words, at the one
10165
+ // moment a person is watching a terminal — and the `--daemon` launcher runs
10166
+ // that preflight in the FOREGROUND for exactly that reason, because the
10167
+ // child's output goes to a file. The boot state is latched in the
10168
+ // constructor, so the first refresh reports nothing new; it lands here as a
10169
+ // debug record instead, present for diagnosis without competing for
10170
+ // attention with the warning the operator already read.
10171
+ // - It does not report a transition in generic prose. A binary that goes
10172
+ // missing at 3am is the case the operator will actually meet, and a line
10173
+ // that says only "binary is missing" leaves them with no repair and no
10174
+ // restart instruction. Transitions carry the specified copy verbatim — the
10175
+ // same sentence the startup path prints.
10176
+ logBundledTransitions() {
10177
+ for (const runtime of ["claude-code", "codex"]) {
10178
+ const intended = runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config);
10179
+ const resolution = this.bundledResolution(runtime);
10180
+ const status = resolution.status;
10181
+ if (this.loggedBundledStatus.get(runtime) === status) continue;
10182
+ this.loggedBundledStatus.set(runtime, status);
10183
+ const meta = { runtime, platform: resolution.platform, arch: resolution.arch, status };
10184
+ if (status === "present") {
10185
+ const say = intended ? this.log.info : this.log.debug;
10186
+ say.call(this.log, meta, "companion: bundled harness binary is present again");
10187
+ continue;
10188
+ }
10189
+ if (intended) this.log.warn(meta, startupWarning(resolution));
10190
+ else
10191
+ this.log.debug(meta, "companion: bundled harness binary is missing (harness not in use)");
10192
+ }
10193
+ }
10194
+ // CT1379: the boot state, latched so the first refresh reports no news, and
10195
+ // recorded once as evidence. Runs in the constructor — the resolve is a module
10196
+ // lookup plus a stat, so it costs nothing and needs no await.
10197
+ latchBootBundledState() {
10198
+ for (const runtime of ["claude-code", "codex"]) {
10199
+ const resolution = this.resolveBundled(runtime);
10200
+ this.loggedBundledStatus.set(runtime, resolution.status);
10201
+ if (resolution.status === "present") continue;
10202
+ this.log.debug(
10203
+ {
10204
+ runtime,
10205
+ platform: resolution.platform,
10206
+ arch: resolution.arch,
10207
+ status: resolution.status,
10208
+ intended: runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config)
10209
+ },
10210
+ "companion: bundled harness binary missing at startup (warned by the readiness check)"
10211
+ );
10212
+ }
9771
10213
  }
9772
10214
  buildDispatcher(ctx) {
9773
10215
  const local = localAgentConfig(this.config, {
@@ -9791,16 +10233,23 @@ var CompanionSupervisor = class {
9791
10233
  // CT833: register the claude-code adapter only when this device actually
9792
10234
  // offers claude-code — read per turn (not captured here), so a harness
9793
10235
  // installed or connected after boot works on the next turn exactly as it
9794
- // appears on the next beat. CT1082: "offers" now means connected as well as
9795
- // installed, so a disconnected harness can't be selected either.
10236
+ // appears on the next beat. CT1082: "offers" means connected as well as
10237
+ // runnable, so a disconnected harness can't be selected either.
10238
+ // CT1379: and the same for the bundled binary — the predicate is shared with
10239
+ // the heartbeat above, so a runtime advertised by one and refused by the
10240
+ // other is not expressible.
9796
10241
  claudeCodeAvailable: () => this.claudeCodeOffered(),
9797
10242
  // CT270: the opencode server URL (operator-configured), when this device
9798
10243
  // offers the opencode runtime. Threaded so an opencode turn selects the
9799
10244
  // opencode adapter; unset leaves the device claude-code-only.
9800
10245
  ...this.config.opencode?.serverUrl ? { opencodeServerUrl: this.config.opencode.serverUrl } : {},
9801
- // CT481: register the codex adapter when this device offers codex; unset
10246
+ // CT481: register the codex adapter when this device offers codex; a miss
9802
10247
  // leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
9803
- ...isCodexEnabled(this.config) ? { codexEnabled: true } : {},
10248
+ // CT1379: read PER TURN, like claude-code above, and off the same predicate
10249
+ // the heartbeat uses — it was captured here at dispatcher-construction time
10250
+ // from the enable flag alone, so a device could register an adapter for a
10251
+ // binary it did not have and keep it registered after the binary came back.
10252
+ codexAvailable: () => this.codexOffered(),
9804
10253
  // CT556: per-turn timeout watchdog windows, from the companion's own env
9805
10254
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
9806
10255
  // dispatcher's baked-in defaults (10 min idle / 6h total).
@@ -10170,9 +10619,13 @@ var CompanionSupervisor = class {
10170
10619
  try {
10171
10620
  const signals = await probeHarnessSignals(this.config, {
10172
10621
  probePresence: this.probePresence,
10173
- presence
10622
+ presence,
10623
+ // CT1379: the bundled executables are resolved on the same cadence as the
10624
+ // CLI probes, through the supervisor's seam so a test pins the host fact.
10625
+ resolveBundled: this.resolveBundled
10174
10626
  });
10175
10627
  this.harnessSignals = signals;
10628
+ this.logBundledTransitions();
10176
10629
  this.harnessVersions = {
10177
10630
  claudeCode: signals.claudeVersion,
10178
10631
  opencode: signals.opencodeVersion,
@@ -10212,8 +10665,15 @@ var CompanionSupervisor = class {
10212
10665
  return this.config;
10213
10666
  }
10214
10667
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
10215
- // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
10216
- // never installs a binary and never drives a login (BYO — Decided).
10668
+ // `~/.cabane/config.json`, no hand-edited JSON. It never installs a binary and
10669
+ // never drives a login (BYO — Decided).
10670
+ //
10671
+ // CT1379: this records INTENT; it does not by itself expose anything. A harness
10672
+ // is advertised only when the user asked for it AND the SDK's own bundled binary
10673
+ // resolves, so a successful enable on a companion whose optional dependency
10674
+ // never installed leaves the device advertising nothing — correctly, and with
10675
+ // the Harnesses surface saying why. Read every "expose" below as "record the
10676
+ // half of the answer the user owns".
10217
10677
  // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
10218
10678
  // on PATH. This is the callable the terminal offer ("We found Claude Code.
10219
10679
  // Connect it? [Y/n]") and the web pairing flow both wire to.
@@ -10222,9 +10682,10 @@ var CompanionSupervisor = class {
10222
10682
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
10223
10683
  // so we never advertise a serve that isn't there. An unreachable URL is a
10224
10684
  // typed failure the UI shows in place, not a silent bad write.
10225
- // On success, persist, re-probe, and beat immediately so Cabane starts routing to
10226
- // the newly-exposed runtime at once. Returns a typed outcome (never throws for a
10227
- // reachable/valid input).
10685
+ // On success, persist, re-probe, and beat immediately, so a runtime that IS
10686
+ // launchable starts taking turns at once rather than on the next tick and one
10687
+ // that isn't reports its issue just as promptly. Returns a typed outcome (never
10688
+ // throws for a reachable/valid input).
10228
10689
  async enableHarness(input) {
10229
10690
  let next;
10230
10691
  let checkedPresence = null;
@@ -10322,8 +10783,8 @@ var CompanionSupervisor = class {
10322
10783
  };
10323
10784
  async function waitForBoundedHeartbeat(heartbeat) {
10324
10785
  let timer = null;
10325
- const timeout = new Promise((resolve) => {
10326
- timer = setTimeout(resolve, SHUTDOWN_HEARTBEAT_WAIT_MS);
10786
+ const timeout = new Promise((resolve2) => {
10787
+ timer = setTimeout(resolve2, SHUTDOWN_HEARTBEAT_WAIT_MS);
10327
10788
  timer.unref?.();
10328
10789
  });
10329
10790
  try {
@@ -10407,9 +10868,9 @@ function handleUncaught(log, err, origin) {
10407
10868
 
10408
10869
  // src/crash-marker.ts
10409
10870
  import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10410
- import { join as join16 } from "path";
10871
+ import { join as join17 } from "path";
10411
10872
  function crashMarkerPath() {
10412
- return join16(cabaneDir(), "last-error.json");
10873
+ return join17(cabaneDir(), "last-error.json");
10413
10874
  }
10414
10875
  function recordCrash(rec) {
10415
10876
  try {
@@ -10458,8 +10919,8 @@ async function createCompanionRuntime(opts = {}) {
10458
10919
  }
10459
10920
  if (cfg.logLevel) log.level = cfg.logLevel;
10460
10921
  const harnessVersions = await probeHarnessVersions({
10461
- // CT1082: versions are probed for the runtimes this device OFFERS, and
10462
- // claude-code is offered only when connected as well as installed.
10922
+ // Connected AND its CLI answered the two things that make a
10923
+ // `claude --version` probe worth spawning. Not the offer predicate.
10463
10924
  claudeCode: claudeCode && isClaudeCodeConnected(cfg),
10464
10925
  opencodeServerUrl: cfg.opencode?.serverUrl,
10465
10926
  codex: isCodexEnabled(cfg)
@@ -10492,7 +10953,6 @@ async function createCompanionRuntime(opts = {}) {
10492
10953
  config: cfg,
10493
10954
  log,
10494
10955
  hub,
10495
- claudeCode,
10496
10956
  harnessVersions
10497
10957
  });
10498
10958
  await supervisor.start();