@cabane/companion 0.6.48 → 0.6.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +232 -176
- package/dist/runtime.js +1127 -1081
- package/package.json +1 -1
package/dist/runtime.js
CHANGED
|
@@ -434,1158 +434,1161 @@ import { readFile } from "fs/promises";
|
|
|
434
434
|
import { extname, join as join3, normalize } from "path";
|
|
435
435
|
import { streamSSE } from "hono/streaming";
|
|
436
436
|
|
|
437
|
-
// src/
|
|
438
|
-
import {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
import
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
"workspaceId",
|
|
449
|
-
"conversationId",
|
|
450
|
-
"agentId",
|
|
451
|
-
"messageId",
|
|
452
|
-
"sessionId",
|
|
453
|
-
"companionId"
|
|
454
|
-
].join(",");
|
|
455
|
-
function consoleShortId(log) {
|
|
456
|
-
const id = log.conversationId ?? log.workspaceId;
|
|
457
|
-
return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
|
|
458
|
-
}
|
|
459
|
-
function consoleMessageFormat(log, messageKey) {
|
|
460
|
-
const short = consoleShortId(log);
|
|
461
|
-
const msg = String(log[messageKey] ?? "");
|
|
462
|
-
return short ? `${short} ${msg}` : msg;
|
|
437
|
+
// src/harness-check.ts
|
|
438
|
+
import { spawn as spawn3 } from "child_process";
|
|
439
|
+
|
|
440
|
+
// src/harness-versions.ts
|
|
441
|
+
import { spawn as spawn2 } from "child_process";
|
|
442
|
+
var EMPTY = { claudeCode: null, opencode: null, codex: null };
|
|
443
|
+
var CLI_PROBE_TIMEOUT_MS = 4e3;
|
|
444
|
+
function parseVersionToken(raw) {
|
|
445
|
+
if (!raw) return null;
|
|
446
|
+
const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
|
|
447
|
+
return m ? m[0] : null;
|
|
463
448
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
ignore: CONSOLE_IGNORE,
|
|
474
|
-
messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
|
|
475
|
-
...destinations.console ? { destination: destinations.console } : {}
|
|
476
|
-
});
|
|
477
|
-
streams.push({
|
|
478
|
-
level: "info",
|
|
479
|
-
stream: {
|
|
480
|
-
write(chunk) {
|
|
481
|
-
if (consoleLogging) consoleStream.write(chunk);
|
|
482
|
-
}
|
|
449
|
+
function probeCliPresence(command, spawnImpl = spawn2) {
|
|
450
|
+
return new Promise((resolve) => {
|
|
451
|
+
let settled = false;
|
|
452
|
+
let timer = null;
|
|
453
|
+
const done = (result) => {
|
|
454
|
+
if (!settled) {
|
|
455
|
+
settled = true;
|
|
456
|
+
if (timer) clearTimeout(timer);
|
|
457
|
+
resolve(result);
|
|
483
458
|
}
|
|
459
|
+
};
|
|
460
|
+
let child;
|
|
461
|
+
try {
|
|
462
|
+
child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
463
|
+
} catch {
|
|
464
|
+
done({ status: "absent" });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
let out = "";
|
|
468
|
+
child.stdout?.on("data", (chunk) => {
|
|
469
|
+
if (out.length < 4096) out += chunk.toString();
|
|
470
|
+
});
|
|
471
|
+
timer = setTimeout(() => {
|
|
472
|
+
child.kill?.("SIGKILL");
|
|
473
|
+
done({ status: "unusable" });
|
|
474
|
+
}, CLI_PROBE_TIMEOUT_MS);
|
|
475
|
+
timer.unref?.();
|
|
476
|
+
child.once("error", () => done({ status: "absent" }));
|
|
477
|
+
child.once("exit", (code) => {
|
|
478
|
+
const version = code === 0 ? parseVersionToken(out) : null;
|
|
479
|
+
done(version ? { status: "present", version } : { status: "unusable" });
|
|
484
480
|
});
|
|
485
|
-
}
|
|
486
|
-
streams.push({
|
|
487
|
-
level: "debug",
|
|
488
|
-
stream: destinations.file ?? createWriteStream(path, { flags: "a" })
|
|
489
481
|
});
|
|
490
|
-
return pino({ level: "debug" }, pino.multistream(streams));
|
|
491
|
-
}
|
|
492
|
-
function getLogger() {
|
|
493
|
-
if (cached) return cached;
|
|
494
|
-
cached = createLogger();
|
|
495
|
-
return cached;
|
|
496
482
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
import { EventEmitter } from "events";
|
|
500
|
-
function dispatchToWire(r) {
|
|
501
|
-
return {
|
|
502
|
-
id: r.id,
|
|
503
|
-
timestamp: r.timestamp,
|
|
504
|
-
workspace_slug: r.workspaceSlug,
|
|
505
|
-
...r.agentUsername ? { agent_username: r.agentUsername } : {},
|
|
506
|
-
message: r.message,
|
|
507
|
-
status: r.status,
|
|
508
|
-
...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
|
|
509
|
-
...r.error ? { error: r.error } : {},
|
|
510
|
-
...r.reply ? { reply: r.reply } : {}
|
|
511
|
-
};
|
|
483
|
+
function probeHarnessPresence(runtime, spawnImpl = spawn2) {
|
|
484
|
+
return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
|
|
512
485
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return
|
|
486
|
+
async function probeCliVersion(command, spawnImpl = spawn2) {
|
|
487
|
+
const result = await probeCliPresence(command, spawnImpl);
|
|
488
|
+
return result.status === "present" ? result.version : null;
|
|
516
489
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
490
|
+
async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
|
|
491
|
+
try {
|
|
492
|
+
const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
|
|
493
|
+
const res = await fetchImpl(`${base}/global/health`, {
|
|
494
|
+
headers: { accept: "application/json" }
|
|
495
|
+
});
|
|
496
|
+
if (!res.ok) return null;
|
|
497
|
+
return extractOpencodeVersion(await res.json());
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
521
500
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
deviceError = null;
|
|
530
|
-
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
531
|
-
// first probe (see CompanionStatusJson.harnesses).
|
|
532
|
-
harnesses = null;
|
|
533
|
-
// ---- subscription (SSE) ----
|
|
534
|
-
on(listener) {
|
|
535
|
-
this.emitter.on("event", listener);
|
|
536
|
-
return () => this.emitter.off("event", listener);
|
|
501
|
+
}
|
|
502
|
+
function extractOpencodeVersion(json) {
|
|
503
|
+
if (!json || typeof json !== "object") return null;
|
|
504
|
+
const obj = json;
|
|
505
|
+
const candidates = [obj.version];
|
|
506
|
+
for (const v of Object.values(obj)) {
|
|
507
|
+
if (v && typeof v === "object") candidates.push(v.version);
|
|
537
508
|
}
|
|
538
|
-
|
|
539
|
-
|
|
509
|
+
for (const c of candidates) {
|
|
510
|
+
if (typeof c === "string") {
|
|
511
|
+
const parsed = parseVersionToken(c);
|
|
512
|
+
if (parsed) return parsed;
|
|
513
|
+
}
|
|
540
514
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
async function probeHarnessVersions(opts, deps = {}) {
|
|
518
|
+
const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
|
|
519
|
+
const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
|
|
520
|
+
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
521
|
+
const [claudeCode, codex, opencode] = await Promise.all([
|
|
522
|
+
opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
|
|
523
|
+
opts.codex ? safe(probeCodex) : Promise.resolve(null),
|
|
524
|
+
opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
|
|
525
|
+
]);
|
|
526
|
+
return { claudeCode, opencode, codex };
|
|
527
|
+
}
|
|
528
|
+
function emptyHarnessVersions() {
|
|
529
|
+
return { ...EMPTY };
|
|
530
|
+
}
|
|
531
|
+
async function safe(fn) {
|
|
532
|
+
try {
|
|
533
|
+
return await fn();
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
546
536
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/manifest.ts
|
|
540
|
+
var DEVICE_MANIFEST = {
|
|
541
|
+
runtimes: [{ name: "claude-code", version: null }],
|
|
542
|
+
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
543
|
+
};
|
|
544
|
+
function buildCompanionManifest(opts) {
|
|
545
|
+
const v = opts.versions ?? {};
|
|
546
|
+
const runtimes = [];
|
|
547
|
+
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
548
|
+
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
549
|
+
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
550
|
+
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/harness-status.ts
|
|
554
|
+
var HARNESS_LABELS = {
|
|
555
|
+
"claude-code": "Claude Code",
|
|
556
|
+
codex: "Codex",
|
|
557
|
+
opencode: "OpenCode"
|
|
558
|
+
};
|
|
559
|
+
var LABELS = HARNESS_LABELS;
|
|
560
|
+
var DEFAULT_OPENCODE_SERVER_URL = "http://127.0.0.1:4096";
|
|
561
|
+
function deriveHarnessSnapshot(signals) {
|
|
562
|
+
const advertised = new Set(
|
|
563
|
+
buildCompanionManifest({
|
|
564
|
+
// CT1082: connected AND installed — the manifest's own rule, restated here
|
|
565
|
+
// through the same function rather than re-decided.
|
|
566
|
+
claudeCode: signals.claudeCodeConnected && signals.claudeOnPath,
|
|
567
|
+
opencode: signals.opencodeConfigured,
|
|
568
|
+
codex: signals.codexEnabled
|
|
569
|
+
}).runtimes.map((r) => r.name)
|
|
570
|
+
);
|
|
571
|
+
const harnesses = [
|
|
572
|
+
deriveClaudeCode(signals, advertised.has("claude-code")),
|
|
573
|
+
deriveCodex(signals, advertised.has("codex")),
|
|
574
|
+
deriveOpencode(signals, advertised.has("opencode"))
|
|
575
|
+
];
|
|
576
|
+
return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
|
|
577
|
+
}
|
|
578
|
+
function deriveClaudeCode(signals, manifestHas) {
|
|
579
|
+
const base = { runtime: "claude-code", label: LABELS["claude-code"] };
|
|
580
|
+
if (manifestHas) {
|
|
581
|
+
return {
|
|
582
|
+
...base,
|
|
583
|
+
state: "exposed",
|
|
584
|
+
version: signals.claudeVersion,
|
|
585
|
+
detail: "Claude Code is connected and exposed to Cabane.",
|
|
586
|
+
enable: null
|
|
587
|
+
};
|
|
551
588
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
589
|
+
if (signals.claudeCodeConnected) {
|
|
590
|
+
return {
|
|
591
|
+
...base,
|
|
592
|
+
state: "needs_attention",
|
|
593
|
+
version: null,
|
|
594
|
+
detail: "Connected, but the `claude` CLI isn\u2019t on your PATH. Install it (`npm i -g @anthropic-ai/claude-code`) and sign in, or disconnect it.",
|
|
595
|
+
enable: null
|
|
596
|
+
};
|
|
556
597
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
598
|
+
if (signals.claudeOnPath) {
|
|
599
|
+
return {
|
|
600
|
+
...base,
|
|
601
|
+
state: "detected_not_exposed",
|
|
602
|
+
version: signals.claudeVersion,
|
|
603
|
+
detail: "Claude Code is installed here but not connected yet. Connect it to let Cabane run Claude Code on this device.",
|
|
604
|
+
enable: "claude-code"
|
|
605
|
+
};
|
|
564
606
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
607
|
+
return {
|
|
608
|
+
...base,
|
|
609
|
+
state: "not_detected",
|
|
610
|
+
version: null,
|
|
611
|
+
detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
|
|
612
|
+
enable: null
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function deriveCodex(signals, manifestHas) {
|
|
616
|
+
const base = { runtime: "codex", label: LABELS.codex };
|
|
617
|
+
if (manifestHas) {
|
|
618
|
+
if (signals.codexOnPath) {
|
|
619
|
+
return {
|
|
620
|
+
...base,
|
|
621
|
+
state: "exposed",
|
|
622
|
+
version: signals.codexVersion,
|
|
623
|
+
detail: "Codex is enabled and exposed to Cabane.",
|
|
624
|
+
enable: null
|
|
625
|
+
};
|
|
572
626
|
}
|
|
573
|
-
this.workspaces.set(ws.workspaceId, {
|
|
574
|
-
workspaceId: ws.workspaceId,
|
|
575
|
-
slug: ws.slug,
|
|
576
|
-
name: ws.name,
|
|
577
|
-
connected: false,
|
|
578
|
-
authFailed: false,
|
|
579
|
-
lastEventAt: null,
|
|
580
|
-
dispatchCountToday: 0,
|
|
581
|
-
countDate: today(),
|
|
582
|
-
agents: /* @__PURE__ */ new Map()
|
|
583
|
-
});
|
|
584
|
-
this.emitStatus();
|
|
585
|
-
}
|
|
586
|
-
removeWorkspace(workspaceId) {
|
|
587
|
-
const ws = this.workspaces.get(workspaceId);
|
|
588
|
-
if (!ws) return;
|
|
589
|
-
this.workspaces.delete(workspaceId);
|
|
590
|
-
this.emit("workspace:disconnected", { slug: ws.slug });
|
|
591
|
-
this.emitStatus();
|
|
592
|
-
}
|
|
593
|
-
setConnected(workspaceId, connected) {
|
|
594
|
-
const ws = this.workspaces.get(workspaceId);
|
|
595
|
-
if (!ws || ws.connected === connected) return;
|
|
596
|
-
ws.connected = connected;
|
|
597
|
-
if (connected) ws.authFailed = false;
|
|
598
|
-
this.emitStatus();
|
|
599
|
-
}
|
|
600
|
-
setAuthFailed(workspaceId) {
|
|
601
|
-
const ws = this.workspaces.get(workspaceId);
|
|
602
|
-
if (!ws) return;
|
|
603
|
-
if (ws.authFailed && !ws.connected) return;
|
|
604
|
-
ws.authFailed = true;
|
|
605
|
-
ws.connected = false;
|
|
606
|
-
this.emitStatus();
|
|
607
|
-
}
|
|
608
|
-
recordEvent(workspaceId) {
|
|
609
|
-
const ws = this.workspaces.get(workspaceId);
|
|
610
|
-
if (!ws) return;
|
|
611
|
-
ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
612
|
-
}
|
|
613
|
-
// ---- agents ----
|
|
614
|
-
// Upsert one assigned agent's observable state (called on each reconcile).
|
|
615
|
-
setAgent(workspaceId, agent) {
|
|
616
|
-
const ws = this.workspaces.get(workspaceId);
|
|
617
|
-
if (!ws) return;
|
|
618
|
-
ws.agents.set(agent.agentId, agent);
|
|
619
|
-
this.emitStatus();
|
|
620
|
-
}
|
|
621
|
-
removeAgent(workspaceId, agentId) {
|
|
622
|
-
const ws = this.workspaces.get(workspaceId);
|
|
623
|
-
if (!ws) return;
|
|
624
|
-
if (ws.agents.delete(agentId)) this.emitStatus();
|
|
625
|
-
}
|
|
626
|
-
// ---- dispatch feed ----
|
|
627
|
-
// A DispatchObserver bound to one (workspace, agent), handed to that agent's
|
|
628
|
-
// Dispatcher by the supervisor.
|
|
629
|
-
observerFor(workspaceId, agentId, slug) {
|
|
630
|
-
const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
|
|
631
627
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const ws = this.workspaces.get(workspaceId);
|
|
638
|
-
if (!ws) return;
|
|
639
|
-
const day = today();
|
|
640
|
-
if (ws.countDate !== day) {
|
|
641
|
-
ws.countDate = day;
|
|
642
|
-
ws.dispatchCountToday = 0;
|
|
643
|
-
}
|
|
644
|
-
ws.dispatchCountToday += 1;
|
|
645
|
-
}
|
|
646
|
-
dispatchStarted(workspaceId, slug, agentUsername, info) {
|
|
647
|
-
this.bumpCount(workspaceId);
|
|
648
|
-
const record = {
|
|
649
|
-
id: info.id,
|
|
650
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
651
|
-
workspaceSlug: slug,
|
|
652
|
-
...agentUsername ? { agentUsername } : {},
|
|
653
|
-
message: info.message,
|
|
654
|
-
status: "running"
|
|
628
|
+
...base,
|
|
629
|
+
state: "needs_attention",
|
|
630
|
+
version: null,
|
|
631
|
+
detail: "Enabled, but the `codex` CLI isn\u2019t on your PATH. Install it and sign in (`codex login`), or turn Codex off.",
|
|
632
|
+
enable: null
|
|
655
633
|
};
|
|
656
|
-
this.upsertDispatch(record);
|
|
657
|
-
this.emit("dispatch:started", dispatchToWire(record));
|
|
658
|
-
this.emitStatus();
|
|
659
634
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
message: existing?.message ?? "",
|
|
668
|
-
status: info.ok ? "replied" : "error",
|
|
669
|
-
durationMs: info.durationMs,
|
|
670
|
-
...info.reason ? { error: info.reason } : {},
|
|
671
|
-
...info.reply ? { reply: info.reply } : {}
|
|
635
|
+
if (signals.codexOnPath) {
|
|
636
|
+
return {
|
|
637
|
+
...base,
|
|
638
|
+
state: "detected_not_exposed",
|
|
639
|
+
version: signals.codexVersion,
|
|
640
|
+
detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
|
|
641
|
+
enable: "codex"
|
|
672
642
|
};
|
|
673
|
-
this.upsertDispatch(record);
|
|
674
|
-
this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
|
|
675
643
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
644
|
+
return {
|
|
645
|
+
...base,
|
|
646
|
+
state: "not_detected",
|
|
647
|
+
version: null,
|
|
648
|
+
detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
|
|
649
|
+
enable: null
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function deriveOpencode(signals, manifestHas) {
|
|
653
|
+
const base = { runtime: "opencode", label: LABELS.opencode };
|
|
654
|
+
if (manifestHas) {
|
|
655
|
+
if (signals.opencodeReachable) {
|
|
656
|
+
return {
|
|
657
|
+
...base,
|
|
658
|
+
state: "exposed",
|
|
659
|
+
version: signals.opencodeVersion,
|
|
660
|
+
detail: "An opencode server is reachable and exposed to Cabane.",
|
|
661
|
+
enable: null
|
|
662
|
+
};
|
|
681
663
|
}
|
|
682
|
-
this.dispatches.unshift(record);
|
|
683
|
-
if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
|
|
684
|
-
}
|
|
685
|
-
// ---- snapshots for the HTTP routes ----
|
|
686
|
-
setDashboardUrl(url) {
|
|
687
|
-
this.dashboardUrl = url;
|
|
688
|
-
}
|
|
689
|
-
recentDispatches(limit = 20) {
|
|
690
|
-
return this.dispatches.slice(0, Math.max(0, limit));
|
|
691
|
-
}
|
|
692
|
-
statusJson() {
|
|
693
|
-
const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
694
|
-
const lastOverall = list.reduce((acc, ws) => {
|
|
695
|
-
if (!ws.lastEventAt) return acc;
|
|
696
|
-
if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
|
|
697
|
-
return acc;
|
|
698
|
-
}, null);
|
|
699
664
|
return {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
workspaces: list.map((ws) => ({
|
|
706
|
-
slug: ws.slug,
|
|
707
|
-
name: ws.name,
|
|
708
|
-
last_event_at: ws.lastEventAt,
|
|
709
|
-
dispatch_count_today: ws.dispatchCountToday,
|
|
710
|
-
connected: ws.connected,
|
|
711
|
-
auth_failed: ws.authFailed,
|
|
712
|
-
agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
|
|
713
|
-
agent_id: a.agentId,
|
|
714
|
-
username: a.username,
|
|
715
|
-
display_name: a.displayName,
|
|
716
|
-
mode: a.mode,
|
|
717
|
-
has_credential: a.hasCredential,
|
|
718
|
-
missing_secrets: a.missingSecrets
|
|
719
|
-
}))
|
|
720
|
-
})),
|
|
721
|
-
last_event_at_overall: lastOverall,
|
|
722
|
-
dashboard_url: this.dashboardUrl,
|
|
723
|
-
companion_version: this.opts.companionVersion,
|
|
724
|
-
instance_id: this.opts.instanceId ?? null,
|
|
725
|
-
harnesses: this.harnesses
|
|
665
|
+
...base,
|
|
666
|
+
state: "needs_attention",
|
|
667
|
+
version: null,
|
|
668
|
+
detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
|
|
669
|
+
enable: null
|
|
726
670
|
};
|
|
727
671
|
}
|
|
728
|
-
|
|
729
|
-
|
|
672
|
+
if (signals.opencodeReachable && signals.opencodeDetectedUrl) {
|
|
673
|
+
return {
|
|
674
|
+
...base,
|
|
675
|
+
state: "detected_not_exposed",
|
|
676
|
+
version: signals.opencodeVersion,
|
|
677
|
+
detail: signals.opencodeDetectedUrl,
|
|
678
|
+
enable: "opencode"
|
|
679
|
+
};
|
|
730
680
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
".svg": "image/svg+xml",
|
|
739
|
-
".ico": "image/x-icon"
|
|
740
|
-
};
|
|
741
|
-
function registerRoutes(app, deps) {
|
|
742
|
-
const { supervisor, hub, staticDir } = deps;
|
|
743
|
-
app.get("/", async (c) => {
|
|
744
|
-
const html = await readFile(join3(staticDir, "index.html"), "utf8");
|
|
745
|
-
return c.html(html);
|
|
746
|
-
});
|
|
747
|
-
app.get("/static/:file", async (c) => {
|
|
748
|
-
const file = c.req.param("file");
|
|
749
|
-
const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
|
|
750
|
-
const full = join3(staticDir, safe3);
|
|
751
|
-
if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
|
|
752
|
-
const body = await readFile(full);
|
|
753
|
-
const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
|
|
754
|
-
c.header("content-type", type);
|
|
755
|
-
return c.body(body);
|
|
756
|
-
});
|
|
757
|
-
app.get("/api/status", (c) => c.json(hub.statusJson()));
|
|
758
|
-
app.get("/api/dispatches", (c) => {
|
|
759
|
-
const limit = clampLimit(c.req.query("limit"), 20);
|
|
760
|
-
return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
|
|
761
|
-
});
|
|
762
|
-
app.get("/api/logs", async (c) => {
|
|
763
|
-
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
764
|
-
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
765
|
-
});
|
|
766
|
-
app.post("/api/settings", async (c) => {
|
|
767
|
-
const body = await readJson(c);
|
|
768
|
-
const patch = {};
|
|
769
|
-
if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
|
|
770
|
-
patch.logLevel = body.logLevel;
|
|
771
|
-
}
|
|
772
|
-
if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
|
|
773
|
-
supervisor.applySettings(patch);
|
|
774
|
-
return c.json({ ok: true, status: hub.statusJson() });
|
|
775
|
-
});
|
|
776
|
-
app.post("/api/control", async (c) => {
|
|
777
|
-
const body = await readJson(c);
|
|
778
|
-
const action = body.action;
|
|
779
|
-
if (action === "stop") {
|
|
780
|
-
setTimeout(() => void supervisor.requestStop(), 50);
|
|
781
|
-
return c.json({ ok: true, action: "stop" });
|
|
782
|
-
}
|
|
783
|
-
if (action === "restart") {
|
|
784
|
-
setTimeout(() => supervisor.requestRestart(), 50);
|
|
785
|
-
return c.json({ ok: true, action: "restart" });
|
|
786
|
-
}
|
|
787
|
-
if (action === "reload-config") {
|
|
788
|
-
supervisor.reloadConfig();
|
|
789
|
-
return c.json({ ok: true, action: "reload-config" });
|
|
790
|
-
}
|
|
791
|
-
if (action === "refresh") {
|
|
792
|
-
supervisor.refresh();
|
|
793
|
-
return c.json({ ok: true, action: "refresh" });
|
|
794
|
-
}
|
|
795
|
-
return c.json({ error: `unknown action "${String(action)}"` }, 400);
|
|
796
|
-
});
|
|
797
|
-
app.post("/api/harnesses/recheck", async (c) => {
|
|
798
|
-
await supervisor.recheckHarnesses();
|
|
799
|
-
return c.json({ ok: true, status: hub.statusJson() });
|
|
800
|
-
});
|
|
801
|
-
app.post("/api/harnesses/enable", async (c) => {
|
|
802
|
-
const body = await readJson(c);
|
|
803
|
-
const runtime = body.runtime;
|
|
804
|
-
if (runtime === "claude-code") {
|
|
805
|
-
const result = await supervisor.enableHarness({ runtime: "claude-code" });
|
|
806
|
-
if (!result.ok) return c.json({ error: result.error }, 400);
|
|
807
|
-
return c.json({ ok: true, status: hub.statusJson() });
|
|
808
|
-
}
|
|
809
|
-
if (runtime === "codex") {
|
|
810
|
-
const result = await supervisor.enableHarness({ runtime: "codex" });
|
|
811
|
-
if (!result.ok) return c.json({ error: result.error }, 400);
|
|
812
|
-
return c.json({ ok: true, status: hub.statusJson() });
|
|
813
|
-
}
|
|
814
|
-
if (runtime === "opencode") {
|
|
815
|
-
if (typeof body.serverUrl !== "string" || body.serverUrl.trim() === "") {
|
|
816
|
-
return c.json({ error: "An opencode server URL is required." }, 400);
|
|
817
|
-
}
|
|
818
|
-
const result = await supervisor.enableHarness({
|
|
819
|
-
runtime: "opencode",
|
|
820
|
-
serverUrl: body.serverUrl
|
|
821
|
-
});
|
|
822
|
-
if (!result.ok) return c.json({ error: result.error }, 400);
|
|
823
|
-
return c.json({ ok: true, status: hub.statusJson() });
|
|
824
|
-
}
|
|
825
|
-
return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
|
|
826
|
-
});
|
|
827
|
-
app.get(
|
|
828
|
-
"/api/events",
|
|
829
|
-
(c) => streamSSE(c, async (stream) => {
|
|
830
|
-
await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
|
|
831
|
-
const queue = [];
|
|
832
|
-
let wake = null;
|
|
833
|
-
const unsubscribe = hub.on((ev) => {
|
|
834
|
-
queue.push(ev);
|
|
835
|
-
wake?.();
|
|
836
|
-
});
|
|
837
|
-
stream.onAbort(() => {
|
|
838
|
-
unsubscribe();
|
|
839
|
-
wake?.();
|
|
840
|
-
});
|
|
841
|
-
while (!stream.aborted) {
|
|
842
|
-
if (queue.length === 0) {
|
|
843
|
-
await Promise.race([
|
|
844
|
-
new Promise((resolve) => {
|
|
845
|
-
wake = resolve;
|
|
846
|
-
}),
|
|
847
|
-
stream.sleep(25e3)
|
|
848
|
-
]);
|
|
849
|
-
wake = null;
|
|
850
|
-
}
|
|
851
|
-
if (stream.aborted) break;
|
|
852
|
-
if (queue.length === 0) {
|
|
853
|
-
await stream.writeSSE({ event: "ping", data: "" });
|
|
854
|
-
continue;
|
|
855
|
-
}
|
|
856
|
-
const ev = queue.shift();
|
|
857
|
-
await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
|
|
858
|
-
}
|
|
859
|
-
unsubscribe();
|
|
860
|
-
})
|
|
861
|
-
);
|
|
862
|
-
}
|
|
863
|
-
async function readJson(c) {
|
|
864
|
-
try {
|
|
865
|
-
const parsed = await c.req.json();
|
|
866
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
867
|
-
} catch {
|
|
868
|
-
return {};
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
function clampLimit(raw, fallback, max = 200) {
|
|
872
|
-
if (!raw) return fallback;
|
|
873
|
-
const n = Number(raw);
|
|
874
|
-
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
875
|
-
return Math.min(Math.floor(n), max);
|
|
681
|
+
return {
|
|
682
|
+
...base,
|
|
683
|
+
state: "not_detected",
|
|
684
|
+
version: null,
|
|
685
|
+
detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
|
|
686
|
+
enable: "opencode"
|
|
687
|
+
};
|
|
876
688
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const len = size - start;
|
|
886
|
-
if (len <= 0) return [];
|
|
887
|
-
const buf = Buffer.alloc(len);
|
|
888
|
-
readSync(fd, buf, 0, len, start);
|
|
889
|
-
const text = buf.toString("utf8");
|
|
890
|
-
const all = text.split("\n");
|
|
891
|
-
if (start > 0 && all.length > 0) all.shift();
|
|
892
|
-
return all.filter((l) => l.length > 0).slice(-lines);
|
|
893
|
-
} catch {
|
|
894
|
-
return [];
|
|
895
|
-
} finally {
|
|
896
|
-
if (fd !== void 0) closeSync(fd);
|
|
897
|
-
}
|
|
689
|
+
var PROBE_TIMEOUT_MS = 4e3;
|
|
690
|
+
var DEFAULT_OPENCODE_PROBE_TIMEOUT_MS = 1e3;
|
|
691
|
+
function probeDefaultOpencodeVersion(probeOpencode = (url) => probeOpencodeVersion(url)) {
|
|
692
|
+
return withTimeout(
|
|
693
|
+
probeOpencode(DEFAULT_OPENCODE_SERVER_URL),
|
|
694
|
+
null,
|
|
695
|
+
DEFAULT_OPENCODE_PROBE_TIMEOUT_MS
|
|
696
|
+
);
|
|
898
697
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const app = new Hono();
|
|
905
|
-
app.onError((err, c) => {
|
|
906
|
-
if (err instanceof ApiError) {
|
|
907
|
-
const status = err.status >= 400 && err.status < 600 ? err.status : 502;
|
|
908
|
-
return c.json({ error: err.message }, status);
|
|
909
|
-
}
|
|
910
|
-
if (err instanceof CompanionError) {
|
|
911
|
-
return c.json({ error: err.message }, 400);
|
|
912
|
-
}
|
|
913
|
-
return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
|
|
914
|
-
});
|
|
915
|
-
registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
|
|
916
|
-
return app;
|
|
698
|
+
async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl, probeDefault = probeDefaultOpencodeVersion) {
|
|
699
|
+
const requested = requestedServerUrl?.trim();
|
|
700
|
+
if (requested) return requested;
|
|
701
|
+
if (configuredServerUrl) return configuredServerUrl;
|
|
702
|
+
return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
|
|
917
703
|
}
|
|
918
|
-
async function
|
|
919
|
-
const
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
704
|
+
async function probeHarnessSignals(cfg, deps = {}) {
|
|
705
|
+
const probePresence = deps.probePresence ?? probeHarnessPresence;
|
|
706
|
+
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
707
|
+
const configuredServerUrl = cfg.opencode?.serverUrl;
|
|
708
|
+
const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
|
|
709
|
+
const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
|
|
710
|
+
deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
|
|
711
|
+
deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
|
|
712
|
+
configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
|
|
713
|
+
]);
|
|
714
|
+
const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
|
|
715
|
+
const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
|
|
716
|
+
return {
|
|
717
|
+
claudeOnPath: claudePresence.status === "present",
|
|
718
|
+
claudeVersion,
|
|
719
|
+
// CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
|
|
720
|
+
// the manifest gate and the probe above is only a suggestion.
|
|
721
|
+
claudeCodeConnected: isClaudeCodeConnected(cfg),
|
|
722
|
+
// A parseable `codex --version` is our presence signal (presence alone never
|
|
723
|
+
// exposes codex; its config flag is the manifest gate either way).
|
|
724
|
+
codexOnPath: codexVersion !== null,
|
|
725
|
+
codexVersion,
|
|
726
|
+
codexEnabled: isCodexEnabled(cfg),
|
|
727
|
+
opencodeConfigured: !!configuredServerUrl,
|
|
728
|
+
// A version came back ⟺ the serve answered its health endpoint (CT584).
|
|
729
|
+
opencodeReachable: opencodeVersion !== null,
|
|
730
|
+
opencodeVersion,
|
|
731
|
+
opencodeDetectedUrl: opencodeVersion !== null ? opencodeProbeUrl : null
|
|
732
|
+
};
|
|
945
733
|
}
|
|
946
|
-
function
|
|
947
|
-
return new Promise((resolve
|
|
734
|
+
function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
735
|
+
return new Promise((resolve) => {
|
|
948
736
|
let settled = false;
|
|
949
|
-
const
|
|
737
|
+
const done = (v) => {
|
|
950
738
|
if (!settled) {
|
|
951
739
|
settled = true;
|
|
952
|
-
resolve(
|
|
740
|
+
resolve(v);
|
|
953
741
|
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
742
|
+
};
|
|
743
|
+
const timer = setTimeout(() => done(fallback), timeoutMs);
|
|
744
|
+
timer.unref?.();
|
|
745
|
+
promise.then(
|
|
746
|
+
(v) => {
|
|
747
|
+
clearTimeout(timer);
|
|
748
|
+
done(v);
|
|
749
|
+
},
|
|
750
|
+
() => {
|
|
751
|
+
clearTimeout(timer);
|
|
752
|
+
done(fallback);
|
|
959
753
|
}
|
|
960
|
-
|
|
754
|
+
);
|
|
961
755
|
});
|
|
962
756
|
}
|
|
963
|
-
function isAddrInUse(err) {
|
|
964
|
-
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
|
|
965
|
-
}
|
|
966
|
-
function resolveStaticDir() {
|
|
967
|
-
return join4(dirname3(fileURLToPath(import.meta.url)), "static");
|
|
968
|
-
}
|
|
969
757
|
|
|
970
|
-
// src/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
var CONTROL_TIMEOUT_MS = 1e3;
|
|
976
|
-
function controlSocketPath() {
|
|
977
|
-
const dir2 = cabaneDir();
|
|
978
|
-
if (process.platform === "win32") {
|
|
979
|
-
const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
|
|
980
|
-
return `\\\\.\\pipe\\cabane-companion-${key}`;
|
|
981
|
-
}
|
|
982
|
-
return join5(dir2, "companion.sock");
|
|
983
|
-
}
|
|
984
|
-
async function startControlServer(handlers) {
|
|
985
|
-
const path = controlSocketPath();
|
|
986
|
-
mkdirSync3(cabaneDir(), { recursive: true });
|
|
987
|
-
if (process.platform !== "win32" && existsSync3(path)) {
|
|
988
|
-
const alive = await ping(path);
|
|
989
|
-
if (alive) throw new Error(`another companion is already listening on ${path}`);
|
|
990
|
-
rmSync2(path, { force: true });
|
|
991
|
-
}
|
|
992
|
-
const server = createServer((socket) => {
|
|
993
|
-
void serveConnection(socket, handlers);
|
|
994
|
-
});
|
|
995
|
-
server.unref();
|
|
996
|
-
await new Promise((resolve, reject) => {
|
|
997
|
-
server.once("error", reject);
|
|
998
|
-
server.listen(path, () => {
|
|
999
|
-
server.removeListener("error", reject);
|
|
1000
|
-
resolve();
|
|
1001
|
-
});
|
|
1002
|
-
});
|
|
1003
|
-
server.on("error", () => {
|
|
1004
|
-
});
|
|
1005
|
-
return {
|
|
1006
|
-
path,
|
|
1007
|
-
close: () => new Promise((resolve) => {
|
|
1008
|
-
server.close(() => {
|
|
1009
|
-
if (process.platform !== "win32") rmSync2(path, { force: true });
|
|
1010
|
-
resolve();
|
|
1011
|
-
});
|
|
1012
|
-
})
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
async function serveConnection(socket, handlers) {
|
|
1016
|
-
socket.on("error", () => socket.destroy());
|
|
1017
|
-
const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
|
|
1018
|
-
if (line === null) {
|
|
1019
|
-
socket.destroy();
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
let req;
|
|
1023
|
-
try {
|
|
1024
|
-
req = JSON.parse(line);
|
|
1025
|
-
} catch {
|
|
1026
|
-
reply(socket, { error: "malformed request" });
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
758
|
+
// src/harness-check.ts
|
|
759
|
+
var CHECK_TIMEOUT_MS = 4e3;
|
|
760
|
+
async function shakeOutHarness(runtime, cfg, deps = {}) {
|
|
761
|
+
const run = deps.run ?? runBounded;
|
|
762
|
+
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
1029
763
|
try {
|
|
1030
|
-
if (
|
|
1031
|
-
|
|
1032
|
-
return;
|
|
1033
|
-
|
|
1034
|
-
if (req.cmd === "connect") {
|
|
1035
|
-
const result = await handlers.connect(req.runtime, req.serverUrl);
|
|
1036
|
-
reply(socket, result);
|
|
1037
|
-
return;
|
|
764
|
+
if (runtime === "opencode") {
|
|
765
|
+
const url = cfg.opencode?.serverUrl;
|
|
766
|
+
if (!url) return "absent";
|
|
767
|
+
return await probeOpencode(url) !== null ? "ok" : "failed";
|
|
1038
768
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
769
|
+
const { auth } = runtime === "codex" ? {
|
|
770
|
+
auth: ["codex", ["login", "status"]]
|
|
771
|
+
} : {
|
|
772
|
+
auth: ["claude", ["auth", "status"]]
|
|
773
|
+
};
|
|
774
|
+
const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
|
|
775
|
+
if (presence.status !== "present") return presence.status;
|
|
776
|
+
const authRun = await run(auth[0], [...auth[1]]);
|
|
777
|
+
if (authRun.code === 0) return "ok";
|
|
778
|
+
if (looksUnsupported(authRun.output)) {
|
|
779
|
+
return "unverified";
|
|
1043
780
|
}
|
|
1044
|
-
|
|
1045
|
-
} catch (err) {
|
|
1046
|
-
reply(socket, { error: err instanceof Error ? err.message : String(err) });
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
function reply(socket, body) {
|
|
1050
|
-
try {
|
|
1051
|
-
socket.end(`${JSON.stringify(body)}
|
|
1052
|
-
`);
|
|
781
|
+
return "failed";
|
|
1053
782
|
} catch {
|
|
1054
|
-
|
|
783
|
+
return "unverified";
|
|
1055
784
|
}
|
|
1056
785
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
await new Promise((resolve, reject) => {
|
|
1061
|
-
const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
|
|
1062
|
-
timer.unref?.();
|
|
1063
|
-
socket.once("connect", () => {
|
|
1064
|
-
clearTimeout(timer);
|
|
1065
|
-
resolve();
|
|
1066
|
-
});
|
|
1067
|
-
socket.once("error", (err) => {
|
|
1068
|
-
clearTimeout(timer);
|
|
1069
|
-
reject(err);
|
|
1070
|
-
});
|
|
1071
|
-
});
|
|
1072
|
-
socket.write(`${JSON.stringify(req)}
|
|
1073
|
-
`);
|
|
1074
|
-
const line = await readLine(socket, timeoutMs);
|
|
1075
|
-
if (line === null) throw new ControlTimeout();
|
|
1076
|
-
return JSON.parse(line);
|
|
1077
|
-
} finally {
|
|
1078
|
-
socket.destroy();
|
|
786
|
+
function connectedLine(runtime, verdict) {
|
|
787
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
788
|
+
throw new Error("an unavailable harness cannot be connected");
|
|
1079
789
|
}
|
|
790
|
+
const label = HARNESS_LABELS[runtime];
|
|
791
|
+
if (verdict !== "failed") return `${label} connected.`;
|
|
792
|
+
return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
|
|
1080
793
|
}
|
|
1081
|
-
var
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
794
|
+
var FAILED_SUFFIX = {
|
|
795
|
+
"claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
|
|
796
|
+
codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
|
|
797
|
+
opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
|
|
1086
798
|
};
|
|
1087
|
-
function
|
|
1088
|
-
|
|
1089
|
-
|
|
799
|
+
function presenceReason(runtime, status) {
|
|
800
|
+
if (runtime === "opencode") {
|
|
801
|
+
return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
|
|
802
|
+
}
|
|
803
|
+
const label = HARNESS_LABELS[runtime];
|
|
804
|
+
return status === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
|
|
1090
805
|
}
|
|
1091
|
-
|
|
806
|
+
var CLI_PRESENCE_ACTION = "Install it and run this again.";
|
|
807
|
+
var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
|
|
808
|
+
function presenceRefusal(runtime, status, action) {
|
|
809
|
+
const reason = presenceReason(runtime, status);
|
|
810
|
+
return runtime === "opencode" ? reason : `${reason} ${action}`;
|
|
811
|
+
}
|
|
812
|
+
function looksUnsupported(output) {
|
|
813
|
+
return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
|
|
814
|
+
output
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
function runBounded(command, args) {
|
|
1092
818
|
return new Promise((resolve) => {
|
|
1093
|
-
let buf = "";
|
|
1094
819
|
let settled = false;
|
|
1095
|
-
const done = (
|
|
820
|
+
const done = (result) => {
|
|
1096
821
|
if (settled) return;
|
|
1097
822
|
settled = true;
|
|
1098
823
|
clearTimeout(timer);
|
|
1099
|
-
|
|
1100
|
-
resolve(v);
|
|
1101
|
-
};
|
|
1102
|
-
const timer = setTimeout(() => done(null), timeoutMs);
|
|
1103
|
-
timer.unref?.();
|
|
1104
|
-
const onData = (chunk) => {
|
|
1105
|
-
buf += chunk.toString("utf8");
|
|
1106
|
-
const nl = buf.indexOf("\n");
|
|
1107
|
-
if (nl >= 0) done(buf.slice(0, nl));
|
|
1108
|
-
else if (buf.length > 1e6) done(null);
|
|
1109
|
-
};
|
|
1110
|
-
socket.on("data", onData);
|
|
1111
|
-
socket.once("close", () => done(null));
|
|
1112
|
-
socket.once("error", () => done(null));
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
async function ping(path) {
|
|
1116
|
-
try {
|
|
1117
|
-
await controlRequest(path, { cmd: "status" });
|
|
1118
|
-
return true;
|
|
1119
|
-
} catch (err) {
|
|
1120
|
-
return !isNotListening(err);
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// src/harness-check.ts
|
|
1125
|
-
import { spawn as spawn4 } from "child_process";
|
|
1126
|
-
|
|
1127
|
-
// src/harness-versions.ts
|
|
1128
|
-
import { spawn as spawn2 } from "child_process";
|
|
1129
|
-
var EMPTY = { claudeCode: null, opencode: null, codex: null };
|
|
1130
|
-
function parseVersionToken(raw) {
|
|
1131
|
-
if (!raw) return null;
|
|
1132
|
-
const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
|
|
1133
|
-
return m ? m[0] : null;
|
|
1134
|
-
}
|
|
1135
|
-
async function probeCliVersion(command, spawnImpl = spawn2) {
|
|
1136
|
-
return new Promise((resolve) => {
|
|
1137
|
-
let settled = false;
|
|
1138
|
-
const done = (v) => {
|
|
1139
|
-
if (!settled) {
|
|
1140
|
-
settled = true;
|
|
1141
|
-
resolve(v);
|
|
1142
|
-
}
|
|
824
|
+
resolve(result);
|
|
1143
825
|
};
|
|
1144
826
|
let child;
|
|
1145
827
|
try {
|
|
1146
|
-
child =
|
|
828
|
+
child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1147
829
|
} catch {
|
|
1148
|
-
|
|
830
|
+
resolve({ code: null, output: "", error: "spawn" });
|
|
1149
831
|
return;
|
|
1150
832
|
}
|
|
1151
833
|
let out = "";
|
|
1152
|
-
|
|
834
|
+
const capture = (chunk) => {
|
|
1153
835
|
if (out.length < 4096) out += chunk.toString();
|
|
1154
|
-
}
|
|
1155
|
-
child.
|
|
1156
|
-
child.
|
|
836
|
+
};
|
|
837
|
+
child.stdout?.on("data", capture);
|
|
838
|
+
child.stderr?.on("data", capture);
|
|
839
|
+
const timer = setTimeout(() => {
|
|
840
|
+
child.kill("SIGKILL");
|
|
841
|
+
done({ code: null, output: out, error: "timeout" });
|
|
842
|
+
}, CHECK_TIMEOUT_MS);
|
|
843
|
+
timer.unref?.();
|
|
844
|
+
child.once("error", () => done({ code: null, output: out, error: "spawn" }));
|
|
845
|
+
child.once("exit", (code) => done({ code, output: out }));
|
|
1157
846
|
});
|
|
1158
847
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
} catch {
|
|
1168
|
-
return null;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
function extractOpencodeVersion(json) {
|
|
1172
|
-
if (!json || typeof json !== "object") return null;
|
|
1173
|
-
const obj = json;
|
|
1174
|
-
const candidates = [obj.version];
|
|
1175
|
-
for (const v of Object.values(obj)) {
|
|
1176
|
-
if (v && typeof v === "object") candidates.push(v.version);
|
|
1177
|
-
}
|
|
1178
|
-
for (const c of candidates) {
|
|
1179
|
-
if (typeof c === "string") {
|
|
1180
|
-
const parsed = parseVersionToken(c);
|
|
1181
|
-
if (parsed) return parsed;
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
return null;
|
|
848
|
+
|
|
849
|
+
// src/logger.ts
|
|
850
|
+
import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
851
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
852
|
+
import pino from "pino";
|
|
853
|
+
import pretty from "pino-pretty";
|
|
854
|
+
function companionLogPath() {
|
|
855
|
+
return join2(cabaneDir(), "companion.log");
|
|
1185
856
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
857
|
+
var CONSOLE_IGNORE = [
|
|
858
|
+
"pid",
|
|
859
|
+
"hostname",
|
|
860
|
+
"workspaceId",
|
|
861
|
+
"conversationId",
|
|
862
|
+
"agentId",
|
|
863
|
+
"messageId",
|
|
864
|
+
"sessionId",
|
|
865
|
+
"companionId"
|
|
866
|
+
].join(",");
|
|
867
|
+
function consoleShortId(log) {
|
|
868
|
+
const id = log.conversationId ?? log.workspaceId;
|
|
869
|
+
return typeof id === "string" && id.length > 0 ? id.slice(0, 8) : null;
|
|
1196
870
|
}
|
|
1197
|
-
function
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
try {
|
|
1202
|
-
return await fn();
|
|
1203
|
-
} catch {
|
|
1204
|
-
return null;
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// src/manifest.ts
|
|
1209
|
-
var DEVICE_MANIFEST = {
|
|
1210
|
-
runtimes: [{ name: "claude-code", version: null }],
|
|
1211
|
-
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
1212
|
-
};
|
|
1213
|
-
function buildCompanionManifest(opts) {
|
|
1214
|
-
const v = opts.versions ?? {};
|
|
1215
|
-
const runtimes = [];
|
|
1216
|
-
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
1217
|
-
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
1218
|
-
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
1219
|
-
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
871
|
+
function consoleMessageFormat(log, messageKey) {
|
|
872
|
+
const short = consoleShortId(log);
|
|
873
|
+
const msg = String(log[messageKey] ?? "");
|
|
874
|
+
return short ? `${short} ${msg}` : msg;
|
|
1220
875
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
}
|
|
876
|
+
var cached = null;
|
|
877
|
+
var consoleLogging = true;
|
|
878
|
+
function createLogger(destinations = {}) {
|
|
879
|
+
const path = companionLogPath();
|
|
880
|
+
if (!destinations.file) mkdirSync2(dirname2(path), { recursive: true });
|
|
881
|
+
const streams = [];
|
|
882
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
883
|
+
const consoleStream = pretty({
|
|
884
|
+
colorize: true,
|
|
885
|
+
ignore: CONSOLE_IGNORE,
|
|
886
|
+
messageFormat: (log, messageKey) => consoleMessageFormat(log, messageKey),
|
|
887
|
+
...destinations.console ? { destination: destinations.console } : {}
|
|
1233
888
|
});
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
889
|
+
streams.push({
|
|
890
|
+
level: "info",
|
|
891
|
+
stream: {
|
|
892
|
+
write(chunk) {
|
|
893
|
+
if (consoleLogging) consoleStream.write(chunk);
|
|
894
|
+
}
|
|
1238
895
|
}
|
|
1239
896
|
});
|
|
897
|
+
}
|
|
898
|
+
streams.push({
|
|
899
|
+
level: "debug",
|
|
900
|
+
stream: destinations.file ?? createWriteStream(path, { flags: "a" })
|
|
1240
901
|
});
|
|
902
|
+
return pino({ level: "debug" }, pino.multistream(streams));
|
|
1241
903
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
new Promise((resolve) => {
|
|
1247
|
-
const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
|
|
1248
|
-
timer.unref?.();
|
|
1249
|
-
})
|
|
1250
|
-
]);
|
|
1251
|
-
return version !== null;
|
|
904
|
+
function getLogger() {
|
|
905
|
+
if (cached) return cached;
|
|
906
|
+
cached = createLogger();
|
|
907
|
+
return cached;
|
|
1252
908
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
909
|
+
|
|
910
|
+
// src/state.ts
|
|
911
|
+
import { EventEmitter } from "events";
|
|
912
|
+
function dispatchToWire(r) {
|
|
913
|
+
return {
|
|
914
|
+
id: r.id,
|
|
915
|
+
timestamp: r.timestamp,
|
|
916
|
+
workspace_slug: r.workspaceSlug,
|
|
917
|
+
...r.agentUsername ? { agent_username: r.agentUsername } : {},
|
|
918
|
+
message: r.message,
|
|
919
|
+
status: r.status,
|
|
920
|
+
...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
|
|
921
|
+
...r.error ? { error: r.error } : {},
|
|
922
|
+
...r.reply ? { reply: r.reply } : {}
|
|
923
|
+
};
|
|
1264
924
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
925
|
+
var MAX_DISPATCHES = 50;
|
|
926
|
+
function today() {
|
|
927
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
928
|
+
}
|
|
929
|
+
var CompanionStateHub = class {
|
|
930
|
+
constructor(opts) {
|
|
931
|
+
this.opts = opts;
|
|
932
|
+
this.emitter.setMaxListeners(0);
|
|
933
|
+
}
|
|
934
|
+
opts;
|
|
935
|
+
emitter = new EventEmitter();
|
|
936
|
+
workspaces = /* @__PURE__ */ new Map();
|
|
937
|
+
dispatches = [];
|
|
938
|
+
dashboardUrl = null;
|
|
939
|
+
deviceId = null;
|
|
940
|
+
deviceLabel = null;
|
|
941
|
+
deviceError = null;
|
|
942
|
+
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
943
|
+
// first probe (see CompanionStatusJson.harnesses).
|
|
944
|
+
harnesses = null;
|
|
945
|
+
// ---- subscription (SSE) ----
|
|
946
|
+
on(listener) {
|
|
947
|
+
this.emitter.on("event", listener);
|
|
948
|
+
return () => this.emitter.off("event", listener);
|
|
949
|
+
}
|
|
950
|
+
emit(type, data) {
|
|
951
|
+
this.emitter.emit("event", { type, data });
|
|
952
|
+
}
|
|
953
|
+
// ---- device ----
|
|
954
|
+
setDevice(info) {
|
|
955
|
+
if (info.deviceId !== void 0) this.deviceId = info.deviceId;
|
|
956
|
+
if (info.deviceLabel !== void 0) this.deviceLabel = info.deviceLabel;
|
|
957
|
+
this.emitStatus();
|
|
958
|
+
}
|
|
959
|
+
setDeviceError(message) {
|
|
960
|
+
if (this.deviceError === message) return;
|
|
961
|
+
this.deviceError = message;
|
|
962
|
+
this.emitStatus();
|
|
963
|
+
}
|
|
964
|
+
clearDeviceError() {
|
|
965
|
+
if (this.deviceError === null) return;
|
|
966
|
+
this.deviceError = null;
|
|
967
|
+
this.emitStatus();
|
|
968
|
+
}
|
|
969
|
+
// ---- harnesses (CT586) ----
|
|
970
|
+
// Replace the harness snapshot and push a status change (so the SSE feed flips
|
|
971
|
+
// the Companion's Harnesses surface live). The supervisor calls this on each
|
|
972
|
+
// heartbeat-cadence probe and on an on-demand recheck.
|
|
973
|
+
setHarnesses(harnesses) {
|
|
974
|
+
this.harnesses = harnesses;
|
|
975
|
+
this.emitStatus();
|
|
976
|
+
}
|
|
977
|
+
// ---- workspace lifecycle ----
|
|
978
|
+
registerWorkspace(ws) {
|
|
979
|
+
const existing = this.workspaces.get(ws.workspaceId);
|
|
980
|
+
if (existing) {
|
|
981
|
+
existing.slug = ws.slug;
|
|
982
|
+
existing.name = ws.name;
|
|
983
|
+
return;
|
|
1280
984
|
}
|
|
1281
|
-
|
|
985
|
+
this.workspaces.set(ws.workspaceId, {
|
|
986
|
+
workspaceId: ws.workspaceId,
|
|
987
|
+
slug: ws.slug,
|
|
988
|
+
name: ws.name,
|
|
989
|
+
connected: false,
|
|
990
|
+
authFailed: false,
|
|
991
|
+
lastEventAt: null,
|
|
992
|
+
dispatchCountToday: 0,
|
|
993
|
+
countDate: today(),
|
|
994
|
+
agents: /* @__PURE__ */ new Map()
|
|
995
|
+
});
|
|
996
|
+
this.emitStatus();
|
|
1282
997
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
998
|
+
removeWorkspace(workspaceId) {
|
|
999
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1000
|
+
if (!ws) return;
|
|
1001
|
+
this.workspaces.delete(workspaceId);
|
|
1002
|
+
this.emit("workspace:disconnected", { slug: ws.slug });
|
|
1003
|
+
this.emitStatus();
|
|
1004
|
+
}
|
|
1005
|
+
setConnected(workspaceId, connected) {
|
|
1006
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1007
|
+
if (!ws || ws.connected === connected) return;
|
|
1008
|
+
ws.connected = connected;
|
|
1009
|
+
if (connected) ws.authFailed = false;
|
|
1010
|
+
this.emitStatus();
|
|
1011
|
+
}
|
|
1012
|
+
setAuthFailed(workspaceId) {
|
|
1013
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1014
|
+
if (!ws) return;
|
|
1015
|
+
if (ws.authFailed && !ws.connected) return;
|
|
1016
|
+
ws.authFailed = true;
|
|
1017
|
+
ws.connected = false;
|
|
1018
|
+
this.emitStatus();
|
|
1019
|
+
}
|
|
1020
|
+
recordEvent(workspaceId) {
|
|
1021
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1022
|
+
if (!ws) return;
|
|
1023
|
+
ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1024
|
+
}
|
|
1025
|
+
// ---- agents ----
|
|
1026
|
+
// Upsert one assigned agent's observable state (called on each reconcile).
|
|
1027
|
+
setAgent(workspaceId, agent) {
|
|
1028
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1029
|
+
if (!ws) return;
|
|
1030
|
+
ws.agents.set(agent.agentId, agent);
|
|
1031
|
+
this.emitStatus();
|
|
1032
|
+
}
|
|
1033
|
+
removeAgent(workspaceId, agentId) {
|
|
1034
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1035
|
+
if (!ws) return;
|
|
1036
|
+
if (ws.agents.delete(agentId)) this.emitStatus();
|
|
1037
|
+
}
|
|
1038
|
+
// ---- dispatch feed ----
|
|
1039
|
+
// A DispatchObserver bound to one (workspace, agent), handed to that agent's
|
|
1040
|
+
// Dispatcher by the supervisor.
|
|
1041
|
+
observerFor(workspaceId, agentId, slug) {
|
|
1042
|
+
const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
|
|
1043
|
+
return {
|
|
1044
|
+
onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
|
|
1045
|
+
onEnd: (info) => this.dispatchEnded(slug, username(), info)
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
bumpCount(workspaceId) {
|
|
1049
|
+
const ws = this.workspaces.get(workspaceId);
|
|
1050
|
+
if (!ws) return;
|
|
1051
|
+
const day = today();
|
|
1052
|
+
if (ws.countDate !== day) {
|
|
1053
|
+
ws.countDate = day;
|
|
1054
|
+
ws.dispatchCountToday = 0;
|
|
1055
|
+
}
|
|
1056
|
+
ws.dispatchCountToday += 1;
|
|
1057
|
+
}
|
|
1058
|
+
dispatchStarted(workspaceId, slug, agentUsername, info) {
|
|
1059
|
+
this.bumpCount(workspaceId);
|
|
1060
|
+
const record = {
|
|
1061
|
+
id: info.id,
|
|
1062
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1063
|
+
workspaceSlug: slug,
|
|
1064
|
+
...agentUsername ? { agentUsername } : {},
|
|
1065
|
+
message: info.message,
|
|
1066
|
+
status: "running"
|
|
1067
|
+
};
|
|
1068
|
+
this.upsertDispatch(record);
|
|
1069
|
+
this.emit("dispatch:started", dispatchToWire(record));
|
|
1070
|
+
this.emitStatus();
|
|
1071
|
+
}
|
|
1072
|
+
dispatchEnded(slug, agentUsername, info) {
|
|
1073
|
+
const existing = this.dispatches.find((d) => d.id === info.id);
|
|
1074
|
+
const record = {
|
|
1075
|
+
id: info.id,
|
|
1076
|
+
timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1077
|
+
workspaceSlug: existing?.workspaceSlug ?? slug,
|
|
1078
|
+
...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
|
|
1079
|
+
message: existing?.message ?? "",
|
|
1080
|
+
status: info.ok ? "replied" : "error",
|
|
1081
|
+
durationMs: info.durationMs,
|
|
1082
|
+
...info.reason ? { error: info.reason } : {},
|
|
1083
|
+
...info.reply ? { reply: info.reply } : {}
|
|
1084
|
+
};
|
|
1085
|
+
this.upsertDispatch(record);
|
|
1086
|
+
this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
|
|
1087
|
+
}
|
|
1088
|
+
upsertDispatch(record) {
|
|
1089
|
+
const idx = this.dispatches.findIndex((d) => d.id === record.id);
|
|
1090
|
+
if (idx >= 0) {
|
|
1091
|
+
this.dispatches[idx] = record;
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
this.dispatches.unshift(record);
|
|
1095
|
+
if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
|
|
1096
|
+
}
|
|
1097
|
+
// ---- snapshots for the HTTP routes ----
|
|
1098
|
+
setDashboardUrl(url) {
|
|
1099
|
+
this.dashboardUrl = url;
|
|
1100
|
+
}
|
|
1101
|
+
recentDispatches(limit = 20) {
|
|
1102
|
+
return this.dispatches.slice(0, Math.max(0, limit));
|
|
1103
|
+
}
|
|
1104
|
+
statusJson() {
|
|
1105
|
+
const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1106
|
+
const lastOverall = list.reduce((acc, ws) => {
|
|
1107
|
+
if (!ws.lastEventAt) return acc;
|
|
1108
|
+
if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
|
|
1109
|
+
return acc;
|
|
1110
|
+
}, null);
|
|
1111
|
+
return {
|
|
1112
|
+
cabane_url: this.opts.baseUrl,
|
|
1113
|
+
device_id: this.deviceId,
|
|
1114
|
+
device_label: this.deviceLabel,
|
|
1115
|
+
device_error: this.deviceError,
|
|
1116
|
+
connected: list.some((ws) => ws.connected),
|
|
1117
|
+
workspaces: list.map((ws) => ({
|
|
1118
|
+
slug: ws.slug,
|
|
1119
|
+
name: ws.name,
|
|
1120
|
+
last_event_at: ws.lastEventAt,
|
|
1121
|
+
dispatch_count_today: ws.dispatchCountToday,
|
|
1122
|
+
connected: ws.connected,
|
|
1123
|
+
auth_failed: ws.authFailed,
|
|
1124
|
+
agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
|
|
1125
|
+
agent_id: a.agentId,
|
|
1126
|
+
username: a.username,
|
|
1127
|
+
display_name: a.displayName,
|
|
1128
|
+
mode: a.mode,
|
|
1129
|
+
has_credential: a.hasCredential,
|
|
1130
|
+
missing_secrets: a.missingSecrets
|
|
1131
|
+
}))
|
|
1132
|
+
})),
|
|
1133
|
+
last_event_at_overall: lastOverall,
|
|
1134
|
+
dashboard_url: this.dashboardUrl,
|
|
1135
|
+
companion_version: this.opts.companionVersion,
|
|
1136
|
+
instance_id: this.opts.instanceId ?? null,
|
|
1137
|
+
harnesses: this.harnesses
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
emitStatus() {
|
|
1141
|
+
this.emit("status:changed", this.statusJson());
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1296
1144
|
|
|
1297
|
-
// src/
|
|
1298
|
-
var
|
|
1299
|
-
"
|
|
1300
|
-
|
|
1301
|
-
|
|
1145
|
+
// src/dashboard/routes.ts
|
|
1146
|
+
var CONTENT_TYPES = {
|
|
1147
|
+
".html": "text/html; charset=utf-8",
|
|
1148
|
+
".css": "text/css; charset=utf-8",
|
|
1149
|
+
".js": "text/javascript; charset=utf-8",
|
|
1150
|
+
".svg": "image/svg+xml",
|
|
1151
|
+
".ico": "image/x-icon"
|
|
1302
1152
|
};
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1153
|
+
function registerRoutes(app, deps) {
|
|
1154
|
+
const { supervisor, hub, staticDir } = deps;
|
|
1155
|
+
app.get("/", async (c) => {
|
|
1156
|
+
const html = await readFile(join3(staticDir, "index.html"), "utf8");
|
|
1157
|
+
return c.html(html);
|
|
1158
|
+
});
|
|
1159
|
+
app.get("/static/:file", async (c) => {
|
|
1160
|
+
const file = c.req.param("file");
|
|
1161
|
+
const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
|
|
1162
|
+
const full = join3(staticDir, safe3);
|
|
1163
|
+
if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
|
|
1164
|
+
const body = await readFile(full);
|
|
1165
|
+
const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
|
|
1166
|
+
c.header("content-type", type);
|
|
1167
|
+
return c.body(body);
|
|
1168
|
+
});
|
|
1169
|
+
app.get("/api/status", (c) => c.json(hub.statusJson()));
|
|
1170
|
+
app.get("/api/dispatches", (c) => {
|
|
1171
|
+
const limit = clampLimit(c.req.query("limit"), 20);
|
|
1172
|
+
return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
|
|
1173
|
+
});
|
|
1174
|
+
app.get("/api/logs", async (c) => {
|
|
1175
|
+
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
1176
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
1177
|
+
});
|
|
1178
|
+
app.post("/api/settings", async (c) => {
|
|
1179
|
+
const body = await readJson(c);
|
|
1180
|
+
const patch = {};
|
|
1181
|
+
if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
|
|
1182
|
+
patch.logLevel = body.logLevel;
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
|
|
1185
|
+
supervisor.applySettings(patch);
|
|
1186
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1187
|
+
});
|
|
1188
|
+
app.post("/api/control", async (c) => {
|
|
1189
|
+
const body = await readJson(c);
|
|
1190
|
+
const action = body.action;
|
|
1191
|
+
if (action === "stop") {
|
|
1192
|
+
setTimeout(() => void supervisor.requestStop(), 50);
|
|
1193
|
+
return c.json({ ok: true, action: "stop" });
|
|
1194
|
+
}
|
|
1195
|
+
if (action === "restart") {
|
|
1196
|
+
setTimeout(() => supervisor.requestRestart(), 50);
|
|
1197
|
+
return c.json({ ok: true, action: "restart" });
|
|
1198
|
+
}
|
|
1199
|
+
if (action === "reload-config") {
|
|
1200
|
+
supervisor.reloadConfig();
|
|
1201
|
+
return c.json({ ok: true, action: "reload-config" });
|
|
1202
|
+
}
|
|
1203
|
+
if (action === "refresh") {
|
|
1204
|
+
supervisor.refresh();
|
|
1205
|
+
return c.json({ ok: true, action: "refresh" });
|
|
1206
|
+
}
|
|
1207
|
+
return c.json({ error: `unknown action "${String(action)}"` }, 400);
|
|
1208
|
+
});
|
|
1209
|
+
app.post("/api/harnesses/recheck", async (c) => {
|
|
1210
|
+
await supervisor.recheckHarnesses();
|
|
1211
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1212
|
+
});
|
|
1213
|
+
app.post("/api/harnesses/enable", async (c) => {
|
|
1214
|
+
const body = await readJson(c);
|
|
1215
|
+
const runtime = body.runtime;
|
|
1216
|
+
if (runtime === "claude-code") {
|
|
1217
|
+
const result = await supervisor.enableHarness({ runtime: "claude-code" });
|
|
1218
|
+
if (!result.ok) {
|
|
1219
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
1220
|
+
return c.json({ error }, 400);
|
|
1221
|
+
}
|
|
1222
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1223
|
+
}
|
|
1224
|
+
if (runtime === "codex") {
|
|
1225
|
+
const result = await supervisor.enableHarness({ runtime: "codex" });
|
|
1226
|
+
if (!result.ok) {
|
|
1227
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
1228
|
+
return c.json({ error }, 400);
|
|
1229
|
+
}
|
|
1230
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1231
|
+
}
|
|
1232
|
+
if (runtime === "opencode") {
|
|
1233
|
+
if (typeof body.serverUrl !== "string" || body.serverUrl.trim() === "") {
|
|
1234
|
+
return c.json({ error: "An opencode server URL is required." }, 400);
|
|
1235
|
+
}
|
|
1236
|
+
const result = await supervisor.enableHarness({
|
|
1237
|
+
runtime: "opencode",
|
|
1238
|
+
serverUrl: body.serverUrl
|
|
1239
|
+
});
|
|
1240
|
+
if (!result.ok) return c.json({ error: result.error }, 400);
|
|
1241
|
+
return c.json({ ok: true, status: hub.statusJson() });
|
|
1242
|
+
}
|
|
1243
|
+
return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
|
|
1244
|
+
});
|
|
1245
|
+
app.get(
|
|
1246
|
+
"/api/events",
|
|
1247
|
+
(c) => streamSSE(c, async (stream) => {
|
|
1248
|
+
await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
|
|
1249
|
+
const queue = [];
|
|
1250
|
+
let wake = null;
|
|
1251
|
+
const unsubscribe = hub.on((ev) => {
|
|
1252
|
+
queue.push(ev);
|
|
1253
|
+
wake?.();
|
|
1254
|
+
});
|
|
1255
|
+
stream.onAbort(() => {
|
|
1256
|
+
unsubscribe();
|
|
1257
|
+
wake?.();
|
|
1258
|
+
});
|
|
1259
|
+
while (!stream.aborted) {
|
|
1260
|
+
if (queue.length === 0) {
|
|
1261
|
+
await Promise.race([
|
|
1262
|
+
new Promise((resolve) => {
|
|
1263
|
+
wake = resolve;
|
|
1264
|
+
}),
|
|
1265
|
+
stream.sleep(25e3)
|
|
1266
|
+
]);
|
|
1267
|
+
wake = null;
|
|
1268
|
+
}
|
|
1269
|
+
if (stream.aborted) break;
|
|
1270
|
+
if (queue.length === 0) {
|
|
1271
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const ev = queue.shift();
|
|
1275
|
+
await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
|
|
1276
|
+
}
|
|
1277
|
+
unsubscribe();
|
|
1278
|
+
})
|
|
1314
1279
|
);
|
|
1315
|
-
const harnesses = [
|
|
1316
|
-
deriveClaudeCode(signals, advertised.has("claude-code")),
|
|
1317
|
-
deriveCodex(signals, advertised.has("codex")),
|
|
1318
|
-
deriveOpencode(signals, advertised.has("opencode"))
|
|
1319
|
-
];
|
|
1320
|
-
return { harnesses, anyExposed: harnesses.some((h) => h.state === "exposed") };
|
|
1321
1280
|
}
|
|
1322
|
-
function
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
version: signals.claudeVersion,
|
|
1329
|
-
detail: "Claude Code is connected and exposed to Cabane.",
|
|
1330
|
-
enable: null
|
|
1331
|
-
};
|
|
1332
|
-
}
|
|
1333
|
-
if (signals.claudeCodeConnected) {
|
|
1334
|
-
return {
|
|
1335
|
-
...base,
|
|
1336
|
-
state: "needs_attention",
|
|
1337
|
-
version: null,
|
|
1338
|
-
detail: "Connected, but the `claude` CLI isn\u2019t on your PATH. Install it (`npm i -g @anthropic-ai/claude-code`) and sign in, or disconnect it.",
|
|
1339
|
-
enable: null
|
|
1340
|
-
};
|
|
1281
|
+
async function readJson(c) {
|
|
1282
|
+
try {
|
|
1283
|
+
const parsed = await c.req.json();
|
|
1284
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1285
|
+
} catch {
|
|
1286
|
+
return {};
|
|
1341
1287
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1288
|
+
}
|
|
1289
|
+
function clampLimit(raw, fallback, max = 200) {
|
|
1290
|
+
if (!raw) return fallback;
|
|
1291
|
+
const n = Number(raw);
|
|
1292
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
1293
|
+
return Math.min(Math.floor(n), max);
|
|
1294
|
+
}
|
|
1295
|
+
function tailFile(path, lines) {
|
|
1296
|
+
if (!existsSync2(path)) return [];
|
|
1297
|
+
const MAX_BYTES = 256 * 1024;
|
|
1298
|
+
let fd;
|
|
1299
|
+
try {
|
|
1300
|
+
fd = openSync(path, "r");
|
|
1301
|
+
const size = fstatSync(fd).size;
|
|
1302
|
+
const start = Math.max(0, size - MAX_BYTES);
|
|
1303
|
+
const len = size - start;
|
|
1304
|
+
if (len <= 0) return [];
|
|
1305
|
+
const buf = Buffer.alloc(len);
|
|
1306
|
+
readSync(fd, buf, 0, len, start);
|
|
1307
|
+
const text = buf.toString("utf8");
|
|
1308
|
+
const all = text.split("\n");
|
|
1309
|
+
if (start > 0 && all.length > 0) all.shift();
|
|
1310
|
+
return all.filter((l) => l.length > 0).slice(-lines);
|
|
1311
|
+
} catch {
|
|
1312
|
+
return [];
|
|
1313
|
+
} finally {
|
|
1314
|
+
if (fd !== void 0) closeSync(fd);
|
|
1350
1315
|
}
|
|
1351
|
-
return {
|
|
1352
|
-
...base,
|
|
1353
|
-
state: "not_detected",
|
|
1354
|
-
version: null,
|
|
1355
|
-
detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
|
|
1356
|
-
enable: null
|
|
1357
|
-
};
|
|
1358
1316
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
};
|
|
1317
|
+
|
|
1318
|
+
// src/dashboard/server.ts
|
|
1319
|
+
var DEFAULT_PORT = 7474;
|
|
1320
|
+
var PORT_FALLBACK_SPAN = 10;
|
|
1321
|
+
function buildDashboardApp(deps) {
|
|
1322
|
+
const app = new Hono();
|
|
1323
|
+
app.onError((err, c) => {
|
|
1324
|
+
if (err instanceof ApiError) {
|
|
1325
|
+
const status = err.status >= 400 && err.status < 600 ? err.status : 502;
|
|
1326
|
+
return c.json({ error: err.message }, status);
|
|
1370
1327
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
}
|
|
1379
|
-
if (signals.codexOnPath) {
|
|
1380
|
-
return {
|
|
1381
|
-
...base,
|
|
1382
|
-
state: "detected_not_exposed",
|
|
1383
|
-
version: signals.codexVersion,
|
|
1384
|
-
detail: "Codex is installed but not exposed yet. Turn it on to let Cabane run Codex here.",
|
|
1385
|
-
enable: "codex"
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
return {
|
|
1389
|
-
...base,
|
|
1390
|
-
state: "not_detected",
|
|
1391
|
-
version: null,
|
|
1392
|
-
detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
|
|
1393
|
-
enable: null
|
|
1394
|
-
};
|
|
1328
|
+
if (err instanceof CompanionError) {
|
|
1329
|
+
return c.json({ error: err.message }, 400);
|
|
1330
|
+
}
|
|
1331
|
+
return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
|
|
1332
|
+
});
|
|
1333
|
+
registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
|
|
1334
|
+
return app;
|
|
1395
1335
|
}
|
|
1396
|
-
function
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1336
|
+
async function startDashboard(opts) {
|
|
1337
|
+
const app = buildDashboardApp({ supervisor: opts.supervisor, hub: opts.hub });
|
|
1338
|
+
const preferred = opts.port ?? DEFAULT_PORT;
|
|
1339
|
+
let lastErr;
|
|
1340
|
+
for (let port = preferred; port < preferred + PORT_FALLBACK_SPAN; port++) {
|
|
1341
|
+
try {
|
|
1342
|
+
const server = await listen(app, port);
|
|
1343
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1400
1344
|
return {
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1345
|
+
url,
|
|
1346
|
+
port,
|
|
1347
|
+
close: () => new Promise((resolve) => {
|
|
1348
|
+
server.close(() => resolve());
|
|
1349
|
+
server.closeAllConnections?.();
|
|
1350
|
+
})
|
|
1406
1351
|
};
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
if (isAddrInUse(err)) {
|
|
1354
|
+
lastErr = err;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
throw err;
|
|
1407
1358
|
}
|
|
1408
|
-
return {
|
|
1409
|
-
...base,
|
|
1410
|
-
state: "needs_attention",
|
|
1411
|
-
version: null,
|
|
1412
|
-
detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
|
|
1413
|
-
enable: null
|
|
1414
|
-
};
|
|
1415
|
-
}
|
|
1416
|
-
if (signals.opencodeReachable && signals.opencodeDetectedUrl) {
|
|
1417
|
-
return {
|
|
1418
|
-
...base,
|
|
1419
|
-
state: "detected_not_exposed",
|
|
1420
|
-
version: signals.opencodeVersion,
|
|
1421
|
-
detail: signals.opencodeDetectedUrl,
|
|
1422
|
-
enable: "opencode"
|
|
1423
|
-
};
|
|
1424
1359
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
state: "not_detected",
|
|
1428
|
-
version: null,
|
|
1429
|
-
detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
|
|
1430
|
-
enable: "opencode"
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
var PROBE_TIMEOUT_MS = 4e3;
|
|
1434
|
-
var DEFAULT_OPENCODE_PROBE_TIMEOUT_MS = 1e3;
|
|
1435
|
-
function probeDefaultOpencodeVersion(probeOpencode = (url) => probeOpencodeVersion(url)) {
|
|
1436
|
-
return withTimeout(
|
|
1437
|
-
probeOpencode(DEFAULT_OPENCODE_SERVER_URL),
|
|
1438
|
-
null,
|
|
1439
|
-
DEFAULT_OPENCODE_PROBE_TIMEOUT_MS
|
|
1360
|
+
throw new CompanionError(
|
|
1361
|
+
`couldn't bind the dashboard to any port in ${preferred}\u2013${preferred + PORT_FALLBACK_SPAN - 1} (all in use). Free one up or pass --port. (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
|
|
1440
1362
|
);
|
|
1441
1363
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
if (requested) return requested;
|
|
1445
|
-
if (configuredServerUrl) return configuredServerUrl;
|
|
1446
|
-
return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
|
|
1447
|
-
}
|
|
1448
|
-
async function probeHarnessSignals(cfg, deps = {}) {
|
|
1449
|
-
const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
|
|
1450
|
-
const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
|
|
1451
|
-
const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
|
|
1452
|
-
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
1453
|
-
const configuredServerUrl = cfg.opencode?.serverUrl;
|
|
1454
|
-
const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
|
|
1455
|
-
const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
|
|
1456
|
-
withTimeout(probeClaudePresence(), false),
|
|
1457
|
-
withTimeout(probeClaudeVersion(), null),
|
|
1458
|
-
withTimeout(probeCodexVersion(), null),
|
|
1459
|
-
configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
|
|
1460
|
-
]);
|
|
1461
|
-
return {
|
|
1462
|
-
claudeOnPath: claudeOnPathResult,
|
|
1463
|
-
claudeVersion,
|
|
1464
|
-
// CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
|
|
1465
|
-
// the manifest gate and the probe above is only a suggestion.
|
|
1466
|
-
claudeCodeConnected: isClaudeCodeConnected(cfg),
|
|
1467
|
-
// A parseable `codex --version` is our presence signal (presence alone never
|
|
1468
|
-
// exposes codex; its config flag is the manifest gate either way).
|
|
1469
|
-
codexOnPath: codexVersion !== null,
|
|
1470
|
-
codexVersion,
|
|
1471
|
-
codexEnabled: isCodexEnabled(cfg),
|
|
1472
|
-
opencodeConfigured: !!configuredServerUrl,
|
|
1473
|
-
// A version came back ⟺ the serve answered its health endpoint (CT584).
|
|
1474
|
-
opencodeReachable: opencodeVersion !== null,
|
|
1475
|
-
opencodeVersion,
|
|
1476
|
-
opencodeDetectedUrl: opencodeVersion !== null ? opencodeProbeUrl : null
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
1480
|
-
return new Promise((resolve) => {
|
|
1364
|
+
function listen(app, port) {
|
|
1365
|
+
return new Promise((resolve, reject) => {
|
|
1481
1366
|
let settled = false;
|
|
1482
|
-
const
|
|
1367
|
+
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
|
|
1483
1368
|
if (!settled) {
|
|
1484
1369
|
settled = true;
|
|
1485
|
-
resolve(
|
|
1370
|
+
resolve(server);
|
|
1486
1371
|
}
|
|
1487
|
-
};
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
clearTimeout(timer);
|
|
1493
|
-
done(v);
|
|
1494
|
-
},
|
|
1495
|
-
() => {
|
|
1496
|
-
clearTimeout(timer);
|
|
1497
|
-
done(fallback);
|
|
1372
|
+
});
|
|
1373
|
+
server.on("error", (err) => {
|
|
1374
|
+
if (!settled) {
|
|
1375
|
+
settled = true;
|
|
1376
|
+
reject(err);
|
|
1498
1377
|
}
|
|
1499
|
-
);
|
|
1378
|
+
});
|
|
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 { existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
1391
|
+
import { createServer, connect } from "net";
|
|
1392
|
+
import { join as join5 } from "path";
|
|
1393
|
+
var CONTROL_TIMEOUT_MS = 1e3;
|
|
1394
|
+
function controlSocketPath() {
|
|
1395
|
+
const dir2 = cabaneDir();
|
|
1396
|
+
if (process.platform === "win32") {
|
|
1397
|
+
const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
|
|
1398
|
+
return `\\\\.\\pipe\\cabane-companion-${key}`;
|
|
1399
|
+
}
|
|
1400
|
+
return join5(dir2, "companion.sock");
|
|
1401
|
+
}
|
|
1402
|
+
async function startControlServer(handlers) {
|
|
1403
|
+
const path = controlSocketPath();
|
|
1404
|
+
mkdirSync3(cabaneDir(), { recursive: true });
|
|
1405
|
+
if (process.platform !== "win32" && existsSync3(path)) {
|
|
1406
|
+
const alive = await ping(path);
|
|
1407
|
+
if (alive) throw new Error(`another companion is already listening on ${path}`);
|
|
1408
|
+
rmSync2(path, { force: true });
|
|
1409
|
+
}
|
|
1410
|
+
const server = createServer((socket) => {
|
|
1411
|
+
void serveConnection(socket, handlers);
|
|
1412
|
+
});
|
|
1413
|
+
server.unref();
|
|
1414
|
+
await new Promise((resolve, reject) => {
|
|
1415
|
+
server.once("error", reject);
|
|
1416
|
+
server.listen(path, () => {
|
|
1417
|
+
server.removeListener("error", reject);
|
|
1418
|
+
resolve();
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
server.on("error", () => {
|
|
1500
1422
|
});
|
|
1423
|
+
return {
|
|
1424
|
+
path,
|
|
1425
|
+
close: () => new Promise((resolve) => {
|
|
1426
|
+
server.close(() => {
|
|
1427
|
+
if (process.platform !== "win32") rmSync2(path, { force: true });
|
|
1428
|
+
resolve();
|
|
1429
|
+
});
|
|
1430
|
+
})
|
|
1431
|
+
};
|
|
1501
1432
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1433
|
+
async function serveConnection(socket, handlers) {
|
|
1434
|
+
socket.on("error", () => socket.destroy());
|
|
1435
|
+
const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
|
|
1436
|
+
if (line === null) {
|
|
1437
|
+
socket.destroy();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
let req;
|
|
1508
1441
|
try {
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1442
|
+
req = JSON.parse(line);
|
|
1443
|
+
} catch {
|
|
1444
|
+
reply(socket, { error: "malformed request" });
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
try {
|
|
1448
|
+
if (req.cmd === "status") {
|
|
1449
|
+
reply(socket, handlers.status());
|
|
1450
|
+
return;
|
|
1513
1451
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
auth: ["claude", ["auth", "status"]],
|
|
1519
|
-
presence: ["claude", ["--version"]]
|
|
1520
|
-
};
|
|
1521
|
-
const presenceRun = await run(presence[0], [...presence[1]]);
|
|
1522
|
-
if (presenceRun.error === "spawn") return "absent";
|
|
1523
|
-
if (presenceRun.code !== 0) return "unverified";
|
|
1524
|
-
const authRun = await run(auth[0], [...auth[1]]);
|
|
1525
|
-
if (authRun.code === 0) return "ok";
|
|
1526
|
-
if (looksUnsupported(authRun.output)) {
|
|
1527
|
-
return "unverified";
|
|
1452
|
+
if (req.cmd === "connect") {
|
|
1453
|
+
const result = await handlers.connect(req.runtime, req.serverUrl);
|
|
1454
|
+
reply(socket, result);
|
|
1455
|
+
return;
|
|
1528
1456
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1457
|
+
if (req.cmd === "stop") {
|
|
1458
|
+
reply(socket, { ok: true });
|
|
1459
|
+
setTimeout(() => handlers.stop(), 50).unref?.();
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
reply(socket, { error: `unknown command "${String(req.cmd)}"` });
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
reply(socket, { error: err instanceof Error ? err.message : String(err) });
|
|
1532
1465
|
}
|
|
1533
1466
|
}
|
|
1534
|
-
function
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1467
|
+
function reply(socket, body) {
|
|
1468
|
+
try {
|
|
1469
|
+
socket.end(`${JSON.stringify(body)}
|
|
1470
|
+
`);
|
|
1471
|
+
} catch {
|
|
1472
|
+
socket.destroy();
|
|
1473
|
+
}
|
|
1539
1474
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1475
|
+
async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
|
|
1476
|
+
const socket = connect(path);
|
|
1477
|
+
try {
|
|
1478
|
+
await new Promise((resolve, reject) => {
|
|
1479
|
+
const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
|
|
1480
|
+
timer.unref?.();
|
|
1481
|
+
socket.once("connect", () => {
|
|
1482
|
+
clearTimeout(timer);
|
|
1483
|
+
resolve();
|
|
1484
|
+
});
|
|
1485
|
+
socket.once("error", (err) => {
|
|
1486
|
+
clearTimeout(timer);
|
|
1487
|
+
reject(err);
|
|
1488
|
+
});
|
|
1489
|
+
});
|
|
1490
|
+
socket.write(`${JSON.stringify(req)}
|
|
1491
|
+
`);
|
|
1492
|
+
const line = await readLine(socket, timeoutMs);
|
|
1493
|
+
if (line === null) throw new ControlTimeout();
|
|
1494
|
+
return JSON.parse(line);
|
|
1495
|
+
} finally {
|
|
1496
|
+
socket.destroy();
|
|
1548
1497
|
}
|
|
1549
|
-
const label = HARNESS_LABELS[runtime];
|
|
1550
|
-
const login = runtime === "codex" ? "`codex login`" : "`claude`";
|
|
1551
|
-
return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
|
|
1552
1498
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1499
|
+
var ControlTimeout = class extends Error {
|
|
1500
|
+
constructor() {
|
|
1501
|
+
super("the companion did not answer its control socket in time");
|
|
1502
|
+
this.name = "ControlTimeout";
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
function isNotListening(err) {
|
|
1506
|
+
const code = err?.code;
|
|
1507
|
+
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
1557
1508
|
}
|
|
1558
|
-
function
|
|
1509
|
+
function readLine(socket, timeoutMs) {
|
|
1559
1510
|
return new Promise((resolve) => {
|
|
1511
|
+
let buf = "";
|
|
1560
1512
|
let settled = false;
|
|
1561
|
-
const done = (
|
|
1513
|
+
const done = (v) => {
|
|
1562
1514
|
if (settled) return;
|
|
1563
1515
|
settled = true;
|
|
1564
1516
|
clearTimeout(timer);
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
let child;
|
|
1568
|
-
try {
|
|
1569
|
-
child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1570
|
-
} catch {
|
|
1571
|
-
resolve({ code: null, output: "", error: "spawn" });
|
|
1572
|
-
return;
|
|
1573
|
-
}
|
|
1574
|
-
let out = "";
|
|
1575
|
-
const capture = (chunk) => {
|
|
1576
|
-
if (out.length < 4096) out += chunk.toString();
|
|
1517
|
+
socket.removeListener("data", onData);
|
|
1518
|
+
resolve(v);
|
|
1577
1519
|
};
|
|
1578
|
-
|
|
1579
|
-
child.stderr?.on("data", capture);
|
|
1580
|
-
const timer = setTimeout(() => {
|
|
1581
|
-
child.kill("SIGKILL");
|
|
1582
|
-
done({ code: null, output: out, error: "timeout" });
|
|
1583
|
-
}, CHECK_TIMEOUT_MS);
|
|
1520
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
1584
1521
|
timer.unref?.();
|
|
1585
|
-
|
|
1586
|
-
|
|
1522
|
+
const onData = (chunk) => {
|
|
1523
|
+
buf += chunk.toString("utf8");
|
|
1524
|
+
const nl = buf.indexOf("\n");
|
|
1525
|
+
if (nl >= 0) done(buf.slice(0, nl));
|
|
1526
|
+
else if (buf.length > 1e6) done(null);
|
|
1527
|
+
};
|
|
1528
|
+
socket.on("data", onData);
|
|
1529
|
+
socket.once("close", () => done(null));
|
|
1530
|
+
socket.once("error", () => done(null));
|
|
1587
1531
|
});
|
|
1588
1532
|
}
|
|
1533
|
+
async function ping(path) {
|
|
1534
|
+
try {
|
|
1535
|
+
await controlRequest(path, { cmd: "status" });
|
|
1536
|
+
return true;
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
return !isNotListening(err);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// src/prereqs.ts
|
|
1543
|
+
async function claudeOnPath() {
|
|
1544
|
+
return (await probeHarnessPresence("claude-code")).status === "present";
|
|
1545
|
+
}
|
|
1546
|
+
async function codexOnPath() {
|
|
1547
|
+
return (await probeHarnessPresence("codex")).status === "present";
|
|
1548
|
+
}
|
|
1549
|
+
async function requireStartConfig(deps = {}) {
|
|
1550
|
+
const requireCfg = deps.requireCfg ?? requireConfig;
|
|
1551
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
1552
|
+
const save2 = deps.save ?? saveConfig;
|
|
1553
|
+
const cfg = requireCfg();
|
|
1554
|
+
const claudeOnPathResult = await probeClaude();
|
|
1555
|
+
const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
|
|
1556
|
+
if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
|
|
1557
|
+
save2(migrated);
|
|
1558
|
+
deps.onMigrated?.(migrated, claudeOnPathResult);
|
|
1559
|
+
return { cfg: migrated, claudeOnPath: claudeOnPathResult };
|
|
1560
|
+
}
|
|
1561
|
+
async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
1562
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
1563
|
+
const probeCodex = deps.probeCodex ?? codexOnPath;
|
|
1564
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
|
|
1565
|
+
`));
|
|
1566
|
+
const connected = [
|
|
1567
|
+
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
1568
|
+
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
1569
|
+
...cfg.opencode ? ["opencode"] : []
|
|
1570
|
+
];
|
|
1571
|
+
if (connected.length > 0) {
|
|
1572
|
+
if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
|
|
1573
|
+
warn(
|
|
1574
|
+
"warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
|
|
1580
|
+
const installed = [
|
|
1581
|
+
...claudeInstalled ? ["Claude Code"] : [],
|
|
1582
|
+
...codexInstalled ? ["Codex"] : []
|
|
1583
|
+
];
|
|
1584
|
+
const connectCommands = [
|
|
1585
|
+
...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
|
|
1586
|
+
...codexInstalled ? ["`cabane-companion connect codex`"] : []
|
|
1587
|
+
];
|
|
1588
|
+
warn(
|
|
1589
|
+
"No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1589
1592
|
|
|
1590
1593
|
// src/runtime-file.ts
|
|
1591
1594
|
import {
|
|
@@ -3107,7 +3110,7 @@ var TurnPump = class {
|
|
|
3107
3110
|
};
|
|
3108
3111
|
}
|
|
3109
3112
|
opts;
|
|
3110
|
-
// `finalEmitted` gates the end-of-turn
|
|
3113
|
+
// `finalEmitted` gates the end-of-turn progress promotion;
|
|
3111
3114
|
// `lastProgressBody` is what we promote to `final` when a clean turn ended on
|
|
3112
3115
|
// a tool call with no closing text.
|
|
3113
3116
|
emittedFinal = false;
|
|
@@ -3122,28 +3125,28 @@ var TurnPump = class {
|
|
|
3122
3125
|
// frozen committer test — drive. Each callback applies the choreography and
|
|
3123
3126
|
// commits through the injected sink.
|
|
3124
3127
|
sink;
|
|
3125
|
-
// End-of-turn
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
//
|
|
3130
|
-
//
|
|
3131
|
-
// this runs.
|
|
3128
|
+
// End-of-turn progress promotion. A clean turn that emitted interim text but
|
|
3129
|
+
// no closing text promotes the agent's own last progress body (the drawer
|
|
3130
|
+
// collapses the duplicate). If the agent emitted no words, emit no message:
|
|
3131
|
+
// Cabane never attributes host-authored text to an agent, in any turn shape,
|
|
3132
|
+
// for any reason. The host closes that wordless turn with a marker instead.
|
|
3133
|
+
// Skipped when cancelled or already final. The held-text flush that precedes
|
|
3134
|
+
// this is a classification concern, driven by the caller before this runs.
|
|
3132
3135
|
async finalize(ok) {
|
|
3133
|
-
if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
|
|
3134
|
-
const body = this.lastProgressBody
|
|
3136
|
+
if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
|
|
3137
|
+
const body = this.lastProgressBody;
|
|
3135
3138
|
const seq = this.opts.nextSeq();
|
|
3136
3139
|
try {
|
|
3137
3140
|
await this.opts.commit.commitMessage({ body, kind: "final", seq });
|
|
3138
3141
|
this.emittedFinal = true;
|
|
3139
3142
|
this.finalReplyBody = body;
|
|
3140
|
-
this.emittedFinalSource =
|
|
3143
|
+
this.emittedFinalSource = "progress_promotion";
|
|
3141
3144
|
} catch (err) {
|
|
3142
|
-
this.opts.onError?.(err, "
|
|
3145
|
+
this.opts.onError?.(err, "progress-promotion");
|
|
3143
3146
|
}
|
|
3144
3147
|
}
|
|
3145
3148
|
// Whether the turn has committed its `final` row — read by the host to decide
|
|
3146
|
-
// whether
|
|
3149
|
+
// whether a marker is owed.
|
|
3147
3150
|
get finalEmitted() {
|
|
3148
3151
|
return this.emittedFinal;
|
|
3149
3152
|
}
|
|
@@ -6782,7 +6785,6 @@ function pruneOld(dir2, retain) {
|
|
|
6782
6785
|
}
|
|
6783
6786
|
|
|
6784
6787
|
// src/turn-committer.ts
|
|
6785
|
-
var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
|
|
6786
6788
|
var TurnCommitter = class {
|
|
6787
6789
|
constructor(deps) {
|
|
6788
6790
|
this.deps = deps;
|
|
@@ -6803,9 +6805,7 @@ var TurnCommitter = class {
|
|
|
6803
6805
|
turnId: deps.turnId,
|
|
6804
6806
|
seq,
|
|
6805
6807
|
parentMessageId: deps.parentMessageId,
|
|
6806
|
-
...kind === "final" ? this.
|
|
6807
|
-
...kind === "final" ? this.askField() : {},
|
|
6808
|
-
...kind === "final" ? this.wakeField() : {}
|
|
6808
|
+
...kind === "final" ? this.turnControlFields() : {}
|
|
6809
6809
|
},
|
|
6810
6810
|
deps.signal
|
|
6811
6811
|
);
|
|
@@ -6852,11 +6852,10 @@ var TurnCommitter = class {
|
|
|
6852
6852
|
commit,
|
|
6853
6853
|
signal: deps.signal,
|
|
6854
6854
|
nextSeq: deps.nextSeq,
|
|
6855
|
-
emptyFinalBody: EMPTY_FINAL_BODY,
|
|
6856
6855
|
onError: (err) => {
|
|
6857
6856
|
deps.log.warn(
|
|
6858
6857
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
6859
|
-
"dispatcher:
|
|
6858
|
+
"dispatcher: progress-promotion commit failed"
|
|
6860
6859
|
);
|
|
6861
6860
|
}
|
|
6862
6861
|
});
|
|
@@ -6882,11 +6881,11 @@ var TurnCommitter = class {
|
|
|
6882
6881
|
this.onError(err, event.type);
|
|
6883
6882
|
}
|
|
6884
6883
|
}
|
|
6885
|
-
// End-of-turn
|
|
6884
|
+
// End-of-turn progress promotion. The held-text flush is now the adapter's
|
|
6886
6885
|
// job (it emits the closing reply as a `text` event before `result`), so this
|
|
6887
|
-
// only
|
|
6888
|
-
//
|
|
6889
|
-
// on cancel no `final` is forced.
|
|
6886
|
+
// only asks the pump to promote agent-authored interim text. A wordless turn
|
|
6887
|
+
// stays wordless and the dispatcher closes it with a marker. Guards on the
|
|
6888
|
+
// abort signal, so on cancel no `final` is forced.
|
|
6890
6889
|
async finalize(okResult) {
|
|
6891
6890
|
await this.pump.finalize(okResult);
|
|
6892
6891
|
}
|
|
@@ -6897,6 +6896,19 @@ var TurnCommitter = class {
|
|
|
6897
6896
|
get finalSource() {
|
|
6898
6897
|
return this.pump.finalSource;
|
|
6899
6898
|
}
|
|
6899
|
+
get finalEmitted() {
|
|
6900
|
+
return this.pump.finalEmitted;
|
|
6901
|
+
}
|
|
6902
|
+
// A closing textual reply and a wordless terminal marker carry the same
|
|
6903
|
+
// per-turn control state. Keep this one projection so ask/wake/summon cannot
|
|
6904
|
+
// silently diverge when the agent ends without words.
|
|
6905
|
+
turnControlFields() {
|
|
6906
|
+
return {
|
|
6907
|
+
...this.summonField(),
|
|
6908
|
+
...this.askField(),
|
|
6909
|
+
...this.wakeField()
|
|
6910
|
+
};
|
|
6911
|
+
}
|
|
6900
6912
|
// CT183: resolve the in-thread summon into the `dispatch` field for a `final`
|
|
6901
6913
|
// commit. A self-target is stripped here (mirror of the in-app self-strip); the
|
|
6902
6914
|
// server strips it again and resolves / ignores an unknown id.
|
|
@@ -7125,6 +7137,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
|
|
|
7125
7137
|
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
|
|
7126
7138
|
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
|
|
7127
7139
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
7140
|
+
var SILENT_MARKER_BODY = "(no reply)";
|
|
7128
7141
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
7129
7142
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
7130
7143
|
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
|
|
@@ -7782,6 +7795,7 @@ ${reason}`,
|
|
|
7782
7795
|
let contentBearingEvents = 0;
|
|
7783
7796
|
let latestSessionState = request.session;
|
|
7784
7797
|
let settledDiagnostics = null;
|
|
7798
|
+
let silentMarkerEmitted = false;
|
|
7785
7799
|
const committer = new TurnCommitter({
|
|
7786
7800
|
api: this.opts.api,
|
|
7787
7801
|
workspaceId,
|
|
@@ -7973,6 +7987,27 @@ ${reason}`,
|
|
|
7973
7987
|
}
|
|
7974
7988
|
} else {
|
|
7975
7989
|
await committer.finalize(okResult);
|
|
7990
|
+
if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
|
|
7991
|
+
try {
|
|
7992
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7993
|
+
body: SILENT_MARKER_BODY,
|
|
7994
|
+
kind: "silent",
|
|
7995
|
+
turnId,
|
|
7996
|
+
seq: nextSeq(),
|
|
7997
|
+
parentMessageId: payload.messageId,
|
|
7998
|
+
// ask, wake and summon must survive a wordless turn exactly as
|
|
7999
|
+
// they survive a textual final; dropping one can strand a person
|
|
8000
|
+
// or the next actor with no visible failure.
|
|
8001
|
+
...committer.turnControlFields()
|
|
8002
|
+
});
|
|
8003
|
+
silentMarkerEmitted = true;
|
|
8004
|
+
} catch (err) {
|
|
8005
|
+
turnLog.warn(
|
|
8006
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
8007
|
+
"dispatcher: silent-marker commit failed"
|
|
8008
|
+
);
|
|
8009
|
+
}
|
|
8010
|
+
}
|
|
7976
8011
|
}
|
|
7977
8012
|
} catch (err) {
|
|
7978
8013
|
okResult = false;
|
|
@@ -8063,7 +8098,7 @@ ${reason}`,
|
|
|
8063
8098
|
sessionFingerprint: fingerprintSessionState(latestSessionState),
|
|
8064
8099
|
eventCounts,
|
|
8065
8100
|
runtimeResultKind,
|
|
8066
|
-
finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
|
|
8101
|
+
finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
|
|
8067
8102
|
};
|
|
8068
8103
|
body.diagnostics = settledDiagnostics;
|
|
8069
8104
|
if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
|
|
@@ -8541,11 +8576,9 @@ var CompanionSupervisor = class {
|
|
|
8541
8576
|
// can never disagree with what the manifest advertises. Null until the first
|
|
8542
8577
|
// probe (the heartbeat then falls back to the boot `claudeCode`).
|
|
8543
8578
|
harnessSignals = null;
|
|
8544
|
-
//
|
|
8545
|
-
//
|
|
8546
|
-
|
|
8547
|
-
probeClaudePresence;
|
|
8548
|
-
probeCodexPresence;
|
|
8579
|
+
// Fresh per attempt, never the cached heartbeat signal: installing a harness
|
|
8580
|
+
// between attempts must be observed immediately.
|
|
8581
|
+
probePresence;
|
|
8549
8582
|
exitFn;
|
|
8550
8583
|
reexecFn;
|
|
8551
8584
|
dispatcherFactory;
|
|
@@ -8577,8 +8610,7 @@ var CompanionSupervisor = class {
|
|
|
8577
8610
|
this.log = opts.log;
|
|
8578
8611
|
this.hub = opts.hub;
|
|
8579
8612
|
this.claudeCode = opts.claudeCode ?? true;
|
|
8580
|
-
this.
|
|
8581
|
-
this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
|
|
8613
|
+
this.probePresence = opts.probePresence ?? probeHarnessPresence;
|
|
8582
8614
|
this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
|
|
8583
8615
|
this.exitFn = opts.exit ?? ((code) => process.exit(code));
|
|
8584
8616
|
this.reexecFn = opts.reexec ?? defaultReexec;
|
|
@@ -9276,10 +9308,11 @@ var CompanionSupervisor = class {
|
|
|
9276
9308
|
// over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
|
|
9277
9309
|
// wrapped anyway so a harness refresh can never take a heartbeat down. Called on
|
|
9278
9310
|
// the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
|
|
9279
|
-
async refreshHarnessStatuses() {
|
|
9311
|
+
async refreshHarnessStatuses(presence = {}) {
|
|
9280
9312
|
try {
|
|
9281
9313
|
const signals = await probeHarnessSignals(this.config, {
|
|
9282
|
-
|
|
9314
|
+
probePresence: this.probePresence,
|
|
9315
|
+
presence
|
|
9283
9316
|
});
|
|
9284
9317
|
this.harnessSignals = signals;
|
|
9285
9318
|
this.harnessVersions = {
|
|
@@ -9336,18 +9369,24 @@ var CompanionSupervisor = class {
|
|
|
9336
9369
|
// reachable/valid input).
|
|
9337
9370
|
async enableHarness(input) {
|
|
9338
9371
|
let next;
|
|
9372
|
+
let checkedPresence = null;
|
|
9339
9373
|
if (input.runtime === "claude-code") {
|
|
9340
|
-
|
|
9374
|
+
const presence = input.presence ?? await this.probePresence("claude-code");
|
|
9375
|
+
if (presence.status !== "present") {
|
|
9341
9376
|
return {
|
|
9342
9377
|
ok: false,
|
|
9343
|
-
error: "
|
|
9378
|
+
error: presenceReason("claude-code", presence.status),
|
|
9379
|
+
presence: true
|
|
9344
9380
|
};
|
|
9345
9381
|
}
|
|
9382
|
+
checkedPresence = { runtime: "claude-code", result: presence };
|
|
9346
9383
|
next = { ...this.config, claudeCode: { enabled: true } };
|
|
9347
9384
|
} else if (input.runtime === "codex") {
|
|
9348
|
-
|
|
9349
|
-
|
|
9385
|
+
const presence = input.presence ?? await this.probePresence("codex");
|
|
9386
|
+
if (presence.status !== "present") {
|
|
9387
|
+
return { ok: false, error: presenceReason("codex", presence.status), presence: true };
|
|
9350
9388
|
}
|
|
9389
|
+
checkedPresence = { runtime: "codex", result: presence };
|
|
9351
9390
|
next = { ...this.config, codex: { enabled: true } };
|
|
9352
9391
|
} else {
|
|
9353
9392
|
const serverUrl = input.serverUrl.trim();
|
|
@@ -9371,7 +9410,9 @@ var CompanionSupervisor = class {
|
|
|
9371
9410
|
this.config = next;
|
|
9372
9411
|
saveConfig(next);
|
|
9373
9412
|
this.rebuildDispatchers();
|
|
9374
|
-
await this.refreshHarnessStatuses(
|
|
9413
|
+
await this.refreshHarnessStatuses(
|
|
9414
|
+
checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
|
|
9415
|
+
);
|
|
9375
9416
|
this.kickHeartbeat();
|
|
9376
9417
|
return { ok: true };
|
|
9377
9418
|
}
|
|
@@ -9435,9 +9476,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
|
|
|
9435
9476
|
}
|
|
9436
9477
|
function defaultReexec() {
|
|
9437
9478
|
clearRuntimeState();
|
|
9438
|
-
void import("child_process").then(({ spawn:
|
|
9479
|
+
void import("child_process").then(({ spawn: spawn4 }) => {
|
|
9439
9480
|
try {
|
|
9440
|
-
const child =
|
|
9481
|
+
const child = spawn4(process.execPath, process.argv.slice(1), {
|
|
9441
9482
|
stdio: "inherit",
|
|
9442
9483
|
detached: false
|
|
9443
9484
|
});
|
|
@@ -9600,10 +9641,15 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9600
9641
|
const currentConfig = supervisor.currentConfig();
|
|
9601
9642
|
const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
|
|
9602
9643
|
const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
|
|
9603
|
-
const
|
|
9604
|
-
|
|
9644
|
+
const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
|
|
9645
|
+
const verdict = await shakeOutHarness(runtime, candidate, {
|
|
9646
|
+
...presence ? { presence } : {}
|
|
9647
|
+
});
|
|
9648
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
9649
|
+
return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
|
|
9650
|
+
}
|
|
9605
9651
|
const result = await supervisor.enableHarness(
|
|
9606
|
-
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
|
|
9652
|
+
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
|
|
9607
9653
|
);
|
|
9608
9654
|
if (!result.ok) return { ok: false, error: result.error };
|
|
9609
9655
|
return { ok: true, message: connectedLine(runtime, verdict) };
|