@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 +196 -53
- package/dist/pairing-config.js +47 -9
- package/dist/runtime.js +152 -40
- package/dist/static/app.js +11 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
388
|
+
return fresh;
|
|
361
389
|
}
|
|
362
|
-
if (raw.trim().length === 0) return
|
|
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 {
|
|
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
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
...cfg
|
|
603
|
-
...
|
|
649
|
+
const connected = [
|
|
650
|
+
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
651
|
+
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
652
|
+
...cfg.opencode ? ["opencode"] : []
|
|
604
653
|
];
|
|
605
|
-
if (
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
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 ??
|
|
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 =
|
|
720
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
|
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
|
|
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
|
-
//
|
|
8285
|
-
// until the first
|
|
8286
|
-
//
|
|
8287
|
-
|
|
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?
|
|
8512
|
-
//
|
|
8513
|
-
//
|
|
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
|
|
8536
|
-
//
|
|
8537
|
-
// after boot works on the next turn exactly as it
|
|
8538
|
-
|
|
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
|
|
8845
|
-
//
|
|
8846
|
-
//
|
|
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 === "
|
|
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 =
|
|
9063
|
-
|
|
9064
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/pairing-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
240
|
+
return fresh;
|
|
220
241
|
}
|
|
221
|
-
if (raw.trim().length === 0) return
|
|
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 {
|
|
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
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
...cfg
|
|
1023
|
-
...
|
|
1064
|
+
const connected = [
|
|
1065
|
+
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
1066
|
+
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
1067
|
+
...cfg.opencode ? ["opencode"] : []
|
|
1024
1068
|
];
|
|
1025
|
-
if (
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
|
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
|
|
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
|
-
//
|
|
7938
|
-
// until the first
|
|
7939
|
-
//
|
|
7940
|
-
|
|
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?
|
|
8165
|
-
//
|
|
8166
|
-
//
|
|
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
|
|
8189
|
-
//
|
|
8190
|
-
// after boot works on the next turn exactly as it
|
|
8191
|
-
|
|
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
|
|
8498
|
-
//
|
|
8499
|
-
//
|
|
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 === "
|
|
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 =
|
|
8716
|
-
|
|
8717
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/static/app.js
CHANGED
|
@@ -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).
|
|
141
|
-
// flag
|
|
142
|
-
|
|
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.
|
|
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",
|