@cabane/companion 0.6.32 → 0.6.34

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/runtime.js CHANGED
@@ -287,10 +287,11 @@ var companionConfigSchema = z2.object({
287
287
  // device with no device-level entry behaves exactly as it did before. A hook
288
288
  // that fails still fails the dispatch loudly, as now.
289
289
  prepareHook: prepareHookSchema.optional(),
290
- // Dashboard settings (all optional). dashboardPort: preferred bind port (next
291
- // free one if taken); autoOpen: whether `start` opens the browser (the
292
- // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
293
- // level, live-editable from the dashboard settings panel.
290
+ // CT1085 §9: `dashboardPort` and `autoOpen` are READ AND IGNORED by the CLI —
291
+ // it binds no port and opens no browser. They stay in the schema (rather than
292
+ // becoming a strict-mode parse error on an existing config file) and still mean
293
+ // what they say to the Electron shell, the one front-end that renders a
294
+ // dashboard. `logLevel` is unaffected: pino level, live-editable.
294
295
  dashboardPort: z2.number().int().min(1).max(65535).optional(),
295
296
  autoOpen: z2.boolean().optional(),
296
297
  logLevel: z2.enum(["warn", "info", "debug"]).optional(),
@@ -950,6 +951,163 @@ function resolveStaticDir() {
950
951
  return join4(dirname3(fileURLToPath(import.meta.url)), "static");
951
952
  }
952
953
 
954
+ // src/control-socket.ts
955
+ import { createHash } from "crypto";
956
+ import { existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
957
+ import { createServer, connect } from "net";
958
+ import { join as join5 } from "path";
959
+ var CONTROL_TIMEOUT_MS = 1e3;
960
+ function controlSocketPath() {
961
+ const dir2 = cabaneDir();
962
+ if (process.platform === "win32") {
963
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
964
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
965
+ }
966
+ return join5(dir2, "companion.sock");
967
+ }
968
+ async function startControlServer(handlers) {
969
+ const path = controlSocketPath();
970
+ mkdirSync3(cabaneDir(), { recursive: true });
971
+ if (process.platform !== "win32" && existsSync3(path)) {
972
+ const alive = await ping(path);
973
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
974
+ rmSync2(path, { force: true });
975
+ }
976
+ const server = createServer((socket) => {
977
+ void serveConnection(socket, handlers);
978
+ });
979
+ server.unref();
980
+ await new Promise((resolve, reject) => {
981
+ server.once("error", reject);
982
+ server.listen(path, () => {
983
+ server.removeListener("error", reject);
984
+ resolve();
985
+ });
986
+ });
987
+ server.on("error", () => {
988
+ });
989
+ return {
990
+ path,
991
+ close: () => new Promise((resolve) => {
992
+ server.close(() => {
993
+ if (process.platform !== "win32") rmSync2(path, { force: true });
994
+ resolve();
995
+ });
996
+ })
997
+ };
998
+ }
999
+ async function serveConnection(socket, handlers) {
1000
+ socket.on("error", () => socket.destroy());
1001
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1002
+ if (line === null) {
1003
+ socket.destroy();
1004
+ return;
1005
+ }
1006
+ let req;
1007
+ try {
1008
+ req = JSON.parse(line);
1009
+ } catch {
1010
+ reply(socket, { error: "malformed request" });
1011
+ return;
1012
+ }
1013
+ try {
1014
+ if (req.cmd === "status") {
1015
+ reply(socket, handlers.status());
1016
+ return;
1017
+ }
1018
+ if (req.cmd === "connect") {
1019
+ const result = await handlers.connect(req.runtime, req.serverUrl);
1020
+ reply(socket, result);
1021
+ return;
1022
+ }
1023
+ if (req.cmd === "stop") {
1024
+ reply(socket, { ok: true });
1025
+ setTimeout(() => handlers.stop(), 50).unref?.();
1026
+ return;
1027
+ }
1028
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1029
+ } catch (err) {
1030
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
1031
+ }
1032
+ }
1033
+ function reply(socket, body) {
1034
+ try {
1035
+ socket.end(`${JSON.stringify(body)}
1036
+ `);
1037
+ } catch {
1038
+ socket.destroy();
1039
+ }
1040
+ }
1041
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1042
+ const socket = connect(path);
1043
+ try {
1044
+ await new Promise((resolve, reject) => {
1045
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1046
+ timer.unref?.();
1047
+ socket.once("connect", () => {
1048
+ clearTimeout(timer);
1049
+ resolve();
1050
+ });
1051
+ socket.once("error", (err) => {
1052
+ clearTimeout(timer);
1053
+ reject(err);
1054
+ });
1055
+ });
1056
+ socket.write(`${JSON.stringify(req)}
1057
+ `);
1058
+ const line = await readLine(socket, timeoutMs);
1059
+ if (line === null) throw new ControlTimeout();
1060
+ return JSON.parse(line);
1061
+ } finally {
1062
+ socket.destroy();
1063
+ }
1064
+ }
1065
+ var ControlTimeout = class extends Error {
1066
+ constructor() {
1067
+ super("the companion did not answer its control socket in time");
1068
+ this.name = "ControlTimeout";
1069
+ }
1070
+ };
1071
+ function isNotListening(err) {
1072
+ const code = err?.code;
1073
+ return code === "ENOENT" || code === "ECONNREFUSED";
1074
+ }
1075
+ function readLine(socket, timeoutMs) {
1076
+ return new Promise((resolve) => {
1077
+ let buf = "";
1078
+ let settled = false;
1079
+ const done = (v) => {
1080
+ if (settled) return;
1081
+ settled = true;
1082
+ clearTimeout(timer);
1083
+ socket.removeListener("data", onData);
1084
+ resolve(v);
1085
+ };
1086
+ const timer = setTimeout(() => done(null), timeoutMs);
1087
+ timer.unref?.();
1088
+ const onData = (chunk) => {
1089
+ buf += chunk.toString("utf8");
1090
+ const nl = buf.indexOf("\n");
1091
+ if (nl >= 0) done(buf.slice(0, nl));
1092
+ else if (buf.length > 1e6) done(null);
1093
+ };
1094
+ socket.on("data", onData);
1095
+ socket.once("close", () => done(null));
1096
+ socket.once("error", () => done(null));
1097
+ });
1098
+ }
1099
+ async function ping(path) {
1100
+ try {
1101
+ await controlRequest(path, { cmd: "status" });
1102
+ return true;
1103
+ } catch (err) {
1104
+ return !isNotListening(err);
1105
+ }
1106
+ }
1107
+
1108
+ // src/harness-check.ts
1109
+ import { spawn as spawn4 } from "child_process";
1110
+
953
1111
  // src/harness-versions.ts
954
1112
  import { spawn as spawn2 } from "child_process";
955
1113
  var EMPTY = { claudeCode: null, opencode: null, codex: null };
@@ -1031,6 +1189,20 @@ async function safe(fn) {
1031
1189
  }
1032
1190
  }
1033
1191
 
1192
+ // src/manifest.ts
1193
+ var DEVICE_MANIFEST = {
1194
+ runtimes: [{ name: "claude-code", version: null }],
1195
+ capabilities: { hostFs: true, browser: true, userMcp: true }
1196
+ };
1197
+ function buildCompanionManifest(opts) {
1198
+ const v = opts.versions ?? {};
1199
+ const runtimes = [];
1200
+ if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
1201
+ if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
1202
+ if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
1203
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
1204
+ }
1205
+
1034
1206
  // src/prereqs.ts
1035
1207
  import { spawn as spawn3 } from "child_process";
1036
1208
  async function claudeOnPath() {
@@ -1102,33 +1274,292 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1102
1274
  );
1103
1275
  }
1104
1276
 
1277
+ // src/harness-status.ts
1278
+ var HARNESS_LABELS = {
1279
+ "claude-code": "Claude Code",
1280
+ codex: "Codex",
1281
+ opencode: "opencode"
1282
+ };
1283
+ var LABELS = HARNESS_LABELS;
1284
+ function deriveHarnessSnapshot(signals) {
1285
+ const advertised = new Set(
1286
+ buildCompanionManifest({
1287
+ // CT1082: connected AND installed — the manifest's own rule, restated here
1288
+ // through the same function rather than re-decided.
1289
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
1290
+ opencode: signals.opencodeConfigured,
1291
+ codex: signals.codexEnabled
1292
+ }).runtimes.map((r) => r.name)
1293
+ );
1294
+ const harnesses = [
1295
+ deriveClaudeCode(signals, advertised.has("claude-code")),
1296
+ deriveCodex(signals, advertised.has("codex")),
1297
+ deriveOpencode(signals, advertised.has("opencode"))
1298
+ ];
1299
+ return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
1300
+ }
1301
+ function deriveClaudeCode(signals, manifestHas) {
1302
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
1303
+ if (manifestHas) {
1304
+ return {
1305
+ ...base,
1306
+ state: "exposed",
1307
+ version: signals.claudeVersion,
1308
+ detail: "Claude Code is connected and exposed to Cabane.",
1309
+ enable: null
1310
+ };
1311
+ }
1312
+ if (signals.claudeCodeConnected) {
1313
+ return {
1314
+ ...base,
1315
+ state: "needs_attention",
1316
+ version: null,
1317
+ 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.",
1318
+ enable: null
1319
+ };
1320
+ }
1321
+ if (signals.claudeOnPath) {
1322
+ return {
1323
+ ...base,
1324
+ state: "detected_not_exposed",
1325
+ version: signals.claudeVersion,
1326
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
1327
+ enable: "claude-code"
1328
+ };
1329
+ }
1330
+ return {
1331
+ ...base,
1332
+ state: "not_detected",
1333
+ version: null,
1334
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
1335
+ enable: null
1336
+ };
1337
+ }
1338
+ function deriveCodex(signals, manifestHas) {
1339
+ const base = { runtime: "codex", label: LABELS.codex };
1340
+ if (manifestHas) {
1341
+ if (signals.codexOnPath) {
1342
+ return {
1343
+ ...base,
1344
+ state: "exposed",
1345
+ version: signals.codexVersion,
1346
+ detail: "Codex is enabled and exposed to Cabane.",
1347
+ enable: null
1348
+ };
1349
+ }
1350
+ return {
1351
+ ...base,
1352
+ state: "needs_attention",
1353
+ version: null,
1354
+ detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
1355
+ enable: null
1356
+ };
1357
+ }
1358
+ if (signals.codexOnPath) {
1359
+ return {
1360
+ ...base,
1361
+ state: "detected_not_exposed",
1362
+ version: signals.codexVersion,
1363
+ detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
1364
+ enable: "codex"
1365
+ };
1366
+ }
1367
+ return {
1368
+ ...base,
1369
+ state: "not_detected",
1370
+ version: null,
1371
+ detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
1372
+ enable: null
1373
+ };
1374
+ }
1375
+ function deriveOpencode(signals, manifestHas) {
1376
+ const base = { runtime: "opencode", label: LABELS.opencode };
1377
+ if (manifestHas) {
1378
+ if (signals.opencodeReachable) {
1379
+ return {
1380
+ ...base,
1381
+ state: "exposed",
1382
+ version: signals.opencodeVersion,
1383
+ detail: "An opencode server is reachable and exposed to Cabane.",
1384
+ enable: null
1385
+ };
1386
+ }
1387
+ return {
1388
+ ...base,
1389
+ state: "needs_attention",
1390
+ version: null,
1391
+ detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
1392
+ enable: null
1393
+ };
1394
+ }
1395
+ return {
1396
+ ...base,
1397
+ state: "not_detected",
1398
+ version: null,
1399
+ detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
1400
+ enable: "opencode"
1401
+ };
1402
+ }
1403
+ function detectedRuntimesFor(snapshot) {
1404
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
1405
+ }
1406
+ var PROBE_TIMEOUT_MS = 4e3;
1407
+ async function probeHarnessSignals(cfg, deps = {}) {
1408
+ const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
1409
+ const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
1410
+ const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
1411
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1412
+ const serverUrl = cfg.opencode?.serverUrl;
1413
+ const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
1414
+ withTimeout(probeClaudePresence(), false),
1415
+ withTimeout(probeClaudeVersion(), null),
1416
+ withTimeout(probeCodexVersion(), null),
1417
+ serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
1418
+ ]);
1419
+ return {
1420
+ claudeOnPath: claudeOnPathResult,
1421
+ claudeVersion,
1422
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
1423
+ // the manifest gate and the probe above is only a suggestion.
1424
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
1425
+ // A parseable `codex --version` is our presence signal (presence alone never
1426
+ // exposes codex; its config flag is the manifest gate either way).
1427
+ codexOnPath: codexVersion !== null,
1428
+ codexVersion,
1429
+ codexEnabled: isCodexEnabled(cfg),
1430
+ opencodeConfigured: !!serverUrl,
1431
+ // A version came back ⟺ the serve answered its health endpoint (CT584).
1432
+ opencodeReachable: opencodeVersion !== null,
1433
+ opencodeVersion
1434
+ };
1435
+ }
1436
+ function withTimeout(promise, fallback) {
1437
+ return new Promise((resolve) => {
1438
+ let settled = false;
1439
+ const done = (v) => {
1440
+ if (!settled) {
1441
+ settled = true;
1442
+ resolve(v);
1443
+ }
1444
+ };
1445
+ const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS);
1446
+ timer.unref?.();
1447
+ promise.then(
1448
+ (v) => {
1449
+ clearTimeout(timer);
1450
+ done(v);
1451
+ },
1452
+ () => {
1453
+ clearTimeout(timer);
1454
+ done(fallback);
1455
+ }
1456
+ );
1457
+ });
1458
+ }
1459
+
1460
+ // src/harness-check.ts
1461
+ var CHECK_TIMEOUT_MS = 4e3;
1462
+ async function shakeOutHarness(runtime, cfg, deps = {}) {
1463
+ const run = deps.run ?? runBounded;
1464
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1465
+ try {
1466
+ if (runtime === "opencode") {
1467
+ const url = cfg.opencode?.serverUrl;
1468
+ if (!url) return "failed";
1469
+ return await probeOpencode(url) !== null ? "ok" : "failed";
1470
+ }
1471
+ const { auth, presence } = runtime === "codex" ? {
1472
+ auth: ["codex", ["login", "status"]],
1473
+ presence: ["codex", ["--version"]]
1474
+ } : {
1475
+ auth: ["claude", ["auth", "status"]],
1476
+ presence: ["claude", ["--version"]]
1477
+ };
1478
+ const authRun = await run(auth[0], [...auth[1]]);
1479
+ if (authRun.code === 0) return "ok";
1480
+ if (looksUnsupported(authRun.output)) {
1481
+ const presenceRun = await run(presence[0], [...presence[1]]);
1482
+ return presenceRun.code === 0 ? "unverified" : "failed";
1483
+ }
1484
+ return "failed";
1485
+ } catch {
1486
+ return "unverified";
1487
+ }
1488
+ }
1489
+ function connectedLine(runtime, verdict) {
1490
+ const label = HARNESS_LABELS[runtime];
1491
+ if (verdict !== "failed") return `${label} connected.`;
1492
+ return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
1493
+ }
1494
+ var FAILED_SUFFIX = {
1495
+ "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1496
+ codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1497
+ opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1498
+ };
1499
+ function looksUnsupported(output) {
1500
+ return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1501
+ output
1502
+ );
1503
+ }
1504
+ function runBounded(command, args) {
1505
+ return new Promise((resolve) => {
1506
+ let settled = false;
1507
+ const done = (code, output) => {
1508
+ if (settled) return;
1509
+ settled = true;
1510
+ clearTimeout(timer);
1511
+ resolve({ code, output });
1512
+ };
1513
+ let child;
1514
+ try {
1515
+ child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1516
+ } catch {
1517
+ resolve({ code: null, output: "" });
1518
+ return;
1519
+ }
1520
+ let out = "";
1521
+ const capture = (chunk) => {
1522
+ if (out.length < 4096) out += chunk.toString();
1523
+ };
1524
+ child.stdout?.on("data", capture);
1525
+ child.stderr?.on("data", capture);
1526
+ const timer = setTimeout(() => {
1527
+ child.kill("SIGKILL");
1528
+ done(null, out);
1529
+ }, CHECK_TIMEOUT_MS);
1530
+ timer.unref?.();
1531
+ child.once("error", () => done(null, out));
1532
+ child.once("exit", (code) => done(code, out));
1533
+ });
1534
+ }
1535
+
1105
1536
  // src/runtime-file.ts
1106
1537
  import {
1107
- existsSync as existsSync3,
1538
+ existsSync as existsSync4,
1108
1539
  readFileSync as readFileSync2,
1109
- rmSync as rmSync2,
1540
+ rmSync as rmSync3,
1110
1541
  writeFileSync as writeFileSync2,
1111
- mkdirSync as mkdirSync3,
1542
+ mkdirSync as mkdirSync4,
1112
1543
  openSync as openSync2,
1113
1544
  closeSync as closeSync2
1114
1545
  } from "fs";
1115
- import { join as join5 } from "path";
1116
- var PROBE_TIMEOUT_MS = 1e3;
1546
+ import { join as join6 } from "path";
1547
+ var PROBE_TIMEOUT_MS2 = 1e3;
1117
1548
  function runtimePath() {
1118
- return join5(cabaneDir(), "runtime.json");
1549
+ return join6(cabaneDir(), "runtime.json");
1119
1550
  }
1120
1551
  function serialize(state) {
1121
1552
  return JSON.stringify(state, null, 2) + "\n";
1122
1553
  }
1123
1554
  function writeRuntimeState(state) {
1124
1555
  const path = runtimePath();
1125
- mkdirSync3(cabaneDir(), { recursive: true });
1556
+ mkdirSync4(cabaneDir(), { recursive: true });
1126
1557
  writeFileSync2(path, serialize(state), "utf8");
1127
1558
  }
1128
1559
  function acquireRuntimeState(state) {
1129
1560
  const live = readLiveRuntimeState();
1130
1561
  if (live) return { acquired: false, existing: live };
1131
- mkdirSync3(cabaneDir(), { recursive: true });
1562
+ mkdirSync4(cabaneDir(), { recursive: true });
1132
1563
  let fd;
1133
1564
  try {
1134
1565
  fd = openSync2(runtimePath(), "wx");
@@ -1144,11 +1575,21 @@ function acquireRuntimeState(state) {
1144
1575
  }
1145
1576
  function clearRuntimeState() {
1146
1577
  const path = runtimePath();
1147
- if (existsSync3(path)) rmSync2(path, { force: true });
1578
+ if (existsSync4(path)) rmSync3(path, { force: true });
1579
+ }
1580
+ function clearRuntimeStateIfOurs(instanceId) {
1581
+ const path = runtimePath();
1582
+ if (!existsSync4(path)) return;
1583
+ try {
1584
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1585
+ if (parsed.instanceId && parsed.instanceId !== instanceId) return;
1586
+ } catch {
1587
+ }
1588
+ rmSync3(path, { force: true });
1148
1589
  }
1149
1590
  function readLiveRuntimeState() {
1150
1591
  const path = runtimePath();
1151
- if (!existsSync3(path)) return null;
1592
+ if (!existsSync4(path)) return null;
1152
1593
  let parsed;
1153
1594
  try {
1154
1595
  parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -1164,33 +1605,21 @@ function readLiveRuntimeState() {
1164
1605
  }
1165
1606
  return parsed;
1166
1607
  }
1167
- async function verifyRuntime(state, fetchImpl = fetch) {
1168
- if (!state.instanceId) return "unknown";
1169
- const url = `${trimSlash(state.url)}/api/status`;
1170
- let res;
1171
- try {
1172
- res = await fetchImpl(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
1173
- } catch (err) {
1174
- return isConnRefused(err) ? "stale" : "unknown";
1175
- }
1176
- if (!res.ok) return "unknown";
1608
+ async function verifyRuntime(state, requestImpl = controlRequest) {
1609
+ if (!state.instanceId || !state.socket) return "unknown";
1177
1610
  let body;
1178
1611
  try {
1179
- body = await res.json();
1180
- } catch {
1181
- return "unknown";
1612
+ body = await requestImpl(
1613
+ state.socket,
1614
+ { cmd: "status" },
1615
+ PROBE_TIMEOUT_MS2
1616
+ );
1617
+ } catch (err) {
1618
+ return isNotListening(err) ? "stale" : "unknown";
1182
1619
  }
1183
- if (typeof body.instance_id !== "string") return "unknown";
1620
+ if (typeof body?.instance_id !== "string") return "unknown";
1184
1621
  return body.instance_id === state.instanceId ? "ours" : "stale";
1185
1622
  }
1186
- function isConnRefused(err) {
1187
- if (!err || typeof err !== "object") return false;
1188
- const cause = err.cause;
1189
- return !!cause && typeof cause === "object" && cause.code === "ECONNREFUSED";
1190
- }
1191
- function trimSlash(s) {
1192
- return s.endsWith("/") ? s.slice(0, -1) : s;
1193
- }
1194
1623
 
1195
1624
  // src/api.ts
1196
1625
  var RETRY_BACKOFF_MS = [250, 750];
@@ -1667,22 +2096,22 @@ function errorMessage2(status, body) {
1667
2096
  // src/credentials.ts
1668
2097
  import {
1669
2098
  chmodSync as chmodSync2,
1670
- existsSync as existsSync4,
1671
- mkdirSync as mkdirSync4,
2099
+ existsSync as existsSync5,
2100
+ mkdirSync as mkdirSync5,
1672
2101
  readFileSync as readFileSync3,
1673
2102
  renameSync as renameSync2,
1674
- rmSync as rmSync3,
2103
+ rmSync as rmSync4,
1675
2104
  writeFileSync as writeFileSync3
1676
2105
  } from "fs";
1677
- import { dirname as dirname4, join as join6 } from "path";
2106
+ import { dirname as dirname4, join as join7 } from "path";
1678
2107
  import { z as z3 } from "zod";
1679
2108
  function credentialsPath() {
1680
- return join6(cabaneDir(), "credentials.json");
2109
+ return join7(cabaneDir(), "credentials.json");
1681
2110
  }
1682
2111
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
1683
2112
  function load() {
1684
2113
  const path = credentialsPath();
1685
- if (!existsSync4(path)) return {};
2114
+ if (!existsSync5(path)) return {};
1686
2115
  let raw;
1687
2116
  try {
1688
2117
  raw = readFileSync3(path, "utf8");
@@ -1699,7 +2128,7 @@ function load() {
1699
2128
  }
1700
2129
  function save(map) {
1701
2130
  const path = credentialsPath();
1702
- mkdirSync4(dirname4(path), { recursive: true });
2131
+ mkdirSync5(dirname4(path), { recursive: true });
1703
2132
  try {
1704
2133
  chmodSync2(cabaneDir(), 448);
1705
2134
  } catch {
@@ -1714,7 +2143,7 @@ function save(map) {
1714
2143
  renameSync2(tmp, path);
1715
2144
  } catch (err) {
1716
2145
  try {
1717
- rmSync3(tmp, { force: true });
2146
+ rmSync4(tmp, { force: true });
1718
2147
  } catch {
1719
2148
  }
1720
2149
  throw err;
@@ -1744,20 +2173,20 @@ function pruneCredentials(keepAgentIds) {
1744
2173
  }
1745
2174
 
1746
2175
  // src/cursor.ts
1747
- import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
1748
- import { join as join7 } from "path";
2176
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2177
+ import { join as join8 } from "path";
1749
2178
  function pathFor(workspaceId) {
1750
- return join7(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2179
+ return join8(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
1751
2180
  }
1752
2181
  function readCursor(workspaceId) {
1753
2182
  const path = pathFor(workspaceId);
1754
- if (!existsSync5(path)) return null;
2183
+ if (!existsSync6(path)) return null;
1755
2184
  const raw = readFileSync4(path, "utf8").trim();
1756
2185
  return raw.length > 0 ? raw : null;
1757
2186
  }
1758
2187
  function writeCursor(workspaceId, eventId) {
1759
2188
  const path = pathFor(workspaceId);
1760
- mkdirSync5(join7(cabaneDir(), "cursors"), { recursive: true });
2189
+ mkdirSync6(join8(cabaneDir(), "cursors"), { recursive: true });
1761
2190
  writeFileSync4(path, eventId + "\n", "utf8");
1762
2191
  }
1763
2192
 
@@ -1801,18 +2230,18 @@ var CursorTracker = class {
1801
2230
  };
1802
2231
 
1803
2232
  // src/dispatch-dedupe.ts
1804
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "fs";
1805
- import { join as join8 } from "path";
2233
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2234
+ import { join as join9 } from "path";
1806
2235
  var MAX_IDS = 256;
1807
2236
  function dir(log) {
1808
- return join8(cabaneDir(), log);
2237
+ return join9(cabaneDir(), log);
1809
2238
  }
1810
2239
  function pathFor2(log, workspaceId) {
1811
- return join8(dir(log), encodeURIComponent(workspaceId));
2240
+ return join9(dir(log), encodeURIComponent(workspaceId));
1812
2241
  }
1813
2242
  function readIds(log, workspaceId) {
1814
2243
  const path = pathFor2(log, workspaceId);
1815
- if (!existsSync6(path)) return [];
2244
+ if (!existsSync7(path)) return [];
1816
2245
  try {
1817
2246
  return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
1818
2247
  } catch {
@@ -1827,7 +2256,7 @@ function mark(log, workspaceId, eventId) {
1827
2256
  if (ids.includes(eventId)) return;
1828
2257
  ids.push(eventId);
1829
2258
  const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
1830
- mkdirSync6(dir(log), { recursive: true });
2259
+ mkdirSync7(dir(log), { recursive: true });
1831
2260
  writeFileSync5(pathFor2(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
1832
2261
  }
1833
2262
  function hasDispatched(workspaceId, eventId) {
@@ -1844,15 +2273,15 @@ function markCompleted(workspaceId, eventId) {
1844
2273
  }
1845
2274
  var MAX_RESUME_ATTEMPTS = 3;
1846
2275
  function resumeDir() {
1847
- return join8(cabaneDir(), "resume-attempts");
2276
+ return join9(cabaneDir(), "resume-attempts");
1848
2277
  }
1849
2278
  function resumePathFor(workspaceId) {
1850
- return join8(resumeDir(), encodeURIComponent(workspaceId));
2279
+ return join9(resumeDir(), encodeURIComponent(workspaceId));
1851
2280
  }
1852
2281
  function readResumeCounts(workspaceId) {
1853
2282
  const out = /* @__PURE__ */ new Map();
1854
2283
  const path = resumePathFor(workspaceId);
1855
- if (!existsSync6(path)) return out;
2284
+ if (!existsSync7(path)) return out;
1856
2285
  try {
1857
2286
  for (const line of readFileSync5(path, "utf8").split("\n")) {
1858
2287
  const trimmed = line.trim();
@@ -1874,7 +2303,7 @@ function bumpResumeAttempt(workspaceId, eventId) {
1874
2303
  counts.set(eventId, next);
1875
2304
  const entries = [...counts.entries()];
1876
2305
  const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
1877
- mkdirSync6(resumeDir(), { recursive: true });
2306
+ mkdirSync7(resumeDir(), { recursive: true });
1878
2307
  writeFileSync5(
1879
2308
  resumePathFor(workspaceId),
1880
2309
  trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
@@ -3766,7 +4195,7 @@ async function acquireServerTurnLock(url) {
3766
4195
  };
3767
4196
  }
3768
4197
  function createHttpOpencodeTransport(opts) {
3769
- const base = trimSlash2(opts.baseUrl);
4198
+ const base = trimSlash(opts.baseUrl);
3770
4199
  const doFetch = opts.fetchImpl ?? fetch;
3771
4200
  return {
3772
4201
  async run(spec, signal) {
@@ -3912,7 +4341,7 @@ function belongsToSession(ev, sessionId) {
3912
4341
  function newOpencodeMessageId() {
3913
4342
  return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
3914
4343
  }
3915
- function trimSlash2(s) {
4344
+ function trimSlash(s) {
3916
4345
  return s.endsWith("/") ? s.slice(0, -1) : s;
3917
4346
  }
3918
4347
 
@@ -4662,7 +5091,13 @@ function parseCodexModel(model) {
4662
5091
  var CABANE_MCP_SERVER3 = "cabane";
4663
5092
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4664
5093
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4665
- var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
5094
+ var ENV_ENVELOPE_KEYS = [
5095
+ "CABANE_PLAYGROUND",
5096
+ "CABANE_PLAYGROUND_BIN",
5097
+ "CABANE_CONVERSATION_ID",
5098
+ "CABANE_AGENT_ID",
5099
+ "CABANE_CONVERSATION_TITLE"
5100
+ ];
4666
5101
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
4667
5102
  const { policy, config } = req;
4668
5103
  const directory = req.local.cwd ?? "";
@@ -5567,8 +6002,8 @@ var ConnectorHealthStore = class {
5567
6002
 
5568
6003
  // src/dispatcher.ts
5569
6004
  import { randomUUID } from "crypto";
5570
- import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9, readdirSync as readdirSync2, statSync } from "fs";
5571
- import { join as join13 } from "path";
6005
+ import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6006
+ import { join as join14 } from "path";
5572
6007
 
5573
6008
  // src/summon.ts
5574
6009
  import { z as z12 } from "zod";
@@ -5842,10 +6277,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
5842
6277
 
5843
6278
  // src/build-options.ts
5844
6279
  function cabaneMcpUrl(baseUrl) {
5845
- return `${trimSlash3(baseUrl)}/api/mcp`;
6280
+ return `${trimSlash2(baseUrl)}/api/mcp`;
5846
6281
  }
5847
6282
  function turnControlMcpUrl(baseUrl) {
5848
- return `${trimSlash3(baseUrl)}/api/turn-control`;
6283
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
5849
6284
  }
5850
6285
  function buildCompanionTurnRequest(params) {
5851
6286
  const { turnContext: t } = params;
@@ -5894,18 +6329,18 @@ function buildCompanionTurnRequest(params) {
5894
6329
  }
5895
6330
  };
5896
6331
  }
5897
- function trimSlash3(s) {
6332
+ function trimSlash2(s) {
5898
6333
  return s.endsWith("/") ? s.slice(0, -1) : s;
5899
6334
  }
5900
6335
 
5901
6336
  // src/codex-instructions.ts
5902
6337
  import { mkdtemp, rm, writeFile } from "fs/promises";
5903
6338
  import { tmpdir } from "os";
5904
- import { join as join9 } from "path";
6339
+ import { join as join10 } from "path";
5905
6340
  var PREFIX = "cabane-codex-instructions-";
5906
6341
  async function writeCodexInstructionsFile(contents) {
5907
- const dir2 = await mkdtemp(join9(tmpdir(), PREFIX));
5908
- const path = join9(dir2, "instructions.md");
6342
+ const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
6343
+ const path = join10(dir2, "instructions.md");
5909
6344
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
5910
6345
  return {
5911
6346
  path,
@@ -5916,20 +6351,20 @@ async function writeCodexInstructionsFile(contents) {
5916
6351
  }
5917
6352
 
5918
6353
  // src/prepared.ts
5919
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
5920
- import { join as join10 } from "path";
6354
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
6355
+ import { join as join11 } from "path";
5921
6356
  function dirFor(workspaceId) {
5922
- return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6357
+ return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
5923
6358
  }
5924
6359
  function conversationDir(workspaceId, conversationId) {
5925
- return join10(dirFor(workspaceId), encodeURIComponent(conversationId));
6360
+ return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
5926
6361
  }
5927
6362
  function pathFor3(workspaceId, conversationId, agentId) {
5928
- return join10(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6363
+ return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
5929
6364
  }
5930
6365
  function readPrepared(workspaceId, conversationId, agentId) {
5931
6366
  const path = pathFor3(workspaceId, conversationId, agentId);
5932
- if (!existsSync7(path)) return null;
6367
+ if (!existsSync8(path)) return null;
5933
6368
  try {
5934
6369
  const parsed = JSON.parse(readFileSync6(path, "utf8"));
5935
6370
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -5944,7 +6379,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
5944
6379
  }
5945
6380
  }
5946
6381
  function writePrepared(workspaceId, conversationId, agentId, result) {
5947
- mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
6382
+ mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
5948
6383
  writeFileSync6(
5949
6384
  pathFor3(workspaceId, conversationId, agentId),
5950
6385
  JSON.stringify(result) + "\n",
@@ -5952,21 +6387,21 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
5952
6387
  );
5953
6388
  }
5954
6389
  function clearPrepared(workspaceId, conversationId, agentId) {
5955
- rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6390
+ rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
5956
6391
  }
5957
6392
 
5958
6393
  // src/secrets.ts
5959
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5960
- import { join as join11 } from "path";
6394
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6395
+ import { join as join12 } from "path";
5961
6396
  import { z as z13 } from "zod";
5962
6397
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
5963
6398
  function secretsPath() {
5964
- return join11(cabaneDir(), "secrets.json");
6399
+ return join12(cabaneDir(), "secrets.json");
5965
6400
  }
5966
6401
  var secretStoreSchema = z13.record(z13.string(), z13.string());
5967
6402
  function loadSecretStore() {
5968
6403
  const path = secretsPath();
5969
- if (!existsSync8(path)) return makeStore({});
6404
+ if (!existsSync9(path)) return makeStore({});
5970
6405
  let raw;
5971
6406
  try {
5972
6407
  raw = readFileSync7(path, "utf8");
@@ -6047,10 +6482,10 @@ function resolveMcpSecrets(mcpServers, store) {
6047
6482
  }
6048
6483
 
6049
6484
  // src/transcript-writer.ts
6050
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync5 } from "fs";
6051
- import { join as join12 } from "path";
6485
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6486
+ import { join as join13 } from "path";
6052
6487
  function transcriptsDir() {
6053
- return join12(cabaneDir(), "transcripts");
6488
+ return join13(cabaneDir(), "transcripts");
6054
6489
  }
6055
6490
  var RETAIN = 200;
6056
6491
  var TranscriptWriter = class {
@@ -6059,9 +6494,9 @@ var TranscriptWriter = class {
6059
6494
  onWarn;
6060
6495
  constructor(dir2, meta, onWarn) {
6061
6496
  this.onWarn = onWarn;
6062
- this.path = join12(dir2, fileName(meta));
6497
+ this.path = join13(dir2, fileName(meta));
6063
6498
  try {
6064
- mkdirSync8(dir2, { recursive: true });
6499
+ mkdirSync9(dir2, { recursive: true });
6065
6500
  try {
6066
6501
  chmodSync3(dir2, 448);
6067
6502
  } catch {
@@ -6118,7 +6553,7 @@ function pruneOld(dir2, retain) {
6118
6553
  const drop = files.sort().slice(0, files.length - retain);
6119
6554
  for (const f of drop) {
6120
6555
  try {
6121
- rmSync5(join12(dir2, f), { force: true });
6556
+ rmSync6(join13(dir2, f), { force: true });
6122
6557
  } catch {
6123
6558
  }
6124
6559
  }
@@ -6464,7 +6899,7 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6464
6899
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6465
6900
  function checkoutState(cwd) {
6466
6901
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
6467
- if (!existsSync9(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
6902
+ if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
6468
6903
  let entries;
6469
6904
  try {
6470
6905
  entries = readdirSync2(cwd);
@@ -6474,15 +6909,15 @@ function checkoutState(cwd) {
6474
6909
  if (entries.length === 0) {
6475
6910
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
6476
6911
  }
6477
- const gitPath = join13(cwd, ".git");
6478
- if (!existsSync9(gitPath)) return { ok: true, reason: "usable" };
6912
+ const gitPath = join14(cwd, ".git");
6913
+ if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
6479
6914
  let stat;
6480
6915
  try {
6481
6916
  stat = statSync(gitPath);
6482
6917
  } catch (error) {
6483
6918
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
6484
6919
  }
6485
- if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
6920
+ if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
6486
6921
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
6487
6922
  return { ok: true, reason: "usable" };
6488
6923
  }
@@ -6673,7 +7108,7 @@ var Dispatcher = class {
6673
7108
  let seqCounter = 0;
6674
7109
  const nextSeq = () => ++seqCounter;
6675
7110
  let effectiveCwd = localCwd ?? cabaneCwd;
6676
- if (effectiveCwd && !existsSync9(effectiveCwd)) {
7111
+ if (effectiveCwd && !existsSync10(effectiveCwd)) {
6677
7112
  turnLog.warn(
6678
7113
  { cwd: effectiveCwd },
6679
7114
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -6981,7 +7416,7 @@ ${reason}`,
6981
7416
  }
6982
7417
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
6983
7418
  }
6984
- const receiptPath = join13(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7419
+ const receiptPath = join14(effectiveCwd, ".git", "cabane", "readiness.jsonl");
6985
7420
  const receiptLine = (fields) => `${JSON.stringify({
6986
7421
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6987
7422
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7005,7 +7440,7 @@ ${reason}`,
7005
7440
  })}
7006
7441
  `;
7007
7442
  try {
7008
- mkdirSync9(join13(effectiveCwd, ".git", "cabane"), { recursive: true });
7443
+ mkdirSync10(join14(effectiveCwd, ".git", "cabane"), { recursive: true });
7009
7444
  appendFileSync2(
7010
7445
  receiptPath,
7011
7446
  // `starting` is the honest classification before the proof has run. The
@@ -7433,202 +7868,6 @@ ${reason}`,
7433
7868
  }
7434
7869
  };
7435
7870
 
7436
- // src/manifest.ts
7437
- var DEVICE_MANIFEST = {
7438
- runtimes: [{ name: "claude-code", version: null }],
7439
- capabilities: { hostFs: true, browser: true, userMcp: true }
7440
- };
7441
- function buildCompanionManifest(opts) {
7442
- const v = opts.versions ?? {};
7443
- const runtimes = [];
7444
- if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
7445
- if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
7446
- if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
7447
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
7448
- }
7449
-
7450
- // src/harness-status.ts
7451
- var LABELS = {
7452
- "claude-code": "Claude Code",
7453
- codex: "Codex",
7454
- opencode: "opencode"
7455
- };
7456
- function deriveHarnessSnapshot(signals) {
7457
- const advertised = new Set(
7458
- buildCompanionManifest({
7459
- // CT1082: connected AND installed — the manifest's own rule, restated here
7460
- // through the same function rather than re-decided.
7461
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7462
- opencode: signals.opencodeConfigured,
7463
- codex: signals.codexEnabled
7464
- }).runtimes.map((r) => r.name)
7465
- );
7466
- const harnesses = [
7467
- deriveClaudeCode(signals, advertised.has("claude-code")),
7468
- deriveCodex(signals, advertised.has("codex")),
7469
- deriveOpencode(signals, advertised.has("opencode"))
7470
- ];
7471
- return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7472
- }
7473
- function deriveClaudeCode(signals, manifestHas) {
7474
- const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7475
- if (manifestHas) {
7476
- return {
7477
- ...base,
7478
- state: "exposed",
7479
- version: signals.claudeVersion,
7480
- detail: "Claude Code is connected and exposed to Cabane.",
7481
- enable: null
7482
- };
7483
- }
7484
- if (signals.claudeCodeConnected) {
7485
- return {
7486
- ...base,
7487
- state: "needs_attention",
7488
- version: null,
7489
- 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.",
7490
- enable: null
7491
- };
7492
- }
7493
- if (signals.claudeOnPath) {
7494
- return {
7495
- ...base,
7496
- state: "detected_not_exposed",
7497
- version: signals.claudeVersion,
7498
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7499
- enable: "claude-code"
7500
- };
7501
- }
7502
- return {
7503
- ...base,
7504
- state: "not_detected",
7505
- version: null,
7506
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7507
- enable: null
7508
- };
7509
- }
7510
- function deriveCodex(signals, manifestHas) {
7511
- const base = { runtime: "codex", label: LABELS.codex };
7512
- if (manifestHas) {
7513
- if (signals.codexOnPath) {
7514
- return {
7515
- ...base,
7516
- state: "exposed",
7517
- version: signals.codexVersion,
7518
- detail: "Codex is enabled and exposed to Cabane.",
7519
- enable: null
7520
- };
7521
- }
7522
- return {
7523
- ...base,
7524
- state: "needs_attention",
7525
- version: null,
7526
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
7527
- enable: null
7528
- };
7529
- }
7530
- if (signals.codexOnPath) {
7531
- return {
7532
- ...base,
7533
- state: "detected_not_exposed",
7534
- version: signals.codexVersion,
7535
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
7536
- enable: "codex"
7537
- };
7538
- }
7539
- return {
7540
- ...base,
7541
- state: "not_detected",
7542
- version: null,
7543
- detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
7544
- enable: null
7545
- };
7546
- }
7547
- function deriveOpencode(signals, manifestHas) {
7548
- const base = { runtime: "opencode", label: LABELS.opencode };
7549
- if (manifestHas) {
7550
- if (signals.opencodeReachable) {
7551
- return {
7552
- ...base,
7553
- state: "exposed",
7554
- version: signals.opencodeVersion,
7555
- detail: "An opencode server is reachable and exposed to Cabane.",
7556
- enable: null
7557
- };
7558
- }
7559
- return {
7560
- ...base,
7561
- state: "needs_attention",
7562
- version: null,
7563
- detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
7564
- enable: null
7565
- };
7566
- }
7567
- return {
7568
- ...base,
7569
- state: "not_detected",
7570
- version: null,
7571
- detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
7572
- enable: "opencode"
7573
- };
7574
- }
7575
- function detectedRuntimesFor(snapshot) {
7576
- return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7577
- }
7578
- var PROBE_TIMEOUT_MS2 = 4e3;
7579
- async function probeHarnessSignals(cfg, deps = {}) {
7580
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
7581
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
7582
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
7583
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
7584
- const serverUrl = cfg.opencode?.serverUrl;
7585
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
7586
- withTimeout(probeClaudePresence(), false),
7587
- withTimeout(probeClaudeVersion(), null),
7588
- withTimeout(probeCodexVersion(), null),
7589
- serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
7590
- ]);
7591
- return {
7592
- claudeOnPath: claudeOnPathResult,
7593
- claudeVersion,
7594
- // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7595
- // the manifest gate and the probe above is only a suggestion.
7596
- claudeCodeConnected: isClaudeCodeConnected(cfg),
7597
- // A parseable `codex --version` is our presence signal (presence alone never
7598
- // exposes codex; its config flag is the manifest gate either way).
7599
- codexOnPath: codexVersion !== null,
7600
- codexVersion,
7601
- codexEnabled: isCodexEnabled(cfg),
7602
- opencodeConfigured: !!serverUrl,
7603
- // A version came back ⟺ the serve answered its health endpoint (CT584).
7604
- opencodeReachable: opencodeVersion !== null,
7605
- opencodeVersion
7606
- };
7607
- }
7608
- function withTimeout(promise, fallback) {
7609
- return new Promise((resolve) => {
7610
- let settled = false;
7611
- const done = (v) => {
7612
- if (!settled) {
7613
- settled = true;
7614
- resolve(v);
7615
- }
7616
- };
7617
- const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS2);
7618
- timer.unref?.();
7619
- promise.then(
7620
- (v) => {
7621
- clearTimeout(timer);
7622
- done(v);
7623
- },
7624
- () => {
7625
- clearTimeout(timer);
7626
- done(fallback);
7627
- }
7628
- );
7629
- });
7630
- }
7631
-
7632
7871
  // src/opencode-models.ts
7633
7872
  var OPENCODE_RUNTIME = "opencode";
7634
7873
  function mapOpencodeProviders(json) {
@@ -7672,15 +7911,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
7672
7911
 
7673
7912
  // src/outbox.ts
7674
7913
  import {
7675
- existsSync as existsSync10,
7676
- mkdirSync as mkdirSync10,
7914
+ existsSync as existsSync11,
7915
+ mkdirSync as mkdirSync11,
7677
7916
  readdirSync as readdirSync3,
7678
7917
  readFileSync as readFileSync8,
7679
7918
  renameSync as renameSync3,
7680
- rmSync as rmSync6,
7919
+ rmSync as rmSync7,
7681
7920
  writeFileSync as writeFileSync7
7682
7921
  } from "fs";
7683
- import { join as join14 } from "path";
7922
+ import { join as join15 } from "path";
7684
7923
  var MAX_ENTRIES = 2e3;
7685
7924
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7686
7925
  var Outbox = class {
@@ -7693,17 +7932,17 @@ var Outbox = class {
7693
7932
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
7694
7933
  // cases route writes at the right tmpdir.
7695
7934
  dir() {
7696
- return join14(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7935
+ return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7697
7936
  }
7698
7937
  fileFor(turnId, seq) {
7699
- return join14(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7938
+ return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7700
7939
  }
7701
7940
  // Persist a commit for later draining. Atomic (temp file + rename) so a
7702
7941
  // concurrent `list()` never reads a half-written entry, then enforces the
7703
7942
  // per-workspace bounds.
7704
7943
  persist(entry) {
7705
7944
  const dir2 = this.dir();
7706
- mkdirSync10(dir2, { recursive: true });
7945
+ mkdirSync11(dir2, { recursive: true });
7707
7946
  const target = this.fileFor(entry.turnId, entry.seq);
7708
7947
  const tmp = `${target}.${process.pid}.tmp`;
7709
7948
  try {
@@ -7711,7 +7950,7 @@ var Outbox = class {
7711
7950
  renameSync3(tmp, target);
7712
7951
  } catch (err) {
7713
7952
  try {
7714
- rmSync6(tmp, { force: true });
7953
+ rmSync7(tmp, { force: true });
7715
7954
  } catch {
7716
7955
  }
7717
7956
  this.log?.warn(
@@ -7728,7 +7967,7 @@ var Outbox = class {
7728
7967
  // wedging the drain.
7729
7968
  list() {
7730
7969
  const dir2 = this.dir();
7731
- if (!existsSync10(dir2)) return [];
7970
+ if (!existsSync11(dir2)) return [];
7732
7971
  let names;
7733
7972
  try {
7734
7973
  names = readdirSync3(dir2);
@@ -7738,7 +7977,7 @@ var Outbox = class {
7738
7977
  const entries = [];
7739
7978
  for (const name of names) {
7740
7979
  if (!name.endsWith(".json")) continue;
7741
- const full = join14(dir2, name);
7980
+ const full = join15(dir2, name);
7742
7981
  try {
7743
7982
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
7744
7983
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -7758,13 +7997,13 @@ var Outbox = class {
7758
7997
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
7759
7998
  remove(turnId, seq) {
7760
7999
  try {
7761
- rmSync6(this.fileFor(turnId, seq), { force: true });
8000
+ rmSync7(this.fileFor(turnId, seq), { force: true });
7762
8001
  } catch {
7763
8002
  }
7764
8003
  }
7765
8004
  size() {
7766
8005
  const dir2 = this.dir();
7767
- if (!existsSync10(dir2)) return 0;
8006
+ if (!existsSync11(dir2)) return 0;
7768
8007
  try {
7769
8008
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
7770
8009
  } catch {
@@ -7777,7 +8016,7 @@ var Outbox = class {
7777
8016
  "companion outbox: dropping unreadable entry"
7778
8017
  );
7779
8018
  try {
7780
- rmSync6(full, { force: true });
8019
+ rmSync7(full, { force: true });
7781
8020
  } catch {
7782
8021
  }
7783
8022
  }
@@ -8706,6 +8945,26 @@ var CompanionSupervisor = class {
8706
8945
  async recheckHarnesses() {
8707
8946
  await this.refreshHarnessStatuses();
8708
8947
  }
8948
+ // CT1085 §1 step 3: beat NOW and wait for it to land. `start` calls this
8949
+ // between bringing the runtime up and asking the person anything, so the
8950
+ // browser's connect step is already showing what this machine has ("your
8951
+ // terminal is asking") rather than sitting blank while the terminal blocks on
8952
+ // an answer. Coalesces with an in-flight beat rather than stacking a second.
8953
+ async heartbeatNow() {
8954
+ if (this.inFlightHeartbeat) {
8955
+ await this.inFlightHeartbeat;
8956
+ return;
8957
+ }
8958
+ this.kickHeartbeat();
8959
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
8960
+ }
8961
+ // CT1085: the config as it stands right now — after any `enableHarness` write.
8962
+ // The control socket's connect handler needs it to run the shake-out check
8963
+ // against the URL/flag that was just persisted, not the one this process booted
8964
+ // with.
8965
+ currentConfig() {
8966
+ return this.config;
8967
+ }
8709
8968
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8710
8969
  // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8711
8970
  // never installs a binary and never drives a login (BYO — Decided).
@@ -8847,9 +9106,9 @@ var CompanionSupervisor = class {
8847
9106
  };
8848
9107
  function defaultReexec() {
8849
9108
  clearRuntimeState();
8850
- void import("child_process").then(({ spawn: spawn4 }) => {
9109
+ void import("child_process").then(({ spawn: spawn5 }) => {
8851
9110
  try {
8852
- const child = spawn4(process.execPath, process.argv.slice(1), {
9111
+ const child = spawn5(process.execPath, process.argv.slice(1), {
8853
9112
  stdio: "inherit",
8854
9113
  detached: false
8855
9114
  });
@@ -8919,14 +9178,14 @@ function handleUncaught(log, err, origin) {
8919
9178
  }
8920
9179
 
8921
9180
  // src/crash-marker.ts
8922
- import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
8923
- import { join as join15 } from "path";
9181
+ import { existsSync as existsSync12, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
9182
+ import { join as join16 } from "path";
8924
9183
  function crashMarkerPath() {
8925
- return join15(cabaneDir(), "last-error.json");
9184
+ return join16(cabaneDir(), "last-error.json");
8926
9185
  }
8927
9186
  function recordCrash(rec) {
8928
9187
  try {
8929
- mkdirSync11(cabaneDir(), { recursive: true });
9188
+ mkdirSync12(cabaneDir(), { recursive: true });
8930
9189
  writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
8931
9190
  } catch {
8932
9191
  }
@@ -8934,7 +9193,7 @@ function recordCrash(rec) {
8934
9193
  function clearCrash() {
8935
9194
  try {
8936
9195
  const path = crashMarkerPath();
8937
- if (existsSync11(path)) rmSync7(path, { force: true });
9196
+ if (existsSync12(path)) rmSync8(path, { force: true });
8938
9197
  } catch {
8939
9198
  }
8940
9199
  }
@@ -8953,7 +9212,13 @@ async function createCompanionRuntime(opts = {}) {
8953
9212
  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)"
8954
9213
  )
8955
9214
  }));
8956
- await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
9215
+ await warnAboutHarnessReadiness(cfg, {
9216
+ probeClaude: async () => claudeCode,
9217
+ // CT1085: the CLI's onboarding script owns the terminal and says this in
9218
+ // its own words (the `!` block, or the per-harness offer), so it hands us a
9219
+ // sink that logs instead. Every other caller keeps the stderr line.
9220
+ ...opts.onReadinessWarning ? { warn: opts.onReadinessWarning } : {}
9221
+ });
8957
9222
  } catch (err) {
8958
9223
  recordCrash({
8959
9224
  reason: err instanceof Error ? err.message : String(err),
@@ -8977,8 +9242,6 @@ async function createCompanionRuntime(opts = {}) {
8977
9242
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
8978
9243
  const claim = acquireRuntimeState({
8979
9244
  pid: process.pid,
8980
- url: "",
8981
- port: 0,
8982
9245
  startedAt,
8983
9246
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8984
9247
  instanceId
@@ -8986,7 +9249,7 @@ async function createCompanionRuntime(opts = {}) {
8986
9249
  if (!claim.acquired) {
8987
9250
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
8988
9251
  }
8989
- process.on("exit", () => clearRuntimeState());
9252
+ process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
8990
9253
  const hub = new CompanionStateHub({
8991
9254
  // CT29: one device, one base URL — the cabane instance this device is paired
8992
9255
  // with. The dashboard's connection line shows it.
@@ -9004,17 +9267,36 @@ async function createCompanionRuntime(opts = {}) {
9004
9267
  harnessVersions
9005
9268
  });
9006
9269
  await supervisor.start();
9007
- const preferredPort = opts.port ?? cfg.dashboardPort;
9008
- const dashboard = await startDashboard({
9009
- supervisor,
9010
- hub,
9011
- ...preferredPort !== void 0 ? { port: preferredPort } : {}
9270
+ const connectHarness = async (runtime, serverUrl) => {
9271
+ const result = await supervisor.enableHarness(
9272
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: serverUrl ?? "" } : { runtime }
9273
+ );
9274
+ if (!result.ok) return { ok: false, error: result.error };
9275
+ const verdict = await shakeOutHarness(runtime, supervisor.currentConfig());
9276
+ return { ok: true, message: connectedLine(runtime, verdict) };
9277
+ };
9278
+ const control = await startControlServer({
9279
+ status: () => hub.statusJson(),
9280
+ connect: async (runtime, serverUrl) => {
9281
+ const result = await connectHarness(runtime, serverUrl);
9282
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
9283
+ },
9284
+ stop: () => void supervisor.requestStop()
9012
9285
  });
9013
- hub.setDashboardUrl(dashboard.url);
9286
+ let dashboard = null;
9287
+ if (opts.dashboard) {
9288
+ const preferredPort = opts.port ?? cfg.dashboardPort;
9289
+ dashboard = await startDashboard({
9290
+ supervisor,
9291
+ hub,
9292
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
9293
+ });
9294
+ hub.setDashboardUrl(dashboard.url);
9295
+ }
9014
9296
  writeRuntimeState({
9015
9297
  pid: process.pid,
9016
- url: dashboard.url,
9017
- port: dashboard.port,
9298
+ socket: control.path,
9299
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9018
9300
  startedAt,
9019
9301
  // SJ495: the daemon launcher sets this env on the detached child, so the
9020
9302
  // marker records whether this companion is backgrounded (foreground start
@@ -9027,30 +9309,37 @@ async function createCompanionRuntime(opts = {}) {
9027
9309
  const stop = async () => {
9028
9310
  if (stopped) return;
9029
9311
  stopped = true;
9030
- clearRuntimeState();
9312
+ clearRuntimeStateIfOurs(instanceId);
9031
9313
  try {
9032
9314
  await supervisor.shutdown();
9033
- await dashboard.close();
9315
+ await closeSurfaces(control, dashboard);
9034
9316
  } catch {
9035
9317
  }
9036
9318
  };
9037
9319
  return {
9038
9320
  ok: true,
9039
9321
  runtime: {
9040
- url: dashboard.url,
9041
- port: dashboard.port,
9322
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9323
+ socketPath: control.path,
9042
9324
  config: cfg,
9043
9325
  stop,
9326
+ heartbeatNow: () => supervisor.heartbeatNow(),
9327
+ harnesses: () => hub.statusJson().harnesses ?? [],
9328
+ connectHarness,
9044
9329
  drainForRestart: async (graceMs) => {
9045
- clearRuntimeState();
9330
+ clearRuntimeStateIfOurs(instanceId);
9046
9331
  const result = await supervisor.drainForRestart(graceMs);
9047
- await dashboard.close();
9332
+ await closeSurfaces(control, dashboard);
9048
9333
  stopped = true;
9049
9334
  return result;
9050
9335
  }
9051
9336
  }
9052
9337
  };
9053
9338
  }
9339
+ async function closeSurfaces(control, dashboard) {
9340
+ await control.close();
9341
+ if (dashboard) await dashboard.close();
9342
+ }
9054
9343
  export {
9055
9344
  createCompanionRuntime
9056
9345
  };