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