@cabane/companion 0.6.88 → 0.6.90

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
@@ -2457,10 +2701,22 @@ var CabaneApi = class {
2457
2701
  // token whose turn has ended. The dispatcher mints it before this call and
2458
2702
  // reuses the same value on its active-run PATCH, so the token's turn id and the
2459
2703
  // pair's `active_turn_id` agree.
2460
- getTurnContext(conversationId, messageId2, turnId) {
2461
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
2704
+ // CT1380: `resumed` says this dispatch is a REPLAY of an interrupted turn, so
2705
+ // the server should hand back `turnSeqFloor` the turn's committed seq
2706
+ // high-water mark. Only the supervisor knows (it minted the id or recovered
2707
+ // it), so it is passed down rather than inferred here. It gates the COST of
2708
+ // the aggregate, never the tenancy scoping, which the route applies
2709
+ // unconditionally.
2710
+ getTurnContext(conversationId, messageId2, turnId, resumed) {
2711
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
2462
2712
  return this.request("GET", `/api/agent/turn-context?${q}`);
2463
2713
  }
2714
+ // CT1380: the local half of a resumed turn's seq floor (see
2715
+ // `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
2716
+ // test has no durable queue, so nothing can be hiding in it.
2717
+ outboxMaxSeqForTurn(turnId) {
2718
+ return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
2719
+ }
2464
2720
  // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
2465
2721
  // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
2466
2722
  // server-side (the URL MCP surface) rather than the dispatcher's in-memory
@@ -2743,15 +2999,15 @@ function isAbortError(err) {
2743
2999
  return err instanceof Error && err.name === "AbortError";
2744
3000
  }
2745
3001
  function sleep2(ms, signal) {
2746
- return new Promise((resolve) => {
2747
- if (signal?.aborted) return resolve();
3002
+ return new Promise((resolve2) => {
3003
+ if (signal?.aborted) return resolve2();
2748
3004
  const timer = setTimeout(() => {
2749
3005
  signal?.removeEventListener("abort", onAbort);
2750
- resolve();
3006
+ resolve2();
2751
3007
  }, ms);
2752
3008
  const onAbort = () => {
2753
3009
  clearTimeout(timer);
2754
- resolve();
3010
+ resolve2();
2755
3011
  };
2756
3012
  signal?.addEventListener("abort", onAbort, { once: true });
2757
3013
  });
@@ -2834,9 +3090,9 @@ function errorMessage2(status2, body) {
2834
3090
 
2835
3091
  // src/cursor.ts
2836
3092
  import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2837
- import { join as join8 } from "path";
3093
+ import { join as join9 } from "path";
2838
3094
  function pathFor(workspaceId) {
2839
- return join8(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
3095
+ return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2840
3096
  }
2841
3097
  function readCursor(workspaceId) {
2842
3098
  const path = pathFor(workspaceId);
@@ -2846,7 +3102,7 @@ function readCursor(workspaceId) {
2846
3102
  }
2847
3103
  function writeCursor(workspaceId, eventId) {
2848
3104
  const path = pathFor(workspaceId);
2849
- mkdirSync6(join8(cabaneDir(), "cursors"), { recursive: true });
3105
+ mkdirSync6(join9(cabaneDir(), "cursors"), { recursive: true });
2850
3106
  writeFileSync4(path, eventId + "\n", "utf8");
2851
3107
  }
2852
3108
 
@@ -2891,13 +3147,13 @@ var CursorTracker = class {
2891
3147
 
2892
3148
  // src/dispatch-dedupe.ts
2893
3149
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2894
- import { join as join9 } from "path";
3150
+ import { join as join10 } from "path";
2895
3151
  var MAX_IDS = 256;
2896
3152
  function dir(log) {
2897
- return join9(cabaneDir(), log);
3153
+ return join10(cabaneDir(), log);
2898
3154
  }
2899
3155
  function pathFor2(log, workspaceId) {
2900
- return join9(dir(log), encodeURIComponent(workspaceId));
3156
+ return join10(dir(log), encodeURIComponent(workspaceId));
2901
3157
  }
2902
3158
  function readIds(log, workspaceId) {
2903
3159
  const path = pathFor2(log, workspaceId);
@@ -2934,10 +3190,10 @@ function markCompleted(workspaceId, eventId) {
2934
3190
  }
2935
3191
  var MAX_RESUME_ATTEMPTS = 3;
2936
3192
  function resumeDir() {
2937
- return join9(cabaneDir(), "resume-attempts");
3193
+ return join10(cabaneDir(), "resume-attempts");
2938
3194
  }
2939
3195
  function resumePathFor(workspaceId) {
2940
- return join9(resumeDir(), encodeURIComponent(workspaceId));
3196
+ return join10(resumeDir(), encodeURIComponent(workspaceId));
2941
3197
  }
2942
3198
  function readResumeCounts(workspaceId) {
2943
3199
  const out = /* @__PURE__ */ new Map();
@@ -2973,10 +3229,10 @@ function bumpResumeAttempt(workspaceId, eventId) {
2973
3229
  return next;
2974
3230
  }
2975
3231
  function turnDir() {
2976
- return join9(cabaneDir(), "turns");
3232
+ return join10(cabaneDir(), "turns");
2977
3233
  }
2978
3234
  function turnPathFor(workspaceId) {
2979
- return join9(turnDir(), encodeURIComponent(workspaceId));
3235
+ return join10(turnDir(), encodeURIComponent(workspaceId));
2980
3236
  }
2981
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;
2982
3238
  function readTurnIds(workspaceId) {
@@ -5351,7 +5607,7 @@ var serverTurnTails = /* @__PURE__ */ new Map();
5351
5607
  async function acquireServerTurnLock(url) {
5352
5608
  const prev = serverTurnTails.get(url) ?? Promise.resolve();
5353
5609
  let release;
5354
- const done = new Promise((resolve) => release = resolve);
5610
+ const done = new Promise((resolve2) => release = resolve2);
5355
5611
  serverTurnTails.set(url, done);
5356
5612
  await prev.catch(() => {
5357
5613
  });
@@ -7590,8 +7846,8 @@ var ConnectorHealthStore = class {
7590
7846
  };
7591
7847
 
7592
7848
  // src/dispatcher.ts
7593
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
7594
- 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";
7595
7851
 
7596
7852
  // src/turn-execution.ts
7597
7853
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
@@ -7953,11 +8209,11 @@ function trimSlash2(s) {
7953
8209
  // src/codex-instructions.ts
7954
8210
  import { mkdtemp, rm, writeFile } from "fs/promises";
7955
8211
  import { tmpdir } from "os";
7956
- import { join as join10 } from "path";
8212
+ import { join as join11 } from "path";
7957
8213
  var PREFIX = "cabane-codex-instructions-";
7958
8214
  async function writeCodexInstructionsFile(contents) {
7959
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
7960
- const path = join10(dir2, "instructions.md");
8215
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
8216
+ const path = join11(dir2, "instructions.md");
7961
8217
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
7962
8218
  return {
7963
8219
  path,
@@ -7967,17 +8223,86 @@ async function writeCodexInstructionsFile(contents) {
7967
8223
  };
7968
8224
  }
7969
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
+
8262
+ // src/turn-seq-floor.ts
8263
+ var SeqFloorUnavailable = class extends Error {
8264
+ constructor(detail) {
8265
+ super(`seq_floor_unavailable: ${detail}`);
8266
+ this.detail = detail;
8267
+ this.name = "SeqFloorUnavailable";
8268
+ }
8269
+ detail;
8270
+ };
8271
+ function resolveSeqFloor(sources, ctx) {
8272
+ const { serverFloor, outboxFloor } = sources;
8273
+ if (serverFloor === void 0) {
8274
+ throw new SeqFloorUnavailable("server sent no committed floor for a resumed turn");
8275
+ }
8276
+ const floor = Math.max(serverFloor, outboxFloor);
8277
+ ctx.log.info(
8278
+ { turnId: ctx.turnId, floor, outboxFloor, serverFloor },
8279
+ "companion: resumed turn \u2014 seq counter seeded above its committed high-water mark"
8280
+ );
8281
+ return floor;
8282
+ }
8283
+ function readOutboxFloor(read, turnId, log) {
8284
+ try {
8285
+ return read(turnId);
8286
+ } catch (err) {
8287
+ log.error(
8288
+ { turnId, err: err instanceof Error ? err.message : String(err) },
8289
+ "companion: the on-disk outbox floor is unreadable; refusing to resume"
8290
+ );
8291
+ throw new SeqFloorUnavailable("outbox unreadable");
8292
+ }
8293
+ }
8294
+
7970
8295
  // src/prepared.ts
7971
8296
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7972
- import { join as join11 } from "path";
8297
+ import { join as join12 } from "path";
7973
8298
  function dirFor(workspaceId) {
7974
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8299
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7975
8300
  }
7976
8301
  function conversationDir(workspaceId, conversationId) {
7977
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
8302
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
7978
8303
  }
7979
8304
  function pathFor3(workspaceId, conversationId, agentId) {
7980
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8305
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7981
8306
  }
7982
8307
  function readPrepared(workspaceId, conversationId, agentId) {
7983
8308
  const path = pathFor3(workspaceId, conversationId, agentId);
@@ -8009,11 +8334,11 @@ function clearPrepared(workspaceId, conversationId, agentId) {
8009
8334
 
8010
8335
  // src/secrets.ts
8011
8336
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
8012
- import { join as join12 } from "path";
8337
+ import { join as join13 } from "path";
8013
8338
  import { z as z14 } from "zod";
8014
8339
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8015
8340
  function secretsPath() {
8016
- return join12(cabaneDir(), "secrets.json");
8341
+ return join13(cabaneDir(), "secrets.json");
8017
8342
  }
8018
8343
  var secretStoreSchema = z14.record(z14.string(), z14.string());
8019
8344
  function loadSecretStore() {
@@ -8100,9 +8425,9 @@ function resolveMcpSecrets(mcpServers, store) {
8100
8425
 
8101
8426
  // src/transcript-writer.ts
8102
8427
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
8103
- import { basename, dirname as dirname5, join as join13 } from "path";
8428
+ import { basename, dirname as dirname6, join as join14 } from "path";
8104
8429
  function transcriptsDir() {
8105
- return join13(cabaneDir(), "transcripts");
8430
+ return join14(cabaneDir(), "transcripts");
8106
8431
  }
8107
8432
  var RETAIN = 200;
8108
8433
  var ANOMALY_RETAIN = 50;
@@ -8112,7 +8437,7 @@ var TranscriptWriter = class {
8112
8437
  onWarn;
8113
8438
  constructor(dir2, meta, onWarn) {
8114
8439
  this.onWarn = onWarn;
8115
- this.path = join13(dir2, fileName(meta));
8440
+ this.path = join14(dir2, fileName(meta));
8116
8441
  try {
8117
8442
  mkdirSync9(dir2, { recursive: true });
8118
8443
  try {
@@ -8141,10 +8466,10 @@ var TranscriptWriter = class {
8141
8466
  preserveAnomaly() {
8142
8467
  if (this.broken) return;
8143
8468
  try {
8144
- const dir2 = join13(dirname5(this.path), "anomalies");
8469
+ const dir2 = join14(dirname6(this.path), "anomalies");
8145
8470
  mkdirSync9(dir2, { recursive: true, mode: 448 });
8146
8471
  chmodSync4(dir2, 448);
8147
- const target = join13(dir2, basename(this.path));
8472
+ const target = join14(dir2, basename(this.path));
8148
8473
  copyFileSync(this.path, target);
8149
8474
  chmodSync4(target, 384);
8150
8475
  pruneOld(dir2, ANOMALY_RETAIN);
@@ -8190,7 +8515,7 @@ function pruneOld(dir2, retain) {
8190
8515
  const drop = files.sort().slice(0, files.length - retain);
8191
8516
  for (const f of drop) {
8192
8517
  try {
8193
- rmSync6(join13(dir2, f), { force: true });
8518
+ rmSync6(join14(dir2, f), { force: true });
8194
8519
  } catch {
8195
8520
  }
8196
8521
  }
@@ -8337,6 +8662,84 @@ var TurnCommitter = class {
8337
8662
  }
8338
8663
  };
8339
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
+
8340
8743
  // src/turn-execution.ts
8341
8744
  var PREPARING_TOOL_NAME = "preparing";
8342
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:";
@@ -8349,40 +8752,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8349
8752
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8350
8753
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8351
8754
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8352
- var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8353
- "dispatch_not_admitted",
8354
- "turn_already_ended",
8355
- "turn_belongs_elsewhere"
8356
- ]);
8357
- function apiErrorCode(err) {
8358
- if (!(err instanceof ApiError)) return null;
8359
- const body = err.body;
8360
- return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8361
- }
8362
- function leaseRefusal(err) {
8363
- const code = apiErrorCode(err);
8364
- if (code && LEASE_REFUSALS.has(code)) return code;
8365
- return null;
8366
- }
8367
- function isWriteFenceRefusal(err) {
8368
- return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
8369
- }
8370
- var ERROR_BODY_LOG_CAP = 2e3;
8371
- function describeErrorBody(body) {
8372
- if (body === void 0 || body === null) return void 0;
8373
- let text;
8374
- if (typeof body === "string") {
8375
- text = body;
8376
- } else {
8377
- try {
8378
- text = JSON.stringify(body);
8379
- } catch {
8380
- text = String(body);
8381
- }
8382
- }
8383
- if (text.length === 0) return void 0;
8384
- return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
8385
- }
8386
8755
  function initialOutcome() {
8387
8756
  return {
8388
8757
  sessionWritten: false,
@@ -8401,7 +8770,8 @@ function initialOutcome() {
8401
8770
  settledDiagnostics: null,
8402
8771
  silentMarkerEmitted: false,
8403
8772
  timeoutReason: null,
8404
- leaseLost: false
8773
+ leaseLost: false,
8774
+ runtimeIncomplete: false
8405
8775
  };
8406
8776
  }
8407
8777
  var TurnConcluded = class {
@@ -8417,6 +8787,7 @@ var TurnExecution = class {
8417
8787
  this.opts = opts;
8418
8788
  this.supervisor = supervisor;
8419
8789
  this.payload = payload;
8790
+ this.resumed = handleOpts.resumed === true;
8420
8791
  this.dispatchId = payload.messageId;
8421
8792
  this.workspaceId = opts.workspaceId;
8422
8793
  this.turnLog = opts.log.child({
@@ -8439,7 +8810,13 @@ var TurnExecution = class {
8439
8810
  outcome = initialOutcome();
8440
8811
  seqCounter = 0;
8441
8812
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
8813
+ //
8814
+ // CT1380: 0 for a NEW turn only — a resume is seeded in `fetchContext` first.
8442
8815
  nextSeq = () => ++this.seqCounter;
8816
+ // CT1380: a REPLAY, told to us by the supervisor — not derivable here, since
8817
+ // `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
8818
+ resumed;
8819
+ resumedFromSeq;
8443
8820
  turnContext;
8444
8821
  resolvedMcpServers;
8445
8822
  effectiveCwd;
@@ -8485,6 +8862,10 @@ var TurnExecution = class {
8485
8862
  await this.acquireLease();
8486
8863
  await this.selectAdapter();
8487
8864
  } catch (err) {
8865
+ if (err instanceof SeqFloorUnavailable) {
8866
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
8867
+ return this.concludeBeforeRun(err.message, err.message);
8868
+ }
8488
8869
  if (err instanceof TurnConcluded) {
8489
8870
  this.supervisor.releaseAbort(this.turnId, this.abortController);
8490
8871
  return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
@@ -8541,7 +8922,9 @@ var TurnExecution = class {
8541
8922
  activeRunStartedAt: null,
8542
8923
  turnId,
8543
8924
  settledMessageId: payload.messageId,
8544
- 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"
8545
8928
  };
8546
8929
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
8547
8930
  try {
@@ -8563,12 +8946,14 @@ var TurnExecution = class {
8563
8946
  }
8564
8947
  async fetchContext() {
8565
8948
  const { payload, turnId, turnLog } = this;
8949
+ const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8566
8950
  let turnContext;
8567
8951
  try {
8568
8952
  turnContext = await this.opts.api.getTurnContext(
8569
8953
  payload.conversationId,
8570
8954
  payload.messageId,
8571
- turnId
8955
+ turnId,
8956
+ this.resumed
8572
8957
  );
8573
8958
  } catch (err) {
8574
8959
  const status2 = err instanceof ApiError ? err.status : 0;
@@ -8585,6 +8970,11 @@ var TurnExecution = class {
8585
8970
  throw this.concluded(fetchReason, fetchReason);
8586
8971
  }
8587
8972
  this.turnContext = turnContext;
8973
+ if (this.resumed) {
8974
+ const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
8975
+ this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
8976
+ this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
8977
+ }
8588
8978
  }
8589
8979
  gateTrigger() {
8590
8980
  const { payload, turnLog } = this;
@@ -8815,6 +9205,8 @@ ${reason}`,
8815
9205
  try {
8816
9206
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8817
9207
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9208
+ // CT1380: the durable record that this turn resumed, and from where.
9209
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
8818
9210
  // CT33: hand the server this turn's id so the new-run chokepoint's
8819
9211
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8820
9212
  // already emitted a "preparing" activity row for this turn above (it
@@ -8872,8 +9264,21 @@ ${reason}`,
8872
9264
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
8873
9265
  });
8874
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
+ }
8875
9279
  async selectAdapter() {
8876
9280
  const { payload, workspaceId, turnId, turnLog } = this;
9281
+ await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
8877
9282
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
8878
9283
  const adapters = [];
8879
9284
  if (this.opts.claudeCodeAvailable?.() ?? true) {
@@ -8882,7 +9287,7 @@ ${reason}`,
8882
9287
  if (this.opts.opencodeServerUrl) {
8883
9288
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
8884
9289
  }
8885
- if (this.opts.codexEnabled) {
9290
+ if (this.opts.codexAvailable?.() ?? false) {
8886
9291
  adapters.push(
8887
9292
  createCodexAdapter({
8888
9293
  enabled: true,
@@ -9166,6 +9571,11 @@ ${reason}`,
9166
9571
  o.okResult = false;
9167
9572
  o.resultReason = err instanceof Error ? err.message : String(err);
9168
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
+ );
9169
9579
  } finally {
9170
9580
  this.disarmWatchdogs();
9171
9581
  if (o.leaseLost) {
@@ -9214,13 +9624,13 @@ ${reason}`,
9214
9624
  if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
9215
9625
  body.resolvedConfig = o.turnResolvedConfig;
9216
9626
  }
9217
- 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) {
9218
9628
  body.errorReason = o.resultReason.slice(0, 200);
9219
9629
  }
9220
9630
  if (o.okResult) {
9221
9631
  body.lastSeenMessageId = payload.messageId;
9222
9632
  }
9223
- body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
9633
+ body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
9224
9634
  if (o.sessionDegraded) {
9225
9635
  body.degraded = true;
9226
9636
  }
@@ -9345,15 +9755,15 @@ function checkoutState(cwd) {
9345
9755
  if (entries.length === 0) {
9346
9756
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
9347
9757
  }
9348
- const gitPath = join14(cwd, ".git");
9758
+ const gitPath = join15(cwd, ".git");
9349
9759
  if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
9350
9760
  let stat;
9351
9761
  try {
9352
- stat = statSync(gitPath);
9762
+ stat = statSync2(gitPath);
9353
9763
  } catch (error) {
9354
9764
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
9355
9765
  }
9356
- if (stat.isDirectory() && !existsSync11(join14(gitPath, "HEAD")))
9766
+ if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
9357
9767
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
9358
9768
  return { ok: true, reason: "usable" };
9359
9769
  }
@@ -9488,7 +9898,7 @@ import {
9488
9898
  rmSync as rmSync7,
9489
9899
  writeFileSync as writeFileSync7
9490
9900
  } from "fs";
9491
- import { join as join15 } from "path";
9901
+ import { join as join16 } from "path";
9492
9902
  var MAX_ENTRIES = 2e3;
9493
9903
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
9494
9904
  var Outbox = class {
@@ -9501,10 +9911,10 @@ var Outbox = class {
9501
9911
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
9502
9912
  // cases route writes at the right tmpdir.
9503
9913
  dir() {
9504
- return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9914
+ return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9505
9915
  }
9506
9916
  fileFor(turnId, seq) {
9507
- return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9917
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9508
9918
  }
9509
9919
  // Persist a commit for later draining. Atomic (temp file + rename) so a
9510
9920
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -9546,7 +9956,7 @@ var Outbox = class {
9546
9956
  const entries = [];
9547
9957
  for (const name of names) {
9548
9958
  if (!name.endsWith(".json")) continue;
9549
- const full = join15(dir2, name);
9959
+ const full = join16(dir2, name);
9550
9960
  try {
9551
9961
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
9552
9962
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -9563,6 +9973,35 @@ var Outbox = class {
9563
9973
  );
9564
9974
  return entries;
9565
9975
  }
9976
+ // CT1380: the highest `seq` this turn has queued but not yet delivered — the
9977
+ // half of a resumed turn's seq floor the SERVER CANNOT SEE. A commit that
9978
+ // failed transiently before the crash is sitting in this directory with its
9979
+ // seq already spent, and the restart's drain is fire-and-forget
9980
+ // (`drain.kick()`, never awaited), so `maxSeqForTurn` on the server can report
9981
+ // below it and the resumed run would mint that number a second time.
9982
+ //
9983
+ // Reads the directory rather than `list()` because only the filename matters:
9984
+ // `<turnId>__<seq>.json` carries both halves of the key, so a corrupt body
9985
+ // can't hide a spent seq from the floor. Returns 0 for a turn with nothing
9986
+ // queued, which is the overwhelmingly common case.
9987
+ // THROWS rather than returning 0 when the directory exists but cannot be
9988
+ // read. 0 is a claim ("this turn has nothing queued"), and an unreadable
9989
+ // directory cannot support it — a queued entry holding seq N would be
9990
+ // invisible and the resumed run would mint N again. A missing directory is
9991
+ // different: it is positive evidence that nothing was ever queued here.
9992
+ maxSeqForTurn(turnId) {
9993
+ const dir2 = this.dir();
9994
+ if (!existsSync12(dir2)) return 0;
9995
+ const names = readdirSync3(dir2);
9996
+ const prefix = `${encodeURIComponent(turnId)}__`;
9997
+ let max = 0;
9998
+ for (const name of names) {
9999
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
10000
+ const seq = Number(name.slice(prefix.length, -".json".length));
10001
+ if (Number.isInteger(seq) && seq > max) max = seq;
10002
+ }
10003
+ return max;
10004
+ }
9566
10005
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9567
10006
  remove(turnId, seq) {
9568
10007
  try {
@@ -9769,12 +10208,12 @@ var SseSubscriber = class {
9769
10208
  }
9770
10209
  };
9771
10210
  function sleep3(ms) {
9772
- return new Promise((resolve) => setTimeout(resolve, ms));
10211
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
9773
10212
  }
9774
10213
 
9775
10214
  // src/version.ts
9776
- import { createRequire } from "module";
9777
- var pkg = createRequire(import.meta.url)("../package.json");
10215
+ import { createRequire as createRequire2 } from "module";
10216
+ var pkg = createRequire2(import.meta.url)("../package.json");
9778
10217
  var COMPANION_VERSION = pkg.version;
9779
10218
 
9780
10219
  // src/supervisor.ts
@@ -9796,10 +10235,15 @@ var CompanionSupervisor = class {
9796
10235
  config;
9797
10236
  log;
9798
10237
  hub;
9799
- // CT309: gates the claude-code runtime in the heartbeat manifest the boot
9800
- // probe (exit-0 `claude --version`), used as the fallback until CT586's
9801
- // per-heartbeat re-probe lands fresh presence in `harnessSignals`.
9802
- 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();
9803
10247
  // CT571: the probed per-harness versions, reported on each heartbeat manifest.
9804
10248
  // CT586: no longer boot-frozen — the per-heartbeat harness re-probe refreshes
9805
10249
  // it, so a harness updated mid-run reports its new version without a restart.
@@ -9843,13 +10287,14 @@ var CompanionSupervisor = class {
9843
10287
  this.config = opts.config;
9844
10288
  this.log = opts.log;
9845
10289
  this.hub = opts.hub;
9846
- this.claudeCode = opts.claudeCode ?? true;
10290
+ this.resolveBundled = opts.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
9847
10291
  this.probePresence = opts.probePresence ?? probeHarnessPresence;
9848
10292
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
9849
10293
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
9850
10294
  this.reexecFn = opts.reexec ?? defaultReexec;
9851
10295
  this.dispatcherFactory = opts.dispatcherFactory;
9852
10296
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
10297
+ this.latchBootBundledState();
9853
10298
  }
9854
10299
  // Stand up the data plane: pair check, initial assignments pull, then the
9855
10300
  // heartbeat + poll loops. A companion with no device token (logged out) does
@@ -9932,19 +10377,25 @@ var CompanionSupervisor = class {
9932
10377
  const res = await this.deviceApi.heartbeat({
9933
10378
  version: COMPANION_VERSION,
9934
10379
  exposedSecretNames: store.names(),
9935
- // Report each runtime only when this device can actually run it: CT309
9936
- // 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
9937
10383
  // configured an `opencode serve`.
9938
10384
  manifest: buildCompanionManifest({
9939
- // CT1082: connected AND installed. Presence is the live re-probe (CT586),
9940
- // falling back to the boot probe until the first one lands; consent is the
9941
- // 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.
9942
10388
  claudeCode: this.claudeCodeOffered(),
9943
10389
  opencode: !!this.config.opencode?.serverUrl,
9944
- // CT481: advertise codex when the operator enabled it (config-gated,
9945
- // like opencode the CLI's presence is the operator's responsibility;
9946
- // a misconfigured device fails the turn loudly, never silently).
9947
- 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(),
9948
10399
  // CT571/CT586: each runtime's `version` from the latest harness probe
9949
10400
  // (fail-soft to null). Informational only — the server matches on name.
9950
10401
  versions: this.harnessVersions
@@ -10165,18 +10616,103 @@ var CompanionSupervisor = class {
10165
10616
  "companion: stopped agent (unassigned)"
10166
10617
  );
10167
10618
  }
10168
- // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
10169
- // falling back to the boot probe until the first one lands. Presence ONLY —
10170
- // CT1082 split presence from exposure, so nothing routes on this directly.
10171
- claudeCodePresent() {
10172
- return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
10173
- }
10174
- // CT1082: does this device OFFER claude-code — connected by its user and actually
10175
- // installed? The ONE signal both the heartbeat manifest and the dispatcher's
10176
- // adapter registry read, so what the device advertises and what it can select
10177
- // 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).
10178
10638
  claudeCodeOffered() {
10179
- 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
+ }
10180
10716
  }
10181
10717
  buildDispatcher(ctx) {
10182
10718
  const local = localAgentConfig(this.config, {
@@ -10200,16 +10736,23 @@ var CompanionSupervisor = class {
10200
10736
  // CT833: register the claude-code adapter only when this device actually
10201
10737
  // offers claude-code — read per turn (not captured here), so a harness
10202
10738
  // installed or connected after boot works on the next turn exactly as it
10203
- // appears on the next beat. CT1082: "offers" now means connected as well as
10204
- // 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.
10205
10744
  claudeCodeAvailable: () => this.claudeCodeOffered(),
10206
10745
  // CT270: the opencode server URL (operator-configured), when this device
10207
10746
  // offers the opencode runtime. Threaded so an opencode turn selects the
10208
10747
  // opencode adapter; unset leaves the device claude-code-only.
10209
10748
  ...this.config.opencode?.serverUrl ? { opencodeServerUrl: this.config.opencode.serverUrl } : {},
10210
- // CT481: register the codex adapter when this device offers codex; unset
10749
+ // CT481: register the codex adapter when this device offers codex; a miss
10211
10750
  // leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
10212
- ...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(),
10213
10756
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10214
10757
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10215
10758
  // dispatcher's baked-in defaults (10 min idle / 6h total).
@@ -10479,7 +11022,10 @@ var CompanionSupervisor = class {
10479
11022
  "companion: resuming an interrupted turn under its original id"
10480
11023
  );
10481
11024
  }
10482
- const result = await agent.dispatcher.handle(payload, { turnId });
11025
+ const result = await agent.dispatcher.handle(payload, {
11026
+ turnId,
11027
+ resumed: resumedTurnId !== null
11028
+ });
10483
11029
  if (ev.id) markCompleted(workspaceId, ev.id);
10484
11030
  if (ev.id) wr.cursor.settle(ev.id);
10485
11031
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -10576,9 +11122,13 @@ var CompanionSupervisor = class {
10576
11122
  try {
10577
11123
  const signals = await probeHarnessSignals(this.config, {
10578
11124
  probePresence: this.probePresence,
10579
- 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
10580
11129
  });
10581
11130
  this.harnessSignals = signals;
11131
+ this.logBundledTransitions();
10582
11132
  this.harnessVersions = {
10583
11133
  claudeCode: signals.claudeVersion,
10584
11134
  opencode: signals.opencodeVersion,
@@ -10618,8 +11168,15 @@ var CompanionSupervisor = class {
10618
11168
  return this.config;
10619
11169
  }
10620
11170
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
10621
- // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
10622
- // 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".
10623
11180
  // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
10624
11181
  // on PATH. This is the callable the terminal offer ("We found Claude Code.
10625
11182
  // Connect it? [Y/n]") and the web pairing flow both wire to.
@@ -10628,9 +11185,10 @@ var CompanionSupervisor = class {
10628
11185
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
10629
11186
  // so we never advertise a serve that isn't there. An unreachable URL is a
10630
11187
  // typed failure the UI shows in place, not a silent bad write.
10631
- // On success, persist, re-probe, and beat immediately so Cabane starts routing to
10632
- // the newly-exposed runtime at once. Returns a typed outcome (never throws for a
10633
- // 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).
10634
11192
  async enableHarness(input) {
10635
11193
  let next;
10636
11194
  let checkedPresence = null;
@@ -10728,8 +11286,8 @@ var CompanionSupervisor = class {
10728
11286
  };
10729
11287
  async function waitForBoundedHeartbeat(heartbeat) {
10730
11288
  let timer = null;
10731
- const timeout = new Promise((resolve) => {
10732
- timer = setTimeout(resolve, SHUTDOWN_HEARTBEAT_WAIT_MS);
11289
+ const timeout = new Promise((resolve2) => {
11290
+ timer = setTimeout(resolve2, SHUTDOWN_HEARTBEAT_WAIT_MS);
10733
11291
  timer.unref?.();
10734
11292
  });
10735
11293
  try {
@@ -10813,9 +11371,9 @@ function handleUncaught(log, err, origin) {
10813
11371
 
10814
11372
  // src/crash-marker.ts
10815
11373
  import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10816
- import { join as join16 } from "path";
11374
+ import { join as join17 } from "path";
10817
11375
  function crashMarkerPath() {
10818
- return join16(cabaneDir(), "last-error.json");
11376
+ return join17(cabaneDir(), "last-error.json");
10819
11377
  }
10820
11378
  function recordCrash(rec2) {
10821
11379
  try {
@@ -10864,8 +11422,8 @@ async function createCompanionRuntime(opts = {}) {
10864
11422
  }
10865
11423
  if (cfg.logLevel) log.level = cfg.logLevel;
10866
11424
  const harnessVersions = await probeHarnessVersions({
10867
- // CT1082: versions are probed for the runtimes this device OFFERS, and
10868
- // 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.
10869
11427
  claudeCode: claudeCode && isClaudeCodeConnected(cfg),
10870
11428
  opencodeServerUrl: cfg.opencode?.serverUrl,
10871
11429
  codex: isCodexEnabled(cfg)
@@ -10898,7 +11456,6 @@ async function createCompanionRuntime(opts = {}) {
10898
11456
  config: cfg,
10899
11457
  log,
10900
11458
  hub,
10901
- claudeCode,
10902
11459
  harnessVersions
10903
11460
  });
10904
11461
  await supervisor.start();
@@ -11003,11 +11560,11 @@ import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync
11003
11560
 
11004
11561
  // src/cli-entry.ts
11005
11562
  import { existsSync as existsSync14 } from "fs";
11006
- import { fileURLToPath as fileURLToPath2 } from "url";
11563
+ import { fileURLToPath as fileURLToPath3 } from "url";
11007
11564
  var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
11008
11565
  function companionCliEntry(deps = {}) {
11009
11566
  const exists = deps.exists ?? existsSync14;
11010
- 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)));
11011
11568
  for (const candidate of candidates) {
11012
11569
  if (exists(candidate)) return candidate;
11013
11570
  }
@@ -11354,7 +11911,7 @@ async function handOffToBackground(runtime, ctx) {
11354
11911
  }
11355
11912
  }
11356
11913
  async function runAttached(runtime) {
11357
- await new Promise((resolve) => {
11914
+ await new Promise((resolve2) => {
11358
11915
  let shuttingDown = false;
11359
11916
  const shutdown = async (signal) => {
11360
11917
  if (shuttingDown) {
@@ -11375,7 +11932,7 @@ companion: received ${signal}, shutting down\u2026
11375
11932
  forceExit.unref?.();
11376
11933
  await runtime.stop();
11377
11934
  clearTimeout(forceExit);
11378
- resolve();
11935
+ resolve2();
11379
11936
  process.exit(0);
11380
11937
  };
11381
11938
  process.on("SIGINT", () => void shutdown("SIGINT"));
@@ -11523,7 +12080,7 @@ function isAlive(kill, pid) {
11523
12080
 
11524
12081
  // src/commands/transcript.ts
11525
12082
  import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
11526
- import { isAbsolute, join as join17 } from "path";
12083
+ import { isAbsolute, join as join18 } from "path";
11527
12084
  async function transcript(opts = {}) {
11528
12085
  const dir2 = transcriptsDir();
11529
12086
  if (opts.follow) {
@@ -11540,7 +12097,7 @@ async function transcript(opts = {}) {
11540
12097
  process.stdout.write(emptyMessage(dir2));
11541
12098
  return;
11542
12099
  }
11543
- process.stdout.write(renderFile(join17(dir2, newest)) + "\n");
12100
+ process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
11544
12101
  return;
11545
12102
  }
11546
12103
  printList(dir2);
@@ -11623,7 +12180,7 @@ function isComplete(content) {
11623
12180
  async function followTranscripts(dir2) {
11624
12181
  const follower = new TranscriptFollower({
11625
12182
  listFiles: () => listFiles(dir2),
11626
- read: (f) => readFileSync10(join17(dir2, f), "utf8"),
12183
+ read: (f) => readFileSync10(join18(dir2, f), "utf8"),
11627
12184
  write: (s) => process.stdout.write(s),
11628
12185
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
11629
12186
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -11632,14 +12189,14 @@ async function followTranscripts(dir2) {
11632
12189
  process.stdout.write(`following ${dir2} \u2014 Ctrl-C to stop.
11633
12190
 
11634
12191
  `);
11635
- await new Promise((resolve) => {
12192
+ await new Promise((resolve2) => {
11636
12193
  const timer = setInterval(() => follower.tick(), FOLLOW_POLL_MS);
11637
12194
  const stop2 = () => {
11638
12195
  clearInterval(timer);
11639
12196
  process.removeListener("SIGINT", stop2);
11640
12197
  process.removeListener("SIGTERM", stop2);
11641
12198
  process.stdout.write("\n");
11642
- resolve();
12199
+ resolve2();
11643
12200
  };
11644
12201
  process.once("SIGINT", stop2);
11645
12202
  process.once("SIGTERM", stop2);
@@ -11663,7 +12220,7 @@ function printList(dir2) {
11663
12220
 
11664
12221
  `);
11665
12222
  for (const f of files.slice(0, 20)) {
11666
- const { meta, outcome } = peek(join17(dir2, f));
12223
+ const { meta, outcome } = peek(join18(dir2, f));
11667
12224
  const when = fmtTime(rec(meta)?.ts);
11668
12225
  const ws = str2(rec(meta)?.workspaceSlug);
11669
12226
  const o = rec(outcome);
@@ -11700,10 +12257,10 @@ function resolveTarget(dir2, target) {
11700
12257
  if (existsSync15(target)) return target;
11701
12258
  throw new CompanionError(`no transcript at ${target}.`);
11702
12259
  }
11703
- const exact = join17(dir2, target);
12260
+ const exact = join18(dir2, target);
11704
12261
  if (existsSync15(exact)) return exact;
11705
12262
  const matches = listFiles(dir2).filter((f) => f.includes(target));
11706
- if (matches.length === 1) return join17(dir2, matches[0]);
12263
+ if (matches.length === 1) return join18(dir2, matches[0]);
11707
12264
  if (matches.length === 0) {
11708
12265
  throw new CompanionError(
11709
12266
  `no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`