@cabane/companion 0.6.25 → 0.6.27

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/dist/cli.js +731 -174
  2. package/dist/runtime.js +26 -15
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5,8 +5,22 @@ import { Command } from "commander";
5
5
 
6
6
  // src/commands/daemon.ts
7
7
  import { spawn as spawn4 } from "child_process";
8
- import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2 } from "fs";
8
+ import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2 } from "fs";
9
+
10
+ // src/cli-entry.ts
11
+ import { existsSync } from "fs";
9
12
  import { fileURLToPath } from "url";
13
+ var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
14
+ function companionCliEntry(deps = {}) {
15
+ const exists = deps.exists ?? existsSync;
16
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath(new URL(rel, import.meta.url)));
17
+ for (const candidate of candidates) {
18
+ if (exists(candidate)) return candidate;
19
+ }
20
+ const argv1 = "argv1" in deps ? deps.argv1 : process.argv[1];
21
+ if (argv1 && exists(argv1)) return argv1;
22
+ return candidates[0] ?? "";
23
+ }
10
24
 
11
25
  // src/config.ts
12
26
  import {
@@ -16,7 +30,7 @@ import {
16
30
  renameSync,
17
31
  rmSync,
18
32
  writeFileSync,
19
- existsSync
33
+ existsSync as existsSync2
20
34
  } from "fs";
21
35
  import { homedir, userInfo } from "os";
22
36
  import { dirname, join } from "path";
@@ -362,7 +376,7 @@ function localAgentConfig(cfg, agent) {
362
376
  }
363
377
  function loadConfig() {
364
378
  const path = configPath();
365
- if (!existsSync(path)) return null;
379
+ if (!existsSync2(path)) return null;
366
380
  let raw;
367
381
  try {
368
382
  raw = readFileSync(path, "utf8");
@@ -398,7 +412,7 @@ function loadConfigTolerant() {
398
412
  const path = configPath();
399
413
  const empty = {};
400
414
  const fresh = { local: empty, note: null, hadPriorConfig: false };
401
- if (!existsSync(path)) return fresh;
415
+ if (!existsSync2(path)) return fresh;
402
416
  let raw;
403
417
  try {
404
418
  raw = readFileSync(path, "utf8");
@@ -485,7 +499,7 @@ function requireConfig() {
485
499
  }
486
500
  function deleteConfig() {
487
501
  const path = configPath();
488
- if (existsSync(path)) {
502
+ if (existsSync2(path)) {
489
503
  writeFileSync(path, "", { mode: 384 });
490
504
  }
491
505
  }
@@ -692,7 +706,7 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
692
706
 
693
707
  // src/runtime-file.ts
694
708
  import {
695
- existsSync as existsSync2,
709
+ existsSync as existsSync3,
696
710
  readFileSync as readFileSync2,
697
711
  rmSync as rmSync2,
698
712
  writeFileSync as writeFileSync2,
@@ -732,11 +746,11 @@ function acquireRuntimeState(state) {
732
746
  }
733
747
  function clearRuntimeState() {
734
748
  const path = runtimePath();
735
- if (existsSync2(path)) rmSync2(path, { force: true });
749
+ if (existsSync3(path)) rmSync2(path, { force: true });
736
750
  }
737
751
  function readLiveRuntimeState() {
738
752
  const path = runtimePath();
739
- if (!existsSync2(path)) return null;
753
+ if (!existsSync3(path)) return null;
740
754
  let parsed;
741
755
  try {
742
756
  parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -780,6 +794,383 @@ function trimSlash(s) {
780
794
  return s.endsWith("/") ? s.slice(0, -1) : s;
781
795
  }
782
796
 
797
+ // src/service/index.ts
798
+ import { dirname as dirname4 } from "path";
799
+
800
+ // src/service/host.ts
801
+ import { spawnSync } from "child_process";
802
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
803
+ import { homedir as homedir2 } from "os";
804
+ var RUN_TIMEOUT_MS = 1e4;
805
+ function defaultServiceHost() {
806
+ if (process.env.VITEST) {
807
+ throw new Error(
808
+ "defaultServiceHost() was reached during a test run \u2014 it shells out to the real service manager. Inject a fake host (apps/companion/test/service-host-fake.ts) instead."
809
+ );
810
+ }
811
+ return {
812
+ platform: process.platform,
813
+ env: process.env,
814
+ home: homedir2(),
815
+ uid: process.getuid?.() ?? 0,
816
+ execPath: process.execPath,
817
+ cliPath: companionCliEntry(),
818
+ logPath: companionLogPath(),
819
+ fs: {
820
+ read: (path) => {
821
+ try {
822
+ return readFileSync3(path, "utf8");
823
+ } catch {
824
+ return null;
825
+ }
826
+ },
827
+ write: (path, contents) => writeFileSync3(path, contents, "utf8"),
828
+ remove: (path) => rmSync3(path, { force: true }),
829
+ exists: (path) => existsSync4(path),
830
+ mkdirp: (dir2) => {
831
+ mkdirSync4(dir2, { recursive: true });
832
+ }
833
+ },
834
+ run: (cmd, args) => {
835
+ const res = spawnSync(cmd, args, { encoding: "utf8", timeout: RUN_TIMEOUT_MS });
836
+ return {
837
+ ok: res.status === 0,
838
+ stdout: res.stdout ?? "",
839
+ stderr: res.stderr ?? (res.error ? res.error.message : "")
840
+ };
841
+ }
842
+ };
843
+ }
844
+
845
+ // src/service/launchd.ts
846
+ import { join as join4 } from "path";
847
+ var LAUNCHD_LABEL = "ai.cabane.companion";
848
+ function launchAgentPath(home) {
849
+ return join4(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
850
+ }
851
+ function renderPlist(input) {
852
+ const args = input.programArguments.map((a) => ` <string>${xml(a)}</string>`).join("\n");
853
+ const env = Object.entries(input.environment).map(([k, v]) => ` <key>${xml(k)}</key>
854
+ <string>${xml(v)}</string>`).join("\n");
855
+ return [
856
+ '<?xml version="1.0" encoding="UTF-8"?>',
857
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
858
+ '<plist version="1.0">',
859
+ "<dict>",
860
+ " <key>Label</key>",
861
+ ` <string>${LAUNCHD_LABEL}</string>`,
862
+ " <key>ProgramArguments</key>",
863
+ " <array>",
864
+ args,
865
+ " </array>",
866
+ " <key>RunAtLoad</key>",
867
+ " <true/>",
868
+ " <key>KeepAlive</key>",
869
+ " <dict>",
870
+ " <key>SuccessfulExit</key>",
871
+ " <false/>",
872
+ " </dict>",
873
+ " <key>EnvironmentVariables</key>",
874
+ " <dict>",
875
+ env,
876
+ " </dict>",
877
+ " <key>StandardOutPath</key>",
878
+ ` <string>${xml(input.logPath)}</string>`,
879
+ " <key>StandardErrorPath</key>",
880
+ ` <string>${xml(input.logPath)}</string>`,
881
+ "</dict>",
882
+ "</plist>",
883
+ ""
884
+ ].join("\n");
885
+ }
886
+ function domain(host) {
887
+ return `gui/${host.uid}`;
888
+ }
889
+ function target(host) {
890
+ return `${domain(host)}/${LAUNCHD_LABEL}`;
891
+ }
892
+ function launchdStart(host, plistPath) {
893
+ host.run("launchctl", ["bootout", target(host)]);
894
+ const res = host.run("launchctl", ["bootstrap", domain(host), plistPath]);
895
+ return res.ok ? { ok: true } : { ok: false, detail: firstLine(res.stderr || res.stdout) };
896
+ }
897
+ function launchdStop(host) {
898
+ const res = host.run("launchctl", ["bootout", target(host)]);
899
+ if (res.ok || notLoaded(res.stderr + res.stdout)) return { ok: true };
900
+ return { ok: false, detail: firstLine(res.stderr || res.stdout) };
901
+ }
902
+ function launchdProbe(host) {
903
+ const res = host.run("launchctl", ["print", target(host)]);
904
+ if (res.ok) {
905
+ const state = /state\s*=\s*(\w+)/.exec(res.stdout)?.[1];
906
+ return { ownership: "held", running: state ? state === "running" : "unknown" };
907
+ }
908
+ return notLoaded(res.stderr + res.stdout) ? { ownership: "clear", running: false } : { ownership: "unknown", running: "unknown" };
909
+ }
910
+ function launchdRemove(host, plistPath) {
911
+ const stopped = launchdStop(host);
912
+ if (!stopped.ok) return stopped;
913
+ host.fs.remove(plistPath);
914
+ return { ok: true };
915
+ }
916
+ var NOT_LOADED = new RegExp(
917
+ `no such process|(could not find|not find service)[^\\n]*${LAUNCHD_LABEL.replace(
918
+ /[.*+?^${}()|[\]\\]/g,
919
+ "\\$&"
920
+ )}`,
921
+ "i"
922
+ );
923
+ function notLoaded(output) {
924
+ return NOT_LOADED.test(output);
925
+ }
926
+ function firstLine(s) {
927
+ return s.trim().split("\n")[0] ?? "";
928
+ }
929
+ function xml(value) {
930
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
931
+ }
932
+
933
+ // src/service/systemd.ts
934
+ import { dirname as dirname3, join as join5 } from "path";
935
+ var SYSTEMD_UNIT = "cabane-companion.service";
936
+ function systemdUnitPath(host) {
937
+ const configHome = host.env.XDG_CONFIG_HOME?.trim() ? host.env.XDG_CONFIG_HOME.trim() : join5(host.home, ".config");
938
+ return join5(configHome, "systemd", "user", SYSTEMD_UNIT);
939
+ }
940
+ function renderUnit(input) {
941
+ const [exec, ...rest] = input.programArguments;
942
+ const execStart = [quote(exec ?? ""), ...rest.map(quote)].join(" ");
943
+ const env = Object.entries(input.environment).map(([k, v]) => `Environment=${k}=${v}`);
944
+ return [
945
+ "[Unit]",
946
+ "Description=Cabane Companion \u2014 keeps this device answering while you are logged in",
947
+ "After=network-online.target",
948
+ "Wants=network-online.target",
949
+ "",
950
+ "[Service]",
951
+ "Type=simple",
952
+ ...env,
953
+ `ExecStart=${execStart}`,
954
+ "Restart=on-failure",
955
+ "RestartSec=5",
956
+ "",
957
+ "[Install]",
958
+ "WantedBy=default.target"
959
+ ].join("\n") + "\n";
960
+ }
961
+ function systemdReload(host) {
962
+ host.run("systemctl", ["--user", "daemon-reload"]);
963
+ }
964
+ function systemdStart(host) {
965
+ systemdReload(host);
966
+ const enabled = host.run("systemctl", ["--user", "enable", SYSTEMD_UNIT]);
967
+ if (!enabled.ok) return { ok: false, detail: firstLine2(enabled.stderr || enabled.stdout) };
968
+ const started = host.run("systemctl", ["--user", "restart", SYSTEMD_UNIT]);
969
+ if (!started.ok) return { ok: false, detail: firstLine2(started.stderr || started.stdout) };
970
+ return { ok: true };
971
+ }
972
+ function systemdStop(host) {
973
+ const res = host.run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
974
+ if (res.ok || notLoaded2(res.stderr + res.stdout)) return { ok: true };
975
+ return { ok: false, detail: firstLine2(res.stderr || res.stdout) };
976
+ }
977
+ function systemdProbe(host) {
978
+ const state = host.run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim();
979
+ if (state === "active") return { ownership: "held", running: true };
980
+ if (state === "activating" || state === "reloading" || state === "deactivating") {
981
+ return { ownership: "held", running: "transitional" };
982
+ }
983
+ if (state === "inactive" || state === "failed") return { ownership: "clear", running: false };
984
+ return { ownership: "unknown", running: "unknown" };
985
+ }
986
+ function systemdWantsLinkPath(host) {
987
+ return join5(dirname3(systemdUnitPath(host)), "default.target.wants", SYSTEMD_UNIT);
988
+ }
989
+ function systemdInstalled(host) {
990
+ return host.fs.exists(systemdUnitPath(host)) || host.fs.exists(systemdWantsLinkPath(host));
991
+ }
992
+ function systemdRemove(host, unitPath) {
993
+ const stopped = host.run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
994
+ if (!(stopped.ok || notLoaded2(stopped.stderr + stopped.stdout))) {
995
+ return { ok: false, detail: firstLine2(stopped.stderr || stopped.stdout) };
996
+ }
997
+ host.fs.remove(unitPath);
998
+ const link = systemdWantsLinkPath(host);
999
+ if (host.fs.exists(link)) host.fs.remove(link);
1000
+ systemdReload(host);
1001
+ return { ok: true };
1002
+ }
1003
+ var NOT_LOADED2 = new RegExp(
1004
+ `unit (file )?${SYSTEMD_UNIT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (not loaded|does not exist|not found|could not be found)`,
1005
+ "i"
1006
+ );
1007
+ function notLoaded2(output) {
1008
+ return NOT_LOADED2.test(output);
1009
+ }
1010
+ function readLinger(host) {
1011
+ const res = host.run("loginctl", ["show-user", String(host.uid), "-p", "Linger"]);
1012
+ if (!res.ok) return "unknown";
1013
+ const match = /Linger=(\w+)/.exec(res.stdout);
1014
+ if (!match) return "unknown";
1015
+ return match[1] === "yes" ? "yes" : "no";
1016
+ }
1017
+ function tryEnableLinger(host) {
1018
+ host.run("loginctl", ["enable-linger", String(host.uid)]);
1019
+ return readLinger(host);
1020
+ }
1021
+ function isRemoteSession(env) {
1022
+ return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1023
+ }
1024
+ function quote(value) {
1025
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1026
+ }
1027
+ function firstLine2(s) {
1028
+ return s.trim().split("\n")[0] ?? "";
1029
+ }
1030
+
1031
+ // src/service/index.ts
1032
+ function detectServiceManager(host = defaultServiceHost()) {
1033
+ if (host.platform === "darwin") return "launchd";
1034
+ if (host.platform !== "linux") return "none";
1035
+ return host.run("systemctl", ["--user", "show-environment"]).ok ? "systemd-user" : "none";
1036
+ }
1037
+ function programArguments(host, opts) {
1038
+ const args = [host.execPath, host.cliPath, "start", "--foreground", "--no-open"];
1039
+ if (opts.port !== void 0) args.push("--port", String(opts.port));
1040
+ return args;
1041
+ }
1042
+ function environment(host) {
1043
+ return {
1044
+ // PATH CAPTURE. launchd's default PATH is `/usr/bin:/bin:/usr/sbin:/sbin`
1045
+ // and a systemd user manager's is barely better — neither has `claude`,
1046
+ // `codex` or `opencode` on it. A companion that runs but can't find its
1047
+ // harness is worse than one that isn't running, because the device reads
1048
+ // Online. So we bake in the PATH of the shell that ran `start`, and because
1049
+ // it's part of the rendered content, a PATH that later drifts re-renders on
1050
+ // the next `start` like any other change.
1051
+ PATH: host.env.PATH ?? "",
1052
+ CABANE_COMPANION_DAEMON: "1"
1053
+ };
1054
+ }
1055
+ function unitPathFor(host, manager) {
1056
+ if (manager === "launchd") return launchAgentPath(host.home);
1057
+ if (manager === "systemd-user") return systemdUnitPath(host);
1058
+ return null;
1059
+ }
1060
+ function renderFor(host, manager, opts) {
1061
+ const input = { programArguments: programArguments(host, opts), environment: environment(host) };
1062
+ return manager === "launchd" ? renderPlist({ ...input, logPath: host.logPath }) : renderUnit(input);
1063
+ }
1064
+ function installService(opts = {}, host = defaultServiceHost()) {
1065
+ const manager = detectServiceManager(host);
1066
+ if (manager === "none") return { installed: false, reason: "unsupported" };
1067
+ const path = unitPathFor(host, manager);
1068
+ if (!path) return { installed: false, reason: "unsupported" };
1069
+ const desired = renderFor(host, manager, opts);
1070
+ const existing = host.fs.read(path);
1071
+ const hadUnit = existing !== null;
1072
+ const changed = existing !== desired;
1073
+ if (changed) {
1074
+ host.fs.mkdirp(dirname4(path));
1075
+ host.fs.write(path, desired);
1076
+ }
1077
+ if (manager === "systemd-user") {
1078
+ const linger = ensureLinger(host);
1079
+ if (linger !== "yes" && isRemoteSession(host.env)) {
1080
+ systemdRemove(host, path);
1081
+ return failedInstall(
1082
+ host,
1083
+ manager,
1084
+ "linger-unavailable",
1085
+ "a systemd --user service would stop when this SSH session ends (lingering is off and could not be enabled)"
1086
+ );
1087
+ }
1088
+ }
1089
+ const started = manager === "launchd" ? launchdStart(host, path) : systemdStart(host);
1090
+ if (!started.ok) {
1091
+ if (!hadUnit) removeService(host, manager, path);
1092
+ return failedInstall(host, manager, "command-failed", started.detail);
1093
+ }
1094
+ return { installed: true, manager, changed };
1095
+ }
1096
+ function failedInstall(host, manager, reason, detail) {
1097
+ return {
1098
+ installed: false,
1099
+ reason,
1100
+ ...detail ? { detail } : {},
1101
+ ...managerOwnership(host, manager) === "clear" ? {} : { leftBehind: manager }
1102
+ };
1103
+ }
1104
+ function managerProbe(host, manager) {
1105
+ if (manager === "launchd") return launchdProbe(host);
1106
+ if (manager === "systemd-user") return systemdProbe(host);
1107
+ return { ownership: "clear", running: false };
1108
+ }
1109
+ function managerOwnership(host, manager) {
1110
+ return managerProbe(host, manager).ownership;
1111
+ }
1112
+ function refreshInstalledService(opts = {}, host = defaultServiceHost()) {
1113
+ const manager = detectServiceManager(host);
1114
+ const path = unitPathFor(host, manager);
1115
+ if (!path || !host.fs.exists(path)) return false;
1116
+ const desired = renderFor(host, manager, opts);
1117
+ if (host.fs.read(path) === desired) return false;
1118
+ host.fs.write(path, desired);
1119
+ if (manager === "systemd-user") systemdReload(host);
1120
+ return true;
1121
+ }
1122
+ function stopService(host = defaultServiceHost()) {
1123
+ const manager = detectServiceManager(host);
1124
+ const path = unitPathFor(host, manager);
1125
+ if (manager === "none" || !path || !managerHasClaim(host, manager, path)) {
1126
+ return { handled: false, ok: true };
1127
+ }
1128
+ const res = manager === "launchd" ? launchdStop(host) : systemdStop(host);
1129
+ return {
1130
+ handled: true,
1131
+ ok: res.ok,
1132
+ manager,
1133
+ ...res.detail ? { detail: res.detail } : {}
1134
+ };
1135
+ }
1136
+ function disableService(host = defaultServiceHost()) {
1137
+ const manager = detectServiceManager(host);
1138
+ const path = unitPathFor(host, manager);
1139
+ if (manager === "none" || !path) return { handled: false, ok: true };
1140
+ if (!managerHasClaim(host, manager, path)) return { handled: false, ok: true, manager };
1141
+ const res = removeService(host, manager, path);
1142
+ return { handled: true, ok: res.ok, manager, ...res.detail ? { detail: res.detail } : {} };
1143
+ }
1144
+ function managerHasClaim(host, manager, path) {
1145
+ if (manager === "none") return false;
1146
+ if (host.fs.exists(path)) return true;
1147
+ if (manager === "systemd-user" && systemdInstalled(host)) return true;
1148
+ return managerOwnership(host, manager) !== "clear";
1149
+ }
1150
+ function removeService(host, manager, path) {
1151
+ return manager === "launchd" ? launchdRemove(host, path) : systemdRemove(host, path);
1152
+ }
1153
+ function serviceStatus(host = defaultServiceHost()) {
1154
+ const manager = detectServiceManager(host);
1155
+ const path = unitPathFor(host, manager);
1156
+ const probe = managerProbe(host, manager);
1157
+ const installed = manager === "none" || !path ? false : host.fs.exists(path) || probe.ownership === "held" || manager === "systemd-user" && systemdInstalled(host);
1158
+ const running = manager === "none" ? false : probe.running;
1159
+ return {
1160
+ manager,
1161
+ unitPath: path,
1162
+ installed,
1163
+ running,
1164
+ linger: manager === "systemd-user" ? readLinger(host) : null,
1165
+ remoteSession: isRemoteSession(host.env)
1166
+ };
1167
+ }
1168
+ function ensureLinger(host) {
1169
+ const current = readLinger(host);
1170
+ if (current === "yes") return current;
1171
+ return tryEnableLinger(host);
1172
+ }
1173
+
783
1174
  // src/commands/daemon.ts
784
1175
  var STARTUP_TIMEOUT_MS = 8e3;
785
1176
  var POLL_INTERVAL_MS = 150;
@@ -790,6 +1181,11 @@ async function startDaemon(opts = {}, deps = {}) {
790
1181
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
791
1182
  const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
792
1183
  const now = deps.now ?? (() => Date.now());
1184
+ const install = deps.installService ?? ((o) => installService(o));
1185
+ const refresh = deps.refreshService ?? ((o) => refreshInstalledService(o));
1186
+ const disable = deps.disableService ?? (() => disableService());
1187
+ const isTty = deps.isTty ?? (() => Boolean(process.stdin.isTTY));
1188
+ const serviceOpts = opts.port !== void 0 ? { port: opts.port } : {};
793
1189
  const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
794
1190
  // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
795
1191
  ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
@@ -809,30 +1205,61 @@ async function startDaemon(opts = {}, deps = {}) {
809
1205
  const existing = readState();
810
1206
  if (existing) {
811
1207
  if (await verify(existing) !== "stale") {
1208
+ const rerendered = isTty() ? refresh(serviceOpts) : false;
812
1209
  process.stdout.write(
813
1210
  `Cabane Companion is already running (pid ${existing.pid}).
814
1211
  \u2192 Dashboard: ${existing.url}
815
1212
  Stop it first with \`cabane-companion stop\` if you want to relaunch.
816
- `
1213
+ ` + (rerendered ? "Autostart: the login service was updated for this install \u2014 it takes effect at the next login, or now with `cabane-companion stop` then `start`.\n" : "")
817
1214
  );
818
1215
  return;
819
1216
  }
820
1217
  clearRuntimeState();
821
1218
  }
1219
+ let service2 = isTty() ? install(serviceOpts) : { installed: false, reason: "unsupported" };
822
1220
  const args = ["start", "--no-open"];
823
1221
  if (opts.port !== void 0) args.push("--port", String(opts.port));
824
- const child = spawnDetached(args);
825
- child.unref();
1222
+ const launchDetached = () => {
1223
+ const spawned = spawnDetached(args);
1224
+ spawned.unref();
1225
+ return spawned;
1226
+ };
826
1227
  const ready = (s) => !!s && !!s.url;
827
- const deadline = now() + STARTUP_TIMEOUT_MS;
828
- let state = readState();
829
- while (!ready(state) && now() < deadline) {
830
- await sleep4(POLL_INTERVAL_MS);
831
- state = readState();
1228
+ const waitForReady = async () => {
1229
+ const deadline = now() + STARTUP_TIMEOUT_MS;
1230
+ let seen = readState();
1231
+ while (!ready(seen) && now() < deadline) {
1232
+ await sleep4(POLL_INTERVAL_MS);
1233
+ seen = readState();
1234
+ }
1235
+ return ready(seen) ? seen : null;
1236
+ };
1237
+ if (!service2.installed && service2.leftBehind) {
1238
+ refuseDouble(service2.leftBehind, service2.detail);
1239
+ process.exitCode = 1;
1240
+ return;
832
1241
  }
833
- if (!ready(state)) {
1242
+ let child = service2.installed ? null : launchDetached();
1243
+ let state = await waitForReady();
1244
+ if (!state && service2.installed) {
834
1245
  process.stdout.write(
835
- `Cabane Companion was launched (pid ${child.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
1246
+ `${service2.manager} started the companion but it didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s \u2014 removing the login service.
1247
+ `
1248
+ );
1249
+ const removed = disable();
1250
+ if (!removed.handled || !removed.ok) {
1251
+ refuseDouble(service2.manager, removed.detail);
1252
+ process.exitCode = 1;
1253
+ return;
1254
+ }
1255
+ process.stdout.write("Launching it directly instead.\n");
1256
+ service2 = { installed: false, reason: "command-failed", detail: "the service never came up" };
1257
+ child = launchDetached();
1258
+ state = await waitForReady();
1259
+ }
1260
+ if (!state) {
1261
+ process.stdout.write(
1262
+ `Cabane Companion was launched (pid ${child?.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
836
1263
  Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
837
1264
  `
838
1265
  );
@@ -845,12 +1272,28 @@ Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
845
1272
  Logs: ${companionLogPath()}
846
1273
  Status: cabane-companion status
847
1274
  Stop: cabane-companion stop
1275
+ `
1276
+ );
1277
+ if (service2.installed) {
1278
+ process.stdout.write(`Autostart: enabled (${service2.manager}) \u2014 it starts again at login.
1279
+ `);
1280
+ } else if (service2.reason !== "unsupported") {
1281
+ process.stdout.write(
1282
+ `Autostart: not enabled \u2014 ${service2.detail ?? "the service manager refused the install"}. Running detached instead; it won't come back after a reboot.
1283
+ `
1284
+ );
1285
+ }
1286
+ }
1287
+ function refuseDouble(manager, detail) {
1288
+ process.stdout.write(
1289
+ `${manager} still has the login service${detail ? ` (${detail})` : ""} \u2014 not launching a second companion beside a service that may still own one.
1290
+ Check \`cabane-companion service status\`, then \`cabane-companion service disable\`, and run \`cabane-companion start --daemon\` again.
848
1291
  `
849
1292
  );
850
1293
  }
851
1294
  function defaultSpawnDetached(args) {
852
- const cliPath = fileURLToPath(new URL("../cli.js", import.meta.url));
853
- mkdirSync4(cabaneDir(), { recursive: true });
1295
+ const cliPath = companionCliEntry();
1296
+ mkdirSync5(cabaneDir(), { recursive: true });
854
1297
  const logFd = openSync2(companionLogPath(), "a");
855
1298
  try {
856
1299
  return spawn4(process.execPath, [cliPath, ...args], {
@@ -869,25 +1312,25 @@ import { confirm } from "@inquirer/prompts";
869
1312
  // src/credentials.ts
870
1313
  import {
871
1314
  chmodSync as chmodSync2,
872
- existsSync as existsSync3,
873
- mkdirSync as mkdirSync5,
874
- readFileSync as readFileSync3,
1315
+ existsSync as existsSync5,
1316
+ mkdirSync as mkdirSync6,
1317
+ readFileSync as readFileSync4,
875
1318
  renameSync as renameSync2,
876
- rmSync as rmSync3,
877
- writeFileSync as writeFileSync3
1319
+ rmSync as rmSync4,
1320
+ writeFileSync as writeFileSync4
878
1321
  } from "fs";
879
- import { dirname as dirname3, join as join4 } from "path";
1322
+ import { dirname as dirname5, join as join6 } from "path";
880
1323
  import { z as z3 } from "zod";
881
1324
  function credentialsPath() {
882
- return join4(cabaneDir(), "credentials.json");
1325
+ return join6(cabaneDir(), "credentials.json");
883
1326
  }
884
1327
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
885
1328
  function load() {
886
1329
  const path = credentialsPath();
887
- if (!existsSync3(path)) return {};
1330
+ if (!existsSync5(path)) return {};
888
1331
  let raw;
889
1332
  try {
890
- raw = readFileSync3(path, "utf8");
1333
+ raw = readFileSync4(path, "utf8");
891
1334
  } catch {
892
1335
  return {};
893
1336
  }
@@ -901,14 +1344,14 @@ function load() {
901
1344
  }
902
1345
  function save(map) {
903
1346
  const path = credentialsPath();
904
- mkdirSync5(dirname3(path), { recursive: true });
1347
+ mkdirSync6(dirname5(path), { recursive: true });
905
1348
  try {
906
1349
  chmodSync2(cabaneDir(), 448);
907
1350
  } catch {
908
1351
  }
909
1352
  const tmp = `${path}.${process.pid}.tmp`;
910
1353
  try {
911
- writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1354
+ writeFileSync4(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
912
1355
  try {
913
1356
  chmodSync2(tmp, 384);
914
1357
  } catch {
@@ -916,7 +1359,7 @@ function save(map) {
916
1359
  renameSync2(tmp, path);
917
1360
  } catch (err) {
918
1361
  try {
919
- rmSync3(tmp, { force: true });
1362
+ rmSync4(tmp, { force: true });
920
1363
  } catch {
921
1364
  }
922
1365
  throw err;
@@ -946,7 +1389,7 @@ function pruneCredentials(keepAgentIds) {
946
1389
  }
947
1390
  function clearCredentials() {
948
1391
  const path = credentialsPath();
949
- if (existsSync3(path)) writeFileSync3(path, "", { mode: 384 });
1392
+ if (existsSync5(path)) writeFileSync4(path, "", { mode: 384 });
950
1393
  }
951
1394
 
952
1395
  // src/commands/logout.ts
@@ -1132,7 +1575,71 @@ async function pair(opts = {}) {
1132
1575
  }
1133
1576
 
1134
1577
  // src/cli.ts
1135
- import { readFileSync as readFileSync11 } from "fs";
1578
+ import { readFileSync as readFileSync12 } from "fs";
1579
+
1580
+ // src/commands/service.ts
1581
+ function serviceStatusCommand(deps = {}) {
1582
+ const status2 = (deps.status ?? serviceStatus)();
1583
+ if (status2.manager === "none") {
1584
+ process.stdout.write(
1585
+ "service: not supported on this machine yet \u2014 `cabane-companion start` runs the companion detached instead (it stops at the next reboot).\n"
1586
+ );
1587
+ return;
1588
+ }
1589
+ process.stdout.write(`manager: ${status2.manager}
1590
+ `);
1591
+ process.stdout.write(
1592
+ `unit: ${status2.unitPath}${status2.installed ? "" : " (not written)"}
1593
+ `
1594
+ );
1595
+ process.stdout.write(`installed: ${status2.installed ? "yes" : "no"}
1596
+ `);
1597
+ process.stdout.write(`running: ${runningLine(status2.running)}
1598
+ `);
1599
+ if (status2.manager === "systemd-user") {
1600
+ process.stdout.write(`linger: ${status2.linger}${lingerNote(status2)}
1601
+ `);
1602
+ }
1603
+ if (status2.installed) {
1604
+ process.stdout.write("disable: cabane-companion service disable\n");
1605
+ } else {
1606
+ process.stdout.write("install: cabane-companion start (installs it for you)\n");
1607
+ }
1608
+ }
1609
+ function serviceDisableCommand(deps = {}) {
1610
+ const disable = deps.disable ?? disableService;
1611
+ const res = disable();
1612
+ if (!res.handled) {
1613
+ process.stdout.write("service: nothing installed (nothing to remove).\n");
1614
+ return;
1615
+ }
1616
+ if (!res.ok) {
1617
+ process.stdout.write(
1618
+ `service: nothing was removed \u2014 ${res.detail ?? "the service manager reported an error"}.
1619
+ The unit is still installed and may still be running; \`cabane-companion service status\` shows where it stands.
1620
+ `
1621
+ );
1622
+ process.exitCode = 1;
1623
+ return;
1624
+ }
1625
+ process.stdout.write(
1626
+ "service: stopped and removed. The companion no longer starts at login; `cabane-companion start` sets it up again.\n"
1627
+ );
1628
+ }
1629
+ function runningLine(running) {
1630
+ if (running === "transitional") {
1631
+ return "starting or stopping \u2014 the manager reported a state in transition; ask again in a moment";
1632
+ }
1633
+ if (running === "unknown") return "unknown \u2014 the service manager didn't answer";
1634
+ return running ? "yes" : "no";
1635
+ }
1636
+ function lingerNote(status2) {
1637
+ if (status2.linger === "yes") return " (survives logout)";
1638
+ if (status2.remoteSession) {
1639
+ return " \u2014 without lingering a --user service stops when this SSH session ends";
1640
+ }
1641
+ return " \u2014 the service runs while you are logged in";
1642
+ }
1136
1643
 
1137
1644
  // src/browser.ts
1138
1645
  import { spawn as spawn5 } from "child_process";
@@ -1169,15 +1676,15 @@ function shouldAutoOpen(opts) {
1169
1676
  import { randomUUID as randomUUID2 } from "crypto";
1170
1677
 
1171
1678
  // src/dashboard/server.ts
1172
- import { dirname as dirname4, join as join6 } from "path";
1679
+ import { dirname as dirname6, join as join8 } from "path";
1173
1680
  import { fileURLToPath as fileURLToPath2 } from "url";
1174
1681
  import { serve } from "@hono/node-server";
1175
1682
  import { Hono } from "hono";
1176
1683
 
1177
1684
  // src/dashboard/routes.ts
1178
- import { openSync as openSync3, readSync, closeSync as closeSync3, fstatSync, existsSync as existsSync4 } from "fs";
1685
+ import { openSync as openSync3, readSync, closeSync as closeSync3, fstatSync, existsSync as existsSync6 } from "fs";
1179
1686
  import { readFile } from "fs/promises";
1180
- import { extname, join as join5, normalize } from "path";
1687
+ import { extname, join as join7, normalize } from "path";
1181
1688
  import { streamSSE } from "hono/streaming";
1182
1689
 
1183
1690
  // src/state.ts
@@ -1426,14 +1933,14 @@ var CONTENT_TYPES = {
1426
1933
  function registerRoutes(app, deps) {
1427
1934
  const { supervisor, hub, staticDir } = deps;
1428
1935
  app.get("/", async (c) => {
1429
- const html = await readFile(join5(staticDir, "index.html"), "utf8");
1936
+ const html = await readFile(join7(staticDir, "index.html"), "utf8");
1430
1937
  return c.html(html);
1431
1938
  });
1432
1939
  app.get("/static/:file", async (c) => {
1433
1940
  const file = c.req.param("file");
1434
1941
  const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1435
- const full = join5(staticDir, safe3);
1436
- if (!full.startsWith(staticDir) || !existsSync4(full)) return c.notFound();
1942
+ const full = join7(staticDir, safe3);
1943
+ if (!full.startsWith(staticDir) || !existsSync6(full)) return c.notFound();
1437
1944
  const body = await readFile(full);
1438
1945
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
1439
1946
  c.header("content-type", type);
@@ -1560,7 +2067,7 @@ function clampLimit(raw, fallback, max = 200) {
1560
2067
  return Math.min(Math.floor(n), max);
1561
2068
  }
1562
2069
  function tailFile(path, lines) {
1563
- if (!existsSync4(path)) return [];
2070
+ if (!existsSync6(path)) return [];
1564
2071
  const MAX_BYTES = 256 * 1024;
1565
2072
  let fd;
1566
2073
  try {
@@ -1649,7 +2156,7 @@ function isAddrInUse(err) {
1649
2156
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
1650
2157
  }
1651
2158
  function resolveStaticDir() {
1652
- return join6(dirname4(fileURLToPath2(import.meta.url)), "static");
2159
+ return join8(dirname6(fileURLToPath2(import.meta.url)), "static");
1653
2160
  }
1654
2161
 
1655
2162
  // src/api.ts
@@ -2125,21 +2632,21 @@ function errorMessage2(status2, body) {
2125
2632
  }
2126
2633
 
2127
2634
  // src/cursor.ts
2128
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
2129
- import { join as join7 } from "path";
2635
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2636
+ import { join as join9 } from "path";
2130
2637
  function pathFor(workspaceId) {
2131
- return join7(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2638
+ return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2132
2639
  }
2133
2640
  function readCursor(workspaceId) {
2134
2641
  const path = pathFor(workspaceId);
2135
- if (!existsSync5(path)) return null;
2136
- const raw = readFileSync4(path, "utf8").trim();
2642
+ if (!existsSync7(path)) return null;
2643
+ const raw = readFileSync5(path, "utf8").trim();
2137
2644
  return raw.length > 0 ? raw : null;
2138
2645
  }
2139
2646
  function writeCursor(workspaceId, eventId) {
2140
2647
  const path = pathFor(workspaceId);
2141
- mkdirSync6(join7(cabaneDir(), "cursors"), { recursive: true });
2142
- writeFileSync4(path, eventId + "\n", "utf8");
2648
+ mkdirSync7(join9(cabaneDir(), "cursors"), { recursive: true });
2649
+ writeFileSync5(path, eventId + "\n", "utf8");
2143
2650
  }
2144
2651
 
2145
2652
  // src/cursor-tracker.ts
@@ -2182,20 +2689,20 @@ var CursorTracker = class {
2182
2689
  };
2183
2690
 
2184
2691
  // src/dispatch-dedupe.ts
2185
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "fs";
2186
- import { join as join8 } from "path";
2692
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
2693
+ import { join as join10 } from "path";
2187
2694
  var MAX_IDS = 256;
2188
2695
  function dir(log) {
2189
- return join8(cabaneDir(), log);
2696
+ return join10(cabaneDir(), log);
2190
2697
  }
2191
2698
  function pathFor2(log, workspaceId) {
2192
- return join8(dir(log), encodeURIComponent(workspaceId));
2699
+ return join10(dir(log), encodeURIComponent(workspaceId));
2193
2700
  }
2194
2701
  function readIds(log, workspaceId) {
2195
2702
  const path = pathFor2(log, workspaceId);
2196
- if (!existsSync6(path)) return [];
2703
+ if (!existsSync8(path)) return [];
2197
2704
  try {
2198
- return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2705
+ return readFileSync6(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2199
2706
  } catch {
2200
2707
  return [];
2201
2708
  }
@@ -2208,8 +2715,8 @@ function mark(log, workspaceId, eventId) {
2208
2715
  if (ids.includes(eventId)) return;
2209
2716
  ids.push(eventId);
2210
2717
  const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
2211
- mkdirSync7(dir(log), { recursive: true });
2212
- writeFileSync5(pathFor2(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
2718
+ mkdirSync8(dir(log), { recursive: true });
2719
+ writeFileSync6(pathFor2(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
2213
2720
  }
2214
2721
  function hasDispatched(workspaceId, eventId) {
2215
2722
  return has("dispatched", workspaceId, eventId);
@@ -2225,17 +2732,17 @@ function markCompleted(workspaceId, eventId) {
2225
2732
  }
2226
2733
  var MAX_RESUME_ATTEMPTS = 3;
2227
2734
  function resumeDir() {
2228
- return join8(cabaneDir(), "resume-attempts");
2735
+ return join10(cabaneDir(), "resume-attempts");
2229
2736
  }
2230
2737
  function resumePathFor(workspaceId) {
2231
- return join8(resumeDir(), encodeURIComponent(workspaceId));
2738
+ return join10(resumeDir(), encodeURIComponent(workspaceId));
2232
2739
  }
2233
2740
  function readResumeCounts(workspaceId) {
2234
2741
  const out = /* @__PURE__ */ new Map();
2235
2742
  const path = resumePathFor(workspaceId);
2236
- if (!existsSync6(path)) return out;
2743
+ if (!existsSync8(path)) return out;
2237
2744
  try {
2238
- for (const line of readFileSync5(path, "utf8").split("\n")) {
2745
+ for (const line of readFileSync6(path, "utf8").split("\n")) {
2239
2746
  const trimmed = line.trim();
2240
2747
  if (!trimmed) continue;
2241
2748
  const tab = trimmed.lastIndexOf(" ");
@@ -2255,8 +2762,8 @@ function bumpResumeAttempt(workspaceId, eventId) {
2255
2762
  counts.set(eventId, next);
2256
2763
  const entries = [...counts.entries()];
2257
2764
  const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
2258
- mkdirSync7(resumeDir(), { recursive: true });
2259
- writeFileSync5(
2765
+ mkdirSync8(resumeDir(), { recursive: true });
2766
+ writeFileSync6(
2260
2767
  resumePathFor(workspaceId),
2261
2768
  trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
2262
2769
  "utf8"
@@ -5968,8 +6475,8 @@ var ConnectorHealthStore = class {
5968
6475
 
5969
6476
  // src/dispatcher.ts
5970
6477
  import { randomUUID } from "crypto";
5971
- import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync10, statSync } from "fs";
5972
- import { join as join13 } from "path";
6478
+ import { appendFileSync as appendFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync11, statSync } from "fs";
6479
+ import { join as join15 } from "path";
5973
6480
 
5974
6481
  // src/summon.ts
5975
6482
  import { z as z12 } from "zod";
@@ -6302,11 +6809,11 @@ function trimSlash3(s) {
6302
6809
  // src/codex-instructions.ts
6303
6810
  import { mkdtemp, rm, writeFile } from "fs/promises";
6304
6811
  import { tmpdir } from "os";
6305
- import { join as join9 } from "path";
6812
+ import { join as join11 } from "path";
6306
6813
  var PREFIX = "cabane-codex-instructions-";
6307
6814
  async function writeCodexInstructionsFile(contents) {
6308
- const dir2 = await mkdtemp(join9(tmpdir(), PREFIX));
6309
- const path = join9(dir2, "instructions.md");
6815
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
6816
+ const path = join11(dir2, "instructions.md");
6310
6817
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
6311
6818
  return {
6312
6819
  path,
@@ -6317,22 +6824,22 @@ async function writeCodexInstructionsFile(contents) {
6317
6824
  }
6318
6825
 
6319
6826
  // src/prepared.ts
6320
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
6321
- import { join as join10 } from "path";
6827
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync7, existsSync as existsSync9 } from "fs";
6828
+ import { join as join12 } from "path";
6322
6829
  function dirFor(workspaceId) {
6323
- return join10(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6830
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6324
6831
  }
6325
6832
  function conversationDir(workspaceId, conversationId) {
6326
- return join10(dirFor(workspaceId), encodeURIComponent(conversationId));
6833
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
6327
6834
  }
6328
6835
  function pathFor3(workspaceId, conversationId, agentId) {
6329
- return join10(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6836
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6330
6837
  }
6331
6838
  function readPrepared(workspaceId, conversationId, agentId) {
6332
6839
  const path = pathFor3(workspaceId, conversationId, agentId);
6333
- if (!existsSync7(path)) return null;
6840
+ if (!existsSync9(path)) return null;
6334
6841
  try {
6335
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
6842
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
6336
6843
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
6337
6844
  return {
6338
6845
  cwd: parsed.cwd,
@@ -6345,32 +6852,32 @@ function readPrepared(workspaceId, conversationId, agentId) {
6345
6852
  }
6346
6853
  }
6347
6854
  function writePrepared(workspaceId, conversationId, agentId, result) {
6348
- mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
6349
- writeFileSync6(
6855
+ mkdirSync9(conversationDir(workspaceId, conversationId), { recursive: true });
6856
+ writeFileSync7(
6350
6857
  pathFor3(workspaceId, conversationId, agentId),
6351
6858
  JSON.stringify(result) + "\n",
6352
6859
  "utf8"
6353
6860
  );
6354
6861
  }
6355
6862
  function clearPrepared(workspaceId, conversationId, agentId) {
6356
- rmSync4(pathFor3(workspaceId, conversationId, agentId), { force: true });
6863
+ rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
6357
6864
  }
6358
6865
 
6359
6866
  // src/secrets.ts
6360
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
6361
- import { join as join11 } from "path";
6867
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
6868
+ import { join as join13 } from "path";
6362
6869
  import { z as z13 } from "zod";
6363
6870
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6364
6871
  function secretsPath() {
6365
- return join11(cabaneDir(), "secrets.json");
6872
+ return join13(cabaneDir(), "secrets.json");
6366
6873
  }
6367
6874
  var secretStoreSchema = z13.record(z13.string(), z13.string());
6368
6875
  function loadSecretStore() {
6369
6876
  const path = secretsPath();
6370
- if (!existsSync8(path)) return makeStore({});
6877
+ if (!existsSync10(path)) return makeStore({});
6371
6878
  let raw;
6372
6879
  try {
6373
- raw = readFileSync7(path, "utf8");
6880
+ raw = readFileSync8(path, "utf8");
6374
6881
  } catch (err) {
6375
6882
  throw new ConfigError(
6376
6883
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -6448,10 +6955,10 @@ function resolveMcpSecrets(mcpServers, store) {
6448
6955
  }
6449
6956
 
6450
6957
  // src/transcript-writer.ts
6451
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync5 } from "fs";
6452
- import { join as join12 } from "path";
6958
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync10, readdirSync, rmSync as rmSync6 } from "fs";
6959
+ import { join as join14 } from "path";
6453
6960
  function transcriptsDir() {
6454
- return join12(cabaneDir(), "transcripts");
6961
+ return join14(cabaneDir(), "transcripts");
6455
6962
  }
6456
6963
  var RETAIN = 200;
6457
6964
  var TranscriptWriter = class {
@@ -6460,9 +6967,9 @@ var TranscriptWriter = class {
6460
6967
  onWarn;
6461
6968
  constructor(dir2, meta, onWarn) {
6462
6969
  this.onWarn = onWarn;
6463
- this.path = join12(dir2, fileName(meta));
6970
+ this.path = join14(dir2, fileName(meta));
6464
6971
  try {
6465
- mkdirSync9(dir2, { recursive: true });
6972
+ mkdirSync10(dir2, { recursive: true });
6466
6973
  try {
6467
6974
  chmodSync3(dir2, 448);
6468
6975
  } catch {
@@ -6519,7 +7026,7 @@ function pruneOld(dir2, retain) {
6519
7026
  const drop = files.sort().slice(0, files.length - retain);
6520
7027
  for (const f of drop) {
6521
7028
  try {
6522
- rmSync5(join12(dir2, f), { force: true });
7029
+ rmSync6(join14(dir2, f), { force: true });
6523
7030
  } catch {
6524
7031
  }
6525
7032
  }
@@ -6642,9 +7149,9 @@ var TurnCommitter = class {
6642
7149
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
6643
7150
  // server strips it again and resolves / ignores an unknown id.
6644
7151
  summonField() {
6645
- const target = this.deps.summonState.agentId;
6646
- if (!target || target === this.deps.agentId) return {};
6647
- return { dispatch: target };
7152
+ const target2 = this.deps.summonState.agentId;
7153
+ if (!target2 || target2 === this.deps.agentId) return {};
7154
+ return { dispatch: target2 };
6648
7155
  }
6649
7156
  // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
6650
7157
  // The server validates the target (must be a workspace member/owner) and
@@ -6819,16 +7326,16 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6819
7326
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6820
7327
  function checkoutState(cwd) {
6821
7328
  if (!cwd) return { ok: false, reason: "no checkout was resolved for this turn" };
6822
- if (!existsSync9(cwd)) return { ok: false, reason: `the checkout directory is gone (${cwd})` };
6823
- const gitPath = join13(cwd, ".git");
6824
- if (!existsSync9(gitPath)) return { ok: false, reason: `${cwd} holds no git metadata` };
7329
+ if (!existsSync11(cwd)) return { ok: false, reason: `the checkout directory is gone (${cwd})` };
7330
+ const gitPath = join15(cwd, ".git");
7331
+ if (!existsSync11(gitPath)) return { ok: false, reason: `${cwd} holds no git metadata` };
6825
7332
  let stat;
6826
7333
  try {
6827
7334
  stat = statSync(gitPath);
6828
7335
  } catch (error) {
6829
7336
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
6830
7337
  }
6831
- if (stat.isDirectory() && !existsSync9(join13(gitPath, "HEAD")))
7338
+ if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
6832
7339
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
6833
7340
  return { ok: true, reason: "usable" };
6834
7341
  }
@@ -7019,7 +7526,7 @@ var Dispatcher = class {
7019
7526
  let seqCounter = 0;
7020
7527
  const nextSeq = () => ++seqCounter;
7021
7528
  let effectiveCwd = localCwd ?? cabaneCwd;
7022
- if (effectiveCwd && !existsSync9(effectiveCwd)) {
7529
+ if (effectiveCwd && !existsSync11(effectiveCwd)) {
7023
7530
  turnLog.warn(
7024
7531
  { cwd: effectiveCwd },
7025
7532
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -7157,9 +7664,9 @@ ${reason}`,
7157
7664
  }
7158
7665
  let turnEnv = hookEnv;
7159
7666
  if (effectiveCwd && turnContext.runtime === "codex") {
7160
- const tmpDir = join13(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
7667
+ const tmpDir = join15(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
7161
7668
  try {
7162
- mkdirSync10(tmpDir, { recursive: true });
7669
+ mkdirSync11(tmpDir, { recursive: true });
7163
7670
  turnEnv = { ...hookEnv, TMPDIR: tmpDir };
7164
7671
  } catch (err) {
7165
7672
  turnLog.warn(
@@ -7298,11 +7805,11 @@ ${reason}`,
7298
7805
  const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
7299
7806
  const closeTurnReceipt = (ok, reason) => {
7300
7807
  if (!turnReceiptPath) return;
7301
- const target = turnReceiptPath;
7808
+ const target2 = turnReceiptPath;
7302
7809
  turnReceiptPath = null;
7303
7810
  try {
7304
7811
  appendFileSync2(
7305
- target,
7812
+ target2,
7306
7813
  `${JSON.stringify({
7307
7814
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7308
7815
  event: "settled",
@@ -7342,7 +7849,7 @@ ${reason}`,
7342
7849
  }
7343
7850
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7344
7851
  }
7345
- const receiptPath = join13(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7852
+ const receiptPath = join15(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7346
7853
  const receiptLine = (fields) => `${JSON.stringify({
7347
7854
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7348
7855
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7366,7 +7873,7 @@ ${reason}`,
7366
7873
  })}
7367
7874
  `;
7368
7875
  try {
7369
- mkdirSync10(join13(effectiveCwd, ".git", "cabane"), { recursive: true });
7876
+ mkdirSync11(join15(effectiveCwd, ".git", "cabane"), { recursive: true });
7370
7877
  appendFileSync2(
7371
7878
  receiptPath,
7372
7879
  // `starting` is the honest classification before the proof has run. The
@@ -8032,15 +8539,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
8032
8539
 
8033
8540
  // src/outbox.ts
8034
8541
  import {
8035
- existsSync as existsSync10,
8036
- mkdirSync as mkdirSync11,
8542
+ existsSync as existsSync12,
8543
+ mkdirSync as mkdirSync12,
8037
8544
  readdirSync as readdirSync2,
8038
- readFileSync as readFileSync8,
8545
+ readFileSync as readFileSync9,
8039
8546
  renameSync as renameSync3,
8040
- rmSync as rmSync6,
8041
- writeFileSync as writeFileSync7
8547
+ rmSync as rmSync7,
8548
+ writeFileSync as writeFileSync8
8042
8549
  } from "fs";
8043
- import { join as join14 } from "path";
8550
+ import { join as join16 } from "path";
8044
8551
  var MAX_ENTRIES = 2e3;
8045
8552
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
8046
8553
  var Outbox = class {
@@ -8053,25 +8560,25 @@ var Outbox = class {
8053
8560
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
8054
8561
  // cases route writes at the right tmpdir.
8055
8562
  dir() {
8056
- return join14(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8563
+ return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8057
8564
  }
8058
8565
  fileFor(turnId, seq) {
8059
- return join14(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8566
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8060
8567
  }
8061
8568
  // Persist a commit for later draining. Atomic (temp file + rename) so a
8062
8569
  // concurrent `list()` never reads a half-written entry, then enforces the
8063
8570
  // per-workspace bounds.
8064
8571
  persist(entry) {
8065
8572
  const dir2 = this.dir();
8066
- mkdirSync11(dir2, { recursive: true });
8067
- const target = this.fileFor(entry.turnId, entry.seq);
8068
- const tmp = `${target}.${process.pid}.tmp`;
8573
+ mkdirSync12(dir2, { recursive: true });
8574
+ const target2 = this.fileFor(entry.turnId, entry.seq);
8575
+ const tmp = `${target2}.${process.pid}.tmp`;
8069
8576
  try {
8070
- writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
8071
- renameSync3(tmp, target);
8577
+ writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
8578
+ renameSync3(tmp, target2);
8072
8579
  } catch (err) {
8073
8580
  try {
8074
- rmSync6(tmp, { force: true });
8581
+ rmSync7(tmp, { force: true });
8075
8582
  } catch {
8076
8583
  }
8077
8584
  this.log?.warn(
@@ -8088,7 +8595,7 @@ var Outbox = class {
8088
8595
  // wedging the drain.
8089
8596
  list() {
8090
8597
  const dir2 = this.dir();
8091
- if (!existsSync10(dir2)) return [];
8598
+ if (!existsSync12(dir2)) return [];
8092
8599
  let names;
8093
8600
  try {
8094
8601
  names = readdirSync2(dir2);
@@ -8098,9 +8605,9 @@ var Outbox = class {
8098
8605
  const entries = [];
8099
8606
  for (const name of names) {
8100
8607
  if (!name.endsWith(".json")) continue;
8101
- const full = join14(dir2, name);
8608
+ const full = join16(dir2, name);
8102
8609
  try {
8103
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
8610
+ const parsed = JSON.parse(readFileSync9(full, "utf8"));
8104
8611
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
8105
8612
  entries.push(parsed);
8106
8613
  } else {
@@ -8118,13 +8625,13 @@ var Outbox = class {
8118
8625
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
8119
8626
  remove(turnId, seq) {
8120
8627
  try {
8121
- rmSync6(this.fileFor(turnId, seq), { force: true });
8628
+ rmSync7(this.fileFor(turnId, seq), { force: true });
8122
8629
  } catch {
8123
8630
  }
8124
8631
  }
8125
8632
  size() {
8126
8633
  const dir2 = this.dir();
8127
- if (!existsSync10(dir2)) return 0;
8634
+ if (!existsSync12(dir2)) return 0;
8128
8635
  try {
8129
8636
  return readdirSync2(dir2).filter((n) => n.endsWith(".json")).length;
8130
8637
  } catch {
@@ -8137,7 +8644,7 @@ var Outbox = class {
8137
8644
  "companion outbox: dropping unreadable entry"
8138
8645
  );
8139
8646
  try {
8140
- rmSync6(full, { force: true });
8647
+ rmSync7(full, { force: true });
8141
8648
  } catch {
8142
8649
  }
8143
8650
  }
@@ -8377,6 +8884,7 @@ var CompanionSupervisor = class {
8377
8884
  refreshing = false;
8378
8885
  stopped = false;
8379
8886
  draining = false;
8887
+ restartPending = false;
8380
8888
  // CT484: latch so the companion/server version-skew warning is logged once, not
8381
8889
  // on every 30s heartbeat.
8382
8890
  versionSkewWarned = false;
@@ -8487,7 +8995,8 @@ var CompanionSupervisor = class {
8487
8995
  // actually landed (`harnessSignals` non-null) — an absent field means "we
8488
8996
  // didn't look this beat" and leaves the server's stored suggestion alone,
8489
8997
  // the same fail-soft contract `models` keeps.
8490
- ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
8998
+ ...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {},
8999
+ ...this.restartPending ? { restarting: true } : {}
8491
9000
  });
8492
9001
  this.hub.setDevice({ deviceId: res.deviceId });
8493
9002
  this.deviceId = res.deviceId;
@@ -9125,32 +9634,41 @@ var CompanionSupervisor = class {
9125
9634
  );
9126
9635
  }
9127
9636
  async drainForRestart(graceMs) {
9128
- this.draining = true;
9129
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
9130
- if (this.pollTimer) clearInterval(this.pollTimer);
9637
+ this.restartPending = true;
9131
9638
  if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
9132
9639
  if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
9133
- await this.deviceApi.beginDrain();
9134
- for (const wr of this.workspaces.values()) wr.sub?.stop();
9135
- const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
9640
+ await this.sendHeartbeat();
9641
+ const drainStartedAt = Date.now();
9642
+ const deadline = Date.now() + Math.max(0, graceMs);
9136
9643
  let timedOut = false;
9137
- if (turns.length > 0) {
9644
+ while (true) {
9645
+ const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
9646
+ if (turns.length === 0) break;
9647
+ const remainingMs = deadline - Date.now();
9648
+ if (remainingMs <= 0) {
9649
+ timedOut = true;
9650
+ break;
9651
+ }
9138
9652
  let timer;
9139
9653
  await Promise.race([
9140
9654
  Promise.allSettled(turns),
9141
9655
  new Promise((resolve) => {
9142
- timer = setTimeout(
9143
- () => {
9144
- timedOut = true;
9145
- resolve();
9146
- },
9147
- Math.max(0, graceMs)
9148
- );
9656
+ timer = setTimeout(resolve, remainingMs);
9149
9657
  timer.unref?.();
9150
9658
  })
9151
9659
  ]);
9152
9660
  if (timer) clearTimeout(timer);
9153
9661
  }
9662
+ this.log.info(
9663
+ { waitedMs: Date.now() - drainStartedAt, timedOut },
9664
+ timedOut ? "companion: deploy drain grace elapsed; fencing admission for restart" : "companion: deploy drain reached a quiet point; fencing admission for restart"
9665
+ );
9666
+ this.draining = true;
9667
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
9668
+ if (this.pollTimer) clearInterval(this.pollTimer);
9669
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
9670
+ await this.deviceApi.beginDrain();
9671
+ for (const wr of this.workspaces.values()) wr.sub?.stop();
9154
9672
  await Promise.allSettled(
9155
9673
  [...this.workspaces.values()].flatMap(
9156
9674
  (wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
@@ -9241,22 +9759,22 @@ function handleUncaught(log, err, origin) {
9241
9759
  }
9242
9760
 
9243
9761
  // src/crash-marker.ts
9244
- import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
9245
- import { join as join15 } from "path";
9762
+ import { existsSync as existsSync13, mkdirSync as mkdirSync13, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
9763
+ import { join as join17 } from "path";
9246
9764
  function crashMarkerPath() {
9247
- return join15(cabaneDir(), "last-error.json");
9765
+ return join17(cabaneDir(), "last-error.json");
9248
9766
  }
9249
9767
  function recordCrash(rec2) {
9250
9768
  try {
9251
- mkdirSync12(cabaneDir(), { recursive: true });
9252
- writeFileSync8(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
9769
+ mkdirSync13(cabaneDir(), { recursive: true });
9770
+ writeFileSync9(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
9253
9771
  } catch {
9254
9772
  }
9255
9773
  }
9256
9774
  function clearCrash() {
9257
9775
  try {
9258
9776
  const path = crashMarkerPath();
9259
- if (existsSync11(path)) rmSync7(path, { force: true });
9777
+ if (existsSync13(path)) rmSync8(path, { force: true });
9260
9778
  } catch {
9261
9779
  }
9262
9780
  }
@@ -9467,7 +9985,8 @@ companion: deploy drain requested (${graceMs}ms grace)\u2026
9467
9985
 
9468
9986
  // src/commands/status.ts
9469
9987
  import { readdirSync as readdirSync3 } from "fs";
9470
- async function status() {
9988
+ async function status(deps = {}) {
9989
+ const readService = deps.service ?? serviceStatus;
9471
9990
  const cfg = loadConfig();
9472
9991
  if (!cfg || !cfg.deviceToken) {
9473
9992
  process.stdout.write(
@@ -9503,6 +10022,13 @@ async function status() {
9503
10022
  } else {
9504
10023
  process.stdout.write(
9505
10024
  `companion: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
10025
+ `
10026
+ );
10027
+ }
10028
+ const svc = readService();
10029
+ if (svc.installed) {
10030
+ process.stdout.write(
10031
+ `autostart: ${svc.manager} \u2014 starts at login` + (svc.manager === "systemd-user" && svc.linger !== "yes" ? ", while you are logged in" : "") + ` (details: cabane-companion service status)
9506
10032
  `
9507
10033
  );
9508
10034
  }
@@ -9552,12 +10078,34 @@ function formatUptime(startedAt) {
9552
10078
  // src/commands/stop.ts
9553
10079
  var TERM_GRACE_MS = 6e3;
9554
10080
  var POLL_INTERVAL_MS2 = 150;
10081
+ var SERVICE_STOP_GRACE_MS = 3e3;
9555
10082
  async function stop(deps = {}) {
9556
10083
  const readState = deps.readState ?? readLiveRuntimeState;
9557
10084
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
9558
10085
  const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
9559
10086
  const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
9560
10087
  const now = deps.now ?? (() => Date.now());
10088
+ const stopSvc = deps.stopService ?? (() => stopService());
10089
+ const svc = stopSvc();
10090
+ if (svc.handled) {
10091
+ if (!svc.ok) {
10092
+ process.stdout.write(
10093
+ `companion: ${svc.manager} wouldn't stop the service${svc.detail ? ` (${svc.detail})` : ""}.
10094
+ The service manager owns this process \u2014 stopping it directly would just be restarted.
10095
+ Check \`cabane-companion service status\`, or remove it with \`cabane-companion service disable\`.
10096
+ `
10097
+ );
10098
+ process.exitCode = 1;
10099
+ return;
10100
+ }
10101
+ const deadline2 = now() + SERVICE_STOP_GRACE_MS;
10102
+ while (readState() && now() < deadline2) await sleep4(POLL_INTERVAL_MS2);
10103
+ if (!readState()) {
10104
+ process.stdout.write(`companion: stopped (${svc.manager} service stopped).
10105
+ `);
10106
+ return;
10107
+ }
10108
+ }
9561
10109
  const state = readState();
9562
10110
  if (!state) {
9563
10111
  process.stdout.write("companion: not running (nothing to stop).\n");
@@ -9607,8 +10155,8 @@ function isAlive(kill, pid) {
9607
10155
  }
9608
10156
 
9609
10157
  // src/commands/transcript.ts
9610
- import { existsSync as existsSync12, readFileSync as readFileSync10, readdirSync as readdirSync4 } from "fs";
9611
- import { isAbsolute, join as join16 } from "path";
10158
+ import { existsSync as existsSync14, readFileSync as readFileSync11, readdirSync as readdirSync4 } from "fs";
10159
+ import { isAbsolute, join as join18 } from "path";
9612
10160
  async function transcript(opts = {}) {
9613
10161
  const dir2 = transcriptsDir();
9614
10162
  if (opts.follow) {
@@ -9625,7 +10173,7 @@ async function transcript(opts = {}) {
9625
10173
  process.stdout.write(emptyMessage(dir2));
9626
10174
  return;
9627
10175
  }
9628
- process.stdout.write(renderFile(join16(dir2, newest)) + "\n");
10176
+ process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
9629
10177
  return;
9630
10178
  }
9631
10179
  printList(dir2);
@@ -9708,7 +10256,7 @@ function isComplete(content) {
9708
10256
  async function followTranscripts(dir2) {
9709
10257
  const follower = new TranscriptFollower({
9710
10258
  listFiles: () => listFiles(dir2),
9711
- read: (f) => readFileSync10(join16(dir2, f), "utf8"),
10259
+ read: (f) => readFileSync11(join18(dir2, f), "utf8"),
9712
10260
  write: (s) => process.stdout.write(s),
9713
10261
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
9714
10262
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -9748,7 +10296,7 @@ function printList(dir2) {
9748
10296
 
9749
10297
  `);
9750
10298
  for (const f of files.slice(0, 20)) {
9751
- const { meta, outcome } = peek(join16(dir2, f));
10299
+ const { meta, outcome } = peek(join18(dir2, f));
9752
10300
  const when = fmtTime(rec(meta)?.ts);
9753
10301
  const ws = str2(rec(meta)?.workspaceSlug);
9754
10302
  const o = rec(outcome);
@@ -9769,7 +10317,7 @@ function peek(path) {
9769
10317
  let meta;
9770
10318
  let outcome;
9771
10319
  try {
9772
- for (const line of readFileSync10(path, "utf8").split("\n")) {
10320
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
9773
10321
  if (!line.trim()) continue;
9774
10322
  const o = safeParse(line);
9775
10323
  const t = str2(rec(o)?.type);
@@ -9780,29 +10328,29 @@ function peek(path) {
9780
10328
  }
9781
10329
  return { meta, outcome };
9782
10330
  }
9783
- function resolveTarget(dir2, target) {
9784
- if (isAbsolute(target) || target.includes("/")) {
9785
- if (existsSync12(target)) return target;
9786
- throw new CompanionError(`no transcript at ${target}.`);
10331
+ function resolveTarget(dir2, target2) {
10332
+ if (isAbsolute(target2) || target2.includes("/")) {
10333
+ if (existsSync14(target2)) return target2;
10334
+ throw new CompanionError(`no transcript at ${target2}.`);
9787
10335
  }
9788
- const exact = join16(dir2, target);
9789
- if (existsSync12(exact)) return exact;
9790
- const matches = listFiles(dir2).filter((f) => f.includes(target));
9791
- if (matches.length === 1) return join16(dir2, matches[0]);
10336
+ const exact = join18(dir2, target2);
10337
+ if (existsSync14(exact)) return exact;
10338
+ const matches = listFiles(dir2).filter((f) => f.includes(target2));
10339
+ if (matches.length === 1) return join18(dir2, matches[0]);
9792
10340
  if (matches.length === 0) {
9793
10341
  throw new CompanionError(
9794
- `no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
10342
+ `no transcript matching "${target2}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
9795
10343
  );
9796
10344
  }
9797
10345
  throw new CompanionError(
9798
- `"${target}" matches ${matches.length} transcripts \u2014 be more specific:
10346
+ `"${target2}" matches ${matches.length} transcripts \u2014 be more specific:
9799
10347
  ` + matches.slice(0, 10).map((m) => ` ${m}`).join("\n")
9800
10348
  );
9801
10349
  }
9802
10350
  function renderFile(path) {
9803
10351
  let content;
9804
10352
  try {
9805
- content = readFileSync10(path, "utf8");
10353
+ content = readFileSync11(path, "utf8");
9806
10354
  } catch (err) {
9807
10355
  throw new CompanionError(
9808
10356
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -9939,28 +10487,37 @@ program.command("pair").description(
9939
10487
  });
9940
10488
  });
9941
10489
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
9942
- writeCompletedPairing(readFileSync11(0, "utf8"));
10490
+ writeCompletedPairing(readFileSync12(0, "utf8"));
9943
10491
  });
9944
- program.command("start").description("pull this device\u2019s assigned agents from cabane and run them.").option("--open", "open the dashboard in a browser on startup (default: off)").option("--no-open", "don't auto-open the dashboard (overrides config.autoOpen)").option("--daemon", "run detached in the background (terminal returns; replies keep landing)").option("--port <port>", "dashboard port (default 7474; falls through if taken)", parsePort).action(async (opts) => {
9945
- if (opts.daemon) {
9946
- await startDaemon({
10492
+ program.command("start").description("pull this device\u2019s assigned agents from cabane and run them.").option("--open", "open the dashboard in a browser on startup (default: off)").option("--no-open", "don't auto-open the dashboard (overrides config.autoOpen)").option("--daemon", "run detached in the background (terminal returns; replies keep landing)").option("--foreground", "run attached in this terminal (never installs the login service)").option("--port <port>", "dashboard port (default 7474; falls through if taken)", parsePort).action(
10493
+ async (opts) => {
10494
+ if (opts.daemon && !opts.foreground) {
10495
+ await startDaemon({
10496
+ ...opts.port !== void 0 ? { port: opts.port } : {}
10497
+ });
10498
+ return;
10499
+ }
10500
+ await start({
10501
+ // commander sets `open` to true for `--open`, false for `--no-open`, and
10502
+ // leaves it undefined when neither is passed (respect config + env).
10503
+ ...opts.open !== void 0 ? { open: opts.open } : {},
9947
10504
  ...opts.port !== void 0 ? { port: opts.port } : {}
9948
10505
  });
9949
- return;
9950
10506
  }
9951
- await start({
9952
- // commander sets `open` to true for `--open`, false for `--no-open`, and
9953
- // leaves it undefined when neither is passed (respect config + env).
9954
- ...opts.open !== void 0 ? { open: opts.open } : {},
9955
- ...opts.port !== void 0 ? { port: opts.port } : {}
9956
- });
9957
- });
10507
+ );
9958
10508
  program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
9959
10509
  await stop();
9960
10510
  });
9961
10511
  program.command("status").description("print the companion's local state (pairing, secrets, log path).").action(async () => {
9962
10512
  await status();
9963
10513
  });
10514
+ var service = program.command("service").description("inspect or remove the login service that starts the companion when you log in.");
10515
+ service.command("status").description("print the service manager, whether the unit is installed and running.").action(() => {
10516
+ serviceStatusCommand();
10517
+ });
10518
+ service.command("disable").description("stop the companion and remove the login service, leaving the machine clean.").action(() => {
10519
+ serviceDisableCommand();
10520
+ });
9964
10521
  program.command("transcript").description("show the full agent transcript for a recent dispatch (the agent's whole turn).").argument("[file]", "a transcript filename or substring; omit to list recent transcripts").option("--last", "render the most recent transcript").option("-f, --follow", "watch for new turns and live-render them as they land (Ctrl-C to stop)").action(async (file, opts) => {
9965
10522
  await transcript({
9966
10523
  ...file !== void 0 ? { target: file } : {},