@cabane/companion 0.6.1 → 0.6.3

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Connect a **coding agent on your machine** to your [Cabane](https://cabane.ai) workspaces as a **responder** — it replies to messages inside Cabane while running as a full local AI client, so you can drive work against your own codebase, files, and MCP servers without putting any of it in Cabane. The model and tools run on your box; only the reply crosses back.
4
4
 
5
- The Companion runs **[Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code)** by default, and also supports **[opencode](https://opencode.ai)** — a provider-agnostic coding agent as an alternate runtime you opt into per machine. See [Runtimes](#runtimes-claude-code-and-opencode).
5
+ The Companion runs your agents through a **harness** on your machine — **[Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code)**, **[Codex](https://developers.openai.com/codex/cli)**, or **[opencode](https://opencode.ai)** — and you expose the ones you have. Install and sign in to a harness yourself; the Companion drives it. See [Harnesses](#harnesses).
6
6
 
7
7
  This is **v0**. It's a small CLI — install it from npm with one command, pair the machine once, and run. It talks to Cabane only over Cabane's public API.
8
8
 
@@ -16,9 +16,25 @@ The Companion runs on a **device you pair**, running **agents you assign to it**
16
16
 
17
17
  ## Prerequisites
18
18
 
19
- You need **Node 22+** (the Companion runtime — check with `node --version`) and, for the default Claude Code runtime, **Claude Code** on your PATH (`which claude` should resolve). The Agent SDK shells out to `claude`, riding your local Claude Code's auth, so install it with `npm i -g @anthropic-ai/claude-code` and complete the login flow (`claude` follow the prompts) **before** starting the Companion.
19
+ You need **Node 22+** (the Companion itself runs on Node — check with `node --version`) and **at least one harness installed and signed in**. The Companion installs no harness and drives no login for you bring your own, and set it up **before** starting the Companion. It refuses to `start` if no harness is exposed, because no turn could be routed to it.
20
20
 
21
- Prefer **opencode**? It's supported as an alternate runtime install it alongside (or instead of) Claude Code and follow [Runtimes](#runtimes-claude-code-and-opencode) to wire it in.
21
+ Set up whichever you already useone is enough, and a machine can expose several. [Harnesses](#harnesses) has the full per-harness wiring; the short version:
22
+
23
+ **Claude Code** — install it globally _and_ log in:
24
+
25
+ ```sh
26
+ npm i -g @anthropic-ai/claude-code
27
+ claude # complete the login, then quit
28
+ ```
29
+
30
+ Both halves are load-bearing, for different reasons:
31
+
32
+ - **The install** puts `claude` on your PATH (`which claude` should resolve). That PATH probe is how the Companion exposes Claude Code — it needs no configuration, but without `claude` the device won't offer it.
33
+ - **The login** writes the credential the turn actually uses. The Companion does _not_ run your global `claude` to answer a turn — inference goes through the bundled Claude Agent SDK — but the SDK reads the credential your Claude Code login left on disk. So a machine with `claude` installed and never logged in looks fine (the Connector reads **Online**) and then fails every turn with **"Sign-in needed"**.
34
+
35
+ **Codex** — install the Codex CLI and log in (`codex login`, or set `CODEX_API_KEY` in Codex's own environment — Cabane never sees the key). Unlike Claude Code, which is auto-detected on the PATH, Codex is an explicit opt-in: a one-line `codex` block in `~/.cabane/config.json`, or one click in the [dashboard](#the-dashboard). → [full setup](https://app.cabane.ai/docs/reference/connectors/chatgpt)
36
+
37
+ **opencode** — install it, authenticate a provider through its own flow, and start its server (`opencode serve`); the Companion addresses it at the URL you give it. → [full setup](https://app.cabane.ai/docs/reference/connectors/opencode)
22
38
 
23
39
  You also need **a Cabane account.** A device is registered under your user account; the agents it runs can live in any workspace you have access to.
24
40
 
@@ -95,7 +111,7 @@ The prompt returns immediately and you can close the terminal — replies keep l
95
111
 
96
112
  ## The dashboard
97
113
 
98
- `cabane-companion start` serves a small localhost dashboard (default `http://127.0.0.1:7474`) so the Companion is usable without tailing logs: live per-workspace connection status, the agents currently assigned to this device, a recent-dispatches feed, a log tail, and a few local-only controls (Stop / Restart / Reload-config). It binds to `127.0.0.1` only — same threat model as any localhost dev tool, no auth. It does **not** auto-open a browser by default — the URL is printed on start; pass `--open` (or set `autoOpen: true` in config) to open it automatically, and `--no-open` / `BRIDGE_NO_OPEN=1` force it off. It keeps working when Cabane itself is unreachable, showing a "⚠ Cabane unreachable" banner.
114
+ `cabane-companion start` serves a small localhost dashboard (default `http://127.0.0.1:7474`) so the Companion is usable without tailing logs: live per-workspace connection status, the agents currently assigned to this device, the [harnesses](#harnesses) it can see (with a one-click enable for one that's installed but not yet exposed), a recent-dispatches feed, a log tail, and a few local-only controls (Stop / Restart / Reload-config). It binds to `127.0.0.1` only — same threat model as any localhost dev tool, no auth. It does **not** auto-open a browser by default — the URL is printed on start; pass `--open` (or set `autoOpen: true` in config) to open it automatically, and `--no-open` / `BRIDGE_NO_OPEN=1` force it off. It keeps working when Cabane itself is unreachable, showing a "⚠ Cabane unreachable" banner.
99
115
 
100
116
  ## Configuring an agent: what's in Cabane vs. on this machine
101
117
 
@@ -163,34 +179,44 @@ When an agent's MCP servers in Cabane need a credential, that credential is writ
163
179
 
164
180
  A `${VAR}` the store doesn't declare fails the turn loudly — it is **never** read from your shell environment, so a config can't smuggle out an ambient credential. The Companion reports the declared **names** (never values) to Cabane on its heartbeat, so the settings UI can warn "this agent needs `${GITHUB_TOKEN}`, this device doesn't expose it" before a turn ever runs.
165
181
 
166
- ## Runtimes: Claude Code and opencode
182
+ ## Harnesses
183
+
184
+ A **harness** is the local tool that runs a model — **[Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code)**, **[Codex](https://developers.openai.com/codex/cli)**, or **[opencode](https://opencode.ai)** — together with the credential behind it. A harness connected to Cabane on this device is a **Connector**, and one device runs as many Connectors as it has harnesses. None is privileged: a device advertises exactly the harnesses it actually has.
167
185
 
168
- Each turn runs through a **runtime** the local AI client that actually executes the agent. **Claude Code is the default**, and every Companion advertises it; it's what the [Prerequisites](#prerequisites) set up. The Companion can _also_ run **[opencode](https://opencode.ai)** a provider-agnostic coding agent as a second runtime, opted into per machine.
186
+ Which harness a turn uses follows the agent's **model**, set in Cabane (Workspace settings Agents): a Claude model runs on Claude Code, an OpenAI model on Codex, an opencode model on opencode. A device only offers a harness's models and only accepts its turns once that harness is exposed, so set it up first, then pick a matching model for the agent. A turn is never routed to a harness the machine can't run.
169
187
 
170
- Which runtime a turn uses follows the agent's **model**, set in Cabane (Workspace settings → Agents): a Claude model runs on Claude Code, an opencode model runs on opencode. A device only offers opencode models and only accepts opencode turnsonce it's actually running opencode, so set it up first, then pick an opencode model for the agent. A turn is never routed to a runtime the machine can't run.
188
+ What "exposed" means differs per harness, because each is discoverable in a different way. Bring your own install and your own login in all three casesthe Companion never installs a binary and never drives a login:
171
189
 
172
- To add opencode to this machine:
190
+ | Harness | How the device exposes it | Setup |
191
+ | --------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
192
+ | **Claude Code** | `claude` is on your PATH — auto-detected, no configuration. | [Claude](https://app.cabane.ai/docs/reference/connectors/claude) |
193
+ | **Codex** | A `codex` block in `~/.cabane/config.json`. Unlike Claude Code, an explicit opt-in. | [ChatGPT (Codex)](https://app.cabane.ai/docs/reference/connectors/chatgpt) |
194
+ | **opencode** | An `opencode.serverUrl` in `~/.cabane/config.json` pointing at a reachable `opencode serve`. | [opencode](https://app.cabane.ai/docs/reference/connectors/opencode) |
195
+
196
+ Each harness has a full setup page in the docs — install, login, enable, verify — linked above; this section is just the shape of it and the config the Companion reads.
197
+
198
+ **You usually don't hand-edit the config.** `cabane-companion start` serves a [localhost dashboard](#the-dashboard) listing every harness it can see, including ones installed but not yet exposed: a `codex` binary on your PATH shows up as detected-but-not-enabled with a **Use Codex** button, and opencode gets an **Add opencode** field for the `serve` URL (health-checked before it's written, so an unreachable server is caught in place). One click writes the block and advertises the Connector straight away — no restart. Editing `~/.cabane/config.json` yourself is the fallback, and what a headless device wants:
199
+
200
+ ```jsonc
201
+ {
202
+ // …device identity, managed by `pair`; don't hand-edit those fields…
203
+ "codex": { "enabled": true },
204
+ "opencode": { "serverUrl": "http://127.0.0.1:4096" },
205
+ }
206
+ ```
173
207
 
174
- 1. **Install opencode** and authenticate a provider through its own flow (`opencode auth login`). Provider keys live in opencode's own store **Cabane never sees them**.
175
- 2. **Start its server:** `opencode serve --port 4096` (any free port). Run one `opencode serve` per Companion process.
176
- 3. **Point the Companion at it** — add a top-level `opencode` block to `~/.cabane/config.json`:
177
- ```jsonc
178
- {
179
- // …device identity, managed by `pair`; don't hand-edit those fields…
180
- "opencode": { "serverUrl": "http://127.0.0.1:4096" },
181
- }
182
- ```
183
- 4. **Restart the Companion.** Its next heartbeat advertises the `opencode` runtime, and an agent assigned to this device can now be set to an opencode model in Cabane. Without this block the device stays Claude-Code-only, exactly as before.
208
+ Either block is optional and independent — set only the harnesses you have. Codex is a local CLI the bundled `@openai/codex-sdk` spawns per turn, so it takes a flag and no URL; opencode is a long-lived server addressed by URL, so run one `opencode serve` per Companion process. A hand-edit needs a restart (`cabane-companion stop && cabane-companion start`); the dashboard's button doesn't.
184
209
 
185
- Everything else about a Companion-run agent is unchanged — same assignment, same working directory, same secrets, same dashboard. opencode adds a runtime, not a new way to run the Companion.
210
+ Everything else about a Companion-run agent is the same whichever harness runs it — same assignment, same working directory, same secrets, same dashboard. A harness is a way to execute a turn, not a different way to run the Companion.
186
211
 
187
212
  ## Files on disk
188
213
 
189
214
  ```
190
215
  ~/.cabane/
191
216
  ├── config.json # device identity: baseUrl + device token (cabdev_…) + device id/label,
192
- │ # plus optional per-agent `agents` overrides (cwd / prepareHook), an
193
- │ # optional `opencode` block (serverUrl), and dashboard prefs. mode 600.
217
+ │ # plus optional per-agent `agents` overrides (cwd / prepareHook), the
218
+ │ # optional harness blocks (`codex` enabled, `opencode` serverUrl), and
219
+ │ # dashboard prefs. mode 600.
194
220
  │ # No account password, no full-account token.
195
221
  ├── credentials.json # agentId → per-agent workspace-bound token, cached on first assignment
196
222
  │ # pull (delivered once, then never re-sent). mode 600.
@@ -206,7 +232,7 @@ No SQLite, no embedded DB. Cabane is the source of truth for which agents to run
206
232
 
207
233
  ## Debugging a turn: transcripts
208
234
 
209
- The dashboard shows the agent's final reply, but not _how_ it got there. When a turn misbehaves — an agent that "couldn't read the file", a tool that errored, MCP tools that didn't load, a missing secret — the full picture is on disk. Every dispatch writes the complete Claude Agent SDK message stream (the system init with its tool list, every tool call **and its result**, the assistant text, the outcome) to a JSONL file under `~/.cabane/transcripts/`. On a failed turn, the Companion log also prints the exact path.
235
+ The dashboard shows the agent's final reply, but not _how_ it got there. When a turn misbehaves — an agent that "couldn't read the file", a tool that errored, MCP tools that didn't load, a missing secret — the full picture is on disk. Every dispatch — on whichever harness ran it — writes the complete turn stream (the system init with its tool list, every tool call **and its result**, the assistant text, the outcome) to a JSONL file under `~/.cabane/transcripts/`. On a failed turn, the Companion log also prints the exact path.
210
236
 
211
237
  Read one back in a readable form:
212
238
 
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/commands/daemon.ts
7
- import { spawn as spawn3 } from "child_process";
7
+ import { spawn as spawn4 } from "child_process";
8
8
  import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2 } from "fs";
9
9
  import { fileURLToPath } from "url";
10
10
 
@@ -457,11 +457,94 @@ function getLogger() {
457
457
  }
458
458
 
459
459
  // src/prereqs.ts
460
+ import { spawn as spawn3 } from "child_process";
461
+
462
+ // src/harness-versions.ts
460
463
  import { spawn as spawn2 } from "child_process";
464
+ var EMPTY = { claudeCode: null, opencode: null, codex: null };
465
+ function parseVersionToken(raw) {
466
+ if (!raw) return null;
467
+ const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
468
+ return m ? m[0] : null;
469
+ }
470
+ async function probeCliVersion(command, spawnImpl = spawn2) {
471
+ return new Promise((resolve) => {
472
+ let settled = false;
473
+ const done = (v) => {
474
+ if (!settled) {
475
+ settled = true;
476
+ resolve(v);
477
+ }
478
+ };
479
+ let child;
480
+ try {
481
+ child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
482
+ } catch {
483
+ done(null);
484
+ return;
485
+ }
486
+ let out = "";
487
+ child.stdout?.on("data", (chunk) => {
488
+ if (out.length < 4096) out += chunk.toString();
489
+ });
490
+ child.once("error", () => done(null));
491
+ child.once("exit", (code) => done(code === 0 ? parseVersionToken(out) : null));
492
+ });
493
+ }
494
+ async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
495
+ try {
496
+ const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
497
+ const res = await fetchImpl(`${base}/global/health`, {
498
+ headers: { accept: "application/json" }
499
+ });
500
+ if (!res.ok) return null;
501
+ return extractOpencodeVersion(await res.json());
502
+ } catch {
503
+ return null;
504
+ }
505
+ }
506
+ function extractOpencodeVersion(json) {
507
+ if (!json || typeof json !== "object") return null;
508
+ const obj = json;
509
+ const candidates = [obj.version];
510
+ for (const v of Object.values(obj)) {
511
+ if (v && typeof v === "object") candidates.push(v.version);
512
+ }
513
+ for (const c of candidates) {
514
+ if (typeof c === "string") {
515
+ const parsed = parseVersionToken(c);
516
+ if (parsed) return parsed;
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+ async function probeHarnessVersions(opts, deps = {}) {
522
+ const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
523
+ const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
524
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
525
+ const [claudeCode, codex, opencode] = await Promise.all([
526
+ opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
527
+ opts.codex ? safe(probeCodex) : Promise.resolve(null),
528
+ opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
529
+ ]);
530
+ return { claudeCode, opencode, codex };
531
+ }
532
+ function emptyHarnessVersions() {
533
+ return { ...EMPTY };
534
+ }
535
+ async function safe(fn) {
536
+ try {
537
+ return await fn();
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+
543
+ // src/prereqs.ts
461
544
  async function claudeOnPath() {
462
545
  return new Promise((resolve) => {
463
546
  let settled = false;
464
- const child = spawn2("claude", ["--version"], { stdio: "ignore" });
547
+ const child = spawn3("claude", ["--version"], { stdio: "ignore" });
465
548
  child.once("error", () => {
466
549
  if (!settled) {
467
550
  settled = true;
@@ -476,8 +559,20 @@ async function claudeOnPath() {
476
559
  });
477
560
  });
478
561
  }
562
+ var CODEX_PROBE_TIMEOUT_MS = 4e3;
563
+ async function codexOnPath() {
564
+ const version = await Promise.race([
565
+ probeCliVersion("codex"),
566
+ new Promise((resolve) => {
567
+ const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
568
+ timer.unref?.();
569
+ })
570
+ ]);
571
+ return version !== null;
572
+ }
479
573
  async function ensureRuntimeAvailable(cfg, deps = {}) {
480
574
  const probeClaude = deps.probeClaude ?? claudeOnPath;
575
+ const probeCodex = deps.probeCodex ?? codexOnPath;
481
576
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
482
577
  `));
483
578
  if (await probeClaude()) return;
@@ -486,15 +581,15 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
486
581
  ...isCodexEnabled(cfg) ? ["codex"] : []
487
582
  ];
488
583
  if (alternates.length > 0) {
584
+ const exposed = alternates.join(" + ");
489
585
  warn(
490
- `warning: Claude Code isn\u2019t on your PATH, so this device will run ${alternates.join(
491
- " + "
492
- )} only \u2014 it advertises just those runtimes, so assign it matching models (a Claude-model agent won\u2019t be routed here). Install Claude Code (\`npm i -g @anthropic-ai/claude-code\`) if you want it to run Claude models too.`
586
+ `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.`
493
587
  );
494
588
  return;
495
589
  }
590
+ 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";
496
591
  throw new CompanionError(
497
- "Claude Code, the companion\u2019s default runtime, is not on your PATH. Install it with `npm i -g @anthropic-ai/claude-code`, log in (`claude` then follow the prompts), and run `cabane-companion start` again. The default runtime uses your local Claude Code subscription to run each agent turn; opencode is supported as an alternate runtime you configure per device (see the companion README)."
592
+ "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).'
498
593
  );
499
594
  }
500
595
 
@@ -648,7 +743,7 @@ function defaultSpawnDetached(args) {
648
743
  mkdirSync4(cabaneDir(), { recursive: true });
649
744
  const logFd = openSync2(companionLogPath(), "a");
650
745
  try {
651
- return spawn3(process.execPath, [cliPath, ...args], {
746
+ return spawn4(process.execPath, [cliPath, ...args], {
652
747
  detached: true,
653
748
  stdio: ["ignore", logFd, logFd],
654
749
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -922,12 +1017,12 @@ async function pair(opts = {}) {
922
1017
  import { readFileSync as readFileSync11 } from "fs";
923
1018
 
924
1019
  // src/browser.ts
925
- import { spawn as spawn4 } from "child_process";
1020
+ import { spawn as spawn5 } from "child_process";
926
1021
  import { platform } from "process";
927
1022
  function openBrowser(url) {
928
1023
  try {
929
1024
  const { command, args } = openerFor(url);
930
- const child = spawn4(command, args, { stdio: "ignore", detached: true });
1025
+ const child = spawn5(command, args, { stdio: "ignore", detached: true });
931
1026
  child.on("error", () => {
932
1027
  });
933
1028
  child.unref();
@@ -1434,87 +1529,6 @@ function resolveStaticDir() {
1434
1529
  return join6(dirname4(fileURLToPath2(import.meta.url)), "static");
1435
1530
  }
1436
1531
 
1437
- // src/harness-versions.ts
1438
- import { spawn as spawn5 } from "child_process";
1439
- var EMPTY = { claudeCode: null, opencode: null, codex: null };
1440
- function parseVersionToken(raw) {
1441
- if (!raw) return null;
1442
- const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
1443
- return m ? m[0] : null;
1444
- }
1445
- async function probeCliVersion(command, spawnImpl = spawn5) {
1446
- return new Promise((resolve) => {
1447
- let settled = false;
1448
- const done = (v) => {
1449
- if (!settled) {
1450
- settled = true;
1451
- resolve(v);
1452
- }
1453
- };
1454
- let child;
1455
- try {
1456
- child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
1457
- } catch {
1458
- done(null);
1459
- return;
1460
- }
1461
- let out = "";
1462
- child.stdout?.on("data", (chunk) => {
1463
- if (out.length < 4096) out += chunk.toString();
1464
- });
1465
- child.once("error", () => done(null));
1466
- child.once("exit", (code) => done(code === 0 ? parseVersionToken(out) : null));
1467
- });
1468
- }
1469
- async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
1470
- try {
1471
- const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
1472
- const res = await fetchImpl(`${base}/global/health`, {
1473
- headers: { accept: "application/json" }
1474
- });
1475
- if (!res.ok) return null;
1476
- return extractOpencodeVersion(await res.json());
1477
- } catch {
1478
- return null;
1479
- }
1480
- }
1481
- function extractOpencodeVersion(json) {
1482
- if (!json || typeof json !== "object") return null;
1483
- const obj = json;
1484
- const candidates = [obj.version];
1485
- for (const v of Object.values(obj)) {
1486
- if (v && typeof v === "object") candidates.push(v.version);
1487
- }
1488
- for (const c of candidates) {
1489
- if (typeof c === "string") {
1490
- const parsed = parseVersionToken(c);
1491
- if (parsed) return parsed;
1492
- }
1493
- }
1494
- return null;
1495
- }
1496
- async function probeHarnessVersions(opts, deps = {}) {
1497
- const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
1498
- const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
1499
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1500
- const [claudeCode, codex, opencode] = await Promise.all([
1501
- opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
1502
- opts.codex ? safe(probeCodex) : Promise.resolve(null),
1503
- opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
1504
- ]);
1505
- return { claudeCode, opencode, codex };
1506
- }
1507
- function emptyHarnessVersions() {
1508
- return { ...EMPTY };
1509
- }
1510
- async function safe(fn) {
1511
- try {
1512
- return await fn();
1513
- } catch {
1514
- return null;
1515
- }
1516
- }
1517
-
1518
1532
  // src/api.ts
1519
1533
  var RETRY_BACKOFF_MS = [250, 750];
1520
1534
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -2972,6 +2986,11 @@ function buildClaudeCodeOptions(req, augment) {
2972
2986
  const freshReason = "fresh" in resumeDecision ? resumeDecision.reason : void 0;
2973
2987
  const devControlsAutoMemory = req.local.claudeCode?.autoMemory === true;
2974
2988
  const model = parseClaudeCodeModel(config.model);
2989
+ if (model === null) {
2990
+ console.warn(
2991
+ `[agent-runtime/claude-code] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 the SDK will fall back to its bundled default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
2992
+ );
2993
+ }
2975
2994
  const base = {
2976
2995
  // model / effort: `model` is pinned only when the config names a real one —
2977
2996
  // omitted for "let it choose" (see `parseClaudeCodeModel`), so the SDK picks
@@ -3593,26 +3612,20 @@ function selectAdapter(registry, runtime) {
3593
3612
 
3594
3613
  // packages/agent-runtime/src/opencode/addendum.ts
3595
3614
  var OPENCODE_ADDENDUM = [
3596
- "Your Cabane tools have plain names \u2014 `read`, `write`, `search`, `edit`,",
3597
- "`post_message`, and so on; the host tools are plain verbs too (`bash`, `read`,",
3598
- "`edit`). If a tool appears in this prompt with an `mcp__\u2026__` prefix, that",
3615
+ "Your one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
3616
+ "workspace by writing a TypeScript program and calling `sdk` with it); the",
3617
+ "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
3618
+ "plain-named too, as are the few ancillary cabane tools (`list_workspaces`,",
3619
+ "`read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
3620
+ "`mint_render_token`), and the host tools are plain verbs (`bash`, `read`,",
3621
+ "`edit`). There is no `write`/`search`/`edit` CABANE tool here \u2014 those are",
3622
+ "`cabane` SDK calls inside your program, not tools (a bare `read`/`edit` is the",
3623
+ "HOST tool). If a tool appears in this prompt with an `mcp__\u2026__` prefix, that",
3599
3624
  "prefix is not part of its name \u2014 call the tool by its plain verb. Write your",
3600
3625
  "closing reply as the last thing you say in the turn: you can interleave",
3601
3626
  "narration with tool calls, but only your final message is recorded as the",
3602
3627
  "turn\u2019s reply."
3603
3628
  ].join(" ");
3604
- var OPENCODE_ADDENDUM_CODE_MODE = [
3605
- "Your one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
3606
- "workspace by writing a TypeScript program and calling `sdk` with it); the",
3607
- "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
3608
- "plain-named too, and the host tools are plain verbs (`bash`, `read`, `edit`).",
3609
- "There is no `write`/`search`/`edit` CABANE tool here \u2014 those are `cabane` SDK",
3610
- "calls inside your program, not tools (a bare `read`/`edit` is the HOST tool). If",
3611
- "a tool appears in this prompt with an `mcp__\u2026__` prefix, that prefix is not part",
3612
- "of its name \u2014 call the tool by its plain verb. Write your closing reply as the",
3613
- "last thing you say in the turn: you can interleave narration with tool calls, but",
3614
- "only your final message is recorded as the turn\u2019s reply."
3615
- ].join(" ");
3616
3629
 
3617
3630
  // packages/agent-runtime/src/opencode/events.ts
3618
3631
  function asRecord(v) {
@@ -4164,7 +4177,7 @@ function createOpencodeAdapter(deps = {}) {
4164
4177
  name: "opencode",
4165
4178
  // CT614: surface-aware — a code-mode turn (only `code` mounted) is taught
4166
4179
  // `code`, not the granular cabane names it no longer has.
4167
- promptAddendum: (codeMode = false) => codeMode ? OPENCODE_ADDENDUM_CODE_MODE : OPENCODE_ADDENDUM,
4180
+ promptAddendum: () => OPENCODE_ADDENDUM,
4168
4181
  dialectSchema: opencodeDialectSchema,
4169
4182
  async *runTurn(req, signal) {
4170
4183
  if (!transport) {
@@ -4564,14 +4577,6 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
4564
4577
 
4565
4578
  // packages/agent-runtime/src/codex/addendum.ts
4566
4579
  var CODEX_ADDENDUM = [
4567
- "Your Cabane tools have plain names \u2014 `read`, `write`, `search`, `edit`,",
4568
- "`post_message`, and so on. If a tool appears in this prompt with an `mcp__\u2026__`",
4569
- "prefix, that prefix is not part of its name \u2014 call the tool by its plain verb.",
4570
- "Write your closing reply as the last thing you say in the turn: you can",
4571
- "interleave narration with tool calls, but only your final message is recorded",
4572
- "as the turn\u2019s reply."
4573
- ].join(" ");
4574
- var CODEX_ADDENDUM_CODE_MODE = [
4575
4580
  "Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
4576
4581
  "`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
4577
4582
  "`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
@@ -4580,13 +4585,14 @@ var CODEX_ADDENDUM_CODE_MODE = [
4580
4585
  "declare the SDK absent without attempting discovery and invocation. The SDK",
4581
4586
  "call runs a TypeScript program against the ambient `cabane` object. The",
4582
4587
  "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
4583
- "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
4584
- "no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
4585
- "are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
4586
- "prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
4587
- "closing reply as the last thing you say in the",
4588
- "turn: you can interleave narration with tool calls, but only your final message",
4589
- "is recorded as the turn\u2019s reply."
4588
+ "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too), as are",
4589
+ "the few ancillary cabane tools (`mcp__cabane__list_workspaces`,",
4590
+ "`mcp__cabane__read_binary`, the upload tools, `mcp__cabane__mint_render_token`).",
4591
+ "There is no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those are `cabane`",
4592
+ "SDK calls inside your program, not tools. If a tool appears in this prompt with",
4593
+ "an `mcp__\u2026__` prefix, preserve that qualified name. Write your closing reply as",
4594
+ "the last thing you say in the turn: you can interleave narration with tool",
4595
+ "calls, but only your final message is recorded as the turn\u2019s reply."
4590
4596
  ].join(" ");
4591
4597
 
4592
4598
  // packages/agent-runtime/src/codex/events.ts
@@ -4895,10 +4901,16 @@ function buildRunSpec2(req, resumeThreadId) {
4895
4901
  const { policy, config } = req;
4896
4902
  const directory = req.local.cwd ?? "";
4897
4903
  const dialect = readCodexDialect(config.runtimeOptions);
4904
+ const model = config.model ? parseCodexModel(config.model) : null;
4905
+ if (model === null) {
4906
+ console.warn(
4907
+ `[agent-runtime/codex] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 Codex will fall back to its own default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
4908
+ );
4909
+ }
4898
4910
  return {
4899
4911
  resumeThreadId,
4900
4912
  directory,
4901
- model: config.model ? parseCodexModel(config.model) : null,
4913
+ model,
4902
4914
  policy: codexToolPolicy(policy),
4903
4915
  skipGitRepoCheck: true,
4904
4916
  ...dialect.modelReasoningEffort ? { modelReasoningEffort: dialect.modelReasoningEffort } : {},
@@ -5566,7 +5578,7 @@ function createCodexAdapter(deps = {}) {
5566
5578
  name: "codex",
5567
5579
  // CT614: surface-aware — a code-mode turn (only `code` mounted) is taught
5568
5580
  // `code`, not the granular names it no longer has.
5569
- promptAddendum: (codeMode = false) => codeMode ? CODEX_ADDENDUM_CODE_MODE : CODEX_ADDENDUM,
5581
+ promptAddendum: () => CODEX_ADDENDUM,
5570
5582
  dialectSchema: codexDialectSchema,
5571
5583
  async *runTurn(req, signal) {
5572
5584
  if (!transport) {
@@ -7319,7 +7331,10 @@ ${reason}`,
7319
7331
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
7320
7332
  });
7321
7333
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
7322
- const adapters = [createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn })];
7334
+ const adapters = [];
7335
+ if (this.opts.claudeCodeAvailable?.() ?? true) {
7336
+ adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
7337
+ }
7323
7338
  if (this.opts.opencodeServerUrl) {
7324
7339
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
7325
7340
  }
@@ -7868,8 +7883,8 @@ async function probeHarnessSignals(cfg, deps = {}) {
7868
7883
  return {
7869
7884
  claudeOnPath: claudeOnPathResult,
7870
7885
  claudeVersion,
7871
- // A parseable `codex --version` is our presence signal (codex isn't the
7872
- // default runtime; its config flag is the manifest gate either way).
7886
+ // A parseable `codex --version` is our presence signal (presence alone never
7887
+ // exposes codex; its config flag is the manifest gate either way).
7873
7888
  codexOnPath: codexVersion !== null,
7874
7889
  codexVersion,
7875
7890
  codexEnabled: isCodexEnabled(cfg),
@@ -8372,7 +8387,7 @@ var CompanionSupervisor = class {
8372
8387
  // until the first re-probe lands. Same exit-0 `claude --version` signal
8373
8388
  // either way, so the manifest's claude-code advertising is unchanged in
8374
8389
  // meaning — just refreshed on the beat instead of frozen at boot.
8375
- claudeCode: this.harnessSignals?.claudeOnPath ?? this.claudeCode,
8390
+ claudeCode: this.claudeCodePresent(),
8376
8391
  opencode: !!this.config.opencode?.serverUrl,
8377
8392
  // CT481: advertise codex when the operator enabled it (config-gated,
8378
8393
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -8595,6 +8610,13 @@ var CompanionSupervisor = class {
8595
8610
  "companion: stopped agent (unassigned)"
8596
8611
  );
8597
8612
  }
8613
+ // CT833: is Claude Code on this machine right now? The ONE signal both the
8614
+ // heartbeat manifest and the dispatcher's adapter registry read, so what the
8615
+ // device advertises and what it can actually select can't disagree. Live (the
8616
+ // per-beat re-probe), falling back to the boot probe until the first one lands.
8617
+ claudeCodePresent() {
8618
+ return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8619
+ }
8598
8620
  buildDispatcher(ctx) {
8599
8621
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8600
8622
  return new Dispatcher({
@@ -8612,6 +8634,10 @@ var CompanionSupervisor = class {
8612
8634
  }),
8613
8635
  runConfig: ctx.runConfig,
8614
8636
  log: this.log,
8637
+ // CT833: register the claude-code adapter only when `claude` is actually on
8638
+ // this machine — read per turn (not captured here), so a harness installed
8639
+ // after boot works on the next turn exactly as it appears on the next beat.
8640
+ claudeCodeAvailable: () => this.claudeCodePresent(),
8615
8641
  // CT270: the opencode server URL (operator-configured), when this device
8616
8642
  // offers the opencode runtime. Threaded so an opencode turn selects the
8617
8643
  // opencode adapter; unset leaves the device claude-code-only.
package/dist/runtime.js CHANGED
@@ -979,8 +979,20 @@ async function claudeOnPath() {
979
979
  });
980
980
  });
981
981
  }
982
+ var CODEX_PROBE_TIMEOUT_MS = 4e3;
983
+ async function codexOnPath() {
984
+ const version = await Promise.race([
985
+ probeCliVersion("codex"),
986
+ new Promise((resolve) => {
987
+ const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
988
+ timer.unref?.();
989
+ })
990
+ ]);
991
+ return version !== null;
992
+ }
982
993
  async function ensureRuntimeAvailable(cfg, deps = {}) {
983
994
  const probeClaude = deps.probeClaude ?? claudeOnPath;
995
+ const probeCodex = deps.probeCodex ?? codexOnPath;
984
996
  const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
985
997
  `));
986
998
  if (await probeClaude()) return;
@@ -989,15 +1001,15 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
989
1001
  ...isCodexEnabled(cfg) ? ["codex"] : []
990
1002
  ];
991
1003
  if (alternates.length > 0) {
1004
+ const exposed = alternates.join(" + ");
992
1005
  warn(
993
- `warning: Claude Code isn\u2019t on your PATH, so this device will run ${alternates.join(
994
- " + "
995
- )} only \u2014 it advertises just those runtimes, so assign it matching models (a Claude-model agent won\u2019t be routed here). Install Claude Code (\`npm i -g @anthropic-ai/claude-code\`) if you want it to run Claude models too.`
1006
+ `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.`
996
1007
  );
997
1008
  return;
998
1009
  }
1010
+ 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";
999
1011
  throw new CompanionError(
1000
- "Claude Code, the companion\u2019s default runtime, is not on your PATH. Install it with `npm i -g @anthropic-ai/claude-code`, log in (`claude` then follow the prompts), and run `cabane-companion start` again. The default runtime uses your local Claude Code subscription to run each agent turn; opencode is supported as an alternate runtime you configure per device (see the companion README)."
1012
+ "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).'
1001
1013
  );
1002
1014
  }
1003
1015
 
@@ -2627,6 +2639,11 @@ function buildClaudeCodeOptions(req, augment) {
2627
2639
  const freshReason = "fresh" in resumeDecision ? resumeDecision.reason : void 0;
2628
2640
  const devControlsAutoMemory = req.local.claudeCode?.autoMemory === true;
2629
2641
  const model = parseClaudeCodeModel(config.model);
2642
+ if (model === null) {
2643
+ console.warn(
2644
+ `[agent-runtime/claude-code] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 the SDK will fall back to its bundled default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
2645
+ );
2646
+ }
2630
2647
  const base = {
2631
2648
  // model / effort: `model` is pinned only when the config names a real one —
2632
2649
  // omitted for "let it choose" (see `parseClaudeCodeModel`), so the SDK picks
@@ -3248,26 +3265,20 @@ function selectAdapter(registry, runtime) {
3248
3265
 
3249
3266
  // packages/agent-runtime/src/opencode/addendum.ts
3250
3267
  var OPENCODE_ADDENDUM = [
3251
- "Your Cabane tools have plain names \u2014 `read`, `write`, `search`, `edit`,",
3252
- "`post_message`, and so on; the host tools are plain verbs too (`bash`, `read`,",
3253
- "`edit`). If a tool appears in this prompt with an `mcp__\u2026__` prefix, that",
3268
+ "Your one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
3269
+ "workspace by writing a TypeScript program and calling `sdk` with it); the",
3270
+ "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
3271
+ "plain-named too, as are the few ancillary cabane tools (`list_workspaces`,",
3272
+ "`read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
3273
+ "`mint_render_token`), and the host tools are plain verbs (`bash`, `read`,",
3274
+ "`edit`). There is no `write`/`search`/`edit` CABANE tool here \u2014 those are",
3275
+ "`cabane` SDK calls inside your program, not tools (a bare `read`/`edit` is the",
3276
+ "HOST tool). If a tool appears in this prompt with an `mcp__\u2026__` prefix, that",
3254
3277
  "prefix is not part of its name \u2014 call the tool by its plain verb. Write your",
3255
3278
  "closing reply as the last thing you say in the turn: you can interleave",
3256
3279
  "narration with tool calls, but only your final message is recorded as the",
3257
3280
  "turn\u2019s reply."
3258
3281
  ].join(" ");
3259
- var OPENCODE_ADDENDUM_CODE_MODE = [
3260
- "Your one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
3261
- "workspace by writing a TypeScript program and calling `sdk` with it); the",
3262
- "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
3263
- "plain-named too, and the host tools are plain verbs (`bash`, `read`, `edit`).",
3264
- "There is no `write`/`search`/`edit` CABANE tool here \u2014 those are `cabane` SDK",
3265
- "calls inside your program, not tools (a bare `read`/`edit` is the HOST tool). If",
3266
- "a tool appears in this prompt with an `mcp__\u2026__` prefix, that prefix is not part",
3267
- "of its name \u2014 call the tool by its plain verb. Write your closing reply as the",
3268
- "last thing you say in the turn: you can interleave narration with tool calls, but",
3269
- "only your final message is recorded as the turn\u2019s reply."
3270
- ].join(" ");
3271
3282
 
3272
3283
  // packages/agent-runtime/src/opencode/events.ts
3273
3284
  function asRecord(v) {
@@ -3819,7 +3830,7 @@ function createOpencodeAdapter(deps = {}) {
3819
3830
  name: "opencode",
3820
3831
  // CT614: surface-aware — a code-mode turn (only `code` mounted) is taught
3821
3832
  // `code`, not the granular cabane names it no longer has.
3822
- promptAddendum: (codeMode = false) => codeMode ? OPENCODE_ADDENDUM_CODE_MODE : OPENCODE_ADDENDUM,
3833
+ promptAddendum: () => OPENCODE_ADDENDUM,
3823
3834
  dialectSchema: opencodeDialectSchema,
3824
3835
  async *runTurn(req, signal) {
3825
3836
  if (!transport) {
@@ -4219,14 +4230,6 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
4219
4230
 
4220
4231
  // packages/agent-runtime/src/codex/addendum.ts
4221
4232
  var CODEX_ADDENDUM = [
4222
- "Your Cabane tools have plain names \u2014 `read`, `write`, `search`, `edit`,",
4223
- "`post_message`, and so on. If a tool appears in this prompt with an `mcp__\u2026__`",
4224
- "prefix, that prefix is not part of its name \u2014 call the tool by its plain verb.",
4225
- "Write your closing reply as the last thing you say in the turn: you can",
4226
- "interleave narration with tool calls, but only your final message is recorded",
4227
- "as the turn\u2019s reply."
4228
- ].join(" ");
4229
- var CODEX_ADDENDUM_CODE_MODE = [
4230
4233
  "Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
4231
4234
  "`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
4232
4235
  "`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
@@ -4235,13 +4238,14 @@ var CODEX_ADDENDUM_CODE_MODE = [
4235
4238
  "declare the SDK absent without attempting discovery and invocation. The SDK",
4236
4239
  "call runs a TypeScript program against the ambient `cabane` object. The",
4237
4240
  "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
4238
- "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
4239
- "no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
4240
- "are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
4241
- "prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
4242
- "closing reply as the last thing you say in the",
4243
- "turn: you can interleave narration with tool calls, but only your final message",
4244
- "is recorded as the turn\u2019s reply."
4241
+ "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too), as are",
4242
+ "the few ancillary cabane tools (`mcp__cabane__list_workspaces`,",
4243
+ "`mcp__cabane__read_binary`, the upload tools, `mcp__cabane__mint_render_token`).",
4244
+ "There is no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those are `cabane`",
4245
+ "SDK calls inside your program, not tools. If a tool appears in this prompt with",
4246
+ "an `mcp__\u2026__` prefix, preserve that qualified name. Write your closing reply as",
4247
+ "the last thing you say in the turn: you can interleave narration with tool",
4248
+ "calls, but only your final message is recorded as the turn\u2019s reply."
4245
4249
  ].join(" ");
4246
4250
 
4247
4251
  // packages/agent-runtime/src/codex/events.ts
@@ -4550,10 +4554,16 @@ function buildRunSpec2(req, resumeThreadId) {
4550
4554
  const { policy, config } = req;
4551
4555
  const directory = req.local.cwd ?? "";
4552
4556
  const dialect = readCodexDialect(config.runtimeOptions);
4557
+ const model = config.model ? parseCodexModel(config.model) : null;
4558
+ if (model === null) {
4559
+ console.warn(
4560
+ `[agent-runtime/codex] CT823: dispatching a subscription turn with NO concrete model (config.model=${JSON.stringify(config.model)}) \u2014 Codex will fall back to its own default. This should be unreachable post-CT823; the model resolver should have supplied a concrete house default.`
4561
+ );
4562
+ }
4553
4563
  return {
4554
4564
  resumeThreadId,
4555
4565
  directory,
4556
- model: config.model ? parseCodexModel(config.model) : null,
4566
+ model,
4557
4567
  policy: codexToolPolicy(policy),
4558
4568
  skipGitRepoCheck: true,
4559
4569
  ...dialect.modelReasoningEffort ? { modelReasoningEffort: dialect.modelReasoningEffort } : {},
@@ -5221,7 +5231,7 @@ function createCodexAdapter(deps = {}) {
5221
5231
  name: "codex",
5222
5232
  // CT614: surface-aware — a code-mode turn (only `code` mounted) is taught
5223
5233
  // `code`, not the granular names it no longer has.
5224
- promptAddendum: (codeMode = false) => codeMode ? CODEX_ADDENDUM_CODE_MODE : CODEX_ADDENDUM,
5234
+ promptAddendum: () => CODEX_ADDENDUM,
5225
5235
  dialectSchema: codexDialectSchema,
5226
5236
  async *runTurn(req, signal) {
5227
5237
  if (!transport) {
@@ -6974,7 +6984,10 @@ ${reason}`,
6974
6984
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
6975
6985
  });
6976
6986
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
6977
- const adapters = [createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn })];
6987
+ const adapters = [];
6988
+ if (this.opts.claudeCodeAvailable?.() ?? true) {
6989
+ adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
6990
+ }
6978
6991
  if (this.opts.opencodeServerUrl) {
6979
6992
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
6980
6993
  }
@@ -7523,8 +7536,8 @@ async function probeHarnessSignals(cfg, deps = {}) {
7523
7536
  return {
7524
7537
  claudeOnPath: claudeOnPathResult,
7525
7538
  claudeVersion,
7526
- // A parseable `codex --version` is our presence signal (codex isn't the
7527
- // default runtime; its config flag is the manifest gate either way).
7539
+ // A parseable `codex --version` is our presence signal (presence alone never
7540
+ // exposes codex; its config flag is the manifest gate either way).
7528
7541
  codexOnPath: codexVersion !== null,
7529
7542
  codexVersion,
7530
7543
  codexEnabled: isCodexEnabled(cfg),
@@ -8027,7 +8040,7 @@ var CompanionSupervisor = class {
8027
8040
  // until the first re-probe lands. Same exit-0 `claude --version` signal
8028
8041
  // either way, so the manifest's claude-code advertising is unchanged in
8029
8042
  // meaning — just refreshed on the beat instead of frozen at boot.
8030
- claudeCode: this.harnessSignals?.claudeOnPath ?? this.claudeCode,
8043
+ claudeCode: this.claudeCodePresent(),
8031
8044
  opencode: !!this.config.opencode?.serverUrl,
8032
8045
  // CT481: advertise codex when the operator enabled it (config-gated,
8033
8046
  // like opencode — the CLI's presence is the operator's responsibility;
@@ -8250,6 +8263,13 @@ var CompanionSupervisor = class {
8250
8263
  "companion: stopped agent (unassigned)"
8251
8264
  );
8252
8265
  }
8266
+ // CT833: is Claude Code on this machine right now? The ONE signal both the
8267
+ // heartbeat manifest and the dispatcher's adapter registry read, so what the
8268
+ // device advertises and what it can actually select can't disagree. Live (the
8269
+ // per-beat re-probe), falling back to the boot probe until the first one lands.
8270
+ claudeCodePresent() {
8271
+ return this.harnessSignals?.claudeOnPath ?? this.claudeCode;
8272
+ }
8253
8273
  buildDispatcher(ctx) {
8254
8274
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
8255
8275
  return new Dispatcher({
@@ -8267,6 +8287,10 @@ var CompanionSupervisor = class {
8267
8287
  }),
8268
8288
  runConfig: ctx.runConfig,
8269
8289
  log: this.log,
8290
+ // CT833: register the claude-code adapter only when `claude` is actually on
8291
+ // this machine — read per turn (not captured here), so a harness installed
8292
+ // after boot works on the next turn exactly as it appears on the next beat.
8293
+ claudeCodeAvailable: () => this.claudeCodePresent(),
8270
8294
  // CT270: the opencode server URL (operator-configured), when this device
8271
8295
  // offers the opencode runtime. Threaded so an opencode turn selects the
8272
8296
  // opencode adapter; unset leaves the device claude-code-only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",