@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.
- package/dist/cli.js +176 -152
- package/dist/runtime.js +1070 -1056
- 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/
|
|
438
|
-
import {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
import
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
return
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
919
|
-
const
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
947
|
-
return new Promise((resolve
|
|
734
|
+
function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
735
|
+
return new Promise((resolve) => {
|
|
948
736
|
let settled = false;
|
|
949
|
-
const
|
|
737
|
+
const done = (v) => {
|
|
950
738
|
if (!settled) {
|
|
951
739
|
settled = true;
|
|
952
|
-
resolve(
|
|
740
|
+
resolve(v);
|
|
953
741
|
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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 (
|
|
1031
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
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
|
-
|
|
783
|
+
return "unverified";
|
|
1055
784
|
}
|
|
1056
785
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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 = (
|
|
820
|
+
const done = (result) => {
|
|
1096
821
|
if (settled) return;
|
|
1097
822
|
settled = true;
|
|
1098
823
|
clearTimeout(timer);
|
|
1099
|
-
|
|
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 =
|
|
828
|
+
child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1147
829
|
} catch {
|
|
1148
|
-
|
|
830
|
+
resolve({ code: null, output: "", error: "spawn" });
|
|
1149
831
|
return;
|
|
1150
832
|
}
|
|
1151
833
|
let out = "";
|
|
1152
|
-
|
|
834
|
+
const capture = (chunk) => {
|
|
1153
835
|
if (out.length < 4096) out += chunk.toString();
|
|
1154
|
-
}
|
|
1155
|
-
child.
|
|
1156
|
-
child.
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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/
|
|
1298
|
-
var
|
|
1299
|
-
"
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
|
1367
|
+
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
|
|
1483
1368
|
if (!settled) {
|
|
1484
1369
|
settled = true;
|
|
1485
|
-
resolve(
|
|
1370
|
+
resolve(server);
|
|
1486
1371
|
}
|
|
1487
|
-
};
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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/
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1467
|
+
function reply(socket, body) {
|
|
1468
|
+
try {
|
|
1469
|
+
socket.end(`${JSON.stringify(body)}
|
|
1470
|
+
`);
|
|
1471
|
+
} catch {
|
|
1472
|
+
socket.destroy();
|
|
1473
|
+
}
|
|
1539
1474
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
|
1509
|
+
function readLine(socket, timeoutMs) {
|
|
1559
1510
|
return new Promise((resolve) => {
|
|
1511
|
+
let buf = "";
|
|
1560
1512
|
let settled = false;
|
|
1561
|
-
const done = (
|
|
1513
|
+
const done = (v) => {
|
|
1562
1514
|
if (settled) return;
|
|
1563
1515
|
settled = true;
|
|
1564
1516
|
clearTimeout(timer);
|
|
1565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
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
|
-
//
|
|
8577
|
-
//
|
|
8578
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
9374
|
+
const presence = input.presence ?? await this.probePresence("claude-code");
|
|
9375
|
+
if (presence.status !== "present") {
|
|
9373
9376
|
return {
|
|
9374
9377
|
ok: false,
|
|
9375
|
-
error: "
|
|
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
|
-
|
|
9381
|
-
|
|
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:
|
|
9479
|
+
void import("child_process").then(({ spawn: spawn4 }) => {
|
|
9471
9480
|
try {
|
|
9472
|
-
const child =
|
|
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
|
|
9636
|
-
|
|
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) };
|