@cabane/companion 0.6.49 → 0.6.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +251 -190
  2. package/dist/runtime.js +1142 -1091
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command, Option } from "commander";
5
5
 
6
6
  // src/control-socket.ts
7
7
  import { createHash } from "crypto";
8
- import { existsSync as existsSync2, rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
8
+ import { chmodSync as chmodSync2, existsSync as existsSync2, rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
9
9
  import { createServer, connect } from "net";
10
10
  import { join as join2 } from "path";
11
11
 
@@ -495,13 +495,18 @@ function deleteConfig() {
495
495
 
496
496
  // src/control-socket.ts
497
497
  var CONTROL_TIMEOUT_MS = 1e3;
498
+ var MAX_UNIX_SOCKET_PATH_BYTES = 103;
498
499
  function controlSocketPath() {
499
500
  const dir2 = cabaneDir();
501
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
500
502
  if (process.platform === "win32") {
501
- const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
502
503
  return `\\\\.\\pipe\\cabane-companion-${key}`;
503
504
  }
504
- return join2(dir2, "companion.sock");
505
+ const besideRuntime = join2(dir2, "companion.sock");
506
+ if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
507
+ const filename = `cabane-companion-${key}.sock`;
508
+ const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync2(process.env.XDG_RUNTIME_DIR) ? join2(process.env.XDG_RUNTIME_DIR, filename) : null;
509
+ return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join2("/tmp", filename);
505
510
  }
506
511
  async function startControlServer(handlers) {
507
512
  const path = controlSocketPath();
@@ -522,6 +527,18 @@ async function startControlServer(handlers) {
522
527
  resolve();
523
528
  });
524
529
  });
530
+ if (process.platform !== "win32") {
531
+ try {
532
+ chmodSync2(path, 384);
533
+ } catch (err) {
534
+ await new Promise((resolve) => server.close(() => resolve()));
535
+ try {
536
+ rmSync2(path, { force: true });
537
+ } catch {
538
+ }
539
+ throw err;
540
+ }
541
+ }
525
542
  server.on("error", () => {
526
543
  });
527
544
  return {
@@ -644,40 +661,58 @@ async function ping(path) {
644
661
  }
645
662
 
646
663
  // src/harness-check.ts
647
- import { spawn as spawn4 } from "child_process";
664
+ import { spawn as spawn3 } from "child_process";
648
665
 
649
666
  // src/harness-versions.ts
650
667
  import { spawn as spawn2 } from "child_process";
651
668
  var EMPTY = { claudeCode: null, opencode: null, codex: null };
669
+ var CLI_PROBE_TIMEOUT_MS = 4e3;
652
670
  function parseVersionToken(raw) {
653
671
  if (!raw) return null;
654
672
  const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
655
673
  return m ? m[0] : null;
656
674
  }
657
- async function probeCliVersion(command, spawnImpl = spawn2) {
675
+ function probeCliPresence(command, spawnImpl = spawn2) {
658
676
  return new Promise((resolve) => {
659
677
  let settled = false;
660
- const done = (v) => {
678
+ let timer = null;
679
+ const done = (result) => {
661
680
  if (!settled) {
662
681
  settled = true;
663
- resolve(v);
682
+ if (timer) clearTimeout(timer);
683
+ resolve(result);
664
684
  }
665
685
  };
666
686
  let child;
667
687
  try {
668
688
  child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
669
689
  } catch {
670
- done(null);
690
+ done({ status: "absent" });
671
691
  return;
672
692
  }
673
693
  let out = "";
674
694
  child.stdout?.on("data", (chunk) => {
675
695
  if (out.length < 4096) out += chunk.toString();
676
696
  });
677
- child.once("error", () => done(null));
678
- child.once("exit", (code) => done(code === 0 ? parseVersionToken(out) : null));
697
+ timer = setTimeout(() => {
698
+ child.kill?.("SIGKILL");
699
+ done({ status: "unusable" });
700
+ }, CLI_PROBE_TIMEOUT_MS);
701
+ timer.unref?.();
702
+ child.once("error", () => done({ status: "absent" }));
703
+ child.once("exit", (code) => {
704
+ const version = code === 0 ? parseVersionToken(out) : null;
705
+ done(version ? { status: "present", version } : { status: "unusable" });
706
+ });
679
707
  });
680
708
  }
709
+ function probeHarnessPresence(runtime, spawnImpl = spawn2) {
710
+ return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
711
+ }
712
+ async function probeCliVersion(command, spawnImpl = spawn2) {
713
+ const result = await probeCliPresence(command, spawnImpl);
714
+ return result.status === "present" ? result.version : null;
715
+ }
681
716
  async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
682
717
  try {
683
718
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
@@ -741,81 +776,6 @@ function buildCompanionManifest(opts) {
741
776
  return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
742
777
  }
743
778
 
744
- // src/prereqs.ts
745
- import { spawn as spawn3 } from "child_process";
746
- async function claudeOnPath() {
747
- return new Promise((resolve) => {
748
- let settled = false;
749
- const child = spawn3("claude", ["--version"], { stdio: "ignore" });
750
- child.once("error", () => {
751
- if (!settled) {
752
- settled = true;
753
- resolve(false);
754
- }
755
- });
756
- child.once("exit", (code) => {
757
- if (!settled) {
758
- settled = true;
759
- resolve(code === 0);
760
- }
761
- });
762
- });
763
- }
764
- var CODEX_PROBE_TIMEOUT_MS = 4e3;
765
- async function codexOnPath() {
766
- const version = await Promise.race([
767
- probeCliVersion("codex"),
768
- new Promise((resolve) => {
769
- const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
770
- timer.unref?.();
771
- })
772
- ]);
773
- return version !== null;
774
- }
775
- async function requireStartConfig(deps = {}) {
776
- const requireCfg = deps.requireCfg ?? requireConfig;
777
- const probeClaude = deps.probeClaude ?? claudeOnPath;
778
- const save2 = deps.save ?? saveConfig;
779
- const cfg = requireCfg();
780
- const claudeOnPathResult = await probeClaude();
781
- const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
782
- if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
783
- save2(migrated);
784
- deps.onMigrated?.(migrated, claudeOnPathResult);
785
- return { cfg: migrated, claudeOnPath: claudeOnPathResult };
786
- }
787
- async function warnAboutHarnessReadiness(cfg, deps = {}) {
788
- const probeClaude = deps.probeClaude ?? claudeOnPath;
789
- const probeCodex = deps.probeCodex ?? codexOnPath;
790
- const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
791
- `));
792
- const connected = [
793
- ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
794
- ...isCodexEnabled(cfg) ? ["Codex"] : [],
795
- ...cfg.opencode ? ["opencode"] : []
796
- ];
797
- if (connected.length > 0) {
798
- if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
799
- warn(
800
- "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."
801
- );
802
- }
803
- return;
804
- }
805
- const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
806
- const installed = [
807
- ...claudeInstalled ? ["Claude Code"] : [],
808
- ...codexInstalled ? ["Codex"] : []
809
- ];
810
- const connectCommands = [
811
- ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
812
- ...codexInstalled ? ["`cabane-companion connect codex`"] : []
813
- ];
814
- warn(
815
- "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).")
816
- );
817
- }
818
-
819
779
  // src/harness-status.ts
820
780
  var HARNESS_LABELS = {
821
781
  "claude-code": "Claude Code",
@@ -976,20 +936,19 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
976
936
  return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
977
937
  }
978
938
  async function probeHarnessSignals(cfg, deps = {}) {
979
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
980
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
981
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
939
+ const probePresence = deps.probePresence ?? probeHarnessPresence;
982
940
  const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
983
941
  const configuredServerUrl = cfg.opencode?.serverUrl;
984
942
  const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
985
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
986
- withTimeout(probeClaudePresence(), false),
987
- withTimeout(probeClaudeVersion(), null),
988
- withTimeout(probeCodexVersion(), null),
943
+ const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
944
+ deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
945
+ deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
989
946
  configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
990
947
  ]);
948
+ const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
949
+ const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
991
950
  return {
992
- claudeOnPath: claudeOnPathResult,
951
+ claudeOnPath: claudePresence.status === "present",
993
952
  claudeVersion,
994
953
  // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
995
954
  // the manifest gate and the probe above is only a suggestion.
@@ -1041,16 +1000,13 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
1041
1000
  if (!url) return "absent";
1042
1001
  return await probeOpencode(url) !== null ? "ok" : "failed";
1043
1002
  }
1044
- const { auth, presence } = runtime === "codex" ? {
1045
- auth: ["codex", ["login", "status"]],
1046
- presence: ["codex", ["--version"]]
1003
+ const { auth } = runtime === "codex" ? {
1004
+ auth: ["codex", ["login", "status"]]
1047
1005
  } : {
1048
- auth: ["claude", ["auth", "status"]],
1049
- presence: ["claude", ["--version"]]
1006
+ auth: ["claude", ["auth", "status"]]
1050
1007
  };
1051
- const presenceRun = await run(presence[0], [...presence[1]]);
1052
- if (presenceRun.error === "spawn") return "absent";
1053
- if (presenceRun.code !== 0) return "unverified";
1008
+ const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
1009
+ if (presence.status !== "present") return presence.status;
1054
1010
  const authRun = await run(auth[0], [...auth[1]]);
1055
1011
  if (authRun.code === 0) return "ok";
1056
1012
  if (looksUnsupported(authRun.output)) {
@@ -1062,7 +1018,9 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
1062
1018
  }
1063
1019
  }
1064
1020
  function connectedLine(runtime, verdict) {
1065
- if (verdict === "absent") throw new Error("an absent harness cannot be connected");
1021
+ if (verdict === "absent" || verdict === "unusable") {
1022
+ throw new Error("an unavailable harness cannot be connected");
1023
+ }
1066
1024
  const label = HARNESS_LABELS[runtime];
1067
1025
  if (verdict !== "failed") return `${label} connected.`;
1068
1026
  return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
@@ -1072,13 +1030,18 @@ var FAILED_SUFFIX = {
1072
1030
  codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1073
1031
  opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1074
1032
  };
1075
- function absentLine(runtime) {
1033
+ function presenceReason(runtime, status2) {
1076
1034
  if (runtime === "opencode") {
1077
1035
  return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
1078
1036
  }
1079
1037
  const label = HARNESS_LABELS[runtime];
1080
- const login = runtime === "codex" ? "`codex login`" : "`claude`";
1081
- return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
1038
+ return status2 === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
1039
+ }
1040
+ var CLI_PRESENCE_ACTION = "Install it and run this again.";
1041
+ var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
1042
+ function presenceRefusal(runtime, status2, action) {
1043
+ const reason = presenceReason(runtime, status2);
1044
+ return runtime === "opencode" ? reason : `${reason} ${action}`;
1082
1045
  }
1083
1046
  function looksUnsupported(output) {
1084
1047
  return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
@@ -1096,7 +1059,7 @@ function runBounded(command, args) {
1096
1059
  };
1097
1060
  let child;
1098
1061
  try {
1099
- child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1062
+ child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1100
1063
  } catch {
1101
1064
  resolve({ code: null, output: "", error: "spawn" });
1102
1065
  return;
@@ -1299,18 +1262,31 @@ async function confirm(question) {
1299
1262
  }
1300
1263
 
1301
1264
  // src/commands/connect.ts
1302
- async function connect2(raw, opts = {}) {
1265
+ async function connect2(raw, opts = {}, deps = {}) {
1303
1266
  const runtime = parseHarnessRuntime(raw);
1304
1267
  if (!runtime) {
1305
1268
  throw new CompanionError(
1306
1269
  `unknown harness "${raw}". Connectable harnesses are: claude-code, codex, opencode.`
1307
1270
  );
1308
1271
  }
1309
- const live = await liveSocket();
1272
+ const loadConfig2 = deps.loadConfig ?? requireConfig;
1273
+ const request = deps.request ?? controlRequest;
1274
+ const save2 = deps.save ?? saveConfig;
1275
+ const output = deps.output ?? write;
1276
+ const probePresence = deps.probePresence ?? probeHarnessPresence;
1277
+ const shakeOut = deps.shakeOut ?? shakeOutHarness;
1278
+ const resolveServerUrl = deps.resolveServerUrl ?? resolveOpencodeServerUrl;
1279
+ const probeOpencode = deps.probeOpencode ?? probeOpencodeVersion;
1280
+ const cfg = loadConfig2();
1281
+ if (!opts.serverUrl?.trim() && alreadyConnected(runtime, cfg)) {
1282
+ output(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1283
+ return;
1284
+ }
1285
+ const live = await (deps.findLiveSocket ?? liveSocket)();
1310
1286
  if (live) {
1311
1287
  let result;
1312
1288
  try {
1313
- result = await controlRequest(
1289
+ result = await request(
1314
1290
  live,
1315
1291
  {
1316
1292
  cmd: "connect",
@@ -1326,42 +1302,38 @@ async function connect2(raw, opts = {}) {
1326
1302
  );
1327
1303
  }
1328
1304
  if (!result.ok) throw new CompanionError(result.message);
1329
- write(INDENT + tick(result.message));
1330
- return;
1331
- }
1332
- const cfg = requireConfig();
1333
- if (alreadyConnected(runtime, cfg)) {
1334
- write(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1305
+ output(INDENT + tick(result.message));
1335
1306
  return;
1336
1307
  }
1337
- if (runtime === "claude-code" && !await claudeOnPath()) {
1338
- throw new CompanionError(
1339
- "Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
1340
- );
1341
- }
1342
1308
  let next = cfg;
1309
+ const presence = runtime === "opencode" ? void 0 : await probePresence(runtime);
1310
+ if (presence && presence.status !== "present") {
1311
+ throw new CompanionError(presenceRefusal(runtime, presence.status, CLI_PRESENCE_ACTION));
1312
+ }
1343
1313
  if (runtime === "claude-code") next = { ...cfg, claudeCode: { enabled: true } };
1344
1314
  else if (runtime === "codex") next = { ...cfg, codex: { enabled: true } };
1345
1315
  else {
1346
1316
  const requestedServerUrl = opts.serverUrl?.trim();
1347
- const serverUrl = await resolveOpencodeServerUrl(void 0, requestedServerUrl);
1317
+ const serverUrl = await resolveServerUrl(cfg.opencode?.serverUrl, requestedServerUrl);
1348
1318
  if (!serverUrl) {
1349
1319
  throw new CompanionError(
1350
1320
  "opencode is addressed by URL \u2014 pass it: `cabane-companion connect opencode --url http://127.0.0.1:4096`."
1351
1321
  );
1352
1322
  }
1353
- if (requestedServerUrl && await probeOpencodeVersion(serverUrl) === null) {
1323
+ if (requestedServerUrl && await probeOpencode(serverUrl) === null) {
1354
1324
  throw new CompanionError(
1355
1325
  `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
1356
1326
  );
1357
1327
  }
1358
1328
  next = { ...cfg, opencode: { serverUrl } };
1359
1329
  }
1360
- const verdict = await shakeOutHarness(runtime, next);
1361
- if (verdict === "absent") throw new CompanionError(absentLine(runtime));
1362
- saveConfig(next);
1363
- write(INDENT + tick(connectedLine(runtime, verdict)));
1364
- write(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
1330
+ const verdict = await shakeOut(runtime, next, { ...presence ? { presence } : {} });
1331
+ if (verdict === "absent" || verdict === "unusable") {
1332
+ throw new CompanionError(presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION));
1333
+ }
1334
+ save2(next);
1335
+ output(INDENT + tick(connectedLine(runtime, verdict)));
1336
+ output(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
1365
1337
  }
1366
1338
  async function liveSocket() {
1367
1339
  const state = readLiveRuntimeState();
@@ -1393,7 +1365,7 @@ import { confirm as confirm2 } from "@inquirer/prompts";
1393
1365
 
1394
1366
  // src/credentials.ts
1395
1367
  import {
1396
- chmodSync as chmodSync2,
1368
+ chmodSync as chmodSync3,
1397
1369
  existsSync as existsSync4,
1398
1370
  mkdirSync as mkdirSync4,
1399
1371
  readFileSync as readFileSync3,
@@ -1428,14 +1400,14 @@ function save(map) {
1428
1400
  const path = credentialsPath();
1429
1401
  mkdirSync4(dirname2(path), { recursive: true });
1430
1402
  try {
1431
- chmodSync2(cabaneDir(), 448);
1403
+ chmodSync3(cabaneDir(), 448);
1432
1404
  } catch {
1433
1405
  }
1434
1406
  const tmp = `${path}.${process.pid}.tmp`;
1435
1407
  try {
1436
1408
  writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1437
1409
  try {
1438
- chmodSync2(tmp, 384);
1410
+ chmodSync3(tmp, 384);
1439
1411
  } catch {
1440
1412
  }
1441
1413
  renameSync2(tmp, path);
@@ -2082,12 +2054,18 @@ function registerRoutes(app, deps) {
2082
2054
  const runtime = body.runtime;
2083
2055
  if (runtime === "claude-code") {
2084
2056
  const result = await supervisor.enableHarness({ runtime: "claude-code" });
2085
- if (!result.ok) return c.json({ error: result.error }, 400);
2057
+ if (!result.ok) {
2058
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
2059
+ return c.json({ error }, 400);
2060
+ }
2086
2061
  return c.json({ ok: true, status: hub.statusJson() });
2087
2062
  }
2088
2063
  if (runtime === "codex") {
2089
2064
  const result = await supervisor.enableHarness({ runtime: "codex" });
2090
- if (!result.ok) return c.json({ error: result.error }, 400);
2065
+ if (!result.ok) {
2066
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
2067
+ return c.json({ error }, 400);
2068
+ }
2091
2069
  return c.json({ ok: true, status: hub.statusJson() });
2092
2070
  }
2093
2071
  if (runtime === "opencode") {
@@ -2246,6 +2224,57 @@ function resolveStaticDir() {
2246
2224
  return join7(dirname4(fileURLToPath(import.meta.url)), "static");
2247
2225
  }
2248
2226
 
2227
+ // src/prereqs.ts
2228
+ async function claudeOnPath() {
2229
+ return (await probeHarnessPresence("claude-code")).status === "present";
2230
+ }
2231
+ async function codexOnPath() {
2232
+ return (await probeHarnessPresence("codex")).status === "present";
2233
+ }
2234
+ async function requireStartConfig(deps = {}) {
2235
+ const requireCfg = deps.requireCfg ?? requireConfig;
2236
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
2237
+ const save2 = deps.save ?? saveConfig;
2238
+ const cfg = requireCfg();
2239
+ const claudeOnPathResult = await probeClaude();
2240
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
2241
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
2242
+ save2(migrated);
2243
+ deps.onMigrated?.(migrated, claudeOnPathResult);
2244
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
2245
+ }
2246
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2247
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
2248
+ const probeCodex = deps.probeCodex ?? codexOnPath;
2249
+ const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
2250
+ `));
2251
+ const connected = [
2252
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
2253
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
2254
+ ...cfg.opencode ? ["opencode"] : []
2255
+ ];
2256
+ if (connected.length > 0) {
2257
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
2258
+ warn(
2259
+ "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."
2260
+ );
2261
+ }
2262
+ return;
2263
+ }
2264
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
2265
+ const installed = [
2266
+ ...claudeInstalled ? ["Claude Code"] : [],
2267
+ ...codexInstalled ? ["Codex"] : []
2268
+ ];
2269
+ const connectCommands = [
2270
+ ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
2271
+ ...codexInstalled ? ["`cabane-companion connect codex`"] : []
2272
+ ];
2273
+ warn(
2274
+ "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).")
2275
+ );
2276
+ }
2277
+
2249
2278
  // src/api.ts
2250
2279
  var RETRY_BACKOFF_MS = [250, 750];
2251
2280
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -7176,7 +7205,7 @@ function resolveMcpSecrets(mcpServers, store) {
7176
7205
  }
7177
7206
 
7178
7207
  // src/transcript-writer.ts
7179
- import { appendFileSync, chmodSync as chmodSync3, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
7208
+ import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
7180
7209
  import { basename, dirname as dirname5, join as join13 } from "path";
7181
7210
  function transcriptsDir() {
7182
7211
  return join13(cabaneDir(), "transcripts");
@@ -7193,7 +7222,7 @@ var TranscriptWriter = class {
7193
7222
  try {
7194
7223
  mkdirSync9(dir2, { recursive: true });
7195
7224
  try {
7196
- chmodSync3(dir2, 448);
7225
+ chmodSync4(dir2, 448);
7197
7226
  } catch {
7198
7227
  }
7199
7228
  pruneOld(dir2, RETAIN);
@@ -7220,10 +7249,10 @@ var TranscriptWriter = class {
7220
7249
  try {
7221
7250
  const dir2 = join13(dirname5(this.path), "anomalies");
7222
7251
  mkdirSync9(dir2, { recursive: true, mode: 448 });
7223
- chmodSync3(dir2, 448);
7252
+ chmodSync4(dir2, 448);
7224
7253
  const target = join13(dir2, basename(this.path));
7225
7254
  copyFileSync(this.path, target);
7226
- chmodSync3(target, 384);
7255
+ chmodSync4(target, 384);
7227
7256
  pruneOld(dir2, ANOMALY_RETAIN);
7228
7257
  } catch (err) {
7229
7258
  this.fail(err);
@@ -9065,11 +9094,9 @@ var CompanionSupervisor = class {
9065
9094
  // can never disagree with what the manifest advertises. Null until the first
9066
9095
  // probe (the heartbeat then falls back to the boot `claudeCode`).
9067
9096
  harnessSignals = null;
9068
- // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
9069
- // NOT the cached beat signal — someone connecting right after installing Claude
9070
- // Code shouldn't be refused by a snapshot up to a heartbeat old.
9071
- probeClaudePresence;
9072
- probeCodexPresence;
9097
+ // Fresh per attempt, never the cached heartbeat signal: installing a harness
9098
+ // between attempts must be observed immediately.
9099
+ probePresence;
9073
9100
  exitFn;
9074
9101
  reexecFn;
9075
9102
  dispatcherFactory;
@@ -9101,8 +9128,7 @@ var CompanionSupervisor = class {
9101
9128
  this.log = opts.log;
9102
9129
  this.hub = opts.hub;
9103
9130
  this.claudeCode = opts.claudeCode ?? true;
9104
- this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
9105
- this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
9131
+ this.probePresence = opts.probePresence ?? probeHarnessPresence;
9106
9132
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
9107
9133
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
9108
9134
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -9800,10 +9826,11 @@ var CompanionSupervisor = class {
9800
9826
  // over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
9801
9827
  // wrapped anyway so a harness refresh can never take a heartbeat down. Called on
9802
9828
  // the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
9803
- async refreshHarnessStatuses() {
9829
+ async refreshHarnessStatuses(presence = {}) {
9804
9830
  try {
9805
9831
  const signals = await probeHarnessSignals(this.config, {
9806
- probeClaudePresence: this.probeClaudePresence
9832
+ probePresence: this.probePresence,
9833
+ presence
9807
9834
  });
9808
9835
  this.harnessSignals = signals;
9809
9836
  this.harnessVersions = {
@@ -9860,18 +9887,24 @@ var CompanionSupervisor = class {
9860
9887
  // reachable/valid input).
9861
9888
  async enableHarness(input) {
9862
9889
  let next;
9890
+ let checkedPresence = null;
9863
9891
  if (input.runtime === "claude-code") {
9864
- if (!await this.probeClaudePresence()) {
9892
+ const presence = input.presence ?? await this.probePresence("claude-code");
9893
+ if (presence.status !== "present") {
9865
9894
  return {
9866
9895
  ok: false,
9867
- error: "Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
9896
+ error: presenceReason("claude-code", presence.status),
9897
+ presence: true
9868
9898
  };
9869
9899
  }
9900
+ checkedPresence = { runtime: "claude-code", result: presence };
9870
9901
  next = { ...this.config, claudeCode: { enabled: true } };
9871
9902
  } else if (input.runtime === "codex") {
9872
- if (!await this.probeCodexPresence()) {
9873
- return { ok: false, error: absentLine("codex") };
9903
+ const presence = input.presence ?? await this.probePresence("codex");
9904
+ if (presence.status !== "present") {
9905
+ return { ok: false, error: presenceReason("codex", presence.status), presence: true };
9874
9906
  }
9907
+ checkedPresence = { runtime: "codex", result: presence };
9875
9908
  next = { ...this.config, codex: { enabled: true } };
9876
9909
  } else {
9877
9910
  const serverUrl = input.serverUrl.trim();
@@ -9895,7 +9928,9 @@ var CompanionSupervisor = class {
9895
9928
  this.config = next;
9896
9929
  saveConfig(next);
9897
9930
  this.rebuildDispatchers();
9898
- await this.refreshHarnessStatuses();
9931
+ await this.refreshHarnessStatuses(
9932
+ checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
9933
+ );
9899
9934
  this.kickHeartbeat();
9900
9935
  return { ok: true };
9901
9936
  }
@@ -9959,9 +9994,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
9959
9994
  }
9960
9995
  function defaultReexec() {
9961
9996
  clearRuntimeState();
9962
- void import("child_process").then(({ spawn: spawn6 }) => {
9997
+ void import("child_process").then(({ spawn: spawn5 }) => {
9963
9998
  try {
9964
- const child = spawn6(process.execPath, process.argv.slice(1), {
9999
+ const child = spawn5(process.execPath, process.argv.slice(1), {
9965
10000
  stdio: "inherit",
9966
10001
  detached: false
9967
10002
  });
@@ -10102,7 +10137,8 @@ async function createCompanionRuntime(opts = {}) {
10102
10137
  if (!claim.acquired) {
10103
10138
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
10104
10139
  }
10105
- process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
10140
+ const clearRuntimeOnExit = () => clearRuntimeStateIfOurs(instanceId);
10141
+ process.on("exit", clearRuntimeOnExit);
10106
10142
  const hub = new CompanionStateHub({
10107
10143
  // CT29: one device, one base URL — the cabane instance this device is paired
10108
10144
  // with. The dashboard's connection line shows it.
@@ -10124,49 +10160,73 @@ async function createCompanionRuntime(opts = {}) {
10124
10160
  const currentConfig = supervisor.currentConfig();
10125
10161
  const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
10126
10162
  const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
10127
- const verdict = await shakeOutHarness(runtime, candidate);
10128
- if (verdict === "absent") return { ok: false, error: absentLine(runtime) };
10163
+ const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
10164
+ const verdict = await shakeOutHarness(runtime, candidate, {
10165
+ ...presence ? { presence } : {}
10166
+ });
10167
+ if (verdict === "absent" || verdict === "unusable") {
10168
+ return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
10169
+ }
10129
10170
  const result = await supervisor.enableHarness(
10130
- runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
10171
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
10131
10172
  );
10132
10173
  if (!result.ok) return { ok: false, error: result.error };
10133
10174
  return { ok: true, message: connectedLine(runtime, verdict) };
10134
10175
  };
10135
- const control = await startControlServer({
10136
- status: () => hub.statusJson(),
10137
- connect: async (runtime, serverUrl) => {
10138
- const result = await connectHarness(runtime, serverUrl);
10139
- return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
10140
- },
10141
- stop: () => void supervisor.requestStop()
10142
- });
10176
+ let control = null;
10143
10177
  let dashboard = null;
10144
- if (opts.dashboard) {
10145
- const preferredPort = opts.port ?? cfg.dashboardPort;
10146
- dashboard = await startDashboard({
10147
- supervisor,
10148
- hub,
10149
- ...preferredPort !== void 0 ? { port: preferredPort } : {}
10178
+ try {
10179
+ control = await startControlServer({
10180
+ status: () => hub.statusJson(),
10181
+ connect: async (runtime, serverUrl) => {
10182
+ const result = await connectHarness(runtime, serverUrl);
10183
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
10184
+ },
10185
+ stop: () => void supervisor.requestStop()
10186
+ });
10187
+ if (opts.dashboard) {
10188
+ const preferredPort = opts.port ?? cfg.dashboardPort;
10189
+ dashboard = await startDashboard({
10190
+ supervisor,
10191
+ hub,
10192
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
10193
+ });
10194
+ hub.setDashboardUrl(dashboard.url);
10195
+ }
10196
+ writeRuntimeState({
10197
+ pid: process.pid,
10198
+ socket: control.path,
10199
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
10200
+ startedAt,
10201
+ // SJ495: the daemon launcher sets this env on the detached child, so the
10202
+ // marker records whether this companion is backgrounded (foreground start
10203
+ // leaves it unset → false).
10204
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
10205
+ instanceId
10206
+ });
10207
+ clearCrash();
10208
+ } catch (err) {
10209
+ clearRuntimeStateIfOurs(instanceId);
10210
+ process.removeListener("exit", clearRuntimeOnExit);
10211
+ await Promise.allSettled([
10212
+ supervisor.shutdown(),
10213
+ ...control ? [control.close()] : [],
10214
+ ...dashboard ? [dashboard.close()] : []
10215
+ ]);
10216
+ recordCrash({
10217
+ reason: err instanceof Error ? err.message : String(err),
10218
+ ...errorCode(err) ? { code: errorCode(err) } : {},
10219
+ origin: "startup",
10220
+ at: (/* @__PURE__ */ new Date()).toISOString()
10150
10221
  });
10151
- hub.setDashboardUrl(dashboard.url);
10222
+ throw err;
10152
10223
  }
10153
- writeRuntimeState({
10154
- pid: process.pid,
10155
- socket: control.path,
10156
- ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
10157
- startedAt,
10158
- // SJ495: the daemon launcher sets this env on the detached child, so the
10159
- // marker records whether this companion is backgrounded (foreground start
10160
- // leaves it unset → false).
10161
- daemon: process.env.CABANE_COMPANION_DAEMON === "1",
10162
- instanceId
10163
- });
10164
- clearCrash();
10165
10224
  let stopped = false;
10166
10225
  const stop2 = async () => {
10167
10226
  if (stopped) return;
10168
10227
  stopped = true;
10169
10228
  clearRuntimeStateIfOurs(instanceId);
10229
+ process.removeListener("exit", clearRuntimeOnExit);
10170
10230
  try {
10171
10231
  await supervisor.shutdown();
10172
10232
  await closeSurfaces(control, dashboard);
@@ -10192,7 +10252,7 @@ async function closeSurfaces(control, dashboard) {
10192
10252
  }
10193
10253
 
10194
10254
  // src/commands/daemon.ts
10195
- import { spawn as spawn5 } from "child_process";
10255
+ import { spawn as spawn4 } from "child_process";
10196
10256
  import { closeSync as closeSync3, mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
10197
10257
 
10198
10258
  // src/cli-entry.ts
@@ -10299,7 +10359,7 @@ function defaultSpawnDetached(args) {
10299
10359
  mkdirSync13(cabaneDir(), { recursive: true });
10300
10360
  const logFd = openSync3(companionLogPath(), "a");
10301
10361
  try {
10302
- return spawn5(process.execPath, [cliPath, ...args], {
10362
+ return spawn4(process.execPath, [cliPath, ...args], {
10303
10363
  detached: true,
10304
10364
  stdio: ["ignore", logFd, logFd],
10305
10365
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -10397,14 +10457,15 @@ function reportAlreadyRunning(pid) {
10397
10457
  write("Connect a harness to the running companion: cabane-companion connect claude-code");
10398
10458
  }
10399
10459
  async function collectHarnessChoices(interactive) {
10400
- const [claudePresent, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
10401
- claudeOnPath().catch(() => false),
10402
- probeCliVersion("claude").catch(() => null),
10403
- probeCliVersion("codex").catch(() => null),
10460
+ const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
10461
+ probeHarnessPresence("claude-code").catch(() => ({ status: "absent" })),
10462
+ probeHarnessPresence("codex").catch(() => ({ status: "absent" })),
10404
10463
  probeDefaultOpencodeVersion()
10405
10464
  ]);
10465
+ const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
10466
+ const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
10406
10467
  const found = [
10407
- ...claudePresent ? [{ runtime: "claude-code", version: claudeVersion }] : [],
10468
+ ...claudeVersion ? [{ runtime: "claude-code", version: claudeVersion }] : [],
10408
10469
  ...codexVersion ? [{ runtime: "codex", version: codexVersion }] : [],
10409
10470
  ...opencodeVersion ? [
10410
10471
  {