@cabane/companion 0.6.32 → 0.6.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,633 +811,581 @@ 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";
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;
724
828
  }
725
- function writeRuntimeState(state) {
726
- const path = runtimePath();
727
- mkdirSync3(cabaneDir(), { recursive: true });
728
- writeFileSync2(path, serialize(state), "utf8");
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") };
729
845
  }
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);
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
+ };
744
856
  }
745
- return { acquired: true };
746
- }
747
- function clearRuntimeState() {
748
- const path = runtimePath();
749
- if (existsSync3(path)) rmSync2(path, { force: true });
750
- }
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;
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
+ };
759
865
  }
760
- if (typeof parsed.pid !== "number") return null;
761
- try {
762
- process.kill(parsed.pid, 0);
763
- } catch {
764
- clearRuntimeState();
765
- return null;
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
+ };
766
874
  }
767
- return parsed;
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
+ };
768
882
  }
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";
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
+ };
777
902
  }
778
- if (!res.ok) return "unknown";
779
- let body;
780
- try {
781
- body = await res.json();
782
- } catch {
783
- return "unknown";
784
- }
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;
795
- }
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
- );
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");
948
+ function detectedRuntimesFor(snapshot) {
949
+ return snapshot.harnesses.filter((h) => h.state === "detected_not_exposed").map((h) => ({ runtime: h.runtime, version: h.version }));
885
950
  }
886
- function domain(host) {
887
- return `gui/${host.uid}`;
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
+ };
888
980
  }
889
- function target(host) {
890
- return `${domain(host)}/${LAUNCHD_LABEL}`;
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
+ });
891
1003
  }
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) };
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 "absent";
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 presenceRun = await run(presence[0], [...presence[1]]);
1024
+ if (presenceRun.error === "spawn") return "absent";
1025
+ if (presenceRun.code !== 0) return "unverified";
1026
+ const authRun = await run(auth[0], [...auth[1]]);
1027
+ if (authRun.code === 0) return "ok";
1028
+ if (looksUnsupported(authRun.output)) {
1029
+ return "unverified";
1030
+ }
1031
+ return "failed";
1032
+ } catch {
1033
+ return "unverified";
1034
+ }
896
1035
  }
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) };
1036
+ function connectedLine(runtime, verdict) {
1037
+ if (verdict === "absent") throw new Error("an absent harness cannot be connected");
1038
+ const label = HARNESS_LABELS[runtime];
1039
+ if (verdict !== "failed") return `${label} connected.`;
1040
+ return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
901
1041
  }
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" };
1042
+ var FAILED_SUFFIX = {
1043
+ "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1044
+ codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1045
+ opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1046
+ };
1047
+ function absentLine(runtime) {
1048
+ if (runtime === "opencode") {
1049
+ return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
907
1050
  }
908
- return notLoaded(res.stderr + res.stdout) ? { ownership: "clear", running: false } : { ownership: "unknown", running: "unknown" };
1051
+ const label = HARNESS_LABELS[runtime];
1052
+ const login = runtime === "codex" ? "`codex login`" : "`claude`";
1053
+ return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
909
1054
  }
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 };
1055
+ function looksUnsupported(output) {
1056
+ return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1057
+ output
1058
+ );
915
1059
  }
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);
1060
+ function runBounded(command, args) {
1061
+ return new Promise((resolve) => {
1062
+ let settled = false;
1063
+ const done = (result) => {
1064
+ if (settled) return;
1065
+ settled = true;
1066
+ clearTimeout(timer);
1067
+ resolve(result);
1068
+ };
1069
+ let child;
1070
+ try {
1071
+ child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1072
+ } catch {
1073
+ resolve({ code: null, output: "", error: "spawn" });
1074
+ return;
1075
+ }
1076
+ let out = "";
1077
+ const capture = (chunk) => {
1078
+ if (out.length < 4096) out += chunk.toString();
1079
+ };
1080
+ child.stdout?.on("data", capture);
1081
+ child.stderr?.on("data", capture);
1082
+ const timer = setTimeout(() => {
1083
+ child.kill("SIGKILL");
1084
+ done({ code: null, output: out, error: "timeout" });
1085
+ }, CHECK_TIMEOUT_MS);
1086
+ timer.unref?.();
1087
+ child.once("error", () => done({ code: null, output: out, error: "spawn" }));
1088
+ child.once("exit", (code) => done({ code, output: out }));
1089
+ });
925
1090
  }
926
- function firstLine(s) {
927
- return s.trim().split("\n")[0] ?? "";
1091
+
1092
+ // src/runtime-file.ts
1093
+ import {
1094
+ existsSync as existsSync3,
1095
+ readFileSync as readFileSync2,
1096
+ rmSync as rmSync3,
1097
+ writeFileSync as writeFileSync2,
1098
+ mkdirSync as mkdirSync3,
1099
+ openSync,
1100
+ closeSync
1101
+ } from "fs";
1102
+ import { join as join3 } from "path";
1103
+ var PROBE_TIMEOUT_MS2 = 1e3;
1104
+ function runtimePath() {
1105
+ return join3(cabaneDir(), "runtime.json");
928
1106
  }
929
- function xml(value) {
930
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1107
+ function serialize(state) {
1108
+ return JSON.stringify(state, null, 2) + "\n";
931
1109
  }
932
-
933
- // src/service/systemd.ts
934
- import { dirname as dirname3, join as join5 } from "path";
935
- var SYSTEMD_UNIT = "cabane-companion.service";
936
- function systemdUnitPath(host) {
937
- const configHome = host.env.XDG_CONFIG_HOME?.trim() ? host.env.XDG_CONFIG_HOME.trim() : join5(host.home, ".config");
938
- return join5(configHome, "systemd", "user", SYSTEMD_UNIT);
1110
+ function writeRuntimeState(state) {
1111
+ const path = runtimePath();
1112
+ mkdirSync3(cabaneDir(), { recursive: true });
1113
+ writeFileSync2(path, serialize(state), "utf8");
939
1114
  }
940
- function renderUnit(input) {
941
- const [exec, ...rest] = input.programArguments;
942
- const execStart = [quote(exec ?? ""), ...rest.map(quote)].join(" ");
943
- const env = Object.entries(input.environment).map(([k, v]) => `Environment=${k}=${v}`);
944
- return [
945
- "[Unit]",
946
- "Description=Cabane Companion \u2014 keeps this device answering while you are logged in",
947
- "After=network-online.target",
948
- "Wants=network-online.target",
949
- "",
950
- "[Service]",
951
- "Type=simple",
952
- ...env,
953
- `ExecStart=${execStart}`,
954
- "Restart=on-failure",
955
- "RestartSec=5",
956
- "",
957
- "[Install]",
958
- "WantedBy=default.target"
959
- ].join("\n") + "\n";
960
- }
961
- function systemdReload(host) {
962
- host.run("systemctl", ["--user", "daemon-reload"]);
963
- }
964
- function systemdStart(host) {
965
- systemdReload(host);
966
- const enabled = host.run("systemctl", ["--user", "enable", SYSTEMD_UNIT]);
967
- if (!enabled.ok) return { ok: false, detail: firstLine2(enabled.stderr || enabled.stdout) };
968
- const started = host.run("systemctl", ["--user", "restart", SYSTEMD_UNIT]);
969
- if (!started.ok) return { ok: false, detail: firstLine2(started.stderr || started.stdout) };
970
- return { ok: true };
971
- }
972
- function systemdStop(host) {
973
- const res = host.run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
974
- if (res.ok || notLoaded2(res.stderr + res.stdout)) return { ok: true };
975
- return { ok: false, detail: firstLine2(res.stderr || res.stdout) };
976
- }
977
- function systemdProbe(host) {
978
- const state = host.run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim();
979
- if (state === "active") return { ownership: "held", running: true };
980
- if (state === "activating" || state === "reloading" || state === "deactivating") {
981
- return { ownership: "held", running: "transitional" };
1115
+ function acquireRuntimeState(state) {
1116
+ const live = readLiveRuntimeState();
1117
+ if (live) return { acquired: false, existing: live };
1118
+ mkdirSync3(cabaneDir(), { recursive: true });
1119
+ let fd;
1120
+ try {
1121
+ fd = openSync(runtimePath(), "wx");
1122
+ } catch {
1123
+ return { acquired: false, existing: readLiveRuntimeState() };
982
1124
  }
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) };
1125
+ try {
1126
+ writeFileSync2(fd, serialize(state), "utf8");
1127
+ } finally {
1128
+ closeSync(fd);
996
1129
  }
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";
1130
+ return { acquired: true };
1016
1131
  }
1017
- function tryEnableLinger(host) {
1018
- host.run("loginctl", ["enable-linger", String(host.uid)]);
1019
- return readLinger(host);
1132
+ function clearRuntimeState() {
1133
+ const path = runtimePath();
1134
+ if (existsSync3(path)) rmSync3(path, { force: true });
1020
1135
  }
1021
- function isRemoteSession(env) {
1022
- return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1136
+ function clearRuntimeStateIfOurs(instanceId) {
1137
+ const path = runtimePath();
1138
+ if (!existsSync3(path)) return;
1139
+ try {
1140
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1141
+ if (parsed.instanceId && parsed.instanceId !== instanceId) return;
1142
+ } catch {
1143
+ }
1144
+ rmSync3(path, { force: true });
1023
1145
  }
1024
- function quote(value) {
1025
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1146
+ function readLiveRuntimeState() {
1147
+ const path = runtimePath();
1148
+ if (!existsSync3(path)) return null;
1149
+ let parsed;
1150
+ try {
1151
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1152
+ } catch {
1153
+ return null;
1154
+ }
1155
+ if (typeof parsed.pid !== "number") return null;
1156
+ try {
1157
+ process.kill(parsed.pid, 0);
1158
+ } catch {
1159
+ clearRuntimeState();
1160
+ return null;
1161
+ }
1162
+ return parsed;
1026
1163
  }
1027
- function firstLine2(s) {
1028
- return s.trim().split("\n")[0] ?? "";
1164
+ async function verifyRuntime(state, requestImpl = controlRequest) {
1165
+ if (!state.instanceId || !state.socket) return "unknown";
1166
+ let body;
1167
+ try {
1168
+ body = await requestImpl(
1169
+ state.socket,
1170
+ { cmd: "status" },
1171
+ PROBE_TIMEOUT_MS2
1172
+ );
1173
+ } catch (err) {
1174
+ return isNotListening(err) ? "stale" : "unknown";
1175
+ }
1176
+ if (typeof body?.instance_id !== "string") return "unknown";
1177
+ return body.instance_id === state.instanceId ? "ours" : "stale";
1029
1178
  }
1030
1179
 
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;
1180
+ // src/term.ts
1181
+ import { createInterface } from "readline";
1182
+ import { homedir as homedir2 } from "os";
1183
+ import pc from "picocolors";
1184
+ var INDENT = " ";
1185
+ var DATA_INDENT = " ";
1186
+ function isInteractive() {
1187
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1188
+ }
1189
+ function write(line) {
1190
+ process.stdout.write(`${line}
1191
+ `);
1059
1192
  }
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);
1193
+ function blank() {
1194
+ process.stdout.write("\n");
1195
+ }
1196
+ var tick = (rest) => `${pc.green("\u2713")} ${rest}`;
1197
+ var arrow = (rest) => `${pc.yellow("\u2192")} ${rest}`;
1198
+ var bang = (rest) => `${pc.yellow("!")} ${rest}`;
1199
+ function tildePath(path) {
1200
+ const home = homedir2();
1201
+ if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
1202
+ return path;
1203
+ }
1204
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1205
+ var FRAME_MS = 80;
1206
+ var Spinner = class {
1207
+ timer = null;
1208
+ frame = 0;
1209
+ text = "";
1210
+ animate;
1211
+ constructor(opts = {}) {
1212
+ this.animate = opts.animate ?? isInteractive();
1213
+ }
1214
+ start(text) {
1215
+ this.text = text;
1216
+ if (!this.animate) {
1217
+ write(`${INDENT}${text}`);
1218
+ return;
1219
+ }
1220
+ this.render();
1221
+ this.timer = setInterval(() => {
1222
+ this.frame = (this.frame + 1) % FRAMES.length;
1223
+ this.render();
1224
+ }, FRAME_MS);
1225
+ this.timer.unref?.();
1226
+ }
1227
+ // Stop, erase the spinner line, and write `line` in its place.
1228
+ replaceWith(line) {
1229
+ this.halt();
1230
+ if (this.animate) this.eraseLine();
1231
+ write(line);
1232
+ }
1233
+ // Stop, leaving the line as it last rendered — for an outcome that reads as
1234
+ // "this is where we gave up waiting" rather than as a resolution. Without a
1235
+ // TTY the line was already terminated when it printed, so there is nothing to
1236
+ // close.
1237
+ freeze() {
1238
+ this.halt();
1239
+ if (this.animate) process.stdout.write("\n");
1240
+ }
1241
+ // Stop and erase, writing nothing — the Ctrl-C path, where the shell's own
1242
+ // `^C` echo is the last thing on the line.
1243
+ clear() {
1244
+ this.halt();
1245
+ if (this.animate) this.eraseLine();
1246
+ }
1247
+ halt() {
1248
+ if (this.timer) {
1249
+ clearInterval(this.timer);
1250
+ this.timer = null;
1251
+ }
1252
+ }
1253
+ render() {
1254
+ this.eraseLine();
1255
+ process.stdout.write(`${INDENT}${pc.dim(FRAMES[this.frame] ?? FRAMES[0])} ${this.text}`);
1256
+ }
1257
+ // Carriage return + "erase the whole line", so a replacement starts clean
1258
+ // however wide the spinner text was.
1259
+ eraseLine() {
1260
+ process.stdout.write("\r\x1B[2K");
1261
+ }
1262
+ };
1263
+ async function confirm(question) {
1264
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1265
+ try {
1266
+ const answer = await new Promise((resolve) => {
1267
+ rl.question(`${INDENT}${question} [Y/n] `, resolve);
1268
+ });
1269
+ return !/^n(o)?$/i.test(answer.trim());
1270
+ } finally {
1271
+ rl.close();
1272
+ }
1063
1273
  }
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);
1274
+
1275
+ // src/commands/connect.ts
1276
+ async function connect2(raw, opts = {}) {
1277
+ const runtime = parseHarnessRuntime(raw);
1278
+ if (!runtime) {
1279
+ throw new CompanionError(
1280
+ `unknown harness "${raw}". Connectable harnesses are: claude-code, codex, opencode.`
1281
+ );
1076
1282
  }
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)"
1283
+ const live = await liveSocket();
1284
+ if (live) {
1285
+ let result;
1286
+ try {
1287
+ result = await controlRequest(
1288
+ live,
1289
+ {
1290
+ cmd: "connect",
1291
+ runtime,
1292
+ ...opts.serverUrl ? { serverUrl: opts.serverUrl } : {}
1293
+ },
1294
+ 1e4
1295
+ );
1296
+ } catch (err) {
1297
+ const detail = err instanceof Error ? err.message : String(err);
1298
+ throw new CompanionError(
1299
+ `Couldn\u2019t reach the running companion \u2014 ${detail}; try again, or \`cabane-companion stop\` and \`start\`.`
1086
1300
  );
1087
1301
  }
1302
+ if (!result.ok) throw new CompanionError(result.message);
1303
+ write(INDENT + tick(result.message));
1304
+ return;
1088
1305
  }
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);
1306
+ const cfg = requireConfig();
1307
+ if (alreadyConnected(runtime, cfg)) {
1308
+ write(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1309
+ return;
1093
1310
  }
1094
- return { installed: true, manager, changed };
1095
- }
1096
- function failedInstall(host, manager, reason, detail) {
1097
- return {
1098
- installed: false,
1099
- reason,
1100
- ...detail ? { detail } : {},
1101
- ...managerOwnership(host, manager) === "clear" ? {} : { leftBehind: manager }
1102
- };
1311
+ if (runtime === "claude-code" && !await claudeOnPath()) {
1312
+ throw new CompanionError(
1313
+ "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."
1314
+ );
1315
+ }
1316
+ let next = cfg;
1317
+ if (runtime === "claude-code") next = { ...cfg, claudeCode: { enabled: true } };
1318
+ else if (runtime === "codex") next = { ...cfg, codex: { enabled: true } };
1319
+ else {
1320
+ const serverUrl = opts.serverUrl?.trim();
1321
+ if (!serverUrl) {
1322
+ throw new CompanionError(
1323
+ "opencode is addressed by URL \u2014 pass it: `cabane-companion connect opencode --url http://127.0.0.1:4096`."
1324
+ );
1325
+ }
1326
+ if (await probeOpencodeVersion(serverUrl) === null) {
1327
+ throw new CompanionError(
1328
+ `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
1329
+ );
1330
+ }
1331
+ next = { ...cfg, opencode: { serverUrl } };
1332
+ }
1333
+ const verdict = await shakeOutHarness(runtime, next);
1334
+ if (verdict === "absent") throw new CompanionError(absentLine(runtime));
1335
+ saveConfig(next);
1336
+ write(INDENT + tick(connectedLine(runtime, verdict)));
1337
+ write(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
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
+ async function liveSocket() {
1340
+ const state = readLiveRuntimeState();
1341
+ if (!state) return null;
1342
+ const verdict = await verifyRuntime(state);
1343
+ if (verdict === "stale") {
1344
+ clearRuntimeState();
1345
+ return null;
1346
+ }
1347
+ if (!state.socket) return null;
1348
+ try {
1349
+ await controlRequest(state.socket, { cmd: "status" });
1350
+ return state.socket;
1351
+ } catch (err) {
1352
+ if (isNotListening(err)) return null;
1353
+ throw new CompanionError(
1354
+ "a companion is running but isn\u2019t answering its control socket. Try `cabane-companion stop`, then `cabane-companion start`."
1355
+ );
1356
+ }
1108
1357
  }
1109
- function managerOwnership(host, manager) {
1110
- return managerProbe(host, manager).ownership;
1358
+ function alreadyConnected(runtime, cfg) {
1359
+ if (runtime === "claude-code") return isClaudeCodeConnected(cfg);
1360
+ if (runtime === "codex") return isCodexEnabled(cfg);
1361
+ return !!cfg.opencode?.serverUrl;
1111
1362
  }
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");
1363
+
1364
+ // src/commands/logout.ts
1365
+ import { confirm as confirm2 } from "@inquirer/prompts";
1366
+
1367
+ // src/credentials.ts
1368
+ import {
1369
+ chmodSync as chmodSync2,
1370
+ existsSync as existsSync4,
1371
+ mkdirSync as mkdirSync4,
1372
+ readFileSync as readFileSync3,
1373
+ renameSync as renameSync2,
1374
+ rmSync as rmSync4,
1375
+ writeFileSync as writeFileSync3
1376
+ } from "fs";
1377
+ import { dirname as dirname2, join as join4 } from "path";
1378
+ import { z as z3 } from "zod";
1379
+ function credentialsPath() {
1380
+ return join4(cabaneDir(), "credentials.json");
1326
1381
  }
1327
1382
  var credentialStoreSchema = z3.record(z3.string(), z3.string());
1328
1383
  function load() {
1329
1384
  const path = credentialsPath();
1330
- if (!existsSync5(path)) return {};
1385
+ if (!existsSync4(path)) return {};
1331
1386
  let raw;
1332
1387
  try {
1333
- raw = readFileSync4(path, "utf8");
1388
+ raw = readFileSync3(path, "utf8");
1334
1389
  } catch {
1335
1390
  return {};
1336
1391
  }
@@ -1344,14 +1399,14 @@ function load() {
1344
1399
  }
1345
1400
  function save(map) {
1346
1401
  const path = credentialsPath();
1347
- mkdirSync6(dirname5(path), { recursive: true });
1402
+ mkdirSync4(dirname2(path), { recursive: true });
1348
1403
  try {
1349
1404
  chmodSync2(cabaneDir(), 448);
1350
1405
  } catch {
1351
1406
  }
1352
1407
  const tmp = `${path}.${process.pid}.tmp`;
1353
1408
  try {
1354
- writeFileSync4(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1409
+ writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
1355
1410
  try {
1356
1411
  chmodSync2(tmp, 384);
1357
1412
  } catch {
@@ -1389,7 +1444,7 @@ function pruneCredentials(keepAgentIds) {
1389
1444
  }
1390
1445
  function clearCredentials() {
1391
1446
  const path = credentialsPath();
1392
- if (existsSync5(path)) writeFileSync4(path, "", { mode: 384 });
1447
+ if (existsSync4(path)) writeFileSync3(path, "", { mode: 384 });
1393
1448
  }
1394
1449
 
1395
1450
  // src/commands/logout.ts
@@ -1401,7 +1456,7 @@ async function logout(opts = {}) {
1401
1456
  }
1402
1457
  if (!opts.yes) {
1403
1458
  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 });
1459
+ const ok = await confirm2({ message, default: false });
1405
1460
  if (!ok) {
1406
1461
  process.stdout.write("cancelled\n");
1407
1462
  return;
@@ -1424,7 +1479,22 @@ async function logout(opts = {}) {
1424
1479
  );
1425
1480
  }
1426
1481
 
1482
+ // src/commands/pair.ts
1483
+ import { hostname } from "os";
1484
+
1427
1485
  // src/enrollment.ts
1486
+ var EnrollmentExpiredError = class extends CompanionError {
1487
+ constructor() {
1488
+ super("this pairing code expired before it was confirmed.");
1489
+ this.name = "EnrollmentExpiredError";
1490
+ }
1491
+ };
1492
+ var EnrollmentCancelledError = class extends CompanionError {
1493
+ constructor() {
1494
+ super("pairing was cancelled.");
1495
+ this.name = "EnrollmentCancelledError";
1496
+ }
1497
+ };
1428
1498
  function trimBase(baseUrl) {
1429
1499
  return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
1430
1500
  }
@@ -1461,18 +1531,25 @@ async function postJson(baseUrl, path, body) {
1461
1531
  }
1462
1532
  return parsed;
1463
1533
  }
1464
- function requestEnrollmentCode(baseUrl) {
1465
- return postJson(baseUrl, "/api/device-enrollment/code", {});
1534
+ function requestEnrollmentCode(baseUrl, opts = {}) {
1535
+ return postJson(baseUrl, "/api/device-enrollment/code", {
1536
+ ...opts.label ? { label: opts.label } : {}
1537
+ });
1538
+ }
1539
+ function deviceLabelFromHostname(hostname3) {
1540
+ const trimmed = hostname3.trim().replace(/\.local$/i, "");
1541
+ if (trimmed.length === 0) return void 0;
1542
+ return trimmed.slice(0, 120);
1466
1543
  }
1467
1544
  function pollOnce(baseUrl, deviceCode) {
1468
1545
  return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
1469
1546
  }
1470
1547
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1471
1548
  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);
1549
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
1550
+ const code = await requestEnrollmentCode(baseUrl, {
1551
+ ...opts.label ? { label: opts.label } : {}
1552
+ });
1476
1553
  if (opts.onCode) await opts.onCode(code);
1477
1554
  print("");
1478
1555
  print(` Enter this code at ${code.verificationUri}`);
@@ -1481,12 +1558,17 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
1481
1558
  print("");
1482
1559
  print(` or open: ${code.verificationUriComplete}`);
1483
1560
  print("");
1484
- print("Waiting for you to confirm it in cabane\u2026");
1561
+ print("Waiting for you to confirm it in Cabane\u2026");
1562
+ return awaitEnrollment(baseUrl, code, opts);
1563
+ }
1564
+ async function awaitEnrollment(baseUrl, code, opts = {}) {
1565
+ const now = opts.now ?? (() => Date.now());
1566
+ const wait = opts.sleepMs ?? sleep;
1485
1567
  const deadline = now() + code.expiresIn * 1e3;
1486
1568
  let intervalMs = Math.max(1, code.interval) * 1e3;
1487
1569
  while (now() < deadline) {
1488
1570
  await wait(intervalMs);
1489
- if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
1571
+ if (opts.signal?.aborted) throw new EnrollmentCancelledError();
1490
1572
  const res = await pollOnce(baseUrl, code.deviceCode);
1491
1573
  if (res.status === "complete") {
1492
1574
  if (!res.deviceToken || !res.baseUrl) {
@@ -1496,19 +1578,14 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
1496
1578
  baseUrl: res.baseUrl,
1497
1579
  deviceToken: res.deviceToken,
1498
1580
  ...res.deviceId ? { deviceId: res.deviceId } : {},
1499
- ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {}
1581
+ ...res.deviceLabel ? { deviceLabel: res.deviceLabel } : {},
1582
+ ...res.ownerName ? { ownerName: res.ownerName } : {}
1500
1583
  };
1501
1584
  }
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
- }
1585
+ if (res.status === "expired") throw new EnrollmentExpiredError();
1507
1586
  if (res.status === "slow_down") intervalMs += 1e3;
1508
1587
  }
1509
- throw new CompanionError(
1510
- "this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
1511
- );
1588
+ throw new EnrollmentExpiredError();
1512
1589
  }
1513
1590
 
1514
1591
  // src/pairing-config.ts
@@ -1542,16 +1619,23 @@ function writePairedConfig(paired) {
1542
1619
  saveConfig(config);
1543
1620
  return { config, note };
1544
1621
  }
1622
+ function isDevicePaired() {
1623
+ try {
1624
+ return !!loadConfig()?.deviceToken;
1625
+ } catch {
1626
+ return false;
1627
+ }
1628
+ }
1545
1629
 
1546
1630
  // src/commands/pair.ts
1547
1631
  function writePairedConfigCli(paired) {
1548
1632
  const { note } = writePairedConfig(paired);
1549
1633
  if (note) process.stderr.write(`note: ${note}
1550
1634
  `);
1551
- const labelSuffix = paired.deviceLabel ? ` as "${paired.deviceLabel}"` : "";
1635
+ const owner = paired.ownerName ? `${paired.ownerName}'s` : "your Cabane";
1552
1636
  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.
1637
+ ` \u2713 Paired to ${owner} account.
1638
+ Run \`cabane-companion start\` \u2014 it connects a harness, runs in the background, and pulls this device's agents.
1555
1639
  `
1556
1640
  );
1557
1641
  }
@@ -1567,16 +1651,476 @@ function writeCompletedPairing(raw) {
1567
1651
  }
1568
1652
  writePairedConfigCli(paired);
1569
1653
  }
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);
1654
+ async function pair(opts = {}) {
1655
+ const baseUrl = resolvePairBaseUrl(opts.server);
1656
+ const paired = await runDeviceFlow(baseUrl, (line) => process.stdout.write(`${line}
1657
+ `), {
1658
+ // The hostname rides the mint here too, so a `pair`-then-`start` device is
1659
+ // named exactly as a one-command one is.
1660
+ ...deviceLabelFromHostname(hostname()) ? { label: deviceLabelFromHostname(hostname()) } : {}
1661
+ });
1662
+ writePairedConfigCli(paired);
1663
+ }
1664
+
1665
+ // src/cli.ts
1666
+ import { readFileSync as readFileSync12 } from "fs";
1667
+
1668
+ // src/service/index.ts
1669
+ import { dirname as dirname5 } from "path";
1670
+
1671
+ // src/service/host.ts
1672
+ import { spawnSync } from "child_process";
1673
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync5, writeFileSync as writeFileSync4 } from "fs";
1674
+ import { homedir as homedir3 } from "os";
1675
+
1676
+ // src/cli-entry.ts
1677
+ import { existsSync as existsSync5 } from "fs";
1678
+ import { fileURLToPath } from "url";
1679
+ var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
1680
+ function companionCliEntry(deps = {}) {
1681
+ const exists = deps.exists ?? existsSync5;
1682
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath(new URL(rel, import.meta.url)));
1683
+ for (const candidate of candidates) {
1684
+ if (exists(candidate)) return candidate;
1685
+ }
1686
+ const argv1 = "argv1" in deps ? deps.argv1 : process.argv[1];
1687
+ if (argv1 && exists(argv1)) return argv1;
1688
+ return candidates[0] ?? "";
1689
+ }
1690
+
1691
+ // src/logger.ts
1692
+ import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
1693
+ import { dirname as dirname3, join as join5 } from "path";
1694
+ import pino from "pino";
1695
+ import pretty from "pino-pretty";
1696
+ function companionLogPath() {
1697
+ return join5(cabaneDir(), "companion.log");
1698
+ }
1699
+ var CONSOLE_IGNORE = [
1700
+ "pid",
1701
+ "hostname",
1702
+ "workspaceId",
1703
+ "conversationId",
1704
+ "agentId",
1705
+ "messageId",
1706
+ "sessionId",
1707
+ "companionId"
1708
+ ].join(",");
1709
+ function consoleShortId(log) {
1710
+ const id = log.conversationId ?? log.workspaceId;
1711
+ return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
1712
+ }
1713
+ function consoleMessageFormat(log, messageKey) {
1714
+ const short = consoleShortId(log);
1715
+ const msg = String(log[messageKey] ?? "");
1716
+ return short ? `${short} ${msg}` : msg;
1717
+ }
1718
+ var cached = null;
1719
+ var consoleLogging = true;
1720
+ function setConsoleLogging(enabled) {
1721
+ consoleLogging = enabled;
1722
+ }
1723
+ function createLogger(destinations = {}) {
1724
+ const path = companionLogPath();
1725
+ if (!destinations.file) mkdirSync5(dirname3(path), { recursive: true });
1726
+ const streams = [];
1727
+ if (process.env.CABANE_COMPANION_DAEMON !== "1") {
1728
+ const consoleStream = pretty({
1729
+ colorize: true,
1730
+ ignore: CONSOLE_IGNORE,
1731
+ messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
1732
+ ...destinations.console ? { destination: destinations.console } : {}
1733
+ });
1734
+ streams.push({
1735
+ level: "info",
1736
+ stream: {
1737
+ write(chunk) {
1738
+ if (consoleLogging) consoleStream.write(chunk);
1739
+ }
1740
+ }
1741
+ });
1742
+ }
1743
+ streams.push({
1744
+ level: "debug",
1745
+ stream: destinations.file ?? createWriteStream(path, { flags: "a" })
1746
+ });
1747
+ return pino({ level: "debug" }, pino.multistream(streams));
1748
+ }
1749
+ function getLogger() {
1750
+ if (cached) return cached;
1751
+ cached = createLogger();
1752
+ return cached;
1753
+ }
1754
+
1755
+ // src/service/host.ts
1756
+ var RUN_TIMEOUT_MS = 1e4;
1757
+ function defaultServiceHost() {
1758
+ if (process.env.VITEST) {
1759
+ throw new Error(
1760
+ "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."
1761
+ );
1762
+ }
1763
+ return {
1764
+ platform: process.platform,
1765
+ env: process.env,
1766
+ home: homedir3(),
1767
+ uid: process.getuid?.() ?? 0,
1768
+ execPath: process.execPath,
1769
+ cliPath: companionCliEntry(),
1770
+ logPath: companionLogPath(),
1771
+ fs: {
1772
+ read: (path) => {
1773
+ try {
1774
+ return readFileSync4(path, "utf8");
1775
+ } catch {
1776
+ return null;
1777
+ }
1778
+ },
1779
+ write: (path, contents) => writeFileSync4(path, contents, "utf8"),
1780
+ remove: (path) => rmSync5(path, { force: true }),
1781
+ exists: (path) => existsSync6(path),
1782
+ mkdirp: (dir2) => {
1783
+ mkdirSync6(dir2, { recursive: true });
1784
+ }
1785
+ },
1786
+ run: (cmd, args) => {
1787
+ const res = spawnSync(cmd, args, { encoding: "utf8", timeout: RUN_TIMEOUT_MS });
1788
+ return {
1789
+ ok: res.status === 0,
1790
+ stdout: res.stdout ?? "",
1791
+ stderr: res.stderr ?? (res.error ? res.error.message : "")
1792
+ };
1793
+ }
1794
+ };
1795
+ }
1796
+
1797
+ // src/service/launchd.ts
1798
+ import { join as join6 } from "path";
1799
+ var LAUNCHD_LABEL = "ai.cabane.companion";
1800
+ function launchAgentPath(home) {
1801
+ return join6(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
1802
+ }
1803
+ function renderPlist(input) {
1804
+ const args = input.programArguments.map((a) => ` <string>${xml(a)}</string>`).join("\n");
1805
+ const env = Object.entries(input.environment).map(([k, v]) => ` <key>${xml(k)}</key>
1806
+ <string>${xml(v)}</string>`).join("\n");
1807
+ return [
1808
+ '<?xml version="1.0" encoding="UTF-8"?>',
1809
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1810
+ '<plist version="1.0">',
1811
+ "<dict>",
1812
+ " <key>Label</key>",
1813
+ ` <string>${LAUNCHD_LABEL}</string>`,
1814
+ " <key>ProgramArguments</key>",
1815
+ " <array>",
1816
+ args,
1817
+ " </array>",
1818
+ " <key>RunAtLoad</key>",
1819
+ " <true/>",
1820
+ " <key>KeepAlive</key>",
1821
+ " <dict>",
1822
+ " <key>SuccessfulExit</key>",
1823
+ " <false/>",
1824
+ " </dict>",
1825
+ " <key>EnvironmentVariables</key>",
1826
+ " <dict>",
1827
+ env,
1828
+ " </dict>",
1829
+ " <key>StandardOutPath</key>",
1830
+ ` <string>${xml(input.logPath)}</string>`,
1831
+ " <key>StandardErrorPath</key>",
1832
+ ` <string>${xml(input.logPath)}</string>`,
1833
+ "</dict>",
1834
+ "</plist>",
1835
+ ""
1836
+ ].join("\n");
1837
+ }
1838
+ function domain(host) {
1839
+ return `gui/${host.uid}`;
1840
+ }
1841
+ function target(host) {
1842
+ return `${domain(host)}/${LAUNCHD_LABEL}`;
1843
+ }
1844
+ function launchdStart(host, plistPath) {
1845
+ host.run("launchctl", ["bootout", target(host)]);
1846
+ const res = host.run("launchctl", ["bootstrap", domain(host), plistPath]);
1847
+ return res.ok ? { ok: true } : { ok: false, detail: firstLine(res.stderr || res.stdout) };
1848
+ }
1849
+ function launchdStop(host) {
1850
+ const res = host.run("launchctl", ["bootout", target(host)]);
1851
+ if (res.ok || notLoaded(res.stderr + res.stdout)) return { ok: true };
1852
+ return { ok: false, detail: firstLine(res.stderr || res.stdout) };
1853
+ }
1854
+ function launchdProbe(host) {
1855
+ const res = host.run("launchctl", ["print", target(host)]);
1856
+ if (res.ok) {
1857
+ const state = /state\s*=\s*(\w+)/.exec(res.stdout)?.[1];
1858
+ return { ownership: "held", running: state ? state === "running" : "unknown" };
1859
+ }
1860
+ return notLoaded(res.stderr + res.stdout) ? { ownership: "clear", running: false } : { ownership: "unknown", running: "unknown" };
1861
+ }
1862
+ function launchdRemove(host, plistPath) {
1863
+ const stopped = launchdStop(host);
1864
+ if (!stopped.ok) return stopped;
1865
+ host.fs.remove(plistPath);
1866
+ return { ok: true };
1867
+ }
1868
+ var NOT_LOADED = new RegExp(
1869
+ `no such process|(could not find|not find service)[^\\n]*${LAUNCHD_LABEL.replace(
1870
+ /[.*+?^${}()|[\]\\]/g,
1871
+ "\\$&"
1872
+ )}`,
1873
+ "i"
1874
+ );
1875
+ function notLoaded(output) {
1876
+ return NOT_LOADED.test(output);
1877
+ }
1878
+ function firstLine(s) {
1879
+ return s.trim().split("\n")[0] ?? "";
1880
+ }
1881
+ function xml(value) {
1882
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1883
+ }
1884
+
1885
+ // src/service/systemd.ts
1886
+ import { dirname as dirname4, join as join7 } from "path";
1887
+ var SYSTEMD_UNIT = "cabane-companion.service";
1888
+ function systemdUnitPath(host) {
1889
+ const configHome = host.env.XDG_CONFIG_HOME?.trim() ? host.env.XDG_CONFIG_HOME.trim() : join7(host.home, ".config");
1890
+ return join7(configHome, "systemd", "user", SYSTEMD_UNIT);
1891
+ }
1892
+ function renderUnit(input) {
1893
+ const [exec, ...rest] = input.programArguments;
1894
+ const execStart = [quote(exec ?? ""), ...rest.map(quote)].join(" ");
1895
+ const env = Object.entries(input.environment).map(([k, v]) => `Environment=${k}=${v}`);
1896
+ return [
1897
+ "[Unit]",
1898
+ "Description=Cabane Companion \u2014 keeps this device answering while you are logged in",
1899
+ "After=network-online.target",
1900
+ "Wants=network-online.target",
1901
+ "",
1902
+ "[Service]",
1903
+ "Type=simple",
1904
+ ...env,
1905
+ `ExecStart=${execStart}`,
1906
+ "Restart=on-failure",
1907
+ "RestartSec=5",
1908
+ "",
1909
+ "[Install]",
1910
+ "WantedBy=default.target"
1911
+ ].join("\n") + "\n";
1912
+ }
1913
+ function systemdReload(host) {
1914
+ host.run("systemctl", ["--user", "daemon-reload"]);
1915
+ }
1916
+ function systemdStart(host) {
1917
+ systemdReload(host);
1918
+ const enabled = host.run("systemctl", ["--user", "enable", SYSTEMD_UNIT]);
1919
+ if (!enabled.ok) return { ok: false, detail: firstLine2(enabled.stderr || enabled.stdout) };
1920
+ const started = host.run("systemctl", ["--user", "restart", SYSTEMD_UNIT]);
1921
+ if (!started.ok) return { ok: false, detail: firstLine2(started.stderr || started.stdout) };
1922
+ return { ok: true };
1923
+ }
1924
+ function systemdStop(host) {
1925
+ const res = host.run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
1926
+ if (res.ok || notLoaded2(res.stderr + res.stdout)) return { ok: true };
1927
+ return { ok: false, detail: firstLine2(res.stderr || res.stdout) };
1928
+ }
1929
+ function systemdProbe(host) {
1930
+ const state = host.run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim();
1931
+ if (state === "active") return { ownership: "held", running: true };
1932
+ if (state === "activating" || state === "reloading" || state === "deactivating") {
1933
+ return { ownership: "held", running: "transitional" };
1934
+ }
1935
+ if (state === "inactive" || state === "failed") return { ownership: "clear", running: false };
1936
+ return { ownership: "unknown", running: "unknown" };
1937
+ }
1938
+ function systemdWantsLinkPath(host) {
1939
+ return join7(dirname4(systemdUnitPath(host)), "default.target.wants", SYSTEMD_UNIT);
1940
+ }
1941
+ function systemdInstalled(host) {
1942
+ return host.fs.exists(systemdUnitPath(host)) || host.fs.exists(systemdWantsLinkPath(host));
1943
+ }
1944
+ function systemdRemove(host, unitPath) {
1945
+ const stopped = host.run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
1946
+ if (!(stopped.ok || notLoaded2(stopped.stderr + stopped.stdout))) {
1947
+ return { ok: false, detail: firstLine2(stopped.stderr || stopped.stdout) };
1948
+ }
1949
+ host.fs.remove(unitPath);
1950
+ const link = systemdWantsLinkPath(host);
1951
+ if (host.fs.exists(link)) host.fs.remove(link);
1952
+ systemdReload(host);
1953
+ return { ok: true };
1954
+ }
1955
+ var NOT_LOADED2 = new RegExp(
1956
+ `unit (file )?${SYSTEMD_UNIT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (not loaded|does not exist|not found|could not be found)`,
1957
+ "i"
1958
+ );
1959
+ function notLoaded2(output) {
1960
+ return NOT_LOADED2.test(output);
1961
+ }
1962
+ function readLinger(host) {
1963
+ const res = host.run("loginctl", ["show-user", String(host.uid), "-p", "Linger"]);
1964
+ if (!res.ok) return "unknown";
1965
+ const match = /Linger=(\w+)/.exec(res.stdout);
1966
+ if (!match) return "unknown";
1967
+ return match[1] === "yes" ? "yes" : "no";
1968
+ }
1969
+ function tryEnableLinger(host) {
1970
+ host.run("loginctl", ["enable-linger", String(host.uid)]);
1971
+ return readLinger(host);
1972
+ }
1973
+ function isRemoteSession(env) {
1974
+ return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1975
+ }
1976
+ function quote(value) {
1977
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1978
+ }
1979
+ function firstLine2(s) {
1980
+ return s.trim().split("\n")[0] ?? "";
1981
+ }
1982
+
1983
+ // src/service/index.ts
1984
+ function detectServiceManager(host = defaultServiceHost()) {
1985
+ if (host.platform === "darwin") return "launchd";
1986
+ if (host.platform !== "linux") return "none";
1987
+ return host.run("systemctl", ["--user", "show-environment"]).ok ? "systemd-user" : "none";
1988
+ }
1989
+ function programArguments(host, _opts) {
1990
+ return [host.execPath, host.cliPath, "start", "--foreground"];
1991
+ }
1992
+ function environment(host) {
1993
+ return {
1994
+ // PATH CAPTURE. launchd's default PATH is `/usr/bin:/bin:/usr/sbin:/sbin`
1995
+ // and a systemd user manager's is barely better — neither has `claude`,
1996
+ // `codex` or `opencode` on it. A companion that runs but can't find its
1997
+ // harness is worse than one that isn't running, because the device reads
1998
+ // Online. So we bake in the PATH of the shell that ran `start`, and because
1999
+ // it's part of the rendered content, a PATH that later drifts re-renders on
2000
+ // the next `start` like any other change.
2001
+ PATH: host.env.PATH ?? "",
2002
+ CABANE_COMPANION_DAEMON: "1"
2003
+ };
2004
+ }
2005
+ function unitPathFor(host, manager) {
2006
+ if (manager === "launchd") return launchAgentPath(host.home);
2007
+ if (manager === "systemd-user") return systemdUnitPath(host);
2008
+ return null;
2009
+ }
2010
+ function renderFor(host, manager, opts) {
2011
+ const input = { programArguments: programArguments(host, opts), environment: environment(host) };
2012
+ return manager === "launchd" ? renderPlist({ ...input, logPath: host.logPath }) : renderUnit(input);
2013
+ }
2014
+ function installService(opts = {}, host = defaultServiceHost()) {
2015
+ const manager = detectServiceManager(host);
2016
+ if (manager === "none") return { installed: false, reason: "unsupported" };
2017
+ const path = unitPathFor(host, manager);
2018
+ if (!path) return { installed: false, reason: "unsupported" };
2019
+ const desired = renderFor(host, manager, opts);
2020
+ const existing = host.fs.read(path);
2021
+ const hadUnit = existing !== null;
2022
+ const changed = existing !== desired;
2023
+ if (changed) {
2024
+ host.fs.mkdirp(dirname5(path));
2025
+ host.fs.write(path, desired);
2026
+ }
2027
+ if (manager === "systemd-user") {
2028
+ const linger = ensureLinger(host);
2029
+ if (linger !== "yes" && isRemoteSession(host.env)) {
2030
+ systemdRemove(host, path);
2031
+ return failedInstall(
2032
+ host,
2033
+ manager,
2034
+ "linger-unavailable",
2035
+ "a systemd --user service would stop when this SSH session ends (lingering is off and could not be enabled)"
2036
+ );
2037
+ }
2038
+ }
2039
+ const started = manager === "launchd" ? launchdStart(host, path) : systemdStart(host);
2040
+ if (!started.ok) {
2041
+ if (!hadUnit) removeService(host, manager, path);
2042
+ return failedInstall(host, manager, "command-failed", started.detail);
2043
+ }
2044
+ return { installed: true, manager, changed };
2045
+ }
2046
+ function failedInstall(host, manager, reason, detail) {
2047
+ return {
2048
+ installed: false,
2049
+ reason,
2050
+ ...detail ? { detail } : {},
2051
+ ...managerOwnership(host, manager) === "clear" ? {} : { leftBehind: manager }
2052
+ };
2053
+ }
2054
+ function managerProbe(host, manager) {
2055
+ if (manager === "launchd") return launchdProbe(host);
2056
+ if (manager === "systemd-user") return systemdProbe(host);
2057
+ return { ownership: "clear", running: false };
2058
+ }
2059
+ function managerOwnership(host, manager) {
2060
+ return managerProbe(host, manager).ownership;
2061
+ }
2062
+ function refreshInstalledService(opts = {}, host = defaultServiceHost()) {
2063
+ const manager = detectServiceManager(host);
2064
+ const path = unitPathFor(host, manager);
2065
+ if (!path || !host.fs.exists(path)) return false;
2066
+ const desired = renderFor(host, manager, opts);
2067
+ if (host.fs.read(path) === desired) return false;
2068
+ host.fs.write(path, desired);
2069
+ if (manager === "systemd-user") systemdReload(host);
2070
+ return true;
2071
+ }
2072
+ function stopService(host = defaultServiceHost()) {
2073
+ const manager = detectServiceManager(host);
2074
+ const path = unitPathFor(host, manager);
2075
+ if (manager === "none" || !path || !managerHasClaim(host, manager, path)) {
2076
+ return { handled: false, ok: true };
2077
+ }
2078
+ const res = manager === "launchd" ? launchdStop(host) : systemdStop(host);
2079
+ return {
2080
+ handled: true,
2081
+ ok: res.ok,
2082
+ manager,
2083
+ ...res.detail ? { detail: res.detail } : {}
2084
+ };
2085
+ }
2086
+ function disableService(host = defaultServiceHost()) {
2087
+ const manager = detectServiceManager(host);
2088
+ const path = unitPathFor(host, manager);
2089
+ if (manager === "none" || !path) return { handled: false, ok: true };
2090
+ if (!managerHasClaim(host, manager, path)) return { handled: false, ok: true, manager };
2091
+ const res = removeService(host, manager, path);
2092
+ return { handled: true, ok: res.ok, manager, ...res.detail ? { detail: res.detail } : {} };
2093
+ }
2094
+ function managerHasClaim(host, manager, path) {
2095
+ if (manager === "none") return false;
2096
+ if (host.fs.exists(path)) return true;
2097
+ if (manager === "systemd-user" && systemdInstalled(host)) return true;
2098
+ return managerOwnership(host, manager) !== "clear";
2099
+ }
2100
+ function removeService(host, manager, path) {
2101
+ return manager === "launchd" ? launchdRemove(host, path) : systemdRemove(host, path);
2102
+ }
2103
+ function serviceStatus(host = defaultServiceHost()) {
2104
+ const manager = detectServiceManager(host);
2105
+ const path = unitPathFor(host, manager);
2106
+ const probe = managerProbe(host, manager);
2107
+ const installed = manager === "none" || !path ? false : host.fs.exists(path) || probe.ownership === "held" || manager === "systemd-user" && systemdInstalled(host);
2108
+ const running = manager === "none" ? false : probe.running;
2109
+ return {
2110
+ manager,
2111
+ unitPath: path,
2112
+ installed,
2113
+ running,
2114
+ linger: manager === "systemd-user" ? readLinger(host) : null,
2115
+ remoteSession: isRemoteSession(host.env)
2116
+ };
2117
+ }
2118
+ function ensureLinger(host) {
2119
+ const current = readLinger(host);
2120
+ if (current === "yes") return current;
2121
+ return tryEnableLinger(host);
1575
2122
  }
1576
2123
 
1577
- // src/cli.ts
1578
- import { readFileSync as readFileSync12 } from "fs";
1579
-
1580
2124
  // src/commands/service.ts
1581
2125
  function serviceStatusCommand(deps = {}) {
1582
2126
  const status2 = (deps.status ?? serviceStatus)();
@@ -1641,50 +2185,22 @@ function lingerNote(status2) {
1641
2185
  return " \u2014 the service runs while you are logged in";
1642
2186
  }
1643
2187
 
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;
1673
- }
2188
+ // src/commands/start.ts
2189
+ import { hostname as hostname2 } from "os";
1674
2190
 
1675
2191
  // src/runtime.ts
1676
2192
  import { randomUUID as randomUUID2 } from "crypto";
1677
2193
 
1678
2194
  // src/dashboard/server.ts
1679
- import { dirname as dirname6, join as join8 } from "path";
2195
+ import { dirname as dirname6, join as join9 } from "path";
1680
2196
  import { fileURLToPath as fileURLToPath2 } from "url";
1681
2197
  import { serve } from "@hono/node-server";
1682
2198
  import { Hono } from "hono";
1683
2199
 
1684
2200
  // src/dashboard/routes.ts
1685
- import { openSync as openSync3, readSync, closeSync as closeSync3, fstatSync, existsSync as existsSync6 } from "fs";
2201
+ import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync7 } from "fs";
1686
2202
  import { readFile } from "fs/promises";
1687
- import { extname, join as join7, normalize } from "path";
2203
+ import { extname, join as join8, normalize } from "path";
1688
2204
  import { streamSSE } from "hono/streaming";
1689
2205
 
1690
2206
  // src/state.ts
@@ -1933,14 +2449,14 @@ var CONTENT_TYPES = {
1933
2449
  function registerRoutes(app, deps) {
1934
2450
  const { supervisor, hub, staticDir } = deps;
1935
2451
  app.get("/", async (c) => {
1936
- const html = await readFile(join7(staticDir, "index.html"), "utf8");
2452
+ const html = await readFile(join8(staticDir, "index.html"), "utf8");
1937
2453
  return c.html(html);
1938
2454
  });
1939
2455
  app.get("/static/:file", async (c) => {
1940
2456
  const file = c.req.param("file");
1941
2457
  const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1942
- const full = join7(staticDir, safe3);
1943
- if (!full.startsWith(staticDir) || !existsSync6(full)) return c.notFound();
2458
+ const full = join8(staticDir, safe3);
2459
+ if (!full.startsWith(staticDir) || !existsSync7(full)) return c.notFound();
1944
2460
  const body = await readFile(full);
1945
2461
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
1946
2462
  c.header("content-type", type);
@@ -2067,11 +2583,11 @@ function clampLimit(raw, fallback, max = 200) {
2067
2583
  return Math.min(Math.floor(n), max);
2068
2584
  }
2069
2585
  function tailFile(path, lines) {
2070
- if (!existsSync6(path)) return [];
2586
+ if (!existsSync7(path)) return [];
2071
2587
  const MAX_BYTES = 256 * 1024;
2072
2588
  let fd;
2073
2589
  try {
2074
- fd = openSync3(path, "r");
2590
+ fd = openSync2(path, "r");
2075
2591
  const size = fstatSync(fd).size;
2076
2592
  const start2 = Math.max(0, size - MAX_BYTES);
2077
2593
  const len = size - start2;
@@ -2085,7 +2601,7 @@ function tailFile(path, lines) {
2085
2601
  } catch {
2086
2602
  return [];
2087
2603
  } finally {
2088
- if (fd !== void 0) closeSync3(fd);
2604
+ if (fd !== void 0) closeSync2(fd);
2089
2605
  }
2090
2606
  }
2091
2607
 
@@ -2156,7 +2672,7 @@ function isAddrInUse(err) {
2156
2672
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
2157
2673
  }
2158
2674
  function resolveStaticDir() {
2159
- return join8(dirname6(fileURLToPath2(import.meta.url)), "static");
2675
+ return join9(dirname6(fileURLToPath2(import.meta.url)), "static");
2160
2676
  }
2161
2677
 
2162
2678
  // src/api.ts
@@ -2632,20 +3148,20 @@ function errorMessage2(status2, body) {
2632
3148
  }
2633
3149
 
2634
3150
  // 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";
3151
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
3152
+ import { join as join10 } from "path";
2637
3153
  function pathFor(workspaceId) {
2638
- return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
3154
+ return join10(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2639
3155
  }
2640
3156
  function readCursor(workspaceId) {
2641
3157
  const path = pathFor(workspaceId);
2642
- if (!existsSync7(path)) return null;
3158
+ if (!existsSync8(path)) return null;
2643
3159
  const raw = readFileSync5(path, "utf8").trim();
2644
3160
  return raw.length > 0 ? raw : null;
2645
3161
  }
2646
3162
  function writeCursor(workspaceId, eventId) {
2647
3163
  const path = pathFor(workspaceId);
2648
- mkdirSync7(join9(cabaneDir(), "cursors"), { recursive: true });
3164
+ mkdirSync7(join10(cabaneDir(), "cursors"), { recursive: true });
2649
3165
  writeFileSync5(path, eventId + "\n", "utf8");
2650
3166
  }
2651
3167
 
@@ -2689,18 +3205,18 @@ var CursorTracker = class {
2689
3205
  };
2690
3206
 
2691
3207
  // 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";
3208
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
3209
+ import { join as join11 } from "path";
2694
3210
  var MAX_IDS = 256;
2695
3211
  function dir(log) {
2696
- return join10(cabaneDir(), log);
3212
+ return join11(cabaneDir(), log);
2697
3213
  }
2698
3214
  function pathFor2(log, workspaceId) {
2699
- return join10(dir(log), encodeURIComponent(workspaceId));
3215
+ return join11(dir(log), encodeURIComponent(workspaceId));
2700
3216
  }
2701
3217
  function readIds(log, workspaceId) {
2702
3218
  const path = pathFor2(log, workspaceId);
2703
- if (!existsSync8(path)) return [];
3219
+ if (!existsSync9(path)) return [];
2704
3220
  try {
2705
3221
  return readFileSync6(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2706
3222
  } catch {
@@ -2732,15 +3248,15 @@ function markCompleted(workspaceId, eventId) {
2732
3248
  }
2733
3249
  var MAX_RESUME_ATTEMPTS = 3;
2734
3250
  function resumeDir() {
2735
- return join10(cabaneDir(), "resume-attempts");
3251
+ return join11(cabaneDir(), "resume-attempts");
2736
3252
  }
2737
3253
  function resumePathFor(workspaceId) {
2738
- return join10(resumeDir(), encodeURIComponent(workspaceId));
3254
+ return join11(resumeDir(), encodeURIComponent(workspaceId));
2739
3255
  }
2740
3256
  function readResumeCounts(workspaceId) {
2741
3257
  const out = /* @__PURE__ */ new Map();
2742
3258
  const path = resumePathFor(workspaceId);
2743
- if (!existsSync8(path)) return out;
3259
+ if (!existsSync9(path)) return out;
2744
3260
  try {
2745
3261
  for (const line of readFileSync6(path, "utf8").split("\n")) {
2746
3262
  const trimmed = line.trim();
@@ -4654,7 +5170,7 @@ async function acquireServerTurnLock(url) {
4654
5170
  };
4655
5171
  }
4656
5172
  function createHttpOpencodeTransport(opts) {
4657
- const base = trimSlash2(opts.baseUrl);
5173
+ const base = trimSlash(opts.baseUrl);
4658
5174
  const doFetch = opts.fetchImpl ?? fetch;
4659
5175
  return {
4660
5176
  async run(spec, signal) {
@@ -4800,7 +5316,7 @@ function belongsToSession(ev, sessionId) {
4800
5316
  function newOpencodeMessageId() {
4801
5317
  return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
4802
5318
  }
4803
- function trimSlash2(s) {
5319
+ function trimSlash(s) {
4804
5320
  return s.endsWith("/") ? s.slice(0, -1) : s;
4805
5321
  }
4806
5322
 
@@ -5550,7 +6066,13 @@ function parseCodexModel(model) {
5550
6066
  var CABANE_MCP_SERVER3 = "cabane";
5551
6067
  var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
5552
6068
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
5553
- var ENV_ENVELOPE_KEYS = ["CABANE_ENV_TIER", "CABANE_ENV_KEY", "CABANE_ENV_BINDING"];
6069
+ var ENV_ENVELOPE_KEYS = [
6070
+ "CABANE_PLAYGROUND",
6071
+ "CABANE_PLAYGROUND_BIN",
6072
+ "CABANE_CONVERSATION_ID",
6073
+ "CABANE_AGENT_ID",
6074
+ "CABANE_CONVERSATION_TITLE"
6075
+ ];
5554
6076
  function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromptFingerprint = null) {
5555
6077
  const { policy, config } = req;
5556
6078
  const directory = req.local.cwd ?? "";
@@ -6455,8 +6977,8 @@ var ConnectorHealthStore = class {
6455
6977
 
6456
6978
  // src/dispatcher.ts
6457
6979
  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";
6980
+ import { appendFileSync as appendFileSync2, existsSync as existsSync12, mkdirSync as mkdirSync11, readdirSync as readdirSync2, statSync } from "fs";
6981
+ import { join as join16 } from "path";
6460
6982
 
6461
6983
  // src/summon.ts
6462
6984
  import { z as z12 } from "zod";
@@ -6730,10 +7252,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6730
7252
 
6731
7253
  // src/build-options.ts
6732
7254
  function cabaneMcpUrl(baseUrl) {
6733
- return `${trimSlash3(baseUrl)}/api/mcp`;
7255
+ return `${trimSlash2(baseUrl)}/api/mcp`;
6734
7256
  }
6735
7257
  function turnControlMcpUrl(baseUrl) {
6736
- return `${trimSlash3(baseUrl)}/api/turn-control`;
7258
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
6737
7259
  }
6738
7260
  function buildCompanionTurnRequest(params) {
6739
7261
  const { turnContext: t } = params;
@@ -6782,18 +7304,18 @@ function buildCompanionTurnRequest(params) {
6782
7304
  }
6783
7305
  };
6784
7306
  }
6785
- function trimSlash3(s) {
7307
+ function trimSlash2(s) {
6786
7308
  return s.endsWith("/") ? s.slice(0, -1) : s;
6787
7309
  }
6788
7310
 
6789
7311
  // src/codex-instructions.ts
6790
7312
  import { mkdtemp, rm, writeFile } from "fs/promises";
6791
7313
  import { tmpdir } from "os";
6792
- import { join as join11 } from "path";
7314
+ import { join as join12 } from "path";
6793
7315
  var PREFIX = "cabane-codex-instructions-";
6794
7316
  async function writeCodexInstructionsFile(contents) {
6795
- const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
6796
- const path = join11(dir2, "instructions.md");
7317
+ const dir2 = await mkdtemp(join12(tmpdir(), PREFIX));
7318
+ const path = join12(dir2, "instructions.md");
6797
7319
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
6798
7320
  return {
6799
7321
  path,
@@ -6804,20 +7326,20 @@ async function writeCodexInstructionsFile(contents) {
6804
7326
  }
6805
7327
 
6806
7328
  // 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";
7329
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync7, existsSync as existsSync10 } from "fs";
7330
+ import { join as join13 } from "path";
6809
7331
  function dirFor(workspaceId) {
6810
- return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7332
+ return join13(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6811
7333
  }
6812
7334
  function conversationDir(workspaceId, conversationId) {
6813
- return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
7335
+ return join13(dirFor(workspaceId), encodeURIComponent(conversationId));
6814
7336
  }
6815
7337
  function pathFor3(workspaceId, conversationId, agentId) {
6816
- return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7338
+ return join13(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6817
7339
  }
6818
7340
  function readPrepared(workspaceId, conversationId, agentId) {
6819
7341
  const path = pathFor3(workspaceId, conversationId, agentId);
6820
- if (!existsSync9(path)) return null;
7342
+ if (!existsSync10(path)) return null;
6821
7343
  try {
6822
7344
  const parsed = JSON.parse(readFileSync7(path, "utf8"));
6823
7345
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -6840,21 +7362,21 @@ function writePrepared(workspaceId, conversationId, agentId, result) {
6840
7362
  );
6841
7363
  }
6842
7364
  function clearPrepared(workspaceId, conversationId, agentId) {
6843
- rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
7365
+ rmSync6(pathFor3(workspaceId, conversationId, agentId), { force: true });
6844
7366
  }
6845
7367
 
6846
7368
  // src/secrets.ts
6847
- import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
6848
- import { join as join13 } from "path";
7369
+ import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
7370
+ import { join as join14 } from "path";
6849
7371
  import { z as z13 } from "zod";
6850
7372
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6851
7373
  function secretsPath() {
6852
- return join13(cabaneDir(), "secrets.json");
7374
+ return join14(cabaneDir(), "secrets.json");
6853
7375
  }
6854
7376
  var secretStoreSchema = z13.record(z13.string(), z13.string());
6855
7377
  function loadSecretStore() {
6856
7378
  const path = secretsPath();
6857
- if (!existsSync10(path)) return makeStore({});
7379
+ if (!existsSync11(path)) return makeStore({});
6858
7380
  let raw;
6859
7381
  try {
6860
7382
  raw = readFileSync8(path, "utf8");
@@ -6935,10 +7457,10 @@ function resolveMcpSecrets(mcpServers, store) {
6935
7457
  }
6936
7458
 
6937
7459
  // 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";
7460
+ import { appendFileSync, chmodSync as chmodSync3, mkdirSync as mkdirSync10, readdirSync, rmSync as rmSync7 } from "fs";
7461
+ import { join as join15 } from "path";
6940
7462
  function transcriptsDir() {
6941
- return join14(cabaneDir(), "transcripts");
7463
+ return join15(cabaneDir(), "transcripts");
6942
7464
  }
6943
7465
  var RETAIN = 200;
6944
7466
  var TranscriptWriter = class {
@@ -6947,7 +7469,7 @@ var TranscriptWriter = class {
6947
7469
  onWarn;
6948
7470
  constructor(dir2, meta, onWarn) {
6949
7471
  this.onWarn = onWarn;
6950
- this.path = join14(dir2, fileName(meta));
7472
+ this.path = join15(dir2, fileName(meta));
6951
7473
  try {
6952
7474
  mkdirSync10(dir2, { recursive: true });
6953
7475
  try {
@@ -7006,7 +7528,7 @@ function pruneOld(dir2, retain) {
7006
7528
  const drop = files.sort().slice(0, files.length - retain);
7007
7529
  for (const f of drop) {
7008
7530
  try {
7009
- rmSync6(join14(dir2, f), { force: true });
7531
+ rmSync7(join15(dir2, f), { force: true });
7010
7532
  } catch {
7011
7533
  }
7012
7534
  }
@@ -7352,7 +7874,7 @@ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7352
7874
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7353
7875
  function checkoutState(cwd) {
7354
7876
  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})` };
7877
+ if (!existsSync12(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
7356
7878
  let entries;
7357
7879
  try {
7358
7880
  entries = readdirSync2(cwd);
@@ -7362,15 +7884,15 @@ function checkoutState(cwd) {
7362
7884
  if (entries.length === 0) {
7363
7885
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
7364
7886
  }
7365
- const gitPath = join15(cwd, ".git");
7366
- if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
7887
+ const gitPath = join16(cwd, ".git");
7888
+ if (!existsSync12(gitPath)) return { ok: true, reason: "usable" };
7367
7889
  let stat;
7368
7890
  try {
7369
7891
  stat = statSync(gitPath);
7370
7892
  } catch (error) {
7371
7893
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
7372
7894
  }
7373
- if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
7895
+ if (stat.isDirectory() && !existsSync12(join16(gitPath, "HEAD")))
7374
7896
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
7375
7897
  return { ok: true, reason: "usable" };
7376
7898
  }
@@ -7561,7 +8083,7 @@ var Dispatcher = class {
7561
8083
  let seqCounter = 0;
7562
8084
  const nextSeq = () => ++seqCounter;
7563
8085
  let effectiveCwd = localCwd ?? cabaneCwd;
7564
- if (effectiveCwd && !existsSync11(effectiveCwd)) {
8086
+ if (effectiveCwd && !existsSync12(effectiveCwd)) {
7565
8087
  turnLog.warn(
7566
8088
  { cwd: effectiveCwd },
7567
8089
  "dispatcher: configured working directory does not exist on this device \u2014 falling back to the process cwd"
@@ -7869,7 +8391,7 @@ ${reason}`,
7869
8391
  }
7870
8392
  return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7871
8393
  }
7872
- const receiptPath = join15(effectiveCwd, ".git", "cabane", "readiness.jsonl");
8394
+ const receiptPath = join16(effectiveCwd, ".git", "cabane", "readiness.jsonl");
7873
8395
  const receiptLine = (fields) => `${JSON.stringify({
7874
8396
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7875
8397
  taskId: hookEnv.CABANE_TASK_ID,
@@ -7893,7 +8415,7 @@ ${reason}`,
7893
8415
  })}
7894
8416
  `;
7895
8417
  try {
7896
- mkdirSync11(join15(effectiveCwd, ".git", "cabane"), { recursive: true });
8418
+ mkdirSync11(join16(effectiveCwd, ".git", "cabane"), { recursive: true });
7897
8419
  appendFileSync2(
7898
8420
  receiptPath,
7899
8421
  // `starting` is the honest classification before the proof has run. The
@@ -8298,224 +8820,28 @@ ${reason}`,
8298
8820
  this.notifyEnd({
8299
8821
  id: dispatchId,
8300
8822
  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
- }
8823
+ durationMs,
8824
+ ...resultReason ? { reason: resultReason } : {}
8825
+ });
8826
+ return {
8827
+ ok: false,
8828
+ durationMs,
8829
+ ...resultReason ? { reason: resultReason } : {}
8504
8830
  };
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);
8515
- }
8516
- );
8517
- });
8518
- }
8831
+ }
8832
+ // SJ383: cancel a specific (conversation, agent) run if one is in flight in
8833
+ // THIS companion process. Returns true if an in-flight run was aborted.
8834
+ cancel(conversationId, agentId) {
8835
+ const key = runKey(conversationId, agentId);
8836
+ const ac = this.aborts.get(key);
8837
+ if (!ac) return false;
8838
+ try {
8839
+ ac.abort();
8840
+ } catch {
8841
+ }
8842
+ return true;
8843
+ }
8844
+ };
8519
8845
 
8520
8846
  // src/opencode-models.ts
8521
8847
  var OPENCODE_RUNTIME = "opencode";
@@ -8560,15 +8886,15 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
8560
8886
 
8561
8887
  // src/outbox.ts
8562
8888
  import {
8563
- existsSync as existsSync12,
8889
+ existsSync as existsSync13,
8564
8890
  mkdirSync as mkdirSync12,
8565
8891
  readdirSync as readdirSync3,
8566
8892
  readFileSync as readFileSync9,
8567
8893
  renameSync as renameSync3,
8568
- rmSync as rmSync7,
8894
+ rmSync as rmSync8,
8569
8895
  writeFileSync as writeFileSync8
8570
8896
  } from "fs";
8571
- import { join as join16 } from "path";
8897
+ import { join as join17 } from "path";
8572
8898
  var MAX_ENTRIES = 2e3;
8573
8899
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
8574
8900
  var Outbox = class {
@@ -8581,10 +8907,10 @@ var Outbox = class {
8581
8907
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
8582
8908
  // cases route writes at the right tmpdir.
8583
8909
  dir() {
8584
- return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8910
+ return join17(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8585
8911
  }
8586
8912
  fileFor(turnId, seq) {
8587
- return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8913
+ return join17(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8588
8914
  }
8589
8915
  // Persist a commit for later draining. Atomic (temp file + rename) so a
8590
8916
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -8599,7 +8925,7 @@ var Outbox = class {
8599
8925
  renameSync3(tmp, target2);
8600
8926
  } catch (err) {
8601
8927
  try {
8602
- rmSync7(tmp, { force: true });
8928
+ rmSync8(tmp, { force: true });
8603
8929
  } catch {
8604
8930
  }
8605
8931
  this.log?.warn(
@@ -8616,7 +8942,7 @@ var Outbox = class {
8616
8942
  // wedging the drain.
8617
8943
  list() {
8618
8944
  const dir2 = this.dir();
8619
- if (!existsSync12(dir2)) return [];
8945
+ if (!existsSync13(dir2)) return [];
8620
8946
  let names;
8621
8947
  try {
8622
8948
  names = readdirSync3(dir2);
@@ -8626,7 +8952,7 @@ var Outbox = class {
8626
8952
  const entries = [];
8627
8953
  for (const name of names) {
8628
8954
  if (!name.endsWith(".json")) continue;
8629
- const full = join16(dir2, name);
8955
+ const full = join17(dir2, name);
8630
8956
  try {
8631
8957
  const parsed = JSON.parse(readFileSync9(full, "utf8"));
8632
8958
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -8646,13 +8972,13 @@ var Outbox = class {
8646
8972
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
8647
8973
  remove(turnId, seq) {
8648
8974
  try {
8649
- rmSync7(this.fileFor(turnId, seq), { force: true });
8975
+ rmSync8(this.fileFor(turnId, seq), { force: true });
8650
8976
  } catch {
8651
8977
  }
8652
8978
  }
8653
8979
  size() {
8654
8980
  const dir2 = this.dir();
8655
- if (!existsSync12(dir2)) return 0;
8981
+ if (!existsSync13(dir2)) return 0;
8656
8982
  try {
8657
8983
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
8658
8984
  } catch {
@@ -8665,7 +8991,7 @@ var Outbox = class {
8665
8991
  "companion outbox: dropping unreadable entry"
8666
8992
  );
8667
8993
  try {
8668
- rmSync7(full, { force: true });
8994
+ rmSync8(full, { force: true });
8669
8995
  } catch {
8670
8996
  }
8671
8997
  }
@@ -9594,6 +9920,26 @@ var CompanionSupervisor = class {
9594
9920
  async recheckHarnesses() {
9595
9921
  await this.refreshHarnessStatuses();
9596
9922
  }
9923
+ // CT1085 §1 step 3: beat NOW and wait for it to land. `start` calls this
9924
+ // between bringing the runtime up and asking the person anything, so the
9925
+ // browser's connect step is already showing what this machine has ("your
9926
+ // terminal is asking") rather than sitting blank while the terminal blocks on
9927
+ // an answer. Coalesces with an in-flight beat rather than stacking a second.
9928
+ async heartbeatNow() {
9929
+ if (this.inFlightHeartbeat) {
9930
+ await this.inFlightHeartbeat;
9931
+ return;
9932
+ }
9933
+ this.kickHeartbeat();
9934
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
9935
+ }
9936
+ // CT1085: the config as it stands right now — after any `enableHarness` write.
9937
+ // The control socket's connect handler needs it to run the shake-out check
9938
+ // against the URL/flag that was just persisted, not the one this process booted
9939
+ // with.
9940
+ currentConfig() {
9941
+ return this.config;
9942
+ }
9597
9943
  // Friendly enable for the config-driven harnesses — flip the flag the app owns in
9598
9944
  // `~/.cabane/config.json`, no hand-edited JSON. This is enable/expose ONLY: it
9599
9945
  // never installs a binary and never drives a login (BYO — Decided).
@@ -9807,10 +10153,10 @@ function handleUncaught(log, err, origin) {
9807
10153
  }
9808
10154
 
9809
10155
  // 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";
10156
+ import { existsSync as existsSync14, mkdirSync as mkdirSync13, readFileSync as readFileSync10, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "fs";
10157
+ import { join as join18 } from "path";
9812
10158
  function crashMarkerPath() {
9813
- return join17(cabaneDir(), "last-error.json");
10159
+ return join18(cabaneDir(), "last-error.json");
9814
10160
  }
9815
10161
  function recordCrash(rec2) {
9816
10162
  try {
@@ -9822,7 +10168,7 @@ function recordCrash(rec2) {
9822
10168
  function clearCrash() {
9823
10169
  try {
9824
10170
  const path = crashMarkerPath();
9825
- if (existsSync13(path)) rmSync8(path, { force: true });
10171
+ if (existsSync14(path)) rmSync9(path, { force: true });
9826
10172
  } catch {
9827
10173
  }
9828
10174
  }
@@ -9841,7 +10187,13 @@ async function createCompanionRuntime(opts = {}) {
9841
10187
  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
10188
  )
9843
10189
  }));
9844
- await warnAboutHarnessReadiness(cfg, { probeClaude: async () => claudeCode });
10190
+ await warnAboutHarnessReadiness(cfg, {
10191
+ probeClaude: async () => claudeCode,
10192
+ // CT1085: the CLI's onboarding script owns the terminal and says this in
10193
+ // its own words (the `!` block, or the per-harness offer), so it hands us a
10194
+ // sink that logs instead. Every other caller keeps the stderr line.
10195
+ ...opts.onReadinessWarning ? { warn: opts.onReadinessWarning } : {}
10196
+ });
9845
10197
  } catch (err) {
9846
10198
  recordCrash({
9847
10199
  reason: err instanceof Error ? err.message : String(err),
@@ -9865,8 +10217,6 @@ async function createCompanionRuntime(opts = {}) {
9865
10217
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
9866
10218
  const claim = acquireRuntimeState({
9867
10219
  pid: process.pid,
9868
- url: "",
9869
- port: 0,
9870
10220
  startedAt,
9871
10221
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
9872
10222
  instanceId
@@ -9874,7 +10224,7 @@ async function createCompanionRuntime(opts = {}) {
9874
10224
  if (!claim.acquired) {
9875
10225
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
9876
10226
  }
9877
- process.on("exit", () => clearRuntimeState());
10227
+ process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
9878
10228
  const hub = new CompanionStateHub({
9879
10229
  // CT29: one device, one base URL — the cabane instance this device is paired
9880
10230
  // with. The dashboard's connection line shows it.
@@ -9892,17 +10242,38 @@ async function createCompanionRuntime(opts = {}) {
9892
10242
  harnessVersions
9893
10243
  });
9894
10244
  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 } : {}
10245
+ const connectHarness = async (runtime, serverUrl) => {
10246
+ const candidate = runtime === "opencode" ? { ...supervisor.currentConfig(), opencode: { serverUrl: serverUrl ?? "" } } : supervisor.currentConfig();
10247
+ const verdict = await shakeOutHarness(runtime, candidate);
10248
+ if (verdict === "absent") return { ok: false, error: absentLine(runtime) };
10249
+ const result = await supervisor.enableHarness(
10250
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: serverUrl ?? "" } : { runtime }
10251
+ );
10252
+ if (!result.ok) return { ok: false, error: result.error };
10253
+ return { ok: true, message: connectedLine(runtime, verdict) };
10254
+ };
10255
+ const control = await startControlServer({
10256
+ status: () => hub.statusJson(),
10257
+ connect: async (runtime, serverUrl) => {
10258
+ const result = await connectHarness(runtime, serverUrl);
10259
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
10260
+ },
10261
+ stop: () => void supervisor.requestStop()
9900
10262
  });
9901
- hub.setDashboardUrl(dashboard.url);
10263
+ let dashboard = null;
10264
+ if (opts.dashboard) {
10265
+ const preferredPort = opts.port ?? cfg.dashboardPort;
10266
+ dashboard = await startDashboard({
10267
+ supervisor,
10268
+ hub,
10269
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
10270
+ });
10271
+ hub.setDashboardUrl(dashboard.url);
10272
+ }
9902
10273
  writeRuntimeState({
9903
10274
  pid: process.pid,
9904
- url: dashboard.url,
9905
- port: dashboard.port,
10275
+ socket: control.path,
10276
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9906
10277
  startedAt,
9907
10278
  // SJ495: the daemon launcher sets this env on the detached child, so the
9908
10279
  // marker records whether this companion is backgrounded (foreground start
@@ -9915,68 +10286,407 @@ async function createCompanionRuntime(opts = {}) {
9915
10286
  const stop2 = async () => {
9916
10287
  if (stopped) return;
9917
10288
  stopped = true;
9918
- clearRuntimeState();
10289
+ clearRuntimeStateIfOurs(instanceId);
9919
10290
  try {
9920
10291
  await supervisor.shutdown();
9921
- await dashboard.close();
10292
+ await closeSurfaces(control, dashboard);
9922
10293
  } catch {
9923
10294
  }
9924
10295
  };
9925
10296
  return {
9926
10297
  ok: true,
9927
10298
  runtime: {
9928
- url: dashboard.url,
9929
- port: dashboard.port,
10299
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
10300
+ socketPath: control.path,
9930
10301
  config: cfg,
9931
10302
  stop: stop2,
10303
+ heartbeatNow: () => supervisor.heartbeatNow(),
10304
+ harnesses: () => hub.statusJson().harnesses ?? [],
10305
+ connectHarness,
9932
10306
  drainForRestart: async (graceMs) => {
9933
- clearRuntimeState();
10307
+ clearRuntimeStateIfOurs(instanceId);
9934
10308
  const result = await supervisor.drainForRestart(graceMs);
9935
- await dashboard.close();
10309
+ await closeSurfaces(control, dashboard);
9936
10310
  stopped = true;
9937
10311
  return result;
9938
10312
  }
9939
10313
  }
9940
10314
  };
9941
10315
  }
10316
+ async function closeSurfaces(control, dashboard) {
10317
+ await control.close();
10318
+ if (dashboard) await dashboard.close();
10319
+ }
10320
+
10321
+ // src/commands/daemon.ts
10322
+ import { spawn as spawn5 } from "child_process";
10323
+ import { closeSync as closeSync3, mkdirSync as mkdirSync14, openSync as openSync3 } from "fs";
10324
+ var STARTUP_TIMEOUT_MS = 8e3;
10325
+ var POLL_INTERVAL_MS = 150;
10326
+ async function startDaemon(opts = {}, deps = {}) {
10327
+ const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
10328
+ const readState = deps.readState ?? readLiveRuntimeState;
10329
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
10330
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
10331
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
10332
+ const now = deps.now ?? (() => Date.now());
10333
+ const install = deps.installService ?? ((o) => installService(o));
10334
+ const refresh = deps.refreshService ?? ((o) => refreshInstalledService(o));
10335
+ const disable = deps.disableService ?? (() => disableService());
10336
+ const isTty = deps.isTty ?? (() => Boolean(process.stdin.isTTY));
10337
+ const serviceOpts = {};
10338
+ const quiet = opts.report === "failures";
10339
+ const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
10340
+ // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
10341
+ ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
10342
+ ...deps.probeClaude ? { probeClaude: deps.probeClaude } : {},
10343
+ ...deps.save ? { save: deps.save } : {},
10344
+ // The child won't log it (the block is written by then), and this process owns
10345
+ // the user's terminal — so the one-time record is one line, here.
10346
+ onMigrated: (_migrated, onPath) => {
10347
+ if (onPath && !quiet) {
10348
+ process.stdout.write(
10349
+ "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
10350
+ );
10351
+ }
10352
+ }
10353
+ });
10354
+ await ensureRuntime(cfg, {
10355
+ probeClaude: async () => claudeOnPath2,
10356
+ // CT1085: the onboarding script already said what's connectable, in its own
10357
+ // words. Saying it again here, on stderr, in the middle of the closing block
10358
+ // would be the same news twice.
10359
+ ...quiet ? { warn: () => {
10360
+ } } : {}
10361
+ });
10362
+ const existing = readState();
10363
+ if (existing) {
10364
+ if (await verify(existing) !== "stale") {
10365
+ const rerendered = isTty() ? refresh(serviceOpts) : false;
10366
+ process.stdout.write(
10367
+ `Cabane Companion is already running (pid ${existing.pid}).
10368
+ Stop it first with \`cabane-companion stop\` if you want to relaunch.
10369
+ Connect a harness to the running companion: cabane-companion connect claude-code
10370
+ ` + (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" : "")
10371
+ );
10372
+ return {
10373
+ started: false,
10374
+ service: { installed: false, reason: "unsupported" },
10375
+ state: existing
10376
+ };
10377
+ }
10378
+ clearRuntimeState();
10379
+ }
10380
+ let service2 = isTty() ? install(serviceOpts) : { installed: false, reason: "unsupported" };
10381
+ const args = ["start", "--foreground"];
10382
+ const launchDetached = () => {
10383
+ const spawned = spawnDetached(args);
10384
+ spawned.unref();
10385
+ return spawned;
10386
+ };
10387
+ const ready = (s) => !!s && !!s.socket;
10388
+ const waitForReady = async () => {
10389
+ const deadline = now() + STARTUP_TIMEOUT_MS;
10390
+ let seen = readState();
10391
+ while (!ready(seen) && now() < deadline) {
10392
+ await sleep4(POLL_INTERVAL_MS);
10393
+ seen = readState();
10394
+ }
10395
+ return ready(seen) ? seen : null;
10396
+ };
10397
+ if (!service2.installed && service2.leftBehind) {
10398
+ refuseDouble(service2.leftBehind, service2.detail);
10399
+ process.exitCode = 1;
10400
+ return { started: false, service: service2, state: null };
10401
+ }
10402
+ let child = service2.installed ? null : launchDetached();
10403
+ let state = await waitForReady();
10404
+ if (!state && service2.installed) {
10405
+ process.stdout.write(
10406
+ `${service2.manager} started the companion but it didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s \u2014 removing the login service.
10407
+ `
10408
+ );
10409
+ const removed = disable();
10410
+ if (!removed.handled || !removed.ok) {
10411
+ refuseDouble(service2.manager, removed.detail);
10412
+ process.exitCode = 1;
10413
+ return { started: false, service: service2, state: null };
10414
+ }
10415
+ process.stdout.write("Launching it directly instead.\n");
10416
+ service2 = { installed: false, reason: "command-failed", detail: "the service never came up" };
10417
+ child = launchDetached();
10418
+ state = await waitForReady();
10419
+ }
10420
+ if (!state) {
10421
+ process.stdout.write(
10422
+ `Cabane Companion was launched (pid ${child?.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
10423
+ Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
10424
+ `
10425
+ );
10426
+ process.exitCode = 1;
10427
+ return { started: false, service: service2, state: null };
10428
+ }
10429
+ if (quiet) return { started: true, service: service2, state };
10430
+ process.stdout.write(
10431
+ `Cabane Companion started in the background (pid ${state.pid}).
10432
+ Logs: ${companionLogPath()}
10433
+ Status: cabane-companion status
10434
+ Stop: cabane-companion stop
10435
+ `
10436
+ );
10437
+ if (service2.installed) {
10438
+ process.stdout.write(`Autostart: enabled (${service2.manager}) \u2014 it starts again at login.
10439
+ `);
10440
+ } else if (service2.reason !== "unsupported") {
10441
+ process.stdout.write(
10442
+ `Autostart: not enabled \u2014 ${service2.detail ?? "the service manager refused the install"}. Running detached instead; it won't come back after a reboot.
10443
+ `
10444
+ );
10445
+ }
10446
+ return { started: true, service: service2, state };
10447
+ }
10448
+ function refuseDouble(manager, detail) {
10449
+ process.stdout.write(
10450
+ `${manager} still has the login service${detail ? ` (${detail})` : ""} \u2014 not launching a second companion beside a service that may still own one.
10451
+ Check \`cabane-companion service status\`, then \`cabane-companion service disable\`, and run \`cabane-companion start --daemon\` again.
10452
+ `
10453
+ );
10454
+ }
10455
+ function defaultSpawnDetached(args) {
10456
+ const cliPath = companionCliEntry();
10457
+ mkdirSync14(cabaneDir(), { recursive: true });
10458
+ const logFd = openSync3(companionLogPath(), "a");
10459
+ try {
10460
+ return spawn5(process.execPath, [cliPath, ...args], {
10461
+ detached: true,
10462
+ stdio: ["ignore", logFd, logFd],
10463
+ env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
10464
+ });
10465
+ } finally {
10466
+ closeSync3(logFd);
10467
+ }
10468
+ }
9942
10469
 
9943
10470
  // src/commands/start.ts
9944
10471
  var FORCE_EXIT_MS = 4e3;
9945
10472
  var DEPLOY_REEXEC_EXIT = 75;
9946
10473
  var DEPLOY_GRACE_MS = 6.5 * 60 * 60 * 1e3;
10474
+ var MAX_CODES = 3;
9947
10475
  async function start(opts = {}) {
10476
+ const interactive = isInteractive();
10477
+ if (!interactive) return startScript(opts, false);
10478
+ setConsoleLogging(false);
10479
+ try {
10480
+ await startScript(opts, true);
10481
+ } finally {
10482
+ setConsoleLogging(true);
10483
+ }
10484
+ }
10485
+ async function startScript(opts, interactive) {
10486
+ const running = await liveCompanion();
10487
+ if (running) {
10488
+ reportAlreadyRunning(running.pid);
10489
+ return;
10490
+ }
10491
+ blank();
10492
+ let paired = null;
10493
+ if (!isDevicePaired()) {
10494
+ paired = await pairHere(opts, interactive);
10495
+ if (!paired) {
10496
+ return;
10497
+ }
10498
+ const { note } = writePairedConfig(paired);
10499
+ if (note) getLogger().warn({ note }, "companion: salvaged an older config on pairing");
10500
+ }
10501
+ const justPaired = paired !== null;
10502
+ const log = getLogger();
9948
10503
  const result = await createCompanionRuntime({
9949
- ...opts.port !== void 0 ? { port: opts.port } : {}
10504
+ // The script says what's connectable in its own words below; a stderr warning
10505
+ // in the middle of it would be the same news, worse.
10506
+ onReadinessWarning: (message) => log.info({ msg: message }, "companion: harness readiness")
9950
10507
  });
9951
10508
  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
- );
10509
+ reportAlreadyRunning(result.existing?.pid);
9959
10510
  return;
9960
10511
  }
9961
10512
  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
- `
10513
+ await runtime.heartbeatNow();
10514
+ const offered = await runConnectorOffer(runtime, { interactive, justPaired });
10515
+ if (interactive && !opts.foreground) {
10516
+ await handOffToBackground(runtime, {
10517
+ justPaired,
10518
+ spaceAbove: justPaired || offered.printedSomething
10519
+ });
10520
+ return;
10521
+ }
10522
+ if (interactive) {
10523
+ blank();
10524
+ write(INDENT + tick("Cabane companion is running in this terminal."));
10525
+ write(` Stop: Ctrl-C Logs: ${tildePath(companionLogPath())}`);
10526
+ blank();
10527
+ write(`${INDENT}Listening for messages\u2026`);
10528
+ setConsoleLogging(true);
10529
+ } else {
10530
+ if (!offered.printedSomething) blank();
10531
+ write(
10532
+ `${INDENT}Cabane companion is running. No terminal attached, so it stays in the foreground.`
9973
10533
  );
10534
+ write(`${INDENT}Listening for messages\u2026`);
9974
10535
  }
9975
- process.stdout.write(`Listening for messages\u2026
9976
- `);
9977
- if (shouldAutoOpen({ flagOpen: opts.open, configAutoOpen: runtime.config.autoOpen })) {
9978
- openBrowser(runtime.url);
10536
+ await runAttached(runtime);
10537
+ }
10538
+ async function liveCompanion() {
10539
+ const state = readLiveRuntimeState();
10540
+ if (!state) return null;
10541
+ if (await verifyRuntime(state) === "stale") {
10542
+ clearRuntimeState();
10543
+ return null;
10544
+ }
10545
+ return state;
10546
+ }
10547
+ function reportAlreadyRunning(pid) {
10548
+ write(`Cabane Companion is already running (pid ${pid ?? "?"}).`);
10549
+ write("Stop it first with `cabane-companion stop` if you want to relaunch.");
10550
+ write("Connect a harness to the running companion: cabane-companion connect claude-code");
10551
+ }
10552
+ async function pairHere(opts, interactive) {
10553
+ const baseUrl = resolvePairBaseUrl(opts.server);
10554
+ const label = deviceLabelFromHostname(hostname2());
10555
+ write(`${INDENT}Not paired yet.`);
10556
+ blank();
10557
+ const aborter = new AbortController();
10558
+ const onSigint = () => aborter.abort();
10559
+ process.on("SIGINT", onSigint);
10560
+ const spinner = new Spinner({ animate: interactive });
10561
+ let minutesWaited = 0;
10562
+ try {
10563
+ for (let attempt = 1; attempt <= MAX_CODES; attempt += 1) {
10564
+ const code = await requestEnrollmentCode(baseUrl, { ...label ? { label } : {} });
10565
+ const minutes = Math.max(1, Math.round(code.expiresIn / 60));
10566
+ if (attempt === 1) printFirstCode(code);
10567
+ else printFreshCode(code);
10568
+ spinner.start(
10569
+ `Waiting for you to confirm it in Cabane\u2026 (the code is good for ${minutes} minutes)`
10570
+ );
10571
+ try {
10572
+ const device = await awaitEnrollment(baseUrl, code, { signal: aborter.signal });
10573
+ spinner.replaceWith(INDENT + tick(`Paired to ${possessive(device.ownerName)} account.`));
10574
+ return device;
10575
+ } catch (err) {
10576
+ if (err instanceof EnrollmentCancelledError) {
10577
+ spinner.clear();
10578
+ blank();
10579
+ write(`${INDENT}Pairing cancelled. Run \`cabane-companion start\` when you're ready.`);
10580
+ return null;
10581
+ }
10582
+ if (!(err instanceof EnrollmentExpiredError)) throw err;
10583
+ minutesWaited += minutes;
10584
+ if (attempt < MAX_CODES) {
10585
+ spinner.replaceWith(`${INDENT}That code expired \u2014 here's a fresh one:`);
10586
+ continue;
10587
+ }
10588
+ spinner.freeze();
10589
+ blank();
10590
+ write(`${INDENT}Still not confirmed after ${minutesWaited} minutes \u2014 stopping here.`);
10591
+ write(`${INDENT}Run \`cabane-companion start\` again when you're ready.`);
10592
+ return null;
10593
+ }
10594
+ }
10595
+ return null;
10596
+ } finally {
10597
+ process.removeListener("SIGINT", onSigint);
10598
+ }
10599
+ }
10600
+ function printFirstCode(code) {
10601
+ write(`${INDENT}Enter this code in Cabane:`);
10602
+ blank();
10603
+ write(`${DATA_INDENT}${code.userCode}`);
10604
+ blank();
10605
+ write(`${INDENT}or open this link:`);
10606
+ blank();
10607
+ write(`${DATA_INDENT}${code.verificationUriComplete}`);
10608
+ blank();
10609
+ }
10610
+ function printFreshCode(code) {
10611
+ blank();
10612
+ write(`${DATA_INDENT}${code.userCode}`);
10613
+ blank();
10614
+ write(`${DATA_INDENT}${code.verificationUriComplete}`);
10615
+ blank();
10616
+ }
10617
+ function possessive(ownerName) {
10618
+ return ownerName ? `${ownerName}'s` : "your Cabane";
10619
+ }
10620
+ async function runConnectorOffer(runtime, ctx) {
10621
+ const snapshot = runtime.harnesses();
10622
+ const detected = OFFER_ORDER.map((r) => snapshot.find((h) => h.runtime === r)).filter(
10623
+ (h) => !!h && h.state === "detected_not_exposed"
10624
+ );
10625
+ if (detected.length === 0) {
10626
+ if (!ctx.justPaired) return { printedSomething: false };
10627
+ blank();
10628
+ write(
10629
+ INDENT + bang(
10630
+ "No coding agent found on this machine yet \u2014 install Claude Code, Codex or opencode and sign in,"
10631
+ )
10632
+ );
10633
+ write(`${INDENT} then: cabane-companion connect claude-code (or codex, or opencode)`);
10634
+ return { printedSomething: true };
10635
+ }
10636
+ blank();
10637
+ if (!ctx.interactive) {
10638
+ for (const h of detected) {
10639
+ write(
10640
+ `${INDENT}${foundPhrase(h, runtime.config)} but it isn't connected. Connect it: ${connectCommand(h.runtime)}`
10641
+ );
10642
+ }
10643
+ return { printedSomething: true };
9979
10644
  }
10645
+ for (const h of detected) {
10646
+ const yes = await confirm(`${foundPhrase(h, runtime.config)}. Connect it to Cabane?`);
10647
+ if (!yes) {
10648
+ write(`${INDENT}Skipped. Connect it later with: ${connectCommand(h.runtime)}`);
10649
+ continue;
10650
+ }
10651
+ const outcome = await runtime.connectHarness(h.runtime);
10652
+ if (!outcome.ok) {
10653
+ write(`${INDENT}${outcome.error}`);
10654
+ continue;
10655
+ }
10656
+ write(INDENT + tick(outcome.message));
10657
+ }
10658
+ return { printedSomething: true };
10659
+ }
10660
+ function foundPhrase(h, cfg) {
10661
+ const label = HARNESS_LABELS[h.runtime];
10662
+ if (h.runtime === "opencode") {
10663
+ const url = cfg.opencode?.serverUrl;
10664
+ return url ? `We found ${label} at ${url}` : `We found ${label} on this machine`;
10665
+ }
10666
+ return h.version ? `We found ${label} on this machine (${h.version})` : `We found ${label} on this machine`;
10667
+ }
10668
+ function connectCommand(runtime) {
10669
+ return `cabane-companion connect ${runtime}`;
10670
+ }
10671
+ async function handOffToBackground(runtime, ctx) {
10672
+ await runtime.stop();
10673
+ const outcome = await startDaemon({ report: "failures" });
10674
+ if (!outcome.started) {
10675
+ return;
10676
+ }
10677
+ if (ctx.spaceAbove) blank();
10678
+ write(
10679
+ INDENT + tick(
10680
+ 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."
10681
+ )
10682
+ );
10683
+ write(` Stop: cabane-companion stop Logs: ${tildePath(companionLogPath())}`);
10684
+ if (ctx.justPaired) {
10685
+ blank();
10686
+ write(INDENT + arrow("Head back to Cabane to finish up."));
10687
+ }
10688
+ }
10689
+ async function runAttached(runtime) {
9980
10690
  await new Promise((resolve) => {
9981
10691
  let shuttingDown = false;
9982
10692
  const shutdown = async (signal) => {
@@ -10038,7 +10748,7 @@ async function status(deps = {}) {
10038
10748
  const cfg = loadConfig();
10039
10749
  if (!cfg || !cfg.deviceToken) {
10040
10750
  process.stdout.write(
10041
- "companion: not paired. Run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
10751
+ "companion: not paired. Run `cabane-companion start` \u2014 it prints a short code you confirm in Cabane, then keeps running.\n"
10042
10752
  );
10043
10753
  process.exitCode = 1;
10044
10754
  return;
@@ -10063,14 +10773,11 @@ async function status(deps = {}) {
10063
10773
  `companion: running in ${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})
10064
10774
  `
10065
10775
  );
10066
- process.stdout.write(`dashboard: ${running.url} (assigned agents + run state live here)
10067
- `);
10068
10776
  process.stdout.write(`stop with: cabane-companion stop
10069
10777
  `);
10070
10778
  } else {
10071
10779
  process.stdout.write(
10072
- `companion: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
10073
- `
10780
+ "companion: not running \u2014 `cabane-companion start` (backgrounds itself; `--foreground` stays attached)\n"
10074
10781
  );
10075
10782
  }
10076
10783
  const svc = readService();
@@ -10081,7 +10788,7 @@ async function status(deps = {}) {
10081
10788
  );
10082
10789
  }
10083
10790
  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"
10791
+ "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
10792
  );
10086
10793
  const names = loadSecretStoreTolerant().names();
10087
10794
  process.stdout.write(
@@ -10203,8 +10910,8 @@ function isAlive(kill, pid) {
10203
10910
  }
10204
10911
 
10205
10912
  // 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";
10913
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
10914
+ import { isAbsolute, join as join19 } from "path";
10208
10915
  async function transcript(opts = {}) {
10209
10916
  const dir2 = transcriptsDir();
10210
10917
  if (opts.follow) {
@@ -10221,7 +10928,7 @@ async function transcript(opts = {}) {
10221
10928
  process.stdout.write(emptyMessage(dir2));
10222
10929
  return;
10223
10930
  }
10224
- process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
10931
+ process.stdout.write(renderFile(join19(dir2, newest)) + "\n");
10225
10932
  return;
10226
10933
  }
10227
10934
  printList(dir2);
@@ -10304,7 +11011,7 @@ function isComplete(content) {
10304
11011
  async function followTranscripts(dir2) {
10305
11012
  const follower = new TranscriptFollower({
10306
11013
  listFiles: () => listFiles(dir2),
10307
- read: (f) => readFileSync11(join18(dir2, f), "utf8"),
11014
+ read: (f) => readFileSync11(join19(dir2, f), "utf8"),
10308
11015
  write: (s) => process.stdout.write(s),
10309
11016
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
10310
11017
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -10344,7 +11051,7 @@ function printList(dir2) {
10344
11051
 
10345
11052
  `);
10346
11053
  for (const f of files.slice(0, 20)) {
10347
- const { meta, outcome } = peek(join18(dir2, f));
11054
+ const { meta, outcome } = peek(join19(dir2, f));
10348
11055
  const when = fmtTime(rec(meta)?.ts);
10349
11056
  const ws = str2(rec(meta)?.workspaceSlug);
10350
11057
  const o = rec(outcome);
@@ -10378,13 +11085,13 @@ function peek(path) {
10378
11085
  }
10379
11086
  function resolveTarget(dir2, target2) {
10380
11087
  if (isAbsolute(target2) || target2.includes("/")) {
10381
- if (existsSync14(target2)) return target2;
11088
+ if (existsSync15(target2)) return target2;
10382
11089
  throw new CompanionError(`no transcript at ${target2}.`);
10383
11090
  }
10384
- const exact = join18(dir2, target2);
10385
- if (existsSync14(exact)) return exact;
11091
+ const exact = join19(dir2, target2);
11092
+ if (existsSync15(exact)) return exact;
10386
11093
  const matches = listFiles(dir2).filter((f) => f.includes(target2));
10387
- if (matches.length === 1) return join18(dir2, matches[0]);
11094
+ if (matches.length === 1) return join19(dir2, matches[0]);
10388
11095
  if (matches.length === 0) {
10389
11096
  throw new CompanionError(
10390
11097
  `no transcript matching "${target2}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
@@ -10528,7 +11235,7 @@ program.name("cabane-companion").description(
10528
11235
  "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
11236
  ).version(COMPANION_VERSION);
10530
11237
  program.command("pair").description(
10531
- "pair this device with cabane \u2014 shows a short code you confirm in Settings \u2192 Devices."
11238
+ "pair this device with cabane \u2014 shows a short code you confirm in Cabane. `start` does this for you."
10532
11239
  ).allowExcessArguments(false).option("--server <url>", "the cabane instance to pair with (default https://app.cabane.ai)").action(async (opts) => {
10533
11240
  await pair({
10534
11241
  ...opts.server !== void 0 ? { server: opts.server } : {}
@@ -10537,22 +11244,17 @@ program.command("pair").description(
10537
11244
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
10538
11245
  writeCompletedPairing(readFileSync12(0, "utf8"));
10539
11246
  });
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
- );
11247
+ program.command("start").description(
11248
+ "pair this device if needed, connect a coding agent on it, and run in the background."
11249
+ ).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) => {
11250
+ await start({
11251
+ ...opts.foreground ? { foreground: true } : {},
11252
+ ...opts.server !== void 0 ? { server: opts.server } : {}
11253
+ });
11254
+ });
11255
+ 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) => {
11256
+ await connect2(harness, { ...opts.url !== void 0 ? { serverUrl: opts.url } : {} });
11257
+ });
10556
11258
  program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
10557
11259
  await stop();
10558
11260
  });
@@ -10584,13 +11286,6 @@ program.command("logout").description(
10584
11286
  ...opts.purge ? { purge: true } : {}
10585
11287
  });
10586
11288
  });
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
11289
  program.parseAsync(process.argv).catch((err) => {
10595
11290
  if (err instanceof CompanionError) {
10596
11291
  process.stderr.write(`error: ${err.message}
@@ -10603,7 +11298,12 @@ program.parseAsync(process.argv).catch((err) => {
10603
11298
  process.exitCode = 130;
10604
11299
  return;
10605
11300
  }
10606
- process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}
11301
+ setConsoleLogging(false);
11302
+ try {
11303
+ getLogger().error({ err }, "companion: command failed");
11304
+ } catch {
11305
+ }
11306
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
10607
11307
  `);
10608
11308
  process.exitCode = 1;
10609
11309
  });