@cabane/companion 0.6.32 → 0.6.35

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(),
@@ -460,21 +461,36 @@ function consoleMessageFormat(log, messageKey) {
460
461
  return short ? `${short} ${msg}` : msg;
461
462
  }
462
463
  var cached = null;
463
- function getLogger() {
464
- if (cached) return cached;
464
+ var consoleLogging = true;
465
+ function createLogger(destinations = {}) {
465
466
  const path = companionLogPath();
466
- mkdirSync2(dirname2(path), { recursive: true });
467
+ if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
467
468
  const streams = [];
468
469
  if (process.env.CABANE_COMPANION_DAEMON !== "1") {
469
470
  const consoleStream = pretty({
470
471
  colorize: true,
471
472
  ignore: CONSOLE_IGNORE,
472
- messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey)
473
+ messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
474
+ ...destinations.console ? { destination: destinations.console } : {}
475
+ });
476
+ streams.push({
477
+ level: "info",
478
+ stream: {
479
+ write(chunk) {
480
+ if (consoleLogging) consoleStream.write(chunk);
481
+ }
482
+ }
473
483
  });
474
- streams.push({ level: "info", stream: consoleStream });
475
484
  }
476
- streams.push({ level: "debug", stream: createWriteStream(path, { flags: "a" }) });
477
- cached = pino({ level: "debug" }, pino.multistream(streams));
485
+ streams.push({
486
+ level: "debug",
487
+ stream: destinations.file ?? createWriteStream(path, { flags: "a" })
488
+ });
489
+ return pino({ level: "debug" }, pino.multistream(streams));
490
+ }
491
+ function getLogger() {
492
+ if (cached) return cached;
493
+ cached = createLogger();
478
494
  return cached;
479
495
  }
480
496
 
@@ -950,6 +966,163 @@ function resolveStaticDir() {
950
966
  return join4(dirname3(fileURLToPath(import.meta.url)), "static");
951
967
  }
952
968
 
969
+ // src/control-socket.ts
970
+ import { createHash } from "crypto";
971
+ import { existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
972
+ import { createServer, connect } from "net";
973
+ import { join as join5 } from "path";
974
+ var CONTROL_TIMEOUT_MS = 1e3;
975
+ function controlSocketPath() {
976
+ const dir2 = cabaneDir();
977
+ if (process.platform === "win32") {
978
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
979
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
980
+ }
981
+ return join5(dir2, "companion.sock");
982
+ }
983
+ async function startControlServer(handlers) {
984
+ const path = controlSocketPath();
985
+ mkdirSync3(cabaneDir(), { recursive: true });
986
+ if (process.platform !== "win32" && existsSync3(path)) {
987
+ const alive = await ping(path);
988
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
989
+ rmSync2(path, { force: true });
990
+ }
991
+ const server = createServer((socket) => {
992
+ void serveConnection(socket, handlers);
993
+ });
994
+ server.unref();
995
+ await new Promise((resolve, reject) => {
996
+ server.once("error", reject);
997
+ server.listen(path, () => {
998
+ server.removeListener("error", reject);
999
+ resolve();
1000
+ });
1001
+ });
1002
+ server.on("error", () => {
1003
+ });
1004
+ return {
1005
+ path,
1006
+ close: () => new Promise((resolve) => {
1007
+ server.close(() => {
1008
+ if (process.platform !== "win32") rmSync2(path, { force: true });
1009
+ resolve();
1010
+ });
1011
+ })
1012
+ };
1013
+ }
1014
+ async function serveConnection(socket, handlers) {
1015
+ socket.on("error", () => socket.destroy());
1016
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1017
+ if (line === null) {
1018
+ socket.destroy();
1019
+ return;
1020
+ }
1021
+ let req;
1022
+ try {
1023
+ req = JSON.parse(line);
1024
+ } catch {
1025
+ reply(socket, { error: "malformed request" });
1026
+ return;
1027
+ }
1028
+ try {
1029
+ if (req.cmd === "status") {
1030
+ reply(socket, handlers.status());
1031
+ return;
1032
+ }
1033
+ if (req.cmd === "connect") {
1034
+ const result = await handlers.connect(req.runtime, req.serverUrl);
1035
+ reply(socket, result);
1036
+ return;
1037
+ }
1038
+ if (req.cmd === "stop") {
1039
+ reply(socket, { ok: true });
1040
+ setTimeout(() => handlers.stop(), 50).unref?.();
1041
+ return;
1042
+ }
1043
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1044
+ } catch (err) {
1045
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
1046
+ }
1047
+ }
1048
+ function reply(socket, body) {
1049
+ try {
1050
+ socket.end(`${JSON.stringify(body)}
1051
+ `);
1052
+ } catch {
1053
+ socket.destroy();
1054
+ }
1055
+ }
1056
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1057
+ const socket = connect(path);
1058
+ try {
1059
+ await new Promise((resolve, reject) => {
1060
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1061
+ timer.unref?.();
1062
+ socket.once("connect", () => {
1063
+ clearTimeout(timer);
1064
+ resolve();
1065
+ });
1066
+ socket.once("error", (err) => {
1067
+ clearTimeout(timer);
1068
+ reject(err);
1069
+ });
1070
+ });
1071
+ socket.write(`${JSON.stringify(req)}
1072
+ `);
1073
+ const line = await readLine(socket, timeoutMs);
1074
+ if (line === null) throw new ControlTimeout();
1075
+ return JSON.parse(line);
1076
+ } finally {
1077
+ socket.destroy();
1078
+ }
1079
+ }
1080
+ var ControlTimeout = class extends Error {
1081
+ constructor() {
1082
+ super("the companion did not answer its control socket in time");
1083
+ this.name = "ControlTimeout";
1084
+ }
1085
+ };
1086
+ function isNotListening(err) {
1087
+ const code = err?.code;
1088
+ return code === "ENOENT" || code === "ECONNREFUSED";
1089
+ }
1090
+ function readLine(socket, timeoutMs) {
1091
+ return new Promise((resolve) => {
1092
+ let buf = "";
1093
+ let settled = false;
1094
+ const done = (v) => {
1095
+ if (settled) return;
1096
+ settled = true;
1097
+ clearTimeout(timer);
1098
+ socket.removeListener("data", onData);
1099
+ resolve(v);
1100
+ };
1101
+ const timer = setTimeout(() => done(null), timeoutMs);
1102
+ timer.unref?.();
1103
+ const onData = (chunk) => {
1104
+ buf += chunk.toString("utf8");
1105
+ const nl = buf.indexOf("\n");
1106
+ if (nl >= 0) done(buf.slice(0, nl));
1107
+ else if (buf.length > 1e6) done(null);
1108
+ };
1109
+ socket.on("data", onData);
1110
+ socket.once("close", () => done(null));
1111
+ socket.once("error", () => done(null));
1112
+ });
1113
+ }
1114
+ async function ping(path) {
1115
+ try {
1116
+ await controlRequest(path, { cmd: "status" });
1117
+ return true;
1118
+ } catch (err) {
1119
+ return !isNotListening(err);
1120
+ }
1121
+ }
1122
+
1123
+ // src/harness-check.ts
1124
+ import { spawn as spawn4 } from "child_process";
1125
+
953
1126
  // src/harness-versions.ts
954
1127
  import { spawn as spawn2 } from "child_process";
955
1128
  var EMPTY = { claudeCode: null, opencode: null, codex: null };
@@ -1031,6 +1204,20 @@ async function safe(fn) {
1031
1204
  }
1032
1205
  }
1033
1206
 
1207
+ // src/manifest.ts
1208
+ var DEVICE_MANIFEST = {
1209
+ runtimes: [{ name: "claude-code", version: null }],
1210
+ capabilities: { hostFs: true, browser: true, userMcp: true }
1211
+ };
1212
+ function buildCompanionManifest(opts) {
1213
+ const v = opts.versions ?? {};
1214
+ const runtimes = [];
1215
+ if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
1216
+ if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
1217
+ if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
1218
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
1219
+ }
1220
+
1034
1221
  // src/prereqs.ts
1035
1222
  import { spawn as spawn3 } from "child_process";
1036
1223
  async function claudeOnPath() {
@@ -1102,33 +1289,303 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1102
1289
  );
1103
1290
  }
1104
1291
 
1292
+ // src/harness-status.ts
1293
+ var HARNESS_LABELS = {
1294
+ "claude-code": "Claude Code",
1295
+ codex: "Codex",
1296
+ opencode: "opencode"
1297
+ };
1298
+ var LABELS = HARNESS_LABELS;
1299
+ function deriveHarnessSnapshot(signals) {
1300
+ const advertised = new Set(
1301
+ buildCompanionManifest({
1302
+ // CT1082: connected AND installed — the manifest's own rule, restated here
1303
+ // through the same function rather than re-decided.
1304
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
1305
+ opencode: signals.opencodeConfigured,
1306
+ codex: signals.codexEnabled
1307
+ }).runtimes.map((r) => r.name)
1308
+ );
1309
+ const harnesses = [
1310
+ deriveClaudeCode(signals, advertised.has("claude-code")),
1311
+ deriveCodex(signals, advertised.has("codex")),
1312
+ deriveOpencode(signals, advertised.has("opencode"))
1313
+ ];
1314
+ return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
1315
+ }
1316
+ function deriveClaudeCode(signals, manifestHas) {
1317
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
1318
+ if (manifestHas) {
1319
+ return {
1320
+ ...base,
1321
+ state: "exposed",
1322
+ version: signals.claudeVersion,
1323
+ detail: "Claude Code is connected and exposed to Cabane.",
1324
+ enable: null
1325
+ };
1326
+ }
1327
+ if (signals.claudeCodeConnected) {
1328
+ return {
1329
+ ...base,
1330
+ state: "needs_attention",
1331
+ version: null,
1332
+ 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.",
1333
+ enable: null
1334
+ };
1335
+ }
1336
+ if (signals.claudeOnPath) {
1337
+ return {
1338
+ ...base,
1339
+ state: "detected_not_exposed",
1340
+ version: signals.claudeVersion,
1341
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
1342
+ enable: "claude-code"
1343
+ };
1344
+ }
1345
+ return {
1346
+ ...base,
1347
+ state: "not_detected",
1348
+ version: null,
1349
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
1350
+ enable: null
1351
+ };
1352
+ }
1353
+ function deriveCodex(signals, manifestHas) {
1354
+ const base = { runtime: "codex", label: LABELS.codex };
1355
+ if (manifestHas) {
1356
+ if (signals.codexOnPath) {
1357
+ return {
1358
+ ...base,
1359
+ state: "exposed",
1360
+ version: signals.codexVersion,
1361
+ detail: "Codex is enabled and exposed to Cabane.",
1362
+ enable: null
1363
+ };
1364
+ }
1365
+ return {
1366
+ ...base,
1367
+ state: "needs_attention",
1368
+ version: null,
1369
+ detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
1370
+ enable: null
1371
+ };
1372
+ }
1373
+ if (signals.codexOnPath) {
1374
+ return {
1375
+ ...base,
1376
+ state: "detected_not_exposed",
1377
+ version: signals.codexVersion,
1378
+ detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
1379
+ enable: "codex"
1380
+ };
1381
+ }
1382
+ return {
1383
+ ...base,
1384
+ state: "not_detected",
1385
+ version: null,
1386
+ detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
1387
+ enable: null
1388
+ };
1389
+ }
1390
+ function deriveOpencode(signals, manifestHas) {
1391
+ const base = { runtime: "opencode", label: LABELS.opencode };
1392
+ if (manifestHas) {
1393
+ if (signals.opencodeReachable) {
1394
+ return {
1395
+ ...base,
1396
+ state: "exposed",
1397
+ version: signals.opencodeVersion,
1398
+ detail: "An opencode server is reachable and exposed to Cabane.",
1399
+ enable: null
1400
+ };
1401
+ }
1402
+ return {
1403
+ ...base,
1404
+ state: "needs_attention",
1405
+ version: null,
1406
+ detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
1407
+ enable: null
1408
+ };
1409
+ }
1410
+ return {
1411
+ ...base,
1412
+ state: "not_detected",
1413
+ version: null,
1414
+ detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
1415
+ enable: "opencode"
1416
+ };
1417
+ }
1418
+ function detectedRuntimesFor(snapshot) {
1419
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
1420
+ }
1421
+ var PROBE_TIMEOUT_MS = 4e3;
1422
+ async function probeHarnessSignals(cfg, deps = {}) {
1423
+ const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
1424
+ const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
1425
+ const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
1426
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1427
+ const serverUrl = cfg.opencode?.serverUrl;
1428
+ const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
1429
+ withTimeout(probeClaudePresence(), false),
1430
+ withTimeout(probeClaudeVersion(), null),
1431
+ withTimeout(probeCodexVersion(), null),
1432
+ serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
1433
+ ]);
1434
+ return {
1435
+ claudeOnPath: claudeOnPathResult,
1436
+ claudeVersion,
1437
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
1438
+ // the manifest gate and the probe above is only a suggestion.
1439
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
1440
+ // A parseable `codex --version` is our presence signal (presence alone never
1441
+ // exposes codex; its config flag is the manifest gate either way).
1442
+ codexOnPath: codexVersion !== null,
1443
+ codexVersion,
1444
+ codexEnabled: isCodexEnabled(cfg),
1445
+ opencodeConfigured: !!serverUrl,
1446
+ // A version came back ⟺ the serve answered its health endpoint (CT584).
1447
+ opencodeReachable: opencodeVersion !== null,
1448
+ opencodeVersion
1449
+ };
1450
+ }
1451
+ function withTimeout(promise, fallback) {
1452
+ return new Promise((resolve) => {
1453
+ let settled = false;
1454
+ const done = (v) => {
1455
+ if (!settled) {
1456
+ settled = true;
1457
+ resolve(v);
1458
+ }
1459
+ };
1460
+ const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS);
1461
+ timer.unref?.();
1462
+ promise.then(
1463
+ (v) => {
1464
+ clearTimeout(timer);
1465
+ done(v);
1466
+ },
1467
+ () => {
1468
+ clearTimeout(timer);
1469
+ done(fallback);
1470
+ }
1471
+ );
1472
+ });
1473
+ }
1474
+
1475
+ // src/harness-check.ts
1476
+ var CHECK_TIMEOUT_MS = 4e3;
1477
+ async function shakeOutHarness(runtime, cfg, deps = {}) {
1478
+ const run = deps.run ?? runBounded;
1479
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1480
+ try {
1481
+ if (runtime === "opencode") {
1482
+ const url = cfg.opencode?.serverUrl;
1483
+ if (!url) return "absent";
1484
+ return await probeOpencode(url) !== null ? "ok" : "failed";
1485
+ }
1486
+ const { auth, presence } = runtime === "codex" ? {
1487
+ auth: ["codex", ["login", "status"]],
1488
+ presence: ["codex", ["--version"]]
1489
+ } : {
1490
+ auth: ["claude", ["auth", "status"]],
1491
+ presence: ["claude", ["--version"]]
1492
+ };
1493
+ const presenceRun = await run(presence[0], [...presence[1]]);
1494
+ if (presenceRun.error === "spawn") return "absent";
1495
+ if (presenceRun.code !== 0) return "unverified";
1496
+ const authRun = await run(auth[0], [...auth[1]]);
1497
+ if (authRun.code === 0) return "ok";
1498
+ if (looksUnsupported(authRun.output)) {
1499
+ return "unverified";
1500
+ }
1501
+ return "failed";
1502
+ } catch {
1503
+ return "unverified";
1504
+ }
1505
+ }
1506
+ function connectedLine(runtime, verdict) {
1507
+ if (verdict === "absent") throw new Error("an absent harness cannot be connected");
1508
+ const label = HARNESS_LABELS[runtime];
1509
+ if (verdict !== "failed") return `${label} connected.`;
1510
+ return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
1511
+ }
1512
+ var FAILED_SUFFIX = {
1513
+ "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1514
+ codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1515
+ opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1516
+ };
1517
+ function absentLine(runtime) {
1518
+ if (runtime === "opencode") {
1519
+ return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
1520
+ }
1521
+ const label = HARNESS_LABELS[runtime];
1522
+ const login = runtime === "codex" ? "`codex login`" : "`claude`";
1523
+ return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
1524
+ }
1525
+ function looksUnsupported(output) {
1526
+ return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1527
+ output
1528
+ );
1529
+ }
1530
+ function runBounded(command, args) {
1531
+ return new Promise((resolve) => {
1532
+ let settled = false;
1533
+ const done = (result) => {
1534
+ if (settled) return;
1535
+ settled = true;
1536
+ clearTimeout(timer);
1537
+ resolve(result);
1538
+ };
1539
+ let child;
1540
+ try {
1541
+ child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1542
+ } catch {
1543
+ resolve({ code: null, output: "", error: "spawn" });
1544
+ return;
1545
+ }
1546
+ let out = "";
1547
+ const capture = (chunk) => {
1548
+ if (out.length < 4096) out += chunk.toString();
1549
+ };
1550
+ child.stdout?.on("data", capture);
1551
+ child.stderr?.on("data", capture);
1552
+ const timer = setTimeout(() => {
1553
+ child.kill("SIGKILL");
1554
+ done({ code: null, output: out, error: "timeout" });
1555
+ }, CHECK_TIMEOUT_MS);
1556
+ timer.unref?.();
1557
+ child.once("error", () => done({ code: null, output: out, error: "spawn" }));
1558
+ child.once("exit", (code) => done({ code, output: out }));
1559
+ });
1560
+ }
1561
+
1105
1562
  // src/runtime-file.ts
1106
1563
  import {
1107
- existsSync as existsSync3,
1564
+ existsSync as existsSync4,
1108
1565
  readFileSync as readFileSync2,
1109
- rmSync as rmSync2,
1566
+ rmSync as rmSync3,
1110
1567
  writeFileSync as writeFileSync2,
1111
- mkdirSync as mkdirSync3,
1568
+ mkdirSync as mkdirSync4,
1112
1569
  openSync as openSync2,
1113
1570
  closeSync as closeSync2
1114
1571
  } from "fs";
1115
- import { join as join5 } from "path";
1116
- var PROBE_TIMEOUT_MS = 1e3;
1572
+ import { join as join6 } from "path";
1573
+ var PROBE_TIMEOUT_MS2 = 1e3;
1117
1574
  function runtimePath() {
1118
- return join5(cabaneDir(), "runtime.json");
1575
+ return join6(cabaneDir(), "runtime.json");
1119
1576
  }
1120
1577
  function serialize(state) {
1121
1578
  return JSON.stringify(state, null, 2) + "\n";
1122
1579
  }
1123
1580
  function writeRuntimeState(state) {
1124
1581
  const path = runtimePath();
1125
- mkdirSync3(cabaneDir(), { recursive: true });
1582
+ mkdirSync4(cabaneDir(), { recursive: true });
1126
1583
  writeFileSync2(path, serialize(state), "utf8");
1127
1584
  }
1128
1585
  function acquireRuntimeState(state) {
1129
1586
  const live = readLiveRuntimeState();
1130
1587
  if (live) return { acquired: false, existing: live };
1131
- mkdirSync3(cabaneDir(), { recursive: true });
1588
+ mkdirSync4(cabaneDir(), { recursive: true });
1132
1589
  let fd;
1133
1590
  try {
1134
1591
  fd = openSync2(runtimePath(), "wx");
@@ -1144,11 +1601,21 @@ function acquireRuntimeState(state) {
1144
1601
  }
1145
1602
  function clearRuntimeState() {
1146
1603
  const path = runtimePath();
1147
- if (existsSync3(path)) rmSync2(path, { force: true });
1604
+ if (existsSync4(path)) rmSync3(path, { force: true });
1605
+ }
1606
+ function clearRuntimeStateIfOurs(instanceId) {
1607
+ const path = runtimePath();
1608
+ if (!existsSync4(path)) return;
1609
+ try {
1610
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1611
+ if (parsed.instanceId && parsed.instanceId !== instanceId) return;
1612
+ } catch {
1613
+ }
1614
+ rmSync3(path, { force: true });
1148
1615
  }
1149
1616
  function readLiveRuntimeState() {
1150
1617
  const path = runtimePath();
1151
- if (!existsSync3(path)) return null;
1618
+ if (!existsSync4(path)) return null;
1152
1619
  let parsed;
1153
1620
  try {
1154
1621
  parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -1164,33 +1631,21 @@ function readLiveRuntimeState() {
1164
1631
  }
1165
1632
  return parsed;
1166
1633
  }
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";
1634
+ async function verifyRuntime(state, requestImpl = controlRequest) {
1635
+ if (!state.instanceId || !state.socket) return "unknown";
1177
1636
  let body;
1178
1637
  try {
1179
- body = await res.json();
1180
- } catch {
1181
- return "unknown";
1638
+ body = await requestImpl(
1639
+ state.socket,
1640
+ { cmd: "status" },
1641
+ PROBE_TIMEOUT_MS2
1642
+ );
1643
+ } catch (err) {
1644
+ return isNotListening(err) ? "stale" : "unknown";
1182
1645
  }
1183
- if (typeof body.instance_id !== "string") return "unknown";
1646
+ if (typeof body?.instance_id !== "string") return "unknown";
1184
1647
  return body.instance_id === state.instanceId ? "ours" : "stale";
1185
1648
  }
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
1649
 
1195
1650
  // src/api.ts
1196
1651
  var RETRY_BACKOFF_MS = [250, 750];
@@ -1667,22 +2122,22 @@ function errorMessage2(status, body) {
1667
2122
  // src/credentials.ts
1668
2123
  import {
1669
2124
  chmodSync as chmodSync2,
1670
- existsSync as existsSync4,
1671
- mkdirSync as mkdirSync4,
2125
+ existsSync as existsSync5,
2126
+ mkdirSync as mkdirSync5,
1672
2127
  readFileSync as readFileSync3,
1673
2128
  renameSync as renameSync2,
1674
- rmSync as rmSync3,
2129
+ rmSync as rmSync4,
1675
2130
  writeFileSync as writeFileSync3
1676
2131
  } from "fs";
1677
- import { dirname as dirname4, join as join6 } from "path";
2132
+ import { dirname as dirname4, join as join7 } from "path";
1678
2133
  import { z as z3 } from "zod";
1679
2134
  function credentialsPath() {
1680
- return join6(cabaneDir(), "credentials.json");
2135
+ return join7(cabaneDir(), "credentials.json");
1681
2136
  }
1682
2137
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
1683
2138
  function load() {
1684
2139
  const path = credentialsPath();
1685
- if (!existsSync4(path)) return {};
2140
+ if (!existsSync5(path)) return {};
1686
2141
  let raw;
1687
2142
  try {
1688
2143
  raw = readFileSync3(path, "utf8");
@@ -1699,7 +2154,7 @@ function load() {
1699
2154
  }
1700
2155
  function save(map) {
1701
2156
  const path = credentialsPath();
1702
- mkdirSync4(dirname4(path), { recursive: true });
2157
+ mkdirSync5(dirname4(path), { recursive: true });
1703
2158
  try {
1704
2159
  chmodSync2(cabaneDir(), 448);
1705
2160
  } catch {
@@ -1714,7 +2169,7 @@ function save(map) {
1714
2169
  renameSync2(tmp, path);
1715
2170
  } catch (err) {
1716
2171
  try {
1717
- rmSync3(tmp, { force: true });
2172
+ rmSync4(tmp, { force: true });
1718
2173
  } catch {
1719
2174
  }
1720
2175
  throw err;
@@ -1744,20 +2199,20 @@ function pruneCredentials(keepAgentIds) {
1744
2199
  }
1745
2200
 
1746
2201
  // 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";
2202
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2203
+ import { join as join8 } from "path";
1749
2204
  function pathFor(workspaceId) {
1750
- return join7(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2205
+ return join8(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
1751
2206
  }
1752
2207
  function readCursor(workspaceId) {
1753
2208
  const path = pathFor(workspaceId);
1754
- if (!existsSync5(path)) return null;
2209
+ if (!existsSync6(path)) return null;
1755
2210
  const raw = readFileSync4(path, "utf8").trim();
1756
2211
  return raw.length > 0 ? raw : null;
1757
2212
  }
1758
2213
  function writeCursor(workspaceId, eventId) {
1759
2214
  const path = pathFor(workspaceId);
1760
- mkdirSync5(join7(cabaneDir(), "cursors"), { recursive: true });
2215
+ mkdirSync6(join8(cabaneDir(), "cursors"), { recursive: true });
1761
2216
  writeFileSync4(path, eventId + "\n", "utf8");
1762
2217
  }
1763
2218
 
@@ -1801,18 +2256,18 @@ var CursorTracker = class {
1801
2256
  };
1802
2257
 
1803
2258
  // 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";
2259
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2260
+ import { join as join9 } from "path";
1806
2261
  var MAX_IDS = 256;
1807
2262
  function dir(log) {
1808
- return join8(cabaneDir(), log);
2263
+ return join9(cabaneDir(), log);
1809
2264
  }
1810
2265
  function pathFor2(log, workspaceId) {
1811
- return join8(dir(log), encodeURIComponent(workspaceId));
2266
+ return join9(dir(log), encodeURIComponent(workspaceId));
1812
2267
  }
1813
2268
  function readIds(log, workspaceId) {
1814
2269
  const path = pathFor2(log, workspaceId);
1815
- if (!existsSync6(path)) return [];
2270
+ if (!existsSync7(path)) return [];
1816
2271
  try {
1817
2272
  return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
1818
2273
  } catch {
@@ -1827,7 +2282,7 @@ function mark(log, workspaceId, eventId) {
1827
2282
  if (ids.includes(eventId)) return;
1828
2283
  ids.push(eventId);
1829
2284
  const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
1830
- mkdirSync6(dir(log), { recursive: true });
2285
+ mkdirSync7(dir(log), { recursive: true });
1831
2286
  writeFileSync5(pathFor2(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
1832
2287
  }
1833
2288
  function hasDispatched(workspaceId, eventId) {
@@ -1844,15 +2299,15 @@ function markCompleted(workspaceId, eventId) {
1844
2299
  }
1845
2300
  var MAX_RESUME_ATTEMPTS = 3;
1846
2301
  function resumeDir() {
1847
- return join8(cabaneDir(), "resume-attempts");
2302
+ return join9(cabaneDir(), "resume-attempts");
1848
2303
  }
1849
2304
  function resumePathFor(workspaceId) {
1850
- return join8(resumeDir(), encodeURIComponent(workspaceId));
2305
+ return join9(resumeDir(), encodeURIComponent(workspaceId));
1851
2306
  }
1852
2307
  function readResumeCounts(workspaceId) {
1853
2308
  const out = /* @__PURE__ */ new Map();
1854
2309
  const path = resumePathFor(workspaceId);
1855
- if (!existsSync6(path)) return out;
2310
+ if (!existsSync7(path)) return out;
1856
2311
  try {
1857
2312
  for (const line of readFileSync5(path, "utf8").split("\n")) {
1858
2313
  const trimmed = line.trim();
@@ -1874,7 +2329,7 @@ function bumpResumeAttempt(workspaceId, eventId) {
1874
2329
  counts.set(eventId, next);
1875
2330
  const entries = [...counts.entries()];
1876
2331
  const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
1877
- mkdirSync6(resumeDir(), { recursive: true });
2332
+ mkdirSync7(resumeDir(), { recursive: true });
1878
2333
  writeFileSync5(
1879
2334
  resumePathFor(workspaceId),
1880
2335
  trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
@@ -3766,7 +4221,7 @@ async function acquireServerTurnLock(url) {
3766
4221
  };
3767
4222
  }
3768
4223
  function createHttpOpencodeTransport(opts) {
3769
- const base = trimSlash2(opts.baseUrl);
4224
+ const base = trimSlash(opts.baseUrl);
3770
4225
  const doFetch = opts.fetchImpl ?? fetch;
3771
4226
  return {
3772
4227
  async run(spec, signal) {
@@ -3912,7 +4367,7 @@ function belongsToSession(ev, sessionId) {
3912
4367
  function newOpencodeMessageId() {
3913
4368
  return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
3914
4369
  }
3915
- function trimSlash2(s) {
4370
+ function trimSlash(s) {
3916
4371
  return s.endsWith("/") ? s.slice(0, -1) : s;
3917
4372
  }
3918
4373
 
@@ -4662,7 +5117,13 @@ function parseCodexModel(model) {
4662
5117
  var CABANE_MCP_SERVER3 = "cabane";
4663
5118
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4664
5119
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4665
- var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
5120
+ var ENV_ENVELOPE_KEYS = [
5121
+ "CABANE_PLAYGROUND",
5122
+ "CABANE_PLAYGROUND_BIN",
5123
+ "CABANE_CONVERSATION_ID",
5124
+ "CABANE_AGENT_ID",
5125
+ "CABANE_CONVERSATION_TITLE"
5126
+ ];
4666
5127
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
4667
5128
  const { policy, config } = req;
4668
5129
  const directory = req.local.cwd ?? "";
@@ -5567,8 +6028,8 @@ var ConnectorHealthStore = class {
5567
6028
 
5568
6029
  // src/dispatcher.ts
5569
6030
  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";
6031
+ import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6032
+ import { join as join14 } from "path";
5572
6033
 
5573
6034
  // src/summon.ts
5574
6035
  import { z as z12 } from "zod";
@@ -5842,10 +6303,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
5842
6303
 
5843
6304
  // src/build-options.ts
5844
6305
  function cabaneMcpUrl(baseUrl) {
5845
- return `${trimSlash3(baseUrl)}/api/mcp`;
6306
+ return `${trimSlash2(baseUrl)}/api/mcp`;
5846
6307
  }
5847
6308
  function turnControlMcpUrl(baseUrl) {
5848
- return `${trimSlash3(baseUrl)}/api/turn-control`;
6309
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
5849
6310
  }
5850
6311
  function buildCompanionTurnRequest(params) {
5851
6312
  const { turnContext: t } = params;
@@ -5894,18 +6355,18 @@ function buildCompanionTurnRequest(params) {
5894
6355
  }
5895
6356
  };
5896
6357
  }
5897
- function trimSlash3(s) {
6358
+ function trimSlash2(s) {
5898
6359
  return s.endsWith("/") ? s.slice(0, -1) : s;
5899
6360
  }
5900
6361
 
5901
6362
  // src/codex-instructions.ts
5902
6363
  import { mkdtemp, rm, writeFile } from "fs/promises";
5903
6364
  import { tmpdir } from "os";
5904
- import { join as join9 } from "path";
6365
+ import { join as join10 } from "path";
5905
6366
  var PREFIX = "cabane-codex-instructions-";
5906
6367
  async function writeCodexInstructionsFile(contents) {
5907
- const dir2 = await mkdtemp(join9(tmpdir(), PREFIX));
5908
- const path = join9(dir2, "instructions.md");
6368
+ const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
6369
+ const path = join10(dir2, "instructions.md");
5909
6370
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
5910
6371
  return {
5911
6372
  path,
@@ -5916,20 +6377,20 @@ async function writeCodexInstructionsFile(contents) {
5916
6377
  }
5917
6378
 
5918
6379
  // 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";
6380
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
6381
+ import { join as join11 } from "path";
5921
6382
  function dirFor(workspaceId) {
5922
- return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6383
+ return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
5923
6384
  }
5924
6385
  function conversationDir(workspaceId, conversationId) {
5925
- return join10(dirFor(workspaceId), encodeURIComponent(conversationId));
6386
+ return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
5926
6387
  }
5927
6388
  function pathFor3(workspaceId, conversationId, agentId) {
5928
- return join10(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6389
+ return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
5929
6390
  }
5930
6391
  function readPrepared(workspaceId, conversationId, agentId) {
5931
6392
  const path = pathFor3(workspaceId, conversationId, agentId);
5932
- if (!existsSync7(path)) return null;
6393
+ if (!existsSync8(path)) return null;
5933
6394
  try {
5934
6395
  const parsed = JSON.parse(readFileSync6(path, "utf8"));
5935
6396
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -5944,7 +6405,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
5944
6405
  }
5945
6406
  }
5946
6407
  function writePrepared(workspaceId, conversationId, agentId, result) {
5947
- mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
6408
+ mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
5948
6409
  writeFileSync6(
5949
6410
  pathFor3(workspaceId, conversationId, agentId),
5950
6411
  JSON.stringify(result) + "\n",
@@ -5952,21 +6413,21 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
5952
6413
  );
5953
6414
  }
5954
6415
  function clearPrepared(workspaceId, conversationId, agentId) {
5955
- rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6416
+ rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
5956
6417
  }
5957
6418
 
5958
6419
  // src/secrets.ts
5959
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5960
- import { join as join11 } from "path";
6420
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6421
+ import { join as join12 } from "path";
5961
6422
  import { z as z13 } from "zod";
5962
6423
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
5963
6424
  function secretsPath() {
5964
- return join11(cabaneDir(), "secrets.json");
6425
+ return join12(cabaneDir(), "secrets.json");
5965
6426
  }
5966
6427
  var secretStoreSchema = z13.record(z13.string(), z13.string());
5967
6428
  function loadSecretStore() {
5968
6429
  const path = secretsPath();
5969
- if (!existsSync8(path)) return makeStore({});
6430
+ if (!existsSync9(path)) return makeStore({});
5970
6431
  let raw;
5971
6432
  try {
5972
6433
  raw = readFileSync7(path, "utf8");
@@ -6047,10 +6508,10 @@ function resolveMcpSecrets(mcpServers, store) {
6047
6508
  }
6048
6509
 
6049
6510
  // 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";
6511
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6512
+ import { join as join13 } from "path";
6052
6513
  function transcriptsDir() {
6053
- return join12(cabaneDir(), "transcripts");
6514
+ return join13(cabaneDir(), "transcripts");
6054
6515
  }
6055
6516
  var RETAIN = 200;
6056
6517
  var TranscriptWriter = class {
@@ -6059,9 +6520,9 @@ var TranscriptWriter = class {
6059
6520
  onWarn;
6060
6521
  constructor(dir2, meta, onWarn) {
6061
6522
  this.onWarn = onWarn;
6062
- this.path = join12(dir2, fileName(meta));
6523
+ this.path = join13(dir2, fileName(meta));
6063
6524
  try {
6064
- mkdirSync8(dir2, { recursive: true });
6525
+ mkdirSync9(dir2, { recursive: true });
6065
6526
  try {
6066
6527
  chmodSync3(dir2, 448);
6067
6528
  } catch {
@@ -6118,7 +6579,7 @@ function pruneOld(dir2, retain) {
6118
6579
  const drop = files.sort().slice(0, files.length - retain);
6119
6580
  for (const f of drop) {
6120
6581
  try {
6121
- rmSync5(join12(dir2, f), { force: true });
6582
+ rmSync6(join13(dir2, f), { force: true });
6122
6583
  } catch {
6123
6584
  }
6124
6585
  }
@@ -6464,7 +6925,7 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6464
6925
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6465
6926
  function checkoutState(cwd) {
6466
6927
  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})` };
6928
+ if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
6468
6929
  let entries;
6469
6930
  try {
6470
6931
  entries = readdirSync2(cwd);
@@ -6474,15 +6935,15 @@ function checkoutState(cwd) {
6474
6935
  if (entries.length === 0) {
6475
6936
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
6476
6937
  }
6477
- const gitPath = join13(cwd, ".git");
6478
- if (!existsSync9(gitPath)) return { ok: true, reason: "usable" };
6938
+ const gitPath = join14(cwd, ".git");
6939
+ if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
6479
6940
  let stat;
6480
6941
  try {
6481
6942
  stat = statSync(gitPath);
6482
6943
  } catch (error) {
6483
6944
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
6484
6945
  }
6485
- if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
6946
+ if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
6486
6947
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
6487
6948
  return { ok: true, reason: "usable" };
6488
6949
  }
@@ -6673,7 +7134,7 @@ var Dispatcher = class {
6673
7134
  let seqCounter = 0;
6674
7135
  const nextSeq = () => ++seqCounter;
6675
7136
  let effectiveCwd = localCwd ?? cabaneCwd;
6676
- if (effectiveCwd && !existsSync9(effectiveCwd)) {
7137
+ if (effectiveCwd && !existsSync10(effectiveCwd)) {
6677
7138
  turnLog.warn(
6678
7139
  { cwd: effectiveCwd },
6679
7140
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -6981,7 +7442,7 @@ ${reason}`,
6981
7442
  }
6982
7443
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
6983
7444
  }
6984
- const receiptPath = join13(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7445
+ const receiptPath = join14(effectiveCwd, ".git", "cabane", "readiness.jsonl");
6985
7446
  const receiptLine = (fields) => `${JSON.stringify({
6986
7447
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6987
7448
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7005,7 +7466,7 @@ ${reason}`,
7005
7466
  })}
7006
7467
  `;
7007
7468
  try {
7008
- mkdirSync9(join13(effectiveCwd, ".git", "cabane"), { recursive: true });
7469
+ mkdirSync10(join14(effectiveCwd, ".git", "cabane"), { recursive: true });
7009
7470
  appendFileSync2(
7010
7471
  receiptPath,
7011
7472
  // `starting` is the honest classification before the proof has run. The
@@ -7433,202 +7894,6 @@ ${reason}`,
7433
7894
  }
7434
7895
  };
7435
7896
 
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
7897
  // src/opencode-models.ts
7633
7898
  var OPENCODE_RUNTIME = "opencode";
7634
7899
  function mapOpencodeProviders(json) {
@@ -7672,15 +7937,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
7672
7937
 
7673
7938
  // src/outbox.ts
7674
7939
  import {
7675
- existsSync as existsSync10,
7676
- mkdirSync as mkdirSync10,
7940
+ existsSync as existsSync11,
7941
+ mkdirSync as mkdirSync11,
7677
7942
  readdirSync as readdirSync3,
7678
7943
  readFileSync as readFileSync8,
7679
7944
  renameSync as renameSync3,
7680
- rmSync as rmSync6,
7945
+ rmSync as rmSync7,
7681
7946
  writeFileSync as writeFileSync7
7682
7947
  } from "fs";
7683
- import { join as join14 } from "path";
7948
+ import { join as join15 } from "path";
7684
7949
  var MAX_ENTRIES = 2e3;
7685
7950
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7686
7951
  var Outbox = class {
@@ -7693,17 +7958,17 @@ var Outbox = class {
7693
7958
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
7694
7959
  // cases route writes at the right tmpdir.
7695
7960
  dir() {
7696
- return join14(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7961
+ return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7697
7962
  }
7698
7963
  fileFor(turnId, seq) {
7699
- return join14(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7964
+ return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7700
7965
  }
7701
7966
  // Persist a commit for later draining. Atomic (temp file + rename) so a
7702
7967
  // concurrent `list()` never reads a half-written entry, then enforces the
7703
7968
  // per-workspace bounds.
7704
7969
  persist(entry) {
7705
7970
  const dir2 = this.dir();
7706
- mkdirSync10(dir2, { recursive: true });
7971
+ mkdirSync11(dir2, { recursive: true });
7707
7972
  const target = this.fileFor(entry.turnId, entry.seq);
7708
7973
  const tmp = `${target}.${process.pid}.tmp`;
7709
7974
  try {
@@ -7711,7 +7976,7 @@ var Outbox = class {
7711
7976
  renameSync3(tmp, target);
7712
7977
  } catch (err) {
7713
7978
  try {
7714
- rmSync6(tmp, { force: true });
7979
+ rmSync7(tmp, { force: true });
7715
7980
  } catch {
7716
7981
  }
7717
7982
  this.log?.warn(
@@ -7728,7 +7993,7 @@ var Outbox = class {
7728
7993
  // wedging the drain.
7729
7994
  list() {
7730
7995
  const dir2 = this.dir();
7731
- if (!existsSync10(dir2)) return [];
7996
+ if (!existsSync11(dir2)) return [];
7732
7997
  let names;
7733
7998
  try {
7734
7999
  names = readdirSync3(dir2);
@@ -7738,7 +8003,7 @@ var Outbox = class {
7738
8003
  const entries = [];
7739
8004
  for (const name of names) {
7740
8005
  if (!name.endsWith(".json")) continue;
7741
- const full = join14(dir2, name);
8006
+ const full = join15(dir2, name);
7742
8007
  try {
7743
8008
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
7744
8009
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -7758,13 +8023,13 @@ var Outbox = class {
7758
8023
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
7759
8024
  remove(turnId, seq) {
7760
8025
  try {
7761
- rmSync6(this.fileFor(turnId, seq), { force: true });
8026
+ rmSync7(this.fileFor(turnId, seq), { force: true });
7762
8027
  } catch {
7763
8028
  }
7764
8029
  }
7765
8030
  size() {
7766
8031
  const dir2 = this.dir();
7767
- if (!existsSync10(dir2)) return 0;
8032
+ if (!existsSync11(dir2)) return 0;
7768
8033
  try {
7769
8034
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
7770
8035
  } catch {
@@ -7777,7 +8042,7 @@ var Outbox = class {
7777
8042
  "companion outbox: dropping unreadable entry"
7778
8043
  );
7779
8044
  try {
7780
- rmSync6(full, { force: true });
8045
+ rmSync7(full, { force: true });
7781
8046
  } catch {
7782
8047
  }
7783
8048
  }
@@ -8706,6 +8971,26 @@ var CompanionSupervisor = class {
8706
8971
  async recheckHarnesses() {
8707
8972
  await this.refreshHarnessStatuses();
8708
8973
  }
8974
+ // CT1085 §1 step 3: beat NOW and wait for it to land. `start` calls this
8975
+ // between bringing the runtime up and asking the person anything, so the
8976
+ // browser's connect step is already showing what this machine has ("your
8977
+ // terminal is asking") rather than sitting blank while the terminal blocks on
8978
+ // an answer. Coalesces with an in-flight beat rather than stacking a second.
8979
+ async heartbeatNow() {
8980
+ if (this.inFlightHeartbeat) {
8981
+ await this.inFlightHeartbeat;
8982
+ return;
8983
+ }
8984
+ this.kickHeartbeat();
8985
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
8986
+ }
8987
+ // CT1085: the config as it stands right now — after any `enableHarness` write.
8988
+ // The control socket's connect handler needs it to run the shake-out check
8989
+ // against the URL/flag that was just persisted, not the one this process booted
8990
+ // with.
8991
+ currentConfig() {
8992
+ return this.config;
8993
+ }
8709
8994
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8710
8995
  // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8711
8996
  // never installs a binary and never drives a login (BYO — Decided).
@@ -8847,9 +9132,9 @@ var CompanionSupervisor = class {
8847
9132
  };
8848
9133
  function defaultReexec() {
8849
9134
  clearRuntimeState();
8850
- void import("child_process").then(({ spawn: spawn4 }) => {
9135
+ void import("child_process").then(({ spawn: spawn5 }) => {
8851
9136
  try {
8852
- const child = spawn4(process.execPath, process.argv.slice(1), {
9137
+ const child = spawn5(process.execPath, process.argv.slice(1), {
8853
9138
  stdio: "inherit",
8854
9139
  detached: false
8855
9140
  });
@@ -8919,14 +9204,14 @@ function handleUncaught(log, err, origin) {
8919
9204
  }
8920
9205
 
8921
9206
  // 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";
9207
+ import { existsSync as existsSync12, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
9208
+ import { join as join16 } from "path";
8924
9209
  function crashMarkerPath() {
8925
- return join15(cabaneDir(), "last-error.json");
9210
+ return join16(cabaneDir(), "last-error.json");
8926
9211
  }
8927
9212
  function recordCrash(rec) {
8928
9213
  try {
8929
- mkdirSync11(cabaneDir(), { recursive: true });
9214
+ mkdirSync12(cabaneDir(), { recursive: true });
8930
9215
  writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
8931
9216
  } catch {
8932
9217
  }
@@ -8934,7 +9219,7 @@ function recordCrash(rec) {
8934
9219
  function clearCrash() {
8935
9220
  try {
8936
9221
  const path = crashMarkerPath();
8937
- if (existsSync11(path)) rmSync7(path, { force: true });
9222
+ if (existsSync12(path)) rmSync8(path, { force: true });
8938
9223
  } catch {
8939
9224
  }
8940
9225
  }
@@ -8953,7 +9238,13 @@ async function createCompanionRuntime(opts = {}) {
8953
9238
  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
9239
  )
8955
9240
  }));
8956
- await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
9241
+ await warnAboutHarnessReadiness(cfg, {
9242
+ probeClaude: async () => claudeCode,
9243
+ // CT1085: the CLI's onboarding script owns the terminal and says this in
9244
+ // its own words (the `!` block, or the per-harness offer), so it hands us a
9245
+ // sink that logs instead. Every other caller keeps the stderr line.
9246
+ ...opts.onReadinessWarning ? { warn: opts.onReadinessWarning } : {}
9247
+ });
8957
9248
  } catch (err) {
8958
9249
  recordCrash({
8959
9250
  reason: err instanceof Error ? err.message : String(err),
@@ -8977,8 +9268,6 @@ async function createCompanionRuntime(opts = {}) {
8977
9268
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
8978
9269
  const claim = acquireRuntimeState({
8979
9270
  pid: process.pid,
8980
- url: "",
8981
- port: 0,
8982
9271
  startedAt,
8983
9272
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8984
9273
  instanceId
@@ -8986,7 +9275,7 @@ async function createCompanionRuntime(opts = {}) {
8986
9275
  if (!claim.acquired) {
8987
9276
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
8988
9277
  }
8989
- process.on("exit", () => clearRuntimeState());
9278
+ process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
8990
9279
  const hub = new CompanionStateHub({
8991
9280
  // CT29: one device, one base URL — the cabane instance this device is paired
8992
9281
  // with. The dashboard's connection line shows it.
@@ -9004,17 +9293,38 @@ async function createCompanionRuntime(opts = {}) {
9004
9293
  harnessVersions
9005
9294
  });
9006
9295
  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 } : {}
9296
+ const connectHarness = async (runtime, serverUrl) => {
9297
+ const candidate = runtime === "opencode" ? { ...supervisor.currentConfig(), opencode: { serverUrl: serverUrl ?? "" } } : supervisor.currentConfig();
9298
+ const verdict = await shakeOutHarness(runtime, candidate);
9299
+ if (verdict === "absent") return { ok: false, error: absentLine(runtime) };
9300
+ const result = await supervisor.enableHarness(
9301
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: serverUrl ?? "" } : { runtime }
9302
+ );
9303
+ if (!result.ok) return { ok: false, error: result.error };
9304
+ return { ok: true, message: connectedLine(runtime, verdict) };
9305
+ };
9306
+ const control = await startControlServer({
9307
+ status: () => hub.statusJson(),
9308
+ connect: async (runtime, serverUrl) => {
9309
+ const result = await connectHarness(runtime, serverUrl);
9310
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
9311
+ },
9312
+ stop: () => void supervisor.requestStop()
9012
9313
  });
9013
- hub.setDashboardUrl(dashboard.url);
9314
+ let dashboard = null;
9315
+ if (opts.dashboard) {
9316
+ const preferredPort = opts.port ?? cfg.dashboardPort;
9317
+ dashboard = await startDashboard({
9318
+ supervisor,
9319
+ hub,
9320
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
9321
+ });
9322
+ hub.setDashboardUrl(dashboard.url);
9323
+ }
9014
9324
  writeRuntimeState({
9015
9325
  pid: process.pid,
9016
- url: dashboard.url,
9017
- port: dashboard.port,
9326
+ socket: control.path,
9327
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9018
9328
  startedAt,
9019
9329
  // SJ495: the daemon launcher sets this env on the detached child, so the
9020
9330
  // marker records whether this companion is backgrounded (foreground start
@@ -9027,30 +9337,37 @@ async function createCompanionRuntime(opts = {}) {
9027
9337
  const stop = async () => {
9028
9338
  if (stopped) return;
9029
9339
  stopped = true;
9030
- clearRuntimeState();
9340
+ clearRuntimeStateIfOurs(instanceId);
9031
9341
  try {
9032
9342
  await supervisor.shutdown();
9033
- await dashboard.close();
9343
+ await closeSurfaces(control, dashboard);
9034
9344
  } catch {
9035
9345
  }
9036
9346
  };
9037
9347
  return {
9038
9348
  ok: true,
9039
9349
  runtime: {
9040
- url: dashboard.url,
9041
- port: dashboard.port,
9350
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9351
+ socketPath: control.path,
9042
9352
  config: cfg,
9043
9353
  stop,
9354
+ heartbeatNow: () => supervisor.heartbeatNow(),
9355
+ harnesses: () => hub.statusJson().harnesses ?? [],
9356
+ connectHarness,
9044
9357
  drainForRestart: async (graceMs) => {
9045
- clearRuntimeState();
9358
+ clearRuntimeStateIfOurs(instanceId);
9046
9359
  const result = await supervisor.drainForRestart(graceMs);
9047
- await dashboard.close();
9360
+ await closeSurfaces(control, dashboard);
9048
9361
  stopped = true;
9049
9362
  return result;
9050
9363
  }
9051
9364
  }
9052
9365
  };
9053
9366
  }
9367
+ async function closeSurfaces(control, dashboard) {
9368
+ await control.close();
9369
+ if (dashboard) await dashboard.close();
9370
+ }
9054
9371
  export {
9055
9372
  createCompanionRuntime
9056
9373
  };