@cabane/companion 0.6.49 → 0.6.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +251 -190
  2. package/dist/runtime.js +1142 -1091
  3. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -434,1158 +434,1178 @@ import { readFile } from "fs/promises";
434
434
  import { extname, join as join3, normalize } from "path";
435
435
  import { streamSSE } from "hono/streaming";
436
436
 
437
- // src/logger.ts
438
- import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
439
- import { dirname as dirname2, join as join2 } from "path";
440
- import pino from "pino";
441
- import pretty from "pino-pretty";
442
- function companionLogPath() {
443
- return join2(cabaneDir(), "companion.log");
444
- }
445
- var CONSOLE_IGNORE = [
446
- "pid",
447
- "hostname",
448
- "workspaceId",
449
- "conversationId",
450
- "agentId",
451
- "messageId",
452
- "sessionId",
453
- "companionId"
454
- ].join(",");
455
- function consoleShortId(log) {
456
- const id = log.conversationId ?? log.workspaceId;
457
- return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
458
- }
459
- function consoleMessageFormat(log, messageKey) {
460
- const short = consoleShortId(log);
461
- const msg = String(log[messageKey] ?? "");
462
- return short ? `${short} ${msg}` : msg;
437
+ // src/harness-check.ts
438
+ import { spawn as spawn3 } from "child_process";
439
+
440
+ // src/harness-versions.ts
441
+ import { spawn as spawn2 } from "child_process";
442
+ var EMPTY = { claudeCode: null, opencode: null, codex: null };
443
+ var CLI_PROBE_TIMEOUT_MS = 4e3;
444
+ function parseVersionToken(raw) {
445
+ if (!raw) return null;
446
+ const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
447
+ return m ? m[0] : null;
463
448
  }
464
- var cached = null;
465
- var consoleLogging = true;
466
- function createLogger(destinations = {}) {
467
- const path = companionLogPath();
468
- if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
469
- const streams = [];
470
- if (process.env.CABANE_COMPANION_DAEMON !== "1") {
471
- const consoleStream = pretty({
472
- colorize: true,
473
- ignore: CONSOLE_IGNORE,
474
- messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
475
- ...destinations.console ? { destination: destinations.console } : {}
476
- });
477
- streams.push({
478
- level: "info",
479
- stream: {
480
- write(chunk) {
481
- if (consoleLogging) consoleStream.write(chunk);
482
- }
449
+ function probeCliPresence(command, spawnImpl = spawn2) {
450
+ return new Promise((resolve) => {
451
+ let settled = false;
452
+ let timer = null;
453
+ const done = (result) => {
454
+ if (!settled) {
455
+ settled = true;
456
+ if (timer) clearTimeout(timer);
457
+ resolve(result);
483
458
  }
459
+ };
460
+ let child;
461
+ try {
462
+ child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
463
+ } catch {
464
+ done({ status: "absent" });
465
+ return;
466
+ }
467
+ let out = "";
468
+ child.stdout?.on("data", (chunk) => {
469
+ if (out.length < 4096) out += chunk.toString();
470
+ });
471
+ timer = setTimeout(() => {
472
+ child.kill?.("SIGKILL");
473
+ done({ status: "unusable" });
474
+ }, CLI_PROBE_TIMEOUT_MS);
475
+ timer.unref?.();
476
+ child.once("error", () => done({ status: "absent" }));
477
+ child.once("exit", (code) => {
478
+ const version = code === 0 ? parseVersionToken(out) : null;
479
+ done(version ? { status: "present", version } : { status: "unusable" });
484
480
  });
485
- }
486
- streams.push({
487
- level: "debug",
488
- stream: destinations.file ?? createWriteStream(path, { flags: "a" })
489
481
  });
490
- return pino({ level: "debug" }, pino.multistream(streams));
491
- }
492
- function getLogger() {
493
- if (cached) return cached;
494
- cached = createLogger();
495
- return cached;
496
482
  }
497
-
498
- // src/state.ts
499
- import { EventEmitter } from "events";
500
- function dispatchToWire(r) {
501
- return {
502
- id: r.id,
503
- timestamp: r.timestamp,
504
- workspace_slug: r.workspaceSlug,
505
- ...r.agentUsername ? { agent_username: r.agentUsername } : {},
506
- message: r.message,
507
- status: r.status,
508
- ...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
509
- ...r.error ? { error: r.error } : {},
510
- ...r.reply ? { reply: r.reply } : {}
511
- };
483
+ function probeHarnessPresence(runtime, spawnImpl = spawn2) {
484
+ return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
512
485
  }
513
- var MAX_DISPATCHES = 50;
514
- function today() {
515
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
486
+ async function probeCliVersion(command, spawnImpl = spawn2) {
487
+ const result = await probeCliPresence(command, spawnImpl);
488
+ return result.status === "present" ? result.version : null;
516
489
  }
517
- var CompanionStateHub = class {
518
- constructor(opts) {
519
- this.opts = opts;
520
- this.emitter.setMaxListeners(0);
490
+ async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
491
+ try {
492
+ const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
493
+ const res = await fetchImpl(`${base}/global/health`, {
494
+ headers: { accept: "application/json" }
495
+ });
496
+ if (!res.ok) return null;
497
+ return extractOpencodeVersion(await res.json());
498
+ } catch {
499
+ return null;
521
500
  }
522
- opts;
523
- emitter = new EventEmitter();
524
- workspaces = /* @__PURE__ */ new Map();
525
- dispatches = [];
526
- dashboardUrl = null;
527
- deviceId = null;
528
- deviceLabel = null;
529
- deviceError = null;
530
- // CT586: the latest harness snapshot the supervisor probed, or null before the
531
- // first probe (see CompanionStatusJson.harnesses).
532
- harnesses = null;
533
- // ---- subscription (SSE) ----
534
- on(listener) {
535
- this.emitter.on("event", listener);
536
- return () => this.emitter.off("event", listener);
501
+ }
502
+ function extractOpencodeVersion(json) {
503
+ if (!json || typeof json !== "object") return null;
504
+ const obj = json;
505
+ const candidates = [obj.version];
506
+ for (const v of Object.values(obj)) {
507
+ if (v && typeof v === "object") candidates.push(v.version);
537
508
  }
538
- emit(type, data) {
539
- this.emitter.emit("event", { type, data });
509
+ for (const c of candidates) {
510
+ if (typeof c === "string") {
511
+ const parsed = parseVersionToken(c);
512
+ if (parsed) return parsed;
513
+ }
540
514
  }
541
- // ---- device ----
542
- setDevice(info) {
543
- if (info.deviceId !== void 0) this.deviceId = info.deviceId;
544
- if (info.deviceLabel !== void 0) this.deviceLabel = info.deviceLabel;
545
- this.emitStatus();
515
+ return null;
516
+ }
517
+ async function probeHarnessVersions(opts, deps = {}) {
518
+ const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
519
+ const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
520
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
521
+ const [claudeCode, codex, opencode] = await Promise.all([
522
+ opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
523
+ opts.codex ? safe(probeCodex) : Promise.resolve(null),
524
+ opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
525
+ ]);
526
+ return { claudeCode, opencode, codex };
527
+ }
528
+ function emptyHarnessVersions() {
529
+ return { ...EMPTY };
530
+ }
531
+ async function safe(fn) {
532
+ try {
533
+ return await fn();
534
+ } catch {
535
+ return null;
546
536
  }
547
- setDeviceError(message) {
548
- if (this.deviceError === message) return;
549
- this.deviceError = message;
550
- this.emitStatus();
537
+ }
538
+
539
+ // src/manifest.ts
540
+ var DEVICE_MANIFEST = {
541
+ runtimes: [{ name: "claude-code", version: null }],
542
+ capabilities: { hostFs: true, browser: true, userMcp: true }
543
+ };
544
+ function buildCompanionManifest(opts) {
545
+ const v = opts.versions ?? {};
546
+ const runtimes = [];
547
+ if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
548
+ if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
549
+ if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
550
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
551
+ }
552
+
553
+ // src/harness-status.ts
554
+ var HARNESS_LABELS = {
555
+ "claude-code": "Claude Code",
556
+ codex: "Codex",
557
+ opencode: "OpenCode"
558
+ };
559
+ var LABELS = HARNESS_LABELS;
560
+ var DEFAULT_OPENCODE_SERVER_URL = "http://127.0.0.1:4096";
561
+ function deriveHarnessSnapshot(signals) {
562
+ const advertised = new Set(
563
+ buildCompanionManifest({
564
+ // CT1082: connected AND installed — the manifest's own rule, restated here
565
+ // through the same function rather than re-decided.
566
+ claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
567
+ opencode: signals.opencodeConfigured,
568
+ codex: signals.codexEnabled
569
+ }).runtimes.map((r) => r.name)
570
+ );
571
+ const harnesses = [
572
+ deriveClaudeCode(signals, advertised.has("claude-code")),
573
+ deriveCodex(signals, advertised.has("codex")),
574
+ deriveOpencode(signals, advertised.has("opencode"))
575
+ ];
576
+ return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
577
+ }
578
+ function deriveClaudeCode(signals, manifestHas) {
579
+ const base = { runtime: "claude-code", label: LABELS["claude-code"] };
580
+ if (manifestHas) {
581
+ return {
582
+ ...base,
583
+ state: "exposed",
584
+ version: signals.claudeVersion,
585
+ detail: "Claude Code is connected and exposed to Cabane.",
586
+ enable: null
587
+ };
551
588
  }
552
- clearDeviceError() {
553
- if (this.deviceError === null) return;
554
- this.deviceError = null;
555
- this.emitStatus();
589
+ if (signals.claudeCodeConnected) {
590
+ return {
591
+ ...base,
592
+ state: "needs_attention",
593
+ version: null,
594
+ 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.",
595
+ enable: null
596
+ };
556
597
  }
557
- // ---- harnesses (CT586) ----
558
- // Replace the harness snapshot and push a status change (so the SSE feed flips
559
- // the Companion's Harnesses surface live). The supervisor calls this on each
560
- // heartbeat-cadence probe and on an on-demand recheck.
561
- setHarnesses(harnesses) {
562
- this.harnesses = harnesses;
563
- this.emitStatus();
598
+ if (signals.claudeOnPath) {
599
+ return {
600
+ ...base,
601
+ state: "detected_not_exposed",
602
+ version: signals.claudeVersion,
603
+ detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
604
+ enable: "claude-code"
605
+ };
564
606
  }
565
- // ---- workspace lifecycle ----
566
- registerWorkspace(ws) {
567
- const existing = this.workspaces.get(ws.workspaceId);
568
- if (existing) {
569
- existing.slug = ws.slug;
570
- existing.name = ws.name;
571
- return;
607
+ return {
608
+ ...base,
609
+ state: "not_detected",
610
+ version: null,
611
+ detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
612
+ enable: null
613
+ };
614
+ }
615
+ function deriveCodex(signals, manifestHas) {
616
+ const base = { runtime: "codex", label: LABELS.codex };
617
+ if (manifestHas) {
618
+ if (signals.codexOnPath) {
619
+ return {
620
+ ...base,
621
+ state: "exposed",
622
+ version: signals.codexVersion,
623
+ detail: "Codex is enabled and exposed to Cabane.",
624
+ enable: null
625
+ };
572
626
  }
573
- this.workspaces.set(ws.workspaceId, {
574
- workspaceId: ws.workspaceId,
575
- slug: ws.slug,
576
- name: ws.name,
577
- connected: false,
578
- authFailed: false,
579
- lastEventAt: null,
580
- dispatchCountToday: 0,
581
- countDate: today(),
582
- agents: /* @__PURE__ */ new Map()
583
- });
584
- this.emitStatus();
585
- }
586
- removeWorkspace(workspaceId) {
587
- const ws = this.workspaces.get(workspaceId);
588
- if (!ws) return;
589
- this.workspaces.delete(workspaceId);
590
- this.emit("workspace:disconnected", { slug: ws.slug });
591
- this.emitStatus();
592
- }
593
- setConnected(workspaceId, connected) {
594
- const ws = this.workspaces.get(workspaceId);
595
- if (!ws || ws.connected === connected) return;
596
- ws.connected = connected;
597
- if (connected) ws.authFailed = false;
598
- this.emitStatus();
599
- }
600
- setAuthFailed(workspaceId) {
601
- const ws = this.workspaces.get(workspaceId);
602
- if (!ws) return;
603
- if (ws.authFailed && !ws.connected) return;
604
- ws.authFailed = true;
605
- ws.connected = false;
606
- this.emitStatus();
607
- }
608
- recordEvent(workspaceId) {
609
- const ws = this.workspaces.get(workspaceId);
610
- if (!ws) return;
611
- ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
612
- }
613
- // ---- agents ----
614
- // Upsert one assigned agent's observable state (called on each reconcile).
615
- setAgent(workspaceId, agent) {
616
- const ws = this.workspaces.get(workspaceId);
617
- if (!ws) return;
618
- ws.agents.set(agent.agentId, agent);
619
- this.emitStatus();
620
- }
621
- removeAgent(workspaceId, agentId) {
622
- const ws = this.workspaces.get(workspaceId);
623
- if (!ws) return;
624
- if (ws.agents.delete(agentId)) this.emitStatus();
625
- }
626
- // ---- dispatch feed ----
627
- // A DispatchObserver bound to one (workspace, agent), handed to that agent's
628
- // Dispatcher by the supervisor.
629
- observerFor(workspaceId, agentId, slug) {
630
- const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
631
627
  return {
632
- onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
633
- onEnd: (info) => this.dispatchEnded(slug, username(), info)
634
- };
635
- }
636
- bumpCount(workspaceId) {
637
- const ws = this.workspaces.get(workspaceId);
638
- if (!ws) return;
639
- const day = today();
640
- if (ws.countDate !== day) {
641
- ws.countDate = day;
642
- ws.dispatchCountToday = 0;
643
- }
644
- ws.dispatchCountToday += 1;
645
- }
646
- dispatchStarted(workspaceId, slug, agentUsername, info) {
647
- this.bumpCount(workspaceId);
648
- const record = {
649
- id: info.id,
650
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
651
- workspaceSlug: slug,
652
- ...agentUsername ? { agentUsername } : {},
653
- message: info.message,
654
- status: "running"
628
+ ...base,
629
+ state: "needs_attention",
630
+ version: null,
631
+ detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
632
+ enable: null
655
633
  };
656
- this.upsertDispatch(record);
657
- this.emit("dispatch:started", dispatchToWire(record));
658
- this.emitStatus();
659
634
  }
660
- dispatchEnded(slug, agentUsername, info) {
661
- const existing = this.dispatches.find((d) => d.id === info.id);
662
- const record = {
663
- id: info.id,
664
- timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
665
- workspaceSlug: existing?.workspaceSlug ?? slug,
666
- ...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
667
- message: existing?.message ?? "",
668
- status: info.ok ? "replied" : "error",
669
- durationMs: info.durationMs,
670
- ...info.reason ? { error: info.reason } : {},
671
- ...info.reply ? { reply: info.reply } : {}
635
+ if (signals.codexOnPath) {
636
+ return {
637
+ ...base,
638
+ state: "detected_not_exposed",
639
+ version: signals.codexVersion,
640
+ detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
641
+ enable: "codex"
672
642
  };
673
- this.upsertDispatch(record);
674
- this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
675
643
  }
676
- upsertDispatch(record) {
677
- const idx = this.dispatches.findIndex((d) => d.id === record.id);
678
- if (idx >= 0) {
679
- this.dispatches[idx] = record;
680
- return;
644
+ return {
645
+ ...base,
646
+ state: "not_detected",
647
+ version: null,
648
+ detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
649
+ enable: null
650
+ };
651
+ }
652
+ function deriveOpencode(signals, manifestHas) {
653
+ const base = { runtime: "opencode", label: LABELS.opencode };
654
+ if (manifestHas) {
655
+ if (signals.opencodeReachable) {
656
+ return {
657
+ ...base,
658
+ state: "exposed",
659
+ version: signals.opencodeVersion,
660
+ detail: "An opencode server is reachable and exposed to Cabane.",
661
+ enable: null
662
+ };
681
663
  }
682
- this.dispatches.unshift(record);
683
- if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
684
- }
685
- // ---- snapshots for the HTTP routes ----
686
- setDashboardUrl(url) {
687
- this.dashboardUrl = url;
688
- }
689
- recentDispatches(limit = 20) {
690
- return this.dispatches.slice(0, Math.max(0, limit));
691
- }
692
- statusJson() {
693
- const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
694
- const lastOverall = list.reduce((acc, ws) => {
695
- if (!ws.lastEventAt) return acc;
696
- if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
697
- return acc;
698
- }, null);
699
664
  return {
700
- cabane_url: this.opts.baseUrl,
701
- device_id: this.deviceId,
702
- device_label: this.deviceLabel,
703
- device_error: this.deviceError,
704
- connected: list.some((ws) => ws.connected),
705
- workspaces: list.map((ws) => ({
706
- slug: ws.slug,
707
- name: ws.name,
708
- last_event_at: ws.lastEventAt,
709
- dispatch_count_today: ws.dispatchCountToday,
710
- connected: ws.connected,
711
- auth_failed: ws.authFailed,
712
- agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
713
- agent_id: a.agentId,
714
- username: a.username,
715
- display_name: a.displayName,
716
- mode: a.mode,
717
- has_credential: a.hasCredential,
718
- missing_secrets: a.missingSecrets
719
- }))
720
- })),
721
- last_event_at_overall: lastOverall,
722
- dashboard_url: this.dashboardUrl,
723
- companion_version: this.opts.companionVersion,
724
- instance_id: this.opts.instanceId ?? null,
725
- harnesses: this.harnesses
665
+ ...base,
666
+ state: "needs_attention",
667
+ version: null,
668
+ detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
669
+ enable: null
726
670
  };
727
671
  }
728
- emitStatus() {
729
- this.emit("status:changed", this.statusJson());
672
+ if (signals.opencodeReachable && signals.opencodeDetectedUrl) {
673
+ return {
674
+ ...base,
675
+ state: "detected_not_exposed",
676
+ version: signals.opencodeVersion,
677
+ detail: signals.opencodeDetectedUrl,
678
+ enable: "opencode"
679
+ };
730
680
  }
731
- };
732
-
733
- // src/dashboard/routes.ts
734
- var CONTENT_TYPES = {
735
- ".html": "text/html; charset=utf-8",
736
- ".css": "text/css; charset=utf-8",
737
- ".js": "text/javascript; charset=utf-8",
738
- ".svg": "image/svg+xml",
739
- ".ico": "image/x-icon"
740
- };
741
- function registerRoutes(app, deps) {
742
- const { supervisor, hub, staticDir } = deps;
743
- app.get("/", async (c) => {
744
- const html = await readFile(join3(staticDir, "index.html"), "utf8");
745
- return c.html(html);
746
- });
747
- app.get("/static/:file", async (c) => {
748
- const file = c.req.param("file");
749
- const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
750
- const full = join3(staticDir, safe3);
751
- if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
752
- const body = await readFile(full);
753
- const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
754
- c.header("content-type", type);
755
- return c.body(body);
756
- });
757
- app.get("/api/status", (c) => c.json(hub.statusJson()));
758
- app.get("/api/dispatches", (c) => {
759
- const limit = clampLimit(c.req.query("limit"), 20);
760
- return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
761
- });
762
- app.get("/api/logs", async (c) => {
763
- const lines = clampLimit(c.req.query("lines"), 200, 1e3);
764
- return c.json({ lines: tailFile(companionLogPath(), lines) });
765
- });
766
- app.post("/api/settings", async (c) => {
767
- const body = await readJson(c);
768
- const patch = {};
769
- if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
770
- patch.logLevel = body.logLevel;
771
- }
772
- if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
773
- supervisor.applySettings(patch);
774
- return c.json({ ok: true, status: hub.statusJson() });
775
- });
776
- app.post("/api/control", async (c) => {
777
- const body = await readJson(c);
778
- const action = body.action;
779
- if (action === "stop") {
780
- setTimeout(() => void supervisor.requestStop(), 50);
781
- return c.json({ ok: true, action: "stop" });
782
- }
783
- if (action === "restart") {
784
- setTimeout(() => supervisor.requestRestart(), 50);
785
- return c.json({ ok: true, action: "restart" });
786
- }
787
- if (action === "reload-config") {
788
- supervisor.reloadConfig();
789
- return c.json({ ok: true, action: "reload-config" });
790
- }
791
- if (action === "refresh") {
792
- supervisor.refresh();
793
- return c.json({ ok: true, action: "refresh" });
794
- }
795
- return c.json({ error: `unknown action "${String(action)}"` }, 400);
796
- });
797
- app.post("/api/harnesses/recheck", async (c) => {
798
- await supervisor.recheckHarnesses();
799
- return c.json({ ok: true, status: hub.statusJson() });
800
- });
801
- app.post("/api/harnesses/enable", async (c) => {
802
- const body = await readJson(c);
803
- const runtime = body.runtime;
804
- if (runtime === "claude-code") {
805
- const result = await supervisor.enableHarness({ runtime: "claude-code" });
806
- if (!result.ok) return c.json({ error: result.error }, 400);
807
- return c.json({ ok: true, status: hub.statusJson() });
808
- }
809
- if (runtime === "codex") {
810
- const result = await supervisor.enableHarness({ runtime: "codex" });
811
- if (!result.ok) return c.json({ error: result.error }, 400);
812
- return c.json({ ok: true, status: hub.statusJson() });
813
- }
814
- if (runtime === "opencode") {
815
- if (typeof body.serverUrl !== "string" || body.serverUrl.trim() === "") {
816
- return c.json({ error: "An opencode server URL is required." }, 400);
817
- }
818
- const result = await supervisor.enableHarness({
819
- runtime: "opencode",
820
- serverUrl: body.serverUrl
821
- });
822
- if (!result.ok) return c.json({ error: result.error }, 400);
823
- return c.json({ ok: true, status: hub.statusJson() });
824
- }
825
- return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
826
- });
827
- app.get(
828
- "/api/events",
829
- (c) => streamSSE(c, async (stream) => {
830
- await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
831
- const queue = [];
832
- let wake = null;
833
- const unsubscribe = hub.on((ev) => {
834
- queue.push(ev);
835
- wake?.();
836
- });
837
- stream.onAbort(() => {
838
- unsubscribe();
839
- wake?.();
840
- });
841
- while (!stream.aborted) {
842
- if (queue.length === 0) {
843
- await Promise.race([
844
- new Promise((resolve) => {
845
- wake = resolve;
846
- }),
847
- stream.sleep(25e3)
848
- ]);
849
- wake = null;
850
- }
851
- if (stream.aborted) break;
852
- if (queue.length === 0) {
853
- await stream.writeSSE({ event: "ping", data: "" });
854
- continue;
855
- }
856
- const ev = queue.shift();
857
- await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
858
- }
859
- unsubscribe();
860
- })
861
- );
862
- }
863
- async function readJson(c) {
864
- try {
865
- const parsed = await c.req.json();
866
- return parsed && typeof parsed === "object" ? parsed : {};
867
- } catch {
868
- return {};
869
- }
870
- }
871
- function clampLimit(raw, fallback, max = 200) {
872
- if (!raw) return fallback;
873
- const n = Number(raw);
874
- if (!Number.isFinite(n) || n <= 0) return fallback;
875
- return Math.min(Math.floor(n), max);
681
+ return {
682
+ ...base,
683
+ state: "not_detected",
684
+ version: null,
685
+ detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
686
+ enable: "opencode"
687
+ };
876
688
  }
877
- function tailFile(path, lines) {
878
- if (!existsSync2(path)) return [];
879
- const MAX_BYTES = 256 * 1024;
880
- let fd;
881
- try {
882
- fd = openSync(path, "r");
883
- const size = fstatSync(fd).size;
884
- const start = Math.max(0, size - MAX_BYTES);
885
- const len = size - start;
886
- if (len <= 0) return [];
887
- const buf = Buffer.alloc(len);
888
- readSync(fd, buf, 0, len, start);
889
- const text = buf.toString("utf8");
890
- const all = text.split("\n");
891
- if (start > 0 && all.length > 0) all.shift();
892
- return all.filter((l) => l.length > 0).slice(-lines);
893
- } catch {
894
- return [];
895
- } finally {
896
- if (fd !== void 0) closeSync(fd);
897
- }
689
+ var PROBE_TIMEOUT_MS = 4e3;
690
+ var DEFAULT_OPENCODE_PROBE_TIMEOUT_MS = 1e3;
691
+ function probeDefaultOpencodeVersion(probeOpencode = (url) => probeOpencodeVersion(url)) {
692
+ return withTimeout(
693
+ probeOpencode(DEFAULT_OPENCODE_SERVER_URL),
694
+ null,
695
+ DEFAULT_OPENCODE_PROBE_TIMEOUT_MS
696
+ );
898
697
  }
899
-
900
- // src/dashboard/server.ts
901
- var DEFAULT_PORT = 7474;
902
- var PORT_FALLBACK_SPAN = 10;
903
- function buildDashboardApp(deps) {
904
- const app = new Hono();
905
- app.onError((err, c) => {
906
- if (err instanceof ApiError) {
907
- const status = err.status >= 400 && err.status < 600 ? err.status : 502;
908
- return c.json({ error: err.message }, status);
909
- }
910
- if (err instanceof CompanionError) {
911
- return c.json({ error: err.message }, 400);
912
- }
913
- return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
914
- });
915
- registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
916
- return app;
698
+ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl, probeDefault = probeDefaultOpencodeVersion) {
699
+ const requested = requestedServerUrl?.trim();
700
+ if (requested) return requested;
701
+ if (configuredServerUrl) return configuredServerUrl;
702
+ return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
917
703
  }
918
- async function startDashboard(opts) {
919
- const app = buildDashboardApp({ supervisor: opts.supervisor, hub: opts.hub });
920
- const preferred = opts.port ?? DEFAULT_PORT;
921
- let lastErr;
922
- for (let port = preferred; port < preferred + PORT_FALLBACK_SPAN; port++) {
923
- try {
924
- const server = await listen(app, port);
925
- const url = `http://127.0.0.1:${port}`;
926
- return {
927
- url,
928
- port,
929
- close: () => new Promise((resolve) => {
930
- server.close(() => resolve());
931
- server.closeAllConnections?.();
932
- })
933
- };
934
- } catch (err) {
935
- if (isAddrInUse(err)) {
936
- lastErr = err;
937
- continue;
938
- }
939
- throw err;
940
- }
941
- }
942
- throw new CompanionError(
943
- `couldn't bind the dashboard to any port in ${preferred}\u2013${preferred + PORT_FALLBACK_SPAN - 1} (all in use). Free one up or pass --port. (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
944
- );
704
+ async function probeHarnessSignals(cfg, deps = {}) {
705
+ const probePresence = deps.probePresence ?? probeHarnessPresence;
706
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
707
+ const configuredServerUrl = cfg.opencode?.serverUrl;
708
+ const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
709
+ const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
710
+ deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
711
+ deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
712
+ configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
713
+ ]);
714
+ const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
715
+ const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
716
+ return {
717
+ claudeOnPath: claudePresence.status === "present",
718
+ claudeVersion,
719
+ // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
720
+ // the manifest gate and the probe above is only a suggestion.
721
+ claudeCodeConnected: isClaudeCodeConnected(cfg),
722
+ // A parseable `codex --version` is our presence signal (presence alone never
723
+ // exposes codex; its config flag is the manifest gate either way).
724
+ codexOnPath: codexVersion !== null,
725
+ codexVersion,
726
+ codexEnabled: isCodexEnabled(cfg),
727
+ opencodeConfigured: !!configuredServerUrl,
728
+ // A version came back ⟺ the serve answered its health endpoint (CT584).
729
+ opencodeReachable: opencodeVersion !== null,
730
+ opencodeVersion,
731
+ opencodeDetectedUrl: opencodeVersion !== null ? opencodeProbeUrl : null
732
+ };
945
733
  }
946
- function listen(app, port) {
947
- return new Promise((resolve, reject) => {
734
+ function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
735
+ return new Promise((resolve) => {
948
736
  let settled = false;
949
- const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
737
+ const done = (v) => {
950
738
  if (!settled) {
951
739
  settled = true;
952
- resolve(server);
740
+ resolve(v);
953
741
  }
954
- });
955
- server.on("error", (err) => {
956
- if (!settled) {
957
- settled = true;
958
- reject(err);
742
+ };
743
+ const timer = setTimeout(() => done(fallback), timeoutMs);
744
+ timer.unref?.();
745
+ promise.then(
746
+ (v) => {
747
+ clearTimeout(timer);
748
+ done(v);
749
+ },
750
+ () => {
751
+ clearTimeout(timer);
752
+ done(fallback);
959
753
  }
960
- });
754
+ );
961
755
  });
962
756
  }
963
- function isAddrInUse(err) {
964
- return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
965
- }
966
- function resolveStaticDir() {
967
- return join4(dirname3(fileURLToPath(import.meta.url)), "static");
968
- }
969
757
 
970
- // src/control-socket.ts
971
- import { createHash } from "crypto";
972
- import { existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
973
- import { createServer, connect } from "net";
974
- import { join as join5 } from "path";
975
- var CONTROL_TIMEOUT_MS = 1e3;
976
- function controlSocketPath() {
977
- const dir2 = cabaneDir();
978
- if (process.platform === "win32") {
979
- const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
980
- return `\\\\.\\pipe\\cabane-companion-${key}`;
981
- }
982
- return join5(dir2, "companion.sock");
983
- }
984
- async function startControlServer(handlers) {
985
- const path = controlSocketPath();
986
- mkdirSync3(cabaneDir(), { recursive: true });
987
- if (process.platform !== "win32" && existsSync3(path)) {
988
- const alive = await ping(path);
989
- if (alive) throw new Error(`another companion is already listening on ${path}`);
990
- rmSync2(path, { force: true });
991
- }
992
- const server = createServer((socket) => {
993
- void serveConnection(socket, handlers);
994
- });
995
- server.unref();
996
- await new Promise((resolve, reject) => {
997
- server.once("error", reject);
998
- server.listen(path, () => {
999
- server.removeListener("error", reject);
1000
- resolve();
1001
- });
1002
- });
1003
- server.on("error", () => {
1004
- });
1005
- return {
1006
- path,
1007
- close: () => new Promise((resolve) => {
1008
- server.close(() => {
1009
- if (process.platform !== "win32") rmSync2(path, { force: true });
1010
- resolve();
1011
- });
1012
- })
1013
- };
1014
- }
1015
- async function serveConnection(socket, handlers) {
1016
- socket.on("error", () => socket.destroy());
1017
- const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1018
- if (line === null) {
1019
- socket.destroy();
1020
- return;
1021
- }
1022
- let req;
1023
- try {
1024
- req = JSON.parse(line);
1025
- } catch {
1026
- reply(socket, { error: "malformed request" });
1027
- return;
1028
- }
758
+ // src/harness-check.ts
759
+ var CHECK_TIMEOUT_MS = 4e3;
760
+ async function shakeOutHarness(runtime, cfg, deps = {}) {
761
+ const run = deps.run ?? runBounded;
762
+ const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1029
763
  try {
1030
- if (req.cmd === "status") {
1031
- reply(socket, handlers.status());
1032
- return;
1033
- }
1034
- if (req.cmd === "connect") {
1035
- const result = await handlers.connect(req.runtime, req.serverUrl);
1036
- reply(socket, result);
1037
- return;
764
+ if (runtime === "opencode") {
765
+ const url = cfg.opencode?.serverUrl;
766
+ if (!url) return "absent";
767
+ return await probeOpencode(url) !== null ? "ok" : "failed";
1038
768
  }
1039
- if (req.cmd === "stop") {
1040
- reply(socket, { ok: true });
1041
- setTimeout(() => handlers.stop(), 50).unref?.();
1042
- return;
769
+ const { auth } = runtime === "codex" ? {
770
+ auth: ["codex", ["login", "status"]]
771
+ } : {
772
+ auth: ["claude", ["auth", "status"]]
773
+ };
774
+ const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
775
+ if (presence.status !== "present") return presence.status;
776
+ const authRun = await run(auth[0], [...auth[1]]);
777
+ if (authRun.code === 0) return "ok";
778
+ if (looksUnsupported(authRun.output)) {
779
+ return "unverified";
1043
780
  }
1044
- reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1045
- } catch (err) {
1046
- reply(socket, { error: err instanceof Error ? err.message : String(err) });
1047
- }
1048
- }
1049
- function reply(socket, body) {
1050
- try {
1051
- socket.end(`${JSON.stringify(body)}
1052
- `);
781
+ return "failed";
1053
782
  } catch {
1054
- socket.destroy();
783
+ return "unverified";
1055
784
  }
1056
785
  }
1057
- async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1058
- const socket = connect(path);
1059
- try {
1060
- await new Promise((resolve, reject) => {
1061
- const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1062
- timer.unref?.();
1063
- socket.once("connect", () => {
1064
- clearTimeout(timer);
1065
- resolve();
1066
- });
1067
- socket.once("error", (err) => {
1068
- clearTimeout(timer);
1069
- reject(err);
1070
- });
1071
- });
1072
- socket.write(`${JSON.stringify(req)}
1073
- `);
1074
- const line = await readLine(socket, timeoutMs);
1075
- if (line === null) throw new ControlTimeout();
1076
- return JSON.parse(line);
1077
- } finally {
1078
- socket.destroy();
786
+ function connectedLine(runtime, verdict) {
787
+ if (verdict === "absent" || verdict === "unusable") {
788
+ throw new Error("an unavailable harness cannot be connected");
1079
789
  }
790
+ const label = HARNESS_LABELS[runtime];
791
+ if (verdict !== "failed") return `${label} connected.`;
792
+ return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
1080
793
  }
1081
- var ControlTimeout = class extends Error {
1082
- constructor() {
1083
- super("the companion did not answer its control socket in time");
1084
- this.name = "ControlTimeout";
1085
- }
794
+ var FAILED_SUFFIX = {
795
+ "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
796
+ codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
797
+ opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1086
798
  };
1087
- function isNotListening(err) {
1088
- const code = err?.code;
1089
- return code === "ENOENT" || code === "ECONNREFUSED";
799
+ function presenceReason(runtime, status) {
800
+ if (runtime === "opencode") {
801
+ return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
802
+ }
803
+ const label = HARNESS_LABELS[runtime];
804
+ return status === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
1090
805
  }
1091
- function readLine(socket, timeoutMs) {
806
+ var CLI_PRESENCE_ACTION = "Install it and run this again.";
807
+ var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
808
+ function presenceRefusal(runtime, status, action) {
809
+ const reason = presenceReason(runtime, status);
810
+ return runtime === "opencode" ? reason : `${reason} ${action}`;
811
+ }
812
+ function looksUnsupported(output) {
813
+ return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
814
+ output
815
+ );
816
+ }
817
+ function runBounded(command, args) {
1092
818
  return new Promise((resolve) => {
1093
- let buf = "";
1094
819
  let settled = false;
1095
- const done = (v) => {
820
+ const done = (result) => {
1096
821
  if (settled) return;
1097
822
  settled = true;
1098
823
  clearTimeout(timer);
1099
- socket.removeListener("data", onData);
1100
- resolve(v);
1101
- };
1102
- const timer = setTimeout(() => done(null), timeoutMs);
1103
- timer.unref?.();
1104
- const onData = (chunk) => {
1105
- buf += chunk.toString("utf8");
1106
- const nl = buf.indexOf("\n");
1107
- if (nl >= 0) done(buf.slice(0, nl));
1108
- else if (buf.length > 1e6) done(null);
1109
- };
1110
- socket.on("data", onData);
1111
- socket.once("close", () => done(null));
1112
- socket.once("error", () => done(null));
1113
- });
1114
- }
1115
- async function ping(path) {
1116
- try {
1117
- await controlRequest(path, { cmd: "status" });
1118
- return true;
1119
- } catch (err) {
1120
- return !isNotListening(err);
1121
- }
1122
- }
1123
-
1124
- // src/harness-check.ts
1125
- import { spawn as spawn4 } from "child_process";
1126
-
1127
- // src/harness-versions.ts
1128
- import { spawn as spawn2 } from "child_process";
1129
- var EMPTY = { claudeCode: null, opencode: null, codex: null };
1130
- function parseVersionToken(raw) {
1131
- if (!raw) return null;
1132
- const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
1133
- return m ? m[0] : null;
1134
- }
1135
- async function probeCliVersion(command, spawnImpl = spawn2) {
1136
- return new Promise((resolve) => {
1137
- let settled = false;
1138
- const done = (v) => {
1139
- if (!settled) {
1140
- settled = true;
1141
- resolve(v);
1142
- }
824
+ resolve(result);
1143
825
  };
1144
826
  let child;
1145
827
  try {
1146
- child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
828
+ child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1147
829
  } catch {
1148
- done(null);
830
+ resolve({ code: null, output: "", error: "spawn" });
1149
831
  return;
1150
832
  }
1151
833
  let out = "";
1152
- child.stdout?.on("data", (chunk) => {
834
+ const capture = (chunk) => {
1153
835
  if (out.length < 4096) out += chunk.toString();
1154
- });
1155
- child.once("error", () => done(null));
1156
- child.once("exit", (code) => done(code === 0 ? parseVersionToken(out) : null));
836
+ };
837
+ child.stdout?.on("data", capture);
838
+ child.stderr?.on("data", capture);
839
+ const timer = setTimeout(() => {
840
+ child.kill("SIGKILL");
841
+ done({ code: null, output: out, error: "timeout" });
842
+ }, CHECK_TIMEOUT_MS);
843
+ timer.unref?.();
844
+ child.once("error", () => done({ code: null, output: out, error: "spawn" }));
845
+ child.once("exit", (code) => done({ code, output: out }));
1157
846
  });
1158
847
  }
1159
- async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
1160
- try {
1161
- const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
1162
- const res = await fetchImpl(`${base}/global/health`, {
1163
- headers: { accept: "application/json" }
1164
- });
1165
- if (!res.ok) return null;
1166
- return extractOpencodeVersion(await res.json());
1167
- } catch {
1168
- return null;
1169
- }
848
+
849
+ // src/logger.ts
850
+ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
851
+ import { dirname as dirname2, join as join2 } from "path";
852
+ import pino from "pino";
853
+ import pretty from "pino-pretty";
854
+ function companionLogPath() {
855
+ return join2(cabaneDir(), "companion.log");
1170
856
  }
1171
- function extractOpencodeVersion(json) {
1172
- if (!json || typeof json !== "object") return null;
1173
- const obj = json;
1174
- const candidates = [obj.version];
1175
- for (const v of Object.values(obj)) {
1176
- if (v && typeof v === "object") candidates.push(v.version);
1177
- }
1178
- for (const c of candidates) {
1179
- if (typeof c === "string") {
1180
- const parsed = parseVersionToken(c);
1181
- if (parsed) return parsed;
1182
- }
1183
- }
1184
- return null;
857
+ var CONSOLE_IGNORE = [
858
+ "pid",
859
+ "hostname",
860
+ "workspaceId",
861
+ "conversationId",
862
+ "agentId",
863
+ "messageId",
864
+ "sessionId",
865
+ "companionId"
866
+ ].join(",");
867
+ function consoleShortId(log) {
868
+ const id = log.conversationId ?? log.workspaceId;
869
+ return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
1185
870
  }
1186
- async function probeHarnessVersions(opts, deps = {}) {
1187
- const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
1188
- const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
1189
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1190
- const [claudeCode, codex, opencode] = await Promise.all([
1191
- opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
1192
- opts.codex ? safe(probeCodex) : Promise.resolve(null),
1193
- opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
1194
- ]);
1195
- return { claudeCode, opencode, codex };
871
+ function consoleMessageFormat(log, messageKey) {
872
+ const short = consoleShortId(log);
873
+ const msg = String(log[messageKey] ?? "");
874
+ return short ? `${short} ${msg}` : msg;
1196
875
  }
1197
- function emptyHarnessVersions() {
1198
- return { ...EMPTY };
1199
- }
1200
- async function safe(fn) {
1201
- try {
1202
- return await fn();
1203
- } catch {
1204
- return null;
1205
- }
1206
- }
1207
-
1208
- // src/manifest.ts
1209
- var DEVICE_MANIFEST = {
1210
- runtimes: [{ name: "claude-code", version: null }],
1211
- capabilities: { hostFs: true, browser: true, userMcp: true }
1212
- };
1213
- function buildCompanionManifest(opts) {
1214
- const v = opts.versions ?? {};
1215
- const runtimes = [];
1216
- if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
1217
- if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
1218
- if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
1219
- return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
1220
- }
1221
-
1222
- // src/prereqs.ts
1223
- import { spawn as spawn3 } from "child_process";
1224
- async function claudeOnPath() {
1225
- return new Promise((resolve) => {
1226
- let settled = false;
1227
- const child = spawn3("claude", ["--version"], { stdio: "ignore" });
1228
- child.once("error", () => {
1229
- if (!settled) {
1230
- settled = true;
1231
- resolve(false);
1232
- }
876
+ var cached = null;
877
+ var consoleLogging = true;
878
+ function createLogger(destinations = {}) {
879
+ const path = companionLogPath();
880
+ if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
881
+ const streams = [];
882
+ if (process.env.CABANE_COMPANION_DAEMON !== "1") {
883
+ const consoleStream = pretty({
884
+ colorize: true,
885
+ ignore: CONSOLE_IGNORE,
886
+ messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
887
+ ...destinations.console ? { destination: destinations.console } : {}
1233
888
  });
1234
- child.once("exit", (code) => {
1235
- if (!settled) {
1236
- settled = true;
1237
- resolve(code === 0);
889
+ streams.push({
890
+ level: "info",
891
+ stream: {
892
+ write(chunk) {
893
+ if (consoleLogging) consoleStream.write(chunk);
894
+ }
1238
895
  }
1239
896
  });
897
+ }
898
+ streams.push({
899
+ level: "debug",
900
+ stream: destinations.file ?? createWriteStream(path, { flags: "a" })
1240
901
  });
902
+ return pino({ level: "debug" }, pino.multistream(streams));
1241
903
  }
1242
- var CODEX_PROBE_TIMEOUT_MS = 4e3;
1243
- async function codexOnPath() {
1244
- const version = await Promise.race([
1245
- probeCliVersion("codex"),
1246
- new Promise((resolve) => {
1247
- const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
1248
- timer.unref?.();
1249
- })
1250
- ]);
1251
- return version !== null;
904
+ function getLogger() {
905
+ if (cached) return cached;
906
+ cached = createLogger();
907
+ return cached;
1252
908
  }
1253
- async function requireStartConfig(deps = {}) {
1254
- const requireCfg = deps.requireCfg ?? requireConfig;
1255
- const probeClaude = deps.probeClaude ?? claudeOnPath;
1256
- const save2 = deps.save ?? saveConfig;
1257
- const cfg = requireCfg();
1258
- const claudeOnPathResult = await probeClaude();
1259
- const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
1260
- if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
1261
- save2(migrated);
1262
- deps.onMigrated?.(migrated, claudeOnPathResult);
1263
- return { cfg: migrated, claudeOnPath: claudeOnPathResult };
909
+
910
+ // src/state.ts
911
+ import { EventEmitter } from "events";
912
+ function dispatchToWire(r) {
913
+ return {
914
+ id: r.id,
915
+ timestamp: r.timestamp,
916
+ workspace_slug: r.workspaceSlug,
917
+ ...r.agentUsername ? { agent_username: r.agentUsername } : {},
918
+ message: r.message,
919
+ status: r.status,
920
+ ...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
921
+ ...r.error ? { error: r.error } : {},
922
+ ...r.reply ? { reply: r.reply } : {}
923
+ };
1264
924
  }
1265
- async function warnAboutHarnessReadiness(cfg, deps = {}) {
1266
- const probeClaude = deps.probeClaude ?? claudeOnPath;
1267
- const probeCodex = deps.probeCodex ?? codexOnPath;
1268
- const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
1269
- `));
1270
- const connected = [
1271
- ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
1272
- ...isCodexEnabled(cfg) ? ["Codex"] : [],
1273
- ...cfg.opencode ? ["opencode"] : []
1274
- ];
1275
- if (connected.length > 0) {
1276
- if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
1277
- warn(
1278
- "warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
1279
- );
925
+ var MAX_DISPATCHES = 50;
926
+ function today() {
927
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
928
+ }
929
+ var CompanionStateHub = class {
930
+ constructor(opts) {
931
+ this.opts = opts;
932
+ this.emitter.setMaxListeners(0);
933
+ }
934
+ opts;
935
+ emitter = new EventEmitter();
936
+ workspaces = /* @__PURE__ */ new Map();
937
+ dispatches = [];
938
+ dashboardUrl = null;
939
+ deviceId = null;
940
+ deviceLabel = null;
941
+ deviceError = null;
942
+ // CT586: the latest harness snapshot the supervisor probed, or null before the
943
+ // first probe (see CompanionStatusJson.harnesses).
944
+ harnesses = null;
945
+ // ---- subscription (SSE) ----
946
+ on(listener) {
947
+ this.emitter.on("event", listener);
948
+ return () => this.emitter.off("event", listener);
949
+ }
950
+ emit(type, data) {
951
+ this.emitter.emit("event", { type, data });
952
+ }
953
+ // ---- device ----
954
+ setDevice(info) {
955
+ if (info.deviceId !== void 0) this.deviceId = info.deviceId;
956
+ if (info.deviceLabel !== void 0) this.deviceLabel = info.deviceLabel;
957
+ this.emitStatus();
958
+ }
959
+ setDeviceError(message) {
960
+ if (this.deviceError === message) return;
961
+ this.deviceError = message;
962
+ this.emitStatus();
963
+ }
964
+ clearDeviceError() {
965
+ if (this.deviceError === null) return;
966
+ this.deviceError = null;
967
+ this.emitStatus();
968
+ }
969
+ // ---- harnesses (CT586) ----
970
+ // Replace the harness snapshot and push a status change (so the SSE feed flips
971
+ // the Companion's Harnesses surface live). The supervisor calls this on each
972
+ // heartbeat-cadence probe and on an on-demand recheck.
973
+ setHarnesses(harnesses) {
974
+ this.harnesses = harnesses;
975
+ this.emitStatus();
976
+ }
977
+ // ---- workspace lifecycle ----
978
+ registerWorkspace(ws) {
979
+ const existing = this.workspaces.get(ws.workspaceId);
980
+ if (existing) {
981
+ existing.slug = ws.slug;
982
+ existing.name = ws.name;
983
+ return;
1280
984
  }
1281
- return;
985
+ this.workspaces.set(ws.workspaceId, {
986
+ workspaceId: ws.workspaceId,
987
+ slug: ws.slug,
988
+ name: ws.name,
989
+ connected: false,
990
+ authFailed: false,
991
+ lastEventAt: null,
992
+ dispatchCountToday: 0,
993
+ countDate: today(),
994
+ agents: /* @__PURE__ */ new Map()
995
+ });
996
+ this.emitStatus();
1282
997
  }
1283
- const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1284
- const installed = [
1285
- ...claudeInstalled ? ["Claude Code"] : [],
1286
- ...codexInstalled ? ["Codex"] : []
1287
- ];
1288
- const connectCommands = [
1289
- ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
1290
- ...codexInstalled ? ["`cabane-companion connect codex`"] : []
1291
- ];
1292
- warn(
1293
- "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
1294
- );
1295
- }
1296
-
1297
- // src/harness-status.ts
1298
- var HARNESS_LABELS = {
1299
- "claude-code": "Claude Code",
1300
- codex: "Codex",
1301
- opencode: "OpenCode"
1302
- };
1303
- var LABELS = HARNESS_LABELS;
1304
- var DEFAULT_OPENCODE_SERVER_URL = "http://127.0.0.1:4096";
1305
- function deriveHarnessSnapshot(signals) {
1306
- const advertised = new Set(
1307
- buildCompanionManifest({
1308
- // CT1082: connected AND installed the manifest's own rule, restated here
1309
- // through the same function rather than re-decided.
1310
- claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
1311
- opencode: signals.opencodeConfigured,
1312
- codex: signals.codexEnabled
1313
- }).runtimes.map((r) => r.name)
1314
- );
1315
- const harnesses = [
1316
- deriveClaudeCode(signals, advertised.has("claude-code")),
1317
- deriveCodex(signals, advertised.has("codex")),
1318
- deriveOpencode(signals, advertised.has("opencode"))
1319
- ];
1320
- return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
1321
- }
1322
- function deriveClaudeCode(signals, manifestHas) {
1323
- const base = { runtime: "claude-code", label: LABELS["claude-code"] };
1324
- if (manifestHas) {
998
+ removeWorkspace(workspaceId) {
999
+ const ws = this.workspaces.get(workspaceId);
1000
+ if (!ws) return;
1001
+ this.workspaces.delete(workspaceId);
1002
+ this.emit("workspace:disconnected", { slug: ws.slug });
1003
+ this.emitStatus();
1004
+ }
1005
+ setConnected(workspaceId, connected) {
1006
+ const ws = this.workspaces.get(workspaceId);
1007
+ if (!ws || ws.connected === connected) return;
1008
+ ws.connected = connected;
1009
+ if (connected) ws.authFailed = false;
1010
+ this.emitStatus();
1011
+ }
1012
+ setAuthFailed(workspaceId) {
1013
+ const ws = this.workspaces.get(workspaceId);
1014
+ if (!ws) return;
1015
+ if (ws.authFailed && !ws.connected) return;
1016
+ ws.authFailed = true;
1017
+ ws.connected = false;
1018
+ this.emitStatus();
1019
+ }
1020
+ recordEvent(workspaceId) {
1021
+ const ws = this.workspaces.get(workspaceId);
1022
+ if (!ws) return;
1023
+ ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
1024
+ }
1025
+ // ---- agents ----
1026
+ // Upsert one assigned agent's observable state (called on each reconcile).
1027
+ setAgent(workspaceId, agent) {
1028
+ const ws = this.workspaces.get(workspaceId);
1029
+ if (!ws) return;
1030
+ ws.agents.set(agent.agentId, agent);
1031
+ this.emitStatus();
1032
+ }
1033
+ removeAgent(workspaceId, agentId) {
1034
+ const ws = this.workspaces.get(workspaceId);
1035
+ if (!ws) return;
1036
+ if (ws.agents.delete(agentId)) this.emitStatus();
1037
+ }
1038
+ // ---- dispatch feed ----
1039
+ // A DispatchObserver bound to one (workspace, agent), handed to that agent's
1040
+ // Dispatcher by the supervisor.
1041
+ observerFor(workspaceId, agentId, slug) {
1042
+ const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
1325
1043
  return {
1326
- ...base,
1327
- state: "exposed",
1328
- version: signals.claudeVersion,
1329
- detail: "Claude Code is connected and exposed to Cabane.",
1330
- enable: null
1044
+ onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
1045
+ onEnd: (info) => this.dispatchEnded(slug, username(), info)
1331
1046
  };
1332
1047
  }
1333
- if (signals.claudeCodeConnected) {
1048
+ bumpCount(workspaceId) {
1049
+ const ws = this.workspaces.get(workspaceId);
1050
+ if (!ws) return;
1051
+ const day = today();
1052
+ if (ws.countDate !== day) {
1053
+ ws.countDate = day;
1054
+ ws.dispatchCountToday = 0;
1055
+ }
1056
+ ws.dispatchCountToday += 1;
1057
+ }
1058
+ dispatchStarted(workspaceId, slug, agentUsername, info) {
1059
+ this.bumpCount(workspaceId);
1060
+ const record = {
1061
+ id: info.id,
1062
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1063
+ workspaceSlug: slug,
1064
+ ...agentUsername ? { agentUsername } : {},
1065
+ message: info.message,
1066
+ status: "running"
1067
+ };
1068
+ this.upsertDispatch(record);
1069
+ this.emit("dispatch:started", dispatchToWire(record));
1070
+ this.emitStatus();
1071
+ }
1072
+ dispatchEnded(slug, agentUsername, info) {
1073
+ const existing = this.dispatches.find((d) => d.id === info.id);
1074
+ const record = {
1075
+ id: info.id,
1076
+ timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
1077
+ workspaceSlug: existing?.workspaceSlug ?? slug,
1078
+ ...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
1079
+ message: existing?.message ?? "",
1080
+ status: info.ok ? "replied" : "error",
1081
+ durationMs: info.durationMs,
1082
+ ...info.reason ? { error: info.reason } : {},
1083
+ ...info.reply ? { reply: info.reply } : {}
1084
+ };
1085
+ this.upsertDispatch(record);
1086
+ this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
1087
+ }
1088
+ upsertDispatch(record) {
1089
+ const idx = this.dispatches.findIndex((d) => d.id === record.id);
1090
+ if (idx >= 0) {
1091
+ this.dispatches[idx] = record;
1092
+ return;
1093
+ }
1094
+ this.dispatches.unshift(record);
1095
+ if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
1096
+ }
1097
+ // ---- snapshots for the HTTP routes ----
1098
+ setDashboardUrl(url) {
1099
+ this.dashboardUrl = url;
1100
+ }
1101
+ recentDispatches(limit = 20) {
1102
+ return this.dispatches.slice(0, Math.max(0, limit));
1103
+ }
1104
+ statusJson() {
1105
+ const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
1106
+ const lastOverall = list.reduce((acc, ws) => {
1107
+ if (!ws.lastEventAt) return acc;
1108
+ if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
1109
+ return acc;
1110
+ }, null);
1334
1111
  return {
1335
- ...base,
1336
- state: "needs_attention",
1337
- version: null,
1338
- 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.",
1339
- enable: null
1112
+ cabane_url: this.opts.baseUrl,
1113
+ device_id: this.deviceId,
1114
+ device_label: this.deviceLabel,
1115
+ device_error: this.deviceError,
1116
+ connected: list.some((ws) => ws.connected),
1117
+ workspaces: list.map((ws) => ({
1118
+ slug: ws.slug,
1119
+ name: ws.name,
1120
+ last_event_at: ws.lastEventAt,
1121
+ dispatch_count_today: ws.dispatchCountToday,
1122
+ connected: ws.connected,
1123
+ auth_failed: ws.authFailed,
1124
+ agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
1125
+ agent_id: a.agentId,
1126
+ username: a.username,
1127
+ display_name: a.displayName,
1128
+ mode: a.mode,
1129
+ has_credential: a.hasCredential,
1130
+ missing_secrets: a.missingSecrets
1131
+ }))
1132
+ })),
1133
+ last_event_at_overall: lastOverall,
1134
+ dashboard_url: this.dashboardUrl,
1135
+ companion_version: this.opts.companionVersion,
1136
+ instance_id: this.opts.instanceId ?? null,
1137
+ harnesses: this.harnesses
1340
1138
  };
1341
1139
  }
1342
- if (signals.claudeOnPath) {
1343
- return {
1344
- ...base,
1345
- state: "detected_not_exposed",
1346
- version: signals.claudeVersion,
1347
- detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
1348
- enable: "claude-code"
1349
- };
1140
+ emitStatus() {
1141
+ this.emit("status:changed", this.statusJson());
1142
+ }
1143
+ };
1144
+
1145
+ // src/dashboard/routes.ts
1146
+ var CONTENT_TYPES = {
1147
+ ".html": "text/html; charset=utf-8",
1148
+ ".css": "text/css; charset=utf-8",
1149
+ ".js": "text/javascript; charset=utf-8",
1150
+ ".svg": "image/svg+xml",
1151
+ ".ico": "image/x-icon"
1152
+ };
1153
+ function registerRoutes(app, deps) {
1154
+ const { supervisor, hub, staticDir } = deps;
1155
+ app.get("/", async (c) => {
1156
+ const html = await readFile(join3(staticDir, "index.html"), "utf8");
1157
+ return c.html(html);
1158
+ });
1159
+ app.get("/static/:file", async (c) => {
1160
+ const file = c.req.param("file");
1161
+ const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1162
+ const full = join3(staticDir, safe3);
1163
+ if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
1164
+ const body = await readFile(full);
1165
+ const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
1166
+ c.header("content-type", type);
1167
+ return c.body(body);
1168
+ });
1169
+ app.get("/api/status", (c) => c.json(hub.statusJson()));
1170
+ app.get("/api/dispatches", (c) => {
1171
+ const limit = clampLimit(c.req.query("limit"), 20);
1172
+ return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
1173
+ });
1174
+ app.get("/api/logs", async (c) => {
1175
+ const lines = clampLimit(c.req.query("lines"), 200, 1e3);
1176
+ return c.json({ lines: tailFile(companionLogPath(), lines) });
1177
+ });
1178
+ app.post("/api/settings", async (c) => {
1179
+ const body = await readJson(c);
1180
+ const patch = {};
1181
+ if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
1182
+ patch.logLevel = body.logLevel;
1183
+ }
1184
+ if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
1185
+ supervisor.applySettings(patch);
1186
+ return c.json({ ok: true, status: hub.statusJson() });
1187
+ });
1188
+ app.post("/api/control", async (c) => {
1189
+ const body = await readJson(c);
1190
+ const action = body.action;
1191
+ if (action === "stop") {
1192
+ setTimeout(() => void supervisor.requestStop(), 50);
1193
+ return c.json({ ok: true, action: "stop" });
1194
+ }
1195
+ if (action === "restart") {
1196
+ setTimeout(() => supervisor.requestRestart(), 50);
1197
+ return c.json({ ok: true, action: "restart" });
1198
+ }
1199
+ if (action === "reload-config") {
1200
+ supervisor.reloadConfig();
1201
+ return c.json({ ok: true, action: "reload-config" });
1202
+ }
1203
+ if (action === "refresh") {
1204
+ supervisor.refresh();
1205
+ return c.json({ ok: true, action: "refresh" });
1206
+ }
1207
+ return c.json({ error: `unknown action "${String(action)}"` }, 400);
1208
+ });
1209
+ app.post("/api/harnesses/recheck", async (c) => {
1210
+ await supervisor.recheckHarnesses();
1211
+ return c.json({ ok: true, status: hub.statusJson() });
1212
+ });
1213
+ app.post("/api/harnesses/enable", async (c) => {
1214
+ const body = await readJson(c);
1215
+ const runtime = body.runtime;
1216
+ if (runtime === "claude-code") {
1217
+ const result = await supervisor.enableHarness({ runtime: "claude-code" });
1218
+ if (!result.ok) {
1219
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
1220
+ return c.json({ error }, 400);
1221
+ }
1222
+ return c.json({ ok: true, status: hub.statusJson() });
1223
+ }
1224
+ if (runtime === "codex") {
1225
+ const result = await supervisor.enableHarness({ runtime: "codex" });
1226
+ if (!result.ok) {
1227
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
1228
+ return c.json({ error }, 400);
1229
+ }
1230
+ return c.json({ ok: true, status: hub.statusJson() });
1231
+ }
1232
+ if (runtime === "opencode") {
1233
+ if (typeof body.serverUrl !== "string" || body.serverUrl.trim() === "") {
1234
+ return c.json({ error: "An opencode server URL is required." }, 400);
1235
+ }
1236
+ const result = await supervisor.enableHarness({
1237
+ runtime: "opencode",
1238
+ serverUrl: body.serverUrl
1239
+ });
1240
+ if (!result.ok) return c.json({ error: result.error }, 400);
1241
+ return c.json({ ok: true, status: hub.statusJson() });
1242
+ }
1243
+ return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
1244
+ });
1245
+ app.get(
1246
+ "/api/events",
1247
+ (c) => streamSSE(c, async (stream) => {
1248
+ await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
1249
+ const queue = [];
1250
+ let wake = null;
1251
+ const unsubscribe = hub.on((ev) => {
1252
+ queue.push(ev);
1253
+ wake?.();
1254
+ });
1255
+ stream.onAbort(() => {
1256
+ unsubscribe();
1257
+ wake?.();
1258
+ });
1259
+ while (!stream.aborted) {
1260
+ if (queue.length === 0) {
1261
+ await Promise.race([
1262
+ new Promise((resolve) => {
1263
+ wake = resolve;
1264
+ }),
1265
+ stream.sleep(25e3)
1266
+ ]);
1267
+ wake = null;
1268
+ }
1269
+ if (stream.aborted) break;
1270
+ if (queue.length === 0) {
1271
+ await stream.writeSSE({ event: "ping", data: "" });
1272
+ continue;
1273
+ }
1274
+ const ev = queue.shift();
1275
+ await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
1276
+ }
1277
+ unsubscribe();
1278
+ })
1279
+ );
1280
+ }
1281
+ async function readJson(c) {
1282
+ try {
1283
+ const parsed = await c.req.json();
1284
+ return parsed && typeof parsed === "object" ? parsed : {};
1285
+ } catch {
1286
+ return {};
1287
+ }
1288
+ }
1289
+ function clampLimit(raw, fallback, max = 200) {
1290
+ if (!raw) return fallback;
1291
+ const n = Number(raw);
1292
+ if (!Number.isFinite(n) || n <= 0) return fallback;
1293
+ return Math.min(Math.floor(n), max);
1294
+ }
1295
+ function tailFile(path, lines) {
1296
+ if (!existsSync2(path)) return [];
1297
+ const MAX_BYTES = 256 * 1024;
1298
+ let fd;
1299
+ try {
1300
+ fd = openSync(path, "r");
1301
+ const size = fstatSync(fd).size;
1302
+ const start = Math.max(0, size - MAX_BYTES);
1303
+ const len = size - start;
1304
+ if (len <= 0) return [];
1305
+ const buf = Buffer.alloc(len);
1306
+ readSync(fd, buf, 0, len, start);
1307
+ const text = buf.toString("utf8");
1308
+ const all = text.split("\n");
1309
+ if (start > 0 && all.length > 0) all.shift();
1310
+ return all.filter((l) => l.length > 0).slice(-lines);
1311
+ } catch {
1312
+ return [];
1313
+ } finally {
1314
+ if (fd !== void 0) closeSync(fd);
1350
1315
  }
1351
- return {
1352
- ...base,
1353
- state: "not_detected",
1354
- version: null,
1355
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
1356
- enable: null
1357
- };
1358
1316
  }
1359
- function deriveCodex(signals, manifestHas) {
1360
- const base = { runtime: "codex", label: LABELS.codex };
1361
- if (manifestHas) {
1362
- if (signals.codexOnPath) {
1363
- return {
1364
- ...base,
1365
- state: "exposed",
1366
- version: signals.codexVersion,
1367
- detail: "Codex is enabled and exposed to Cabane.",
1368
- enable: null
1369
- };
1317
+
1318
+ // src/dashboard/server.ts
1319
+ var DEFAULT_PORT = 7474;
1320
+ var PORT_FALLBACK_SPAN = 10;
1321
+ function buildDashboardApp(deps) {
1322
+ const app = new Hono();
1323
+ app.onError((err, c) => {
1324
+ if (err instanceof ApiError) {
1325
+ const status = err.status >= 400 && err.status < 600 ? err.status : 502;
1326
+ return c.json({ error: err.message }, status);
1370
1327
  }
1371
- return {
1372
- ...base,
1373
- state: "needs_attention",
1374
- version: null,
1375
- detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
1376
- enable: null
1377
- };
1378
- }
1379
- if (signals.codexOnPath) {
1380
- return {
1381
- ...base,
1382
- state: "detected_not_exposed",
1383
- version: signals.codexVersion,
1384
- detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
1385
- enable: "codex"
1386
- };
1387
- }
1388
- return {
1389
- ...base,
1390
- state: "not_detected",
1391
- version: null,
1392
- detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
1393
- enable: null
1394
- };
1328
+ if (err instanceof CompanionError) {
1329
+ return c.json({ error: err.message }, 400);
1330
+ }
1331
+ return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
1332
+ });
1333
+ registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
1334
+ return app;
1395
1335
  }
1396
- function deriveOpencode(signals, manifestHas) {
1397
- const base = { runtime: "opencode", label: LABELS.opencode };
1398
- if (manifestHas) {
1399
- if (signals.opencodeReachable) {
1336
+ async function startDashboard(opts) {
1337
+ const app = buildDashboardApp({ supervisor: opts.supervisor, hub: opts.hub });
1338
+ const preferred = opts.port ?? DEFAULT_PORT;
1339
+ let lastErr;
1340
+ for (let port = preferred; port < preferred + PORT_FALLBACK_SPAN; port++) {
1341
+ try {
1342
+ const server = await listen(app, port);
1343
+ const url = `http://127.0.0.1:${port}`;
1400
1344
  return {
1401
- ...base,
1402
- state: "exposed",
1403
- version: signals.opencodeVersion,
1404
- detail: "An opencode server is reachable and exposed to Cabane.",
1405
- enable: null
1345
+ url,
1346
+ port,
1347
+ close: () => new Promise((resolve) => {
1348
+ server.close(() => resolve());
1349
+ server.closeAllConnections?.();
1350
+ })
1406
1351
  };
1352
+ } catch (err) {
1353
+ if (isAddrInUse(err)) {
1354
+ lastErr = err;
1355
+ continue;
1356
+ }
1357
+ throw err;
1407
1358
  }
1408
- return {
1409
- ...base,
1410
- state: "needs_attention",
1411
- version: null,
1412
- detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
1413
- enable: null
1414
- };
1415
- }
1416
- if (signals.opencodeReachable && signals.opencodeDetectedUrl) {
1417
- return {
1418
- ...base,
1419
- state: "detected_not_exposed",
1420
- version: signals.opencodeVersion,
1421
- detail: signals.opencodeDetectedUrl,
1422
- enable: "opencode"
1423
- };
1424
1359
  }
1425
- return {
1426
- ...base,
1427
- state: "not_detected",
1428
- version: null,
1429
- detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
1430
- enable: "opencode"
1431
- };
1432
- }
1433
- var PROBE_TIMEOUT_MS = 4e3;
1434
- var DEFAULT_OPENCODE_PROBE_TIMEOUT_MS = 1e3;
1435
- function probeDefaultOpencodeVersion(probeOpencode = (url) => probeOpencodeVersion(url)) {
1436
- return withTimeout(
1437
- probeOpencode(DEFAULT_OPENCODE_SERVER_URL),
1438
- null,
1439
- DEFAULT_OPENCODE_PROBE_TIMEOUT_MS
1360
+ throw new CompanionError(
1361
+ `couldn't bind the dashboard to any port in ${preferred}\u2013${preferred + PORT_FALLBACK_SPAN - 1} (all in use). Free one up or pass --port. (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
1440
1362
  );
1441
1363
  }
1442
- async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl, probeDefault = probeDefaultOpencodeVersion) {
1443
- const requested = requestedServerUrl?.trim();
1444
- if (requested) return requested;
1445
- if (configuredServerUrl) return configuredServerUrl;
1446
- return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
1447
- }
1448
- async function probeHarnessSignals(cfg, deps = {}) {
1449
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
1450
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
1451
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
1452
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1453
- const configuredServerUrl = cfg.opencode?.serverUrl;
1454
- const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
1455
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
1456
- withTimeout(probeClaudePresence(), false),
1457
- withTimeout(probeClaudeVersion(), null),
1458
- withTimeout(probeCodexVersion(), null),
1459
- configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
1460
- ]);
1461
- return {
1462
- claudeOnPath: claudeOnPathResult,
1463
- claudeVersion,
1464
- // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
1465
- // the manifest gate and the probe above is only a suggestion.
1466
- claudeCodeConnected: isClaudeCodeConnected(cfg),
1467
- // A parseable `codex --version` is our presence signal (presence alone never
1468
- // exposes codex; its config flag is the manifest gate either way).
1469
- codexOnPath: codexVersion !== null,
1470
- codexVersion,
1471
- codexEnabled: isCodexEnabled(cfg),
1472
- opencodeConfigured: !!configuredServerUrl,
1473
- // A version came back ⟺ the serve answered its health endpoint (CT584).
1474
- opencodeReachable: opencodeVersion !== null,
1475
- opencodeVersion,
1476
- opencodeDetectedUrl: opencodeVersion !== null ? opencodeProbeUrl : null
1477
- };
1478
- }
1479
- function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
1480
- return new Promise((resolve) => {
1364
+ function listen(app, port) {
1365
+ return new Promise((resolve, reject) => {
1481
1366
  let settled = false;
1482
- const done = (v) => {
1367
+ const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
1483
1368
  if (!settled) {
1484
1369
  settled = true;
1485
- resolve(v);
1370
+ resolve(server);
1486
1371
  }
1487
- };
1488
- const timer = setTimeout(() => done(fallback), timeoutMs);
1489
- timer.unref?.();
1490
- promise.then(
1491
- (v) => {
1492
- clearTimeout(timer);
1493
- done(v);
1494
- },
1495
- () => {
1496
- clearTimeout(timer);
1497
- done(fallback);
1372
+ });
1373
+ server.on("error", (err) => {
1374
+ if (!settled) {
1375
+ settled = true;
1376
+ reject(err);
1377
+ }
1378
+ });
1379
+ });
1380
+ }
1381
+ function isAddrInUse(err) {
1382
+ return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
1383
+ }
1384
+ function resolveStaticDir() {
1385
+ return join4(dirname3(fileURLToPath(import.meta.url)), "static");
1386
+ }
1387
+
1388
+ // src/control-socket.ts
1389
+ import { createHash } from "crypto";
1390
+ import { chmodSync as chmodSync2, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
1391
+ import { createServer, connect } from "net";
1392
+ import { join as join5 } from "path";
1393
+ var CONTROL_TIMEOUT_MS = 1e3;
1394
+ var MAX_UNIX_SOCKET_PATH_BYTES = 103;
1395
+ function controlSocketPath() {
1396
+ const dir2 = cabaneDir();
1397
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
1398
+ if (process.platform === "win32") {
1399
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
1400
+ }
1401
+ const besideRuntime = join5(dir2, "companion.sock");
1402
+ if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
1403
+ const filename = `cabane-companion-${key}.sock`;
1404
+ const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync3(process.env.XDG_RUNTIME_DIR) ? join5(process.env.XDG_RUNTIME_DIR, filename) : null;
1405
+ return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join5("/tmp", filename);
1406
+ }
1407
+ async function startControlServer(handlers) {
1408
+ const path = controlSocketPath();
1409
+ mkdirSync3(cabaneDir(), { recursive: true });
1410
+ if (process.platform !== "win32" && existsSync3(path)) {
1411
+ const alive = await ping(path);
1412
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
1413
+ rmSync2(path, { force: true });
1414
+ }
1415
+ const server = createServer((socket) => {
1416
+ void serveConnection(socket, handlers);
1417
+ });
1418
+ server.unref();
1419
+ await new Promise((resolve, reject) => {
1420
+ server.once("error", reject);
1421
+ server.listen(path, () => {
1422
+ server.removeListener("error", reject);
1423
+ resolve();
1424
+ });
1425
+ });
1426
+ if (process.platform !== "win32") {
1427
+ try {
1428
+ chmodSync2(path, 384);
1429
+ } catch (err) {
1430
+ await new Promise((resolve) => server.close(() => resolve()));
1431
+ try {
1432
+ rmSync2(path, { force: true });
1433
+ } catch {
1498
1434
  }
1499
- );
1435
+ throw err;
1436
+ }
1437
+ }
1438
+ server.on("error", () => {
1500
1439
  });
1440
+ return {
1441
+ path,
1442
+ close: () => new Promise((resolve) => {
1443
+ server.close(() => {
1444
+ if (process.platform !== "win32") rmSync2(path, { force: true });
1445
+ resolve();
1446
+ });
1447
+ })
1448
+ };
1501
1449
  }
1502
-
1503
- // src/harness-check.ts
1504
- var CHECK_TIMEOUT_MS = 4e3;
1505
- async function shakeOutHarness(runtime, cfg, deps = {}) {
1506
- const run = deps.run ?? runBounded;
1507
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1450
+ async function serveConnection(socket, handlers) {
1451
+ socket.on("error", () => socket.destroy());
1452
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1453
+ if (line === null) {
1454
+ socket.destroy();
1455
+ return;
1456
+ }
1457
+ let req;
1508
1458
  try {
1509
- if (runtime === "opencode") {
1510
- const url = cfg.opencode?.serverUrl;
1511
- if (!url) return "absent";
1512
- return await probeOpencode(url) !== null ? "ok" : "failed";
1459
+ req = JSON.parse(line);
1460
+ } catch {
1461
+ reply(socket, { error: "malformed request" });
1462
+ return;
1463
+ }
1464
+ try {
1465
+ if (req.cmd === "status") {
1466
+ reply(socket, handlers.status());
1467
+ return;
1513
1468
  }
1514
- const { auth, presence } = runtime === "codex" ? {
1515
- auth: ["codex", ["login", "status"]],
1516
- presence: ["codex", ["--version"]]
1517
- } : {
1518
- auth: ["claude", ["auth", "status"]],
1519
- presence: ["claude", ["--version"]]
1520
- };
1521
- const presenceRun = await run(presence[0], [...presence[1]]);
1522
- if (presenceRun.error === "spawn") return "absent";
1523
- if (presenceRun.code !== 0) return "unverified";
1524
- const authRun = await run(auth[0], [...auth[1]]);
1525
- if (authRun.code === 0) return "ok";
1526
- if (looksUnsupported(authRun.output)) {
1527
- return "unverified";
1469
+ if (req.cmd === "connect") {
1470
+ const result = await handlers.connect(req.runtime, req.serverUrl);
1471
+ reply(socket, result);
1472
+ return;
1528
1473
  }
1529
- return "failed";
1530
- } catch {
1531
- return "unverified";
1474
+ if (req.cmd === "stop") {
1475
+ reply(socket, { ok: true });
1476
+ setTimeout(() => handlers.stop(), 50).unref?.();
1477
+ return;
1478
+ }
1479
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1480
+ } catch (err) {
1481
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
1532
1482
  }
1533
1483
  }
1534
- function connectedLine(runtime, verdict) {
1535
- if (verdict === "absent") throw new Error("an absent harness cannot be connected");
1536
- const label = HARNESS_LABELS[runtime];
1537
- if (verdict !== "failed") return `${label} connected.`;
1538
- return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
1484
+ function reply(socket, body) {
1485
+ try {
1486
+ socket.end(`${JSON.stringify(body)}
1487
+ `);
1488
+ } catch {
1489
+ socket.destroy();
1490
+ }
1539
1491
  }
1540
- var FAILED_SUFFIX = {
1541
- "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1542
- codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1543
- opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1544
- };
1545
- function absentLine(runtime) {
1546
- if (runtime === "opencode") {
1547
- return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
1492
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1493
+ const socket = connect(path);
1494
+ try {
1495
+ await new Promise((resolve, reject) => {
1496
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1497
+ timer.unref?.();
1498
+ socket.once("connect", () => {
1499
+ clearTimeout(timer);
1500
+ resolve();
1501
+ });
1502
+ socket.once("error", (err) => {
1503
+ clearTimeout(timer);
1504
+ reject(err);
1505
+ });
1506
+ });
1507
+ socket.write(`${JSON.stringify(req)}
1508
+ `);
1509
+ const line = await readLine(socket, timeoutMs);
1510
+ if (line === null) throw new ControlTimeout();
1511
+ return JSON.parse(line);
1512
+ } finally {
1513
+ socket.destroy();
1548
1514
  }
1549
- const label = HARNESS_LABELS[runtime];
1550
- const login = runtime === "codex" ? "`codex login`" : "`claude`";
1551
- return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
1552
1515
  }
1553
- function looksUnsupported(output) {
1554
- return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1555
- output
1556
- );
1516
+ var ControlTimeout = class extends Error {
1517
+ constructor() {
1518
+ super("the companion did not answer its control socket in time");
1519
+ this.name = "ControlTimeout";
1520
+ }
1521
+ };
1522
+ function isNotListening(err) {
1523
+ const code = err?.code;
1524
+ return code === "ENOENT" || code === "ECONNREFUSED";
1557
1525
  }
1558
- function runBounded(command, args) {
1526
+ function readLine(socket, timeoutMs) {
1559
1527
  return new Promise((resolve) => {
1528
+ let buf = "";
1560
1529
  let settled = false;
1561
- const done = (result) => {
1530
+ const done = (v) => {
1562
1531
  if (settled) return;
1563
1532
  settled = true;
1564
1533
  clearTimeout(timer);
1565
- resolve(result);
1566
- };
1567
- let child;
1568
- try {
1569
- child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1570
- } catch {
1571
- resolve({ code: null, output: "", error: "spawn" });
1572
- return;
1573
- }
1574
- let out = "";
1575
- const capture = (chunk) => {
1576
- if (out.length < 4096) out += chunk.toString();
1534
+ socket.removeListener("data", onData);
1535
+ resolve(v);
1577
1536
  };
1578
- child.stdout?.on("data", capture);
1579
- child.stderr?.on("data", capture);
1580
- const timer = setTimeout(() => {
1581
- child.kill("SIGKILL");
1582
- done({ code: null, output: out, error: "timeout" });
1583
- }, CHECK_TIMEOUT_MS);
1537
+ const timer = setTimeout(() => done(null), timeoutMs);
1584
1538
  timer.unref?.();
1585
- child.once("error", () => done({ code: null, output: out, error: "spawn" }));
1586
- child.once("exit", (code) => done({ code, output: out }));
1539
+ const onData = (chunk) => {
1540
+ buf += chunk.toString("utf8");
1541
+ const nl = buf.indexOf("\n");
1542
+ if (nl >= 0) done(buf.slice(0, nl));
1543
+ else if (buf.length > 1e6) done(null);
1544
+ };
1545
+ socket.on("data", onData);
1546
+ socket.once("close", () => done(null));
1547
+ socket.once("error", () => done(null));
1587
1548
  });
1588
1549
  }
1550
+ async function ping(path) {
1551
+ try {
1552
+ await controlRequest(path, { cmd: "status" });
1553
+ return true;
1554
+ } catch (err) {
1555
+ return !isNotListening(err);
1556
+ }
1557
+ }
1558
+
1559
+ // src/prereqs.ts
1560
+ async function claudeOnPath() {
1561
+ return (await probeHarnessPresence("claude-code")).status === "present";
1562
+ }
1563
+ async function codexOnPath() {
1564
+ return (await probeHarnessPresence("codex")).status === "present";
1565
+ }
1566
+ async function requireStartConfig(deps = {}) {
1567
+ const requireCfg = deps.requireCfg ?? requireConfig;
1568
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
1569
+ const save2 = deps.save ?? saveConfig;
1570
+ const cfg = requireCfg();
1571
+ const claudeOnPathResult = await probeClaude();
1572
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
1573
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
1574
+ save2(migrated);
1575
+ deps.onMigrated?.(migrated, claudeOnPathResult);
1576
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
1577
+ }
1578
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1579
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
1580
+ const probeCodex = deps.probeCodex ?? codexOnPath;
1581
+ const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
1582
+ `));
1583
+ const connected = [
1584
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
1585
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
1586
+ ...cfg.opencode ? ["opencode"] : []
1587
+ ];
1588
+ if (connected.length > 0) {
1589
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
1590
+ warn(
1591
+ "warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
1592
+ );
1593
+ }
1594
+ return;
1595
+ }
1596
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1597
+ const installed = [
1598
+ ...claudeInstalled ? ["Claude Code"] : [],
1599
+ ...codexInstalled ? ["Codex"] : []
1600
+ ];
1601
+ const connectCommands = [
1602
+ ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
1603
+ ...codexInstalled ? ["`cabane-companion connect codex`"] : []
1604
+ ];
1605
+ warn(
1606
+ "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
1607
+ );
1608
+ }
1589
1609
 
1590
1610
  // src/runtime-file.ts
1591
1611
  import {
@@ -2159,7 +2179,7 @@ function errorMessage2(status, body) {
2159
2179
 
2160
2180
  // src/credentials.ts
2161
2181
  import {
2162
- chmodSync as chmodSync2,
2182
+ chmodSync as chmodSync3,
2163
2183
  existsSync as existsSync5,
2164
2184
  mkdirSync as mkdirSync5,
2165
2185
  readFileSync as readFileSync3,
@@ -2194,14 +2214,14 @@ function save(map) {
2194
2214
  const path = credentialsPath();
2195
2215
  mkdirSync5(dirname4(path), { recursive: true });
2196
2216
  try {
2197
- chmodSync2(cabaneDir(), 448);
2217
+ chmodSync3(cabaneDir(), 448);
2198
2218
  } catch {
2199
2219
  }
2200
2220
  const tmp = `${path}.${process.pid}.tmp`;
2201
2221
  try {
2202
2222
  writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
2203
2223
  try {
2204
- chmodSync2(tmp, 384);
2224
+ chmodSync3(tmp, 384);
2205
2225
  } catch {
2206
2226
  }
2207
2227
  renameSync2(tmp, path);
@@ -6684,7 +6704,7 @@ function resolveMcpSecrets(mcpServers, store) {
6684
6704
  }
6685
6705
 
6686
6706
  // src/transcript-writer.ts
6687
- import { appendFileSync, chmodSync as chmodSync3, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6707
+ import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
6688
6708
  import { basename, dirname as dirname5, join as join13 } from "path";
6689
6709
  function transcriptsDir() {
6690
6710
  return join13(cabaneDir(), "transcripts");
@@ -6701,7 +6721,7 @@ var TranscriptWriter = class {
6701
6721
  try {
6702
6722
  mkdirSync9(dir2, { recursive: true });
6703
6723
  try {
6704
- chmodSync3(dir2, 448);
6724
+ chmodSync4(dir2, 448);
6705
6725
  } catch {
6706
6726
  }
6707
6727
  pruneOld(dir2, RETAIN);
@@ -6728,10 +6748,10 @@ var TranscriptWriter = class {
6728
6748
  try {
6729
6749
  const dir2 = join13(dirname5(this.path), "anomalies");
6730
6750
  mkdirSync9(dir2, { recursive: true, mode: 448 });
6731
- chmodSync3(dir2, 448);
6751
+ chmodSync4(dir2, 448);
6732
6752
  const target = join13(dir2, basename(this.path));
6733
6753
  copyFileSync(this.path, target);
6734
- chmodSync3(target, 384);
6754
+ chmodSync4(target, 384);
6735
6755
  pruneOld(dir2, ANOMALY_RETAIN);
6736
6756
  } catch (err) {
6737
6757
  this.fail(err);
@@ -8573,11 +8593,9 @@ var CompanionSupervisor = class {
8573
8593
  // can never disagree with what the manifest advertises. Null until the first
8574
8594
  // probe (the heartbeat then falls back to the boot `claudeCode`).
8575
8595
  harnessSignals = null;
8576
- // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
8577
- // NOT the cached beat signal — someone connecting right after installing Claude
8578
- // Code shouldn't be refused by a snapshot up to a heartbeat old.
8579
- probeClaudePresence;
8580
- probeCodexPresence;
8596
+ // Fresh per attempt, never the cached heartbeat signal: installing a harness
8597
+ // between attempts must be observed immediately.
8598
+ probePresence;
8581
8599
  exitFn;
8582
8600
  reexecFn;
8583
8601
  dispatcherFactory;
@@ -8609,8 +8627,7 @@ var CompanionSupervisor = class {
8609
8627
  this.log = opts.log;
8610
8628
  this.hub = opts.hub;
8611
8629
  this.claudeCode = opts.claudeCode ?? true;
8612
- this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
8613
- this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
8630
+ this.probePresence = opts.probePresence ?? probeHarnessPresence;
8614
8631
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
8615
8632
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
8616
8633
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -9308,10 +9325,11 @@ var CompanionSupervisor = class {
9308
9325
  // over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
9309
9326
  // wrapped anyway so a harness refresh can never take a heartbeat down. Called on
9310
9327
  // the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
9311
- async refreshHarnessStatuses() {
9328
+ async refreshHarnessStatuses(presence = {}) {
9312
9329
  try {
9313
9330
  const signals = await probeHarnessSignals(this.config, {
9314
- probeClaudePresence: this.probeClaudePresence
9331
+ probePresence: this.probePresence,
9332
+ presence
9315
9333
  });
9316
9334
  this.harnessSignals = signals;
9317
9335
  this.harnessVersions = {
@@ -9368,18 +9386,24 @@ var CompanionSupervisor = class {
9368
9386
  // reachable/valid input).
9369
9387
  async enableHarness(input) {
9370
9388
  let next;
9389
+ let checkedPresence = null;
9371
9390
  if (input.runtime === "claude-code") {
9372
- if (!await this.probeClaudePresence()) {
9391
+ const presence = input.presence ?? await this.probePresence("claude-code");
9392
+ if (presence.status !== "present") {
9373
9393
  return {
9374
9394
  ok: false,
9375
- error: "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."
9395
+ error: presenceReason("claude-code", presence.status),
9396
+ presence: true
9376
9397
  };
9377
9398
  }
9399
+ checkedPresence = { runtime: "claude-code", result: presence };
9378
9400
  next = { ...this.config, claudeCode: { enabled: true } };
9379
9401
  } else if (input.runtime === "codex") {
9380
- if (!await this.probeCodexPresence()) {
9381
- return { ok: false, error: absentLine("codex") };
9402
+ const presence = input.presence ?? await this.probePresence("codex");
9403
+ if (presence.status !== "present") {
9404
+ return { ok: false, error: presenceReason("codex", presence.status), presence: true };
9382
9405
  }
9406
+ checkedPresence = { runtime: "codex", result: presence };
9383
9407
  next = { ...this.config, codex: { enabled: true } };
9384
9408
  } else {
9385
9409
  const serverUrl = input.serverUrl.trim();
@@ -9403,7 +9427,9 @@ var CompanionSupervisor = class {
9403
9427
  this.config = next;
9404
9428
  saveConfig(next);
9405
9429
  this.rebuildDispatchers();
9406
- await this.refreshHarnessStatuses();
9430
+ await this.refreshHarnessStatuses(
9431
+ checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
9432
+ );
9407
9433
  this.kickHeartbeat();
9408
9434
  return { ok: true };
9409
9435
  }
@@ -9467,9 +9493,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
9467
9493
  }
9468
9494
  function defaultReexec() {
9469
9495
  clearRuntimeState();
9470
- void import("child_process").then(({ spawn: spawn5 }) => {
9496
+ void import("child_process").then(({ spawn: spawn4 }) => {
9471
9497
  try {
9472
- const child = spawn5(process.execPath, process.argv.slice(1), {
9498
+ const child = spawn4(process.execPath, process.argv.slice(1), {
9473
9499
  stdio: "inherit",
9474
9500
  detached: false
9475
9501
  });
@@ -9610,7 +9636,8 @@ async function createCompanionRuntime(opts = {}) {
9610
9636
  if (!claim.acquired) {
9611
9637
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
9612
9638
  }
9613
- process.on("exit", () => clearRuntimeStateIfOurs(instanceId));
9639
+ const clearRuntimeOnExit = () => clearRuntimeStateIfOurs(instanceId);
9640
+ process.on("exit", clearRuntimeOnExit);
9614
9641
  const hub = new CompanionStateHub({
9615
9642
  // CT29: one device, one base URL — the cabane instance this device is paired
9616
9643
  // with. The dashboard's connection line shows it.
@@ -9632,49 +9659,73 @@ async function createCompanionRuntime(opts = {}) {
9632
9659
  const currentConfig = supervisor.currentConfig();
9633
9660
  const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
9634
9661
  const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
9635
- const verdict = await shakeOutHarness(runtime, candidate);
9636
- if (verdict === "absent") return { ok: false, error: absentLine(runtime) };
9662
+ const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
9663
+ const verdict = await shakeOutHarness(runtime, candidate, {
9664
+ ...presence ? { presence } : {}
9665
+ });
9666
+ if (verdict === "absent" || verdict === "unusable") {
9667
+ return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
9668
+ }
9637
9669
  const result = await supervisor.enableHarness(
9638
- runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
9670
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
9639
9671
  );
9640
9672
  if (!result.ok) return { ok: false, error: result.error };
9641
9673
  return { ok: true, message: connectedLine(runtime, verdict) };
9642
9674
  };
9643
- const control = await startControlServer({
9644
- status: () => hub.statusJson(),
9645
- connect: async (runtime, serverUrl) => {
9646
- const result = await connectHarness(runtime, serverUrl);
9647
- return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
9648
- },
9649
- stop: () => void supervisor.requestStop()
9650
- });
9675
+ let control = null;
9651
9676
  let dashboard = null;
9652
- if (opts.dashboard) {
9653
- const preferredPort = opts.port ?? cfg.dashboardPort;
9654
- dashboard = await startDashboard({
9655
- supervisor,
9656
- hub,
9657
- ...preferredPort !== void 0 ? { port: preferredPort } : {}
9677
+ try {
9678
+ control = await startControlServer({
9679
+ status: () => hub.statusJson(),
9680
+ connect: async (runtime, serverUrl) => {
9681
+ const result = await connectHarness(runtime, serverUrl);
9682
+ return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
9683
+ },
9684
+ stop: () => void supervisor.requestStop()
9685
+ });
9686
+ if (opts.dashboard) {
9687
+ const preferredPort = opts.port ?? cfg.dashboardPort;
9688
+ dashboard = await startDashboard({
9689
+ supervisor,
9690
+ hub,
9691
+ ...preferredPort !== void 0 ? { port: preferredPort } : {}
9692
+ });
9693
+ hub.setDashboardUrl(dashboard.url);
9694
+ }
9695
+ writeRuntimeState({
9696
+ pid: process.pid,
9697
+ socket: control.path,
9698
+ ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9699
+ startedAt,
9700
+ // SJ495: the daemon launcher sets this env on the detached child, so the
9701
+ // marker records whether this companion is backgrounded (foreground start
9702
+ // leaves it unset → false).
9703
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
9704
+ instanceId
9705
+ });
9706
+ clearCrash();
9707
+ } catch (err) {
9708
+ clearRuntimeStateIfOurs(instanceId);
9709
+ process.removeListener("exit", clearRuntimeOnExit);
9710
+ await Promise.allSettled([
9711
+ supervisor.shutdown(),
9712
+ ...control ? [control.close()] : [],
9713
+ ...dashboard ? [dashboard.close()] : []
9714
+ ]);
9715
+ recordCrash({
9716
+ reason: err instanceof Error ? err.message : String(err),
9717
+ ...errorCode(err) ? { code: errorCode(err) } : {},
9718
+ origin: "startup",
9719
+ at: (/* @__PURE__ */ new Date()).toISOString()
9658
9720
  });
9659
- hub.setDashboardUrl(dashboard.url);
9721
+ throw err;
9660
9722
  }
9661
- writeRuntimeState({
9662
- pid: process.pid,
9663
- socket: control.path,
9664
- ...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
9665
- startedAt,
9666
- // SJ495: the daemon launcher sets this env on the detached child, so the
9667
- // marker records whether this companion is backgrounded (foreground start
9668
- // leaves it unset → false).
9669
- daemon: process.env.CABANE_COMPANION_DAEMON === "1",
9670
- instanceId
9671
- });
9672
- clearCrash();
9673
9723
  let stopped = false;
9674
9724
  const stop = async () => {
9675
9725
  if (stopped) return;
9676
9726
  stopped = true;
9677
9727
  clearRuntimeStateIfOurs(instanceId);
9728
+ process.removeListener("exit", clearRuntimeOnExit);
9678
9729
  try {
9679
9730
  await supervisor.shutdown();
9680
9731
  await closeSurfaces(control, dashboard);