@cabane/companion 0.6.156 → 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/cli.js CHANGED
@@ -692,16 +692,16 @@ function migrateConnectedHarnesses(cfg) {
692
692
  }
693
693
  function localAgentConfig(cfg, agent) {
694
694
  const map = cfg.agents ?? {};
695
- const row2 = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
696
- if (!cfg.prepareHook || row2.prepareHook) return row2;
697
- return { ...row2, prepareHook: cfg.prepareHook };
695
+ const row3 = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
696
+ if (!cfg.prepareHook || row3.prepareHook) return row3;
697
+ return { ...row3, prepareHook: cfg.prepareHook };
698
698
  }
699
699
  function agentSandbox(cfg, agent) {
700
700
  const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
701
701
  return sandbox ? { sandbox } : {};
702
702
  }
703
- function runnerSandboxFor(cfg, row2, host) {
704
- const asked = row2.sandbox ?? cfg.runners?.sandbox ?? "none";
703
+ function runnerSandboxFor(cfg, row3, host) {
704
+ const asked = row3.sandbox ?? cfg.runners?.sandbox ?? "none";
705
705
  if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
706
706
  return host.osSandboxUsable() ? "os" : "none";
707
707
  }
@@ -715,7 +715,7 @@ function runnerSandboxReport(cfg, agents, host) {
715
715
  }
716
716
  function agentSandboxRows(cfg) {
717
717
  return Object.entries(cfg.agents ?? {}).flatMap(
718
- ([key, row2]) => row2.sandbox ? [[key, row2.sandbox]] : []
718
+ ([key, row3]) => row3.sandbox ? [[key, row3.sandbox]] : []
719
719
  );
720
720
  }
721
721
  function loadConfig() {
@@ -897,8 +897,8 @@ var init_config = __esm({
897
897
  }).strict();
898
898
  AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
899
899
  agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx2) => {
900
- for (const [key, row2] of Object.entries(agents)) {
901
- if (row2.sandbox === void 0) continue;
900
+ for (const [key, row3] of Object.entries(agents)) {
901
+ if (row3.sandbox === void 0) continue;
902
902
  if (AGENT_ID_KEY.test(key)) continue;
903
903
  ctx2.addIssue({
904
904
  code: z2.ZodIssueCode.custom,
@@ -965,6 +965,15 @@ var init_config = __esm({
965
965
  // absent `claudeCode` block means "never offered"; without it, the block's
966
966
  // absence marks a config from before connectors were chosen.
967
967
  harnessesChosen: z2.boolean().optional(),
968
+ // Whether this companion keeps itself current: installs the version Cabane names, between
969
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
970
+ // which `start` offers; off either way until someone says yes.
971
+ update: z2.object({
972
+ auto: z2.boolean()
973
+ }).strict().optional(),
974
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
975
+ // `cabane service install` still installs it.
976
+ serviceDeclined: z2.boolean().optional(),
968
977
  // OpenCode's decline. Its block is an address, so a decline can't live there
969
978
  // the way `claudeCode`/`codex` `enabled: false` does; without it a declined
970
979
  // serve would be offered again on every `start`.
@@ -2682,8 +2691,8 @@ var init_policy = __esm({
2682
2691
  // packages/agent-runtime/src/claude-code/model.ts
2683
2692
  function parseClaudeCodeModel(model) {
2684
2693
  if (!model) return null;
2685
- const sep5 = model.indexOf("/");
2686
- const bare = sep5 === -1 || model.slice(0, sep5) !== "anthropic" ? model : model.slice(sep5 + 1);
2694
+ const sep6 = model.indexOf("/");
2695
+ const bare = sep6 === -1 || model.slice(0, sep6) !== "anthropic" ? model : model.slice(sep6 + 1);
2687
2696
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
2688
2697
  }
2689
2698
  var CLAUDE_CODE_DEFAULT_MODEL_BARE;
@@ -3151,10 +3160,10 @@ function parseContextUsage(response) {
3151
3160
  if (total === void 0 || window === void 0 || window === 0) return void 0;
3152
3161
  const categories = [];
3153
3162
  for (const c of Array.isArray(r.categories) ? r.categories : []) {
3154
- const row2 = c;
3155
- const tokens = count(row2?.tokens);
3156
- if (typeof row2?.name === "string" && tokens !== void 0 && typeof row2.kind === "string" && CATEGORY_KINDS.has(row2.kind)) {
3157
- categories.push({ name: row2.name, tokens, kind: row2.kind });
3163
+ const row3 = c;
3164
+ const tokens = count(row3?.tokens);
3165
+ if (typeof row3?.name === "string" && tokens !== void 0 && typeof row3.kind === "string" && CATEGORY_KINDS.has(row3.kind)) {
3166
+ categories.push({ name: row3.name, tokens, kind: row3.kind });
3158
3167
  }
3159
3168
  }
3160
3169
  const buffer = categories.filter((c) => c.kind === "buffer").reduce((sum, c) => sum + c.tokens, 0);
@@ -4236,9 +4245,9 @@ var init_policy2 = __esm({
4236
4245
 
4237
4246
  // packages/agent-runtime/src/opencode/model.ts
4238
4247
  function parseOpencodeModel(model) {
4239
- const sep5 = model.indexOf("/");
4240
- if (sep5 === -1) return { providerID: "", modelID: model };
4241
- return { providerID: model.slice(0, sep5), modelID: model.slice(sep5 + 1) };
4248
+ const sep6 = model.indexOf("/");
4249
+ if (sep6 === -1) return { providerID: "", modelID: model };
4250
+ return { providerID: model.slice(0, sep6), modelID: model.slice(sep6 + 1) };
4242
4251
  }
4243
4252
  var init_model2 = __esm({
4244
4253
  "packages/agent-runtime/src/opencode/model.ts"() {
@@ -4539,10 +4548,10 @@ async function* parseSseStream(body, sessionId, signal) {
4539
4548
  const { value, done } = await reader.read();
4540
4549
  if (done) return;
4541
4550
  buf += decoder.decode(value, { stream: true });
4542
- let sep5;
4543
- while ((sep5 = buf.indexOf("\n\n")) !== -1) {
4544
- const frame = buf.slice(0, sep5);
4545
- buf = buf.slice(sep5 + 2);
4551
+ let sep6;
4552
+ while ((sep6 = buf.indexOf("\n\n")) !== -1) {
4553
+ const frame = buf.slice(0, sep6);
4554
+ buf = buf.slice(sep6 + 2);
4546
4555
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
4547
4556
  if (!data) continue;
4548
4557
  let ev;
@@ -5553,8 +5562,8 @@ var init_policy3 = __esm({
5553
5562
 
5554
5563
  // packages/agent-runtime/src/codex/model.ts
5555
5564
  function parseCodexModel(model) {
5556
- const sep5 = model.indexOf("/");
5557
- const bare = sep5 === -1 || model.slice(0, sep5) !== "openai" ? model : model.slice(sep5 + 1);
5565
+ const sep6 = model.indexOf("/");
5566
+ const bare = sep6 === -1 || model.slice(0, sep6) !== "openai" ? model : model.slice(sep6 + 1);
5558
5567
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
5559
5568
  }
5560
5569
  var CODEX_DEFAULT_MODEL_BARES;
@@ -10249,7 +10258,7 @@ var init_turn_execution = __esm({
10249
10258
 
10250
10259
  // src/dispatcher.ts
10251
10260
  import { randomUUID as randomUUID4 } from "crypto";
10252
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
10261
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
10253
10262
  import { join as join17 } from "path";
10254
10263
  function checkoutState(cwd) {
10255
10264
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
@@ -10267,7 +10276,7 @@ function checkoutState(cwd) {
10267
10276
  if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
10268
10277
  let stat;
10269
10278
  try {
10270
- stat = statSync2(gitPath);
10279
+ stat = statSync3(gitPath);
10271
10280
  } catch (error) {
10272
10281
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
10273
10282
  }
@@ -11079,7 +11088,7 @@ import {
11079
11088
  mkdirSync as mkdirSync12,
11080
11089
  readdirSync as readdirSync5,
11081
11090
  readFileSync as readFileSync11,
11082
- renameSync as renameSync6,
11091
+ renameSync as renameSync7,
11083
11092
  rmSync as rmSync11,
11084
11093
  writeFileSync as writeFileSync8
11085
11094
  } from "fs";
@@ -11116,7 +11125,7 @@ var init_outbox = __esm({
11116
11125
  const tmp = `${target}.${process.pid}.tmp`;
11117
11126
  try {
11118
11127
  writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
11119
- renameSync6(tmp, target);
11128
+ renameSync7(tmp, target);
11120
11129
  } catch (err) {
11121
11130
  try {
11122
11131
  rmSync11(tmp, { force: true });
@@ -11285,8 +11294,8 @@ var init_image_manifest = __esm({
11285
11294
  });
11286
11295
 
11287
11296
  // src/runner/exec-shim.ts
11288
- import { chmodSync as chmodSync7, mkdirSync as mkdirSync15, mkdtempSync as mkdtempSync2, rmSync as rmSync13, writeFileSync as writeFileSync10 } from "fs";
11289
- import { join as join26 } from "path";
11297
+ import { chmodSync as chmodSync8, mkdirSync as mkdirSync20, mkdtempSync as mkdtempSync2, rmSync as rmSync17, writeFileSync as writeFileSync13 } from "fs";
11298
+ import { join as join31 } from "path";
11290
11299
  function shellQuote2(value) {
11291
11300
  return `'${value.replace(/'/g, `'\\''`)}'`;
11292
11301
  }
@@ -11296,18 +11305,18 @@ function createShimDir() {
11296
11305
  return {
11297
11306
  write(argv) {
11298
11307
  if (!dir2) {
11299
- mkdirSync15(join26(cabaneDir(), "runner"), { recursive: true });
11300
- dir2 = mkdtempSync2(join26(cabaneDir(), "runner", "turn-"));
11308
+ mkdirSync20(join31(cabaneDir(), "runner"), { recursive: true });
11309
+ dir2 = mkdtempSync2(join31(cabaneDir(), "runner", "turn-"));
11301
11310
  }
11302
- const path = join26(dir2, `exec-${++count}`);
11303
- writeFileSync10(path, `#!/bin/sh
11311
+ const path = join31(dir2, `exec-${++count}`);
11312
+ writeFileSync13(path, `#!/bin/sh
11304
11313
  exec ${argv.map(shellQuote2).join(" ")} "$@"
11305
11314
  `);
11306
- chmodSync7(path, 448);
11315
+ chmodSync8(path, 448);
11307
11316
  return path;
11308
11317
  },
11309
11318
  dispose() {
11310
- if (dir2) rmSync13(dir2, { recursive: true, force: true });
11319
+ if (dir2) rmSync17(dir2, { recursive: true, force: true });
11311
11320
  dir2 = null;
11312
11321
  }
11313
11322
  };
@@ -12260,6 +12269,10 @@ async function serveConnection(socket, handlers) {
12260
12269
  reply(socket, result);
12261
12270
  return;
12262
12271
  }
12272
+ if (req.cmd === "update") {
12273
+ reply(socket, await handlers.update(req.auto));
12274
+ return;
12275
+ }
12263
12276
  if (req.cmd === "stop") {
12264
12277
  reply(socket, { ok: true });
12265
12278
  setTimeout(() => handlers.stop(), 50).unref?.();
@@ -12885,13 +12898,19 @@ init_term();
12885
12898
 
12886
12899
  // src/commands/start.ts
12887
12900
  init_config();
12888
- import { spawn as spawn9 } from "child_process";
12901
+ import { spawn as spawn10 } from "child_process";
12889
12902
  import { hostname as hostname3 } from "os";
12890
12903
  init_cabane_command();
12891
12904
 
12892
12905
  // src/logger.ts
12893
12906
  init_config();
12894
- import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
12907
+ import {
12908
+ createWriteStream,
12909
+ mkdirSync as mkdirSync5,
12910
+ openSync as openSync2,
12911
+ renameSync as renameSync3,
12912
+ statSync as statSync2
12913
+ } from "fs";
12895
12914
  import { dirname as dirname4, join as join8 } from "path";
12896
12915
  import { stripVTControlCharacters } from "util";
12897
12916
  import pino from "pino";
@@ -12946,6 +12965,53 @@ function formatHumanLine(record) {
12946
12965
  }
12947
12966
  return line + "\n";
12948
12967
  }
12968
+ var LOG_ROTATE_BYTES = 50 * 1024 * 1024;
12969
+ var LOG_ROTATIONS_KEPT = 3;
12970
+ var RotatingLog = class {
12971
+ constructor(path, maxBytes = LOG_ROTATE_BYTES, keep = LOG_ROTATIONS_KEPT) {
12972
+ this.path = path;
12973
+ this.maxBytes = maxBytes;
12974
+ this.keep = keep;
12975
+ this.size = sizeOf(path);
12976
+ this.stream = openAppend(path);
12977
+ }
12978
+ path;
12979
+ maxBytes;
12980
+ keep;
12981
+ stream;
12982
+ size;
12983
+ write(chunk) {
12984
+ const ok = this.stream.write(chunk);
12985
+ this.size += Buffer.byteLength(chunk);
12986
+ if (this.size >= this.maxBytes) this.rotate();
12987
+ return ok;
12988
+ }
12989
+ rotate() {
12990
+ this.stream.end();
12991
+ try {
12992
+ for (let i = this.keep - 1; i >= 1; i--) {
12993
+ try {
12994
+ renameSync3(`${this.path}.${i}`, `${this.path}.${i + 1}`);
12995
+ } catch {
12996
+ }
12997
+ }
12998
+ renameSync3(this.path, `${this.path}.1`);
12999
+ } catch {
13000
+ }
13001
+ this.stream = openAppend(this.path);
13002
+ this.size = sizeOf(this.path);
13003
+ }
13004
+ };
13005
+ function openAppend(path) {
13006
+ return createWriteStream("", { fd: openSync2(path, "a") });
13007
+ }
13008
+ function sizeOf(path) {
13009
+ try {
13010
+ return statSync2(path).size;
13011
+ } catch {
13012
+ return 0;
13013
+ }
13014
+ }
12949
13015
  var cached = null;
12950
13016
  var consoleLogging = true;
12951
13017
  var overrides = {};
@@ -12964,7 +13030,7 @@ function configureLogger(log, config) {
12964
13030
  function createLogger(destinations = {}, options = {}) {
12965
13031
  const path = companionLogPath();
12966
13032
  if (!destinations.file) mkdirSync5(dirname4(path), { recursive: true });
12967
- const file = destinations.file ?? createWriteStream(path, { flags: "a" });
13033
+ const file = destinations.file ?? new RotatingLog(path);
12968
13034
  const state = { format: options.logFormat ?? "human" };
12969
13035
  const streams = [];
12970
13036
  const human = (chunk) => {
@@ -13022,7 +13088,7 @@ import { serve } from "@hono/node-server";
13022
13088
  import { Hono } from "hono";
13023
13089
 
13024
13090
  // src/dashboard/routes.ts
13025
- import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync6 } from "fs";
13091
+ import { openSync as openSync3, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync6 } from "fs";
13026
13092
  import { readFile } from "fs/promises";
13027
13093
  import { extname, join as join9, normalize } from "path";
13028
13094
  import { streamSSE } from "hono/streaming";
@@ -13406,7 +13472,7 @@ function tailFile(path, lines2) {
13406
13472
  const MAX_BYTES = 256 * 1024;
13407
13473
  let fd;
13408
13474
  try {
13409
- fd = openSync2(path, "r");
13475
+ fd = openSync3(path, "r");
13410
13476
  const size = fstatSync(fd).size;
13411
13477
  const start2 = Math.max(0, size - MAX_BYTES);
13412
13478
  const len = size - start2;
@@ -13657,7 +13723,7 @@ init_config();
13657
13723
 
13658
13724
  // src/dispatch-dedupe.ts
13659
13725
  init_config();
13660
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
13726
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync7 } from "fs";
13661
13727
  import { join as join11 } from "path";
13662
13728
  var MAX_IDS = 256;
13663
13729
  function dir(log) {
@@ -13775,7 +13841,7 @@ function writeTurnIds(workspaceId, turns) {
13775
13841
  const path = turnPathFor(workspaceId);
13776
13842
  const tmp = `${path}.${process.pid}.tmp`;
13777
13843
  writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
13778
- renameSync3(tmp, path);
13844
+ renameSync4(tmp, path);
13779
13845
  return entries.length;
13780
13846
  }
13781
13847
  function rememberTurnId(workspaceId, eventId, turnId) {
@@ -13971,9 +14037,9 @@ import {
13971
14037
  mkdirSync as mkdirSync10,
13972
14038
  readdirSync as readdirSync4,
13973
14039
  readFileSync as readFileSync9,
13974
- renameSync as renameSync5,
14040
+ renameSync as renameSync6,
13975
14041
  rmSync as rmSync9,
13976
- statSync as statSync3,
14042
+ statSync as statSync4,
13977
14043
  writeFileSync as writeFileSync6
13978
14044
  } from "fs";
13979
14045
  import { join as join20 } from "path";
@@ -14046,7 +14112,7 @@ import {
14046
14112
  mkdirSync as mkdirSync9,
14047
14113
  readdirSync as readdirSync3,
14048
14114
  readFileSync as readFileSync8,
14049
- renameSync as renameSync4,
14115
+ renameSync as renameSync5,
14050
14116
  rmSync as rmSync8
14051
14117
  } from "fs";
14052
14118
  import { join as join18 } from "path";
@@ -14082,11 +14148,11 @@ function admitCollectedEntry(raw, turn) {
14082
14148
  }
14083
14149
  var atomicMove = (from, to) => {
14084
14150
  try {
14085
- renameSync4(from, to);
14151
+ renameSync5(from, to);
14086
14152
  } catch {
14087
14153
  const partial = `${to}.partial`;
14088
14154
  copyFileSync2(from, partial);
14089
- renameSync4(partial, to);
14155
+ renameSync5(partial, to);
14090
14156
  rmSync8(from, { force: true });
14091
14157
  }
14092
14158
  };
@@ -14330,6 +14396,25 @@ function pruneOtherAttempts(deviceDir, pair2, keep) {
14330
14396
  }
14331
14397
  }
14332
14398
  }
14399
+ var IDLE_SESSION_ARCHIVE_MS = 30 * 24 * 60 * 6e4;
14400
+ function pruneIdleSessionArchives(deviceDir, now = Date.now(), maxAgeMs = IDLE_SESSION_ARCHIVE_MS) {
14401
+ const root = join20(deviceDir, "attempts");
14402
+ if (!existsSync14(root)) return [];
14403
+ const removed = [];
14404
+ for (const id of readdirSync4(root)) {
14405
+ if (!UUID_RE.test(id)) continue;
14406
+ let kept;
14407
+ try {
14408
+ kept = statSync4(join20(root, id)).mtimeMs;
14409
+ } catch {
14410
+ continue;
14411
+ }
14412
+ if (now - kept < maxAgeMs) continue;
14413
+ rmSync9(join20(root, id), { recursive: true, force: true });
14414
+ removed.push(id);
14415
+ }
14416
+ return removed;
14417
+ }
14333
14418
  var CappedFile = class extends Writable {
14334
14419
  constructor(path, cap) {
14335
14420
  super();
@@ -14707,7 +14792,7 @@ function createDockerProvider(opts) {
14707
14792
  (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
14708
14793
  );
14709
14794
  if (r.code === 0 && !file.over) {
14710
- renameSync5(partial, archive);
14795
+ renameSync6(partial, archive);
14711
14796
  writeFileSync6(join20(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
14712
14797
  pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
14713
14798
  await writeMarker(name, attemptId);
@@ -14844,7 +14929,7 @@ function createDockerProvider(opts) {
14844
14929
  );
14845
14930
  } else {
14846
14931
  log.info(
14847
- { container: name, attemptId, bytes: statSync3(archive).size },
14932
+ { container: name, attemptId, bytes: statSync4(archive).size },
14848
14933
  "runners: session files restored"
14849
14934
  );
14850
14935
  }
@@ -15308,6 +15393,12 @@ var SseSubscriber = class {
15308
15393
  this.aborted = true;
15309
15394
  this.controller?.abort();
15310
15395
  }
15396
+ // Connect again after a `stop()`, once its loop has exited.
15397
+ async resume() {
15398
+ await this.finished;
15399
+ this.aborted = false;
15400
+ this.start();
15401
+ }
15311
15402
  setCursor(eventId) {
15312
15403
  this.lastEventId = eventId;
15313
15404
  }
@@ -15396,9 +15487,564 @@ function sleep4(ms) {
15396
15487
 
15397
15488
  // src/supervisor.ts
15398
15489
  init_transcript_writer();
15490
+
15491
+ // src/updater.ts
15492
+ import { spawn as spawn8 } from "child_process";
15493
+ import { copyFileSync as copyFileSync3, existsSync as existsSync17, mkdirSync as mkdirSync16, renameSync as renameSync11, rmSync as rmSync14 } from "fs";
15494
+ import { dirname as dirname12, join as join26 } from "path";
15495
+
15496
+ // src/boot-rule.ts
15497
+ import {
15498
+ mkdirSync as mkdirSync13,
15499
+ readFileSync as readFileSync12,
15500
+ realpathSync as realpathSync5,
15501
+ renameSync as renameSync8,
15502
+ rmSync as rmSync12,
15503
+ symlinkSync,
15504
+ writeFileSync as writeFileSync9
15505
+ } from "fs";
15506
+ import { dirname as dirname9, join as join23 } from "path";
15507
+ var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
15508
+ var MAX_STAGE_ATTEMPTS = 3;
15509
+ function superseded(pending, ...running) {
15510
+ return !!pending && !running.includes(pending.to);
15511
+ }
15512
+ function rolledBackState(state, pending, reason, now) {
15513
+ const { pending: _done, ...rest } = state;
15514
+ return {
15515
+ ...rest,
15516
+ lastResult: {
15517
+ kind: "rolled_back",
15518
+ from: pending.from,
15519
+ to: pending.to,
15520
+ at: now.toISOString(),
15521
+ reason: reason.replace(/\s+/g, " ").trim().slice(0, 400)
15522
+ },
15523
+ failed: { version: pending.to, attempts: MAX_STAGE_ATTEMPTS, at: now.toISOString() }
15524
+ };
15525
+ }
15526
+
15527
+ // src/install-layout.ts
15528
+ init_config();
15529
+ import {
15530
+ existsSync as existsSync16,
15531
+ lstatSync,
15532
+ mkdirSync as mkdirSync14,
15533
+ readFileSync as readFileSync13,
15534
+ readdirSync as readdirSync6,
15535
+ realpathSync as realpathSync6,
15536
+ renameSync as renameSync9,
15537
+ rmSync as rmSync13,
15538
+ symlinkSync as symlinkSync2
15539
+ } from "fs";
15540
+ import { dirname as dirname10, join as join24 } from "path";
15541
+ import { fileURLToPath as fileURLToPath6 } from "url";
15542
+ function versionsDir() {
15543
+ return join24(cabaneDir(), "versions");
15544
+ }
15545
+ function currentLink() {
15546
+ return join24(cabaneDir(), "current");
15547
+ }
15548
+ function bootGuardPath() {
15549
+ return join24(cabaneDir(), "boot-guard.mjs");
15550
+ }
15551
+ function pinnedNodeDir() {
15552
+ return join24(cabaneDir(), "node");
15553
+ }
15554
+ function pinnedNodePath() {
15555
+ return join24(pinnedNodeDir(), "bin", "node");
15556
+ }
15557
+ function versionPackageRoot(version) {
15558
+ return join24(versionsDir(), version, "node_modules", "@cabane", "companion");
15559
+ }
15560
+ function ownPackageRoot(moduleUrl = import.meta.url) {
15561
+ return dirname10(dirname10(fileURLToPath6(moduleUrl)));
15562
+ }
15563
+ function packageVersion(root) {
15564
+ try {
15565
+ const pkg2 = JSON.parse(readFileSync13(join24(root, "package.json"), "utf8"));
15566
+ return typeof pkg2.version === "string" ? pkg2.version : null;
15567
+ } catch {
15568
+ return null;
15569
+ }
15570
+ }
15571
+ function currentRoot() {
15572
+ try {
15573
+ const root = realpathSync6(currentLink());
15574
+ return existsSync16(join24(root, "dist", "cli.js")) ? root : null;
15575
+ } catch {
15576
+ return null;
15577
+ }
15578
+ }
15579
+ function pointCurrentAt(root) {
15580
+ const link = currentLink();
15581
+ mkdirSync14(dirname10(link), { recursive: true });
15582
+ const fresh = `${link}.${process.pid}.${Date.now()}`;
15583
+ symlinkSync2(root, fresh);
15584
+ try {
15585
+ renameSync9(fresh, link);
15586
+ } catch (err) {
15587
+ rmSync13(fresh, { force: true });
15588
+ throw err;
15589
+ }
15590
+ }
15591
+ function pruneVersions(keep) {
15592
+ const dir2 = versionsDir();
15593
+ if (!existsSync16(dir2)) return [];
15594
+ const removed = [];
15595
+ for (const name of readdirSync6(dir2)) {
15596
+ if (keep.includes(name)) continue;
15597
+ rmSync13(join24(dir2, name), { recursive: true, force: true });
15598
+ removed.push(name);
15599
+ }
15600
+ return removed;
15601
+ }
15602
+ function compareVersions(a, b) {
15603
+ const parts = (v) => /^\d+(\.\d+)*$/.test(v) ? v.split(".").map(Number) : null;
15604
+ const pa = parts(a);
15605
+ const pb = parts(b);
15606
+ if (!pa || !pb) return pa ? 1 : pb ? -1 : 0;
15607
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
15608
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
15609
+ if (d !== 0) return d;
15610
+ }
15611
+ return 0;
15612
+ }
15613
+
15614
+ // src/update-state.ts
15615
+ init_config();
15616
+ import { mkdirSync as mkdirSync15, readFileSync as readFileSync14, renameSync as renameSync10, writeFileSync as writeFileSync10 } from "fs";
15617
+ import { dirname as dirname11, join as join25 } from "path";
15618
+ import { z as z18 } from "zod";
15619
+ var resultSchema = z18.object({
15620
+ kind: z18.enum(["updated", "rolled_back", "failed"]),
15621
+ from: z18.string(),
15622
+ to: z18.string(),
15623
+ at: z18.string(),
15624
+ reason: z18.string().optional()
15625
+ });
15626
+ var pendingSchema = z18.object({
15627
+ from: z18.string(),
15628
+ // The package root `current` pointed at before the swap, so a rollback can point it back.
15629
+ fromRoot: z18.string(),
15630
+ to: z18.string(),
15631
+ at: z18.string(),
15632
+ boots: z18.number().int().nonnegative()
15633
+ });
15634
+ var stateSchema = z18.object({
15635
+ server: z18.object({ latest: z18.string(), minimum: z18.string(), at: z18.string() }).optional(),
15636
+ pending: pendingSchema.optional(),
15637
+ lastResult: resultSchema.optional(),
15638
+ failed: z18.object({ version: z18.string(), attempts: z18.number().int(), at: z18.string() }).optional()
15639
+ });
15640
+ function updateStatePath(dir2 = cabaneDir()) {
15641
+ return join25(dir2, "update.json");
15642
+ }
15643
+ function readUpdateState(path = updateStatePath()) {
15644
+ try {
15645
+ const parsed = stateSchema.safeParse(JSON.parse(readFileSync14(path, "utf8")));
15646
+ return parsed.success ? parsed.data : {};
15647
+ } catch {
15648
+ return {};
15649
+ }
15650
+ }
15651
+ function writeUpdateState(state, path = updateStatePath()) {
15652
+ mkdirSync15(dirname11(path), { recursive: true });
15653
+ const tmp = `${path}.${process.pid}.tmp`;
15654
+ writeFileSync10(tmp, `${JSON.stringify(state, null, 2)}
15655
+ `, { mode: 384 });
15656
+ renameSync10(tmp, path);
15657
+ }
15658
+ function updateUpdateState(change, path = updateStatePath()) {
15659
+ const next = change(readUpdateState(path));
15660
+ writeUpdateState(next, path);
15661
+ return next;
15662
+ }
15663
+
15664
+ // src/updater.ts
15665
+ function updateMode(env = process.env) {
15666
+ return env.CABANE_COMPANION_SUPERVISED === "1" ? "supervised" : "attached";
15667
+ }
15668
+ var STAGE_RETRY_MS = 60 * 6e4;
15669
+ var SWAP_DRAIN_MS = 1e4;
15670
+ var SOURCE_BUILD_VERSION = "0.0.0";
15671
+ function versionToStage(input) {
15672
+ const { own, state, now, force } = input;
15673
+ if (!input.auto && !force) return null;
15674
+ if (own === SOURCE_BUILD_VERSION) return null;
15675
+ const latest = state.server?.latest;
15676
+ if (!latest || compareVersions(latest, own) <= 0) return null;
15677
+ if (state.pending) return null;
15678
+ const failed = state.failed;
15679
+ if (!force && failed?.version === latest) {
15680
+ if (failed.attempts >= MAX_STAGE_ATTEMPTS) return null;
15681
+ if (now - Date.parse(failed.at) < STAGE_RETRY_MS) return null;
15682
+ }
15683
+ return latest;
15684
+ }
15685
+ function rollBack(pending, reason, now = /* @__PURE__ */ new Date()) {
15686
+ if (existsSync17(pending.fromRoot)) pointCurrentAt(pending.fromRoot);
15687
+ return updateUpdateState((state) => rolledBackState(state, pending, reason, now));
15688
+ }
15689
+ function writeBootGuard(root = ownPackageRoot()) {
15690
+ const built = join26(root, "dist", "boot-guard.js");
15691
+ if (!existsSync17(built)) return false;
15692
+ const target = bootGuardPath();
15693
+ mkdirSync16(dirname12(target), { recursive: true });
15694
+ const tmp = `${target}.${process.pid}.tmp`;
15695
+ copyFileSync3(built, tmp);
15696
+ renameSync11(tmp, target);
15697
+ return true;
15698
+ }
15699
+ function oneLine(text) {
15700
+ return text.replace(/\s+/g, " ").trim().slice(0, 400);
15701
+ }
15702
+ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
15703
+ let stdout = "";
15704
+ let stderr = "";
15705
+ let child;
15706
+ try {
15707
+ child = spawn8(command, [...args], {
15708
+ stdio: ["ignore", "pipe", "pipe"],
15709
+ env: opts.env ?? process.env
15710
+ });
15711
+ } catch (err) {
15712
+ resolve2({ code: null, stdout, stderr: err instanceof Error ? err.message : String(err) });
15713
+ return;
15714
+ }
15715
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : null;
15716
+ child.stdout.on("data", (d) => stdout += d.toString());
15717
+ child.stderr.on("data", (d) => stderr += d.toString());
15718
+ child.on("error", (err) => {
15719
+ if (timer) clearTimeout(timer);
15720
+ resolve2({ code: null, stdout, stderr: stderr || err.message });
15721
+ });
15722
+ child.on("close", (code) => {
15723
+ if (timer) clearTimeout(timer);
15724
+ resolve2({ code, stdout, stderr });
15725
+ });
15726
+ });
15727
+ async function stageVersion(version, harnesses, deps = {}) {
15728
+ const run = deps.run ?? runProcess;
15729
+ const node = deps.execPath ?? process.execPath;
15730
+ const target = join26(versionsDir(), version);
15731
+ const placed = versionPackageRoot(version);
15732
+ if (existsSync17(placed)) {
15733
+ if (!await verifyInstall(placed, version, harnesses, { run, execPath: node }))
15734
+ return { ok: true, root: placed };
15735
+ }
15736
+ const npm = join26(dirname12(node), "npm");
15737
+ if (!existsSync17(npm))
15738
+ return { ok: false, reason: `there is no npm beside Node at ${dirname12(node)}` };
15739
+ mkdirSync16(versionsDir(), { recursive: true });
15740
+ const staging = join26(versionsDir(), `.${version}.staging-${process.pid}`);
15741
+ rmSync14(staging, { recursive: true, force: true });
15742
+ const installed = await run(
15743
+ npm,
15744
+ [
15745
+ "install",
15746
+ "--prefix",
15747
+ staging,
15748
+ "--include=optional",
15749
+ "--no-audit",
15750
+ "--no-fund",
15751
+ "--no-save",
15752
+ "--loglevel=error",
15753
+ `@cabane/companion@${version}`
15754
+ ],
15755
+ {
15756
+ // npm's install scripts run on this Node, not whichever `node` is first on PATH.
15757
+ env: { ...process.env, PATH: `${dirname12(node)}:${process.env.PATH ?? ""}` },
15758
+ timeoutMs: 15 * 6e4
15759
+ }
15760
+ );
15761
+ if (installed.code !== 0) {
15762
+ rmSync14(staging, { recursive: true, force: true });
15763
+ if (/\bE404\b|\bETARGET\b|No matching version found|is not in this registry/i.test(
15764
+ installed.stderr
15765
+ ))
15766
+ return { ok: false, reason: `version ${version} isn't published yet`, unpublished: true };
15767
+ return { ok: false, reason: `npm couldn't install it: ${lastLine(installed.stderr)}` };
15768
+ }
15769
+ const root = join26(staging, "node_modules", "@cabane", "companion");
15770
+ const verdict = await verifyInstall(root, version, harnesses, { run, execPath: node });
15771
+ if (verdict) {
15772
+ rmSync14(staging, { recursive: true, force: true });
15773
+ return { ok: false, reason: verdict };
15774
+ }
15775
+ rmSync14(target, { recursive: true, force: true });
15776
+ renameSync11(staging, target);
15777
+ return { ok: true, root: versionPackageRoot(version) };
15778
+ }
15779
+ async function verifyInstall(root, version, harnesses, deps) {
15780
+ if (packageVersion(root) !== version) return `the installed package isn't version ${version}`;
15781
+ const answered = await deps.run(deps.execPath, [join26(root, "dist", "bin.js"), "--version"], {
15782
+ timeoutMs: 6e4,
15783
+ env: { ...process.env, CABANE_LAUNCH_SELF: "1" }
15784
+ });
15785
+ if (answered.code !== 0 || answered.stdout.trim() !== version) {
15786
+ return `it doesn't start: ${lastLine(answered.stderr) || "no version printed"}`;
15787
+ }
15788
+ if (harnesses.length === 0) return null;
15789
+ const probed = await deps.run(deps.execPath, [join26(root, "dist", "postinstall.js"), "--json"], {
15790
+ timeoutMs: 6e4
15791
+ });
15792
+ let resolutions = {};
15793
+ try {
15794
+ resolutions = JSON.parse(probed.stdout);
15795
+ } catch {
15796
+ return "it couldn't say which bundled agents it has";
15797
+ }
15798
+ const missing = harnesses.filter((h) => resolutions[h]?.status !== "present");
15799
+ if (missing.length > 0) {
15800
+ return `its bundled ${missing.map(harnessName).join(" and ")} ${missing.length > 1 ? "are" : "is"} missing`;
15801
+ }
15802
+ return null;
15803
+ }
15804
+ function harnessName(h) {
15805
+ return h === "claude-code" ? "Claude Code" : "Codex";
15806
+ }
15807
+ function lastLine(text) {
15808
+ const lines2 = text.split("\n").map((l) => l.trim()).filter(Boolean);
15809
+ return lines2.at(-1)?.slice(0, 300) ?? "";
15810
+ }
15811
+ var CompanionUpdater = class {
15812
+ constructor(deps) {
15813
+ this.deps = deps;
15814
+ this.now = deps.now ?? Date.now;
15815
+ }
15816
+ deps;
15817
+ staging = null;
15818
+ ready = null;
15819
+ swapping = false;
15820
+ watchdog = null;
15821
+ now;
15822
+ // At start: a recorded swap to a version that will never start again (someone installed or
15823
+ // chose another since) is dropped, or nothing would ever confirm it and no later update would
15824
+ // be installed.
15825
+ start() {
15826
+ const pending = readUpdateState().pending;
15827
+ const current = currentRoot();
15828
+ const currentVersion = current ? packageVersion(current) : null;
15829
+ if (superseded(pending, this.deps.own, ...currentVersion ? [currentVersion] : [])) {
15830
+ updateUpdateState(({ pending: _gone, ...rest }) => rest);
15831
+ this.deps.log.info(
15832
+ { to: pending.to, own: this.deps.own },
15833
+ `Dropped the update to ${pending.to}: Cabane ${this.deps.own} was installed since`
15834
+ );
15835
+ }
15836
+ }
15837
+ // The deadline for an update this process is the result of: ten minutes from the first time
15838
+ // Cabane answers it without its stream connecting. Until Cabane answers, there's no verdict on
15839
+ // time: a server or network that is down says nothing about the version.
15840
+ armDeadline() {
15841
+ if (this.watchdog) return;
15842
+ this.watchdog = setTimeout(() => void this.giveUp(), UNCONFIRMED_WINDOW_MS);
15843
+ this.watchdog.unref?.();
15844
+ }
15845
+ stop() {
15846
+ if (this.watchdog) clearTimeout(this.watchdog);
15847
+ this.watchdog = null;
15848
+ }
15849
+ async giveUp() {
15850
+ const pending = readUpdateState().pending;
15851
+ if (!pending || pending.to !== this.deps.own) return;
15852
+ this.deps.log.warn(
15853
+ { from: pending.from, to: pending.to },
15854
+ `Cabane ${pending.to} didn't connect within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes of Cabane answering; going back to ${pending.from}`
15855
+ );
15856
+ rollBack(
15857
+ pending,
15858
+ `it didn't connect to Cabane within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes`
15859
+ );
15860
+ await this.deps.restart();
15861
+ }
15862
+ // Auto-update is on and something will restart this companion into the new version.
15863
+ updatesItself() {
15864
+ return this.deps.mode === "supervised" && this.deps.autoUpdate();
15865
+ }
15866
+ // What the heartbeat reports.
15867
+ report() {
15868
+ const last = readUpdateState().lastResult;
15869
+ return { auto: this.updatesItself(), ...last ? { lastResult: last } : {} };
15870
+ }
15871
+ // A heartbeat landed. Record what the server named; confirm an update this process is the
15872
+ // result of; then, when there is something newer, install it and swap when idle.
15873
+ async onHeartbeat(server, streamConnected) {
15874
+ const state = updateUpdateState((s) => ({
15875
+ ...s,
15876
+ ...server ? { server: { ...server, at: new Date(this.now()).toISOString() } } : {}
15877
+ }));
15878
+ if (state.pending?.to === this.deps.own) {
15879
+ if (streamConnected) this.confirm(state.pending);
15880
+ else this.armDeadline();
15881
+ }
15882
+ if (this.ready) {
15883
+ await this.trySwap();
15884
+ return;
15885
+ }
15886
+ this.maybeStage(false);
15887
+ }
15888
+ confirm(pending) {
15889
+ this.stop();
15890
+ updateUpdateState((s) => {
15891
+ const { pending: _done, failed: _failed, ...rest } = s;
15892
+ return {
15893
+ ...rest,
15894
+ lastResult: {
15895
+ kind: "updated",
15896
+ from: pending.from,
15897
+ to: pending.to,
15898
+ at: new Date(this.now()).toISOString()
15899
+ }
15900
+ };
15901
+ });
15902
+ const removed = pruneVersions([pending.to, pending.from]);
15903
+ this.deps.log.info(
15904
+ { from: pending.from, to: pending.to, removed },
15905
+ `Updated Cabane from ${pending.from} to ${pending.to}`
15906
+ );
15907
+ }
15908
+ // `cabane update`, from the person: install the latest now, whatever the setting.
15909
+ async updateNow() {
15910
+ const state = readUpdateState();
15911
+ const latest = state.server?.latest;
15912
+ if (!latest) return { ok: false, message: "Cabane hasn't said which version is latest yet." };
15913
+ if (compareVersions(latest, this.deps.own) <= 0)
15914
+ return { ok: true, message: `Cabane ${this.deps.own} is up to date.` };
15915
+ if (state.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
15916
+ if (this.ready === latest) {
15917
+ void this.trySwap();
15918
+ return { ok: true, message: this.waitingMessage(latest) };
15919
+ }
15920
+ this.maybeStage(true);
15921
+ if (this.staging) await this.staging;
15922
+ if (this.ready !== latest) {
15923
+ const after = readUpdateState();
15924
+ if (after.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
15925
+ if (after.failed?.version === latest && after.failed.attempts === 0)
15926
+ return { ok: false, message: `Cabane ${latest} isn't published yet. Try again later.` };
15927
+ const failed = after.lastResult;
15928
+ return {
15929
+ ok: false,
15930
+ 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.`
15931
+ };
15932
+ }
15933
+ void this.trySwap();
15934
+ return { ok: true, message: this.waitingMessage(latest) };
15935
+ }
15936
+ waitingMessage(version) {
15937
+ if (this.deps.mode === "attached")
15938
+ return `Cabane ${version} is installed. It runs from the next cabane start.`;
15939
+ return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} once the turns running now finish.`;
15940
+ }
15941
+ maybeStage(force) {
15942
+ if (this.staging) return;
15943
+ const version = versionToStage({
15944
+ auto: this.updatesItself(),
15945
+ own: this.deps.own,
15946
+ state: readUpdateState(),
15947
+ now: this.now(),
15948
+ force
15949
+ });
15950
+ if (!version) return;
15951
+ const stage = this.deps.stage ?? stageVersion;
15952
+ this.staging = (async () => {
15953
+ this.deps.log.info({ version }, `Installing Cabane ${version} beside this one`);
15954
+ const outcome = await stage(version, this.deps.harnesses());
15955
+ if (outcome.ok) {
15956
+ this.ready = version;
15957
+ updateUpdateState((s) => {
15958
+ const { failed: _failed, ...rest } = s;
15959
+ return rest;
15960
+ });
15961
+ return;
15962
+ }
15963
+ if (outcome.unpublished) {
15964
+ this.deps.log.info({ version }, `Cabane ${version} isn't published yet; trying later`);
15965
+ updateUpdateState((s) => ({
15966
+ ...s,
15967
+ failed: { version, attempts: 0, at: new Date(this.now()).toISOString() }
15968
+ }));
15969
+ return;
15970
+ }
15971
+ this.deps.log.warn({ version, reason: outcome.reason }, `Couldn't install Cabane ${version}`);
15972
+ updateUpdateState((s) => {
15973
+ const attempts = s.failed?.version === version ? s.failed.attempts + 1 : 1;
15974
+ const at = new Date(this.now()).toISOString();
15975
+ return {
15976
+ ...s,
15977
+ failed: { version, attempts, at },
15978
+ // Reported once it has failed for good, so a registry blip isn't an email.
15979
+ ...attempts >= MAX_STAGE_ATTEMPTS || force ? {
15980
+ lastResult: {
15981
+ kind: "failed",
15982
+ from: this.deps.own,
15983
+ to: version,
15984
+ at,
15985
+ reason: oneLine(outcome.reason)
15986
+ }
15987
+ } : {}
15988
+ };
15989
+ });
15990
+ })().finally(() => {
15991
+ this.staging = null;
15992
+ });
15993
+ void this.staging.then(() => this.trySwap());
15994
+ }
15995
+ // Swap into the ready version once nothing is running. Called on each heartbeat until it can.
15996
+ async trySwap() {
15997
+ const to = this.ready;
15998
+ if (!to || this.swapping) return;
15999
+ if (readUpdateState().pending) return;
16000
+ if (this.deps.mode === "attached") {
16001
+ this.select(to);
16002
+ this.deps.log.info({ version: to }, `Cabane ${to} is installed; it runs from the next start`);
16003
+ return;
16004
+ }
16005
+ if (!this.deps.isIdle()) return;
16006
+ this.swapping = true;
16007
+ try {
16008
+ await this.deps.pauseStream();
16009
+ const sleep5 = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
16010
+ const deadline = this.now() + SWAP_DRAIN_MS;
16011
+ await sleep5(250);
16012
+ while (!this.deps.isIdle() && this.now() < deadline) await sleep5(250);
16013
+ if (!this.deps.isIdle()) {
16014
+ this.deps.resumeStream();
16015
+ return;
16016
+ }
16017
+ this.select(to);
16018
+ this.deps.log.info({ from: this.deps.own, to }, `Restarting into Cabane ${to}`);
16019
+ await this.deps.restart();
16020
+ } finally {
16021
+ this.swapping = false;
16022
+ }
16023
+ }
16024
+ // Record the swap and point `current` at `to`, leaving the guard that counts its starts.
16025
+ select(to) {
16026
+ const fromRoot = currentRoot() ?? ownPackageRoot();
16027
+ writeBootGuard();
16028
+ updateUpdateState((s) => ({
16029
+ ...s,
16030
+ pending: {
16031
+ from: this.deps.own,
16032
+ fromRoot,
16033
+ to,
16034
+ at: new Date(this.now()).toISOString(),
16035
+ boots: 0
16036
+ }
16037
+ }));
16038
+ pointCurrentAt(versionPackageRoot(to));
16039
+ this.ready = null;
16040
+ }
16041
+ };
16042
+
16043
+ // src/supervisor.ts
15399
16044
  init_version2();
15400
16045
  init_src();
15401
16046
  var HEARTBEAT_INTERVAL_MS = 3e4;
16047
+ var ARCHIVE_PRUNE_MS = 24 * 60 * 6e4;
15402
16048
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
15403
16049
  function positiveIntEnv(name) {
15404
16050
  const raw = process.env[name];
@@ -15480,6 +16126,10 @@ var CompanionSupervisor = class {
15480
16126
  // Which dispatches this process is running, so a redelivered frame doesn't
15481
16127
  // run the turn a second time.
15482
16128
  claims = new DispatchClaims();
16129
+ archivePruneTimer = null;
16130
+ // Keeps this companion current when its person said so; null until paired.
16131
+ updater = null;
16132
+ restartForUpdate;
15483
16133
  constructor(opts) {
15484
16134
  this.config = opts.config;
15485
16135
  this.log = opts.log;
@@ -15489,6 +16139,10 @@ var CompanionSupervisor = class {
15489
16139
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
15490
16140
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
15491
16141
  this.reexecFn = opts.reexec ?? defaultReexec;
16142
+ this.restartForUpdate = opts.restartForUpdate ?? (async () => {
16143
+ await this.shutdown();
16144
+ this.exitFn(0);
16145
+ });
15492
16146
  this.dispatcherFactory = opts.dispatcherFactory;
15493
16147
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
15494
16148
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
@@ -15539,6 +16193,9 @@ var CompanionSupervisor = class {
15539
16193
  if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
15540
16194
  }
15541
16195
  this.latchBootBundledState();
16196
+ this.pruneSessionArchives();
16197
+ this.archivePruneTimer = setInterval(() => this.pruneSessionArchives(), ARCHIVE_PRUNE_MS);
16198
+ this.archivePruneTimer.unref?.();
15542
16199
  }
15543
16200
  const sandboxRows = agentSandboxRows(this.config);
15544
16201
  if (sandboxRows.length > 0 && this.dockerRunners()) {
@@ -15548,7 +16205,7 @@ var CompanionSupervisor = class {
15548
16205
  );
15549
16206
  }
15550
16207
  const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
15551
- (row2) => this.runnerSandboxFor(row2) === "os"
16208
+ (row3) => this.runnerSandboxFor(row3) === "os"
15552
16209
  );
15553
16210
  if (anySandboxed) {
15554
16211
  const policy = this.sandboxPolicy();
@@ -15602,6 +16259,23 @@ var CompanionSupervisor = class {
15602
16259
  );
15603
16260
  }
15604
16261
  });
16262
+ this.updater = new CompanionUpdater({
16263
+ own: COMPANION_VERSION,
16264
+ mode: updateMode(),
16265
+ autoUpdate: () => this.config.update?.auto === true,
16266
+ harnesses: () => this.connectedBundledHarnesses(),
16267
+ isIdle: () => this.idle(),
16268
+ pauseStream: async () => {
16269
+ const sub = this.deviceSub;
16270
+ if (!sub) return;
16271
+ sub.stop();
16272
+ await sub.finished;
16273
+ },
16274
+ resumeStream: () => void this.deviceSub?.resume(),
16275
+ restart: () => this.restartForUpdate(),
16276
+ log: this.log
16277
+ });
16278
+ this.updater.start();
15605
16279
  await this.refreshAssignments();
15606
16280
  this.deviceSub.start();
15607
16281
  const firstHeartbeat = startupHarnessRefresh.then(async () => {
@@ -15662,13 +16336,20 @@ var CompanionSupervisor = class {
15662
16336
  // Omitted until the first observed failure or heal, so the server's
15663
16337
  // status-less manifest synthesis still runs for a fresh companion.
15664
16338
  ...connectorReports.length > 0 ? { connectors: connectorReports } : {},
15665
- ...opencodeModels !== null ? { models: opencodeModels } : {}
16339
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
16340
+ ...this.updater ? { update: this.updater.report() } : {}
15666
16341
  });
15667
16342
  this.hub.setDevice({ deviceId: res.deviceId });
15668
16343
  this.deviceId = res.deviceId;
15669
16344
  this.checkVersionSkew(res.serverVersion);
15670
16345
  this.heartbeated = true;
15671
16346
  void this.recoverRuns();
16347
+ void this.updater?.onHeartbeat(res.companion, this.deviceConnected).catch(
16348
+ (err) => this.log.warn(
16349
+ { err: err instanceof Error ? err.message : String(err) },
16350
+ "Couldn't check for a Cabane update; trying again on the next check-in."
16351
+ )
16352
+ );
15672
16353
  } catch (err) {
15673
16354
  this.log.warn(
15674
16355
  { ...apiErrorLogFields(err) },
@@ -15991,8 +16672,8 @@ var CompanionSupervisor = class {
15991
16672
  // The sandbox one agent's turns run under: its `agents` row, then the
15992
16673
  // device's. Resolved from the current config on every ask, so a reload
15993
16674
  // changes it; only the host probe is kept.
15994
- runnerSandboxFor(row2) {
15995
- return runnerSandboxFor(this.config, row2, this.sandboxHost);
16675
+ runnerSandboxFor(row3) {
16676
+ return runnerSandboxFor(this.config, row3, this.sandboxHost);
15996
16677
  }
15997
16678
  // Resolved against the agents this device runs right now.
15998
16679
  runnerManifest() {
@@ -16699,9 +17380,54 @@ var CompanionSupervisor = class {
16699
17380
  }
16700
17381
  }
16701
17382
  }
17383
+ // A long-lived device keeps a session archive per conversation and agent it ever ran; those
17384
+ // nobody has used in a month go.
17385
+ pruneSessionArchives() {
17386
+ try {
17387
+ const removed = pruneIdleSessionArchives(cabaneDir());
17388
+ if (removed.length > 0)
17389
+ this.log.info({ removed: removed.length }, "runners: removed idle session archives");
17390
+ } catch (err) {
17391
+ this.log.warn(
17392
+ { err: err instanceof Error ? err.message : String(err) },
17393
+ "runners: couldn't remove idle session archives"
17394
+ );
17395
+ }
17396
+ }
17397
+ // Nothing running and nothing about to: no dispatch claimed, no turn chained, no recovery
17398
+ // in progress. What a self-update swap waits for.
17399
+ idle() {
17400
+ if (this.claims.size > 0 || this.recovering) return false;
17401
+ for (const wr of this.workspaces.values()) if (wr.chains.size > 0) return false;
17402
+ return true;
17403
+ }
17404
+ // The bundled harnesses this device has connected, which a new version must still carry.
17405
+ connectedBundledHarnesses() {
17406
+ return [
17407
+ ...isClaudeCodeConnected(this.config) ? ["claude-code"] : [],
17408
+ ...isCodexEnabled(this.config) ? ["codex"] : []
17409
+ ];
17410
+ }
17411
+ // `cabane update`, over the control socket: set whether this companion keeps itself current
17412
+ // (`auto`), or install the latest now.
17413
+ async update(auto) {
17414
+ if (auto !== void 0) {
17415
+ this.config = { ...this.config, update: { auto } };
17416
+ saveConfig(this.config);
17417
+ if (auto) this.kickHeartbeat();
17418
+ return {
17419
+ ok: true,
17420
+ 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."
17421
+ };
17422
+ }
17423
+ if (!this.updater) return { ok: false, message: "This computer isn't paired yet." };
17424
+ return this.updater.updateNow();
17425
+ }
16702
17426
  // ---- control actions ----
16703
17427
  async shutdown() {
16704
17428
  this.stopped = true;
17429
+ this.updater?.stop();
17430
+ if (this.archivePruneTimer) clearInterval(this.archivePruneTimer);
16705
17431
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
16706
17432
  if (this.pollTimer) clearInterval(this.pollTimer);
16707
17433
  const heartbeat = this.inFlightHeartbeat;
@@ -16736,9 +17462,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
16736
17462
  }
16737
17463
  function defaultReexec() {
16738
17464
  clearRuntimeState();
16739
- void import("child_process").then(({ spawn: spawn10 }) => {
17465
+ void import("child_process").then(({ spawn: spawn11 }) => {
16740
17466
  try {
16741
- const child = spawn10(process.execPath, process.argv.slice(1), {
17467
+ const child = spawn11(process.execPath, process.argv.slice(1), {
16742
17468
  stdio: "inherit",
16743
17469
  detached: false
16744
17470
  });
@@ -16812,22 +17538,22 @@ function handleUncaught(log, err, origin) {
16812
17538
 
16813
17539
  // src/crash-marker.ts
16814
17540
  init_config();
16815
- import { existsSync as existsSync16, mkdirSync as mkdirSync13, readFileSync as readFileSync12, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
16816
- import { join as join23 } from "path";
17541
+ import { existsSync as existsSync18, mkdirSync as mkdirSync17, readFileSync as readFileSync15, rmSync as rmSync15, writeFileSync as writeFileSync11 } from "fs";
17542
+ import { join as join27 } from "path";
16817
17543
  function crashMarkerPath() {
16818
- return join23(cabaneDir(), "last-error.json");
17544
+ return join27(cabaneDir(), "last-error.json");
16819
17545
  }
16820
17546
  function recordCrash(rec2) {
16821
17547
  try {
16822
- mkdirSync13(cabaneDir(), { recursive: true });
16823
- writeFileSync9(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
17548
+ mkdirSync17(cabaneDir(), { recursive: true });
17549
+ writeFileSync11(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
16824
17550
  } catch {
16825
17551
  }
16826
17552
  }
16827
17553
  function clearCrash() {
16828
17554
  try {
16829
17555
  const path = crashMarkerPath();
16830
- if (existsSync16(path)) rmSync12(path, { force: true });
17556
+ if (existsSync18(path)) rmSync15(path, { force: true });
16831
17557
  } catch {
16832
17558
  }
16833
17559
  }
@@ -16882,11 +17608,14 @@ async function createCompanionRuntime(opts = {}) {
16882
17608
  // process behind the marker pid is this companion (not a recycled pid).
16883
17609
  instanceId
16884
17610
  });
17611
+ let restartForUpdate = async () => {
17612
+ };
16885
17613
  const supervisor = new CompanionSupervisor({
16886
17614
  config: cfg,
16887
17615
  log,
16888
17616
  hub,
16889
- ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {}
17617
+ ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
17618
+ restartForUpdate: () => restartForUpdate()
16890
17619
  });
16891
17620
  await supervisor.start();
16892
17621
  const connectHarness = async (runtime, serverUrl) => {
@@ -16911,6 +17640,7 @@ async function createCompanionRuntime(opts = {}) {
16911
17640
  control = await startControlServer({
16912
17641
  status: () => hub.statusJson(),
16913
17642
  connect: (runtime, serverUrl) => connectHarness(runtime, serverUrl),
17643
+ update: (auto) => supervisor.update(auto),
16914
17644
  stop: () => void supervisor.requestStop()
16915
17645
  });
16916
17646
  if (opts.dashboard) {
@@ -16963,6 +17693,10 @@ async function createCompanionRuntime(opts = {}) {
16963
17693
  } catch {
16964
17694
  }
16965
17695
  };
17696
+ restartForUpdate = async () => {
17697
+ await stop2();
17698
+ process.exit(0);
17699
+ };
16966
17700
  return {
16967
17701
  ok: true,
16968
17702
  runtime: {
@@ -16984,41 +17718,437 @@ async function closeSurfaces(control, dashboard) {
16984
17718
  // src/commands/start.ts
16985
17719
  init_term();
16986
17720
 
16987
- // src/commands/daemon.ts
16988
- init_cabane_command();
16989
- init_cli_entry();
17721
+ // src/service.ts
16990
17722
  init_config();
16991
- import { spawn as spawn8 } from "child_process";
16992
- import { closeSync as closeSync3, mkdirSync as mkdirSync14, openSync as openSync3 } from "fs";
16993
- var STARTUP_TIMEOUT_MS = 8e3;
16994
- var POLL_INTERVAL_MS = 150;
16995
- async function startDaemon(opts = {}, deps = {}) {
16996
- const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
16997
- const readState = deps.readState ?? readLiveRuntimeState;
16998
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
16999
- const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
17000
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17001
- const now = deps.now ?? (() => Date.now());
17002
- const quiet = opts.report === "failures";
17003
- const cfg = requireStartConfig({
17004
- // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
17005
- ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
17006
- ...deps.save ? { save: deps.save } : {},
17007
- // The child won't log it (the block is written by then), and this process owns
17008
- // the user's terminal — so the one-time record is one line, here.
17009
- onMigrated: () => {
17010
- if (!quiet) {
17011
- process.stdout.write(
17012
- "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
17013
- );
17014
- }
17015
- }
17723
+ import { createHash as createHash6 } from "crypto";
17724
+ import {
17725
+ chmodSync as chmodSync7,
17726
+ cpSync,
17727
+ existsSync as existsSync19,
17728
+ mkdirSync as mkdirSync18,
17729
+ readFileSync as readFileSync16,
17730
+ renameSync as renameSync12,
17731
+ rmSync as rmSync16,
17732
+ writeFileSync as writeFileSync12
17733
+ } from "fs";
17734
+ import { homedir as homedir7, userInfo as userInfo3 } from "os";
17735
+ import { dirname as dirname13, join as join28, sep as sep5 } from "path";
17736
+ function servicePlatform(platform = process.platform) {
17737
+ if (platform === "linux") return "systemd";
17738
+ if (platform === "darwin") return "launchd";
17739
+ return null;
17740
+ }
17741
+ function serviceName(env = process.env) {
17742
+ const home = env.CABANE_COMPANION_HOME;
17743
+ if (!home) return "cabane-companion";
17744
+ return `cabane-companion-${createHash6("sha256").update(home).digest("hex").slice(0, 8)}`;
17745
+ }
17746
+ function launchdLabel(env = process.env) {
17747
+ return serviceName(env).replace(/^cabane-companion/, "ai.cabane.companion").replace("-", ".");
17748
+ }
17749
+ function serviceNode(execPath = process.execPath) {
17750
+ return existsSync19(pinnedNodePath()) ? pinnedNodePath() : execPath;
17751
+ }
17752
+ function servicePaths(platform, env = process.env, home = homedir7(), node = serviceNode()) {
17753
+ const unitFile = platform === "systemd" ? join28(
17754
+ env.XDG_CONFIG_HOME || join28(home, ".config"),
17755
+ "systemd",
17756
+ "user",
17757
+ `${serviceName(env)}.service`
17758
+ ) : join28(home, "Library", "LaunchAgents", `${launchdLabel(env)}.plist`);
17759
+ return {
17760
+ unitFile,
17761
+ envFile: join28(cabaneDir(), "service.env"),
17762
+ node,
17763
+ entry: bootGuardPath()
17764
+ };
17765
+ }
17766
+ function unitEnvironment(env) {
17767
+ return {
17768
+ CABANE_COMPANION_SUPERVISED: "1",
17769
+ ...env.CABANE_COMPANION_HOME ? { CABANE_COMPANION_HOME: env.CABANE_COMPANION_HOME } : {}
17770
+ };
17771
+ }
17772
+ function renderSystemdUnit(paths, env = process.env) {
17773
+ const environment = Object.entries(unitEnvironment(env)).map(
17774
+ ([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`
17775
+ );
17776
+ return [
17777
+ "# Written by `cabane service install`. Remove with `cabane service uninstall`.",
17778
+ "[Unit]",
17779
+ "Description=Cabane companion",
17780
+ "Wants=network-online.target",
17781
+ "After=network-online.target",
17782
+ // Never stop restarting: a machine nobody watches has nobody to reset a failed unit.
17783
+ "StartLimitIntervalSec=0",
17784
+ "",
17785
+ "[Service]",
17786
+ "Type=simple",
17787
+ `ExecStart=${systemdQuote(paths.node)} ${systemdQuote(paths.entry)} start --foreground`,
17788
+ "Restart=always",
17789
+ "RestartSec=5",
17790
+ ...environment,
17791
+ `EnvironmentFile=-${paths.envFile}`,
17792
+ "",
17793
+ "[Install]",
17794
+ "WantedBy=default.target",
17795
+ ""
17796
+ ].join("\n");
17797
+ }
17798
+ function systemdQuote(value) {
17799
+ return /[\s"\\]/.test(value) ? `"${value.replace(/(["\\])/g, "\\$1")}"` : value;
17800
+ }
17801
+ function renderLaunchdPlist(paths, env = process.env, servicePath) {
17802
+ const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17803
+ const vars = { ...unitEnvironment(env), PATH: servicePath };
17804
+ return [
17805
+ '<?xml version="1.0" encoding="UTF-8"?>',
17806
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
17807
+ "<!-- Written by `cabane service install`. Remove with `cabane service uninstall`. -->",
17808
+ '<plist version="1.0">',
17809
+ "<dict>",
17810
+ ` <key>Label</key><string>${xml(launchdLabel(env))}</string>`,
17811
+ " <key>ProgramArguments</key>",
17812
+ " <array>",
17813
+ ...[paths.node, paths.entry, "start", "--foreground"].map(
17814
+ (arg) => ` <string>${xml(arg)}</string>`
17815
+ ),
17816
+ " </array>",
17817
+ " <key>EnvironmentVariables</key>",
17818
+ " <dict>",
17819
+ ...Object.entries(vars).map(
17820
+ ([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`
17821
+ ),
17822
+ " </dict>",
17823
+ " <key>RunAtLoad</key><true/>",
17824
+ " <key>KeepAlive</key><true/>",
17825
+ " <key>ThrottleInterval</key><integer>5</integer>",
17826
+ // The companion writes its own log; the console copy would only grow a second one.
17827
+ " <key>StandardOutPath</key><string>/dev/null</string>",
17828
+ ` <key>StandardErrorPath</key><string>${xml(join28(cabaneDir(), "service-error.log"))}</string>`,
17829
+ "</dict>",
17830
+ "</plist>",
17831
+ ""
17832
+ ].join("\n");
17833
+ }
17834
+ function withServicePath(existing, path) {
17835
+ const kept = existing.split("\n").filter((line) => line.trim() && !/^\s*PATH\s*=/.test(line));
17836
+ return [
17837
+ "# The Cabane service reads this. PATH is your login shell's, written by `cabane service install`.",
17838
+ "# Add a headless sign-in here if this machine has no keychain, e.g. CLAUDE_CODE_OAUTH_TOKEN=\u2026",
17839
+ `PATH=${path}`,
17840
+ ...kept.filter(
17841
+ (line) => !line.startsWith("# The Cabane service reads this") && !line.startsWith("# Add a headless sign-in here")
17842
+ ),
17843
+ ""
17844
+ ].join("\n");
17845
+ }
17846
+ async function loginShellPath(run = runProcess, env = process.env) {
17847
+ const shell = env.SHELL;
17848
+ if (shell) {
17849
+ const r = await run(shell, ["-l", "-c", 'printf "%s" "$PATH"'], { timeoutMs: 1e4 });
17850
+ const path = r.stdout.trim().split("\n").at(-1)?.trim();
17851
+ if (r.code === 0 && path && path.includes("/")) return path;
17852
+ }
17853
+ return env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
17854
+ }
17855
+ async function installService(deps = {}) {
17856
+ const run = deps.run ?? runProcess;
17857
+ const env = deps.env ?? process.env;
17858
+ const platform = servicePlatform(deps.platform);
17859
+ if (!platform)
17860
+ return {
17861
+ ok: false,
17862
+ lines: [
17863
+ "Running Cabane as a service works on macOS and Linux. Start it with cabane start here."
17864
+ ]
17865
+ };
17866
+ const node = serviceNode(deps.execPath);
17867
+ const seeded = await seedManagedInstall({
17868
+ run,
17869
+ execPath: node,
17870
+ ...deps.own ? { own: deps.own } : {}
17016
17871
  });
17017
- ensureRuntime(cfg, {
17018
- // The onboarding script already said what's connectable, in its own
17019
- // words. Saying it again here, on stderr, in the middle of the closing block
17020
- // would be the same news twice.
17021
- ...quiet ? { warn: () => {
17872
+ if (!seeded.ok) return { ok: false, lines: [seeded.reason] };
17873
+ if (!writeBootGuard(seeded.root))
17874
+ return {
17875
+ ok: false,
17876
+ lines: [
17877
+ "This install has no boot guard to run the service with. Reinstall Cabane, then run cabane service install again."
17878
+ ]
17879
+ };
17880
+ rewriteScriptWrappers();
17881
+ const paths = servicePaths(platform, env, deps.home, node);
17882
+ const servicePath = await loginShellPath(run, env);
17883
+ mkdirSync18(dirname13(paths.envFile), { recursive: true });
17884
+ const existing = existsSync19(paths.envFile) ? readFileSync16(paths.envFile, "utf8") : "";
17885
+ writeFileSync12(paths.envFile, withServicePath(existing, servicePath), { mode: 384 });
17886
+ chmodSync7(paths.envFile, 384);
17887
+ mkdirSync18(dirname13(paths.unitFile), { recursive: true });
17888
+ const outcome = await writeAndStartUnit(platform, paths, env, servicePath, run);
17889
+ if (!outcome.ok) await removeUnit(platform, paths, env, run);
17890
+ return outcome;
17891
+ }
17892
+ async function writeAndStartUnit(platform, paths, env, servicePath, run) {
17893
+ const lines2 = [];
17894
+ if (platform === "systemd") {
17895
+ writeAtomic(paths.unitFile, renderSystemdUnit(paths, env));
17896
+ const unit = `${serviceName(env)}.service`;
17897
+ for (const args of [
17898
+ ["--user", "daemon-reload"],
17899
+ ["--user", "enable", unit],
17900
+ ["--user", "restart", unit]
17901
+ ]) {
17902
+ const r = await run("systemctl", args, { timeoutMs: 3e4 });
17903
+ if (r.code !== 0)
17904
+ return {
17905
+ ok: false,
17906
+ lines: [
17907
+ `systemctl ${args.join(" ")} failed: ${r.stderr.trim() || `exit ${r.code}`}`,
17908
+ "This needs a systemd user session. Start Cabane with cabane start instead."
17909
+ ]
17910
+ };
17911
+ }
17912
+ const linger = await run("loginctl", ["show-user", userName(), "--property=Linger"], {
17913
+ timeoutMs: 1e4
17914
+ });
17915
+ if (linger.code === 0 && /Linger=no/.test(linger.stdout)) {
17916
+ lines2.push(
17917
+ "! It stops when you log out of this computer. To keep it running after logout, run once:",
17918
+ ` sudo loginctl enable-linger ${userName()}`
17919
+ );
17920
+ }
17921
+ } else {
17922
+ writeAtomic(paths.unitFile, renderLaunchdPlist(paths, env, servicePath));
17923
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
17924
+ await run("launchctl", ["bootout", `${domain}/${launchdLabel(env)}`], { timeoutMs: 3e4 });
17925
+ const r = await run("launchctl", ["bootstrap", domain, paths.unitFile], { timeoutMs: 3e4 });
17926
+ if (r.code !== 0)
17927
+ return {
17928
+ ok: false,
17929
+ lines: [`launchctl bootstrap failed: ${r.stderr.trim() || `exit ${r.code}`}`]
17930
+ };
17931
+ }
17932
+ return { ok: true, lines: lines2 };
17933
+ }
17934
+ async function uninstallService(deps = {}) {
17935
+ const run = deps.run ?? runProcess;
17936
+ const env = deps.env ?? process.env;
17937
+ const platform = servicePlatform(deps.platform);
17938
+ if (!platform) return { ok: true, lines: [] };
17939
+ const paths = servicePaths(platform, env, deps.home);
17940
+ if (!existsSync19(paths.unitFile)) return { ok: true, lines: ["No Cabane service is installed."] };
17941
+ await removeUnit(platform, paths, env, run);
17942
+ return { ok: true, lines: [] };
17943
+ }
17944
+ async function removeUnit(platform, paths, env, run) {
17945
+ if (platform === "systemd") {
17946
+ await run("systemctl", ["--user", "disable", "--now", `${serviceName(env)}.service`], {
17947
+ timeoutMs: 3e4
17948
+ });
17949
+ rmSync16(paths.unitFile, { force: true });
17950
+ await run("systemctl", ["--user", "daemon-reload"], { timeoutMs: 3e4 });
17951
+ } else {
17952
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
17953
+ await run("launchctl", ["bootout", `${domain}/${launchdLabel(env)}`], { timeoutMs: 3e4 });
17954
+ rmSync16(paths.unitFile, { force: true });
17955
+ }
17956
+ }
17957
+ async function seedManagedInstall(deps) {
17958
+ const current = currentRoot();
17959
+ if (current && current.startsWith(versionsDir() + sep5)) return { ok: true, root: current };
17960
+ const own = deps.own ?? ownPackageRoot();
17961
+ const version = packageVersion(own);
17962
+ if (!version || version === "0.0.0") {
17963
+ pointCurrentAt(own);
17964
+ return { ok: true, root: own };
17965
+ }
17966
+ const target = versionPackageRoot(version);
17967
+ if (!existsSync19(target)) {
17968
+ const staging = join28(versionsDir(), `.${version}.seed-${process.pid}`);
17969
+ rmSync16(staging, { recursive: true, force: true });
17970
+ try {
17971
+ cpSync(own, join28(staging, "node_modules", "@cabane", "companion"), {
17972
+ recursive: true,
17973
+ verbatimSymlinks: true
17974
+ });
17975
+ rmSync16(join28(versionsDir(), version), { recursive: true, force: true });
17976
+ renameSync12(staging, join28(versionsDir(), version));
17977
+ } catch (err) {
17978
+ rmSync16(staging, { recursive: true, force: true });
17979
+ return {
17980
+ ok: false,
17981
+ reason: `Couldn't copy Cabane into ${tildeDir()}/versions for the service: ${err instanceof Error ? err.message : String(err)}`
17982
+ };
17983
+ }
17984
+ }
17985
+ const unrunnable = await verifyInstall(target, version, [], deps);
17986
+ if (unrunnable) {
17987
+ rmSync16(join28(versionsDir(), version), { recursive: true, force: true });
17988
+ return { ok: false, reason: `The copy of Cabane for the service can't run: ${unrunnable}.` };
17989
+ }
17990
+ pointCurrentAt(target);
17991
+ return { ok: true, root: target };
17992
+ }
17993
+ function tildeDir() {
17994
+ return cabaneDir().replace(homedir7(), "~");
17995
+ }
17996
+ function unitNode(platform, unitFile) {
17997
+ let text;
17998
+ try {
17999
+ text = readFileSync16(unitFile, "utf8");
18000
+ } catch {
18001
+ return null;
18002
+ }
18003
+ const found = platform === "systemd" ? /^ExecStart=("(?:[^"\\]|\\.)*"|\S+)/m.exec(text)?.[1] : /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
18004
+ if (!found) return null;
18005
+ return found.startsWith('"') ? found.slice(1, -1).replace(/\\(.)/g, "$1") : found;
18006
+ }
18007
+ async function serviceState(deps = {}) {
18008
+ const run = deps.run ?? runProcess;
18009
+ const env = deps.env ?? process.env;
18010
+ const platform = servicePlatform(deps.platform);
18011
+ const none = {
18012
+ installed: false,
18013
+ platform,
18014
+ state: "not installed",
18015
+ pid: null,
18016
+ node: null,
18017
+ nodeOk: true
18018
+ };
18019
+ if (!platform) return none;
18020
+ const paths = servicePaths(platform, env, deps.home);
18021
+ if (!existsSync19(paths.unitFile)) return none;
18022
+ const node = unitNode(platform, paths.unitFile);
18023
+ const nodeOk = node === null || existsSync19(node);
18024
+ if (platform === "systemd") {
18025
+ const r2 = await run(
18026
+ "systemctl",
18027
+ ["--user", "show", `${serviceName(env)}.service`, "--property=ActiveState,SubState,MainPID"],
18028
+ { timeoutMs: 1e4 }
18029
+ );
18030
+ const prop = (name) => new RegExp(`^${name}=(.*)$`, "m").exec(r2.stdout)?.[1] ?? "";
18031
+ const active = prop("ActiveState");
18032
+ const pid2 = Number(prop("MainPID")) || null;
18033
+ return {
18034
+ installed: true,
18035
+ platform,
18036
+ state: active === "active" ? "running" : prop("SubState") || active || "unknown",
18037
+ pid: pid2,
18038
+ node,
18039
+ nodeOk
18040
+ };
18041
+ }
18042
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
18043
+ const r = await run("launchctl", ["print", `${domain}/${launchdLabel(env)}`], {
18044
+ timeoutMs: 1e4
18045
+ });
18046
+ if (r.code !== 0)
18047
+ return { installed: true, platform, state: "not loaded", pid: null, node, nodeOk };
18048
+ const state = /^\s*state = (.+)$/m.exec(r.stdout)?.[1]?.trim() ?? "unknown";
18049
+ const pid = Number(/^\s*pid = (\d+)$/m.exec(r.stdout)?.[1]) || null;
18050
+ return { installed: true, platform, state, pid, node, nodeOk };
18051
+ }
18052
+ function serviceInstalled(deps = {}) {
18053
+ const platform = servicePlatform(deps.platform);
18054
+ if (!platform) return false;
18055
+ return existsSync19(servicePaths(platform, deps.env ?? process.env, deps.home).unitFile);
18056
+ }
18057
+ function writeAtomic(path, content) {
18058
+ const tmp = `${path}.${process.pid}.tmp`;
18059
+ writeFileSync12(tmp, content);
18060
+ renameSync12(tmp, path);
18061
+ }
18062
+ function userName() {
18063
+ try {
18064
+ return userInfo3().username;
18065
+ } catch {
18066
+ return process.env.USER ?? "";
18067
+ }
18068
+ }
18069
+ function rewriteScriptWrappers(dir2 = join28(cabaneDir(), "bin")) {
18070
+ for (const name of ["cabane", "cabane-companion"]) {
18071
+ const path = join28(dir2, name);
18072
+ let text;
18073
+ try {
18074
+ text = readFileSync16(path, "utf8");
18075
+ } catch {
18076
+ continue;
18077
+ }
18078
+ if (!text.includes("Written by https://cabane.ai/install.sh")) continue;
18079
+ if (!text.includes("/dist/cli.js")) continue;
18080
+ writeAtomic(path, text.replace("/dist/cli.js", "/dist/bin.js"));
18081
+ chmodSync7(path, 493);
18082
+ }
18083
+ }
18084
+ async function startService(deps = {}) {
18085
+ return controlService("start", deps);
18086
+ }
18087
+ async function stopService(deps = {}) {
18088
+ return controlService("stop", deps);
18089
+ }
18090
+ async function controlService(action, deps) {
18091
+ const run = deps.run ?? runProcess;
18092
+ const env = deps.env ?? process.env;
18093
+ const platform = servicePlatform(deps.platform);
18094
+ if (!platform) return { ok: false, lines: [] };
18095
+ const paths = servicePaths(platform, env, deps.home);
18096
+ const r = platform === "systemd" ? await run("systemctl", ["--user", action, `${serviceName(env)}.service`], {
18097
+ timeoutMs: 3e4
18098
+ }) : action === "start" ? await run(
18099
+ "launchctl",
18100
+ ["bootstrap", `gui/${process.getuid?.() ?? userInfo3().uid}`, paths.unitFile],
18101
+ {
18102
+ timeoutMs: 3e4
18103
+ }
18104
+ ) : await run(
18105
+ "launchctl",
18106
+ ["bootout", `gui/${process.getuid?.() ?? userInfo3().uid}/${launchdLabel(env)}`],
18107
+ {
18108
+ timeoutMs: 3e4
18109
+ }
18110
+ );
18111
+ return r.code === 0 ? { ok: true, lines: [] } : {
18112
+ ok: false,
18113
+ lines: [`Couldn't ${action} the Cabane service: ${r.stderr.trim() || `exit ${r.code}`}`]
18114
+ };
18115
+ }
18116
+
18117
+ // src/commands/daemon.ts
18118
+ init_cabane_command();
18119
+ init_cli_entry();
18120
+ init_config();
18121
+ import { spawn as spawn9 } from "child_process";
18122
+ import { closeSync as closeSync3, mkdirSync as mkdirSync19, openSync as openSync4 } from "fs";
18123
+ var STARTUP_TIMEOUT_MS = 8e3;
18124
+ var POLL_INTERVAL_MS = 150;
18125
+ async function startDaemon(opts = {}, deps = {}) {
18126
+ const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
18127
+ const readState = deps.readState ?? readLiveRuntimeState;
18128
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
18129
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
18130
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18131
+ const now = deps.now ?? (() => Date.now());
18132
+ const quiet = opts.report === "failures";
18133
+ const cfg = requireStartConfig({
18134
+ // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
18135
+ ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
18136
+ ...deps.save ? { save: deps.save } : {},
18137
+ // The child won't log it (the block is written by then), and this process owns
18138
+ // the user's terminal — so the one-time record is one line, here.
18139
+ onMigrated: () => {
18140
+ if (!quiet) {
18141
+ process.stdout.write(
18142
+ "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
18143
+ );
18144
+ }
18145
+ }
18146
+ });
18147
+ ensureRuntime(cfg, {
18148
+ // The onboarding script already said what's connectable, in its own
18149
+ // words. Saying it again here, on stderr, in the middle of the closing block
18150
+ // would be the same news twice.
18151
+ ...quiet ? { warn: () => {
17022
18152
  } } : {}
17023
18153
  });
17024
18154
  const existing = readState();
@@ -17079,10 +18209,10 @@ function alreadyRunningLines(pid) {
17079
18209
  }
17080
18210
  function defaultSpawnDetached(args) {
17081
18211
  const cliPath = companionCliEntry();
17082
- mkdirSync14(cabaneDir(), { recursive: true });
17083
- const logFd = openSync3(companionLogPath(), "a");
18212
+ mkdirSync19(cabaneDir(), { recursive: true });
18213
+ const logFd = openSync4(companionLogPath(), "a");
17084
18214
  try {
17085
- return spawn8(process.execPath, [cliPath, ...args], {
18215
+ return spawn9(process.execPath, [cliPath, ...args], {
17086
18216
  detached: true,
17087
18217
  stdio: ["ignore", logFd, logFd],
17088
18218
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -17092,6 +18222,209 @@ function defaultSpawnDetached(args) {
17092
18222
  }
17093
18223
  }
17094
18224
 
18225
+ // src/commands/service.ts
18226
+ init_cabane_command();
18227
+ init_config();
18228
+ init_term();
18229
+ init_version2();
18230
+
18231
+ // src/commands/stop.ts
18232
+ var TERM_GRACE_MS = 6e3;
18233
+ var POLL_INTERVAL_MS2 = 150;
18234
+ async function stop(deps = {}) {
18235
+ if ((deps.serviceInstalled ?? serviceInstalled)()) {
18236
+ const outcome = await (deps.stopService ?? stopService)();
18237
+ for (const line of outcome.lines) process.stdout.write(`${line}
18238
+ `);
18239
+ if (!outcome.ok) {
18240
+ process.exitCode = 1;
18241
+ return;
18242
+ }
18243
+ process.stdout.write(
18244
+ "companion: stopped the service. It starts again at boot, or with `cabane start`.\n"
18245
+ );
18246
+ return;
18247
+ }
18248
+ const readState = deps.readState ?? readLiveRuntimeState;
18249
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
18250
+ const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
18251
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18252
+ const now = deps.now ?? (() => Date.now());
18253
+ const state = readState();
18254
+ if (!state) {
18255
+ process.stdout.write("companion: not running (nothing to stop).\n");
18256
+ return;
18257
+ }
18258
+ if (await verify(state) === "stale") {
18259
+ clearRuntimeState();
18260
+ process.stdout.write("companion: not running (stale marker swept).\n");
18261
+ return;
18262
+ }
18263
+ const { pid } = state;
18264
+ process.stdout.write(`companion: stopping (pid ${pid})\u2026
18265
+ `);
18266
+ try {
18267
+ kill(pid, "SIGTERM");
18268
+ } catch {
18269
+ clearRuntimeState();
18270
+ process.stdout.write("companion: already stopped.\n");
18271
+ return;
18272
+ }
18273
+ const deadline = now() + TERM_GRACE_MS;
18274
+ while (now() < deadline) {
18275
+ if (!isAlive(kill, pid)) {
18276
+ process.stdout.write("companion: stopped.\n");
18277
+ return;
18278
+ }
18279
+ await sleep5(POLL_INTERVAL_MS2);
18280
+ }
18281
+ process.stdout.write(
18282
+ `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
18283
+ `
18284
+ );
18285
+ try {
18286
+ kill(pid, "SIGKILL");
18287
+ } catch {
18288
+ }
18289
+ clearRuntimeState();
18290
+ process.stdout.write("companion: force-stopped.\n");
18291
+ }
18292
+ function isAlive(kill, pid) {
18293
+ try {
18294
+ kill(pid, 0);
18295
+ return true;
18296
+ } catch {
18297
+ return false;
18298
+ }
18299
+ }
18300
+
18301
+ // src/commands/service.ts
18302
+ var STARTUP_WAIT_MS = 2e4;
18303
+ async function installAndStartService(deps = {}) {
18304
+ const readState = deps.readState ?? readLiveRuntimeState;
18305
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18306
+ const now = deps.now ?? Date.now;
18307
+ const before = await serviceState(deps);
18308
+ const running = readState();
18309
+ if (running && running.pid !== before.pid) await (deps.stopRunning ?? (() => stop()))();
18310
+ setRunsAsService(true);
18311
+ const outcome = await installService(deps);
18312
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18313
+ if (!outcome.ok) {
18314
+ setRunsAsService(false);
18315
+ return false;
18316
+ }
18317
+ const deadline = now() + STARTUP_WAIT_MS;
18318
+ while (now() < deadline) {
18319
+ const state = readState();
18320
+ if (state?.socket) return true;
18321
+ await sleep5(250);
18322
+ }
18323
+ write(
18324
+ `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
18325
+ );
18326
+ return false;
18327
+ }
18328
+ async function serviceInstall(deps = {}) {
18329
+ if (!await installAndStartService(deps)) {
18330
+ process.exitCode = 1;
18331
+ return;
18332
+ }
18333
+ write(
18334
+ INDENT + tick(
18335
+ "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself between turns."
18336
+ )
18337
+ );
18338
+ write(`${INDENT} Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
18339
+ }
18340
+ async function serviceUninstall(deps = {}) {
18341
+ const outcome = await uninstallService(deps);
18342
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18343
+ if (outcome.lines.length > 0) return;
18344
+ setRunsAsService(false);
18345
+ write(
18346
+ INDENT + tick("Removed the service. Cabane won't start at boot, and it isn't running now.")
18347
+ );
18348
+ write(`${INDENT} Start it again with ${cabane("start")}`);
18349
+ }
18350
+ async function serviceStatus(deps = {}) {
18351
+ const state = await serviceState(deps);
18352
+ for (const line of serviceRows(state)) write(line);
18353
+ for (const line of updateRows(readUpdateState(), safeConfig()?.update?.auto, {
18354
+ service: state.installed
18355
+ }))
18356
+ write(line);
18357
+ if (state.installed && state.state !== "running") process.exitCode = 1;
18358
+ }
18359
+ var LABEL_WIDTH = 13;
18360
+ var row = (label, value) => ` ${label.padEnd(LABEL_WIDTH)}${value}`;
18361
+ function serviceRows(state) {
18362
+ if (!state.installed) {
18363
+ return [
18364
+ row(
18365
+ "Service",
18366
+ state.platform ? `not installed \u2014 ${cabane("service install")} starts Cabane at boot` : "not available on this system"
18367
+ )
18368
+ ];
18369
+ }
18370
+ const via = state.platform === "systemd" ? "systemd" : "launchd";
18371
+ const rows = [
18372
+ row(
18373
+ "Service",
18374
+ state.state === "running" ? `running (${via}${state.pid ? `, pid ${state.pid}` : ""})` : `installed, not running (${via}: ${state.state})`
18375
+ )
18376
+ ];
18377
+ if (!state.nodeOk) {
18378
+ rows.push(
18379
+ ` ! The service's Node at ${tildePath(state.node ?? "")} is gone. Run ${cabane("service install")} again.`
18380
+ );
18381
+ }
18382
+ return rows;
18383
+ }
18384
+ function updateRows(state, auto, opts = {}) {
18385
+ const rows = [];
18386
+ if (opts.version !== false) rows.push(row("Version", COMPANION_VERSION));
18387
+ if (state.server) {
18388
+ rows.push(
18389
+ row(
18390
+ "Cabane wants",
18391
+ `${state.server.latest} (the latest), ${state.server.minimum} or newer to run turns`
18392
+ )
18393
+ );
18394
+ }
18395
+ rows.push(
18396
+ row(
18397
+ "Auto-update",
18398
+ !opts.service ? `off \u2014 runs with the service: ${cabane("service install")}` : auto === true ? "on \u2014 updates install between turns and roll back if they fail" : `off \u2014 ${cabane("update --auto on")} turns it on`
18399
+ )
18400
+ );
18401
+ const last = state.lastResult;
18402
+ if (last) {
18403
+ const when = new Date(last.at).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
18404
+ const why = last.reason ? `: ${last.reason}` : "";
18405
+ rows.push(
18406
+ row(
18407
+ "Last update",
18408
+ last.kind === "updated" ? `${last.from} \u2192 ${last.to} on ${when}` : last.kind === "rolled_back" ? `${last.to} rolled back to ${last.from} on ${when}${why}` : `${last.to} failed on ${when}${why}`
18409
+ )
18410
+ );
18411
+ }
18412
+ return rows;
18413
+ }
18414
+ function safeConfig() {
18415
+ try {
18416
+ return loadConfig();
18417
+ } catch {
18418
+ return null;
18419
+ }
18420
+ }
18421
+ function setRunsAsService(on) {
18422
+ const cfg = safeConfig();
18423
+ if (!cfg) return;
18424
+ const { serviceDeclined: _declined, ...rest } = cfg;
18425
+ saveConfig({ ...rest, update: { auto: on } });
18426
+ }
18427
+
17095
18428
  // src/commands/start.ts
17096
18429
  var FORCE_EXIT_MS = 4e3;
17097
18430
  var MAX_CODES = 3;
@@ -17116,6 +18449,10 @@ async function startScript(opts, interactive) {
17116
18449
  await startSupervised(opts, interactive, daemon);
17117
18450
  return;
17118
18451
  }
18452
+ if (serviceInstalled()) {
18453
+ await startTheService();
18454
+ return;
18455
+ }
17119
18456
  await startForPerson(opts, interactive);
17120
18457
  }
17121
18458
  async function surveyAgents(cfg, deps = {}) {
@@ -17499,7 +18836,7 @@ function openLink(link, spinner) {
17499
18836
  `${INDENT}Couldn't open a browser here. Open this link on any device: ${link}`
17500
18837
  );
17501
18838
  try {
17502
- const child = spawn9(opener, [link], { stdio: "ignore", detached: true });
18839
+ const child = spawn10(opener, [link], { stdio: "ignore", detached: true });
17503
18840
  child.once("error", failed);
17504
18841
  child.once("exit", (code) => {
17505
18842
  if (code !== 0) failed();
@@ -17510,32 +18847,67 @@ function openLink(link, spinner) {
17510
18847
  }
17511
18848
  }
17512
18849
  async function handOffToBackground(runtime, ctx2) {
18850
+ const { asService } = ctx2.justPaired ? await askHowToRun() : { asService: false };
17513
18851
  await runtime.stop();
17514
- const outcome = await startDaemon({ report: "failures", ...ctx2 });
17515
- if (!outcome.started) {
17516
- return;
18852
+ if (asService) {
18853
+ if (!await installAndStartService()) {
18854
+ write(`${INDENT}Running it in the background instead.`);
18855
+ if (!(await startDaemon({ report: "failures", ...ctx2 })).started) return;
18856
+ }
18857
+ } else {
18858
+ const outcome = await startDaemon({ report: "failures", ...ctx2 });
18859
+ if (!outcome.started) {
18860
+ return;
18861
+ }
17517
18862
  }
18863
+ const supervised = asService && serviceInstalled();
17518
18864
  for (const label of ctx2.connected) write(INDENT + tick(`${label} connected.`));
17519
18865
  for (const runtime2 of ctx2.signedOut) {
17520
18866
  write(
17521
18867
  `${INDENT}! ${HARNESS_LABELS[runtime2]} is connected here but signed out. Run ${loginCommand(runtime2)} to use it again.`
17522
18868
  );
17523
18869
  }
18870
+ const where = supervised ? "as a service, and starts at boot" : "in the background";
17524
18871
  write(
17525
18872
  INDENT + tick(
17526
- ctx2.justPaired ? "Cabane is running in the background. You can close this window." : "Cabane is running in the background."
18873
+ ctx2.justPaired ? `Cabane is running ${where}. You can close this window.` : `Cabane is running ${where}.`
17527
18874
  )
17528
18875
  );
17529
18876
  write(` Stop: ${cabane("stop")} Logs: ${cabane("logs")}`);
17530
18877
  if (ctx2.justPaired) {
17531
- blank();
17532
- write(
17533
- `${INDENT}! It won't restart on its own after a reboot \u2014 run ${cabane("start")} again.`
17534
- );
18878
+ if (!supervised) {
18879
+ blank();
18880
+ write(
18881
+ `${INDENT}! It won't restart on its own after a reboot \u2014 run ${cabane("start")} again, or ${cabane("service install")} to start it at boot.`
18882
+ );
18883
+ }
17535
18884
  blank();
17536
18885
  write(`${INDENT}All set on this side. Finish up in Cabane.`);
17537
18886
  }
17538
18887
  }
18888
+ async function askHowToRun(deps = {}) {
18889
+ const ask = deps.confirm ?? confirm;
18890
+ const cfg = safeLoadConfig();
18891
+ if (!cfg || !servicePlatform() || cfg.serviceDeclined || serviceInstalled())
18892
+ return { asService: false };
18893
+ blank();
18894
+ const asService = await ask(
18895
+ "Keep Cabane running and up to date on this computer? It starts at boot, restarts if it stops, and updates itself between turns."
18896
+ );
18897
+ if (!asService) saveConfig({ ...cfg, serviceDeclined: true });
18898
+ blank();
18899
+ return { asService };
18900
+ }
18901
+ async function startTheService() {
18902
+ const outcome = await startService();
18903
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18904
+ if (!outcome.ok) {
18905
+ process.exitCode = 1;
18906
+ return;
18907
+ }
18908
+ write(INDENT + tick("Cabane runs as a service here, and is starting."));
18909
+ write(` Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
18910
+ }
17539
18911
  async function runAttached(runtime) {
17540
18912
  await new Promise((resolve2) => {
17541
18913
  let shuttingDown = false;
@@ -17607,7 +18979,7 @@ async function pair(opts = {}, deps = {}) {
17607
18979
  }
17608
18980
 
17609
18981
  // src/cli.ts
17610
- import { readFileSync as readFileSync14 } from "fs";
18982
+ import { readFileSync as readFileSync18 } from "fs";
17611
18983
 
17612
18984
  // src/commands/logs.ts
17613
18985
  init_cabane_command();
@@ -17691,18 +19063,18 @@ async function logs(opts = {}) {
17691
19063
  // src/commands/status.ts
17692
19064
  init_bundled_binaries();
17693
19065
  init_cabane_command();
17694
- import { readdirSync as readdirSync6, realpathSync as realpathSync5, existsSync as existsSync17 } from "fs";
17695
- import { homedir as homedir7 } from "os";
17696
- import { dirname as dirname9, join as join24 } from "path";
19066
+ import { readdirSync as readdirSync7, realpathSync as realpathSync7, existsSync as existsSync20 } from "fs";
19067
+ import { homedir as homedir8 } from "os";
19068
+ import { dirname as dirname14, join as join29 } from "path";
17697
19069
  init_config();
17698
19070
  init_sandbox_policy();
17699
19071
  init_secrets();
17700
19072
  init_term();
17701
19073
  init_transcript_writer();
17702
19074
  init_version2();
17703
- var LABEL_WIDTH = 13;
17704
- function row(label, value) {
17705
- return ` ${label.padEnd(LABEL_WIDTH)}${value}`;
19075
+ var LABEL_WIDTH2 = 13;
19076
+ function row2(label, value) {
19077
+ return ` ${label.padEnd(LABEL_WIDTH2)}${value}`;
17706
19078
  }
17707
19079
  async function status() {
17708
19080
  const out = (line) => void process.stdout.write(`${line}
@@ -17720,29 +19092,36 @@ async function status() {
17720
19092
  process.exitCode = 1;
17721
19093
  return;
17722
19094
  }
17723
- out(row("Paired with", `${cfg.baseUrl}${cfg.deviceLabel ? ` as ${cfg.deviceLabel}` : ""}`));
19095
+ out(row2("Paired with", `${cfg.baseUrl}${cfg.deviceLabel ? ` as ${cfg.deviceLabel}` : ""}`));
17724
19096
  const running = await liveState();
17725
19097
  let harnesses = null;
17726
19098
  if (running) {
17727
19099
  const mode = running.daemon ? "in the background" : "in a terminal";
17728
19100
  const uptime = formatUptime(running.startedAt);
17729
- out(row("Running", `${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})`));
19101
+ out(row2("Running", `${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})`));
17730
19102
  harnesses = await runningHarnesses(running);
17731
19103
  } else {
17732
- out(row("Running", `no \u2014 start it with ${cabane("start")}`));
17733
- }
19104
+ out(row2("Running", `no \u2014 start it with ${cabane("start")}`));
19105
+ }
19106
+ const service2 = await serviceState();
19107
+ for (const line of serviceRows(service2)) out(line);
19108
+ for (const line of updateRows(readUpdateState(), cfg.update?.auto, {
19109
+ version: false,
19110
+ service: service2.installed
19111
+ }))
19112
+ out(line);
17734
19113
  for (const line of await agentLines(cfg, harnesses)) out(line);
17735
- out(row("Logs", `${cabane("logs")} (${tildePath(companionLogPath())})`));
17736
- out(row("Transcripts", transcriptsLine()));
19114
+ out(row2("Logs", `${cabane("logs")} (${tildePath(companionLogPath())})`));
19115
+ out(row2("Transcripts", transcriptsLine()));
17737
19116
  const names = loadSecretStoreTolerant().names();
17738
19117
  out(
17739
- names.length > 0 ? row("Secrets", `${names.length} declared (${names.join(", ")})`) : row(
19118
+ names.length > 0 ? row2("Secrets", `${names.length} declared (${names.join(", ")})`) : row2(
17740
19119
  "Secrets",
17741
19120
  "none declared \u2014 add `${VAR}` values to ~/.cabane/secrets.json for MCP servers that need them"
17742
19121
  )
17743
19122
  );
17744
19123
  if (cfg.prepareHook) {
17745
- out(row("Prepare hook", `${cfg.prepareHook.command} (device-level, all agents)`));
19124
+ out(row2("Prepare hook", `${cfg.prepareHook.command} (device-level, all agents)`));
17746
19125
  }
17747
19126
  for (const line of sandboxStatusLines(cfg)) out(line);
17748
19127
  const overrides2 = Object.keys(cfg.agents ?? {});
@@ -17752,10 +19131,10 @@ function versionLine() {
17752
19131
  const install = companionInstall();
17753
19132
  const own = installedCommandPath({ install });
17754
19133
  if (install === "script") {
17755
- return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join24(homedir7(), ".cabane"))})`;
19134
+ return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join29(homedir8(), ".cabane"))})`;
17756
19135
  }
17757
19136
  if (own)
17758
- return `Cabane ${COMPANION_VERSION} \xB7 installed with npm (${tildePath(dirname9(dirname9(own)))})`;
19137
+ return `Cabane ${COMPANION_VERSION} \xB7 installed with npm (${tildePath(dirname14(dirname14(own)))})`;
17759
19138
  return `Cabane ${COMPANION_VERSION}`;
17760
19139
  }
17761
19140
  function otherInstallLines(running) {
@@ -17772,14 +19151,14 @@ function otherInstallLines(running) {
17772
19151
  function otherInstall(install, own) {
17773
19152
  const real = (p) => {
17774
19153
  try {
17775
- return realpathSync5(p);
19154
+ return realpathSync7(p);
17776
19155
  } catch {
17777
19156
  return null;
17778
19157
  }
17779
19158
  };
17780
19159
  if (install === "npm") {
17781
- const script = join24(
17782
- homedir7(),
19160
+ const script = join29(
19161
+ homedir8(),
17783
19162
  ".cabane",
17784
19163
  "node",
17785
19164
  "lib",
@@ -17787,12 +19166,12 @@ function otherInstall(install, own) {
17787
19166
  "@cabane",
17788
19167
  "companion"
17789
19168
  );
17790
- return existsSync17(script) ? join24(homedir7(), ".cabane") : null;
19169
+ return existsSync20(script) ? join29(homedir8(), ".cabane") : null;
17791
19170
  }
17792
19171
  const found = firstOnPath("cabane-companion", process.env.PATH);
17793
19172
  if (!found) return null;
17794
- const ownAlias = own ? real(join24(dirname9(own), "cabane-companion")) : null;
17795
- return real(found) === ownAlias ? null : dirname9(found);
19173
+ const ownAlias = own ? real(join29(dirname14(own), "cabane-companion")) : null;
19174
+ return real(found) === ownAlias ? null : dirname14(found);
17796
19175
  }
17797
19176
  async function liveState() {
17798
19177
  const running = readLiveRuntimeState();
@@ -17819,18 +19198,18 @@ async function agentLines(cfg, running) {
17819
19198
  const label = HARNESS_LABELS[runtime];
17820
19199
  const connected = runtime === "codex" ? !!cfg.codex && cfg.codex.enabled !== false : !!cfg.claudeCode && cfg.claudeCode.enabled !== false;
17821
19200
  if (!connected) {
17822
- lines2.push(row(label, "not connected"));
19201
+ lines2.push(row2(label, "not connected"));
17823
19202
  continue;
17824
19203
  }
17825
19204
  const fromRunning = running?.find((h) => h.runtime === runtime);
17826
19205
  const signIn = fromRunning ? { status: fromRunning.signIn, version: fromRunning.version } : await localSignIn(runtime);
17827
- lines2.push(row(label, signInText(runtime, signIn.status, signIn.version)));
19206
+ lines2.push(row2(label, signInText(runtime, signIn.status, signIn.version)));
17828
19207
  }
17829
19208
  if (cfg.opencode?.serverUrl) {
17830
19209
  const fromRunning = running?.find((h) => h.runtime === "opencode");
17831
19210
  const answering = fromRunning ? fromRunning.state === "exposed" : null;
17832
19211
  lines2.push(
17833
- row(
19212
+ row2(
17834
19213
  HARNESS_LABELS.opencode,
17835
19214
  `${cfg.opencode.serverUrl}${answering === false ? " \u2014 not answering" : ""}`
17836
19215
  )
@@ -17886,7 +19265,7 @@ function transcriptsLine() {
17886
19265
  let count = 0;
17887
19266
  let newest;
17888
19267
  try {
17889
- const files = readdirSync6(dir2).filter((f) => f.endsWith(".jsonl")).sort();
19268
+ const files = readdirSync7(dir2).filter((f) => f.endsWith(".jsonl")).sort();
17890
19269
  count = files.length;
17891
19270
  newest = files[files.length - 1];
17892
19271
  } catch {
@@ -17906,61 +19285,107 @@ function formatUptime(startedAt) {
17906
19285
  return remMin ? `${hr}h${remMin}m` : `${hr}h`;
17907
19286
  }
17908
19287
 
17909
- // src/commands/stop.ts
17910
- var TERM_GRACE_MS = 6e3;
17911
- var POLL_INTERVAL_MS2 = 150;
17912
- async function stop(deps = {}) {
17913
- const readState = deps.readState ?? readLiveRuntimeState;
17914
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
17915
- const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
17916
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17917
- const now = deps.now ?? (() => Date.now());
17918
- const state = readState();
17919
- if (!state) {
17920
- process.stdout.write("companion: not running (nothing to stop).\n");
19288
+ // src/commands/update.ts
19289
+ init_config();
19290
+ init_term();
19291
+ init_version2();
19292
+ init_cabane_command();
19293
+ var UPDATE_TIMEOUT_MS = 20 * 6e4;
19294
+ async function update(opts = {}) {
19295
+ const running = readLiveRuntimeState();
19296
+ if (opts.auto) {
19297
+ const auto = opts.auto === "on";
19298
+ if (auto && !serviceInstalled()) {
19299
+ write(
19300
+ `${INDENT}Cabane updates itself only when it runs as a service. Run ${cabane("service install")} to run it as one, with updates on.`
19301
+ );
19302
+ process.exitCode = 1;
19303
+ return;
19304
+ }
19305
+ if (running?.socket) {
19306
+ const r = await controlRequest(running.socket, { cmd: "update", auto });
19307
+ write(`${INDENT}${r.ok ? tick(r.message) : r.message}`);
19308
+ return;
19309
+ }
19310
+ const cfg2 = loadConfig();
19311
+ if (!cfg2) {
19312
+ write(`${INDENT}This computer isn't set up yet. Run ${cabane("start")} first.`);
19313
+ process.exitCode = 1;
19314
+ return;
19315
+ }
19316
+ saveConfig({ ...cfg2, update: { auto } });
19317
+ write(
19318
+ INDENT + tick(
19319
+ auto ? "Cabane will keep itself up to date. Updates install between turns and roll back if they fail." : "Cabane won't update itself."
19320
+ )
19321
+ );
17921
19322
  return;
17922
19323
  }
17923
- if (await verify(state) === "stale") {
17924
- clearRuntimeState();
17925
- process.stdout.write("companion: not running (stale marker swept).\n");
19324
+ if (running?.socket) {
19325
+ const r = await controlRequest(
19326
+ running.socket,
19327
+ { cmd: "update" },
19328
+ UPDATE_TIMEOUT_MS
19329
+ );
19330
+ write(`${INDENT}${r.ok ? tick(r.message) : r.message}`);
19331
+ if (!r.ok) process.exitCode = 1;
17926
19332
  return;
17927
19333
  }
17928
- const { pid } = state;
17929
- process.stdout.write(`companion: stopping (pid ${pid})\u2026
17930
- `);
17931
- try {
17932
- kill(pid, "SIGTERM");
17933
- } catch {
17934
- clearRuntimeState();
17935
- process.stdout.write("companion: already stopped.\n");
19334
+ const latest = readUpdateState().server?.latest;
19335
+ if (!latest) {
19336
+ write(
19337
+ `${INDENT}Cabane hasn't said which version is latest yet. Run ${cabane("start")}, and it updates from there.`
19338
+ );
19339
+ process.exitCode = 1;
17936
19340
  return;
17937
19341
  }
17938
- const deadline = now() + TERM_GRACE_MS;
17939
- while (now() < deadline) {
17940
- if (!isAlive(kill, pid)) {
17941
- process.stdout.write("companion: stopped.\n");
17942
- return;
17943
- }
17944
- await sleep5(POLL_INTERVAL_MS2);
19342
+ if (compareVersions(latest, COMPANION_VERSION) <= 0) {
19343
+ write(INDENT + tick(`Cabane ${COMPANION_VERSION} is up to date.`));
19344
+ return;
17945
19345
  }
17946
- process.stdout.write(
17947
- `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
17948
- `
19346
+ const cfg = loadConfig();
19347
+ const harnesses = cfg ? [
19348
+ ...isClaudeCodeConnected(cfg) ? ["claude-code"] : [],
19349
+ ...isCodexEnabled(cfg) ? ["codex"] : []
19350
+ ] : [];
19351
+ write(
19352
+ `${INDENT}Installing Cabane ${latest}. It brings its own copies of the agents, so this can take a few minutes.`
17949
19353
  );
17950
- try {
17951
- kill(pid, "SIGKILL");
17952
- } catch {
17953
- }
17954
- clearRuntimeState();
17955
- process.stdout.write("companion: force-stopped.\n");
17956
- }
17957
- function isAlive(kill, pid) {
17958
- try {
17959
- kill(pid, 0);
17960
- return true;
17961
- } catch {
17962
- return false;
19354
+ const outcome = await stageVersion(latest, harnesses);
19355
+ if (!outcome.ok) {
19356
+ if (outcome.unpublished) {
19357
+ write(`${INDENT}Cabane ${latest} isn't published yet. Try again later.`);
19358
+ process.exitCode = 1;
19359
+ return;
19360
+ }
19361
+ updateUpdateState((s) => ({
19362
+ ...s,
19363
+ lastResult: {
19364
+ kind: "failed",
19365
+ from: COMPANION_VERSION,
19366
+ to: latest,
19367
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19368
+ reason: outcome.reason
19369
+ }
19370
+ }));
19371
+ write(`${INDENT}Couldn't update to ${latest}: ${outcome.reason}.`);
19372
+ process.exitCode = 1;
19373
+ return;
17963
19374
  }
19375
+ const fromRoot = currentRoot() ?? ownPackageRoot();
19376
+ updateUpdateState((s) => ({
19377
+ ...s,
19378
+ pending: {
19379
+ from: COMPANION_VERSION,
19380
+ fromRoot,
19381
+ to: latest,
19382
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19383
+ boots: 0
19384
+ }
19385
+ }));
19386
+ writeBootGuard();
19387
+ pointCurrentAt(outcome.root);
19388
+ write(INDENT + tick(`Updated to ${latest}. Start it with ${cabane("start")}`));
17964
19389
  }
17965
19390
 
17966
19391
  // src/commands/transcript.ts
@@ -17968,8 +19393,8 @@ init_cabane_command();
17968
19393
  init_errors();
17969
19394
  init_src();
17970
19395
  init_transcript_writer();
17971
- import { existsSync as existsSync18, readFileSync as readFileSync13, readdirSync as readdirSync7 } from "fs";
17972
- import { isAbsolute, join as join25 } from "path";
19396
+ import { existsSync as existsSync21, readFileSync as readFileSync17, readdirSync as readdirSync8 } from "fs";
19397
+ import { isAbsolute, join as join30 } from "path";
17973
19398
  async function transcript(opts = {}) {
17974
19399
  const dir2 = transcriptsDir();
17975
19400
  if (opts.follow) {
@@ -17986,7 +19411,7 @@ async function transcript(opts = {}) {
17986
19411
  process.stdout.write(emptyMessage(dir2));
17987
19412
  return;
17988
19413
  }
17989
- process.stdout.write(renderFile(join25(dir2, newest)) + "\n");
19414
+ process.stdout.write(renderFile(join30(dir2, newest)) + "\n");
17990
19415
  return;
17991
19416
  }
17992
19417
  printList(dir2);
@@ -18069,7 +19494,7 @@ function isComplete(content) {
18069
19494
  async function followTranscripts(dir2) {
18070
19495
  const follower = new TranscriptFollower({
18071
19496
  listFiles: () => listFiles(dir2),
18072
- read: (f) => readFileSync13(join25(dir2, f), "utf8"),
19497
+ read: (f) => readFileSync17(join30(dir2, f), "utf8"),
18073
19498
  write: (s) => process.stdout.write(s),
18074
19499
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
18075
19500
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -18094,7 +19519,7 @@ async function followTranscripts(dir2) {
18094
19519
  }
18095
19520
  function listFiles(dir2) {
18096
19521
  try {
18097
- return readdirSync7(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
19522
+ return readdirSync8(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
18098
19523
  } catch {
18099
19524
  return [];
18100
19525
  }
@@ -18109,7 +19534,7 @@ function printList(dir2) {
18109
19534
 
18110
19535
  `);
18111
19536
  for (const f of files.slice(0, 20)) {
18112
- const { meta, outcome } = peek(join25(dir2, f));
19537
+ const { meta, outcome } = peek(join30(dir2, f));
18113
19538
  const when = fmtTime(rec(meta)?.ts);
18114
19539
  const ws = str2(rec(meta)?.workspaceSlug);
18115
19540
  const o = rec(outcome);
@@ -18131,7 +19556,7 @@ function peek(path) {
18131
19556
  let meta;
18132
19557
  let outcome;
18133
19558
  try {
18134
- for (const line of readFileSync13(path, "utf8").split("\n")) {
19559
+ for (const line of readFileSync17(path, "utf8").split("\n")) {
18135
19560
  if (!line.trim()) continue;
18136
19561
  const o = safeParse(line);
18137
19562
  const t = str2(rec(o)?.type);
@@ -18144,13 +19569,13 @@ function peek(path) {
18144
19569
  }
18145
19570
  function resolveTarget(dir2, target) {
18146
19571
  if (isAbsolute(target) || target.includes("/")) {
18147
- if (existsSync18(target)) return target;
19572
+ if (existsSync21(target)) return target;
18148
19573
  throw new CompanionError(`no transcript at ${target}.`);
18149
19574
  }
18150
- const exact = join25(dir2, target);
18151
- if (existsSync18(exact)) return exact;
19575
+ const exact = join30(dir2, target);
19576
+ if (existsSync21(exact)) return exact;
18152
19577
  const matches = listFiles(dir2).filter((f) => f.includes(target));
18153
- if (matches.length === 1) return join25(dir2, matches[0]);
19578
+ if (matches.length === 1) return join30(dir2, matches[0]);
18154
19579
  if (matches.length === 0) {
18155
19580
  throw new CompanionError(
18156
19581
  `no transcript matching "${target}" in ${dir2}. Run \`${cabane("transcript")}\` to list them.`
@@ -18164,7 +19589,7 @@ function resolveTarget(dir2, target) {
18164
19589
  function renderFile(path) {
18165
19590
  let content;
18166
19591
  try {
18167
- content = readFileSync13(path, "utf8");
19592
+ content = readFileSync17(path, "utf8");
18168
19593
  } catch (err) {
18169
19594
  throw new CompanionError(
18170
19595
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -18324,7 +19749,7 @@ program.command("pair").description(
18324
19749
  });
18325
19750
  });
18326
19751
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
18327
- writeCompletedPairing(readFileSync14(0, "utf8"));
19752
+ writeCompletedPairing(readFileSync18(0, "utf8"));
18328
19753
  });
18329
19754
  program.command("start").description("pair this device if needed, connect an agent on it, and run in the background.").option("--foreground", "run attached in this terminal (for terminals and supervisors)").option("--daemon", "run in the background (the default; kept for compatibility)").option("--server <url>", "the cabane instance to pair with, if this device isn\u2019t paired yet").addOption(
18330
19755
  new Option("--log-level <level>", "info (default), warn, or debug").choices([
@@ -18348,6 +19773,21 @@ program.command("start").description("pair this device if needed, connect an age
18348
19773
  program.command("connect").description("connect an agent on this machine to Cabane (claude-code, codex, opencode).").argument("<harness>", "claude-code, codex, or opencode").option("--url <url>", "the `opencode serve` URL (opencode only)").action(async (harness, opts) => {
18349
19774
  await connect2(harness, { ...opts.url !== void 0 ? { serverUrl: opts.url } : {} });
18350
19775
  });
19776
+ program.command("update").description(
19777
+ "update to the latest version now, or set whether Cabane keeps itself up to date (--auto on|off)."
19778
+ ).addOption(new Option("--auto <setting>", "on or off").choices(["on", "off"])).action(async (opts) => {
19779
+ await update(opts.auto ? { auto: opts.auto } : {});
19780
+ });
19781
+ var service = program.command("service").description("run Cabane as a service that starts at boot and restarts if it stops.");
19782
+ service.command("install").description("install the service and start it (systemd on Linux, launchd on macOS).").action(async () => {
19783
+ await serviceInstall();
19784
+ });
19785
+ service.command("uninstall").description("stop the service and remove it.").action(async () => {
19786
+ await serviceUninstall();
19787
+ });
19788
+ service.command("status").description("show the service's state, this version, and how updates are going.").action(async () => {
19789
+ await serviceStatus();
19790
+ });
18351
19791
  program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
18352
19792
  await stop();
18353
19793
  });