@cabane/companion 0.6.155 → 0.6.157

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/runtime.js CHANGED
@@ -1,6 +1,294 @@
1
1
  // src/runtime.ts
2
2
  import { randomUUID as randomUUID7 } from "crypto";
3
3
 
4
+ // src/cabane-command.ts
5
+ import { accessSync, constants, realpathSync as realpathSync2 } from "fs";
6
+ import { homedir as homedir3 } from "os";
7
+ import { delimiter, join as join2, sep as sep2 } from "path";
8
+
9
+ // src/bundled-binaries.ts
10
+ import { realpathSync, statSync } from "fs";
11
+ import { createRequire } from "module";
12
+ import { homedir } from "os";
13
+ import { dirname, join, sep } from "path";
14
+ import { fileURLToPath } from "url";
15
+ function nodeResolveSelf(specifier) {
16
+ const here = fileURLToPath(import.meta.url);
17
+ if (typeof import.meta.resolve === "function") {
18
+ try {
19
+ return fileURLToPath(import.meta.resolve(specifier));
20
+ } catch {
21
+ }
22
+ }
23
+ try {
24
+ return createRequire(here).resolve(specifier);
25
+ } catch {
26
+ }
27
+ return walkNodeModules(specifier, here);
28
+ }
29
+ function walkNodeModules(pkg2, fromPath) {
30
+ let dir2 = dirname(fromPath);
31
+ for (; ; ) {
32
+ const manifest = join(dir2, "node_modules", pkg2, "package.json");
33
+ if (nodeIsFile(manifest)) {
34
+ try {
35
+ return realpathSync(manifest);
36
+ } catch {
37
+ return manifest;
38
+ }
39
+ }
40
+ const parent = dirname(dir2);
41
+ if (parent === dir2) return null;
42
+ dir2 = parent;
43
+ }
44
+ }
45
+ function nodeResolve(specifier, fromPath) {
46
+ try {
47
+ return createRequire(fromPath).resolve(specifier);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ function nodeIsFile(path) {
53
+ try {
54
+ return statSync(path).isFile();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ function detectPreferMusl(platform = process.platform) {
60
+ if (platform !== "linux") return false;
61
+ const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
62
+ const header = report?.header;
63
+ return report != null && header?.glibcVersionRuntime === void 0;
64
+ }
65
+ function defaultResolveEnv() {
66
+ return {
67
+ platform: process.platform,
68
+ arch: process.arch,
69
+ preferMusl: detectPreferMusl(),
70
+ // Both resolutions start from this module, which tsup bundles into
71
+ // `dist/cli.js` and `dist/postinstall.js` — both at the package's `dist/`
72
+ // root — so one environment serves the runtime checks and the install
73
+ // verifier alike.
74
+ resolveSelf: nodeResolveSelf,
75
+ resolve: nodeResolve,
76
+ isFile: nodeIsFile
77
+ };
78
+ }
79
+ var CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
80
+ function claudePlatformPackages(platform, arch, preferMusl) {
81
+ if (platform === "android") return [`${CLAUDE_SDK}-linux-${arch}-android`];
82
+ if (platform === "linux") {
83
+ return preferMusl ? [`${CLAUDE_SDK}-linux-${arch}-musl`, `${CLAUDE_SDK}-linux-${arch}`] : [`${CLAUDE_SDK}-linux-${arch}`, `${CLAUDE_SDK}-linux-${arch}-musl`];
84
+ }
85
+ return [`${CLAUDE_SDK}-${platform}-${arch}`];
86
+ }
87
+ function resolveClaude(env) {
88
+ const candidates = claudePlatformPackages(env.platform, env.arch, env.preferMusl);
89
+ const base = {
90
+ runtime: "claude-code",
91
+ packageName: candidates[0] ?? null,
92
+ platform: env.platform,
93
+ arch: env.arch
94
+ };
95
+ const sdkEntry = env.resolveSelf(CLAUDE_SDK);
96
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
97
+ let sawPackage = null;
98
+ for (const pkg2 of candidates) {
99
+ const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
100
+ if (!manifest) continue;
101
+ sawPackage ??= pkg2;
102
+ const exe = join(dirname(manifest), env.platform === "win32" ? "claude.exe" : "claude");
103
+ if (env.isFile(exe)) {
104
+ return { ...base, packageName: pkg2, status: "present", path: exe };
105
+ }
106
+ }
107
+ return sawPackage ? { ...base, packageName: sawPackage, status: "executable_missing", path: null } : { ...base, status: "platform_package_missing", path: null };
108
+ }
109
+ var CODEX_SDK = "@openai/codex-sdk";
110
+ var CODEX_CLI = "@openai/codex";
111
+ function codexTargetTriple(platform, arch) {
112
+ if (platform === "linux" || platform === "android") {
113
+ if (arch === "x64") return "x86_64-unknown-linux-musl";
114
+ if (arch === "arm64") return "aarch64-unknown-linux-musl";
115
+ return null;
116
+ }
117
+ if (platform === "darwin") {
118
+ if (arch === "x64") return "x86_64-apple-darwin";
119
+ if (arch === "arm64") return "aarch64-apple-darwin";
120
+ return null;
121
+ }
122
+ if (platform === "win32") {
123
+ if (arch === "x64") return "x86_64-pc-windows-msvc";
124
+ if (arch === "arm64") return "aarch64-pc-windows-msvc";
125
+ return null;
126
+ }
127
+ return null;
128
+ }
129
+ var CODEX_PACKAGE_BY_TRIPLE = {
130
+ "x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
131
+ "aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
132
+ "x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
133
+ "aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
134
+ "x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
135
+ "aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
136
+ };
137
+ function resolveCodex(env) {
138
+ const triple = codexTargetTriple(env.platform, env.arch);
139
+ const pkg2 = triple ? CODEX_PACKAGE_BY_TRIPLE[triple] : void 0;
140
+ const base = {
141
+ runtime: "codex",
142
+ packageName: pkg2 ?? null,
143
+ platform: env.platform,
144
+ arch: env.arch
145
+ };
146
+ if (!triple || !pkg2) return { ...base, status: "platform_package_missing", path: null };
147
+ const sdkEntry = env.resolveSelf(CODEX_SDK);
148
+ if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
149
+ const cliManifest = env.resolve(`${CODEX_CLI}/package.json`, sdkEntry);
150
+ if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
151
+ const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
152
+ if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
153
+ const packageRoot = join(dirname(platformManifest), "vendor", triple);
154
+ const exeName = env.platform === "win32" ? "codex.exe" : "codex";
155
+ const current = join(packageRoot, "bin", exeName);
156
+ if (env.isFile(current) && env.isFile(join(packageRoot, "codex-package.json"))) {
157
+ return { ...base, status: "present", path: current };
158
+ }
159
+ const legacy = join(packageRoot, "codex", exeName);
160
+ if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
161
+ return { ...base, status: "executable_missing", path: null };
162
+ }
163
+ function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
164
+ try {
165
+ return runtime === "codex" ? resolveCodex(env) : resolveClaude(env);
166
+ } catch {
167
+ return {
168
+ runtime,
169
+ status: "platform_package_missing",
170
+ path: null,
171
+ packageName: null,
172
+ platform: env.platform,
173
+ arch: env.arch
174
+ };
175
+ }
176
+ }
177
+ var HARNESS_LABEL = {
178
+ "claude-code": "Claude Code",
179
+ codex: "Codex"
180
+ };
181
+ var HARNESS_INTENT_WORD = {
182
+ "claude-code": "connected",
183
+ codex: "enabled"
184
+ };
185
+ function missingNoun(status) {
186
+ return status === "executable_missing" ? "executable" : "platform package";
187
+ }
188
+ var INSTALL_SCRIPT_COMMAND = "curl -fsSL https://cabane.ai/install.sh | bash";
189
+ function companionInstall(execPath = process.execPath, home = homedir()) {
190
+ const real = (path) => {
191
+ try {
192
+ return realpathSync(path);
193
+ } catch {
194
+ return path;
195
+ }
196
+ };
197
+ const scriptNode = join(real(home), ".cabane", "node") + sep;
198
+ return real(execPath).startsWith(scriptNode) ? "script" : "npm";
199
+ }
200
+ function repairCommands(install = companionInstall()) {
201
+ return install === "script" ? [INSTALL_SCRIPT_COMMAND] : ["npm i -g @cabane/companion --include=optional"];
202
+ }
203
+ function repairAction(install = companionInstall()) {
204
+ return `Reinstall with \`${repairCommands(install)[0]}\`.`;
205
+ }
206
+ function startupWarning(r) {
207
+ const label = HARNESS_LABEL[r.runtime];
208
+ return `${label} is ${HARNESS_INTENT_WORD[r.runtime]}, but its installation is incomplete (${missingNoun(r.status)} is missing). It cannot run turns until repaired. ${repairAction()} Then restart the companion.`;
209
+ }
210
+ function harnessIssueNote(runtime, status, install = companionInstall()) {
211
+ const intent = HARNESS_INTENT_WORD[runtime];
212
+ const capitalized = `${intent[0].toUpperCase()}${intent.slice(1)}`;
213
+ return `${capitalized}, but @cabane/companion's bundled ${HARNESS_LABEL[runtime]} ${missingNoun(status)} is missing. ${repairAction(install)} Then restart the companion.`;
214
+ }
215
+ function turnIncompleteCopy(runtime) {
216
+ return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
217
+ }
218
+
219
+ // src/cli-entry.ts
220
+ import { existsSync } from "fs";
221
+ import { fileURLToPath as fileURLToPath2 } from "url";
222
+ var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
223
+ function companionCliEntry(deps = {}) {
224
+ const exists = deps.exists ?? existsSync;
225
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath2(new URL(rel, import.meta.url)));
226
+ for (const candidate of candidates) {
227
+ if (exists(candidate)) return candidate;
228
+ }
229
+ const argv1 = "argv1" in deps ? deps.argv1 : process.argv[1];
230
+ if (argv1 && exists(argv1)) return argv1;
231
+ return candidates[0] ?? "";
232
+ }
233
+
234
+ // src/term.ts
235
+ import { createInterface } from "readline";
236
+ import { homedir as homedir2 } from "os";
237
+ import pc from "picocolors";
238
+ function tildePath(path, home = homedir2()) {
239
+ if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
240
+ return path;
241
+ }
242
+
243
+ // src/cabane-command.ts
244
+ function installedCommandPath(deps = {}) {
245
+ const install = deps.install ?? companionInstall();
246
+ const home = deps.home ?? homedir3();
247
+ if (install === "script") return join2(home, ".cabane", "bin", "cabane");
248
+ const entry = deps.cliEntry ?? companionCliEntry();
249
+ const marker = `${sep2}lib${sep2}node_modules${sep2}@cabane${sep2}companion${sep2}`;
250
+ const at = entry.indexOf(marker);
251
+ if (at < 0) return null;
252
+ return join2(entry.slice(0, at), "bin", "cabane");
253
+ }
254
+ function cabaneCommand(deps = {}) {
255
+ const own = installedCommandPath(deps);
256
+ if (!own) return "cabane";
257
+ const realpath = deps.realpath ?? safeRealpath;
258
+ const isExecutable = deps.isExecutable ?? executable;
259
+ const ownReal = realpath(own);
260
+ if (!ownReal) return "cabane";
261
+ const onPath = firstOnPath("cabane", "path" in deps ? deps.path : process.env.PATH, isExecutable);
262
+ if (onPath && realpath(onPath) === ownReal) return "cabane";
263
+ return tildePath(own, deps.home);
264
+ }
265
+ function cabane(rest, deps) {
266
+ return `${cabaneCommand(deps)} ${rest}`;
267
+ }
268
+ function firstOnPath(name, path, isExecutable = executable) {
269
+ for (const dir2 of (path ?? "").split(delimiter)) {
270
+ if (!dir2) continue;
271
+ const candidate = join2(dir2, name);
272
+ if (isExecutable(candidate)) return candidate;
273
+ }
274
+ return null;
275
+ }
276
+ function executable(path) {
277
+ try {
278
+ accessSync(path, constants.X_OK);
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+ function safeRealpath(path) {
285
+ try {
286
+ return realpathSync2(path);
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
4
292
  // src/config.ts
5
293
  import {
6
294
  chmodSync,
@@ -9,10 +297,10 @@ import {
9
297
  renameSync,
10
298
  rmSync,
11
299
  writeFileSync,
12
- existsSync
300
+ existsSync as existsSync2
13
301
  } from "fs";
14
- import { homedir, userInfo } from "os";
15
- import { dirname, join } from "path";
302
+ import { homedir as homedir4, userInfo } from "os";
303
+ import { dirname as dirname2, join as join3 } from "path";
16
304
  import { z as z2 } from "zod";
17
305
 
18
306
  // src/errors.ts
@@ -225,13 +513,13 @@ function realAccountHome() {
225
513
  return realHomeCache;
226
514
  }
227
515
  function agentCompanionHome() {
228
- return join(cabaneDir(), "agent-home");
516
+ return join3(cabaneDir(), "agent-home");
229
517
  }
230
518
  function cabaneDir() {
231
- const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
519
+ const dir2 = join3(process.env.CABANE_COMPANION_HOME || homedir4(), ".cabane");
232
520
  if (process.env.VITEST) {
233
521
  const real = realAccountHome();
234
- if (real && dir2 === join(real, ".cabane")) {
522
+ if (real && dir2 === join3(real, ".cabane")) {
235
523
  throw new Error(
236
524
  `cabaneDir() resolved to the real ${dir2} during a test run. Companion tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real companion config (see apps/companion/test/setup-home.ts).`
237
525
  );
@@ -240,7 +528,7 @@ function cabaneDir() {
240
528
  return dir2;
241
529
  }
242
530
  function configPath() {
243
- return join(cabaneDir(), "config.json");
531
+ return join3(cabaneDir(), "config.json");
244
532
  }
245
533
  var localAgentConfigSchema = z2.object({
246
534
  cwd: z2.string().optional(),
@@ -259,11 +547,11 @@ var localAgentConfigSchema = z2.object({
259
547
  sandbox: z2.enum(["os", "none"]).optional()
260
548
  }).strict();
261
549
  var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
262
- var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
550
+ var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx2) => {
263
551
  for (const [key, row] of Object.entries(agents)) {
264
552
  if (row.sandbox === void 0) continue;
265
553
  if (AGENT_ID_KEY.test(key)) continue;
266
- ctx.addIssue({
554
+ ctx2.addIssue({
267
555
  code: z2.ZodIssueCode.custom,
268
556
  path: [key, "sandbox"],
269
557
  message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
@@ -314,15 +602,33 @@ var companionConfigSchema = z2.object({
314
602
  // the Agent SDK's bundled executable resolves (not `claude` on PATH, which the
315
603
  // SDK never runs).
316
604
  //
317
- // The block's PRESENCE is also the migration marker
318
- // (`migrateConnectedHarnesses`): absent means a config whose user was never
319
- // asked. So every config written carries it, a fresh pair at `enabled: false`.
605
+ // Absent means one of two things, told apart by `harnessesChosen`: a legacy
606
+ // config whose user was never asked (`migrateConnectedHarnesses`), or a
607
+ // device that has never been offered Claude Code. `enabled: false` is a
608
+ // decline, and `start` doesn't offer it again.
320
609
  //
321
610
  // Not the per-agent `agents.<key>.claudeCode.autoMemory`, one agent's memory
322
611
  // switch.
323
612
  claudeCode: z2.object({
324
613
  enabled: z2.boolean().optional()
325
614
  }).strict().optional(),
615
+ // Written by every pairing that asks which agents to connect. With it, an
616
+ // absent `claudeCode` block means "never offered"; without it, the block's
617
+ // absence marks a config from before connectors were chosen.
618
+ harnessesChosen: z2.boolean().optional(),
619
+ // Whether this companion keeps itself current: installs the version Cabane names, between
620
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
621
+ // which `start` offers; off either way until someone says yes.
622
+ update: z2.object({
623
+ auto: z2.boolean()
624
+ }).strict().optional(),
625
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
626
+ // `cabane service install` still installs it.
627
+ serviceDeclined: z2.boolean().optional(),
628
+ // OpenCode's decline. Its block is an address, so a decline can't live there
629
+ // the way `claudeCode`/`codex` `enabled: false` does; without it a declined
630
+ // serve would be offered again on every `start`.
631
+ opencodeDeclined: z2.boolean().optional(),
326
632
  // Codex is a local CLI the SDK spawns per turn, so this is an opt-in flag, not
327
633
  // an address. The operator logs in (`codex login` or `CODEX_API_KEY`; Cabane
328
634
  // never sees the key). The block's presence enables it; `enabled: false` turns
@@ -377,6 +683,9 @@ var companionConfigSchema = z2.object({
377
683
  function runnersEnabled(cfg) {
378
684
  return cfg.runners?.enabled === true;
379
685
  }
686
+ function dockerRunnersConfigured(cfg) {
687
+ return runnersEnabled(cfg) && cfg.runners?.provider === "docker";
688
+ }
380
689
  function runnerStateDir() {
381
690
  return process.env.CABANE_RUNNER_STATE_DIR || cabaneDir();
382
691
  }
@@ -386,9 +695,9 @@ function isCodexEnabled(cfg) {
386
695
  function isClaudeCodeConnected(cfg) {
387
696
  return !!cfg.claudeCode && cfg.claudeCode.enabled !== false;
388
697
  }
389
- function migrateConnectedHarnesses(cfg, claudeOnPath2) {
390
- if (cfg.claudeCode !== void 0) return null;
391
- return { ...cfg, claudeCode: { enabled: claudeOnPath2 } };
698
+ function migrateConnectedHarnesses(cfg) {
699
+ if (cfg.claudeCode !== void 0 || cfg.harnessesChosen) return null;
700
+ return { ...cfg, claudeCode: { enabled: true } };
392
701
  }
393
702
  function localAgentConfig(cfg, agent) {
394
703
  const map = cfg.agents ?? {};
@@ -420,7 +729,7 @@ function agentSandboxRows(cfg) {
420
729
  }
421
730
  function loadConfig() {
422
731
  const path = configPath();
423
- if (!existsSync(path)) return null;
732
+ if (!existsSync2(path)) return null;
424
733
  let raw;
425
734
  try {
426
735
  raw = readFileSync(path, "utf8");
@@ -447,14 +756,14 @@ function loadConfig() {
447
756
  );
448
757
  }
449
758
  throw new ConfigError(
450
- `${path} is from an incompatible or older version of the companion, or was hand-edited. Run \`cabane-companion pair\` to re-pair, or \`cabane-companion logout --purge\` to reset.`
759
+ `${path} is from an incompatible or older version of the companion, or was hand-edited. Run \`${cabane("pair")}\` to re-pair, or \`${cabane("logout --purge")}\` to reset.`
451
760
  );
452
761
  }
453
762
  return result.data;
454
763
  }
455
764
  function saveConfig(cfg) {
456
765
  const path = configPath();
457
- mkdirSync(dirname(path), { recursive: true });
766
+ mkdirSync(dirname2(path), { recursive: true });
458
767
  try {
459
768
  chmodSync(cabaneDir(), 448);
460
769
  } catch {
@@ -479,77 +788,38 @@ function requireConfig() {
479
788
  const cfg = loadConfig();
480
789
  if (!cfg) {
481
790
  throw new ConfigError(
482
- "this companion is not paired. Run `cabane-companion pair` \u2014 it shows a short code \u2014 then enter that code in the cabane app (Settings \u2192 Companions) to connect this machine."
791
+ `this companion is not paired. Run \`${cabane("start")}\` \u2014 it shows a short code \u2014 then enter that code in the cabane app (Settings \u2192 Companions) to connect this machine.`
483
792
  );
484
793
  }
485
794
  return cfg;
486
795
  }
487
796
 
488
797
  // src/dashboard/server.ts
489
- import { dirname as dirname4, join as join5 } from "path";
490
- import { fileURLToPath as fileURLToPath2 } from "url";
798
+ import { dirname as dirname4, join as join7 } from "path";
799
+ import { fileURLToPath as fileURLToPath3 } from "url";
491
800
  import { serve } from "@hono/node-server";
492
801
  import { Hono } from "hono";
493
802
 
494
803
  // src/dashboard/routes.ts
495
- import { openSync, readSync, closeSync, fstatSync, existsSync as existsSync2 } from "fs";
804
+ import { openSync as openSync2, readSync, closeSync, fstatSync, existsSync as existsSync3 } from "fs";
496
805
  import { readFile } from "fs/promises";
497
- import { extname, join as join4, normalize } from "path";
806
+ import { extname, join as join6, normalize } from "path";
498
807
  import { streamSSE } from "hono/streaming";
499
808
 
500
- // src/harness-check.ts
501
- import { spawn as spawn3 } from "child_process";
809
+ // src/harness-copy.ts
810
+ import { homedir as homedir5 } from "os";
811
+ import { join as join4 } from "path";
502
812
 
503
- // src/harness-versions.ts
813
+ // src/harness-usable.ts
504
814
  import { spawn as spawn2 } from "child_process";
815
+
816
+ // src/harness-versions.ts
505
817
  var EMPTY = { claudeCode: null, opencode: null, codex: null };
506
- var CLI_PROBE_TIMEOUT_MS = 4e3;
507
818
  function parseVersionToken(raw) {
508
819
  if (!raw) return null;
509
820
  const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
510
821
  return m ? m[0] : null;
511
822
  }
512
- function probeCliPresence(command, spawnImpl = spawn2) {
513
- return new Promise((resolve2) => {
514
- let settled = false;
515
- let timer = null;
516
- const done = (result) => {
517
- if (!settled) {
518
- settled = true;
519
- if (timer) clearTimeout(timer);
520
- resolve2(result);
521
- }
522
- };
523
- let child;
524
- try {
525
- child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
526
- } catch {
527
- done({ status: "absent" });
528
- return;
529
- }
530
- let out = "";
531
- child.stdout?.on("data", (chunk) => {
532
- if (out.length < 4096) out += chunk.toString();
533
- });
534
- timer = setTimeout(() => {
535
- child.kill?.("SIGKILL");
536
- done({ status: "unusable" });
537
- }, CLI_PROBE_TIMEOUT_MS);
538
- timer.unref?.();
539
- child.once("error", () => done({ status: "absent" }));
540
- child.once("exit", (code) => {
541
- const version = code === 0 ? parseVersionToken(out) : null;
542
- done(version ? { status: "present", version } : { status: "unusable" });
543
- });
544
- });
545
- }
546
- function probeHarnessPresence(runtime, spawnImpl = spawn2) {
547
- return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
548
- }
549
- async function probeCliVersion(command, spawnImpl = spawn2) {
550
- const result = await probeCliPresence(command, spawnImpl);
551
- return result.status === "present" ? result.version : null;
552
- }
553
823
  async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
554
824
  try {
555
825
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
@@ -577,236 +847,98 @@ function extractOpencodeVersion(json) {
577
847
  }
578
848
  return null;
579
849
  }
580
- async function probeHarnessVersions(opts, deps = {}) {
581
- const probeClaudeCode = deps.probeClaudeCode ?? (() => probeCliVersion("claude"));
582
- const probeCodex = deps.probeCodex ?? (() => probeCliVersion("codex"));
583
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
584
- const [claudeCode, codex, opencode] = await Promise.all([
585
- opts.claudeCode ? safe(probeClaudeCode) : Promise.resolve(null),
586
- opts.codex ? safe(probeCodex) : Promise.resolve(null),
587
- opts.opencodeServerUrl ? safe(() => probeOpencode(opts.opencodeServerUrl)) : Promise.resolve(null)
588
- ]);
589
- return { claudeCode, opencode, codex };
590
- }
591
850
  function emptyHarnessVersions() {
592
851
  return { ...EMPTY };
593
852
  }
594
- async function safe(fn) {
595
- try {
596
- return await fn();
597
- } catch {
598
- return null;
599
- }
600
- }
601
853
 
602
- // src/bundled-binaries.ts
603
- import { realpathSync, statSync } from "fs";
604
- import { createRequire } from "module";
605
- import { homedir as homedir2 } from "os";
606
- import { dirname as dirname2, join as join2, sep } from "path";
607
- import { fileURLToPath } from "url";
608
- function nodeResolveSelf(specifier) {
609
- const here = fileURLToPath(import.meta.url);
610
- if (typeof import.meta.resolve === "function") {
611
- try {
612
- return fileURLToPath(import.meta.resolve(specifier));
613
- } catch {
614
- }
615
- }
616
- try {
617
- return createRequire(here).resolve(specifier);
618
- } catch {
619
- }
620
- return walkNodeModules(specifier, here);
854
+ // src/harness-usable.ts
855
+ function isUsable(check) {
856
+ return check.status === "signed_in" || check.status === "api_key";
621
857
  }
622
- function walkNodeModules(pkg2, fromPath) {
623
- let dir2 = dirname2(fromPath);
624
- for (; ; ) {
625
- const manifest = join2(dir2, "node_modules", pkg2, "package.json");
626
- if (nodeIsFile(manifest)) {
627
- try {
628
- return realpathSync(manifest);
629
- } catch {
630
- return manifest;
631
- }
632
- }
633
- const parent = dirname2(dir2);
634
- if (parent === dir2) return null;
635
- dir2 = parent;
636
- }
858
+ var API_KEY_VARIABLES = {
859
+ "claude-code": ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
860
+ codex: ["CODEX_API_KEY"]
861
+ };
862
+ function apiKeyVariable(runtime, env = process.env) {
863
+ return API_KEY_VARIABLES[runtime].find((name) => !!env[name]?.trim()) ?? null;
637
864
  }
638
- function nodeResolve(specifier, fromPath) {
639
- try {
640
- return createRequire(fromPath).resolve(specifier);
641
- } catch {
642
- return null;
865
+ var CHECK_TIMEOUT_MS = 5e3;
866
+ async function checkHarnessUsable(runtime, deps = {}) {
867
+ const env = deps.env ?? process.env;
868
+ const resolveBundled = deps.resolveBundled ?? ((r) => resolveBundledBinary(r));
869
+ const run = deps.run ?? runBounded;
870
+ const resolution = resolveBundled(runtime);
871
+ if (resolution.status !== "present" || !resolution.path) {
872
+ return {
873
+ status: "bundle_missing",
874
+ bundled: resolution.status === "present" ? "executable_missing" : resolution.status
875
+ };
643
876
  }
644
- }
645
- function nodeIsFile(path) {
877
+ const path = resolution.path;
878
+ const version = run(path, ["--version"], env).then(
879
+ (r) => r.code === 0 ? parseVersionToken(r.output) : null
880
+ );
881
+ const variable = apiKeyVariable(runtime, env);
882
+ if (variable) return { status: "api_key", variable, version: await version };
883
+ const statusArgs = runtime === "codex" ? ["login", "status"] : ["auth", "status", "--json"];
884
+ const [answer, v] = await Promise.all([run(path, statusArgs, env), version]);
885
+ if (answer.error) return { status: "check_failed", reason: answer.error };
886
+ const signedIn = runtime === "codex" ? readCodexStatus(answer) : readClaudeStatus(answer);
887
+ if (typeof signedIn === "string") return { status: "check_failed", reason: signedIn };
888
+ return signedIn ? { status: "signed_in", version: v } : { status: "signed_out", version: v };
889
+ }
890
+ function readClaudeStatus(answer) {
891
+ const start = answer.output.indexOf("{");
892
+ const end = answer.output.lastIndexOf("}");
893
+ if (start < 0 || end < start) return "unreadable";
646
894
  try {
647
- return statSync(path).isFile();
895
+ const parsed = JSON.parse(answer.output.slice(start, end + 1));
896
+ const loggedIn = parsed?.loggedIn;
897
+ return typeof loggedIn === "boolean" ? loggedIn : "unreadable";
648
898
  } catch {
649
- return false;
650
- }
651
- }
652
- function detectPreferMusl(platform = process.platform) {
653
- if (platform !== "linux") return false;
654
- const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
655
- const header = report?.header;
656
- return report != null && header?.glibcVersionRuntime === void 0;
657
- }
658
- function defaultResolveEnv() {
659
- return {
660
- platform: process.platform,
661
- arch: process.arch,
662
- preferMusl: detectPreferMusl(),
663
- // Both resolutions start from this module, which tsup bundles into
664
- // `dist/cli.js` and `dist/postinstall.js` — both at the package's `dist/`
665
- // root — so one environment serves the runtime checks and the install
666
- // verifier alike.
667
- resolveSelf: nodeResolveSelf,
668
- resolve: nodeResolve,
669
- isFile: nodeIsFile
670
- };
671
- }
672
- var CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
673
- function claudePlatformPackages(platform, arch, preferMusl) {
674
- if (platform === "android") return [`${CLAUDE_SDK}-linux-${arch}-android`];
675
- if (platform === "linux") {
676
- return preferMusl ? [`${CLAUDE_SDK}-linux-${arch}-musl`, `${CLAUDE_SDK}-linux-${arch}`] : [`${CLAUDE_SDK}-linux-${arch}`, `${CLAUDE_SDK}-linux-${arch}-musl`];
899
+ return "unreadable";
677
900
  }
678
- return [`${CLAUDE_SDK}-${platform}-${arch}`];
679
901
  }
680
- function resolveClaude(env) {
681
- const candidates = claudePlatformPackages(env.platform, env.arch, env.preferMusl);
682
- const base = {
683
- runtime: "claude-code",
684
- packageName: candidates[0] ?? null,
685
- platform: env.platform,
686
- arch: env.arch
687
- };
688
- const sdkEntry = env.resolveSelf(CLAUDE_SDK);
689
- if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
690
- let sawPackage = null;
691
- for (const pkg2 of candidates) {
692
- const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
693
- if (!manifest) continue;
694
- sawPackage ??= pkg2;
695
- const exe = join2(dirname2(manifest), env.platform === "win32" ? "claude.exe" : "claude");
696
- if (env.isFile(exe)) {
697
- return { ...base, packageName: pkg2, status: "present", path: exe };
698
- }
699
- }
700
- return sawPackage ? { ...base, packageName: sawPackage, status: "executable_missing", path: null } : { ...base, status: "platform_package_missing", path: null };
701
- }
702
- var CODEX_SDK = "@openai/codex-sdk";
703
- var CODEX_CLI = "@openai/codex";
704
- function codexTargetTriple(platform, arch) {
705
- if (platform === "linux" || platform === "android") {
706
- if (arch === "x64") return "x86_64-unknown-linux-musl";
707
- if (arch === "arm64") return "aarch64-unknown-linux-musl";
708
- return null;
709
- }
710
- if (platform === "darwin") {
711
- if (arch === "x64") return "x86_64-apple-darwin";
712
- if (arch === "arm64") return "aarch64-apple-darwin";
713
- return null;
714
- }
715
- if (platform === "win32") {
716
- if (arch === "x64") return "x86_64-pc-windows-msvc";
717
- if (arch === "arm64") return "aarch64-pc-windows-msvc";
718
- return null;
719
- }
720
- return null;
721
- }
722
- var CODEX_PACKAGE_BY_TRIPLE = {
723
- "x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
724
- "aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
725
- "x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
726
- "aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
727
- "x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
728
- "aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
729
- };
730
- function resolveCodex(env) {
731
- const triple = codexTargetTriple(env.platform, env.arch);
732
- const pkg2 = triple ? CODEX_PACKAGE_BY_TRIPLE[triple] : void 0;
733
- const base = {
734
- runtime: "codex",
735
- packageName: pkg2 ?? null,
736
- platform: env.platform,
737
- arch: env.arch
738
- };
739
- if (!triple || !pkg2) return { ...base, status: "platform_package_missing", path: null };
740
- const sdkEntry = env.resolveSelf(CODEX_SDK);
741
- if (!sdkEntry) return { ...base, status: "platform_package_missing", path: null };
742
- const cliManifest = env.resolve(`${CODEX_CLI}/package.json`, sdkEntry);
743
- if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
744
- const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
745
- if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
746
- const packageRoot = join2(dirname2(platformManifest), "vendor", triple);
747
- const exeName = env.platform === "win32" ? "codex.exe" : "codex";
748
- const current = join2(packageRoot, "bin", exeName);
749
- if (env.isFile(current) && env.isFile(join2(packageRoot, "codex-package.json"))) {
750
- return { ...base, status: "present", path: current };
751
- }
752
- const legacy = join2(packageRoot, "codex", exeName);
753
- if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
754
- return { ...base, status: "executable_missing", path: null };
902
+ function readCodexStatus(answer) {
903
+ if (answer.code === 0) return true;
904
+ if (/not logged in/i.test(answer.output)) return false;
905
+ if (/config/i.test(answer.output)) return "config";
906
+ return "unreadable";
755
907
  }
756
- function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
757
- try {
758
- return runtime === "codex" ? resolveCodex(env) : resolveClaude(env);
759
- } catch {
760
- return {
761
- runtime,
762
- status: "platform_package_missing",
763
- path: null,
764
- packageName: null,
765
- platform: env.platform,
766
- arch: env.arch
908
+ function runBounded(path, args, env) {
909
+ return new Promise((resolve2) => {
910
+ let settled = false;
911
+ let out = "";
912
+ const done = (result) => {
913
+ if (settled) return;
914
+ settled = true;
915
+ clearTimeout(timer);
916
+ resolve2(result);
767
917
  };
768
- }
769
- }
770
- var HARNESS_LABEL = {
771
- "claude-code": "Claude Code",
772
- codex: "Codex"
773
- };
774
- var HARNESS_INTENT_WORD = {
775
- "claude-code": "connected",
776
- codex: "enabled"
777
- };
778
- function missingNoun(status) {
779
- return status === "executable_missing" ? "executable" : "platform package";
780
- }
781
- var INSTALL_SCRIPT_COMMAND = "curl -fsSL https://cabane.ai/install.sh | bash";
782
- function companionInstall(execPath = process.execPath, home = homedir2()) {
783
- const real = (path) => {
918
+ let child;
784
919
  try {
785
- return realpathSync(path);
920
+ child = spawn2(path, args, { stdio: ["ignore", "pipe", "pipe"], env });
786
921
  } catch {
787
- return path;
922
+ resolve2({ code: null, output: "", error: "spawn" });
923
+ return;
788
924
  }
789
- };
790
- const scriptNode = join2(real(home), ".cabane", "node") + sep;
791
- return real(execPath).startsWith(scriptNode) ? "script" : "npm";
792
- }
793
- function repairCommands(install = companionInstall()) {
794
- return install === "script" ? [INSTALL_SCRIPT_COMMAND] : ["npm i -g @cabane/companion --include=optional"];
795
- }
796
- function repairAction(install = companionInstall()) {
797
- return `Reinstall with \`${repairCommands(install)[0]}\`.`;
798
- }
799
- function startupWarning(r) {
800
- const label = HARNESS_LABEL[r.runtime];
801
- return `${label} is ${HARNESS_INTENT_WORD[r.runtime]}, but its installation is incomplete (${missingNoun(r.status)} is missing). It cannot run turns until repaired. ${repairAction()} Then restart the companion.`;
802
- }
803
- function harnessIssueNote(runtime, status, install = companionInstall()) {
804
- const intent = HARNESS_INTENT_WORD[runtime];
805
- const capitalized = `${intent[0].toUpperCase()}${intent.slice(1)}`;
806
- return `${capitalized}, but @cabane/companion's bundled ${HARNESS_LABEL[runtime]} ${missingNoun(status)} is missing. ${repairAction(install)} Then restart the companion.`;
925
+ const capture = (chunk) => {
926
+ if (out.length < 8192) out += chunk.toString();
927
+ };
928
+ child.stdout?.on("data", capture);
929
+ child.stderr?.on("data", capture);
930
+ const timer = setTimeout(() => {
931
+ child.kill("SIGKILL");
932
+ done({ code: null, output: out, error: "timeout" });
933
+ }, CHECK_TIMEOUT_MS);
934
+ timer.unref?.();
935
+ child.once("error", () => done({ code: null, output: out, error: "spawn" }));
936
+ child.once("close", (code) => done({ code, output: out }));
937
+ });
807
938
  }
808
- function turnIncompleteCopy(runtime) {
809
- return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
939
+ function checkVersion(check) {
940
+ if (!check) return null;
941
+ return check.status === "signed_in" || check.status === "api_key" || check.status === "signed_out" ? check.version : null;
810
942
  }
811
943
 
812
944
  // src/manifest.ts
@@ -864,13 +996,15 @@ function deriveHarnessSnapshot(signals) {
864
996
  }
865
997
  function deriveClaudeCode(signals, manifestHas) {
866
998
  const base = { runtime: "claude-code", label: LABELS["claude-code"] };
999
+ const version = checkVersion(signals.claudeCheck);
867
1000
  if (manifestHas) {
868
1001
  return {
869
1002
  ...base,
870
1003
  state: "exposed",
871
- version: signals.claudeVersion,
872
- detail: "Claude Code is connected and exposed to Cabane.",
873
- enable: null
1004
+ version,
1005
+ detail: exposedDetail("claude-code", signals.claudeCheck),
1006
+ enable: null,
1007
+ signIn: signals.claudeCheck?.status ?? null
874
1008
  };
875
1009
  }
876
1010
  if (signals.claudeCodeConnected) {
@@ -879,37 +1013,56 @@ function deriveClaudeCode(signals, manifestHas) {
879
1013
  state: "needs_attention",
880
1014
  version: null,
881
1015
  detail: harnessIssueNote("claude-code", signals.claudeBundled),
882
- enable: null
1016
+ enable: null,
1017
+ signIn: signals.claudeCheck?.status ?? null
883
1018
  };
884
1019
  }
885
- if (signals.claudeOnPath) {
1020
+ if (signals.claudeCheck && isUsable(signals.claudeCheck)) {
886
1021
  return {
887
1022
  ...base,
888
1023
  state: "detected_not_exposed",
889
- version: signals.claudeVersion,
890
- // Connecting records INTENT, not runnability: with the bundled binary
891
- // missing a connect succeeds and nothing is advertised, so say so.
892
- 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()}`,
893
- enable: "claude-code"
1024
+ version,
1025
+ detail: `${usableLine("claude-code", signals.claudeCheck)} Connect it to offer Claude Code from this device.`,
1026
+ enable: "claude-code",
1027
+ signIn: signals.claudeCheck?.status ?? null
894
1028
  };
895
1029
  }
896
1030
  return {
897
1031
  ...base,
898
1032
  state: "not_detected",
899
1033
  version: null,
900
- detail: "Not detected. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in with `claude`, then connect it here.",
901
- enable: null
1034
+ detail: notUsableDetail("claude-code", signals.claudeCheck),
1035
+ enable: null,
1036
+ signIn: signals.claudeCheck?.status ?? null
902
1037
  };
903
1038
  }
1039
+ function exposedDetail(runtime, check) {
1040
+ const label = LABELS[runtime];
1041
+ const problem = check ? unusableReason(runtime, check) : null;
1042
+ if (problem) return problem;
1043
+ if (check?.status === "signed_out") {
1044
+ const login = runtime === "codex" ? "codex login" : "claude auth login";
1045
+ return `${label} is connected, but signed out on this computer. Run \`${login}\` to use it again.`;
1046
+ }
1047
+ if (check?.status === "api_key") return `${label} is connected with an API key configured.`;
1048
+ return `${label} is connected and exposed to Cabane.`;
1049
+ }
1050
+ function notUsableDetail(runtime, check) {
1051
+ const problem = check ? unusableReason(runtime, check) : null;
1052
+ if (problem) return problem;
1053
+ return runtime === "codex" ? "Not signed in on this computer. Sign in with `codex login`, then enable it here." : "Not signed in on this computer. Sign in with `claude auth login`, then connect it here.";
1054
+ }
904
1055
  function deriveCodex(signals, manifestHas) {
905
1056
  const base = { runtime: "codex", label: LABELS.codex };
1057
+ const version = checkVersion(signals.codexCheck);
906
1058
  if (manifestHas) {
907
1059
  return {
908
1060
  ...base,
909
1061
  state: "exposed",
910
- version: signals.codexVersion,
911
- detail: "Codex is enabled and exposed to Cabane.",
912
- enable: null
1062
+ version,
1063
+ detail: exposedDetail("codex", signals.codexCheck),
1064
+ enable: null,
1065
+ signIn: signals.codexCheck?.status ?? null
913
1066
  };
914
1067
  }
915
1068
  if (signals.codexEnabled) {
@@ -918,25 +1071,27 @@ function deriveCodex(signals, manifestHas) {
918
1071
  state: "needs_attention",
919
1072
  version: null,
920
1073
  detail: harnessIssueNote("codex", signals.codexBundled),
921
- enable: null
1074
+ enable: null,
1075
+ signIn: signals.codexCheck?.status ?? null
922
1076
  };
923
1077
  }
924
- if (signals.codexOnPath) {
1078
+ if (signals.codexCheck && isUsable(signals.codexCheck)) {
925
1079
  return {
926
1080
  ...base,
927
1081
  state: "detected_not_exposed",
928
- version: signals.codexVersion,
929
- // As for Claude Code: enabling is intent, not launchability.
930
- 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()}`,
931
- enable: "codex"
1082
+ version,
1083
+ detail: `${usableLine("codex", signals.codexCheck)} Turn it on to offer Codex from this device.`,
1084
+ enable: "codex",
1085
+ signIn: signals.codexCheck?.status ?? null
932
1086
  };
933
1087
  }
934
1088
  return {
935
1089
  ...base,
936
1090
  state: "not_detected",
937
1091
  version: null,
938
- detail: "Not detected. Install the Codex CLI and sign in (`codex login`), then enable it here.",
939
- enable: null
1092
+ detail: notUsableDetail("codex", signals.codexCheck),
1093
+ enable: null,
1094
+ signIn: signals.codexCheck?.status ?? null
940
1095
  };
941
1096
  }
942
1097
  function deriveOpencode(signals, manifestHas) {
@@ -948,7 +1103,8 @@ function deriveOpencode(signals, manifestHas) {
948
1103
  state: "exposed",
949
1104
  version: signals.opencodeVersion,
950
1105
  detail: "An opencode server is reachable and exposed to Cabane.",
951
- enable: null
1106
+ enable: null,
1107
+ signIn: null
952
1108
  };
953
1109
  }
954
1110
  return {
@@ -956,7 +1112,8 @@ function deriveOpencode(signals, manifestHas) {
956
1112
  state: "needs_attention",
957
1113
  version: null,
958
1114
  detail: "Configured, but the opencode server isn\u2019t answering. Start `opencode serve` and check the URL.",
959
- enable: null
1115
+ enable: null,
1116
+ signIn: null
960
1117
  };
961
1118
  }
962
1119
  if (signals.opencodeReachable && signals.opencodeDetectedUrl) {
@@ -965,7 +1122,8 @@ function deriveOpencode(signals, manifestHas) {
965
1122
  state: "detected_not_exposed",
966
1123
  version: signals.opencodeVersion,
967
1124
  detail: signals.opencodeDetectedUrl,
968
- enable: "opencode"
1125
+ enable: "opencode",
1126
+ signIn: null
969
1127
  };
970
1128
  }
971
1129
  return {
@@ -973,7 +1131,8 @@ function deriveOpencode(signals, manifestHas) {
973
1131
  state: "not_detected",
974
1132
  version: null,
975
1133
  detail: "Not detected. Run `opencode serve` and add its URL here to expose opencode.",
976
- enable: "opencode"
1134
+ enable: "opencode",
1135
+ signIn: null
977
1136
  };
978
1137
  }
979
1138
  var PROBE_TIMEOUT_MS = 4e3;
@@ -992,31 +1151,20 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
992
1151
  return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
993
1152
  }
994
1153
  async function probeHarnessSignals(cfg, deps = {}) {
995
- const probePresence = deps.probePresence ?? probeHarnessPresence;
996
1154
  const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
997
1155
  const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
998
1156
  const configuredServerUrl = cfg.opencode?.serverUrl;
999
1157
  const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
1000
- const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
1001
- deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
1002
- deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
1003
- configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
1004
- ]);
1005
- const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
1006
- const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
1158
+ const opencodeVersion = configuredServerUrl ? await withTimeout(probeOpencode(opencodeProbeUrl), null) : await probeDefaultOpencodeVersion(probeOpencode);
1007
1159
  return {
1008
- claudeOnPath: claudePresence.status === "present",
1009
- claudeVersion,
1010
- // The user's opt-in, the manifest gate; the probe above only suggests.
1160
+ claudeCheck: deps.checks?.["claude-code"] ?? null,
1161
+ // The user's opt-in, the manifest gate; the check above only suggests.
1011
1162
  claudeCodeConnected: isClaudeCodeConnected(cfg),
1012
- // The executable a Claude turn launches, resolved with every probe so a
1163
+ // The executable a Claude turn launches, resolved with every refresh so a
1013
1164
  // binary that disappears is caught on the next beat, not at turn time.
1014
1165
  claudeBundled: resolveBundled("claude-code").status,
1015
1166
  codexBundled: resolveBundled("codex").status,
1016
- // A parseable `codex --version` is our presence signal (presence alone never
1017
- // exposes codex; its config flag is the manifest gate either way).
1018
- codexOnPath: codexVersion !== null,
1019
- codexVersion,
1167
+ codexCheck: deps.checks?.codex ?? null,
1020
1168
  codexEnabled: isCodexEnabled(cfg),
1021
1169
  opencodeConfigured: !!configuredServerUrl,
1022
1170
  // A version came back ⟺ the serve answered its health endpoint.
@@ -1049,104 +1197,94 @@ function withTimeout(promise, fallback, timeoutMs = PROBE_TIMEOUT_MS) {
1049
1197
  });
1050
1198
  }
1051
1199
 
1052
- // src/harness-check.ts
1053
- var CHECK_TIMEOUT_MS = 4e3;
1054
- async function shakeOutHarness(runtime, cfg, deps = {}) {
1055
- const run = deps.run ?? runBounded;
1056
- const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
1057
- try {
1058
- if (runtime === "opencode") {
1059
- const url = cfg.opencode?.serverUrl;
1060
- if (!url) return "absent";
1061
- return await probeOpencode(url) !== null ? "ok" : "failed";
1062
- }
1063
- const { auth } = runtime === "codex" ? {
1064
- auth: ["codex", ["login", "status"]]
1065
- } : {
1066
- auth: ["claude", ["auth", "status"]]
1067
- };
1068
- const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
1069
- if (presence.status !== "present") return presence.status;
1070
- const authRun = await run(auth[0], [...auth[1]]);
1071
- if (authRun.code === 0) return "ok";
1072
- if (looksUnsupported(authRun.output)) {
1073
- return "unverified";
1074
- }
1075
- return "failed";
1076
- } catch {
1077
- return "unverified";
1078
- }
1200
+ // src/harness-copy.ts
1201
+ function claudeLoginCommand(path = process.env.PATH) {
1202
+ if (firstOnPath("claude", path)) return "claude auth login";
1203
+ return "~/.local/bin/claude auth login";
1079
1204
  }
1080
- function connectedLine(runtime, verdict) {
1081
- if (verdict === "absent" || verdict === "unusable") {
1082
- throw new Error("an unavailable harness cannot be connected");
1083
- }
1205
+ function ctx(c = {}) {
1206
+ return {
1207
+ cmd: c.cmd ?? ((rest) => cabane(rest)),
1208
+ claudeLogin: c.claudeLogin ?? claudeLoginCommand()
1209
+ };
1210
+ }
1211
+ function loginCommand(runtime, c = {}) {
1212
+ return runtime === "codex" ? "codex login" : ctx(c).claudeLogin;
1213
+ }
1214
+ function usableLine(runtime, check) {
1084
1215
  const label = HARNESS_LABELS[runtime];
1085
- if (verdict !== "failed") return `${label} connected.`;
1086
- return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
1216
+ if (!check) return `${label} is running on this computer.`;
1217
+ const version = "version" in check && check.version ? ` (${check.version})` : "";
1218
+ if (check.status === "api_key")
1219
+ return `${label} has an API key configured on this computer${version}.`;
1220
+ return `${label} is signed in on this computer${version}.`;
1087
1221
  }
1088
- var FAILED_SUFFIX = {
1089
- "claude-code": "it doesn\u2019t look signed in yet. Run `claude` once and sign in, then it\u2019s ready.",
1090
- codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1091
- opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1092
- };
1093
- function presenceReason(runtime, status) {
1094
- if (runtime === "opencode") {
1095
- return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
1096
- }
1222
+ function failureText(runtime, reason) {
1097
1223
  const label = HARNESS_LABELS[runtime];
1098
- return status === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
1224
+ switch (reason) {
1225
+ case "timeout":
1226
+ return `Couldn't check whether ${label} is signed in: the check didn't answer in time. Try again.`;
1227
+ case "spawn":
1228
+ return `Couldn't check whether ${label} is signed in: Cabane's bundled ${label} wouldn't start. Repair it with ${repairCommands()[0]} , then try again.`;
1229
+ case "config":
1230
+ return `Couldn't check whether ${label} is signed in: its configuration file didn't load${runtime === "codex" ? ` (${join4(homedir5(), ".codex", "config.toml").replace(homedir5(), "~")})` : ""}. Fix it, then try again.`;
1231
+ case "unreadable":
1232
+ return `Couldn't check whether ${label} is signed in: it answered in a way Cabane couldn't read. Try again, and if it keeps happening, update Cabane.`;
1233
+ }
1099
1234
  }
1100
- var CLI_PRESENCE_ACTION = "Install it and run this again.";
1101
- var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
1102
- function presenceRefusal(runtime, status, action) {
1103
- const reason = presenceReason(runtime, status);
1104
- return runtime === "opencode" ? reason : `${reason} ${action}`;
1235
+ function bundleMissingText(runtime) {
1236
+ const label = HARNESS_LABELS[runtime];
1237
+ return `This Cabane install is missing its bundled ${label}. Repair it with ${repairCommands()[0]} , then try again.`;
1105
1238
  }
1106
- function looksUnsupported(output) {
1107
- return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
1108
- output
1109
- );
1239
+ function unusableReason(runtime, check) {
1240
+ if (check.status === "bundle_missing") return bundleMissingText(runtime);
1241
+ if (check.status === "check_failed") return failureText(runtime, check.reason);
1242
+ return null;
1110
1243
  }
1111
- function runBounded(command, args) {
1112
- return new Promise((resolve2) => {
1113
- let settled = false;
1114
- const done = (result) => {
1115
- if (settled) return;
1116
- settled = true;
1117
- clearTimeout(timer);
1118
- resolve2(result);
1119
- };
1120
- let child;
1121
- try {
1122
- child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1123
- } catch {
1124
- resolve2({ code: null, output: "", error: "spawn" });
1125
- return;
1126
- }
1127
- let out = "";
1128
- const capture = (chunk) => {
1129
- if (out.length < 4096) out += chunk.toString();
1244
+ function connectLines(outcome, opts = {}) {
1245
+ const c = ctx(opts);
1246
+ if (outcome.kind === "opencode") return { ok: false, lines: [outcome.message] };
1247
+ const label = HARNESS_LABELS[outcome.runtime];
1248
+ if (outcome.kind === "connected") return { ok: true, lines: [`${label} connected.`] };
1249
+ if (outcome.kind === "already") {
1250
+ const where = outcome.deviceLabel ?? "this computer";
1251
+ const how = outcome.check?.status === "api_key" ? ", with an API key configured" : outcome.check?.status === "signed_in" ? ", and signed in" : "";
1252
+ return { ok: true, lines: [`${label} is already connected on ${where}${how}.`] };
1253
+ }
1254
+ const { runtime, check, connected } = outcome;
1255
+ const problem = unusableReason(runtime, check);
1256
+ if (problem) return { ok: false, lines: [problem] };
1257
+ if (opts.windowKey) {
1258
+ return {
1259
+ ok: false,
1260
+ lines: [
1261
+ `${label} has an API key in this window, but the running Cabane can't see it.`,
1262
+ `Run ${c.cmd("stop")} then ${c.cmd("start")} here, and it will.`
1263
+ ]
1130
1264
  };
1131
- child.stdout?.on("data", capture);
1132
- child.stderr?.on("data", capture);
1133
- const timer = setTimeout(() => {
1134
- child.kill("SIGKILL");
1135
- done({ code: null, output: out, error: "timeout" });
1136
- }, CHECK_TIMEOUT_MS);
1137
- timer.unref?.();
1138
- child.once("error", () => done({ code: null, output: out, error: "spawn" }));
1139
- child.once("exit", (code) => done({ code, output: out }));
1140
- });
1265
+ }
1266
+ const again = c.cmd(`connect ${runtime}`);
1267
+ return {
1268
+ ok: false,
1269
+ lines: [
1270
+ connected ? `${label} is connected here but signed out. Run ${loginCommand(runtime, c)} , then ${again} again.` : `${label} isn't signed in on this computer. Run ${loginCommand(runtime, c)} , then ${again} again.`
1271
+ ]
1272
+ };
1141
1273
  }
1142
1274
 
1143
1275
  // src/logger.ts
1144
- import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
1145
- import { dirname as dirname3, join as join3 } from "path";
1276
+ import {
1277
+ createWriteStream,
1278
+ mkdirSync as mkdirSync2,
1279
+ openSync,
1280
+ renameSync as renameSync2,
1281
+ statSync as statSync2
1282
+ } from "fs";
1283
+ import { dirname as dirname3, join as join5 } from "path";
1146
1284
  import { stripVTControlCharacters } from "util";
1147
1285
  import pino from "pino";
1148
1286
  function companionLogPath() {
1149
- return join3(cabaneDir(), "companion.log");
1287
+ return join5(cabaneDir(), "companion.log");
1150
1288
  }
1151
1289
  function humanText(value) {
1152
1290
  return stripVTControlCharacters(String(value ?? "")).replace(
@@ -1196,6 +1334,53 @@ function formatHumanLine(record) {
1196
1334
  }
1197
1335
  return line + "\n";
1198
1336
  }
1337
+ var LOG_ROTATE_BYTES = 50 * 1024 * 1024;
1338
+ var LOG_ROTATIONS_KEPT = 3;
1339
+ var RotatingLog = class {
1340
+ constructor(path, maxBytes = LOG_ROTATE_BYTES, keep = LOG_ROTATIONS_KEPT) {
1341
+ this.path = path;
1342
+ this.maxBytes = maxBytes;
1343
+ this.keep = keep;
1344
+ this.size = sizeOf(path);
1345
+ this.stream = openAppend(path);
1346
+ }
1347
+ path;
1348
+ maxBytes;
1349
+ keep;
1350
+ stream;
1351
+ size;
1352
+ write(chunk) {
1353
+ const ok = this.stream.write(chunk);
1354
+ this.size += Buffer.byteLength(chunk);
1355
+ if (this.size >= this.maxBytes) this.rotate();
1356
+ return ok;
1357
+ }
1358
+ rotate() {
1359
+ this.stream.end();
1360
+ try {
1361
+ for (let i = this.keep - 1; i >= 1; i--) {
1362
+ try {
1363
+ renameSync2(`${this.path}.${i}`, `${this.path}.${i + 1}`);
1364
+ } catch {
1365
+ }
1366
+ }
1367
+ renameSync2(this.path, `${this.path}.1`);
1368
+ } catch {
1369
+ }
1370
+ this.stream = openAppend(this.path);
1371
+ this.size = sizeOf(this.path);
1372
+ }
1373
+ };
1374
+ function openAppend(path) {
1375
+ return createWriteStream("", { fd: openSync(path, "a") });
1376
+ }
1377
+ function sizeOf(path) {
1378
+ try {
1379
+ return statSync2(path).size;
1380
+ } catch {
1381
+ return 0;
1382
+ }
1383
+ }
1199
1384
  var cached = null;
1200
1385
  var consoleLogging = true;
1201
1386
  var overrides = {};
@@ -1208,7 +1393,7 @@ function configureLogger(log, config) {
1208
1393
  function createLogger(destinations = {}, options = {}) {
1209
1394
  const path = companionLogPath();
1210
1395
  if (!destinations.file) mkdirSync2(dirname3(path), { recursive: true });
1211
- const file = destinations.file ?? createWriteStream(path, { flags: "a" });
1396
+ const file = destinations.file ?? new RotatingLog(path);
1212
1397
  const state = { format: options.logFormat ?? "human" };
1213
1398
  const streams = [];
1214
1399
  const human = (chunk) => {
@@ -1499,14 +1684,14 @@ var CONTENT_TYPES = {
1499
1684
  function registerRoutes(app, deps) {
1500
1685
  const { supervisor, hub, staticDir } = deps;
1501
1686
  app.get("/", async (c) => {
1502
- const html = await readFile(join4(staticDir, "index.html"), "utf8");
1687
+ const html = await readFile(join6(staticDir, "index.html"), "utf8");
1503
1688
  return c.html(html);
1504
1689
  });
1505
1690
  app.get("/static/:file", async (c) => {
1506
1691
  const file = c.req.param("file");
1507
- const safe3 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1508
- const full = join4(staticDir, safe3);
1509
- if (!full.startsWith(staticDir) || !existsSync2(full)) return c.notFound();
1692
+ const safe2 = normalize(file).replace(/^(\.\.[/\\])+/, "");
1693
+ const full = join6(staticDir, safe2);
1694
+ if (!full.startsWith(staticDir) || !existsSync3(full)) return c.notFound();
1510
1695
  const body = await readFile(full);
1511
1696
  const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
1512
1697
  c.header("content-type", type);
@@ -1559,20 +1744,9 @@ function registerRoutes(app, deps) {
1559
1744
  app.post("/api/harnesses/enable", async (c) => {
1560
1745
  const body = await readJson(c);
1561
1746
  const runtime = body.runtime;
1562
- if (runtime === "claude-code") {
1563
- const result = await supervisor.enableHarness({ runtime: "claude-code" });
1564
- if (!result.ok) {
1565
- const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
1566
- return c.json({ error }, 400);
1567
- }
1568
- return c.json({ ok: true, status: hub.statusJson() });
1569
- }
1570
- if (runtime === "codex") {
1571
- const result = await supervisor.enableHarness({ runtime: "codex" });
1572
- if (!result.ok) {
1573
- const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
1574
- return c.json({ error }, 400);
1575
- }
1747
+ if (runtime === "claude-code" || runtime === "codex") {
1748
+ const result = await supervisor.enableHarness({ runtime });
1749
+ if (!result.ok) return c.json({ error: connectLines(result.outcome).lines.join(" ") }, 400);
1576
1750
  return c.json({ ok: true, status: hub.statusJson() });
1577
1751
  }
1578
1752
  if (runtime === "opencode") {
@@ -1583,7 +1757,7 @@ function registerRoutes(app, deps) {
1583
1757
  runtime: "opencode",
1584
1758
  serverUrl: body.serverUrl
1585
1759
  });
1586
- if (!result.ok) return c.json({ error: result.error }, 400);
1760
+ if (!result.ok) return c.json({ error: connectLines(result.outcome).lines.join(" ") }, 400);
1587
1761
  return c.json({ ok: true, status: hub.statusJson() });
1588
1762
  }
1589
1763
  return c.json({ error: `can't enable "${String(runtime)}" from the app` }, 400);
@@ -1639,11 +1813,11 @@ function clampLimit(raw, fallback, max = 200) {
1639
1813
  return Math.min(Math.floor(n), max);
1640
1814
  }
1641
1815
  function tailFile(path, lines2) {
1642
- if (!existsSync2(path)) return [];
1816
+ if (!existsSync3(path)) return [];
1643
1817
  const MAX_BYTES = 256 * 1024;
1644
1818
  let fd;
1645
1819
  try {
1646
- fd = openSync(path, "r");
1820
+ fd = openSync2(path, "r");
1647
1821
  const size = fstatSync(fd).size;
1648
1822
  const start = Math.max(0, size - MAX_BYTES);
1649
1823
  const len = size - start;
@@ -1728,14 +1902,14 @@ function isAddrInUse(err) {
1728
1902
  return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
1729
1903
  }
1730
1904
  function resolveStaticDir() {
1731
- return join5(dirname4(fileURLToPath2(import.meta.url)), "static");
1905
+ return join7(dirname4(fileURLToPath3(import.meta.url)), "static");
1732
1906
  }
1733
1907
 
1734
1908
  // src/control-socket.ts
1735
1909
  import { createHash } from "crypto";
1736
- import { chmodSync as chmodSync2, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
1910
+ import { chmodSync as chmodSync2, existsSync as existsSync4, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
1737
1911
  import { createServer, connect } from "net";
1738
- import { join as join6 } from "path";
1912
+ import { join as join8 } from "path";
1739
1913
  var CONTROL_TIMEOUT_MS = 1e3;
1740
1914
  var MAX_UNIX_SOCKET_PATH_BYTES = 103;
1741
1915
  function controlSocketPath() {
@@ -1744,16 +1918,16 @@ function controlSocketPath() {
1744
1918
  if (process.platform === "win32") {
1745
1919
  return `\\\\.\\pipe\\cabane-companion-${key}`;
1746
1920
  }
1747
- const besideRuntime = join6(dir2, "companion.sock");
1921
+ const besideRuntime = join8(dir2, "companion.sock");
1748
1922
  if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
1749
1923
  const filename = `cabane-companion-${key}.sock`;
1750
- const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync3(process.env.XDG_RUNTIME_DIR) ? join6(process.env.XDG_RUNTIME_DIR, filename) : null;
1751
- return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join6("/tmp", filename);
1924
+ const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync4(process.env.XDG_RUNTIME_DIR) ? join8(process.env.XDG_RUNTIME_DIR, filename) : null;
1925
+ return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join8("/tmp", filename);
1752
1926
  }
1753
1927
  async function startControlServer(handlers) {
1754
1928
  const path = controlSocketPath();
1755
1929
  mkdirSync3(cabaneDir(), { recursive: true });
1756
- if (process.platform !== "win32" && existsSync3(path)) {
1930
+ if (process.platform !== "win32" && existsSync4(path)) {
1757
1931
  const alive = await ping(path);
1758
1932
  if (alive) throw new Error(`another companion is already listening on ${path}`);
1759
1933
  rmSync2(path, { force: true });
@@ -1813,10 +1987,16 @@ async function serveConnection(socket, handlers) {
1813
1987
  return;
1814
1988
  }
1815
1989
  if (req.cmd === "connect") {
1816
- const result = await handlers.connect(req.runtime, req.serverUrl);
1990
+ const outcome = await handlers.connect(req.runtime, req.serverUrl);
1991
+ const { ok, lines: lines2 } = connectLines(outcome, { cmd: (rest) => `cabane ${rest}` });
1992
+ const result = { ok, message: lines2.join(" "), outcome };
1817
1993
  reply(socket, result);
1818
1994
  return;
1819
1995
  }
1996
+ if (req.cmd === "update") {
1997
+ reply(socket, await handlers.update(req.auto));
1998
+ return;
1999
+ }
1820
2000
  if (req.cmd === "stop") {
1821
2001
  reply(socket, { ok: true });
1822
2002
  setTimeout(() => handlers.stop(), 50).unref?.();
@@ -1903,88 +2083,49 @@ async function ping(path) {
1903
2083
  }
1904
2084
 
1905
2085
  // src/prereqs.ts
1906
- async function claudeOnPath() {
1907
- return (await probeHarnessPresence("claude-code")).status === "present";
1908
- }
1909
- async function codexOnPath() {
1910
- return (await probeHarnessPresence("codex")).status === "present";
1911
- }
1912
- async function requireStartConfig(deps = {}) {
2086
+ function requireStartConfig(deps = {}) {
1913
2087
  const requireCfg = deps.requireCfg ?? requireConfig;
1914
- const probeClaude = deps.probeClaude ?? claudeOnPath;
1915
2088
  const save2 = deps.save ?? saveConfig;
1916
2089
  const cfg = requireCfg();
1917
- const claudeOnPathResult = await probeClaude();
1918
- const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
1919
- if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
2090
+ const migrated = migrateConnectedHarnesses(cfg);
2091
+ if (!migrated) return cfg;
1920
2092
  save2(migrated);
1921
- deps.onMigrated?.(migrated, claudeOnPathResult);
1922
- return { cfg: migrated, claudeOnPath: claudeOnPathResult };
2093
+ deps.onMigrated?.(migrated);
2094
+ return migrated;
1923
2095
  }
1924
- async function warnAboutHarnessReadiness(cfg, deps = {}) {
1925
- const probeClaude = deps.probeClaude ?? claudeOnPath;
1926
- const probeCodex = deps.probeCodex ?? codexOnPath;
2096
+ function warnAboutHarnessReadiness(cfg, deps = {}) {
1927
2097
  const resolveBundled = deps.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
1928
2098
  const warn = deps.warn ?? ((message2) => process.stderr.write(`${message2}
1929
2099
  `));
1930
- const connected = [
1931
- ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
1932
- ...isCodexEnabled(cfg) ? ["Codex"] : [],
1933
- ...cfg.opencode ? ["opencode"] : []
1934
- ];
1935
- if (connected.length > 0) {
1936
- for (const runtime of ["claude-code", "codex"]) {
1937
- const intended = runtime === "codex" ? isCodexEnabled(cfg) : isClaudeCodeConnected(cfg);
1938
- if (!intended) continue;
1939
- const resolution = resolveBundled(runtime);
1940
- if (resolution.status !== "present") warn(startupWarning(resolution));
1941
- }
2100
+ const connected = isClaudeCodeConnected(cfg) || isCodexEnabled(cfg) || Boolean(cfg.opencode?.serverUrl);
2101
+ if (!connected) {
2102
+ warn(
2103
+ `No agent is connected on this device yet, so no agent turn can run here. Sign in to one, then connect it with \`${cabane("connect claude-code")}\` (or codex, or opencode).`
2104
+ );
1942
2105
  return;
1943
2106
  }
1944
- const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
1945
- const found = [
1946
- ...claudeInstalled ? [
1947
- {
1948
- label: "Claude Code",
1949
- command: "`cabane-companion connect claude-code`",
1950
- bundled: resolveBundled("claude-code").status
1951
- }
1952
- ] : [],
1953
- ...codexInstalled ? [
1954
- {
1955
- label: "Codex",
1956
- command: "`cabane-companion connect codex`",
1957
- bundled: resolveBundled("codex").status
1958
- }
1959
- ] : []
1960
- ];
1961
- const runnable = found.filter((h) => h.bundled === "present");
1962
- const incomplete = found.filter((h) => h.bundled !== "present");
1963
- const incompleteNote = incomplete.map(
1964
- (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.`
1965
- ).join(" ");
1966
- 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 ? (
1967
- // Their machine is fine; this companion's install is not. Repair that,
1968
- // then connect — no "install a harness" they already have.
1969
- `${incompleteNote} ${repairAction()} Then connect it with ${incomplete.map((h) => h.command).join(" or ")}.`
1970
- ) : "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).";
1971
- warn(`No harness is connected on this device yet, so no agent turn can run here. ${guidance}`);
2107
+ for (const runtime of ["claude-code", "codex"]) {
2108
+ const intended = runtime === "codex" ? isCodexEnabled(cfg) : isClaudeCodeConnected(cfg);
2109
+ if (!intended) continue;
2110
+ const resolution = resolveBundled(runtime);
2111
+ if (resolution.status !== "present") warn(startupWarning(resolution));
2112
+ }
1972
2113
  }
1973
2114
 
1974
2115
  // src/runtime-file.ts
1975
2116
  import {
1976
- existsSync as existsSync4,
2117
+ existsSync as existsSync5,
1977
2118
  readFileSync as readFileSync2,
1978
2119
  rmSync as rmSync3,
1979
2120
  writeFileSync as writeFileSync2,
1980
2121
  mkdirSync as mkdirSync4,
1981
- openSync as openSync2,
2122
+ openSync as openSync3,
1982
2123
  closeSync as closeSync2
1983
2124
  } from "fs";
1984
- import { join as join7 } from "path";
2125
+ import { join as join9 } from "path";
1985
2126
  var PROBE_TIMEOUT_MS2 = 1e3;
1986
2127
  function runtimePath() {
1987
- return join7(cabaneDir(), "runtime.json");
2128
+ return join9(cabaneDir(), "runtime.json");
1988
2129
  }
1989
2130
  function serialize(state) {
1990
2131
  return JSON.stringify(state, null, 2) + "\n";
@@ -2000,7 +2141,7 @@ function acquireRuntimeState(state) {
2000
2141
  mkdirSync4(cabaneDir(), { recursive: true });
2001
2142
  let fd;
2002
2143
  try {
2003
- fd = openSync2(runtimePath(), "wx");
2144
+ fd = openSync3(runtimePath(), "wx");
2004
2145
  } catch {
2005
2146
  return { acquired: false, existing: readLiveRuntimeState() };
2006
2147
  }
@@ -2013,11 +2154,11 @@ function acquireRuntimeState(state) {
2013
2154
  }
2014
2155
  function clearRuntimeState() {
2015
2156
  const path = runtimePath();
2016
- if (existsSync4(path)) rmSync3(path, { force: true });
2157
+ if (existsSync5(path)) rmSync3(path, { force: true });
2017
2158
  }
2018
2159
  function clearRuntimeStateIfOurs(instanceId) {
2019
2160
  const path = runtimePath();
2020
- if (!existsSync4(path)) return;
2161
+ if (!existsSync5(path)) return;
2021
2162
  try {
2022
2163
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
2023
2164
  if (parsed.instanceId && parsed.instanceId !== instanceId) return;
@@ -2027,7 +2168,7 @@ function clearRuntimeStateIfOurs(instanceId) {
2027
2168
  }
2028
2169
  function readLiveRuntimeState() {
2029
2170
  const path = runtimePath();
2030
- if (!existsSync4(path)) return null;
2171
+ if (!existsSync5(path)) return null;
2031
2172
  let parsed;
2032
2173
  try {
2033
2174
  parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -2192,7 +2333,7 @@ function turnFailureCopy(reason, runtime) {
2192
2333
  return "Claude Code sign-in has expired on this machine. Open Claude Code, run /login, then reply in Cabane to try again.";
2193
2334
  if (runtime === "codex")
2194
2335
  return "Codex sign-in has expired on this machine. Run codex login, then reply in Cabane to try again.";
2195
- return "Sign-in has expired on this machine. Sign in to the coding agent, then reply in Cabane to try again.";
2336
+ return "Sign-in has expired on this machine. Sign in to the agent, then reply in Cabane to try again.";
2196
2337
  }
2197
2338
  if (failure?.kind === "usage_capped")
2198
2339
  return failure.resetsAt ? `The usage limit is reached; it resets around ${new Date(failure.resetsAt).toLocaleTimeString()}. Reply in Cabane after it resets.` : "The usage limit is reached. Try again later in Cabane.";
@@ -2203,9 +2344,9 @@ function turnFailureCopy(reason, runtime) {
2203
2344
  if (reason?.startsWith("missing_secret:"))
2204
2345
  return `A required secret is missing: ${humanText(reason.slice(15))}. Add it to ~/.cabane/secrets.json, then reply in Cabane to try again.`;
2205
2346
  if (reason?.startsWith("runtime_incomplete:"))
2206
- return "The coding agent installation is incomplete. Reinstall with npm i -g @cabane/companion@latest, then restart the companion.";
2347
+ return "The agent installation is incomplete. Reinstall with npm i -g @cabane/companion@latest, then restart the companion.";
2207
2348
  if (/^(runtime|codex|opencode)_unavailable:/.test(reason ?? ""))
2208
- return "The coding agent is unavailable on this machine. Enable it here or choose another connector in Cabane.";
2349
+ return "The agent is unavailable on this machine. Enable it here or choose another connector in Cabane.";
2209
2350
  if (reason?.startsWith("model_unavailable:"))
2210
2351
  return "The selected model is unavailable. Choose a different model in the agent settings in Cabane.";
2211
2352
  if (reason?.startsWith("prepare_failed:"))
@@ -2213,17 +2354,17 @@ function turnFailureCopy(reason, runtime) {
2213
2354
  if (reason?.startsWith("fetch_failed:"))
2214
2355
  return `Could not load the conversation (${humanText(reason.slice(13))}). Reply in Cabane to try again.`;
2215
2356
  if (reason === "timeout_idle")
2216
- return "The coding agent stopped responding. Reply in Cabane to try again.";
2357
+ return "The agent stopped responding. Reply in Cabane to try again.";
2217
2358
  if (reason === "timeout_total")
2218
2359
  return "The turn ran longer than expected and was stopped. Reply in Cabane to continue.";
2219
2360
  if (reason === "result_error:error_max_turns")
2220
- return "The coding agent reached its step limit. Reply in Cabane to continue.";
2361
+ return "The agent reached its step limit. Reply in Cabane to continue.";
2221
2362
  if (reason === "no_result" || reason === "empty_result" || reason === "empty_result_unverified")
2222
- return "The coding agent finished without a response. Reply in Cabane to try again.";
2363
+ return "The agent finished without a response. Reply in Cabane to try again.";
2223
2364
  if (reason === "session_start_failed")
2224
- return "The coding agent could not start a session. Restart the companion, then reply in Cabane to try again.";
2365
+ return "The agent could not start a session. Restart the companion, then reply in Cabane to try again.";
2225
2366
  if (reason === "no_terminal")
2226
- return "The coding agent stopped before confirming the turn was complete. Reply in Cabane to continue.";
2367
+ return "The agent stopped before confirming the turn was complete. Reply in Cabane to continue.";
2227
2368
  if (reason === "lease_unconfirmed")
2228
2369
  return "Could not confirm with Cabane that the turn could start. Check this machine\u2019s connection, then reply in Cabane to try again.";
2229
2370
  if (reason === "workspace_tools_missing")
@@ -2231,13 +2372,13 @@ function turnFailureCopy(reason, runtime) {
2231
2372
  if (reason?.startsWith("seq_floor_unavailable:"))
2232
2373
  return "The interrupted turn could not safely continue. Reply in Cabane to continue.";
2233
2374
  if (reason === "result_error:error_max_budget_usd")
2234
- return "The coding agent reached its spending limit. Check its budget settings before trying again in Cabane.";
2375
+ return "The agent reached its spending limit. Check its budget settings before trying again in Cabane.";
2235
2376
  if (reason === "result_error:error_max_structured_output_retries")
2236
- return "The coding agent could not produce the requested response format. Check the requested format, then reply in Cabane to try again.";
2377
+ return "The agent could not produce the requested response format. Check the requested format, then reply in Cabane to try again.";
2237
2378
  if (reason?.startsWith("result_error:"))
2238
- return "The coding agent stopped with an error while running. Check its transcript, then reply in Cabane to try again.";
2379
+ return "The agent stopped with an error while running. Check its transcript, then reply in Cabane to try again.";
2239
2380
  if (reason === "setup_failed")
2240
- return "The turn could not be set up. Check the coding agent setup on this machine, then reply in Cabane to try again.";
2381
+ return "The turn could not be set up. Check the agent setup on this machine, then reply in Cabane to try again.";
2241
2382
  if (reason === "unexpected_role")
2242
2383
  return "The message could not start an agent response. Send a new message in Cabane to try again.";
2243
2384
  if (reason === "lease_lost" || reason?.startsWith("lease_refused:"))
@@ -2245,11 +2386,11 @@ function turnFailureCopy(reason, runtime) {
2245
2386
  const detail = humanText(reason?.replace(/^error:/, "") ?? "").slice(0, 400);
2246
2387
  if (/\b(EACCES|EPERM)\b|permission denied/i.test(detail)) {
2247
2388
  const readable = detail.replace(/\b(EACCES|EPERM)\b/g, "permission denied");
2248
- return `The coding agent was denied access: ${readable}. Check access permissions for the reported file or command, then reply in Cabane to try again.`;
2389
+ return `The agent was denied access: ${readable}. Check access permissions for the reported file or command, then reply in Cabane to try again.`;
2249
2390
  }
2250
2391
  if (detail)
2251
- return `The coding agent reported: ${detail}. Check the coding agent setup on this machine, then reply in Cabane to try again.`;
2252
- return "The coding agent could not finish this turn. Reply in Cabane to try again.";
2392
+ return `The agent reported: ${detail}. Check the agent setup on this machine, then reply in Cabane to try again.`;
2393
+ return "The agent could not finish this turn. Reply in Cabane to try again.";
2253
2394
  }
2254
2395
 
2255
2396
  // src/api-error-shape.ts
@@ -2883,22 +3024,22 @@ function errorMessage2(status, body) {
2883
3024
  // src/credentials.ts
2884
3025
  import {
2885
3026
  chmodSync as chmodSync3,
2886
- existsSync as existsSync5,
3027
+ existsSync as existsSync6,
2887
3028
  mkdirSync as mkdirSync5,
2888
3029
  readFileSync as readFileSync3,
2889
- renameSync as renameSync2,
3030
+ renameSync as renameSync3,
2890
3031
  rmSync as rmSync4,
2891
3032
  writeFileSync as writeFileSync3
2892
3033
  } from "fs";
2893
- import { dirname as dirname5, join as join8 } from "path";
3034
+ import { dirname as dirname5, join as join10 } from "path";
2894
3035
  import { z as z4 } from "zod";
2895
3036
  function credentialsPath() {
2896
- return join8(cabaneDir(), "credentials.json");
3037
+ return join10(cabaneDir(), "credentials.json");
2897
3038
  }
2898
3039
  var credentialStoreSchema = z4.record(z4.string(), z4.string());
2899
3040
  function load() {
2900
3041
  const path = credentialsPath();
2901
- if (!existsSync5(path)) return {};
3042
+ if (!existsSync6(path)) return {};
2902
3043
  let raw;
2903
3044
  try {
2904
3045
  raw = readFileSync3(path, "utf8");
@@ -2927,7 +3068,7 @@ function save(map) {
2927
3068
  chmodSync3(tmp, 384);
2928
3069
  } catch {
2929
3070
  }
2930
- renameSync2(tmp, path);
3071
+ renameSync3(tmp, path);
2931
3072
  } catch (err) {
2932
3073
  try {
2933
3074
  rmSync4(tmp, { force: true });
@@ -2960,18 +3101,18 @@ function pruneCredentials(keepAgentIds) {
2960
3101
  }
2961
3102
 
2962
3103
  // src/dispatch-dedupe.ts
2963
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync3, existsSync as existsSync6 } from "fs";
2964
- import { join as join9 } from "path";
3104
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync7 } from "fs";
3105
+ import { join as join11 } from "path";
2965
3106
  var MAX_IDS = 256;
2966
3107
  function dir(log) {
2967
- return join9(cabaneDir(), log);
3108
+ return join11(cabaneDir(), log);
2968
3109
  }
2969
3110
  function pathFor(log, workspaceId) {
2970
- return join9(dir(log), encodeURIComponent(workspaceId));
3111
+ return join11(dir(log), encodeURIComponent(workspaceId));
2971
3112
  }
2972
3113
  function readIds(log, workspaceId) {
2973
3114
  const path = pathFor(log, workspaceId);
2974
- if (!existsSync6(path)) return [];
3115
+ if (!existsSync7(path)) return [];
2975
3116
  try {
2976
3117
  return readFileSync4(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2977
3118
  } catch {
@@ -3004,15 +3145,15 @@ function markCompleted(workspaceId, eventId) {
3004
3145
  }
3005
3146
  var MAX_RESUME_ATTEMPTS = 3;
3006
3147
  function resumeDir() {
3007
- return join9(cabaneDir(), "resume-attempts");
3148
+ return join11(cabaneDir(), "resume-attempts");
3008
3149
  }
3009
3150
  function resumePathFor(workspaceId) {
3010
- return join9(resumeDir(), encodeURIComponent(workspaceId));
3151
+ return join11(resumeDir(), encodeURIComponent(workspaceId));
3011
3152
  }
3012
3153
  function readResumeCounts(workspaceId) {
3013
3154
  const out = /* @__PURE__ */ new Map();
3014
3155
  const path = resumePathFor(workspaceId);
3015
- if (!existsSync6(path)) return out;
3156
+ if (!existsSync7(path)) return out;
3016
3157
  try {
3017
3158
  for (const line of readFileSync4(path, "utf8").split("\n")) {
3018
3159
  const trimmed = line.trim();
@@ -3043,16 +3184,16 @@ function bumpResumeAttempt(workspaceId, eventId) {
3043
3184
  return next;
3044
3185
  }
3045
3186
  function turnDir() {
3046
- return join9(cabaneDir(), "turns");
3187
+ return join11(cabaneDir(), "turns");
3047
3188
  }
3048
3189
  function turnPathFor(workspaceId) {
3049
- return join9(turnDir(), encodeURIComponent(workspaceId));
3190
+ return join11(turnDir(), encodeURIComponent(workspaceId));
3050
3191
  }
3051
3192
  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;
3052
3193
  function readTurnIds(workspaceId) {
3053
3194
  const out = /* @__PURE__ */ new Map();
3054
3195
  const path = turnPathFor(workspaceId);
3055
- if (!existsSync6(path)) return out;
3196
+ if (!existsSync7(path)) return out;
3056
3197
  try {
3057
3198
  for (const line of readFileSync4(path, "utf8").split("\n")) {
3058
3199
  const trimmed = line.trim();
@@ -3078,7 +3219,7 @@ function writeTurnIds(workspaceId, turns) {
3078
3219
  const path = turnPathFor(workspaceId);
3079
3220
  const tmp = `${path}.${process.pid}.tmp`;
3080
3221
  writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
3081
- renameSync3(tmp, path);
3222
+ renameSync4(tmp, path);
3082
3223
  return entries.length;
3083
3224
  }
3084
3225
  function rememberTurnId(workspaceId, eventId, turnId) {
@@ -3964,8 +4105,8 @@ function readThinking(runtimeOptions) {
3964
4105
  var CLAUDE_CODE_DEFAULT_MODEL_BARE = "default";
3965
4106
  function parseClaudeCodeModel(model) {
3966
4107
  if (!model) return null;
3967
- const sep4 = model.indexOf("/");
3968
- const bare = sep4 === -1 || model.slice(0, sep4) !== "anthropic" ? model : model.slice(sep4 + 1);
4108
+ const sep5 = model.indexOf("/");
4109
+ const bare = sep5 === -1 || model.slice(0, sep5) !== "anthropic" ? model : model.slice(sep5 + 1);
3969
4110
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
3970
4111
  }
3971
4112
 
@@ -4148,7 +4289,7 @@ function dedupe(xs) {
4148
4289
  }
4149
4290
 
4150
4291
  // packages/agent-runtime/src/claude-code/loop.ts
4151
- async function* decodeSdkStream(iter, ctx) {
4292
+ async function* decodeSdkStream(iter, ctx2) {
4152
4293
  const pending = /* @__PURE__ */ new Map();
4153
4294
  const buffer = createTerminalTextBuffer();
4154
4295
  const out = [];
@@ -4169,7 +4310,7 @@ async function* decodeSdkStream(iter, ctx) {
4169
4310
  let preInitNotice = null;
4170
4311
  try {
4171
4312
  for await (const msg of iter) {
4172
- if (ctx.signal.aborted) return;
4313
+ if (ctx2.signal.aborted) return;
4173
4314
  if (msg.type === "system" && msg.subtype === "init") {
4174
4315
  const initModel = msg.model;
4175
4316
  if (typeof initModel === "string" && initModel.length > 0) resolvedModel = initModel;
@@ -4180,19 +4321,19 @@ async function* decodeSdkStream(iter, ctx) {
4180
4321
  break;
4181
4322
  }
4182
4323
  const sdkSessionId = msg.session_id;
4183
- if (!sessionEmitted && sdkSessionId && sdkSessionId !== ctx.resumedSessionId) {
4324
+ if (!sessionEmitted && sdkSessionId && sdkSessionId !== ctx2.resumedSessionId) {
4184
4325
  sessionEmitted = true;
4185
4326
  yield {
4186
4327
  type: "session",
4187
- state: encodeSession({ sdkSessionId, cwd: ctx.cwd ?? "" }),
4188
- ...ctx.degraded ? { degraded: true } : {}
4328
+ state: encodeSession({ sdkSessionId, cwd: ctx2.cwd ?? "" }),
4329
+ ...ctx2.degraded ? { degraded: true } : {}
4189
4330
  };
4190
4331
  }
4191
4332
  } else if (msg.type === "system" && msg.subtype === "compact_boundary") {
4192
4333
  const compaction = readCompaction(msg);
4193
4334
  if (compaction) compactions.push(compaction);
4194
4335
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
4195
- if (preInitToolActivity(msg) && ctx.startEvidence) ctx.startEvidence.toolActivity = true;
4336
+ if (preInitToolActivity(msg) && ctx2.startEvidence) ctx2.startEvidence.toolActivity = true;
4196
4337
  const failure = classifyPreInitFrame(msg, signals);
4197
4338
  resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
4198
4339
  if (msg.type === "assistant" && isHarnessAuthored(msg))
@@ -4236,7 +4377,7 @@ async function* decodeSdkStream(iter, ctx) {
4236
4377
  const window = readContextWindow(msg, resolvedModel);
4237
4378
  if (window !== void 0) usage.contextWindow = window;
4238
4379
  if (compactions.length > 0) usage.contextCompactions = compactions;
4239
- const reading = await readHarnessContext(ctx.readContextUsage);
4380
+ const reading = await readHarnessContext(ctx2.readContextUsage);
4240
4381
  if (reading) Object.assign(usage, reading);
4241
4382
  }
4242
4383
  const isError = msg.is_error === true;
@@ -4252,20 +4393,20 @@ async function* decodeSdkStream(iter, ctx) {
4252
4393
  }
4253
4394
  }
4254
4395
  } catch (err) {
4255
- if (ctx.signal.aborted) throw err;
4396
+ if (ctx2.signal.aborted) throw err;
4256
4397
  const message2 = err instanceof Error ? err.message : String(err);
4257
4398
  const failure = mcpInventory.initReceived ? classifyErrorText(message2) : classifyFailure(signals, { text: message2 });
4258
- if (!failure && (mcpInventory.initReceived || ctx.resumedSessionId === null)) throw err;
4399
+ if (!failure && (mcpInventory.initReceived || ctx2.resumedSessionId === null)) throw err;
4259
4400
  ok = false;
4260
4401
  if (failure) {
4261
4402
  resultReason = encodeFailureReason(failure);
4262
4403
  } else {
4263
4404
  resultReason = "session_start_failed";
4264
- if (ctx.startEvidence) ctx.startEvidence.thrown = message2.slice(0, 200);
4405
+ if (ctx2.startEvidence) ctx2.startEvidence.thrown = message2.slice(0, 200);
4265
4406
  }
4266
4407
  sawResult = true;
4267
4408
  }
4268
- if (ctx.signal.aborted) return;
4409
+ if (ctx2.signal.aborted) return;
4269
4410
  if (!workspaceProven) {
4270
4411
  ok = false;
4271
4412
  if (resultReason === void 0) {
@@ -5273,7 +5414,7 @@ function deepestError(node) {
5273
5414
  }
5274
5415
 
5275
5416
  // packages/agent-runtime/src/opencode/loop.ts
5276
- async function* decodeOpencodeStream(events, ctx) {
5417
+ async function* decodeOpencodeStream(events, ctx2) {
5277
5418
  let held = null;
5278
5419
  const pending = /* @__PURE__ */ new Map();
5279
5420
  const startedTools = /* @__PURE__ */ new Set();
@@ -5282,9 +5423,9 @@ async function* decodeOpencodeStream(events, ctx) {
5282
5423
  let reason;
5283
5424
  let settled = false;
5284
5425
  const userMessageIds = /* @__PURE__ */ new Set();
5285
- if (ctx.skipMessageId) userMessageIds.add(ctx.skipMessageId);
5426
+ if (ctx2.skipMessageId) userMessageIds.add(ctx2.skipMessageId);
5286
5427
  for await (const ev of events) {
5287
- if (ctx.signal.aborted) return;
5428
+ if (ctx2.signal.aborted) return;
5288
5429
  if (ev.type === "message.updated") {
5289
5430
  const info = readMessageInfo(ev.properties);
5290
5431
  if (info && info.role === "user") userMessageIds.add(info.id);
@@ -5337,7 +5478,7 @@ async function* decodeOpencodeStream(events, ctx) {
5337
5478
  }
5338
5479
  } else if (ev.type === "session.idle") {
5339
5480
  const sid = readSessionId(ev.properties);
5340
- if (sid && sid !== ctx.sessionId) continue;
5481
+ if (sid && sid !== ctx2.sessionId) continue;
5341
5482
  const sealed = sealHeld(held, true);
5342
5483
  held = null;
5343
5484
  if (sealed) yield sealed;
@@ -5346,7 +5487,7 @@ async function* decodeOpencodeStream(events, ctx) {
5346
5487
  break;
5347
5488
  } else if (ev.type === "session.error") {
5348
5489
  const sid = readSessionId(ev.properties);
5349
- if (sid && sid !== ctx.sessionId) continue;
5490
+ if (sid && sid !== ctx2.sessionId) continue;
5350
5491
  const sealed = sealHeld(held, false);
5351
5492
  held = null;
5352
5493
  if (sealed) yield sealed;
@@ -5362,7 +5503,7 @@ async function* decodeOpencodeStream(events, ctx) {
5362
5503
  break;
5363
5504
  }
5364
5505
  }
5365
- if (ctx.signal.aborted) return;
5506
+ if (ctx2.signal.aborted) return;
5366
5507
  if (!settled) {
5367
5508
  const sealed = sealHeld(held, true);
5368
5509
  held = null;
@@ -5420,9 +5561,9 @@ function readOpencodeDialect(runtimeOptions) {
5420
5561
 
5421
5562
  // packages/agent-runtime/src/opencode/model.ts
5422
5563
  function parseOpencodeModel(model) {
5423
- const sep4 = model.indexOf("/");
5424
- if (sep4 === -1) return { providerID: "", modelID: model };
5425
- return { providerID: model.slice(0, sep4), modelID: model.slice(sep4 + 1) };
5564
+ const sep5 = model.indexOf("/");
5565
+ if (sep5 === -1) return { providerID: "", modelID: model };
5566
+ return { providerID: model.slice(0, sep5), modelID: model.slice(sep5 + 1) };
5426
5567
  }
5427
5568
 
5428
5569
  // packages/agent-runtime/src/opencode/run-spec.ts
@@ -5710,10 +5851,10 @@ async function* parseSseStream(body, sessionId, signal) {
5710
5851
  const { value, done } = await reader.read();
5711
5852
  if (done) return;
5712
5853
  buf += decoder.decode(value, { stream: true });
5713
- let sep4;
5714
- while ((sep4 = buf.indexOf("\n\n")) !== -1) {
5715
- const frame = buf.slice(0, sep4);
5716
- buf = buf.slice(sep4 + 2);
5854
+ let sep5;
5855
+ while ((sep5 = buf.indexOf("\n\n")) !== -1) {
5856
+ const frame = buf.slice(0, sep5);
5857
+ buf = buf.slice(sep5 + 2);
5717
5858
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
5718
5859
  if (!data) continue;
5719
5860
  let ev;
@@ -6461,7 +6602,7 @@ function readUsageBaseline(raw) {
6461
6602
  }
6462
6603
 
6463
6604
  // packages/agent-runtime/src/codex/loop.ts
6464
- async function* decodeCodexStream(events, ctx) {
6605
+ async function* decodeCodexStream(events, ctx2) {
6465
6606
  let held = null;
6466
6607
  const pending = /* @__PURE__ */ new Map();
6467
6608
  const startedTools = /* @__PURE__ */ new Set();
@@ -6471,7 +6612,7 @@ async function* decodeCodexStream(events, ctx) {
6471
6612
  let reason;
6472
6613
  let usage;
6473
6614
  let cumulativeUsage;
6474
- let threadIdForSession = ctx.resumedThreadId;
6615
+ let threadIdForSession = ctx2.resumedThreadId;
6475
6616
  let settled = false;
6476
6617
  function* flushInterim() {
6477
6618
  const sealed = sealHeld2(held, false);
@@ -6479,26 +6620,26 @@ async function* decodeCodexStream(events, ctx) {
6479
6620
  if (sealed) yield sealed;
6480
6621
  }
6481
6622
  for await (const ev of events) {
6482
- if (ctx.signal.aborted) return;
6623
+ if (ctx2.signal.aborted) return;
6483
6624
  if (ev.type === "thread.started") {
6484
6625
  const threadId = readThreadId(ev);
6485
6626
  if (threadId) threadIdForSession = threadId;
6486
6627
  if (threadId && !sessionEmitted) {
6487
6628
  const state = encodeSession3({
6488
6629
  threadId,
6489
- cwd: ctx.cwd ?? "",
6490
- promptFingerprint: ctx.promptFingerprint ?? null,
6630
+ cwd: ctx2.cwd ?? "",
6631
+ promptFingerprint: ctx2.promptFingerprint ?? null,
6491
6632
  // The baseline this turn inherited, carried through unchanged. This early write records the
6492
6633
  // thread id even if the turn dies mid-stream; it can't yet know what the turn will spend. The
6493
6634
  // closing write records the new baseline.
6494
- usageBaseline: ctx.usageBaseline ?? null
6635
+ usageBaseline: ctx2.usageBaseline ?? null
6495
6636
  });
6496
- if (threadId !== ctx.resumedThreadId || state !== ctx.storedState) {
6637
+ if (threadId !== ctx2.resumedThreadId || state !== ctx2.storedState) {
6497
6638
  sessionEmitted = true;
6498
6639
  yield {
6499
6640
  type: "session",
6500
6641
  state,
6501
- ...ctx.degraded ? { degraded: true } : {}
6642
+ ...ctx2.degraded ? { degraded: true } : {}
6502
6643
  };
6503
6644
  }
6504
6645
  }
@@ -6567,7 +6708,7 @@ async function* decodeCodexStream(events, ctx) {
6567
6708
  held = null;
6568
6709
  if (sealed) yield sealed;
6569
6710
  cumulativeUsage = readUsage(ev);
6570
- usage = cumulativeUsage ? codexUsageDelta(cumulativeUsage, ctx.usageBaseline ?? null) : void 0;
6711
+ usage = cumulativeUsage ? codexUsageDelta(cumulativeUsage, ctx2.usageBaseline ?? null) : void 0;
6571
6712
  ok = true;
6572
6713
  settled = true;
6573
6714
  break;
@@ -6586,7 +6727,7 @@ async function* decodeCodexStream(events, ctx) {
6586
6727
  break;
6587
6728
  }
6588
6729
  }
6589
- if (ctx.signal.aborted) return;
6730
+ if (ctx2.signal.aborted) return;
6590
6731
  if (!settled) {
6591
6732
  const sealed = sealHeld2(held, true);
6592
6733
  held = null;
@@ -6596,19 +6737,19 @@ async function* decodeCodexStream(events, ctx) {
6596
6737
  if (ok && cumulativeUsage && threadIdForSession) {
6597
6738
  const closing = encodeSession3({
6598
6739
  threadId: threadIdForSession,
6599
- cwd: ctx.cwd ?? "",
6600
- promptFingerprint: ctx.promptFingerprint ?? null,
6740
+ cwd: ctx2.cwd ?? "",
6741
+ promptFingerprint: ctx2.promptFingerprint ?? null,
6601
6742
  usageBaseline: cumulativeUsage
6602
6743
  });
6603
6744
  yield { type: "session", state: closing };
6604
6745
  }
6605
- const resolvedConfig = ctx.resolvedReasoningEffort ? { reasoningEffort: ctx.resolvedReasoningEffort } : void 0;
6746
+ const resolvedConfig = ctx2.resolvedReasoningEffort ? { reasoningEffort: ctx2.resolvedReasoningEffort } : void 0;
6606
6747
  yield {
6607
6748
  type: "result",
6608
6749
  ok,
6609
6750
  ...reason ? { reason } : {},
6610
6751
  ...usage ? { usage } : {},
6611
- ...ctx.resolvedModel ? { resolvedModel: ctx.resolvedModel } : {},
6752
+ ...ctx2.resolvedModel ? { resolvedModel: ctx2.resolvedModel } : {},
6612
6753
  ...resolvedConfig ? { resolvedConfig } : {}
6613
6754
  };
6614
6755
  }
@@ -6653,8 +6794,8 @@ function readCodexDialect(runtimeOptions) {
6653
6794
  // packages/agent-runtime/src/codex/model.ts
6654
6795
  var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
6655
6796
  function parseCodexModel(model) {
6656
- const sep4 = model.indexOf("/");
6657
- const bare = sep4 === -1 || model.slice(0, sep4) !== "openai" ? model : model.slice(sep4 + 1);
6797
+ const sep5 = model.indexOf("/");
6798
+ const bare = sep5 === -1 || model.slice(0, sep5) !== "openai" ? model : model.slice(sep5 + 1);
6658
6799
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
6659
6800
  }
6660
6801
 
@@ -7934,6 +8075,7 @@ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
7934
8075
  // src/connector-health.ts
7935
8076
  var ConnectorHealthStore = class {
7936
8077
  byRuntime = /* @__PURE__ */ new Map();
8078
+ sources = /* @__PURE__ */ new Map();
7937
8079
  // Record a settled turn's outcome for `runtime`. A success heals to `ok`; a
7938
8080
  // classified failure sets `limited`(+reset) / `auth_expired` with the reason as
7939
8081
  // `last_error`; anything else (a user Stop, a timeout, an unclassified error)
@@ -7942,6 +8084,7 @@ var ConnectorHealthStore = class {
7942
8084
  recordSettle(runtime, outcome) {
7943
8085
  if (outcome.ok) {
7944
8086
  this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
8087
+ this.sources.set(runtime, "turn");
7945
8088
  return;
7946
8089
  }
7947
8090
  const failure = decodeFailureReason(outcome.errorReason);
@@ -7953,6 +8096,26 @@ var ConnectorHealthStore = class {
7953
8096
  limitedUntil: quota.limitedUntil,
7954
8097
  lastError: outcome.errorReason ?? null
7955
8098
  });
8099
+ this.sources.set(runtime, "turn");
8100
+ }
8101
+ // Record what the companion's own sign-in check found for a connected
8102
+ // runtime at `start` or `connect`, so the heartbeat carries "sign-in needed"
8103
+ // before any turn fails. Signed out seeds `auth_expired` unless a turn has
8104
+ // already spoken; usable clears only a seed of our own.
8105
+ recordLocalCheck(runtime, usable) {
8106
+ const source = this.sources.get(runtime);
8107
+ if (!usable) {
8108
+ if (source === "turn" && this.byRuntime.get(runtime)?.quotaState !== "ok") return;
8109
+ this.byRuntime.set(runtime, {
8110
+ quotaState: "auth_expired",
8111
+ limitedUntil: null,
8112
+ lastError: encodeFailureReason({ kind: "auth_expired" })
8113
+ });
8114
+ this.sources.set(runtime, "local");
8115
+ return;
8116
+ }
8117
+ if (source !== "local") return;
8118
+ this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
7956
8119
  }
7957
8120
  // The store's current opinion for one runtime, or undefined when it has
7958
8121
  // no observation. Read by the dispatcher at idle-timeout fire: an idle reap
@@ -7981,11 +8144,11 @@ var ConnectorHealthStore = class {
7981
8144
 
7982
8145
  // src/dispatcher.ts
7983
8146
  import { randomUUID as randomUUID4 } from "crypto";
7984
- import { existsSync as existsSync10, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7985
- import { join as join15 } from "path";
8147
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
8148
+ import { join as join17 } from "path";
7986
8149
 
7987
8150
  // src/runner/launch.ts
7988
- import { createInterface } from "readline";
8151
+ import { createInterface as createInterface2 } from "readline";
7989
8152
 
7990
8153
  // src/runner/sandbox-policy.ts
7991
8154
  import { NetworkConfigSchema } from "@anthropic-ai/sandbox-runtime";
@@ -8162,7 +8325,7 @@ async function stopRemoteOpencodeSession(serverUrl, sessionId, opts = {}) {
8162
8325
  }
8163
8326
 
8164
8327
  // src/turn-containment.ts
8165
- import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8328
+ import { execFile, execFileSync, spawn as spawn3 } from "child_process";
8166
8329
  import { readFileSync as readFileSync5 } from "fs";
8167
8330
  import { promisify } from "util";
8168
8331
  var execFileAsync = promisify(execFile);
@@ -8171,8 +8334,8 @@ var CONTAINMENT_STOP_GRACE_SEC = 5;
8171
8334
  var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8172
8335
  var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8173
8336
  function turnScopeUnit(turnId, attempt) {
8174
- const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8175
- return `cabane-turn-${safe3}-${attempt}`;
8337
+ const safe2 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8338
+ return `cabane-turn-${safe2}-${attempt}`;
8176
8339
  }
8177
8340
  async function reapTurnScopes(turnId) {
8178
8341
  if (process.platform !== "linux") return { failures: [] };
@@ -8285,7 +8448,7 @@ function createTurnContainment(turnId, deps = {}) {
8285
8448
  command: options.command
8286
8449
  });
8287
8450
  return asContained(
8288
- spawn4("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
8451
+ spawn3("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
8289
8452
  ...options.cwd ? { cwd: options.cwd } : {},
8290
8453
  env: options.env,
8291
8454
  stdio: ["pipe", "pipe", "pipe"]
@@ -8293,7 +8456,7 @@ function createTurnContainment(turnId, deps = {}) {
8293
8456
  );
8294
8457
  }
8295
8458
  function spawnInGroup(options) {
8296
- const child = spawn4(options.command, options.args, {
8459
+ const child = spawn3(options.command, options.args, {
8297
8460
  ...options.cwd ? { cwd: options.cwd } : {},
8298
8461
  env: options.env,
8299
8462
  stdio: ["pipe", "pipe", "pipe"],
@@ -8440,17 +8603,17 @@ function createTurnContainment(turnId, deps = {}) {
8440
8603
  }
8441
8604
 
8442
8605
  // src/secrets.ts
8443
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
8444
- import { join as join10 } from "path";
8606
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
8607
+ import { join as join12 } from "path";
8445
8608
  import { z as z14 } from "zod";
8446
8609
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8447
8610
  function secretsPath() {
8448
- return join10(cabaneDir(), "secrets.json");
8611
+ return join12(cabaneDir(), "secrets.json");
8449
8612
  }
8450
8613
  var secretStoreSchema = z14.record(z14.string(), z14.string());
8451
8614
  function loadSecretStore() {
8452
8615
  const path = secretsPath();
8453
- if (!existsSync7(path)) return makeStore({});
8616
+ if (!existsSync8(path)) return makeStore({});
8454
8617
  let raw;
8455
8618
  try {
8456
8619
  raw = readFileSync6(path, "utf8");
@@ -8829,7 +8992,7 @@ function relayRunnerLog(line, log) {
8829
8992
  log[method](fields, typeof msg === "string" ? msg : "");
8830
8993
  }
8831
8994
  function lines(stream, onLine) {
8832
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
8995
+ const rl = createInterface2({ input: stream, crlfDelay: Infinity });
8833
8996
  rl.on("line", onLine);
8834
8997
  return new Promise((resolve2) => rl.once("close", resolve2));
8835
8998
  }
@@ -9169,19 +9332,9 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
9169
9332
  return { ok: false, durationMs, reason };
9170
9333
  }
9171
9334
 
9172
- // src/term.ts
9173
- import { createInterface as createInterface2 } from "readline";
9174
- import { homedir as homedir3 } from "os";
9175
- import pc from "picocolors";
9176
- function tildePath(path) {
9177
- const home = homedir3();
9178
- if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
9179
- return path;
9180
- }
9181
-
9182
9335
  // src/turn-execution.ts
9183
9336
  import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
9184
- import { existsSync as existsSync9 } from "fs";
9337
+ import { existsSync as existsSync10 } from "fs";
9185
9338
 
9186
9339
  // src/turn-control-tools.ts
9187
9340
  import { randomUUID as randomUUID2 } from "crypto";
@@ -9425,11 +9578,11 @@ function trimSlash2(s) {
9425
9578
  // src/codex-instructions.ts
9426
9579
  import { mkdtemp, rm, writeFile } from "fs/promises";
9427
9580
  import { tmpdir } from "os";
9428
- import { join as join11 } from "path";
9581
+ import { join as join13 } from "path";
9429
9582
  var PREFIX = "cabane-codex-instructions-";
9430
9583
  async function writeCodexInstructionsFile(contents) {
9431
- const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
9432
- const path = join11(dir2, "instructions.md");
9584
+ const dir2 = await mkdtemp(join13(tmpdir(), PREFIX));
9585
+ const path = join13(dir2, "instructions.md");
9433
9586
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
9434
9587
  return {
9435
9588
  path,
@@ -9442,7 +9595,7 @@ async function writeCodexInstructionsFile(contents) {
9442
9595
  // src/codex-images.ts
9443
9596
  import { mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
9444
9597
  import { tmpdir as tmpdir2 } from "os";
9445
- import { join as join12 } from "path";
9598
+ import { join as join14 } from "path";
9446
9599
  var FETCH_TIMEOUT_MS = 3e4;
9447
9600
  var EXTENSIONS = {
9448
9601
  "image/png": "png",
@@ -9455,7 +9608,7 @@ function createCodexImagePreparer(opts) {
9455
9608
  const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
9456
9609
  return async (urls, signal) => {
9457
9610
  signal.throwIfAborted();
9458
- const dir2 = await mkdtemp2(join12(tmpdir2(), `cabane-codex-images-${opts.turnId}-`));
9611
+ const dir2 = await mkdtemp2(join14(tmpdir2(), `cabane-codex-images-${opts.turnId}-`));
9459
9612
  const cleanup = async () => {
9460
9613
  try {
9461
9614
  await rm2(dir2, { recursive: true, force: true });
@@ -9476,7 +9629,7 @@ function createCodexImagePreparer(opts) {
9476
9629
  const ext = EXTENSIONS[type];
9477
9630
  if (!ext) throw new Error(`not a vision image type: ${type || "none"}`);
9478
9631
  const bytes = new Uint8Array(await res.arrayBuffer());
9479
- const path = join12(dir2, `image-${index + 1}.${ext}`);
9632
+ const path = join14(dir2, `image-${index + 1}.${ext}`);
9480
9633
  await writeFile2(path, bytes, { mode: 384 });
9481
9634
  return path;
9482
9635
  } catch (err) {
@@ -9510,14 +9663,14 @@ var SeqFloorUnavailable = class extends Error {
9510
9663
  }
9511
9664
  detail;
9512
9665
  };
9513
- function resolveSeqFloor(sources, ctx) {
9666
+ function resolveSeqFloor(sources, ctx2) {
9514
9667
  const { serverFloor, outboxFloor } = sources;
9515
9668
  if (serverFloor === void 0) {
9516
9669
  throw new SeqFloorUnavailable("server sent no committed floor for a resumed turn");
9517
9670
  }
9518
9671
  const floor = Math.max(serverFloor, outboxFloor);
9519
- ctx.log.debug(
9520
- { turnId: ctx.turnId, floor, outboxFloor, serverFloor },
9672
+ ctx2.log.debug(
9673
+ { turnId: ctx2.turnId, floor, outboxFloor, serverFloor },
9521
9674
  "companion: resumed turn \u2014 seq counter seeded above its committed high-water mark"
9522
9675
  );
9523
9676
  return floor;
@@ -9535,7 +9688,7 @@ function readOutboxFloor(read, turnId, log) {
9535
9688
  }
9536
9689
 
9537
9690
  // src/prepare-failure.ts
9538
- import { spawn as spawn5 } from "child_process";
9691
+ import { spawn as spawn4 } from "child_process";
9539
9692
  import { hostname } from "os";
9540
9693
  function prepareFailureCause(error) {
9541
9694
  const message2 = error instanceof Error ? error.message : String(error);
@@ -9573,7 +9726,7 @@ async function runPrepareWithReporting(runner, hook, input, context, reportError
9573
9726
  }
9574
9727
  function reportFailure(config, body) {
9575
9728
  return new Promise((resolve2, reject) => {
9576
- const child = spawn5(config.command, config.args ?? [], {
9729
+ const child = spawn4(config.command, config.args ?? [], {
9577
9730
  stdio: ["pipe", "ignore", "ignore"],
9578
9731
  env: { ...process.env, ...config.env }
9579
9732
  });
@@ -9597,20 +9750,20 @@ function reportFailure(config, body) {
9597
9750
  }
9598
9751
 
9599
9752
  // src/prepared.ts
9600
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
9601
- import { join as join13 } from "path";
9753
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync9 } from "fs";
9754
+ import { join as join15 } from "path";
9602
9755
  function dirFor(workspaceId) {
9603
- return join13(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9756
+ return join15(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9604
9757
  }
9605
9758
  function conversationDir(workspaceId, conversationId) {
9606
- return join13(dirFor(workspaceId), encodeURIComponent(conversationId));
9759
+ return join15(dirFor(workspaceId), encodeURIComponent(conversationId));
9607
9760
  }
9608
9761
  function pathFor2(workspaceId, conversationId, agentId) {
9609
- return join13(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9762
+ return join15(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9610
9763
  }
9611
9764
  function readPrepared(workspaceId, conversationId, agentId) {
9612
9765
  const path = pathFor2(workspaceId, conversationId, agentId);
9613
- if (!existsSync8(path)) return null;
9766
+ if (!existsSync9(path)) return null;
9614
9767
  try {
9615
9768
  const parsed = JSON.parse(readFileSync7(path, "utf8"));
9616
9769
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
@@ -9638,9 +9791,9 @@ function clearPrepared(workspaceId, conversationId, agentId) {
9638
9791
 
9639
9792
  // src/transcript-writer.ts
9640
9793
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
9641
- import { basename, dirname as dirname6, join as join14 } from "path";
9794
+ import { basename, dirname as dirname6, join as join16 } from "path";
9642
9795
  function transcriptsDir() {
9643
- return join14(runnerStateDir(), "transcripts");
9796
+ return join16(runnerStateDir(), "transcripts");
9644
9797
  }
9645
9798
  var RETAIN = 200;
9646
9799
  var ANOMALY_RETAIN = 50;
@@ -9650,7 +9803,7 @@ var TranscriptWriter = class {
9650
9803
  onWarn;
9651
9804
  constructor(dir2, meta, onWarn) {
9652
9805
  this.onWarn = onWarn;
9653
- this.path = join14(dir2, fileName(meta));
9806
+ this.path = join16(dir2, fileName(meta));
9654
9807
  try {
9655
9808
  mkdirSync8(dir2, { recursive: true });
9656
9809
  try {
@@ -9679,10 +9832,10 @@ var TranscriptWriter = class {
9679
9832
  preserveAnomaly() {
9680
9833
  if (this.broken) return;
9681
9834
  try {
9682
- const dir2 = join14(dirname6(this.path), "anomalies");
9835
+ const dir2 = join16(dirname6(this.path), "anomalies");
9683
9836
  mkdirSync8(dir2, { recursive: true, mode: 448 });
9684
9837
  chmodSync4(dir2, 448);
9685
- const target = join14(dir2, basename(this.path));
9838
+ const target = join16(dir2, basename(this.path));
9686
9839
  copyFileSync(this.path, target);
9687
9840
  chmodSync4(target, 384);
9688
9841
  pruneOld(dir2, ANOMALY_RETAIN);
@@ -9707,14 +9860,14 @@ var TranscriptWriter = class {
9707
9860
  function fileName(meta) {
9708
9861
  const d = new Date(meta.ts);
9709
9862
  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
9710
- const slug = safe2(meta.workspaceSlug) || "ws";
9863
+ const slug = safe(meta.workspaceSlug) || "ws";
9711
9864
  const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
9712
9865
  return `${stamp}-${slug}-${short}.jsonl`;
9713
9866
  }
9714
9867
  function pad(n) {
9715
9868
  return String(n).padStart(2, "0");
9716
9869
  }
9717
- function safe2(s) {
9870
+ function safe(s) {
9718
9871
  return s.replace(/[^a-zA-Z0-9_-]/g, "-");
9719
9872
  }
9720
9873
  function pruneOld(dir2, retain) {
@@ -9728,7 +9881,7 @@ function pruneOld(dir2, retain) {
9728
9881
  const drop = files.sort().slice(0, files.length - retain);
9729
9882
  for (const f of drop) {
9730
9883
  try {
9731
- rmSync6(join14(dir2, f), { force: true });
9884
+ rmSync6(join16(dir2, f), { force: true });
9732
9885
  } catch {
9733
9886
  }
9734
9887
  }
@@ -9867,32 +10020,32 @@ var TurnCommitter = class {
9867
10020
  function bundledHarnessFor(runtime) {
9868
10021
  return runtime === "claude-code" || runtime === "codex" ? runtime : null;
9869
10022
  }
9870
- function resolve(ctx, harness) {
9871
- return (ctx.resolveBundled ?? resolveBundledBinary)(harness);
10023
+ function resolve(ctx2, harness) {
10024
+ return (ctx2.resolveBundled ?? resolveBundledBinary)(harness);
9872
10025
  }
9873
- async function postIncompleteNotice(ctx, harness, resolution) {
10026
+ async function postIncompleteNotice(ctx2, harness, resolution) {
9874
10027
  try {
9875
- await ctx.postTurnMessage(ctx.workspaceId, ctx.conversationId, {
10028
+ await ctx2.postTurnMessage(ctx2.workspaceId, ctx2.conversationId, {
9876
10029
  body: turnIncompleteCopy(harness),
9877
10030
  kind: "runtime_notice",
9878
- turnId: ctx.turnId,
9879
- parentMessageId: ctx.parentMessageId
10031
+ turnId: ctx2.turnId,
10032
+ parentMessageId: ctx2.parentMessageId
9880
10033
  });
9881
10034
  return true;
9882
10035
  } catch (err) {
9883
- ctx.log.warn(
10036
+ ctx2.log.warn(
9884
10037
  { ...apiErrorLogFields(err) },
9885
10038
  "Couldn't save the explanation to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9886
10039
  );
9887
10040
  return false;
9888
10041
  }
9889
10042
  }
9890
- async function checkBundledBinary(ctx) {
9891
- const harness = bundledHarnessFor(ctx.runtime);
10043
+ async function checkBundledBinary(ctx2) {
10044
+ const harness = bundledHarnessFor(ctx2.runtime);
9892
10045
  if (!harness) return null;
9893
- const resolution = resolve(ctx, harness);
10046
+ const resolution = resolve(ctx2, harness);
9894
10047
  if (resolution.status === "present") return null;
9895
- ctx.log.debug(
10048
+ ctx2.log.debug(
9896
10049
  {
9897
10050
  runtime: harness,
9898
10051
  status: resolution.status,
@@ -9904,15 +10057,15 @@ async function checkBundledBinary(ctx) {
9904
10057
  return {
9905
10058
  harness,
9906
10059
  reason: `runtime_incomplete:${harness}`,
9907
- posted: await postIncompleteNotice(ctx, harness, resolution)
10060
+ posted: await postIncompleteNotice(ctx2, harness, resolution)
9908
10061
  };
9909
10062
  }
9910
- async function reclassifyThrow(ctx, opts) {
9911
- const harness = bundledHarnessFor(ctx.runtime);
10063
+ async function reclassifyThrow(ctx2, opts) {
10064
+ const harness = bundledHarnessFor(ctx2.runtime);
9912
10065
  if (!harness || opts.aborted) return null;
9913
- const resolution = resolve(ctx, harness);
10066
+ const resolution = resolve(ctx2, harness);
9914
10067
  if (resolution.status === "present") return null;
9915
- ctx.log.debug(
10068
+ ctx2.log.debug(
9916
10069
  {
9917
10070
  runtime: harness,
9918
10071
  status: resolution.status,
@@ -9925,17 +10078,17 @@ async function reclassifyThrow(ctx, opts) {
9925
10078
  return {
9926
10079
  harness,
9927
10080
  reason: `runtime_incomplete:${harness}`,
9928
- posted: await postIncompleteNotice(ctx, harness, resolution)
10081
+ posted: await postIncompleteNotice(ctx2, harness, resolution)
9929
10082
  };
9930
10083
  }
9931
- async function guardBundledBinary(ctx, concluded, outcome) {
9932
- const incomplete = await checkBundledBinary(ctx);
10084
+ async function guardBundledBinary(ctx2, concluded, outcome) {
10085
+ const incomplete = await checkBundledBinary(ctx2);
9933
10086
  if (!incomplete) return;
9934
10087
  outcome.runtimeIncomplete = incomplete.posted;
9935
10088
  throw incomplete.posted ? concluded(incomplete.reason) : concluded(incomplete.reason, incomplete.reason);
9936
10089
  }
9937
- async function absorbBundledLoss(ctx, aborted, outcome) {
9938
- const lost = await reclassifyThrow(ctx, { aborted, originalReason: outcome.resultReason });
10090
+ async function absorbBundledLoss(ctx2, aborted, outcome) {
10091
+ const lost = await reclassifyThrow(ctx2, { aborted, originalReason: outcome.resultReason });
9939
10092
  if (!lost) return false;
9940
10093
  outcome.resultReason = lost.reason;
9941
10094
  return lost.posted;
@@ -10330,7 +10483,7 @@ var TurnExecution = class {
10330
10483
  const prepareHook = this.opts.local.prepareHook;
10331
10484
  const cabaneCwd = this.turnContext.cwd;
10332
10485
  let effectiveCwd = localCwd ?? cabaneCwd;
10333
- if (effectiveCwd && !existsSync9(effectiveCwd)) {
10486
+ if (effectiveCwd && !existsSync10(effectiveCwd)) {
10334
10487
  turnLog.warn(
10335
10488
  { cwd: effectiveCwd },
10336
10489
  "The working directory is missing; using the companion's directory instead. Check this agent's working directory setting."
@@ -11098,7 +11251,7 @@ function fingerprintSessionState(state) {
11098
11251
  // src/dispatcher.ts
11099
11252
  function checkoutState(cwd) {
11100
11253
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
11101
- if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
11254
+ if (!existsSync11(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
11102
11255
  let entries;
11103
11256
  try {
11104
11257
  entries = readdirSync2(cwd);
@@ -11108,15 +11261,15 @@ function checkoutState(cwd) {
11108
11261
  if (entries.length === 0) {
11109
11262
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
11110
11263
  }
11111
- const gitPath = join15(cwd, ".git");
11112
- if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
11264
+ const gitPath = join17(cwd, ".git");
11265
+ if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
11113
11266
  let stat;
11114
11267
  try {
11115
- stat = statSync2(gitPath);
11268
+ stat = statSync3(gitPath);
11116
11269
  } catch (error) {
11117
11270
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
11118
11271
  }
11119
- if (stat.isDirectory() && !existsSync10(join15(gitPath, "HEAD")))
11272
+ if (stat.isDirectory() && !existsSync11(join17(gitPath, "HEAD")))
11120
11273
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
11121
11274
  return { ok: true, reason: "usable" };
11122
11275
  }
@@ -11229,18 +11382,18 @@ var Dispatcher = class {
11229
11382
  };
11230
11383
 
11231
11384
  // src/providers/local-process.ts
11232
- import { spawn as spawn6 } from "child_process";
11233
- import { constants } from "os";
11385
+ import { spawn as spawn5 } from "child_process";
11386
+ import { constants as constants2 } from "os";
11234
11387
  var DEFAULT_KILL_GRACE_MS = 3e4;
11235
11388
  var DEFAULT_REAP_GRACE_MS = 3e3;
11236
11389
  function exitCode(code, signal) {
11237
11390
  if (code !== null) return code;
11238
- const signum = signal ? constants.signals[signal] : void 0;
11391
+ const signum = signal ? constants2.signals[signal] : void 0;
11239
11392
  return signum !== void 0 ? 128 + signum : 1;
11240
11393
  }
11241
11394
  function createLocalProcessProvider(opts = {}) {
11242
11395
  const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
11243
- const spawnFn = opts.spawnFn ?? spawn6;
11396
+ const spawnFn = opts.spawnFn ?? spawn5;
11244
11397
  const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
11245
11398
  const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
11246
11399
  const groupAlive = (pgid) => {
@@ -11321,24 +11474,24 @@ function createLocalProcessProvider(opts = {}) {
11321
11474
 
11322
11475
  // src/providers/docker.ts
11323
11476
  import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
11324
- import { constants as constants2, tmpdir as tmpdir3 } from "os";
11477
+ import { constants as constants3, tmpdir as tmpdir3 } from "os";
11325
11478
  import {
11326
11479
  createReadStream,
11327
11480
  createWriteStream as createWriteStream2,
11328
- existsSync as existsSync13,
11481
+ existsSync as existsSync14,
11329
11482
  mkdirSync as mkdirSync10,
11330
11483
  readdirSync as readdirSync4,
11331
11484
  readFileSync as readFileSync9,
11332
- renameSync as renameSync5,
11485
+ renameSync as renameSync6,
11333
11486
  rmSync as rmSync9,
11334
- statSync as statSync3,
11487
+ statSync as statSync4,
11335
11488
  writeFileSync as writeFileSync6
11336
11489
  } from "fs";
11337
- import { join as join18 } from "path";
11490
+ import { join as join20 } from "path";
11338
11491
  import { Writable } from "stream";
11339
11492
 
11340
11493
  // src/providers/docker-cli.ts
11341
- import { spawn as spawn7 } from "child_process";
11494
+ import { spawn as spawn6 } from "child_process";
11342
11495
  function cliEnv(extra) {
11343
11496
  const keep = [
11344
11497
  "PATH",
@@ -11358,7 +11511,7 @@ function dockerCli(bin = "docker") {
11358
11511
  return new Promise((resolve2) => {
11359
11512
  let child;
11360
11513
  try {
11361
- child = spawn7(bin, [...args], {
11514
+ child = spawn6(bin, [...args], {
11362
11515
  env: cliEnv(opts.env),
11363
11516
  stdio: ["pipe", "pipe", "pipe"]
11364
11517
  });
@@ -11389,13 +11542,13 @@ function dockerCli(bin = "docker") {
11389
11542
  });
11390
11543
  },
11391
11544
  spawn(args, env) {
11392
- return spawn7(bin, [...args], { env: cliEnv(env), stdio: ["pipe", "pipe", "pipe"] });
11545
+ return spawn6(bin, [...args], { env: cliEnv(env), stdio: ["pipe", "pipe", "pipe"] });
11393
11546
  }
11394
11547
  };
11395
11548
  }
11396
11549
 
11397
11550
  // src/runner/git-credential.ts
11398
- import { spawn as spawn8 } from "child_process";
11551
+ import { spawn as spawn7 } from "child_process";
11399
11552
  import { chmodSync as chmodSync5, rmSync as rmSync7 } from "fs";
11400
11553
  import { connect as connect3, createServer as createServer3 } from "net";
11401
11554
 
@@ -11437,7 +11590,7 @@ function deviceGitFill(helper, timeoutMs = 15e3) {
11437
11590
  "credential",
11438
11591
  "fill"
11439
11592
  ];
11440
- const child = spawn8("git", args, {
11593
+ const child = spawn7("git", args, {
11441
11594
  stdio: ["pipe", "pipe", "ignore"],
11442
11595
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" }
11443
11596
  });
@@ -11521,14 +11674,14 @@ async function serveGitCredentials(opts) {
11521
11674
  // src/runner/collected-outbox.ts
11522
11675
  import {
11523
11676
  copyFileSync as copyFileSync2,
11524
- existsSync as existsSync11,
11677
+ existsSync as existsSync12,
11525
11678
  mkdirSync as mkdirSync9,
11526
11679
  readdirSync as readdirSync3,
11527
11680
  readFileSync as readFileSync8,
11528
- renameSync as renameSync4,
11681
+ renameSync as renameSync5,
11529
11682
  rmSync as rmSync8
11530
11683
  } from "fs";
11531
- import { join as join16 } from "path";
11684
+ import { join as join18 } from "path";
11532
11685
  function routes(t) {
11533
11686
  const conv = `/api/workspaces/${t.workspaceId}/conversations/${t.conversationId}`;
11534
11687
  const agent = `${conv}/participants/agents/${t.agentId}`;
@@ -11561,20 +11714,20 @@ function admitCollectedEntry(raw, turn) {
11561
11714
  }
11562
11715
  var atomicMove = (from, to) => {
11563
11716
  try {
11564
- renameSync4(from, to);
11717
+ renameSync5(from, to);
11565
11718
  } catch {
11566
11719
  const partial = `${to}.partial`;
11567
11720
  copyFileSync2(from, partial);
11568
- renameSync4(partial, to);
11721
+ renameSync5(partial, to);
11569
11722
  rmSync8(from, { force: true });
11570
11723
  }
11571
11724
  };
11572
11725
  function publishStaged(from, to, move = atomicMove) {
11573
- if (!existsSync11(from)) return { published: 0, complete: true };
11726
+ if (!existsSync12(from)) return { published: 0, complete: true };
11574
11727
  const staged = readdirSync3(from).filter((n) => n.endsWith(".json")).map((name) => {
11575
11728
  let entry = {};
11576
11729
  try {
11577
- entry = JSON.parse(readFileSync8(join16(from, name), "utf8"));
11730
+ entry = JSON.parse(readFileSync8(join18(from, name), "utf8"));
11578
11731
  } catch {
11579
11732
  }
11580
11733
  return { name, entry };
@@ -11587,7 +11740,7 @@ function publishStaged(from, to, move = atomicMove) {
11587
11740
  let published = 0;
11588
11741
  for (const { name } of staged) {
11589
11742
  try {
11590
- move(join16(from, name), join16(to, name));
11743
+ move(join18(from, name), join18(to, name));
11591
11744
  } catch {
11592
11745
  return { published, complete: false };
11593
11746
  }
@@ -11599,9 +11752,9 @@ function publishStaged(from, to, move = atomicMove) {
11599
11752
 
11600
11753
  // src/providers/runner-base.ts
11601
11754
  import { createHash as createHash3 } from "crypto";
11602
- import { existsSync as existsSync12, realpathSync as realpathSync2 } from "fs";
11603
- import { dirname as dirname7, join as join17, sep as sep2 } from "path";
11604
- import { fileURLToPath as fileURLToPath3 } from "url";
11755
+ import { existsSync as existsSync13, realpathSync as realpathSync3 } from "fs";
11756
+ import { dirname as dirname7, join as join19, sep as sep3 } from "path";
11757
+ import { fileURLToPath as fileURLToPath4 } from "url";
11605
11758
  var COMPANION_MOUNT = "/opt/cabane/companion";
11606
11759
  var BASE_DOCKERFILE = `FROM node:22-slim
11607
11760
  RUN apt-get update \\
@@ -11645,17 +11798,17 @@ async function ensureBaseImage(docker, image, log) {
11645
11798
  }
11646
11799
  return true;
11647
11800
  }
11648
- function ownInstall(moduleFile = fileURLToPath3(import.meta.url)) {
11801
+ function ownInstall(moduleFile = fileURLToPath4(import.meta.url)) {
11649
11802
  let dir2 = dirname7(moduleFile);
11650
11803
  for (let i = 0; i < 4; i++) {
11651
- if (existsSync12(join17(dir2, "dist", "cli.js")) && existsSync12(join17(dir2, "package.json"))) break;
11804
+ if (existsSync13(join19(dir2, "dist", "cli.js")) && existsSync13(join19(dir2, "package.json"))) break;
11652
11805
  dir2 = dirname7(dir2);
11653
11806
  }
11654
- if (!existsSync12(join17(dir2, "dist", "cli.js"))) return null;
11655
- const sdk = join17(dir2, "node_modules", "@anthropic-ai", "claude-agent-sdk");
11656
- if (!existsSync12(sdk)) return null;
11657
- const root = realpathSync2(dir2);
11658
- return realpathSync2(sdk).startsWith(root + sep2) ? root : null;
11807
+ if (!existsSync13(join19(dir2, "dist", "cli.js"))) return null;
11808
+ const sdk = join19(dir2, "node_modules", "@anthropic-ai", "claude-agent-sdk");
11809
+ if (!existsSync13(sdk)) return null;
11810
+ const root = realpathSync3(dir2);
11811
+ return realpathSync3(sdk).startsWith(root + sep3) ? root : null;
11659
11812
  }
11660
11813
  function companionSource(input) {
11661
11814
  if (input.configured) {
@@ -11791,23 +11944,42 @@ function networkCreateError(stderr) {
11791
11944
  }
11792
11945
  function sessionArchivePath(deviceDir, attemptId) {
11793
11946
  if (!UUID_RE.test(attemptId)) throw new Error(`docker: not an attempt id: ${attemptId}`);
11794
- return join18(deviceDir, "attempts", attemptId, "session.tar");
11947
+ return join20(deviceDir, "attempts", attemptId, "session.tar");
11795
11948
  }
11796
11949
  function pruneOtherAttempts(deviceDir, pair, keep) {
11797
- const root = join18(deviceDir, "attempts");
11798
- if (!existsSync13(root)) return;
11950
+ const root = join20(deviceDir, "attempts");
11951
+ if (!existsSync14(root)) return;
11799
11952
  for (const id of readdirSync4(root)) {
11800
11953
  if (id === keep || !UUID_RE.test(id)) continue;
11801
11954
  let pairOf = "";
11802
11955
  try {
11803
- pairOf = readFileSync9(join18(root, id, "pair"), "utf8");
11956
+ pairOf = readFileSync9(join20(root, id, "pair"), "utf8");
11804
11957
  } catch {
11805
11958
  continue;
11806
11959
  }
11807
11960
  if (pairOf === `${pair.conversationId}/${pair.agentId}`) {
11808
- rmSync9(join18(root, id), { recursive: true, force: true });
11961
+ rmSync9(join20(root, id), { recursive: true, force: true });
11962
+ }
11963
+ }
11964
+ }
11965
+ var IDLE_SESSION_ARCHIVE_MS = 30 * 24 * 60 * 6e4;
11966
+ function pruneIdleSessionArchives(deviceDir, now = Date.now(), maxAgeMs = IDLE_SESSION_ARCHIVE_MS) {
11967
+ const root = join20(deviceDir, "attempts");
11968
+ if (!existsSync14(root)) return [];
11969
+ const removed = [];
11970
+ for (const id of readdirSync4(root)) {
11971
+ if (!UUID_RE.test(id)) continue;
11972
+ let kept;
11973
+ try {
11974
+ kept = statSync4(join20(root, id)).mtimeMs;
11975
+ } catch {
11976
+ continue;
11809
11977
  }
11978
+ if (now - kept < maxAgeMs) continue;
11979
+ rmSync9(join20(root, id), { recursive: true, force: true });
11980
+ removed.push(id);
11810
11981
  }
11982
+ return removed;
11811
11983
  }
11812
11984
  var CappedFile = class extends Writable {
11813
11985
  constructor(path, cap) {
@@ -12005,15 +12177,15 @@ function createDockerProvider(opts) {
12005
12177
  await must(["network", "disconnect", "-f", other, name], "moving the container");
12006
12178
  if (others.includes(sharedNetwork)) await retireSharedNetwork();
12007
12179
  };
12008
- const socketRoot = opts.socketRoot ?? join18(
12009
- process.env.XDG_RUNTIME_DIR && existsSync13(process.env.XDG_RUNTIME_DIR) ? process.env.XDG_RUNTIME_DIR : tmpdir3(),
12180
+ const socketRoot = opts.socketRoot ?? join20(
12181
+ process.env.XDG_RUNTIME_DIR && existsSync14(process.env.XDG_RUNTIME_DIR) ? process.env.XDG_RUNTIME_DIR : tmpdir3(),
12010
12182
  `cabane-runners-${opts.deviceKey}`
12011
12183
  );
12012
- const socketDir = (name) => join18(socketRoot, createHash4("sha256").update(name).digest("hex").slice(0, 12));
12184
+ const socketDir = (name) => join20(socketRoot, createHash4("sha256").update(name).digest("hex").slice(0, 12));
12013
12185
  const create = async (name, attempt) => {
12014
12186
  mkdirSync10(socketRoot, { recursive: true, mode: 448 });
12015
12187
  mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
12016
- const mounts = (opts.mounts ?? []).filter((m) => m.source.startsWith("/") && existsSync13(m.source)).flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target},readonly`]);
12188
+ const mounts = (opts.mounts ?? []).filter((m) => m.source.startsWith("/") && existsSync14(m.source)).flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target},readonly`]);
12017
12189
  await must(
12018
12190
  [
12019
12191
  "create",
@@ -12151,7 +12323,7 @@ function createDockerProvider(opts) {
12151
12323
  }
12152
12324
  };
12153
12325
  const removeIn = (name, from, names) => names.length ? execIn(name, ["sh", "-c", 'cd "$1" && shift && rm -f -- "$@"', "sh", from, ...names]) : Promise.resolve(null);
12154
- const pendingPath = (name) => join18(opts.deviceDir, "runners-pending", `${name}.json`);
12326
+ const pendingPath = (name) => join20(opts.deviceDir, "runners-pending", `${name}.json`);
12155
12327
  const readPending = (name) => {
12156
12328
  try {
12157
12329
  const ids = JSON.parse(readFileSync9(pendingPath(name), "utf8"));
@@ -12165,12 +12337,12 @@ function createDockerProvider(opts) {
12165
12337
  rmSync9(pendingPath(name), { force: true });
12166
12338
  return;
12167
12339
  }
12168
- mkdirSync10(join18(opts.deviceDir, "runners-pending"), { recursive: true });
12340
+ mkdirSync10(join20(opts.deviceDir, "runners-pending"), { recursive: true });
12169
12341
  writeFileSync6(pendingPath(name), JSON.stringify([...new Set(ids)]));
12170
12342
  };
12171
12343
  const keepSession = async (name, attempt, attemptId) => {
12172
12344
  const archive = sessionArchivePath(opts.deviceDir, attemptId);
12173
- mkdirSync10(join18(archive, ".."), { recursive: true });
12345
+ mkdirSync10(join20(archive, ".."), { recursive: true });
12174
12346
  const partial = `${archive}.${process.pid}.partial`;
12175
12347
  const file = new CappedFile(partial, sessionCap);
12176
12348
  const r = await execIn(
@@ -12186,8 +12358,8 @@ function createDockerProvider(opts) {
12186
12358
  (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
12187
12359
  );
12188
12360
  if (r.code === 0 && !file.over) {
12189
- renameSync5(partial, archive);
12190
- writeFileSync6(join18(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
12361
+ renameSync6(partial, archive);
12362
+ writeFileSync6(join20(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
12191
12363
  pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
12192
12364
  await writeMarker(name, attemptId);
12193
12365
  log.debug({ container: name, attemptId, bytes: file.bytes }, "runners: session files kept");
@@ -12201,7 +12373,7 @@ function createDockerProvider(opts) {
12201
12373
  file.over ? "runners: the session files are over the size cap and weren't kept; the next new container starts the session over" : "runners: couldn't keep the session files; the next new container starts the session over"
12202
12374
  );
12203
12375
  };
12204
- const stagingDir = (name) => join18(opts.deviceDir, "runners-pending", `${name}.outbox`);
12376
+ const stagingDir = (name) => join20(opts.deviceDir, "runners-pending", `${name}.outbox`);
12205
12377
  const ensureRunning = async (name) => {
12206
12378
  const seen = await docker.run(["inspect", "-f", "{{.State.Running}}", name]);
12207
12379
  if (seen.code !== 0) return false;
@@ -12209,7 +12381,7 @@ function createDockerProvider(opts) {
12209
12381
  return (await docker.run(["start", name])).code === 0;
12210
12382
  };
12211
12383
  const dropArchive = (attemptId) => {
12212
- rmSync9(join18(sessionArchivePath(opts.deviceDir, attemptId), ".."), {
12384
+ rmSync9(join20(sessionArchivePath(opts.deviceDir, attemptId), ".."), {
12213
12385
  recursive: true,
12214
12386
  force: true
12215
12387
  });
@@ -12278,7 +12450,7 @@ function createDockerProvider(opts) {
12278
12450
  if (seen.stdout.trim().split(" ")[0] !== "true")
12279
12451
  await must(["start", name], "starting the container");
12280
12452
  const git = await serveGitCredentials({
12281
- socketPath: join18(socketDir(name), "git.sock"),
12453
+ socketPath: join20(socketDir(name), "git.sock"),
12282
12454
  hosts: opts.gitHosts,
12283
12455
  fill: opts.gitFill,
12284
12456
  log
@@ -12312,7 +12484,7 @@ function createDockerProvider(opts) {
12312
12484
  }
12313
12485
  if (attemptId && UUID_RE.test(attemptId)) {
12314
12486
  const archive = sessionArchivePath(opts.deviceDir, attemptId);
12315
- if (existsSync13(archive)) {
12487
+ if (existsSync14(archive)) {
12316
12488
  const r = await execIn(name, ["tar", "-x", "-C", CONTAINER_HOME, "-f", "-"], {
12317
12489
  input: createReadStream(archive)
12318
12490
  });
@@ -12323,7 +12495,7 @@ function createDockerProvider(opts) {
12323
12495
  );
12324
12496
  } else {
12325
12497
  log.info(
12326
- { container: name, attemptId, bytes: statSync3(archive).size },
12498
+ { container: name, attemptId, bytes: statSync4(archive).size },
12327
12499
  "runners: session files restored"
12328
12500
  );
12329
12501
  }
@@ -12378,7 +12550,7 @@ function createDockerProvider(opts) {
12378
12550
  };
12379
12551
  child.once("error", () => done(1));
12380
12552
  child.once("close", (code, sig) => {
12381
- const signum = sig ? constants2.signals[sig] : void 0;
12553
+ const signum = sig ? constants3.signals[sig] : void 0;
12382
12554
  done(code ?? (signum !== void 0 ? 128 + signum : 1));
12383
12555
  });
12384
12556
  if (signal.aborted) stop();
@@ -12427,15 +12599,15 @@ function createDockerProvider(opts) {
12427
12599
  const transcripts = await readDir(name, `${CONTAINER_STATE}/transcripts`);
12428
12600
  if (transcripts) {
12429
12601
  const kept = transcripts.files.filter((f) => SAFE_NAME_RE.test(f.name));
12430
- if (kept.length) mkdirSync10(join18(opts.hostStateDir, "transcripts"), { recursive: true });
12431
- for (const f of kept) writeFileSync6(join18(opts.hostStateDir, "transcripts", f.name), f.data);
12602
+ if (kept.length) mkdirSync10(join20(opts.hostStateDir, "transcripts"), { recursive: true });
12603
+ for (const f of kept) writeFileSync6(join20(opts.hostStateDir, "transcripts", f.name), f.data);
12432
12604
  await removeIn(
12433
12605
  name,
12434
12606
  `${CONTAINER_STATE}/transcripts`,
12435
12607
  kept.map((f) => f.name)
12436
12608
  );
12437
12609
  }
12438
- const outboxRel = join18("outbox", encodeURIComponent(attempt.agentId));
12610
+ const outboxRel = join20("outbox", encodeURIComponent(attempt.agentId));
12439
12611
  const outbox = await readDir(name, `${CONTAINER_STATE}/${outboxRel}`);
12440
12612
  let unpublished = false;
12441
12613
  if (outbox) {
@@ -12461,7 +12633,7 @@ function createDockerProvider(opts) {
12461
12633
  const staged = stagingDir(name);
12462
12634
  mkdirSync10(staged, { recursive: true });
12463
12635
  writeFileSync6(
12464
- join18(staged, `${encodeURIComponent(entry.turnId)}__${entry.seq}.json`),
12636
+ join20(staged, `${encodeURIComponent(entry.turnId)}__${entry.seq}.json`),
12465
12637
  `${JSON.stringify(entry)}
12466
12638
  `
12467
12639
  );
@@ -12474,7 +12646,7 @@ function createDockerProvider(opts) {
12474
12646
  if (outbox.complete) {
12475
12647
  const published = publishStaged(
12476
12648
  stagingDir(name),
12477
- join18(opts.hostStateDir, outboxRel),
12649
+ join20(opts.hostStateDir, outboxRel),
12478
12650
  opts.moveFile
12479
12651
  );
12480
12652
  queued = published.published;
@@ -12683,15 +12855,15 @@ import {
12683
12855
  mkdirSync as mkdirSync11,
12684
12856
  mkdtempSync,
12685
12857
  readFileSync as readFileSync10,
12686
- realpathSync as realpathSync3,
12858
+ realpathSync as realpathSync4,
12687
12859
  rmSync as rmSync10,
12688
12860
  writeFileSync as writeFileSync7
12689
12861
  } from "fs";
12690
12862
  import { createRequire as createRequire2 } from "module";
12691
12863
  import { isIP } from "net";
12692
- import { homedir as homedir4, networkInterfaces, tmpdir as tmpdir4, userInfo as userInfo2 } from "os";
12693
- import { dirname as dirname8, join as join19, sep as sep3 } from "path";
12694
- import { fileURLToPath as fileURLToPath4 } from "url";
12864
+ import { homedir as homedir6, networkInterfaces, tmpdir as tmpdir4, userInfo as userInfo2 } from "os";
12865
+ import { dirname as dirname8, join as join21, sep as sep4 } from "path";
12866
+ import { fileURLToPath as fileURLToPath5 } from "url";
12695
12867
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
12696
12868
  function accountHome() {
12697
12869
  try {
@@ -12787,13 +12959,13 @@ function companionStoreWatch(log) {
12787
12959
  return new CompanionStoreWatch({
12788
12960
  log,
12789
12961
  roots: () => {
12790
- const home = homedir4();
12962
+ const home = homedir6();
12791
12963
  return storeWalkRoots([.../* @__PURE__ */ new Set([home, accountHome() ?? home])], cabaneDir());
12792
12964
  }
12793
12965
  });
12794
12966
  }
12795
12967
  function isAtOrUnder(path, dir2) {
12796
- return path === dir2 || path.startsWith(dir2 + sep3);
12968
+ return path === dir2 || path.startsWith(dir2 + sep4);
12797
12969
  }
12798
12970
  function osSandboxUsable(log) {
12799
12971
  if (!SandboxManager.isSupportedPlatform()) {
@@ -12892,15 +13064,15 @@ var DispatchClaims = class {
12892
13064
 
12893
13065
  // src/outbox.ts
12894
13066
  import {
12895
- existsSync as existsSync14,
13067
+ existsSync as existsSync15,
12896
13068
  mkdirSync as mkdirSync12,
12897
13069
  readdirSync as readdirSync5,
12898
13070
  readFileSync as readFileSync11,
12899
- renameSync as renameSync6,
13071
+ renameSync as renameSync7,
12900
13072
  rmSync as rmSync11,
12901
13073
  writeFileSync as writeFileSync8
12902
13074
  } from "fs";
12903
- import { join as join20 } from "path";
13075
+ import { join as join22 } from "path";
12904
13076
  var MAX_ENTRIES = 2e3;
12905
13077
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
12906
13078
  var Outbox = class {
@@ -12913,10 +13085,10 @@ var Outbox = class {
12913
13085
  // Resolved lazily (per call) so tests that swap HOME between
12914
13086
  // cases route writes at the right tmpdir.
12915
13087
  dir() {
12916
- return join20(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
13088
+ return join22(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
12917
13089
  }
12918
13090
  fileFor(turnId, seq) {
12919
- return join20(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
13091
+ return join22(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
12920
13092
  }
12921
13093
  // Persist a commit for later draining. Atomic (temp file + rename) so a
12922
13094
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -12928,7 +13100,7 @@ var Outbox = class {
12928
13100
  const tmp = `${target}.${process.pid}.tmp`;
12929
13101
  try {
12930
13102
  writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
12931
- renameSync6(tmp, target);
13103
+ renameSync7(tmp, target);
12932
13104
  } catch (err) {
12933
13105
  try {
12934
13106
  rmSync11(tmp, { force: true });
@@ -12948,7 +13120,7 @@ var Outbox = class {
12948
13120
  // wedging the drain.
12949
13121
  list() {
12950
13122
  const dir2 = this.dir();
12951
- if (!existsSync14(dir2)) return [];
13123
+ if (!existsSync15(dir2)) return [];
12952
13124
  let names;
12953
13125
  try {
12954
13126
  names = readdirSync5(dir2);
@@ -12958,7 +13130,7 @@ var Outbox = class {
12958
13130
  const entries = [];
12959
13131
  for (const name of names) {
12960
13132
  if (!name.endsWith(".json")) continue;
12961
- const full = join20(dir2, name);
13133
+ const full = join22(dir2, name);
12962
13134
  try {
12963
13135
  const parsed = JSON.parse(readFileSync11(full, "utf8"));
12964
13136
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -12994,7 +13166,7 @@ var Outbox = class {
12994
13166
  // different: it is positive evidence that nothing was ever queued here.
12995
13167
  maxSeqForTurn(turnId) {
12996
13168
  const dir2 = this.dir();
12997
- if (!existsSync14(dir2)) return 0;
13169
+ if (!existsSync15(dir2)) return 0;
12998
13170
  const names = readdirSync5(dir2);
12999
13171
  const prefix = `${encodeURIComponent(turnId)}__`;
13000
13172
  let max = 0;
@@ -13014,7 +13186,7 @@ var Outbox = class {
13014
13186
  }
13015
13187
  size() {
13016
13188
  const dir2 = this.dir();
13017
- if (!existsSync14(dir2)) return 0;
13189
+ if (!existsSync15(dir2)) return 0;
13018
13190
  try {
13019
13191
  return readdirSync5(dir2).filter((n) => n.endsWith(".json")).length;
13020
13192
  } catch {
@@ -13083,6 +13255,12 @@ var SseSubscriber = class {
13083
13255
  this.aborted = true;
13084
13256
  this.controller?.abort();
13085
13257
  }
13258
+ // Connect again after a `stop()`, once its loop has exited.
13259
+ async resume() {
13260
+ await this.finished;
13261
+ this.aborted = false;
13262
+ this.start();
13263
+ }
13086
13264
  setCursor(eventId) {
13087
13265
  this.lastEventId = eventId;
13088
13266
  }
@@ -13169,6 +13347,550 @@ function sleep3(ms) {
13169
13347
  return new Promise((resolve2) => setTimeout(resolve2, ms));
13170
13348
  }
13171
13349
 
13350
+ // src/updater.ts
13351
+ import { spawn as spawn8 } from "child_process";
13352
+ import { copyFileSync as copyFileSync3, existsSync as existsSync17, mkdirSync as mkdirSync16, renameSync as renameSync11, rmSync as rmSync14 } from "fs";
13353
+ import { dirname as dirname12, join as join26 } from "path";
13354
+
13355
+ // src/boot-rule.ts
13356
+ import {
13357
+ mkdirSync as mkdirSync13,
13358
+ readFileSync as readFileSync12,
13359
+ realpathSync as realpathSync5,
13360
+ renameSync as renameSync8,
13361
+ rmSync as rmSync12,
13362
+ symlinkSync,
13363
+ writeFileSync as writeFileSync9
13364
+ } from "fs";
13365
+ import { dirname as dirname9, join as join23 } from "path";
13366
+ var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
13367
+ var MAX_STAGE_ATTEMPTS = 3;
13368
+ function superseded(pending, ...running) {
13369
+ return !!pending && !running.includes(pending.to);
13370
+ }
13371
+ function rolledBackState(state, pending, reason, now) {
13372
+ const { pending: _done, ...rest } = state;
13373
+ return {
13374
+ ...rest,
13375
+ lastResult: {
13376
+ kind: "rolled_back",
13377
+ from: pending.from,
13378
+ to: pending.to,
13379
+ at: now.toISOString(),
13380
+ reason: reason.replace(/\s+/g, " ").trim().slice(0, 400)
13381
+ },
13382
+ failed: { version: pending.to, attempts: MAX_STAGE_ATTEMPTS, at: now.toISOString() }
13383
+ };
13384
+ }
13385
+
13386
+ // src/install-layout.ts
13387
+ import {
13388
+ existsSync as existsSync16,
13389
+ lstatSync,
13390
+ mkdirSync as mkdirSync14,
13391
+ readFileSync as readFileSync13,
13392
+ readdirSync as readdirSync6,
13393
+ realpathSync as realpathSync6,
13394
+ renameSync as renameSync9,
13395
+ rmSync as rmSync13,
13396
+ symlinkSync as symlinkSync2
13397
+ } from "fs";
13398
+ import { dirname as dirname10, join as join24 } from "path";
13399
+ import { fileURLToPath as fileURLToPath6 } from "url";
13400
+ function versionsDir() {
13401
+ return join24(cabaneDir(), "versions");
13402
+ }
13403
+ function currentLink() {
13404
+ return join24(cabaneDir(), "current");
13405
+ }
13406
+ function bootGuardPath() {
13407
+ return join24(cabaneDir(), "boot-guard.mjs");
13408
+ }
13409
+ function versionPackageRoot(version) {
13410
+ return join24(versionsDir(), version, "node_modules", "@cabane", "companion");
13411
+ }
13412
+ function ownPackageRoot(moduleUrl = import.meta.url) {
13413
+ return dirname10(dirname10(fileURLToPath6(moduleUrl)));
13414
+ }
13415
+ function packageVersion(root) {
13416
+ try {
13417
+ const pkg2 = JSON.parse(readFileSync13(join24(root, "package.json"), "utf8"));
13418
+ return typeof pkg2.version === "string" ? pkg2.version : null;
13419
+ } catch {
13420
+ return null;
13421
+ }
13422
+ }
13423
+ function currentRoot() {
13424
+ try {
13425
+ const root = realpathSync6(currentLink());
13426
+ return existsSync16(join24(root, "dist", "cli.js")) ? root : null;
13427
+ } catch {
13428
+ return null;
13429
+ }
13430
+ }
13431
+ function pointCurrentAt(root) {
13432
+ const link = currentLink();
13433
+ mkdirSync14(dirname10(link), { recursive: true });
13434
+ const fresh = `${link}.${process.pid}.${Date.now()}`;
13435
+ symlinkSync2(root, fresh);
13436
+ try {
13437
+ renameSync9(fresh, link);
13438
+ } catch (err) {
13439
+ rmSync13(fresh, { force: true });
13440
+ throw err;
13441
+ }
13442
+ }
13443
+ function pruneVersions(keep) {
13444
+ const dir2 = versionsDir();
13445
+ if (!existsSync16(dir2)) return [];
13446
+ const removed = [];
13447
+ for (const name of readdirSync6(dir2)) {
13448
+ if (keep.includes(name)) continue;
13449
+ rmSync13(join24(dir2, name), { recursive: true, force: true });
13450
+ removed.push(name);
13451
+ }
13452
+ return removed;
13453
+ }
13454
+ function compareVersions(a, b) {
13455
+ const parts = (v) => /^\d+(\.\d+)*$/.test(v) ? v.split(".").map(Number) : null;
13456
+ const pa = parts(a);
13457
+ const pb = parts(b);
13458
+ if (!pa || !pb) return pa ? 1 : pb ? -1 : 0;
13459
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
13460
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
13461
+ if (d !== 0) return d;
13462
+ }
13463
+ return 0;
13464
+ }
13465
+
13466
+ // src/update-state.ts
13467
+ import { mkdirSync as mkdirSync15, readFileSync as readFileSync14, renameSync as renameSync10, writeFileSync as writeFileSync10 } from "fs";
13468
+ import { dirname as dirname11, join as join25 } from "path";
13469
+ import { z as z18 } from "zod";
13470
+ var resultSchema = z18.object({
13471
+ kind: z18.enum(["updated", "rolled_back", "failed"]),
13472
+ from: z18.string(),
13473
+ to: z18.string(),
13474
+ at: z18.string(),
13475
+ reason: z18.string().optional()
13476
+ });
13477
+ var pendingSchema = z18.object({
13478
+ from: z18.string(),
13479
+ // The package root `current` pointed at before the swap, so a rollback can point it back.
13480
+ fromRoot: z18.string(),
13481
+ to: z18.string(),
13482
+ at: z18.string(),
13483
+ boots: z18.number().int().nonnegative()
13484
+ });
13485
+ var stateSchema = z18.object({
13486
+ server: z18.object({ latest: z18.string(), minimum: z18.string(), at: z18.string() }).optional(),
13487
+ pending: pendingSchema.optional(),
13488
+ lastResult: resultSchema.optional(),
13489
+ failed: z18.object({ version: z18.string(), attempts: z18.number().int(), at: z18.string() }).optional()
13490
+ });
13491
+ function updateStatePath(dir2 = cabaneDir()) {
13492
+ return join25(dir2, "update.json");
13493
+ }
13494
+ function readUpdateState(path = updateStatePath()) {
13495
+ try {
13496
+ const parsed = stateSchema.safeParse(JSON.parse(readFileSync14(path, "utf8")));
13497
+ return parsed.success ? parsed.data : {};
13498
+ } catch {
13499
+ return {};
13500
+ }
13501
+ }
13502
+ function writeUpdateState(state, path = updateStatePath()) {
13503
+ mkdirSync15(dirname11(path), { recursive: true });
13504
+ const tmp = `${path}.${process.pid}.tmp`;
13505
+ writeFileSync10(tmp, `${JSON.stringify(state, null, 2)}
13506
+ `, { mode: 384 });
13507
+ renameSync10(tmp, path);
13508
+ }
13509
+ function updateUpdateState(change, path = updateStatePath()) {
13510
+ const next = change(readUpdateState(path));
13511
+ writeUpdateState(next, path);
13512
+ return next;
13513
+ }
13514
+
13515
+ // src/updater.ts
13516
+ function updateMode(env = process.env) {
13517
+ return env.CABANE_COMPANION_SUPERVISED === "1" ? "supervised" : "attached";
13518
+ }
13519
+ var STAGE_RETRY_MS = 60 * 6e4;
13520
+ var SWAP_DRAIN_MS = 1e4;
13521
+ var SOURCE_BUILD_VERSION = "0.0.0";
13522
+ function versionToStage(input) {
13523
+ const { own, state, now, force } = input;
13524
+ if (!input.auto && !force) return null;
13525
+ if (own === SOURCE_BUILD_VERSION) return null;
13526
+ const latest = state.server?.latest;
13527
+ if (!latest || compareVersions(latest, own) <= 0) return null;
13528
+ if (state.pending) return null;
13529
+ const failed = state.failed;
13530
+ if (!force && failed?.version === latest) {
13531
+ if (failed.attempts >= MAX_STAGE_ATTEMPTS) return null;
13532
+ if (now - Date.parse(failed.at) < STAGE_RETRY_MS) return null;
13533
+ }
13534
+ return latest;
13535
+ }
13536
+ function rollBack(pending, reason, now = /* @__PURE__ */ new Date()) {
13537
+ if (existsSync17(pending.fromRoot)) pointCurrentAt(pending.fromRoot);
13538
+ return updateUpdateState((state) => rolledBackState(state, pending, reason, now));
13539
+ }
13540
+ function writeBootGuard(root = ownPackageRoot()) {
13541
+ const built = join26(root, "dist", "boot-guard.js");
13542
+ if (!existsSync17(built)) return false;
13543
+ const target = bootGuardPath();
13544
+ mkdirSync16(dirname12(target), { recursive: true });
13545
+ const tmp = `${target}.${process.pid}.tmp`;
13546
+ copyFileSync3(built, tmp);
13547
+ renameSync11(tmp, target);
13548
+ return true;
13549
+ }
13550
+ function oneLine(text) {
13551
+ return text.replace(/\s+/g, " ").trim().slice(0, 400);
13552
+ }
13553
+ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
13554
+ let stdout = "";
13555
+ let stderr = "";
13556
+ let child;
13557
+ try {
13558
+ child = spawn8(command, [...args], {
13559
+ stdio: ["ignore", "pipe", "pipe"],
13560
+ env: opts.env ?? process.env
13561
+ });
13562
+ } catch (err) {
13563
+ resolve2({ code: null, stdout, stderr: err instanceof Error ? err.message : String(err) });
13564
+ return;
13565
+ }
13566
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : null;
13567
+ child.stdout.on("data", (d) => stdout += d.toString());
13568
+ child.stderr.on("data", (d) => stderr += d.toString());
13569
+ child.on("error", (err) => {
13570
+ if (timer) clearTimeout(timer);
13571
+ resolve2({ code: null, stdout, stderr: stderr || err.message });
13572
+ });
13573
+ child.on("close", (code) => {
13574
+ if (timer) clearTimeout(timer);
13575
+ resolve2({ code, stdout, stderr });
13576
+ });
13577
+ });
13578
+ async function stageVersion(version, harnesses, deps = {}) {
13579
+ const run = deps.run ?? runProcess;
13580
+ const node = deps.execPath ?? process.execPath;
13581
+ const target = join26(versionsDir(), version);
13582
+ const placed = versionPackageRoot(version);
13583
+ if (existsSync17(placed)) {
13584
+ if (!await verifyInstall(placed, version, harnesses, { run, execPath: node }))
13585
+ return { ok: true, root: placed };
13586
+ }
13587
+ const npm = join26(dirname12(node), "npm");
13588
+ if (!existsSync17(npm))
13589
+ return { ok: false, reason: `there is no npm beside Node at ${dirname12(node)}` };
13590
+ mkdirSync16(versionsDir(), { recursive: true });
13591
+ const staging = join26(versionsDir(), `.${version}.staging-${process.pid}`);
13592
+ rmSync14(staging, { recursive: true, force: true });
13593
+ const installed = await run(
13594
+ npm,
13595
+ [
13596
+ "install",
13597
+ "--prefix",
13598
+ staging,
13599
+ "--include=optional",
13600
+ "--no-audit",
13601
+ "--no-fund",
13602
+ "--no-save",
13603
+ "--loglevel=error",
13604
+ `@cabane/companion@${version}`
13605
+ ],
13606
+ {
13607
+ // npm's install scripts run on this Node, not whichever `node` is first on PATH.
13608
+ env: { ...process.env, PATH: `${dirname12(node)}:${process.env.PATH ?? ""}` },
13609
+ timeoutMs: 15 * 6e4
13610
+ }
13611
+ );
13612
+ if (installed.code !== 0) {
13613
+ rmSync14(staging, { recursive: true, force: true });
13614
+ if (/\bE404\b|\bETARGET\b|No matching version found|is not in this registry/i.test(
13615
+ installed.stderr
13616
+ ))
13617
+ return { ok: false, reason: `version ${version} isn't published yet`, unpublished: true };
13618
+ return { ok: false, reason: `npm couldn't install it: ${lastLine(installed.stderr)}` };
13619
+ }
13620
+ const root = join26(staging, "node_modules", "@cabane", "companion");
13621
+ const verdict = await verifyInstall(root, version, harnesses, { run, execPath: node });
13622
+ if (verdict) {
13623
+ rmSync14(staging, { recursive: true, force: true });
13624
+ return { ok: false, reason: verdict };
13625
+ }
13626
+ rmSync14(target, { recursive: true, force: true });
13627
+ renameSync11(staging, target);
13628
+ return { ok: true, root: versionPackageRoot(version) };
13629
+ }
13630
+ async function verifyInstall(root, version, harnesses, deps) {
13631
+ if (packageVersion(root) !== version) return `the installed package isn't version ${version}`;
13632
+ const answered = await deps.run(deps.execPath, [join26(root, "dist", "bin.js"), "--version"], {
13633
+ timeoutMs: 6e4,
13634
+ env: { ...process.env, CABANE_LAUNCH_SELF: "1" }
13635
+ });
13636
+ if (answered.code !== 0 || answered.stdout.trim() !== version) {
13637
+ return `it doesn't start: ${lastLine(answered.stderr) || "no version printed"}`;
13638
+ }
13639
+ if (harnesses.length === 0) return null;
13640
+ const probed = await deps.run(deps.execPath, [join26(root, "dist", "postinstall.js"), "--json"], {
13641
+ timeoutMs: 6e4
13642
+ });
13643
+ let resolutions = {};
13644
+ try {
13645
+ resolutions = JSON.parse(probed.stdout);
13646
+ } catch {
13647
+ return "it couldn't say which bundled agents it has";
13648
+ }
13649
+ const missing = harnesses.filter((h) => resolutions[h]?.status !== "present");
13650
+ if (missing.length > 0) {
13651
+ return `its bundled ${missing.map(harnessName).join(" and ")} ${missing.length > 1 ? "are" : "is"} missing`;
13652
+ }
13653
+ return null;
13654
+ }
13655
+ function harnessName(h) {
13656
+ return h === "claude-code" ? "Claude Code" : "Codex";
13657
+ }
13658
+ function lastLine(text) {
13659
+ const lines2 = text.split("\n").map((l) => l.trim()).filter(Boolean);
13660
+ return lines2.at(-1)?.slice(0, 300) ?? "";
13661
+ }
13662
+ var CompanionUpdater = class {
13663
+ constructor(deps) {
13664
+ this.deps = deps;
13665
+ this.now = deps.now ?? Date.now;
13666
+ }
13667
+ deps;
13668
+ staging = null;
13669
+ ready = null;
13670
+ swapping = false;
13671
+ watchdog = null;
13672
+ now;
13673
+ // At start: a recorded swap to a version that will never start again (someone installed or
13674
+ // chose another since) is dropped, or nothing would ever confirm it and no later update would
13675
+ // be installed.
13676
+ start() {
13677
+ const pending = readUpdateState().pending;
13678
+ const current = currentRoot();
13679
+ const currentVersion = current ? packageVersion(current) : null;
13680
+ if (superseded(pending, this.deps.own, ...currentVersion ? [currentVersion] : [])) {
13681
+ updateUpdateState(({ pending: _gone, ...rest }) => rest);
13682
+ this.deps.log.info(
13683
+ { to: pending.to, own: this.deps.own },
13684
+ `Dropped the update to ${pending.to}: Cabane ${this.deps.own} was installed since`
13685
+ );
13686
+ }
13687
+ }
13688
+ // The deadline for an update this process is the result of: ten minutes from the first time
13689
+ // Cabane answers it without its stream connecting. Until Cabane answers, there's no verdict on
13690
+ // time: a server or network that is down says nothing about the version.
13691
+ armDeadline() {
13692
+ if (this.watchdog) return;
13693
+ this.watchdog = setTimeout(() => void this.giveUp(), UNCONFIRMED_WINDOW_MS);
13694
+ this.watchdog.unref?.();
13695
+ }
13696
+ stop() {
13697
+ if (this.watchdog) clearTimeout(this.watchdog);
13698
+ this.watchdog = null;
13699
+ }
13700
+ async giveUp() {
13701
+ const pending = readUpdateState().pending;
13702
+ if (!pending || pending.to !== this.deps.own) return;
13703
+ this.deps.log.warn(
13704
+ { from: pending.from, to: pending.to },
13705
+ `Cabane ${pending.to} didn't connect within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes of Cabane answering; going back to ${pending.from}`
13706
+ );
13707
+ rollBack(
13708
+ pending,
13709
+ `it didn't connect to Cabane within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes`
13710
+ );
13711
+ await this.deps.restart();
13712
+ }
13713
+ // Auto-update is on and something will restart this companion into the new version.
13714
+ updatesItself() {
13715
+ return this.deps.mode === "supervised" && this.deps.autoUpdate();
13716
+ }
13717
+ // What the heartbeat reports.
13718
+ report() {
13719
+ const last = readUpdateState().lastResult;
13720
+ return { auto: this.updatesItself(), ...last ? { lastResult: last } : {} };
13721
+ }
13722
+ // A heartbeat landed. Record what the server named; confirm an update this process is the
13723
+ // result of; then, when there is something newer, install it and swap when idle.
13724
+ async onHeartbeat(server, streamConnected) {
13725
+ const state = updateUpdateState((s) => ({
13726
+ ...s,
13727
+ ...server ? { server: { ...server, at: new Date(this.now()).toISOString() } } : {}
13728
+ }));
13729
+ if (state.pending?.to === this.deps.own) {
13730
+ if (streamConnected) this.confirm(state.pending);
13731
+ else this.armDeadline();
13732
+ }
13733
+ if (this.ready) {
13734
+ await this.trySwap();
13735
+ return;
13736
+ }
13737
+ this.maybeStage(false);
13738
+ }
13739
+ confirm(pending) {
13740
+ this.stop();
13741
+ updateUpdateState((s) => {
13742
+ const { pending: _done, failed: _failed, ...rest } = s;
13743
+ return {
13744
+ ...rest,
13745
+ lastResult: {
13746
+ kind: "updated",
13747
+ from: pending.from,
13748
+ to: pending.to,
13749
+ at: new Date(this.now()).toISOString()
13750
+ }
13751
+ };
13752
+ });
13753
+ const removed = pruneVersions([pending.to, pending.from]);
13754
+ this.deps.log.info(
13755
+ { from: pending.from, to: pending.to, removed },
13756
+ `Updated Cabane from ${pending.from} to ${pending.to}`
13757
+ );
13758
+ }
13759
+ // `cabane update`, from the person: install the latest now, whatever the setting.
13760
+ async updateNow() {
13761
+ const state = readUpdateState();
13762
+ const latest = state.server?.latest;
13763
+ if (!latest) return { ok: false, message: "Cabane hasn't said which version is latest yet." };
13764
+ if (compareVersions(latest, this.deps.own) <= 0)
13765
+ return { ok: true, message: `Cabane ${this.deps.own} is up to date.` };
13766
+ if (state.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
13767
+ if (this.ready === latest) {
13768
+ void this.trySwap();
13769
+ return { ok: true, message: this.waitingMessage(latest) };
13770
+ }
13771
+ this.maybeStage(true);
13772
+ if (this.staging) await this.staging;
13773
+ if (this.ready !== latest) {
13774
+ const after = readUpdateState();
13775
+ if (after.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
13776
+ if (after.failed?.version === latest && after.failed.attempts === 0)
13777
+ return { ok: false, message: `Cabane ${latest} isn't published yet. Try again later.` };
13778
+ const failed = after.lastResult;
13779
+ return {
13780
+ ok: false,
13781
+ message: failed?.kind === "failed" && failed.to === latest ? `Couldn't update to ${latest}: ${failed.reason ?? "see cabane logs"}.` : `Couldn't update to ${latest}. cabane logs says why.`
13782
+ };
13783
+ }
13784
+ void this.trySwap();
13785
+ return { ok: true, message: this.waitingMessage(latest) };
13786
+ }
13787
+ waitingMessage(version) {
13788
+ if (this.deps.mode === "attached")
13789
+ return `Cabane ${version} is installed. It runs from the next cabane start.`;
13790
+ return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} once the turns running now finish.`;
13791
+ }
13792
+ maybeStage(force) {
13793
+ if (this.staging) return;
13794
+ const version = versionToStage({
13795
+ auto: this.updatesItself(),
13796
+ own: this.deps.own,
13797
+ state: readUpdateState(),
13798
+ now: this.now(),
13799
+ force
13800
+ });
13801
+ if (!version) return;
13802
+ const stage = this.deps.stage ?? stageVersion;
13803
+ this.staging = (async () => {
13804
+ this.deps.log.info({ version }, `Installing Cabane ${version} beside this one`);
13805
+ const outcome = await stage(version, this.deps.harnesses());
13806
+ if (outcome.ok) {
13807
+ this.ready = version;
13808
+ updateUpdateState((s) => {
13809
+ const { failed: _failed, ...rest } = s;
13810
+ return rest;
13811
+ });
13812
+ return;
13813
+ }
13814
+ if (outcome.unpublished) {
13815
+ this.deps.log.info({ version }, `Cabane ${version} isn't published yet; trying later`);
13816
+ updateUpdateState((s) => ({
13817
+ ...s,
13818
+ failed: { version, attempts: 0, at: new Date(this.now()).toISOString() }
13819
+ }));
13820
+ return;
13821
+ }
13822
+ this.deps.log.warn({ version, reason: outcome.reason }, `Couldn't install Cabane ${version}`);
13823
+ updateUpdateState((s) => {
13824
+ const attempts = s.failed?.version === version ? s.failed.attempts + 1 : 1;
13825
+ const at = new Date(this.now()).toISOString();
13826
+ return {
13827
+ ...s,
13828
+ failed: { version, attempts, at },
13829
+ // Reported once it has failed for good, so a registry blip isn't an email.
13830
+ ...attempts >= MAX_STAGE_ATTEMPTS || force ? {
13831
+ lastResult: {
13832
+ kind: "failed",
13833
+ from: this.deps.own,
13834
+ to: version,
13835
+ at,
13836
+ reason: oneLine(outcome.reason)
13837
+ }
13838
+ } : {}
13839
+ };
13840
+ });
13841
+ })().finally(() => {
13842
+ this.staging = null;
13843
+ });
13844
+ void this.staging.then(() => this.trySwap());
13845
+ }
13846
+ // Swap into the ready version once nothing is running. Called on each heartbeat until it can.
13847
+ async trySwap() {
13848
+ const to = this.ready;
13849
+ if (!to || this.swapping) return;
13850
+ if (readUpdateState().pending) return;
13851
+ if (this.deps.mode === "attached") {
13852
+ this.select(to);
13853
+ this.deps.log.info({ version: to }, `Cabane ${to} is installed; it runs from the next start`);
13854
+ return;
13855
+ }
13856
+ if (!this.deps.isIdle()) return;
13857
+ this.swapping = true;
13858
+ try {
13859
+ await this.deps.pauseStream();
13860
+ const sleep4 = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
13861
+ const deadline = this.now() + SWAP_DRAIN_MS;
13862
+ await sleep4(250);
13863
+ while (!this.deps.isIdle() && this.now() < deadline) await sleep4(250);
13864
+ if (!this.deps.isIdle()) {
13865
+ this.deps.resumeStream();
13866
+ return;
13867
+ }
13868
+ this.select(to);
13869
+ this.deps.log.info({ from: this.deps.own, to }, `Restarting into Cabane ${to}`);
13870
+ await this.deps.restart();
13871
+ } finally {
13872
+ this.swapping = false;
13873
+ }
13874
+ }
13875
+ // Record the swap and point `current` at `to`, leaving the guard that counts its starts.
13876
+ select(to) {
13877
+ const fromRoot = currentRoot() ?? ownPackageRoot();
13878
+ writeBootGuard();
13879
+ updateUpdateState((s) => ({
13880
+ ...s,
13881
+ pending: {
13882
+ from: this.deps.own,
13883
+ fromRoot,
13884
+ to,
13885
+ at: new Date(this.now()).toISOString(),
13886
+ boots: 0
13887
+ }
13888
+ }));
13889
+ pointCurrentAt(versionPackageRoot(to));
13890
+ this.ready = null;
13891
+ }
13892
+ };
13893
+
13172
13894
  // src/version.ts
13173
13895
  import { createRequire as createRequire3 } from "module";
13174
13896
  var pkg = createRequire3(import.meta.url)("../package.json");
@@ -13176,6 +13898,7 @@ var COMPANION_VERSION = pkg.version;
13176
13898
 
13177
13899
  // src/supervisor.ts
13178
13900
  var HEARTBEAT_INTERVAL_MS = 3e4;
13901
+ var ARCHIVE_PRUNE_MS = 24 * 60 * 6e4;
13179
13902
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
13180
13903
  function positiveIntEnv(name) {
13181
13904
  const raw = process.env[name];
@@ -13215,9 +13938,10 @@ var CompanionSupervisor = class {
13215
13938
  // surface, so the UI never disagrees with what is advertised. Null until the
13216
13939
  // first probe.
13217
13940
  harnessSignals = null;
13218
- // Fresh per attempt, never the cached heartbeat signal: installing a harness
13219
- // between attempts must be observed immediately.
13220
- probePresence;
13941
+ // Fresh per connect, cached for the heartbeat: a sign-in between attempts
13942
+ // must be observed immediately, and a heartbeat must spawn nothing.
13943
+ runHarnessCheck;
13944
+ harnessChecks = {};
13221
13945
  exitFn;
13222
13946
  reexecFn;
13223
13947
  dispatcherFactory;
@@ -13256,15 +13980,23 @@ var CompanionSupervisor = class {
13256
13980
  // Which dispatches this process is running, so a redelivered frame doesn't
13257
13981
  // run the turn a second time.
13258
13982
  claims = new DispatchClaims();
13983
+ archivePruneTimer = null;
13984
+ // Keeps this companion current when its person said so; null until paired.
13985
+ updater = null;
13986
+ restartForUpdate;
13259
13987
  constructor(opts) {
13260
13988
  this.config = opts.config;
13261
13989
  this.log = opts.log;
13262
13990
  this.hub = opts.hub;
13263
13991
  this.resolveBundled = opts.resolveBundled ?? ((runtime) => resolveBundledBinary(runtime));
13264
- this.probePresence = opts.probePresence ?? probeHarnessPresence;
13992
+ this.runHarnessCheck = opts.checkHarness ?? ((runtime) => checkHarnessUsable(runtime));
13265
13993
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
13266
13994
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
13267
13995
  this.reexecFn = opts.reexec ?? defaultReexec;
13996
+ this.restartForUpdate = opts.restartForUpdate ?? (async () => {
13997
+ await this.shutdown();
13998
+ this.exitFn(0);
13999
+ });
13268
14000
  this.dispatcherFactory = opts.dispatcherFactory;
13269
14001
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
13270
14002
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
@@ -13288,7 +14020,7 @@ var CompanionSupervisor = class {
13288
14020
  });
13289
14021
  }
13290
14022
  dockerRunners() {
13291
- return runnersEnabled(this.config) && this.config.runners?.provider === "docker";
14023
+ return dockerRunnersConfigured(this.config);
13292
14024
  }
13293
14025
  // Stand up the data plane: pair check, initial assignments pull, then the
13294
14026
  // heartbeat + poll loops. A companion with no device token (logged out) does
@@ -13315,6 +14047,9 @@ var CompanionSupervisor = class {
13315
14047
  if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
13316
14048
  }
13317
14049
  this.latchBootBundledState();
14050
+ this.pruneSessionArchives();
14051
+ this.archivePruneTimer = setInterval(() => this.pruneSessionArchives(), ARCHIVE_PRUNE_MS);
14052
+ this.archivePruneTimer.unref?.();
13318
14053
  }
13319
14054
  const sandboxRows = agentSandboxRows(this.config);
13320
14055
  if (sandboxRows.length > 0 && this.dockerRunners()) {
@@ -13335,10 +14070,10 @@ var CompanionSupervisor = class {
13335
14070
  else this.log.error({ posture }, `runners: ${summary}`);
13336
14071
  void this.companionStores().refresh();
13337
14072
  }
13338
- const startupHarnessRefresh = this.refreshHarnessStatuses();
14073
+ const startupHarnessRefresh = this.checkHarnesses().then(() => this.refreshHarnessStatuses());
13339
14074
  this.trackHeartbeat(startupHarnessRefresh);
13340
14075
  if (!this.config.deviceToken) {
13341
- this.log.warn("This device isn't paired. Run cabane-companion start to pair it.");
14076
+ this.log.warn("This device isn't paired. Run cabane start to pair it.");
13342
14077
  return;
13343
14078
  }
13344
14079
  this.deviceApi = new DeviceApi({
@@ -13374,10 +14109,27 @@ var CompanionSupervisor = class {
13374
14109
  },
13375
14110
  onAuthFailure: () => {
13376
14111
  this.log.error(
13377
- "Cabane no longer accepts this device. Run cabane-companion logout, then cabane-companion start to pair again."
14112
+ `Cabane no longer accepts this device. Run ${cabane("logout")}, then ${cabane("start")} to pair again.`
13378
14113
  );
13379
14114
  }
13380
14115
  });
14116
+ this.updater = new CompanionUpdater({
14117
+ own: COMPANION_VERSION,
14118
+ mode: updateMode(),
14119
+ autoUpdate: () => this.config.update?.auto === true,
14120
+ harnesses: () => this.connectedBundledHarnesses(),
14121
+ isIdle: () => this.idle(),
14122
+ pauseStream: async () => {
14123
+ const sub = this.deviceSub;
14124
+ if (!sub) return;
14125
+ sub.stop();
14126
+ await sub.finished;
14127
+ },
14128
+ resumeStream: () => void this.deviceSub?.resume(),
14129
+ restart: () => this.restartForUpdate(),
14130
+ log: this.log
14131
+ });
14132
+ this.updater.start();
13381
14133
  await this.refreshAssignments();
13382
14134
  this.deviceSub.start();
13383
14135
  const firstHeartbeat = startupHarnessRefresh.then(async () => {
@@ -13438,13 +14190,20 @@ var CompanionSupervisor = class {
13438
14190
  // Omitted until the first observed failure or heal, so the server's
13439
14191
  // status-less manifest synthesis still runs for a fresh companion.
13440
14192
  ...connectorReports.length > 0 ? { connectors: connectorReports } : {},
13441
- ...opencodeModels !== null ? { models: opencodeModels } : {}
14193
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
14194
+ ...this.updater ? { update: this.updater.report() } : {}
13442
14195
  });
13443
14196
  this.hub.setDevice({ deviceId: res.deviceId });
13444
14197
  this.deviceId = res.deviceId;
13445
14198
  this.checkVersionSkew(res.serverVersion);
13446
14199
  this.heartbeated = true;
13447
14200
  void this.recoverRuns();
14201
+ void this.updater?.onHeartbeat(res.companion, this.deviceConnected).catch(
14202
+ (err) => this.log.warn(
14203
+ { err: err instanceof Error ? err.message : String(err) },
14204
+ "Couldn't check for a Cabane update; trying again on the next check-in."
14205
+ )
14206
+ );
13448
14207
  } catch (err) {
13449
14208
  this.log.warn(
13450
14209
  { ...apiErrorLogFields(err) },
@@ -13831,11 +14590,11 @@ var CompanionSupervisor = class {
13831
14590
  if (this.runnerCommand) return this.runnerCommand;
13832
14591
  return this.dockerRunners() ? ["cabane-companion", "run-turn"] : selfRunnerCommand();
13833
14592
  }
13834
- buildDispatcher(ctx) {
14593
+ buildDispatcher(ctx2) {
13835
14594
  const hostLocal = localAgentConfig(this.config, {
13836
- agentId: ctx.agentId,
13837
- agentUsername: ctx.agentUsername,
13838
- workspaceSlug: ctx.workspaceSlug
14595
+ agentId: ctx2.agentId,
14596
+ agentUsername: ctx2.agentUsername,
14597
+ workspaceSlug: ctx2.workspaceSlug
13839
14598
  });
13840
14599
  const local = this.dockerRunners() && hostLocal.prepareHook ? {
13841
14600
  ...hostLocal,
@@ -13844,20 +14603,20 @@ var CompanionSupervisor = class {
13844
14603
  command: containerHookPath(hostLocal.prepareHook.command)
13845
14604
  }
13846
14605
  } : hostLocal;
13847
- if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
14606
+ if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx2, local });
13848
14607
  return new Dispatcher({
13849
14608
  deviceId: this.deviceId ?? void 0,
13850
- api: ctx.api,
13851
- baseUrl: ctx.baseUrl,
13852
- workspaceId: ctx.workspaceId,
13853
- workspaceSlug: ctx.workspaceSlug,
13854
- agentId: ctx.agentId,
13855
- agentUsername: ctx.agentUsername,
13856
- credential: ctx.credential,
14609
+ api: ctx2.api,
14610
+ baseUrl: ctx2.baseUrl,
14611
+ workspaceId: ctx2.workspaceId,
14612
+ workspaceSlug: ctx2.workspaceSlug,
14613
+ agentId: ctx2.agentId,
14614
+ agentUsername: ctx2.agentUsername,
14615
+ credential: ctx2.credential,
13857
14616
  local,
13858
- aborts: ctx.aborts,
13859
- runConfig: ctx.runConfig,
13860
- log: this.log.child({ agentName: ctx.agentDisplayName ?? ctx.agentUsername }),
14617
+ aborts: ctx2.aborts,
14618
+ runConfig: ctx2.runConfig,
14619
+ log: this.log.child({ agentName: ctx2.agentDisplayName ?? ctx2.agentUsername }),
13861
14620
  // Read PER TURN off the heartbeat's own predicates, so a harness installed
13862
14621
  // or connected after boot works on the next turn exactly as it appears on
13863
14622
  // the next beat, and a runtime advertised by one and refused by the other
@@ -13870,7 +14629,7 @@ var CompanionSupervisor = class {
13870
14629
  // DEFAULT_AGENT_IDLE_TIMEOUT_MS.
13871
14630
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
13872
14631
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
13873
- observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
14632
+ observer: this.hub.observerFor(ctx2.workspaceId, ctx2.agentId, ctx2.workspaceSlug),
13874
14633
  transcriptDir: transcriptsDir(),
13875
14634
  connectorHealth: this.connectorHealth,
13876
14635
  ...runnersEnabled(this.config) ? {
@@ -13881,7 +14640,7 @@ var CompanionSupervisor = class {
13881
14640
  reportRunnerGone: (report) => this.reportRunnerGone(report),
13882
14641
  connectorHealth: this.connectorHealth,
13883
14642
  locks: this.runnerLocks,
13884
- sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx)),
14643
+ sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx2)),
13885
14644
  policy: this.sandboxPolicy(),
13886
14645
  companionStores: () => this.companionStores().stores()
13887
14646
  }
@@ -14309,23 +15068,23 @@ var CompanionSupervisor = class {
14309
15068
  configureLogger(this.log, next);
14310
15069
  }
14311
15070
  // ---- harnesses ----
14312
- // Probe all three harnesses, cache signals and versions, and push the snapshot
14313
- // to the hub. `probeHarnessSignals` never throws, but this is wrapped anyway so
14314
- // a refresh can never take a heartbeat down.
14315
- async refreshHarnessStatuses(presence = {}) {
15071
+ // Assemble the signals from the cached sign-in checks, cache versions, and
15072
+ // push the snapshot to the hub. Runs no sign-in check: the heartbeat calls
15073
+ // this. `probeHarnessSignals` never throws, but this is wrapped anyway so a
15074
+ // refresh can never take a heartbeat down.
15075
+ async refreshHarnessStatuses() {
14316
15076
  try {
14317
15077
  const signals = await probeHarnessSignals(this.config, {
14318
- probePresence: this.probePresence,
14319
- presence,
15078
+ checks: this.harnessChecks,
14320
15079
  // Through the supervisor's seam, so a test pins the host fact.
14321
15080
  resolveBundled: this.resolveBundled
14322
15081
  });
14323
15082
  this.harnessSignals = signals;
14324
15083
  this.logBundledTransitions();
14325
15084
  this.harnessVersions = {
14326
- claudeCode: signals.claudeVersion,
15085
+ claudeCode: checkVersion(signals.claudeCheck),
14327
15086
  opencode: signals.opencodeVersion,
14328
- codex: signals.codexVersion
15087
+ codex: checkVersion(signals.codexCheck)
14329
15088
  };
14330
15089
  this.hub.setHarnesses(deriveHarnessSnapshot(signals).harnesses);
14331
15090
  } catch (err) {
@@ -14335,9 +15094,32 @@ var CompanionSupervisor = class {
14335
15094
  );
14336
15095
  }
14337
15096
  }
15097
+ // Check one bundled harness's sign-in in this process, cache it for the
15098
+ // heartbeat, and — for a harness the user connected — seed connector health,
15099
+ // so Cabane says "sign-in needed" without waiting for a turn to fail. A
15100
+ // check that couldn't answer seeds nothing either way.
15101
+ async checkHarness(runtime) {
15102
+ let check;
15103
+ try {
15104
+ check = await this.runHarnessCheck(runtime);
15105
+ } catch {
15106
+ check = { status: "check_failed", reason: "spawn" };
15107
+ }
15108
+ this.harnessChecks[runtime] = check;
15109
+ const intended = runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config);
15110
+ if (intended && (isUsable(check) || check.status === "signed_out")) {
15111
+ this.connectorHealth.recordLocalCheck(runtime, isUsable(check));
15112
+ }
15113
+ return check;
15114
+ }
15115
+ async checkHarnesses() {
15116
+ if (this.dockerRunners()) return;
15117
+ await Promise.all([this.checkHarness("claude-code"), this.checkHarness("codex")]);
15118
+ }
14338
15119
  // The dashboard "recheck harnesses" control — an immediate re-probe so a user who
14339
15120
  // just installed/started a harness sees it flip without waiting for the next beat.
14340
15121
  async recheckHarnesses() {
15122
+ await this.checkHarnesses();
14341
15123
  await this.refreshHarnessStatuses();
14342
15124
  }
14343
15125
  // Beat NOW and wait for it to land. `start` calls this before asking the
@@ -14357,48 +15139,57 @@ var CompanionSupervisor = class {
14357
15139
  currentConfig() {
14358
15140
  return this.config;
14359
15141
  }
14360
- // Enable a config-driven harness by flipping the flag the app owns in
15142
+ // Connect a harness by flipping the flag the app owns in
14361
15143
  // `~/.cabane/config.json`. Never installs a binary or drives a login (BYO).
14362
15144
  //
14363
- // This records INTENT, the user's half: a harness is advertised only when also
14364
- // its bundled binary resolves, so an enable on a companion whose optional
14365
- // dependency never installed advertises nothing, and the Harnesses surface
14366
- // says why.
14367
- // - claude-code: `claudeCode.enabled`, after a presence check. The terminal
14368
- // offer and the web pairing flow both call this.
14369
- // - codex: `codex.enabled`, after a presence check (login stays the user's).
15145
+ // Claude Code and Codex are checked FIRST, in this process — the one that
15146
+ // will run their turns — and an agent that isn't usable here is refused before
15147
+ // anything is written: the terminal, the control socket and the dashboard all
15148
+ // come through here, so they all get the same answer. An agent already
15149
+ // connected is checked too and reported truthfully, with nothing written.
14370
15150
  // - opencode: `opencode.serverUrl`, ONLY after health-probing the URL; an
14371
15151
  // unreachable one is a typed failure shown in place.
14372
- // On success: persist, re-probe and beat at once, so the answer is true in
14373
- // Cabane promptly either way. Never throws for a valid input.
15152
+ // A connect records INTENT: a harness is advertised only when its bundled
15153
+ // binary also resolves. On success: persist, refresh and beat at once, so the
15154
+ // answer is true in Cabane promptly either way. Never throws for a valid input.
14374
15155
  async enableHarness(input) {
14375
15156
  let next;
14376
- let checkedPresence = null;
14377
- if (input.runtime === "claude-code") {
14378
- const presence = input.presence ?? await this.probePresence("claude-code");
14379
- if (presence.status !== "present") {
15157
+ let outcome;
15158
+ if (input.runtime === "claude-code" || input.runtime === "codex") {
15159
+ const runtime = input.runtime;
15160
+ const connected = runtime === "codex" ? isCodexEnabled(this.config) : isClaudeCodeConnected(this.config);
15161
+ const check = this.dockerRunners() ? void 0 : await this.checkHarness(runtime);
15162
+ if (check && !isUsable(check)) {
15163
+ await this.refreshHarnessStatuses();
15164
+ this.kickHeartbeat();
15165
+ return { ok: false, outcome: { kind: "refused", runtime, check, connected } };
15166
+ }
15167
+ const label = this.config.deviceLabel;
15168
+ if (connected) {
15169
+ await this.refreshHarnessStatuses();
15170
+ this.kickHeartbeat();
14380
15171
  return {
14381
- ok: false,
14382
- error: presenceReason("claude-code", presence.status),
14383
- presence: true
15172
+ ok: true,
15173
+ outcome: {
15174
+ kind: "already",
15175
+ runtime,
15176
+ ...check ? { check } : {},
15177
+ ...label ? { deviceLabel: label } : {}
15178
+ }
14384
15179
  };
14385
15180
  }
14386
- checkedPresence = { runtime: "claude-code", result: presence };
14387
- next = { ...this.config, claudeCode: { enabled: true } };
14388
- } else if (input.runtime === "codex") {
14389
- const presence = input.presence ?? await this.probePresence("codex");
14390
- if (presence.status !== "present") {
14391
- return { ok: false, error: presenceReason("codex", presence.status), presence: true };
14392
- }
14393
- checkedPresence = { runtime: "codex", result: presence };
14394
- next = { ...this.config, codex: { enabled: true } };
15181
+ next = runtime === "codex" ? { ...this.config, codex: { enabled: true } } : { ...this.config, claudeCode: { enabled: true } };
15182
+ outcome = { kind: "connected", runtime, ...check ? { check } : {} };
14395
15183
  } else {
14396
15184
  const serverUrl = input.serverUrl.trim();
14397
15185
  const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
14398
15186
  if (!parsed.success) {
14399
15187
  return {
14400
15188
  ok: false,
14401
- error: "That doesn\u2019t look like a valid URL (e.g. http://127.0.0.1:4096)."
15189
+ outcome: {
15190
+ kind: "opencode",
15191
+ message: "That doesn\u2019t look like a valid URL (e.g. http://127.0.0.1:4096)."
15192
+ }
14402
15193
  };
14403
15194
  }
14404
15195
  const boundedFetch = ((url, init2) => fetch(url, { ...init2, signal: AbortSignal.timeout(4e3) }));
@@ -14406,19 +15197,21 @@ var CompanionSupervisor = class {
14406
15197
  if (version === null) {
14407
15198
  return {
14408
15199
  ok: false,
14409
- error: `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
15200
+ outcome: {
15201
+ kind: "opencode",
15202
+ message: `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
15203
+ }
14410
15204
  };
14411
15205
  }
14412
15206
  next = { ...this.config, opencode: { serverUrl } };
15207
+ outcome = { kind: "connected", runtime: "opencode" };
14413
15208
  }
14414
15209
  this.config = next;
14415
15210
  saveConfig(next);
14416
15211
  this.rebuildDispatchers();
14417
- await this.refreshHarnessStatuses(
14418
- checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
14419
- );
15212
+ await this.refreshHarnessStatuses();
14420
15213
  this.kickHeartbeat();
14421
- return { ok: true };
15214
+ return { ok: true, outcome };
14422
15215
  }
14423
15216
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
14424
15217
  // the runner (its api, outbox, chains) intact, so constructor-captured options
@@ -14441,9 +15234,54 @@ var CompanionSupervisor = class {
14441
15234
  }
14442
15235
  }
14443
15236
  }
15237
+ // A long-lived device keeps a session archive per conversation and agent it ever ran; those
15238
+ // nobody has used in a month go.
15239
+ pruneSessionArchives() {
15240
+ try {
15241
+ const removed = pruneIdleSessionArchives(cabaneDir());
15242
+ if (removed.length > 0)
15243
+ this.log.info({ removed: removed.length }, "runners: removed idle session archives");
15244
+ } catch (err) {
15245
+ this.log.warn(
15246
+ { err: err instanceof Error ? err.message : String(err) },
15247
+ "runners: couldn't remove idle session archives"
15248
+ );
15249
+ }
15250
+ }
15251
+ // Nothing running and nothing about to: no dispatch claimed, no turn chained, no recovery
15252
+ // in progress. What a self-update swap waits for.
15253
+ idle() {
15254
+ if (this.claims.size > 0 || this.recovering) return false;
15255
+ for (const wr of this.workspaces.values()) if (wr.chains.size > 0) return false;
15256
+ return true;
15257
+ }
15258
+ // The bundled harnesses this device has connected, which a new version must still carry.
15259
+ connectedBundledHarnesses() {
15260
+ return [
15261
+ ...isClaudeCodeConnected(this.config) ? ["claude-code"] : [],
15262
+ ...isCodexEnabled(this.config) ? ["codex"] : []
15263
+ ];
15264
+ }
15265
+ // `cabane update`, over the control socket: set whether this companion keeps itself current
15266
+ // (`auto`), or install the latest now.
15267
+ async update(auto) {
15268
+ if (auto !== void 0) {
15269
+ this.config = { ...this.config, update: { auto } };
15270
+ saveConfig(this.config);
15271
+ if (auto) this.kickHeartbeat();
15272
+ return {
15273
+ ok: true,
15274
+ message: auto ? "Cabane will keep itself up to date. Updates install between turns and roll back if they fail." : "Cabane won't update itself. Run cabane update to update."
15275
+ };
15276
+ }
15277
+ if (!this.updater) return { ok: false, message: "This computer isn't paired yet." };
15278
+ return this.updater.updateNow();
15279
+ }
14444
15280
  // ---- control actions ----
14445
15281
  async shutdown() {
14446
15282
  this.stopped = true;
15283
+ this.updater?.stop();
15284
+ if (this.archivePruneTimer) clearInterval(this.archivePruneTimer);
14447
15285
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
14448
15286
  if (this.pollTimer) clearInterval(this.pollTimer);
14449
15287
  const heartbeat = this.inFlightHeartbeat;
@@ -14550,22 +15388,22 @@ function handleUncaught(log, err, origin) {
14550
15388
  }
14551
15389
 
14552
15390
  // src/crash-marker.ts
14553
- import { existsSync as existsSync15, mkdirSync as mkdirSync13, readFileSync as readFileSync12, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
14554
- import { join as join21 } from "path";
15391
+ import { existsSync as existsSync18, mkdirSync as mkdirSync17, readFileSync as readFileSync15, rmSync as rmSync15, writeFileSync as writeFileSync11 } from "fs";
15392
+ import { join as join27 } from "path";
14555
15393
  function crashMarkerPath() {
14556
- return join21(cabaneDir(), "last-error.json");
15394
+ return join27(cabaneDir(), "last-error.json");
14557
15395
  }
14558
15396
  function recordCrash(rec) {
14559
15397
  try {
14560
- mkdirSync13(cabaneDir(), { recursive: true });
14561
- writeFileSync9(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
15398
+ mkdirSync17(cabaneDir(), { recursive: true });
15399
+ writeFileSync11(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
14562
15400
  } catch {
14563
15401
  }
14564
15402
  }
14565
15403
  function clearCrash() {
14566
15404
  try {
14567
15405
  const path = crashMarkerPath();
14568
- if (existsSync15(path)) rmSync12(path, { force: true });
15406
+ if (existsSync18(path)) rmSync15(path, { force: true });
14569
15407
  } catch {
14570
15408
  }
14571
15409
  }
@@ -14575,17 +15413,14 @@ async function createCompanionRuntime(opts = {}) {
14575
15413
  const log = getLogger();
14576
15414
  installProcessSafetyNet(log);
14577
15415
  let cfg;
14578
- let claudeCode;
14579
15416
  try {
14580
- ({ cfg, claudeOnPath: claudeCode } = await requireStartConfig({
14581
- ...opts.probeClaude ? { probeClaude: opts.probeClaude } : {},
14582
- onMigrated: (migrated, onPath) => log.debug(
15417
+ cfg = requireStartConfig({
15418
+ onMigrated: (migrated) => log.debug(
14583
15419
  { claudeCode: isClaudeCodeConnected(migrated) },
14584
- onPath ? "companion: carried Claude Code over as a connected harness on this device (connectors are now chosen, not detected)" : "companion: no Claude Code on PATH, so this device starts with it disconnected (connectors are now chosen, not detected)"
15420
+ "companion: carried Claude Code over as a connected harness on this device (connectors are now chosen, not detected)"
14585
15421
  )
14586
- }));
14587
- await warnAboutHarnessReadiness(cfg, {
14588
- probeClaude: async () => claudeCode,
15422
+ });
15423
+ warnAboutHarnessReadiness(cfg, {
14589
15424
  // The CLI's onboarding script says this in its own words, so it hands a
14590
15425
  // sink that logs instead. Every other caller keeps the stderr line.
14591
15426
  ...opts.onReadinessWarning ? { warn: opts.onReadinessWarning } : {}
@@ -14600,13 +15435,6 @@ async function createCompanionRuntime(opts = {}) {
14600
15435
  throw err;
14601
15436
  }
14602
15437
  configureLogger(log, cfg);
14603
- const harnessVersions = await probeHarnessVersions({
14604
- // Connected AND its CLI answered — the two things that make a
14605
- // `claude --version` probe worth spawning. Not the offer predicate.
14606
- claudeCode: claudeCode && isClaudeCodeConnected(cfg),
14607
- opencodeServerUrl: cfg.opencode?.serverUrl,
14608
- codex: isCodexEnabled(cfg)
14609
- });
14610
15438
  const instanceId = randomUUID7();
14611
15439
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
14612
15440
  const pre = readLiveRuntimeState();
@@ -14630,39 +15458,39 @@ async function createCompanionRuntime(opts = {}) {
14630
15458
  // process behind the marker pid is this companion (not a recycled pid).
14631
15459
  instanceId
14632
15460
  });
15461
+ let restartForUpdate = async () => {
15462
+ };
14633
15463
  const supervisor = new CompanionSupervisor({
14634
15464
  config: cfg,
14635
15465
  log,
14636
15466
  hub,
14637
- harnessVersions
15467
+ ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
15468
+ restartForUpdate: () => restartForUpdate()
14638
15469
  });
14639
15470
  await supervisor.start();
14640
15471
  const connectHarness = async (runtime, serverUrl) => {
14641
- const currentConfig = supervisor.currentConfig();
14642
- const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
14643
- const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
14644
- const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
14645
- const verdict = await shakeOutHarness(runtime, candidate, {
14646
- ...presence ? { presence } : {}
14647
- });
14648
- if (verdict === "absent" || verdict === "unusable") {
14649
- return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
15472
+ if (runtime !== "opencode") return (await supervisor.enableHarness({ runtime })).outcome;
15473
+ const configured = supervisor.currentConfig().opencode?.serverUrl;
15474
+ const resolved = await resolveOpencodeServerUrl(configured, serverUrl);
15475
+ if (!resolved) {
15476
+ return {
15477
+ kind: "opencode",
15478
+ message: "No opencode server is answering at http://127.0.0.1:4096. Start `opencode serve`, or pass its address with `--url`."
15479
+ };
14650
15480
  }
14651
- const result = await supervisor.enableHarness(
14652
- runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
14653
- );
14654
- if (!result.ok) return { ok: false, error: result.error };
14655
- return { ok: true, message: connectedLine(runtime, verdict) };
15481
+ if (!serverUrl?.trim() && configured === resolved) {
15482
+ const label = supervisor.currentConfig().deviceLabel;
15483
+ return { kind: "already", runtime, ...label ? { deviceLabel: label } : {} };
15484
+ }
15485
+ return (await supervisor.enableHarness({ runtime: "opencode", serverUrl: resolved })).outcome;
14656
15486
  };
14657
15487
  let control = null;
14658
15488
  let dashboard = null;
14659
15489
  try {
14660
15490
  control = await startControlServer({
14661
15491
  status: () => hub.statusJson(),
14662
- connect: async (runtime, serverUrl) => {
14663
- const result = await connectHarness(runtime, serverUrl);
14664
- return result.ok ? { ok: true, message: result.message } : { ok: false, message: result.error };
14665
- },
15492
+ connect: (runtime, serverUrl) => connectHarness(runtime, serverUrl),
15493
+ update: (auto) => supervisor.update(auto),
14666
15494
  stop: () => void supervisor.requestStop()
14667
15495
  });
14668
15496
  if (opts.dashboard) {
@@ -14683,7 +15511,8 @@ async function createCompanionRuntime(opts = {}) {
14683
15511
  // marker records whether this companion is backgrounded (foreground start
14684
15512
  // leaves it unset → false).
14685
15513
  daemon: process.env.CABANE_COMPANION_DAEMON === "1",
14686
- instanceId
15514
+ instanceId,
15515
+ ...installedCommandPath() ? { command: installedCommandPath() } : {}
14687
15516
  });
14688
15517
  clearCrash();
14689
15518
  } catch (err) {
@@ -14714,6 +15543,10 @@ async function createCompanionRuntime(opts = {}) {
14714
15543
  } catch {
14715
15544
  }
14716
15545
  };
15546
+ restartForUpdate = async () => {
15547
+ await stop();
15548
+ process.exit(0);
15549
+ };
14717
15550
  return {
14718
15551
  ok: true,
14719
15552
  runtime: {