@cabane/companion 0.6.20 → 0.6.22

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);
@@ -6754,10 +6834,11 @@ function describeSubAgentError(status2, body) {
6754
6834
  var Dispatcher = class {
6755
6835
  constructor(opts) {
6756
6836
  this.opts = opts;
6837
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
6757
6838
  }
6758
6839
  opts;
6759
6840
  // SJ383: per-(conversation, agent) abort registry.
6760
- aborts = /* @__PURE__ */ new Map();
6841
+ aborts;
6761
6842
  notifyStart(info) {
6762
6843
  try {
6763
6844
  this.opts.observer?.onStart(info);
@@ -7671,7 +7752,9 @@ var LABELS = {
7671
7752
  function deriveHarnessSnapshot(signals) {
7672
7753
  const advertised = new Set(
7673
7754
  buildCompanionManifest({
7674
- claudeCode: signals.claudeOnPath,
7755
+ // CT1082: connected AND installed — the manifest's own rule, restated here
7756
+ // through the same function rather than re-decided.
7757
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7675
7758
  opencode: signals.opencodeConfigured,
7676
7759
  codex: signals.codexEnabled
7677
7760
  }).runtimes.map((r) => r.name)
@@ -7684,20 +7767,40 @@ function deriveHarnessSnapshot(signals) {
7684
7767
  return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7685
7768
  }
7686
7769
  function deriveClaudeCode(signals, manifestHas) {
7687
- const base = { runtime: "claude-code", label: LABELS["claude-code"], enable: null };
7770
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7688
7771
  if (manifestHas) {
7689
7772
  return {
7690
7773
  ...base,
7691
7774
  state: "exposed",
7692
7775
  version: signals.claudeVersion,
7693
- detail: "Claude Code is installed and exposed to Cabane."
7776
+ detail: "Claude Code is connected and exposed to Cabane.",
7777
+ enable: null
7778
+ };
7779
+ }
7780
+ if (signals.claudeCodeConnected) {
7781
+ return {
7782
+ ...base,
7783
+ state: "needs_attention",
7784
+ version: null,
7785
+ 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.",
7786
+ enable: null
7787
+ };
7788
+ }
7789
+ if (signals.claudeOnPath) {
7790
+ return {
7791
+ ...base,
7792
+ state: "detected_not_exposed",
7793
+ version: signals.claudeVersion,
7794
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7795
+ enable: "claude-code"
7694
7796
  };
7695
7797
  }
7696
7798
  return {
7697
7799
  ...base,
7698
7800
  state: "not_detected",
7699
7801
  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."
7802
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7803
+ enable: null
7701
7804
  };
7702
7805
  }
7703
7806
  function deriveCodex(signals, manifestHas) {
@@ -7765,6 +7868,9 @@ function deriveOpencode(signals, manifestHas) {
7765
7868
  enable: "opencode"
7766
7869
  };
7767
7870
  }
7871
+ function detectedRuntimesFor(snapshot) {
7872
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7873
+ }
7768
7874
  var PROBE_TIMEOUT_MS2 = 4e3;
7769
7875
  async function probeHarnessSignals(cfg, deps = {}) {
7770
7876
  const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
@@ -7781,6 +7887,9 @@ async function probeHarnessSignals(cfg, deps = {}) {
7781
7887
  return {
7782
7888
  claudeOnPath: claudeOnPathResult,
7783
7889
  claudeVersion,
7890
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7891
+ // the manifest gate and the probe above is only a suggestion.
7892
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
7784
7893
  // A parseable `codex --version` is our presence signal (presence alone never
7785
7894
  // exposes codex; its config flag is the manifest gate either way).
7786
7895
  codexOnPath: codexVersion !== null,
@@ -8189,6 +8298,10 @@ var CompanionSupervisor = class {
8189
8298
  // can never disagree with what the manifest advertises. Null until the first
8190
8299
  // probe (the heartbeat then falls back to the boot `claudeCode`).
8191
8300
  harnessSignals = null;
8301
+ // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
8302
+ // NOT the cached beat signal — someone connecting right after installing Claude
8303
+ // Code shouldn't be refused by a snapshot up to a heartbeat old.
8304
+ probeClaudePresence;
8192
8305
  exitFn;
8193
8306
  reexecFn;
8194
8307
  dispatcherFactory;
@@ -8219,6 +8332,7 @@ var CompanionSupervisor = class {
8219
8332
  this.log = opts.log;
8220
8333
  this.hub = opts.hub;
8221
8334
  this.claudeCode = opts.claudeCode ?? true;
8335
+ this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
8222
8336
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
8223
8337
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
8224
8338
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -8281,11 +8395,10 @@ var CompanionSupervisor = class {
8281
8395
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
8282
8396
  // configured an `opencode serve`.
8283
8397
  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(),
8398
+ // CT1082: connected AND installed. Presence is the live re-probe (CT586),
8399
+ // falling back to the boot probe until the first one lands; consent is the
8400
+ // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
8401
+ claudeCode: this.claudeCodeOffered(),
8289
8402
  opencode: !!this.config.opencode?.serverUrl,
8290
8403
  // CT481: advertise codex when the operator enabled it (config-gated,
8291
8404
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -8303,7 +8416,13 @@ var CompanionSupervisor = class {
8303
8416
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
8304
8417
  // A null (probe failed / no opencode) omits the field, and the server then
8305
8418
  // leaves this device's stored availability untouched.
8306
- ...opencodeModels !== null ? { models: opencodeModels } : {}
8419
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
8420
+ // CT1082: what this machine has that the user hasn't connected, so the web
8421
+ // UI can offer it without a companion round-trip. Sent only once a probe has
8422
+ // actually landed (`harnessSignals` non-null) — an absent field means "we
8423
+ // didn't look this beat" and leaves the server's stored suggestion alone,
8424
+ // the same fail-soft contract `models` keeps.
8425
+ ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
8307
8426
  });
8308
8427
  this.hub.setDevice({ deviceId: res.deviceId });
8309
8428
  this.deviceId = res.deviceId;
@@ -8469,6 +8588,7 @@ var CompanionSupervisor = class {
8469
8588
  outbox,
8470
8589
  log: this.log
8471
8590
  });
8591
+ const aborts = /* @__PURE__ */ new Map();
8472
8592
  const dispatcher = this.buildDispatcher({
8473
8593
  api,
8474
8594
  baseUrl: this.config.baseUrl,
@@ -8477,7 +8597,8 @@ var CompanionSupervisor = class {
8477
8597
  agentId: it.agentId,
8478
8598
  agentUsername: it.agentUsername,
8479
8599
  credential,
8480
- runConfig
8600
+ runConfig,
8601
+ aborts
8481
8602
  });
8482
8603
  const drain2 = this.makeDrain(api, it.agentId);
8483
8604
  wr.agents.set(it.agentId, {
@@ -8487,6 +8608,7 @@ var CompanionSupervisor = class {
8487
8608
  credential,
8488
8609
  runConfig,
8489
8610
  api,
8611
+ aborts,
8490
8612
  dispatcher,
8491
8613
  cancelDrain: drain2.cancel,
8492
8614
  kickDrain: drain2.kick
@@ -8508,15 +8630,26 @@ var CompanionSupervisor = class {
8508
8630
  "companion: stopped agent (unassigned)"
8509
8631
  );
8510
8632
  }
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.
8633
+ // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
8634
+ // falling back to the boot probe until the first one lands. Presence ONLY —
8635
+ // CT1082 split presence from exposure, so nothing routes on this directly.
8515
8636
  claudeCodePresent() {
8516
8637
  return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8517
8638
  }
8639
+ // CT1082: does this device OFFER claude-code — connected by its user and actually
8640
+ // installed? The ONE signal both the heartbeat manifest and the dispatcher's
8641
+ // adapter registry read, so what the device advertises and what it can select
8642
+ // can't disagree (the CT833 invariant, now with consent in front of it).
8643
+ claudeCodeOffered() {
8644
+ return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8645
+ }
8518
8646
  buildDispatcher(ctx) {
8519
- if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8647
+ const local = localAgentConfig(this.config, {
8648
+ agentId: ctx.agentId,
8649
+ agentUsername: ctx.agentUsername,
8650
+ workspaceSlug: ctx.workspaceSlug
8651
+ });
8652
+ if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
8520
8653
  return new Dispatcher({
8521
8654
  api: ctx.api,
8522
8655
  baseUrl: ctx.baseUrl,
@@ -8525,17 +8658,16 @@ var CompanionSupervisor = class {
8525
8658
  agentId: ctx.agentId,
8526
8659
  agentUsername: ctx.agentUsername,
8527
8660
  credential: ctx.credential,
8528
- local: localAgentConfig(this.config, {
8529
- agentId: ctx.agentId,
8530
- agentUsername: ctx.agentUsername,
8531
- workspaceSlug: ctx.workspaceSlug
8532
- }),
8661
+ local,
8662
+ aborts: ctx.aborts,
8533
8663
  runConfig: ctx.runConfig,
8534
8664
  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(),
8665
+ // CT833: register the claude-code adapter only when this device actually
8666
+ // offers claude-code — read per turn (not captured here), so a harness
8667
+ // installed or connected after boot works on the next turn exactly as it
8668
+ // appears on the next beat. CT1082: "offers" now means connected as well as
8669
+ // installed, so a disconnected harness can't be selected either.
8670
+ claudeCodeAvailable: () => this.claudeCodeOffered(),
8539
8671
  // CT270: the opencode server URL (operator-configured), when this device
8540
8672
  // offers the opencode runtime. Threaded so an opencode turn selects the
8541
8673
  // opencode adapter; unset leaves the device claude-code-only.
@@ -8797,6 +8929,7 @@ var CompanionSupervisor = class {
8797
8929
  if (!next) return;
8798
8930
  this.config = next;
8799
8931
  this.log.level = next.logLevel ?? "debug";
8932
+ this.rebuildDispatchers();
8800
8933
  void this.refreshAssignments();
8801
8934
  }
8802
8935
  // The dashboard "refresh assignments" control — an immediate pull.
@@ -8841,9 +8974,12 @@ var CompanionSupervisor = class {
8841
8974
  async recheckHarnesses() {
8842
8975
  await this.refreshHarnessStatuses();
8843
8976
  }
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).
8977
+ // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8978
+ // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8979
+ // never installs a binary and never drives a login (BYO — Decided).
8980
+ // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
8981
+ // on PATH. This is the callable the terminal offer ("We found Claude Code.
8982
+ // Connect it? [Y/n]") and the web pairing flow both wire to.
8847
8983
  // - codex: set `codex.enabled` (the CLI + `codex login` remain the user's).
8848
8984
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
8849
8985
  // so we never advertise a serve that isn't there. An unreachable URL is a
@@ -8853,7 +8989,15 @@ var CompanionSupervisor = class {
8853
8989
  // reachable/valid input).
8854
8990
  async enableHarness(input) {
8855
8991
  let next;
8856
- if (input.runtime === "codex") {
8992
+ if (input.runtime === "claude-code") {
8993
+ if (!await this.probeClaudePresence()) {
8994
+ return {
8995
+ ok: false,
8996
+ 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."
8997
+ };
8998
+ }
8999
+ next = { ...this.config, claudeCode: { enabled: true } };
9000
+ } else if (input.runtime === "codex") {
8857
9001
  next = { ...this.config, codex: { enabled: true } };
8858
9002
  } else {
8859
9003
  const serverUrl = input.serverUrl.trim();
@@ -8883,8 +9027,8 @@ var CompanionSupervisor = class {
8883
9027
  }
8884
9028
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
8885
9029
  // the runner (its api/outbox/chains/SSE) intact. Used after a runtime-enabling
8886
- // config change so the adapter registry rebuilt per turn from the dispatcher's
8887
- // constructor optspicks up the new opencode/codex adapter.
9030
+ // config change so constructor-captured options — per-agent cwd/prepareHook and
9031
+ // the adapter registry pick up their new values without an app restart.
8888
9032
  rebuildDispatchers() {
8889
9033
  for (const wr of this.workspaces.values()) {
8890
9034
  for (const runner of wr.agents.values()) {
@@ -8896,7 +9040,8 @@ var CompanionSupervisor = class {
8896
9040
  agentId: runner.agentId,
8897
9041
  agentUsername: runner.username,
8898
9042
  credential: runner.credential,
8899
- runConfig: runner.runConfig
9043
+ runConfig: runner.runConfig,
9044
+ aborts: runner.aborts
8900
9045
  });
8901
9046
  }
8902
9047
  }
@@ -9055,13 +9200,17 @@ function clearCrash() {
9055
9200
  async function createCompanionRuntime(opts = {}) {
9056
9201
  const log = getLogger();
9057
9202
  installProcessSafetyNet(log);
9058
- const probeClaude = opts.probeClaude ?? claudeOnPath;
9059
9203
  let cfg;
9060
9204
  let claudeCode;
9061
9205
  try {
9062
- cfg = requireConfig();
9063
- claudeCode = await probeClaude();
9064
- await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
9206
+ ({ cfg, claudeOnPath: claudeCode } = await requireStartConfig({
9207
+ ...opts.probeClaude ? { probeClaude: opts.probeClaude } : {},
9208
+ onMigrated: (migrated, onPath) => log.info(
9209
+ { claudeCode: isClaudeCodeConnected(migrated) },
9210
+ 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)"
9211
+ )
9212
+ }));
9213
+ await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
9065
9214
  } catch (err) {
9066
9215
  recordCrash({
9067
9216
  reason: err instanceof Error ? err.message : String(err),
@@ -9073,7 +9222,9 @@ async function createCompanionRuntime(opts = {}) {
9073
9222
  }
9074
9223
  if (cfg.logLevel) log.level = cfg.logLevel;
9075
9224
  const harnessVersions = await probeHarnessVersions({
9076
- claudeCode,
9225
+ // CT1082: versions are probed for the runtimes this device OFFERS, and
9226
+ // claude-code is offered only when connected as well as installed.
9227
+ claudeCode: claudeCode && isClaudeCodeConnected(cfg),
9077
9228
  opencodeServerUrl: cfg.opencode?.serverUrl,
9078
9229
  codex: isCodexEnabled(cfg)
9079
9230
  });
@@ -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
 
@@ -6407,10 +6456,11 @@ function describeSubAgentError(status, body) {
6407
6456
  var Dispatcher = class {
6408
6457
  constructor(opts) {
6409
6458
  this.opts = opts;
6459
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
6410
6460
  }
6411
6461
  opts;
6412
6462
  // SJ383: per-(conversation, agent) abort registry.
6413
- aborts = /* @__PURE__ */ new Map();
6463
+ aborts;
6414
6464
  notifyStart(info) {
6415
6465
  try {
6416
6466
  this.opts.observer?.onStart(info);
@@ -7324,7 +7374,9 @@ var LABELS = {
7324
7374
  function deriveHarnessSnapshot(signals) {
7325
7375
  const advertised = new Set(
7326
7376
  buildCompanionManifest({
7327
- claudeCode: signals.claudeOnPath,
7377
+ // CT1082: connected AND installed — the manifest's own rule, restated here
7378
+ // through the same function rather than re-decided.
7379
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7328
7380
  opencode: signals.opencodeConfigured,
7329
7381
  codex: signals.codexEnabled
7330
7382
  }).runtimes.map((r) => r.name)
@@ -7337,20 +7389,40 @@ function deriveHarnessSnapshot(signals) {
7337
7389
  return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7338
7390
  }
7339
7391
  function deriveClaudeCode(signals, manifestHas) {
7340
- const base = { runtime: "claude-code", label: LABELS["claude-code"], enable: null };
7392
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7341
7393
  if (manifestHas) {
7342
7394
  return {
7343
7395
  ...base,
7344
7396
  state: "exposed",
7345
7397
  version: signals.claudeVersion,
7346
- detail: "Claude Code is installed and exposed to Cabane."
7398
+ detail: "Claude Code is connected and exposed to Cabane.",
7399
+ enable: null
7400
+ };
7401
+ }
7402
+ if (signals.claudeCodeConnected) {
7403
+ return {
7404
+ ...base,
7405
+ state: "needs_attention",
7406
+ version: null,
7407
+ 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.",
7408
+ enable: null
7409
+ };
7410
+ }
7411
+ if (signals.claudeOnPath) {
7412
+ return {
7413
+ ...base,
7414
+ state: "detected_not_exposed",
7415
+ version: signals.claudeVersion,
7416
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7417
+ enable: "claude-code"
7347
7418
  };
7348
7419
  }
7349
7420
  return {
7350
7421
  ...base,
7351
7422
  state: "not_detected",
7352
7423
  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."
7424
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7425
+ enable: null
7354
7426
  };
7355
7427
  }
7356
7428
  function deriveCodex(signals, manifestHas) {
@@ -7418,6 +7490,9 @@ function deriveOpencode(signals, manifestHas) {
7418
7490
  enable: "opencode"
7419
7491
  };
7420
7492
  }
7493
+ function detectedRuntimesFor(snapshot) {
7494
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7495
+ }
7421
7496
  var PROBE_TIMEOUT_MS2 = 4e3;
7422
7497
  async function probeHarnessSignals(cfg, deps = {}) {
7423
7498
  const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
@@ -7434,6 +7509,9 @@ async function probeHarnessSignals(cfg, deps = {}) {
7434
7509
  return {
7435
7510
  claudeOnPath: claudeOnPathResult,
7436
7511
  claudeVersion,
7512
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7513
+ // the manifest gate and the probe above is only a suggestion.
7514
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
7437
7515
  // A parseable `codex --version` is our presence signal (presence alone never
7438
7516
  // exposes codex; its config flag is the manifest gate either way).
7439
7517
  codexOnPath: codexVersion !== null,
@@ -7842,6 +7920,10 @@ var CompanionSupervisor = class {
7842
7920
  // can never disagree with what the manifest advertises. Null until the first
7843
7921
  // probe (the heartbeat then falls back to the boot `claudeCode`).
7844
7922
  harnessSignals = null;
7923
+ // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
7924
+ // NOT the cached beat signal — someone connecting right after installing Claude
7925
+ // Code shouldn't be refused by a snapshot up to a heartbeat old.
7926
+ probeClaudePresence;
7845
7927
  exitFn;
7846
7928
  reexecFn;
7847
7929
  dispatcherFactory;
@@ -7872,6 +7954,7 @@ var CompanionSupervisor = class {
7872
7954
  this.log = opts.log;
7873
7955
  this.hub = opts.hub;
7874
7956
  this.claudeCode = opts.claudeCode ?? true;
7957
+ this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
7875
7958
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
7876
7959
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
7877
7960
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -7934,11 +8017,10 @@ var CompanionSupervisor = class {
7934
8017
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
7935
8018
  // configured an `opencode serve`.
7936
8019
  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(),
8020
+ // CT1082: connected AND installed. Presence is the live re-probe (CT586),
8021
+ // falling back to the boot probe until the first one lands; consent is the
8022
+ // user's `claudeCode.enabled`. Claude Code no longer rides presence alone.
8023
+ claudeCode: this.claudeCodeOffered(),
7942
8024
  opencode: !!this.config.opencode?.serverUrl,
7943
8025
  // CT481: advertise codex when the operator enabled it (config-gated,
7944
8026
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -7956,7 +8038,13 @@ var CompanionSupervisor = class {
7956
8038
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
7957
8039
  // A null (probe failed / no opencode) omits the field, and the server then
7958
8040
  // leaves this device's stored availability untouched.
7959
- ...opencodeModels !== null ? { models: opencodeModels } : {}
8041
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
8042
+ // CT1082: what this machine has that the user hasn't connected, so the web
8043
+ // UI can offer it without a companion round-trip. Sent only once a probe has
8044
+ // actually landed (`harnessSignals` non-null) — an absent field means "we
8045
+ // didn't look this beat" and leaves the server's stored suggestion alone,
8046
+ // the same fail-soft contract `models` keeps.
8047
+ ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
7960
8048
  });
7961
8049
  this.hub.setDevice({ deviceId: res.deviceId });
7962
8050
  this.deviceId = res.deviceId;
@@ -8122,6 +8210,7 @@ var CompanionSupervisor = class {
8122
8210
  outbox,
8123
8211
  log: this.log
8124
8212
  });
8213
+ const aborts = /* @__PURE__ */ new Map();
8125
8214
  const dispatcher = this.buildDispatcher({
8126
8215
  api,
8127
8216
  baseUrl: this.config.baseUrl,
@@ -8130,7 +8219,8 @@ var CompanionSupervisor = class {
8130
8219
  agentId: it.agentId,
8131
8220
  agentUsername: it.agentUsername,
8132
8221
  credential,
8133
- runConfig
8222
+ runConfig,
8223
+ aborts
8134
8224
  });
8135
8225
  const drain2 = this.makeDrain(api, it.agentId);
8136
8226
  wr.agents.set(it.agentId, {
@@ -8140,6 +8230,7 @@ var CompanionSupervisor = class {
8140
8230
  credential,
8141
8231
  runConfig,
8142
8232
  api,
8233
+ aborts,
8143
8234
  dispatcher,
8144
8235
  cancelDrain: drain2.cancel,
8145
8236
  kickDrain: drain2.kick
@@ -8161,15 +8252,26 @@ var CompanionSupervisor = class {
8161
8252
  "companion: stopped agent (unassigned)"
8162
8253
  );
8163
8254
  }
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.
8255
+ // CT833: is Claude Code on this machine right now? Live (the per-beat re-probe),
8256
+ // falling back to the boot probe until the first one lands. Presence ONLY —
8257
+ // CT1082 split presence from exposure, so nothing routes on this directly.
8168
8258
  claudeCodePresent() {
8169
8259
  return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8170
8260
  }
8261
+ // CT1082: does this device OFFER claude-code — connected by its user and actually
8262
+ // installed? The ONE signal both the heartbeat manifest and the dispatcher's
8263
+ // adapter registry read, so what the device advertises and what it can select
8264
+ // can't disagree (the CT833 invariant, now with consent in front of it).
8265
+ claudeCodeOffered() {
8266
+ return isClaudeCodeConnected(this.config) && this.claudeCodePresent();
8267
+ }
8171
8268
  buildDispatcher(ctx) {
8172
- if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8269
+ const local = localAgentConfig(this.config, {
8270
+ agentId: ctx.agentId,
8271
+ agentUsername: ctx.agentUsername,
8272
+ workspaceSlug: ctx.workspaceSlug
8273
+ });
8274
+ if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
8173
8275
  return new Dispatcher({
8174
8276
  api: ctx.api,
8175
8277
  baseUrl: ctx.baseUrl,
@@ -8178,17 +8280,16 @@ var CompanionSupervisor = class {
8178
8280
  agentId: ctx.agentId,
8179
8281
  agentUsername: ctx.agentUsername,
8180
8282
  credential: ctx.credential,
8181
- local: localAgentConfig(this.config, {
8182
- agentId: ctx.agentId,
8183
- agentUsername: ctx.agentUsername,
8184
- workspaceSlug: ctx.workspaceSlug
8185
- }),
8283
+ local,
8284
+ aborts: ctx.aborts,
8186
8285
  runConfig: ctx.runConfig,
8187
8286
  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(),
8287
+ // CT833: register the claude-code adapter only when this device actually
8288
+ // offers claude-code — read per turn (not captured here), so a harness
8289
+ // installed or connected after boot works on the next turn exactly as it
8290
+ // appears on the next beat. CT1082: "offers" now means connected as well as
8291
+ // installed, so a disconnected harness can't be selected either.
8292
+ claudeCodeAvailable: () => this.claudeCodeOffered(),
8192
8293
  // CT270: the opencode server URL (operator-configured), when this device
8193
8294
  // offers the opencode runtime. Threaded so an opencode turn selects the
8194
8295
  // opencode adapter; unset leaves the device claude-code-only.
@@ -8450,6 +8551,7 @@ var CompanionSupervisor = class {
8450
8551
  if (!next) return;
8451
8552
  this.config = next;
8452
8553
  this.log.level = next.logLevel ?? "debug";
8554
+ this.rebuildDispatchers();
8453
8555
  void this.refreshAssignments();
8454
8556
  }
8455
8557
  // The dashboard "refresh assignments" control — an immediate pull.
@@ -8494,9 +8596,12 @@ var CompanionSupervisor = class {
8494
8596
  async recheckHarnesses() {
8495
8597
  await this.refreshHarnessStatuses();
8496
8598
  }
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).
8599
+ // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8600
+ // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8601
+ // never installs a binary and never drives a login (BYO — Decided).
8602
+ // - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
8603
+ // on PATH. This is the callable the terminal offer ("We found Claude Code.
8604
+ // Connect it? [Y/n]") and the web pairing flow both wire to.
8500
8605
  // - codex: set `codex.enabled` (the CLI + `codex login` remain the user's).
8501
8606
  // - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
8502
8607
  // so we never advertise a serve that isn't there. An unreachable URL is a
@@ -8506,7 +8611,15 @@ var CompanionSupervisor = class {
8506
8611
  // reachable/valid input).
8507
8612
  async enableHarness(input) {
8508
8613
  let next;
8509
- if (input.runtime === "codex") {
8614
+ if (input.runtime === "claude-code") {
8615
+ if (!await this.probeClaudePresence()) {
8616
+ return {
8617
+ ok: false,
8618
+ 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."
8619
+ };
8620
+ }
8621
+ next = { ...this.config, claudeCode: { enabled: true } };
8622
+ } else if (input.runtime === "codex") {
8510
8623
  next = { ...this.config, codex: { enabled: true } };
8511
8624
  } else {
8512
8625
  const serverUrl = input.serverUrl.trim();
@@ -8536,8 +8649,8 @@ var CompanionSupervisor = class {
8536
8649
  }
8537
8650
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
8538
8651
  // the runner (its api/outbox/chains/SSE) intact. Used after a runtime-enabling
8539
- // config change so the adapter registry rebuilt per turn from the dispatcher's
8540
- // constructor optspicks up the new opencode/codex adapter.
8652
+ // config change so constructor-captured options — per-agent cwd/prepareHook and
8653
+ // the adapter registry pick up their new values without an app restart.
8541
8654
  rebuildDispatchers() {
8542
8655
  for (const wr of this.workspaces.values()) {
8543
8656
  for (const runner of wr.agents.values()) {
@@ -8549,7 +8662,8 @@ var CompanionSupervisor = class {
8549
8662
  agentId: runner.agentId,
8550
8663
  agentUsername: runner.username,
8551
8664
  credential: runner.credential,
8552
- runConfig: runner.runConfig
8665
+ runConfig: runner.runConfig,
8666
+ aborts: runner.aborts
8553
8667
  });
8554
8668
  }
8555
8669
  }
@@ -8708,13 +8822,17 @@ function clearCrash() {
8708
8822
  async function createCompanionRuntime(opts = {}) {
8709
8823
  const log = getLogger();
8710
8824
  installProcessSafetyNet(log);
8711
- const probeClaude = opts.probeClaude ?? claudeOnPath;
8712
8825
  let cfg;
8713
8826
  let claudeCode;
8714
8827
  try {
8715
- cfg = requireConfig();
8716
- claudeCode = await probeClaude();
8717
- await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
8828
+ ({ cfg, claudeOnPath: claudeCode } = await requireStartConfig({
8829
+ ...opts.probeClaude ? { probeClaude: opts.probeClaude } : {},
8830
+ onMigrated: (migrated, onPath) => log.info(
8831
+ { claudeCode: isClaudeCodeConnected(migrated) },
8832
+ 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)"
8833
+ )
8834
+ }));
8835
+ await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
8718
8836
  } catch (err) {
8719
8837
  recordCrash({
8720
8838
  reason: err instanceof Error ? err.message : String(err),
@@ -8726,7 +8844,9 @@ async function createCompanionRuntime(opts = {}) {
8726
8844
  }
8727
8845
  if (cfg.logLevel) log.level = cfg.logLevel;
8728
8846
  const harnessVersions = await probeHarnessVersions({
8729
- claudeCode,
8847
+ // CT1082: versions are probed for the runtimes this device OFFERS, and
8848
+ // claude-code is offered only when connected as well as installed.
8849
+ claudeCode: claudeCode && isClaudeCodeConnected(cfg),
8730
8850
  opencodeServerUrl: cfg.opencode?.serverUrl,
8731
8851
  codex: isCodexEnabled(cfg)
8732
8852
  });
@@ -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.22",
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",