@cabane/companion 0.6.31 → 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");
@@ -1142,13 +1573,23 @@ function acquireRuntimeState(state) {
1142
1573
  }
1143
1574
  return { acquired: true };
1144
1575
  }
1145
- function clearRuntimeState() {
1576
+ function clearRuntimeState() {
1577
+ const path = runtimePath();
1578
+ if (existsSync4(path)) rmSync3(path, { force: true });
1579
+ }
1580
+ function clearRuntimeStateIfOurs(instanceId) {
1146
1581
  const path = runtimePath();
1147
- if (existsSync3(path)) rmSync2(path, { force: true });
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
 
@@ -4626,9 +5055,13 @@ function sealHeld2(held, terminal) {
4626
5055
  import { z as z11 } from "zod";
4627
5056
  function codexToolPolicy(policy) {
4628
5057
  return policy.hostFs ? {
4629
- permissionProfile: "cabane-coding",
5058
+ sandboxMode: "danger-full-access",
5059
+ // Headless: nothing to approve, because nothing is denied.
4630
5060
  approvalPolicy: "never",
4631
- networkAccessEnabled: policy.web
5061
+ // Full access means the command network is open regardless of the grant —
5062
+ // recorded as `true` so the value never claims a restriction that isn't
5063
+ // enforced.
5064
+ networkAccessEnabled: true
4632
5065
  } : {
4633
5066
  sandboxMode: "read-only",
4634
5067
  // Headless: the sandbox is the boundary; never pause for a human.
@@ -4658,7 +5091,13 @@ function parseCodexModel(model) {
4658
5091
  var CABANE_MCP_SERVER3 = "cabane";
4659
5092
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4660
5093
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4661
- 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
+ ];
4662
5101
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
4663
5102
  const { policy, config } = req;
4664
5103
  const directory = req.local.cwd ?? "";
@@ -4748,7 +5187,6 @@ function buildConfig(req, baseInstructionsFile) {
4748
5187
  default_tools_approval_mode: "approve"
4749
5188
  };
4750
5189
  }
4751
- const policy = codexToolPolicy(req.policy);
4752
5190
  const tmpDir = req.local.env?.TMPDIR;
4753
5191
  const shellEnv = {};
4754
5192
  if (tmpDir) shellEnv.TMPDIR = tmpDir;
@@ -4756,7 +5194,6 @@ function buildConfig(req, baseInstructionsFile) {
4756
5194
  const value = req.local.env?.[key];
4757
5195
  if (value !== void 0) shellEnv[key] = value;
4758
5196
  }
4759
- const gateLockDir = req.local.env?.CABANE_GATE_LOCK_DIR;
4760
5197
  return {
4761
5198
  mcp_servers,
4762
5199
  experimental_use_rmcp_client: true,
@@ -4764,28 +5201,7 @@ function buildConfig(req, baseInstructionsFile) {
4764
5201
  // becomes Codex's base instructions, and Codex's own environment preamble goes
4765
5202
  // with the harness prompt it belonged to.
4766
5203
  ...baseInstructionsFile ? { model_instructions_file: baseInstructionsFile, include_environment_context: false } : {},
4767
- ...Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {},
4768
- ...policy.permissionProfile ? {
4769
- // CT733: named permission profiles are Codex's split-filesystem path.
4770
- // `:root = read` preserves coding-mode host reads; the one explicit
4771
- // workspace-root write grants the checkout, and the more-specific
4772
- // `.git` write reopens the metadata Codex protects by default. Neither
4773
- // rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
4774
- // `sandbox_workspace_write`, which would restore the `.git` carve-out.
4775
- approval_policy: policy.approvalPolicy,
4776
- default_permissions: policy.permissionProfile,
4777
- permissions: {
4778
- [policy.permissionProfile]: {
4779
- filesystem: {
4780
- ":root": "read",
4781
- ":workspace_roots": { ".": "write", ".git": "write" },
4782
- // The one absolute-path grant — see the CT612-fallout note above.
4783
- ...gateLockDir ? { [gateLockDir]: "write" } : {}
4784
- },
4785
- network: { enabled: policy.networkAccessEnabled, mode: "full" }
4786
- }
4787
- }
4788
- } : {}
5204
+ ...Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}
4789
5205
  };
4790
5206
  }
4791
5207
  function isStringRecord2(v) {
@@ -4799,11 +5215,10 @@ import {
4799
5215
  function buildSdkThreadOptions(spec) {
4800
5216
  return {
4801
5217
  ...spec.model ? { model: spec.model } : {},
4802
- ...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
5218
+ sandboxMode: spec.policy.sandboxMode,
4803
5219
  workingDirectory: spec.directory,
4804
5220
  skipGitRepoCheck: spec.skipGitRepoCheck,
4805
- ...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
4806
- ...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
5221
+ approvalPolicy: spec.policy.approvalPolicy,
4807
5222
  ...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
4808
5223
  };
4809
5224
  }
@@ -5587,8 +6002,8 @@ var ConnectorHealthStore = class {
5587
6002
 
5588
6003
  // src/dispatcher.ts
5589
6004
  import { randomUUID } from "crypto";
5590
- import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9, readdirSync as readdirSync2, statSync } from "fs";
5591
- 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";
5592
6007
 
5593
6008
  // src/summon.ts
5594
6009
  import { z as z12 } from "zod";
@@ -5862,10 +6277,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
5862
6277
 
5863
6278
  // src/build-options.ts
5864
6279
  function cabaneMcpUrl(baseUrl) {
5865
- return `${trimSlash3(baseUrl)}/api/mcp`;
6280
+ return `${trimSlash2(baseUrl)}/api/mcp`;
5866
6281
  }
5867
6282
  function turnControlMcpUrl(baseUrl) {
5868
- return `${trimSlash3(baseUrl)}/api/turn-control`;
6283
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
5869
6284
  }
5870
6285
  function buildCompanionTurnRequest(params) {
5871
6286
  const { turnContext: t } = params;
@@ -5914,18 +6329,18 @@ function buildCompanionTurnRequest(params) {
5914
6329
  }
5915
6330
  };
5916
6331
  }
5917
- function trimSlash3(s) {
6332
+ function trimSlash2(s) {
5918
6333
  return s.endsWith("/") ? s.slice(0, -1) : s;
5919
6334
  }
5920
6335
 
5921
6336
  // src/codex-instructions.ts
5922
6337
  import { mkdtemp, rm, writeFile } from "fs/promises";
5923
6338
  import { tmpdir } from "os";
5924
- import { join as join9 } from "path";
6339
+ import { join as join10 } from "path";
5925
6340
  var PREFIX = "cabane-codex-instructions-";
5926
6341
  async function writeCodexInstructionsFile(contents) {
5927
- const dir2 = await mkdtemp(join9(tmpdir(), PREFIX));
5928
- const path = join9(dir2, "instructions.md");
6342
+ const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
6343
+ const path = join10(dir2, "instructions.md");
5929
6344
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
5930
6345
  return {
5931
6346
  path,
@@ -5936,20 +6351,20 @@ async function writeCodexInstructionsFile(contents) {
5936
6351
  }
5937
6352
 
5938
6353
  // src/prepared.ts
5939
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
5940
- 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";
5941
6356
  function dirFor(workspaceId) {
5942
- return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6357
+ return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
5943
6358
  }
5944
6359
  function conversationDir(workspaceId, conversationId) {
5945
- return join10(dirFor(workspaceId), encodeURIComponent(conversationId));
6360
+ return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
5946
6361
  }
5947
6362
  function pathFor3(workspaceId, conversationId, agentId) {
5948
- return join10(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6363
+ return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
5949
6364
  }
5950
6365
  function readPrepared(workspaceId, conversationId, agentId) {
5951
6366
  const path = pathFor3(workspaceId, conversationId, agentId);
5952
- if (!existsSync7(path)) return null;
6367
+ if (!existsSync8(path)) return null;
5953
6368
  try {
5954
6369
  const parsed = JSON.parse(readFileSync6(path, "utf8"));
5955
6370
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -5964,7 +6379,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
5964
6379
  }
5965
6380
  }
5966
6381
  function writePrepared(workspaceId, conversationId, agentId, result) {
5967
- mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
6382
+ mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
5968
6383
  writeFileSync6(
5969
6384
  pathFor3(workspaceId, conversationId, agentId),
5970
6385
  JSON.stringify(result) + "\n",
@@ -5972,21 +6387,21 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
5972
6387
  );
5973
6388
  }
5974
6389
  function clearPrepared(workspaceId, conversationId, agentId) {
5975
- rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6390
+ rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
5976
6391
  }
5977
6392
 
5978
6393
  // src/secrets.ts
5979
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5980
- import { join as join11 } from "path";
6394
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
6395
+ import { join as join12 } from "path";
5981
6396
  import { z as z13 } from "zod";
5982
6397
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
5983
6398
  function secretsPath() {
5984
- return join11(cabaneDir(), "secrets.json");
6399
+ return join12(cabaneDir(), "secrets.json");
5985
6400
  }
5986
6401
  var secretStoreSchema = z13.record(z13.string(), z13.string());
5987
6402
  function loadSecretStore() {
5988
6403
  const path = secretsPath();
5989
- if (!existsSync8(path)) return makeStore({});
6404
+ if (!existsSync9(path)) return makeStore({});
5990
6405
  let raw;
5991
6406
  try {
5992
6407
  raw = readFileSync7(path, "utf8");
@@ -6067,10 +6482,10 @@ function resolveMcpSecrets(mcpServers, store) {
6067
6482
  }
6068
6483
 
6069
6484
  // src/transcript-writer.ts
6070
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync5 } from "fs";
6071
- 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";
6072
6487
  function transcriptsDir() {
6073
- return join12(cabaneDir(), "transcripts");
6488
+ return join13(cabaneDir(), "transcripts");
6074
6489
  }
6075
6490
  var RETAIN = 200;
6076
6491
  var TranscriptWriter = class {
@@ -6079,9 +6494,9 @@ var TranscriptWriter = class {
6079
6494
  onWarn;
6080
6495
  constructor(dir2, meta, onWarn) {
6081
6496
  this.onWarn = onWarn;
6082
- this.path = join12(dir2, fileName(meta));
6497
+ this.path = join13(dir2, fileName(meta));
6083
6498
  try {
6084
- mkdirSync8(dir2, { recursive: true });
6499
+ mkdirSync9(dir2, { recursive: true });
6085
6500
  try {
6086
6501
  chmodSync3(dir2, 448);
6087
6502
  } catch {
@@ -6138,7 +6553,7 @@ function pruneOld(dir2, retain) {
6138
6553
  const drop = files.sort().slice(0, files.length - retain);
6139
6554
  for (const f of drop) {
6140
6555
  try {
6141
- rmSync5(join12(dir2, f), { force: true });
6556
+ rmSync6(join13(dir2, f), { force: true });
6142
6557
  } catch {
6143
6558
  }
6144
6559
  }
@@ -6484,7 +6899,7 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6484
6899
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6485
6900
  function checkoutState(cwd) {
6486
6901
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
6487
- 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})` };
6488
6903
  let entries;
6489
6904
  try {
6490
6905
  entries = readdirSync2(cwd);
@@ -6494,15 +6909,15 @@ function checkoutState(cwd) {
6494
6909
  if (entries.length === 0) {
6495
6910
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
6496
6911
  }
6497
- const gitPath = join13(cwd, ".git");
6498
- if (!existsSync9(gitPath)) return { ok: true, reason: "usable" };
6912
+ const gitPath = join14(cwd, ".git");
6913
+ if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
6499
6914
  let stat;
6500
6915
  try {
6501
6916
  stat = statSync(gitPath);
6502
6917
  } catch (error) {
6503
6918
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
6504
6919
  }
6505
- if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
6920
+ if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
6506
6921
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
6507
6922
  return { ok: true, reason: "usable" };
6508
6923
  }
@@ -6693,7 +7108,7 @@ var Dispatcher = class {
6693
7108
  let seqCounter = 0;
6694
7109
  const nextSeq = () => ++seqCounter;
6695
7110
  let effectiveCwd = localCwd ?? cabaneCwd;
6696
- if (effectiveCwd && !existsSync9(effectiveCwd)) {
7111
+ if (effectiveCwd && !existsSync10(effectiveCwd)) {
6697
7112
  turnLog.warn(
6698
7113
  { cwd: effectiveCwd },
6699
7114
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -6829,22 +7244,7 @@ ${reason}`,
6829
7244
  }
6830
7245
  }
6831
7246
  }
6832
- let turnEnv = hookEnv;
6833
- if (effectiveCwd && turnContext.runtime === "codex") {
6834
- const tmpDir = join13(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
6835
- try {
6836
- mkdirSync9(tmpDir, { recursive: true });
6837
- turnEnv = { ...hookEnv, TMPDIR: tmpDir };
6838
- } catch (err) {
6839
- turnLog.warn(
6840
- { err: err instanceof Error ? err.message : String(err), tmpDir },
6841
- "dispatcher: failed to create per-turn TMPDIR \u2014 proceeding with the inherited temp dir"
6842
- );
6843
- }
6844
- }
6845
- if (turnContext.runtime === "codex" && process.env.CABANE_GATE_LOCK_DIR) {
6846
- turnEnv = { ...turnEnv, CABANE_GATE_LOCK_DIR: process.env.CABANE_GATE_LOCK_DIR };
6847
- }
7247
+ const turnEnv = hookEnv;
6848
7248
  const key = runKey(payload.conversationId, payload.agentId);
6849
7249
  const abortController = new AbortController();
6850
7250
  this.aborts.set(key, abortController);
@@ -7016,7 +7416,7 @@ ${reason}`,
7016
7416
  }
7017
7417
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7018
7418
  }
7019
- const receiptPath = join13(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7419
+ const receiptPath = join14(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7020
7420
  const receiptLine = (fields) => `${JSON.stringify({
7021
7421
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7022
7422
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7040,7 +7440,7 @@ ${reason}`,
7040
7440
  })}
7041
7441
  `;
7042
7442
  try {
7043
- mkdirSync9(join13(effectiveCwd, ".git", "cabane"), { recursive: true });
7443
+ mkdirSync10(join14(effectiveCwd, ".git", "cabane"), { recursive: true });
7044
7444
  appendFileSync2(
7045
7445
  receiptPath,
7046
7446
  // `starting` is the honest classification before the proof has run. The
@@ -7468,202 +7868,6 @@ ${reason}`,
7468
7868
  }
7469
7869
  };
7470
7870
 
7471
- // src/manifest.ts
7472
- var DEVICE_MANIFEST = {
7473
- runtimes: [{ name: "claude-code", version: null }],
7474
- capabilities: { hostFs: true, browser: true, userMcp: true }
7475
- };
7476
- function buildCompanionManifest(opts) {
7477
- const v = opts.versions ?? {};
7478
- const runtimes = [];
7479
- if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
7480
- if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
7481
- if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
7482
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
7483
- }
7484
-
7485
- // src/harness-status.ts
7486
- var LABELS = {
7487
- "claude-code": "Claude Code",
7488
- codex: "Codex",
7489
- opencode: "opencode"
7490
- };
7491
- function deriveHarnessSnapshot(signals) {
7492
- const advertised = new Set(
7493
- buildCompanionManifest({
7494
- // CT1082: connected AND installed — the manifest's own rule, restated here
7495
- // through the same function rather than re-decided.
7496
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
7497
- opencode: signals.opencodeConfigured,
7498
- codex: signals.codexEnabled
7499
- }).runtimes.map((r) => r.name)
7500
- );
7501
- const harnesses = [
7502
- deriveClaudeCode(signals, advertised.has("claude-code")),
7503
- deriveCodex(signals, advertised.has("codex")),
7504
- deriveOpencode(signals, advertised.has("opencode"))
7505
- ];
7506
- return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
7507
- }
7508
- function deriveClaudeCode(signals, manifestHas) {
7509
- const base = { runtime: "claude-code", label: LABELS["claude-code"] };
7510
- if (manifestHas) {
7511
- return {
7512
- ...base,
7513
- state: "exposed",
7514
- version: signals.claudeVersion,
7515
- detail: "Claude Code is connected and exposed to Cabane.",
7516
- enable: null
7517
- };
7518
- }
7519
- if (signals.claudeCodeConnected) {
7520
- return {
7521
- ...base,
7522
- state: "needs_attention",
7523
- version: null,
7524
- 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.",
7525
- enable: null
7526
- };
7527
- }
7528
- if (signals.claudeOnPath) {
7529
- return {
7530
- ...base,
7531
- state: "detected_not_exposed",
7532
- version: signals.claudeVersion,
7533
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
7534
- enable: "claude-code"
7535
- };
7536
- }
7537
- return {
7538
- ...base,
7539
- state: "not_detected",
7540
- version: null,
7541
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
7542
- enable: null
7543
- };
7544
- }
7545
- function deriveCodex(signals, manifestHas) {
7546
- const base = { runtime: "codex", label: LABELS.codex };
7547
- if (manifestHas) {
7548
- if (signals.codexOnPath) {
7549
- return {
7550
- ...base,
7551
- state: "exposed",
7552
- version: signals.codexVersion,
7553
- detail: "Codex is enabled and exposed to Cabane.",
7554
- enable: null
7555
- };
7556
- }
7557
- return {
7558
- ...base,
7559
- state: "needs_attention",
7560
- version: null,
7561
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
7562
- enable: null
7563
- };
7564
- }
7565
- if (signals.codexOnPath) {
7566
- return {
7567
- ...base,
7568
- state: "detected_not_exposed",
7569
- version: signals.codexVersion,
7570
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
7571
- enable: "codex"
7572
- };
7573
- }
7574
- return {
7575
- ...base,
7576
- state: "not_detected",
7577
- version: null,
7578
- detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
7579
- enable: null
7580
- };
7581
- }
7582
- function deriveOpencode(signals, manifestHas) {
7583
- const base = { runtime: "opencode", label: LABELS.opencode };
7584
- if (manifestHas) {
7585
- if (signals.opencodeReachable) {
7586
- return {
7587
- ...base,
7588
- state: "exposed",
7589
- version: signals.opencodeVersion,
7590
- detail: "An opencode server is reachable and exposed to Cabane.",
7591
- enable: null
7592
- };
7593
- }
7594
- return {
7595
- ...base,
7596
- state: "needs_attention",
7597
- version: null,
7598
- detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
7599
- enable: null
7600
- };
7601
- }
7602
- return {
7603
- ...base,
7604
- state: "not_detected",
7605
- version: null,
7606
- detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
7607
- enable: "opencode"
7608
- };
7609
- }
7610
- function detectedRuntimesFor(snapshot) {
7611
- return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
7612
- }
7613
- var PROBE_TIMEOUT_MS2 = 4e3;
7614
- async function probeHarnessSignals(cfg, deps = {}) {
7615
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
7616
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
7617
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
7618
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
7619
- const serverUrl = cfg.opencode?.serverUrl;
7620
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
7621
- withTimeout(probeClaudePresence(), false),
7622
- withTimeout(probeClaudeVersion(), null),
7623
- withTimeout(probeCodexVersion(), null),
7624
- serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
7625
- ]);
7626
- return {
7627
- claudeOnPath: claudeOnPathResult,
7628
- claudeVersion,
7629
- // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
7630
- // the manifest gate and the probe above is only a suggestion.
7631
- claudeCodeConnected: isClaudeCodeConnected(cfg),
7632
- // A parseable `codex --version` is our presence signal (presence alone never
7633
- // exposes codex; its config flag is the manifest gate either way).
7634
- codexOnPath: codexVersion !== null,
7635
- codexVersion,
7636
- codexEnabled: isCodexEnabled(cfg),
7637
- opencodeConfigured: !!serverUrl,
7638
- // A version came back ⟺ the serve answered its health endpoint (CT584).
7639
- opencodeReachable: opencodeVersion !== null,
7640
- opencodeVersion
7641
- };
7642
- }
7643
- function withTimeout(promise, fallback) {
7644
- return new Promise((resolve) => {
7645
- let settled = false;
7646
- const done = (v) => {
7647
- if (!settled) {
7648
- settled = true;
7649
- resolve(v);
7650
- }
7651
- };
7652
- const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS2);
7653
- timer.unref?.();
7654
- promise.then(
7655
- (v) => {
7656
- clearTimeout(timer);
7657
- done(v);
7658
- },
7659
- () => {
7660
- clearTimeout(timer);
7661
- done(fallback);
7662
- }
7663
- );
7664
- });
7665
- }
7666
-
7667
7871
  // src/opencode-models.ts
7668
7872
  var OPENCODE_RUNTIME = "opencode";
7669
7873
  function mapOpencodeProviders(json) {
@@ -7707,15 +7911,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
7707
7911
 
7708
7912
  // src/outbox.ts
7709
7913
  import {
7710
- existsSync as existsSync10,
7711
- mkdirSync as mkdirSync10,
7914
+ existsSync as existsSync11,
7915
+ mkdirSync as mkdirSync11,
7712
7916
  readdirSync as readdirSync3,
7713
7917
  readFileSync as readFileSync8,
7714
7918
  renameSync as renameSync3,
7715
- rmSync as rmSync6,
7919
+ rmSync as rmSync7,
7716
7920
  writeFileSync as writeFileSync7
7717
7921
  } from "fs";
7718
- import { join as join14 } from "path";
7922
+ import { join as join15 } from "path";
7719
7923
  var MAX_ENTRIES = 2e3;
7720
7924
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7721
7925
  var Outbox = class {
@@ -7728,17 +7932,17 @@ var Outbox = class {
7728
7932
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
7729
7933
  // cases route writes at the right tmpdir.
7730
7934
  dir() {
7731
- return join14(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7935
+ return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7732
7936
  }
7733
7937
  fileFor(turnId, seq) {
7734
- return join14(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7938
+ return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7735
7939
  }
7736
7940
  // Persist a commit for later draining. Atomic (temp file + rename) so a
7737
7941
  // concurrent `list()` never reads a half-written entry, then enforces the
7738
7942
  // per-workspace bounds.
7739
7943
  persist(entry) {
7740
7944
  const dir2 = this.dir();
7741
- mkdirSync10(dir2, { recursive: true });
7945
+ mkdirSync11(dir2, { recursive: true });
7742
7946
  const target = this.fileFor(entry.turnId, entry.seq);
7743
7947
  const tmp = `${target}.${process.pid}.tmp`;
7744
7948
  try {
@@ -7746,7 +7950,7 @@ var Outbox = class {
7746
7950
  renameSync3(tmp, target);
7747
7951
  } catch (err) {
7748
7952
  try {
7749
- rmSync6(tmp, { force: true });
7953
+ rmSync7(tmp, { force: true });
7750
7954
  } catch {
7751
7955
  }
7752
7956
  this.log?.warn(
@@ -7763,7 +7967,7 @@ var Outbox = class {
7763
7967
  // wedging the drain.
7764
7968
  list() {
7765
7969
  const dir2 = this.dir();
7766
- if (!existsSync10(dir2)) return [];
7970
+ if (!existsSync11(dir2)) return [];
7767
7971
  let names;
7768
7972
  try {
7769
7973
  names = readdirSync3(dir2);
@@ -7773,7 +7977,7 @@ var Outbox = class {
7773
7977
  const entries = [];
7774
7978
  for (const name of names) {
7775
7979
  if (!name.endsWith(".json")) continue;
7776
- const full = join14(dir2, name);
7980
+ const full = join15(dir2, name);
7777
7981
  try {
7778
7982
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
7779
7983
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -7793,13 +7997,13 @@ var Outbox = class {
7793
7997
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
7794
7998
  remove(turnId, seq) {
7795
7999
  try {
7796
- rmSync6(this.fileFor(turnId, seq), { force: true });
8000
+ rmSync7(this.fileFor(turnId, seq), { force: true });
7797
8001
  } catch {
7798
8002
  }
7799
8003
  }
7800
8004
  size() {
7801
8005
  const dir2 = this.dir();
7802
- if (!existsSync10(dir2)) return 0;
8006
+ if (!existsSync11(dir2)) return 0;
7803
8007
  try {
7804
8008
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
7805
8009
  } catch {
@@ -7812,7 +8016,7 @@ var Outbox = class {
7812
8016
  "companion outbox: dropping unreadable entry"
7813
8017
  );
7814
8018
  try {
7815
- rmSync6(full, { force: true });
8019
+ rmSync7(full, { force: true });
7816
8020
  } catch {
7817
8021
  }
7818
8022
  }
@@ -8741,6 +8945,26 @@ var CompanionSupervisor = class {
8741
8945
  async recheckHarnesses() {
8742
8946
  await this.refreshHarnessStatuses();
8743
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
+ }
8744
8968
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
8745
8969
  // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
8746
8970
  // never installs a binary and never drives a login (BYO — Decided).
@@ -8882,9 +9106,9 @@ var CompanionSupervisor = class {
8882
9106
  };
8883
9107
  function defaultReexec() {
8884
9108
  clearRuntimeState();
8885
- void import("child_process").then(({ spawn: spawn4 }) => {
9109
+ void import("child_process").then(({ spawn: spawn5 }) => {
8886
9110
  try {
8887
- const child = spawn4(process.execPath, process.argv.slice(1), {
9111
+ const child = spawn5(process.execPath, process.argv.slice(1), {
8888
9112
  stdio: "inherit",
8889
9113
  detached: false
8890
9114
  });
@@ -8954,14 +9178,14 @@ function handleUncaught(log, err, origin) {
8954
9178
  }
8955
9179
 
8956
9180
  // src/crash-marker.ts
8957
- import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
8958
- 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";
8959
9183
  function crashMarkerPath() {
8960
- return join15(cabaneDir(), "last-error.json");
9184
+ return join16(cabaneDir(), "last-error.json");
8961
9185
  }
8962
9186
  function recordCrash(rec) {
8963
9187
  try {
8964
- mkdirSync11(cabaneDir(), { recursive: true });
9188
+ mkdirSync12(cabaneDir(), { recursive: true });
8965
9189
  writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
8966
9190
  } catch {
8967
9191
  }
@@ -8969,7 +9193,7 @@ function recordCrash(rec) {
8969
9193
  function clearCrash() {
8970
9194
  try {
8971
9195
  const path = crashMarkerPath();
8972
- if (existsSync11(path)) rmSync7(path, { force: true });
9196
+ if (existsSync12(path)) rmSync8(path, { force: true });
8973
9197
  } catch {
8974
9198
  }
8975
9199
  }
@@ -8988,7 +9212,13 @@ async function createCompanionRuntime(opts = {}) {
8988
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)"
8989
9213
  )
8990
9214
  }));
8991
- 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
+ });
8992
9222
  } catch (err) {
8993
9223
  recordCrash({
8994
9224
  reason: err instanceof Error ? err.message : String(err),
@@ -9012,8 +9242,6 @@ async function createCompanionRuntime(opts = {}) {
9012
9242
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
9013
9243
  const claim = acquireRuntimeState({
9014
9244
  pid: process.pid,
9015
- url: "",
9016
- port: 0,
9017
9245
  startedAt,
9018
9246
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
9019
9247
  instanceId
@@ -9021,7 +9249,7 @@ async function createCompanionRuntime(opts = {}) {
9021
9249
  if (!claim.acquired) {
9022
9250
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
9023
9251
  }
9024
- process.on("exit", () => clearRuntimeState());
9252
+ process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
9025
9253
  const hub = new CompanionStateHub({
9026
9254
  // CT29: one device, one base URL — the cabane instance this device is paired
9027
9255
  // with. The dashboard's connection line shows it.
@@ -9039,17 +9267,36 @@ async function createCompanionRuntime(opts = {}) {
9039
9267
  harnessVersions
9040
9268
  });
9041
9269
  await supervisor.start();
9042
- const preferredPort = opts.port ?? cfg.dashboardPort;
9043
- const dashboard = await startDashboard({
9044
- supervisor,
9045
- hub,
9046
- ...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()
9047
9285
  });
9048
- 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
+ }
9049
9296
  writeRuntimeState({
9050
9297
  pid: process.pid,
9051
- url: dashboard.url,
9052
- port: dashboard.port,
9298
+ socket: control.path,
9299
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9053
9300
  startedAt,
9054
9301
  // SJ495: the daemon launcher sets this env on the detached child, so the
9055
9302
  // marker records whether this companion is backgrounded (foreground start
@@ -9062,30 +9309,37 @@ async function createCompanionRuntime(opts = {}) {
9062
9309
  const stop = async () => {
9063
9310
  if (stopped) return;
9064
9311
  stopped = true;
9065
- clearRuntimeState();
9312
+ clearRuntimeStateIfOurs(instanceId);
9066
9313
  try {
9067
9314
  await supervisor.shutdown();
9068
- await dashboard.close();
9315
+ await closeSurfaces(control, dashboard);
9069
9316
  } catch {
9070
9317
  }
9071
9318
  };
9072
9319
  return {
9073
9320
  ok: true,
9074
9321
  runtime: {
9075
- url: dashboard.url,
9076
- port: dashboard.port,
9322
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9323
+ socketPath: control.path,
9077
9324
  config: cfg,
9078
9325
  stop,
9326
+ heartbeatNow: () => supervisor.heartbeatNow(),
9327
+ harnesses: () => hub.statusJson().harnesses ?? [],
9328
+ connectHarness,
9079
9329
  drainForRestart: async (graceMs) => {
9080
- clearRuntimeState();
9330
+ clearRuntimeStateIfOurs(instanceId);
9081
9331
  const result = await supervisor.drainForRestart(graceMs);
9082
- await dashboard.close();
9332
+ await closeSurfaces(control, dashboard);
9083
9333
  stopped = true;
9084
9334
  return result;
9085
9335
  }
9086
9336
  }
9087
9337
  };
9088
9338
  }
9339
+ async function closeSurfaces(control, dashboard) {
9340
+ await control.close();
9341
+ if (dashboard) await dashboard.close();
9342
+ }
9089
9343
  export {
9090
9344
  createCompanionRuntime
9091
9345
  };