@cabane/companion 0.6.49 → 0.6.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +251 -190
- package/dist/runtime.js +1142 -1091
- package/package.json +1 -1
package/dist/runtime.js
CHANGED
|
@@ -434,1158 +434,1178 @@ import { readFile } from "fs/promises";
|
|
|
434
434
|
import { extname, join as join3, normalize } from "path";
|
|
435
435
|
import { streamSSE } from "hono/streaming";
|
|
436
436
|
|
|
437
|
-
// src/
|
|
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
|
-
}
|
|
848
|
+
|
|
849
|
+
// src/logger.ts
|
|
850
|
+
import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
851
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
852
|
+
import pino from "pino";
|
|
853
|
+
import pretty from "pino-pretty";
|
|
854
|
+
function companionLogPath() {
|
|
855
|
+
return join2(cabaneDir(), "companion.log");
|
|
1170
856
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
return null;
|
|
857
|
+
var CONSOLE_IGNORE = [
|
|
858
|
+
"pid",
|
|
859
|
+
"hostname",
|
|
860
|
+
"workspaceId",
|
|
861
|
+
"conversationId",
|
|
862
|
+
"agentId",
|
|
863
|
+
"messageId",
|
|
864
|
+
"sessionId",
|
|
865
|
+
"companionId"
|
|
866
|
+
].join(",");
|
|
867
|
+
function consoleShortId(log) {
|
|
868
|
+
const id = log.conversationId ?? log.workspaceId;
|
|
869
|
+
return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
|
|
1185
870
|
}
|
|
1186
|
-
|
|
1187
|
-
const
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
const [claudeCode, codex, opencode] = await Promise.all([
|
|
1191
|
-
opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
|
|
1192
|
-
opts.codex ? safe(probeCodex) : Promise.resolve(null),
|
|
1193
|
-
opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
|
|
1194
|
-
]);
|
|
1195
|
-
return { claudeCode, opencode, codex };
|
|
871
|
+
function consoleMessageFormat(log, messageKey) {
|
|
872
|
+
const short = consoleShortId(log);
|
|
873
|
+
const msg = String(log[messageKey] ?? "");
|
|
874
|
+
return short ? `${short} ${msg}` : msg;
|
|
1196
875
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
var DEVICE_MANIFEST = {
|
|
1210
|
-
runtimes: [{ name: "claude-code", version: null }],
|
|
1211
|
-
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
1212
|
-
};
|
|
1213
|
-
function buildCompanionManifest(opts) {
|
|
1214
|
-
const v = opts.versions ?? {};
|
|
1215
|
-
const runtimes = [];
|
|
1216
|
-
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
1217
|
-
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
1218
|
-
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
1219
|
-
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// src/prereqs.ts
|
|
1223
|
-
import { spawn as spawn3 } from "child_process";
|
|
1224
|
-
async function claudeOnPath() {
|
|
1225
|
-
return new Promise((resolve) => {
|
|
1226
|
-
let settled = false;
|
|
1227
|
-
const child = spawn3("claude", ["--version"], { stdio: "ignore" });
|
|
1228
|
-
child.once("error", () => {
|
|
1229
|
-
if (!settled) {
|
|
1230
|
-
settled = true;
|
|
1231
|
-
resolve(false);
|
|
1232
|
-
}
|
|
876
|
+
var cached = null;
|
|
877
|
+
var consoleLogging = true;
|
|
878
|
+
function createLogger(destinations = {}) {
|
|
879
|
+
const path = companionLogPath();
|
|
880
|
+
if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
|
|
881
|
+
const streams = [];
|
|
882
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
883
|
+
const consoleStream = pretty({
|
|
884
|
+
colorize: true,
|
|
885
|
+
ignore: CONSOLE_IGNORE,
|
|
886
|
+
messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
|
|
887
|
+
...destinations.console ? { destination: destinations.console } : {}
|
|
1233
888
|
});
|
|
1234
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
998
|
+
removeWorkspace(workspaceId) {
|
|
999
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1000
|
+
if (!ws) return;
|
|
1001
|
+
this.workspaces.delete(workspaceId);
|
|
1002
|
+
this.emit("workspace:disconnected", { slug: ws.slug });
|
|
1003
|
+
this.emitStatus();
|
|
1004
|
+
}
|
|
1005
|
+
setConnected(workspaceId, connected) {
|
|
1006
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1007
|
+
if (!ws || ws.connected === connected) return;
|
|
1008
|
+
ws.connected = connected;
|
|
1009
|
+
if (connected) ws.authFailed = false;
|
|
1010
|
+
this.emitStatus();
|
|
1011
|
+
}
|
|
1012
|
+
setAuthFailed(workspaceId) {
|
|
1013
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1014
|
+
if (!ws) return;
|
|
1015
|
+
if (ws.authFailed && !ws.connected) return;
|
|
1016
|
+
ws.authFailed = true;
|
|
1017
|
+
ws.connected = false;
|
|
1018
|
+
this.emitStatus();
|
|
1019
|
+
}
|
|
1020
|
+
recordEvent(workspaceId) {
|
|
1021
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1022
|
+
if (!ws) return;
|
|
1023
|
+
ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1024
|
+
}
|
|
1025
|
+
// ---- agents ----
|
|
1026
|
+
// Upsert one assigned agent's observable state (called on each reconcile).
|
|
1027
|
+
setAgent(workspaceId, agent) {
|
|
1028
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1029
|
+
if (!ws) return;
|
|
1030
|
+
ws.agents.set(agent.agentId, agent);
|
|
1031
|
+
this.emitStatus();
|
|
1032
|
+
}
|
|
1033
|
+
removeAgent(workspaceId, agentId) {
|
|
1034
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1035
|
+
if (!ws) return;
|
|
1036
|
+
if (ws.agents.delete(agentId)) this.emitStatus();
|
|
1037
|
+
}
|
|
1038
|
+
// ---- dispatch feed ----
|
|
1039
|
+
// A DispatchObserver bound to one (workspace, agent), handed to that agent's
|
|
1040
|
+
// Dispatcher by the supervisor.
|
|
1041
|
+
observerFor(workspaceId, agentId, slug) {
|
|
1042
|
+
const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
|
|
1325
1043
|
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
version: signals.claudeVersion,
|
|
1329
|
-
detail: "Claude Code is connected and exposed to Cabane.",
|
|
1330
|
-
enable: null
|
|
1044
|
+
onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
|
|
1045
|
+
onEnd: (info) => this.dispatchEnded(slug, username(), info)
|
|
1331
1046
|
};
|
|
1332
1047
|
}
|
|
1333
|
-
|
|
1048
|
+
bumpCount(workspaceId) {
|
|
1049
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1050
|
+
if (!ws) return;
|
|
1051
|
+
const day = today();
|
|
1052
|
+
if (ws.countDate !== day) {
|
|
1053
|
+
ws.countDate = day;
|
|
1054
|
+
ws.dispatchCountToday = 0;
|
|
1055
|
+
}
|
|
1056
|
+
ws.dispatchCountToday += 1;
|
|
1057
|
+
}
|
|
1058
|
+
dispatchStarted(workspaceId, slug, agentUsername, info) {
|
|
1059
|
+
this.bumpCount(workspaceId);
|
|
1060
|
+
const record = {
|
|
1061
|
+
id: info.id,
|
|
1062
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1063
|
+
workspaceSlug: slug,
|
|
1064
|
+
...agentUsername ? { agentUsername } : {},
|
|
1065
|
+
message: info.message,
|
|
1066
|
+
status: "running"
|
|
1067
|
+
};
|
|
1068
|
+
this.upsertDispatch(record);
|
|
1069
|
+
this.emit("dispatch:started", dispatchToWire(record));
|
|
1070
|
+
this.emitStatus();
|
|
1071
|
+
}
|
|
1072
|
+
dispatchEnded(slug, agentUsername, info) {
|
|
1073
|
+
const existing = this.dispatches.find((d) => d.id === info.id);
|
|
1074
|
+
const record = {
|
|
1075
|
+
id: info.id,
|
|
1076
|
+
timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1077
|
+
workspaceSlug: existing?.workspaceSlug ?? slug,
|
|
1078
|
+
...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
|
|
1079
|
+
message: existing?.message ?? "",
|
|
1080
|
+
status: info.ok ? "replied" : "error",
|
|
1081
|
+
durationMs: info.durationMs,
|
|
1082
|
+
...info.reason ? { error: info.reason } : {},
|
|
1083
|
+
...info.reply ? { reply: info.reply } : {}
|
|
1084
|
+
};
|
|
1085
|
+
this.upsertDispatch(record);
|
|
1086
|
+
this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
|
|
1087
|
+
}
|
|
1088
|
+
upsertDispatch(record) {
|
|
1089
|
+
const idx = this.dispatches.findIndex((d) => d.id === record.id);
|
|
1090
|
+
if (idx >= 0) {
|
|
1091
|
+
this.dispatches[idx] = record;
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
this.dispatches.unshift(record);
|
|
1095
|
+
if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
|
|
1096
|
+
}
|
|
1097
|
+
// ---- snapshots for the HTTP routes ----
|
|
1098
|
+
setDashboardUrl(url) {
|
|
1099
|
+
this.dashboardUrl = url;
|
|
1100
|
+
}
|
|
1101
|
+
recentDispatches(limit = 20) {
|
|
1102
|
+
return this.dispatches.slice(0, Math.max(0, limit));
|
|
1103
|
+
}
|
|
1104
|
+
statusJson() {
|
|
1105
|
+
const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1106
|
+
const lastOverall = list.reduce((acc, ws) => {
|
|
1107
|
+
if (!ws.lastEventAt) return acc;
|
|
1108
|
+
if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
|
|
1109
|
+
return acc;
|
|
1110
|
+
}, null);
|
|
1334
1111
|
return {
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1112
|
+
cabane_url: this.opts.baseUrl,
|
|
1113
|
+
device_id: this.deviceId,
|
|
1114
|
+
device_label: this.deviceLabel,
|
|
1115
|
+
device_error: this.deviceError,
|
|
1116
|
+
connected: list.some((ws) => ws.connected),
|
|
1117
|
+
workspaces: list.map((ws) => ({
|
|
1118
|
+
slug: ws.slug,
|
|
1119
|
+
name: ws.name,
|
|
1120
|
+
last_event_at: ws.lastEventAt,
|
|
1121
|
+
dispatch_count_today: ws.dispatchCountToday,
|
|
1122
|
+
connected: ws.connected,
|
|
1123
|
+
auth_failed: ws.authFailed,
|
|
1124
|
+
agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
|
|
1125
|
+
agent_id: a.agentId,
|
|
1126
|
+
username: a.username,
|
|
1127
|
+
display_name: a.displayName,
|
|
1128
|
+
mode: a.mode,
|
|
1129
|
+
has_credential: a.hasCredential,
|
|
1130
|
+
missing_secrets: a.missingSecrets
|
|
1131
|
+
}))
|
|
1132
|
+
})),
|
|
1133
|
+
last_event_at_overall: lastOverall,
|
|
1134
|
+
dashboard_url: this.dashboardUrl,
|
|
1135
|
+
companion_version: this.opts.companionVersion,
|
|
1136
|
+
instance_id: this.opts.instanceId ?? null,
|
|
1137
|
+
harnesses: this.harnesses
|
|
1340
1138
|
};
|
|
1341
1139
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1140
|
+
emitStatus() {
|
|
1141
|
+
this.emit("status:changed", this.statusJson());
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// src/dashboard/routes.ts
|
|
1146
|
+
var CONTENT_TYPES = {
|
|
1147
|
+
".html": "text/html; charset=utf-8",
|
|
1148
|
+
".css": "text/css; charset=utf-8",
|
|
1149
|
+
".js": "text/javascript; charset=utf-8",
|
|
1150
|
+
".svg": "image/svg+xml",
|
|
1151
|
+
".ico": "image/x-icon"
|
|
1152
|
+
};
|
|
1153
|
+
function registerRoutes(app, deps) {
|
|
1154
|
+
const { supervisor, hub, staticDir } = deps;
|
|
1155
|
+
app.get("/", async (c) => {
|
|
1156
|
+
const html = await readFile(join3(staticDir, "index.html"), "utf8");
|
|
1157
|
+
return c.html(html);
|
|
1158
|
+
});
|
|
1159
|
+
app.get("/static/:file", async (c) => {
|
|
1160
|
+
const file = c.req.param("file");
|
|
1161
|
+
const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
|
|
1162
|
+
const full = join3(staticDir, safe3);
|
|
1163
|
+
if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
|
|
1164
|
+
const body = await readFile(full);
|
|
1165
|
+
const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
|
|
1166
|
+
c.header("content-type", type);
|
|
1167
|
+
return c.body(body);
|
|
1168
|
+
});
|
|
1169
|
+
app.get("/api/status", (c) => c.json(hub.statusJson()));
|
|
1170
|
+
app.get("/api/dispatches", (c) => {
|
|
1171
|
+
const limit = clampLimit(c.req.query("limit"), 20);
|
|
1172
|
+
return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
|
|
1173
|
+
});
|
|
1174
|
+
app.get("/api/logs", async (c) => {
|
|
1175
|
+
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
1176
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
1177
|
+
});
|
|
1178
|
+
app.post("/api/settings", async (c) => {
|
|
1179
|
+
const body = await readJson(c);
|
|
1180
|
+
const patch = {};
|
|
1181
|
+
if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
|
|
1182
|
+
patch.logLevel = body.logLevel;
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
|
|
1185
|
+
supervisor.applySettings(patch);
|
|
1186
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1187
|
+
});
|
|
1188
|
+
app.post("/api/control", async (c) => {
|
|
1189
|
+
const body = await readJson(c);
|
|
1190
|
+
const action = body.action;
|
|
1191
|
+
if (action === "stop") {
|
|
1192
|
+
setTimeout(() => void supervisor.requestStop(), 50);
|
|
1193
|
+
return c.json({ ok: true, action: "stop" });
|
|
1194
|
+
}
|
|
1195
|
+
if (action === "restart") {
|
|
1196
|
+
setTimeout(() => supervisor.requestRestart(), 50);
|
|
1197
|
+
return c.json({ ok: true, action: "restart" });
|
|
1198
|
+
}
|
|
1199
|
+
if (action === "reload-config") {
|
|
1200
|
+
supervisor.reloadConfig();
|
|
1201
|
+
return c.json({ ok: true, action: "reload-config" });
|
|
1202
|
+
}
|
|
1203
|
+
if (action === "refresh") {
|
|
1204
|
+
supervisor.refresh();
|
|
1205
|
+
return c.json({ ok: true, action: "refresh" });
|
|
1206
|
+
}
|
|
1207
|
+
return c.json({ error: `unknown action "${String(action)}"` }, 400);
|
|
1208
|
+
});
|
|
1209
|
+
app.post("/api/harnesses/recheck", async (c) => {
|
|
1210
|
+
await supervisor.recheckHarnesses();
|
|
1211
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1212
|
+
});
|
|
1213
|
+
app.post("/api/harnesses/enable", async (c) => {
|
|
1214
|
+
const body = await readJson(c);
|
|
1215
|
+
const runtime = body.runtime;
|
|
1216
|
+
if (runtime === "claude-code") {
|
|
1217
|
+
const result = await supervisor.enableHarness({ runtime: "claude-code" });
|
|
1218
|
+
if (!result.ok) {
|
|
1219
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
1220
|
+
return c.json({ error }, 400);
|
|
1221
|
+
}
|
|
1222
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1223
|
+
}
|
|
1224
|
+
if (runtime === "codex") {
|
|
1225
|
+
const result = await supervisor.enableHarness({ runtime: "codex" });
|
|
1226
|
+
if (!result.ok) {
|
|
1227
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
1228
|
+
return c.json({ error }, 400);
|
|
1229
|
+
}
|
|
1230
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1231
|
+
}
|
|
1232
|
+
if (runtime === "opencode") {
|
|
1233
|
+
if (typeof body.serverUrl !== "string" || body.serverUrl.trim() === "") {
|
|
1234
|
+
return c.json({ error: "An opencode server URL is required." }, 400);
|
|
1235
|
+
}
|
|
1236
|
+
const result = await supervisor.enableHarness({
|
|
1237
|
+
runtime: "opencode",
|
|
1238
|
+
serverUrl: body.serverUrl
|
|
1239
|
+
});
|
|
1240
|
+
if (!result.ok) return c.json({ error: result.error }, 400);
|
|
1241
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1242
|
+
}
|
|
1243
|
+
return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
|
|
1244
|
+
});
|
|
1245
|
+
app.get(
|
|
1246
|
+
"/api/events",
|
|
1247
|
+
(c) => streamSSE(c, async (stream) => {
|
|
1248
|
+
await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
|
|
1249
|
+
const queue = [];
|
|
1250
|
+
let wake = null;
|
|
1251
|
+
const unsubscribe = hub.on((ev) => {
|
|
1252
|
+
queue.push(ev);
|
|
1253
|
+
wake?.();
|
|
1254
|
+
});
|
|
1255
|
+
stream.onAbort(() => {
|
|
1256
|
+
unsubscribe();
|
|
1257
|
+
wake?.();
|
|
1258
|
+
});
|
|
1259
|
+
while (!stream.aborted) {
|
|
1260
|
+
if (queue.length === 0) {
|
|
1261
|
+
await Promise.race([
|
|
1262
|
+
new Promise((resolve) => {
|
|
1263
|
+
wake = resolve;
|
|
1264
|
+
}),
|
|
1265
|
+
stream.sleep(25e3)
|
|
1266
|
+
]);
|
|
1267
|
+
wake = null;
|
|
1268
|
+
}
|
|
1269
|
+
if (stream.aborted) break;
|
|
1270
|
+
if (queue.length === 0) {
|
|
1271
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const ev = queue.shift();
|
|
1275
|
+
await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
|
|
1276
|
+
}
|
|
1277
|
+
unsubscribe();
|
|
1278
|
+
})
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
async function readJson(c) {
|
|
1282
|
+
try {
|
|
1283
|
+
const parsed = await c.req.json();
|
|
1284
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1285
|
+
} catch {
|
|
1286
|
+
return {};
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
function clampLimit(raw, fallback, max = 200) {
|
|
1290
|
+
if (!raw) return fallback;
|
|
1291
|
+
const n = Number(raw);
|
|
1292
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
1293
|
+
return Math.min(Math.floor(n), max);
|
|
1294
|
+
}
|
|
1295
|
+
function tailFile(path, lines) {
|
|
1296
|
+
if (!existsSync2(path)) return [];
|
|
1297
|
+
const MAX_BYTES = 256 * 1024;
|
|
1298
|
+
let fd;
|
|
1299
|
+
try {
|
|
1300
|
+
fd = openSync(path, "r");
|
|
1301
|
+
const size = fstatSync(fd).size;
|
|
1302
|
+
const start = Math.max(0, size - MAX_BYTES);
|
|
1303
|
+
const len = size - start;
|
|
1304
|
+
if (len <= 0) return [];
|
|
1305
|
+
const buf = Buffer.alloc(len);
|
|
1306
|
+
readSync(fd, buf, 0, len, start);
|
|
1307
|
+
const text = buf.toString("utf8");
|
|
1308
|
+
const all = text.split("\n");
|
|
1309
|
+
if (start > 0 && all.length > 0) all.shift();
|
|
1310
|
+
return all.filter((l) => l.length > 0).slice(-lines);
|
|
1311
|
+
} catch {
|
|
1312
|
+
return [];
|
|
1313
|
+
} finally {
|
|
1314
|
+
if (fd !== void 0) closeSync(fd);
|
|
1350
1315
|
}
|
|
1351
|
-
return {
|
|
1352
|
-
...base,
|
|
1353
|
-
state: "not_detected",
|
|
1354
|
-
version: null,
|
|
1355
|
-
detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
|
|
1356
|
-
enable: null
|
|
1357
|
-
};
|
|
1358
1316
|
}
|
|
1359
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1372
|
+
});
|
|
1373
|
+
server.on("error", (err) => {
|
|
1374
|
+
if (!settled) {
|
|
1375
|
+
settled = true;
|
|
1376
|
+
reject(err);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
function isAddrInUse(err) {
|
|
1382
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
|
|
1383
|
+
}
|
|
1384
|
+
function resolveStaticDir() {
|
|
1385
|
+
return join4(dirname3(fileURLToPath(import.meta.url)), "static");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// src/control-socket.ts
|
|
1389
|
+
import { createHash } from "crypto";
|
|
1390
|
+
import { chmodSync as chmodSync2, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
1391
|
+
import { createServer, connect } from "net";
|
|
1392
|
+
import { join as join5 } from "path";
|
|
1393
|
+
var CONTROL_TIMEOUT_MS = 1e3;
|
|
1394
|
+
var MAX_UNIX_SOCKET_PATH_BYTES = 103;
|
|
1395
|
+
function controlSocketPath() {
|
|
1396
|
+
const dir2 = cabaneDir();
|
|
1397
|
+
const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
|
|
1398
|
+
if (process.platform === "win32") {
|
|
1399
|
+
return `\\\\.\\pipe\\cabane-companion-${key}`;
|
|
1400
|
+
}
|
|
1401
|
+
const besideRuntime = join5(dir2, "companion.sock");
|
|
1402
|
+
if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
|
|
1403
|
+
const filename = `cabane-companion-${key}.sock`;
|
|
1404
|
+
const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync3(process.env.XDG_RUNTIME_DIR) ? join5(process.env.XDG_RUNTIME_DIR, filename) : null;
|
|
1405
|
+
return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join5("/tmp", filename);
|
|
1406
|
+
}
|
|
1407
|
+
async function startControlServer(handlers) {
|
|
1408
|
+
const path = controlSocketPath();
|
|
1409
|
+
mkdirSync3(cabaneDir(), { recursive: true });
|
|
1410
|
+
if (process.platform !== "win32" && existsSync3(path)) {
|
|
1411
|
+
const alive = await ping(path);
|
|
1412
|
+
if (alive) throw new Error(`another companion is already listening on ${path}`);
|
|
1413
|
+
rmSync2(path, { force: true });
|
|
1414
|
+
}
|
|
1415
|
+
const server = createServer((socket) => {
|
|
1416
|
+
void serveConnection(socket, handlers);
|
|
1417
|
+
});
|
|
1418
|
+
server.unref();
|
|
1419
|
+
await new Promise((resolve, reject) => {
|
|
1420
|
+
server.once("error", reject);
|
|
1421
|
+
server.listen(path, () => {
|
|
1422
|
+
server.removeListener("error", reject);
|
|
1423
|
+
resolve();
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
1426
|
+
if (process.platform !== "win32") {
|
|
1427
|
+
try {
|
|
1428
|
+
chmodSync2(path, 384);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1431
|
+
try {
|
|
1432
|
+
rmSync2(path, { force: true });
|
|
1433
|
+
} catch {
|
|
1498
1434
|
}
|
|
1499
|
-
|
|
1435
|
+
throw err;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
server.on("error", () => {
|
|
1500
1439
|
});
|
|
1440
|
+
return {
|
|
1441
|
+
path,
|
|
1442
|
+
close: () => new Promise((resolve) => {
|
|
1443
|
+
server.close(() => {
|
|
1444
|
+
if (process.platform !== "win32") rmSync2(path, { force: true });
|
|
1445
|
+
resolve();
|
|
1446
|
+
});
|
|
1447
|
+
})
|
|
1448
|
+
};
|
|
1501
1449
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1450
|
+
async function serveConnection(socket, handlers) {
|
|
1451
|
+
socket.on("error", () => socket.destroy());
|
|
1452
|
+
const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
|
|
1453
|
+
if (line === null) {
|
|
1454
|
+
socket.destroy();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
let req;
|
|
1508
1458
|
try {
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1459
|
+
req = JSON.parse(line);
|
|
1460
|
+
} catch {
|
|
1461
|
+
reply(socket, { error: "malformed request" });
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
if (req.cmd === "status") {
|
|
1466
|
+
reply(socket, handlers.status());
|
|
1467
|
+
return;
|
|
1513
1468
|
}
|
|
1514
|
-
|
|
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";
|
|
1469
|
+
if (req.cmd === "connect") {
|
|
1470
|
+
const result = await handlers.connect(req.runtime, req.serverUrl);
|
|
1471
|
+
reply(socket, result);
|
|
1472
|
+
return;
|
|
1528
1473
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1474
|
+
if (req.cmd === "stop") {
|
|
1475
|
+
reply(socket, { ok: true });
|
|
1476
|
+
setTimeout(() => handlers.stop(), 50).unref?.();
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
reply(socket, { error: `unknown command "${String(req.cmd)}"` });
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
reply(socket, { error: err instanceof Error ? err.message : String(err) });
|
|
1532
1482
|
}
|
|
1533
1483
|
}
|
|
1534
|
-
function
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1484
|
+
function reply(socket, body) {
|
|
1485
|
+
try {
|
|
1486
|
+
socket.end(`${JSON.stringify(body)}
|
|
1487
|
+
`);
|
|
1488
|
+
} catch {
|
|
1489
|
+
socket.destroy();
|
|
1490
|
+
}
|
|
1539
1491
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1492
|
+
async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
|
|
1493
|
+
const socket = connect(path);
|
|
1494
|
+
try {
|
|
1495
|
+
await new Promise((resolve, reject) => {
|
|
1496
|
+
const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
|
|
1497
|
+
timer.unref?.();
|
|
1498
|
+
socket.once("connect", () => {
|
|
1499
|
+
clearTimeout(timer);
|
|
1500
|
+
resolve();
|
|
1501
|
+
});
|
|
1502
|
+
socket.once("error", (err) => {
|
|
1503
|
+
clearTimeout(timer);
|
|
1504
|
+
reject(err);
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
socket.write(`${JSON.stringify(req)}
|
|
1508
|
+
`);
|
|
1509
|
+
const line = await readLine(socket, timeoutMs);
|
|
1510
|
+
if (line === null) throw new ControlTimeout();
|
|
1511
|
+
return JSON.parse(line);
|
|
1512
|
+
} finally {
|
|
1513
|
+
socket.destroy();
|
|
1548
1514
|
}
|
|
1549
|
-
const label = HARNESS_LABELS[runtime];
|
|
1550
|
-
const login = runtime === "codex" ? "`codex login`" : "`claude`";
|
|
1551
|
-
return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
|
|
1552
1515
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1516
|
+
var ControlTimeout = class extends Error {
|
|
1517
|
+
constructor() {
|
|
1518
|
+
super("the companion did not answer its control socket in time");
|
|
1519
|
+
this.name = "ControlTimeout";
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
function isNotListening(err) {
|
|
1523
|
+
const code = err?.code;
|
|
1524
|
+
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
1557
1525
|
}
|
|
1558
|
-
function
|
|
1526
|
+
function readLine(socket, timeoutMs) {
|
|
1559
1527
|
return new Promise((resolve) => {
|
|
1528
|
+
let buf = "";
|
|
1560
1529
|
let settled = false;
|
|
1561
|
-
const done = (
|
|
1530
|
+
const done = (v) => {
|
|
1562
1531
|
if (settled) return;
|
|
1563
1532
|
settled = true;
|
|
1564
1533
|
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();
|
|
1534
|
+
socket.removeListener("data", onData);
|
|
1535
|
+
resolve(v);
|
|
1577
1536
|
};
|
|
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);
|
|
1537
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
1584
1538
|
timer.unref?.();
|
|
1585
|
-
|
|
1586
|
-
|
|
1539
|
+
const onData = (chunk) => {
|
|
1540
|
+
buf += chunk.toString("utf8");
|
|
1541
|
+
const nl = buf.indexOf("\n");
|
|
1542
|
+
if (nl >= 0) done(buf.slice(0, nl));
|
|
1543
|
+
else if (buf.length > 1e6) done(null);
|
|
1544
|
+
};
|
|
1545
|
+
socket.on("data", onData);
|
|
1546
|
+
socket.once("close", () => done(null));
|
|
1547
|
+
socket.once("error", () => done(null));
|
|
1587
1548
|
});
|
|
1588
1549
|
}
|
|
1550
|
+
async function ping(path) {
|
|
1551
|
+
try {
|
|
1552
|
+
await controlRequest(path, { cmd: "status" });
|
|
1553
|
+
return true;
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
return !isNotListening(err);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/prereqs.ts
|
|
1560
|
+
async function claudeOnPath() {
|
|
1561
|
+
return (await probeHarnessPresence("claude-code")).status === "present";
|
|
1562
|
+
}
|
|
1563
|
+
async function codexOnPath() {
|
|
1564
|
+
return (await probeHarnessPresence("codex")).status === "present";
|
|
1565
|
+
}
|
|
1566
|
+
async function requireStartConfig(deps = {}) {
|
|
1567
|
+
const requireCfg = deps.requireCfg ?? requireConfig;
|
|
1568
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
1569
|
+
const save2 = deps.save ?? saveConfig;
|
|
1570
|
+
const cfg = requireCfg();
|
|
1571
|
+
const claudeOnPathResult = await probeClaude();
|
|
1572
|
+
const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
|
|
1573
|
+
if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
|
|
1574
|
+
save2(migrated);
|
|
1575
|
+
deps.onMigrated?.(migrated, claudeOnPathResult);
|
|
1576
|
+
return { cfg: migrated, claudeOnPath: claudeOnPathResult };
|
|
1577
|
+
}
|
|
1578
|
+
async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
1579
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
1580
|
+
const probeCodex = deps.probeCodex ?? codexOnPath;
|
|
1581
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
|
|
1582
|
+
`));
|
|
1583
|
+
const connected = [
|
|
1584
|
+
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
1585
|
+
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
1586
|
+
...cfg.opencode ? ["opencode"] : []
|
|
1587
|
+
];
|
|
1588
|
+
if (connected.length > 0) {
|
|
1589
|
+
if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
|
|
1590
|
+
warn(
|
|
1591
|
+
"warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
|
|
1597
|
+
const installed = [
|
|
1598
|
+
...claudeInstalled ? ["Claude Code"] : [],
|
|
1599
|
+
...codexInstalled ? ["Codex"] : []
|
|
1600
|
+
];
|
|
1601
|
+
const connectCommands = [
|
|
1602
|
+
...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
|
|
1603
|
+
...codexInstalled ? ["`cabane-companion connect codex`"] : []
|
|
1604
|
+
];
|
|
1605
|
+
warn(
|
|
1606
|
+
"No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1589
1609
|
|
|
1590
1610
|
// src/runtime-file.ts
|
|
1591
1611
|
import {
|
|
@@ -2159,7 +2179,7 @@ function errorMessage2(status, body) {
|
|
|
2159
2179
|
|
|
2160
2180
|
// src/credentials.ts
|
|
2161
2181
|
import {
|
|
2162
|
-
chmodSync as
|
|
2182
|
+
chmodSync as chmodSync3,
|
|
2163
2183
|
existsSync as existsSync5,
|
|
2164
2184
|
mkdirSync as mkdirSync5,
|
|
2165
2185
|
readFileSync as readFileSync3,
|
|
@@ -2194,14 +2214,14 @@ function save(map) {
|
|
|
2194
2214
|
const path = credentialsPath();
|
|
2195
2215
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
2196
2216
|
try {
|
|
2197
|
-
|
|
2217
|
+
chmodSync3(cabaneDir(), 448);
|
|
2198
2218
|
} catch {
|
|
2199
2219
|
}
|
|
2200
2220
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
2201
2221
|
try {
|
|
2202
2222
|
writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
|
|
2203
2223
|
try {
|
|
2204
|
-
|
|
2224
|
+
chmodSync3(tmp, 384);
|
|
2205
2225
|
} catch {
|
|
2206
2226
|
}
|
|
2207
2227
|
renameSync2(tmp, path);
|
|
@@ -6684,7 +6704,7 @@ function resolveMcpSecrets(mcpServers, store) {
|
|
|
6684
6704
|
}
|
|
6685
6705
|
|
|
6686
6706
|
// src/transcript-writer.ts
|
|
6687
|
-
import { appendFileSync, chmodSync as
|
|
6707
|
+
import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
|
|
6688
6708
|
import { basename, dirname as dirname5, join as join13 } from "path";
|
|
6689
6709
|
function transcriptsDir() {
|
|
6690
6710
|
return join13(cabaneDir(), "transcripts");
|
|
@@ -6701,7 +6721,7 @@ var TranscriptWriter = class {
|
|
|
6701
6721
|
try {
|
|
6702
6722
|
mkdirSync9(dir2, { recursive: true });
|
|
6703
6723
|
try {
|
|
6704
|
-
|
|
6724
|
+
chmodSync4(dir2, 448);
|
|
6705
6725
|
} catch {
|
|
6706
6726
|
}
|
|
6707
6727
|
pruneOld(dir2, RETAIN);
|
|
@@ -6728,10 +6748,10 @@ var TranscriptWriter = class {
|
|
|
6728
6748
|
try {
|
|
6729
6749
|
const dir2 = join13(dirname5(this.path), "anomalies");
|
|
6730
6750
|
mkdirSync9(dir2, { recursive: true, mode: 448 });
|
|
6731
|
-
|
|
6751
|
+
chmodSync4(dir2, 448);
|
|
6732
6752
|
const target = join13(dir2, basename(this.path));
|
|
6733
6753
|
copyFileSync(this.path, target);
|
|
6734
|
-
|
|
6754
|
+
chmodSync4(target, 384);
|
|
6735
6755
|
pruneOld(dir2, ANOMALY_RETAIN);
|
|
6736
6756
|
} catch (err) {
|
|
6737
6757
|
this.fail(err);
|
|
@@ -8573,11 +8593,9 @@ var CompanionSupervisor = class {
|
|
|
8573
8593
|
// can never disagree with what the manifest advertises. Null until the first
|
|
8574
8594
|
// probe (the heartbeat then falls back to the boot `claudeCode`).
|
|
8575
8595
|
harnessSignals = null;
|
|
8576
|
-
//
|
|
8577
|
-
//
|
|
8578
|
-
|
|
8579
|
-
probeClaudePresence;
|
|
8580
|
-
probeCodexPresence;
|
|
8596
|
+
// Fresh per attempt, never the cached heartbeat signal: installing a harness
|
|
8597
|
+
// between attempts must be observed immediately.
|
|
8598
|
+
probePresence;
|
|
8581
8599
|
exitFn;
|
|
8582
8600
|
reexecFn;
|
|
8583
8601
|
dispatcherFactory;
|
|
@@ -8609,8 +8627,7 @@ var CompanionSupervisor = class {
|
|
|
8609
8627
|
this.log = opts.log;
|
|
8610
8628
|
this.hub = opts.hub;
|
|
8611
8629
|
this.claudeCode = opts.claudeCode ?? true;
|
|
8612
|
-
this.
|
|
8613
|
-
this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
|
|
8630
|
+
this.probePresence = opts.probePresence ?? probeHarnessPresence;
|
|
8614
8631
|
this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
|
|
8615
8632
|
this.exitFn = opts.exit ?? ((code) => process.exit(code));
|
|
8616
8633
|
this.reexecFn = opts.reexec ?? defaultReexec;
|
|
@@ -9308,10 +9325,11 @@ var CompanionSupervisor = class {
|
|
|
9308
9325
|
// over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
|
|
9309
9326
|
// wrapped anyway so a harness refresh can never take a heartbeat down. Called on
|
|
9310
9327
|
// the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
|
|
9311
|
-
async refreshHarnessStatuses() {
|
|
9328
|
+
async refreshHarnessStatuses(presence = {}) {
|
|
9312
9329
|
try {
|
|
9313
9330
|
const signals = await probeHarnessSignals(this.config, {
|
|
9314
|
-
|
|
9331
|
+
probePresence: this.probePresence,
|
|
9332
|
+
presence
|
|
9315
9333
|
});
|
|
9316
9334
|
this.harnessSignals = signals;
|
|
9317
9335
|
this.harnessVersions = {
|
|
@@ -9368,18 +9386,24 @@ var CompanionSupervisor = class {
|
|
|
9368
9386
|
// reachable/valid input).
|
|
9369
9387
|
async enableHarness(input) {
|
|
9370
9388
|
let next;
|
|
9389
|
+
let checkedPresence = null;
|
|
9371
9390
|
if (input.runtime === "claude-code") {
|
|
9372
|
-
|
|
9391
|
+
const presence = input.presence ?? await this.probePresence("claude-code");
|
|
9392
|
+
if (presence.status !== "present") {
|
|
9373
9393
|
return {
|
|
9374
9394
|
ok: false,
|
|
9375
|
-
error: "
|
|
9395
|
+
error: presenceReason("claude-code", presence.status),
|
|
9396
|
+
presence: true
|
|
9376
9397
|
};
|
|
9377
9398
|
}
|
|
9399
|
+
checkedPresence = { runtime: "claude-code", result: presence };
|
|
9378
9400
|
next = { ...this.config, claudeCode: { enabled: true } };
|
|
9379
9401
|
} else if (input.runtime === "codex") {
|
|
9380
|
-
|
|
9381
|
-
|
|
9402
|
+
const presence = input.presence ?? await this.probePresence("codex");
|
|
9403
|
+
if (presence.status !== "present") {
|
|
9404
|
+
return { ok: false, error: presenceReason("codex", presence.status), presence: true };
|
|
9382
9405
|
}
|
|
9406
|
+
checkedPresence = { runtime: "codex", result: presence };
|
|
9383
9407
|
next = { ...this.config, codex: { enabled: true } };
|
|
9384
9408
|
} else {
|
|
9385
9409
|
const serverUrl = input.serverUrl.trim();
|
|
@@ -9403,7 +9427,9 @@ var CompanionSupervisor = class {
|
|
|
9403
9427
|
this.config = next;
|
|
9404
9428
|
saveConfig(next);
|
|
9405
9429
|
this.rebuildDispatchers();
|
|
9406
|
-
await this.refreshHarnessStatuses(
|
|
9430
|
+
await this.refreshHarnessStatuses(
|
|
9431
|
+
checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
|
|
9432
|
+
);
|
|
9407
9433
|
this.kickHeartbeat();
|
|
9408
9434
|
return { ok: true };
|
|
9409
9435
|
}
|
|
@@ -9467,9 +9493,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
|
|
|
9467
9493
|
}
|
|
9468
9494
|
function defaultReexec() {
|
|
9469
9495
|
clearRuntimeState();
|
|
9470
|
-
void import("child_process").then(({ spawn:
|
|
9496
|
+
void import("child_process").then(({ spawn: spawn4 }) => {
|
|
9471
9497
|
try {
|
|
9472
|
-
const child =
|
|
9498
|
+
const child = spawn4(process.execPath, process.argv.slice(1), {
|
|
9473
9499
|
stdio: "inherit",
|
|
9474
9500
|
detached: false
|
|
9475
9501
|
});
|
|
@@ -9610,7 +9636,8 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9610
9636
|
if (!claim.acquired) {
|
|
9611
9637
|
return { ok: false, reason: "already-running", existing: claim.existing ?? null };
|
|
9612
9638
|
}
|
|
9613
|
-
|
|
9639
|
+
const clearRuntimeOnExit = () => clearRuntimeStateIfOurs(instanceId);
|
|
9640
|
+
process.on("exit", clearRuntimeOnExit);
|
|
9614
9641
|
const hub = new CompanionStateHub({
|
|
9615
9642
|
// CT29: one device, one base URL — the cabane instance this device is paired
|
|
9616
9643
|
// with. The dashboard's connection line shows it.
|
|
@@ -9632,49 +9659,73 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9632
9659
|
const currentConfig = supervisor.currentConfig();
|
|
9633
9660
|
const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
|
|
9634
9661
|
const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
|
|
9635
|
-
const
|
|
9636
|
-
|
|
9662
|
+
const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
|
|
9663
|
+
const verdict = await shakeOutHarness(runtime, candidate, {
|
|
9664
|
+
...presence ? { presence } : {}
|
|
9665
|
+
});
|
|
9666
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
9667
|
+
return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
|
|
9668
|
+
}
|
|
9637
9669
|
const result = await supervisor.enableHarness(
|
|
9638
|
-
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
|
|
9670
|
+
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
|
|
9639
9671
|
);
|
|
9640
9672
|
if (!result.ok) return { ok: false, error: result.error };
|
|
9641
9673
|
return { ok: true, message: connectedLine(runtime, verdict) };
|
|
9642
9674
|
};
|
|
9643
|
-
|
|
9644
|
-
status: () => hub.statusJson(),
|
|
9645
|
-
connect: async (runtime, serverUrl) => {
|
|
9646
|
-
const result = await connectHarness(runtime, serverUrl);
|
|
9647
|
-
return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
|
|
9648
|
-
},
|
|
9649
|
-
stop: () => void supervisor.requestStop()
|
|
9650
|
-
});
|
|
9675
|
+
let control = null;
|
|
9651
9676
|
let dashboard = null;
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9677
|
+
try {
|
|
9678
|
+
control = await startControlServer({
|
|
9679
|
+
status: () => hub.statusJson(),
|
|
9680
|
+
connect: async (runtime, serverUrl) => {
|
|
9681
|
+
const result = await connectHarness(runtime, serverUrl);
|
|
9682
|
+
return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
|
|
9683
|
+
},
|
|
9684
|
+
stop: () => void supervisor.requestStop()
|
|
9685
|
+
});
|
|
9686
|
+
if (opts.dashboard) {
|
|
9687
|
+
const preferredPort = opts.port ?? cfg.dashboardPort;
|
|
9688
|
+
dashboard = await startDashboard({
|
|
9689
|
+
supervisor,
|
|
9690
|
+
hub,
|
|
9691
|
+
...preferredPort !== void 0 ? { port: preferredPort } : {}
|
|
9692
|
+
});
|
|
9693
|
+
hub.setDashboardUrl(dashboard.url);
|
|
9694
|
+
}
|
|
9695
|
+
writeRuntimeState({
|
|
9696
|
+
pid: process.pid,
|
|
9697
|
+
socket: control.path,
|
|
9698
|
+
...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
|
|
9699
|
+
startedAt,
|
|
9700
|
+
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
9701
|
+
// marker records whether this companion is backgrounded (foreground start
|
|
9702
|
+
// leaves it unset → false).
|
|
9703
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9704
|
+
instanceId
|
|
9705
|
+
});
|
|
9706
|
+
clearCrash();
|
|
9707
|
+
} catch (err) {
|
|
9708
|
+
clearRuntimeStateIfOurs(instanceId);
|
|
9709
|
+
process.removeListener("exit", clearRuntimeOnExit);
|
|
9710
|
+
await Promise.allSettled([
|
|
9711
|
+
supervisor.shutdown(),
|
|
9712
|
+
...control ? [control.close()] : [],
|
|
9713
|
+
...dashboard ? [dashboard.close()] : []
|
|
9714
|
+
]);
|
|
9715
|
+
recordCrash({
|
|
9716
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
9717
|
+
...errorCode(err) ? { code: errorCode(err) } : {},
|
|
9718
|
+
origin: "startup",
|
|
9719
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
9658
9720
|
});
|
|
9659
|
-
|
|
9721
|
+
throw err;
|
|
9660
9722
|
}
|
|
9661
|
-
writeRuntimeState({
|
|
9662
|
-
pid: process.pid,
|
|
9663
|
-
socket: control.path,
|
|
9664
|
-
...dashboard ? { url: dashboard.url, port: dashboard.port } : {},
|
|
9665
|
-
startedAt,
|
|
9666
|
-
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
9667
|
-
// marker records whether this companion is backgrounded (foreground start
|
|
9668
|
-
// leaves it unset → false).
|
|
9669
|
-
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9670
|
-
instanceId
|
|
9671
|
-
});
|
|
9672
|
-
clearCrash();
|
|
9673
9723
|
let stopped = false;
|
|
9674
9724
|
const stop = async () => {
|
|
9675
9725
|
if (stopped) return;
|
|
9676
9726
|
stopped = true;
|
|
9677
9727
|
clearRuntimeStateIfOurs(instanceId);
|
|
9728
|
+
process.removeListener("exit", clearRuntimeOnExit);
|
|
9678
9729
|
try {
|
|
9679
9730
|
await supervisor.shutdown();
|
|
9680
9731
|
await closeSurfaces(control, dashboard);
|