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