@cabane/companion 0.6.20 → 0.6.21

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
@@ -295,6 +295,26 @@ var companionConfigSchema = z2.object({
295
295
  opencode: z2.object({
296
296
  serverUrl: z2.string().url()
297
297
  }).strict().optional(),
298
+ // CT1082: the Claude Code runtime, when the user connected it on this machine.
299
+ // The change this task exists for: Claude Code used to be the one harness a
300
+ // device exposed with no configuration — a `claude --version` exit-0 was taken as
301
+ // consent — and it is now the third config-driven harness, opted into exactly
302
+ // like codex. `claude` on PATH is still required (you can't run what isn't
303
+ // installed), but presence alone no longer exposes anything: the manifest
304
+ // advertises claude-code only when this block says so AND the binary is there.
305
+ //
306
+ // The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
307
+ // Absent means the config predates this task — a device whose user was never
308
+ // asked — and the migration grandfathers it on first start. So every config
309
+ // written from here on carries the block explicitly, including a freshly-paired
310
+ // one, which starts at `enabled: false`: a new device is connected to nothing
311
+ // until its user says otherwise.
312
+ //
313
+ // Not to be confused with the per-agent `agents.<key>.claudeCode.autoMemory`
314
+ // above — that's one agent's memory switch, this is the device's connected set.
315
+ claudeCode: z2.object({
316
+ enabled: z2.boolean().optional()
317
+ }).strict().optional(),
298
318
  // CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
299
319
  // opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
300
320
  // the `@openai/codex-sdk` spawns per turn — so the config is just an opt-in flag,
@@ -311,6 +331,13 @@ var companionConfigSchema = z2.object({
311
331
  function isCodexEnabled(cfg) {
312
332
  return !!cfg.codex && cfg.codex.enabled !== false;
313
333
  }
334
+ function isClaudeCodeConnected(cfg) {
335
+ return !!cfg.claudeCode && cfg.claudeCode.enabled !== false;
336
+ }
337
+ function migrateConnectedHarnesses(cfg, claudeOnPath2) {
338
+ if (cfg.claudeCode !== void 0) return null;
339
+ return { ...cfg, claudeCode: { enabled: claudeOnPath2 } };
340
+ }
314
341
  function localAgentConfig(cfg, agent) {
315
342
  const map = cfg.agents ?? {};
316
343
  return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
@@ -352,31 +379,38 @@ function loadConfig() {
352
379
  function loadConfigTolerant() {
353
380
  const path = configPath();
354
381
  const empty = {};
355
- if (!existsSync(path)) return { local: empty, note: null };
382
+ const fresh = { local: empty, note: null, hadPriorConfig: false };
383
+ if (!existsSync(path)) return fresh;
356
384
  let raw;
357
385
  try {
358
386
  raw = readFileSync(path, "utf8");
359
387
  } catch {
360
- return { local: empty, note: null };
388
+ return fresh;
361
389
  }
362
- if (raw.trim().length === 0) return { local: empty, note: null };
390
+ if (raw.trim().length === 0) return fresh;
363
391
  let parsed;
364
392
  try {
365
393
  parsed = JSON.parse(raw);
366
394
  } catch {
367
- return { local: empty, note: `${path} was unreadable (invalid JSON) and has been reset.` };
395
+ return {
396
+ local: empty,
397
+ note: `${path} was unreadable (invalid JSON) and has been reset.`,
398
+ hadPriorConfig: true
399
+ };
368
400
  }
369
401
  const strict = companionConfigSchema.safeParse(parsed);
370
402
  if (strict.success) {
371
- const { agents: agents2, dashboardPort, autoOpen, logLevel } = strict.data;
403
+ const { agents: agents2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
372
404
  return {
373
405
  local: {
374
406
  ...agents2 !== void 0 ? { agents: agents2 } : {},
375
407
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
376
408
  ...autoOpen !== void 0 ? { autoOpen } : {},
377
- ...logLevel !== void 0 ? { logLevel } : {}
409
+ ...logLevel !== void 0 ? { logLevel } : {},
410
+ ...claudeCode2 !== void 0 ? { claudeCode: claudeCode2 } : {}
378
411
  },
379
- note: null
412
+ note: null,
413
+ hadPriorConfig: true
380
414
  };
381
415
  }
382
416
  const obj = parsed && typeof parsed === "object" ? parsed : {};
@@ -388,9 +422,12 @@ function loadConfigTolerant() {
388
422
  if (obj.logLevel === "warn" || obj.logLevel === "info" || obj.logLevel === "debug") {
389
423
  local.logLevel = obj.logLevel;
390
424
  }
425
+ const claudeCode = companionConfigSchema.shape.claudeCode.safeParse(obj.claudeCode);
426
+ if (claudeCode.success && claudeCode.data !== void 0) local.claudeCode = claudeCode.data;
391
427
  return {
392
428
  local,
393
- note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`
429
+ note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`,
430
+ hadPriorConfig: true
394
431
  };
395
432
  }
396
433
  function saveConfig(cfg) {
@@ -592,26 +629,43 @@ async function codexOnPath() {
592
629
  ]);
593
630
  return version !== null;
594
631
  }
595
- async function ensureRuntimeAvailable(cfg, deps = {}) {
632
+ async function requireStartConfig(deps = {}) {
633
+ const requireCfg = deps.requireCfg ?? requireConfig;
634
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
635
+ const save2 = deps.save ?? saveConfig;
636
+ const cfg = requireCfg();
637
+ const claudeOnPathResult = await probeClaude();
638
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
639
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
640
+ save2(migrated);
641
+ deps.onMigrated?.(migrated, claudeOnPathResult);
642
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
643
+ }
644
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
596
645
  const probeClaude = deps.probeClaude ?? claudeOnPath;
597
646
  const probeCodex = deps.probeCodex ?? codexOnPath;
598
647
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
599
648
  `));
600
- if (await probeClaude()) return;
601
- const alternates = [
602
- ...cfg.opencode ? ["opencode"] : [],
603
- ...isCodexEnabled(cfg) ? ["codex"] : []
649
+ const connected = [
650
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
651
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
652
+ ...cfg.opencode ? ["opencode"] : []
604
653
  ];
605
- if (alternates.length > 0) {
606
- const exposed = alternates.join(" + ");
607
- warn(
608
- `warning: Claude Code isn\u2019t on your PATH, so this device exposes ${exposed} only \u2014 it advertises nothing else, so a Claude-model agent won\u2019t be routed here. Assign it agents on models ${exposed} can run, or install Claude Code (\`npm i -g @anthropic-ai/claude-code\`) and log in if you want this device to run Claude models too.`
609
- );
654
+ if (connected.length > 0) {
655
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
656
+ warn(
657
+ "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."
658
+ );
659
+ }
610
660
  return;
611
661
  }
612
- const installedButUnexposed = await probeCodex() ? 'Codex is installed on this machine but not exposed \u2014 enable it with `{ "codex": { "enabled": true } }` in ~/.cabane/config.json. Otherwise, expose a harness:\n' : "Expose at least one:\n";
613
- throw new CompanionError(
614
- "This device exposes no harness, so no agent turn can run here. " + installedButUnexposed + ' \u2022 Claude Code \u2014 install it (`npm i -g @anthropic-ai/claude-code`) and log in (`claude`, then follow the prompts); it\u2019s exposed automatically once `claude` is on your PATH.\n \u2022 Codex \u2014 install the Codex CLI and log in (`codex login`), then add `{ "codex": { "enabled": true } }` to ~/.cabane/config.json.\n \u2022 opencode \u2014 run `opencode serve --port 4096`, then add `{ "opencode": { "serverUrl": "http://127.0.0.1:4096" } }` to ~/.cabane/config.json.\nThen run `cabane-companion start` again (see the companion README).'
662
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
663
+ const installed = [
664
+ ...claudeInstalled ? ["Claude Code"] : [],
665
+ ...codexInstalled ? ["Codex"] : []
666
+ ];
667
+ warn(
668
+ "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" : "it"} in the Companion dashboard (or in cabane, Settings \u2192 Devices) 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 in the Companion dashboard.")
615
669
  );
616
670
  }
617
671
 
@@ -709,15 +763,28 @@ function trimSlash(s) {
709
763
  var STARTUP_TIMEOUT_MS = 8e3;
710
764
  var POLL_INTERVAL_MS = 150;
711
765
  async function startDaemon(opts = {}, deps = {}) {
712
- const ensureRuntime = deps.ensureRuntime ?? ensureRuntimeAvailable;
713
- const requireCfg = deps.requireCfg ?? requireConfig;
766
+ const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
714
767
  const readState = deps.readState ?? readLiveRuntimeState;
715
768
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
716
769
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
717
770
  const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
718
771
  const now = deps.now ?? (() => Date.now());
719
- const cfg = requireCfg();
720
- await ensureRuntime(cfg);
772
+ const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
773
+ // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
774
+ ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
775
+ ...deps.probeClaude ? { probeClaude: deps.probeClaude } : {},
776
+ ...deps.save ? { save: deps.save } : {},
777
+ // The child won't log it (the block is written by then), and this process owns
778
+ // the user's terminal — so the one-time record is one line, here.
779
+ onMigrated: (_migrated, onPath) => {
780
+ if (onPath) {
781
+ process.stdout.write(
782
+ "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
783
+ );
784
+ }
785
+ }
786
+ });
787
+ await ensureRuntime(cfg, { probeClaude: async () => claudeOnPath2 });
721
788
  const existing = readState();
722
789
  if (existing) {
723
790
  if (await verify(existing) !== "stale") {
@@ -992,8 +1059,16 @@ function resolvePairBaseUrl(server) {
992
1059
  return raw;
993
1060
  }
994
1061
  function writePairedConfig(paired) {
995
- const { local, note } = loadConfigTolerant();
1062
+ const { local, note, hadPriorConfig } = loadConfigTolerant();
996
1063
  const config = {
1064
+ // CT1082: a device pairs connected to NOTHING — `claudeCode: { enabled: false }`
1065
+ // written explicitly, not left absent, because absence is exactly what marks a
1066
+ // pre-CT1082 config for the grandfathering migration. Omit it and a brand-new
1067
+ // device gets auto-connected on its first start by the behaviour this task
1068
+ // removes; stamp it on a RE-pair and an upgrading user's working device gets
1069
+ // disconnected instead. So it's written only when there was no config here
1070
+ // before. `local` spreads after, so a re-pair keeps the answer already given.
1071
+ ...hadPriorConfig ? {} : { claudeCode: { enabled: false } },
997
1072
  ...local,
998
1073
  baseUrl: paired.baseUrl,
999
1074
  deviceToken: paired.deviceToken,
@@ -1390,6 +1465,11 @@ function registerRoutes(app, deps) {
1390
1465
  app.post("/api/harnesses/enable", async (c) => {
1391
1466
  const body = await readJson(c);
1392
1467
  const runtime = body.runtime;
1468
+ if (runtime === "claude-code") {
1469
+ const result = await supervisor.enableHarness({ runtime: "claude-code" });
1470
+ if (!result.ok) return c.json({ error: result.error }, 400);
1471
+ return c.json({ ok: true, status: hub.statusJson() });
1472
+ }
1393
1473
  if (runtime === "codex") {
1394
1474
  const result = await supervisor.enableHarness({ runtime: "codex" });
1395
1475
  if (!result.ok) return c.json({ error: result.error }, 400);
@@ -7671,7 +7751,9 @@ var LABELS = {
7671
7751
  function deriveHarnessSnapshot(signals) {
7672
7752
  const advertised = new Set(
7673
7753
  buildCompanionManifest({
7674
- claudeCode: signals.claudeOnPath,
7754
+ // CT1082: connected AND installed — the manifest's own rule, restated here
7755
+ // through the same function rather than re-decided.
7756
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7675
7757
  opencode: signals.opencodeConfigured,
7676
7758
  codex: signals.codexEnabled
7677
7759
  }).runtimes.map((r) => r.name)
@@ -7684,20 +7766,40 @@ function deriveHarnessSnapshot(signals) {
7684
7766
  return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7685
7767
  }
7686
7768
  function deriveClaudeCode(signals, manifestHas) {
7687
- const base = { runtime: "claude-code", label: LABELS["claude-code"], enable: null };
7769
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7688
7770
  if (manifestHas) {
7689
7771
  return {
7690
7772
  ...base,
7691
7773
  state: "exposed",
7692
7774
  version: signals.claudeVersion,
7693
- detail: "Claude Code is installed and exposed to Cabane."
7775
+ detail: "Claude Code is connected and exposed to Cabane.",
7776
+ enable: null
7777
+ };
7778
+ }
7779
+ if (signals.claudeCodeConnected) {
7780
+ return {
7781
+ ...base,
7782
+ state: "needs_attention",
7783
+ version: null,
7784
+ 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.",
7785
+ enable: null
7786
+ };
7787
+ }
7788
+ if (signals.claudeOnPath) {
7789
+ return {
7790
+ ...base,
7791
+ state: "detected_not_exposed",
7792
+ version: signals.claudeVersion,
7793
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7794
+ enable: "claude-code"
7694
7795
  };
7695
7796
  }
7696
7797
  return {
7697
7798
  ...base,
7698
7799
  state: "not_detected",
7699
7800
  version: null,
7700
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then it appears here."
7801
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7802
+ enable: null
7701
7803
  };
7702
7804
  }
7703
7805
  function deriveCodex(signals, manifestHas) {
@@ -7765,6 +7867,9 @@ function deriveOpencode(signals, manifestHas) {
7765
7867
  enable: "opencode"
7766
7868
  };
7767
7869
  }
7870
+ function detectedRuntimesFor(snapshot) {
7871
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7872
+ }
7768
7873
  var PROBE_TIMEOUT_MS2 = 4e3;
7769
7874
  async function probeHarnessSignals(cfg, deps = {}) {
7770
7875
  const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
@@ -7781,6 +7886,9 @@ async function probeHarnessSignals(cfg, deps = {}) {
7781
7886
  return {
7782
7887
  claudeOnPath: claudeOnPathResult,
7783
7888
  claudeVersion,
7889
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7890
+ // the manifest gate and the probe above is only a suggestion.
7891
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
7784
7892
  // A parseable `codex --version` is our presence signal (presence alone never
7785
7893
  // exposes codex; its config flag is the manifest gate either way).
7786
7894
  codexOnPath: codexVersion !== null,
@@ -8189,6 +8297,10 @@ var CompanionSupervisor = class {
8189
8297
  // can never disagree with what the manifest advertises. Null until the first
8190
8298
  // probe (the heartbeat then falls back to the boot `claudeCode`).
8191
8299
  harnessSignals = null;
8300
+ // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
8301
+ // NOT the cached beat signal — someone connecting right after installing Claude
8302
+ // Code shouldn't be refused by a snapshot up to a heartbeat old.
8303
+ probeClaudePresence;
8192
8304
  exitFn;
8193
8305
  reexecFn;
8194
8306
  dispatcherFactory;
@@ -8219,6 +8331,7 @@ var CompanionSupervisor = class {
8219
8331
  this.log = opts.log;
8220
8332
  this.hub = opts.hub;
8221
8333
  this.claudeCode = opts.claudeCode ?? true;
8334
+ this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
8222
8335
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
8223
8336
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
8224
8337
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -8281,11 +8394,10 @@ var CompanionSupervisor = class {
8281
8394
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
8282
8395
  // configured an `opencode serve`.
8283
8396
  manifest: buildCompanionManifest({
8284
- // CT586: prefer the live re-probe's presence; fall back to the boot probe
8285
- // until the first re-probe lands. Same exit-0 `claude --version` signal
8286
- // either way, so the manifest's claude-code advertising is unchanged in
8287
- // meaning — just refreshed on the beat instead of frozen at boot.
8288
- claudeCode: this.claudeCodePresent(),
8397
+ // CT1082: connected AND installed. Presence is the live re-probe (CT586),
8398
+ // falling back to the boot probe until the first one lands; consent is the
8399
+ // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
8400
+ claudeCode: this.claudeCodeOffered(),
8289
8401
  opencode: !!this.config.opencode?.serverUrl,
8290
8402
  // CT481: advertise codex when the operator enabled it (config-gated,
8291
8403
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -8303,7 +8415,13 @@ var CompanionSupervisor = class {
8303
8415
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
8304
8416
  // A null (probe failed / no opencode) omits the field, and the server then
8305
8417
  // leaves this device's stored availability untouched.
8306
- ...opencodeModels !== null ? { models: opencodeModels } : {}
8418
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
8419
+ // CT1082: what this machine has that the user hasn't connected, so the web
8420
+ // UI can offer it without a companion round-trip. Sent only once a probe has
8421
+ // actually landed (`harnessSignals` non-null) — an absent field means "we
8422
+ // didn't look this beat" and leaves the server's stored suggestion alone,
8423
+ // the same fail-soft contract `models` keeps.
8424
+ ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
8307
8425
  });
8308
8426
  this.hub.setDevice({ deviceId: res.deviceId });
8309
8427
  this.deviceId = res.deviceId;
@@ -8508,13 +8626,19 @@ var CompanionSupervisor = class {
8508
8626
  "companion: stopped agent (unassigned)"
8509
8627
  );
8510
8628
  }
8511
- // CT833: is Claude Code on this machine right now? The ONE signal both the
8512
- // heartbeat manifest and the dispatcher's adapter registry read, so what the
8513
- // device advertises and what it can actually select can't disagree. Live (the
8514
- // per-beat re-probe), falling back to the boot probe until the first one lands.
8629
+ // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
8630
+ // falling back to the boot probe until the first one lands. Presence ONLY —
8631
+ // CT1082 split presence from exposure, so nothing routes on this directly.
8515
8632
  claudeCodePresent() {
8516
8633
  return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8517
8634
  }
8635
+ // CT1082: does this device OFFER claude-code — connected by its user and actually
8636
+ // installed? The ONE signal both the heartbeat manifest and the dispatcher's
8637
+ // adapter registry read, so what the device advertises and what it can select
8638
+ // can't disagree (the CT833 invariant, now with consent in front of it).
8639
+ claudeCodeOffered() {
8640
+ return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8641
+ }
8518
8642
  buildDispatcher(ctx) {
8519
8643
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8520
8644
  return new Dispatcher({
@@ -8532,10 +8656,12 @@ var CompanionSupervisor = class {
8532
8656
  }),
8533
8657
  runConfig: ctx.runConfig,
8534
8658
  log: this.log,
8535
- // CT833: register the claude-code adapter only when `claude` is actually on
8536
- // this machine — read per turn (not captured here), so a harness installed
8537
- // after boot works on the next turn exactly as it appears on the next beat.
8538
- claudeCodeAvailable: () => this.claudeCodePresent(),
8659
+ // CT833: register the claude-code adapter only when this device actually
8660
+ // offers claude-code — read per turn (not captured here), so a harness
8661
+ // installed or connected after boot works on the next turn exactly as it
8662
+ // appears on the next beat. CT1082: "offers" now means connected as well as
8663
+ // installed, so a disconnected harness can't be selected either.
8664
+ claudeCodeAvailable: () => this.claudeCodeOffered(),
8539
8665
  // CT270: the opencode server URL (operator-configured), when this device
8540
8666
  // offers the opencode runtime. Threaded so an opencode turn selects the
8541
8667
  // opencode adapter; unset leaves the device claude-code-only.
@@ -8841,9 +8967,12 @@ var CompanionSupervisor = class {
8841
8967
  async recheckHarnesses() {
8842
8968
  await this.refreshHarnessStatuses();
8843
8969
  }
8844
- // Friendly enable for the two config-driven harnesses — flip the flag the app
8845
- // owns in `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose
8846
- // ONLY: it never installs a binary and never drives a login (BYO — Decided).
8970
+ // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8971
+ // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8972
+ // never installs a binary and never drives a login (BYO — Decided).
8973
+ // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
8974
+ // on PATH. This is the callable the terminal offer ("We found Claude Code.
8975
+ // Connect it? [Y/n]") and the web pairing flow both wire to.
8847
8976
  // - codex: set `codex.enabled` (the CLI + `codex login` remain the user's).
8848
8977
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
8849
8978
  // so we never advertise a serve that isn't there. An unreachable URL is a
@@ -8853,7 +8982,15 @@ var CompanionSupervisor = class {
8853
8982
  // reachable/valid input).
8854
8983
  async enableHarness(input) {
8855
8984
  let next;
8856
- if (input.runtime === "codex") {
8985
+ if (input.runtime === "claude-code") {
8986
+ if (!await this.probeClaudePresence()) {
8987
+ return {
8988
+ ok: false,
8989
+ 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."
8990
+ };
8991
+ }
8992
+ next = { ...this.config, claudeCode: { enabled: true } };
8993
+ } else if (input.runtime === "codex") {
8857
8994
  next = { ...this.config, codex: { enabled: true } };
8858
8995
  } else {
8859
8996
  const serverUrl = input.serverUrl.trim();
@@ -9055,13 +9192,17 @@ function clearCrash() {
9055
9192
  async function createCompanionRuntime(opts = {}) {
9056
9193
  const log = getLogger();
9057
9194
  installProcessSafetyNet(log);
9058
- const probeClaude = opts.probeClaude ?? claudeOnPath;
9059
9195
  let cfg;
9060
9196
  let claudeCode;
9061
9197
  try {
9062
- cfg = requireConfig();
9063
- claudeCode = await probeClaude();
9064
- await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
9198
+ ({ cfg, claudeOnPath: claudeCode } = await requireStartConfig({
9199
+ ...opts.probeClaude ? { probeClaude: opts.probeClaude } : {},
9200
+ onMigrated: (migrated, onPath) => log.info(
9201
+ { claudeCode: isClaudeCodeConnected(migrated) },
9202
+ onPath ? "companion: carried Claude Code over as a connected harness on this device (connectors are now chosen, not detected)" : "companion: no Claude Code on PATH, so this device starts with it disconnected (connectors are now chosen, not detected)"
9203
+ )
9204
+ }));
9205
+ await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
9065
9206
  } catch (err) {
9066
9207
  recordCrash({
9067
9208
  reason: err instanceof Error ? err.message : String(err),
@@ -9073,7 +9214,9 @@ async function createCompanionRuntime(opts = {}) {
9073
9214
  }
9074
9215
  if (cfg.logLevel) log.level = cfg.logLevel;
9075
9216
  const harnessVersions = await probeHarnessVersions({
9076
- claudeCode,
9217
+ // CT1082: versions are probed for the runtimes this device OFFERS, and
9218
+ // claude-code is offered only when connected as well as installed.
9219
+ claudeCode: claudeCode && isClaudeCodeConnected(cfg),
9077
9220
  opencodeServerUrl: cfg.opencode?.serverUrl,
9078
9221
  codex: isCodexEnabled(cfg)
9079
9222
  });
@@ -161,6 +161,26 @@ var companionConfigSchema = z2.object({
161
161
  opencode: z2.object({
162
162
  serverUrl: z2.string().url()
163
163
  }).strict().optional(),
164
+ // CT1082: the Claude Code runtime, when the user connected it on this machine.
165
+ // The change this task exists for: Claude Code used to be the one harness a
166
+ // device exposed with no configuration — a `claude --version` exit-0 was taken as
167
+ // consent — and it is now the third config-driven harness, opted into exactly
168
+ // like codex. `claude` on PATH is still required (you can't run what isn't
169
+ // installed), but presence alone no longer exposes anything: the manifest
170
+ // advertises claude-code only when this block says so AND the binary is there.
171
+ //
172
+ // The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
173
+ // Absent means the config predates this task — a device whose user was never
174
+ // asked — and the migration grandfathers it on first start. So every config
175
+ // written from here on carries the block explicitly, including a freshly-paired
176
+ // one, which starts at `enabled: false`: a new device is connected to nothing
177
+ // until its user says otherwise.
178
+ //
179
+ // Not to be confused with the per-agent `agents.<key>.claudeCode.autoMemory`
180
+ // above — that's one agent's memory switch, this is the device's connected set.
181
+ claudeCode: z2.object({
182
+ enabled: z2.boolean().optional()
183
+ }).strict().optional(),
164
184
  // CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
165
185
  // opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
166
186
  // the `@openai/codex-sdk` spawns per turn — so the config is just an opt-in flag,
@@ -211,31 +231,38 @@ function loadConfig() {
211
231
  function loadConfigTolerant() {
212
232
  const path = configPath();
213
233
  const empty = {};
214
- if (!existsSync(path)) return { local: empty, note: null };
234
+ const fresh = { local: empty, note: null, hadPriorConfig: false };
235
+ if (!existsSync(path)) return fresh;
215
236
  let raw;
216
237
  try {
217
238
  raw = readFileSync(path, "utf8");
218
239
  } catch {
219
- return { local: empty, note: null };
240
+ return fresh;
220
241
  }
221
- if (raw.trim().length === 0) return { local: empty, note: null };
242
+ if (raw.trim().length === 0) return fresh;
222
243
  let parsed;
223
244
  try {
224
245
  parsed = JSON.parse(raw);
225
246
  } catch {
226
- return { local: empty, note: `${path} was unreadable (invalid JSON) and has been reset.` };
247
+ return {
248
+ local: empty,
249
+ note: `${path} was unreadable (invalid JSON) and has been reset.`,
250
+ hadPriorConfig: true
251
+ };
227
252
  }
228
253
  const strict = companionConfigSchema.safeParse(parsed);
229
254
  if (strict.success) {
230
- const { agents: agents2, dashboardPort, autoOpen, logLevel } = strict.data;
255
+ const { agents: agents2, dashboardPort, autoOpen, logLevel, claudeCode: claudeCode2 } = strict.data;
231
256
  return {
232
257
  local: {
233
258
  ...agents2 !== void 0 ? { agents: agents2 } : {},
234
259
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
235
260
  ...autoOpen !== void 0 ? { autoOpen } : {},
236
- ...logLevel !== void 0 ? { logLevel } : {}
261
+ ...logLevel !== void 0 ? { logLevel } : {},
262
+ ...claudeCode2 !== void 0 ? { claudeCode: claudeCode2 } : {}
237
263
  },
238
- note: null
264
+ note: null,
265
+ hadPriorConfig: true
239
266
  };
240
267
  }
241
268
  const obj = parsed && typeof parsed === "object" ? parsed : {};
@@ -247,9 +274,12 @@ function loadConfigTolerant() {
247
274
  if (obj.logLevel === "warn" || obj.logLevel === "info" || obj.logLevel === "debug") {
248
275
  local.logLevel = obj.logLevel;
249
276
  }
277
+ const claudeCode = companionConfigSchema.shape.claudeCode.safeParse(obj.claudeCode);
278
+ if (claudeCode.success && claudeCode.data !== void 0) local.claudeCode = claudeCode.data;
250
279
  return {
251
280
  local,
252
- note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`
281
+ note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`,
282
+ hadPriorConfig: true
253
283
  };
254
284
  }
255
285
  function saveConfig(cfg) {
@@ -375,8 +405,16 @@ function resolvePairBaseUrl(server) {
375
405
  return raw;
376
406
  }
377
407
  function writePairedConfig(paired) {
378
- const { local, note } = loadConfigTolerant();
408
+ const { local, note, hadPriorConfig } = loadConfigTolerant();
379
409
  const config = {
410
+ // CT1082: a device pairs connected to NOTHING — `claudeCode: { enabled: false }`
411
+ // written explicitly, not left absent, because absence is exactly what marks a
412
+ // pre-CT1082 config for the grandfathering migration. Omit it and a brand-new
413
+ // device gets auto-connected on its first start by the behaviour this task
414
+ // removes; stamp it on a RE-pair and an upgrading user's working device gets
415
+ // disconnected instead. So it's written only when there was no config here
416
+ // before. `local` spreads after, so a re-pair keeps the answer already given.
417
+ ...hadPriorConfig ? {} : { claudeCode: { enabled: false } },
380
418
  ...local,
381
419
  baseUrl: paired.baseUrl,
382
420
  deviceToken: paired.deviceToken,
package/dist/runtime.js CHANGED
@@ -288,6 +288,26 @@ var companionConfigSchema = z2.object({
288
288
  opencode: z2.object({
289
289
  serverUrl: z2.string().url()
290
290
  }).strict().optional(),
291
+ // CT1082: the Claude Code runtime, when the user connected it on this machine.
292
+ // The change this task exists for: Claude Code used to be the one harness a
293
+ // device exposed with no configuration — a `claude --version` exit-0 was taken as
294
+ // consent — and it is now the third config-driven harness, opted into exactly
295
+ // like codex. `claude` on PATH is still required (you can't run what isn't
296
+ // installed), but presence alone no longer exposes anything: the manifest
297
+ // advertises claude-code only when this block says so AND the binary is there.
298
+ //
299
+ // The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
300
+ // Absent means the config predates this task — a device whose user was never
301
+ // asked — and the migration grandfathers it on first start. So every config
302
+ // written from here on carries the block explicitly, including a freshly-paired
303
+ // one, which starts at `enabled: false`: a new device is connected to nothing
304
+ // until its user says otherwise.
305
+ //
306
+ // Not to be confused with the per-agent `agents.<key>.claudeCode.autoMemory`
307
+ // above — that's one agent's memory switch, this is the device's connected set.
308
+ claudeCode: z2.object({
309
+ enabled: z2.boolean().optional()
310
+ }).strict().optional(),
291
311
  // CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
292
312
  // opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
293
313
  // the `@openai/codex-sdk` spawns per turn — so the config is just an opt-in flag,
@@ -304,6 +324,13 @@ var companionConfigSchema = z2.object({
304
324
  function isCodexEnabled(cfg) {
305
325
  return !!cfg.codex && cfg.codex.enabled !== false;
306
326
  }
327
+ function isClaudeCodeConnected(cfg) {
328
+ return !!cfg.claudeCode && cfg.claudeCode.enabled !== false;
329
+ }
330
+ function migrateConnectedHarnesses(cfg, claudeOnPath2) {
331
+ if (cfg.claudeCode !== void 0) return null;
332
+ return { ...cfg, claudeCode: { enabled: claudeOnPath2 } };
333
+ }
307
334
  function localAgentConfig(cfg, agent) {
308
335
  const map = cfg.agents ?? {};
309
336
  return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
@@ -739,6 +766,11 @@ function registerRoutes(app, deps) {
739
766
  app.post("/api/harnesses/enable", async (c) => {
740
767
  const body = await readJson(c);
741
768
  const runtime = body.runtime;
769
+ if (runtime === "claude-code") {
770
+ const result = await supervisor.enableHarness({ runtime: "claude-code" });
771
+ if (!result.ok) return c.json({ error: result.error }, 400);
772
+ return c.json({ ok: true, status: hub.statusJson() });
773
+ }
742
774
  if (runtime === "codex") {
743
775
  const result = await supervisor.enableHarness({ runtime: "codex" });
744
776
  if (!result.ok) return c.json({ error: result.error }, 400);
@@ -1012,26 +1044,43 @@ async function codexOnPath() {
1012
1044
  ]);
1013
1045
  return version !== null;
1014
1046
  }
1015
- async function ensureRuntimeAvailable(cfg, deps = {}) {
1047
+ async function requireStartConfig(deps = {}) {
1048
+ const requireCfg = deps.requireCfg ?? requireConfig;
1049
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
1050
+ const save2 = deps.save ?? saveConfig;
1051
+ const cfg = requireCfg();
1052
+ const claudeOnPathResult = await probeClaude();
1053
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
1054
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
1055
+ save2(migrated);
1056
+ deps.onMigrated?.(migrated, claudeOnPathResult);
1057
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
1058
+ }
1059
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1016
1060
  const probeClaude = deps.probeClaude ?? claudeOnPath;
1017
1061
  const probeCodex = deps.probeCodex ?? codexOnPath;
1018
1062
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
1019
1063
  `));
1020
- if (await probeClaude()) return;
1021
- const alternates = [
1022
- ...cfg.opencode ? ["opencode"] : [],
1023
- ...isCodexEnabled(cfg) ? ["codex"] : []
1064
+ const connected = [
1065
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
1066
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
1067
+ ...cfg.opencode ? ["opencode"] : []
1024
1068
  ];
1025
- if (alternates.length > 0) {
1026
- const exposed = alternates.join(" + ");
1027
- warn(
1028
- `warning: Claude Code isn\u2019t on your PATH, so this device exposes ${exposed} only \u2014 it advertises nothing else, so a Claude-model agent won\u2019t be routed here. Assign it agents on models ${exposed} can run, or install Claude Code (\`npm i -g @anthropic-ai/claude-code\`) and log in if you want this device to run Claude models too.`
1029
- );
1069
+ if (connected.length > 0) {
1070
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
1071
+ warn(
1072
+ "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."
1073
+ );
1074
+ }
1030
1075
  return;
1031
1076
  }
1032
- const installedButUnexposed = await probeCodex() ? 'Codex is installed on this machine but not exposed \u2014 enable it with `{ "codex": { "enabled": true } }` in ~/.cabane/config.json. Otherwise, expose a harness:\n' : "Expose at least one:\n";
1033
- throw new CompanionError(
1034
- "This device exposes no harness, so no agent turn can run here. " + installedButUnexposed + ' \u2022 Claude Code \u2014 install it (`npm i -g @anthropic-ai/claude-code`) and log in (`claude`, then follow the prompts); it\u2019s exposed automatically once `claude` is on your PATH.\n \u2022 Codex \u2014 install the Codex CLI and log in (`codex login`), then add `{ "codex": { "enabled": true } }` to ~/.cabane/config.json.\n \u2022 opencode \u2014 run `opencode serve --port 4096`, then add `{ "opencode": { "serverUrl": "http://127.0.0.1:4096" } }` to ~/.cabane/config.json.\nThen run `cabane-companion start` again (see the companion README).'
1077
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1078
+ const installed = [
1079
+ ...claudeInstalled ? ["Claude Code"] : [],
1080
+ ...codexInstalled ? ["Codex"] : []
1081
+ ];
1082
+ warn(
1083
+ "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" : "it"} in the Companion dashboard (or in cabane, Settings \u2192 Devices) 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 in the Companion dashboard.")
1035
1084
  );
1036
1085
  }
1037
1086
 
@@ -7324,7 +7373,9 @@ var LABELS = {
7324
7373
  function deriveHarnessSnapshot(signals) {
7325
7374
  const advertised = new Set(
7326
7375
  buildCompanionManifest({
7327
- claudeCode: signals.claudeOnPath,
7376
+ // CT1082: connected AND installed — the manifest's own rule, restated here
7377
+ // through the same function rather than re-decided.
7378
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7328
7379
  opencode: signals.opencodeConfigured,
7329
7380
  codex: signals.codexEnabled
7330
7381
  }).runtimes.map((r) => r.name)
@@ -7337,20 +7388,40 @@ function deriveHarnessSnapshot(signals) {
7337
7388
  return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7338
7389
  }
7339
7390
  function deriveClaudeCode(signals, manifestHas) {
7340
- const base = { runtime: "claude-code", label: LABELS["claude-code"], enable: null };
7391
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7341
7392
  if (manifestHas) {
7342
7393
  return {
7343
7394
  ...base,
7344
7395
  state: "exposed",
7345
7396
  version: signals.claudeVersion,
7346
- detail: "Claude Code is installed and exposed to Cabane."
7397
+ detail: "Claude Code is connected and exposed to Cabane.",
7398
+ enable: null
7399
+ };
7400
+ }
7401
+ if (signals.claudeCodeConnected) {
7402
+ return {
7403
+ ...base,
7404
+ state: "needs_attention",
7405
+ version: null,
7406
+ 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.",
7407
+ enable: null
7408
+ };
7409
+ }
7410
+ if (signals.claudeOnPath) {
7411
+ return {
7412
+ ...base,
7413
+ state: "detected_not_exposed",
7414
+ version: signals.claudeVersion,
7415
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7416
+ enable: "claude-code"
7347
7417
  };
7348
7418
  }
7349
7419
  return {
7350
7420
  ...base,
7351
7421
  state: "not_detected",
7352
7422
  version: null,
7353
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then it appears here."
7423
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7424
+ enable: null
7354
7425
  };
7355
7426
  }
7356
7427
  function deriveCodex(signals, manifestHas) {
@@ -7418,6 +7489,9 @@ function deriveOpencode(signals, manifestHas) {
7418
7489
  enable: "opencode"
7419
7490
  };
7420
7491
  }
7492
+ function detectedRuntimesFor(snapshot) {
7493
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7494
+ }
7421
7495
  var PROBE_TIMEOUT_MS2 = 4e3;
7422
7496
  async function probeHarnessSignals(cfg, deps = {}) {
7423
7497
  const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
@@ -7434,6 +7508,9 @@ async function probeHarnessSignals(cfg, deps = {}) {
7434
7508
  return {
7435
7509
  claudeOnPath: claudeOnPathResult,
7436
7510
  claudeVersion,
7511
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7512
+ // the manifest gate and the probe above is only a suggestion.
7513
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
7437
7514
  // A parseable `codex --version` is our presence signal (presence alone never
7438
7515
  // exposes codex; its config flag is the manifest gate either way).
7439
7516
  codexOnPath: codexVersion !== null,
@@ -7842,6 +7919,10 @@ var CompanionSupervisor = class {
7842
7919
  // can never disagree with what the manifest advertises. Null until the first
7843
7920
  // probe (the heartbeat then falls back to the boot `claudeCode`).
7844
7921
  harnessSignals = null;
7922
+ // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
7923
+ // NOT the cached beat signal — someone connecting right after installing Claude
7924
+ // Code shouldn't be refused by a snapshot up to a heartbeat old.
7925
+ probeClaudePresence;
7845
7926
  exitFn;
7846
7927
  reexecFn;
7847
7928
  dispatcherFactory;
@@ -7872,6 +7953,7 @@ var CompanionSupervisor = class {
7872
7953
  this.log = opts.log;
7873
7954
  this.hub = opts.hub;
7874
7955
  this.claudeCode = opts.claudeCode ?? true;
7956
+ this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
7875
7957
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
7876
7958
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
7877
7959
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -7934,11 +8016,10 @@ var CompanionSupervisor = class {
7934
8016
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
7935
8017
  // configured an `opencode serve`.
7936
8018
  manifest: buildCompanionManifest({
7937
- // CT586: prefer the live re-probe's presence; fall back to the boot probe
7938
- // until the first re-probe lands. Same exit-0 `claude --version` signal
7939
- // either way, so the manifest's claude-code advertising is unchanged in
7940
- // meaning — just refreshed on the beat instead of frozen at boot.
7941
- claudeCode: this.claudeCodePresent(),
8019
+ // CT1082: connected AND installed. Presence is the live re-probe (CT586),
8020
+ // falling back to the boot probe until the first one lands; consent is the
8021
+ // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
8022
+ claudeCode: this.claudeCodeOffered(),
7942
8023
  opencode: !!this.config.opencode?.serverUrl,
7943
8024
  // CT481: advertise codex when the operator enabled it (config-gated,
7944
8025
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -7956,7 +8037,13 @@ var CompanionSupervisor = class {
7956
8037
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
7957
8038
  // A null (probe failed / no opencode) omits the field, and the server then
7958
8039
  // leaves this device's stored availability untouched.
7959
- ...opencodeModels !== null ? { models: opencodeModels } : {}
8040
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
8041
+ // CT1082: what this machine has that the user hasn't connected, so the web
8042
+ // UI can offer it without a companion round-trip. Sent only once a probe has
8043
+ // actually landed (`harnessSignals` non-null) — an absent field means "we
8044
+ // didn't look this beat" and leaves the server's stored suggestion alone,
8045
+ // the same fail-soft contract `models` keeps.
8046
+ ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
7960
8047
  });
7961
8048
  this.hub.setDevice({ deviceId: res.deviceId });
7962
8049
  this.deviceId = res.deviceId;
@@ -8161,13 +8248,19 @@ var CompanionSupervisor = class {
8161
8248
  "companion: stopped agent (unassigned)"
8162
8249
  );
8163
8250
  }
8164
- // CT833: is Claude Code on this machine right now? The ONE signal both the
8165
- // heartbeat manifest and the dispatcher's adapter registry read, so what the
8166
- // device advertises and what it can actually select can't disagree. Live (the
8167
- // per-beat re-probe), falling back to the boot probe until the first one lands.
8251
+ // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
8252
+ // falling back to the boot probe until the first one lands. Presence ONLY —
8253
+ // CT1082 split presence from exposure, so nothing routes on this directly.
8168
8254
  claudeCodePresent() {
8169
8255
  return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8170
8256
  }
8257
+ // CT1082: does this device OFFER claude-code — connected by its user and actually
8258
+ // installed? The ONE signal both the heartbeat manifest and the dispatcher's
8259
+ // adapter registry read, so what the device advertises and what it can select
8260
+ // can't disagree (the CT833 invariant, now with consent in front of it).
8261
+ claudeCodeOffered() {
8262
+ return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8263
+ }
8171
8264
  buildDispatcher(ctx) {
8172
8265
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8173
8266
  return new Dispatcher({
@@ -8185,10 +8278,12 @@ var CompanionSupervisor = class {
8185
8278
  }),
8186
8279
  runConfig: ctx.runConfig,
8187
8280
  log: this.log,
8188
- // CT833: register the claude-code adapter only when `claude` is actually on
8189
- // this machine — read per turn (not captured here), so a harness installed
8190
- // after boot works on the next turn exactly as it appears on the next beat.
8191
- claudeCodeAvailable: () => this.claudeCodePresent(),
8281
+ // CT833: register the claude-code adapter only when this device actually
8282
+ // offers claude-code — read per turn (not captured here), so a harness
8283
+ // installed or connected after boot works on the next turn exactly as it
8284
+ // appears on the next beat. CT1082: "offers" now means connected as well as
8285
+ // installed, so a disconnected harness can't be selected either.
8286
+ claudeCodeAvailable: () => this.claudeCodeOffered(),
8192
8287
  // CT270: the opencode server URL (operator-configured), when this device
8193
8288
  // offers the opencode runtime. Threaded so an opencode turn selects the
8194
8289
  // opencode adapter; unset leaves the device claude-code-only.
@@ -8494,9 +8589,12 @@ var CompanionSupervisor = class {
8494
8589
  async recheckHarnesses() {
8495
8590
  await this.refreshHarnessStatuses();
8496
8591
  }
8497
- // Friendly enable for the two config-driven harnesses — flip the flag the app
8498
- // owns in `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose
8499
- // ONLY: it never installs a binary and never drives a login (BYO — Decided).
8592
+ // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8593
+ // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8594
+ // never installs a binary and never drives a login (BYO — Decided).
8595
+ // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
8596
+ // on PATH. This is the callable the terminal offer ("We found Claude Code.
8597
+ // Connect it? [Y/n]") and the web pairing flow both wire to.
8500
8598
  // - codex: set `codex.enabled` (the CLI + `codex login` remain the user's).
8501
8599
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
8502
8600
  // so we never advertise a serve that isn't there. An unreachable URL is a
@@ -8506,7 +8604,15 @@ var CompanionSupervisor = class {
8506
8604
  // reachable/valid input).
8507
8605
  async enableHarness(input) {
8508
8606
  let next;
8509
- if (input.runtime === "codex") {
8607
+ if (input.runtime === "claude-code") {
8608
+ if (!await this.probeClaudePresence()) {
8609
+ return {
8610
+ ok: false,
8611
+ 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."
8612
+ };
8613
+ }
8614
+ next = { ...this.config, claudeCode: { enabled: true } };
8615
+ } else if (input.runtime === "codex") {
8510
8616
  next = { ...this.config, codex: { enabled: true } };
8511
8617
  } else {
8512
8618
  const serverUrl = input.serverUrl.trim();
@@ -8708,13 +8814,17 @@ function clearCrash() {
8708
8814
  async function createCompanionRuntime(opts = {}) {
8709
8815
  const log = getLogger();
8710
8816
  installProcessSafetyNet(log);
8711
- const probeClaude = opts.probeClaude ?? claudeOnPath;
8712
8817
  let cfg;
8713
8818
  let claudeCode;
8714
8819
  try {
8715
- cfg = requireConfig();
8716
- claudeCode = await probeClaude();
8717
- await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
8820
+ ({ cfg, claudeOnPath: claudeCode } = await requireStartConfig({
8821
+ ...opts.probeClaude ? { probeClaude: opts.probeClaude } : {},
8822
+ onMigrated: (migrated, onPath) => log.info(
8823
+ { claudeCode: isClaudeCodeConnected(migrated) },
8824
+ onPath ? "companion: carried Claude Code over as a connected harness on this device (connectors are now chosen, not detected)" : "companion: no Claude Code on PATH, so this device starts with it disconnected (connectors are now chosen, not detected)"
8825
+ )
8826
+ }));
8827
+ await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
8718
8828
  } catch (err) {
8719
8829
  recordCrash({
8720
8830
  reason: err instanceof Error ? err.message : String(err),
@@ -8726,7 +8836,9 @@ async function createCompanionRuntime(opts = {}) {
8726
8836
  }
8727
8837
  if (cfg.logLevel) log.level = cfg.logLevel;
8728
8838
  const harnessVersions = await probeHarnessVersions({
8729
- claudeCode,
8839
+ // CT1082: versions are probed for the runtimes this device OFFERS, and
8840
+ // claude-code is offered only when connected as well as installed.
8841
+ claudeCode: claudeCode && isClaudeCodeConnected(cfg),
8730
8842
  opencodeServerUrl: cfg.opencode?.serverUrl,
8731
8843
  codex: isCodexEnabled(cfg)
8732
8844
  });
@@ -137,9 +137,17 @@ function renderHarnesses() {
137
137
 
138
138
  if (h.detail) li.append(el('p', 'harness-detail', h.detail));
139
139
 
140
- // Friendly enable — never install, never login (BYO). Codex is a one-click
141
- // flag flip; opencode takes a URL we validate is reachable before writing.
142
- if (h.enable === 'codex') {
140
+ // Friendly enable — never install, never login (BYO). Claude Code and Codex are
141
+ // one-click flag flips; opencode takes a URL we validate is reachable before
142
+ // writing. CT1082: Claude Code is connectable here too — it used to be exposed
143
+ // the moment it was installed, with nothing to click.
144
+ if (h.enable === 'claude-code') {
145
+ const btn = el('button', 'primary small', 'Connect Claude Code');
146
+ btn.addEventListener('click', () => void enableHarness({ runtime: 'claude-code' }, btn));
147
+ const wrap = el('div', 'harness-enable');
148
+ wrap.append(btn);
149
+ li.append(wrap);
150
+ } else if (h.enable === 'codex') {
143
151
  const btn = el('button', 'primary small', 'Use Codex');
144
152
  btn.addEventListener('click', () => void enableHarness({ runtime: 'codex' }, btn));
145
153
  const wrap = el('div', 'harness-enable');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.20",
3
+ "version": "0.6.21",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",