@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/cli.js
CHANGED
|
@@ -644,40 +644,58 @@ async function ping(path) {
|
|
|
644
644
|
}
|
|
645
645
|
|
|
646
646
|
// src/harness-check.ts
|
|
647
|
-
import { spawn as
|
|
647
|
+
import { spawn as spawn3 } from "child_process";
|
|
648
648
|
|
|
649
649
|
// src/harness-versions.ts
|
|
650
650
|
import { spawn as spawn2 } from "child_process";
|
|
651
651
|
var EMPTY = { claudeCode: null, opencode: null, codex: null };
|
|
652
|
+
var CLI_PROBE_TIMEOUT_MS = 4e3;
|
|
652
653
|
function parseVersionToken(raw) {
|
|
653
654
|
if (!raw) return null;
|
|
654
655
|
const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
|
|
655
656
|
return m ? m[0] : null;
|
|
656
657
|
}
|
|
657
|
-
|
|
658
|
+
function probeCliPresence(command, spawnImpl = spawn2) {
|
|
658
659
|
return new Promise((resolve) => {
|
|
659
660
|
let settled = false;
|
|
660
|
-
|
|
661
|
+
let timer = null;
|
|
662
|
+
const done = (result) => {
|
|
661
663
|
if (!settled) {
|
|
662
664
|
settled = true;
|
|
663
|
-
|
|
665
|
+
if (timer) clearTimeout(timer);
|
|
666
|
+
resolve(result);
|
|
664
667
|
}
|
|
665
668
|
};
|
|
666
669
|
let child;
|
|
667
670
|
try {
|
|
668
671
|
child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
669
672
|
} catch {
|
|
670
|
-
done(
|
|
673
|
+
done({ status: "absent" });
|
|
671
674
|
return;
|
|
672
675
|
}
|
|
673
676
|
let out = "";
|
|
674
677
|
child.stdout?.on("data", (chunk) => {
|
|
675
678
|
if (out.length < 4096) out += chunk.toString();
|
|
676
679
|
});
|
|
677
|
-
|
|
678
|
-
|
|
680
|
+
timer = setTimeout(() => {
|
|
681
|
+
child.kill?.("SIGKILL");
|
|
682
|
+
done({ status: "unusable" });
|
|
683
|
+
}, CLI_PROBE_TIMEOUT_MS);
|
|
684
|
+
timer.unref?.();
|
|
685
|
+
child.once("error", () => done({ status: "absent" }));
|
|
686
|
+
child.once("exit", (code) => {
|
|
687
|
+
const version = code === 0 ? parseVersionToken(out) : null;
|
|
688
|
+
done(version ? { status: "present", version } : { status: "unusable" });
|
|
689
|
+
});
|
|
679
690
|
});
|
|
680
691
|
}
|
|
692
|
+
function probeHarnessPresence(runtime, spawnImpl = spawn2) {
|
|
693
|
+
return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
|
|
694
|
+
}
|
|
695
|
+
async function probeCliVersion(command, spawnImpl = spawn2) {
|
|
696
|
+
const result = await probeCliPresence(command, spawnImpl);
|
|
697
|
+
return result.status === "present" ? result.version : null;
|
|
698
|
+
}
|
|
681
699
|
async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
|
|
682
700
|
try {
|
|
683
701
|
const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
|
|
@@ -741,81 +759,6 @@ function buildCompanionManifest(opts) {
|
|
|
741
759
|
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
742
760
|
}
|
|
743
761
|
|
|
744
|
-
// src/prereqs.ts
|
|
745
|
-
import { spawn as spawn3 } from "child_process";
|
|
746
|
-
async function claudeOnPath() {
|
|
747
|
-
return new Promise((resolve) => {
|
|
748
|
-
let settled = false;
|
|
749
|
-
const child = spawn3("claude", ["--version"], { stdio: "ignore" });
|
|
750
|
-
child.once("error", () => {
|
|
751
|
-
if (!settled) {
|
|
752
|
-
settled = true;
|
|
753
|
-
resolve(false);
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
child.once("exit", (code) => {
|
|
757
|
-
if (!settled) {
|
|
758
|
-
settled = true;
|
|
759
|
-
resolve(code === 0);
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
var CODEX_PROBE_TIMEOUT_MS = 4e3;
|
|
765
|
-
async function codexOnPath() {
|
|
766
|
-
const version = await Promise.race([
|
|
767
|
-
probeCliVersion("codex"),
|
|
768
|
-
new Promise((resolve) => {
|
|
769
|
-
const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
|
|
770
|
-
timer.unref?.();
|
|
771
|
-
})
|
|
772
|
-
]);
|
|
773
|
-
return version !== null;
|
|
774
|
-
}
|
|
775
|
-
async function requireStartConfig(deps = {}) {
|
|
776
|
-
const requireCfg = deps.requireCfg ?? requireConfig;
|
|
777
|
-
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
778
|
-
const save2 = deps.save ?? saveConfig;
|
|
779
|
-
const cfg = requireCfg();
|
|
780
|
-
const claudeOnPathResult = await probeClaude();
|
|
781
|
-
const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
|
|
782
|
-
if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
|
|
783
|
-
save2(migrated);
|
|
784
|
-
deps.onMigrated?.(migrated, claudeOnPathResult);
|
|
785
|
-
return { cfg: migrated, claudeOnPath: claudeOnPathResult };
|
|
786
|
-
}
|
|
787
|
-
async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
788
|
-
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
789
|
-
const probeCodex = deps.probeCodex ?? codexOnPath;
|
|
790
|
-
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
|
|
791
|
-
`));
|
|
792
|
-
const connected = [
|
|
793
|
-
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
794
|
-
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
795
|
-
...cfg.opencode ? ["opencode"] : []
|
|
796
|
-
];
|
|
797
|
-
if (connected.length > 0) {
|
|
798
|
-
if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
|
|
799
|
-
warn(
|
|
800
|
-
"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."
|
|
801
|
-
);
|
|
802
|
-
}
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
|
|
806
|
-
const installed = [
|
|
807
|
-
...claudeInstalled ? ["Claude Code"] : [],
|
|
808
|
-
...codexInstalled ? ["Codex"] : []
|
|
809
|
-
];
|
|
810
|
-
const connectCommands = [
|
|
811
|
-
...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
|
|
812
|
-
...codexInstalled ? ["`cabane-companion connect codex`"] : []
|
|
813
|
-
];
|
|
814
|
-
warn(
|
|
815
|
-
"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).")
|
|
816
|
-
);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
762
|
// src/harness-status.ts
|
|
820
763
|
var HARNESS_LABELS = {
|
|
821
764
|
"claude-code": "Claude Code",
|
|
@@ -976,20 +919,19 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
|
|
|
976
919
|
return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
|
|
977
920
|
}
|
|
978
921
|
async function probeHarnessSignals(cfg, deps = {}) {
|
|
979
|
-
const
|
|
980
|
-
const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
|
|
981
|
-
const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
|
|
922
|
+
const probePresence = deps.probePresence ?? probeHarnessPresence;
|
|
982
923
|
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
983
924
|
const configuredServerUrl = cfg.opencode?.serverUrl;
|
|
984
925
|
const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
|
|
985
|
-
const [
|
|
986
|
-
withTimeout(
|
|
987
|
-
withTimeout(
|
|
988
|
-
withTimeout(probeCodexVersion(), null),
|
|
926
|
+
const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
|
|
927
|
+
deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
|
|
928
|
+
deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
|
|
989
929
|
configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
|
|
990
930
|
]);
|
|
931
|
+
const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
|
|
932
|
+
const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
|
|
991
933
|
return {
|
|
992
|
-
claudeOnPath:
|
|
934
|
+
claudeOnPath: claudePresence.status === "present",
|
|
993
935
|
claudeVersion,
|
|
994
936
|
// CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
|
|
995
937
|
// the manifest gate and the probe above is only a suggestion.
|
|
@@ -1041,16 +983,13 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
|
|
|
1041
983
|
if (!url) return "absent";
|
|
1042
984
|
return await probeOpencode(url) !== null ? "ok" : "failed";
|
|
1043
985
|
}
|
|
1044
|
-
const { auth
|
|
1045
|
-
auth: ["codex", ["login", "status"]]
|
|
1046
|
-
presence: ["codex", ["--version"]]
|
|
986
|
+
const { auth } = runtime === "codex" ? {
|
|
987
|
+
auth: ["codex", ["login", "status"]]
|
|
1047
988
|
} : {
|
|
1048
|
-
auth: ["claude", ["auth", "status"]]
|
|
1049
|
-
presence: ["claude", ["--version"]]
|
|
989
|
+
auth: ["claude", ["auth", "status"]]
|
|
1050
990
|
};
|
|
1051
|
-
const
|
|
1052
|
-
if (
|
|
1053
|
-
if (presenceRun.code !== 0) return "unverified";
|
|
991
|
+
const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
|
|
992
|
+
if (presence.status !== "present") return presence.status;
|
|
1054
993
|
const authRun = await run(auth[0], [...auth[1]]);
|
|
1055
994
|
if (authRun.code === 0) return "ok";
|
|
1056
995
|
if (looksUnsupported(authRun.output)) {
|
|
@@ -1062,7 +1001,9 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
|
|
|
1062
1001
|
}
|
|
1063
1002
|
}
|
|
1064
1003
|
function connectedLine(runtime, verdict) {
|
|
1065
|
-
if (verdict === "absent"
|
|
1004
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
1005
|
+
throw new Error("an unavailable harness cannot be connected");
|
|
1006
|
+
}
|
|
1066
1007
|
const label = HARNESS_LABELS[runtime];
|
|
1067
1008
|
if (verdict !== "failed") return `${label} connected.`;
|
|
1068
1009
|
return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
|
|
@@ -1072,13 +1013,18 @@ var FAILED_SUFFIX = {
|
|
|
1072
1013
|
codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
|
|
1073
1014
|
opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
|
|
1074
1015
|
};
|
|
1075
|
-
function
|
|
1016
|
+
function presenceReason(runtime, status2) {
|
|
1076
1017
|
if (runtime === "opencode") {
|
|
1077
1018
|
return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
|
|
1078
1019
|
}
|
|
1079
1020
|
const label = HARNESS_LABELS[runtime];
|
|
1080
|
-
|
|
1081
|
-
|
|
1021
|
+
return status2 === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
|
|
1022
|
+
}
|
|
1023
|
+
var CLI_PRESENCE_ACTION = "Install it and run this again.";
|
|
1024
|
+
var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
|
|
1025
|
+
function presenceRefusal(runtime, status2, action) {
|
|
1026
|
+
const reason = presenceReason(runtime, status2);
|
|
1027
|
+
return runtime === "opencode" ? reason : `${reason} ${action}`;
|
|
1082
1028
|
}
|
|
1083
1029
|
function looksUnsupported(output) {
|
|
1084
1030
|
return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
|
|
@@ -1096,7 +1042,7 @@ function runBounded(command, args) {
|
|
|
1096
1042
|
};
|
|
1097
1043
|
let child;
|
|
1098
1044
|
try {
|
|
1099
|
-
child =
|
|
1045
|
+
child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1100
1046
|
} catch {
|
|
1101
1047
|
resolve({ code: null, output: "", error: "spawn" });
|
|
1102
1048
|
return;
|
|
@@ -1299,18 +1245,31 @@ async function confirm(question) {
|
|
|
1299
1245
|
}
|
|
1300
1246
|
|
|
1301
1247
|
// src/commands/connect.ts
|
|
1302
|
-
async function connect2(raw, opts = {}) {
|
|
1248
|
+
async function connect2(raw, opts = {}, deps = {}) {
|
|
1303
1249
|
const runtime = parseHarnessRuntime(raw);
|
|
1304
1250
|
if (!runtime) {
|
|
1305
1251
|
throw new CompanionError(
|
|
1306
1252
|
`unknown harness "${raw}". Connectable harnesses are: claude-code, codex, opencode.`
|
|
1307
1253
|
);
|
|
1308
1254
|
}
|
|
1309
|
-
const
|
|
1255
|
+
const loadConfig2 = deps.loadConfig ?? requireConfig;
|
|
1256
|
+
const request = deps.request ?? controlRequest;
|
|
1257
|
+
const save2 = deps.save ?? saveConfig;
|
|
1258
|
+
const output = deps.output ?? write;
|
|
1259
|
+
const probePresence = deps.probePresence ?? probeHarnessPresence;
|
|
1260
|
+
const shakeOut = deps.shakeOut ?? shakeOutHarness;
|
|
1261
|
+
const resolveServerUrl = deps.resolveServerUrl ?? resolveOpencodeServerUrl;
|
|
1262
|
+
const probeOpencode = deps.probeOpencode ?? probeOpencodeVersion;
|
|
1263
|
+
const cfg = loadConfig2();
|
|
1264
|
+
if (!opts.serverUrl?.trim() && alreadyConnected(runtime, cfg)) {
|
|
1265
|
+
output(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const live = await (deps.findLiveSocket ?? liveSocket)();
|
|
1310
1269
|
if (live) {
|
|
1311
1270
|
let result;
|
|
1312
1271
|
try {
|
|
1313
|
-
result = await
|
|
1272
|
+
result = await request(
|
|
1314
1273
|
live,
|
|
1315
1274
|
{
|
|
1316
1275
|
cmd: "connect",
|
|
@@ -1326,42 +1285,38 @@ async function connect2(raw, opts = {}) {
|
|
|
1326
1285
|
);
|
|
1327
1286
|
}
|
|
1328
1287
|
if (!result.ok) throw new CompanionError(result.message);
|
|
1329
|
-
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
const cfg = requireConfig();
|
|
1333
|
-
if (alreadyConnected(runtime, cfg)) {
|
|
1334
|
-
write(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
|
|
1288
|
+
output(INDENT + tick(result.message));
|
|
1335
1289
|
return;
|
|
1336
1290
|
}
|
|
1337
|
-
if (runtime === "claude-code" && !await claudeOnPath()) {
|
|
1338
|
-
throw new CompanionError(
|
|
1339
|
-
"Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
|
|
1340
|
-
);
|
|
1341
|
-
}
|
|
1342
1291
|
let next = cfg;
|
|
1292
|
+
const presence = runtime === "opencode" ? void 0 : await probePresence(runtime);
|
|
1293
|
+
if (presence && presence.status !== "present") {
|
|
1294
|
+
throw new CompanionError(presenceRefusal(runtime, presence.status, CLI_PRESENCE_ACTION));
|
|
1295
|
+
}
|
|
1343
1296
|
if (runtime === "claude-code") next = { ...cfg, claudeCode: { enabled: true } };
|
|
1344
1297
|
else if (runtime === "codex") next = { ...cfg, codex: { enabled: true } };
|
|
1345
1298
|
else {
|
|
1346
1299
|
const requestedServerUrl = opts.serverUrl?.trim();
|
|
1347
|
-
const serverUrl = await
|
|
1300
|
+
const serverUrl = await resolveServerUrl(cfg.opencode?.serverUrl, requestedServerUrl);
|
|
1348
1301
|
if (!serverUrl) {
|
|
1349
1302
|
throw new CompanionError(
|
|
1350
1303
|
"opencode is addressed by URL \u2014 pass it: `cabane-companion connect opencode --url http://127.0.0.1:4096`."
|
|
1351
1304
|
);
|
|
1352
1305
|
}
|
|
1353
|
-
if (requestedServerUrl && await
|
|
1306
|
+
if (requestedServerUrl && await probeOpencode(serverUrl) === null) {
|
|
1354
1307
|
throw new CompanionError(
|
|
1355
1308
|
`Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
|
|
1356
1309
|
);
|
|
1357
1310
|
}
|
|
1358
1311
|
next = { ...cfg, opencode: { serverUrl } };
|
|
1359
1312
|
}
|
|
1360
|
-
const verdict = await
|
|
1361
|
-
if (verdict === "absent"
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1313
|
+
const verdict = await shakeOut(runtime, next, { ...presence ? { presence } : {} });
|
|
1314
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
1315
|
+
throw new CompanionError(presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION));
|
|
1316
|
+
}
|
|
1317
|
+
save2(next);
|
|
1318
|
+
output(INDENT + tick(connectedLine(runtime, verdict)));
|
|
1319
|
+
output(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
|
|
1365
1320
|
}
|
|
1366
1321
|
async function liveSocket() {
|
|
1367
1322
|
const state = readLiveRuntimeState();
|
|
@@ -2082,12 +2037,18 @@ function registerRoutes(app, deps) {
|
|
|
2082
2037
|
const runtime = body.runtime;
|
|
2083
2038
|
if (runtime === "claude-code") {
|
|
2084
2039
|
const result = await supervisor.enableHarness({ runtime: "claude-code" });
|
|
2085
|
-
if (!result.ok)
|
|
2040
|
+
if (!result.ok) {
|
|
2041
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
2042
|
+
return c.json({ error }, 400);
|
|
2043
|
+
}
|
|
2086
2044
|
return c.json({ ok: true, status: hub.statusJson() });
|
|
2087
2045
|
}
|
|
2088
2046
|
if (runtime === "codex") {
|
|
2089
2047
|
const result = await supervisor.enableHarness({ runtime: "codex" });
|
|
2090
|
-
if (!result.ok)
|
|
2048
|
+
if (!result.ok) {
|
|
2049
|
+
const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
|
|
2050
|
+
return c.json({ error }, 400);
|
|
2051
|
+
}
|
|
2091
2052
|
return c.json({ ok: true, status: hub.statusJson() });
|
|
2092
2053
|
}
|
|
2093
2054
|
if (runtime === "opencode") {
|
|
@@ -2246,6 +2207,57 @@ function resolveStaticDir() {
|
|
|
2246
2207
|
return join7(dirname4(fileURLToPath(import.meta.url)), "static");
|
|
2247
2208
|
}
|
|
2248
2209
|
|
|
2210
|
+
// src/prereqs.ts
|
|
2211
|
+
async function claudeOnPath() {
|
|
2212
|
+
return (await probeHarnessPresence("claude-code")).status === "present";
|
|
2213
|
+
}
|
|
2214
|
+
async function codexOnPath() {
|
|
2215
|
+
return (await probeHarnessPresence("codex")).status === "present";
|
|
2216
|
+
}
|
|
2217
|
+
async function requireStartConfig(deps = {}) {
|
|
2218
|
+
const requireCfg = deps.requireCfg ?? requireConfig;
|
|
2219
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
2220
|
+
const save2 = deps.save ?? saveConfig;
|
|
2221
|
+
const cfg = requireCfg();
|
|
2222
|
+
const claudeOnPathResult = await probeClaude();
|
|
2223
|
+
const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
|
|
2224
|
+
if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
|
|
2225
|
+
save2(migrated);
|
|
2226
|
+
deps.onMigrated?.(migrated, claudeOnPathResult);
|
|
2227
|
+
return { cfg: migrated, claudeOnPath: claudeOnPathResult };
|
|
2228
|
+
}
|
|
2229
|
+
async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
2230
|
+
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
2231
|
+
const probeCodex = deps.probeCodex ?? codexOnPath;
|
|
2232
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
|
|
2233
|
+
`));
|
|
2234
|
+
const connected = [
|
|
2235
|
+
...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
|
|
2236
|
+
...isCodexEnabled(cfg) ? ["Codex"] : [],
|
|
2237
|
+
...cfg.opencode ? ["opencode"] : []
|
|
2238
|
+
];
|
|
2239
|
+
if (connected.length > 0) {
|
|
2240
|
+
if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
|
|
2241
|
+
warn(
|
|
2242
|
+
"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."
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
|
|
2248
|
+
const installed = [
|
|
2249
|
+
...claudeInstalled ? ["Claude Code"] : [],
|
|
2250
|
+
...codexInstalled ? ["Codex"] : []
|
|
2251
|
+
];
|
|
2252
|
+
const connectCommands = [
|
|
2253
|
+
...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
|
|
2254
|
+
...codexInstalled ? ["`cabane-companion connect codex`"] : []
|
|
2255
|
+
];
|
|
2256
|
+
warn(
|
|
2257
|
+
"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).")
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2249
2261
|
// src/api.ts
|
|
2250
2262
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
2251
2263
|
var ACTIVE_RUN_OUTBOX_SEQ = 0;
|
|
@@ -3599,7 +3611,7 @@ var TurnPump = class {
|
|
|
3599
3611
|
};
|
|
3600
3612
|
}
|
|
3601
3613
|
opts;
|
|
3602
|
-
// `finalEmitted` gates the end-of-turn
|
|
3614
|
+
// `finalEmitted` gates the end-of-turn progress promotion;
|
|
3603
3615
|
// `lastProgressBody` is what we promote to `final` when a clean turn ended on
|
|
3604
3616
|
// a tool call with no closing text.
|
|
3605
3617
|
emittedFinal = false;
|
|
@@ -3614,28 +3626,28 @@ var TurnPump = class {
|
|
|
3614
3626
|
// frozen committer test — drive. Each callback applies the choreography and
|
|
3615
3627
|
// commits through the injected sink.
|
|
3616
3628
|
sink;
|
|
3617
|
-
// End-of-turn
|
|
3618
|
-
//
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
//
|
|
3622
|
-
//
|
|
3623
|
-
// this runs.
|
|
3629
|
+
// End-of-turn progress promotion. A clean turn that emitted interim text but
|
|
3630
|
+
// no closing text promotes the agent's own last progress body (the drawer
|
|
3631
|
+
// collapses the duplicate). If the agent emitted no words, emit no message:
|
|
3632
|
+
// Cabane never attributes host-authored text to an agent, in any turn shape,
|
|
3633
|
+
// for any reason. The host closes that wordless turn with a marker instead.
|
|
3634
|
+
// Skipped when cancelled or already final. The held-text flush that precedes
|
|
3635
|
+
// this is a classification concern, driven by the caller before this runs.
|
|
3624
3636
|
async finalize(ok) {
|
|
3625
|
-
if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
|
|
3626
|
-
const body = this.lastProgressBody
|
|
3637
|
+
if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
|
|
3638
|
+
const body = this.lastProgressBody;
|
|
3627
3639
|
const seq = this.opts.nextSeq();
|
|
3628
3640
|
try {
|
|
3629
3641
|
await this.opts.commit.commitMessage({ body, kind: "final", seq });
|
|
3630
3642
|
this.emittedFinal = true;
|
|
3631
3643
|
this.finalReplyBody = body;
|
|
3632
|
-
this.emittedFinalSource =
|
|
3644
|
+
this.emittedFinalSource = "progress_promotion";
|
|
3633
3645
|
} catch (err) {
|
|
3634
|
-
this.opts.onError?.(err, "
|
|
3646
|
+
this.opts.onError?.(err, "progress-promotion");
|
|
3635
3647
|
}
|
|
3636
3648
|
}
|
|
3637
3649
|
// Whether the turn has committed its `final` row — read by the host to decide
|
|
3638
|
-
// whether
|
|
3650
|
+
// whether a marker is owed.
|
|
3639
3651
|
get finalEmitted() {
|
|
3640
3652
|
return this.emittedFinal;
|
|
3641
3653
|
}
|
|
@@ -7274,7 +7286,6 @@ function pruneOld(dir2, retain) {
|
|
|
7274
7286
|
}
|
|
7275
7287
|
|
|
7276
7288
|
// src/turn-committer.ts
|
|
7277
|
-
var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
|
|
7278
7289
|
var TurnCommitter = class {
|
|
7279
7290
|
constructor(deps) {
|
|
7280
7291
|
this.deps = deps;
|
|
@@ -7295,9 +7306,7 @@ var TurnCommitter = class {
|
|
|
7295
7306
|
turnId: deps.turnId,
|
|
7296
7307
|
seq,
|
|
7297
7308
|
parentMessageId: deps.parentMessageId,
|
|
7298
|
-
...kind === "final" ? this.
|
|
7299
|
-
...kind === "final" ? this.askField() : {},
|
|
7300
|
-
...kind === "final" ? this.wakeField() : {}
|
|
7309
|
+
...kind === "final" ? this.turnControlFields() : {}
|
|
7301
7310
|
},
|
|
7302
7311
|
deps.signal
|
|
7303
7312
|
);
|
|
@@ -7344,11 +7353,10 @@ var TurnCommitter = class {
|
|
|
7344
7353
|
commit,
|
|
7345
7354
|
signal: deps.signal,
|
|
7346
7355
|
nextSeq: deps.nextSeq,
|
|
7347
|
-
emptyFinalBody: EMPTY_FINAL_BODY,
|
|
7348
7356
|
onError: (err) => {
|
|
7349
7357
|
deps.log.warn(
|
|
7350
7358
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
7351
|
-
"dispatcher:
|
|
7359
|
+
"dispatcher: progress-promotion commit failed"
|
|
7352
7360
|
);
|
|
7353
7361
|
}
|
|
7354
7362
|
});
|
|
@@ -7374,11 +7382,11 @@ var TurnCommitter = class {
|
|
|
7374
7382
|
this.onError(err, event.type);
|
|
7375
7383
|
}
|
|
7376
7384
|
}
|
|
7377
|
-
// End-of-turn
|
|
7385
|
+
// End-of-turn progress promotion. The held-text flush is now the adapter's
|
|
7378
7386
|
// job (it emits the closing reply as a `text` event before `result`), so this
|
|
7379
|
-
// only
|
|
7380
|
-
//
|
|
7381
|
-
// on cancel no `final` is forced.
|
|
7387
|
+
// only asks the pump to promote agent-authored interim text. A wordless turn
|
|
7388
|
+
// stays wordless and the dispatcher closes it with a marker. Guards on the
|
|
7389
|
+
// abort signal, so on cancel no `final` is forced.
|
|
7382
7390
|
async finalize(okResult) {
|
|
7383
7391
|
await this.pump.finalize(okResult);
|
|
7384
7392
|
}
|
|
@@ -7389,6 +7397,19 @@ var TurnCommitter = class {
|
|
|
7389
7397
|
get finalSource() {
|
|
7390
7398
|
return this.pump.finalSource;
|
|
7391
7399
|
}
|
|
7400
|
+
get finalEmitted() {
|
|
7401
|
+
return this.pump.finalEmitted;
|
|
7402
|
+
}
|
|
7403
|
+
// A closing textual reply and a wordless terminal marker carry the same
|
|
7404
|
+
// per-turn control state. Keep this one projection so ask/wake/summon cannot
|
|
7405
|
+
// silently diverge when the agent ends without words.
|
|
7406
|
+
turnControlFields() {
|
|
7407
|
+
return {
|
|
7408
|
+
...this.summonField(),
|
|
7409
|
+
...this.askField(),
|
|
7410
|
+
...this.wakeField()
|
|
7411
|
+
};
|
|
7412
|
+
}
|
|
7392
7413
|
// CT183: resolve the in-thread summon into the `dispatch` field for a `final`
|
|
7393
7414
|
// commit. A self-target is stripped here (mirror of the in-app self-strip); the
|
|
7394
7415
|
// server strips it again and resolves / ignores an unknown id.
|
|
@@ -7617,6 +7638,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
|
|
|
7617
7638
|
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:";
|
|
7618
7639
|
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`;
|
|
7619
7640
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
7641
|
+
var SILENT_MARKER_BODY = "(no reply)";
|
|
7620
7642
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
7621
7643
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
7622
7644
|
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
|
|
@@ -8274,6 +8296,7 @@ ${reason}`,
|
|
|
8274
8296
|
let contentBearingEvents = 0;
|
|
8275
8297
|
let latestSessionState = request.session;
|
|
8276
8298
|
let settledDiagnostics = null;
|
|
8299
|
+
let silentMarkerEmitted = false;
|
|
8277
8300
|
const committer = new TurnCommitter({
|
|
8278
8301
|
api: this.opts.api,
|
|
8279
8302
|
workspaceId,
|
|
@@ -8465,6 +8488,27 @@ ${reason}`,
|
|
|
8465
8488
|
}
|
|
8466
8489
|
} else {
|
|
8467
8490
|
await committer.finalize(okResult);
|
|
8491
|
+
if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
|
|
8492
|
+
try {
|
|
8493
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8494
|
+
body: SILENT_MARKER_BODY,
|
|
8495
|
+
kind: "silent",
|
|
8496
|
+
turnId,
|
|
8497
|
+
seq: nextSeq(),
|
|
8498
|
+
parentMessageId: payload.messageId,
|
|
8499
|
+
// ask, wake and summon must survive a wordless turn exactly as
|
|
8500
|
+
// they survive a textual final; dropping one can strand a person
|
|
8501
|
+
// or the next actor with no visible failure.
|
|
8502
|
+
...committer.turnControlFields()
|
|
8503
|
+
});
|
|
8504
|
+
silentMarkerEmitted = true;
|
|
8505
|
+
} catch (err) {
|
|
8506
|
+
turnLog.warn(
|
|
8507
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
8508
|
+
"dispatcher: silent-marker commit failed"
|
|
8509
|
+
);
|
|
8510
|
+
}
|
|
8511
|
+
}
|
|
8468
8512
|
}
|
|
8469
8513
|
} catch (err) {
|
|
8470
8514
|
okResult = false;
|
|
@@ -8555,7 +8599,7 @@ ${reason}`,
|
|
|
8555
8599
|
sessionFingerprint: fingerprintSessionState(latestSessionState),
|
|
8556
8600
|
eventCounts,
|
|
8557
8601
|
runtimeResultKind,
|
|
8558
|
-
finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
|
|
8602
|
+
finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
|
|
8559
8603
|
};
|
|
8560
8604
|
body.diagnostics = settledDiagnostics;
|
|
8561
8605
|
if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
|
|
@@ -9033,11 +9077,9 @@ var CompanionSupervisor = class {
|
|
|
9033
9077
|
// can never disagree with what the manifest advertises. Null until the first
|
|
9034
9078
|
// probe (the heartbeat then falls back to the boot `claudeCode`).
|
|
9035
9079
|
harnessSignals = null;
|
|
9036
|
-
//
|
|
9037
|
-
//
|
|
9038
|
-
|
|
9039
|
-
probeClaudePresence;
|
|
9040
|
-
probeCodexPresence;
|
|
9080
|
+
// Fresh per attempt, never the cached heartbeat signal: installing a harness
|
|
9081
|
+
// between attempts must be observed immediately.
|
|
9082
|
+
probePresence;
|
|
9041
9083
|
exitFn;
|
|
9042
9084
|
reexecFn;
|
|
9043
9085
|
dispatcherFactory;
|
|
@@ -9069,8 +9111,7 @@ var CompanionSupervisor = class {
|
|
|
9069
9111
|
this.log = opts.log;
|
|
9070
9112
|
this.hub = opts.hub;
|
|
9071
9113
|
this.claudeCode = opts.claudeCode ?? true;
|
|
9072
|
-
this.
|
|
9073
|
-
this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
|
|
9114
|
+
this.probePresence = opts.probePresence ?? probeHarnessPresence;
|
|
9074
9115
|
this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
|
|
9075
9116
|
this.exitFn = opts.exit ?? ((code) => process.exit(code));
|
|
9076
9117
|
this.reexecFn = opts.reexec ?? defaultReexec;
|
|
@@ -9768,10 +9809,11 @@ var CompanionSupervisor = class {
|
|
|
9768
9809
|
// over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
|
|
9769
9810
|
// wrapped anyway so a harness refresh can never take a heartbeat down. Called on
|
|
9770
9811
|
// the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
|
|
9771
|
-
async refreshHarnessStatuses() {
|
|
9812
|
+
async refreshHarnessStatuses(presence = {}) {
|
|
9772
9813
|
try {
|
|
9773
9814
|
const signals = await probeHarnessSignals(this.config, {
|
|
9774
|
-
|
|
9815
|
+
probePresence: this.probePresence,
|
|
9816
|
+
presence
|
|
9775
9817
|
});
|
|
9776
9818
|
this.harnessSignals = signals;
|
|
9777
9819
|
this.harnessVersions = {
|
|
@@ -9828,18 +9870,24 @@ var CompanionSupervisor = class {
|
|
|
9828
9870
|
// reachable/valid input).
|
|
9829
9871
|
async enableHarness(input) {
|
|
9830
9872
|
let next;
|
|
9873
|
+
let checkedPresence = null;
|
|
9831
9874
|
if (input.runtime === "claude-code") {
|
|
9832
|
-
|
|
9875
|
+
const presence = input.presence ?? await this.probePresence("claude-code");
|
|
9876
|
+
if (presence.status !== "present") {
|
|
9833
9877
|
return {
|
|
9834
9878
|
ok: false,
|
|
9835
|
-
error: "
|
|
9879
|
+
error: presenceReason("claude-code", presence.status),
|
|
9880
|
+
presence: true
|
|
9836
9881
|
};
|
|
9837
9882
|
}
|
|
9883
|
+
checkedPresence = { runtime: "claude-code", result: presence };
|
|
9838
9884
|
next = { ...this.config, claudeCode: { enabled: true } };
|
|
9839
9885
|
} else if (input.runtime === "codex") {
|
|
9840
|
-
|
|
9841
|
-
|
|
9886
|
+
const presence = input.presence ?? await this.probePresence("codex");
|
|
9887
|
+
if (presence.status !== "present") {
|
|
9888
|
+
return { ok: false, error: presenceReason("codex", presence.status), presence: true };
|
|
9842
9889
|
}
|
|
9890
|
+
checkedPresence = { runtime: "codex", result: presence };
|
|
9843
9891
|
next = { ...this.config, codex: { enabled: true } };
|
|
9844
9892
|
} else {
|
|
9845
9893
|
const serverUrl = input.serverUrl.trim();
|
|
@@ -9863,7 +9911,9 @@ var CompanionSupervisor = class {
|
|
|
9863
9911
|
this.config = next;
|
|
9864
9912
|
saveConfig(next);
|
|
9865
9913
|
this.rebuildDispatchers();
|
|
9866
|
-
await this.refreshHarnessStatuses(
|
|
9914
|
+
await this.refreshHarnessStatuses(
|
|
9915
|
+
checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
|
|
9916
|
+
);
|
|
9867
9917
|
this.kickHeartbeat();
|
|
9868
9918
|
return { ok: true };
|
|
9869
9919
|
}
|
|
@@ -9927,9 +9977,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
|
|
|
9927
9977
|
}
|
|
9928
9978
|
function defaultReexec() {
|
|
9929
9979
|
clearRuntimeState();
|
|
9930
|
-
void import("child_process").then(({ spawn:
|
|
9980
|
+
void import("child_process").then(({ spawn: spawn5 }) => {
|
|
9931
9981
|
try {
|
|
9932
|
-
const child =
|
|
9982
|
+
const child = spawn5(process.execPath, process.argv.slice(1), {
|
|
9933
9983
|
stdio: "inherit",
|
|
9934
9984
|
detached: false
|
|
9935
9985
|
});
|
|
@@ -10092,10 +10142,15 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10092
10142
|
const currentConfig = supervisor.currentConfig();
|
|
10093
10143
|
const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
|
|
10094
10144
|
const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
|
|
10095
|
-
const
|
|
10096
|
-
|
|
10145
|
+
const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
|
|
10146
|
+
const verdict = await shakeOutHarness(runtime, candidate, {
|
|
10147
|
+
...presence ? { presence } : {}
|
|
10148
|
+
});
|
|
10149
|
+
if (verdict === "absent" || verdict === "unusable") {
|
|
10150
|
+
return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
|
|
10151
|
+
}
|
|
10097
10152
|
const result = await supervisor.enableHarness(
|
|
10098
|
-
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
|
|
10153
|
+
runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
|
|
10099
10154
|
);
|
|
10100
10155
|
if (!result.ok) return { ok: false, error: result.error };
|
|
10101
10156
|
return { ok: true, message: connectedLine(runtime, verdict) };
|
|
@@ -10160,7 +10215,7 @@ async function closeSurfaces(control, dashboard) {
|
|
|
10160
10215
|
}
|
|
10161
10216
|
|
|
10162
10217
|
// src/commands/daemon.ts
|
|
10163
|
-
import { spawn as
|
|
10218
|
+
import { spawn as spawn4 } from "child_process";
|
|
10164
10219
|
import { closeSync as closeSync3, mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
|
|
10165
10220
|
|
|
10166
10221
|
// src/cli-entry.ts
|
|
@@ -10267,7 +10322,7 @@ function defaultSpawnDetached(args) {
|
|
|
10267
10322
|
mkdirSync13(cabaneDir(), { recursive: true });
|
|
10268
10323
|
const logFd = openSync3(companionLogPath(), "a");
|
|
10269
10324
|
try {
|
|
10270
|
-
return
|
|
10325
|
+
return spawn4(process.execPath, [cliPath, ...args], {
|
|
10271
10326
|
detached: true,
|
|
10272
10327
|
stdio: ["ignore", logFd, logFd],
|
|
10273
10328
|
env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
|
|
@@ -10365,14 +10420,15 @@ function reportAlreadyRunning(pid) {
|
|
|
10365
10420
|
write("Connect a harness to the running companion: cabane-companion connect claude-code");
|
|
10366
10421
|
}
|
|
10367
10422
|
async function collectHarnessChoices(interactive) {
|
|
10368
|
-
const [
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
probeCliVersion("codex").catch(() => null),
|
|
10423
|
+
const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
|
|
10424
|
+
probeHarnessPresence("claude-code").catch(() => ({ status: "absent" })),
|
|
10425
|
+
probeHarnessPresence("codex").catch(() => ({ status: "absent" })),
|
|
10372
10426
|
probeDefaultOpencodeVersion()
|
|
10373
10427
|
]);
|
|
10428
|
+
const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
|
|
10429
|
+
const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
|
|
10374
10430
|
const found = [
|
|
10375
|
-
...
|
|
10431
|
+
...claudeVersion ? [{ runtime: "claude-code", version: claudeVersion }] : [],
|
|
10376
10432
|
...codexVersion ? [{ runtime: "codex", version: codexVersion }] : [],
|
|
10377
10433
|
...opencodeVersion ? [
|
|
10378
10434
|
{
|