@primusclaw/connector 0.1.5 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +106 -25
  2. package/dist/cli.js +1684 -356
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
5
+
3
6
  // src/commands.ts
4
- import fs18 from "node:fs";
5
- import os7 from "node:os";
6
- import path19 from "node:path";
7
+ import fs19 from "node:fs";
8
+ import os9 from "node:os";
9
+ import path21 from "node:path";
7
10
  import { fileURLToPath as fileURLToPath2 } from "node:url";
8
11
  import { parseArgs as parseArgs2 } from "node:util";
9
12
 
@@ -563,11 +566,11 @@ var RevokedError = class extends Error {
563
566
  }
564
567
  };
565
568
  var HttpError = class extends Error {
566
- constructor(status2, code, path20) {
567
- super(`${path20} answered ${status2} ${code}`);
569
+ constructor(status2, code, path22) {
570
+ super(`${path22} answered ${status2} ${code}`);
568
571
  this.status = status2;
569
572
  this.code = code;
570
- this.path = path20;
573
+ this.path = path22;
571
574
  }
572
575
  status;
573
576
  code;
@@ -591,8 +594,8 @@ var ClawClient = class {
591
594
  ...extra
592
595
  };
593
596
  }
594
- async send(method, path20, body, signal) {
595
- const url = this.o.server + path20;
597
+ async send(method, path22, body, signal) {
598
+ const url = this.o.server + path22;
596
599
  for (let attempt = 0; ; attempt++) {
597
600
  const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
598
601
  let res;
@@ -604,7 +607,7 @@ var ClawClient = class {
604
607
  signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
605
608
  });
606
609
  } catch (e) {
607
- throw new TransportError(`${method} ${path20}: ${errMessage(e)}`);
610
+ throw new TransportError(`${method} ${path22}: ${errMessage(e)}`);
608
611
  }
609
612
  if (res.status !== 401) return res;
610
613
  const err = await errorCode(res);
@@ -613,12 +616,12 @@ var ClawClient = class {
613
616
  this.o.tokens.invalidate();
614
617
  continue;
615
618
  }
616
- throw new HttpError(401, err, path20);
619
+ throw new HttpError(401, err, path22);
617
620
  }
618
621
  }
619
- async json(method, path20, body) {
620
- const res = await this.send(method, path20, body);
621
- if (!res.ok) throw new HttpError(res.status, await errorCode(res), path20);
622
+ async json(method, path22, body) {
623
+ const res = await this.send(method, path22, body);
624
+ if (!res.ok) throw new HttpError(res.status, await errorCode(res), path22);
622
625
  const parsed = await res.json().catch(() => null);
623
626
  return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
624
627
  }
@@ -667,21 +670,21 @@ var ClawClient = class {
667
670
  };
668
671
  try {
669
672
  arm();
670
- const path20 = `/v1/remote-agents/connectors/${cid}/commands`;
673
+ const path22 = `/v1/remote-agents/connectors/${cid}/commands`;
671
674
  const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
672
675
  let res;
673
676
  try {
674
- res = await this.f(this.o.server + path20, { headers, signal: stop.signal });
677
+ res = await this.f(this.o.server + path22, { headers, signal: stop.signal });
675
678
  } catch (e) {
676
- throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path20}: ${errMessage(e)}`);
679
+ throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path22}: ${errMessage(e)}`);
677
680
  }
678
681
  if (res.status === 401) {
679
682
  const err = await errorCode(res);
680
683
  if (err === "revoked") throw new RevokedError();
681
684
  this.o.tokens.invalidate();
682
- throw new HttpError(401, err, path20);
685
+ throw new HttpError(401, err, path22);
683
686
  }
684
- if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path20);
687
+ if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path22);
685
688
  try {
686
689
  for await (const frame of readSse(res.body, arm)) yield frame;
687
690
  } catch (e) {
@@ -701,59 +704,10 @@ async function errorCode(res) {
701
704
  }
702
705
 
703
706
  // src/config.ts
704
- import fs5 from "node:fs";
705
- import os from "node:os";
706
- import path5 from "node:path";
707
- import { parse as parseToml } from "smol-toml";
708
-
709
- // src/roots.ts
710
707
  import fs4 from "node:fs";
708
+ import os from "node:os";
711
709
  import path4 from "node:path";
712
- function realRoot(root) {
713
- try {
714
- return fs4.realpathSync.native(root);
715
- } catch {
716
- return null;
717
- }
718
- }
719
- var inside = (child, root) => child === root || child.startsWith(root.endsWith(path4.sep) ? root : root + path4.sep);
720
- function checkCwd(cwd, roots) {
721
- if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
722
- if (!path4.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
723
- let real;
724
- try {
725
- real = fs4.realpathSync.native(cwd);
726
- } catch {
727
- return { ok: false, reason: "cwd_not_found" };
728
- }
729
- try {
730
- if (!fs4.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
731
- } catch {
732
- return { ok: false, reason: "cwd_not_found" };
733
- }
734
- for (const root of roots) {
735
- const r = realRoot(root);
736
- if (r && inside(real, r)) return { ok: true, real };
737
- }
738
- return { ok: false, reason: "cwd_outside_roots" };
739
- }
740
- function relativeInside(p, cwd) {
741
- const abs = path4.resolve(cwd, p);
742
- let real;
743
- try {
744
- real = fs4.realpathSync.native(abs);
745
- } catch {
746
- try {
747
- real = path4.join(fs4.realpathSync.native(path4.dirname(abs)), path4.basename(abs));
748
- } catch {
749
- return null;
750
- }
751
- }
752
- if (!inside(real, cwd)) return null;
753
- return path4.relative(cwd, real).split(path4.sep).join("/");
754
- }
755
-
756
- // src/config.ts
710
+ import { parse as parseToml } from "smol-toml";
757
711
  var ConfigError = class extends Error {
758
712
  constructor(problems) {
759
713
  super(`agents.toml: ${problems.join("; ")}`);
@@ -769,7 +723,8 @@ var PRESETS = {
769
723
  args: ["-y", "@agentclientprotocol/claude-agent-acp@0.99.0"],
770
724
  askMode: "default",
771
725
  safeModes: ["default", "plan"],
772
- unattended: { askMode: "bypassPermissions" }
726
+ unattended: { askMode: "bypassPermissions" },
727
+ readOnly: ["plan"]
773
728
  },
774
729
  // Existing Claude Code sessions, taken over from the phone. The kind is the opaque label a chat client picks an icon
775
730
  // from; it says what the entry is for, not which product serves it. Same adapter and same ask mode as `claude`.
@@ -781,7 +736,8 @@ var PRESETS = {
781
736
  askMode: "default",
782
737
  safeModes: ["default", "plan"],
783
738
  attach: true,
784
- unattended: { askMode: "bypassPermissions" }
739
+ unattended: { askMode: "bypassPermissions" },
740
+ readOnly: ["plan"]
785
741
  },
786
742
  codex: {
787
743
  kind: "codex",
@@ -799,7 +755,8 @@ var PRESETS = {
799
755
  args: ["acp"],
800
756
  askMode: "ask",
801
757
  safeModes: ["ask", "plan"],
802
- unattended: { askMode: "agent", args: ["--force", "acp"] }
758
+ unattended: { askMode: "agent", args: ["--force", "acp"] },
759
+ readOnly: ["ask", "plan"]
803
760
  }
804
761
  };
805
762
  var ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
@@ -813,7 +770,7 @@ var strList = (v, what, problems) => {
813
770
  }
814
771
  return v;
815
772
  };
816
- var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path5.join(os.homedir(), p.slice(2)) : p;
773
+ var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path4.join(os.homedir(), p.slice(2)) : p;
817
774
  function parseConfig(text, defaults = {}) {
818
775
  const problems = [];
819
776
  let doc;
@@ -880,11 +837,10 @@ function parseConfig(text, defaults = {}) {
880
837
  } else asking.add(askMode);
881
838
  }
882
839
  if (asking.has(askMode) && !safeModes.includes(askMode)) safeModes.push(askMode);
883
- const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path5.resolve(expandHome(r)));
840
+ const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path4.resolve(expandHome(r)));
884
841
  if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
885
842
  for (const r of roots) {
886
- if (r === path5.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
887
- else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
843
+ if (r === path4.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
888
844
  }
889
845
  const env = {};
890
846
  if (raw.env !== void 0) {
@@ -920,6 +876,8 @@ function parseConfig(text, defaults = {}) {
920
876
  askingModes: [...asking],
921
877
  allowUnsafeModes: allowUnsafe,
922
878
  unattended: !asking.has(askMode),
879
+ // What the preset measured; a custom agent declares nothing here.
880
+ readOnlyModes: [...preset?.readOnly ?? []],
923
881
  autoAllow: [...globalAllow, ...own],
924
882
  attach
925
883
  });
@@ -931,7 +889,7 @@ function parseConfig(text, defaults = {}) {
931
889
  var EXISTING_SESSIONS_SUFFIX = "-sessions";
932
890
  function existingSessionAgents(rawAgents, agents, problems) {
933
891
  const out = [];
934
- const configDir = (a) => path5.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path5.join(os.homedir(), ".claude")));
892
+ const configDir = (a) => path4.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude")));
935
893
  const ids = new Set(agents.map((a) => a.id));
936
894
  for (const raw of rawAgents) {
937
895
  if (!isObject(raw) || typeof raw.id !== "string") continue;
@@ -979,7 +937,7 @@ function parseAutoAllow(raw, at, problems) {
979
937
  if (!isObject(r) || typeof r.kind !== "string" || typeof r.path_glob !== "string" || !r.kind || !r.path_glob) {
980
938
  return void problems.push(`${at}[${i}] needs a kind and a path_glob`);
981
939
  }
982
- if (path5.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
940
+ if (path4.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
983
941
  return void problems.push(`${at}[${i}].path_glob must be relative to the working directory and may not contain ..`);
984
942
  }
985
943
  rules.push({ kind: r.kind, pathGlob: r.path_glob });
@@ -989,7 +947,7 @@ function parseAutoAllow(raw, at, problems) {
989
947
  function loadConfig(file, defaults = {}) {
990
948
  let text;
991
949
  try {
992
- text = fs5.readFileSync(file, "utf8");
950
+ text = fs4.readFileSync(file, "utf8");
993
951
  } catch (e) {
994
952
  if (e.code === "ENOENT") throw new ConfigError([`${file} does not exist; run "claw-connector init" to create one`]);
995
953
  throw e;
@@ -1003,7 +961,9 @@ function sampleApprovals(approval) {
1003
961
  return { unattended: never.filter((id) => SAMPLE_ACTIVE.includes(id)), parked: never.filter((id) => !SAMPLE_ACTIVE.includes(id)) };
1004
962
  }
1005
963
  var SAMPLE_NOTES = {
1006
- "claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first)."
964
+ "claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first).",
965
+ cursor: `# Cursor asks before it runs a command but cannot ask before it edits a file, so with approval "ask" its sessions are
966
+ # read-only (ask and plan modes): they answer questions and change nothing. To let it write code, set approval = "never".`
1007
967
  };
1008
968
  var NEVER_NOTE = `# No per-step approval: this agent runs like it does in your terminal. A phone session writes files and runs
1009
969
  # commands without asking, and the phone shows it as unattended. To go back to asking, set it to "ask".`;
@@ -1036,8 +996,8 @@ ${SAMPLE_AGENTS.map(agent).join("\n\n")}
1036
996
  var SAMPLE_CONFIG = sampleConfig();
1037
997
 
1038
998
  // src/credentials.ts
1039
- import fs6 from "node:fs";
1040
- import path6 from "node:path";
999
+ import fs5 from "node:fs";
1000
+ import path5 from "node:path";
1041
1001
  var DEFAULT_CLIENT_ID = "claw-connector";
1042
1002
  var CONNECTOR_SCOPE = "claw.connector";
1043
1003
  var CredentialsError = class extends Error {
@@ -1052,20 +1012,20 @@ var TransientAuthError = class extends Error {
1052
1012
  function loadCredentials(file) {
1053
1013
  let fd;
1054
1014
  try {
1055
- fd = fs6.openSync(file, "r");
1015
+ fd = fs5.openSync(file, "r");
1056
1016
  } catch (e) {
1057
1017
  if (e.code === "ENOENT") return null;
1058
1018
  throw e;
1059
1019
  }
1060
1020
  let text;
1061
1021
  try {
1062
- const st = fs6.fstatSync(fd);
1022
+ const st = fs5.fstatSync(fd);
1063
1023
  if (process.platform !== "win32" && (st.mode & 63) !== 0) {
1064
1024
  throw new CredentialsError(`${file} is readable by other users (mode ${(st.mode & 511).toString(8)}); run: chmod 600 ${file}`);
1065
1025
  }
1066
- text = fs6.readFileSync(fd, "utf8");
1026
+ text = fs5.readFileSync(fd, "utf8");
1067
1027
  } finally {
1068
- fs6.closeSync(fd);
1028
+ fs5.closeSync(fd);
1069
1029
  }
1070
1030
  let raw;
1071
1031
  try {
@@ -1079,11 +1039,11 @@ function loadCredentials(file) {
1079
1039
  return raw;
1080
1040
  }
1081
1041
  function saveCredentials(file, creds) {
1082
- fs6.mkdirSync(path6.dirname(file), { recursive: true, mode: 448 });
1042
+ fs5.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
1083
1043
  const tmp = `${file}.${process.pid}.tmp`;
1084
- fs6.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
1085
- fs6.chmodSync(tmp, 384);
1086
- fs6.renameSync(tmp, file);
1044
+ fs5.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
1045
+ fs5.chmodSync(tmp, 384);
1046
+ fs5.renameSync(tmp, file);
1087
1047
  }
1088
1048
  var KeyTokenProvider = class {
1089
1049
  constructor(key) {
@@ -1273,9 +1233,9 @@ async function deviceLogin(o) {
1273
1233
  }
1274
1234
 
1275
1235
  // src/claude-mode.ts
1276
- import fs7 from "node:fs";
1236
+ import fs6 from "node:fs";
1277
1237
  import os2 from "node:os";
1278
- import path7 from "node:path";
1238
+ import path6 from "node:path";
1279
1239
  function managedSettingsFile(platform = process.platform) {
1280
1240
  if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
1281
1241
  if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
@@ -1284,7 +1244,7 @@ function managedSettingsFile(platform = process.platform) {
1284
1244
  function readSettings(file, unreadable) {
1285
1245
  let text;
1286
1246
  try {
1287
- text = fs7.readFileSync(file, "utf8");
1247
+ text = fs6.readFileSync(file, "utf8");
1288
1248
  } catch (e) {
1289
1249
  if (e.code !== "ENOENT") unreadable.push(file);
1290
1250
  return null;
@@ -1320,9 +1280,9 @@ function bypassUnavailableWarnings(agents, env, opts = {}) {
1320
1280
  continue;
1321
1281
  }
1322
1282
  const home = merged.HOME?.trim() || os2.homedir();
1323
- const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path7.join(home, ".claude");
1283
+ const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path6.join(home, ".claude");
1324
1284
  const unreadable = [];
1325
- for (const file of [path7.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
1285
+ for (const file of [path6.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
1326
1286
  const s = readSettings(file, unreadable);
1327
1287
  const perms = s && isObject(s.permissions) ? s.permissions : null;
1328
1288
  if (perms?.disableBypassPermissionsMode === "disable") {
@@ -1339,21 +1299,21 @@ function claudeSessionMeta(a) {
1339
1299
  }
1340
1300
 
1341
1301
  // src/hooks.ts
1342
- import fs9 from "node:fs";
1343
- import path9 from "node:path";
1344
-
1345
- // src/terminals.ts
1346
1302
  import fs8 from "node:fs";
1347
1303
  import path8 from "node:path";
1304
+
1305
+ // src/terminals.ts
1306
+ import fs7 from "node:fs";
1307
+ import path7 from "node:path";
1348
1308
  var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1349
1309
  var TERMINALS_DIR = "claw-connector-terminals";
1350
- var terminalsDir = (configDir) => path8.join(configDir, TERMINALS_DIR);
1310
+ var terminalsDir = (configDir) => path7.join(configDir, TERMINALS_DIR);
1351
1311
  var UNREADABLE_GRACE_MS = 6e4;
1352
1312
  var MAX_STDIN = 1 << 20;
1353
1313
  function localPidDomain() {
1354
1314
  if (process.platform !== "linux") return null;
1355
1315
  try {
1356
- return `linux:${fs8.readFileSync("/etc/machine-id", "utf8").trim()}:${fs8.readlinkSync("/proc/self/ns/pid")}`;
1316
+ return `linux:${fs7.readFileSync("/etc/machine-id", "utf8").trim()}:${fs7.readlinkSync("/proc/self/ns/pid")}`;
1357
1317
  } catch {
1358
1318
  return null;
1359
1319
  }
@@ -1384,7 +1344,7 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
1384
1344
  const dir = terminalsDir(configDir);
1385
1345
  let names;
1386
1346
  try {
1387
- names = fs8.readdirSync(dir);
1347
+ names = fs7.readdirSync(dir);
1388
1348
  } catch (e) {
1389
1349
  if (e.code === "ENOENT") return [];
1390
1350
  throw e;
@@ -1392,15 +1352,15 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
1392
1352
  const out = [];
1393
1353
  for (const name of names) {
1394
1354
  if (!name.endsWith(".json")) continue;
1395
- const file = path8.join(dir, name);
1355
+ const file = path7.join(dir, name);
1396
1356
  let doc;
1397
1357
  try {
1398
- doc = JSON.parse(fs8.readFileSync(file, "utf8"));
1358
+ doc = JSON.parse(fs7.readFileSync(file, "utf8"));
1399
1359
  } catch (e) {
1400
1360
  if (e.code === "ENOENT") continue;
1401
1361
  let age = Infinity;
1402
1362
  try {
1403
- age = now - fs8.statSync(file).mtimeMs;
1363
+ age = now - fs7.statSync(file).mtimeMs;
1404
1364
  } catch {
1405
1365
  }
1406
1366
  if (age < UNREADABLE_GRACE_MS) throw new Error(`${file} is unreadable (${e.message.split("\n")[0]})`);
@@ -1427,19 +1387,19 @@ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
1427
1387
  function hookCommand(node, script, arg, configDir) {
1428
1388
  return `${shellQuote(node)} ${shellQuote(script)} ${arg} --dir ${shellQuote(configDir)} ${MANAGED_MARK}`;
1429
1389
  }
1430
- var settingsFile = (configDir) => path9.join(configDir, "settings.json");
1390
+ var settingsFile = (configDir) => path8.join(configDir, "settings.json");
1431
1391
  var isObject3 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
1432
1392
  var isOurs = (h) => isObject3(h) && h.type === "command" && typeof h.command === "string" && h.command.endsWith(` ${MANAGED_MARK}`) && OURS_RE.test(h.command);
1433
1393
  function load(configDir) {
1434
1394
  const given = settingsFile(configDir);
1435
1395
  let file = given;
1436
1396
  try {
1437
- file = fs9.realpathSync(given);
1397
+ file = fs8.realpathSync(given);
1438
1398
  } catch {
1439
1399
  }
1440
1400
  let text;
1441
1401
  try {
1442
- text = fs9.readFileSync(file, "utf8");
1402
+ text = fs8.readFileSync(file, "utf8");
1443
1403
  } catch (e) {
1444
1404
  if (e.code === "ENOENT") return { doc: {}, text: null, file };
1445
1405
  throw new HooksError(`cannot read ${given}: ${e.message}`);
@@ -1457,18 +1417,18 @@ function load(configDir) {
1457
1417
  function save(l, doc) {
1458
1418
  const indent = l.text?.match(/^([ \t]+)"/m)?.[1] ?? " ";
1459
1419
  const body = JSON.stringify(doc, null, indent) + "\n";
1460
- fs9.mkdirSync(path9.dirname(l.file), { recursive: true, mode: 448 });
1420
+ fs8.mkdirSync(path8.dirname(l.file), { recursive: true, mode: 448 });
1461
1421
  let mode = 384;
1462
1422
  try {
1463
- mode = fs9.statSync(l.file).mode & 511;
1423
+ mode = fs8.statSync(l.file).mode & 511;
1464
1424
  } catch {
1465
1425
  }
1466
1426
  const tmp = `${l.file}.claw-connector.${process.pid}.tmp`;
1467
- fs9.writeFileSync(tmp, body, { mode });
1427
+ fs8.writeFileSync(tmp, body, { mode });
1468
1428
  try {
1469
- fs9.renameSync(tmp, l.file);
1429
+ fs8.renameSync(tmp, l.file);
1470
1430
  } catch (e) {
1471
- fs9.rmSync(tmp, { force: true });
1431
+ fs8.rmSync(tmp, { force: true });
1472
1432
  throw e;
1473
1433
  }
1474
1434
  }
@@ -1548,7 +1508,7 @@ function uninstallHooks(configDir) {
1548
1508
  if (touchedAny && Object.keys(hooks).length === 0) delete doc.hooks;
1549
1509
  }
1550
1510
  if (touchedAny) save(l, doc);
1551
- fs9.rmSync(terminalsDir(configDir), { recursive: true, force: true });
1511
+ fs8.rmSync(terminalsDir(configDir), { recursive: true, force: true });
1552
1512
  return { changed: touchedAny, file: l.file };
1553
1513
  }
1554
1514
  function hooksStatus(configDir) {
@@ -1571,7 +1531,7 @@ function hooksStatus(configDir) {
1571
1531
  const m = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(String(x.command));
1572
1532
  if (!m) continue;
1573
1533
  for (const f of [m[1], m[2]].map((s) => s.replace(/'\\''/g, "'"))) {
1574
- if (!fs9.existsSync(f)) {
1534
+ if (!fs8.existsSync(f)) {
1575
1535
  healthy = false;
1576
1536
  problem ??= `${f} (named by the ${h.event} hook) does not exist; run: claw-connector hooks install`;
1577
1537
  }
@@ -1582,7 +1542,7 @@ function hooksStatus(configDir) {
1582
1542
  }
1583
1543
 
1584
1544
  // src/connector.ts
1585
- import fs15 from "node:fs";
1545
+ import fs16 from "node:fs";
1586
1546
  import { randomUUID as randomUUID2 } from "node:crypto";
1587
1547
 
1588
1548
  // src/attach.ts
@@ -1594,10 +1554,11 @@ import { StringDecoder } from "node:string_decoder";
1594
1554
 
1595
1555
  // src/adapter.ts
1596
1556
  import { spawn as spawn2 } from "node:child_process";
1597
- import fs10 from "node:fs";
1598
- import path10 from "node:path";
1557
+ import fs9 from "node:fs";
1558
+ import path9 from "node:path";
1599
1559
  import { Readable, Writable } from "node:stream";
1600
1560
  import * as acp from "@agentclientprotocol/sdk";
1561
+ var AUTH_STATUS_METHOD = "_auth/status_update";
1601
1562
  var AdapterProcess = class _AdapterProcess {
1602
1563
  constructor(child, pid, registry) {
1603
1564
  this.child = child;
@@ -1608,13 +1569,18 @@ var AdapterProcess = class _AdapterProcess {
1608
1569
  pid;
1609
1570
  registry;
1610
1571
  caps = {};
1572
+ /** The ways to sign in the adapter offered at `initialize` (`authMethods`), for `authenticate`. */
1573
+ authMethods = [];
1574
+ /** The last sign-in it reported, if it reports one; see {@link waitAuthStatus}. */
1575
+ authStatus = null;
1611
1576
  exited = false;
1612
1577
  conn;
1578
+ authWaiters = [];
1613
1579
  static async start(o) {
1614
- fs10.mkdirSync(path10.dirname(o.logFile), { recursive: true, mode: 448 });
1580
+ fs9.mkdirSync(path9.dirname(o.logFile), { recursive: true, mode: 448 });
1615
1581
  const env = { ...process.env, ...o.cfg.env };
1616
1582
  const run2 = await adapterStore()?.launch(o.cfg, o.cwd, env) ?? { command: o.cfg.command, args: o.cfg.args, cwd: o.cwd };
1617
- const log = fs10.openSync(o.logFile, "a", 384);
1583
+ const log = fs9.openSync(o.logFile, "a", 384);
1618
1584
  const child = spawn2(run2.command, run2.args, {
1619
1585
  cwd: run2.cwd,
1620
1586
  env,
@@ -1626,7 +1592,7 @@ var AdapterProcess = class _AdapterProcess {
1626
1592
  child.once("spawn", () => resolve());
1627
1593
  child.once("error", (e) => reject(new Error(`could not start ${run2.command}: ${errMessage(e)}`)));
1628
1594
  }).catch((e) => {
1629
- fs10.closeSync(log);
1595
+ fs9.closeSync(log);
1630
1596
  throw e;
1631
1597
  });
1632
1598
  const pid = child.pid;
@@ -1636,7 +1602,7 @@ var AdapterProcess = class _AdapterProcess {
1636
1602
  const flushLine = (line) => {
1637
1603
  const text = redactText(line).text;
1638
1604
  try {
1639
- fs10.writeSync(log, text + "\n");
1605
+ fs9.writeSync(log, text + "\n");
1640
1606
  } catch {
1641
1607
  }
1642
1608
  try {
@@ -1662,7 +1628,7 @@ var AdapterProcess = class _AdapterProcess {
1662
1628
  tail = "";
1663
1629
  }
1664
1630
  try {
1665
- fs10.closeSync(log);
1631
+ fs9.closeSync(log);
1666
1632
  } catch {
1667
1633
  }
1668
1634
  });
@@ -1680,7 +1646,12 @@ var AdapterProcess = class _AdapterProcess {
1680
1646
  if (ctx.method.startsWith("cursor/")) return await o.hooks.onExtension(ctx.method, ctx.params ?? {});
1681
1647
  throw acp.RequestError.methodNotFound(ctx.method);
1682
1648
  }).onNotification("*", async (ctx) => {
1683
- o.hooks.onNotification(ctx.method, ctx.params ?? {});
1649
+ const params = ctx.params ?? {};
1650
+ if (ctx.method === AUTH_STATUS_METHOD && params.authStatus && typeof params.authStatus.kind === "string") {
1651
+ a.authStatus = { kind: params.authStatus.kind, ...typeof params.authStatus.label === "string" ? { label: params.authStatus.label } : {} };
1652
+ for (const w of a.authWaiters.splice(0)) w();
1653
+ }
1654
+ o.hooks.onNotification(ctx.method, params);
1684
1655
  });
1685
1656
  a.conn = app.connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
1686
1657
  try {
@@ -1690,12 +1661,46 @@ var AdapterProcess = class _AdapterProcess {
1690
1661
  "initialize"
1691
1662
  );
1692
1663
  a.caps = init.agentCapabilities ?? {};
1664
+ const methods2 = init.authMethods;
1665
+ a.authMethods = Array.isArray(methods2) ? methods2.filter((m) => typeof m === "object" && m !== null) : [];
1693
1666
  } catch (e) {
1694
1667
  await a.kill();
1695
1668
  throw e;
1696
1669
  }
1697
1670
  return a;
1698
1671
  }
1672
+ /** Whether the adapter says it reports its sign-in (`agentCapabilities._meta.authStatus`). */
1673
+ get reportsAuth() {
1674
+ const meta = this.caps._meta;
1675
+ return !!meta && typeof meta === "object" && meta.authStatus !== void 0;
1676
+ }
1677
+ /**
1678
+ * The sign-in the adapter reports, waiting up to `ms` for its first report (claude-agent-acp sends it shortly after
1679
+ * `initialize`, and again for each new session). Null when it reports nothing in that time: not known, not "none".
1680
+ */
1681
+ async waitAuthStatus(ms) {
1682
+ if (this.authStatus || !this.reportsAuth || ms <= 0) return this.authStatus;
1683
+ await new Promise((resolve) => {
1684
+ const done = () => {
1685
+ clearTimeout(t);
1686
+ const i = this.authWaiters.indexOf(done);
1687
+ if (i >= 0) this.authWaiters.splice(i, 1);
1688
+ resolve();
1689
+ };
1690
+ const t = setTimeout(done, ms);
1691
+ t.unref?.();
1692
+ this.authWaiters.push(done);
1693
+ });
1694
+ return this.authStatus;
1695
+ }
1696
+ /** The id of an `authenticate` method that signs in with an API key the adapter reads from its own environment. */
1697
+ apiKeyMethod() {
1698
+ for (const m of this.authMethods) {
1699
+ const id = typeof m.id === "string" ? m.id : typeof m.methodId === "string" ? m.methodId : null;
1700
+ if (id === "api-key") return id;
1701
+ }
1702
+ return null;
1703
+ }
1699
1704
  request(method, params, timeoutMs = 6e4) {
1700
1705
  return withTimeout(this.conn.agent.request(method, params), timeoutMs, method);
1701
1706
  }
@@ -1771,6 +1776,155 @@ function isRemoteCommandType(v) {
1771
1776
  return typeof v === "string" && REMOTE_COMMAND_TYPES.includes(v);
1772
1777
  }
1773
1778
 
1779
+ // src/roots.ts
1780
+ import fs10 from "node:fs";
1781
+ import path10 from "node:path";
1782
+ function realRoot(root) {
1783
+ try {
1784
+ return fs10.realpathSync.native(root);
1785
+ } catch {
1786
+ return null;
1787
+ }
1788
+ }
1789
+ var inside = (child, root) => child === root || child.startsWith(root.endsWith(path10.sep) ? root : root + path10.sep);
1790
+ function checkCwd(cwd, roots) {
1791
+ if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
1792
+ if (!path10.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
1793
+ let real;
1794
+ try {
1795
+ real = fs10.realpathSync.native(cwd);
1796
+ } catch {
1797
+ return { ok: false, reason: "cwd_not_found" };
1798
+ }
1799
+ try {
1800
+ if (!fs10.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
1801
+ } catch {
1802
+ return { ok: false, reason: "cwd_not_found" };
1803
+ }
1804
+ for (const root of roots) {
1805
+ const r = realRoot(root);
1806
+ if (r && inside(real, r)) return { ok: true, real };
1807
+ }
1808
+ return { ok: false, reason: "cwd_outside_roots" };
1809
+ }
1810
+ var stalled = /* @__PURE__ */ new Set();
1811
+ var Stalled = class extends Error {
1812
+ };
1813
+ async function bounded(what, f, ms) {
1814
+ if (stalled.has(what)) throw new Stalled(what);
1815
+ let timer;
1816
+ const call = f();
1817
+ const late = new Promise((_, reject) => {
1818
+ timer = setTimeout(() => reject(new Stalled(what)), ms);
1819
+ timer.unref?.();
1820
+ });
1821
+ try {
1822
+ return await Promise.race([call, late]);
1823
+ } catch (e) {
1824
+ if (e instanceof Stalled) {
1825
+ stalled.add(what);
1826
+ void call.then(() => stalled.delete(what), () => stalled.delete(what));
1827
+ }
1828
+ throw e;
1829
+ } finally {
1830
+ clearTimeout(timer);
1831
+ }
1832
+ }
1833
+ var realpathOf = (p, ms, f = fs10.promises) => bounded(p, () => f.realpath(p), ms);
1834
+ var isDir = async (p, ms, f = fs10.promises) => {
1835
+ try {
1836
+ return (await bounded(`stat:${p}`, () => f.stat(p), ms)).isDirectory();
1837
+ } catch (e) {
1838
+ if (e instanceof Stalled) throw e;
1839
+ return false;
1840
+ }
1841
+ };
1842
+ async function resolveCwd(cwd, roots, o) {
1843
+ const ms = o.timeoutMs ?? 1e4;
1844
+ if (typeof cwd !== "string" || cwd.trim() === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
1845
+ const typed = cwd.trim();
1846
+ let candidates;
1847
+ if (typed === "~" || typed.startsWith("~/")) candidates = [path10.join(o.home, typed.slice(2))];
1848
+ else if (path10.isAbsolute(typed)) candidates = [typed];
1849
+ else if (typed.startsWith("~")) return { ok: false, reason: "cwd_not_absolute" };
1850
+ else {
1851
+ const clean = path10.normalize(typed).replace(/\/+$/, "");
1852
+ if (clean === ".." || clean.startsWith("../")) return { ok: false, reason: "cwd_outside_roots" };
1853
+ candidates = [.../* @__PURE__ */ new Set([
1854
+ ...roots.map((r) => path10.join(r, clean)),
1855
+ ...roots.filter((r) => path10.basename(r) === clean)
1856
+ ])];
1857
+ }
1858
+ const realRoots = [];
1859
+ const down = [];
1860
+ await Promise.all(roots.map(async (r) => {
1861
+ try {
1862
+ realRoots.push(await realpathOf(r, ms, o.fs));
1863
+ } catch {
1864
+ down.push(path10.resolve(r));
1865
+ }
1866
+ }));
1867
+ const underDown = (p) => down.some((r) => inside(path10.resolve(p), r));
1868
+ const hits = /* @__PURE__ */ new Map();
1869
+ let unavailable = false;
1870
+ for (const c of candidates) {
1871
+ if (underDown(c)) {
1872
+ unavailable = true;
1873
+ continue;
1874
+ }
1875
+ let real;
1876
+ try {
1877
+ real = await realpathOf(c, ms, o.fs);
1878
+ } catch (e) {
1879
+ if (e instanceof Stalled) unavailable = true;
1880
+ continue;
1881
+ }
1882
+ let dir;
1883
+ try {
1884
+ dir = await isDir(real, ms, o.fs);
1885
+ } catch {
1886
+ unavailable = true;
1887
+ continue;
1888
+ }
1889
+ if (!dir) {
1890
+ if (candidates.length === 1) return { ok: false, reason: "cwd_not_directory" };
1891
+ continue;
1892
+ }
1893
+ if (realRoots.some((r) => inside(real, r))) hits.set(real, true);
1894
+ else if (candidates.length === 1) return { ok: false, reason: "cwd_outside_roots" };
1895
+ }
1896
+ if (hits.size > 1) return { ok: false, reason: "cwd_ambiguous" };
1897
+ if (hits.size === 1) return { ok: true, real: [...hits.keys()][0] };
1898
+ return { ok: false, reason: unavailable ? "root_unavailable" : "cwd_not_found" };
1899
+ }
1900
+ async function rootProblems(agents, timeoutMs = 1e4) {
1901
+ const out = [];
1902
+ await Promise.all(agents.flatMap((a) => a.roots.map(async (r) => {
1903
+ try {
1904
+ const real = await realpathOf(r, timeoutMs);
1905
+ if (!await isDir(real, timeoutMs)) out.push(`${a.id}: root ${r} is not a directory; sessions there are refused`);
1906
+ } catch (e) {
1907
+ out.push(e instanceof Stalled ? `${a.id}: root ${r} does not answer (a stalled network mount?); its sessions are refused (root_unavailable) until it does` : `${a.id}: root ${r} does not exist (yet); its sessions are refused until it does (create it, or fix roots in agents.toml)`);
1908
+ }
1909
+ })));
1910
+ return out.sort();
1911
+ }
1912
+ function relativeInside(p, cwd) {
1913
+ const abs = path10.resolve(cwd, p);
1914
+ let real;
1915
+ try {
1916
+ real = fs10.realpathSync.native(abs);
1917
+ } catch {
1918
+ try {
1919
+ real = path10.join(fs10.realpathSync.native(path10.dirname(abs)), path10.basename(abs));
1920
+ } catch {
1921
+ return null;
1922
+ }
1923
+ }
1924
+ if (!inside(real, cwd)) return null;
1925
+ return path10.relative(cwd, real).split(path10.sep).join("/");
1926
+ }
1927
+
1774
1928
  // src/attach.ts
1775
1929
  var TwinCheckError = class extends Error {
1776
1930
  };
@@ -1850,6 +2004,14 @@ var AttachSupport = class {
1850
2004
  });
1851
2005
  return this.refreshing;
1852
2006
  }
2007
+ /** Where the listing adapter starts: the first root that is there now (a root may be missing), else the home directory. */
2008
+ async listingDir() {
2009
+ for (const r of this.agent.roots) {
2010
+ const v = await resolveCwd(r, [r], { home: os3.homedir(), timeoutMs: 5e3 });
2011
+ if (v.ok) return v.real;
2012
+ }
2013
+ return os3.homedir();
2014
+ }
1853
2015
  async doRefresh() {
1854
2016
  let adapter = null;
1855
2017
  this.noteHooks();
@@ -1858,7 +2020,7 @@ var AttachSupport = class {
1858
2020
  cfg: this.agent,
1859
2021
  registry: this.d.registry,
1860
2022
  sessionId: `list-${this.agent.id}`,
1861
- cwd: this.agent.roots[0] ?? os3.homedir(),
2023
+ cwd: await this.listingDir(),
1862
2024
  hooks: NO_HOOKS,
1863
2025
  logFile: path11.join(this.d.logDir, `adapter-${this.agent.id}.log`),
1864
2026
  version: this.d.version,
@@ -1872,7 +2034,8 @@ var AttachSupport = class {
1872
2034
  const res = await adapter.request("session/list", cursor ? { cursor } : {}, 3e4);
1873
2035
  for (const s of Array.isArray(res.sessions) ? res.sessions : []) {
1874
2036
  if (!isObject(s) || typeof s.sessionId !== "string" || !SESSION_REF_RE.test(s.sessionId) || typeof s.cwd !== "string") continue;
1875
- const where = checkCwd(s.cwd, this.agent.roots);
2037
+ if (!path11.isAbsolute(s.cwd)) continue;
2038
+ const where = await resolveCwd(s.cwd, this.agent.roots, { home: os3.homedir(), timeoutMs: 5e3 });
1876
2039
  if (!where.ok) continue;
1877
2040
  const at = typeof s.updatedAt === "string" ? Date.parse(s.updatedAt) : NaN;
1878
2041
  found.set(s.sessionId.toLowerCase(), {
@@ -2432,13 +2595,15 @@ import os4 from "node:os";
2432
2595
  import path12 from "node:path";
2433
2596
  function resolveDirs(env = process.env, home = os4.homedir()) {
2434
2597
  const one = env.CLAW_CONNECTOR_HOME?.trim();
2435
- if (one) return { config: path12.join(one, "config"), state: path12.join(one, "state") };
2598
+ const own = env.CLAW_CONNECTOR_CONFIG_DIR?.trim();
2599
+ if (one) return { config: own ? path12.resolve(own) : path12.join(one, "config"), state: path12.join(one, "state") };
2436
2600
  const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
2437
2601
  const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
2438
- return { config: path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
2602
+ return { config: own ? path12.resolve(own) : path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
2439
2603
  }
2440
2604
  var configFile = (d) => path12.join(d.config, "agents.toml");
2441
2605
  var credentialsFile = (d) => path12.join(d.config, "credentials.json");
2606
+ var serviceEnvFile = (d) => path12.join(d.config, "service-env.json");
2442
2607
  var identityFile = (d) => path12.join(d.state, "connector.json");
2443
2608
  var sessionsFile = (d) => path12.join(d.state, "sessions.json");
2444
2609
  var childrenFile = (d) => path12.join(d.state, "children.json");
@@ -2454,6 +2619,8 @@ var supervisorPidFile = (d) => path12.join(d.state, "supervisor.pid");
2454
2619
  var runLogFile = (d) => path12.join(d.state, "run.log");
2455
2620
  var keepaliveFile = (d) => path12.join(d.state, "keepalive.sh");
2456
2621
  var serviceFile = (d) => path12.join(d.state, "service.json");
2622
+ var doctorFile = (d) => path12.join(d.state, "doctor.json");
2623
+ var keepaliveStampFile = (d) => path12.join(d.state, "keepalive-last");
2457
2624
 
2458
2625
  // src/health.ts
2459
2626
  var MAX_BYTES = 128 * 1024;
@@ -2523,7 +2690,70 @@ function clearNeedsLogin(dirs) {
2523
2690
  }
2524
2691
 
2525
2692
  // src/session.ts
2693
+ import path15 from "node:path";
2694
+
2695
+ // src/hints.ts
2526
2696
  import path14 from "node:path";
2697
+ function agentFamily(a) {
2698
+ const words = [a.command, ...a.args].map((s) => path14.basename(s));
2699
+ if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
2700
+ if (words.some((s) => s.includes("codex-acp"))) return "codex";
2701
+ const cmd = path14.basename(a.command);
2702
+ if (cmd === "agent" || cmd === "cursor-agent") return "cursor";
2703
+ if (a.kind === "claude" || a.kind === "attach") return "claude";
2704
+ if (a.kind === "codex" || a.kind === "cursor") return a.kind;
2705
+ return "other";
2706
+ }
2707
+ var SIGN_IN = {
2708
+ claude: `Claude Code is not signed in on that machine: run "claude" there and sign in with /login, or set ANTHROPIC_API_KEY (or your gateway's ANTHROPIC_AUTH_TOKEN) for this agent in agents.toml under [agents.env]; a service installed from a shell that has the variable keeps it too`,
2709
+ codex: `Codex is not signed in on that machine: run "codex login" there (or "codex login --with-api-key"), or set OPENAI_API_KEY for this agent in agents.toml under [agents.env]`,
2710
+ cursor: `Cursor is not signed in on that machine: run "agent login" there, or set CURSOR_API_KEY for this agent in agents.toml under [agents.env]`,
2711
+ other: `the agent is not signed in on that machine: sign it in there, then try again`
2712
+ };
2713
+ var DOCTOR = `run "claw-connector doctor" on that machine; it names the fix`;
2714
+ var HINTS = {
2715
+ invalid_cwd: "give the folder to work in: a full path, ~/path, or the name of a folder inside one of this agent's roots",
2716
+ cwd_not_absolute: "give the folder as a full path, as ~/path, or as the name of a folder inside one of this agent's roots",
2717
+ cwd_not_found: "that folder does not exist on that machine: pick an existing folder inside one of this agent's roots, or create it there first",
2718
+ cwd_not_directory: "that is a file, not a folder: pick a folder inside one of this agent's roots",
2719
+ cwd_ambiguous: "more than one of this agent's roots has a folder by that name: give its full path",
2720
+ cwd_outside_roots: "this agent may only work inside its roots (agents.toml on that machine): pick a folder inside one of them, or add the folder to roots there and restart the connector",
2721
+ cwd_mismatch: "that conversation belongs to another folder: take it over without naming a folder, or name the one it was started in",
2722
+ root_unavailable: "the folder's disk is not answering on that machine (a network mount that is down or not mounted yet): try again in a minute, or check the mount there",
2723
+ unknown_agent: "that machine no longer offers this agent: check agents.toml there, then restart the connector",
2724
+ too_many_sessions: "that machine already runs the most sessions it allows at once (10): close one, then try again",
2725
+ mode_not_allowed: `that mode does not ask before each step, and this agent may use such a mode only with approval = "never" in agents.toml on that machine`,
2726
+ mode_unavailable: `the agent does not offer the mode this machine requires for it: ${DOCTOR}`,
2727
+ mode_not_enforced: `the agent would not run in a mode that asks before each step (often because Claude's own settings default to bypassPermissions): ${DOCTOR}, or set approval = "never" for this agent there`,
2728
+ mode_not_confirmed: `the agent did not confirm the mode this machine requires: ${DOCTOR}`,
2729
+ mode_mismatch: `the agent switched to a mode other than the one this machine requires: ${DOCTOR}`,
2730
+ adapter_start_failed: `the agent's program could not start on that machine (not installed, not on the service's PATH, or the download failed): ${DOCTOR}`,
2731
+ adapter_error: `the agent failed while opening the session: ${DOCTOR}; the adapter's log is in the connector's logs directory there`,
2732
+ adapter_protocol: `the agent answered in a way the connector does not understand: ${DOCTOR}, and update the agent's adapter`,
2733
+ adapter_cannot_resume: "this agent cannot reopen an earlier session: start a new one",
2734
+ adapter_gone: "the agent stopped while the session was opening: try again",
2735
+ resume_not_supported: "this agent starts new conversations only: start a new session instead of continuing one",
2736
+ resume_ref_required: "this agent only takes over conversations that already exist on that machine: pick one from its list, or use the agent that starts new ones",
2737
+ invalid_resume_ref: "that is not a conversation this machine lists: pick one from its list",
2738
+ attach_unavailable: "taking over conversations is not available for this agent on that machine: start a new session instead",
2739
+ already_attached: "that conversation is already open from the chat: use that session",
2740
+ unknown_session: "that machine no longer has this conversation (or it is outside the agent's roots): pick another one from its list",
2741
+ catalog_unavailable: `that machine could not list its conversations just now: try again; if it keeps failing, ${DOCTOR}`,
2742
+ session_live: "that conversation is open in a terminal on that machine: exit it there (/exit), then try again",
2743
+ recently_written: "that conversation was written to a moment ago: wait a few seconds, then try again",
2744
+ twin_check_failed: "the connector could not check whether a terminal holds that conversation open: try again in a moment",
2745
+ too_many_queued_prompts: "five messages are already waiting for this session: wait for the agent to finish one",
2746
+ text_required: "the message was empty",
2747
+ session_closed: "the session has already ended: start a new one"
2748
+ };
2749
+ function hintFor(reason, agent) {
2750
+ if (reason === "agent_auth_required") return SIGN_IN[agent ? agentFamily(agent) : "other"];
2751
+ return HINTS[reason] ?? null;
2752
+ }
2753
+ function withHint(data, agent) {
2754
+ const hint = hintFor(data.reason, agent);
2755
+ return hint ? { ...data, hint } : data;
2756
+ }
2527
2757
 
2528
2758
  // src/permissions.ts
2529
2759
  import { randomUUID } from "node:crypto";
@@ -2657,7 +2887,9 @@ var DEFAULT_SESSION_TIMING = {
2657
2887
  modeConfirmMs: 3e3,
2658
2888
  twinWatchMs: 1e3,
2659
2889
  spectateMs: 1e3,
2660
- spectateCheckMs: 5e3
2890
+ spectateCheckMs: 5e3,
2891
+ rootCheckMs: 1e4,
2892
+ authWaitMs: 3e3
2661
2893
  };
2662
2894
  var READ_ONLY_REASON = "read_only_live_session";
2663
2895
  var SessionRefusal = class extends Error {
@@ -2722,6 +2954,13 @@ var Session = class {
2722
2954
  */
2723
2955
  twinSeen = false;
2724
2956
  turnRunning = false;
2957
+ /**
2958
+ * The connector is going away (stopped, or restarted by its supervisor) and will come back. What this process cannot
2959
+ * finish is left recorded for the next run instead of being ended here: the running turn (no `turn_end`), its waiting
2960
+ * permission requests (still pending), and the prompts queued behind it (not counted as handled, so Claw delivers them
2961
+ * again). See {@link SessionManager.recover}.
2962
+ */
2963
+ stopping = false;
2725
2964
  queue = [];
2726
2965
  launching = null;
2727
2966
  idle;
@@ -2763,7 +3002,7 @@ var Session = class {
2763
3002
  return this.noAskWarning || this.currentMode !== null && !this.agent.askingModes.includes(this.currentMode);
2764
3003
  }
2765
3004
  shownModes() {
2766
- return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id) }));
3005
+ return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id), ...this.agent.readOnlyModes.includes(m.id) ? { read_only: true } : {} }));
2767
3006
  }
2768
3007
  // --- starting ----------------------------------------------------------------
2769
3008
  /** `session.new`: start the adapter, create the ACP session, lock the mode. A refusal closes the session with a reason. */
@@ -2971,7 +3210,7 @@ var Session = class {
2971
3210
  sessionId: this.id,
2972
3211
  cwd: this.cwd,
2973
3212
  hooks: this.hooks(),
2974
- logFile: path14.join(this.env.logDir, `adapter-${this.agent.id}.log`),
3213
+ logFile: path15.join(this.env.logDir, `adapter-${this.agent.id}.log`),
2975
3214
  version: this.env.version
2976
3215
  }).catch((e) => {
2977
3216
  throw new SessionRefusal("adapter_start_failed", errMessage(e));
@@ -2984,19 +3223,20 @@ var Session = class {
2984
3223
  this.modeSeen = [];
2985
3224
  try {
2986
3225
  if (how === "new") {
2987
- const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) });
3226
+ const res = await this.signedIn(adapter, () => adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
2988
3227
  if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
2989
3228
  this.acpId = res.sessionId;
2990
3229
  this.absorb(res);
3230
+ await this.refuseSignedOut(adapter);
2991
3231
  } else {
2992
3232
  if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
2993
3233
  const caps = adapter.caps.sessionCapabilities;
2994
3234
  if (caps?.resume) {
2995
- this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
3235
+ this.absorb(await this.signedIn(adapter, () => adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
2996
3236
  } else if (adapter.caps.loadSession) {
2997
3237
  this.replaying = true;
2998
3238
  try {
2999
- this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
3239
+ this.absorb(await this.signedIn(adapter, () => adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
3000
3240
  } finally {
3001
3241
  this.replaying = false;
3002
3242
  }
@@ -3011,6 +3251,43 @@ var Session = class {
3011
3251
  throw e instanceof SessionRefusal ? e : mapAdapterError(e);
3012
3252
  }
3013
3253
  }
3254
+ /**
3255
+ * `open` (a `session/new`), signing the adapter in first when it needs that and can do it by itself: an adapter that
3256
+ * answers "Authentication required" and offers an `api-key` method, while its environment holds a key it reads
3257
+ * (codex-acp: OPENAI_API_KEY or CODEX_API_KEY), is told to `authenticate` with it and asked once more. Without this a
3258
+ * Codex agent set up with only an API key never opened a session. The key itself is never sent: the adapter reads
3259
+ * its own environment. (codex-acp then keeps the key in its auth.json, as `codex login --with-api-key` does.)
3260
+ */
3261
+ async signedIn(adapter, open) {
3262
+ try {
3263
+ return await open();
3264
+ } catch (e) {
3265
+ const method = adapter.apiKeyMethod();
3266
+ if (mapAdapterError(e).reason !== "agent_auth_required" || !method || !this.hasApiKey()) throw e;
3267
+ this.audit("agent_authenticate", { method });
3268
+ try {
3269
+ await adapter.request("authenticate", { methodId: method });
3270
+ } catch (ae) {
3271
+ throw new SessionRefusal("agent_auth_required", `authenticate ${method} failed: ${errMessage(ae)}`);
3272
+ }
3273
+ return await open();
3274
+ }
3275
+ }
3276
+ /** Whether the adapter's environment (the connector's, with the agent's `[agents.env]` over it) holds an OpenAI/Codex key. */
3277
+ hasApiKey() {
3278
+ const env = { ...process.env, ...this.agent.env };
3279
+ return ["CODEX_API_KEY", "OPENAI_API_KEY"].some((k) => typeof env[k] === "string" && env[k].trim() !== "");
3280
+ }
3281
+ /**
3282
+ * An adapter that reports its sign-in (claude-agent-acp) and says there is none: the session would open, say it is
3283
+ * ready and fail at its first message with "Authentication required". A new session is refused now instead, with the
3284
+ * fix. A session that already exists (resumed after an idle recycle or a restart) is not: its prompts say why and it
3285
+ * stays, so it carries on once someone signs in on the machine.
3286
+ */
3287
+ async refuseSignedOut(adapter) {
3288
+ const st = await adapter.waitAuthStatus(this.env.timing.authWaitMs);
3289
+ if (st?.kind === "none") throw new SessionRefusal("agent_auth_required", `the agent reports that it is not signed in${st.label ? ` (${st.label})` : ""}`);
3290
+ }
3014
3291
  /** Take the modes and the mode config option out of a `session/new|resume|load` answer. */
3015
3292
  absorb(res) {
3016
3293
  const modes = res.modes;
@@ -3146,7 +3423,7 @@ var Session = class {
3146
3423
  this.adapter = null;
3147
3424
  const hadTurn = this.turnRunning;
3148
3425
  const cancelled = this.book.cancelAll();
3149
- for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
3426
+ if (!this.stopping) for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
3150
3427
  if (!this.closed) this.audit("adapter_exited", { code: info.code, signal: info.signal, during_turn: hadTurn });
3151
3428
  }
3152
3429
  onUpdate(update, params) {
@@ -3195,16 +3472,20 @@ var Session = class {
3195
3472
  kind: str(toolCall.kind, 64),
3196
3473
  input: boundedInput(toolCall.rawInput),
3197
3474
  toolCallId: str(toolCall.toolCallId, 128)
3198
- }, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } });
3475
+ }, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } }, true);
3199
3476
  }
3200
3477
  /** Put a question on the phone and wait for the answer, a cancel or the timeout. `shape` turns the answer into what the adapter expects. */
3201
- askPerson(options, info, shape) {
3478
+ askPerson(options, info, shape, permission = false) {
3202
3479
  return new Promise((resolve) => {
3203
3480
  const p = this.book.open(options, (actionId) => redactDeep({ actionId, ...info, options, timeout_ms: this.book.ttl }), (optionId) => {
3204
- this.env.store.removePending(this.id, p.actionId);
3481
+ if (!this.stopping) {
3482
+ this.env.store.removePending(this.id, p.actionId);
3483
+ if (this.book.size === 0) this.env.store.markWaiting(this.id, false);
3484
+ }
3205
3485
  resolve(shape(optionId));
3206
3486
  });
3207
3487
  this.env.store.addPending(this.id, p.actionId);
3488
+ this.env.store.markWaiting(this.id, true, permission);
3208
3489
  this.audit("permission_requested", { action_id: p.actionId, tool: info.tool, tool_kind: info.kind, options: options.map((o) => o.optionId) });
3209
3490
  this.emit("permissionRequest", p.payload);
3210
3491
  });
@@ -3286,11 +3567,16 @@ var Session = class {
3286
3567
  this.queue.push({ text, cmdId, cmdSeq, started });
3287
3568
  if (!this.turnRunning) void this.pump();
3288
3569
  }
3289
- /** Prompts that will now never run (the process is being stopped): they count as handled. */
3290
- dropQueued() {
3570
+ /** Prompts that will now never run here: they count as handled, unless (`keep`) the connector is only restarting and the next run should get them again. */
3571
+ dropQueued(keep = false) {
3291
3572
  const q = this.queue;
3292
3573
  this.queue = [];
3293
- for (const item of q) item.started();
3574
+ if (!keep) for (const item of q) item.started();
3575
+ }
3576
+ /** The connector is about to stop its adapters and will come back: see {@link stopping}. */
3577
+ shutdown() {
3578
+ this.stopping = true;
3579
+ this.dropQueued(true);
3294
3580
  }
3295
3581
  async pump() {
3296
3582
  if (this.turnRunning) return;
@@ -3314,6 +3600,7 @@ var Session = class {
3314
3600
  this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
3315
3601
  this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
3316
3602
  this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
3603
+ this.env.store.endTurn(this.id);
3317
3604
  return;
3318
3605
  }
3319
3606
  }
@@ -3321,7 +3608,11 @@ var Session = class {
3321
3608
  await this.ensureAdapter();
3322
3609
  } catch (e) {
3323
3610
  started();
3324
- this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable", cmd_id: cmdId, cmd_seq: cmdSeq });
3611
+ const reason = e instanceof SessionRefusal ? e.reason : "adapter_unavailable";
3612
+ const hint = hintFor(reason, this.agent);
3613
+ this.emit("turn_end", { stopReason: "error", error: reason, ...hint ? { hint } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
3614
+ this.env.store.endTurn(this.id);
3615
+ if (reason === "agent_auth_required") return;
3325
3616
  return this.refuse(e);
3326
3617
  }
3327
3618
  const a = this.adapter;
@@ -3330,13 +3621,22 @@ var Session = class {
3330
3621
  const watch = support ? this.watchTwins(support, this.acpId) : null;
3331
3622
  try {
3332
3623
  const reply = a.requestUnbounded("session/prompt", { sessionId: this.acpId, prompt: [{ type: "text", text }] });
3624
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3625
+ this.env.store.beginTurn({ session_id: this.id, cmd_id: cmdId, cmd_seq: cmdSeq, started_at: now });
3626
+ this.env.store.patch(this.id, { used_at: now });
3333
3627
  started();
3334
3628
  const res = await reply;
3335
3629
  const usage = isObject(res.usage) ? res.usage : void 0;
3336
- this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
3630
+ this.endTurn({ stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
3337
3631
  } catch (e) {
3338
- this.audit("turn_failed", { error: errMessage(e) });
3339
- this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
3632
+ if (!this.stopping) this.audit("turn_failed", { error: errMessage(e) });
3633
+ const r = mapAdapterError(e);
3634
+ if (r.reason === "agent_auth_required") {
3635
+ this.endTurn({ stopReason: "error", error: r.reason, message: errMessage(e).slice(0, 300), hint: hintFor(r.reason, this.agent), cmd_id: cmdId, cmd_seq: cmdSeq });
3636
+ if (this.adapter === a) await this.dropAdapter();
3637
+ } else {
3638
+ this.endTurn({ stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
3639
+ }
3340
3640
  } finally {
3341
3641
  this.markTranscript();
3342
3642
  if (watch) {
@@ -3381,6 +3681,12 @@ var Session = class {
3381
3681
  }
3382
3682
  return null;
3383
3683
  }
3684
+ /** The turn is over: say so and forget it. Not while the connector is going away: the next run ends or continues it. */
3685
+ endTurn(data) {
3686
+ if (this.stopping) return;
3687
+ this.emit("turn_end", data);
3688
+ this.env.store.endTurn(this.id);
3689
+ }
3384
3690
  /** What size the transcript is as this connector leaves it: after it starts the session and after every turn. */
3385
3691
  markTranscript() {
3386
3692
  if (this.agent.attach && this.acpId) this.transcriptMark = this.env.attach.get(this.agent.id)?.transcriptSize(this.acpId) ?? null;
@@ -3403,6 +3709,7 @@ var Session = class {
3403
3709
  if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
3404
3710
  else await this.launch("resume");
3405
3711
  } catch (e) {
3712
+ if (e instanceof SessionRefusal && e.reason === "agent_auth_required") return this.rejectCommand("resume", cmdId, e.reason);
3406
3713
  return this.refuse(e);
3407
3714
  }
3408
3715
  this.audit("session_resumed", { mode: this.currentMode, unattended: this.unattended });
@@ -3449,7 +3756,7 @@ var Session = class {
3449
3756
  await this.dropAdapter();
3450
3757
  this.env.store.patch(this.id, { status: "closed" });
3451
3758
  this.audit("session_closed", { reason });
3452
- this.emit("session_closed", { reason });
3759
+ this.emit("session_closed", withHint({ reason }, this.agent));
3453
3760
  }
3454
3761
  /** A session that cannot be opened (or kept safe) is closed with the reason, never left half-open. */
3455
3762
  async refuse(e) {
@@ -3461,7 +3768,7 @@ var Session = class {
3461
3768
  /** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
3462
3769
  rejectCommand(type, cmdId, reason, cmdSeq = null) {
3463
3770
  this.audit("command_rejected", { type, cmd_id: cmdId, reason });
3464
- this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
3771
+ this.emit("command_rejected", withHint({ type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} }, this.agent));
3465
3772
  }
3466
3773
  };
3467
3774
  function mapAdapterError(e) {
@@ -3486,7 +3793,10 @@ function limitStrings(value, max) {
3486
3793
  }
3487
3794
 
3488
3795
  // src/manager.ts
3796
+ import os5 from "node:os";
3489
3797
  var MAX_ACTIVE_SESSIONS = 10;
3798
+ var RECOVER_NOTICE_MS = 4 * 36e5;
3799
+ var CONTINUE_PROMPT = "[claw-connector] The connector on this machine restarted while you were waiting for my permission, so that request was cancelled. Continue the task from where you left off, and ask again for anything that still needs my permission.";
3490
3800
  var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
3491
3801
  var SessionManager = class {
3492
3802
  constructor(env) {
@@ -3500,6 +3810,15 @@ var SessionManager = class {
3500
3810
  agent(id) {
3501
3811
  return typeof id === "string" ? this.env.config.agents.find((a) => a.id === id) : void 0;
3502
3812
  }
3813
+ /** The folder a phone asked for, as this agent would see it: `~` is the home its adapter runs with. */
3814
+ where(cwd, agent) {
3815
+ return resolveCwd(typeof cwd === "string" ? cwd : "", agent.roots, { home: agent.env.HOME?.trim() || os5.homedir(), timeoutMs: this.env.timing.rootCheckMs });
3816
+ }
3817
+ /** A session refused before it had a process: audited, and closed with its reason and what to do about it. */
3818
+ refuseNew(sid, reason, agent, extra = {}) {
3819
+ this.env.audit.record("session_refused", { session_id: sid, ...agent ? { agent_id: agent.id } : {}, reason, ...extra });
3820
+ this.env.emit(sid, "session_closed", withHint({ reason }, agent));
3821
+ }
3503
3822
  /**
3504
3823
  * Commands of one session run one after another; sessions run side by side.
3505
3824
  * `handled` is called exactly once, when the command counts as carried out: for most that is when the
@@ -3549,8 +3868,20 @@ var SessionManager = class {
3549
3868
  return false;
3550
3869
  }
3551
3870
  const s = await this.lookup(sid);
3552
- if (!s) {
3553
- this.env.emit(sid, "session_closed", { reason: "unknown_session" });
3871
+ if (s === null) {
3872
+ this.env.emit(sid, "session_closed", { reason: "unknown_session", hint: "the session has already ended on that machine: start a new one" });
3873
+ return false;
3874
+ }
3875
+ if (!(s instanceof Session)) {
3876
+ if (type === "close") {
3877
+ this.env.store.patch(sid, { status: "closed" });
3878
+ this.env.audit.record("session_closed", { session_id: sid, reason: "closed" });
3879
+ this.env.emit(sid, "session_closed", { reason: "closed" });
3880
+ return false;
3881
+ }
3882
+ const seq = cmdSeqOf(cmd);
3883
+ this.env.audit.record("command_rejected", { type, cmd_id: cmdId, reason: s.unavailable });
3884
+ this.env.emit(sid, "command_rejected", withHint({ type, cmd_id: cmdId, reason: s.unavailable, ...seq !== null ? { cmd_seq: seq } : {} }, s.agent));
3554
3885
  return false;
3555
3886
  }
3556
3887
  switch (type) {
@@ -3589,30 +3920,15 @@ var SessionManager = class {
3589
3920
  return;
3590
3921
  }
3591
3922
  const agent = this.agent(cmd.agent_id);
3592
- if (!agent) {
3593
- this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
3594
- this.env.emit(sid, "session_closed", { reason: "unknown_agent" });
3595
- return;
3596
- }
3923
+ if (!agent) return this.refuseNew(sid, "unknown_agent", null, { agent_id: String(cmd.agent_id).slice(0, 64) });
3597
3924
  const ref = cmd.resume_ref;
3598
3925
  if (agent.attach) return this.attachNew(cmd, agent);
3599
- if (ref !== void 0 && ref !== null) {
3600
- this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "resume_not_supported" });
3601
- this.env.emit(sid, "session_closed", { reason: "resume_not_supported" });
3602
- return;
3603
- }
3604
- const verdict = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
3605
- if (!verdict.ok) {
3606
- this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: verdict.reason, cwd: String(cmd.cwd).slice(0, 300) });
3607
- this.env.emit(sid, "session_closed", { reason: verdict.reason });
3608
- return;
3609
- }
3926
+ if (ref !== void 0 && ref !== null) return this.refuseNew(sid, "resume_not_supported", agent);
3927
+ const verdict = await this.where(cmd.cwd, agent);
3928
+ if (!verdict.ok) return this.refuseNew(sid, verdict.reason, agent, { cwd: String(cmd.cwd).slice(0, 300) });
3929
+ if (this.sessions.has(sid)) return;
3610
3930
  const active = [...this.sessions.values()].filter((s2) => !s2.closed).length;
3611
- if (active >= MAX_ACTIVE_SESSIONS) {
3612
- this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "too_many_sessions" });
3613
- this.env.emit(sid, "session_closed", { reason: "too_many_sessions" });
3614
- return;
3615
- }
3931
+ if (active >= MAX_ACTIVE_SESSIONS) return this.refuseNew(sid, "too_many_sessions", agent);
3616
3932
  const s = new Session(sid, agent, verdict.real, this.env);
3617
3933
  this.sessions.set(sid, s);
3618
3934
  await s.create(typeof cmd.mode === "string" ? cmd.mode : null);
@@ -3624,10 +3940,7 @@ var SessionManager = class {
3624
3940
  */
3625
3941
  async attachNew(cmd, agent) {
3626
3942
  const sid = cmd.session_id;
3627
- const refuse = (reason, extra = {}) => {
3628
- this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason, ...extra });
3629
- this.env.emit(sid, "session_closed", { reason });
3630
- };
3943
+ const refuse = (reason, extra = {}) => this.refuseNew(sid, reason, agent, extra);
3631
3944
  const ref = typeof cmd.resume_ref === "string" ? cmd.resume_ref : "";
3632
3945
  if (!ref) return refuse("resume_ref_required");
3633
3946
  if (!SESSION_REF_RE.test(ref)) return refuse("invalid_resume_ref");
@@ -3641,7 +3954,7 @@ var SessionManager = class {
3641
3954
  try {
3642
3955
  const found = await support.resolve(want);
3643
3956
  if (!found.ok) return refuse(found.reason);
3644
- const asked = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
3957
+ const asked = await this.where(cmd.cwd, agent);
3645
3958
  if (!asked.ok) return refuse(asked.reason, { cwd: String(cmd.cwd).slice(0, 300) });
3646
3959
  if (asked.real !== found.item.cwd) return refuse("cwd_mismatch", { cwd: String(cmd.cwd).slice(0, 300) });
3647
3960
  const s = new Session(sid, agent, found.item.cwd, this.env, found.item.ref);
@@ -3658,20 +3971,25 @@ var SessionManager = class {
3658
3971
  for (const s of this.sessions.values()) if (!s.closed && !s.readOnly && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
3659
3972
  return out;
3660
3973
  }
3661
- /** A session this process knows, or one it made earlier (before a restart) and can bring back. */
3974
+ /**
3975
+ * A session this process knows, or one it made earlier (before a restart) and can bring back; `unavailable` when its
3976
+ * folder's disk does not answer now (the session is kept for later).
3977
+ */
3662
3978
  async lookup(sid) {
3663
3979
  const have = this.sessions.get(sid);
3664
3980
  if (have) return have;
3665
3981
  const rec = this.env.store.get(sid);
3666
3982
  if (!rec || rec.status !== "active") return null;
3667
3983
  const agent = this.agent(rec.agent_id);
3668
- const verdict = agent ? checkCwd(rec.cwd, agent.roots) : null;
3984
+ const verdict = agent ? await this.where(rec.cwd, agent) : null;
3669
3985
  if (!agent || !verdict?.ok) {
3986
+ if (agent && verdict && !verdict.ok && verdict.reason === "root_unavailable") return { unavailable: verdict.reason, agent };
3670
3987
  this.env.store.patch(sid, { status: "closed" });
3671
- this.env.audit.record("session_refused", { session_id: sid, reason: agent ? "cwd_outside_roots" : "unknown_agent" });
3672
- this.env.emit(sid, "session_closed", { reason: agent ? "cwd_outside_roots" : "unknown_agent" });
3988
+ this.refuseNew(sid, agent ? verdict && !verdict.ok ? verdict.reason : "cwd_outside_roots" : "unknown_agent", agent ?? null);
3673
3989
  return null;
3674
3990
  }
3991
+ const raced = this.sessions.get(sid);
3992
+ if (raced) return raced;
3675
3993
  const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
3676
3994
  this.sessions.set(sid, s);
3677
3995
  if (rec.read_only) s.restoreSpectate();
@@ -3691,11 +4009,61 @@ var SessionManager = class {
3691
4009
  get pendingPermissions() {
3692
4010
  return [...this.sessions.values()].reduce((n, s) => n + s.pendingCount, 0);
3693
4011
  }
3694
- /** The kill switch: every adapter process, now. Sessions stay recorded and can be resumed. */
3695
- async killAll(reason) {
4012
+ /**
4013
+ * After a restart (a crash, a kill, a supervisor's restart, an upgrade): every turn the previous run left running is
4014
+ * ended or carried on, and every session used lately is told it is back (`session_recovered`), once.
4015
+ *
4016
+ * A turn whose agent was waiting for a person's permission for a tool call, cut off no longer ago than a card lives, in a
4017
+ * session of an agent that always asks, goes on with {@link CONTINUE_PROMPT} under the same prompt (`cmd_id`,
4018
+ * `cmd_seq`): the agent asks again, so the card comes back, and the prompt's own `turn_end` still ends it. Not while
4019
+ * the kill switch is on (`mayContinue`). Any other turn ends with `turn_end` `error: "connector_restarted"`, naming its
4020
+ * prompt, so the phone stops waiting for it; whether the agent finished what it was doing is in the conversation, which
4021
+ * the next message resumes.
4022
+ */
4023
+ recover(o) {
4024
+ const now = o.now ?? Date.now();
4025
+ const told = /* @__PURE__ */ new Set();
4026
+ for (const t of this.env.store.listTurns()) {
4027
+ const sid = t.session_id;
4028
+ const rec = this.env.store.get(sid);
4029
+ const active = rec?.status === "active";
4030
+ const agent = rec ? this.agent(rec.agent_id) : void 0;
4031
+ const asks = agent !== void 0 && !agent.allowUnsafeModes && rec?.mode !== null && rec?.mode !== void 0 && agent.askingModes.includes(rec.mode);
4032
+ const waiting = Boolean(t.waiting_permission);
4033
+ const recent = o.downForMs !== null && o.downForMs <= this.env.timing.permissionTtlMs;
4034
+ if (o.mayContinue && active && asks && waiting && recent) {
4035
+ this.env.audit.record("turn_continued", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, down_ms: o.downForMs });
4036
+ this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: true, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
4037
+ void this.handle({ cmd_id: t.cmd_id ?? `continue-${t.cmd_seq ?? sid}`, type: "prompt", session_id: sid, text: CONTINUE_PROMPT, ...t.cmd_seq !== null ? { cmd_seq: t.cmd_seq } : {} }, () => {
4038
+ if (this.env.store.turn(sid)?.started_at === t.started_at) this.env.store.endTurn(sid);
4039
+ });
4040
+ } else {
4041
+ this.env.audit.record("turn_interrupted", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, waiting, down_ms: o.downForMs });
4042
+ if (active) this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: false, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
4043
+ this.env.emit(sid, "turn_end", { stopReason: "error", error: "connector_restarted", cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
4044
+ this.env.store.endTurn(sid);
4045
+ }
4046
+ told.add(sid);
4047
+ }
4048
+ for (const sid of this.env.store.ids()) {
4049
+ const rec = this.env.store.get(sid);
4050
+ if (told.has(sid) || rec?.status !== "active" || !rec.used_at) continue;
4051
+ const used = Date.parse(rec.used_at);
4052
+ if (!Number.isFinite(used) || now - used > RECOVER_NOTICE_MS) continue;
4053
+ this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: false, continuing: false });
4054
+ }
4055
+ }
4056
+ /**
4057
+ * Every adapter process, now. Sessions stay recorded and can be resumed. `restarting`: the connector is only going away
4058
+ * for a while (stopped, or restarted by its supervisor); what is in flight is left for the next run ({@link recover})
4059
+ * instead of being ended, and queued prompts are delivered again. Otherwise (the kill switch: revoked, paused) every
4060
+ * turn ends here and queued prompts count as handled.
4061
+ */
4062
+ async killAll(reason, restarting = false) {
3696
4063
  for (const s of this.sessions.values()) {
3697
4064
  this.env.audit.record("session_killed", { session_id: s.id, reason });
3698
- s.dropQueued();
4065
+ if (restarting) s.shutdown();
4066
+ else s.dropQueued();
3699
4067
  s.stopTail();
3700
4068
  }
3701
4069
  await this.env.registry.killAll();
@@ -3705,7 +4073,7 @@ var SessionManager = class {
3705
4073
 
3706
4074
  // src/outbox.ts
3707
4075
  import fs13 from "node:fs";
3708
- import path15 from "node:path";
4076
+ import path16 from "node:path";
3709
4077
  var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
3710
4078
  "agent_message_chunk",
3711
4079
  "agent_thought_chunk",
@@ -3735,8 +4103,8 @@ var Outbox = class {
3735
4103
  fs13.mkdirSync(this.dir, { recursive: true, mode: 448 });
3736
4104
  this.load();
3737
4105
  }
3738
- eventsFile = (sid) => path15.join(this.dir, `${safeName(sid)}.jsonl`);
3739
- metaFile = (sid) => path15.join(this.dir, `${safeName(sid)}.meta.json`);
4106
+ eventsFile = (sid) => path16.join(this.dir, `${safeName(sid)}.jsonl`);
4107
+ metaFile = (sid) => path16.join(this.dir, `${safeName(sid)}.meta.json`);
3740
4108
  writeMeta(sid) {
3741
4109
  const tmp = this.metaFile(sid) + ".tmp";
3742
4110
  fs13.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
@@ -3753,11 +4121,11 @@ var Outbox = class {
3753
4121
  for (const base of bases) {
3754
4122
  let meta = { lastSeq: 0, acked: 0 };
3755
4123
  try {
3756
- meta = JSON.parse(fs13.readFileSync(path15.join(this.dir, `${base}.meta.json`), "utf8"));
4124
+ meta = JSON.parse(fs13.readFileSync(path16.join(this.dir, `${base}.meta.json`), "utf8"));
3757
4125
  } catch {
3758
4126
  }
3759
4127
  let sid = null;
3760
- const file = path15.join(this.dir, `${base}.jsonl`);
4128
+ const file = path16.join(this.dir, `${base}.jsonl`);
3761
4129
  let text = "";
3762
4130
  try {
3763
4131
  text = fs13.readFileSync(file, "utf8");
@@ -3778,8 +4146,8 @@ var Outbox = class {
3778
4146
  if (sid === null) {
3779
4147
  sid = base;
3780
4148
  try {
3781
- if (this.now() - fs13.statSync(path15.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
3782
- for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path15.join(this.dir, f), { force: true });
4149
+ if (this.now() - fs13.statSync(path16.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
4150
+ for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path16.join(this.dir, f), { force: true });
3783
4151
  continue;
3784
4152
  }
3785
4153
  } catch {
@@ -3897,11 +4265,19 @@ var Outbox = class {
3897
4265
 
3898
4266
  // src/session-store.ts
3899
4267
  import fs14 from "node:fs";
3900
- import path16 from "node:path";
4268
+ import path17 from "node:path";
3901
4269
  var SessionStore = class {
3902
4270
  constructor(file) {
3903
4271
  this.file = file;
3904
4272
  this.pendingFile = `${file}.pending`;
4273
+ this.turnsFile = `${file}.turns`;
4274
+ try {
4275
+ const raw = JSON.parse(fs14.readFileSync(this.turnsFile, "utf8"));
4276
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
4277
+ for (const [k, t] of Object.entries(raw)) if (t && typeof t.session_id === "string") this.turns[k] = t;
4278
+ }
4279
+ } catch {
4280
+ }
3905
4281
  try {
3906
4282
  const raw = JSON.parse(fs14.readFileSync(this.pendingFile, "utf8"));
3907
4283
  if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
@@ -3917,6 +4293,8 @@ var SessionStore = class {
3917
4293
  recs = {};
3918
4294
  pending = [];
3919
4295
  pendingFile;
4296
+ turns = {};
4297
+ turnsFile;
3920
4298
  get(id) {
3921
4299
  return this.recs[id];
3922
4300
  }
@@ -3951,50 +4329,527 @@ var SessionStore = class {
3951
4329
  return out;
3952
4330
  }
3953
4331
  savePending() {
3954
- fs14.mkdirSync(path16.dirname(this.pendingFile), { recursive: true, mode: 448 });
4332
+ fs14.mkdirSync(path17.dirname(this.pendingFile), { recursive: true, mode: 448 });
3955
4333
  const tmp = `${this.pendingFile}.${process.pid}.tmp`;
3956
4334
  fs14.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
3957
4335
  fs14.renameSync(tmp, this.pendingFile);
3958
4336
  }
4337
+ /**
4338
+ * The turn a session's agent is running. It lives in the adapter's memory, so a connector that dies (or is stopped)
4339
+ * mid-turn would leave the prompt open for ever: the next run reads these and ends or continues each one.
4340
+ */
4341
+ beginTurn(t) {
4342
+ this.turns[t.session_id] = t;
4343
+ this.saveTurns();
4344
+ }
4345
+ /** The turn's agent is waiting for a person (`true`; `permission`: a tool call's permission request), or no longer (`false`). */
4346
+ markWaiting(sessionId, waiting, permission = false) {
4347
+ const t = this.turns[sessionId];
4348
+ if (!t) return;
4349
+ const next = { ...t, waiting, waiting_permission: waiting && (permission || Boolean(t.waiting_permission)) };
4350
+ if (Boolean(t.waiting) === next.waiting && Boolean(t.waiting_permission) === next.waiting_permission) return;
4351
+ this.turns[sessionId] = next;
4352
+ this.saveTurns();
4353
+ }
4354
+ turn(sessionId) {
4355
+ return this.turns[sessionId];
4356
+ }
4357
+ endTurn(sessionId) {
4358
+ if (!(sessionId in this.turns)) return;
4359
+ delete this.turns[sessionId];
4360
+ this.saveTurns();
4361
+ }
4362
+ /** The turns recorded as running. Each stays until it is ended ({@link endTurn}) or a new turn of its session replaces it. */
4363
+ listTurns() {
4364
+ return Object.values(this.turns).map((t) => ({ ...t }));
4365
+ }
4366
+ saveTurns() {
4367
+ fs14.mkdirSync(path17.dirname(this.turnsFile), { recursive: true, mode: 448 });
4368
+ const tmp = `${this.turnsFile}.${process.pid}.tmp`;
4369
+ fs14.writeFileSync(tmp, JSON.stringify(this.turns), { mode: 384 });
4370
+ fs14.renameSync(tmp, this.turnsFile);
4371
+ }
3959
4372
  ids() {
3960
4373
  return Object.keys(this.recs);
3961
4374
  }
3962
4375
  save() {
3963
- fs14.mkdirSync(path16.dirname(this.file), { recursive: true, mode: 448 });
4376
+ fs14.mkdirSync(path17.dirname(this.file), { recursive: true, mode: 448 });
3964
4377
  const tmp = `${this.file}.${process.pid}.tmp`;
3965
4378
  fs14.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
3966
4379
  fs14.renameSync(tmp, this.file);
3967
4380
  }
3968
4381
  };
3969
4382
 
3970
- // src/connector.ts
3971
- var RESTART_RECONNECT_GAP_MS = 5e3;
3972
- var DEFAULT_TIMING = {
3973
- ...DEFAULT_SESSION_TIMING,
3974
- heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
3975
- watchdogMs: REMOTE_AGENT_TIMING.watchdogMs,
3976
- batchMs: 150,
3977
- batchMax: 20,
3978
- backoffMinMs: 1e3,
3979
- backoffMaxMs: 6e4,
3980
- pausePollMs: 1e3,
3981
- catalogMs: 12e4,
3982
- sessionsWatchMs: 1e4,
3983
- attachCliMs: 8e3,
3984
- recentWriteMs: 1e4,
3985
- authFailLimit: 4,
3986
- authFailWindowMs: 12e4
3987
- };
3988
- var CommandLedger = class {
3989
- inflight = /* @__PURE__ */ new Set();
3990
- inflightIds = /* @__PURE__ */ new Set();
3991
- ids;
3992
- highest = 0;
3993
- floor;
3994
- constructor(cursor = 0, handled = []) {
3995
- this.floor = cursor;
3996
- this.highest = cursor;
3997
- this.ids = [...handled];
4383
+ // src/doctor.ts
4384
+ import { spawn as spawn3 } from "node:child_process";
4385
+ import fs15 from "node:fs";
4386
+ import fsp from "node:fs/promises";
4387
+ import os6 from "node:os";
4388
+ import path18 from "node:path";
4389
+ import { parse as parseToml2 } from "smol-toml";
4390
+ var FS_TIMEOUT_MS = 5e3;
4391
+ async function within2(p, ms = FS_TIMEOUT_MS) {
4392
+ let t;
4393
+ const late = new Promise((resolve) => {
4394
+ t = setTimeout(() => resolve(null), ms);
4395
+ t.unref?.();
4396
+ });
4397
+ p.catch(() => void 0);
4398
+ try {
4399
+ return await Promise.race([p, late]);
4400
+ } finally {
4401
+ clearTimeout(t);
4402
+ }
4403
+ }
4404
+ var errnoOf = async (p) => within2(p.then(() => "", (e) => e.code ?? "EIO"));
4405
+ var fileExists = async (f) => await errnoOf(fsp.access(f)) === "";
4406
+ async function readText(f) {
4407
+ return within2(fsp.readFile(f, "utf8").catch(() => null));
4408
+ }
4409
+ async function readJson(file) {
4410
+ const t = await readText(file);
4411
+ if (t === null) return null;
4412
+ try {
4413
+ const v = JSON.parse(t);
4414
+ return isObject(v) ? v : null;
4415
+ } catch {
4416
+ return null;
4417
+ }
4418
+ }
4419
+ var NODE_MIN_MAJOR = 22;
4420
+ var RECHECK_MS = 15 * 6e4;
4421
+ var KEEPALIVE_STALE_MS = 15 * 6e4;
4422
+ var PROBE_TIMEOUT_MS = 15e3;
4423
+ var PIPE_GRACE_MS = 500;
4424
+ var MAX_REPORTED2 = 6;
4425
+ function runProbe(cmd, args, env, signal, timeoutMs = PROBE_TIMEOUT_MS, input, ownGroup = true) {
4426
+ return new Promise((resolve) => {
4427
+ if (signal?.aborted) return resolve({ code: null, out: "stopped" });
4428
+ let out = "";
4429
+ let child;
4430
+ try {
4431
+ child = spawn3(cmd, args, { env, cwd: os6.homedir(), detached: ownGroup, stdio: [input === void 0 ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true });
4432
+ } catch (e) {
4433
+ return resolve({ code: null, out: errMessage(e) });
4434
+ }
4435
+ if (input !== void 0) {
4436
+ child.stdin?.on("error", () => void 0);
4437
+ child.stdin?.end(input);
4438
+ }
4439
+ const take = (b) => {
4440
+ if (out.length < 256 * 1024) out += b.toString("utf8");
4441
+ };
4442
+ child.stdout?.on("data", take);
4443
+ child.stderr?.on("data", take);
4444
+ const killGroup = () => {
4445
+ try {
4446
+ if (child.pid) process.kill(ownGroup ? -child.pid : child.pid, "SIGKILL");
4447
+ } catch {
4448
+ }
4449
+ };
4450
+ let settled = false;
4451
+ const finish = (code) => {
4452
+ if (settled) return;
4453
+ settled = true;
4454
+ clearTimeout(timer);
4455
+ signal?.removeEventListener("abort", onAbort);
4456
+ killGroup();
4457
+ child.stdout?.destroy();
4458
+ child.stderr?.destroy();
4459
+ resolve({ code, out: out.trim() });
4460
+ };
4461
+ const onAbort = () => finish(null);
4462
+ const timer = setTimeout(() => finish(null), timeoutMs);
4463
+ signal?.addEventListener("abort", onAbort, { once: true });
4464
+ child.once("error", (e) => {
4465
+ out = errMessage(e);
4466
+ finish(null);
4467
+ });
4468
+ const ended = (st) => new Promise((res) => {
4469
+ if (!st || st.readableEnded) res();
4470
+ else {
4471
+ st.once("end", () => res());
4472
+ st.once("close", () => res());
4473
+ }
4474
+ });
4475
+ const drained = Promise.all([ended(child.stdout), ended(child.stderr)]);
4476
+ child.once("exit", (code) => {
4477
+ const grace = setTimeout(() => finish(code), PIPE_GRACE_MS);
4478
+ void drained.then(() => {
4479
+ clearTimeout(grace);
4480
+ finish(code);
4481
+ });
4482
+ });
4483
+ });
4484
+ }
4485
+ function defaultDeps(env, over = {}) {
4486
+ const signal = over.signal;
4487
+ const ownGroup = over.ownGroup ?? true;
4488
+ const run2 = (cmd, args, e) => runProbe(cmd, args, e, signal, PROBE_TIMEOUT_MS, void 0, ownGroup);
4489
+ return {
4490
+ env,
4491
+ platform: process.platform,
4492
+ home: os6.homedir(),
4493
+ user: env.USER || (() => {
4494
+ try {
4495
+ return os6.userInfo().username;
4496
+ } catch {
4497
+ return "";
4498
+ }
4499
+ })(),
4500
+ nodeVersion: process.versions.node,
4501
+ uid: process.getuid?.() ?? null,
4502
+ now: () => Date.now(),
4503
+ probe: run2,
4504
+ crontab: async () => {
4505
+ const r = await run2("crontab", ["-l"], env);
4506
+ return r.code === 0 ? r.out : null;
4507
+ },
4508
+ exec: (cmd, args) => run2(cmd, args, env),
4509
+ writable: async (dir) => {
4510
+ const code = await errnoOf(fsp.access(dir, fs15.constants.W_OK));
4511
+ return code === "" ? null : code ?? "ETIMEDOUT";
4512
+ },
4513
+ get: async (url) => {
4514
+ try {
4515
+ const r = await fetch(url, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8e3)]) : AbortSignal.timeout(8e3), redirect: "manual" });
4516
+ await r.body?.cancel().catch(() => void 0);
4517
+ return { status: r.status };
4518
+ } catch (e) {
4519
+ const cause = e.cause;
4520
+ return { error: cause instanceof Error ? `${errMessage(e)} (${cause.message})` : errMessage(e) };
4521
+ }
4522
+ },
4523
+ ...over
4524
+ };
4525
+ }
4526
+ async function which(cmd, pathVar) {
4527
+ const executable = async (p) => await within2(fsp.stat(p).then(async (st) => st.isFile() && await fsp.access(p, fs15.constants.X_OK).then(() => true, () => false), () => false)) === true;
4528
+ if (cmd.includes("/")) return await executable(cmd) ? cmd : null;
4529
+ for (const dir of (pathVar ?? "").split(path18.delimiter)) {
4530
+ if (!dir) continue;
4531
+ const p = path18.join(dir, cmd);
4532
+ if (await executable(p)) return p;
4533
+ }
4534
+ return null;
4535
+ }
4536
+ var agentEnv = (env, a) => ({ ...env, ...a.env });
4537
+ function familyOf(a) {
4538
+ const words = [a.command, ...a.args].map((s) => path18.basename(s));
4539
+ if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
4540
+ if (words.some((s) => s.includes("codex-acp"))) return "codex";
4541
+ if (path18.basename(a.command) === "agent" || path18.basename(a.command) === "cursor-agent") return "cursor";
4542
+ return "other";
4543
+ }
4544
+ var LOGIN_FIX = {
4545
+ claude: "claude auth login",
4546
+ codex: "codex login",
4547
+ cursor: "agent login"
4548
+ };
4549
+ function installFix(cmd, family) {
4550
+ const base = path18.basename(cmd);
4551
+ if (base === "npx" || base === "node") return "install Node.js 22 or newer (it brings npx): https://nodejs.org";
4552
+ if (family === "cursor") return "install the Cursor CLI: curl https://cursor.com/install -fsS | bash";
4553
+ return `install ${base}, or set this agent's command in agents.toml to its full path`;
4554
+ }
4555
+ var set = (env, name) => typeof env[name] === "string" && env[name] !== "";
4556
+ async function claudeSignedIn(env, d) {
4557
+ const keys = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"];
4558
+ if (keys.some((k) => set(env, k))) return true;
4559
+ const dir = env.CLAUDE_CONFIG_DIR?.trim() || path18.join(d.home, ".claude");
4560
+ const settings = await readJson(path18.join(dir, "settings.json"));
4561
+ if (settings && (typeof settings.apiKeyHelper === "string" || isObject(settings.env) && keys.some((k) => typeof settings.env[k] === "string"))) return true;
4562
+ const claude = await which("claude", env.PATH);
4563
+ if (claude) {
4564
+ const r = await d.probe(claude, ["auth", "status", "--json"], env);
4565
+ try {
4566
+ const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
4567
+ if (isObject(v) && typeof v.loggedIn === "boolean") return v.loggedIn;
4568
+ } catch {
4569
+ }
4570
+ }
4571
+ if (await fileExists(path18.join(dir, ".credentials.json"))) return true;
4572
+ if (d.platform === "darwin") return null;
4573
+ return false;
4574
+ }
4575
+ async function codexAuth(env, d) {
4576
+ const home = env.CODEX_HOME?.trim() || path18.join(d.home, ".codex");
4577
+ let cfg = {};
4578
+ try {
4579
+ const t = await readText(path18.join(home, "config.toml"));
4580
+ if (t !== null) cfg = parseToml2(t);
4581
+ } catch {
4582
+ }
4583
+ const name = typeof cfg.model_provider === "string" ? cfg.model_provider : "openai";
4584
+ const providers = isObject(cfg.model_providers) ? cfg.model_providers : {};
4585
+ const provider = isObject(providers[name]) ? providers[name] : null;
4586
+ if (name !== "openai" && provider && provider.requires_openai_auth !== true) {
4587
+ const key = typeof provider.env_key === "string" ? provider.env_key : null;
4588
+ if (!key || set(env, key)) return { ok: true };
4589
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) return { ok: false, detail: `Codex's model provider "${name.slice(0, 64)}" has an env_key that is not a variable name`, fix: `set env_key in ${path18.join(home, "config.toml")} to the NAME of the variable that holds the key` };
4590
+ return { ok: false, detail: `Codex uses the model provider "${name}", which reads ${key}, and ${key} is not set where the connector runs`, fix: `set ${key} for this agent: add it to the agent's env in agents.toml, or export it before starting the connector` };
4591
+ }
4592
+ if (set(env, "OPENAI_API_KEY")) return { ok: true };
4593
+ const codex = await which("codex", env.PATH);
4594
+ if (codex) {
4595
+ const r = await d.probe(codex, ["login", "status"], env);
4596
+ if (r.code === 0 && !/not logged in/i.test(r.out)) return { ok: true };
4597
+ if (/not logged in/i.test(r.out)) return { ok: false, detail: "Codex is not signed in on this machine", fix: LOGIN_FIX.codex };
4598
+ }
4599
+ if (await fileExists(path18.join(home, "auth.json"))) return { ok: true };
4600
+ if (codex || typeof cfg.cli_auth_credentials_store === "string" && cfg.cli_auth_credentials_store !== "file") return null;
4601
+ return { ok: false, detail: "Codex is not signed in on this machine", fix: "npm install -g @openai/codex, then: codex login" };
4602
+ }
4603
+ async function checkAll(input, d) {
4604
+ const ok = [];
4605
+ const problems = [...input.before ?? []];
4606
+ const add = (p) => problems.push(p);
4607
+ const major = Number(d.nodeVersion.split(".")[0]);
4608
+ if (!Number.isFinite(major) || major < NODE_MIN_MAJOR) {
4609
+ add({ code: "node_too_old", severity: "fail", detail: `Node.js ${d.nodeVersion} is older than ${NODE_MIN_MAJOR}`, fix: `install Node.js ${NODE_MIN_MAJOR} or newer (https://nodejs.org), then: npm install -g @primusclaw/connector` });
4610
+ } else ok.push(`Node.js ${d.nodeVersion}`);
4611
+ const servicePath = input.by === "doctor" ? await installedServicePath(input.dirs) : null;
4612
+ const same = (x, y) => x.length === y.length && x.every((v, i) => v === y[i]);
4613
+ const checked = input.agents.filter((a) => !(a.attach && input.agents.some((b) => !b.attach && b.command === a.command && same(b.args, a.args) && same(b.roots, a.roots))));
4614
+ const perAgent = await Promise.all(checked.map(async (a) => {
4615
+ const env = agentEnv(d.env, a);
4616
+ const family = familyOf(a);
4617
+ const out = { ok: [], problems: [] };
4618
+ const found = await which(a.command, env.PATH);
4619
+ if (!found) {
4620
+ out.problems.push({ code: "agent_cli_missing", severity: "fail", agent: a.id, detail: `${a.command} (the program agent ${a.id} runs) is not on PATH`, fix: installFix(a.command, family) });
4621
+ } else {
4622
+ out.ok.push(`${a.id}: ${a.command} is ${found}`);
4623
+ if (servicePath !== null && a.env.PATH === void 0 && !await which(a.command, servicePath)) {
4624
+ out.problems.push({ code: "service_path_missing", severity: "fail", agent: a.id, detail: `the installed service's PATH does not find ${a.command}, so the service cannot start agent ${a.id}`, fix: "claw-connector service install (run it again from this shell: it carries this shell's PATH into the service)" });
4625
+ }
4626
+ }
4627
+ if (family === "claude") {
4628
+ const signed = await claudeSignedIn(env, d);
4629
+ if (signed === false) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: "Claude Code is not signed in on this machine", fix: LOGIN_FIX.claude });
4630
+ else if (signed) out.ok.push(`${a.id}: Claude Code is signed in`);
4631
+ } else if (family === "codex") {
4632
+ const auth = await codexAuth(env, d);
4633
+ if (auth && !auth.ok) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: auth.detail, fix: auth.fix });
4634
+ else if (auth?.ok) out.ok.push(`${a.id}: Codex can sign in`);
4635
+ }
4636
+ for (const root of a.roots) {
4637
+ const why = await within2(Promise.resolve(d.writable(root)), d.fsTimeoutMs) ?? "ETIMEDOUT";
4638
+ if (why === "ETIMEDOUT") continue;
4639
+ if (why === null) out.ok.push(`${a.id}: ${root} is writable`);
4640
+ else {
4641
+ out.problems.push(why === "ENOENT" ? { code: "root_missing", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) does not exist`, fix: `mkdir -p ${shq(root)} (or change roots in agents.toml)` } : { code: "root_not_writable", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) is not writable by ${d.user || "this user"}${why === "EROFS" ? " (a read-only file system)" : ""}`, fix: await rootFix(root, why, d) });
4642
+ }
4643
+ }
4644
+ return out;
4645
+ }));
4646
+ for (const r of perAgent) {
4647
+ ok.push(...r.ok);
4648
+ problems.push(...r.problems);
4649
+ }
4650
+ for (const [upper, lower] of [["HTTPS_PROXY", "https_proxy"], ["HTTP_PROXY", "http_proxy"], ["NO_PROXY", "no_proxy"]]) {
4651
+ const u = d.env[upper];
4652
+ const l = d.env[lower];
4653
+ if (u !== void 0 && l !== void 0 && u !== l) {
4654
+ add({ code: "proxy_mismatch", severity: "warn", detail: `${upper} and ${lower} are set to different values${l === "" || u === "" ? " (one of them is empty)" : ""}; agents that read ${lower} (git, curl) take ${l === "" ? "no proxy" : "the other one"}`, fix: `export ${lower}="$${upper}" (or unset the one you do not mean)` });
4655
+ }
4656
+ }
4657
+ for (const name of ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) {
4658
+ const v = d.env[name];
4659
+ if (!v) continue;
4660
+ try {
4661
+ new URL(v);
4662
+ } catch {
4663
+ add({ code: "proxy_invalid", severity: "warn", detail: `${name} is not a URL`, fix: `export ${name}=http://HOST:PORT (or unset it)` });
4664
+ }
4665
+ }
4666
+ await checkSupervisor(input, d, ok, add);
4667
+ if (input.by === "doctor" && input.server) {
4668
+ const r = await d.get(`${input.server.replace(/\/+$/, "")}/health`);
4669
+ const shown = withoutUserinfo(input.server);
4670
+ if ("status" in r && r.status < 500) ok.push(`Claw answers at ${shown}`);
4671
+ else {
4672
+ const why = "status" in r ? `HTTP ${r.status}` : r.error;
4673
+ const proxied = ["HTTPS_PROXY", "https_proxy"].some((k) => set(d.env, k));
4674
+ const untrusted = /certificate|self[- ]signed|UNABLE_TO_GET_ISSUER|CERT_/i.test(why);
4675
+ if (untrusted) {
4676
+ add({
4677
+ code: "claw_tls_untrusted",
4678
+ severity: "fail",
4679
+ detail: `Node.js does not trust the certificate of ${shown} (${why.split(input.server).join(shown)})`,
4680
+ fix: "export NODE_USE_SYSTEM_CA=1 (Node.js then trusts the CAs this machine trusts; or NODE_EXTRA_CA_CERTS=/path/to/ca.pem), then run the connector again; an installed service: claw-connector service install again so it carries it"
4681
+ });
4682
+ } else add({
4683
+ code: "claw_unreachable",
4684
+ severity: "fail",
4685
+ detail: `${shown} does not answer (${why.split(input.server).join(shown)})`,
4686
+ fix: proxied && !set(d.env, "NODE_USE_ENV_PROXY") ? "export NODE_USE_ENV_PROXY=1 (Node.js only uses HTTPS_PROXY when it is set; needs Node 22.21+ or 24+), then run the connector again" : `check that this machine reaches it: curl -sS ${shown.replace(/\/+$/, "")}/health`
4687
+ });
4688
+ }
4689
+ }
4690
+ return { checked_at: new Date(d.now()).toISOString(), by: input.by, ok, problems: dedupe(problems) };
4691
+ }
4692
+ function withoutUserinfo(v) {
4693
+ try {
4694
+ const u = new URL(v);
4695
+ return `${u.protocol}//${u.host}${u.pathname === "/" && !v.endsWith("/") ? "" : u.pathname}`;
4696
+ } catch {
4697
+ return v.replace(/\/\/[^/@]*@/, "//").replace(/[?#].*$/, "");
4698
+ }
4699
+ }
4700
+ function dedupe(ps) {
4701
+ const seen = /* @__PURE__ */ new Set();
4702
+ return ps.filter((p) => {
4703
+ const k = `${p.code}\0${p.detail}`;
4704
+ if (seen.has(k)) return false;
4705
+ seen.add(k);
4706
+ return true;
4707
+ });
4708
+ }
4709
+ async function rootFix(root, why, d) {
4710
+ if (why === "EROFS") return "pick a root on a writable disk in agents.toml";
4711
+ const owner = await within2(fsp.stat(root).then((st) => st.uid, () => null));
4712
+ if (owner !== null && d.uid !== null && owner !== d.uid) return `pick a root you own in agents.toml (${root} belongs to uid ${owner})`;
4713
+ return `chmod u+w ${shq(root)} (or pick a root you own in agents.toml)`;
4714
+ }
4715
+ var shq = (s) => /^[A-Za-z0-9_./~:@%+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
4716
+ async function installedSupervisorOf(dirs) {
4717
+ const v = await readJson(serviceFile(dirs));
4718
+ return v && typeof v.supervisor === "string" ? v.supervisor : null;
4719
+ }
4720
+ async function checkSupervisor(input, d, ok, add) {
4721
+ const sup = await installedSupervisorOf(input.dirs);
4722
+ if (sup === null) {
4723
+ add({ code: "no_service", severity: "warn", detail: "the connector is not installed as a service: nothing starts it again after it dies or the machine restarts", fix: "claw-connector service install" });
4724
+ return;
4725
+ }
4726
+ if (sup === "systemd") {
4727
+ if (d.platform !== "linux") return;
4728
+ const linger = await d.exec("loginctl", ["show-user", d.user, "-p", "Linger", "--value"]);
4729
+ if (linger.code === 0 && linger.out.trim() !== "yes") {
4730
+ add({ code: "linger_off", severity: "warn", detail: "the systemd user service stops when you log out and does not start at boot", fix: `loginctl enable-linger ${d.user}` });
4731
+ } else ok.push("service: systemd --user");
4732
+ return;
4733
+ }
4734
+ if (sup === "script") {
4735
+ const last = await within2(fsp.stat(keepaliveStampFile(input.dirs)).then((st) => st.mtimeMs, () => 0)) ?? 0;
4736
+ if (d.now() - last <= KEEPALIVE_STALE_MS) {
4737
+ ok.push("service: keep-alive script, called recently");
4738
+ return;
4739
+ }
4740
+ const script = keepaliveFile(input.dirs);
4741
+ const tab = await d.crontab();
4742
+ if (tab !== null && tab.split("\n").some((l) => !l.trim().startsWith("#") && l.includes(script))) {
4743
+ ok.push("service: keep-alive script in crontab");
4744
+ return;
4745
+ }
4746
+ add({
4747
+ code: "keepalive_not_scheduled",
4748
+ severity: "warn",
4749
+ detail: `nothing has run ${script} in the last ${KEEPALIVE_STALE_MS / 6e4} minutes: after a restart the connector stays offline`,
4750
+ fix: `add to crontab -e (or the container's startup hook): */5 * * * * sh ${script}`
4751
+ });
4752
+ return;
4753
+ }
4754
+ ok.push(`service: ${sup}`);
4755
+ }
4756
+ async function installedServicePath(dirs, home = os6.homedir(), xdgConfig) {
4757
+ const v = await readJson(serviceFile(dirs));
4758
+ if (!v) return null;
4759
+ if (typeof v.path === "string") return v.path;
4760
+ if (v.supervisor === "script") {
4761
+ const m = (await readText(keepaliveFile(dirs)))?.match(/^export PATH='((?:[^']|'\\'')*)'$/m);
4762
+ return m ? m[1].replace(/'\\''/g, "'") : null;
4763
+ }
4764
+ if (v.supervisor === "systemd") {
4765
+ const unit = await readText(path18.join(xdgConfig?.trim() || path18.join(home, ".config"), "systemd", "user", "claw-connector.service"));
4766
+ const m = unit?.match(/^Environment="PATH=((?:[^"\\]|\\.)*)"$/m);
4767
+ return m ? m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%") : null;
4768
+ }
4769
+ if (v.supervisor === "launchd") {
4770
+ const plist = await readText(path18.join(home, "Library", "LaunchAgents", "com.primusclaw.connector.plist"));
4771
+ const m = plist?.match(/<key>PATH<\/key><string>([^<]*)<\/string>/);
4772
+ return m ? m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&") : null;
4773
+ }
4774
+ return null;
4775
+ }
4776
+ function agentHealth(report, agentId) {
4777
+ const cut = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
4778
+ const mine = report.problems.filter((p) => p.agent === void 0 || p.agent === agentId);
4779
+ mine.sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fail" ? -1 : 1);
4780
+ return {
4781
+ checked_at: report.checked_at,
4782
+ problems: mine.slice(0, MAX_REPORTED2).map((p) => ({ code: p.code, severity: p.severity, scope: p.agent === void 0 ? "machine" : "agent", detail: cut(p.detail, 200), fix: cut(p.fix, 200) }))
4783
+ };
4784
+ }
4785
+ function writeReport(dirs, report) {
4786
+ try {
4787
+ fs15.mkdirSync(dirs.state, { recursive: true, mode: 448 });
4788
+ const file = doctorFile(dirs);
4789
+ const tmp = `${file}.${process.pid}.tmp`;
4790
+ fs15.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n", { mode: 384 });
4791
+ fs15.renameSync(tmp, file);
4792
+ } catch {
4793
+ }
4794
+ }
4795
+ function formatReport(r) {
4796
+ const lines = r.ok.map((l) => `ok ${l}`);
4797
+ for (const p of r.problems) {
4798
+ lines.push(`${p.severity === "fail" ? "FAIL" : "warn"} ${p.agent ? `${p.agent}: ` : ""}${p.detail}`);
4799
+ lines.push(` fix: ${p.fix}`);
4800
+ }
4801
+ const fails = r.problems.filter((p) => p.severity === "fail").length;
4802
+ const warns = r.problems.length - fails;
4803
+ lines.push("");
4804
+ lines.push(r.problems.length === 0 ? "No problems found." : `${fails} problem${fails === 1 ? " that stops" : "s that stop"} sessions, ${warns} warning${warns === 1 ? "" : "s"}.`);
4805
+ return lines;
4806
+ }
4807
+
4808
+ // src/connector.ts
4809
+ function withHealth(base, health, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBytes / 2) {
4810
+ if (!health) return base;
4811
+ const size = (c) => new TextEncoder().encode(JSON.stringify(c)).length;
4812
+ const problems = [...health.problems];
4813
+ for (; ; ) {
4814
+ const caps = { ...base, health: { ...health, problems } };
4815
+ if (size(caps) <= limit) return caps;
4816
+ if (problems.length === 0) return base;
4817
+ problems.pop();
4818
+ }
4819
+ }
4820
+ var RESTART_RECONNECT_GAP_MS = 5e3;
4821
+ var READ_ONLY_HINT = `sessions of this agent start read-only: it answers and plans but changes no file. To let phone sessions write code, set approval = "never" for this agent in agents.toml on that machine and restart the connector`;
4822
+ var DEFAULT_TIMING = {
4823
+ ...DEFAULT_SESSION_TIMING,
4824
+ heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
4825
+ watchdogMs: REMOTE_AGENT_TIMING.watchdogMs,
4826
+ batchMs: 150,
4827
+ batchMax: 20,
4828
+ backoffMinMs: 1e3,
4829
+ backoffMaxMs: 6e4,
4830
+ pausePollMs: 1e3,
4831
+ catalogMs: 12e4,
4832
+ sessionsWatchMs: 1e4,
4833
+ attachCliMs: 8e3,
4834
+ recentWriteMs: 1e4,
4835
+ authFailLimit: 4,
4836
+ authFailWindowMs: 12e4,
4837
+ healthMs: RECHECK_MS,
4838
+ healthMinGapMs: 5e3,
4839
+ limitRetryMs: 10 * 6e4
4840
+ };
4841
+ var CONNECTOR_LIMIT_HINT = "Claw already holds the most connectors one person may have, so it will not register this machine: remove machines you no longer use (in the chat: /agents, or claw-connector logout on that machine). This connector tries again every 10 minutes by itself.";
4842
+ var isConnectorLimit = (e) => e instanceof HttpError && e.status === 409 && e.code === "connector_limit_reached";
4843
+ var CommandLedger = class {
4844
+ inflight = /* @__PURE__ */ new Set();
4845
+ inflightIds = /* @__PURE__ */ new Set();
4846
+ ids;
4847
+ highest = 0;
4848
+ floor;
4849
+ constructor(cursor = 0, handled = []) {
4850
+ this.floor = cursor;
4851
+ this.highest = cursor;
4852
+ this.ids = [...handled];
3998
4853
  }
3999
4854
  /** Whether this command was already carried out (or is being carried out now). */
4000
4855
  seen(seq, cmdId) {
@@ -4085,6 +4940,11 @@ var Connector = class {
4085
4940
  lastFrame = "";
4086
4941
  wakeSender = null;
4087
4942
  wakeHeartbeat = null;
4943
+ /** The last health report (null until the first check finishes, or when none is configured). */
4944
+ healthReport = null;
4945
+ wakeHealth = null;
4946
+ healthAsked = false;
4947
+ lastHealthAsk = 0;
4088
4948
  streamCtl = null;
4089
4949
  ctl = new AbortController();
4090
4950
  /** Every event leaves through here: scrubbed of credential shapes, numbered, written to disk, then posted. */
@@ -4106,16 +4966,19 @@ var Connector = class {
4106
4966
  }
4107
4967
  async descriptors() {
4108
4968
  return Promise.all(this.d.config.agents.map(async (a) => {
4969
+ const readOnly = a.readOnlyModes.includes(a.askMode);
4109
4970
  const support = this.attach.get(a.id);
4110
- const base = {
4971
+ const base = withHealth({
4111
4972
  ask_mode: a.askMode,
4112
4973
  modes: a.safeModes,
4113
4974
  roots: a.roots.slice(0, 8),
4114
4975
  protocol: 1,
4115
4976
  unattended: a.unattended,
4116
4977
  unattended_allowed: a.allowUnsafeModes,
4117
- ...support ? { watch_live: true } : {}
4118
- };
4978
+ ...support ? { watch_live: true } : {},
4979
+ ...a.readOnlyModes.length ? { read_only_modes: a.readOnlyModes } : {},
4980
+ ...readOnly ? { edits: false, edits_hint: READ_ONLY_HINT } : {}
4981
+ }, this.healthReport ? agentHealth(this.healthReport, a.id) : null);
4119
4982
  const capabilities = support ? fitResumable(base, await support.resumable()) : base;
4120
4983
  return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
4121
4984
  }));
@@ -4123,7 +4986,7 @@ var Connector = class {
4123
4986
  // --- identity -------------------------------------------------------------------
4124
4987
  readIdentity() {
4125
4988
  try {
4126
- const raw = JSON.parse(fs15.readFileSync(identityFile(this.d.dirs), "utf8"));
4989
+ const raw = JSON.parse(fs16.readFileSync(identityFile(this.d.dirs), "utf8"));
4127
4990
  if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
4128
4991
  return { server: raw.server, connector_id: raw.connector_id, cursor: typeof raw.cursor === "number" ? raw.cursor : 0, handled: Array.isArray(raw.handled) ? raw.handled.filter((x) => typeof x === "string") : [] };
4129
4992
  }
@@ -4134,9 +4997,9 @@ var Connector = class {
4134
4997
  writeIdentity() {
4135
4998
  const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
4136
4999
  const file = identityFile(this.d.dirs);
4137
- fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
4138
- fs15.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
4139
- fs15.renameSync(`${file}.tmp`, file);
5000
+ fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
5001
+ fs16.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
5002
+ fs16.renameSync(`${file}.tmp`, file);
4140
5003
  }
4141
5004
  /** Use the connector id this machine already has, or register a new one. */
4142
5005
  async identify(forceNew = false) {
@@ -4178,6 +5041,7 @@ var Connector = class {
4178
5041
  * fix and ends the run with its message.
4179
5042
  */
4180
5043
  async startup() {
5044
+ let atLimit = false;
4181
5045
  for (let attempt = 0; !this.ctl.signal.aborted; attempt++) {
4182
5046
  try {
4183
5047
  await this.identify();
@@ -4188,6 +5052,17 @@ var Connector = class {
4188
5052
  await this.onRevoked(e);
4189
5053
  return "revoked";
4190
5054
  }
5055
+ if (isConnectorLimit(e)) {
5056
+ if (!atLimit) {
5057
+ atLimit = true;
5058
+ this.d.log.error(CONNECTOR_LIMIT_HINT, { error: errMessage(e) });
5059
+ this.d.audit.record("connector_limit_reached", {});
5060
+ recordDisconnect(this.d.dirs, { reason: "connector_limit_reached", detail: CONNECTOR_LIMIT_HINT });
5061
+ }
5062
+ await sleep(this.timing.limitRetryMs, this.ctl.signal);
5063
+ while (!this.ctl.signal.aborted && fs16.existsSync(pauseFile(this.d.dirs))) await sleep(this.timing.pausePollMs, this.ctl.signal);
5064
+ continue;
5065
+ }
4191
5066
  if (!isTransient(e)) throw e;
4192
5067
  this.d.log.warn("cannot reach Claw yet; will retry", { error: errMessage(e), attempt });
4193
5068
  await sleep(backoffMs(attempt, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
@@ -4243,7 +5118,7 @@ var Connector = class {
4243
5118
  }
4244
5119
  async pauseWatcher() {
4245
5120
  while (!this.ctl.signal.aborted) {
4246
- const want = fs15.existsSync(pauseFile(this.d.dirs));
5121
+ const want = fs16.existsSync(pauseFile(this.d.dirs));
4247
5122
  if (want && !this.paused) {
4248
5123
  this.paused = true;
4249
5124
  this.d.audit.record("paused");
@@ -4258,6 +5133,63 @@ var Connector = class {
4258
5133
  await sleep(this.timing.pausePollMs, this.ctl.signal);
4259
5134
  }
4260
5135
  }
5136
+ /**
5137
+ * Check the machine at start, every `healthMs`, and when asked (`recheckHealth`: `claw-connector doctor` signals a running
5138
+ * connector after it ran). A changed result is sent with an early heartbeat. A check that throws keeps the last report, and
5139
+ * a stop never waits for one: it must never take the connection down. Nothing here touches the file system itself (the
5140
+ * checks run in a child process: see commands.ts `runHealth`), so a stalled disk cannot reach this process.
5141
+ */
5142
+ async healthWatcher() {
5143
+ const check = this.d.health;
5144
+ if (!check) return;
5145
+ while (!this.ctl.signal.aborted) {
5146
+ this.healthAsked = false;
5147
+ try {
5148
+ const before = this.healthReport ? JSON.stringify(this.healthReport.problems) : null;
5149
+ let onAbort = () => {
5150
+ };
5151
+ const stopped = new Promise((resolve) => {
5152
+ onAbort = () => resolve(null);
5153
+ if (this.ctl.signal.aborted) resolve(null);
5154
+ else this.ctl.signal.addEventListener("abort", onAbort, { once: true });
5155
+ });
5156
+ let report;
5157
+ try {
5158
+ report = await Promise.race([check(this.ctl.signal), stopped]);
5159
+ } finally {
5160
+ this.ctl.signal.removeEventListener("abort", onAbort);
5161
+ }
5162
+ if (report === null) break;
5163
+ this.healthReport = report;
5164
+ if (JSON.stringify(this.healthReport.problems) !== before) {
5165
+ this.d.log.info("health", { problems: this.healthReport.problems.map((p) => `${p.agent ?? "machine"}:${p.code}`) });
5166
+ this.wakeHeartbeat?.();
5167
+ }
5168
+ } catch (e) {
5169
+ this.d.log.warn("health check failed", { error: errMessage(e) });
5170
+ }
5171
+ if (this.healthAsked) continue;
5172
+ await new Promise((resolve) => {
5173
+ const done = () => {
5174
+ clearTimeout(t);
5175
+ this.wakeHealth = null;
5176
+ this.ctl.signal.removeEventListener("abort", done);
5177
+ resolve();
5178
+ };
5179
+ const t = setTimeout(done, this.timing.healthMs);
5180
+ this.wakeHealth = done;
5181
+ this.ctl.signal.addEventListener("abort", done, { once: true });
5182
+ });
5183
+ }
5184
+ }
5185
+ /** Check the machine again now (a `doctor` run asks; at most once per `healthMinGapMs`, so a burst of asks is one check). */
5186
+ recheckHealth() {
5187
+ const now = Date.now();
5188
+ if (now - this.lastHealthAsk < this.timing.healthMinGapMs) return;
5189
+ this.lastHealthAsk = now;
5190
+ this.healthAsked = true;
5191
+ this.wakeHealth?.();
5192
+ }
4261
5193
  // --- the loops -----------------------------------------------------------------------
4262
5194
  /** Post events in batches, one request at a time and in order, until everything is acknowledged. */
4263
5195
  async sender() {
@@ -4438,51 +5370,57 @@ var Connector = class {
4438
5370
  }
4439
5371
  /** Run until stopped or revoked. Always leaves no adapter process behind. */
4440
5372
  async run() {
4441
- fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
5373
+ fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
4442
5374
  await this.registry.killAll();
4443
5375
  for (const p of this.store.takePending()) {
4444
5376
  this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
4445
5377
  this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
4446
5378
  }
5379
+ let downForMs = null;
5380
+ try {
5381
+ downForMs = Math.max(0, Date.now() - fs16.statSync(identityFile(this.d.dirs)).mtimeMs);
5382
+ } catch {
5383
+ }
4447
5384
  const pidf = pidFile(this.d.dirs);
4448
5385
  let leftover = false;
4449
5386
  try {
4450
- fs15.readFileSync(pidf);
5387
+ fs16.readFileSync(pidf);
4451
5388
  leftover = true;
4452
5389
  } catch {
4453
5390
  }
4454
5391
  if (leftover) {
4455
5392
  let last;
4456
5393
  try {
4457
- last = fs15.statSync(identityFile(this.d.dirs)).mtime.toISOString();
5394
+ last = fs16.statSync(identityFile(this.d.dirs)).mtime.toISOString();
4458
5395
  } catch {
4459
5396
  }
4460
5397
  recordDisconnect(this.d.dirs, { reason: "process_died", detail: `the previous run ended without shutting down (killed, crashed, or the machine restarted)${last ? `; last heartbeat ${last}` : ""}`, ...last ? { at: last } : {} });
4461
5398
  }
4462
- fs15.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
4463
- this.paused = fs15.existsSync(pauseFile(this.d.dirs));
5399
+ fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
5400
+ this.paused = fs16.existsSync(pauseFile(this.d.dirs));
4464
5401
  let result = "stopped";
4465
5402
  let failure = "";
4466
5403
  try {
4467
5404
  const up = await this.startup();
4468
5405
  if (up !== "ok") return result = up;
4469
5406
  await this.manager.restoreReadOnly();
5407
+ this.manager.recover({ downForMs, mayContinue: !this.paused && !fs16.existsSync(pauseFile(this.d.dirs)) });
4470
5408
  this.d.audit.record("connector_online", {
4471
5409
  connector_id: this.cid,
4472
5410
  agents: this.d.config.agents.map((a) => a.id),
4473
5411
  paused: this.paused,
4474
5412
  unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
4475
5413
  });
4476
- await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher()]);
5414
+ await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher(), this.healthWatcher()]);
4477
5415
  result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
4478
5416
  } catch (e) {
4479
5417
  failure = errMessage(e);
4480
5418
  throw e;
4481
5419
  } finally {
4482
- await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
5420
+ await this.manager.killAll(this.revoked ? "revoked" : "stopped", !this.revoked && !this.authFailed).catch(() => void 0);
4483
5421
  const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
4484
5422
  recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
4485
- fs15.rmSync(pidf, { force: true });
5423
+ fs16.rmSync(pidf, { force: true });
4486
5424
  }
4487
5425
  return result;
4488
5426
  }
@@ -4498,12 +5436,13 @@ var Connector = class {
4498
5436
  };
4499
5437
 
4500
5438
  // src/service.ts
4501
- import { spawn as spawn3, spawnSync } from "node:child_process";
4502
- import fs16 from "node:fs";
4503
- import os5 from "node:os";
4504
- import path17 from "node:path";
5439
+ import { spawn as spawn4, spawnSync } from "node:child_process";
5440
+ import fs17 from "node:fs";
5441
+ import os7 from "node:os";
5442
+ import path19 from "node:path";
4505
5443
  import { fileURLToPath } from "node:url";
4506
5444
  import { parseArgs } from "node:util";
5445
+ import { parse as parseToml3 } from "smol-toml";
4507
5446
  var SUPERVISORS = ["systemd", "launchd", "script"];
4508
5447
  var UNIT = "claw-connector.service";
4509
5448
  var LABEL = "com.primusclaw.connector";
@@ -4512,11 +5451,14 @@ var NEEDS_PERSON = 78;
4512
5451
  var CARRIED_ENV = [
4513
5452
  "PATH",
4514
5453
  "CLAW_CONNECTOR_HOME",
5454
+ "CLAW_CONNECTOR_CONFIG_DIR",
4515
5455
  "XDG_CONFIG_HOME",
4516
5456
  "XDG_STATE_HOME",
4517
5457
  "CLAUDE_CONFIG_DIR",
4518
5458
  "CODEX_HOME",
4519
5459
  "NODE_EXTRA_CA_CERTS",
5460
+ "NODE_USE_SYSTEM_CA",
5461
+ "NODE_USE_ENV_PROXY",
4520
5462
  "SSL_CERT_FILE",
4521
5463
  "HTTPS_PROXY",
4522
5464
  "https_proxy",
@@ -4524,27 +5466,97 @@ var CARRIED_ENV = [
4524
5466
  "http_proxy",
4525
5467
  "NO_PROXY",
4526
5468
  "no_proxy",
4527
- "LANG"
5469
+ "LANG",
5470
+ "IS_SANDBOX",
5471
+ "npm_config_registry",
5472
+ "NPM_CONFIG_REGISTRY",
5473
+ "npm_config_proxy",
5474
+ "npm_config_https_proxy",
5475
+ "AWS_REGION",
5476
+ "AWS_PROFILE",
5477
+ "CLOUD_ML_REGION",
5478
+ "GOOGLE_APPLICATION_CREDENTIALS"
5479
+ ];
5480
+ var AGENT_SETTING = /^(?:ANTHROPIC|CLAUDE_CODE|OPENAI|CODEX|CURSOR)_/;
5481
+ var TERMINAL_SESSION = /^(?:CLAUDE_CODE_(?:ENTRYPOINT|SSE_PORT|SESSION|CHILD_SESSION|MESSAGING|EXECPATH|IDE|GIT_BASH)|CODEX_(?:SANDBOX|THREAD|SESSION|MANAGED|INTERNAL|CI))/;
5482
+ var SECRET_NAME = /KEY|TOKEN(?!S)|SECRET|PASSWORD|CREDENTIAL/i;
5483
+ var AGENT_SECRETS = [
5484
+ "ANTHROPIC_API_KEY",
5485
+ "ANTHROPIC_AUTH_TOKEN",
5486
+ "CLAUDE_CODE_OAUTH_TOKEN",
5487
+ "ANTHROPIC_CUSTOM_HEADERS",
5488
+ "OPENAI_API_KEY",
5489
+ "CODEX_API_KEY",
5490
+ "CURSOR_API_KEY",
5491
+ "AWS_ACCESS_KEY_ID",
5492
+ "AWS_SECRET_ACCESS_KEY",
5493
+ "AWS_SESSION_TOKEN"
4528
5494
  ];
4529
- var SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
5495
+ var NEVER_CARRIED = /* @__PURE__ */ new Set(["CLAW_CONNECTOR_KEY"]);
5496
+ function accountName(env, uid, userInfo = os7.userInfo) {
5497
+ if (env.USER) return env.USER;
5498
+ if (env.LOGNAME) return env.LOGNAME;
5499
+ try {
5500
+ return userInfo().username;
5501
+ } catch {
5502
+ return String(uid);
5503
+ }
5504
+ }
5505
+ function cronDaemonRunning(proc = "/proc") {
5506
+ let self;
5507
+ let pids;
5508
+ try {
5509
+ self = fs17.readlinkSync(`${proc}/self/ns/mnt`);
5510
+ pids = fs17.readdirSync(proc).filter((n) => /^\d+$/.test(n));
5511
+ } catch {
5512
+ return false;
5513
+ }
5514
+ for (const pid of pids) {
5515
+ try {
5516
+ if (!/^(?:cron|crond|busybox)$/.test(fs17.readFileSync(`${proc}/${pid}/comm`, "utf8").trim())) continue;
5517
+ if (!/cron/.test(fs17.readFileSync(`${proc}/${pid}/cmdline`, "utf8"))) continue;
5518
+ if (fs17.readlinkSync(`${proc}/${pid}/ns/mnt`) === self) return true;
5519
+ } catch {
5520
+ }
5521
+ }
5522
+ return false;
5523
+ }
5524
+ function systemCrontab() {
5525
+ return {
5526
+ readCrontab: () => {
5527
+ if (!cronDaemonRunning()) return null;
5528
+ const r = spawnSync("crontab", ["-l"], { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "pipe"] });
5529
+ if (r.error) return null;
5530
+ return r.status === 0 ? r.stdout : /no crontab/i.test(r.stderr ?? "") ? "" : null;
5531
+ },
5532
+ writeCrontab: (text) => {
5533
+ const r = spawnSync("crontab", ["-"], { input: text, encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
5534
+ return !r.error && r.status === 0;
5535
+ }
5536
+ };
5537
+ }
4530
5538
  function deps(ctx) {
4531
5539
  const d = ctx.service ?? {};
5540
+ const uid = d.uid ?? (process.getuid?.() ?? 0);
5541
+ const cron = d.readCrontab && d.writeCrontab ? null : systemCrontab();
4532
5542
  return {
4533
5543
  platform: d.platform ?? process.platform,
4534
- home: d.home ?? os5.homedir(),
4535
- uid: d.uid ?? (process.getuid?.() ?? 0),
4536
- user: d.user ?? (ctx.env.USER || os5.userInfo().username),
5544
+ home: d.home ?? os7.homedir(),
5545
+ uid,
5546
+ user: d.user ?? accountName(ctx.env, uid),
5547
+ readCrontab: d.readCrontab ?? cron.readCrontab,
5548
+ writeCrontab: d.writeCrontab ?? cron.writeCrontab,
4537
5549
  exec: d.exec ?? ((cmd, args) => {
4538
5550
  const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
4539
5551
  return { code: r.error ? null : r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
4540
5552
  }),
4541
5553
  node: d.node ?? ctx.node ?? process.execPath,
4542
5554
  cli: d.cli ?? fileURLToPath(import.meta.url),
4543
- systemdBooted: d.systemdBooted ?? (() => fs16.existsSync("/run/systemd/system"))
5555
+ systemdBooted: d.systemdBooted ?? (() => fs17.existsSync("/run/systemd/system"))
4544
5556
  };
4545
5557
  }
4546
- var systemdUnitPath = (ctx, d) => path17.join(ctx.env.XDG_CONFIG_HOME?.trim() || path17.join(d.home, ".config"), "systemd", "user", UNIT);
4547
- var launchdPlistPath = (d) => path17.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
5558
+ var systemdUnitPath = (ctx, d) => path19.join(ctx.env.XDG_CONFIG_HOME?.trim() || path19.join(d.home, ".config"), "systemd", "user", UNIT);
5559
+ var launchdPlistPath = (d) => path19.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
4548
5560
  function detectSupervisor(d) {
4549
5561
  if (d.platform === "darwin") return "launchd";
4550
5562
  if (!d.systemdBooted()) return "script";
@@ -4554,29 +5566,71 @@ function detectSupervisor(d) {
4554
5566
  var hasUserinfo = (v) => {
4555
5567
  try {
4556
5568
  const u = new URL(v);
4557
- return u.username !== "" || u.password !== "";
5569
+ if (u.username !== "" || u.password !== "") return true;
4558
5570
  } catch {
4559
- return /\/\/[^/]*@/.test(v);
4560
5571
  }
5572
+ return /\/\/[^/\s]*@/.test(v);
4561
5573
  };
4562
- function carriedEnv(env, extra = []) {
5574
+ function codexEnvKeys(env, home) {
5575
+ const dir = env.CODEX_HOME?.trim() || path19.join(home, ".codex");
5576
+ try {
5577
+ const doc = parseToml3(fs17.readFileSync(path19.join(dir, "config.toml"), "utf8"));
5578
+ const providers = isObject(doc.model_providers) ? doc.model_providers : {};
5579
+ return Object.values(providers).flatMap((p) => isObject(p) && typeof p.env_key === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(p.env_key) ? [p.env_key] : []);
5580
+ } catch {
5581
+ return [];
5582
+ }
5583
+ }
5584
+ function carriedEnv(env, extra = [], secretNames = AGENT_SECRETS) {
4563
5585
  const vars = [];
5586
+ const secrets = [];
4564
5587
  const refused = [];
4565
5588
  const withheld = [];
4566
- for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...extra])) {
4567
- if (!CARRIED_ENV.includes(name) && SECRET_NAME.test(name)) {
4568
- refused.push(name);
5589
+ const settings = Object.keys(env).filter((k) => AGENT_SETTING.test(k) && !TERMINAL_SESSION.test(k)).sort();
5590
+ for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...settings, ...secretNames, ...extra])) {
5591
+ if (NEVER_CARRIED.has(name)) {
5592
+ if (extra.includes(name)) refused.push(name);
4569
5593
  continue;
4570
5594
  }
4571
5595
  const v = env[name];
4572
5596
  if (v === void 0 || v === "") continue;
4573
- if (/proxy$/i.test(name) && hasUserinfo(v) && !extra.includes(name)) {
5597
+ const proxy = /proxy$/i.test(name);
5598
+ const secret = !CARRIED_ENV.includes(name) && (SECRET_NAME.test(name) || secretNames.includes(name)) || !proxy && hasUserinfo(v);
5599
+ if (secret) {
5600
+ secrets.push([name, v]);
5601
+ continue;
5602
+ }
5603
+ if (proxy && hasUserinfo(v) && !extra.includes(name)) {
5604
+ withheld.push(name);
5605
+ continue;
5606
+ }
5607
+ if (/[\r\n]/.test(v)) {
4574
5608
  withheld.push(name);
4575
5609
  continue;
4576
5610
  }
4577
5611
  vars.push([name, v]);
4578
5612
  }
4579
- return { vars, refused, withheld };
5613
+ return { vars, secrets, refused, withheld };
5614
+ }
5615
+ function applyServiceEnv(agents, kept, env) {
5616
+ const used = /* @__PURE__ */ new Set();
5617
+ for (const a of agents) {
5618
+ for (const [k, v] of Object.entries(kept)) {
5619
+ if (env[k] !== void 0 || Object.hasOwn(a.env, k)) continue;
5620
+ a.env = { ...a.env, [k]: v };
5621
+ used.add(k);
5622
+ }
5623
+ }
5624
+ return [...used].sort();
5625
+ }
5626
+ function loadServiceEnv(file) {
5627
+ try {
5628
+ const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
5629
+ if (!isObject(raw)) return {};
5630
+ return Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === "string" && !NEVER_CARRIED.has(e[0])));
5631
+ } catch {
5632
+ return {};
5633
+ }
4580
5634
  }
4581
5635
  var sdEnv = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
4582
5636
  var sdQuote = (s) => sdEnv(s).replace(/\$/g, "$$$$");
@@ -4645,7 +5699,7 @@ exec ${sh(d.node)} ${sh(d.cli)} service ensure
4645
5699
  }
4646
5700
  function readPidRec(file) {
4647
5701
  try {
4648
- const raw = JSON.parse(fs16.readFileSync(file, "utf8"));
5702
+ const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
4649
5703
  if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
4650
5704
  } catch {
4651
5705
  }
@@ -4665,8 +5719,8 @@ async function supervise(ctx, o = {}) {
4665
5719
  ctx.err(`a supervisor is already running (pid ${other})`);
4666
5720
  return 1;
4667
5721
  }
4668
- fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
4669
- fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
5722
+ fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
5723
+ fs17.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
4670
5724
  let stopping2 = false;
4671
5725
  let child = null;
4672
5726
  const wake = new AbortController();
@@ -4681,7 +5735,7 @@ async function supervise(ctx, o = {}) {
4681
5735
  o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
4682
5736
  try {
4683
5737
  for (let round = 0; !stopping2; round++) {
4684
- child = spawn3(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
5738
+ child = spawn4(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
4685
5739
  const c = child;
4686
5740
  const { code, signal } = await new Promise((resolve) => {
4687
5741
  c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
@@ -4710,13 +5764,14 @@ async function supervise(ctx, o = {}) {
4710
5764
  } finally {
4711
5765
  for (const [s, h] of handlers) process.off(s, h);
4712
5766
  const rec = readPidRec(pidf);
4713
- if (rec?.pid === process.pid) fs16.rmSync(pidf, { force: true });
5767
+ if (rec?.pid === process.pid) fs17.rmSync(pidf, { force: true });
4714
5768
  }
4715
5769
  }
4716
5770
  async function cmdSupervise(_args, ctx) {
4717
5771
  return supervise(ctx);
4718
5772
  }
4719
- async function ensure(ctx, d, h) {
5773
+ async function ensure(ctx, d, h, o = {}) {
5774
+ if (!o.byInstall) stampKeepalive(ctx);
4720
5775
  const sup = supervisorPid(ctx);
4721
5776
  if (sup !== null) {
4722
5777
  ctx.out(`supervisor running (pid ${sup})`);
@@ -4732,17 +5787,24 @@ async function ensure(ctx, d, h) {
4732
5787
  ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
4733
5788
  return 0;
4734
5789
  }
4735
- fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
4736
- const log = fs16.openSync(runLogFile(ctx.dirs), "a", 384);
5790
+ fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
5791
+ const log = fs17.openSync(runLogFile(ctx.dirs), "a", 384);
4737
5792
  try {
4738
- const p = spawn3(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
5793
+ const p = spawn4(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
4739
5794
  p.unref();
4740
5795
  ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
4741
5796
  } finally {
4742
- fs16.closeSync(log);
5797
+ fs17.closeSync(log);
4743
5798
  }
4744
5799
  return 0;
4745
5800
  }
5801
+ function stampKeepalive(ctx) {
5802
+ try {
5803
+ fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
5804
+ fs17.writeFileSync(keepaliveStampFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
5805
+ } catch {
5806
+ }
5807
+ }
4746
5808
  async function cmdService(args, ctx, h) {
4747
5809
  const [sub, ...rest] = args;
4748
5810
  const usage = "usage: claw-connector service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]";
@@ -4769,7 +5831,7 @@ async function cmdService(args, ctx, h) {
4769
5831
  }
4770
5832
  function installedSupervisor(ctx) {
4771
5833
  try {
4772
- const raw = JSON.parse(fs16.readFileSync(serviceFile(ctx.dirs), "utf8"));
5834
+ const raw = JSON.parse(fs17.readFileSync(serviceFile(ctx.dirs), "utf8"));
4773
5835
  if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
4774
5836
  } catch {
4775
5837
  }
@@ -4789,11 +5851,67 @@ function startAfterLogin(ctx, h) {
4789
5851
  }
4790
5852
  ctx.out(r.code === 0 ? `started the ${sup} service again` : `could not start the ${sup} service (${r.out || `exit ${r.code}`}); run: claw-connector service install`);
4791
5853
  }
5854
+ var UnsafeDirError = class extends Error {
5855
+ };
5856
+ function writeSecretFile(file, body) {
5857
+ const dir = path19.dirname(file);
5858
+ fs17.mkdirSync(dir, { recursive: true, mode: 448 });
5859
+ let dfd;
5860
+ try {
5861
+ dfd = fs17.openSync(dir, fs17.constants.O_RDONLY | (fs17.constants.O_DIRECTORY ?? 0) | (fs17.constants.O_NOFOLLOW ?? 0));
5862
+ } catch (e) {
5863
+ throw new UnsafeDirError(`${dir} cannot be opened as a directory of its own (${e.code ?? errMessage(e)}; a link is not followed): the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
5864
+ }
5865
+ try {
5866
+ const st = fs17.fstatSync(dfd);
5867
+ const uid = process.getuid?.();
5868
+ if (!st.isDirectory() || uid !== void 0 && st.uid !== uid) {
5869
+ throw new UnsafeDirError(`${dir} is ${!st.isDirectory() ? "not a directory" : "owned by another account"}: the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
5870
+ }
5871
+ if ((st.mode & 63) !== 0) fs17.fchmodSync(dfd, 448);
5872
+ let anchored;
5873
+ if (fs17.existsSync(`/proc/self/fd/${dfd}`)) anchored = `/proc/self/fd/${dfd}`;
5874
+ else {
5875
+ const real = fs17.realpathSync(dir);
5876
+ const rs = fs17.statSync(real);
5877
+ if (rs.dev !== st.dev || rs.ino !== st.ino) throw new UnsafeDirError(`${dir} changed while it was being checked; the agents' sign-in variables are not written there`);
5878
+ const weak = renamableAncestor(real);
5879
+ if (weak) throw new UnsafeDirError(`${weak} can be changed by other accounts, so ${dir} could be swapped while the agents' sign-in variables are written; they are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
5880
+ anchored = real;
5881
+ }
5882
+ const tmp = path19.join(anchored, `${path19.basename(file)}.${process.pid}.${Date.now()}.tmp`);
5883
+ const fd = fs17.openSync(tmp, fs17.constants.O_WRONLY | fs17.constants.O_CREAT | fs17.constants.O_EXCL | (fs17.constants.O_NOFOLLOW ?? 0), 384);
5884
+ try {
5885
+ fs17.writeSync(fd, body);
5886
+ fs17.fchmodSync(fd, 384);
5887
+ } finally {
5888
+ fs17.closeSync(fd);
5889
+ }
5890
+ fs17.renameSync(tmp, path19.join(anchored, path19.basename(file)));
5891
+ } finally {
5892
+ fs17.closeSync(dfd);
5893
+ }
5894
+ }
5895
+ function renamableAncestor(dir, uid = process.getuid?.()) {
5896
+ for (let p = path19.dirname(path19.resolve(dir)); ; p = path19.dirname(p)) {
5897
+ let st;
5898
+ try {
5899
+ st = fs17.lstatSync(p);
5900
+ } catch {
5901
+ return p;
5902
+ }
5903
+ if (!st.isDirectory()) return p;
5904
+ const owner = st.uid === 0 || uid === void 0 || st.uid === uid;
5905
+ const sticky = (st.mode & 512) !== 0;
5906
+ if (!owner || (st.mode & 18) !== 0 && !sticky) return p;
5907
+ if (p === path19.dirname(p)) return null;
5908
+ }
5909
+ }
4792
5910
  function writeFile600(file, body, mode = 384) {
4793
- fs16.mkdirSync(path17.dirname(file), { recursive: true, mode: 448 });
5911
+ fs17.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
4794
5912
  const tmp = `${file}.${process.pid}.tmp`;
4795
- fs16.writeFileSync(tmp, body, { mode });
4796
- fs16.renameSync(tmp, file);
5913
+ fs17.writeFileSync(tmp, body, { mode });
5914
+ fs17.renameSync(tmp, file);
4797
5915
  }
4798
5916
  async function install(ctx, d, h, sup, extra) {
4799
5917
  const problem = h.preflight(ctx);
@@ -4801,13 +5919,22 @@ async function install(ctx, d, h, sup, extra) {
4801
5919
  ctx.err(`cannot install the service yet: ${problem}`);
4802
5920
  return NEEDS_PERSON;
4803
5921
  }
4804
- if (!fs16.existsSync(d.cli)) {
5922
+ if (!fs17.existsSync(d.cli)) {
4805
5923
  ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
4806
5924
  return 1;
4807
5925
  }
4808
- const { vars, refused, withheld } = carriedEnv(ctx.env, extra);
4809
- for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it looks like a credential)`);
4810
- for (const w of withheld) ctx.err(`${w}: not carried into the service because it holds a password; if the connector needs it, install again with --env ${w} (it is then written into the 0600 unit file)`);
5926
+ const { vars, secrets, refused, withheld } = carriedEnv(ctx.env, extra, [...AGENT_SECRETS, ...codexEnvKeys(ctx.env, d.home)]);
5927
+ for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it is the connector's own key, kept in credentials.json)`);
5928
+ for (const w of withheld) {
5929
+ ctx.err(/[\r\n]/.test(ctx.env[w] ?? "") ? `${w}: not carried into the service because its value spans several lines` : `${w}: not carried into the service because it holds a password; if the connector needs it, install again with --env ${w} (it is then written into the 0600 unit file)`);
5930
+ }
5931
+ try {
5932
+ saveServiceEnv(ctx, secrets);
5933
+ } catch (e) {
5934
+ if (!(e instanceof UnsafeDirError)) throw e;
5935
+ ctx.err(`cannot install the service: ${e.message}`);
5936
+ return 1;
5937
+ }
4811
5938
  const manual = h.runningPid(ctx);
4812
5939
  if (manual !== null && supervisorPid(ctx) === null) {
4813
5940
  await h.stop(ctx);
@@ -4817,7 +5944,7 @@ async function install(ctx, d, h, sup, extra) {
4817
5944
  if (sup === "systemd") {
4818
5945
  const unit = systemdUnitPath(ctx, d);
4819
5946
  writeFile600(unit, systemdUnit(d, vars));
4820
- recordInstalled(ctx, "systemd");
5947
+ recordInstalled(ctx, "systemd", vars);
4821
5948
  if (!d.systemdBooted()) {
4822
5949
  ctx.err("systemd is not this machine's init (no /run/systemd/system): use --supervisor script");
4823
5950
  return 1;
@@ -4840,7 +5967,7 @@ async function install(ctx, d, h, sup, extra) {
4840
5967
  } else if (sup === "launchd") {
4841
5968
  const plist = launchdPlistPath(d);
4842
5969
  writeFile600(plist, launchdPlist(d, vars, runLogFile(ctx.dirs)));
4843
- recordInstalled(ctx, "launchd");
5970
+ recordInstalled(ctx, "launchd", vars);
4844
5971
  d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
4845
5972
  const r = d.exec("launchctl", ["bootstrap", `gui/${d.uid}`, plist]);
4846
5973
  if (r.code !== 0) {
@@ -4851,33 +5978,126 @@ async function install(ctx, d, h, sup, extra) {
4851
5978
  } else {
4852
5979
  const script = keepaliveFile(ctx.dirs);
4853
5980
  writeFile600(script, keepaliveScript(d, vars), 448);
4854
- recordInstalled(ctx, "script");
4855
- const code = await ensure(ctx, d, h);
5981
+ recordInstalled(ctx, "script", vars);
5982
+ const code = await ensure(ctx, d, h, { byInstall: true });
4856
5983
  if (code !== 0) return code;
4857
- ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies, but`);
4858
- ctx.out("nothing starts the supervisor after a reboot or a container restart. Add both of these (crontab -e, or your container's startup hook):");
4859
- ctx.out(` @reboot sh ${script}`);
4860
- ctx.out(` */5 * * * * sh ${script}`);
5984
+ ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies.`);
5985
+ scheduleKeepalive(ctx, d, script);
4861
5986
  }
4862
5987
  return 0;
4863
5988
  }
4864
- function recordInstalled(ctx, sup) {
4865
- writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString() }));
5989
+ var cronLines = (script) => [`@reboot sh ${sh(script)} >/dev/null 2>&1`, `*/5 * * * * sh ${sh(script)} >/dev/null 2>&1`];
5990
+ function scheduleKeepalive(ctx, d, script) {
5991
+ const tab = d.readCrontab();
5992
+ const want = cronLines(script);
5993
+ if (tab !== null) {
5994
+ const lines = tab.split("\n");
5995
+ if (lines[lines.length - 1] === "") lines.pop();
5996
+ const kept = lines.filter((l) => !want.includes(l.trim()));
5997
+ if (d.writeCrontab([...kept, ...want].join("\n") + "\n")) {
5998
+ ctx.out(`added to your crontab, so it comes back after a reboot and within 5 minutes of the supervisor dying:`);
5999
+ for (const l of want) ctx.out(` ${l}`);
6000
+ return;
6001
+ }
6002
+ ctx.err("could not write your crontab (crontab -); add the lines below by hand");
6003
+ }
6004
+ ctx.out("No cron daemon runs here: after a reboot or a container restart NOTHING starts the connector again.");
6005
+ ctx.out("Run this from the container's startup command (it does nothing while the connector is up; ~/.bashrc works too, then it");
6006
+ ctx.out("comes back when you next open a shell):");
6007
+ ctx.out(` sh ${sh(script)} >/dev/null 2>&1`);
6008
+ const where = ephemeralWhere(ctx.dirs.config);
6009
+ if (where) ctx.out(`Note: ${where}`);
6010
+ }
6011
+ function withoutOurCron(d, script) {
6012
+ const tab = d.readCrontab();
6013
+ if (tab === null) return null;
6014
+ const ours = cronLines(script);
6015
+ const lines = tab.split("\n");
6016
+ if (lines[lines.length - 1] === "") lines.pop();
6017
+ const kept = lines.filter((l) => !ours.includes(l.trim()));
6018
+ return { tab: kept.length ? kept.join("\n") + "\n" : "", removed: lines.length - kept.length };
6019
+ }
6020
+ var EPHEMERAL_FS = /* @__PURE__ */ new Set(["overlay", "overlayfs", "aufs", "tmpfs"]);
6021
+ var PERSISTENT_FS = /^(?:ceph|nfs|nfs4|lustre|wekafs|gpfs|beegfs|cifs|smb3|fuse\.(?:ceph|juicefs|s3fs|gcsfuse|sshfs|rclone))$/;
6022
+ function readMounts(text) {
6023
+ let raw = text;
6024
+ if (raw === void 0) {
6025
+ try {
6026
+ raw = fs17.readFileSync("/proc/self/mountinfo", "utf8");
6027
+ } catch {
6028
+ return [];
6029
+ }
6030
+ }
6031
+ const out = [];
6032
+ for (const line of raw.split("\n")) {
6033
+ const dash = line.indexOf(" - ");
6034
+ if (dash < 0) continue;
6035
+ const fields = line.slice(0, dash).split(" ");
6036
+ const point = fields[4]?.replace(/\\([0-7]{3})/g, (_m, o) => String.fromCharCode(parseInt(o, 8)));
6037
+ const fstype = line.slice(dash + 3).split(" ")[0];
6038
+ if (point && fstype) out.push({ point, fstype });
6039
+ }
6040
+ return out;
6041
+ }
6042
+ function mountOf(p, mounts) {
6043
+ let best = null;
6044
+ for (const m of mounts) {
6045
+ const inside2 = p === m.point || p.startsWith(m.point.endsWith("/") ? m.point : `${m.point}/`);
6046
+ if (inside2 && (!best || m.point.length >= best.point.length)) best = m;
6047
+ }
6048
+ return best;
6049
+ }
6050
+ function ephemeralWhere(configDir, mounts = readMounts()) {
6051
+ let real = configDir;
6052
+ for (let p = configDir; ; p = path19.dirname(p)) {
6053
+ try {
6054
+ real = path19.join(fs17.realpathSync(p), path19.relative(p, configDir));
6055
+ break;
6056
+ } catch {
6057
+ if (p === path19.dirname(p)) break;
6058
+ }
6059
+ }
6060
+ const m = mountOf(real, mounts);
6061
+ if (!m || !EPHEMERAL_FS.has(m.fstype)) return null;
6062
+ const persistent = [...new Set(mounts.filter((x) => PERSISTENT_FS.test(x.fstype) && !x.point.startsWith("/proc") && !x.point.startsWith("/sys")).map((x) => x.point))].filter((pt) => {
6063
+ try {
6064
+ fs17.accessSync(pt, fs17.constants.W_OK);
6065
+ return true;
6066
+ } catch {
6067
+ return false;
6068
+ }
6069
+ }).slice(0, 3);
6070
+ return `agents.toml and the sign-in are in ${configDir}, on the container's own file system (${m.fstype}), which is lost when the container is recreated. Keep them on storage that outlives it: export CLAW_CONNECTOR_CONFIG_DIR=<a directory of yours on ${persistent.length ? persistent.join(" or ") : "a persistent volume"}>/claw-connector in your shell profile and the container's startup command, then run init and login again (the state directory, with its pid and lock files, stays where it is).`;
6071
+ }
6072
+ function saveServiceEnv(ctx, secrets) {
6073
+ const file = serviceEnvFile(ctx.dirs);
6074
+ const before = loadServiceEnv(file);
6075
+ const merged = { ...before, ...Object.fromEntries(secrets) };
6076
+ const names = Object.keys(merged).sort();
6077
+ if (names.length === 0) return;
6078
+ writeSecretFile(file, JSON.stringify(merged, null, 2) + "\n");
6079
+ const fresh = secrets.map(([k]) => k);
6080
+ const kept = names.filter((n) => !fresh.includes(n));
6081
+ ctx.out(`the agents' sign-in for the service (in ${file}, mode 0600, never in a unit): ${[...fresh, ...kept.map((k) => `${k} (kept from before)`)].join(", ")}`);
6082
+ }
6083
+ function recordInstalled(ctx, sup, vars) {
6084
+ const p = vars.find(([k]) => k === "PATH")?.[1];
6085
+ writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...p !== void 0 ? { path: p } : {} }));
4866
6086
  }
4867
6087
  async function uninstall(ctx, d, sup) {
4868
- fs16.rmSync(serviceFile(ctx.dirs), { force: true });
6088
+ fs17.rmSync(serviceFile(ctx.dirs), { force: true });
4869
6089
  if (sup === "systemd") {
4870
6090
  const unit = systemdUnitPath(ctx, d);
4871
6091
  d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
4872
- const had = fs16.existsSync(unit);
4873
- fs16.rmSync(unit, { force: true });
6092
+ const had = fs17.existsSync(unit);
6093
+ fs17.rmSync(unit, { force: true });
4874
6094
  d.exec("systemctl", ["--user", "daemon-reload"]);
4875
6095
  ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
4876
6096
  } else if (sup === "launchd") {
4877
6097
  const plist = launchdPlistPath(d);
4878
6098
  d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
4879
- const had = fs16.existsSync(plist);
4880
- fs16.rmSync(plist, { force: true });
6099
+ const had = fs17.existsSync(plist);
6100
+ fs17.rmSync(plist, { force: true });
4881
6101
  ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
4882
6102
  } else {
4883
6103
  const pid = supervisorPid(ctx);
@@ -4887,9 +6107,15 @@ async function uninstall(ctx, d, sup) {
4887
6107
  while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
4888
6108
  }
4889
6109
  const script = keepaliveFile(ctx.dirs);
4890
- const had = fs16.existsSync(script);
4891
- fs16.rmSync(script, { force: true });
4892
- ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}. Remove the @reboot and */5 lines that call it.`);
6110
+ const had = fs17.existsSync(script);
6111
+ fs17.rmSync(script, { force: true });
6112
+ const cron = withoutOurCron(d, script);
6113
+ const cronGone = cron !== null && cron.removed > 0 && d.writeCrontab(cron.tab);
6114
+ ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}${cronGone ? " and its crontab lines" : ". Remove any startup-hook or cron line that calls it."}`);
6115
+ }
6116
+ if (fs17.existsSync(serviceEnvFile(ctx.dirs))) {
6117
+ fs17.rmSync(serviceEnvFile(ctx.dirs), { force: true });
6118
+ ctx.out(`removed ${serviceEnvFile(ctx.dirs)} (the agents' sign-in variables kept for the service)`);
4893
6119
  }
4894
6120
  return 0;
4895
6121
  }
@@ -4905,6 +6131,8 @@ function status(ctx, d, h, json) {
4905
6131
  const st = {
4906
6132
  installed: sup,
4907
6133
  active,
6134
+ /** The names (never the values) of the agents' sign-in variables the service keeps. */
6135
+ agent_env: Object.keys(loadServiceEnv(serviceEnvFile(ctx.dirs))).sort(),
4908
6136
  running: h.runningPid(ctx),
4909
6137
  needs_login: needsLogin(ctx.dirs),
4910
6138
  recent_disconnects: recentDisconnects(ctx.dirs, 5)
@@ -4915,18 +6143,19 @@ function status(ctx, d, h, json) {
4915
6143
  }
4916
6144
  ctx.out(`service: ${sup ? `${sup} (${active})` : "not installed; run: claw-connector service install"}`);
4917
6145
  ctx.out(`connector: ${st.running !== null ? `running (pid ${st.running})` : "not running"}`);
6146
+ if (st.agent_env.length) ctx.out(`agent env: ${st.agent_env.join(", ")} (from ${serviceEnvFile(ctx.dirs)})`);
4918
6147
  if (st.needs_login) ctx.out(`problem: needs a new sign-in: ${st.needs_login.reason}; run: claw-connector login`);
4919
6148
  for (const x of st.recent_disconnects) ctx.out(`down: ${x.at} ${x.reason}${x.detail ? ` (${x.detail})` : ""}`);
4920
6149
  return 0;
4921
6150
  }
4922
6151
 
4923
6152
  // src/version.ts
4924
- var VERSION = "0.1.5";
6153
+ var VERSION = "0.1.6";
4925
6154
 
4926
6155
  // src/probe.ts
4927
- import fs17 from "node:fs";
4928
- import os6 from "node:os";
4929
- import path18 from "node:path";
6156
+ import fs18 from "node:fs";
6157
+ import os8 from "node:os";
6158
+ import path20 from "node:path";
4930
6159
  var BYPASS_FIX = `Claude Code offers bypassPermissions to root only with IS_SANDBOX set (the connector sets it for an agent with approval = "never" unless its env names IS_SANDBOX), and never when permissions.disableBypassPermissionsMode is "disable" in its settings; fix that, or set approval = "ask" on this agent`;
4931
6160
  function modesOf(res) {
4932
6161
  const m = isObject(res.modes) ? res.modes : null;
@@ -4937,17 +6166,17 @@ function modesOf(res) {
4937
6166
  }
4938
6167
  var probeDir = (a) => a.roots.find((r) => {
4939
6168
  try {
4940
- return fs17.statSync(r).isDirectory();
6169
+ return fs18.statSync(r).isDirectory();
4941
6170
  } catch {
4942
6171
  return false;
4943
6172
  }
4944
- }) ?? os6.homedir();
6173
+ }) ?? os8.homedir();
4945
6174
  async function probeAgent(a, o) {
4946
6175
  const cwd = probeDir(a);
4947
6176
  const out = { agent: a.id, cwd, ok: false, modes: null, problems: [], notes: [] };
4948
6177
  if (!a.roots.includes(cwd)) out.notes.push(`no root of this agent exists yet (${a.roots.join(", ")}); probed in ${cwd}`);
4949
- const tmp = fs17.mkdtempSync(path18.join(os6.tmpdir(), "claw-probe-"));
4950
- const registry = new ChildRegistry(path18.join(tmp, "children.json"));
6178
+ const tmp = fs18.mkdtempSync(path20.join(os8.tmpdir(), "claw-probe-"));
6179
+ const registry = new ChildRegistry(path20.join(tmp, "children.json"));
4951
6180
  const timeoutMs = o.timeoutMs ?? 12e4;
4952
6181
  let adapter = null;
4953
6182
  try {
@@ -4956,7 +6185,7 @@ async function probeAgent(a, o) {
4956
6185
  registry,
4957
6186
  sessionId: `probe-${a.id}`,
4958
6187
  cwd,
4959
- logFile: path18.join(o.logDir, `adapter-${a.id}.log`),
6188
+ logFile: path20.join(o.logDir, `adapter-${a.id}.log`),
4960
6189
  version: o.version,
4961
6190
  initTimeoutMs: timeoutMs,
4962
6191
  hooks: {
@@ -4987,10 +6216,10 @@ async function probeAgent(a, o) {
4987
6216
  if (missing.length) out.notes.push(`safe_modes lists ${missing.join(", ")}, which the adapter does not offer here; a phone that picks one is refused`);
4988
6217
  out.ok = out.problems.length === 0;
4989
6218
  } catch (e) {
4990
- out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path18.join(o.logDir, `adapter-${a.id}.log`)})`);
6219
+ out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path20.join(o.logDir, `adapter-${a.id}.log`)})`);
4991
6220
  } finally {
4992
6221
  await adapter?.kill().catch(() => void 0);
4993
- fs17.rmSync(tmp, { recursive: true, force: true });
6222
+ fs18.rmSync(tmp, { recursive: true, force: true });
4994
6223
  }
4995
6224
  return out;
4996
6225
  }
@@ -5031,11 +6260,18 @@ Usage: claw-connector <command> [options]
5031
6260
  service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]
5032
6261
  keep "run" up: started at login/boot, restarted 5 s after it dies, never restarted
5033
6262
  after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
5034
- systemd) writes a keep-alive script for a startup hook and a 5-minute cron line
6263
+ systemd) writes a keep-alive script and adds it to crontab, or says what must call it.
6264
+ The agents' settings in this shell (PATH, proxies, ANTHROPIC_BASE_URL...) go into the
6265
+ service; their sign-in variables (ANTHROPIC_API_KEY, OPENAI_API_KEY...) and any --env
6266
+ named like a credential go into service-env.json (0600), never into the unit
5035
6267
  status [--json] what is running, signed in, paused, the last disconnect and why
5036
6268
  doctor [--agent ID] [--json]
5037
- start each agent's adapter, open a session (no prompt) and check that it offers the
5038
- modes agents.toml needs, so a phone session is not refused; init runs it too
6269
+ check this machine before a phone session fails on it, one fix per problem: Node, each
6270
+ agent's program on PATH (and on the installed service's PATH), Claude Code / Codex signed
6271
+ in, the roots writable, the proxy variables, Claw's certificate, the service and Claw
6272
+ itself; then start each agent's adapter, open a session (no prompt) and check that it
6273
+ offers the modes agents.toml needs. Exit 1 when something stops sessions. A running
6274
+ connector repeats the machine checks and shows them in the chat; init runs the adapter check
5039
6275
  pause stop every agent now and stay offline until "resume"
5040
6276
  resume undo "pause"
5041
6277
  stop stop the running connector and every agent it started
@@ -5043,10 +6279,12 @@ Usage: claw-connector <command> [options]
5043
6279
  --version, --help
5044
6280
 
5045
6281
  Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
5046
- (audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
6282
+ (audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory, and
6283
+ CLAW_CONNECTOR_CONFIG_DIR to keep only the configuration and sign-in elsewhere (storage that outlives a container).
5047
6284
  `;
5048
6285
  function loadCfg(ctx, notes = []) {
5049
- const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os7.hostname() });
6286
+ const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os9.hostname() });
6287
+ applyServiceEnv(cfg.agents, loadServiceEnv(serviceEnvFile(ctx.dirs)), ctx.env);
5050
6288
  notes.push(...applyRootSandbox(cfg.agents, ctx.env, { euid: ctx.euid }));
5051
6289
  return cfg;
5052
6290
  }
@@ -5090,6 +6328,8 @@ async function main(argv, ctx) {
5090
6328
  return cmdStatus(rest, ctx);
5091
6329
  case "doctor":
5092
6330
  return await cmdDoctor(rest, ctx);
6331
+ case "health-check":
6332
+ return await cmdHealthCheck(ctx, ctx.stdin ?? readStdin);
5093
6333
  case "pause":
5094
6334
  return await cmdPause(ctx);
5095
6335
  case "resume":
@@ -5136,9 +6376,9 @@ async function cmdInit(args, ctx) {
5136
6376
  return EXIT.usage;
5137
6377
  }
5138
6378
  const file = configFile(ctx.dirs);
5139
- fs18.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
6379
+ fs19.mkdirSync(path21.dirname(file), { recursive: true, mode: 448 });
5140
6380
  try {
5141
- fs18.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
6381
+ fs19.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
5142
6382
  ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
5143
6383
  const { unattended, parked } = sampleApprovals(approval);
5144
6384
  ctx.out(unattended.length ? `no per-step approval for: ${unattended.join(", ")}. Those agents write files and run commands without asking; the phone shows such a session as unattended. To go back, set approval = "ask" on the agent.` : `every agent in this file asks before it writes a file or runs a command. To let phone sessions run like your terminal instead: claw-connector init --approval never (on a new file), or set approval = "never" on an agent.`);
@@ -5147,9 +6387,11 @@ async function cmdInit(args, ctx) {
5147
6387
  if (e.code !== "EEXIST") throw e;
5148
6388
  ctx.out(`${file} already exists; left alone${values.approval?.length ? ` (to change an agent's approval, set approval = "ask" or "never" in its [[agents]] table)` : ""}`);
5149
6389
  }
6390
+ const where = ephemeralWhere(ctx.dirs.config);
6391
+ if (where) ctx.out(`note: ${where}`);
5150
6392
  if (!values["no-hooks"]) {
5151
6393
  const dir = defaultClaudeDir(ctx);
5152
- if (!fs18.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
6394
+ if (!fs19.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
5153
6395
  else installForInit(ctx, dir);
5154
6396
  }
5155
6397
  await installAdapters(ctx, true);
@@ -5164,27 +6406,6 @@ async function cmdInit(args, ctx) {
5164
6406
  }
5165
6407
  return EXIT.ok;
5166
6408
  }
5167
- async function cmdDoctor(args, ctx) {
5168
- const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
5169
- const notes = [];
5170
- const cfg = loadCfg(ctx, notes);
5171
- const agents = values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents;
5172
- if (agents.length === 0) {
5173
- ctx.err(values.agent ? `no agent ${JSON.stringify(values.agent)} in agents.toml` : "agents.toml defines no agents; add an [[agents]] table");
5174
- return EXIT.failure;
5175
- }
5176
- const warnings = noAsk(ctx, { ...cfg, agents });
5177
- const results = await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION });
5178
- const ok = results.every((r) => r.ok);
5179
- if (values.json) {
5180
- ctx.out(JSON.stringify({ version: VERSION, ok, notes, warnings, agents: results }, null, 2));
5181
- } else {
5182
- for (const n of notes) ctx.out(`note: ${n}`);
5183
- for (const w of warnings) ctx.out(`warning: ${w}`);
5184
- for (const l of probeLines(results)) ctx.out(l);
5185
- }
5186
- return ok ? EXIT.ok : EXIT.failure;
5187
- }
5188
6409
  async function installAdapters(ctx, initFallback) {
5189
6410
  const store2 = adapterStore();
5190
6411
  if (!store2 || !ctx.installAdapters) return;
@@ -5204,19 +6425,19 @@ function installForInit(ctx, dir) {
5204
6425
  ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
5205
6426
  }
5206
6427
  }
5207
- var defaultClaudeDir = (ctx) => path19.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path19.join(os7.homedir(), ".claude"));
6428
+ var defaultClaudeDir = (ctx) => path21.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path21.join(os9.homedir(), ".claude"));
5208
6429
  function claudeDirs(ctx, flag) {
5209
- if (flag !== void 0) return [path19.resolve(flag)];
6430
+ if (flag !== void 0) return [path21.resolve(flag)];
5210
6431
  let dirs = [];
5211
6432
  try {
5212
- dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path19.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path19.join(os7.homedir(), ".claude")));
6433
+ dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path21.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path21.join(os9.homedir(), ".claude")));
5213
6434
  } catch {
5214
6435
  }
5215
6436
  return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
5216
6437
  }
5217
6438
  function hookScript(ctx) {
5218
6439
  const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
5219
- if (!fs18.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
6440
+ if (!fs19.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
5220
6441
  return script;
5221
6442
  }
5222
6443
  function installOne(ctx, dir) {
@@ -5236,7 +6457,7 @@ function cmdHooks(args, ctx) {
5236
6457
  for (const dir of dirs) {
5237
6458
  try {
5238
6459
  if (sub === "install") {
5239
- if (values["config-dir"] === void 0 && !fs18.existsSync(dir)) {
6460
+ if (values["config-dir"] === void 0 && !fs19.existsSync(dir)) {
5240
6461
  ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
5241
6462
  code = EXIT.failure;
5242
6463
  continue;
@@ -5261,11 +6482,11 @@ function forgetRevokedIdentity(ctx) {
5261
6482
  const file = identityFile(ctx.dirs);
5262
6483
  let revoked = false;
5263
6484
  try {
5264
- const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
6485
+ const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
5265
6486
  revoked = isObject(raw) && raw.revoked === true;
5266
6487
  } catch {
5267
6488
  }
5268
- if (revoked) fs18.rmSync(file, { force: true });
6489
+ if (revoked) fs19.rmSync(file, { force: true });
5269
6490
  }
5270
6491
  async function cmdLogin(args, ctx) {
5271
6492
  const { values } = parseArgs2({
@@ -5300,7 +6521,7 @@ async function cmdLogin(args, ctx) {
5300
6521
  ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
5301
6522
  return EXIT.usage;
5302
6523
  }
5303
- const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os7.hostname();
6524
+ const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os9.hostname();
5304
6525
  const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
5305
6526
  saveCredentials(file, {
5306
6527
  v: 1,
@@ -5320,7 +6541,7 @@ async function cmdLogin(args, ctx) {
5320
6541
  }
5321
6542
  function runningPid(ctx) {
5322
6543
  try {
5323
- const raw = JSON.parse(fs18.readFileSync(pidFile(ctx.dirs), "utf8"));
6544
+ const raw = JSON.parse(fs19.readFileSync(pidFile(ctx.dirs), "utf8"));
5324
6545
  if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
5325
6546
  } catch {
5326
6547
  }
@@ -5366,12 +6587,16 @@ async function cmdRun(args, ctx) {
5366
6587
  const client2 = new ClawClient({ server, tokens, fetch: ctx.fetch });
5367
6588
  const log = stderrLogger();
5368
6589
  const audit = fileAudit(auditFile(ctx.dirs));
5369
- const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
6590
+ const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing, health: runHealth(ctx, cfg) });
5370
6591
  if (ctx.installAdapters) for (const { pkg, env } of pinnedOf(cfg.agents, ctx.env)) void adapterStore()?.install(pkg, env, (l) => ctx.out(`adapters: ${l}`));
5371
6592
  ctx.onConnector?.(connector2);
5372
6593
  ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
5373
6594
  for (const n of notes) ctx.out(`note: ${n}`);
5374
6595
  for (const w of noAsk(ctx, cfg)) ctx.out(`warning: ${w}`);
6596
+ for (const a of cfg.agents) if (a.readOnlyModes.includes(a.askMode)) ctx.out(`note: ${a.id}: its sessions start read-only (${a.askMode} mode changes no file); to let phone sessions write code, set approval = "never" on it in agents.toml`);
6597
+ void rootProblems(cfg.agents).then((ps) => {
6598
+ for (const p of ps) ctx.out(`warning: ${p}`);
6599
+ }, () => void 0);
5375
6600
  try {
5376
6601
  const result = await connector2.run();
5377
6602
  if (result === "revoked") {
@@ -5402,6 +6627,94 @@ async function cmdRun(args, ctx) {
5402
6627
  throw e;
5403
6628
  }
5404
6629
  }
6630
+ async function cmdDoctor(args, ctx) {
6631
+ const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
6632
+ const before = [];
6633
+ const notes = [];
6634
+ let cfg = null;
6635
+ let creds = null;
6636
+ try {
6637
+ cfg = loadCfg(ctx, notes);
6638
+ } catch (e) {
6639
+ const missing = !fs19.existsSync(configFile(ctx.dirs));
6640
+ const roots = e instanceof ConfigError ? e.problems.map((p) => /^agent (\S+): root (.+) does not exist$/.exec(p)) : [];
6641
+ if (roots.length && roots.every((m) => m !== null)) {
6642
+ for (const m of roots) before.push({ code: "root_missing", severity: "fail", agent: m[1], detail: `${m[2]} (a root of agent ${m[1]}) does not exist`, fix: `mkdir -p ${shq(m[2])} (or change roots in ${configFile(ctx.dirs)})` });
6643
+ } else before.push({ code: "config_invalid", severity: "fail", detail: errMessage(e), fix: missing ? "claw-connector init" : `edit ${configFile(ctx.dirs)}` });
6644
+ }
6645
+ const agents = cfg ? values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents : [];
6646
+ if (cfg && agents.length === 0) {
6647
+ if (values.agent) {
6648
+ ctx.err(`no agent ${JSON.stringify(values.agent)} in agents.toml`);
6649
+ return EXIT.failure;
6650
+ }
6651
+ before.push({ code: "config_invalid", severity: "fail", detail: "agents.toml defines no agents", fix: `add an [[agents]] table to ${configFile(ctx.dirs)} (claw-connector init writes a sample)` });
6652
+ }
6653
+ try {
6654
+ creds = loadCredentials(credentialsFile(ctx.dirs));
6655
+ } catch (e) {
6656
+ before.push({ code: "not_signed_in", severity: "fail", detail: errMessage(e), fix: "claw-connector login" });
6657
+ }
6658
+ if (!creds && !before.some((p) => p.code === "not_signed_in")) before.push({ code: "not_signed_in", severity: "fail", detail: "this connector is not signed in to Claw", fix: "claw-connector login" });
6659
+ const nl = needsLogin(ctx.dirs);
6660
+ if (nl) before.push({ code: "needs_login", severity: "fail", detail: `needs a new sign-in: ${nl.reason}`, fix: "claw-connector login" });
6661
+ let server = null;
6662
+ try {
6663
+ server = serverUrl(ctx, void 0, cfg, creds);
6664
+ } catch {
6665
+ }
6666
+ const report = await checkAll({ dirs: ctx.dirs, agents, by: "doctor", server, before }, ctx.doctor ?? defaultDeps(ctx.env));
6667
+ writeReport(ctx.dirs, report);
6668
+ const warnings = cfg ? noAsk(ctx, { ...cfg, agents }) : [];
6669
+ const results = agents.length ? redactDeep(await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION })) : [];
6670
+ const adaptersOk = results.every((r) => r.ok);
6671
+ const machineOk = !report.problems.some((p) => p.severity === "fail");
6672
+ if (values.json) {
6673
+ ctx.out(JSON.stringify({ version: VERSION, ok: adaptersOk, machine_ok: machineOk, notes, warnings, agents: results, environment: report }, null, 2));
6674
+ } else {
6675
+ ctx.out(`claw-connector ${VERSION} doctor${cfg ? ` (${cfg.name})` : ""}`);
6676
+ for (const l of formatReport(report)) ctx.out(l);
6677
+ if (results.length) {
6678
+ ctx.out("");
6679
+ ctx.out("each agent's adapter (a session opened without a prompt):");
6680
+ for (const n of notes) ctx.out(`note: ${n}`);
6681
+ for (const w of warnings) ctx.out(`warning: ${w}`);
6682
+ for (const l of probeLines(results)) ctx.out(l);
6683
+ }
6684
+ }
6685
+ const running = runningPid(ctx);
6686
+ if (running !== null) {
6687
+ try {
6688
+ (ctx.askRecheck ?? ((pid) => process.kill(pid, "SIGWINCH")))(running);
6689
+ if (!values.json) ctx.out("The running connector checks the machine again now and shows the result in the chat.");
6690
+ } catch {
6691
+ }
6692
+ }
6693
+ return adaptersOk && machineOk ? EXIT.ok : EXIT.failure;
6694
+ }
6695
+ var HEALTH_CHILD_MS = 6e4;
6696
+ function runHealth(ctx, cfg) {
6697
+ if (ctx.doctor) return () => checkAll({ dirs: ctx.dirs, agents: cfg.agents, by: "run" }, ctx.doctor);
6698
+ const cli = ctx.cli ?? ctx.service?.cli ?? fileURLToPath2(import.meta.url);
6699
+ const node = ctx.node ?? process.execPath;
6700
+ return async (signal) => {
6701
+ const r = await runProbe(node, [cli, "health-check"], ctx.env, signal, HEALTH_CHILD_MS, JSON.stringify({ agents: cfg.agents }));
6702
+ const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
6703
+ if (!isObject(v) || typeof v.checked_at !== "string" || !Array.isArray(v.problems)) throw new Error(`health-check answered ${r.code === null ? "nothing in time" : `exit ${r.code}`}`);
6704
+ return v;
6705
+ };
6706
+ }
6707
+ async function cmdHealthCheck(ctx, stdin) {
6708
+ const v = JSON.parse(await stdin());
6709
+ const agents = isObject(v) && Array.isArray(v.agents) ? v.agents : [];
6710
+ ctx.out(JSON.stringify(await checkAll({ dirs: ctx.dirs, agents, by: "run" }, defaultDeps(ctx.env, { ownGroup: false }))));
6711
+ return EXIT.ok;
6712
+ }
6713
+ async function readStdin() {
6714
+ const chunks = [];
6715
+ for await (const c of process.stdin) chunks.push(c);
6716
+ return Buffer.concat(chunks).toString("utf8");
6717
+ }
5405
6718
  function cmdStatus(args, ctx) {
5406
6719
  const { values } = parseArgs2({ args, strict: true, options: { json: { type: "boolean" } } });
5407
6720
  let creds = null;
@@ -5425,7 +6738,7 @@ function cmdStatus(args, ctx) {
5425
6738
  }
5426
6739
  let connectorId = null;
5427
6740
  try {
5428
- const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
6741
+ const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
5429
6742
  if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
5430
6743
  } catch {
5431
6744
  }
@@ -5433,7 +6746,7 @@ function cmdStatus(args, ctx) {
5433
6746
  const status2 = {
5434
6747
  version: VERSION,
5435
6748
  running: runningPid(ctx),
5436
- paused: fs18.existsSync(pauseFile(ctx.dirs)),
6749
+ paused: fs19.existsSync(pauseFile(ctx.dirs)),
5437
6750
  signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
5438
6751
  connector_id: connectorId,
5439
6752
  agents,
@@ -5445,7 +6758,9 @@ function cmdStatus(args, ctx) {
5445
6758
  warnings,
5446
6759
  /** Why the connection last went down, and whether only a new sign-in can bring it back. */
5447
6760
  last_disconnect: recentDisconnects(ctx.dirs, 1)[0] ?? null,
5448
- needs_login: needsLogin(ctx.dirs)
6761
+ needs_login: needsLogin(ctx.dirs),
6762
+ /** The last `doctor` report's problems (null: doctor never ran). */
6763
+ doctor: lastDoctor(ctx)
5449
6764
  };
5450
6765
  if (values.json) {
5451
6766
  ctx.out(JSON.stringify(status2, null, 2));
@@ -5459,11 +6774,22 @@ function cmdStatus(args, ctx) {
5459
6774
  if (status2.needs_login) ctx.out(`problem: needs a new sign-in: ${status2.needs_login.reason}; run: claw-connector login`);
5460
6775
  for (const p of status2.problems) ctx.out(`problem: ${p}`);
5461
6776
  for (const w of status2.warnings) ctx.out(`warning: ${w}`);
6777
+ if (status2.doctor) ctx.out(`doctor: ${status2.doctor.problems.length ? `${status2.doctor.problems.length} problem(s) at ${status2.doctor.checked_at}; run: claw-connector doctor` : `no problems at ${status2.doctor.checked_at}`}`);
6778
+ else ctx.out("doctor: never run; run: claw-connector doctor");
5462
6779
  return EXIT.ok;
5463
6780
  }
6781
+ function lastDoctor(ctx) {
6782
+ try {
6783
+ const raw = JSON.parse(fs19.readFileSync(doctorFile(ctx.dirs), "utf8"));
6784
+ if (!isObject(raw) || typeof raw.checked_at !== "string" || !Array.isArray(raw.problems)) return null;
6785
+ return { checked_at: raw.checked_at, problems: raw.problems.filter(isObject).map((p) => ({ code: String(p.code), ...typeof p.agent === "string" ? { agent: p.agent } : {}, fix: String(p.fix) })) };
6786
+ } catch {
6787
+ return null;
6788
+ }
6789
+ }
5464
6790
  async function cmdPause(ctx) {
5465
- fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
5466
- fs18.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
6791
+ fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
6792
+ fs19.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
5467
6793
  if (runningPid(ctx) === null) {
5468
6794
  const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
5469
6795
  ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
@@ -5473,15 +6799,15 @@ async function cmdPause(ctx) {
5473
6799
  return EXIT.ok;
5474
6800
  }
5475
6801
  function cmdResume(ctx) {
5476
- fs18.rmSync(pauseFile(ctx.dirs), { force: true });
6802
+ fs19.rmSync(pauseFile(ctx.dirs), { force: true });
5477
6803
  ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
5478
6804
  return EXIT.ok;
5479
6805
  }
5480
6806
  async function cmdStop(ctx, quiet = false) {
5481
6807
  const pid = runningPid(ctx);
5482
6808
  if (pid !== null) {
5483
- fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
5484
- fs18.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
6809
+ fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
6810
+ fs19.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
5485
6811
  process.kill(pid, "SIGTERM");
5486
6812
  const deadline = Date.now() + 15e3;
5487
6813
  while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
@@ -5489,7 +6815,7 @@ async function cmdStop(ctx, quiet = false) {
5489
6815
  process.kill(pid, "SIGKILL");
5490
6816
  if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
5491
6817
  }
5492
- fs18.rmSync(stopRequestFile(ctx.dirs), { force: true });
6818
+ fs19.rmSync(stopRequestFile(ctx.dirs), { force: true });
5493
6819
  }
5494
6820
  const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
5495
6821
  if (!quiet) ctx.out(pid === null ? `no connector running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""}` : `stopped the connector${left.killed.length ? ` and ${left.killed.length} leftover agent process group(s)` : ""}`);
@@ -5499,17 +6825,17 @@ function takeStopRequest(ctx) {
5499
6825
  const file = stopRequestFile(ctx.dirs);
5500
6826
  let mine = false;
5501
6827
  try {
5502
- const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
6828
+ const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
5503
6829
  mine = isObject(raw) && raw.pid === process.pid && raw.start === (processStart(process.pid) ?? "");
5504
6830
  } catch {
5505
6831
  return false;
5506
6832
  }
5507
- if (mine) fs18.rmSync(file, { force: true });
6833
+ if (mine) fs19.rmSync(file, { force: true });
5508
6834
  return mine;
5509
6835
  }
5510
6836
  function storedConnector(ctx) {
5511
6837
  try {
5512
- const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
6838
+ const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
5513
6839
  if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
5514
6840
  return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
5515
6841
  }
@@ -5581,8 +6907,8 @@ async function cmdLogout(ctx) {
5581
6907
  ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
5582
6908
  }
5583
6909
  }
5584
- fs18.rmSync(file, { force: true });
5585
- if (rowGone) fs18.rmSync(identityFile(ctx.dirs), { force: true });
6910
+ fs19.rmSync(file, { force: true });
6911
+ if (rowGone) fs19.rmSync(identityFile(ctx.dirs), { force: true });
5586
6912
  else forgetRevokedIdentity(ctx);
5587
6913
  if (row && notRevoked !== null) {
5588
6914
  ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
@@ -5620,6 +6946,7 @@ var onSignal = () => {
5620
6946
  };
5621
6947
  main(process.argv.slice(2), {
5622
6948
  dirs: resolveDirs(),
6949
+ cli: fileURLToPath3(import.meta.url),
5623
6950
  env: process.env,
5624
6951
  out: (l) => process.stdout.write(l + "\n"),
5625
6952
  err: (l) => process.stderr.write(l + "\n"),
@@ -5628,6 +6955,7 @@ main(process.argv.slice(2), {
5628
6955
  onConnector: (c) => {
5629
6956
  connector = c;
5630
6957
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
6958
+ process.on("SIGWINCH", () => c.recheckHealth());
5631
6959
  }
5632
6960
  }).then((code) => process.exit(code), (e) => {
5633
6961
  process.stderr.write(`fatal: ${e instanceof Error ? e.stack : String(e)}