@cabane/companion 0.6.158 → 0.6.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,7 +106,7 @@ A freshly paired device runs nothing yet: pairing doesn't put any agent on it. I
106
106
  | `pair [--server <url>]` | Pair this device with a short code entered in Cabane, without starting the Companion. Refuses, like `start`, until an agent is signed in. `start` pairs by itself, so `pair` is rarely needed. |
107
107
  | `start [--foreground] [--daemon]` | Offer the signed-in agents, pair if needed, and run in the background; refuse, pairing nothing, when none is usable. `--daemon` is an accepted compatibility alias for the default. `--foreground` stays attached for a terminal or supervisor and never refuses. A non-interactive `start` on a paired device also stays attached. |
108
108
  | `connect <claude-code\|codex\|opencode>` | Connect a signed-in agent; with the Companion running, it checks and connects over its control socket, no restart. Refuses a signed-out agent and changes nothing. |
109
- | `update [--auto on\|off]` | Update to the version Cabane names now, between turns; with `--auto`, set whether the Companion keeps itself up to date. |
109
+ | `update [--auto on\|off]` | Update to the version Cabane names now; with `--auto`, set whether the Companion keeps itself up to date. |
110
110
  | `service install\|uninstall\|status` | Run the Companion as a systemd user service or a LaunchAgent that starts at boot and restarts if it stops; remove it; show its state and how updates are going. |
111
111
  | `stop` | Stop a foreground or detached Companion cleanly. Idempotent — "nothing running" is a success. |
112
112
  | `status` | Print the version and install, the paired device, whether it's running, each agent's sign-in, log and transcript paths, declared secret names, and local overrides. Works unpaired. |
@@ -134,7 +134,7 @@ In an interactive terminal, `cabane start` backgrounds itself, returns the promp
134
134
 
135
135
  Yes, or `cabane service install` later, runs it as a service: a systemd user unit (`~/.config/systemd/user/cabane-companion.service`) on Linux, a LaunchAgent (`~/Library/LaunchAgents/ai.cabane.companion.plist`) on macOS, with auto-update on. It runs `<node> ~/.cabane/boot-guard.mjs start --foreground` on its own copy of this install under `~/.cabane/versions`, restarts it whatever it exits with, and reads `~/.cabane/service.env`, which holds your login shell's PATH and any headless sign-in (`CLAUDE_CODE_OAUTH_TOKEN=…`). `<node>` is the install script's `~/.cabane/node/bin/node` when it's there, otherwise the Node that ran `service install`; `cabane service status` says if it's gone. On Linux it tells you when lingering is off and a logout would stop it. With a service installed, `cabane start` and `cabane stop` start and stop the service; `cabane service uninstall` removes it.
136
136
 
137
- Updates need the service: it's what restarts the Companion into a new version. The Companion follows the version Cabane names on each heartbeat: it installs it under `~/.cabane/versions/<version>`, checks it starts and still has the bundled agents this device uses, points `~/.cabane/current` at it between turns, and exits for the service to restart it. A version that stops three times before connecting, or can't connect within ten minutes of Cabane answering, is rolled back by the boot guard. `cabane update --auto off|on` stops or resumes updates under the service. Without the service, `cabane update` installs the latest version and the next `cabane start` runs it.
137
+ Updates need the service: it's what restarts the Companion into a new version. The Companion follows the version Cabane names on each heartbeat: it installs it under `~/.cabane/versions/<version>`, checks it starts and still has the bundled agents this device uses, points `~/.cabane/current` at it, and exits for the service to restart it. That happens between turns, or after ten minutes if the Companion never stops working, in which case the turns it cuts carry on after the restart. A version below the minimum Cabane accepts swaps at once. A version that stops three times before connecting, or can't connect within ten minutes of Cabane answering, is rolled back by the boot guard. `cabane update --auto off|on` stops or resumes updates under the service. Without the service, `cabane update` installs the latest version and the next `cabane start` runs it.
138
138
 
139
139
  ### Other supervisors
140
140
 
package/dist/cli.js CHANGED
@@ -15433,6 +15433,8 @@ var SseSubscriber = class {
15433
15433
  opts;
15434
15434
  aborted = false;
15435
15435
  controller = null;
15436
+ // Ends the reconnect backoff early, so a stop never waits out a sleep of up to 30 s.
15437
+ wake = null;
15436
15438
  lastEventId;
15437
15439
  // Set after `start()`; resolved when the subscriber loop exits (either
15438
15440
  // because `stop()` was called or because an auth failure tore it down).
@@ -15443,12 +15445,7 @@ var SseSubscriber = class {
15443
15445
  stop() {
15444
15446
  this.aborted = true;
15445
15447
  this.controller?.abort();
15446
- }
15447
- // Connect again after a `stop()`, once its loop has exited.
15448
- async resume() {
15449
- await this.finished;
15450
- this.aborted = false;
15451
- this.start();
15448
+ this.wake?.();
15452
15449
  }
15453
15450
  setCursor(eventId) {
15454
15451
  this.lastEventId = eventId;
@@ -15461,7 +15458,7 @@ var SseSubscriber = class {
15461
15458
  backoff = 500;
15462
15459
  if (!this.aborted) {
15463
15460
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
15464
- await sleep4(backoff);
15461
+ await this.backoff(backoff);
15465
15462
  }
15466
15463
  } catch (err) {
15467
15464
  if (this.aborted) return;
@@ -15481,11 +15478,21 @@ var SseSubscriber = class {
15481
15478
  },
15482
15479
  "Lost the connection to Cabane; reconnecting"
15483
15480
  );
15484
- await sleep4(backoff);
15481
+ await this.backoff(backoff);
15485
15482
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
15486
15483
  }
15487
15484
  }
15488
15485
  }
15486
+ backoff(ms) {
15487
+ return new Promise((resolve2) => {
15488
+ const timer = setTimeout(done, ms);
15489
+ this.wake = done;
15490
+ function done() {
15491
+ clearTimeout(timer);
15492
+ resolve2();
15493
+ }
15494
+ });
15495
+ }
15489
15496
  async connect() {
15490
15497
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
15491
15498
  const url = `${base}${this.opts.path}`;
@@ -15532,9 +15539,6 @@ var SseSubscriber = class {
15532
15539
  }
15533
15540
  }
15534
15541
  };
15535
- function sleep4(ms) {
15536
- return new Promise((resolve2) => setTimeout(resolve2, ms));
15537
- }
15538
15542
 
15539
15543
  // src/supervisor.ts
15540
15544
  init_transcript_writer();
@@ -15717,7 +15721,7 @@ function updateMode(env = process.env) {
15717
15721
  return env.CABANE_COMPANION_SUPERVISED === "1" ? "supervised" : "attached";
15718
15722
  }
15719
15723
  var STAGE_RETRY_MS = 60 * 6e4;
15720
- var SWAP_DRAIN_MS = 1e4;
15724
+ var SWAP_WAIT_MS = 10 * 6e4;
15721
15725
  var SOURCE_BUILD_VERSION = "0.0.0";
15722
15726
  function versionToStage(input) {
15723
15727
  const { own, state, now, force } = input;
@@ -15867,6 +15871,8 @@ var CompanionUpdater = class {
15867
15871
  deps;
15868
15872
  staging = null;
15869
15873
  ready = null;
15874
+ // When `ready` verified; `SWAP_WAIT_MS` counts from here.
15875
+ readyAt = 0;
15870
15876
  swapping = false;
15871
15877
  watchdog = null;
15872
15878
  now;
@@ -15965,7 +15971,7 @@ var CompanionUpdater = class {
15965
15971
  return { ok: true, message: `Cabane ${this.deps.own} is up to date.` };
15966
15972
  if (state.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
15967
15973
  if (this.ready === latest) {
15968
- void this.trySwap();
15974
+ void this.trySwap(true);
15969
15975
  return { ok: true, message: this.waitingMessage(latest) };
15970
15976
  }
15971
15977
  this.maybeStage(true);
@@ -15981,13 +15987,14 @@ var CompanionUpdater = class {
15981
15987
  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.`
15982
15988
  };
15983
15989
  }
15984
- void this.trySwap();
15990
+ void this.trySwap(true);
15985
15991
  return { ok: true, message: this.waitingMessage(latest) };
15986
15992
  }
15993
+ // Only the person's `cabane update` asks, and that swaps at once.
15987
15994
  waitingMessage(version) {
15988
15995
  if (this.deps.mode === "attached")
15989
15996
  return `Cabane ${version} is installed. It runs from the next cabane start.`;
15990
- return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} once the turns running now finish.`;
15997
+ return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} now. The turns running now carry on after the restart.`;
15991
15998
  }
15992
15999
  maybeStage(force) {
15993
16000
  if (this.staging) return;
@@ -16005,6 +16012,7 @@ var CompanionUpdater = class {
16005
16012
  const outcome = await stage(version, this.deps.harnesses());
16006
16013
  if (outcome.ok) {
16007
16014
  this.ready = version;
16015
+ this.readyAt = this.now();
16008
16016
  updateUpdateState((s) => {
16009
16017
  const { failed: _failed, ...rest } = s;
16010
16018
  return rest;
@@ -16043,8 +16051,10 @@ var CompanionUpdater = class {
16043
16051
  });
16044
16052
  void this.staging.then(() => this.trySwap());
16045
16053
  }
16046
- // Swap into the ready version once nothing is running. Called on each heartbeat until it can.
16047
- async trySwap() {
16054
+ // Swap into the ready version: when nothing is running, when it has waited `SWAP_WAIT_MS` for
16055
+ // that, when this version is below the server's minimum, or `now` (the person asked). Called on
16056
+ // each heartbeat until it does.
16057
+ async trySwap(now = false) {
16048
16058
  const to = this.ready;
16049
16059
  if (!to || this.swapping) return;
16050
16060
  if (readUpdateState().pending) return;
@@ -16053,18 +16063,9 @@ var CompanionUpdater = class {
16053
16063
  this.deps.log.info({ version: to }, `Cabane ${to} is installed; it runs from the next start`);
16054
16064
  return;
16055
16065
  }
16056
- if (!this.deps.isIdle()) return;
16066
+ if (!now && !this.deps.isIdle() && !this.waitedLongEnough() && !this.belowMinimum()) return;
16057
16067
  this.swapping = true;
16058
16068
  try {
16059
- await this.deps.pauseStream();
16060
- const sleep5 = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
16061
- const deadline = this.now() + SWAP_DRAIN_MS;
16062
- await sleep5(250);
16063
- while (!this.deps.isIdle() && this.now() < deadline) await sleep5(250);
16064
- if (!this.deps.isIdle()) {
16065
- this.deps.resumeStream();
16066
- return;
16067
- }
16068
16069
  this.select(to);
16069
16070
  this.deps.log.info({ from: this.deps.own, to }, `Restarting into Cabane ${to}`);
16070
16071
  await this.deps.restart();
@@ -16072,6 +16073,14 @@ var CompanionUpdater = class {
16072
16073
  this.swapping = false;
16073
16074
  }
16074
16075
  }
16076
+ waitedLongEnough() {
16077
+ return this.now() - this.readyAt >= SWAP_WAIT_MS;
16078
+ }
16079
+ // Cabane refuses this version's turns: waiting would only turn them away.
16080
+ belowMinimum() {
16081
+ const minimum = readUpdateState().server?.minimum;
16082
+ return !!minimum && compareVersions(this.deps.own, minimum) < 0;
16083
+ }
16075
16084
  // Record the swap and point `current` at `to`, leaving the guard that counts its starts.
16076
16085
  select(to) {
16077
16086
  const fromRoot = currentRoot() ?? ownPackageRoot();
@@ -16096,6 +16105,7 @@ init_version2();
16096
16105
  init_src();
16097
16106
  var HEARTBEAT_INTERVAL_MS = 3e4;
16098
16107
  var ARCHIVE_PRUNE_MS = 24 * 60 * 6e4;
16108
+ var FORCE_EXIT_MS = 4e3;
16099
16109
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
16100
16110
  function positiveIntEnv(name) {
16101
16111
  const raw = process.env[name];
@@ -16316,14 +16326,7 @@ var CompanionSupervisor = class {
16316
16326
  autoUpdate: () => this.config.update?.auto === true,
16317
16327
  harnesses: () => this.connectedBundledHarnesses(),
16318
16328
  isIdle: () => this.idle(),
16319
- pauseStream: async () => {
16320
- const sub = this.deviceSub;
16321
- if (!sub) return;
16322
- sub.stop();
16323
- await sub.finished;
16324
- },
16325
- resumeStream: () => void this.deviceSub?.resume(),
16326
- restart: () => this.restartForUpdate(),
16329
+ restart: () => this.restartBounded(),
16327
16330
  log: this.log
16328
16331
  });
16329
16332
  this.updater.start();
@@ -17445,8 +17448,22 @@ var CompanionSupervisor = class {
17445
17448
  );
17446
17449
  }
17447
17450
  }
17451
+ // The update's restart, held to the same `FORCE_EXIT_MS` a signal's stop gets. Exit 0: the
17452
+ // swap is recorded and `current` names the new version, so the service starts it either way.
17453
+ async restartBounded() {
17454
+ const force = setTimeout(() => {
17455
+ this.log.warn({ ms: FORCE_EXIT_MS }, "Restarting for the update: shutdown took too long");
17456
+ this.exitFn(0);
17457
+ }, FORCE_EXIT_MS);
17458
+ force.unref?.();
17459
+ try {
17460
+ await this.restartForUpdate();
17461
+ } finally {
17462
+ clearTimeout(force);
17463
+ }
17464
+ }
17448
17465
  // Nothing running and nothing about to: no dispatch claimed, no turn chained, no recovery
17449
- // in progress. What a self-update swap waits for.
17466
+ // in progress. What a self-update swap waits for, up to `SWAP_WAIT_MS`.
17450
17467
  idle() {
17451
17468
  if (this.claims.size > 0 || this.recovering) return false;
17452
17469
  for (const wr of this.workspaces.values()) if (wr.chains.size > 0) return false;
@@ -17468,7 +17485,7 @@ var CompanionSupervisor = class {
17468
17485
  if (auto) this.kickHeartbeat();
17469
17486
  return {
17470
17487
  ok: true,
17471
- 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."
17488
+ message: auto ? "Cabane will keep itself up to date. Updates install between turns when they can, and roll back if they fail." : "Cabane won't update itself. Run cabane update to update."
17472
17489
  };
17473
17490
  }
17474
17491
  if (!this.updater) return { ok: false, message: "This computer isn't paired yet." };
@@ -18178,7 +18195,7 @@ async function startDaemon(opts = {}, deps = {}) {
18178
18195
  const readState = deps.readState ?? readLiveRuntimeState;
18179
18196
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
18180
18197
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
18181
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18198
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18182
18199
  const now = deps.now ?? (() => Date.now());
18183
18200
  const quiet = opts.report === "failures";
18184
18201
  const cfg = requireStartConfig({
@@ -18226,7 +18243,7 @@ async function startDaemon(opts = {}, deps = {}) {
18226
18243
  const deadline = now() + STARTUP_TIMEOUT_MS;
18227
18244
  let seen = readState();
18228
18245
  while (!ready(seen) && now() < deadline) {
18229
- await sleep5(POLL_INTERVAL_MS);
18246
+ await sleep4(POLL_INTERVAL_MS);
18230
18247
  seen = readState();
18231
18248
  }
18232
18249
  return ready(seen) ? seen : null;
@@ -18299,7 +18316,7 @@ async function stop(deps = {}) {
18299
18316
  const readState = deps.readState ?? readLiveRuntimeState;
18300
18317
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
18301
18318
  const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
18302
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18319
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18303
18320
  const now = deps.now ?? (() => Date.now());
18304
18321
  const state = readState();
18305
18322
  if (!state) {
@@ -18327,7 +18344,7 @@ async function stop(deps = {}) {
18327
18344
  process.stdout.write("companion: stopped.\n");
18328
18345
  return;
18329
18346
  }
18330
- await sleep5(POLL_INTERVAL_MS2);
18347
+ await sleep4(POLL_INTERVAL_MS2);
18331
18348
  }
18332
18349
  process.stdout.write(
18333
18350
  `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
@@ -18353,7 +18370,7 @@ function isAlive(kill, pid) {
18353
18370
  var STARTUP_WAIT_MS = 2e4;
18354
18371
  async function installAndStartService(deps = {}) {
18355
18372
  const readState = deps.readState ?? readLiveRuntimeState;
18356
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18373
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18357
18374
  const now = deps.now ?? Date.now;
18358
18375
  const before = await serviceState(deps);
18359
18376
  const running = readState();
@@ -18369,7 +18386,7 @@ async function installAndStartService(deps = {}) {
18369
18386
  while (now() < deadline) {
18370
18387
  const state = readState();
18371
18388
  if (state?.socket) return true;
18372
- await sleep5(250);
18389
+ await sleep4(250);
18373
18390
  }
18374
18391
  write(
18375
18392
  `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
@@ -18383,7 +18400,7 @@ async function serviceInstall(deps = {}) {
18383
18400
  }
18384
18401
  write(
18385
18402
  INDENT + tick(
18386
- "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself between turns."
18403
+ "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself, usually between turns."
18387
18404
  )
18388
18405
  );
18389
18406
  write(`${INDENT} Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
@@ -18446,7 +18463,7 @@ function updateRows(state, auto, opts = {}) {
18446
18463
  rows.push(
18447
18464
  row(
18448
18465
  "Auto-update",
18449
- !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`
18466
+ !opts.service ? `off \u2014 runs with the service: ${cabane("service install")}` : auto === true ? "on \u2014 updates install between turns when they can, and roll back if they fail" : `off \u2014 ${cabane("update --auto on")} turns it on`
18450
18467
  )
18451
18468
  );
18452
18469
  const last = state.lastResult;
@@ -18477,7 +18494,6 @@ function setRunsAsService(on) {
18477
18494
  }
18478
18495
 
18479
18496
  // src/commands/start.ts
18480
- var FORCE_EXIT_MS = 4e3;
18481
18497
  var MAX_CODES = 3;
18482
18498
  async function start(opts = {}) {
18483
18499
  setLogOverrides(opts);
@@ -18943,7 +18959,7 @@ async function askHowToRun(deps = {}) {
18943
18959
  return { asService: false };
18944
18960
  blank();
18945
18961
  const asService = await ask(
18946
- "Keep Cabane running and up to date on this computer? It starts at boot, restarts if it stops, and updates itself between turns."
18962
+ "Keep Cabane running and up to date on this computer? It starts at boot, restarts if it stops, and updates itself, usually between turns."
18947
18963
  );
18948
18964
  if (!asService) saveConfig({ ...cfg, serviceDeclined: true });
18949
18965
  blank();
@@ -19367,7 +19383,7 @@ async function update(opts = {}) {
19367
19383
  saveConfig({ ...cfg2, update: { auto } });
19368
19384
  write(
19369
19385
  INDENT + tick(
19370
- auto ? "Cabane will keep itself up to date. Updates install between turns and roll back if they fail." : "Cabane won't update itself."
19386
+ auto ? "Cabane will keep itself up to date. Updates install between turns when they can, and roll back if they fail." : "Cabane won't update itself."
19371
19387
  )
19372
19388
  );
19373
19389
  return;
package/dist/runtime.js CHANGED
@@ -13281,6 +13281,8 @@ var SseSubscriber = class {
13281
13281
  opts;
13282
13282
  aborted = false;
13283
13283
  controller = null;
13284
+ // Ends the reconnect backoff early, so a stop never waits out a sleep of up to 30 s.
13285
+ wake = null;
13284
13286
  lastEventId;
13285
13287
  // Set after `start()`; resolved when the subscriber loop exits (either
13286
13288
  // because `stop()` was called or because an auth failure tore it down).
@@ -13291,12 +13293,7 @@ var SseSubscriber = class {
13291
13293
  stop() {
13292
13294
  this.aborted = true;
13293
13295
  this.controller?.abort();
13294
- }
13295
- // Connect again after a `stop()`, once its loop has exited.
13296
- async resume() {
13297
- await this.finished;
13298
- this.aborted = false;
13299
- this.start();
13296
+ this.wake?.();
13300
13297
  }
13301
13298
  setCursor(eventId) {
13302
13299
  this.lastEventId = eventId;
@@ -13309,7 +13306,7 @@ var SseSubscriber = class {
13309
13306
  backoff = 500;
13310
13307
  if (!this.aborted) {
13311
13308
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
13312
- await sleep3(backoff);
13309
+ await this.backoff(backoff);
13313
13310
  }
13314
13311
  } catch (err) {
13315
13312
  if (this.aborted) return;
@@ -13329,11 +13326,21 @@ var SseSubscriber = class {
13329
13326
  },
13330
13327
  "Lost the connection to Cabane; reconnecting"
13331
13328
  );
13332
- await sleep3(backoff);
13329
+ await this.backoff(backoff);
13333
13330
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
13334
13331
  }
13335
13332
  }
13336
13333
  }
13334
+ backoff(ms) {
13335
+ return new Promise((resolve2) => {
13336
+ const timer = setTimeout(done, ms);
13337
+ this.wake = done;
13338
+ function done() {
13339
+ clearTimeout(timer);
13340
+ resolve2();
13341
+ }
13342
+ });
13343
+ }
13337
13344
  async connect() {
13338
13345
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
13339
13346
  const url = `${base}${this.opts.path}`;
@@ -13380,9 +13387,6 @@ var SseSubscriber = class {
13380
13387
  }
13381
13388
  }
13382
13389
  };
13383
- function sleep3(ms) {
13384
- return new Promise((resolve2) => setTimeout(resolve2, ms));
13385
- }
13386
13390
 
13387
13391
  // src/updater.ts
13388
13392
  import { spawn as spawn8 } from "child_process";
@@ -13554,7 +13558,7 @@ function updateMode(env = process.env) {
13554
13558
  return env.CABANE_COMPANION_SUPERVISED === "1" ? "supervised" : "attached";
13555
13559
  }
13556
13560
  var STAGE_RETRY_MS = 60 * 6e4;
13557
- var SWAP_DRAIN_MS = 1e4;
13561
+ var SWAP_WAIT_MS = 10 * 6e4;
13558
13562
  var SOURCE_BUILD_VERSION = "0.0.0";
13559
13563
  function versionToStage(input) {
13560
13564
  const { own, state, now, force } = input;
@@ -13704,6 +13708,8 @@ var CompanionUpdater = class {
13704
13708
  deps;
13705
13709
  staging = null;
13706
13710
  ready = null;
13711
+ // When `ready` verified; `SWAP_WAIT_MS` counts from here.
13712
+ readyAt = 0;
13707
13713
  swapping = false;
13708
13714
  watchdog = null;
13709
13715
  now;
@@ -13802,7 +13808,7 @@ var CompanionUpdater = class {
13802
13808
  return { ok: true, message: `Cabane ${this.deps.own} is up to date.` };
13803
13809
  if (state.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
13804
13810
  if (this.ready === latest) {
13805
- void this.trySwap();
13811
+ void this.trySwap(true);
13806
13812
  return { ok: true, message: this.waitingMessage(latest) };
13807
13813
  }
13808
13814
  this.maybeStage(true);
@@ -13818,13 +13824,14 @@ var CompanionUpdater = class {
13818
13824
  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.`
13819
13825
  };
13820
13826
  }
13821
- void this.trySwap();
13827
+ void this.trySwap(true);
13822
13828
  return { ok: true, message: this.waitingMessage(latest) };
13823
13829
  }
13830
+ // Only the person's `cabane update` asks, and that swaps at once.
13824
13831
  waitingMessage(version) {
13825
13832
  if (this.deps.mode === "attached")
13826
13833
  return `Cabane ${version} is installed. It runs from the next cabane start.`;
13827
- return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} once the turns running now finish.`;
13834
+ return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} now. The turns running now carry on after the restart.`;
13828
13835
  }
13829
13836
  maybeStage(force) {
13830
13837
  if (this.staging) return;
@@ -13842,6 +13849,7 @@ var CompanionUpdater = class {
13842
13849
  const outcome = await stage(version, this.deps.harnesses());
13843
13850
  if (outcome.ok) {
13844
13851
  this.ready = version;
13852
+ this.readyAt = this.now();
13845
13853
  updateUpdateState((s) => {
13846
13854
  const { failed: _failed, ...rest } = s;
13847
13855
  return rest;
@@ -13880,8 +13888,10 @@ var CompanionUpdater = class {
13880
13888
  });
13881
13889
  void this.staging.then(() => this.trySwap());
13882
13890
  }
13883
- // Swap into the ready version once nothing is running. Called on each heartbeat until it can.
13884
- async trySwap() {
13891
+ // Swap into the ready version: when nothing is running, when it has waited `SWAP_WAIT_MS` for
13892
+ // that, when this version is below the server's minimum, or `now` (the person asked). Called on
13893
+ // each heartbeat until it does.
13894
+ async trySwap(now = false) {
13885
13895
  const to = this.ready;
13886
13896
  if (!to || this.swapping) return;
13887
13897
  if (readUpdateState().pending) return;
@@ -13890,18 +13900,9 @@ var CompanionUpdater = class {
13890
13900
  this.deps.log.info({ version: to }, `Cabane ${to} is installed; it runs from the next start`);
13891
13901
  return;
13892
13902
  }
13893
- if (!this.deps.isIdle()) return;
13903
+ if (!now && !this.deps.isIdle() && !this.waitedLongEnough() && !this.belowMinimum()) return;
13894
13904
  this.swapping = true;
13895
13905
  try {
13896
- await this.deps.pauseStream();
13897
- const sleep4 = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
13898
- const deadline = this.now() + SWAP_DRAIN_MS;
13899
- await sleep4(250);
13900
- while (!this.deps.isIdle() && this.now() < deadline) await sleep4(250);
13901
- if (!this.deps.isIdle()) {
13902
- this.deps.resumeStream();
13903
- return;
13904
- }
13905
13906
  this.select(to);
13906
13907
  this.deps.log.info({ from: this.deps.own, to }, `Restarting into Cabane ${to}`);
13907
13908
  await this.deps.restart();
@@ -13909,6 +13910,14 @@ var CompanionUpdater = class {
13909
13910
  this.swapping = false;
13910
13911
  }
13911
13912
  }
13913
+ waitedLongEnough() {
13914
+ return this.now() - this.readyAt >= SWAP_WAIT_MS;
13915
+ }
13916
+ // Cabane refuses this version's turns: waiting would only turn them away.
13917
+ belowMinimum() {
13918
+ const minimum = readUpdateState().server?.minimum;
13919
+ return !!minimum && compareVersions(this.deps.own, minimum) < 0;
13920
+ }
13912
13921
  // Record the swap and point `current` at `to`, leaving the guard that counts its starts.
13913
13922
  select(to) {
13914
13923
  const fromRoot = currentRoot() ?? ownPackageRoot();
@@ -13936,6 +13945,7 @@ var COMPANION_VERSION = pkg.version;
13936
13945
  // src/supervisor.ts
13937
13946
  var HEARTBEAT_INTERVAL_MS = 3e4;
13938
13947
  var ARCHIVE_PRUNE_MS = 24 * 60 * 6e4;
13948
+ var FORCE_EXIT_MS = 4e3;
13939
13949
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
13940
13950
  function positiveIntEnv(name) {
13941
13951
  const raw = process.env[name];
@@ -14156,14 +14166,7 @@ var CompanionSupervisor = class {
14156
14166
  autoUpdate: () => this.config.update?.auto === true,
14157
14167
  harnesses: () => this.connectedBundledHarnesses(),
14158
14168
  isIdle: () => this.idle(),
14159
- pauseStream: async () => {
14160
- const sub = this.deviceSub;
14161
- if (!sub) return;
14162
- sub.stop();
14163
- await sub.finished;
14164
- },
14165
- resumeStream: () => void this.deviceSub?.resume(),
14166
- restart: () => this.restartForUpdate(),
14169
+ restart: () => this.restartBounded(),
14167
14170
  log: this.log
14168
14171
  });
14169
14172
  this.updater.start();
@@ -15285,8 +15288,22 @@ var CompanionSupervisor = class {
15285
15288
  );
15286
15289
  }
15287
15290
  }
15291
+ // The update's restart, held to the same `FORCE_EXIT_MS` a signal's stop gets. Exit 0: the
15292
+ // swap is recorded and `current` names the new version, so the service starts it either way.
15293
+ async restartBounded() {
15294
+ const force = setTimeout(() => {
15295
+ this.log.warn({ ms: FORCE_EXIT_MS }, "Restarting for the update: shutdown took too long");
15296
+ this.exitFn(0);
15297
+ }, FORCE_EXIT_MS);
15298
+ force.unref?.();
15299
+ try {
15300
+ await this.restartForUpdate();
15301
+ } finally {
15302
+ clearTimeout(force);
15303
+ }
15304
+ }
15288
15305
  // Nothing running and nothing about to: no dispatch claimed, no turn chained, no recovery
15289
- // in progress. What a self-update swap waits for.
15306
+ // in progress. What a self-update swap waits for, up to `SWAP_WAIT_MS`.
15290
15307
  idle() {
15291
15308
  if (this.claims.size > 0 || this.recovering) return false;
15292
15309
  for (const wr of this.workspaces.values()) if (wr.chains.size > 0) return false;
@@ -15308,7 +15325,7 @@ var CompanionSupervisor = class {
15308
15325
  if (auto) this.kickHeartbeat();
15309
15326
  return {
15310
15327
  ok: true,
15311
- 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."
15328
+ message: auto ? "Cabane will keep itself up to date. Updates install between turns when they can, and roll back if they fail." : "Cabane won't update itself. Run cabane update to update."
15312
15329
  };
15313
15330
  }
15314
15331
  if (!this.updater) return { ok: false, message: "This computer isn't paired yet." };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.158",
3
+ "version": "0.6.159",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",