@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/cli.js CHANGED
@@ -128,7 +128,7 @@ function parsePrepareOutput(stdout) {
128
128
  return { cwd: last };
129
129
  }
130
130
  var runPrepareHook = (hook, input) => {
131
- return new Promise((resolve, reject) => {
131
+ return new Promise((resolve2, reject) => {
132
132
  const timeoutMs = hook.timeoutMs ?? DEFAULT_TIMEOUT_MS;
133
133
  let settled = false;
134
134
  const finish = (fn) => {
@@ -202,11 +202,11 @@ var runPrepareHook = (hook, input) => {
202
202
  return;
203
203
  }
204
204
  if (input.prepared) {
205
- resolve(input.prepared);
205
+ resolve2(input.prepared);
206
206
  return;
207
207
  }
208
208
  try {
209
- resolve(parsePrepareOutput(stdout));
209
+ resolve2(parsePrepareOutput(stdout));
210
210
  } catch (err) {
211
211
  reject(err instanceof PrepareHookError ? err : new PrepareHookError(String(err)));
212
212
  }
@@ -321,9 +321,10 @@ var companionConfigSchema = z2.object({
321
321
  // The change this task exists for: Claude Code used to be the one harness a
322
322
  // device exposed with no configuration — a `claude --version` exit-0 was taken as
323
323
  // consent — and it is now the third config-driven harness, opted into exactly
324
- // like codex. `claude` on PATH is still required (you can't run what isn't
325
- // installed), but presence alone no longer exposes anything: the manifest
326
- // advertises claude-code only when this block says so AND the binary is there.
324
+ // like codex. CT1379: the binary that must be there is the Agent SDK's own
325
+ // bundled executable the file a turn actually launches — not the user's
326
+ // `claude` on PATH, which the SDK never runs. The manifest advertises
327
+ // claude-code only when this block says so AND that binary resolves.
327
328
  //
328
329
  // The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
329
330
  // Absent means the config predates this task — a device whose user was never
@@ -343,9 +344,11 @@ var companionConfigSchema = z2.object({
343
344
  // not a server address. The operator installs Codex + logs in (`codex login`, or
344
345
  // `CODEX_API_KEY` — Cabane never sees the key) and sets `{ "codex": { "enabled":
345
346
  // true } }`. Presence of the block enables it (an explicit `enabled: false`
346
- // keeps the block but turns it off). Enabling makes the device advertise the
347
- // `codex` runtime on its heartbeat manifest AND registers the codex adapter in
348
- // the dispatcher. Absent the device doesn't offer codex, exactly as before.
347
+ // keeps the block but turns it off). Absent the device doesn't offer codex.
348
+ // CT1379: enabling is now half the answer, not all of it — the manifest and the
349
+ // adapter registry additionally require the SDK's vendored `codex` binary to
350
+ // resolve, because the enable flag alone advertised a runtime a device with a
351
+ // half-installed platform package could not start.
349
352
  codex: z2.object({
350
353
  enabled: z2.boolean().optional()
351
354
  }).strict().optional()
@@ -523,18 +526,18 @@ async function startControlServer(handlers) {
523
526
  void serveConnection(socket, handlers);
524
527
  });
525
528
  server.unref();
526
- await new Promise((resolve, reject) => {
529
+ await new Promise((resolve2, reject) => {
527
530
  server.once("error", reject);
528
531
  server.listen(path, () => {
529
532
  server.removeListener("error", reject);
530
- resolve();
533
+ resolve2();
531
534
  });
532
535
  });
533
536
  if (process.platform !== "win32") {
534
537
  try {
535
538
  chmodSync2(path, 384);
536
539
  } catch (err) {
537
- await new Promise((resolve) => server.close(() => resolve()));
540
+ await new Promise((resolve2) => server.close(() => resolve2()));
538
541
  try {
539
542
  rmSync2(path, { force: true });
540
543
  } catch {
@@ -546,10 +549,10 @@ async function startControlServer(handlers) {
546
549
  });
547
550
  return {
548
551
  path,
549
- close: () => new Promise((resolve) => {
552
+ close: () => new Promise((resolve2) => {
550
553
  server.close(() => {
551
554
  if (process.platform !== "win32") rmSync2(path, { force: true });
552
- resolve();
555
+ resolve2();
553
556
  });
554
557
  })
555
558
  };
@@ -599,12 +602,12 @@ function reply(socket, body) {
599
602
  async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
600
603
  const socket = connect(path);
601
604
  try {
602
- await new Promise((resolve, reject) => {
605
+ await new Promise((resolve2, reject) => {
603
606
  const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
604
607
  timer.unref?.();
605
608
  socket.once("connect", () => {
606
609
  clearTimeout(timer);
607
- resolve();
610
+ resolve2();
608
611
  });
609
612
  socket.once("error", (err) => {
610
613
  clearTimeout(timer);
@@ -631,7 +634,7 @@ function isNotListening(err) {
631
634
  return code === "ENOENT" || code === "ECONNREFUSED";
632
635
  }
633
636
  function readLine(socket, timeoutMs) {
634
- return new Promise((resolve) => {
637
+ return new Promise((resolve2) => {
635
638
  let buf = "";
636
639
  let settled = false;
637
640
  const done = (v) => {
@@ -639,7 +642,7 @@ function readLine(socket, timeoutMs) {
639
642
  settled = true;
640
643
  clearTimeout(timer);
641
644
  socket.removeListener("data", onData);
642
- resolve(v);
645
+ resolve2(v);
643
646
  };
644
647
  const timer = setTimeout(() => done(null), timeoutMs);
645
648
  timer.unref?.();
@@ -676,14 +679,14 @@ function parseVersionToken(raw) {
676
679
  return m ? m[0] : null;
677
680
  }
678
681
  function probeCliPresence(command, spawnImpl = spawn2) {
679
- return new Promise((resolve) => {
682
+ return new Promise((resolve2) => {
680
683
  let settled = false;
681
684
  let timer = null;
682
685
  const done = (result) => {
683
686
  if (!settled) {
684
687
  settled = true;
685
688
  if (timer) clearTimeout(timer);
686
- resolve(result);
689
+ resolve2(result);
687
690
  }
688
691
  };
689
692
  let child;
@@ -765,6 +768,203 @@ async function safe(fn) {
765
768
  }
766
769
  }
767
770
 
771
+ // src/bundled-binaries.ts
772
+ import { realpathSync, statSync } from "fs";
773
+ import { createRequire } from "module";
774
+ import { dirname as dirname2, join as join3 } from "path";
775
+ import { fileURLToPath } from "url";
776
+ function nodeResolveSelf(specifier) {
777
+ const here = fileURLToPath(import.meta.url);
778
+ if (typeof import.meta.resolve === "function") {
779
+ try {
780
+ return fileURLToPath(import.meta.resolve(specifier));
781
+ } catch {
782
+ }
783
+ }
784
+ try {
785
+ return createRequire(here).resolve(specifier);
786
+ } catch {
787
+ }
788
+ return walkNodeModules(specifier, here);
789
+ }
790
+ function walkNodeModules(pkg2, fromPath) {
791
+ let dir2 = dirname2(fromPath);
792
+ for (; ; ) {
793
+ const manifest = join3(dir2, "node_modules", pkg2, "package.json");
794
+ if (nodeIsFile(manifest)) {
795
+ try {
796
+ return realpathSync(manifest);
797
+ } catch {
798
+ return manifest;
799
+ }
800
+ }
801
+ const parent = dirname2(dir2);
802
+ if (parent === dir2) return null;
803
+ dir2 = parent;
804
+ }
805
+ }
806
+ function nodeResolve(specifier, fromPath) {
807
+ try {
808
+ return createRequire(fromPath).resolve(specifier);
809
+ } catch {
810
+ return null;
811
+ }
812
+ }
813
+ function nodeIsFile(path) {
814
+ try {
815
+ return statSync(path).isFile();
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
820
+ function detectPreferMusl(platform = process.platform) {
821
+ if (platform !== "linux") return false;
822
+ const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
823
+ const header = report?.header;
824
+ return report != null && header?.glibcVersionRuntime === void 0;
825
+ }
826
+ function defaultResolveEnv() {
827
+ return {
828
+ platform: process.platform,
829
+ arch: process.arch,
830
+ preferMusl: detectPreferMusl(),
831
+ // Both resolutions start from this module, which tsup bundles into
832
+ // `dist/cli.js` and `dist/postinstall.js` — both at the package's `dist/`
833
+ // root — so one environment serves the runtime checks and the install
834
+ // verifier alike.
835
+ resolveSelf: nodeResolveSelf,
836
+ resolve: nodeResolve,
837
+ isFile: nodeIsFile
838
+ };
839
+ }
840
+ var CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
841
+ function claudePlatformPackages(platform, arch, preferMusl) {
842
+ if (platform === "android") return [`${CLAUDE_SDK}-linux-${arch}-android`];
843
+ if (platform === "linux") {
844
+ return preferMusl ? [`${CLAUDE_SDK}-linux-${arch}-musl`, `${CLAUDE_SDK}-linux-${arch}`] : [`${CLAUDE_SDK}-linux-${arch}`, `${CLAUDE_SDK}-linux-${arch}-musl`];
845
+ }
846
+ return [`${CLAUDE_SDK}-${platform}-${arch}`];
847
+ }
848
+ function resolveClaude(env) {
849
+ const candidates = claudePlatformPackages(env.platform, env.arch, env.preferMusl);
850
+ const base = {
851
+ runtime: "claude-code",
852
+ packageName: candidates[0] ?? null,
853
+ platform: env.platform,
854
+ arch: env.arch
855
+ };
856
+ const sdkEntry = env.resolveSelf(CLAUDE_SDK);
857
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
858
+ let sawPackage = null;
859
+ for (const pkg2 of candidates) {
860
+ const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
861
+ if (!manifest) continue;
862
+ sawPackage ??= pkg2;
863
+ const exe = join3(dirname2(manifest), env.platform === "win32" ? "claude.exe" : "claude");
864
+ if (env.isFile(exe)) {
865
+ return { ...base, packageName: pkg2, status: "present", path: exe };
866
+ }
867
+ }
868
+ return sawPackage ? { ...base, packageName: sawPackage, status: "executable_missing", path: null } : { ...base, status: "platform_package_missing", path: null };
869
+ }
870
+ var CODEX_SDK = "@openai/codex-sdk";
871
+ var CODEX_CLI = "@openai/codex";
872
+ function codexTargetTriple(platform, arch) {
873
+ if (platform === "linux" || platform === "android") {
874
+ if (arch === "x64") return "x86_64-unknown-linux-musl";
875
+ if (arch === "arm64") return "aarch64-unknown-linux-musl";
876
+ return null;
877
+ }
878
+ if (platform === "darwin") {
879
+ if (arch === "x64") return "x86_64-apple-darwin";
880
+ if (arch === "arm64") return "aarch64-apple-darwin";
881
+ return null;
882
+ }
883
+ if (platform === "win32") {
884
+ if (arch === "x64") return "x86_64-pc-windows-msvc";
885
+ if (arch === "arm64") return "aarch64-pc-windows-msvc";
886
+ return null;
887
+ }
888
+ return null;
889
+ }
890
+ var CODEX_PACKAGE_BY_TRIPLE = {
891
+ "x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
892
+ "aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
893
+ "x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
894
+ "aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
895
+ "x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
896
+ "aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
897
+ };
898
+ function resolveCodex(env) {
899
+ const triple = codexTargetTriple(env.platform, env.arch);
900
+ const pkg2 = triple ? CODEX_PACKAGE_BY_TRIPLE[triple] : void 0;
901
+ const base = {
902
+ runtime: "codex",
903
+ packageName: pkg2 ?? null,
904
+ platform: env.platform,
905
+ arch: env.arch
906
+ };
907
+ if (!triple || !pkg2) return { ...base, status: "platform_package_missing", path: null };
908
+ const sdkEntry = env.resolveSelf(CODEX_SDK);
909
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
910
+ const cliManifest = env.resolve(`${CODEX_CLI}/package.json`, sdkEntry);
911
+ if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
912
+ const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
913
+ if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
914
+ const packageRoot = join3(dirname2(platformManifest), "vendor", triple);
915
+ const exeName = env.platform === "win32" ? "codex.exe" : "codex";
916
+ const current = join3(packageRoot, "bin", exeName);
917
+ if (env.isFile(current) && env.isFile(join3(packageRoot, "codex-package.json"))) {
918
+ return { ...base, status: "present", path: current };
919
+ }
920
+ const legacy = join3(packageRoot, "codex", exeName);
921
+ if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
922
+ return { ...base, status: "executable_missing", path: null };
923
+ }
924
+ function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
925
+ try {
926
+ return runtime === "codex" ? resolveCodex(env) : resolveClaude(env);
927
+ } catch {
928
+ return {
929
+ runtime,
930
+ status: "platform_package_missing",
931
+ path: null,
932
+ packageName: null,
933
+ platform: env.platform,
934
+ arch: env.arch
935
+ };
936
+ }
937
+ }
938
+ var HARNESS_LABEL = {
939
+ "claude-code": "Claude Code",
940
+ codex: "Codex"
941
+ };
942
+ var HARNESS_INTENT_WORD = {
943
+ "claude-code": "connected",
944
+ codex: "enabled"
945
+ };
946
+ function missingNoun(status2) {
947
+ return status2 === "executable_missing" ? "executable" : "platform package";
948
+ }
949
+ function repairCommands() {
950
+ return ["npm i -g @cabane/companion --include=optional"];
951
+ }
952
+ function repairAction() {
953
+ return `Reinstall with \`${repairCommands()[0]}\`.`;
954
+ }
955
+ function startupWarning(r) {
956
+ const label = HARNESS_LABEL[r.runtime];
957
+ 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.`;
958
+ }
959
+ function harnessIssueNote(runtime, status2) {
960
+ const intent = HARNESS_INTENT_WORD[runtime];
961
+ const capitalized = `${intent[0].toUpperCase()}${intent.slice(1)}`;
962
+ return `${capitalized}, but @cabane/companion's bundled ${HARNESS_LABEL[runtime]} ${missingNoun(status2)} is missing. ${repairAction()} Then restart the companion.`;
963
+ }
964
+ function turnIncompleteCopy(runtime) {
965
+ return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
966
+ }
967
+
768
968
  // src/manifest.ts
769
969
  var DEVICE_MANIFEST = {
770
970
  runtimes: [{ name: "claude-code", version: null }],
@@ -776,7 +976,20 @@ function buildCompanionManifest(opts) {
776
976
  if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
777
977
  if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
778
978
  if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
779
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
979
+ return {
980
+ runtimes,
981
+ capabilities: { ...DEVICE_MANIFEST.capabilities },
982
+ runtimeIssues: opts.runtimeIssues ?? []
983
+ };
984
+ }
985
+ function runtimeIssuesFor(gates) {
986
+ const issues = [];
987
+ for (const name of ["claude-code", "codex"]) {
988
+ const gate = name === "codex" ? gates.codex : gates.claudeCode;
989
+ if (!gate.intended || gate.bundled === "present") continue;
990
+ issues.push({ name, reason: gate.bundled });
991
+ }
992
+ return issues;
780
993
  }
781
994
 
782
995
  // src/harness-status.ts
@@ -798,11 +1011,12 @@ function parseHarnessRuntime(raw) {
798
1011
  function deriveHarnessSnapshot(signals) {
799
1012
  const advertised = new Set(
800
1013
  buildCompanionManifest({
801
- // CT1082: connected AND installed the manifest's own rule, restated here
802
- // through the same function rather than re-decided.
803
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
1014
+ // CT1082 + CT1379: connected AND the bundled binary the SDK launches is
1015
+ // there — the manifest's own rule, restated here through the same function
1016
+ // rather than re-decided. PATH is deliberately absent from both gates now.
1017
+ claudeCode: signals.claudeCodeConnected && signals.claudeBundled === "present",
804
1018
  opencode: signals.opencodeConfigured,
805
- codex: signals.codexEnabled
1019
+ codex: signals.codexEnabled && signals.codexBundled === "present"
806
1020
  }).runtimes.map((r) => r.name)
807
1021
  );
808
1022
  const harnesses = [
@@ -828,7 +1042,7 @@ function deriveClaudeCode(signals, manifestHas) {
828
1042
  ...base,
829
1043
  state: "needs_attention",
830
1044
  version: null,
831
- 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.",
1045
+ detail: harnessIssueNote("claude-code", signals.claudeBundled),
832
1046
  enable: null
833
1047
  };
834
1048
  }
@@ -837,7 +1051,11 @@ function deriveClaudeCode(signals, manifestHas) {
837
1051
  ...base,
838
1052
  state: "detected_not_exposed",
839
1053
  version: signals.claudeVersion,
840
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
1054
+ // CT1379: connecting records INTENT; it is not the same as becoming
1055
+ // runnable. With the bundled binary missing, a connect succeeds and the
1056
+ // device still advertises nothing — so this says so up front rather than
1057
+ // promising routing it can't deliver.
1058
+ 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()}`,
841
1059
  enable: "claude-code"
842
1060
  };
843
1061
  }
@@ -852,20 +1070,20 @@ function deriveClaudeCode(signals, manifestHas) {
852
1070
  function deriveCodex(signals, manifestHas) {
853
1071
  const base = { runtime: "codex", label: LABELS.codex };
854
1072
  if (manifestHas) {
855
- if (signals.codexOnPath) {
856
- return {
857
- ...base,
858
- state: "exposed",
859
- version: signals.codexVersion,
860
- detail: "Codex is enabled and exposed to Cabane.",
861
- enable: null
862
- };
863
- }
1073
+ return {
1074
+ ...base,
1075
+ state: "exposed",
1076
+ version: signals.codexVersion,
1077
+ detail: "Codex is enabled and exposed to Cabane.",
1078
+ enable: null
1079
+ };
1080
+ }
1081
+ if (signals.codexEnabled) {
864
1082
  return {
865
1083
  ...base,
866
1084
  state: "needs_attention",
867
1085
  version: null,
868
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
1086
+ detail: harnessIssueNote("codex", signals.codexBundled),
869
1087
  enable: null
870
1088
  };
871
1089
  }
@@ -874,7 +1092,9 @@ function deriveCodex(signals, manifestHas) {
874
1092
  ...base,
875
1093
  state: "detected_not_exposed",
876
1094
  version: signals.codexVersion,
877
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
1095
+ // CT1379: same distinction as Claude Code enabling is intent, not
1096
+ // launchability.
1097
+ 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()}`,
878
1098
  enable: "codex"
879
1099
  };
880
1100
  }
@@ -941,6 +1161,7 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
941
1161
  async function probeHarnessSignals(cfg, deps = {}) {
942
1162
  const probePresence = deps.probePresence ?? probeHarnessPresence;
943
1163
  const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1164
+ const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
944
1165
  const configuredServerUrl = cfg.opencode?.serverUrl;
945
1166
  const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
946
1167
  const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
@@ -956,6 +1177,11 @@ async function probeHarnessSignals(cfg, deps = {}) {
956
1177
  // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
957
1178
  // the manifest gate and the probe above is only a suggestion.
958
1179
  claudeCodeConnected: isClaudeCodeConnected(cfg),
1180
+ // CT1379: the executable a Claude turn actually launches. Resolved every time
1181
+ // the signals are, so a binary that disappears between beats is caught on the
1182
+ // next one rather than at turn time.
1183
+ claudeBundled: resolveBundled("claude-code").status,
1184
+ codexBundled: resolveBundled("codex").status,
959
1185
  // A parseable `codex --version` is our presence signal (presence alone never
960
1186
  // exposes codex; its config flag is the manifest gate either way).
961
1187
  codexOnPath: codexVersion !== null,
@@ -969,12 +1195,12 @@ async function probeHarnessSignals(cfg, deps = {}) {
969
1195
  };
970
1196
  }
971
1197
  function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
972
- return new Promise((resolve) => {
1198
+ return new Promise((resolve2) => {
973
1199
  let settled = false;
974
1200
  const done = (v) => {
975
1201
  if (!settled) {
976
1202
  settled = true;
977
- resolve(v);
1203
+ resolve2(v);
978
1204
  }
979
1205
  };
980
1206
  const timer = setTimeout(() => done(fallback), timeoutMs);
@@ -1052,19 +1278,19 @@ function looksUnsupported(output) {
1052
1278
  );
1053
1279
  }
1054
1280
  function runBounded(command, args) {
1055
- return new Promise((resolve) => {
1281
+ return new Promise((resolve2) => {
1056
1282
  let settled = false;
1057
1283
  const done = (result) => {
1058
1284
  if (settled) return;
1059
1285
  settled = true;
1060
1286
  clearTimeout(timer);
1061
- resolve(result);
1287
+ resolve2(result);
1062
1288
  };
1063
1289
  let child;
1064
1290
  try {
1065
1291
  child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1066
1292
  } catch {
1067
- resolve({ code: null, output: "", error: "spawn" });
1293
+ resolve2({ code: null, output: "", error: "spawn" });
1068
1294
  return;
1069
1295
  }
1070
1296
  let out = "";
@@ -1093,10 +1319,10 @@ import {
1093
1319
  openSync,
1094
1320
  closeSync
1095
1321
  } from "fs";
1096
- import { join as join3 } from "path";
1322
+ import { join as join4 } from "path";
1097
1323
  var PROBE_TIMEOUT_MS2 = 1e3;
1098
1324
  function runtimePath() {
1099
- return join3(cabaneDir(), "runtime.json");
1325
+ return join4(cabaneDir(), "runtime.json");
1100
1326
  }
1101
1327
  function serialize(state) {
1102
1328
  return JSON.stringify(state, null, 2) + "\n";
@@ -1255,8 +1481,8 @@ var Spinner = class {
1255
1481
  async function confirm(question) {
1256
1482
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1257
1483
  try {
1258
- const answer = await new Promise((resolve) => {
1259
- rl.question(`${INDENT}${question} [Y/n] `, resolve);
1484
+ const answer = await new Promise((resolve2) => {
1485
+ rl.question(`${INDENT}${question} [Y/n] `, resolve2);
1260
1486
  });
1261
1487
  return !/^n(o)?$/i.test(answer.trim());
1262
1488
  } finally {
@@ -1378,10 +1604,10 @@ import {
1378
1604
  rmSync as rmSync4,
1379
1605
  writeFileSync as writeFileSync3
1380
1606
  } from "fs";
1381
- import { dirname as dirname2, join as join4 } from "path";
1607
+ import { dirname as dirname3, join as join5 } from "path";
1382
1608
  import { z as z3 } from "zod";
1383
1609
  function credentialsPath() {
1384
- return join4(cabaneDir(), "credentials.json");
1610
+ return join5(cabaneDir(), "credentials.json");
1385
1611
  }
1386
1612
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
1387
1613
  function load() {
@@ -1403,7 +1629,7 @@ function load() {
1403
1629
  }
1404
1630
  function save(map) {
1405
1631
  const path = credentialsPath();
1406
- mkdirSync4(dirname2(path), { recursive: true });
1632
+ mkdirSync4(dirname3(path), { recursive: true });
1407
1633
  try {
1408
1634
  chmodSync3(cabaneDir(), 448);
1409
1635
  } catch {
@@ -1548,7 +1774,7 @@ function deviceLabelFromHostname(hostname3) {
1548
1774
  function pollOnce(baseUrl, deviceCode) {
1549
1775
  return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
1550
1776
  }
1551
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1777
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1552
1778
  async function runDeviceFlow(baseUrl, print, opts = {}) {
1553
1779
  if (opts.signal?.aborted) throw new EnrollmentCancelledError();
1554
1780
  const code = await requestEnrollmentCode(baseUrl, {
@@ -1674,11 +1900,11 @@ import { hostname as hostname2 } from "os";
1674
1900
 
1675
1901
  // src/logger.ts
1676
1902
  import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
1677
- import { dirname as dirname3, join as join5 } from "path";
1903
+ import { dirname as dirname4, join as join6 } from "path";
1678
1904
  import pino from "pino";
1679
1905
  import pretty from "pino-pretty";
1680
1906
  function companionLogPath() {
1681
- return join5(cabaneDir(), "companion.log");
1907
+ return join6(cabaneDir(), "companion.log");
1682
1908
  }
1683
1909
  var CONSOLE_IGNORE = [
1684
1910
  "pid",
@@ -1706,7 +1932,7 @@ function setConsoleLogging(enabled) {
1706
1932
  }
1707
1933
  function createLogger(destinations = {}) {
1708
1934
  const path = companionLogPath();
1709
- if (!destinations.file) mkdirSync5(dirname3(path), { recursive: true });
1935
+ if (!destinations.file) mkdirSync5(dirname4(path), { recursive: true });
1710
1936
  const streams = [];
1711
1937
  if (process.env.CABANE_COMPANION_DAEMON !== "1") {
1712
1938
  const consoleStream = pretty({
@@ -1740,15 +1966,15 @@ function getLogger() {
1740
1966
  import { randomUUID as randomUUID4 } from "crypto";
1741
1967
 
1742
1968
  // src/dashboard/server.ts
1743
- import { dirname as dirname4, join as join7 } from "path";
1744
- import { fileURLToPath } from "url";
1969
+ import { dirname as dirname5, join as join8 } from "path";
1970
+ import { fileURLToPath as fileURLToPath2 } from "url";
1745
1971
  import { serve } from "@hono/node-server";
1746
1972
  import { Hono } from "hono";
1747
1973
 
1748
1974
  // src/dashboard/routes.ts
1749
1975
  import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync5 } from "fs";
1750
1976
  import { readFile } from "fs/promises";
1751
- import { extname, join as join6, normalize } from "path";
1977
+ import { extname, join as join7, normalize } from "path";
1752
1978
  import { streamSSE } from "hono/streaming";
1753
1979
 
1754
1980
  // src/state.ts
@@ -1997,13 +2223,13 @@ var CONTENT_TYPES = {
1997
2223
  function registerRoutes(app, deps) {
1998
2224
  const { supervisor, hub, staticDir } = deps;
1999
2225
  app.get("/", async (c) => {
2000
- const html = await readFile(join6(staticDir, "index.html"), "utf8");
2226
+ const html = await readFile(join7(staticDir, "index.html"), "utf8");
2001
2227
  return c.html(html);
2002
2228
  });
2003
2229
  app.get("/static/:file", async (c) => {
2004
2230
  const file = c.req.param("file");
2005
2231
  const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
2006
- const full = join6(staticDir, safe3);
2232
+ const full = join7(staticDir, safe3);
2007
2233
  if (!full.startsWith(staticDir) || !existsSync5(full)) return c.notFound();
2008
2234
  const body = await readFile(full);
2009
2235
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
@@ -2103,8 +2329,8 @@ function registerRoutes(app, deps) {
2103
2329
  while (!stream.aborted) {
2104
2330
  if (queue.length === 0) {
2105
2331
  await Promise.race([
2106
- new Promise((resolve) => {
2107
- wake = resolve;
2332
+ new Promise((resolve2) => {
2333
+ wake = resolve2;
2108
2334
  }),
2109
2335
  stream.sleep(25e3)
2110
2336
  ]);
@@ -2188,8 +2414,8 @@ async function startDashboard(opts) {
2188
2414
  return {
2189
2415
  url,
2190
2416
  port,
2191
- close: () => new Promise((resolve) => {
2192
- server.close(() => resolve());
2417
+ close: () => new Promise((resolve2) => {
2418
+ server.close(() => resolve2());
2193
2419
  server.closeAllConnections?.();
2194
2420
  })
2195
2421
  };
@@ -2206,12 +2432,12 @@ async function startDashboard(opts) {
2206
2432
  );
2207
2433
  }
2208
2434
  function listen(app, port) {
2209
- return new Promise((resolve, reject) => {
2435
+ return new Promise((resolve2, reject) => {
2210
2436
  let settled = false;
2211
2437
  const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
2212
2438
  if (!settled) {
2213
2439
  settled = true;
2214
- resolve(server);
2440
+ resolve2(server);
2215
2441
  }
2216
2442
  });
2217
2443
  server.on("error", (err) => {
@@ -2226,7 +2452,7 @@ function isAddrInUse(err) {
2226
2452
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
2227
2453
  }
2228
2454
  function resolveStaticDir() {
2229
- return join7(dirname4(fileURLToPath(import.meta.url)), "static");
2455
+ return join8(dirname5(fileURLToPath2(import.meta.url)), "static");
2230
2456
  }
2231
2457
 
2232
2458
  // src/prereqs.ts
@@ -2251,6 +2477,7 @@ async function requireStartConfig(deps = {}) {
2251
2477
  async function warnAboutHarnessReadiness(cfg, deps = {}) {
2252
2478
  const probeClaude = deps.probeClaude ?? claudeOnPath;
2253
2479
  const probeCodex = deps.probeCodex ?? codexOnPath;
2480
+ const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
2254
2481
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
2255
2482
  `));
2256
2483
  const connected = [
@@ -2259,25 +2486,42 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2259
2486
  ...cfg.opencode ? ["opencode"] : []
2260
2487
  ];
2261
2488
  if (connected.length > 0) {
2262
- if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
2263
- warn(
2264
- "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."
2265
- );
2489
+ for (const runtime of ["claude-code", "codex"]) {
2490
+ const intended = runtime === "codex" ? isCodexEnabled(cfg) : isClaudeCodeConnected(cfg);
2491
+ if (!intended) continue;
2492
+ const resolution = resolveBundled(runtime);
2493
+ if (resolution.status !== "present") warn(startupWarning(resolution));
2266
2494
  }
2267
2495
  return;
2268
2496
  }
2269
2497
  const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
2270
- const installed = [
2271
- ...claudeInstalled ? ["Claude Code"] : [],
2272
- ...codexInstalled ? ["Codex"] : []
2273
- ];
2274
- const connectCommands = [
2275
- ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
2276
- ...codexInstalled ? ["`cabane-companion connect codex`"] : []
2498
+ const found = [
2499
+ ...claudeInstalled ? [
2500
+ {
2501
+ label: "Claude Code",
2502
+ command: "`cabane-companion connect claude-code`",
2503
+ bundled: resolveBundled("claude-code").status
2504
+ }
2505
+ ] : [],
2506
+ ...codexInstalled ? [
2507
+ {
2508
+ label: "Codex",
2509
+ command: "`cabane-companion connect codex`",
2510
+ bundled: resolveBundled("codex").status
2511
+ }
2512
+ ] : []
2277
2513
  ];
2278
- warn(
2279
- "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).")
2280
- );
2514
+ const runnable = found.filter((h) => h.bundled === "present");
2515
+ const incomplete = found.filter((h) => h.bundled !== "present");
2516
+ const incompleteNote = incomplete.map(
2517
+ (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.`
2518
+ ).join(" ");
2519
+ 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 ? (
2520
+ // Their machine is fine; this companion's install is not. Repair that,
2521
+ // then connect — no "install a harness" they already have.
2522
+ `${incompleteNote} ${repairAction()} Then connect it with ${incomplete.map((h) => h.command).join(" or ")}.`
2523
+ ) : "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).";
2524
+ warn(`No harness is connected on this device yet, so no agent turn can run here. ${guidance}`);
2281
2525
  }
2282
2526
 
2283
2527
  // src/supervisor.ts
@@ -2755,15 +2999,15 @@ function isAbortError(err) {
2755
2999
  return err instanceof Error && err.name === "AbortError";
2756
3000
  }
2757
3001
  function sleep2(ms, signal) {
2758
- return new Promise((resolve) => {
2759
- if (signal?.aborted) return resolve();
3002
+ return new Promise((resolve2) => {
3003
+ if (signal?.aborted) return resolve2();
2760
3004
  const timer = setTimeout(() => {
2761
3005
  signal?.removeEventListener("abort", onAbort);
2762
- resolve();
3006
+ resolve2();
2763
3007
  }, ms);
2764
3008
  const onAbort = () => {
2765
3009
  clearTimeout(timer);
2766
- resolve();
3010
+ resolve2();
2767
3011
  };
2768
3012
  signal?.addEventListener("abort", onAbort, { once: true });
2769
3013
  });
@@ -2846,9 +3090,9 @@ function errorMessage2(status2, body) {
2846
3090
 
2847
3091
  // src/cursor.ts
2848
3092
  import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2849
- import { join as join8 } from "path";
3093
+ import { join as join9 } from "path";
2850
3094
  function pathFor(workspaceId) {
2851
- return join8(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
3095
+ return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2852
3096
  }
2853
3097
  function readCursor(workspaceId) {
2854
3098
  const path = pathFor(workspaceId);
@@ -2858,7 +3102,7 @@ function readCursor(workspaceId) {
2858
3102
  }
2859
3103
  function writeCursor(workspaceId, eventId) {
2860
3104
  const path = pathFor(workspaceId);
2861
- mkdirSync6(join8(cabaneDir(), "cursors"), { recursive: true });
3105
+ mkdirSync6(join9(cabaneDir(), "cursors"), { recursive: true });
2862
3106
  writeFileSync4(path, eventId + "\n", "utf8");
2863
3107
  }
2864
3108
 
@@ -2903,13 +3147,13 @@ var CursorTracker = class {
2903
3147
 
2904
3148
  // src/dispatch-dedupe.ts
2905
3149
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2906
- import { join as join9 } from "path";
3150
+ import { join as join10 } from "path";
2907
3151
  var MAX_IDS = 256;
2908
3152
  function dir(log) {
2909
- return join9(cabaneDir(), log);
3153
+ return join10(cabaneDir(), log);
2910
3154
  }
2911
3155
  function pathFor2(log, workspaceId) {
2912
- return join9(dir(log), encodeURIComponent(workspaceId));
3156
+ return join10(dir(log), encodeURIComponent(workspaceId));
2913
3157
  }
2914
3158
  function readIds(log, workspaceId) {
2915
3159
  const path = pathFor2(log, workspaceId);
@@ -2946,10 +3190,10 @@ function markCompleted(workspaceId, eventId) {
2946
3190
  }
2947
3191
  var MAX_RESUME_ATTEMPTS = 3;
2948
3192
  function resumeDir() {
2949
- return join9(cabaneDir(), "resume-attempts");
3193
+ return join10(cabaneDir(), "resume-attempts");
2950
3194
  }
2951
3195
  function resumePathFor(workspaceId) {
2952
- return join9(resumeDir(), encodeURIComponent(workspaceId));
3196
+ return join10(resumeDir(), encodeURIComponent(workspaceId));
2953
3197
  }
2954
3198
  function readResumeCounts(workspaceId) {
2955
3199
  const out = /* @__PURE__ */ new Map();
@@ -2985,10 +3229,10 @@ function bumpResumeAttempt(workspaceId, eventId) {
2985
3229
  return next;
2986
3230
  }
2987
3231
  function turnDir() {
2988
- return join9(cabaneDir(), "turns");
3232
+ return join10(cabaneDir(), "turns");
2989
3233
  }
2990
3234
  function turnPathFor(workspaceId) {
2991
- return join9(turnDir(), encodeURIComponent(workspaceId));
3235
+ return join10(turnDir(), encodeURIComponent(workspaceId));
2992
3236
  }
2993
3237
  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;
2994
3238
  function readTurnIds(workspaceId) {
@@ -5363,7 +5607,7 @@ var serverTurnTails = /* @__PURE__ */ new Map();
5363
5607
  async function acquireServerTurnLock(url) {
5364
5608
  const prev = serverTurnTails.get(url) ?? Promise.resolve();
5365
5609
  let release;
5366
- const done = new Promise((resolve) => release = resolve);
5610
+ const done = new Promise((resolve2) => release = resolve2);
5367
5611
  serverTurnTails.set(url, done);
5368
5612
  await prev.catch(() => {
5369
5613
  });
@@ -7602,8 +7846,8 @@ var ConnectorHealthStore = class {
7602
7846
  };
7603
7847
 
7604
7848
  // src/dispatcher.ts
7605
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
7606
- import { join as join14 } from "path";
7849
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7850
+ import { join as join15 } from "path";
7607
7851
 
7608
7852
  // src/turn-execution.ts
7609
7853
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
@@ -7965,11 +8209,11 @@ function trimSlash2(s) {
7965
8209
  // src/codex-instructions.ts
7966
8210
  import { mkdtemp, rm, writeFile } from "fs/promises";
7967
8211
  import { tmpdir } from "os";
7968
- import { join as join10 } from "path";
8212
+ import { join as join11 } from "path";
7969
8213
  var PREFIX = "cabane-codex-instructions-";
7970
8214
  async function writeCodexInstructionsFile(contents) {
7971
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
7972
- const path = join10(dir2, "instructions.md");
8215
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
8216
+ const path = join11(dir2, "instructions.md");
7973
8217
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
7974
8218
  return {
7975
8219
  path,
@@ -7979,6 +8223,42 @@ async function writeCodexInstructionsFile(contents) {
7979
8223
  };
7980
8224
  }
7981
8225
 
8226
+ // src/api-error-shape.ts
8227
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8228
+ "dispatch_not_admitted",
8229
+ "turn_already_ended",
8230
+ "turn_belongs_elsewhere"
8231
+ ]);
8232
+ function apiErrorCode(err) {
8233
+ if (!(err instanceof ApiError)) return null;
8234
+ const body = err.body;
8235
+ return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8236
+ }
8237
+ function leaseRefusal(err) {
8238
+ const code = apiErrorCode(err);
8239
+ if (code && LEASE_REFUSALS.has(code)) return code;
8240
+ return null;
8241
+ }
8242
+ function isWriteFenceRefusal(err) {
8243
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
8244
+ }
8245
+ var ERROR_BODY_LOG_CAP = 2e3;
8246
+ function describeErrorBody(body) {
8247
+ if (body === void 0 || body === null) return void 0;
8248
+ let text;
8249
+ if (typeof body === "string") {
8250
+ text = body;
8251
+ } else {
8252
+ try {
8253
+ text = JSON.stringify(body);
8254
+ } catch {
8255
+ text = String(body);
8256
+ }
8257
+ }
8258
+ if (text.length === 0) return void 0;
8259
+ return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
8260
+ }
8261
+
7982
8262
  // src/turn-seq-floor.ts
7983
8263
  var SeqFloorUnavailable = class extends Error {
7984
8264
  constructor(detail) {
@@ -8014,15 +8294,15 @@ function readOutboxFloor(read, turnId, log) {
8014
8294
 
8015
8295
  // src/prepared.ts
8016
8296
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
8017
- import { join as join11 } from "path";
8297
+ import { join as join12 } from "path";
8018
8298
  function dirFor(workspaceId) {
8019
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8299
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8020
8300
  }
8021
8301
  function conversationDir(workspaceId, conversationId) {
8022
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
8302
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
8023
8303
  }
8024
8304
  function pathFor3(workspaceId, conversationId, agentId) {
8025
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8305
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8026
8306
  }
8027
8307
  function readPrepared(workspaceId, conversationId, agentId) {
8028
8308
  const path = pathFor3(workspaceId, conversationId, agentId);
@@ -8054,11 +8334,11 @@ function clearPrepared(workspaceId, conversationId, agentId) {
8054
8334
 
8055
8335
  // src/secrets.ts
8056
8336
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
8057
- import { join as join12 } from "path";
8337
+ import { join as join13 } from "path";
8058
8338
  import { z as z14 } from "zod";
8059
8339
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8060
8340
  function secretsPath() {
8061
- return join12(cabaneDir(), "secrets.json");
8341
+ return join13(cabaneDir(), "secrets.json");
8062
8342
  }
8063
8343
  var secretStoreSchema = z14.record(z14.string(), z14.string());
8064
8344
  function loadSecretStore() {
@@ -8145,9 +8425,9 @@ function resolveMcpSecrets(mcpServers, store) {
8145
8425
 
8146
8426
  // src/transcript-writer.ts
8147
8427
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
8148
- import { basename, dirname as dirname5, join as join13 } from "path";
8428
+ import { basename, dirname as dirname6, join as join14 } from "path";
8149
8429
  function transcriptsDir() {
8150
- return join13(cabaneDir(), "transcripts");
8430
+ return join14(cabaneDir(), "transcripts");
8151
8431
  }
8152
8432
  var RETAIN = 200;
8153
8433
  var ANOMALY_RETAIN = 50;
@@ -8157,7 +8437,7 @@ var TranscriptWriter = class {
8157
8437
  onWarn;
8158
8438
  constructor(dir2, meta, onWarn) {
8159
8439
  this.onWarn = onWarn;
8160
- this.path = join13(dir2, fileName(meta));
8440
+ this.path = join14(dir2, fileName(meta));
8161
8441
  try {
8162
8442
  mkdirSync9(dir2, { recursive: true });
8163
8443
  try {
@@ -8186,10 +8466,10 @@ var TranscriptWriter = class {
8186
8466
  preserveAnomaly() {
8187
8467
  if (this.broken) return;
8188
8468
  try {
8189
- const dir2 = join13(dirname5(this.path), "anomalies");
8469
+ const dir2 = join14(dirname6(this.path), "anomalies");
8190
8470
  mkdirSync9(dir2, { recursive: true, mode: 448 });
8191
8471
  chmodSync4(dir2, 448);
8192
- const target = join13(dir2, basename(this.path));
8472
+ const target = join14(dir2, basename(this.path));
8193
8473
  copyFileSync(this.path, target);
8194
8474
  chmodSync4(target, 384);
8195
8475
  pruneOld(dir2, ANOMALY_RETAIN);
@@ -8235,7 +8515,7 @@ function pruneOld(dir2, retain) {
8235
8515
  const drop = files.sort().slice(0, files.length - retain);
8236
8516
  for (const f of drop) {
8237
8517
  try {
8238
- rmSync6(join13(dir2, f), { force: true });
8518
+ rmSync6(join14(dir2, f), { force: true });
8239
8519
  } catch {
8240
8520
  }
8241
8521
  }
@@ -8382,6 +8662,84 @@ var TurnCommitter = class {
8382
8662
  }
8383
8663
  };
8384
8664
 
8665
+ // src/turn-runtime-integrity.ts
8666
+ function bundledHarnessFor(runtime) {
8667
+ return runtime === "claude-code" || runtime === "codex" ? runtime : null;
8668
+ }
8669
+ function resolve(ctx, harness) {
8670
+ return (ctx.resolveBundled ?? resolveBundledBinary)(harness);
8671
+ }
8672
+ async function postIncompleteNotice(ctx, harness, resolution) {
8673
+ try {
8674
+ await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
8675
+ body: turnIncompleteCopy(harness),
8676
+ kind: "final",
8677
+ turnId: ctx.turnId,
8678
+ parentMessageId: ctx.parentMessageId
8679
+ });
8680
+ return true;
8681
+ } catch (err) {
8682
+ ctx.log.warn(
8683
+ { err: err instanceof Error ? err.message : String(err) },
8684
+ "dispatcher: runtime-incomplete notice post failed"
8685
+ );
8686
+ return false;
8687
+ }
8688
+ }
8689
+ async function checkBundledBinary(ctx) {
8690
+ const harness = bundledHarnessFor(ctx.runtime);
8691
+ if (!harness) return null;
8692
+ const resolution = resolve(ctx, harness);
8693
+ if (resolution.status === "present") return null;
8694
+ ctx.log.error(
8695
+ {
8696
+ runtime: harness,
8697
+ status: resolution.status,
8698
+ platform: resolution.platform,
8699
+ arch: resolution.arch
8700
+ },
8701
+ "dispatcher: bundled harness binary missing at dispatch"
8702
+ );
8703
+ return {
8704
+ harness,
8705
+ reason: `runtime_incomplete:${harness}`,
8706
+ posted: await postIncompleteNotice(ctx, harness, resolution)
8707
+ };
8708
+ }
8709
+ async function reclassifyThrow(ctx, opts) {
8710
+ const harness = bundledHarnessFor(ctx.runtime);
8711
+ if (!harness || opts.aborted) return null;
8712
+ const resolution = resolve(ctx, harness);
8713
+ if (resolution.status === "present") return null;
8714
+ ctx.log.error(
8715
+ {
8716
+ runtime: harness,
8717
+ status: resolution.status,
8718
+ platform: resolution.platform,
8719
+ arch: resolution.arch,
8720
+ originalReason: opts.originalReason
8721
+ },
8722
+ "dispatcher: adapter threw and the bundled harness binary is missing \u2014 reporting the incomplete runtime"
8723
+ );
8724
+ return {
8725
+ harness,
8726
+ reason: `runtime_incomplete:${harness}`,
8727
+ posted: await postIncompleteNotice(ctx, harness, resolution)
8728
+ };
8729
+ }
8730
+ async function guardBundledBinary(ctx, concluded, outcome) {
8731
+ const incomplete = await checkBundledBinary(ctx);
8732
+ if (!incomplete) return;
8733
+ outcome.runtimeIncomplete = incomplete.posted;
8734
+ throw incomplete.posted ? concluded(incomplete.reason) : concluded(incomplete.reason, incomplete.reason);
8735
+ }
8736
+ async function absorbBundledLoss(ctx, aborted, outcome) {
8737
+ const lost = await reclassifyThrow(ctx, { aborted, originalReason: outcome.resultReason });
8738
+ if (!lost) return false;
8739
+ outcome.resultReason = lost.reason;
8740
+ return lost.posted;
8741
+ }
8742
+
8385
8743
  // src/turn-execution.ts
8386
8744
  var PREPARING_TOOL_NAME = "preparing";
8387
8745
  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:";
@@ -8394,40 +8752,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8394
8752
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8395
8753
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8396
8754
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8397
- var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8398
- "dispatch_not_admitted",
8399
- "turn_already_ended",
8400
- "turn_belongs_elsewhere"
8401
- ]);
8402
- function apiErrorCode(err) {
8403
- if (!(err instanceof ApiError)) return null;
8404
- const body = err.body;
8405
- return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8406
- }
8407
- function leaseRefusal(err) {
8408
- const code = apiErrorCode(err);
8409
- if (code && LEASE_REFUSALS.has(code)) return code;
8410
- return null;
8411
- }
8412
- function isWriteFenceRefusal(err) {
8413
- return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
8414
- }
8415
- var ERROR_BODY_LOG_CAP = 2e3;
8416
- function describeErrorBody(body) {
8417
- if (body === void 0 || body === null) return void 0;
8418
- let text;
8419
- if (typeof body === "string") {
8420
- text = body;
8421
- } else {
8422
- try {
8423
- text = JSON.stringify(body);
8424
- } catch {
8425
- text = String(body);
8426
- }
8427
- }
8428
- if (text.length === 0) return void 0;
8429
- return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
8430
- }
8431
8755
  function initialOutcome() {
8432
8756
  return {
8433
8757
  sessionWritten: false,
@@ -8446,7 +8770,8 @@ function initialOutcome() {
8446
8770
  settledDiagnostics: null,
8447
8771
  silentMarkerEmitted: false,
8448
8772
  timeoutReason: null,
8449
- leaseLost: false
8773
+ leaseLost: false,
8774
+ runtimeIncomplete: false
8450
8775
  };
8451
8776
  }
8452
8777
  var TurnConcluded = class {
@@ -8597,7 +8922,9 @@ var TurnExecution = class {
8597
8922
  activeRunStartedAt: null,
8598
8923
  turnId,
8599
8924
  settledMessageId: payload.messageId,
8600
- outcome: errorReason ? "failed" : "settled"
8925
+ // CT1379: `runtimeIncomplete` is a real failure that deliberately carries
8926
+ // no `errorReason`, so the inference alone would read it as settled.
8927
+ outcome: errorReason || this.outcome.runtimeIncomplete ? "failed" : "settled"
8601
8928
  };
8602
8929
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
8603
8930
  try {
@@ -8937,8 +9264,21 @@ ${reason}`,
8937
9264
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
8938
9265
  });
8939
9266
  }
9267
+ integrityContext() {
9268
+ return {
9269
+ runtime: this.turnContext.runtime,
9270
+ workspaceId: this.workspaceId,
9271
+ conversationId: this.payload.conversationId,
9272
+ turnId: this.turnId,
9273
+ parentMessageId: this.payload.messageId,
9274
+ log: this.turnLog,
9275
+ postTurnMessage: (workspaceId, conversationId, body) => this.opts.api.postTurnMessage(workspaceId, conversationId, body),
9276
+ ...this.opts.resolveBundled ? { resolveBundled: this.opts.resolveBundled } : {}
9277
+ };
9278
+ }
8940
9279
  async selectAdapter() {
8941
9280
  const { payload, workspaceId, turnId, turnLog } = this;
9281
+ await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
8942
9282
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
8943
9283
  const adapters = [];
8944
9284
  if (this.opts.claudeCodeAvailable?.() ?? true) {
@@ -8947,7 +9287,7 @@ ${reason}`,
8947
9287
  if (this.opts.opencodeServerUrl) {
8948
9288
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
8949
9289
  }
8950
- if (this.opts.codexEnabled) {
9290
+ if (this.opts.codexAvailable?.() ?? false) {
8951
9291
  adapters.push(
8952
9292
  createCodexAdapter({
8953
9293
  enabled: true,
@@ -9231,6 +9571,11 @@ ${reason}`,
9231
9571
  o.okResult = false;
9232
9572
  o.resultReason = err instanceof Error ? err.message : String(err);
9233
9573
  turnLog.error({ err: o.resultReason }, "dispatcher: SDK query threw");
9574
+ o.runtimeIncomplete = await absorbBundledLoss(
9575
+ this.integrityContext(),
9576
+ abortController.signal.aborted,
9577
+ o
9578
+ );
9234
9579
  } finally {
9235
9580
  this.disarmWatchdogs();
9236
9581
  if (o.leaseLost) {
@@ -9279,13 +9624,13 @@ ${reason}`,
9279
9624
  if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
9280
9625
  body.resolvedConfig = o.turnResolvedConfig;
9281
9626
  }
9282
- if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !this.skipState.skipped) {
9627
+ if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !o.runtimeIncomplete && !this.skipState.skipped) {
9283
9628
  body.errorReason = o.resultReason.slice(0, 200);
9284
9629
  }
9285
9630
  if (o.okResult) {
9286
9631
  body.lastSeenMessageId = payload.messageId;
9287
9632
  }
9288
- body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
9633
+ body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9289
9634
  if (o.sessionDegraded) {
9290
9635
  body.degraded = true;
9291
9636
  }
@@ -9410,15 +9755,15 @@ function checkoutState(cwd) {
9410
9755
  if (entries.length === 0) {
9411
9756
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
9412
9757
  }
9413
- const gitPath = join14(cwd, ".git");
9758
+ const gitPath = join15(cwd, ".git");
9414
9759
  if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
9415
9760
  let stat;
9416
9761
  try {
9417
- stat = statSync(gitPath);
9762
+ stat = statSync2(gitPath);
9418
9763
  } catch (error) {
9419
9764
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
9420
9765
  }
9421
- if (stat.isDirectory() && !existsSync11(join14(gitPath, "HEAD")))
9766
+ if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
9422
9767
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
9423
9768
  return { ok: true, reason: "usable" };
9424
9769
  }
@@ -9553,7 +9898,7 @@ import {
9553
9898
  rmSync as rmSync7,
9554
9899
  writeFileSync as writeFileSync7
9555
9900
  } from "fs";
9556
- import { join as join15 } from "path";
9901
+ import { join as join16 } from "path";
9557
9902
  var MAX_ENTRIES = 2e3;
9558
9903
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
9559
9904
  var Outbox = class {
@@ -9566,10 +9911,10 @@ var Outbox = class {
9566
9911
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
9567
9912
  // cases route writes at the right tmpdir.
9568
9913
  dir() {
9569
- return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9914
+ return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9570
9915
  }
9571
9916
  fileFor(turnId, seq) {
9572
- return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9917
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9573
9918
  }
9574
9919
  // Persist a commit for later draining. Atomic (temp file + rename) so a
9575
9920
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -9611,7 +9956,7 @@ var Outbox = class {
9611
9956
  const entries = [];
9612
9957
  for (const name of names) {
9613
9958
  if (!name.endsWith(".json")) continue;
9614
- const full = join15(dir2, name);
9959
+ const full = join16(dir2, name);
9615
9960
  try {
9616
9961
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
9617
9962
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -9863,12 +10208,12 @@ var SseSubscriber = class {
9863
10208
  }
9864
10209
  };
9865
10210
  function sleep3(ms) {
9866
- return new Promise((resolve) => setTimeout(resolve, ms));
10211
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
9867
10212
  }
9868
10213
 
9869
10214
  // src/version.ts
9870
- import { createRequire } from "module";
9871
- var pkg = createRequire(import.meta.url)("../package.json");
10215
+ import { createRequire as createRequire2 } from "module";
10216
+ var pkg = createRequire2(import.meta.url)("../package.json");
9872
10217
  var COMPANION_VERSION = pkg.version;
9873
10218
 
9874
10219
  // src/supervisor.ts
@@ -9890,10 +10235,15 @@ var CompanionSupervisor = class {
9890
10235
  config;
9891
10236
  log;
9892
10237
  hub;
9893
- // CT309: gates the claude-code runtime in the heartbeat manifest the boot
9894
- // probe (exit-0 `claude --version`), used as the fallback until CT586's
9895
- // per-heartbeat re-probe lands fresh presence in `harnessSignals`.
9896
- claudeCode;
10238
+ // CT1379: the bundled-executable resolver the execution-presence half of both
10239
+ // routing gates. Synchronous and cheap (a module resolve plus a stat), so unlike
10240
+ // the CLI probes it needs no boot-frozen fallback: a caller that asks before the
10241
+ // first harness refresh simply resolves now.
10242
+ resolveBundled;
10243
+ // CT1379: the last bundled status LOGGED per harness, so a missing binary is
10244
+ // reported at startup and on each healthy↔missing transition rather than on
10245
+ // every 30s beat (the CT484 latch pattern, per runtime).
10246
+ loggedBundledStatus = /* @__PURE__ */ new Map();
9897
10247
  // CT571: the probed per-harness versions, reported on each heartbeat manifest.
9898
10248
  // CT586: no longer boot-frozen — the per-heartbeat harness re-probe refreshes
9899
10249
  // it, so a harness updated mid-run reports its new version without a restart.
@@ -9937,13 +10287,14 @@ var CompanionSupervisor = class {
9937
10287
  this.config = opts.config;
9938
10288
  this.log = opts.log;
9939
10289
  this.hub = opts.hub;
9940
- this.claudeCode = opts.claudeCode ?? true;
10290
+ this.resolveBundled = opts.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
9941
10291
  this.probePresence = opts.probePresence ?? probeHarnessPresence;
9942
10292
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
9943
10293
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
9944
10294
  this.reexecFn = opts.reexec ?? defaultReexec;
9945
10295
  this.dispatcherFactory = opts.dispatcherFactory;
9946
10296
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
10297
+ this.latchBootBundledState();
9947
10298
  }
9948
10299
  // Stand up the data plane: pair check, initial assignments pull, then the
9949
10300
  // heartbeat + poll loops. A companion with no device token (logged out) does
@@ -10026,19 +10377,25 @@ var CompanionSupervisor = class {
10026
10377
  const res = await this.deviceApi.heartbeat({
10027
10378
  version: COMPANION_VERSION,
10028
10379
  exposedSecretNames: store.names(),
10029
- // Report each runtime only when this device can actually run it: CT309
10030
- // claude-code when `claude` is on PATH, CT270 opencode when the operator
10380
+ // Report each runtime only when this device can actually run it:
10381
+ // CT1379 claude-code and codex when the SDK's own bundled binary is there
10382
+ // and the user asked for the harness, CT270 opencode when the operator
10031
10383
  // configured an `opencode serve`.
10032
10384
  manifest: buildCompanionManifest({
10033
- // CT1082: connected AND installed. Presence is the live re-probe (CT586),
10034
- // falling back to the boot probe until the first one lands; consent is the
10035
- // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
10385
+ // CT1082/CT1379: connected AND launchable. Execution presence is the
10386
+ // live bundled-binary resolution (refreshed on this beat), consent is
10387
+ // the user's `claudeCode.enabled`. Claude Code rides neither alone.
10036
10388
  claudeCode: this.claudeCodeOffered(),
10037
10389
  opencode: !!this.config.opencode?.serverUrl,
10038
- // CT481: advertise codex when the operator enabled it (config-gated,
10039
- // like opencode the CLI's presence is the operator's responsibility;
10040
- // a misconfigured device fails the turn loudly, never silently).
10041
- codex: isCodexEnabled(this.config),
10390
+ // CT481/CT1379: advertise codex when the operator enabled it AND its
10391
+ // vendored binary is there. The enable flag alone used to be enough,
10392
+ // which is how a device with a half-installed 331 MB platform package
10393
+ // advertised a runtime that could not start.
10394
+ codex: this.codexOffered(),
10395
+ // CT1379: and say WHY when an asked-for harness is absent from the list
10396
+ // above. Diagnostic only — the server never routes on it — and always
10397
+ // sent, so an empty array clears a previously reported issue.
10398
+ runtimeIssues: this.runtimeIssues(),
10042
10399
  // CT571/CT586: each runtime's `version` from the latest harness probe
10043
10400
  // (fail-soft to null). Informational only — the server matches on name.
10044
10401
  versions: this.harnessVersions
@@ -10259,18 +10616,103 @@ var CompanionSupervisor = class {
10259
10616
  "companion: stopped agent (unassigned)"
10260
10617
  );
10261
10618
  }
10262
- // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
10263
- // falling back to the boot probe until the first one lands. Presence ONLY —
10264
- // CT1082 split presence from exposure, so nothing routes on this directly.
10265
- claudeCodePresent() {
10266
- return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
10267
- }
10268
- // CT1082: does this device OFFER claude-code — connected by its user and actually
10269
- // installed? The ONE signal both the heartbeat manifest and the dispatcher's
10270
- // adapter registry read, so what the device advertises and what it can select
10271
- // can't disagree (the CT833 invariant, now with consent in front of it).
10619
+ // CT1379: can this machine LAUNCH the harness is the SDK's own bundled
10620
+ // executable there? Live (the per-beat re-resolve), falling back to resolving
10621
+ // right now before the first refresh lands. Execution presence ONLY; consent is
10622
+ // the caller's half.
10623
+ bundledStatus(runtime) {
10624
+ const cached2 = runtime === "codex" ? this.harnessSignals?.codexBundled : this.harnessSignals?.claudeBundled;
10625
+ return cached2 ?? this.resolveBundled(runtime).status;
10626
+ }
10627
+ // The full resolution rather than just its status the transition log needs the
10628
+ // platform/architecture and the repair the copy is built from, and re-resolving
10629
+ // is a module lookup plus a stat.
10630
+ bundledResolution(runtime) {
10631
+ return this.resolveBundled(runtime);
10632
+ }
10633
+ // CT1082/CT1379: does this device OFFER claude-code — connected by its user, and
10634
+ // able to start the binary a turn runs? The ONE signal both the heartbeat
10635
+ // manifest and the dispatcher's adapter registry read, so what the device
10636
+ // advertises and what it can select can't disagree (the CT833 invariant, with
10637
+ // consent in front of it and the real executable underneath it).
10272
10638
  claudeCodeOffered() {
10273
- return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
10639
+ return isClaudeCodeConnected(this.config) && this.bundledStatus("claude-code") === "present";
10640
+ }
10641
+ // CT1379: and the same question for codex, which until now was the enable flag
10642
+ // alone — so an enabled device with no vendored binary advertised a runtime it
10643
+ // could not start. Same shape as Claude Code, same predicate on both sides.
10644
+ codexOffered() {
10645
+ return isCodexEnabled(this.config) && this.bundledStatus("codex") === "present";
10646
+ }
10647
+ // CT1379: the manifest's diagnostic half, derived from the same two facts as the
10648
+ // two predicates above so an issue and an advertisement can never both be made
10649
+ // for one harness.
10650
+ runtimeIssues() {
10651
+ return runtimeIssuesFor({
10652
+ claudeCode: {
10653
+ intended: isClaudeCodeConnected(this.config),
10654
+ bundled: this.bundledStatus("claude-code")
10655
+ },
10656
+ codex: { intended: isCodexEnabled(this.config), bundled: this.bundledStatus("codex") }
10657
+ });
10658
+ }
10659
+ // CT1379: a missing binary is reported ONCE, and again only when the answer
10660
+ // CHANGES — with the runtime, the platform/architecture and the resolution
10661
+ // result, which is enough to find the occurrence in `companion.log` without a
10662
+ // repro and carries nothing that could be a credential or a vendor's output.
10663
+ //
10664
+ // Two things this deliberately does NOT do (Codo's review, blocking finding #2):
10665
+ //
10666
+ // - It does not re-announce the state the device booted in. `warnAboutHarnessReadiness`
10667
+ // already said that on stderr at startup, in the specified words, at the one
10668
+ // moment a person is watching a terminal — and the `--daemon` launcher runs
10669
+ // that preflight in the FOREGROUND for exactly that reason, because the
10670
+ // child's output goes to a file. The boot state is latched in the
10671
+ // constructor, so the first refresh reports nothing new; it lands here as a
10672
+ // debug record instead, present for diagnosis without competing for
10673
+ // attention with the warning the operator already read.
10674
+ // - It does not report a transition in generic prose. A binary that goes
10675
+ // missing at 3am is the case the operator will actually meet, and a line
10676
+ // that says only "binary is missing" leaves them with no repair and no
10677
+ // restart instruction. Transitions carry the specified copy verbatim — the
10678
+ // same sentence the startup path prints.
10679
+ logBundledTransitions() {
10680
+ for (const runtime of ["claude-code", "codex"]) {
10681
+ const intended = runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config);
10682
+ const resolution = this.bundledResolution(runtime);
10683
+ const status2 = resolution.status;
10684
+ if (this.loggedBundledStatus.get(runtime) === status2) continue;
10685
+ this.loggedBundledStatus.set(runtime, status2);
10686
+ const meta = { runtime, platform: resolution.platform, arch: resolution.arch, status: status2 };
10687
+ if (status2 === "present") {
10688
+ const say = intended ? this.log.info : this.log.debug;
10689
+ say.call(this.log, meta, "companion: bundled harness binary is present again");
10690
+ continue;
10691
+ }
10692
+ if (intended) this.log.warn(meta, startupWarning(resolution));
10693
+ else
10694
+ this.log.debug(meta, "companion: bundled harness binary is missing (harness not in use)");
10695
+ }
10696
+ }
10697
+ // CT1379: the boot state, latched so the first refresh reports no news, and
10698
+ // recorded once as evidence. Runs in the constructor — the resolve is a module
10699
+ // lookup plus a stat, so it costs nothing and needs no await.
10700
+ latchBootBundledState() {
10701
+ for (const runtime of ["claude-code", "codex"]) {
10702
+ const resolution = this.resolveBundled(runtime);
10703
+ this.loggedBundledStatus.set(runtime, resolution.status);
10704
+ if (resolution.status === "present") continue;
10705
+ this.log.debug(
10706
+ {
10707
+ runtime,
10708
+ platform: resolution.platform,
10709
+ arch: resolution.arch,
10710
+ status: resolution.status,
10711
+ intended: runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config)
10712
+ },
10713
+ "companion: bundled harness binary missing at startup (warned by the readiness check)"
10714
+ );
10715
+ }
10274
10716
  }
10275
10717
  buildDispatcher(ctx) {
10276
10718
  const local = localAgentConfig(this.config, {
@@ -10294,16 +10736,23 @@ var CompanionSupervisor = class {
10294
10736
  // CT833: register the claude-code adapter only when this device actually
10295
10737
  // offers claude-code — read per turn (not captured here), so a harness
10296
10738
  // installed or connected after boot works on the next turn exactly as it
10297
- // appears on the next beat. CT1082: "offers" now means connected as well as
10298
- // installed, so a disconnected harness can't be selected either.
10739
+ // appears on the next beat. CT1082: "offers" means connected as well as
10740
+ // runnable, so a disconnected harness can't be selected either.
10741
+ // CT1379: and the same for the bundled binary — the predicate is shared with
10742
+ // the heartbeat above, so a runtime advertised by one and refused by the
10743
+ // other is not expressible.
10299
10744
  claudeCodeAvailable: () => this.claudeCodeOffered(),
10300
10745
  // CT270: the opencode server URL (operator-configured), when this device
10301
10746
  // offers the opencode runtime. Threaded so an opencode turn selects the
10302
10747
  // opencode adapter; unset leaves the device claude-code-only.
10303
10748
  ...this.config.opencode?.serverUrl ? { opencodeServerUrl: this.config.opencode.serverUrl } : {},
10304
- // CT481: register the codex adapter when this device offers codex; unset
10749
+ // CT481: register the codex adapter when this device offers codex; a miss
10305
10750
  // leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
10306
- ...isCodexEnabled(this.config) ? { codexEnabled: true } : {},
10751
+ // CT1379: read PER TURN, like claude-code above, and off the same predicate
10752
+ // the heartbeat uses — it was captured here at dispatcher-construction time
10753
+ // from the enable flag alone, so a device could register an adapter for a
10754
+ // binary it did not have and keep it registered after the binary came back.
10755
+ codexAvailable: () => this.codexOffered(),
10307
10756
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10308
10757
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10309
10758
  // dispatcher's baked-in defaults (10 min idle / 6h total).
@@ -10673,9 +11122,13 @@ var CompanionSupervisor = class {
10673
11122
  try {
10674
11123
  const signals = await probeHarnessSignals(this.config, {
10675
11124
  probePresence: this.probePresence,
10676
- presence
11125
+ presence,
11126
+ // CT1379: the bundled executables are resolved on the same cadence as the
11127
+ // CLI probes, through the supervisor's seam so a test pins the host fact.
11128
+ resolveBundled: this.resolveBundled
10677
11129
  });
10678
11130
  this.harnessSignals = signals;
11131
+ this.logBundledTransitions();
10679
11132
  this.harnessVersions = {
10680
11133
  claudeCode: signals.claudeVersion,
10681
11134
  opencode: signals.opencodeVersion,
@@ -10715,8 +11168,15 @@ var CompanionSupervisor = class {
10715
11168
  return this.config;
10716
11169
  }
10717
11170
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
10718
- // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
10719
- // never installs a binary and never drives a login (BYO — Decided).
11171
+ // `~/.cabane/config.json`, no hand-edited JSON. It never installs a binary and
11172
+ // never drives a login (BYO — Decided).
11173
+ //
11174
+ // CT1379: this records INTENT; it does not by itself expose anything. A harness
11175
+ // is advertised only when the user asked for it AND the SDK's own bundled binary
11176
+ // resolves, so a successful enable on a companion whose optional dependency
11177
+ // never installed leaves the device advertising nothing — correctly, and with
11178
+ // the Harnesses surface saying why. Read every "expose" below as "record the
11179
+ // half of the answer the user owns".
10720
11180
  // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
10721
11181
  // on PATH. This is the callable the terminal offer ("We found Claude Code.
10722
11182
  // Connect it? [Y/n]") and the web pairing flow both wire to.
@@ -10725,9 +11185,10 @@ var CompanionSupervisor = class {
10725
11185
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
10726
11186
  // so we never advertise a serve that isn't there. An unreachable URL is a
10727
11187
  // typed failure the UI shows in place, not a silent bad write.
10728
- // On success, persist, re-probe, and beat immediately so Cabane starts routing to
10729
- // the newly-exposed runtime at once. Returns a typed outcome (never throws for a
10730
- // reachable/valid input).
11188
+ // On success, persist, re-probe, and beat immediately, so a runtime that IS
11189
+ // launchable starts taking turns at once rather than on the next tick and one
11190
+ // that isn't reports its issue just as promptly. Returns a typed outcome (never
11191
+ // throws for a reachable/valid input).
10731
11192
  async enableHarness(input) {
10732
11193
  let next;
10733
11194
  let checkedPresence = null;
@@ -10825,8 +11286,8 @@ var CompanionSupervisor = class {
10825
11286
  };
10826
11287
  async function waitForBoundedHeartbeat(heartbeat) {
10827
11288
  let timer = null;
10828
- const timeout = new Promise((resolve) => {
10829
- timer = setTimeout(resolve, SHUTDOWN_HEARTBEAT_WAIT_MS);
11289
+ const timeout = new Promise((resolve2) => {
11290
+ timer = setTimeout(resolve2, SHUTDOWN_HEARTBEAT_WAIT_MS);
10830
11291
  timer.unref?.();
10831
11292
  });
10832
11293
  try {
@@ -10910,9 +11371,9 @@ function handleUncaught(log, err, origin) {
10910
11371
 
10911
11372
  // src/crash-marker.ts
10912
11373
  import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10913
- import { join as join16 } from "path";
11374
+ import { join as join17 } from "path";
10914
11375
  function crashMarkerPath() {
10915
- return join16(cabaneDir(), "last-error.json");
11376
+ return join17(cabaneDir(), "last-error.json");
10916
11377
  }
10917
11378
  function recordCrash(rec2) {
10918
11379
  try {
@@ -10961,8 +11422,8 @@ async function createCompanionRuntime(opts = {}) {
10961
11422
  }
10962
11423
  if (cfg.logLevel) log.level = cfg.logLevel;
10963
11424
  const harnessVersions = await probeHarnessVersions({
10964
- // CT1082: versions are probed for the runtimes this device OFFERS, and
10965
- // claude-code is offered only when connected as well as installed.
11425
+ // Connected AND its CLI answered the two things that make a
11426
+ // `claude --version` probe worth spawning. Not the offer predicate.
10966
11427
  claudeCode: claudeCode && isClaudeCodeConnected(cfg),
10967
11428
  opencodeServerUrl: cfg.opencode?.serverUrl,
10968
11429
  codex: isCodexEnabled(cfg)
@@ -10995,7 +11456,6 @@ async function createCompanionRuntime(opts = {}) {
10995
11456
  config: cfg,
10996
11457
  log,
10997
11458
  hub,
10998
- claudeCode,
10999
11459
  harnessVersions
11000
11460
  });
11001
11461
  await supervisor.start();
@@ -11100,11 +11560,11 @@ import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync
11100
11560
 
11101
11561
  // src/cli-entry.ts
11102
11562
  import { existsSync as existsSync14 } from "fs";
11103
- import { fileURLToPath as fileURLToPath2 } from "url";
11563
+ import { fileURLToPath as fileURLToPath3 } from "url";
11104
11564
  var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
11105
11565
  function companionCliEntry(deps = {}) {
11106
11566
  const exists = deps.exists ?? existsSync14;
11107
- const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath2(new URL(rel, import.meta.url)));
11567
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath3(new URL(rel, import.meta.url)));
11108
11568
  for (const candidate of candidates) {
11109
11569
  if (exists(candidate)) return candidate;
11110
11570
  }
@@ -11451,7 +11911,7 @@ async function handOffToBackground(runtime, ctx) {
11451
11911
  }
11452
11912
  }
11453
11913
  async function runAttached(runtime) {
11454
- await new Promise((resolve) => {
11914
+ await new Promise((resolve2) => {
11455
11915
  let shuttingDown = false;
11456
11916
  const shutdown = async (signal) => {
11457
11917
  if (shuttingDown) {
@@ -11472,7 +11932,7 @@ companion: received ${signal}, shutting down\u2026
11472
11932
  forceExit.unref?.();
11473
11933
  await runtime.stop();
11474
11934
  clearTimeout(forceExit);
11475
- resolve();
11935
+ resolve2();
11476
11936
  process.exit(0);
11477
11937
  };
11478
11938
  process.on("SIGINT", () => void shutdown("SIGINT"));
@@ -11620,7 +12080,7 @@ function isAlive(kill, pid) {
11620
12080
 
11621
12081
  // src/commands/transcript.ts
11622
12082
  import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
11623
- import { isAbsolute, join as join17 } from "path";
12083
+ import { isAbsolute, join as join18 } from "path";
11624
12084
  async function transcript(opts = {}) {
11625
12085
  const dir2 = transcriptsDir();
11626
12086
  if (opts.follow) {
@@ -11637,7 +12097,7 @@ async function transcript(opts = {}) {
11637
12097
  process.stdout.write(emptyMessage(dir2));
11638
12098
  return;
11639
12099
  }
11640
- process.stdout.write(renderFile(join17(dir2, newest)) + "\n");
12100
+ process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
11641
12101
  return;
11642
12102
  }
11643
12103
  printList(dir2);
@@ -11720,7 +12180,7 @@ function isComplete(content) {
11720
12180
  async function followTranscripts(dir2) {
11721
12181
  const follower = new TranscriptFollower({
11722
12182
  listFiles: () => listFiles(dir2),
11723
- read: (f) => readFileSync10(join17(dir2, f), "utf8"),
12183
+ read: (f) => readFileSync10(join18(dir2, f), "utf8"),
11724
12184
  write: (s) => process.stdout.write(s),
11725
12185
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
11726
12186
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -11729,14 +12189,14 @@ async function followTranscripts(dir2) {
11729
12189
  process.stdout.write(`following ${dir2} \u2014 Ctrl-C to stop.
11730
12190
 
11731
12191
  `);
11732
- await new Promise((resolve) => {
12192
+ await new Promise((resolve2) => {
11733
12193
  const timer = setInterval(() => follower.tick(), FOLLOW_POLL_MS);
11734
12194
  const stop2 = () => {
11735
12195
  clearInterval(timer);
11736
12196
  process.removeListener("SIGINT", stop2);
11737
12197
  process.removeListener("SIGTERM", stop2);
11738
12198
  process.stdout.write("\n");
11739
- resolve();
12199
+ resolve2();
11740
12200
  };
11741
12201
  process.once("SIGINT", stop2);
11742
12202
  process.once("SIGTERM", stop2);
@@ -11760,7 +12220,7 @@ function printList(dir2) {
11760
12220
 
11761
12221
  `);
11762
12222
  for (const f of files.slice(0, 20)) {
11763
- const { meta, outcome } = peek(join17(dir2, f));
12223
+ const { meta, outcome } = peek(join18(dir2, f));
11764
12224
  const when = fmtTime(rec(meta)?.ts);
11765
12225
  const ws = str2(rec(meta)?.workspaceSlug);
11766
12226
  const o = rec(outcome);
@@ -11797,10 +12257,10 @@ function resolveTarget(dir2, target) {
11797
12257
  if (existsSync15(target)) return target;
11798
12258
  throw new CompanionError(`no transcript at ${target}.`);
11799
12259
  }
11800
- const exact = join17(dir2, target);
12260
+ const exact = join18(dir2, target);
11801
12261
  if (existsSync15(exact)) return exact;
11802
12262
  const matches = listFiles(dir2).filter((f) => f.includes(target));
11803
- if (matches.length === 1) return join17(dir2, matches[0]);
12263
+ if (matches.length === 1) return join18(dir2, matches[0]);
11804
12264
  if (matches.length === 0) {
11805
12265
  throw new CompanionError(
11806
12266
  `no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`