@cabane/companion 0.6.49 → 0.6.50

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 +176 -152
  2. package/dist/runtime.js +1070 -1056
  3. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -434,1158 +434,1161 @@ 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
- }
1170
- }
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;
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");
1185
856
  }
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 };
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;
1196
870
  }
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 } };
871
+ function consoleMessageFormat(log, messageKey) {
872
+ const short = consoleShortId(log);
873
+ const msg = String(log[messageKey] ?? "");
874
+ return short ? `${short} ${msg}` : msg;
1220
875
  }
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
- }
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 ?? "";
1043
+ return {
1044
+ onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
1045
+ onEnd: (info) => this.dispatchEnded(slug, username(), info)
1046
+ };
1047
+ }
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);
1111
+ return {
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
1138
+ };
1139
+ }
1140
+ emitStatus() {
1141
+ this.emit("status:changed", this.statusJson());
1142
+ }
1143
+ };
1296
1144
 
1297
- // src/harness-status.ts
1298
- var HARNESS_LABELS = {
1299
- "claude-code": "Claude Code",
1300
- codex: "Codex",
1301
- opencode: "OpenCode"
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"
1302
1152
  };
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)
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
+ })
1314
1279
  );
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
1280
  }
1322
- function deriveClaudeCode(signals, manifestHas) {
1323
- const base = { runtime: "claude-code", label: LABELS["claude-code"] };
1324
- if (manifestHas) {
1325
- return {
1326
- ...base,
1327
- state: "exposed",
1328
- version: signals.claudeVersion,
1329
- detail: "Claude Code is connected and exposed to Cabane.",
1330
- enable: null
1331
- };
1332
- }
1333
- if (signals.claudeCodeConnected) {
1334
- 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
1340
- };
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 {};
1341
1287
  }
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
- };
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);
1498
1377
  }
1499
- );
1378
+ });
1500
1379
  });
1501
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
+ }
1502
1387
 
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));
1388
+ // src/control-socket.ts
1389
+ import { createHash } from "crypto";
1390
+ import { 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
+ function controlSocketPath() {
1395
+ const dir2 = cabaneDir();
1396
+ if (process.platform === "win32") {
1397
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
1398
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
1399
+ }
1400
+ return join5(dir2, "companion.sock");
1401
+ }
1402
+ async function startControlServer(handlers) {
1403
+ const path = controlSocketPath();
1404
+ mkdirSync3(cabaneDir(), { recursive: true });
1405
+ if (process.platform !== "win32" && existsSync3(path)) {
1406
+ const alive = await ping(path);
1407
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
1408
+ rmSync2(path, { force: true });
1409
+ }
1410
+ const server = createServer((socket) => {
1411
+ void serveConnection(socket, handlers);
1412
+ });
1413
+ server.unref();
1414
+ await new Promise((resolve, reject) => {
1415
+ server.once("error", reject);
1416
+ server.listen(path, () => {
1417
+ server.removeListener("error", reject);
1418
+ resolve();
1419
+ });
1420
+ });
1421
+ server.on("error", () => {
1422
+ });
1423
+ return {
1424
+ path,
1425
+ close: () => new Promise((resolve) => {
1426
+ server.close(() => {
1427
+ if (process.platform !== "win32") rmSync2(path, { force: true });
1428
+ resolve();
1429
+ });
1430
+ })
1431
+ };
1432
+ }
1433
+ async function serveConnection(socket, handlers) {
1434
+ socket.on("error", () => socket.destroy());
1435
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1436
+ if (line === null) {
1437
+ socket.destroy();
1438
+ return;
1439
+ }
1440
+ let req;
1508
1441
  try {
1509
- if (runtime === "opencode") {
1510
- const url = cfg.opencode?.serverUrl;
1511
- if (!url) return "absent";
1512
- return await probeOpencode(url) !== null ? "ok" : "failed";
1442
+ req = JSON.parse(line);
1443
+ } catch {
1444
+ reply(socket, { error: "malformed request" });
1445
+ return;
1446
+ }
1447
+ try {
1448
+ if (req.cmd === "status") {
1449
+ reply(socket, handlers.status());
1450
+ return;
1513
1451
  }
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";
1452
+ if (req.cmd === "connect") {
1453
+ const result = await handlers.connect(req.runtime, req.serverUrl);
1454
+ reply(socket, result);
1455
+ return;
1528
1456
  }
1529
- return "failed";
1530
- } catch {
1531
- return "unverified";
1457
+ if (req.cmd === "stop") {
1458
+ reply(socket, { ok: true });
1459
+ setTimeout(() => handlers.stop(), 50).unref?.();
1460
+ return;
1461
+ }
1462
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1463
+ } catch (err) {
1464
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
1532
1465
  }
1533
1466
  }
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]}`;
1467
+ function reply(socket, body) {
1468
+ try {
1469
+ socket.end(`${JSON.stringify(body)}
1470
+ `);
1471
+ } catch {
1472
+ socket.destroy();
1473
+ }
1539
1474
  }
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`.";
1475
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1476
+ const socket = connect(path);
1477
+ try {
1478
+ await new Promise((resolve, reject) => {
1479
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1480
+ timer.unref?.();
1481
+ socket.once("connect", () => {
1482
+ clearTimeout(timer);
1483
+ resolve();
1484
+ });
1485
+ socket.once("error", (err) => {
1486
+ clearTimeout(timer);
1487
+ reject(err);
1488
+ });
1489
+ });
1490
+ socket.write(`${JSON.stringify(req)}
1491
+ `);
1492
+ const line = await readLine(socket, timeoutMs);
1493
+ if (line === null) throw new ControlTimeout();
1494
+ return JSON.parse(line);
1495
+ } finally {
1496
+ socket.destroy();
1548
1497
  }
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
1498
  }
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
- );
1499
+ var ControlTimeout = class extends Error {
1500
+ constructor() {
1501
+ super("the companion did not answer its control socket in time");
1502
+ this.name = "ControlTimeout";
1503
+ }
1504
+ };
1505
+ function isNotListening(err) {
1506
+ const code = err?.code;
1507
+ return code === "ENOENT" || code === "ECONNREFUSED";
1557
1508
  }
1558
- function runBounded(command, args) {
1509
+ function readLine(socket, timeoutMs) {
1559
1510
  return new Promise((resolve) => {
1511
+ let buf = "";
1560
1512
  let settled = false;
1561
- const done = (result) => {
1513
+ const done = (v) => {
1562
1514
  if (settled) return;
1563
1515
  settled = true;
1564
1516
  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();
1517
+ socket.removeListener("data", onData);
1518
+ resolve(v);
1577
1519
  };
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);
1520
+ const timer = setTimeout(() => done(null), timeoutMs);
1584
1521
  timer.unref?.();
1585
- child.once("error", () => done({ code: null, output: out, error: "spawn" }));
1586
- child.once("exit", (code) => done({ code, output: out }));
1522
+ const onData = (chunk) => {
1523
+ buf += chunk.toString("utf8");
1524
+ const nl = buf.indexOf("\n");
1525
+ if (nl >= 0) done(buf.slice(0, nl));
1526
+ else if (buf.length > 1e6) done(null);
1527
+ };
1528
+ socket.on("data", onData);
1529
+ socket.once("close", () => done(null));
1530
+ socket.once("error", () => done(null));
1587
1531
  });
1588
1532
  }
1533
+ async function ping(path) {
1534
+ try {
1535
+ await controlRequest(path, { cmd: "status" });
1536
+ return true;
1537
+ } catch (err) {
1538
+ return !isNotListening(err);
1539
+ }
1540
+ }
1541
+
1542
+ // src/prereqs.ts
1543
+ async function claudeOnPath() {
1544
+ return (await probeHarnessPresence("claude-code")).status === "present";
1545
+ }
1546
+ async function codexOnPath() {
1547
+ return (await probeHarnessPresence("codex")).status === "present";
1548
+ }
1549
+ async function requireStartConfig(deps = {}) {
1550
+ const requireCfg = deps.requireCfg ?? requireConfig;
1551
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
1552
+ const save2 = deps.save ?? saveConfig;
1553
+ const cfg = requireCfg();
1554
+ const claudeOnPathResult = await probeClaude();
1555
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
1556
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
1557
+ save2(migrated);
1558
+ deps.onMigrated?.(migrated, claudeOnPathResult);
1559
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
1560
+ }
1561
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
1562
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
1563
+ const probeCodex = deps.probeCodex ?? codexOnPath;
1564
+ const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
1565
+ `));
1566
+ const connected = [
1567
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
1568
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
1569
+ ...cfg.opencode ? ["opencode"] : []
1570
+ ];
1571
+ if (connected.length > 0) {
1572
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
1573
+ warn(
1574
+ "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."
1575
+ );
1576
+ }
1577
+ return;
1578
+ }
1579
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1580
+ const installed = [
1581
+ ...claudeInstalled ? ["Claude Code"] : [],
1582
+ ...codexInstalled ? ["Codex"] : []
1583
+ ];
1584
+ const connectCommands = [
1585
+ ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
1586
+ ...codexInstalled ? ["`cabane-companion connect codex`"] : []
1587
+ ];
1588
+ warn(
1589
+ "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).")
1590
+ );
1591
+ }
1589
1592
 
1590
1593
  // src/runtime-file.ts
1591
1594
  import {
@@ -8573,11 +8576,9 @@ var CompanionSupervisor = class {
8573
8576
  // can never disagree with what the manifest advertises. Null until the first
8574
8577
  // probe (the heartbeat then falls back to the boot `claudeCode`).
8575
8578
  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;
8579
+ // Fresh per attempt, never the cached heartbeat signal: installing a harness
8580
+ // between attempts must be observed immediately.
8581
+ probePresence;
8581
8582
  exitFn;
8582
8583
  reexecFn;
8583
8584
  dispatcherFactory;
@@ -8609,8 +8610,7 @@ var CompanionSupervisor = class {
8609
8610
  this.log = opts.log;
8610
8611
  this.hub = opts.hub;
8611
8612
  this.claudeCode = opts.claudeCode ?? true;
8612
- this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
8613
- this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
8613
+ this.probePresence = opts.probePresence ?? probeHarnessPresence;
8614
8614
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
8615
8615
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
8616
8616
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -9308,10 +9308,11 @@ var CompanionSupervisor = class {
9308
9308
  // over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
9309
9309
  // wrapped anyway so a harness refresh can never take a heartbeat down. Called on
9310
9310
  // the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
9311
- async refreshHarnessStatuses() {
9311
+ async refreshHarnessStatuses(presence = {}) {
9312
9312
  try {
9313
9313
  const signals = await probeHarnessSignals(this.config, {
9314
- probeClaudePresence: this.probeClaudePresence
9314
+ probePresence: this.probePresence,
9315
+ presence
9315
9316
  });
9316
9317
  this.harnessSignals = signals;
9317
9318
  this.harnessVersions = {
@@ -9368,18 +9369,24 @@ var CompanionSupervisor = class {
9368
9369
  // reachable/valid input).
9369
9370
  async enableHarness(input) {
9370
9371
  let next;
9372
+ let checkedPresence = null;
9371
9373
  if (input.runtime === "claude-code") {
9372
- if (!await this.probeClaudePresence()) {
9374
+ const presence = input.presence ?? await this.probePresence("claude-code");
9375
+ if (presence.status !== "present") {
9373
9376
  return {
9374
9377
  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."
9378
+ error: presenceReason("claude-code", presence.status),
9379
+ presence: true
9376
9380
  };
9377
9381
  }
9382
+ checkedPresence = { runtime: "claude-code", result: presence };
9378
9383
  next = { ...this.config, claudeCode: { enabled: true } };
9379
9384
  } else if (input.runtime === "codex") {
9380
- if (!await this.probeCodexPresence()) {
9381
- return { ok: false, error: absentLine("codex") };
9385
+ const presence = input.presence ?? await this.probePresence("codex");
9386
+ if (presence.status !== "present") {
9387
+ return { ok: false, error: presenceReason("codex", presence.status), presence: true };
9382
9388
  }
9389
+ checkedPresence = { runtime: "codex", result: presence };
9383
9390
  next = { ...this.config, codex: { enabled: true } };
9384
9391
  } else {
9385
9392
  const serverUrl = input.serverUrl.trim();
@@ -9403,7 +9410,9 @@ var CompanionSupervisor = class {
9403
9410
  this.config = next;
9404
9411
  saveConfig(next);
9405
9412
  this.rebuildDispatchers();
9406
- await this.refreshHarnessStatuses();
9413
+ await this.refreshHarnessStatuses(
9414
+ checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
9415
+ );
9407
9416
  this.kickHeartbeat();
9408
9417
  return { ok: true };
9409
9418
  }
@@ -9467,9 +9476,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
9467
9476
  }
9468
9477
  function defaultReexec() {
9469
9478
  clearRuntimeState();
9470
- void import("child_process").then(({ spawn: spawn5 }) => {
9479
+ void import("child_process").then(({ spawn: spawn4 }) => {
9471
9480
  try {
9472
- const child = spawn5(process.execPath, process.argv.slice(1), {
9481
+ const child = spawn4(process.execPath, process.argv.slice(1), {
9473
9482
  stdio: "inherit",
9474
9483
  detached: false
9475
9484
  });
@@ -9632,10 +9641,15 @@ async function createCompanionRuntime(opts = {}) {
9632
9641
  const currentConfig = supervisor.currentConfig();
9633
9642
  const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
9634
9643
  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) };
9644
+ const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
9645
+ const verdict = await shakeOutHarness(runtime, candidate, {
9646
+ ...presence ? { presence } : {}
9647
+ });
9648
+ if (verdict === "absent" || verdict === "unusable") {
9649
+ return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
9650
+ }
9637
9651
  const result = await supervisor.enableHarness(
9638
- runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
9652
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
9639
9653
  );
9640
9654
  if (!result.ok) return { ok: false, error: result.error };
9641
9655
  return { ok: true, message: connectedLine(runtime, verdict) };