@cabane/companion 0.6.88 → 0.6.90
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/README.md +17 -11
- package/dist/cli.js +776 -219
- package/dist/pairing-config.js +9 -6
- package/dist/postinstall.js +209 -0
- package/dist/runtime.js +765 -208
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -128,7 +128,7 @@ function parsePrepareOutput(stdout) {
|
|
|
128
128
|
return { cwd: last };
|
|
129
129
|
}
|
|
130
130
|
var runPrepareHook = (hook, input) => {
|
|
131
|
-
return new Promise((
|
|
131
|
+
return new Promise((resolve2, reject) => {
|
|
132
132
|
const timeoutMs = hook.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
133
133
|
let settled = false;
|
|
134
134
|
const finish = (fn) => {
|
|
@@ -202,11 +202,11 @@ var runPrepareHook = (hook, input) => {
|
|
|
202
202
|
return;
|
|
203
203
|
}
|
|
204
204
|
if (input.prepared) {
|
|
205
|
-
|
|
205
|
+
resolve2(input.prepared);
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
try {
|
|
209
|
-
|
|
209
|
+
resolve2(parsePrepareOutput(stdout));
|
|
210
210
|
} catch (err) {
|
|
211
211
|
reject(err instanceof PrepareHookError ? err : new PrepareHookError(String(err)));
|
|
212
212
|
}
|
|
@@ -321,9 +321,10 @@ var companionConfigSchema = z2.object({
|
|
|
321
321
|
// The change this task exists for: Claude Code used to be the one harness a
|
|
322
322
|
// device exposed with no configuration — a `claude --version` exit-0 was taken as
|
|
323
323
|
// consent — and it is now the third config-driven harness, opted into exactly
|
|
324
|
-
// like codex.
|
|
325
|
-
//
|
|
326
|
-
//
|
|
324
|
+
// like codex. CT1379: the binary that must be there is the Agent SDK's own
|
|
325
|
+
// bundled executable — the file a turn actually launches — not the user's
|
|
326
|
+
// `claude` on PATH, which the SDK never runs. The manifest advertises
|
|
327
|
+
// claude-code only when this block says so AND that binary resolves.
|
|
327
328
|
//
|
|
328
329
|
// The block's PRESENCE is also the migration marker (see `migrateConnectedHarnesses`).
|
|
329
330
|
// Absent means the config predates this task — a device whose user was never
|
|
@@ -343,9 +344,11 @@ var companionConfigSchema = z2.object({
|
|
|
343
344
|
// not a server address. The operator installs Codex + logs in (`codex login`, or
|
|
344
345
|
// `CODEX_API_KEY` — Cabane never sees the key) and sets `{ "codex": { "enabled":
|
|
345
346
|
// true } }`. Presence of the block enables it (an explicit `enabled: false`
|
|
346
|
-
// keeps the block but turns it off).
|
|
347
|
-
//
|
|
348
|
-
//
|
|
347
|
+
// keeps the block but turns it off). Absent → the device doesn't offer codex.
|
|
348
|
+
// CT1379: enabling is now half the answer, not all of it — the manifest and the
|
|
349
|
+
// adapter registry additionally require the SDK's vendored `codex` binary to
|
|
350
|
+
// resolve, because the enable flag alone advertised a runtime a device with a
|
|
351
|
+
// half-installed platform package could not start.
|
|
349
352
|
codex: z2.object({
|
|
350
353
|
enabled: z2.boolean().optional()
|
|
351
354
|
}).strict().optional()
|
|
@@ -523,18 +526,18 @@ async function startControlServer(handlers) {
|
|
|
523
526
|
void serveConnection(socket, handlers);
|
|
524
527
|
});
|
|
525
528
|
server.unref();
|
|
526
|
-
await new Promise((
|
|
529
|
+
await new Promise((resolve2, reject) => {
|
|
527
530
|
server.once("error", reject);
|
|
528
531
|
server.listen(path, () => {
|
|
529
532
|
server.removeListener("error", reject);
|
|
530
|
-
|
|
533
|
+
resolve2();
|
|
531
534
|
});
|
|
532
535
|
});
|
|
533
536
|
if (process.platform !== "win32") {
|
|
534
537
|
try {
|
|
535
538
|
chmodSync2(path, 384);
|
|
536
539
|
} catch (err) {
|
|
537
|
-
await new Promise((
|
|
540
|
+
await new Promise((resolve2) => server.close(() => resolve2()));
|
|
538
541
|
try {
|
|
539
542
|
rmSync2(path, { force: true });
|
|
540
543
|
} catch {
|
|
@@ -546,10 +549,10 @@ async function startControlServer(handlers) {
|
|
|
546
549
|
});
|
|
547
550
|
return {
|
|
548
551
|
path,
|
|
549
|
-
close: () => new Promise((
|
|
552
|
+
close: () => new Promise((resolve2) => {
|
|
550
553
|
server.close(() => {
|
|
551
554
|
if (process.platform !== "win32") rmSync2(path, { force: true });
|
|
552
|
-
|
|
555
|
+
resolve2();
|
|
553
556
|
});
|
|
554
557
|
})
|
|
555
558
|
};
|
|
@@ -599,12 +602,12 @@ function reply(socket, body) {
|
|
|
599
602
|
async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
|
|
600
603
|
const socket = connect(path);
|
|
601
604
|
try {
|
|
602
|
-
await new Promise((
|
|
605
|
+
await new Promise((resolve2, reject) => {
|
|
603
606
|
const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
|
|
604
607
|
timer.unref?.();
|
|
605
608
|
socket.once("connect", () => {
|
|
606
609
|
clearTimeout(timer);
|
|
607
|
-
|
|
610
|
+
resolve2();
|
|
608
611
|
});
|
|
609
612
|
socket.once("error", (err) => {
|
|
610
613
|
clearTimeout(timer);
|
|
@@ -631,7 +634,7 @@ function isNotListening(err) {
|
|
|
631
634
|
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
632
635
|
}
|
|
633
636
|
function readLine(socket, timeoutMs) {
|
|
634
|
-
return new Promise((
|
|
637
|
+
return new Promise((resolve2) => {
|
|
635
638
|
let buf = "";
|
|
636
639
|
let settled = false;
|
|
637
640
|
const done = (v) => {
|
|
@@ -639,7 +642,7 @@ function readLine(socket, timeoutMs) {
|
|
|
639
642
|
settled = true;
|
|
640
643
|
clearTimeout(timer);
|
|
641
644
|
socket.removeListener("data", onData);
|
|
642
|
-
|
|
645
|
+
resolve2(v);
|
|
643
646
|
};
|
|
644
647
|
const timer = setTimeout(() => done(null), timeoutMs);
|
|
645
648
|
timer.unref?.();
|
|
@@ -676,14 +679,14 @@ function parseVersionToken(raw) {
|
|
|
676
679
|
return m ? m[0] : null;
|
|
677
680
|
}
|
|
678
681
|
function probeCliPresence(command, spawnImpl = spawn2) {
|
|
679
|
-
return new Promise((
|
|
682
|
+
return new Promise((resolve2) => {
|
|
680
683
|
let settled = false;
|
|
681
684
|
let timer = null;
|
|
682
685
|
const done = (result) => {
|
|
683
686
|
if (!settled) {
|
|
684
687
|
settled = true;
|
|
685
688
|
if (timer) clearTimeout(timer);
|
|
686
|
-
|
|
689
|
+
resolve2(result);
|
|
687
690
|
}
|
|
688
691
|
};
|
|
689
692
|
let child;
|
|
@@ -765,6 +768,203 @@ async function safe(fn) {
|
|
|
765
768
|
}
|
|
766
769
|
}
|
|
767
770
|
|
|
771
|
+
// src/bundled-binaries.ts
|
|
772
|
+
import { realpathSync, statSync } from "fs";
|
|
773
|
+
import { createRequire } from "module";
|
|
774
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
775
|
+
import { fileURLToPath } from "url";
|
|
776
|
+
function nodeResolveSelf(specifier) {
|
|
777
|
+
const here = fileURLToPath(import.meta.url);
|
|
778
|
+
if (typeof import.meta.resolve === "function") {
|
|
779
|
+
try {
|
|
780
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
781
|
+
} catch {
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
return createRequire(here).resolve(specifier);
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
return walkNodeModules(specifier, here);
|
|
789
|
+
}
|
|
790
|
+
function walkNodeModules(pkg2, fromPath) {
|
|
791
|
+
let dir2 = dirname2(fromPath);
|
|
792
|
+
for (; ; ) {
|
|
793
|
+
const manifest = join3(dir2, "node_modules", pkg2, "package.json");
|
|
794
|
+
if (nodeIsFile(manifest)) {
|
|
795
|
+
try {
|
|
796
|
+
return realpathSync(manifest);
|
|
797
|
+
} catch {
|
|
798
|
+
return manifest;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const parent = dirname2(dir2);
|
|
802
|
+
if (parent === dir2) return null;
|
|
803
|
+
dir2 = parent;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function nodeResolve(specifier, fromPath) {
|
|
807
|
+
try {
|
|
808
|
+
return createRequire(fromPath).resolve(specifier);
|
|
809
|
+
} catch {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function nodeIsFile(path) {
|
|
814
|
+
try {
|
|
815
|
+
return statSync(path).isFile();
|
|
816
|
+
} catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function detectPreferMusl(platform = process.platform) {
|
|
821
|
+
if (platform !== "linux") return false;
|
|
822
|
+
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
|
|
823
|
+
const header = report?.header;
|
|
824
|
+
return report != null && header?.glibcVersionRuntime === void 0;
|
|
825
|
+
}
|
|
826
|
+
function defaultResolveEnv() {
|
|
827
|
+
return {
|
|
828
|
+
platform: process.platform,
|
|
829
|
+
arch: process.arch,
|
|
830
|
+
preferMusl: detectPreferMusl(),
|
|
831
|
+
// Both resolutions start from this module, which tsup bundles into
|
|
832
|
+
// `dist/cli.js` and `dist/postinstall.js` — both at the package's `dist/`
|
|
833
|
+
// root — so one environment serves the runtime checks and the install
|
|
834
|
+
// verifier alike.
|
|
835
|
+
resolveSelf: nodeResolveSelf,
|
|
836
|
+
resolve: nodeResolve,
|
|
837
|
+
isFile: nodeIsFile
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
var CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
|
|
841
|
+
function claudePlatformPackages(platform, arch, preferMusl) {
|
|
842
|
+
if (platform === "android") return [`${CLAUDE_SDK}-linux-${arch}-android`];
|
|
843
|
+
if (platform === "linux") {
|
|
844
|
+
return preferMusl ? [`${CLAUDE_SDK}-linux-${arch}-musl`, `${CLAUDE_SDK}-linux-${arch}`] : [`${CLAUDE_SDK}-linux-${arch}`, `${CLAUDE_SDK}-linux-${arch}-musl`];
|
|
845
|
+
}
|
|
846
|
+
return [`${CLAUDE_SDK}-${platform}-${arch}`];
|
|
847
|
+
}
|
|
848
|
+
function resolveClaude(env) {
|
|
849
|
+
const candidates = claudePlatformPackages(env.platform, env.arch, env.preferMusl);
|
|
850
|
+
const base = {
|
|
851
|
+
runtime: "claude-code",
|
|
852
|
+
packageName: candidates[0] ?? null,
|
|
853
|
+
platform: env.platform,
|
|
854
|
+
arch: env.arch
|
|
855
|
+
};
|
|
856
|
+
const sdkEntry = env.resolveSelf(CLAUDE_SDK);
|
|
857
|
+
if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
|
|
858
|
+
let sawPackage = null;
|
|
859
|
+
for (const pkg2 of candidates) {
|
|
860
|
+
const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
|
|
861
|
+
if (!manifest) continue;
|
|
862
|
+
sawPackage ??= pkg2;
|
|
863
|
+
const exe = join3(dirname2(manifest), env.platform === "win32" ? "claude.exe" : "claude");
|
|
864
|
+
if (env.isFile(exe)) {
|
|
865
|
+
return { ...base, packageName: pkg2, status: "present", path: exe };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return sawPackage ? { ...base, packageName: sawPackage, status: "executable_missing", path: null } : { ...base, status: "platform_package_missing", path: null };
|
|
869
|
+
}
|
|
870
|
+
var CODEX_SDK = "@openai/codex-sdk";
|
|
871
|
+
var CODEX_CLI = "@openai/codex";
|
|
872
|
+
function codexTargetTriple(platform, arch) {
|
|
873
|
+
if (platform === "linux" || platform === "android") {
|
|
874
|
+
if (arch === "x64") return "x86_64-unknown-linux-musl";
|
|
875
|
+
if (arch === "arm64") return "aarch64-unknown-linux-musl";
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
if (platform === "darwin") {
|
|
879
|
+
if (arch === "x64") return "x86_64-apple-darwin";
|
|
880
|
+
if (arch === "arm64") return "aarch64-apple-darwin";
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
if (platform === "win32") {
|
|
884
|
+
if (arch === "x64") return "x86_64-pc-windows-msvc";
|
|
885
|
+
if (arch === "arm64") return "aarch64-pc-windows-msvc";
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
var CODEX_PACKAGE_BY_TRIPLE = {
|
|
891
|
+
"x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
|
|
892
|
+
"aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
|
|
893
|
+
"x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
|
|
894
|
+
"aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
|
|
895
|
+
"x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
|
|
896
|
+
"aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
|
|
897
|
+
};
|
|
898
|
+
function resolveCodex(env) {
|
|
899
|
+
const triple = codexTargetTriple(env.platform, env.arch);
|
|
900
|
+
const pkg2 = triple ? CODEX_PACKAGE_BY_TRIPLE[triple] : void 0;
|
|
901
|
+
const base = {
|
|
902
|
+
runtime: "codex",
|
|
903
|
+
packageName: pkg2 ?? null,
|
|
904
|
+
platform: env.platform,
|
|
905
|
+
arch: env.arch
|
|
906
|
+
};
|
|
907
|
+
if (!triple || !pkg2) return { ...base, status: "platform_package_missing", path: null };
|
|
908
|
+
const sdkEntry = env.resolveSelf(CODEX_SDK);
|
|
909
|
+
if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
|
|
910
|
+
const cliManifest = env.resolve(`${CODEX_CLI}/package.json`, sdkEntry);
|
|
911
|
+
if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
|
|
912
|
+
const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
|
|
913
|
+
if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
|
|
914
|
+
const packageRoot = join3(dirname2(platformManifest), "vendor", triple);
|
|
915
|
+
const exeName = env.platform === "win32" ? "codex.exe" : "codex";
|
|
916
|
+
const current = join3(packageRoot, "bin", exeName);
|
|
917
|
+
if (env.isFile(current) && env.isFile(join3(packageRoot, "codex-package.json"))) {
|
|
918
|
+
return { ...base, status: "present", path: current };
|
|
919
|
+
}
|
|
920
|
+
const legacy = join3(packageRoot, "codex", exeName);
|
|
921
|
+
if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
|
|
922
|
+
return { ...base, status: "executable_missing", path: null };
|
|
923
|
+
}
|
|
924
|
+
function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
|
|
925
|
+
try {
|
|
926
|
+
return runtime === "codex" ? resolveCodex(env) : resolveClaude(env);
|
|
927
|
+
} catch {
|
|
928
|
+
return {
|
|
929
|
+
runtime,
|
|
930
|
+
status: "platform_package_missing",
|
|
931
|
+
path: null,
|
|
932
|
+
packageName: null,
|
|
933
|
+
platform: env.platform,
|
|
934
|
+
arch: env.arch
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
var HARNESS_LABEL = {
|
|
939
|
+
"claude-code": "Claude Code",
|
|
940
|
+
codex: "Codex"
|
|
941
|
+
};
|
|
942
|
+
var HARNESS_INTENT_WORD = {
|
|
943
|
+
"claude-code": "connected",
|
|
944
|
+
codex: "enabled"
|
|
945
|
+
};
|
|
946
|
+
function missingNoun(status2) {
|
|
947
|
+
return status2 === "executable_missing" ? "executable" : "platform package";
|
|
948
|
+
}
|
|
949
|
+
function repairCommands() {
|
|
950
|
+
return ["npm i -g @cabane/companion --include=optional"];
|
|
951
|
+
}
|
|
952
|
+
function repairAction() {
|
|
953
|
+
return `Reinstall with \`${repairCommands()[0]}\`.`;
|
|
954
|
+
}
|
|
955
|
+
function startupWarning(r) {
|
|
956
|
+
const label = HARNESS_LABEL[r.runtime];
|
|
957
|
+
return `warning: ${label} is ${HARNESS_INTENT_WORD[r.runtime]}, but @cabane/companion's bundled ${label} ${missingNoun(r.status)} is missing. It will not be offered to Cabane. ${repairAction()} Then restart the companion.`;
|
|
958
|
+
}
|
|
959
|
+
function harnessIssueNote(runtime, status2) {
|
|
960
|
+
const intent = HARNESS_INTENT_WORD[runtime];
|
|
961
|
+
const capitalized = `${intent[0].toUpperCase()}${intent.slice(1)}`;
|
|
962
|
+
return `${capitalized}, but @cabane/companion's bundled ${HARNESS_LABEL[runtime]} ${missingNoun(status2)} is missing. ${repairAction()} Then restart the companion.`;
|
|
963
|
+
}
|
|
964
|
+
function turnIncompleteCopy(runtime) {
|
|
965
|
+
return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
|
|
966
|
+
}
|
|
967
|
+
|
|
768
968
|
// src/manifest.ts
|
|
769
969
|
var DEVICE_MANIFEST = {
|
|
770
970
|
runtimes: [{ name: "claude-code", version: null }],
|
|
@@ -776,7 +976,20 @@ function buildCompanionManifest(opts) {
|
|
|
776
976
|
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
777
977
|
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
778
978
|
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
779
|
-
return {
|
|
979
|
+
return {
|
|
980
|
+
runtimes,
|
|
981
|
+
capabilities: { ...DEVICE_MANIFEST.capabilities },
|
|
982
|
+
runtimeIssues: opts.runtimeIssues ?? []
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function runtimeIssuesFor(gates) {
|
|
986
|
+
const issues = [];
|
|
987
|
+
for (const name of ["claude-code", "codex"]) {
|
|
988
|
+
const gate = name === "codex" ? gates.codex : gates.claudeCode;
|
|
989
|
+
if (!gate.intended || gate.bundled === "present") continue;
|
|
990
|
+
issues.push({ name, reason: gate.bundled });
|
|
991
|
+
}
|
|
992
|
+
return issues;
|
|
780
993
|
}
|
|
781
994
|
|
|
782
995
|
// src/harness-status.ts
|
|
@@ -798,11 +1011,12 @@ function parseHarnessRuntime(raw) {
|
|
|
798
1011
|
function deriveHarnessSnapshot(signals) {
|
|
799
1012
|
const advertised = new Set(
|
|
800
1013
|
buildCompanionManifest({
|
|
801
|
-
// CT1082: connected AND
|
|
802
|
-
// through the same function
|
|
803
|
-
|
|
1014
|
+
// CT1082 + CT1379: connected AND the bundled binary the SDK launches is
|
|
1015
|
+
// there — the manifest's own rule, restated here through the same function
|
|
1016
|
+
// rather than re-decided. PATH is deliberately absent from both gates now.
|
|
1017
|
+
claudeCode: signals.claudeCodeConnected && signals.claudeBundled === "present",
|
|
804
1018
|
opencode: signals.opencodeConfigured,
|
|
805
|
-
codex: signals.codexEnabled
|
|
1019
|
+
codex: signals.codexEnabled && signals.codexBundled === "present"
|
|
806
1020
|
}).runtimes.map((r) => r.name)
|
|
807
1021
|
);
|
|
808
1022
|
const harnesses = [
|
|
@@ -828,7 +1042,7 @@ function deriveClaudeCode(signals, manifestHas) {
|
|
|
828
1042
|
...base,
|
|
829
1043
|
state: "needs_attention",
|
|
830
1044
|
version: null,
|
|
831
|
-
detail: "
|
|
1045
|
+
detail: harnessIssueNote("claude-code", signals.claudeBundled),
|
|
832
1046
|
enable: null
|
|
833
1047
|
};
|
|
834
1048
|
}
|
|
@@ -837,7 +1051,11 @@ function deriveClaudeCode(signals, manifestHas) {
|
|
|
837
1051
|
...base,
|
|
838
1052
|
state: "detected_not_exposed",
|
|
839
1053
|
version: signals.claudeVersion,
|
|
840
|
-
|
|
1054
|
+
// CT1379: connecting records INTENT; it is not the same as becoming
|
|
1055
|
+
// runnable. With the bundled binary missing, a connect succeeds and the
|
|
1056
|
+
// device still advertises nothing — so this says so up front rather than
|
|
1057
|
+
// promising routing it can't deliver.
|
|
1058
|
+
detail: signals.claudeBundled === "present" ? "Claude Code is installed here but not connected yet. Connect it to offer Claude Code from this device." : `Claude Code is installed here but not connected yet \u2014 and @cabane/companion's bundled Claude Code ${missingNoun(signals.claudeBundled)} is missing, so connecting alone won't make it runnable. ${repairAction()}`,
|
|
841
1059
|
enable: "claude-code"
|
|
842
1060
|
};
|
|
843
1061
|
}
|
|
@@ -852,20 +1070,20 @@ function deriveClaudeCode(signals, manifestHas) {
|
|
|
852
1070
|
function deriveCodex(signals, manifestHas) {
|
|
853
1071
|
const base = { runtime: "codex", label: LABELS.codex };
|
|
854
1072
|
if (manifestHas) {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1073
|
+
return {
|
|
1074
|
+
...base,
|
|
1075
|
+
state: "exposed",
|
|
1076
|
+
version: signals.codexVersion,
|
|
1077
|
+
detail: "Codex is enabled and exposed to Cabane.",
|
|
1078
|
+
enable: null
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
if (signals.codexEnabled) {
|
|
864
1082
|
return {
|
|
865
1083
|
...base,
|
|
866
1084
|
state: "needs_attention",
|
|
867
1085
|
version: null,
|
|
868
|
-
detail: "
|
|
1086
|
+
detail: harnessIssueNote("codex", signals.codexBundled),
|
|
869
1087
|
enable: null
|
|
870
1088
|
};
|
|
871
1089
|
}
|
|
@@ -874,7 +1092,9 @@ function deriveCodex(signals, manifestHas) {
|
|
|
874
1092
|
...base,
|
|
875
1093
|
state: "detected_not_exposed",
|
|
876
1094
|
version: signals.codexVersion,
|
|
877
|
-
|
|
1095
|
+
// CT1379: same distinction as Claude Code — enabling is intent, not
|
|
1096
|
+
// launchability.
|
|
1097
|
+
detail: signals.codexBundled === "present" ? "Codex is installed but not enabled yet. Turn it on to offer Codex from this device." : `Codex is installed but not enabled yet \u2014 and @cabane/companion's bundled Codex ${missingNoun(signals.codexBundled)} is missing, so enabling alone won't make it runnable. ${repairAction()}`,
|
|
878
1098
|
enable: "codex"
|
|
879
1099
|
};
|
|
880
1100
|
}
|
|
@@ -941,6 +1161,7 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
|
|
|
941
1161
|
async function probeHarnessSignals(cfg, deps = {}) {
|
|
942
1162
|
const probePresence = deps.probePresence ?? probeHarnessPresence;
|
|
943
1163
|
const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
|
|
1164
|
+
const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
|
|
944
1165
|
const configuredServerUrl = cfg.opencode?.serverUrl;
|
|
945
1166
|
const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
|
|
946
1167
|
const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
|
|
@@ -956,6 +1177,11 @@ async function probeHarnessSignals(cfg, deps = {}) {
|
|
|
956
1177
|
// CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
|
|
957
1178
|
// the manifest gate and the probe above is only a suggestion.
|
|
958
1179
|
claudeCodeConnected: isClaudeCodeConnected(cfg),
|
|
1180
|
+
// CT1379: the executable a Claude turn actually launches. Resolved every time
|
|
1181
|
+
// the signals are, so a binary that disappears between beats is caught on the
|
|
1182
|
+
// next one rather than at turn time.
|
|
1183
|
+
claudeBundled: resolveBundled("claude-code").status,
|
|
1184
|
+
codexBundled: resolveBundled("codex").status,
|
|
959
1185
|
// A parseable `codex --version` is our presence signal (presence alone never
|
|
960
1186
|
// exposes codex; its config flag is the manifest gate either way).
|
|
961
1187
|
codexOnPath: codexVersion !== null,
|
|
@@ -969,12 +1195,12 @@ async function probeHarnessSignals(cfg, deps = {}) {
|
|
|
969
1195
|
};
|
|
970
1196
|
}
|
|
971
1197
|
function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
972
|
-
return new Promise((
|
|
1198
|
+
return new Promise((resolve2) => {
|
|
973
1199
|
let settled = false;
|
|
974
1200
|
const done = (v) => {
|
|
975
1201
|
if (!settled) {
|
|
976
1202
|
settled = true;
|
|
977
|
-
|
|
1203
|
+
resolve2(v);
|
|
978
1204
|
}
|
|
979
1205
|
};
|
|
980
1206
|
const timer = setTimeout(() => done(fallback), timeoutMs);
|
|
@@ -1052,19 +1278,19 @@ function looksUnsupported(output) {
|
|
|
1052
1278
|
);
|
|
1053
1279
|
}
|
|
1054
1280
|
function runBounded(command, args) {
|
|
1055
|
-
return new Promise((
|
|
1281
|
+
return new Promise((resolve2) => {
|
|
1056
1282
|
let settled = false;
|
|
1057
1283
|
const done = (result) => {
|
|
1058
1284
|
if (settled) return;
|
|
1059
1285
|
settled = true;
|
|
1060
1286
|
clearTimeout(timer);
|
|
1061
|
-
|
|
1287
|
+
resolve2(result);
|
|
1062
1288
|
};
|
|
1063
1289
|
let child;
|
|
1064
1290
|
try {
|
|
1065
1291
|
child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1066
1292
|
} catch {
|
|
1067
|
-
|
|
1293
|
+
resolve2({ code: null, output: "", error: "spawn" });
|
|
1068
1294
|
return;
|
|
1069
1295
|
}
|
|
1070
1296
|
let out = "";
|
|
@@ -1093,10 +1319,10 @@ import {
|
|
|
1093
1319
|
openSync,
|
|
1094
1320
|
closeSync
|
|
1095
1321
|
} from "fs";
|
|
1096
|
-
import { join as
|
|
1322
|
+
import { join as join4 } from "path";
|
|
1097
1323
|
var PROBE_TIMEOUT_MS2 = 1e3;
|
|
1098
1324
|
function runtimePath() {
|
|
1099
|
-
return
|
|
1325
|
+
return join4(cabaneDir(), "runtime.json");
|
|
1100
1326
|
}
|
|
1101
1327
|
function serialize(state) {
|
|
1102
1328
|
return JSON.stringify(state, null, 2) + "\n";
|
|
@@ -1255,8 +1481,8 @@ var Spinner = class {
|
|
|
1255
1481
|
async function confirm(question) {
|
|
1256
1482
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1257
1483
|
try {
|
|
1258
|
-
const answer = await new Promise((
|
|
1259
|
-
rl.question(`${INDENT}${question} [Y/n] `,
|
|
1484
|
+
const answer = await new Promise((resolve2) => {
|
|
1485
|
+
rl.question(`${INDENT}${question} [Y/n] `, resolve2);
|
|
1260
1486
|
});
|
|
1261
1487
|
return !/^n(o)?$/i.test(answer.trim());
|
|
1262
1488
|
} finally {
|
|
@@ -1378,10 +1604,10 @@ import {
|
|
|
1378
1604
|
rmSync as rmSync4,
|
|
1379
1605
|
writeFileSync as writeFileSync3
|
|
1380
1606
|
} from "fs";
|
|
1381
|
-
import { dirname as
|
|
1607
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1382
1608
|
import { z as z3 } from "zod";
|
|
1383
1609
|
function credentialsPath() {
|
|
1384
|
-
return
|
|
1610
|
+
return join5(cabaneDir(), "credentials.json");
|
|
1385
1611
|
}
|
|
1386
1612
|
var credentialStoreSchema = z3.record(z3.string(), z3.string());
|
|
1387
1613
|
function load() {
|
|
@@ -1403,7 +1629,7 @@ function load() {
|
|
|
1403
1629
|
}
|
|
1404
1630
|
function save(map) {
|
|
1405
1631
|
const path = credentialsPath();
|
|
1406
|
-
mkdirSync4(
|
|
1632
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
1407
1633
|
try {
|
|
1408
1634
|
chmodSync3(cabaneDir(), 448);
|
|
1409
1635
|
} catch {
|
|
@@ -1548,7 +1774,7 @@ function deviceLabelFromHostname(hostname3) {
|
|
|
1548
1774
|
function pollOnce(baseUrl, deviceCode) {
|
|
1549
1775
|
return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
|
|
1550
1776
|
}
|
|
1551
|
-
var sleep = (ms) => new Promise((
|
|
1777
|
+
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1552
1778
|
async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
1553
1779
|
if (opts.signal?.aborted) throw new EnrollmentCancelledError();
|
|
1554
1780
|
const code = await requestEnrollmentCode(baseUrl, {
|
|
@@ -1674,11 +1900,11 @@ import { hostname as hostname2 } from "os";
|
|
|
1674
1900
|
|
|
1675
1901
|
// src/logger.ts
|
|
1676
1902
|
import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
|
|
1677
|
-
import { dirname as
|
|
1903
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
1678
1904
|
import pino from "pino";
|
|
1679
1905
|
import pretty from "pino-pretty";
|
|
1680
1906
|
function companionLogPath() {
|
|
1681
|
-
return
|
|
1907
|
+
return join6(cabaneDir(), "companion.log");
|
|
1682
1908
|
}
|
|
1683
1909
|
var CONSOLE_IGNORE = [
|
|
1684
1910
|
"pid",
|
|
@@ -1706,7 +1932,7 @@ function setConsoleLogging(enabled) {
|
|
|
1706
1932
|
}
|
|
1707
1933
|
function createLogger(destinations = {}) {
|
|
1708
1934
|
const path = companionLogPath();
|
|
1709
|
-
if (!destinations.file) mkdirSync5(
|
|
1935
|
+
if (!destinations.file) mkdirSync5(dirname4(path), { recursive: true });
|
|
1710
1936
|
const streams = [];
|
|
1711
1937
|
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
1712
1938
|
const consoleStream = pretty({
|
|
@@ -1740,15 +1966,15 @@ function getLogger() {
|
|
|
1740
1966
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
1741
1967
|
|
|
1742
1968
|
// src/dashboard/server.ts
|
|
1743
|
-
import { dirname as
|
|
1744
|
-
import { fileURLToPath } from "url";
|
|
1969
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
1970
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1745
1971
|
import { serve } from "@hono/node-server";
|
|
1746
1972
|
import { Hono } from "hono";
|
|
1747
1973
|
|
|
1748
1974
|
// src/dashboard/routes.ts
|
|
1749
1975
|
import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync5 } from "fs";
|
|
1750
1976
|
import { readFile } from "fs/promises";
|
|
1751
|
-
import { extname, join as
|
|
1977
|
+
import { extname, join as join7, normalize } from "path";
|
|
1752
1978
|
import { streamSSE } from "hono/streaming";
|
|
1753
1979
|
|
|
1754
1980
|
// src/state.ts
|
|
@@ -1997,13 +2223,13 @@ var CONTENT_TYPES = {
|
|
|
1997
2223
|
function registerRoutes(app, deps) {
|
|
1998
2224
|
const { supervisor, hub, staticDir } = deps;
|
|
1999
2225
|
app.get("/", async (c) => {
|
|
2000
|
-
const html = await readFile(
|
|
2226
|
+
const html = await readFile(join7(staticDir, "index.html"), "utf8");
|
|
2001
2227
|
return c.html(html);
|
|
2002
2228
|
});
|
|
2003
2229
|
app.get("/static/:file", async (c) => {
|
|
2004
2230
|
const file = c.req.param("file");
|
|
2005
2231
|
const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
|
|
2006
|
-
const full =
|
|
2232
|
+
const full = join7(staticDir, safe3);
|
|
2007
2233
|
if (!full.startsWith(staticDir) || !existsSync5(full)) return c.notFound();
|
|
2008
2234
|
const body = await readFile(full);
|
|
2009
2235
|
const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
|
|
@@ -2103,8 +2329,8 @@ function registerRoutes(app, deps) {
|
|
|
2103
2329
|
while (!stream.aborted) {
|
|
2104
2330
|
if (queue.length === 0) {
|
|
2105
2331
|
await Promise.race([
|
|
2106
|
-
new Promise((
|
|
2107
|
-
wake =
|
|
2332
|
+
new Promise((resolve2) => {
|
|
2333
|
+
wake = resolve2;
|
|
2108
2334
|
}),
|
|
2109
2335
|
stream.sleep(25e3)
|
|
2110
2336
|
]);
|
|
@@ -2188,8 +2414,8 @@ async function startDashboard(opts) {
|
|
|
2188
2414
|
return {
|
|
2189
2415
|
url,
|
|
2190
2416
|
port,
|
|
2191
|
-
close: () => new Promise((
|
|
2192
|
-
server.close(() =>
|
|
2417
|
+
close: () => new Promise((resolve2) => {
|
|
2418
|
+
server.close(() => resolve2());
|
|
2193
2419
|
server.closeAllConnections?.();
|
|
2194
2420
|
})
|
|
2195
2421
|
};
|
|
@@ -2206,12 +2432,12 @@ async function startDashboard(opts) {
|
|
|
2206
2432
|
);
|
|
2207
2433
|
}
|
|
2208
2434
|
function listen(app, port) {
|
|
2209
|
-
return new Promise((
|
|
2435
|
+
return new Promise((resolve2, reject) => {
|
|
2210
2436
|
let settled = false;
|
|
2211
2437
|
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
|
|
2212
2438
|
if (!settled) {
|
|
2213
2439
|
settled = true;
|
|
2214
|
-
|
|
2440
|
+
resolve2(server);
|
|
2215
2441
|
}
|
|
2216
2442
|
});
|
|
2217
2443
|
server.on("error", (err) => {
|
|
@@ -2226,7 +2452,7 @@ function isAddrInUse(err) {
|
|
|
2226
2452
|
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
|
|
2227
2453
|
}
|
|
2228
2454
|
function resolveStaticDir() {
|
|
2229
|
-
return
|
|
2455
|
+
return join8(dirname5(fileURLToPath2(import.meta.url)), "static");
|
|
2230
2456
|
}
|
|
2231
2457
|
|
|
2232
2458
|
// src/prereqs.ts
|
|
@@ -2251,6 +2477,7 @@ async function requireStartConfig(deps = {}) {
|
|
|
2251
2477
|
async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
2252
2478
|
const probeClaude = deps.probeClaude ?? claudeOnPath;
|
|
2253
2479
|
const probeCodex = deps.probeCodex ?? codexOnPath;
|
|
2480
|
+
const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
|
|
2254
2481
|
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
|
|
2255
2482
|
`));
|
|
2256
2483
|
const connected = [
|
|
@@ -2259,25 +2486,42 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
|
2259
2486
|
...cfg.opencode ? ["opencode"] : []
|
|
2260
2487
|
];
|
|
2261
2488
|
if (connected.length > 0) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
);
|
|
2489
|
+
for (const runtime of ["claude-code", "codex"]) {
|
|
2490
|
+
const intended = runtime === "codex" ? isCodexEnabled(cfg) : isClaudeCodeConnected(cfg);
|
|
2491
|
+
if (!intended) continue;
|
|
2492
|
+
const resolution = resolveBundled(runtime);
|
|
2493
|
+
if (resolution.status !== "present") warn(startupWarning(resolution));
|
|
2266
2494
|
}
|
|
2267
2495
|
return;
|
|
2268
2496
|
}
|
|
2269
2497
|
const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
|
|
2270
|
-
const
|
|
2271
|
-
...claudeInstalled ? [
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2498
|
+
const found = [
|
|
2499
|
+
...claudeInstalled ? [
|
|
2500
|
+
{
|
|
2501
|
+
label: "Claude Code",
|
|
2502
|
+
command: "`cabane-companion connect claude-code`",
|
|
2503
|
+
bundled: resolveBundled("claude-code").status
|
|
2504
|
+
}
|
|
2505
|
+
] : [],
|
|
2506
|
+
...codexInstalled ? [
|
|
2507
|
+
{
|
|
2508
|
+
label: "Codex",
|
|
2509
|
+
command: "`cabane-companion connect codex`",
|
|
2510
|
+
bundled: resolveBundled("codex").status
|
|
2511
|
+
}
|
|
2512
|
+
] : []
|
|
2277
2513
|
];
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2514
|
+
const runnable = found.filter((h) => h.bundled === "present");
|
|
2515
|
+
const incomplete = found.filter((h) => h.bundled !== "present");
|
|
2516
|
+
const incompleteNote = incomplete.map(
|
|
2517
|
+
(h) => `${h.label} is installed here, but @cabane/companion's bundled ${h.label} ${missingNoun(h.bundled)} is missing, so connecting it alone won't make it runnable.`
|
|
2518
|
+
).join(" ");
|
|
2519
|
+
const guidance = runnable.length > 0 ? `We found ${runnable.map((h) => h.label).join(" and ")} on this machine \u2014 connect ${runnable.length > 1 ? "one with " : "it with "}${runnable.map((h) => h.command).join(" or ")} and turns start routing here.${incompleteNote ? ` ${incompleteNote} ${repairAction()}` : ""}` : incomplete.length > 0 ? (
|
|
2520
|
+
// Their machine is fine; this companion's install is not. Repair that,
|
|
2521
|
+
// then connect — no "install a harness" they already have.
|
|
2522
|
+
`${incompleteNote} ${repairAction()} Then connect it with ${incomplete.map((h) => h.command).join(" or ")}.`
|
|
2523
|
+
) : "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).";
|
|
2524
|
+
warn(`No harness is connected on this device yet, so no agent turn can run here. ${guidance}`);
|
|
2281
2525
|
}
|
|
2282
2526
|
|
|
2283
2527
|
// src/supervisor.ts
|
|
@@ -2457,10 +2701,22 @@ var CabaneApi = class {
|
|
|
2457
2701
|
// token whose turn has ended. The dispatcher mints it before this call and
|
|
2458
2702
|
// reuses the same value on its active-run PATCH, so the token's turn id and the
|
|
2459
2703
|
// pair's `active_turn_id` agree.
|
|
2460
|
-
|
|
2461
|
-
|
|
2704
|
+
// CT1380: `resumed` says this dispatch is a REPLAY of an interrupted turn, so
|
|
2705
|
+
// the server should hand back `turnSeqFloor` — the turn's committed seq
|
|
2706
|
+
// high-water mark. Only the supervisor knows (it minted the id or recovered
|
|
2707
|
+
// it), so it is passed down rather than inferred here. It gates the COST of
|
|
2708
|
+
// the aggregate, never the tenancy scoping, which the route applies
|
|
2709
|
+
// unconditionally.
|
|
2710
|
+
getTurnContext(conversationId, messageId2, turnId, resumed) {
|
|
2711
|
+
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
|
|
2462
2712
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
2463
2713
|
}
|
|
2714
|
+
// CT1380: the local half of a resumed turn's seq floor (see
|
|
2715
|
+
// `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
|
|
2716
|
+
// test has no durable queue, so nothing can be hiding in it.
|
|
2717
|
+
outboxMaxSeqForTurn(turnId) {
|
|
2718
|
+
return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
|
|
2719
|
+
}
|
|
2464
2720
|
// CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
|
|
2465
2721
|
// (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
|
|
2466
2722
|
// server-side (the URL MCP surface) rather than the dispatcher's in-memory
|
|
@@ -2743,15 +2999,15 @@ function isAbortError(err) {
|
|
|
2743
2999
|
return err instanceof Error && err.name === "AbortError";
|
|
2744
3000
|
}
|
|
2745
3001
|
function sleep2(ms, signal) {
|
|
2746
|
-
return new Promise((
|
|
2747
|
-
if (signal?.aborted) return
|
|
3002
|
+
return new Promise((resolve2) => {
|
|
3003
|
+
if (signal?.aborted) return resolve2();
|
|
2748
3004
|
const timer = setTimeout(() => {
|
|
2749
3005
|
signal?.removeEventListener("abort", onAbort);
|
|
2750
|
-
|
|
3006
|
+
resolve2();
|
|
2751
3007
|
}, ms);
|
|
2752
3008
|
const onAbort = () => {
|
|
2753
3009
|
clearTimeout(timer);
|
|
2754
|
-
|
|
3010
|
+
resolve2();
|
|
2755
3011
|
};
|
|
2756
3012
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2757
3013
|
});
|
|
@@ -2834,9 +3090,9 @@ function errorMessage2(status2, body) {
|
|
|
2834
3090
|
|
|
2835
3091
|
// src/cursor.ts
|
|
2836
3092
|
import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
|
|
2837
|
-
import { join as
|
|
3093
|
+
import { join as join9 } from "path";
|
|
2838
3094
|
function pathFor(workspaceId) {
|
|
2839
|
-
return
|
|
3095
|
+
return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
|
|
2840
3096
|
}
|
|
2841
3097
|
function readCursor(workspaceId) {
|
|
2842
3098
|
const path = pathFor(workspaceId);
|
|
@@ -2846,7 +3102,7 @@ function readCursor(workspaceId) {
|
|
|
2846
3102
|
}
|
|
2847
3103
|
function writeCursor(workspaceId, eventId) {
|
|
2848
3104
|
const path = pathFor(workspaceId);
|
|
2849
|
-
mkdirSync6(
|
|
3105
|
+
mkdirSync6(join9(cabaneDir(), "cursors"), { recursive: true });
|
|
2850
3106
|
writeFileSync4(path, eventId + "\n", "utf8");
|
|
2851
3107
|
}
|
|
2852
3108
|
|
|
@@ -2891,13 +3147,13 @@ var CursorTracker = class {
|
|
|
2891
3147
|
|
|
2892
3148
|
// src/dispatch-dedupe.ts
|
|
2893
3149
|
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
|
|
2894
|
-
import { join as
|
|
3150
|
+
import { join as join10 } from "path";
|
|
2895
3151
|
var MAX_IDS = 256;
|
|
2896
3152
|
function dir(log) {
|
|
2897
|
-
return
|
|
3153
|
+
return join10(cabaneDir(), log);
|
|
2898
3154
|
}
|
|
2899
3155
|
function pathFor2(log, workspaceId) {
|
|
2900
|
-
return
|
|
3156
|
+
return join10(dir(log), encodeURIComponent(workspaceId));
|
|
2901
3157
|
}
|
|
2902
3158
|
function readIds(log, workspaceId) {
|
|
2903
3159
|
const path = pathFor2(log, workspaceId);
|
|
@@ -2934,10 +3190,10 @@ function markCompleted(workspaceId, eventId) {
|
|
|
2934
3190
|
}
|
|
2935
3191
|
var MAX_RESUME_ATTEMPTS = 3;
|
|
2936
3192
|
function resumeDir() {
|
|
2937
|
-
return
|
|
3193
|
+
return join10(cabaneDir(), "resume-attempts");
|
|
2938
3194
|
}
|
|
2939
3195
|
function resumePathFor(workspaceId) {
|
|
2940
|
-
return
|
|
3196
|
+
return join10(resumeDir(), encodeURIComponent(workspaceId));
|
|
2941
3197
|
}
|
|
2942
3198
|
function readResumeCounts(workspaceId) {
|
|
2943
3199
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -2973,10 +3229,10 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2973
3229
|
return next;
|
|
2974
3230
|
}
|
|
2975
3231
|
function turnDir() {
|
|
2976
|
-
return
|
|
3232
|
+
return join10(cabaneDir(), "turns");
|
|
2977
3233
|
}
|
|
2978
3234
|
function turnPathFor(workspaceId) {
|
|
2979
|
-
return
|
|
3235
|
+
return join10(turnDir(), encodeURIComponent(workspaceId));
|
|
2980
3236
|
}
|
|
2981
3237
|
var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2982
3238
|
function readTurnIds(workspaceId) {
|
|
@@ -5351,7 +5607,7 @@ var serverTurnTails = /* @__PURE__ */ new Map();
|
|
|
5351
5607
|
async function acquireServerTurnLock(url) {
|
|
5352
5608
|
const prev = serverTurnTails.get(url) ?? Promise.resolve();
|
|
5353
5609
|
let release;
|
|
5354
|
-
const done = new Promise((
|
|
5610
|
+
const done = new Promise((resolve2) => release = resolve2);
|
|
5355
5611
|
serverTurnTails.set(url, done);
|
|
5356
5612
|
await prev.catch(() => {
|
|
5357
5613
|
});
|
|
@@ -7590,8 +7846,8 @@ var ConnectorHealthStore = class {
|
|
|
7590
7846
|
};
|
|
7591
7847
|
|
|
7592
7848
|
// src/dispatcher.ts
|
|
7593
|
-
import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
|
|
7594
|
-
import { join as
|
|
7849
|
+
import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
7850
|
+
import { join as join15 } from "path";
|
|
7595
7851
|
|
|
7596
7852
|
// src/turn-execution.ts
|
|
7597
7853
|
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
@@ -7953,11 +8209,11 @@ function trimSlash2(s) {
|
|
|
7953
8209
|
// src/codex-instructions.ts
|
|
7954
8210
|
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
7955
8211
|
import { tmpdir } from "os";
|
|
7956
|
-
import { join as
|
|
8212
|
+
import { join as join11 } from "path";
|
|
7957
8213
|
var PREFIX = "cabane-codex-instructions-";
|
|
7958
8214
|
async function writeCodexInstructionsFile(contents) {
|
|
7959
|
-
const dir2 = await mkdtemp(
|
|
7960
|
-
const path =
|
|
8215
|
+
const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
|
|
8216
|
+
const path = join11(dir2, "instructions.md");
|
|
7961
8217
|
await writeFile(path, contents, { encoding: "utf8", mode: 384 });
|
|
7962
8218
|
return {
|
|
7963
8219
|
path,
|
|
@@ -7967,17 +8223,86 @@ async function writeCodexInstructionsFile(contents) {
|
|
|
7967
8223
|
};
|
|
7968
8224
|
}
|
|
7969
8225
|
|
|
8226
|
+
// src/api-error-shape.ts
|
|
8227
|
+
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
8228
|
+
"dispatch_not_admitted",
|
|
8229
|
+
"turn_already_ended",
|
|
8230
|
+
"turn_belongs_elsewhere"
|
|
8231
|
+
]);
|
|
8232
|
+
function apiErrorCode(err) {
|
|
8233
|
+
if (!(err instanceof ApiError)) return null;
|
|
8234
|
+
const body = err.body;
|
|
8235
|
+
return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
8236
|
+
}
|
|
8237
|
+
function leaseRefusal(err) {
|
|
8238
|
+
const code = apiErrorCode(err);
|
|
8239
|
+
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
8240
|
+
return null;
|
|
8241
|
+
}
|
|
8242
|
+
function isWriteFenceRefusal(err) {
|
|
8243
|
+
return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
|
|
8244
|
+
}
|
|
8245
|
+
var ERROR_BODY_LOG_CAP = 2e3;
|
|
8246
|
+
function describeErrorBody(body) {
|
|
8247
|
+
if (body === void 0 || body === null) return void 0;
|
|
8248
|
+
let text;
|
|
8249
|
+
if (typeof body === "string") {
|
|
8250
|
+
text = body;
|
|
8251
|
+
} else {
|
|
8252
|
+
try {
|
|
8253
|
+
text = JSON.stringify(body);
|
|
8254
|
+
} catch {
|
|
8255
|
+
text = String(body);
|
|
8256
|
+
}
|
|
8257
|
+
}
|
|
8258
|
+
if (text.length === 0) return void 0;
|
|
8259
|
+
return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
|
|
8260
|
+
}
|
|
8261
|
+
|
|
8262
|
+
// src/turn-seq-floor.ts
|
|
8263
|
+
var SeqFloorUnavailable = class extends Error {
|
|
8264
|
+
constructor(detail) {
|
|
8265
|
+
super(`seq_floor_unavailable: ${detail}`);
|
|
8266
|
+
this.detail = detail;
|
|
8267
|
+
this.name = "SeqFloorUnavailable";
|
|
8268
|
+
}
|
|
8269
|
+
detail;
|
|
8270
|
+
};
|
|
8271
|
+
function resolveSeqFloor(sources, ctx) {
|
|
8272
|
+
const { serverFloor, outboxFloor } = sources;
|
|
8273
|
+
if (serverFloor === void 0) {
|
|
8274
|
+
throw new SeqFloorUnavailable("server sent no committed floor for a resumed turn");
|
|
8275
|
+
}
|
|
8276
|
+
const floor = Math.max(serverFloor, outboxFloor);
|
|
8277
|
+
ctx.log.info(
|
|
8278
|
+
{ turnId: ctx.turnId, floor, outboxFloor, serverFloor },
|
|
8279
|
+
"companion: resumed turn \u2014 seq counter seeded above its committed high-water mark"
|
|
8280
|
+
);
|
|
8281
|
+
return floor;
|
|
8282
|
+
}
|
|
8283
|
+
function readOutboxFloor(read, turnId, log) {
|
|
8284
|
+
try {
|
|
8285
|
+
return read(turnId);
|
|
8286
|
+
} catch (err) {
|
|
8287
|
+
log.error(
|
|
8288
|
+
{ turnId, err: err instanceof Error ? err.message : String(err) },
|
|
8289
|
+
"companion: the on-disk outbox floor is unreadable; refusing to resume"
|
|
8290
|
+
);
|
|
8291
|
+
throw new SeqFloorUnavailable("outbox unreadable");
|
|
8292
|
+
}
|
|
8293
|
+
}
|
|
8294
|
+
|
|
7970
8295
|
// src/prepared.ts
|
|
7971
8296
|
import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
|
|
7972
|
-
import { join as
|
|
8297
|
+
import { join as join12 } from "path";
|
|
7973
8298
|
function dirFor(workspaceId) {
|
|
7974
|
-
return
|
|
8299
|
+
return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
7975
8300
|
}
|
|
7976
8301
|
function conversationDir(workspaceId, conversationId) {
|
|
7977
|
-
return
|
|
8302
|
+
return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
|
|
7978
8303
|
}
|
|
7979
8304
|
function pathFor3(workspaceId, conversationId, agentId) {
|
|
7980
|
-
return
|
|
8305
|
+
return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
|
|
7981
8306
|
}
|
|
7982
8307
|
function readPrepared(workspaceId, conversationId, agentId) {
|
|
7983
8308
|
const path = pathFor3(workspaceId, conversationId, agentId);
|
|
@@ -8009,11 +8334,11 @@ function clearPrepared(workspaceId, conversationId, agentId) {
|
|
|
8009
8334
|
|
|
8010
8335
|
// src/secrets.ts
|
|
8011
8336
|
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
|
|
8012
|
-
import { join as
|
|
8337
|
+
import { join as join13 } from "path";
|
|
8013
8338
|
import { z as z14 } from "zod";
|
|
8014
8339
|
var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
8015
8340
|
function secretsPath() {
|
|
8016
|
-
return
|
|
8341
|
+
return join13(cabaneDir(), "secrets.json");
|
|
8017
8342
|
}
|
|
8018
8343
|
var secretStoreSchema = z14.record(z14.string(), z14.string());
|
|
8019
8344
|
function loadSecretStore() {
|
|
@@ -8100,9 +8425,9 @@ function resolveMcpSecrets(mcpServers, store) {
|
|
|
8100
8425
|
|
|
8101
8426
|
// src/transcript-writer.ts
|
|
8102
8427
|
import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
|
|
8103
|
-
import { basename, dirname as
|
|
8428
|
+
import { basename, dirname as dirname6, join as join14 } from "path";
|
|
8104
8429
|
function transcriptsDir() {
|
|
8105
|
-
return
|
|
8430
|
+
return join14(cabaneDir(), "transcripts");
|
|
8106
8431
|
}
|
|
8107
8432
|
var RETAIN = 200;
|
|
8108
8433
|
var ANOMALY_RETAIN = 50;
|
|
@@ -8112,7 +8437,7 @@ var TranscriptWriter = class {
|
|
|
8112
8437
|
onWarn;
|
|
8113
8438
|
constructor(dir2, meta, onWarn) {
|
|
8114
8439
|
this.onWarn = onWarn;
|
|
8115
|
-
this.path =
|
|
8440
|
+
this.path = join14(dir2, fileName(meta));
|
|
8116
8441
|
try {
|
|
8117
8442
|
mkdirSync9(dir2, { recursive: true });
|
|
8118
8443
|
try {
|
|
@@ -8141,10 +8466,10 @@ var TranscriptWriter = class {
|
|
|
8141
8466
|
preserveAnomaly() {
|
|
8142
8467
|
if (this.broken) return;
|
|
8143
8468
|
try {
|
|
8144
|
-
const dir2 =
|
|
8469
|
+
const dir2 = join14(dirname6(this.path), "anomalies");
|
|
8145
8470
|
mkdirSync9(dir2, { recursive: true, mode: 448 });
|
|
8146
8471
|
chmodSync4(dir2, 448);
|
|
8147
|
-
const target =
|
|
8472
|
+
const target = join14(dir2, basename(this.path));
|
|
8148
8473
|
copyFileSync(this.path, target);
|
|
8149
8474
|
chmodSync4(target, 384);
|
|
8150
8475
|
pruneOld(dir2, ANOMALY_RETAIN);
|
|
@@ -8190,7 +8515,7 @@ function pruneOld(dir2, retain) {
|
|
|
8190
8515
|
const drop = files.sort().slice(0, files.length - retain);
|
|
8191
8516
|
for (const f of drop) {
|
|
8192
8517
|
try {
|
|
8193
|
-
rmSync6(
|
|
8518
|
+
rmSync6(join14(dir2, f), { force: true });
|
|
8194
8519
|
} catch {
|
|
8195
8520
|
}
|
|
8196
8521
|
}
|
|
@@ -8337,6 +8662,84 @@ var TurnCommitter = class {
|
|
|
8337
8662
|
}
|
|
8338
8663
|
};
|
|
8339
8664
|
|
|
8665
|
+
// src/turn-runtime-integrity.ts
|
|
8666
|
+
function bundledHarnessFor(runtime) {
|
|
8667
|
+
return runtime === "claude-code" || runtime === "codex" ? runtime : null;
|
|
8668
|
+
}
|
|
8669
|
+
function resolve(ctx, harness) {
|
|
8670
|
+
return (ctx.resolveBundled ?? resolveBundledBinary)(harness);
|
|
8671
|
+
}
|
|
8672
|
+
async function postIncompleteNotice(ctx, harness, resolution) {
|
|
8673
|
+
try {
|
|
8674
|
+
await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
|
|
8675
|
+
body: turnIncompleteCopy(harness),
|
|
8676
|
+
kind: "final",
|
|
8677
|
+
turnId: ctx.turnId,
|
|
8678
|
+
parentMessageId: ctx.parentMessageId
|
|
8679
|
+
});
|
|
8680
|
+
return true;
|
|
8681
|
+
} catch (err) {
|
|
8682
|
+
ctx.log.warn(
|
|
8683
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
8684
|
+
"dispatcher: runtime-incomplete notice post failed"
|
|
8685
|
+
);
|
|
8686
|
+
return false;
|
|
8687
|
+
}
|
|
8688
|
+
}
|
|
8689
|
+
async function checkBundledBinary(ctx) {
|
|
8690
|
+
const harness = bundledHarnessFor(ctx.runtime);
|
|
8691
|
+
if (!harness) return null;
|
|
8692
|
+
const resolution = resolve(ctx, harness);
|
|
8693
|
+
if (resolution.status === "present") return null;
|
|
8694
|
+
ctx.log.error(
|
|
8695
|
+
{
|
|
8696
|
+
runtime: harness,
|
|
8697
|
+
status: resolution.status,
|
|
8698
|
+
platform: resolution.platform,
|
|
8699
|
+
arch: resolution.arch
|
|
8700
|
+
},
|
|
8701
|
+
"dispatcher: bundled harness binary missing at dispatch"
|
|
8702
|
+
);
|
|
8703
|
+
return {
|
|
8704
|
+
harness,
|
|
8705
|
+
reason: `runtime_incomplete:${harness}`,
|
|
8706
|
+
posted: await postIncompleteNotice(ctx, harness, resolution)
|
|
8707
|
+
};
|
|
8708
|
+
}
|
|
8709
|
+
async function reclassifyThrow(ctx, opts) {
|
|
8710
|
+
const harness = bundledHarnessFor(ctx.runtime);
|
|
8711
|
+
if (!harness || opts.aborted) return null;
|
|
8712
|
+
const resolution = resolve(ctx, harness);
|
|
8713
|
+
if (resolution.status === "present") return null;
|
|
8714
|
+
ctx.log.error(
|
|
8715
|
+
{
|
|
8716
|
+
runtime: harness,
|
|
8717
|
+
status: resolution.status,
|
|
8718
|
+
platform: resolution.platform,
|
|
8719
|
+
arch: resolution.arch,
|
|
8720
|
+
originalReason: opts.originalReason
|
|
8721
|
+
},
|
|
8722
|
+
"dispatcher: adapter threw and the bundled harness binary is missing \u2014 reporting the incomplete runtime"
|
|
8723
|
+
);
|
|
8724
|
+
return {
|
|
8725
|
+
harness,
|
|
8726
|
+
reason: `runtime_incomplete:${harness}`,
|
|
8727
|
+
posted: await postIncompleteNotice(ctx, harness, resolution)
|
|
8728
|
+
};
|
|
8729
|
+
}
|
|
8730
|
+
async function guardBundledBinary(ctx, concluded, outcome) {
|
|
8731
|
+
const incomplete = await checkBundledBinary(ctx);
|
|
8732
|
+
if (!incomplete) return;
|
|
8733
|
+
outcome.runtimeIncomplete = incomplete.posted;
|
|
8734
|
+
throw incomplete.posted ? concluded(incomplete.reason) : concluded(incomplete.reason, incomplete.reason);
|
|
8735
|
+
}
|
|
8736
|
+
async function absorbBundledLoss(ctx, aborted, outcome) {
|
|
8737
|
+
const lost = await reclassifyThrow(ctx, { aborted, originalReason: outcome.resultReason });
|
|
8738
|
+
if (!lost) return false;
|
|
8739
|
+
outcome.resultReason = lost.reason;
|
|
8740
|
+
return lost.posted;
|
|
8741
|
+
}
|
|
8742
|
+
|
|
8340
8743
|
// src/turn-execution.ts
|
|
8341
8744
|
var PREPARING_TOOL_NAME = "preparing";
|
|
8342
8745
|
var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
|
|
@@ -8349,40 +8752,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
|
8349
8752
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
8350
8753
|
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
|
|
8351
8754
|
var DEFAULT_LEASE_RENEWAL_MS = 3e4;
|
|
8352
|
-
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
8353
|
-
"dispatch_not_admitted",
|
|
8354
|
-
"turn_already_ended",
|
|
8355
|
-
"turn_belongs_elsewhere"
|
|
8356
|
-
]);
|
|
8357
|
-
function apiErrorCode(err) {
|
|
8358
|
-
if (!(err instanceof ApiError)) return null;
|
|
8359
|
-
const body = err.body;
|
|
8360
|
-
return typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
8361
|
-
}
|
|
8362
|
-
function leaseRefusal(err) {
|
|
8363
|
-
const code = apiErrorCode(err);
|
|
8364
|
-
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
8365
|
-
return null;
|
|
8366
|
-
}
|
|
8367
|
-
function isWriteFenceRefusal(err) {
|
|
8368
|
-
return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
|
|
8369
|
-
}
|
|
8370
|
-
var ERROR_BODY_LOG_CAP = 2e3;
|
|
8371
|
-
function describeErrorBody(body) {
|
|
8372
|
-
if (body === void 0 || body === null) return void 0;
|
|
8373
|
-
let text;
|
|
8374
|
-
if (typeof body === "string") {
|
|
8375
|
-
text = body;
|
|
8376
|
-
} else {
|
|
8377
|
-
try {
|
|
8378
|
-
text = JSON.stringify(body);
|
|
8379
|
-
} catch {
|
|
8380
|
-
text = String(body);
|
|
8381
|
-
}
|
|
8382
|
-
}
|
|
8383
|
-
if (text.length === 0) return void 0;
|
|
8384
|
-
return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
|
|
8385
|
-
}
|
|
8386
8755
|
function initialOutcome() {
|
|
8387
8756
|
return {
|
|
8388
8757
|
sessionWritten: false,
|
|
@@ -8401,7 +8770,8 @@ function initialOutcome() {
|
|
|
8401
8770
|
settledDiagnostics: null,
|
|
8402
8771
|
silentMarkerEmitted: false,
|
|
8403
8772
|
timeoutReason: null,
|
|
8404
|
-
leaseLost: false
|
|
8773
|
+
leaseLost: false,
|
|
8774
|
+
runtimeIncomplete: false
|
|
8405
8775
|
};
|
|
8406
8776
|
}
|
|
8407
8777
|
var TurnConcluded = class {
|
|
@@ -8417,6 +8787,7 @@ var TurnExecution = class {
|
|
|
8417
8787
|
this.opts = opts;
|
|
8418
8788
|
this.supervisor = supervisor;
|
|
8419
8789
|
this.payload = payload;
|
|
8790
|
+
this.resumed = handleOpts.resumed === true;
|
|
8420
8791
|
this.dispatchId = payload.messageId;
|
|
8421
8792
|
this.workspaceId = opts.workspaceId;
|
|
8422
8793
|
this.turnLog = opts.log.child({
|
|
@@ -8439,7 +8810,13 @@ var TurnExecution = class {
|
|
|
8439
8810
|
outcome = initialOutcome();
|
|
8440
8811
|
seqCounter = 0;
|
|
8441
8812
|
// CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
|
|
8813
|
+
//
|
|
8814
|
+
// CT1380: 0 for a NEW turn only — a resume is seeded in `fetchContext` first.
|
|
8442
8815
|
nextSeq = () => ++this.seqCounter;
|
|
8816
|
+
// CT1380: a REPLAY, told to us by the supervisor — not derivable here, since
|
|
8817
|
+
// `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
|
|
8818
|
+
resumed;
|
|
8819
|
+
resumedFromSeq;
|
|
8443
8820
|
turnContext;
|
|
8444
8821
|
resolvedMcpServers;
|
|
8445
8822
|
effectiveCwd;
|
|
@@ -8485,6 +8862,10 @@ var TurnExecution = class {
|
|
|
8485
8862
|
await this.acquireLease();
|
|
8486
8863
|
await this.selectAdapter();
|
|
8487
8864
|
} catch (err) {
|
|
8865
|
+
if (err instanceof SeqFloorUnavailable) {
|
|
8866
|
+
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
8867
|
+
return this.concludeBeforeRun(err.message, err.message);
|
|
8868
|
+
}
|
|
8488
8869
|
if (err instanceof TurnConcluded) {
|
|
8489
8870
|
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
8490
8871
|
return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
|
|
@@ -8541,7 +8922,9 @@ var TurnExecution = class {
|
|
|
8541
8922
|
activeRunStartedAt: null,
|
|
8542
8923
|
turnId,
|
|
8543
8924
|
settledMessageId: payload.messageId,
|
|
8544
|
-
|
|
8925
|
+
// CT1379: `runtimeIncomplete` is a real failure that deliberately carries
|
|
8926
|
+
// no `errorReason`, so the inference alone would read it as settled.
|
|
8927
|
+
outcome: errorReason || this.outcome.runtimeIncomplete ? "failed" : "settled"
|
|
8545
8928
|
};
|
|
8546
8929
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
8547
8930
|
try {
|
|
@@ -8563,12 +8946,14 @@ var TurnExecution = class {
|
|
|
8563
8946
|
}
|
|
8564
8947
|
async fetchContext() {
|
|
8565
8948
|
const { payload, turnId, turnLog } = this;
|
|
8949
|
+
const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
|
|
8566
8950
|
let turnContext;
|
|
8567
8951
|
try {
|
|
8568
8952
|
turnContext = await this.opts.api.getTurnContext(
|
|
8569
8953
|
payload.conversationId,
|
|
8570
8954
|
payload.messageId,
|
|
8571
|
-
turnId
|
|
8955
|
+
turnId,
|
|
8956
|
+
this.resumed
|
|
8572
8957
|
);
|
|
8573
8958
|
} catch (err) {
|
|
8574
8959
|
const status2 = err instanceof ApiError ? err.status : 0;
|
|
@@ -8585,6 +8970,11 @@ var TurnExecution = class {
|
|
|
8585
8970
|
throw this.concluded(fetchReason, fetchReason);
|
|
8586
8971
|
}
|
|
8587
8972
|
this.turnContext = turnContext;
|
|
8973
|
+
if (this.resumed) {
|
|
8974
|
+
const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
|
|
8975
|
+
this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
|
|
8976
|
+
this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
|
|
8977
|
+
}
|
|
8588
8978
|
}
|
|
8589
8979
|
gateTrigger() {
|
|
8590
8980
|
const { payload, turnLog } = this;
|
|
@@ -8815,6 +9205,8 @@ ${reason}`,
|
|
|
8815
9205
|
try {
|
|
8816
9206
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
8817
9207
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9208
|
+
// CT1380: the durable record that this turn resumed, and from where.
|
|
9209
|
+
...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
|
|
8818
9210
|
// CT33: hand the server this turn's id so the new-run chokepoint's
|
|
8819
9211
|
// `closeAbandonedTurns` sweep excludes it. The prepare hook may have
|
|
8820
9212
|
// already emitted a "preparing" activity row for this turn above (it
|
|
@@ -8872,8 +9264,21 @@ ${reason}`,
|
|
|
8872
9264
|
...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
|
|
8873
9265
|
});
|
|
8874
9266
|
}
|
|
9267
|
+
integrityContext() {
|
|
9268
|
+
return {
|
|
9269
|
+
runtime: this.turnContext.runtime,
|
|
9270
|
+
workspaceId: this.workspaceId,
|
|
9271
|
+
conversationId: this.payload.conversationId,
|
|
9272
|
+
turnId: this.turnId,
|
|
9273
|
+
parentMessageId: this.payload.messageId,
|
|
9274
|
+
log: this.turnLog,
|
|
9275
|
+
postTurnMessage: (workspaceId, conversationId, body) => this.opts.api.postTurnMessage(workspaceId, conversationId, body),
|
|
9276
|
+
...this.opts.resolveBundled ? { resolveBundled: this.opts.resolveBundled } : {}
|
|
9277
|
+
};
|
|
9278
|
+
}
|
|
8875
9279
|
async selectAdapter() {
|
|
8876
9280
|
const { payload, workspaceId, turnId, turnLog } = this;
|
|
9281
|
+
await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
|
|
8877
9282
|
const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
|
|
8878
9283
|
const adapters = [];
|
|
8879
9284
|
if (this.opts.claudeCodeAvailable?.() ?? true) {
|
|
@@ -8882,7 +9287,7 @@ ${reason}`,
|
|
|
8882
9287
|
if (this.opts.opencodeServerUrl) {
|
|
8883
9288
|
adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
|
|
8884
9289
|
}
|
|
8885
|
-
if (this.opts.
|
|
9290
|
+
if (this.opts.codexAvailable?.() ?? false) {
|
|
8886
9291
|
adapters.push(
|
|
8887
9292
|
createCodexAdapter({
|
|
8888
9293
|
enabled: true,
|
|
@@ -9166,6 +9571,11 @@ ${reason}`,
|
|
|
9166
9571
|
o.okResult = false;
|
|
9167
9572
|
o.resultReason = err instanceof Error ? err.message : String(err);
|
|
9168
9573
|
turnLog.error({ err: o.resultReason }, "dispatcher: SDK query threw");
|
|
9574
|
+
o.runtimeIncomplete = await absorbBundledLoss(
|
|
9575
|
+
this.integrityContext(),
|
|
9576
|
+
abortController.signal.aborted,
|
|
9577
|
+
o
|
|
9578
|
+
);
|
|
9169
9579
|
} finally {
|
|
9170
9580
|
this.disarmWatchdogs();
|
|
9171
9581
|
if (o.leaseLost) {
|
|
@@ -9214,13 +9624,13 @@ ${reason}`,
|
|
|
9214
9624
|
if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
|
|
9215
9625
|
body.resolvedConfig = o.turnResolvedConfig;
|
|
9216
9626
|
}
|
|
9217
|
-
if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !this.skipState.skipped) {
|
|
9627
|
+
if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !o.runtimeIncomplete && !this.skipState.skipped) {
|
|
9218
9628
|
body.errorReason = o.resultReason.slice(0, 200);
|
|
9219
9629
|
}
|
|
9220
9630
|
if (o.okResult) {
|
|
9221
9631
|
body.lastSeenMessageId = payload.messageId;
|
|
9222
9632
|
}
|
|
9223
|
-
body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
|
|
9633
|
+
body.outcome = o.okResult ? "settled" : body.errorReason || o.runtimeIncomplete ? "failed" : "interrupted";
|
|
9224
9634
|
if (o.sessionDegraded) {
|
|
9225
9635
|
body.degraded = true;
|
|
9226
9636
|
}
|
|
@@ -9345,15 +9755,15 @@ function checkoutState(cwd) {
|
|
|
9345
9755
|
if (entries.length === 0) {
|
|
9346
9756
|
return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
|
|
9347
9757
|
}
|
|
9348
|
-
const gitPath =
|
|
9758
|
+
const gitPath = join15(cwd, ".git");
|
|
9349
9759
|
if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
|
|
9350
9760
|
let stat;
|
|
9351
9761
|
try {
|
|
9352
|
-
stat =
|
|
9762
|
+
stat = statSync2(gitPath);
|
|
9353
9763
|
} catch (error) {
|
|
9354
9764
|
return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
|
|
9355
9765
|
}
|
|
9356
|
-
if (stat.isDirectory() && !existsSync11(
|
|
9766
|
+
if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
|
|
9357
9767
|
return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
|
|
9358
9768
|
return { ok: true, reason: "usable" };
|
|
9359
9769
|
}
|
|
@@ -9488,7 +9898,7 @@ import {
|
|
|
9488
9898
|
rmSync as rmSync7,
|
|
9489
9899
|
writeFileSync as writeFileSync7
|
|
9490
9900
|
} from "fs";
|
|
9491
|
-
import { join as
|
|
9901
|
+
import { join as join16 } from "path";
|
|
9492
9902
|
var MAX_ENTRIES = 2e3;
|
|
9493
9903
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
9494
9904
|
var Outbox = class {
|
|
@@ -9501,10 +9911,10 @@ var Outbox = class {
|
|
|
9501
9911
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
9502
9912
|
// cases route writes at the right tmpdir.
|
|
9503
9913
|
dir() {
|
|
9504
|
-
return
|
|
9914
|
+
return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
9505
9915
|
}
|
|
9506
9916
|
fileFor(turnId, seq) {
|
|
9507
|
-
return
|
|
9917
|
+
return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
9508
9918
|
}
|
|
9509
9919
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
9510
9920
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
@@ -9546,7 +9956,7 @@ var Outbox = class {
|
|
|
9546
9956
|
const entries = [];
|
|
9547
9957
|
for (const name of names) {
|
|
9548
9958
|
if (!name.endsWith(".json")) continue;
|
|
9549
|
-
const full =
|
|
9959
|
+
const full = join16(dir2, name);
|
|
9550
9960
|
try {
|
|
9551
9961
|
const parsed = JSON.parse(readFileSync8(full, "utf8"));
|
|
9552
9962
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
@@ -9563,6 +9973,35 @@ var Outbox = class {
|
|
|
9563
9973
|
);
|
|
9564
9974
|
return entries;
|
|
9565
9975
|
}
|
|
9976
|
+
// CT1380: the highest `seq` this turn has queued but not yet delivered — the
|
|
9977
|
+
// half of a resumed turn's seq floor the SERVER CANNOT SEE. A commit that
|
|
9978
|
+
// failed transiently before the crash is sitting in this directory with its
|
|
9979
|
+
// seq already spent, and the restart's drain is fire-and-forget
|
|
9980
|
+
// (`drain.kick()`, never awaited), so `maxSeqForTurn` on the server can report
|
|
9981
|
+
// below it and the resumed run would mint that number a second time.
|
|
9982
|
+
//
|
|
9983
|
+
// Reads the directory rather than `list()` because only the filename matters:
|
|
9984
|
+
// `<turnId>__<seq>.json` carries both halves of the key, so a corrupt body
|
|
9985
|
+
// can't hide a spent seq from the floor. Returns 0 for a turn with nothing
|
|
9986
|
+
// queued, which is the overwhelmingly common case.
|
|
9987
|
+
// THROWS rather than returning 0 when the directory exists but cannot be
|
|
9988
|
+
// read. 0 is a claim ("this turn has nothing queued"), and an unreadable
|
|
9989
|
+
// directory cannot support it — a queued entry holding seq N would be
|
|
9990
|
+
// invisible and the resumed run would mint N again. A missing directory is
|
|
9991
|
+
// different: it is positive evidence that nothing was ever queued here.
|
|
9992
|
+
maxSeqForTurn(turnId) {
|
|
9993
|
+
const dir2 = this.dir();
|
|
9994
|
+
if (!existsSync12(dir2)) return 0;
|
|
9995
|
+
const names = readdirSync3(dir2);
|
|
9996
|
+
const prefix = `${encodeURIComponent(turnId)}__`;
|
|
9997
|
+
let max = 0;
|
|
9998
|
+
for (const name of names) {
|
|
9999
|
+
if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
|
|
10000
|
+
const seq = Number(name.slice(prefix.length, -".json".length));
|
|
10001
|
+
if (Number.isInteger(seq) && seq > max) max = seq;
|
|
10002
|
+
}
|
|
10003
|
+
return max;
|
|
10004
|
+
}
|
|
9566
10005
|
// Remove a delivered (or terminally-discarded) entry. No-op if already gone.
|
|
9567
10006
|
remove(turnId, seq) {
|
|
9568
10007
|
try {
|
|
@@ -9769,12 +10208,12 @@ var SseSubscriber = class {
|
|
|
9769
10208
|
}
|
|
9770
10209
|
};
|
|
9771
10210
|
function sleep3(ms) {
|
|
9772
|
-
return new Promise((
|
|
10211
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
9773
10212
|
}
|
|
9774
10213
|
|
|
9775
10214
|
// src/version.ts
|
|
9776
|
-
import { createRequire } from "module";
|
|
9777
|
-
var pkg =
|
|
10215
|
+
import { createRequire as createRequire2 } from "module";
|
|
10216
|
+
var pkg = createRequire2(import.meta.url)("../package.json");
|
|
9778
10217
|
var COMPANION_VERSION = pkg.version;
|
|
9779
10218
|
|
|
9780
10219
|
// src/supervisor.ts
|
|
@@ -9796,10 +10235,15 @@ var CompanionSupervisor = class {
|
|
|
9796
10235
|
config;
|
|
9797
10236
|
log;
|
|
9798
10237
|
hub;
|
|
9799
|
-
//
|
|
9800
|
-
//
|
|
9801
|
-
//
|
|
9802
|
-
|
|
10238
|
+
// CT1379: the bundled-executable resolver — the execution-presence half of both
|
|
10239
|
+
// routing gates. Synchronous and cheap (a module resolve plus a stat), so unlike
|
|
10240
|
+
// the CLI probes it needs no boot-frozen fallback: a caller that asks before the
|
|
10241
|
+
// first harness refresh simply resolves now.
|
|
10242
|
+
resolveBundled;
|
|
10243
|
+
// CT1379: the last bundled status LOGGED per harness, so a missing binary is
|
|
10244
|
+
// reported at startup and on each healthy↔missing transition rather than on
|
|
10245
|
+
// every 30s beat (the CT484 latch pattern, per runtime).
|
|
10246
|
+
loggedBundledStatus = /* @__PURE__ */ new Map();
|
|
9803
10247
|
// CT571: the probed per-harness versions, reported on each heartbeat manifest.
|
|
9804
10248
|
// CT586: no longer boot-frozen — the per-heartbeat harness re-probe refreshes
|
|
9805
10249
|
// it, so a harness updated mid-run reports its new version without a restart.
|
|
@@ -9843,13 +10287,14 @@ var CompanionSupervisor = class {
|
|
|
9843
10287
|
this.config = opts.config;
|
|
9844
10288
|
this.log = opts.log;
|
|
9845
10289
|
this.hub = opts.hub;
|
|
9846
|
-
this.
|
|
10290
|
+
this.resolveBundled = opts.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
|
|
9847
10291
|
this.probePresence = opts.probePresence ?? probeHarnessPresence;
|
|
9848
10292
|
this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
|
|
9849
10293
|
this.exitFn = opts.exit ?? ((code) => process.exit(code));
|
|
9850
10294
|
this.reexecFn = opts.reexec ?? defaultReexec;
|
|
9851
10295
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
9852
10296
|
this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
|
|
10297
|
+
this.latchBootBundledState();
|
|
9853
10298
|
}
|
|
9854
10299
|
// Stand up the data plane: pair check, initial assignments pull, then the
|
|
9855
10300
|
// heartbeat + poll loops. A companion with no device token (logged out) does
|
|
@@ -9932,19 +10377,25 @@ var CompanionSupervisor = class {
|
|
|
9932
10377
|
const res = await this.deviceApi.heartbeat({
|
|
9933
10378
|
version: COMPANION_VERSION,
|
|
9934
10379
|
exposedSecretNames: store.names(),
|
|
9935
|
-
// Report each runtime only when this device can actually run it:
|
|
9936
|
-
// claude-code
|
|
10380
|
+
// Report each runtime only when this device can actually run it:
|
|
10381
|
+
// CT1379 claude-code and codex when the SDK's own bundled binary is there
|
|
10382
|
+
// and the user asked for the harness, CT270 opencode when the operator
|
|
9937
10383
|
// configured an `opencode serve`.
|
|
9938
10384
|
manifest: buildCompanionManifest({
|
|
9939
|
-
// CT1082: connected AND
|
|
9940
|
-
//
|
|
9941
|
-
// user's `claudeCode.enabled`. Claude Code
|
|
10385
|
+
// CT1082/CT1379: connected AND launchable. Execution presence is the
|
|
10386
|
+
// live bundled-binary resolution (refreshed on this beat), consent is
|
|
10387
|
+
// the user's `claudeCode.enabled`. Claude Code rides neither alone.
|
|
9942
10388
|
claudeCode: this.claudeCodeOffered(),
|
|
9943
10389
|
opencode: !!this.config.opencode?.serverUrl,
|
|
9944
|
-
// CT481: advertise codex when the operator enabled it
|
|
9945
|
-
//
|
|
9946
|
-
// a
|
|
9947
|
-
|
|
10390
|
+
// CT481/CT1379: advertise codex when the operator enabled it AND its
|
|
10391
|
+
// vendored binary is there. The enable flag alone used to be enough,
|
|
10392
|
+
// which is how a device with a half-installed 331 MB platform package
|
|
10393
|
+
// advertised a runtime that could not start.
|
|
10394
|
+
codex: this.codexOffered(),
|
|
10395
|
+
// CT1379: and say WHY when an asked-for harness is absent from the list
|
|
10396
|
+
// above. Diagnostic only — the server never routes on it — and always
|
|
10397
|
+
// sent, so an empty array clears a previously reported issue.
|
|
10398
|
+
runtimeIssues: this.runtimeIssues(),
|
|
9948
10399
|
// CT571/CT586: each runtime's `version` from the latest harness probe
|
|
9949
10400
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
9950
10401
|
versions: this.harnessVersions
|
|
@@ -10165,18 +10616,103 @@ var CompanionSupervisor = class {
|
|
|
10165
10616
|
"companion: stopped agent (unassigned)"
|
|
10166
10617
|
);
|
|
10167
10618
|
}
|
|
10168
|
-
//
|
|
10169
|
-
//
|
|
10170
|
-
//
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
//
|
|
10177
|
-
//
|
|
10619
|
+
// CT1379: can this machine LAUNCH the harness — is the SDK's own bundled
|
|
10620
|
+
// executable there? Live (the per-beat re-resolve), falling back to resolving
|
|
10621
|
+
// right now before the first refresh lands. Execution presence ONLY; consent is
|
|
10622
|
+
// the caller's half.
|
|
10623
|
+
bundledStatus(runtime) {
|
|
10624
|
+
const cached2 = runtime === "codex" ? this.harnessSignals?.codexBundled : this.harnessSignals?.claudeBundled;
|
|
10625
|
+
return cached2 ?? this.resolveBundled(runtime).status;
|
|
10626
|
+
}
|
|
10627
|
+
// The full resolution rather than just its status — the transition log needs the
|
|
10628
|
+
// platform/architecture and the repair the copy is built from, and re-resolving
|
|
10629
|
+
// is a module lookup plus a stat.
|
|
10630
|
+
bundledResolution(runtime) {
|
|
10631
|
+
return this.resolveBundled(runtime);
|
|
10632
|
+
}
|
|
10633
|
+
// CT1082/CT1379: does this device OFFER claude-code — connected by its user, and
|
|
10634
|
+
// able to start the binary a turn runs? The ONE signal both the heartbeat
|
|
10635
|
+
// manifest and the dispatcher's adapter registry read, so what the device
|
|
10636
|
+
// advertises and what it can select can't disagree (the CT833 invariant, with
|
|
10637
|
+
// consent in front of it and the real executable underneath it).
|
|
10178
10638
|
claudeCodeOffered() {
|
|
10179
|
-
return isClaudeCodeConnected(this.config) && this.
|
|
10639
|
+
return isClaudeCodeConnected(this.config) && this.bundledStatus("claude-code") === "present";
|
|
10640
|
+
}
|
|
10641
|
+
// CT1379: and the same question for codex, which until now was the enable flag
|
|
10642
|
+
// alone — so an enabled device with no vendored binary advertised a runtime it
|
|
10643
|
+
// could not start. Same shape as Claude Code, same predicate on both sides.
|
|
10644
|
+
codexOffered() {
|
|
10645
|
+
return isCodexEnabled(this.config) && this.bundledStatus("codex") === "present";
|
|
10646
|
+
}
|
|
10647
|
+
// CT1379: the manifest's diagnostic half, derived from the same two facts as the
|
|
10648
|
+
// two predicates above so an issue and an advertisement can never both be made
|
|
10649
|
+
// for one harness.
|
|
10650
|
+
runtimeIssues() {
|
|
10651
|
+
return runtimeIssuesFor({
|
|
10652
|
+
claudeCode: {
|
|
10653
|
+
intended: isClaudeCodeConnected(this.config),
|
|
10654
|
+
bundled: this.bundledStatus("claude-code")
|
|
10655
|
+
},
|
|
10656
|
+
codex: { intended: isCodexEnabled(this.config), bundled: this.bundledStatus("codex") }
|
|
10657
|
+
});
|
|
10658
|
+
}
|
|
10659
|
+
// CT1379: a missing binary is reported ONCE, and again only when the answer
|
|
10660
|
+
// CHANGES — with the runtime, the platform/architecture and the resolution
|
|
10661
|
+
// result, which is enough to find the occurrence in `companion.log` without a
|
|
10662
|
+
// repro and carries nothing that could be a credential or a vendor's output.
|
|
10663
|
+
//
|
|
10664
|
+
// Two things this deliberately does NOT do (Codo's review, blocking finding #2):
|
|
10665
|
+
//
|
|
10666
|
+
// - It does not re-announce the state the device booted in. `warnAboutHarnessReadiness`
|
|
10667
|
+
// already said that on stderr at startup, in the specified words, at the one
|
|
10668
|
+
// moment a person is watching a terminal — and the `--daemon` launcher runs
|
|
10669
|
+
// that preflight in the FOREGROUND for exactly that reason, because the
|
|
10670
|
+
// child's output goes to a file. The boot state is latched in the
|
|
10671
|
+
// constructor, so the first refresh reports nothing new; it lands here as a
|
|
10672
|
+
// debug record instead, present for diagnosis without competing for
|
|
10673
|
+
// attention with the warning the operator already read.
|
|
10674
|
+
// - It does not report a transition in generic prose. A binary that goes
|
|
10675
|
+
// missing at 3am is the case the operator will actually meet, and a line
|
|
10676
|
+
// that says only "binary is missing" leaves them with no repair and no
|
|
10677
|
+
// restart instruction. Transitions carry the specified copy verbatim — the
|
|
10678
|
+
// same sentence the startup path prints.
|
|
10679
|
+
logBundledTransitions() {
|
|
10680
|
+
for (const runtime of ["claude-code", "codex"]) {
|
|
10681
|
+
const intended = runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config);
|
|
10682
|
+
const resolution = this.bundledResolution(runtime);
|
|
10683
|
+
const status2 = resolution.status;
|
|
10684
|
+
if (this.loggedBundledStatus.get(runtime) === status2) continue;
|
|
10685
|
+
this.loggedBundledStatus.set(runtime, status2);
|
|
10686
|
+
const meta = { runtime, platform: resolution.platform, arch: resolution.arch, status: status2 };
|
|
10687
|
+
if (status2 === "present") {
|
|
10688
|
+
const say = intended ? this.log.info : this.log.debug;
|
|
10689
|
+
say.call(this.log, meta, "companion: bundled harness binary is present again");
|
|
10690
|
+
continue;
|
|
10691
|
+
}
|
|
10692
|
+
if (intended) this.log.warn(meta, startupWarning(resolution));
|
|
10693
|
+
else
|
|
10694
|
+
this.log.debug(meta, "companion: bundled harness binary is missing (harness not in use)");
|
|
10695
|
+
}
|
|
10696
|
+
}
|
|
10697
|
+
// CT1379: the boot state, latched so the first refresh reports no news, and
|
|
10698
|
+
// recorded once as evidence. Runs in the constructor — the resolve is a module
|
|
10699
|
+
// lookup plus a stat, so it costs nothing and needs no await.
|
|
10700
|
+
latchBootBundledState() {
|
|
10701
|
+
for (const runtime of ["claude-code", "codex"]) {
|
|
10702
|
+
const resolution = this.resolveBundled(runtime);
|
|
10703
|
+
this.loggedBundledStatus.set(runtime, resolution.status);
|
|
10704
|
+
if (resolution.status === "present") continue;
|
|
10705
|
+
this.log.debug(
|
|
10706
|
+
{
|
|
10707
|
+
runtime,
|
|
10708
|
+
platform: resolution.platform,
|
|
10709
|
+
arch: resolution.arch,
|
|
10710
|
+
status: resolution.status,
|
|
10711
|
+
intended: runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config)
|
|
10712
|
+
},
|
|
10713
|
+
"companion: bundled harness binary missing at startup (warned by the readiness check)"
|
|
10714
|
+
);
|
|
10715
|
+
}
|
|
10180
10716
|
}
|
|
10181
10717
|
buildDispatcher(ctx) {
|
|
10182
10718
|
const local = localAgentConfig(this.config, {
|
|
@@ -10200,16 +10736,23 @@ var CompanionSupervisor = class {
|
|
|
10200
10736
|
// CT833: register the claude-code adapter only when this device actually
|
|
10201
10737
|
// offers claude-code — read per turn (not captured here), so a harness
|
|
10202
10738
|
// installed or connected after boot works on the next turn exactly as it
|
|
10203
|
-
// appears on the next beat. CT1082: "offers"
|
|
10204
|
-
//
|
|
10739
|
+
// appears on the next beat. CT1082: "offers" means connected as well as
|
|
10740
|
+
// runnable, so a disconnected harness can't be selected either.
|
|
10741
|
+
// CT1379: and the same for the bundled binary — the predicate is shared with
|
|
10742
|
+
// the heartbeat above, so a runtime advertised by one and refused by the
|
|
10743
|
+
// other is not expressible.
|
|
10205
10744
|
claudeCodeAvailable: () => this.claudeCodeOffered(),
|
|
10206
10745
|
// CT270: the opencode server URL (operator-configured), when this device
|
|
10207
10746
|
// offers the opencode runtime. Threaded so an opencode turn selects the
|
|
10208
10747
|
// opencode adapter; unset leaves the device claude-code-only.
|
|
10209
10748
|
...this.config.opencode?.serverUrl ? { opencodeServerUrl: this.config.opencode.serverUrl } : {},
|
|
10210
|
-
// CT481: register the codex adapter when this device offers codex;
|
|
10749
|
+
// CT481: register the codex adapter when this device offers codex; a miss
|
|
10211
10750
|
// leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
|
|
10212
|
-
|
|
10751
|
+
// CT1379: read PER TURN, like claude-code above, and off the same predicate
|
|
10752
|
+
// the heartbeat uses — it was captured here at dispatcher-construction time
|
|
10753
|
+
// from the enable flag alone, so a device could register an adapter for a
|
|
10754
|
+
// binary it did not have and keep it registered after the binary came back.
|
|
10755
|
+
codexAvailable: () => this.codexOffered(),
|
|
10213
10756
|
// CT556: per-turn timeout watchdog windows, from the companion's own env
|
|
10214
10757
|
// (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
|
|
10215
10758
|
// dispatcher's baked-in defaults (10 min idle / 6h total).
|
|
@@ -10479,7 +11022,10 @@ var CompanionSupervisor = class {
|
|
|
10479
11022
|
"companion: resuming an interrupted turn under its original id"
|
|
10480
11023
|
);
|
|
10481
11024
|
}
|
|
10482
|
-
const result = await agent.dispatcher.handle(payload, {
|
|
11025
|
+
const result = await agent.dispatcher.handle(payload, {
|
|
11026
|
+
turnId,
|
|
11027
|
+
resumed: resumedTurnId !== null
|
|
11028
|
+
});
|
|
10483
11029
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
10484
11030
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
10485
11031
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -10576,9 +11122,13 @@ var CompanionSupervisor = class {
|
|
|
10576
11122
|
try {
|
|
10577
11123
|
const signals = await probeHarnessSignals(this.config, {
|
|
10578
11124
|
probePresence: this.probePresence,
|
|
10579
|
-
presence
|
|
11125
|
+
presence,
|
|
11126
|
+
// CT1379: the bundled executables are resolved on the same cadence as the
|
|
11127
|
+
// CLI probes, through the supervisor's seam so a test pins the host fact.
|
|
11128
|
+
resolveBundled: this.resolveBundled
|
|
10580
11129
|
});
|
|
10581
11130
|
this.harnessSignals = signals;
|
|
11131
|
+
this.logBundledTransitions();
|
|
10582
11132
|
this.harnessVersions = {
|
|
10583
11133
|
claudeCode: signals.claudeVersion,
|
|
10584
11134
|
opencode: signals.opencodeVersion,
|
|
@@ -10618,8 +11168,15 @@ var CompanionSupervisor = class {
|
|
|
10618
11168
|
return this.config;
|
|
10619
11169
|
}
|
|
10620
11170
|
// Friendly enable for the config-driven harnesses — flip the flag the app owns in
|
|
10621
|
-
// `~/.cabane/config.json`, no hand-edited JSON.
|
|
10622
|
-
// never
|
|
11171
|
+
// `~/.cabane/config.json`, no hand-edited JSON. It never installs a binary and
|
|
11172
|
+
// never drives a login (BYO — Decided).
|
|
11173
|
+
//
|
|
11174
|
+
// CT1379: this records INTENT; it does not by itself expose anything. A harness
|
|
11175
|
+
// is advertised only when the user asked for it AND the SDK's own bundled binary
|
|
11176
|
+
// resolves, so a successful enable on a companion whose optional dependency
|
|
11177
|
+
// never installed leaves the device advertising nothing — correctly, and with
|
|
11178
|
+
// the Harnesses surface saying why. Read every "expose" below as "record the
|
|
11179
|
+
// half of the answer the user owns".
|
|
10623
11180
|
// - claude-code (CT1082): set `claudeCode.enabled`, after checking `claude` is
|
|
10624
11181
|
// on PATH. This is the callable the terminal offer ("We found Claude Code.
|
|
10625
11182
|
// Connect it? [Y/n]") and the web pairing flow both wire to.
|
|
@@ -10628,9 +11185,10 @@ var CompanionSupervisor = class {
|
|
|
10628
11185
|
// - opencode: set `opencode.serverUrl` — but ONLY after health-probing the URL,
|
|
10629
11186
|
// so we never advertise a serve that isn't there. An unreachable URL is a
|
|
10630
11187
|
// typed failure the UI shows in place, not a silent bad write.
|
|
10631
|
-
// On success, persist, re-probe, and beat immediately so
|
|
10632
|
-
//
|
|
10633
|
-
//
|
|
11188
|
+
// On success, persist, re-probe, and beat immediately, so a runtime that IS
|
|
11189
|
+
// launchable starts taking turns at once rather than on the next tick — and one
|
|
11190
|
+
// that isn't reports its issue just as promptly. Returns a typed outcome (never
|
|
11191
|
+
// throws for a reachable/valid input).
|
|
10634
11192
|
async enableHarness(input) {
|
|
10635
11193
|
let next;
|
|
10636
11194
|
let checkedPresence = null;
|
|
@@ -10728,8 +11286,8 @@ var CompanionSupervisor = class {
|
|
|
10728
11286
|
};
|
|
10729
11287
|
async function waitForBoundedHeartbeat(heartbeat) {
|
|
10730
11288
|
let timer = null;
|
|
10731
|
-
const timeout = new Promise((
|
|
10732
|
-
timer = setTimeout(
|
|
11289
|
+
const timeout = new Promise((resolve2) => {
|
|
11290
|
+
timer = setTimeout(resolve2, SHUTDOWN_HEARTBEAT_WAIT_MS);
|
|
10733
11291
|
timer.unref?.();
|
|
10734
11292
|
});
|
|
10735
11293
|
try {
|
|
@@ -10813,9 +11371,9 @@ function handleUncaught(log, err, origin) {
|
|
|
10813
11371
|
|
|
10814
11372
|
// src/crash-marker.ts
|
|
10815
11373
|
import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
|
|
10816
|
-
import { join as
|
|
11374
|
+
import { join as join17 } from "path";
|
|
10817
11375
|
function crashMarkerPath() {
|
|
10818
|
-
return
|
|
11376
|
+
return join17(cabaneDir(), "last-error.json");
|
|
10819
11377
|
}
|
|
10820
11378
|
function recordCrash(rec2) {
|
|
10821
11379
|
try {
|
|
@@ -10864,8 +11422,8 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10864
11422
|
}
|
|
10865
11423
|
if (cfg.logLevel) log.level = cfg.logLevel;
|
|
10866
11424
|
const harnessVersions = await probeHarnessVersions({
|
|
10867
|
-
//
|
|
10868
|
-
// claude
|
|
11425
|
+
// Connected AND its CLI answered — the two things that make a
|
|
11426
|
+
// `claude --version` probe worth spawning. Not the offer predicate.
|
|
10869
11427
|
claudeCode: claudeCode && isClaudeCodeConnected(cfg),
|
|
10870
11428
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
10871
11429
|
codex: isCodexEnabled(cfg)
|
|
@@ -10898,7 +11456,6 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10898
11456
|
config: cfg,
|
|
10899
11457
|
log,
|
|
10900
11458
|
hub,
|
|
10901
|
-
claudeCode,
|
|
10902
11459
|
harnessVersions
|
|
10903
11460
|
});
|
|
10904
11461
|
await supervisor.start();
|
|
@@ -11003,11 +11560,11 @@ import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync
|
|
|
11003
11560
|
|
|
11004
11561
|
// src/cli-entry.ts
|
|
11005
11562
|
import { existsSync as existsSync14 } from "fs";
|
|
11006
|
-
import { fileURLToPath as
|
|
11563
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
11007
11564
|
var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
|
|
11008
11565
|
function companionCliEntry(deps = {}) {
|
|
11009
11566
|
const exists = deps.exists ?? existsSync14;
|
|
11010
|
-
const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) =>
|
|
11567
|
+
const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath3(new URL(rel, import.meta.url)));
|
|
11011
11568
|
for (const candidate of candidates) {
|
|
11012
11569
|
if (exists(candidate)) return candidate;
|
|
11013
11570
|
}
|
|
@@ -11354,7 +11911,7 @@ async function handOffToBackground(runtime, ctx) {
|
|
|
11354
11911
|
}
|
|
11355
11912
|
}
|
|
11356
11913
|
async function runAttached(runtime) {
|
|
11357
|
-
await new Promise((
|
|
11914
|
+
await new Promise((resolve2) => {
|
|
11358
11915
|
let shuttingDown = false;
|
|
11359
11916
|
const shutdown = async (signal) => {
|
|
11360
11917
|
if (shuttingDown) {
|
|
@@ -11375,7 +11932,7 @@ companion: received ${signal}, shutting down\u2026
|
|
|
11375
11932
|
forceExit.unref?.();
|
|
11376
11933
|
await runtime.stop();
|
|
11377
11934
|
clearTimeout(forceExit);
|
|
11378
|
-
|
|
11935
|
+
resolve2();
|
|
11379
11936
|
process.exit(0);
|
|
11380
11937
|
};
|
|
11381
11938
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
@@ -11523,7 +12080,7 @@ function isAlive(kill, pid) {
|
|
|
11523
12080
|
|
|
11524
12081
|
// src/commands/transcript.ts
|
|
11525
12082
|
import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
|
|
11526
|
-
import { isAbsolute, join as
|
|
12083
|
+
import { isAbsolute, join as join18 } from "path";
|
|
11527
12084
|
async function transcript(opts = {}) {
|
|
11528
12085
|
const dir2 = transcriptsDir();
|
|
11529
12086
|
if (opts.follow) {
|
|
@@ -11540,7 +12097,7 @@ async function transcript(opts = {}) {
|
|
|
11540
12097
|
process.stdout.write(emptyMessage(dir2));
|
|
11541
12098
|
return;
|
|
11542
12099
|
}
|
|
11543
|
-
process.stdout.write(renderFile(
|
|
12100
|
+
process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
|
|
11544
12101
|
return;
|
|
11545
12102
|
}
|
|
11546
12103
|
printList(dir2);
|
|
@@ -11623,7 +12180,7 @@ function isComplete(content) {
|
|
|
11623
12180
|
async function followTranscripts(dir2) {
|
|
11624
12181
|
const follower = new TranscriptFollower({
|
|
11625
12182
|
listFiles: () => listFiles(dir2),
|
|
11626
|
-
read: (f) => readFileSync10(
|
|
12183
|
+
read: (f) => readFileSync10(join18(dir2, f), "utf8"),
|
|
11627
12184
|
write: (s) => process.stdout.write(s),
|
|
11628
12185
|
// CSI: cursor up `n` lines, then erase from cursor to end of screen.
|
|
11629
12186
|
clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
|
|
@@ -11632,14 +12189,14 @@ async function followTranscripts(dir2) {
|
|
|
11632
12189
|
process.stdout.write(`following ${dir2} \u2014 Ctrl-C to stop.
|
|
11633
12190
|
|
|
11634
12191
|
`);
|
|
11635
|
-
await new Promise((
|
|
12192
|
+
await new Promise((resolve2) => {
|
|
11636
12193
|
const timer = setInterval(() => follower.tick(), FOLLOW_POLL_MS);
|
|
11637
12194
|
const stop2 = () => {
|
|
11638
12195
|
clearInterval(timer);
|
|
11639
12196
|
process.removeListener("SIGINT", stop2);
|
|
11640
12197
|
process.removeListener("SIGTERM", stop2);
|
|
11641
12198
|
process.stdout.write("\n");
|
|
11642
|
-
|
|
12199
|
+
resolve2();
|
|
11643
12200
|
};
|
|
11644
12201
|
process.once("SIGINT", stop2);
|
|
11645
12202
|
process.once("SIGTERM", stop2);
|
|
@@ -11663,7 +12220,7 @@ function printList(dir2) {
|
|
|
11663
12220
|
|
|
11664
12221
|
`);
|
|
11665
12222
|
for (const f of files.slice(0, 20)) {
|
|
11666
|
-
const { meta, outcome } = peek(
|
|
12223
|
+
const { meta, outcome } = peek(join18(dir2, f));
|
|
11667
12224
|
const when = fmtTime(rec(meta)?.ts);
|
|
11668
12225
|
const ws = str2(rec(meta)?.workspaceSlug);
|
|
11669
12226
|
const o = rec(outcome);
|
|
@@ -11700,10 +12257,10 @@ function resolveTarget(dir2, target) {
|
|
|
11700
12257
|
if (existsSync15(target)) return target;
|
|
11701
12258
|
throw new CompanionError(`no transcript at ${target}.`);
|
|
11702
12259
|
}
|
|
11703
|
-
const exact =
|
|
12260
|
+
const exact = join18(dir2, target);
|
|
11704
12261
|
if (existsSync15(exact)) return exact;
|
|
11705
12262
|
const matches = listFiles(dir2).filter((f) => f.includes(target));
|
|
11706
|
-
if (matches.length === 1) return
|
|
12263
|
+
if (matches.length === 1) return join18(dir2, matches[0]);
|
|
11707
12264
|
if (matches.length === 0) {
|
|
11708
12265
|
throw new CompanionError(
|
|
11709
12266
|
`no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
|