@cabane/companion 0.6.32 → 0.6.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,26 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command } from "commander";
4
+ import { Command, Option } from "commander";
5
5
 
6
- // src/commands/daemon.ts
7
- import { spawn as spawn4 } from "child_process";
8
- import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2 } from "fs";
9
-
10
- // src/cli-entry.ts
11
- import { existsSync } from "fs";
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
- }
6
+ // src/control-socket.ts
7
+ import { createHash } from "crypto";
8
+ import { existsSync as existsSync2, rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
9
+ import { createServer, connect } from "net";
10
+ import { join as join2 } from "path";
24
11
 
25
12
  // src/config.ts
26
13
  import {
@@ -30,7 +17,7 @@ import {
30
17
  renameSync,
31
18
  rmSync,
32
19
  writeFileSync,
33
- existsSync as existsSync2
20
+ existsSync
34
21
  } from "fs";
35
22
  import { homedir, userInfo } from "os";
36
23
  import { dirname, join } from "path";
@@ -308,10 +295,11 @@ var companionConfigSchema = z2.object({
308
295
  // device with no device-level entry behaves exactly as it did before. A hook
309
296
  // that fails still fails the dispatch loudly, as now.
310
297
  prepareHook: prepareHookSchema.optional(),
311
- // Dashboard settings (all optional). dashboardPort: preferred bind port (next
312
- // free one if taken); autoOpen: whether `start` opens the browser (the
313
- // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
314
- // level, live-editable from the dashboard settings panel.
298
+ // CT1085 §9: `dashboardPort` and `autoOpen` are READ AND IGNORED by the CLI —
299
+ // it binds no port and opens no browser. They stay in the schema (rather than
300
+ // becoming a strict-mode parse error on an existing config file) and still mean
301
+ // what they say to the Electron shell, the one front-end that renders a
302
+ // dashboard. `logLevel` is unaffected: pino level, live-editable.
315
303
  dashboardPort: z2.number().int().min(1).max(65535).optional(),
316
304
  autoOpen: z2.boolean().optional(),
317
305
  logLevel: z2.enum(["warn", "info", "debug"]).optional(),
@@ -376,7 +364,7 @@ function localAgentConfig(cfg, agent) {
376
364
  }
377
365
  function loadConfig() {
378
366
  const path = configPath();
379
- if (!existsSync2(path)) return null;
367
+ if (!existsSync(path)) return null;
380
368
  let raw;
381
369
  try {
382
370
  raw = readFileSync(path, "utf8");
@@ -412,7 +400,7 @@ function loadConfigTolerant() {
412
400
  const path = configPath();
413
401
  const empty = {};
414
402
  const fresh = { local: empty, note: null, hadPriorConfig: false };
415
- if (!existsSync2(path)) return fresh;
403
+ if (!existsSync(path)) return fresh;
416
404
  let raw;
417
405
  try {
418
406
  raw = readFileSync(path, "utf8");
@@ -499,59 +487,163 @@ function requireConfig() {
499
487
  }
500
488
  function deleteConfig() {
501
489
  const path = configPath();
502
- if (existsSync2(path)) {
490
+ if (existsSync(path)) {
503
491
  writeFileSync(path, "", { mode: 384 });
504
492
  }
505
493
  }
506
494
 
507
- // src/logger.ts
508
- import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
509
- import { dirname as dirname2, join as join2 } from "path";
510
- import pino from "pino";
511
- import pretty from "pino-pretty";
512
- function companionLogPath() {
513
- return join2(cabaneDir(), "companion.log");
495
+ // src/control-socket.ts
496
+ var CONTROL_TIMEOUT_MS = 1e3;
497
+ function controlSocketPath() {
498
+ const dir2 = cabaneDir();
499
+ if (process.platform === "win32") {
500
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
501
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
502
+ }
503
+ return join2(dir2, "companion.sock");
504
+ }
505
+ async function startControlServer(handlers) {
506
+ const path = controlSocketPath();
507
+ mkdirSync2(cabaneDir(), { recursive: true });
508
+ if (process.platform !== "win32" && existsSync2(path)) {
509
+ const alive = await ping(path);
510
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
511
+ rmSync2(path, { force: true });
512
+ }
513
+ const server = createServer((socket) => {
514
+ void serveConnection(socket, handlers);
515
+ });
516
+ server.unref();
517
+ await new Promise((resolve, reject) => {
518
+ server.once("error", reject);
519
+ server.listen(path, () => {
520
+ server.removeListener("error", reject);
521
+ resolve();
522
+ });
523
+ });
524
+ server.on("error", () => {
525
+ });
526
+ return {
527
+ path,
528
+ close: () => new Promise((resolve) => {
529
+ server.close(() => {
530
+ if (process.platform !== "win32") rmSync2(path, { force: true });
531
+ resolve();
532
+ });
533
+ })
534
+ };
514
535
  }
515
- var CONSOLE_IGNORE = [
516
- "pid",
517
- "hostname",
518
- "workspaceId",
519
- "conversationId",
520
- "agentId",
521
- "messageId",
522
- "sessionId",
523
- "companionId"
524
- ].join(",");
525
- function consoleShortId(log) {
526
- const id = log.conversationId ?? log.workspaceId;
527
- return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
536
+ async function serveConnection(socket, handlers) {
537
+ socket.on("error", () => socket.destroy());
538
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
539
+ if (line === null) {
540
+ socket.destroy();
541
+ return;
542
+ }
543
+ let req;
544
+ try {
545
+ req = JSON.parse(line);
546
+ } catch {
547
+ reply(socket, { error: "malformed request" });
548
+ return;
549
+ }
550
+ try {
551
+ if (req.cmd === "status") {
552
+ reply(socket, handlers.status());
553
+ return;
554
+ }
555
+ if (req.cmd === "connect") {
556
+ const result = await handlers.connect(req.runtime, req.serverUrl);
557
+ reply(socket, result);
558
+ return;
559
+ }
560
+ if (req.cmd === "stop") {
561
+ reply(socket, { ok: true });
562
+ setTimeout(() => handlers.stop(), 50).unref?.();
563
+ return;
564
+ }
565
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
566
+ } catch (err) {
567
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
568
+ }
528
569
  }
529
- function consoleMessageFormat(log, messageKey) {
530
- const short = consoleShortId(log);
531
- const msg = String(log[messageKey] ?? "");
532
- return short ? `${short} ${msg}` : msg;
570
+ function reply(socket, body) {
571
+ try {
572
+ socket.end(`${JSON.stringify(body)}
573
+ `);
574
+ } catch {
575
+ socket.destroy();
576
+ }
533
577
  }
534
- var cached = null;
535
- function getLogger() {
536
- if (cached) return cached;
537
- const path = companionLogPath();
538
- mkdirSync2(dirname2(path), { recursive: true });
539
- const streams = [];
540
- if (process.env.CABANE_COMPANION_DAEMON !== "1") {
541
- const consoleStream = pretty({
542
- colorize: true,
543
- ignore: CONSOLE_IGNORE,
544
- messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey)
578
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
579
+ const socket = connect(path);
580
+ try {
581
+ await new Promise((resolve, reject) => {
582
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
583
+ timer.unref?.();
584
+ socket.once("connect", () => {
585
+ clearTimeout(timer);
586
+ resolve();
587
+ });
588
+ socket.once("error", (err) => {
589
+ clearTimeout(timer);
590
+ reject(err);
591
+ });
545
592
  });
546
- streams.push({ level: "info", stream: consoleStream });
593
+ socket.write(`${JSON.stringify(req)}
594
+ `);
595
+ const line = await readLine(socket, timeoutMs);
596
+ if (line === null) throw new ControlTimeout();
597
+ return JSON.parse(line);
598
+ } finally {
599
+ socket.destroy();
600
+ }
601
+ }
602
+ var ControlTimeout = class extends Error {
603
+ constructor() {
604
+ super("the companion did not answer its control socket in time");
605
+ this.name = "ControlTimeout";
606
+ }
607
+ };
608
+ function isNotListening(err) {
609
+ const code = err?.code;
610
+ return code === "ENOENT" || code === "ECONNREFUSED";
611
+ }
612
+ function readLine(socket, timeoutMs) {
613
+ return new Promise((resolve) => {
614
+ let buf = "";
615
+ let settled = false;
616
+ const done = (v) => {
617
+ if (settled) return;
618
+ settled = true;
619
+ clearTimeout(timer);
620
+ socket.removeListener("data", onData);
621
+ resolve(v);
622
+ };
623
+ const timer = setTimeout(() => done(null), timeoutMs);
624
+ timer.unref?.();
625
+ const onData = (chunk) => {
626
+ buf += chunk.toString("utf8");
627
+ const nl = buf.indexOf("\n");
628
+ if (nl >= 0) done(buf.slice(0, nl));
629
+ else if (buf.length > 1e6) done(null);
630
+ };
631
+ socket.on("data", onData);
632
+ socket.once("close", () => done(null));
633
+ socket.once("error", () => done(null));
634
+ });
635
+ }
636
+ async function ping(path) {
637
+ try {
638
+ await controlRequest(path, { cmd: "status" });
639
+ return true;
640
+ } catch (err) {
641
+ return !isNotListening(err);
547
642
  }
548
- streams.push({ level: "debug", stream: createWriteStream(path, { flags: "a" }) });
549
- cached = pino({ level: "debug" }, pino.multistream(streams));
550
- return cached;
551
643
  }
552
644
 
553
- // src/prereqs.ts
554
- import { spawn as spawn3 } from "child_process";
645
+ // src/harness-check.ts
646
+ import { spawn as spawn4 } from "child_process";
555
647
 
556
648
  // src/harness-versions.ts
557
649
  import { spawn as spawn2 } from "child_process";
@@ -634,7 +726,22 @@ async function safe(fn) {
634
726
  }
635
727
  }
636
728
 
729
+ // src/manifest.ts
730
+ var DEVICE_MANIFEST = {
731
+ runtimes: [{ name: "claude-code", version: null }],
732
+ capabilities: { hostFs: true, browser: true, userMcp: true }
733
+ };
734
+ function buildCompanionManifest(opts) {
735
+ const v = opts.versions ?? {};
736
+ const runtimes = [];
737
+ if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
738
+ if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
739
+ if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
740
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
741
+ }
742
+
637
743
  // src/prereqs.ts
744
+ import { spawn as spawn3 } from "child_process";
638
745
  async function claudeOnPath() {
639
746
  return new Promise((resolve) => {
640
747
  let settled = false;
@@ -704,654 +811,578 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
704
811
  );
705
812
  }
706
813
 
707
- // src/runtime-file.ts
708
- import {
709
- existsSync as existsSync3,
710
- readFileSync as readFileSync2,
711
- rmSync as rmSync2,
712
- writeFileSync as writeFileSync2,
713
- mkdirSync as mkdirSync3,
714
- openSync,
715
- closeSync
716
- } from "fs";
717
- import { join as join3 } from "path";
718
- var PROBE_TIMEOUT_MS = 1e3;
719
- function runtimePath() {
720
- return join3(cabaneDir(), "runtime.json");
721
- }
722
- function serialize(state) {
723
- return JSON.stringify(state, null, 2) + "\n";
724
- }
725
- function writeRuntimeState(state) {
726
- const path = runtimePath();
727
- mkdirSync3(cabaneDir(), { recursive: true });
728
- writeFileSync2(path, serialize(state), "utf8");
729
- }
730
- function acquireRuntimeState(state) {
731
- const live = readLiveRuntimeState();
732
- if (live) return { acquired: false, existing: live };
733
- mkdirSync3(cabaneDir(), { recursive: true });
734
- let fd;
735
- try {
736
- fd = openSync(runtimePath(), "wx");
737
- } catch {
738
- return { acquired: false, existing: readLiveRuntimeState() };
739
- }
740
- try {
741
- writeFileSync2(fd, serialize(state), "utf8");
742
- } finally {
743
- closeSync(fd);
744
- }
745
- return { acquired: true };
814
+ // src/harness-status.ts
815
+ var HARNESS_LABELS = {
816
+ "claude-code": "Claude Code",
817
+ codex: "Codex",
818
+ opencode: "opencode"
819
+ };
820
+ var LABELS = HARNESS_LABELS;
821
+ var OFFER_ORDER = ["claude-code", "codex", "opencode"];
822
+ function parseHarnessRuntime(raw) {
823
+ const key = raw.trim().toLowerCase().replace(/[\s_]+/g, "-");
824
+ if (key === "claude-code" || key === "claudecode" || key === "claude") return "claude-code";
825
+ if (key === "codex") return "codex";
826
+ if (key === "opencode") return "opencode";
827
+ return null;
746
828
  }
747
- function clearRuntimeState() {
748
- const path = runtimePath();
749
- if (existsSync3(path)) rmSync2(path, { force: true });
829
+ function deriveHarnessSnapshot(signals) {
830
+ const advertised = new Set(
831
+ buildCompanionManifest({
832
+ // CT1082: connected AND installed — the manifest's own rule, restated here
833
+ // through the same function rather than re-decided.
834
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
835
+ opencode: signals.opencodeConfigured,
836
+ codex: signals.codexEnabled
837
+ }).runtimes.map((r) => r.name)
838
+ );
839
+ const harnesses = [
840
+ deriveClaudeCode(signals, advertised.has("claude-code")),
841
+ deriveCodex(signals, advertised.has("codex")),
842
+ deriveOpencode(signals, advertised.has("opencode"))
843
+ ];
844
+ return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
750
845
  }
751
- function readLiveRuntimeState() {
752
- const path = runtimePath();
753
- if (!existsSync3(path)) return null;
754
- let parsed;
755
- try {
756
- parsed = JSON.parse(readFileSync2(path, "utf8"));
757
- } catch {
758
- return null;
759
- }
760
- if (typeof parsed.pid !== "number") return null;
761
- try {
762
- process.kill(parsed.pid, 0);
763
- } catch {
764
- clearRuntimeState();
765
- return null;
846
+ function deriveClaudeCode(signals, manifestHas) {
847
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
848
+ if (manifestHas) {
849
+ return {
850
+ ...base,
851
+ state: "exposed",
852
+ version: signals.claudeVersion,
853
+ detail: "Claude Code is connected and exposed to Cabane.",
854
+ enable: null
855
+ };
766
856
  }
767
- return parsed;
768
- }
769
- async function verifyRuntime(state, fetchImpl = fetch) {
770
- if (!state.instanceId) return "unknown";
771
- const url = `${trimSlash(state.url)}/api/status`;
772
- let res;
773
- try {
774
- res = await fetchImpl(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
775
- } catch (err) {
776
- return isConnRefused(err) ? "stale" : "unknown";
857
+ if (signals.claudeCodeConnected) {
858
+ return {
859
+ ...base,
860
+ state: "needs_attention",
861
+ version: null,
862
+ detail: "Connected, but the `claude` CLI isn\u2019t on your PATH. Install it (`npm i -g @anthropic-ai/claude-code`) and sign in, or disconnect it.",
863
+ enable: null
864
+ };
777
865
  }
778
- if (!res.ok) return "unknown";
779
- let body;
780
- try {
781
- body = await res.json();
782
- } catch {
783
- return "unknown";
866
+ if (signals.claudeOnPath) {
867
+ return {
868
+ ...base,
869
+ state: "detected_not_exposed",
870
+ version: signals.claudeVersion,
871
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
872
+ enable: "claude-code"
873
+ };
784
874
  }
785
- if (typeof body.instance_id !== "string") return "unknown";
786
- return body.instance_id === state.instanceId ? "ours" : "stale";
787
- }
788
- function isConnRefused(err) {
789
- if (!err || typeof err !== "object") return false;
790
- const cause = err.cause;
791
- return !!cause && typeof cause === "object" && cause.code === "ECONNREFUSED";
792
- }
793
- function trimSlash(s) {
794
- return s.endsWith("/") ? s.slice(0, -1) : s;
875
+ return {
876
+ ...base,
877
+ state: "not_detected",
878
+ version: null,
879
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
880
+ enable: null
881
+ };
795
882
  }
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
- );
883
+ function deriveCodex(signals, manifestHas) {
884
+ const base = { runtime: "codex", label: LABELS.codex };
885
+ if (manifestHas) {
886
+ if (signals.codexOnPath) {
887
+ return {
888
+ ...base,
889
+ state: "exposed",
890
+ version: signals.codexVersion,
891
+ detail: "Codex is enabled and exposed to Cabane.",
892
+ enable: null
893
+ };
894
+ }
895
+ return {
896
+ ...base,
897
+ state: "needs_attention",
898
+ version: null,
899
+ detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
900
+ enable: null
901
+ };
902
+ }
903
+ if (signals.codexOnPath) {
904
+ return {
905
+ ...base,
906
+ state: "detected_not_exposed",
907
+ version: signals.codexVersion,
908
+ detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
909
+ enable: "codex"
910
+ };
810
911
  }
811
912
  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 });
913
+ ...base,
914
+ state: "not_detected",
915
+ version: null,
916
+ detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
917
+ enable: null
918
+ };
919
+ }
920
+ function deriveOpencode(signals, manifestHas) {
921
+ const base = { runtime: "opencode", label: LABELS.opencode };
922
+ if (manifestHas) {
923
+ if (signals.opencodeReachable) {
836
924
  return {
837
- ok: res.status === 0,
838
- stdout: res.stdout ?? "",
839
- stderr: res.stderr ?? (res.error ? res.error.message : "")
925
+ ...base,
926
+ state: "exposed",
927
+ version: signals.opencodeVersion,
928
+ detail: "An opencode server is reachable and exposed to Cabane.",
929
+ enable: null
840
930
  };
841
931
  }
932
+ return {
933
+ ...base,
934
+ state: "needs_attention",
935
+ version: null,
936
+ detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
937
+ enable: null
938
+ };
939
+ }
940
+ return {
941
+ ...base,
942
+ state: "not_detected",
943
+ version: null,
944
+ detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
945
+ enable: "opencode"
842
946
  };
843
947
  }
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}`;
948
+ function detectedRuntimesFor(snapshot) {
949
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
891
950
  }
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) };
951
+ var PROBE_TIMEOUT_MS = 4e3;
952
+ async function probeHarnessSignals(cfg, deps = {}) {
953
+ const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
954
+ const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
955
+ const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
956
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
957
+ const serverUrl = cfg.opencode?.serverUrl;
958
+ const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
959
+ withTimeout(probeClaudePresence(), false),
960
+ withTimeout(probeClaudeVersion(), null),
961
+ withTimeout(probeCodexVersion(), null),
962
+ serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
963
+ ]);
964
+ return {
965
+ claudeOnPath: claudeOnPathResult,
966
+ claudeVersion,
967
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
968
+ // the manifest gate and the probe above is only a suggestion.
969
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
970
+ // A parseable `codex --version` is our presence signal (presence alone never
971
+ // exposes codex; its config flag is the manifest gate either way).
972
+ codexOnPath: codexVersion !== null,
973
+ codexVersion,
974
+ codexEnabled: isCodexEnabled(cfg),
975
+ opencodeConfigured: !!serverUrl,
976
+ // A version came back ⟺ the serve answered its health endpoint (CT584).
977
+ opencodeReachable: opencodeVersion !== null,
978
+ opencodeVersion
979
+ };
896
980
  }
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) };
981
+ function withTimeout(promise, fallback) {
982
+ return new Promise((resolve) => {
983
+ let settled = false;
984
+ const done = (v) => {
985
+ if (!settled) {
986
+ settled = true;
987
+ resolve(v);
988
+ }
989
+ };
990
+ const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS);
991
+ timer.unref?.();
992
+ promise.then(
993
+ (v) => {
994
+ clearTimeout(timer);
995
+ done(v);
996
+ },
997
+ () => {
998
+ clearTimeout(timer);
999
+ done(fallback);
1000
+ }
1001
+ );
1002
+ });
901
1003
  }
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" };
1004
+
1005
+ // src/harness-check.ts
1006
+ var CHECK_TIMEOUT_MS = 4e3;
1007
+ async function shakeOutHarness(runtime, cfg, deps = {}) {
1008
+ const run = deps.run ?? runBounded;
1009
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1010
+ try {
1011
+ if (runtime === "opencode") {
1012
+ const url = cfg.opencode?.serverUrl;
1013
+ if (!url) return "failed";
1014
+ return await probeOpencode(url) !== null ? "ok" : "failed";
1015
+ }
1016
+ const { auth, presence } = runtime === "codex" ? {
1017
+ auth: ["codex", ["login", "status"]],
1018
+ presence: ["codex", ["--version"]]
1019
+ } : {
1020
+ auth: ["claude", ["auth", "status"]],
1021
+ presence: ["claude", ["--version"]]
1022
+ };
1023
+ const authRun = await run(auth[0], [...auth[1]]);
1024
+ if (authRun.code === 0) return "ok";
1025
+ if (looksUnsupported(authRun.output)) {
1026
+ const presenceRun = await run(presence[0], [...presence[1]]);
1027
+ return presenceRun.code === 0 ? "unverified" : "failed";
1028
+ }
1029
+ return "failed";
1030
+ } catch {
1031
+ return "unverified";
907
1032
  }
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
1033
  }
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);
1034
+ function connectedLine(runtime, verdict) {
1035
+ const label = HARNESS_LABELS[runtime];
1036
+ if (verdict !== "failed") return `${label} connected.`;
1037
+ return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
925
1038
  }
926
- function firstLine(s) {
927
- return s.trim().split("\n")[0] ?? "";
1039
+ var FAILED_SUFFIX = {
1040
+ "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1041
+ codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1042
+ opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1043
+ };
1044
+ function looksUnsupported(output) {
1045
+ return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1046
+ output
1047
+ );
928
1048
  }
929
- function xml(value) {
930
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1049
+ function runBounded(command, args) {
1050
+ return new Promise((resolve) => {
1051
+ let settled = false;
1052
+ const done = (code, output) => {
1053
+ if (settled) return;
1054
+ settled = true;
1055
+ clearTimeout(timer);
1056
+ resolve({ code, output });
1057
+ };
1058
+ let child;
1059
+ try {
1060
+ child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1061
+ } catch {
1062
+ resolve({ code: null, output: "" });
1063
+ return;
1064
+ }
1065
+ let out = "";
1066
+ const capture = (chunk) => {
1067
+ if (out.length < 4096) out += chunk.toString();
1068
+ };
1069
+ child.stdout?.on("data", capture);
1070
+ child.stderr?.on("data", capture);
1071
+ const timer = setTimeout(() => {
1072
+ child.kill("SIGKILL");
1073
+ done(null, out);
1074
+ }, CHECK_TIMEOUT_MS);
1075
+ timer.unref?.();
1076
+ child.once("error", () => done(null, out));
1077
+ child.once("exit", (code) => done(code, out));
1078
+ });
931
1079
  }
932
1080
 
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);
1081
+ // src/runtime-file.ts
1082
+ import {
1083
+ existsSync as existsSync3,
1084
+ readFileSync as readFileSync2,
1085
+ rmSync as rmSync3,
1086
+ writeFileSync as writeFileSync2,
1087
+ mkdirSync as mkdirSync3,
1088
+ openSync,
1089
+ closeSync
1090
+ } from "fs";
1091
+ import { join as join3 } from "path";
1092
+ var PROBE_TIMEOUT_MS2 = 1e3;
1093
+ function runtimePath() {
1094
+ return join3(cabaneDir(), "runtime.json");
939
1095
  }
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 };
1096
+ function serialize(state) {
1097
+ return JSON.stringify(state, null, 2) + "\n";
971
1098
  }
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) };
1099
+ function writeRuntimeState(state) {
1100
+ const path = runtimePath();
1101
+ mkdirSync3(cabaneDir(), { recursive: true });
1102
+ writeFileSync2(path, serialize(state), "utf8");
976
1103
  }
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" };
1104
+ function acquireRuntimeState(state) {
1105
+ const live = readLiveRuntimeState();
1106
+ if (live) return { acquired: false, existing: live };
1107
+ mkdirSync3(cabaneDir(), { recursive: true });
1108
+ let fd;
1109
+ try {
1110
+ fd = openSync(runtimePath(), "wx");
1111
+ } catch {
1112
+ return { acquired: false, existing: readLiveRuntimeState() };
982
1113
  }
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) };
1114
+ try {
1115
+ writeFileSync2(fd, serialize(state), "utf8");
1116
+ } finally {
1117
+ closeSync(fd);
996
1118
  }
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";
1119
+ return { acquired: true };
1016
1120
  }
1017
- function tryEnableLinger(host) {
1018
- host.run("loginctl", ["enable-linger", String(host.uid)]);
1019
- return readLinger(host);
1121
+ function clearRuntimeState() {
1122
+ const path = runtimePath();
1123
+ if (existsSync3(path)) rmSync3(path, { force: true });
1020
1124
  }
1021
- function isRemoteSession(env) {
1022
- return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1125
+ function clearRuntimeStateIfOurs(instanceId) {
1126
+ const path = runtimePath();
1127
+ if (!existsSync3(path)) return;
1128
+ try {
1129
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1130
+ if (parsed.instanceId && parsed.instanceId !== instanceId) return;
1131
+ } catch {
1132
+ }
1133
+ rmSync3(path, { force: true });
1023
1134
  }
1024
- function quote(value) {
1025
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1135
+ function readLiveRuntimeState() {
1136
+ const path = runtimePath();
1137
+ if (!existsSync3(path)) return null;
1138
+ let parsed;
1139
+ try {
1140
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1141
+ } catch {
1142
+ return null;
1143
+ }
1144
+ if (typeof parsed.pid !== "number") return null;
1145
+ try {
1146
+ process.kill(parsed.pid, 0);
1147
+ } catch {
1148
+ clearRuntimeState();
1149
+ return null;
1150
+ }
1151
+ return parsed;
1026
1152
  }
1027
- function firstLine2(s) {
1028
- return s.trim().split("\n")[0] ?? "";
1153
+ async function verifyRuntime(state, requestImpl = controlRequest) {
1154
+ if (!state.instanceId || !state.socket) return "unknown";
1155
+ let body;
1156
+ try {
1157
+ body = await requestImpl(
1158
+ state.socket,
1159
+ { cmd: "status" },
1160
+ PROBE_TIMEOUT_MS2
1161
+ );
1162
+ } catch (err) {
1163
+ return isNotListening(err) ? "stale" : "unknown";
1164
+ }
1165
+ if (typeof body?.instance_id !== "string") return "unknown";
1166
+ return body.instance_id === state.instanceId ? "ours" : "stale";
1029
1167
  }
1030
1168
 
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;
1169
+ // src/term.ts
1170
+ import { createInterface } from "readline";
1171
+ import { homedir as homedir2 } from "os";
1172
+ import pc from "picocolors";
1173
+ var INDENT = " ";
1174
+ var DATA_INDENT = " ";
1175
+ function isInteractive() {
1176
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1177
+ }
1178
+ function write(line) {
1179
+ process.stdout.write(`${line}
1180
+ `);
1059
1181
  }
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);
1182
+ function blank() {
1183
+ process.stdout.write("\n");
1184
+ }
1185
+ var tick = (rest) => `${pc.green("\u2713")} ${rest}`;
1186
+ var arrow = (rest) => `${pc.yellow("\u2192")} ${rest}`;
1187
+ var bang = (rest) => `${pc.yellow("!")} ${rest}`;
1188
+ function tildePath(path) {
1189
+ const home = homedir2();
1190
+ if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
1191
+ return path;
1192
+ }
1193
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1194
+ var FRAME_MS = 80;
1195
+ var Spinner = class {
1196
+ timer = null;
1197
+ frame = 0;
1198
+ text = "";
1199
+ animate;
1200
+ constructor(opts = {}) {
1201
+ this.animate = opts.animate ?? isInteractive();
1202
+ }
1203
+ start(text) {
1204
+ this.text = text;
1205
+ if (!this.animate) {
1206
+ write(`${INDENT}${text}`);
1207
+ return;
1208
+ }
1209
+ this.render();
1210
+ this.timer = setInterval(() => {
1211
+ this.frame = (this.frame + 1) % FRAMES.length;
1212
+ this.render();
1213
+ }, FRAME_MS);
1214
+ this.timer.unref?.();
1215
+ }
1216
+ // Stop, erase the spinner line, and write `line` in its place.
1217
+ replaceWith(line) {
1218
+ this.halt();
1219
+ if (this.animate) this.eraseLine();
1220
+ write(line);
1221
+ }
1222
+ // Stop, leaving the line as it last rendered — for an outcome that reads as
1223
+ // "this is where we gave up waiting" rather than as a resolution. Without a
1224
+ // TTY the line was already terminated when it printed, so there is nothing to
1225
+ // close.
1226
+ freeze() {
1227
+ this.halt();
1228
+ if (this.animate) process.stdout.write("\n");
1229
+ }
1230
+ // Stop and erase, writing nothing — the Ctrl-C path, where the shell's own
1231
+ // `^C` echo is the last thing on the line.
1232
+ clear() {
1233
+ this.halt();
1234
+ if (this.animate) this.eraseLine();
1235
+ }
1236
+ halt() {
1237
+ if (this.timer) {
1238
+ clearInterval(this.timer);
1239
+ this.timer = null;
1240
+ }
1241
+ }
1242
+ render() {
1243
+ this.eraseLine();
1244
+ process.stdout.write(`${INDENT}${pc.dim(FRAMES[this.frame] ?? FRAMES[0])} ${this.text}`);
1245
+ }
1246
+ // Carriage return + "erase the whole line", so a replacement starts clean
1247
+ // however wide the spinner text was.
1248
+ eraseLine() {
1249
+ process.stdout.write("\r\x1B[2K");
1250
+ }
1251
+ };
1252
+ async function confirm(question) {
1253
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1254
+ try {
1255
+ const answer = await new Promise((resolve) => {
1256
+ rl.question(`${INDENT}${question} [Y/n] `, resolve);
1257
+ });
1258
+ return !/^n(o)?$/i.test(answer.trim());
1259
+ } finally {
1260
+ rl.close();
1261
+ }
1063
1262
  }
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);
1263
+
1264
+ // src/commands/connect.ts
1265
+ async function connect2(raw, opts = {}) {
1266
+ const runtime = parseHarnessRuntime(raw);
1267
+ if (!runtime) {
1268
+ throw new CompanionError(
1269
+ `unknown harness "${raw}". Connectable harnesses are: claude-code, codex, opencode.`
1270
+ );
1076
1271
  }
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)"
1272
+ const live = await liveSocket();
1273
+ if (live) {
1274
+ const result = await controlRequest(live, {
1275
+ cmd: "connect",
1276
+ runtime,
1277
+ ...opts.serverUrl ? { serverUrl: opts.serverUrl } : {}
1278
+ });
1279
+ if (!result.ok) throw new CompanionError(result.message);
1280
+ write(INDENT + tick(result.message));
1281
+ return;
1282
+ }
1283
+ const cfg = requireConfig();
1284
+ if (alreadyConnected(runtime, cfg)) {
1285
+ write(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1286
+ return;
1287
+ }
1288
+ if (runtime === "claude-code" && !await claudeOnPath()) {
1289
+ throw new CompanionError(
1290
+ "Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
1291
+ );
1292
+ }
1293
+ let next = cfg;
1294
+ if (runtime === "claude-code") next = { ...cfg, claudeCode: { enabled: true } };
1295
+ else if (runtime === "codex") next = { ...cfg, codex: { enabled: true } };
1296
+ else {
1297
+ const serverUrl = opts.serverUrl?.trim();
1298
+ if (!serverUrl) {
1299
+ throw new CompanionError(
1300
+ "opencode is addressed by URL \u2014 pass it: `cabane-companion connect opencode --url http://127.0.0.1:4096`."
1301
+ );
1302
+ }
1303
+ if (await probeOpencodeVersion(serverUrl) === null) {
1304
+ throw new CompanionError(
1305
+ `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
1086
1306
  );
1087
1307
  }
1308
+ next = { ...cfg, opencode: { serverUrl } };
1088
1309
  }
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);
1310
+ saveConfig(next);
1311
+ const verdict = await shakeOutHarness(runtime, next);
1312
+ write(INDENT + tick(connectedLine(runtime, verdict)));
1313
+ write(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
1314
+ }
1315
+ async function liveSocket() {
1316
+ const state = readLiveRuntimeState();
1317
+ if (!state) return null;
1318
+ const verdict = await verifyRuntime(state);
1319
+ if (verdict === "stale") {
1320
+ clearRuntimeState();
1321
+ return null;
1322
+ }
1323
+ if (!state.socket) return null;
1324
+ try {
1325
+ await controlRequest(state.socket, { cmd: "status" });
1326
+ return state.socket;
1327
+ } catch (err) {
1328
+ if (isNotListening(err)) return null;
1329
+ throw new CompanionError(
1330
+ "a companion is running but isn\u2019t answering its control socket. Try `cabane-companion stop`, then `cabane-companion start`."
1331
+ );
1093
1332
  }
1094
- return { installed: true, manager, changed };
1095
1333
  }
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
- };
1334
+ function alreadyConnected(runtime, cfg) {
1335
+ if (runtime === "claude-code") return isClaudeCodeConnected(cfg);
1336
+ if (runtime === "codex") return isCodexEnabled(cfg);
1337
+ return !!cfg.opencode?.serverUrl;
1103
1338
  }
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 };
1339
+
1340
+ // src/commands/logout.ts
1341
+ import { confirm as confirm2 } from "@inquirer/prompts";
1342
+
1343
+ // src/credentials.ts
1344
+ import {
1345
+ chmodSync as chmodSync2,
1346
+ existsSync as existsSync4,
1347
+ mkdirSync as mkdirSync4,
1348
+ readFileSync as readFileSync3,
1349
+ renameSync as renameSync2,
1350
+ rmSync as rmSync4,
1351
+ writeFileSync as writeFileSync3
1352
+ } from "fs";
1353
+ import { dirname as dirname2, join as join4 } from "path";
1354
+ import { z as z3 } from "zod";
1355
+ function credentialsPath() {
1356
+ return join4(cabaneDir(), "credentials.json");
1108
1357
  }
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
-
1174
- // src/commands/daemon.ts
1175
- var STARTUP_TIMEOUT_MS = 8e3;
1176
- var POLL_INTERVAL_MS = 150;
1177
- async function startDaemon(opts = {}, deps = {}) {
1178
- const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
1179
- const readState = deps.readState ?? readLiveRuntimeState;
1180
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
1181
- const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
1182
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
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 } : {};
1189
- const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
1190
- // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
1191
- ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
1192
- ...deps.probeClaude ? { probeClaude: deps.probeClaude } : {},
1193
- ...deps.save ? { save: deps.save } : {},
1194
- // The child won't log it (the block is written by then), and this process owns
1195
- // the user's terminal — so the one-time record is one line, here.
1196
- onMigrated: (_migrated, onPath) => {
1197
- if (onPath) {
1198
- process.stdout.write(
1199
- "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
1200
- );
1201
- }
1202
- }
1203
- });
1204
- await ensureRuntime(cfg, { probeClaude: async () => claudeOnPath2 });
1205
- const existing = readState();
1206
- if (existing) {
1207
- if (await verify(existing) !== "stale") {
1208
- const rerendered = isTty() ? refresh(serviceOpts) : false;
1209
- process.stdout.write(
1210
- `Cabane Companion is already running (pid ${existing.pid}).
1211
- \u2192 Dashboard: ${existing.url}
1212
- Stop it first with \`cabane-companion stop\` if you want to relaunch.
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" : "")
1214
- );
1215
- return;
1216
- }
1217
- clearRuntimeState();
1218
- }
1219
- let service2 = isTty() ? install(serviceOpts) : { installed: false, reason: "unsupported" };
1220
- const args = ["start", "--no-open"];
1221
- if (opts.port !== void 0) args.push("--port", String(opts.port));
1222
- const launchDetached = () => {
1223
- const spawned = spawnDetached(args);
1224
- spawned.unref();
1225
- return spawned;
1226
- };
1227
- const ready = (s) => !!s && !!s.url;
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;
1241
- }
1242
- let child = service2.installed ? null : launchDetached();
1243
- let state = await waitForReady();
1244
- if (!state && service2.installed) {
1245
- process.stdout.write(
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.
1263
- Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
1264
- `
1265
- );
1266
- process.exitCode = 1;
1267
- return;
1268
- }
1269
- process.stdout.write(
1270
- `Cabane Companion started in the background (pid ${state.pid}).
1271
- \u2192 Dashboard: ${state.url}
1272
- Logs: ${companionLogPath()}
1273
- Status: cabane-companion status
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.
1291
- `
1292
- );
1293
- }
1294
- function defaultSpawnDetached(args) {
1295
- const cliPath = companionCliEntry();
1296
- mkdirSync5(cabaneDir(), { recursive: true });
1297
- const logFd = openSync2(companionLogPath(), "a");
1298
- try {
1299
- return spawn4(process.execPath, [cliPath, ...args], {
1300
- detached: true,
1301
- stdio: ["ignore", logFd, logFd],
1302
- env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
1303
- });
1304
- } finally {
1305
- closeSync2(logFd);
1306
- }
1307
- }
1308
-
1309
- // src/commands/logout.ts
1310
- import { confirm } from "@inquirer/prompts";
1311
-
1312
- // src/credentials.ts
1313
- import {
1314
- chmodSync as chmodSync2,
1315
- existsSync as existsSync5,
1316
- mkdirSync as mkdirSync6,
1317
- readFileSync as readFileSync4,
1318
- renameSync as renameSync2,
1319
- rmSync as rmSync4,
1320
- writeFileSync as writeFileSync4
1321
- } from "fs";
1322
- import { dirname as dirname5, join as join6 } from "path";
1323
- import { z as z3 } from "zod";
1324
- function credentialsPath() {
1325
- return join6(cabaneDir(), "credentials.json");
1326
- }
1327
- var credentialStoreSchema = z3.record(z3.string(), z3.string());
1328
- function load() {
1329
- const path = credentialsPath();
1330
- if (!existsSync5(path)) return {};
1331
- let raw;
1332
- try {
1333
- raw = readFileSync4(path, "utf8");
1334
- } catch {
1335
- return {};
1336
- }
1337
- if (raw.trim().length === 0) return {};
1338
- try {
1339
- const result = credentialStoreSchema.safeParse(JSON.parse(raw));
1340
- return result.success ? result.data : {};
1341
- } catch {
1342
- return {};
1343
- }
1358
+ var credentialStoreSchema = z3.record(z3.string(), z3.string());
1359
+ function load() {
1360
+ const path = credentialsPath();
1361
+ if (!existsSync4(path)) return {};
1362
+ let raw;
1363
+ try {
1364
+ raw = readFileSync3(path, "utf8");
1365
+ } catch {
1366
+ return {};
1367
+ }
1368
+ if (raw.trim().length === 0) return {};
1369
+ try {
1370
+ const result = credentialStoreSchema.safeParse(JSON.parse(raw));
1371
+ return result.success ? result.data : {};
1372
+ } catch {
1373
+ return {};
1374
+ }
1344
1375
  }
1345
1376
  function save(map) {
1346
1377
  const path = credentialsPath();
1347
- mkdirSync6(dirname5(path), { recursive: true });
1378
+ mkdirSync4(dirname2(path), { recursive: true });
1348
1379
  try {
1349
1380
  chmodSync2(cabaneDir(), 448);
1350
1381
  } catch {
1351
1382
  }
1352
1383
  const tmp = `${path}.${process.pid}.tmp`;
1353
1384
  try {
1354
- writeFileSync4(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1385
+ writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1355
1386
  try {
1356
1387
  chmodSync2(tmp, 384);
1357
1388
  } catch {
@@ -1389,7 +1420,7 @@ function pruneCredentials(keepAgentIds) {
1389
1420
  }
1390
1421
  function clearCredentials() {
1391
1422
  const path = credentialsPath();
1392
- if (existsSync5(path)) writeFileSync4(path, "", { mode: 384 });
1423
+ if (existsSync4(path)) writeFileSync3(path, "", { mode: 384 });
1393
1424
  }
1394
1425
 
1395
1426
  // src/commands/logout.ts
@@ -1401,7 +1432,7 @@ async function logout(opts = {}) {
1401
1432
  }
1402
1433
  if (!opts.yes) {
1403
1434
  const message = opts.purge ? "Purge the entire local companion config (device + agent overrides + settings)?" : "Log out this device (removes the device token + cached agent credentials; keeps your config)?";
1404
- const ok = await confirm({ message, default: false });
1435
+ const ok = await confirm2({ message, default: false });
1405
1436
  if (!ok) {
1406
1437
  process.stdout.write("cancelled\n");
1407
1438
  return;
@@ -1423,159 +1454,630 @@ async function logout(opts = {}) {
1423
1454
  `
1424
1455
  );
1425
1456
  }
1426
-
1427
- // src/enrollment.ts
1428
- function trimBase(baseUrl) {
1429
- return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
1457
+
1458
+ // src/commands/pair.ts
1459
+ import { hostname } from "os";
1460
+
1461
+ // src/enrollment.ts
1462
+ var EnrollmentExpiredError = class extends CompanionError {
1463
+ constructor() {
1464
+ super("this pairing code expired before it was confirmed.");
1465
+ this.name = "EnrollmentExpiredError";
1466
+ }
1467
+ };
1468
+ var EnrollmentCancelledError = class extends CompanionError {
1469
+ constructor() {
1470
+ super("pairing was cancelled.");
1471
+ this.name = "EnrollmentCancelledError";
1472
+ }
1473
+ };
1474
+ function trimBase(baseUrl) {
1475
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
1476
+ }
1477
+ async function postJson(baseUrl, path, body) {
1478
+ let res;
1479
+ try {
1480
+ res = await fetch(`${trimBase(baseUrl)}${path}`, {
1481
+ method: "POST",
1482
+ headers: { "content-type": "application/json", accept: "application/json" },
1483
+ body: JSON.stringify(body)
1484
+ });
1485
+ } catch (err) {
1486
+ throw new CompanionError(
1487
+ `couldn't reach cabane at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}. Check the server URL (pass --server <url>) and your connection.`
1488
+ );
1489
+ }
1490
+ const raw = await res.text();
1491
+ let parsed = void 0;
1492
+ if (raw.length > 0) {
1493
+ try {
1494
+ parsed = JSON.parse(raw);
1495
+ } catch {
1496
+ parsed = raw;
1497
+ }
1498
+ }
1499
+ if (res.status >= 400) {
1500
+ if (res.status === 404 && path.endsWith("/code")) {
1501
+ throw new CompanionError(
1502
+ `no device-flow pairing endpoint at ${baseUrl}. Either that server predates \`cabane-companion pair\`, or it isn't a Cabane API origin \u2014 the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
1503
+ );
1504
+ }
1505
+ const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
1506
+ throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
1507
+ }
1508
+ return parsed;
1509
+ }
1510
+ function requestEnrollmentCode(baseUrl, opts = {}) {
1511
+ return postJson(baseUrl, "/api/device-enrollment/code", {
1512
+ ...opts.label ? { label: opts.label } : {}
1513
+ });
1514
+ }
1515
+ function deviceLabelFromHostname(hostname3) {
1516
+ const trimmed = hostname3.trim().replace(/\.local$/i, "");
1517
+ if (trimmed.length === 0) return void 0;
1518
+ return trimmed.slice(0, 120);
1519
+ }
1520
+ function pollOnce(baseUrl, deviceCode) {
1521
+ return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
1522
+ }
1523
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1524
+ async function runDeviceFlow(baseUrl, print, opts = {}) {
1525
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
1526
+ const code = await requestEnrollmentCode(baseUrl, {
1527
+ ...opts.label ? { label: opts.label } : {}
1528
+ });
1529
+ if (opts.onCode) await opts.onCode(code);
1530
+ print("");
1531
+ print(` Enter this code at ${code.verificationUri}`);
1532
+ print("");
1533
+ print(` ${code.userCode}`);
1534
+ print("");
1535
+ print(` or open: ${code.verificationUriComplete}`);
1536
+ print("");
1537
+ print("Waiting for you to confirm it in Cabane\u2026");
1538
+ return awaitEnrollment(baseUrl, code, opts);
1539
+ }
1540
+ async function awaitEnrollment(baseUrl, code, opts = {}) {
1541
+ const now = opts.now ?? (() => Date.now());
1542
+ const wait = opts.sleepMs ?? sleep;
1543
+ const deadline = now() + code.expiresIn * 1e3;
1544
+ let intervalMs = Math.max(1, code.interval) * 1e3;
1545
+ while (now() < deadline) {
1546
+ await wait(intervalMs);
1547
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
1548
+ const res = await pollOnce(baseUrl, code.deviceCode);
1549
+ if (res.status === "complete") {
1550
+ if (!res.deviceToken || !res.baseUrl) {
1551
+ throw new CompanionError("the server reported the pairing complete but returned no token.");
1552
+ }
1553
+ return {
1554
+ baseUrl: res.baseUrl,
1555
+ deviceToken: res.deviceToken,
1556
+ ...res.deviceId ? { deviceId: res.deviceId } : {},
1557
+ ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {},
1558
+ ...res.ownerName ? { ownerName: res.ownerName } : {}
1559
+ };
1560
+ }
1561
+ if (res.status === "expired") throw new EnrollmentExpiredError();
1562
+ if (res.status === "slow_down") intervalMs += 1e3;
1563
+ }
1564
+ throw new EnrollmentExpiredError();
1565
+ }
1566
+
1567
+ // src/pairing-config.ts
1568
+ var DEFAULT_BASE_URL = "https://app.cabane.ai";
1569
+ function resolvePairBaseUrl(server) {
1570
+ const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
1571
+ if (!isAllowedBaseUrl(raw)) {
1572
+ throw new CompanionError(
1573
+ `invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`cabane-companion pair --server https://your-cabane\`.`
1574
+ );
1575
+ }
1576
+ return raw;
1577
+ }
1578
+ function writePairedConfig(paired) {
1579
+ const { local, note, hadPriorConfig } = loadConfigTolerant();
1580
+ const config = {
1581
+ // CT1082: a device pairs connected to NOTHING — `claudeCode: { enabled: false }`
1582
+ // written explicitly, not left absent, because absence is exactly what marks a
1583
+ // pre-CT1082 config for the grandfathering migration. Omit it and a brand-new
1584
+ // device gets auto-connected on its first start by the behaviour this task
1585
+ // removes; stamp it on a RE-pair and an upgrading user's working device gets
1586
+ // disconnected instead. So it's written only when there was no config here
1587
+ // before. `local` spreads after, so a re-pair keeps the answer already given.
1588
+ ...hadPriorConfig ? {} : { claudeCode: { enabled: false } },
1589
+ ...local,
1590
+ baseUrl: paired.baseUrl,
1591
+ deviceToken: paired.deviceToken,
1592
+ ...paired.deviceId ? { deviceId: paired.deviceId } : {},
1593
+ ...paired.deviceLabel ? { deviceLabel: paired.deviceLabel } : {}
1594
+ };
1595
+ saveConfig(config);
1596
+ return { config, note };
1597
+ }
1598
+ function isDevicePaired() {
1599
+ try {
1600
+ return !!loadConfig()?.deviceToken;
1601
+ } catch {
1602
+ return false;
1603
+ }
1604
+ }
1605
+
1606
+ // src/commands/pair.ts
1607
+ function writePairedConfigCli(paired) {
1608
+ const { note } = writePairedConfig(paired);
1609
+ if (note) process.stderr.write(`note: ${note}
1610
+ `);
1611
+ const owner = paired.ownerName ? `${paired.ownerName}'s` : "your Cabane";
1612
+ process.stdout.write(
1613
+ ` \u2713 Paired to ${owner} account.
1614
+ Run \`cabane-companion start\` \u2014 it connects a harness, runs in the background, and pulls this device's agents.
1615
+ `
1616
+ );
1617
+ }
1618
+ function writeCompletedPairing(raw) {
1619
+ let paired;
1620
+ try {
1621
+ paired = JSON.parse(raw);
1622
+ } catch {
1623
+ throw new Error("invalid completed pairing payload: expected JSON on stdin.");
1624
+ }
1625
+ if (!paired || typeof paired !== "object" || typeof paired.baseUrl !== "string" || typeof paired.deviceToken !== "string") {
1626
+ throw new Error("invalid completed pairing payload: baseUrl and deviceToken are required.");
1627
+ }
1628
+ writePairedConfigCli(paired);
1629
+ }
1630
+ async function pair(opts = {}) {
1631
+ const baseUrl = resolvePairBaseUrl(opts.server);
1632
+ const paired = await runDeviceFlow(baseUrl, (line) => process.stdout.write(`${line}
1633
+ `), {
1634
+ // The hostname rides the mint here too, so a `pair`-then-`start` device is
1635
+ // named exactly as a one-command one is.
1636
+ ...deviceLabelFromHostname(hostname()) ? { label: deviceLabelFromHostname(hostname()) } : {}
1637
+ });
1638
+ writePairedConfigCli(paired);
1639
+ }
1640
+
1641
+ // src/cli.ts
1642
+ import { readFileSync as readFileSync12 } from "fs";
1643
+
1644
+ // src/service/index.ts
1645
+ import { dirname as dirname5 } from "path";
1646
+
1647
+ // src/service/host.ts
1648
+ import { spawnSync } from "child_process";
1649
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync5, writeFileSync as writeFileSync4 } from "fs";
1650
+ import { homedir as homedir3 } from "os";
1651
+
1652
+ // src/cli-entry.ts
1653
+ import { existsSync as existsSync5 } from "fs";
1654
+ import { fileURLToPath } from "url";
1655
+ var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
1656
+ function companionCliEntry(deps = {}) {
1657
+ const exists = deps.exists ?? existsSync5;
1658
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath(new URL(rel, import.meta.url)));
1659
+ for (const candidate of candidates) {
1660
+ if (exists(candidate)) return candidate;
1661
+ }
1662
+ const argv1 = "argv1" in deps ? deps.argv1 : process.argv[1];
1663
+ if (argv1 && exists(argv1)) return argv1;
1664
+ return candidates[0] ?? "";
1665
+ }
1666
+
1667
+ // src/logger.ts
1668
+ import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
1669
+ import { dirname as dirname3, join as join5 } from "path";
1670
+ import pino from "pino";
1671
+ import pretty from "pino-pretty";
1672
+ function companionLogPath() {
1673
+ return join5(cabaneDir(), "companion.log");
1674
+ }
1675
+ var CONSOLE_IGNORE = [
1676
+ "pid",
1677
+ "hostname",
1678
+ "workspaceId",
1679
+ "conversationId",
1680
+ "agentId",
1681
+ "messageId",
1682
+ "sessionId",
1683
+ "companionId"
1684
+ ].join(",");
1685
+ function consoleShortId(log) {
1686
+ const id = log.conversationId ?? log.workspaceId;
1687
+ return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
1688
+ }
1689
+ function consoleMessageFormat(log, messageKey) {
1690
+ const short = consoleShortId(log);
1691
+ const msg = String(log[messageKey] ?? "");
1692
+ return short ? `${short} ${msg}` : msg;
1693
+ }
1694
+ var cached = null;
1695
+ function getLogger() {
1696
+ if (cached) return cached;
1697
+ const path = companionLogPath();
1698
+ mkdirSync5(dirname3(path), { recursive: true });
1699
+ const streams = [];
1700
+ if (process.env.CABANE_COMPANION_DAEMON !== "1") {
1701
+ const consoleStream = pretty({
1702
+ colorize: true,
1703
+ ignore: CONSOLE_IGNORE,
1704
+ messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey)
1705
+ });
1706
+ streams.push({ level: "info", stream: consoleStream });
1707
+ }
1708
+ streams.push({ level: "debug", stream: createWriteStream(path, { flags: "a" }) });
1709
+ cached = pino({ level: "debug" }, pino.multistream(streams));
1710
+ return cached;
1711
+ }
1712
+
1713
+ // src/service/host.ts
1714
+ var RUN_TIMEOUT_MS = 1e4;
1715
+ function defaultServiceHost() {
1716
+ if (process.env.VITEST) {
1717
+ throw new Error(
1718
+ "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."
1719
+ );
1720
+ }
1721
+ return {
1722
+ platform: process.platform,
1723
+ env: process.env,
1724
+ home: homedir3(),
1725
+ uid: process.getuid?.() ?? 0,
1726
+ execPath: process.execPath,
1727
+ cliPath: companionCliEntry(),
1728
+ logPath: companionLogPath(),
1729
+ fs: {
1730
+ read: (path) => {
1731
+ try {
1732
+ return readFileSync4(path, "utf8");
1733
+ } catch {
1734
+ return null;
1735
+ }
1736
+ },
1737
+ write: (path, contents) => writeFileSync4(path, contents, "utf8"),
1738
+ remove: (path) => rmSync5(path, { force: true }),
1739
+ exists: (path) => existsSync6(path),
1740
+ mkdirp: (dir2) => {
1741
+ mkdirSync6(dir2, { recursive: true });
1742
+ }
1743
+ },
1744
+ run: (cmd, args) => {
1745
+ const res = spawnSync(cmd, args, { encoding: "utf8", timeout: RUN_TIMEOUT_MS });
1746
+ return {
1747
+ ok: res.status === 0,
1748
+ stdout: res.stdout ?? "",
1749
+ stderr: res.stderr ?? (res.error ? res.error.message : "")
1750
+ };
1751
+ }
1752
+ };
1753
+ }
1754
+
1755
+ // src/service/launchd.ts
1756
+ import { join as join6 } from "path";
1757
+ var LAUNCHD_LABEL = "ai.cabane.companion";
1758
+ function launchAgentPath(home) {
1759
+ return join6(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
1760
+ }
1761
+ function renderPlist(input) {
1762
+ const args = input.programArguments.map((a) => ` <string>${xml(a)}</string>`).join("\n");
1763
+ const env = Object.entries(input.environment).map(([k, v]) => ` <key>${xml(k)}</key>
1764
+ <string>${xml(v)}</string>`).join("\n");
1765
+ return [
1766
+ '<?xml version="1.0" encoding="UTF-8"?>',
1767
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1768
+ '<plist version="1.0">',
1769
+ "<dict>",
1770
+ " <key>Label</key>",
1771
+ ` <string>${LAUNCHD_LABEL}</string>`,
1772
+ " <key>ProgramArguments</key>",
1773
+ " <array>",
1774
+ args,
1775
+ " </array>",
1776
+ " <key>RunAtLoad</key>",
1777
+ " <true/>",
1778
+ " <key>KeepAlive</key>",
1779
+ " <dict>",
1780
+ " <key>SuccessfulExit</key>",
1781
+ " <false/>",
1782
+ " </dict>",
1783
+ " <key>EnvironmentVariables</key>",
1784
+ " <dict>",
1785
+ env,
1786
+ " </dict>",
1787
+ " <key>StandardOutPath</key>",
1788
+ ` <string>${xml(input.logPath)}</string>`,
1789
+ " <key>StandardErrorPath</key>",
1790
+ ` <string>${xml(input.logPath)}</string>`,
1791
+ "</dict>",
1792
+ "</plist>",
1793
+ ""
1794
+ ].join("\n");
1795
+ }
1796
+ function domain(host) {
1797
+ return `gui/${host.uid}`;
1798
+ }
1799
+ function target(host) {
1800
+ return `${domain(host)}/${LAUNCHD_LABEL}`;
1801
+ }
1802
+ function launchdStart(host, plistPath) {
1803
+ host.run("launchctl", ["bootout", target(host)]);
1804
+ const res = host.run("launchctl", ["bootstrap", domain(host), plistPath]);
1805
+ return res.ok ? { ok: true } : { ok: false, detail: firstLine(res.stderr || res.stdout) };
1806
+ }
1807
+ function launchdStop(host) {
1808
+ const res = host.run("launchctl", ["bootout", target(host)]);
1809
+ if (res.ok || notLoaded(res.stderr + res.stdout)) return { ok: true };
1810
+ return { ok: false, detail: firstLine(res.stderr || res.stdout) };
1811
+ }
1812
+ function launchdProbe(host) {
1813
+ const res = host.run("launchctl", ["print", target(host)]);
1814
+ if (res.ok) {
1815
+ const state = /state\s*=\s*(\w+)/.exec(res.stdout)?.[1];
1816
+ return { ownership: "held", running: state ? state === "running" : "unknown" };
1817
+ }
1818
+ return notLoaded(res.stderr + res.stdout) ? { ownership: "clear", running: false } : { ownership: "unknown", running: "unknown" };
1819
+ }
1820
+ function launchdRemove(host, plistPath) {
1821
+ const stopped = launchdStop(host);
1822
+ if (!stopped.ok) return stopped;
1823
+ host.fs.remove(plistPath);
1824
+ return { ok: true };
1825
+ }
1826
+ var NOT_LOADED = new RegExp(
1827
+ `no such process|(could not find|not find service)[^\\n]*${LAUNCHD_LABEL.replace(
1828
+ /[.*+?^${}()|[\]\\]/g,
1829
+ "\\$&"
1830
+ )}`,
1831
+ "i"
1832
+ );
1833
+ function notLoaded(output) {
1834
+ return NOT_LOADED.test(output);
1835
+ }
1836
+ function firstLine(s) {
1837
+ return s.trim().split("\n")[0] ?? "";
1838
+ }
1839
+ function xml(value) {
1840
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1841
+ }
1842
+
1843
+ // src/service/systemd.ts
1844
+ import { dirname as dirname4, join as join7 } from "path";
1845
+ var SYSTEMD_UNIT = "cabane-companion.service";
1846
+ function systemdUnitPath(host) {
1847
+ const configHome = host.env.XDG_CONFIG_HOME?.trim() ? host.env.XDG_CONFIG_HOME.trim() : join7(host.home, ".config");
1848
+ return join7(configHome, "systemd", "user", SYSTEMD_UNIT);
1849
+ }
1850
+ function renderUnit(input) {
1851
+ const [exec, ...rest] = input.programArguments;
1852
+ const execStart = [quote(exec ?? ""), ...rest.map(quote)].join(" ");
1853
+ const env = Object.entries(input.environment).map(([k, v]) => `Environment=${k}=${v}`);
1854
+ return [
1855
+ "[Unit]",
1856
+ "Description=Cabane Companion \u2014 keeps this device answering while you are logged in",
1857
+ "After=network-online.target",
1858
+ "Wants=network-online.target",
1859
+ "",
1860
+ "[Service]",
1861
+ "Type=simple",
1862
+ ...env,
1863
+ `ExecStart=${execStart}`,
1864
+ "Restart=on-failure",
1865
+ "RestartSec=5",
1866
+ "",
1867
+ "[Install]",
1868
+ "WantedBy=default.target"
1869
+ ].join("\n") + "\n";
1870
+ }
1871
+ function systemdReload(host) {
1872
+ host.run("systemctl", ["--user", "daemon-reload"]);
1873
+ }
1874
+ function systemdStart(host) {
1875
+ systemdReload(host);
1876
+ const enabled = host.run("systemctl", ["--user", "enable", SYSTEMD_UNIT]);
1877
+ if (!enabled.ok) return { ok: false, detail: firstLine2(enabled.stderr || enabled.stdout) };
1878
+ const started = host.run("systemctl", ["--user", "restart", SYSTEMD_UNIT]);
1879
+ if (!started.ok) return { ok: false, detail: firstLine2(started.stderr || started.stdout) };
1880
+ return { ok: true };
1881
+ }
1882
+ function systemdStop(host) {
1883
+ const res = host.run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
1884
+ if (res.ok || notLoaded2(res.stderr + res.stdout)) return { ok: true };
1885
+ return { ok: false, detail: firstLine2(res.stderr || res.stdout) };
1886
+ }
1887
+ function systemdProbe(host) {
1888
+ const state = host.run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim();
1889
+ if (state === "active") return { ownership: "held", running: true };
1890
+ if (state === "activating" || state === "reloading" || state === "deactivating") {
1891
+ return { ownership: "held", running: "transitional" };
1892
+ }
1893
+ if (state === "inactive" || state === "failed") return { ownership: "clear", running: false };
1894
+ return { ownership: "unknown", running: "unknown" };
1895
+ }
1896
+ function systemdWantsLinkPath(host) {
1897
+ return join7(dirname4(systemdUnitPath(host)), "default.target.wants", SYSTEMD_UNIT);
1898
+ }
1899
+ function systemdInstalled(host) {
1900
+ return host.fs.exists(systemdUnitPath(host)) || host.fs.exists(systemdWantsLinkPath(host));
1901
+ }
1902
+ function systemdRemove(host, unitPath) {
1903
+ const stopped = host.run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
1904
+ if (!(stopped.ok || notLoaded2(stopped.stderr + stopped.stdout))) {
1905
+ return { ok: false, detail: firstLine2(stopped.stderr || stopped.stdout) };
1906
+ }
1907
+ host.fs.remove(unitPath);
1908
+ const link = systemdWantsLinkPath(host);
1909
+ if (host.fs.exists(link)) host.fs.remove(link);
1910
+ systemdReload(host);
1911
+ return { ok: true };
1912
+ }
1913
+ var NOT_LOADED2 = new RegExp(
1914
+ `unit (file )?${SYSTEMD_UNIT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (not loaded|does not exist|not found|could not be found)`,
1915
+ "i"
1916
+ );
1917
+ function notLoaded2(output) {
1918
+ return NOT_LOADED2.test(output);
1919
+ }
1920
+ function readLinger(host) {
1921
+ const res = host.run("loginctl", ["show-user", String(host.uid), "-p", "Linger"]);
1922
+ if (!res.ok) return "unknown";
1923
+ const match = /Linger=(\w+)/.exec(res.stdout);
1924
+ if (!match) return "unknown";
1925
+ return match[1] === "yes" ? "yes" : "no";
1926
+ }
1927
+ function tryEnableLinger(host) {
1928
+ host.run("loginctl", ["enable-linger", String(host.uid)]);
1929
+ return readLinger(host);
1930
+ }
1931
+ function isRemoteSession(env) {
1932
+ return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1933
+ }
1934
+ function quote(value) {
1935
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1936
+ }
1937
+ function firstLine2(s) {
1938
+ return s.trim().split("\n")[0] ?? "";
1939
+ }
1940
+
1941
+ // src/service/index.ts
1942
+ function detectServiceManager(host = defaultServiceHost()) {
1943
+ if (host.platform === "darwin") return "launchd";
1944
+ if (host.platform !== "linux") return "none";
1945
+ return host.run("systemctl", ["--user", "show-environment"]).ok ? "systemd-user" : "none";
1946
+ }
1947
+ function programArguments(host, _opts) {
1948
+ return [host.execPath, host.cliPath, "start", "--foreground"];
1949
+ }
1950
+ function environment(host) {
1951
+ return {
1952
+ // PATH CAPTURE. launchd's default PATH is `/usr/bin:/bin:/usr/sbin:/sbin`
1953
+ // and a systemd user manager's is barely better — neither has `claude`,
1954
+ // `codex` or `opencode` on it. A companion that runs but can't find its
1955
+ // harness is worse than one that isn't running, because the device reads
1956
+ // Online. So we bake in the PATH of the shell that ran `start`, and because
1957
+ // it's part of the rendered content, a PATH that later drifts re-renders on
1958
+ // the next `start` like any other change.
1959
+ PATH: host.env.PATH ?? "",
1960
+ CABANE_COMPANION_DAEMON: "1"
1961
+ };
1962
+ }
1963
+ function unitPathFor(host, manager) {
1964
+ if (manager === "launchd") return launchAgentPath(host.home);
1965
+ if (manager === "systemd-user") return systemdUnitPath(host);
1966
+ return null;
1430
1967
  }
1431
- async function postJson(baseUrl, path, body) {
1432
- let res;
1433
- try {
1434
- res = await fetch(`${trimBase(baseUrl)}${path}`, {
1435
- method: "POST",
1436
- headers: { "content-type": "application/json", accept: "application/json" },
1437
- body: JSON.stringify(body)
1438
- });
1439
- } catch (err) {
1440
- throw new CompanionError(
1441
- `couldn't reach cabane at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}. Check the server URL (pass --server <url>) and your connection.`
1442
- );
1443
- }
1444
- const raw = await res.text();
1445
- let parsed = void 0;
1446
- if (raw.length > 0) {
1447
- try {
1448
- parsed = JSON.parse(raw);
1449
- } catch {
1450
- parsed = raw;
1451
- }
1968
+ function renderFor(host, manager, opts) {
1969
+ const input = { programArguments: programArguments(host, opts), environment: environment(host) };
1970
+ return manager === "launchd" ? renderPlist({ ...input, logPath: host.logPath }) : renderUnit(input);
1971
+ }
1972
+ function installService(opts = {}, host = defaultServiceHost()) {
1973
+ const manager = detectServiceManager(host);
1974
+ if (manager === "none") return { installed: false, reason: "unsupported" };
1975
+ const path = unitPathFor(host, manager);
1976
+ if (!path) return { installed: false, reason: "unsupported" };
1977
+ const desired = renderFor(host, manager, opts);
1978
+ const existing = host.fs.read(path);
1979
+ const hadUnit = existing !== null;
1980
+ const changed = existing !== desired;
1981
+ if (changed) {
1982
+ host.fs.mkdirp(dirname5(path));
1983
+ host.fs.write(path, desired);
1452
1984
  }
1453
- if (res.status >= 400) {
1454
- if (res.status === 404 && path.endsWith("/code")) {
1455
- throw new CompanionError(
1456
- `no device-flow pairing endpoint at ${baseUrl}. Either that server predates \`cabane-companion pair\`, or it isn't a Cabane API origin \u2014 the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
1985
+ if (manager === "systemd-user") {
1986
+ const linger = ensureLinger(host);
1987
+ if (linger !== "yes" && isRemoteSession(host.env)) {
1988
+ systemdRemove(host, path);
1989
+ return failedInstall(
1990
+ host,
1991
+ manager,
1992
+ "linger-unavailable",
1993
+ "a systemd --user service would stop when this SSH session ends (lingering is off and could not be enabled)"
1457
1994
  );
1458
1995
  }
1459
- const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
1460
- throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
1461
1996
  }
1462
- return parsed;
1997
+ const started = manager === "launchd" ? launchdStart(host, path) : systemdStart(host);
1998
+ if (!started.ok) {
1999
+ if (!hadUnit) removeService(host, manager, path);
2000
+ return failedInstall(host, manager, "command-failed", started.detail);
2001
+ }
2002
+ return { installed: true, manager, changed };
1463
2003
  }
1464
- function requestEnrollmentCode(baseUrl) {
1465
- return postJson(baseUrl, "/api/device-enrollment/code", {});
2004
+ function failedInstall(host, manager, reason, detail) {
2005
+ return {
2006
+ installed: false,
2007
+ reason,
2008
+ ...detail ? { detail } : {},
2009
+ ...managerOwnership(host, manager) === "clear" ? {} : { leftBehind: manager }
2010
+ };
1466
2011
  }
1467
- function pollOnce(baseUrl, deviceCode) {
1468
- return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
2012
+ function managerProbe(host, manager) {
2013
+ if (manager === "launchd") return launchdProbe(host);
2014
+ if (manager === "systemd-user") return systemdProbe(host);
2015
+ return { ownership: "clear", running: false };
1469
2016
  }
1470
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1471
- async function runDeviceFlow(baseUrl, print, opts = {}) {
1472
- const now = opts.now ?? (() => Date.now());
1473
- const wait = opts.sleepMs ?? sleep;
1474
- if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
1475
- const code = await requestEnrollmentCode(baseUrl);
1476
- if (opts.onCode) await opts.onCode(code);
1477
- print("");
1478
- print(` Enter this code at ${code.verificationUri}`);
1479
- print("");
1480
- print(` ${code.userCode}`);
1481
- print("");
1482
- print(` or open: ${code.verificationUriComplete}`);
1483
- print("");
1484
- print("Waiting for you to confirm it in cabane\u2026");
1485
- const deadline = now() + code.expiresIn * 1e3;
1486
- let intervalMs = Math.max(1, code.interval) * 1e3;
1487
- while (now() < deadline) {
1488
- await wait(intervalMs);
1489
- if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
1490
- const res = await pollOnce(baseUrl, code.deviceCode);
1491
- if (res.status === "complete") {
1492
- if (!res.deviceToken || !res.baseUrl) {
1493
- throw new CompanionError("the server reported the pairing complete but returned no token.");
1494
- }
1495
- return {
1496
- baseUrl: res.baseUrl,
1497
- deviceToken: res.deviceToken,
1498
- ...res.deviceId ? { deviceId: res.deviceId } : {},
1499
- ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {}
1500
- };
1501
- }
1502
- if (res.status === "expired") {
1503
- throw new CompanionError(
1504
- "this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
1505
- );
1506
- }
1507
- if (res.status === "slow_down") intervalMs += 1e3;
1508
- }
1509
- throw new CompanionError(
1510
- "this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
1511
- );
2017
+ function managerOwnership(host, manager) {
2018
+ return managerProbe(host, manager).ownership;
1512
2019
  }
1513
-
1514
- // src/pairing-config.ts
1515
- var DEFAULT_BASE_URL = "https://app.cabane.ai";
1516
- function resolvePairBaseUrl(server) {
1517
- const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
1518
- if (!isAllowedBaseUrl(raw)) {
1519
- throw new CompanionError(
1520
- `invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`cabane-companion pair --server https://your-cabane\`.`
1521
- );
1522
- }
1523
- return raw;
2020
+ function refreshInstalledService(opts = {}, host = defaultServiceHost()) {
2021
+ const manager = detectServiceManager(host);
2022
+ const path = unitPathFor(host, manager);
2023
+ if (!path || !host.fs.exists(path)) return false;
2024
+ const desired = renderFor(host, manager, opts);
2025
+ if (host.fs.read(path) === desired) return false;
2026
+ host.fs.write(path, desired);
2027
+ if (manager === "systemd-user") systemdReload(host);
2028
+ return true;
1524
2029
  }
1525
- function writePairedConfig(paired) {
1526
- const { local, note, hadPriorConfig } = loadConfigTolerant();
1527
- const config = {
1528
- // CT1082: a device pairs connected to NOTHING `claudeCode: { enabled: false }`
1529
- // written explicitly, not left absent, because absence is exactly what marks a
1530
- // pre-CT1082 config for the grandfathering migration. Omit it and a brand-new
1531
- // device gets auto-connected on its first start by the behaviour this task
1532
- // removes; stamp it on a RE-pair and an upgrading user's working device gets
1533
- // disconnected instead. So it's written only when there was no config here
1534
- // before. `local` spreads after, so a re-pair keeps the answer already given.
1535
- ...hadPriorConfig ? {} : { claudeCode: { enabled: false } },
1536
- ...local,
1537
- baseUrl: paired.baseUrl,
1538
- deviceToken: paired.deviceToken,
1539
- ...paired.deviceId ? { deviceId: paired.deviceId } : {},
1540
- ...paired.deviceLabel ? { deviceLabel: paired.deviceLabel } : {}
2030
+ function stopService(host = defaultServiceHost()) {
2031
+ const manager = detectServiceManager(host);
2032
+ const path = unitPathFor(host, manager);
2033
+ if (manager === "none" || !path || !managerHasClaim(host, manager, path)) {
2034
+ return { handled: false, ok: true };
2035
+ }
2036
+ const res = manager === "launchd" ? launchdStop(host) : systemdStop(host);
2037
+ return {
2038
+ handled: true,
2039
+ ok: res.ok,
2040
+ manager,
2041
+ ...res.detail ? { detail: res.detail } : {}
1541
2042
  };
1542
- saveConfig(config);
1543
- return { config, note };
1544
2043
  }
1545
-
1546
- // src/commands/pair.ts
1547
- function writePairedConfigCli(paired) {
1548
- const { note } = writePairedConfig(paired);
1549
- if (note) process.stderr.write(`note: ${note}
1550
- `);
1551
- const labelSuffix = paired.deviceLabel ? ` as "${paired.deviceLabel}"` : "";
1552
- process.stdout.write(
1553
- `\u2713 Paired this device${labelSuffix} with ${paired.baseUrl}.
1554
- Run \`cabane-companion start\` \u2014 it will pull the agents assigned to this device and run them.
1555
- `
1556
- );
2044
+ function disableService(host = defaultServiceHost()) {
2045
+ const manager = detectServiceManager(host);
2046
+ const path = unitPathFor(host, manager);
2047
+ if (manager === "none" || !path) return { handled: false, ok: true };
2048
+ if (!managerHasClaim(host, manager, path)) return { handled: false, ok: true, manager };
2049
+ const res = removeService(host, manager, path);
2050
+ return { handled: true, ok: res.ok, manager, ...res.detail ? { detail: res.detail } : {} };
1557
2051
  }
1558
- function writeCompletedPairing(raw) {
1559
- let paired;
1560
- try {
1561
- paired = JSON.parse(raw);
1562
- } catch {
1563
- throw new Error("invalid completed pairing payload: expected JSON on stdin.");
1564
- }
1565
- if (!paired || typeof paired !== "object" || typeof paired.baseUrl !== "string" || typeof paired.deviceToken !== "string") {
1566
- throw new Error("invalid completed pairing payload: baseUrl and deviceToken are required.");
1567
- }
1568
- writePairedConfigCli(paired);
2052
+ function managerHasClaim(host, manager, path) {
2053
+ if (manager === "none") return false;
2054
+ if (host.fs.exists(path)) return true;
2055
+ if (manager === "systemd-user" && systemdInstalled(host)) return true;
2056
+ return managerOwnership(host, manager) !== "clear";
1569
2057
  }
1570
- async function pair(opts = {}) {
1571
- const baseUrl = resolvePairBaseUrl(opts.server);
1572
- const paired = await runDeviceFlow(baseUrl, (line) => process.stdout.write(`${line}
1573
- `));
1574
- writePairedConfigCli(paired);
2058
+ function removeService(host, manager, path) {
2059
+ return manager === "launchd" ? launchdRemove(host, path) : systemdRemove(host, path);
2060
+ }
2061
+ function serviceStatus(host = defaultServiceHost()) {
2062
+ const manager = detectServiceManager(host);
2063
+ const path = unitPathFor(host, manager);
2064
+ const probe = managerProbe(host, manager);
2065
+ const installed = manager === "none" || !path ? false : host.fs.exists(path) || probe.ownership === "held" || manager === "systemd-user" && systemdInstalled(host);
2066
+ const running = manager === "none" ? false : probe.running;
2067
+ return {
2068
+ manager,
2069
+ unitPath: path,
2070
+ installed,
2071
+ running,
2072
+ linger: manager === "systemd-user" ? readLinger(host) : null,
2073
+ remoteSession: isRemoteSession(host.env)
2074
+ };
2075
+ }
2076
+ function ensureLinger(host) {
2077
+ const current = readLinger(host);
2078
+ if (current === "yes") return current;
2079
+ return tryEnableLinger(host);
1575
2080
  }
1576
-
1577
- // src/cli.ts
1578
- import { readFileSync as readFileSync12 } from "fs";
1579
2081
 
1580
2082
  // src/commands/service.ts
1581
2083
  function serviceStatusCommand(deps = {}) {
@@ -1633,58 +2135,30 @@ function runningLine(running) {
1633
2135
  if (running === "unknown") return "unknown \u2014 the service manager didn't answer";
1634
2136
  return running ? "yes" : "no";
1635
2137
  }
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
- }
1643
-
1644
- // src/browser.ts
1645
- import { spawn as spawn5 } from "child_process";
1646
- import { platform } from "process";
1647
- function openBrowser(url) {
1648
- try {
1649
- const { command, args } = openerFor(url);
1650
- const child = spawn5(command, args, { stdio: "ignore", detached: true });
1651
- child.on("error", () => {
1652
- });
1653
- child.unref();
1654
- } catch {
1655
- }
1656
- }
1657
- function openerFor(url) {
1658
- switch (platform) {
1659
- case "darwin":
1660
- return { command: "open", args: [url] };
1661
- case "win32":
1662
- return { command: "cmd", args: ["/c", "start", "", url] };
1663
- default:
1664
- return { command: "xdg-open", args: [url] };
1665
- }
1666
- }
1667
- function shouldAutoOpen(opts) {
1668
- if (opts.flagOpen === true) return true;
1669
- if (opts.flagOpen === false) return false;
1670
- if (process.env.COMPANION_NO_OPEN === "1") return false;
1671
- if (opts.configAutoOpen === true) return true;
1672
- return false;
2138
+ function lingerNote(status2) {
2139
+ if (status2.linger === "yes") return " (survives logout)";
2140
+ if (status2.remoteSession) {
2141
+ return " \u2014 without lingering a --user service stops when this SSH session ends";
2142
+ }
2143
+ return " \u2014 the service runs while you are logged in";
1673
2144
  }
1674
2145
 
2146
+ // src/commands/start.ts
2147
+ import { hostname as hostname2 } from "os";
2148
+
1675
2149
  // src/runtime.ts
1676
2150
  import { randomUUID as randomUUID2 } from "crypto";
1677
2151
 
1678
2152
  // src/dashboard/server.ts
1679
- import { dirname as dirname6, join as join8 } from "path";
2153
+ import { dirname as dirname6, join as join9 } from "path";
1680
2154
  import { fileURLToPath as fileURLToPath2 } from "url";
1681
2155
  import { serve } from "@hono/node-server";
1682
2156
  import { Hono } from "hono";
1683
2157
 
1684
2158
  // src/dashboard/routes.ts
1685
- import { openSync as openSync3, readSync, closeSync as closeSync3, fstatSync, existsSync as existsSync6 } from "fs";
2159
+ import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync7 } from "fs";
1686
2160
  import { readFile } from "fs/promises";
1687
- import { extname, join as join7, normalize } from "path";
2161
+ import { extname, join as join8, normalize } from "path";
1688
2162
  import { streamSSE } from "hono/streaming";
1689
2163
 
1690
2164
  // src/state.ts
@@ -1933,14 +2407,14 @@ var CONTENT_TYPES = {
1933
2407
  function registerRoutes(app, deps) {
1934
2408
  const { supervisor, hub, staticDir } = deps;
1935
2409
  app.get("/", async (c) => {
1936
- const html = await readFile(join7(staticDir, "index.html"), "utf8");
2410
+ const html = await readFile(join8(staticDir, "index.html"), "utf8");
1937
2411
  return c.html(html);
1938
2412
  });
1939
2413
  app.get("/static/:file", async (c) => {
1940
2414
  const file = c.req.param("file");
1941
2415
  const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1942
- const full = join7(staticDir, safe3);
1943
- if (!full.startsWith(staticDir) || !existsSync6(full)) return c.notFound();
2416
+ const full = join8(staticDir, safe3);
2417
+ if (!full.startsWith(staticDir) || !existsSync7(full)) return c.notFound();
1944
2418
  const body = await readFile(full);
1945
2419
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
1946
2420
  c.header("content-type", type);
@@ -2067,11 +2541,11 @@ function clampLimit(raw, fallback, max = 200) {
2067
2541
  return Math.min(Math.floor(n), max);
2068
2542
  }
2069
2543
  function tailFile(path, lines) {
2070
- if (!existsSync6(path)) return [];
2544
+ if (!existsSync7(path)) return [];
2071
2545
  const MAX_BYTES = 256 * 1024;
2072
2546
  let fd;
2073
2547
  try {
2074
- fd = openSync3(path, "r");
2548
+ fd = openSync2(path, "r");
2075
2549
  const size = fstatSync(fd).size;
2076
2550
  const start2 = Math.max(0, size - MAX_BYTES);
2077
2551
  const len = size - start2;
@@ -2085,7 +2559,7 @@ function tailFile(path, lines) {
2085
2559
  } catch {
2086
2560
  return [];
2087
2561
  } finally {
2088
- if (fd !== void 0) closeSync3(fd);
2562
+ if (fd !== void 0) closeSync2(fd);
2089
2563
  }
2090
2564
  }
2091
2565
 
@@ -2156,7 +2630,7 @@ function isAddrInUse(err) {
2156
2630
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
2157
2631
  }
2158
2632
  function resolveStaticDir() {
2159
- return join8(dirname6(fileURLToPath2(import.meta.url)), "static");
2633
+ return join9(dirname6(fileURLToPath2(import.meta.url)), "static");
2160
2634
  }
2161
2635
 
2162
2636
  // src/api.ts
@@ -2632,20 +3106,20 @@ function errorMessage2(status2, body) {
2632
3106
  }
2633
3107
 
2634
3108
  // src/cursor.ts
2635
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2636
- import { join as join9 } from "path";
3109
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
3110
+ import { join as join10 } from "path";
2637
3111
  function pathFor(workspaceId) {
2638
- return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
3112
+ return join10(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2639
3113
  }
2640
3114
  function readCursor(workspaceId) {
2641
3115
  const path = pathFor(workspaceId);
2642
- if (!existsSync7(path)) return null;
3116
+ if (!existsSync8(path)) return null;
2643
3117
  const raw = readFileSync5(path, "utf8").trim();
2644
3118
  return raw.length > 0 ? raw : null;
2645
3119
  }
2646
3120
  function writeCursor(workspaceId, eventId) {
2647
3121
  const path = pathFor(workspaceId);
2648
- mkdirSync7(join9(cabaneDir(), "cursors"), { recursive: true });
3122
+ mkdirSync7(join10(cabaneDir(), "cursors"), { recursive: true });
2649
3123
  writeFileSync5(path, eventId + "\n", "utf8");
2650
3124
  }
2651
3125
 
@@ -2689,18 +3163,18 @@ var CursorTracker = class {
2689
3163
  };
2690
3164
 
2691
3165
  // src/dispatch-dedupe.ts
2692
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
2693
- import { join as join10 } from "path";
3166
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
3167
+ import { join as join11 } from "path";
2694
3168
  var MAX_IDS = 256;
2695
3169
  function dir(log) {
2696
- return join10(cabaneDir(), log);
3170
+ return join11(cabaneDir(), log);
2697
3171
  }
2698
3172
  function pathFor2(log, workspaceId) {
2699
- return join10(dir(log), encodeURIComponent(workspaceId));
3173
+ return join11(dir(log), encodeURIComponent(workspaceId));
2700
3174
  }
2701
3175
  function readIds(log, workspaceId) {
2702
3176
  const path = pathFor2(log, workspaceId);
2703
- if (!existsSync8(path)) return [];
3177
+ if (!existsSync9(path)) return [];
2704
3178
  try {
2705
3179
  return readFileSync6(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2706
3180
  } catch {
@@ -2732,15 +3206,15 @@ function markCompleted(workspaceId, eventId) {
2732
3206
  }
2733
3207
  var MAX_RESUME_ATTEMPTS = 3;
2734
3208
  function resumeDir() {
2735
- return join10(cabaneDir(), "resume-attempts");
3209
+ return join11(cabaneDir(), "resume-attempts");
2736
3210
  }
2737
3211
  function resumePathFor(workspaceId) {
2738
- return join10(resumeDir(), encodeURIComponent(workspaceId));
3212
+ return join11(resumeDir(), encodeURIComponent(workspaceId));
2739
3213
  }
2740
3214
  function readResumeCounts(workspaceId) {
2741
3215
  const out = /* @__PURE__ */ new Map();
2742
3216
  const path = resumePathFor(workspaceId);
2743
- if (!existsSync8(path)) return out;
3217
+ if (!existsSync9(path)) return out;
2744
3218
  try {
2745
3219
  for (const line of readFileSync6(path, "utf8").split("\n")) {
2746
3220
  const trimmed = line.trim();
@@ -4654,7 +5128,7 @@ async function acquireServerTurnLock(url) {
4654
5128
  };
4655
5129
  }
4656
5130
  function createHttpOpencodeTransport(opts) {
4657
- const base = trimSlash2(opts.baseUrl);
5131
+ const base = trimSlash(opts.baseUrl);
4658
5132
  const doFetch = opts.fetchImpl ?? fetch;
4659
5133
  return {
4660
5134
  async run(spec, signal) {
@@ -4800,7 +5274,7 @@ function belongsToSession(ev, sessionId) {
4800
5274
  function newOpencodeMessageId() {
4801
5275
  return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
4802
5276
  }
4803
- function trimSlash2(s) {
5277
+ function trimSlash(s) {
4804
5278
  return s.endsWith("/") ? s.slice(0, -1) : s;
4805
5279
  }
4806
5280
 
@@ -5550,7 +6024,13 @@ function parseCodexModel(model) {
5550
6024
  var CABANE_MCP_SERVER3 = "cabane";
5551
6025
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
5552
6026
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
5553
- var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
6027
+ var ENV_ENVELOPE_KEYS = [
6028
+ "CABANE_PLAYGROUND",
6029
+ "CABANE_PLAYGROUND_BIN",
6030
+ "CABANE_CONVERSATION_ID",
6031
+ "CABANE_AGENT_ID",
6032
+ "CABANE_CONVERSATION_TITLE"
6033
+ ];
5554
6034
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
5555
6035
  const { policy, config } = req;
5556
6036
  const directory = req.local.cwd ?? "";
@@ -6455,8 +6935,8 @@ var ConnectorHealthStore = class {
6455
6935
 
6456
6936
  // src/dispatcher.ts
6457
6937
  import { randomUUID } from "crypto";
6458
- import { appendFileSync as appendFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync11, readdirSync as readdirSync2, statSync } from "fs";
6459
- import { join as join15 } from "path";
6938
+ import { appendFileSync as appendFileSync2, existsSync as existsSync12, mkdirSync as mkdirSync11, readdirSync as readdirSync2, statSync } from "fs";
6939
+ import { join as join16 } from "path";
6460
6940
 
6461
6941
  // src/summon.ts
6462
6942
  import { z as z12 } from "zod";
@@ -6730,10 +7210,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6730
7210
 
6731
7211
  // src/build-options.ts
6732
7212
  function cabaneMcpUrl(baseUrl) {
6733
- return `${trimSlash3(baseUrl)}/api/mcp`;
7213
+ return `${trimSlash2(baseUrl)}/api/mcp`;
6734
7214
  }
6735
7215
  function turnControlMcpUrl(baseUrl) {
6736
- return `${trimSlash3(baseUrl)}/api/turn-control`;
7216
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
6737
7217
  }
6738
7218
  function buildCompanionTurnRequest(params) {
6739
7219
  const { turnContext: t } = params;
@@ -6782,18 +7262,18 @@ function buildCompanionTurnRequest(params) {
6782
7262
  }
6783
7263
  };
6784
7264
  }
6785
- function trimSlash3(s) {
7265
+ function trimSlash2(s) {
6786
7266
  return s.endsWith("/") ? s.slice(0, -1) : s;
6787
7267
  }
6788
7268
 
6789
7269
  // src/codex-instructions.ts
6790
7270
  import { mkdtemp, rm, writeFile } from "fs/promises";
6791
7271
  import { tmpdir } from "os";
6792
- import { join as join11 } from "path";
7272
+ import { join as join12 } from "path";
6793
7273
  var PREFIX = "cabane-codex-instructions-";
6794
7274
  async function writeCodexInstructionsFile(contents) {
6795
- const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
6796
- const path = join11(dir2, "instructions.md");
7275
+ const dir2 = await mkdtemp(join12(tmpdir(), PREFIX));
7276
+ const path = join12(dir2, "instructions.md");
6797
7277
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
6798
7278
  return {
6799
7279
  path,
@@ -6804,20 +7284,20 @@ async function writeCodexInstructionsFile(contents) {
6804
7284
  }
6805
7285
 
6806
7286
  // src/prepared.ts
6807
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync7, existsSync as existsSync9 } from "fs";
6808
- import { join as join12 } from "path";
7287
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync7, existsSync as existsSync10 } from "fs";
7288
+ import { join as join13 } from "path";
6809
7289
  function dirFor(workspaceId) {
6810
- return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7290
+ return join13(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6811
7291
  }
6812
7292
  function conversationDir(workspaceId, conversationId) {
6813
- return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
7293
+ return join13(dirFor(workspaceId), encodeURIComponent(conversationId));
6814
7294
  }
6815
7295
  function pathFor3(workspaceId, conversationId, agentId) {
6816
- return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7296
+ return join13(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6817
7297
  }
6818
7298
  function readPrepared(workspaceId, conversationId, agentId) {
6819
7299
  const path = pathFor3(workspaceId, conversationId, agentId);
6820
- if (!existsSync9(path)) return null;
7300
+ if (!existsSync10(path)) return null;
6821
7301
  try {
6822
7302
  const parsed = JSON.parse(readFileSync7(path, "utf8"));
6823
7303
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -6840,21 +7320,21 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
6840
7320
  );
6841
7321
  }
6842
7322
  function clearPrepared(workspaceId, conversationId, agentId) {
6843
- rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
7323
+ rmSync6(pathFor3(workspaceId, conversationId, agentId), { force: true });
6844
7324
  }
6845
7325
 
6846
7326
  // src/secrets.ts
6847
- import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
6848
- import { join as join13 } from "path";
7327
+ import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
7328
+ import { join as join14 } from "path";
6849
7329
  import { z as z13 } from "zod";
6850
7330
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6851
7331
  function secretsPath() {
6852
- return join13(cabaneDir(), "secrets.json");
7332
+ return join14(cabaneDir(), "secrets.json");
6853
7333
  }
6854
7334
  var secretStoreSchema = z13.record(z13.string(), z13.string());
6855
7335
  function loadSecretStore() {
6856
7336
  const path = secretsPath();
6857
- if (!existsSync10(path)) return makeStore({});
7337
+ if (!existsSync11(path)) return makeStore({});
6858
7338
  let raw;
6859
7339
  try {
6860
7340
  raw = readFileSync8(path, "utf8");
@@ -6935,10 +7415,10 @@ function resolveMcpSecrets(mcpServers, store) {
6935
7415
  }
6936
7416
 
6937
7417
  // src/transcript-writer.ts
6938
- import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync10, readdirSync, rmSync as rmSync6 } from "fs";
6939
- import { join as join14 } from "path";
7418
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync10, readdirSync, rmSync as rmSync7 } from "fs";
7419
+ import { join as join15 } from "path";
6940
7420
  function transcriptsDir() {
6941
- return join14(cabaneDir(), "transcripts");
7421
+ return join15(cabaneDir(), "transcripts");
6942
7422
  }
6943
7423
  var RETAIN = 200;
6944
7424
  var TranscriptWriter = class {
@@ -6947,7 +7427,7 @@ var TranscriptWriter = class {
6947
7427
  onWarn;
6948
7428
  constructor(dir2, meta, onWarn) {
6949
7429
  this.onWarn = onWarn;
6950
- this.path = join14(dir2, fileName(meta));
7430
+ this.path = join15(dir2, fileName(meta));
6951
7431
  try {
6952
7432
  mkdirSync10(dir2, { recursive: true });
6953
7433
  try {
@@ -7006,7 +7486,7 @@ function pruneOld(dir2, retain) {
7006
7486
  const drop = files.sort().slice(0, files.length - retain);
7007
7487
  for (const f of drop) {
7008
7488
  try {
7009
- rmSync6(join14(dir2, f), { force: true });
7489
+ rmSync7(join15(dir2, f), { force: true });
7010
7490
  } catch {
7011
7491
  }
7012
7492
  }
@@ -7352,7 +7832,7 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7352
7832
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7353
7833
  function checkoutState(cwd) {
7354
7834
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
7355
- if (!existsSync11(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
7835
+ if (!existsSync12(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
7356
7836
  let entries;
7357
7837
  try {
7358
7838
  entries = readdirSync2(cwd);
@@ -7362,15 +7842,15 @@ function checkoutState(cwd) {
7362
7842
  if (entries.length === 0) {
7363
7843
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
7364
7844
  }
7365
- const gitPath = join15(cwd, ".git");
7366
- if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
7845
+ const gitPath = join16(cwd, ".git");
7846
+ if (!existsSync12(gitPath)) return { ok: true, reason: "usable" };
7367
7847
  let stat;
7368
7848
  try {
7369
7849
  stat = statSync(gitPath);
7370
7850
  } catch (error) {
7371
7851
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
7372
7852
  }
7373
- if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
7853
+ if (stat.isDirectory() && !existsSync12(join16(gitPath, "HEAD")))
7374
7854
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
7375
7855
  return { ok: true, reason: "usable" };
7376
7856
  }
@@ -7561,7 +8041,7 @@ var Dispatcher = class {
7561
8041
  let seqCounter = 0;
7562
8042
  const nextSeq = () => ++seqCounter;
7563
8043
  let effectiveCwd = localCwd ?? cabaneCwd;
7564
- if (effectiveCwd && !existsSync11(effectiveCwd)) {
8044
+ if (effectiveCwd && !existsSync12(effectiveCwd)) {
7565
8045
  turnLog.warn(
7566
8046
  { cwd: effectiveCwd },
7567
8047
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -7869,7 +8349,7 @@ ${reason}`,
7869
8349
  }
7870
8350
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7871
8351
  }
7872
- const receiptPath = join15(effectiveCwd, ".git", "cabane", "readiness.jsonl");
8352
+ const receiptPath = join16(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7873
8353
  const receiptLine = (fields) => `${JSON.stringify({
7874
8354
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7875
8355
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7893,7 +8373,7 @@ ${reason}`,
7893
8373
  })}
7894
8374
  `;
7895
8375
  try {
7896
- mkdirSync11(join15(effectiveCwd, ".git", "cabane"), { recursive: true });
8376
+ mkdirSync11(join16(effectiveCwd, ".git", "cabane"), { recursive: true });
7897
8377
  appendFileSync2(
7898
8378
  receiptPath,
7899
8379
  // `starting` is the honest classification before the proof has run. The
@@ -8279,243 +8759,47 @@ ${reason}`,
8279
8759
  ok: okResult,
8280
8760
  ...resultReason ? { reason: resultReason } : {},
8281
8761
  durationMs
8282
- });
8283
- if (!okResult && resultReason !== "cancelled") {
8284
- turnLog.info(`turn failed \u2014 full transcript: ${transcript2.path}`);
8285
- }
8286
- }
8287
- if (okResult) {
8288
- turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
8289
- this.notifyEnd({
8290
- id: dispatchId,
8291
- ok: true,
8292
- durationMs,
8293
- ...finalReplyBody ? { reply: finalReplyBody.trim() } : {}
8294
- });
8295
- return { ok: true, durationMs };
8296
- }
8297
- turnLog.debug({ durationMs, reason: resultReason }, "dispatcher: turn end (not ok)");
8298
- this.notifyEnd({
8299
- id: dispatchId,
8300
- ok: false,
8301
- durationMs,
8302
- ...resultReason ? { reason: resultReason } : {}
8303
- });
8304
- return {
8305
- ok: false,
8306
- durationMs,
8307
- ...resultReason ? { reason: resultReason } : {}
8308
- };
8309
- }
8310
- // SJ383: cancel a specific (conversation, agent) run if one is in flight in
8311
- // THIS companion process. Returns true if an in-flight run was aborted.
8312
- cancel(conversationId, agentId) {
8313
- const key = runKey(conversationId, agentId);
8314
- const ac = this.aborts.get(key);
8315
- if (!ac) return false;
8316
- try {
8317
- ac.abort();
8318
- } catch {
8319
- }
8320
- return true;
8321
- }
8322
- };
8323
-
8324
- // src/manifest.ts
8325
- var DEVICE_MANIFEST = {
8326
- runtimes: [{ name: "claude-code", version: null }],
8327
- capabilities: { hostFs: true, browser: true, userMcp: true }
8328
- };
8329
- function buildCompanionManifest(opts) {
8330
- const v = opts.versions ?? {};
8331
- const runtimes = [];
8332
- if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
8333
- if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
8334
- if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
8335
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
8336
- }
8337
-
8338
- // src/harness-status.ts
8339
- var LABELS = {
8340
- "claude-code": "Claude Code",
8341
- codex: "Codex",
8342
- opencode: "opencode"
8343
- };
8344
- function deriveHarnessSnapshot(signals) {
8345
- const advertised = new Set(
8346
- buildCompanionManifest({
8347
- // CT1082: connected AND installed — the manifest's own rule, restated here
8348
- // through the same function rather than re-decided.
8349
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
8350
- opencode: signals.opencodeConfigured,
8351
- codex: signals.codexEnabled
8352
- }).runtimes.map((r) => r.name)
8353
- );
8354
- const harnesses = [
8355
- deriveClaudeCode(signals, advertised.has("claude-code")),
8356
- deriveCodex(signals, advertised.has("codex")),
8357
- deriveOpencode(signals, advertised.has("opencode"))
8358
- ];
8359
- return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
8360
- }
8361
- function deriveClaudeCode(signals, manifestHas) {
8362
- const base = { runtime: "claude-code", label: LABELS["claude-code"] };
8363
- if (manifestHas) {
8364
- return {
8365
- ...base,
8366
- state: "exposed",
8367
- version: signals.claudeVersion,
8368
- detail: "Claude Code is connected and exposed to Cabane.",
8369
- enable: null
8370
- };
8371
- }
8372
- if (signals.claudeCodeConnected) {
8373
- return {
8374
- ...base,
8375
- state: "needs_attention",
8376
- version: null,
8377
- detail: "Connected, but the `claude` CLI isn\u2019t on your PATH. Install it (`npm i -g @anthropic-ai/claude-code`) and sign in, or disconnect it.",
8378
- enable: null
8379
- };
8380
- }
8381
- if (signals.claudeOnPath) {
8382
- return {
8383
- ...base,
8384
- state: "detected_not_exposed",
8385
- version: signals.claudeVersion,
8386
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
8387
- enable: "claude-code"
8388
- };
8389
- }
8390
- return {
8391
- ...base,
8392
- state: "not_detected",
8393
- version: null,
8394
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
8395
- enable: null
8396
- };
8397
- }
8398
- function deriveCodex(signals, manifestHas) {
8399
- const base = { runtime: "codex", label: LABELS.codex };
8400
- if (manifestHas) {
8401
- if (signals.codexOnPath) {
8402
- return {
8403
- ...base,
8404
- state: "exposed",
8405
- version: signals.codexVersion,
8406
- detail: "Codex is enabled and exposed to Cabane.",
8407
- enable: null
8408
- };
8409
- }
8410
- return {
8411
- ...base,
8412
- state: "needs_attention",
8413
- version: null,
8414
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
8415
- enable: null
8416
- };
8417
- }
8418
- if (signals.codexOnPath) {
8419
- return {
8420
- ...base,
8421
- state: "detected_not_exposed",
8422
- version: signals.codexVersion,
8423
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
8424
- enable: "codex"
8425
- };
8426
- }
8427
- return {
8428
- ...base,
8429
- state: "not_detected",
8430
- version: null,
8431
- detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
8432
- enable: null
8433
- };
8434
- }
8435
- function deriveOpencode(signals, manifestHas) {
8436
- const base = { runtime: "opencode", label: LABELS.opencode };
8437
- if (manifestHas) {
8438
- if (signals.opencodeReachable) {
8439
- return {
8440
- ...base,
8441
- state: "exposed",
8442
- version: signals.opencodeVersion,
8443
- detail: "An opencode server is reachable and exposed to Cabane.",
8444
- enable: null
8445
- };
8446
- }
8447
- return {
8448
- ...base,
8449
- state: "needs_attention",
8450
- version: null,
8451
- detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
8452
- enable: null
8453
- };
8454
- }
8455
- return {
8456
- ...base,
8457
- state: "not_detected",
8458
- version: null,
8459
- detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
8460
- enable: "opencode"
8461
- };
8462
- }
8463
- function detectedRuntimesFor(snapshot) {
8464
- return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
8465
- }
8466
- var PROBE_TIMEOUT_MS2 = 4e3;
8467
- async function probeHarnessSignals(cfg, deps = {}) {
8468
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
8469
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
8470
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
8471
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
8472
- const serverUrl = cfg.opencode?.serverUrl;
8473
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
8474
- withTimeout(probeClaudePresence(), false),
8475
- withTimeout(probeClaudeVersion(), null),
8476
- withTimeout(probeCodexVersion(), null),
8477
- serverUrl ? withTimeout(probeOpencode(serverUrl), null) : Promise.resolve(null)
8478
- ]);
8479
- return {
8480
- claudeOnPath: claudeOnPathResult,
8481
- claudeVersion,
8482
- // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
8483
- // the manifest gate and the probe above is only a suggestion.
8484
- claudeCodeConnected: isClaudeCodeConnected(cfg),
8485
- // A parseable `codex --version` is our presence signal (presence alone never
8486
- // exposes codex; its config flag is the manifest gate either way).
8487
- codexOnPath: codexVersion !== null,
8488
- codexVersion,
8489
- codexEnabled: isCodexEnabled(cfg),
8490
- opencodeConfigured: !!serverUrl,
8491
- // A version came back ⟺ the serve answered its health endpoint (CT584).
8492
- opencodeReachable: opencodeVersion !== null,
8493
- opencodeVersion
8494
- };
8495
- }
8496
- function withTimeout(promise, fallback) {
8497
- return new Promise((resolve) => {
8498
- let settled = false;
8499
- const done = (v) => {
8500
- if (!settled) {
8501
- settled = true;
8502
- resolve(v);
8503
- }
8504
- };
8505
- const timer = setTimeout(() => done(fallback), PROBE_TIMEOUT_MS2);
8506
- timer.unref?.();
8507
- promise.then(
8508
- (v) => {
8509
- clearTimeout(timer);
8510
- done(v);
8511
- },
8512
- () => {
8513
- clearTimeout(timer);
8514
- done(fallback);
8762
+ });
8763
+ if (!okResult && resultReason !== "cancelled") {
8764
+ turnLog.info(`turn failed \u2014 full transcript: ${transcript2.path}`);
8515
8765
  }
8516
- );
8517
- });
8518
- }
8766
+ }
8767
+ if (okResult) {
8768
+ turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
8769
+ this.notifyEnd({
8770
+ id: dispatchId,
8771
+ ok: true,
8772
+ durationMs,
8773
+ ...finalReplyBody ? { reply: finalReplyBody.trim() } : {}
8774
+ });
8775
+ return { ok: true, durationMs };
8776
+ }
8777
+ turnLog.debug({ durationMs, reason: resultReason }, "dispatcher: turn end (not ok)");
8778
+ this.notifyEnd({
8779
+ id: dispatchId,
8780
+ ok: false,
8781
+ durationMs,
8782
+ ...resultReason ? { reason: resultReason } : {}
8783
+ });
8784
+ return {
8785
+ ok: false,
8786
+ durationMs,
8787
+ ...resultReason ? { reason: resultReason } : {}
8788
+ };
8789
+ }
8790
+ // SJ383: cancel a specific (conversation, agent) run if one is in flight in
8791
+ // THIS companion process. Returns true if an in-flight run was aborted.
8792
+ cancel(conversationId, agentId) {
8793
+ const key = runKey(conversationId, agentId);
8794
+ const ac = this.aborts.get(key);
8795
+ if (!ac) return false;
8796
+ try {
8797
+ ac.abort();
8798
+ } catch {
8799
+ }
8800
+ return true;
8801
+ }
8802
+ };
8519
8803
 
8520
8804
  // src/opencode-models.ts
8521
8805
  var OPENCODE_RUNTIME = "opencode";
@@ -8560,15 +8844,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
8560
8844
 
8561
8845
  // src/outbox.ts
8562
8846
  import {
8563
- existsSync as existsSync12,
8847
+ existsSync as existsSync13,
8564
8848
  mkdirSync as mkdirSync12,
8565
8849
  readdirSync as readdirSync3,
8566
8850
  readFileSync as readFileSync9,
8567
8851
  renameSync as renameSync3,
8568
- rmSync as rmSync7,
8852
+ rmSync as rmSync8,
8569
8853
  writeFileSync as writeFileSync8
8570
8854
  } from "fs";
8571
- import { join as join16 } from "path";
8855
+ import { join as join17 } from "path";
8572
8856
  var MAX_ENTRIES = 2e3;
8573
8857
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
8574
8858
  var Outbox = class {
@@ -8581,10 +8865,10 @@ var Outbox = class {
8581
8865
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
8582
8866
  // cases route writes at the right tmpdir.
8583
8867
  dir() {
8584
- return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8868
+ return join17(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8585
8869
  }
8586
8870
  fileFor(turnId, seq) {
8587
- return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8871
+ return join17(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8588
8872
  }
8589
8873
  // Persist a commit for later draining. Atomic (temp file + rename) so a
8590
8874
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -8599,7 +8883,7 @@ var Outbox = class {
8599
8883
  renameSync3(tmp, target2);
8600
8884
  } catch (err) {
8601
8885
  try {
8602
- rmSync7(tmp, { force: true });
8886
+ rmSync8(tmp, { force: true });
8603
8887
  } catch {
8604
8888
  }
8605
8889
  this.log?.warn(
@@ -8616,7 +8900,7 @@ var Outbox = class {
8616
8900
  // wedging the drain.
8617
8901
  list() {
8618
8902
  const dir2 = this.dir();
8619
- if (!existsSync12(dir2)) return [];
8903
+ if (!existsSync13(dir2)) return [];
8620
8904
  let names;
8621
8905
  try {
8622
8906
  names = readdirSync3(dir2);
@@ -8626,7 +8910,7 @@ var Outbox = class {
8626
8910
  const entries = [];
8627
8911
  for (const name of names) {
8628
8912
  if (!name.endsWith(".json")) continue;
8629
- const full = join16(dir2, name);
8913
+ const full = join17(dir2, name);
8630
8914
  try {
8631
8915
  const parsed = JSON.parse(readFileSync9(full, "utf8"));
8632
8916
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -8646,13 +8930,13 @@ var Outbox = class {
8646
8930
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
8647
8931
  remove(turnId, seq) {
8648
8932
  try {
8649
- rmSync7(this.fileFor(turnId, seq), { force: true });
8933
+ rmSync8(this.fileFor(turnId, seq), { force: true });
8650
8934
  } catch {
8651
8935
  }
8652
8936
  }
8653
8937
  size() {
8654
8938
  const dir2 = this.dir();
8655
- if (!existsSync12(dir2)) return 0;
8939
+ if (!existsSync13(dir2)) return 0;
8656
8940
  try {
8657
8941
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
8658
8942
  } catch {
@@ -8665,7 +8949,7 @@ var Outbox = class {
8665
8949
  "companion outbox: dropping unreadable entry"
8666
8950
  );
8667
8951
  try {
8668
- rmSync7(full, { force: true });
8952
+ rmSync8(full, { force: true });
8669
8953
  } catch {
8670
8954
  }
8671
8955
  }
@@ -9594,6 +9878,26 @@ var CompanionSupervisor = class {
9594
9878
  async recheckHarnesses() {
9595
9879
  await this.refreshHarnessStatuses();
9596
9880
  }
9881
+ // CT1085 §1 step 3: beat NOW and wait for it to land. `start` calls this
9882
+ // between bringing the runtime up and asking the person anything, so the
9883
+ // browser's connect step is already showing what this machine has ("your
9884
+ // terminal is asking") rather than sitting blank while the terminal blocks on
9885
+ // an answer. Coalesces with an in-flight beat rather than stacking a second.
9886
+ async heartbeatNow() {
9887
+ if (this.inFlightHeartbeat) {
9888
+ await this.inFlightHeartbeat;
9889
+ return;
9890
+ }
9891
+ this.kickHeartbeat();
9892
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
9893
+ }
9894
+ // CT1085: the config as it stands right now — after any `enableHarness` write.
9895
+ // The control socket's connect handler needs it to run the shake-out check
9896
+ // against the URL/flag that was just persisted, not the one this process booted
9897
+ // with.
9898
+ currentConfig() {
9899
+ return this.config;
9900
+ }
9597
9901
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
9598
9902
  // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
9599
9903
  // never installs a binary and never drives a login (BYO — Decided).
@@ -9807,10 +10111,10 @@ function handleUncaught(log, err, origin) {
9807
10111
  }
9808
10112
 
9809
10113
  // src/crash-marker.ts
9810
- import { existsSync as existsSync13, mkdirSync as mkdirSync13, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
9811
- import { join as join17 } from "path";
10114
+ import { existsSync as existsSync14, mkdirSync as mkdirSync13, readFileSync as readFileSync10, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "fs";
10115
+ import { join as join18 } from "path";
9812
10116
  function crashMarkerPath() {
9813
- return join17(cabaneDir(), "last-error.json");
10117
+ return join18(cabaneDir(), "last-error.json");
9814
10118
  }
9815
10119
  function recordCrash(rec2) {
9816
10120
  try {
@@ -9822,7 +10126,7 @@ function recordCrash(rec2) {
9822
10126
  function clearCrash() {
9823
10127
  try {
9824
10128
  const path = crashMarkerPath();
9825
- if (existsSync13(path)) rmSync8(path, { force: true });
10129
+ if (existsSync14(path)) rmSync9(path, { force: true });
9826
10130
  } catch {
9827
10131
  }
9828
10132
  }
@@ -9841,7 +10145,13 @@ async function createCompanionRuntime(opts = {}) {
9841
10145
  onPath ? "companion: carried Claude Code over as a connected harness on this device (connectors are now chosen, not detected)" : "companion: no Claude Code on PATH, so this device starts with it disconnected (connectors are now chosen, not detected)"
9842
10146
  )
9843
10147
  }));
9844
- await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
10148
+ await warnAboutHarnessReadiness(cfg, {
10149
+ probeClaude: async () => claudeCode,
10150
+ // CT1085: the CLI's onboarding script owns the terminal and says this in
10151
+ // its own words (the `!` block, or the per-harness offer), so it hands us a
10152
+ // sink that logs instead. Every other caller keeps the stderr line.
10153
+ ...opts.onReadinessWarning ? { warn: opts.onReadinessWarning } : {}
10154
+ });
9845
10155
  } catch (err) {
9846
10156
  recordCrash({
9847
10157
  reason: err instanceof Error ? err.message : String(err),
@@ -9865,8 +10175,6 @@ async function createCompanionRuntime(opts = {}) {
9865
10175
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
9866
10176
  const claim = acquireRuntimeState({
9867
10177
  pid: process.pid,
9868
- url: "",
9869
- port: 0,
9870
10178
  startedAt,
9871
10179
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
9872
10180
  instanceId
@@ -9874,7 +10182,7 @@ async function createCompanionRuntime(opts = {}) {
9874
10182
  if (!claim.acquired) {
9875
10183
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
9876
10184
  }
9877
- process.on("exit", () => clearRuntimeState());
10185
+ process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
9878
10186
  const hub = new CompanionStateHub({
9879
10187
  // CT29: one device, one base URL — the cabane instance this device is paired
9880
10188
  // with. The dashboard's connection line shows it.
@@ -9892,17 +10200,36 @@ async function createCompanionRuntime(opts = {}) {
9892
10200
  harnessVersions
9893
10201
  });
9894
10202
  await supervisor.start();
9895
- const preferredPort = opts.port ?? cfg.dashboardPort;
9896
- const dashboard = await startDashboard({
9897
- supervisor,
9898
- hub,
9899
- ...preferredPort !== void 0 ? { port: preferredPort } : {}
10203
+ const connectHarness = async (runtime, serverUrl) => {
10204
+ const result = await supervisor.enableHarness(
10205
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: serverUrl ?? "" } : { runtime }
10206
+ );
10207
+ if (!result.ok) return { ok: false, error: result.error };
10208
+ const verdict = await shakeOutHarness(runtime, supervisor.currentConfig());
10209
+ return { ok: true, message: connectedLine(runtime, verdict) };
10210
+ };
10211
+ const control = await startControlServer({
10212
+ status: () => hub.statusJson(),
10213
+ connect: async (runtime, serverUrl) => {
10214
+ const result = await connectHarness(runtime, serverUrl);
10215
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
10216
+ },
10217
+ stop: () => void supervisor.requestStop()
9900
10218
  });
9901
- hub.setDashboardUrl(dashboard.url);
10219
+ let dashboard = null;
10220
+ if (opts.dashboard) {
10221
+ const preferredPort = opts.port ?? cfg.dashboardPort;
10222
+ dashboard = await startDashboard({
10223
+ supervisor,
10224
+ hub,
10225
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
10226
+ });
10227
+ hub.setDashboardUrl(dashboard.url);
10228
+ }
9902
10229
  writeRuntimeState({
9903
10230
  pid: process.pid,
9904
- url: dashboard.url,
9905
- port: dashboard.port,
10231
+ socket: control.path,
10232
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9906
10233
  startedAt,
9907
10234
  // SJ495: the daemon launcher sets this env on the detached child, so the
9908
10235
  // marker records whether this companion is backgrounded (foreground start
@@ -9915,68 +10242,395 @@ async function createCompanionRuntime(opts = {}) {
9915
10242
  const stop2 = async () => {
9916
10243
  if (stopped) return;
9917
10244
  stopped = true;
9918
- clearRuntimeState();
10245
+ clearRuntimeStateIfOurs(instanceId);
9919
10246
  try {
9920
10247
  await supervisor.shutdown();
9921
- await dashboard.close();
10248
+ await closeSurfaces(control, dashboard);
9922
10249
  } catch {
9923
10250
  }
9924
10251
  };
9925
10252
  return {
9926
10253
  ok: true,
9927
10254
  runtime: {
9928
- url: dashboard.url,
9929
- port: dashboard.port,
10255
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
10256
+ socketPath: control.path,
9930
10257
  config: cfg,
9931
10258
  stop: stop2,
10259
+ heartbeatNow: () => supervisor.heartbeatNow(),
10260
+ harnesses: () => hub.statusJson().harnesses ?? [],
10261
+ connectHarness,
9932
10262
  drainForRestart: async (graceMs) => {
9933
- clearRuntimeState();
10263
+ clearRuntimeStateIfOurs(instanceId);
9934
10264
  const result = await supervisor.drainForRestart(graceMs);
9935
- await dashboard.close();
10265
+ await closeSurfaces(control, dashboard);
9936
10266
  stopped = true;
9937
10267
  return result;
9938
10268
  }
9939
10269
  }
9940
10270
  };
9941
10271
  }
10272
+ async function closeSurfaces(control, dashboard) {
10273
+ await control.close();
10274
+ if (dashboard) await dashboard.close();
10275
+ }
10276
+
10277
+ // src/commands/daemon.ts
10278
+ import { spawn as spawn5 } from "child_process";
10279
+ import { closeSync as closeSync3, mkdirSync as mkdirSync14, openSync as openSync3 } from "fs";
10280
+ var STARTUP_TIMEOUT_MS = 8e3;
10281
+ var POLL_INTERVAL_MS = 150;
10282
+ async function startDaemon(opts = {}, deps = {}) {
10283
+ const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
10284
+ const readState = deps.readState ?? readLiveRuntimeState;
10285
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
10286
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
10287
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
10288
+ const now = deps.now ?? (() => Date.now());
10289
+ const install = deps.installService ?? ((o) => installService(o));
10290
+ const refresh = deps.refreshService ?? ((o) => refreshInstalledService(o));
10291
+ const disable = deps.disableService ?? (() => disableService());
10292
+ const isTty = deps.isTty ?? (() => Boolean(process.stdin.isTTY));
10293
+ const serviceOpts = {};
10294
+ const quiet = opts.report === "failures";
10295
+ const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
10296
+ // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
10297
+ ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
10298
+ ...deps.probeClaude ? { probeClaude: deps.probeClaude } : {},
10299
+ ...deps.save ? { save: deps.save } : {},
10300
+ // The child won't log it (the block is written by then), and this process owns
10301
+ // the user's terminal — so the one-time record is one line, here.
10302
+ onMigrated: (_migrated, onPath) => {
10303
+ if (onPath && !quiet) {
10304
+ process.stdout.write(
10305
+ "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
10306
+ );
10307
+ }
10308
+ }
10309
+ });
10310
+ await ensureRuntime(cfg, {
10311
+ probeClaude: async () => claudeOnPath2,
10312
+ // CT1085: the onboarding script already said what's connectable, in its own
10313
+ // words. Saying it again here, on stderr, in the middle of the closing block
10314
+ // would be the same news twice.
10315
+ ...quiet ? { warn: () => {
10316
+ } } : {}
10317
+ });
10318
+ const existing = readState();
10319
+ if (existing) {
10320
+ if (await verify(existing) !== "stale") {
10321
+ const rerendered = isTty() ? refresh(serviceOpts) : false;
10322
+ process.stdout.write(
10323
+ `Cabane Companion is already running (pid ${existing.pid}).
10324
+ Stop it first with \`cabane-companion stop\` if you want to relaunch.
10325
+ Connect a harness to the running companion: cabane-companion connect claude-code
10326
+ ` + (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" : "")
10327
+ );
10328
+ return {
10329
+ started: false,
10330
+ service: { installed: false, reason: "unsupported" },
10331
+ state: existing
10332
+ };
10333
+ }
10334
+ clearRuntimeState();
10335
+ }
10336
+ let service2 = isTty() ? install(serviceOpts) : { installed: false, reason: "unsupported" };
10337
+ const args = ["start", "--foreground"];
10338
+ const launchDetached = () => {
10339
+ const spawned = spawnDetached(args);
10340
+ spawned.unref();
10341
+ return spawned;
10342
+ };
10343
+ const ready = (s) => !!s && !!s.socket;
10344
+ const waitForReady = async () => {
10345
+ const deadline = now() + STARTUP_TIMEOUT_MS;
10346
+ let seen = readState();
10347
+ while (!ready(seen) && now() < deadline) {
10348
+ await sleep4(POLL_INTERVAL_MS);
10349
+ seen = readState();
10350
+ }
10351
+ return ready(seen) ? seen : null;
10352
+ };
10353
+ if (!service2.installed && service2.leftBehind) {
10354
+ refuseDouble(service2.leftBehind, service2.detail);
10355
+ process.exitCode = 1;
10356
+ return { started: false, service: service2, state: null };
10357
+ }
10358
+ let child = service2.installed ? null : launchDetached();
10359
+ let state = await waitForReady();
10360
+ if (!state && service2.installed) {
10361
+ process.stdout.write(
10362
+ `${service2.manager} started the companion but it didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s \u2014 removing the login service.
10363
+ `
10364
+ );
10365
+ const removed = disable();
10366
+ if (!removed.handled || !removed.ok) {
10367
+ refuseDouble(service2.manager, removed.detail);
10368
+ process.exitCode = 1;
10369
+ return { started: false, service: service2, state: null };
10370
+ }
10371
+ process.stdout.write("Launching it directly instead.\n");
10372
+ service2 = { installed: false, reason: "command-failed", detail: "the service never came up" };
10373
+ child = launchDetached();
10374
+ state = await waitForReady();
10375
+ }
10376
+ if (!state) {
10377
+ process.stdout.write(
10378
+ `Cabane Companion was launched (pid ${child?.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
10379
+ Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
10380
+ `
10381
+ );
10382
+ process.exitCode = 1;
10383
+ return { started: false, service: service2, state: null };
10384
+ }
10385
+ if (quiet) return { started: true, service: service2, state };
10386
+ process.stdout.write(
10387
+ `Cabane Companion started in the background (pid ${state.pid}).
10388
+ Logs: ${companionLogPath()}
10389
+ Status: cabane-companion status
10390
+ Stop: cabane-companion stop
10391
+ `
10392
+ );
10393
+ if (service2.installed) {
10394
+ process.stdout.write(`Autostart: enabled (${service2.manager}) \u2014 it starts again at login.
10395
+ `);
10396
+ } else if (service2.reason !== "unsupported") {
10397
+ process.stdout.write(
10398
+ `Autostart: not enabled \u2014 ${service2.detail ?? "the service manager refused the install"}. Running detached instead; it won't come back after a reboot.
10399
+ `
10400
+ );
10401
+ }
10402
+ return { started: true, service: service2, state };
10403
+ }
10404
+ function refuseDouble(manager, detail) {
10405
+ process.stdout.write(
10406
+ `${manager} still has the login service${detail ? ` (${detail})` : ""} \u2014 not launching a second companion beside a service that may still own one.
10407
+ Check \`cabane-companion service status\`, then \`cabane-companion service disable\`, and run \`cabane-companion start --daemon\` again.
10408
+ `
10409
+ );
10410
+ }
10411
+ function defaultSpawnDetached(args) {
10412
+ const cliPath = companionCliEntry();
10413
+ mkdirSync14(cabaneDir(), { recursive: true });
10414
+ const logFd = openSync3(companionLogPath(), "a");
10415
+ try {
10416
+ return spawn5(process.execPath, [cliPath, ...args], {
10417
+ detached: true,
10418
+ stdio: ["ignore", logFd, logFd],
10419
+ env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
10420
+ });
10421
+ } finally {
10422
+ closeSync3(logFd);
10423
+ }
10424
+ }
9942
10425
 
9943
10426
  // src/commands/start.ts
9944
10427
  var FORCE_EXIT_MS = 4e3;
9945
10428
  var DEPLOY_REEXEC_EXIT = 75;
9946
10429
  var DEPLOY_GRACE_MS = 6.5 * 60 * 60 * 1e3;
10430
+ var MAX_CODES = 3;
9947
10431
  async function start(opts = {}) {
10432
+ const interactive = isInteractive();
10433
+ const running = await liveCompanion();
10434
+ if (running) {
10435
+ reportAlreadyRunning(running.pid);
10436
+ return;
10437
+ }
10438
+ blank();
10439
+ let paired = null;
10440
+ if (!isDevicePaired()) {
10441
+ paired = await pairHere(opts, interactive);
10442
+ if (!paired) return;
10443
+ const { note } = writePairedConfig(paired);
10444
+ if (note) getLogger().warn({ note }, "companion: salvaged an older config on pairing");
10445
+ }
10446
+ const justPaired = paired !== null;
10447
+ const log = getLogger();
9948
10448
  const result = await createCompanionRuntime({
9949
- ...opts.port !== void 0 ? { port: opts.port } : {}
10449
+ // The script says what's connectable in its own words below; a stderr warning
10450
+ // in the middle of it would be the same news, worse.
10451
+ onReadinessWarning: (message) => log.info({ msg: message }, "companion: harness readiness")
9950
10452
  });
9951
10453
  if (!result.ok) {
9952
- const existing = result.existing;
9953
- process.stdout.write(
9954
- `Cabane Companion is already running (pid ${existing?.pid ?? "?"}).
9955
- ` + (existing?.url ? `\u2192 Dashboard: ${existing.url}
9956
- ` : "") + `Stop it first with \`cabane-companion stop\` if you want to relaunch.
9957
- `
9958
- );
10454
+ reportAlreadyRunning(result.existing?.pid);
9959
10455
  return;
9960
10456
  }
9961
10457
  const runtime = result.runtime;
9962
- process.stdout.write(`
9963
- Cabane Companion is running.
9964
- `);
9965
- process.stdout.write(`\u2192 Dashboard: ${runtime.url}
9966
-
9967
- `);
9968
- if (!runtime.config.deviceToken) {
9969
- process.stdout.write(
9970
- `This device isn't paired yet \u2014 run \`cabane-companion pair\`, then confirm the short code in Settings \u2192 Connectors.
9971
-
9972
- `
10458
+ await runtime.heartbeatNow();
10459
+ const offered = await runConnectorOffer(runtime, { interactive, justPaired });
10460
+ if (interactive && !opts.foreground) {
10461
+ await handOffToBackground(runtime, {
10462
+ justPaired,
10463
+ spaceAbove: justPaired || offered.printedSomething
10464
+ });
10465
+ return;
10466
+ }
10467
+ if (interactive) {
10468
+ blank();
10469
+ write(INDENT + tick("Cabane companion is running in this terminal."));
10470
+ write(` Stop: Ctrl-C Logs: ${tildePath(companionLogPath())}`);
10471
+ blank();
10472
+ write(`${INDENT}Listening for messages\u2026`);
10473
+ } else {
10474
+ if (!offered.printedSomething) blank();
10475
+ write(
10476
+ `${INDENT}Cabane companion is running. No terminal attached, so it stays in the foreground.`
9973
10477
  );
10478
+ write(`${INDENT}Listening for messages\u2026`);
9974
10479
  }
9975
- process.stdout.write(`Listening for messages\u2026
9976
- `);
9977
- if (shouldAutoOpen({ flagOpen: opts.open, configAutoOpen: runtime.config.autoOpen })) {
9978
- openBrowser(runtime.url);
10480
+ await runAttached(runtime);
10481
+ }
10482
+ async function liveCompanion() {
10483
+ const state = readLiveRuntimeState();
10484
+ if (!state) return null;
10485
+ if (await verifyRuntime(state) === "stale") {
10486
+ clearRuntimeState();
10487
+ return null;
10488
+ }
10489
+ return state;
10490
+ }
10491
+ function reportAlreadyRunning(pid) {
10492
+ write(`Cabane Companion is already running (pid ${pid ?? "?"}).`);
10493
+ write("Stop it first with `cabane-companion stop` if you want to relaunch.");
10494
+ write("Connect a harness to the running companion: cabane-companion connect claude-code");
10495
+ }
10496
+ async function pairHere(opts, interactive) {
10497
+ const baseUrl = resolvePairBaseUrl(opts.server);
10498
+ const label = deviceLabelFromHostname(hostname2());
10499
+ write(`${INDENT}Not paired yet.`);
10500
+ blank();
10501
+ const aborter = new AbortController();
10502
+ const onSigint = () => aborter.abort();
10503
+ process.on("SIGINT", onSigint);
10504
+ const spinner = new Spinner({ animate: interactive });
10505
+ let minutesWaited = 0;
10506
+ try {
10507
+ for (let attempt = 1; attempt <= MAX_CODES; attempt += 1) {
10508
+ const code = await requestEnrollmentCode(baseUrl, { ...label ? { label } : {} });
10509
+ const minutes = Math.max(1, Math.round(code.expiresIn / 60));
10510
+ if (attempt === 1) printFirstCode(code);
10511
+ else printFreshCode(code);
10512
+ spinner.start(
10513
+ `Waiting for you to confirm it in Cabane\u2026 (the code is good for ${minutes} minutes)`
10514
+ );
10515
+ try {
10516
+ const device = await awaitEnrollment(baseUrl, code, { signal: aborter.signal });
10517
+ spinner.replaceWith(INDENT + tick(`Paired to ${possessive(device.ownerName)} account.`));
10518
+ return device;
10519
+ } catch (err) {
10520
+ if (err instanceof EnrollmentCancelledError) {
10521
+ spinner.clear();
10522
+ blank();
10523
+ write(`${INDENT}Pairing cancelled. Run \`cabane-companion start\` when you're ready.`);
10524
+ return null;
10525
+ }
10526
+ if (!(err instanceof EnrollmentExpiredError)) throw err;
10527
+ minutesWaited += minutes;
10528
+ if (attempt < MAX_CODES) {
10529
+ spinner.replaceWith(`${INDENT}That code expired \u2014 here's a fresh one:`);
10530
+ continue;
10531
+ }
10532
+ spinner.freeze();
10533
+ blank();
10534
+ write(`${INDENT}Still not confirmed after ${minutesWaited} minutes \u2014 stopping here.`);
10535
+ write(`${INDENT}Run \`cabane-companion start\` again when you're ready.`);
10536
+ return null;
10537
+ }
10538
+ }
10539
+ return null;
10540
+ } finally {
10541
+ process.removeListener("SIGINT", onSigint);
10542
+ }
10543
+ }
10544
+ function printFirstCode(code) {
10545
+ write(`${INDENT}Enter this code in Cabane:`);
10546
+ blank();
10547
+ write(`${DATA_INDENT}${code.userCode}`);
10548
+ blank();
10549
+ write(`${INDENT}or open this link:`);
10550
+ blank();
10551
+ write(`${DATA_INDENT}${code.verificationUriComplete}`);
10552
+ blank();
10553
+ }
10554
+ function printFreshCode(code) {
10555
+ blank();
10556
+ write(`${DATA_INDENT}${code.userCode}`);
10557
+ blank();
10558
+ write(`${DATA_INDENT}${code.verificationUriComplete}`);
10559
+ blank();
10560
+ }
10561
+ function possessive(ownerName) {
10562
+ return ownerName ? `${ownerName}'s` : "your Cabane";
10563
+ }
10564
+ async function runConnectorOffer(runtime, ctx) {
10565
+ const snapshot = runtime.harnesses();
10566
+ const detected = OFFER_ORDER.map((r) => snapshot.find((h) => h.runtime === r)).filter(
10567
+ (h) => !!h && h.state === "detected_not_exposed"
10568
+ );
10569
+ if (detected.length === 0) {
10570
+ if (!ctx.justPaired) return { printedSomething: false };
10571
+ blank();
10572
+ write(
10573
+ INDENT + bang(
10574
+ "No coding agent found on this machine yet \u2014 install Claude Code, Codex or opencode and sign in,"
10575
+ )
10576
+ );
10577
+ write(`${INDENT} then: cabane-companion connect claude-code (or codex, or opencode)`);
10578
+ return { printedSomething: true };
10579
+ }
10580
+ blank();
10581
+ if (!ctx.interactive) {
10582
+ for (const h of detected) {
10583
+ write(
10584
+ `${INDENT}${foundPhrase(h, runtime.config)} but it isn't connected. Connect it: ${connectCommand(h.runtime)}`
10585
+ );
10586
+ }
10587
+ return { printedSomething: true };
10588
+ }
10589
+ for (const h of detected) {
10590
+ const yes = await confirm(`${foundPhrase(h, runtime.config)}. Connect it to Cabane?`);
10591
+ if (!yes) {
10592
+ write(`${INDENT}Skipped. Connect it later with: ${connectCommand(h.runtime)}`);
10593
+ continue;
10594
+ }
10595
+ const outcome = await runtime.connectHarness(h.runtime);
10596
+ if (!outcome.ok) {
10597
+ write(`${INDENT}${outcome.error}`);
10598
+ continue;
10599
+ }
10600
+ write(INDENT + tick(outcome.message));
10601
+ }
10602
+ return { printedSomething: true };
10603
+ }
10604
+ function foundPhrase(h, cfg) {
10605
+ const label = HARNESS_LABELS[h.runtime];
10606
+ if (h.runtime === "opencode") {
10607
+ const url = cfg.opencode?.serverUrl;
10608
+ return url ? `We found ${label} at ${url}` : `We found ${label} on this machine`;
10609
+ }
10610
+ return h.version ? `We found ${label} on this machine (${h.version})` : `We found ${label} on this machine`;
10611
+ }
10612
+ function connectCommand(runtime) {
10613
+ return `cabane-companion connect ${runtime}`;
10614
+ }
10615
+ async function handOffToBackground(runtime, ctx) {
10616
+ await runtime.stop();
10617
+ const outcome = await startDaemon({ report: "failures" });
10618
+ if (!outcome.started) {
10619
+ return;
10620
+ }
10621
+ if (ctx.spaceAbove) blank();
10622
+ write(
10623
+ INDENT + tick(
10624
+ outcome.service.installed ? "Cabane companion is running in the background, and will start again when you log in." : "Cabane companion is running in the background."
10625
+ )
10626
+ );
10627
+ write(` Stop: cabane-companion stop Logs: ${tildePath(companionLogPath())}`);
10628
+ if (ctx.justPaired) {
10629
+ blank();
10630
+ write(INDENT + arrow("Head back to Cabane to finish up."));
9979
10631
  }
10632
+ }
10633
+ async function runAttached(runtime) {
9980
10634
  await new Promise((resolve) => {
9981
10635
  let shuttingDown = false;
9982
10636
  const shutdown = async (signal) => {
@@ -10038,7 +10692,7 @@ async function status(deps = {}) {
10038
10692
  const cfg = loadConfig();
10039
10693
  if (!cfg || !cfg.deviceToken) {
10040
10694
  process.stdout.write(
10041
- "companion: not paired. Run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
10695
+ "companion: not paired. Run `cabane-companion start` \u2014 it prints a short code you confirm in Cabane, then keeps running.\n"
10042
10696
  );
10043
10697
  process.exitCode = 1;
10044
10698
  return;
@@ -10063,14 +10717,11 @@ async function status(deps = {}) {
10063
10717
  `companion: running in ${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})
10064
10718
  `
10065
10719
  );
10066
- process.stdout.write(`dashboard: ${running.url} (assigned agents + run state live here)
10067
- `);
10068
10720
  process.stdout.write(`stop with: cabane-companion stop
10069
10721
  `);
10070
10722
  } else {
10071
10723
  process.stdout.write(
10072
- `companion: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
10073
- `
10724
+ "companion: not running \u2014 `cabane-companion start` (backgrounds itself; `--foreground` stays attached)\n"
10074
10725
  );
10075
10726
  }
10076
10727
  const svc = readService();
@@ -10081,7 +10732,7 @@ async function status(deps = {}) {
10081
10732
  );
10082
10733
  }
10083
10734
  process.stdout.write(
10084
- "agents: pulled from cabane at runtime \u2014 open the dashboard while running to see assigned agents, their run state, and any missing-secret warnings.\n"
10735
+ "agents: pulled from cabane at runtime \u2014 see this device in Cabane for its assigned agents, their run state, and any missing-secret warnings.\n"
10085
10736
  );
10086
10737
  const names = loadSecretStoreTolerant().names();
10087
10738
  process.stdout.write(
@@ -10203,8 +10854,8 @@ function isAlive(kill, pid) {
10203
10854
  }
10204
10855
 
10205
10856
  // src/commands/transcript.ts
10206
- import { existsSync as existsSync14, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
10207
- import { isAbsolute, join as join18 } from "path";
10857
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
10858
+ import { isAbsolute, join as join19 } from "path";
10208
10859
  async function transcript(opts = {}) {
10209
10860
  const dir2 = transcriptsDir();
10210
10861
  if (opts.follow) {
@@ -10221,7 +10872,7 @@ async function transcript(opts = {}) {
10221
10872
  process.stdout.write(emptyMessage(dir2));
10222
10873
  return;
10223
10874
  }
10224
- process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
10875
+ process.stdout.write(renderFile(join19(dir2, newest)) + "\n");
10225
10876
  return;
10226
10877
  }
10227
10878
  printList(dir2);
@@ -10304,7 +10955,7 @@ function isComplete(content) {
10304
10955
  async function followTranscripts(dir2) {
10305
10956
  const follower = new TranscriptFollower({
10306
10957
  listFiles: () => listFiles(dir2),
10307
- read: (f) => readFileSync11(join18(dir2, f), "utf8"),
10958
+ read: (f) => readFileSync11(join19(dir2, f), "utf8"),
10308
10959
  write: (s) => process.stdout.write(s),
10309
10960
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
10310
10961
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -10344,7 +10995,7 @@ function printList(dir2) {
10344
10995
 
10345
10996
  `);
10346
10997
  for (const f of files.slice(0, 20)) {
10347
- const { meta, outcome } = peek(join18(dir2, f));
10998
+ const { meta, outcome } = peek(join19(dir2, f));
10348
10999
  const when = fmtTime(rec(meta)?.ts);
10349
11000
  const ws = str2(rec(meta)?.workspaceSlug);
10350
11001
  const o = rec(outcome);
@@ -10378,13 +11029,13 @@ function peek(path) {
10378
11029
  }
10379
11030
  function resolveTarget(dir2, target2) {
10380
11031
  if (isAbsolute(target2) || target2.includes("/")) {
10381
- if (existsSync14(target2)) return target2;
11032
+ if (existsSync15(target2)) return target2;
10382
11033
  throw new CompanionError(`no transcript at ${target2}.`);
10383
11034
  }
10384
- const exact = join18(dir2, target2);
10385
- if (existsSync14(exact)) return exact;
11035
+ const exact = join19(dir2, target2);
11036
+ if (existsSync15(exact)) return exact;
10386
11037
  const matches = listFiles(dir2).filter((f) => f.includes(target2));
10387
- if (matches.length === 1) return join18(dir2, matches[0]);
11038
+ if (matches.length === 1) return join19(dir2, matches[0]);
10388
11039
  if (matches.length === 0) {
10389
11040
  throw new CompanionError(
10390
11041
  `no transcript matching "${target2}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
@@ -10528,7 +11179,7 @@ program.name("cabane-companion").description(
10528
11179
  "Connect a coding agent on your machine to your Cabane workspace as a responder \u2014 reply to Cabane messages while staying a full local AI client (Claude Code or OpenCode)."
10529
11180
  ).version(COMPANION_VERSION);
10530
11181
  program.command("pair").description(
10531
- "pair this device with cabane \u2014 shows a short code you confirm in Settings \u2192 Devices."
11182
+ "pair this device with cabane \u2014 shows a short code you confirm in Cabane. `start` does this for you."
10532
11183
  ).allowExcessArguments(false).option("--server <url>", "the cabane instance to pair with (default https://app.cabane.ai)").action(async (opts) => {
10533
11184
  await pair({
10534
11185
  ...opts.server !== void 0 ? { server: opts.server } : {}
@@ -10537,22 +11188,17 @@ program.command("pair").description(
10537
11188
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
10538
11189
  writeCompletedPairing(readFileSync12(0, "utf8"));
10539
11190
  });
10540
- 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(
10541
- async (opts) => {
10542
- if (opts.daemon && !opts.foreground) {
10543
- await startDaemon({
10544
- ...opts.port !== void 0 ? { port: opts.port } : {}
10545
- });
10546
- return;
10547
- }
10548
- await start({
10549
- // commander sets `open` to true for `--open`, false for `--no-open`, and
10550
- // leaves it undefined when neither is passed (respect config + env).
10551
- ...opts.open !== void 0 ? { open: opts.open } : {},
10552
- ...opts.port !== void 0 ? { port: opts.port } : {}
10553
- });
10554
- }
10555
- );
11191
+ program.command("start").description(
11192
+ "pair this device if needed, connect a coding agent on it, and run in the background."
11193
+ ).option("--foreground", "run attached in this terminal (never installs the login service)").option("--daemon", "run in the background (the default; kept for compatibility)").option("--server <url>", "the cabane instance to pair with, if this device isn\u2019t paired yet").addOption(new Option("--open").hideHelp()).addOption(new Option("--no-open").hideHelp()).addOption(new Option("--port <port>").hideHelp()).action(async (opts) => {
11194
+ await start({
11195
+ ...opts.foreground ? { foreground: true } : {},
11196
+ ...opts.server !== void 0 ? { server: opts.server } : {}
11197
+ });
11198
+ });
11199
+ program.command("connect").description("connect a coding agent on this machine to Cabane (claude-code, codex, opencode).").argument("<harness>", "claude-code, codex, or opencode").option("--url <url>", "the `opencode serve` URL (opencode only)").action(async (harness, opts) => {
11200
+ await connect2(harness, { ...opts.url !== void 0 ? { serverUrl: opts.url } : {} });
11201
+ });
10556
11202
  program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
10557
11203
  await stop();
10558
11204
  });
@@ -10584,13 +11230,6 @@ program.command("logout").description(
10584
11230
  ...opts.purge ? { purge: true } : {}
10585
11231
  });
10586
11232
  });
10587
- function parsePort(raw) {
10588
- const n = Number(raw);
10589
- if (!Number.isInteger(n) || n < 1 || n > 65535) {
10590
- throw new CompanionError(`invalid --port "${raw}": expected an integer between 1 and 65535.`);
10591
- }
10592
- return n;
10593
- }
10594
11233
  program.parseAsync(process.argv).catch((err) => {
10595
11234
  if (err instanceof CompanionError) {
10596
11235
  process.stderr.write(`error: ${err.message}