@cabane/companion 0.6.187 → 0.6.188

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.
Files changed (3) hide show
  1. package/dist/cli.js +203 -88
  2. package/dist/runtime.js +72 -45
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -12420,14 +12420,19 @@ var DeviceApi = class {
12420
12420
  return parsed;
12421
12421
  }
12422
12422
  // Pull the agents this device should run, across all the owner's workspaces.
12423
- getAssignments() {
12424
- return this.request("GET", "/api/companion/assignments");
12423
+ getAssignments(signal) {
12424
+ return this.request("GET", "/api/companion/assignments", void 0, requestSignal(signal));
12425
12425
  }
12426
12426
  // Ask again for the credential of an agent assigned to this device, when the saved copy is
12427
12427
  // gone. The previous credential stops working; any agent not assigned here is a 404, and a
12428
12428
  // second ask inside a minute a 429.
12429
- reissueAgentCredential(agentId) {
12430
- return this.request("POST", `/api/companion/agents/${encodeURIComponent(agentId)}/credential`);
12429
+ reissueAgentCredential(agentId, signal) {
12430
+ return this.request(
12431
+ "POST",
12432
+ `/api/companion/agents/${encodeURIComponent(agentId)}/credential`,
12433
+ void 0,
12434
+ requestSignal(signal, 3e4)
12435
+ );
12431
12436
  }
12432
12437
  getRuns(cursor) {
12433
12438
  const query = new URLSearchParams({ limit: "100", order: "asc" });
@@ -12467,6 +12472,10 @@ function errorMessage2(status2, body) {
12467
12472
  if (typeof body === "string" && body.length > 0) return `${status2} ${body.slice(0, 200)}`;
12468
12473
  return `${status2} error`;
12469
12474
  }
12475
+ function requestSignal(signal, timeoutMs = 5e3) {
12476
+ const deadline = AbortSignal.timeout(timeoutMs);
12477
+ return signal ? AbortSignal.any([signal, deadline]) : deadline;
12478
+ }
12470
12479
 
12471
12480
  // src/supervisor.ts
12472
12481
  init_config();
@@ -13842,6 +13851,7 @@ var CompanionSupervisor = class {
13842
13851
  pollTimer = null;
13843
13852
  refreshing = false;
13844
13853
  stopped = false;
13854
+ reconciliationAbort = new AbortController();
13845
13855
  // The version-skew warning is logged once, not every heartbeat.
13846
13856
  versionSkewWarned = false;
13847
13857
  // This device's id, captured from the heartbeat / assignments response. The SSE
@@ -13887,6 +13897,7 @@ var CompanionSupervisor = class {
13887
13897
  // heartbeat + poll loops. A companion with no device token (logged out) does
13888
13898
  // nothing but say so.
13889
13899
  async start() {
13900
+ if (this.stopped) return;
13890
13901
  this.log.info(
13891
13902
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
13892
13903
  `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
@@ -13971,6 +13982,7 @@ var CompanionSupervisor = class {
13971
13982
  });
13972
13983
  this.updater.start();
13973
13984
  await this.refreshAssignments();
13985
+ if (this.stopped) return;
13974
13986
  this.deviceSub.start();
13975
13987
  this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
13976
13988
  this.heartbeatTimer.unref?.();
@@ -14083,10 +14095,12 @@ var CompanionSupervisor = class {
14083
14095
  let items;
14084
14096
  let device;
14085
14097
  try {
14086
- const resp = await this.deviceApi.getAssignments();
14098
+ const resp = await this.deviceApi.getAssignments(this.reconciliationAbort.signal);
14099
+ if (this.stopped) return;
14087
14100
  items = resp.assignments;
14088
14101
  device = resp.device;
14089
14102
  } catch (err) {
14103
+ if (this.stopped) return;
14090
14104
  this.log.error(
14091
14105
  { ...apiErrorLogFields(err) },
14092
14106
  "Couldn't load the assigned agents. Check that this device is active in Cabane; trying again shortly."
@@ -14105,6 +14119,7 @@ var CompanionSupervisor = class {
14105
14119
  if (it.credential) setCredential(device.id, it.agentId, it.credential);
14106
14120
  }
14107
14121
  items = await this.restoreMissingCredentials(device.id, items);
14122
+ if (this.stopped) return;
14108
14123
  this.uncredentialed = new Set(
14109
14124
  items.filter((it) => !it.credential && !getCredential(device.id, it.agentId)).map((it) => `${it.workspaceId}|${it.agentId}`)
14110
14125
  );
@@ -14117,6 +14132,7 @@ var CompanionSupervisor = class {
14117
14132
  for (const wsId of [...this.workspaces.keys()]) {
14118
14133
  if (!byWorkspace.has(wsId)) await this.removeWorkspace(wsId);
14119
14134
  }
14135
+ if (this.stopped) return;
14120
14136
  for (const [wsId, list] of byWorkspace) this.reconcileWorkspace(device.id, wsId, list);
14121
14137
  const pruned = pruneCredentials(
14122
14138
  device.id,
@@ -14148,6 +14164,7 @@ var CompanionSupervisor = class {
14148
14164
  async restoreMissingCredentials(deviceId, items) {
14149
14165
  const restored = [];
14150
14166
  for (const it of items) {
14167
+ if (this.stopped) return restored;
14151
14168
  if (it.credential || getCredential(deviceId, it.agentId)) {
14152
14169
  restored.push(it);
14153
14170
  continue;
@@ -14159,7 +14176,11 @@ var CompanionSupervisor = class {
14159
14176
  continue;
14160
14177
  }
14161
14178
  try {
14162
- const { credential } = await this.deviceApi.reissueAgentCredential(it.agentId);
14179
+ const { credential } = await this.deviceApi.reissueAgentCredential(
14180
+ it.agentId,
14181
+ this.reconciliationAbort.signal
14182
+ );
14183
+ if (this.stopped) return restored;
14163
14184
  setCredential(deviceId, it.agentId, credential);
14164
14185
  this.log.info(
14165
14186
  { agentId: it.agentId, agentName: it.agentDisplayName },
@@ -15034,6 +15055,7 @@ var CompanionSupervisor = class {
15034
15055
  // ---- control actions ----
15035
15056
  async shutdown() {
15036
15057
  this.stopped = true;
15058
+ this.reconciliationAbort.abort();
15037
15059
  this.updater?.stop();
15038
15060
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
15039
15061
  if (this.detectTimer) clearInterval(this.detectTimer);
@@ -15311,33 +15333,46 @@ async function createCompanionRuntime(opts = {}) {
15311
15333
  });
15312
15334
  let restartForUpdate = async () => {
15313
15335
  };
15314
- const supervisor = new CompanionSupervisor({
15315
- config: cfg,
15316
- log: log2,
15317
- hub,
15318
- ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
15319
- ...embeddedRunner({
15320
- electron: Boolean(process.versions.electron),
15321
- execPath: process.execPath,
15322
- moduleUrl: import.meta.url,
15323
- env: process.env
15324
- }) ?? {},
15325
- restartForUpdate: () => restartForUpdate()
15326
- });
15327
- await supervisor.start();
15336
+ let supervisor;
15328
15337
  let control = null;
15329
15338
  let dashboard = null;
15339
+ let stopped = false;
15340
+ const stop2 = async () => {
15341
+ if (stopped) return;
15342
+ stopped = true;
15343
+ clearRuntimeStateIfOurs(instanceId);
15344
+ process.removeListener("exit", clearRuntimeOnExit);
15345
+ await Promise.allSettled([
15346
+ ...supervisor ? [supervisor.shutdown()] : [],
15347
+ ...control ? [control.close()] : [],
15348
+ ...dashboard ? [dashboard.close()] : []
15349
+ ]);
15350
+ };
15330
15351
  try {
15352
+ supervisor = new CompanionSupervisor({
15353
+ config: cfg,
15354
+ log: log2,
15355
+ hub,
15356
+ ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
15357
+ ...embeddedRunner({
15358
+ electron: Boolean(process.versions.electron),
15359
+ execPath: process.execPath,
15360
+ moduleUrl: import.meta.url,
15361
+ env: process.env
15362
+ }) ?? {},
15363
+ restartForUpdate: () => restartForUpdate()
15364
+ });
15365
+ const active2 = supervisor;
15331
15366
  control = await startControlServer({
15332
15367
  status: () => ({
15333
15368
  ...hub.statusJson(),
15334
- ...supervisor.lastHeartbeat(),
15335
- detection: supervisor.currentDetection()
15369
+ ...active2.lastHeartbeat(),
15370
+ detection: active2.currentDetection()
15336
15371
  }),
15337
- detect: () => supervisor.redetect(),
15338
- allow: (runtime) => supervisor.allowHarness(runtime),
15339
- update: (auto) => supervisor.update(auto),
15340
- stop: () => void supervisor.requestStop()
15372
+ detect: () => active2.redetect(),
15373
+ allow: (runtime) => active2.allowHarness(runtime),
15374
+ update: (auto) => active2.update(auto),
15375
+ stop: () => void stop2().then(() => active2.requestStop())
15341
15376
  });
15342
15377
  if (opts.dashboard) {
15343
15378
  const preferredPort = opts.port ?? cfg.dashboardPort;
@@ -15348,6 +15383,7 @@ async function createCompanionRuntime(opts = {}) {
15348
15383
  });
15349
15384
  hub.setDashboardUrl(dashboard.url);
15350
15385
  }
15386
+ if (stopped) throw new Error("The companion was stopped during startup.");
15351
15387
  writeRuntimeState({
15352
15388
  pid: process.pid,
15353
15389
  socket: control.path,
@@ -15365,7 +15401,7 @@ async function createCompanionRuntime(opts = {}) {
15365
15401
  clearRuntimeStateIfOurs(instanceId);
15366
15402
  process.removeListener("exit", clearRuntimeOnExit);
15367
15403
  await Promise.allSettled([
15368
- supervisor.shutdown(),
15404
+ ...supervisor ? [supervisor.shutdown()] : [],
15369
15405
  ...control ? [control.close()] : [],
15370
15406
  ...dashboard ? [dashboard.close()] : []
15371
15407
  ]);
@@ -15377,22 +15413,17 @@ async function createCompanionRuntime(opts = {}) {
15377
15413
  });
15378
15414
  throw err;
15379
15415
  }
15380
- let stopped = false;
15381
- const stop2 = async () => {
15382
- if (stopped) return;
15383
- stopped = true;
15384
- clearRuntimeStateIfOurs(instanceId);
15385
- process.removeListener("exit", clearRuntimeOnExit);
15386
- try {
15387
- await supervisor.shutdown();
15388
- await closeSurfaces(control, dashboard);
15389
- } catch {
15390
- }
15391
- };
15416
+ const active = supervisor;
15392
15417
  restartForUpdate = async () => {
15393
15418
  await stop2();
15394
15419
  process.exit(0);
15395
15420
  };
15421
+ void active.start().catch(async (err) => {
15422
+ log2.error({ err }, "Couldn't initialize the companion.");
15423
+ recordCrash({ reason: String(err), origin: "startup", at: (/* @__PURE__ */ new Date()).toISOString() });
15424
+ await stop2();
15425
+ process.exitCode = 1;
15426
+ });
15396
15427
  return {
15397
15428
  ok: true,
15398
15429
  runtime: {
@@ -15400,15 +15431,38 @@ async function createCompanionRuntime(opts = {}) {
15400
15431
  socketPath: control.path,
15401
15432
  config: cfg,
15402
15433
  stop: stop2,
15403
- heartbeatNow: () => supervisor.heartbeatNow(),
15434
+ heartbeatNow: () => active.heartbeatNow(),
15404
15435
  harnesses: () => hub.statusJson().harnesses ?? [],
15405
- detect: () => supervisor.redetect()
15436
+ detect: () => active.redetect()
15406
15437
  }
15407
15438
  };
15408
15439
  }
15409
- async function closeSurfaces(control, dashboard) {
15410
- await control.close();
15411
- if (dashboard) await dashboard.close();
15440
+
15441
+ // src/start-observation.ts
15442
+ init_cabane_command();
15443
+ async function observeStart(deps = {}) {
15444
+ const read2 = deps.readState ?? readLiveRuntimeState;
15445
+ const verify = deps.verify ?? verifyRuntime;
15446
+ const now = deps.now ?? Date.now;
15447
+ const sleep3 = deps.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
15448
+ const deadline = now() + 8e3;
15449
+ for (; ; ) {
15450
+ const state = await read2();
15451
+ if (state?.socket && await verify(state) === "ours") return state;
15452
+ if (now() >= deadline) return null;
15453
+ await sleep3(Math.min(150, Math.max(0, deadline - now())));
15454
+ }
15455
+ }
15456
+ function markLaunchRequester() {
15457
+ process.title = "cabane-launcher";
15458
+ }
15459
+ function unconfirmedStartLines(service2) {
15460
+ return [
15461
+ "Cabane's start was requested, but readiness isn't confirmed yet. It may still finish starting in the background.",
15462
+ `Check: ${cabane("status")}`,
15463
+ ...service2 ? [`Service: ${cabane("service status")}`] : [],
15464
+ `Log: ${cabane("logs")}`
15465
+ ];
15412
15466
  }
15413
15467
 
15414
15468
  // src/commands/start.ts
@@ -15817,15 +15871,11 @@ init_config();
15817
15871
  import { spawn as spawn7 } from "child_process";
15818
15872
  import { closeSync as closeSync3, mkdirSync as mkdirSync17, openSync as openSync4 } from "fs";
15819
15873
  init_config();
15820
- var STARTUP_TIMEOUT_MS = 8e3;
15821
- var POLL_INTERVAL_MS = 150;
15822
15874
  async function startDaemon(opts = {}, deps = {}) {
15823
15875
  const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
15824
15876
  const readState = deps.readState ?? readLiveRuntimeState;
15825
15877
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
15826
15878
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
15827
- const sleep3 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
15828
- const now = deps.now ?? (() => Date.now());
15829
15879
  const quiet = opts.report === "failures" || opts.report === "silent";
15830
15880
  const cfg = (deps.requireCfg ?? requireConfig)();
15831
15881
  ensureRuntime(cfg, {
@@ -15837,7 +15887,18 @@ async function startDaemon(opts = {}, deps = {}) {
15837
15887
  });
15838
15888
  const existing = readState();
15839
15889
  if (existing) {
15840
- if (await verify(existing) !== "stale") {
15890
+ const verdict = await verify(existing);
15891
+ if (verdict !== "stale") {
15892
+ if (verdict !== "ours") {
15893
+ const state2 = await observeStart({ ...deps, readState, verify });
15894
+ if (!state2) {
15895
+ if (opts.report !== "silent")
15896
+ process.stdout.write(unconfirmedStartLines(false).join("\n") + "\n");
15897
+ process.exitCode = 1;
15898
+ return { started: false, state: null, unconfirmed: true };
15899
+ }
15900
+ return { started: false, state: state2 };
15901
+ }
15841
15902
  if (opts.report !== "silent")
15842
15903
  process.stdout.write(alreadyRunningLines(existing.pid).join("\n") + "\n");
15843
15904
  return { started: false, state: existing };
@@ -15855,28 +15916,21 @@ async function startDaemon(opts = {}, deps = {}) {
15855
15916
  spawned.unref();
15856
15917
  return spawned;
15857
15918
  };
15858
- const ready = (s) => !!s && !!s.socket;
15859
- const waitForReady = async () => {
15860
- const deadline = now() + STARTUP_TIMEOUT_MS;
15861
- let seen = readState();
15862
- while (!ready(seen) && now() < deadline) {
15863
- await sleep3(POLL_INTERVAL_MS);
15864
- seen = readState();
15865
- }
15866
- return ready(seen) ? seen : null;
15867
- };
15919
+ markLaunchRequester();
15868
15920
  const child = launchDetached();
15869
- const state = await waitForReady();
15921
+ const state = await observeStart({ ...deps, readState, verify });
15870
15922
  if (!state) {
15871
- const error = `The background process (pid ${child?.pid ?? "?"}) did not report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.`;
15923
+ const error = child.failure?.();
15924
+ if (error) {
15925
+ if (opts.report !== "silent") process.stdout.write(`Cabane couldn't start: ${error}
15926
+ `);
15927
+ process.exitCode = 1;
15928
+ return { started: false, state: null, error };
15929
+ }
15872
15930
  if (opts.report !== "silent")
15873
- process.stdout.write(
15874
- `Cabane Companion was launched (pid ${child?.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
15875
- Check ${companionLogPath()} for errors, or run \`${cabane("status")}\`.
15876
- `
15877
- );
15931
+ process.stdout.write(unconfirmedStartLines(false).join("\n") + "\n");
15878
15932
  process.exitCode = 1;
15879
- return { started: false, state: null, error };
15933
+ return { started: false, state: null, unconfirmed: true };
15880
15934
  }
15881
15935
  if (quiet) return { started: true, state };
15882
15936
  process.stdout.write(
@@ -15896,11 +15950,19 @@ function defaultSpawnDetached(args) {
15896
15950
  mkdirSync17(cabaneDir(), { recursive: true });
15897
15951
  const logFd = openSync4(companionLogPath(), "a");
15898
15952
  try {
15899
- return spawn7(process.execPath, [cliPath, ...args], {
15953
+ const child = spawn7(process.execPath, [cliPath, ...args], {
15900
15954
  detached: true,
15901
15955
  stdio: ["ignore", logFd, logFd],
15902
15956
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
15903
15957
  });
15958
+ let failure;
15959
+ child.once("error", (err) => {
15960
+ failure = err.message;
15961
+ });
15962
+ child.once("exit", (code, signal) => {
15963
+ failure = `The background process exited (${signal ?? code}). Check cabane logs.`;
15964
+ });
15965
+ return { pid: child.pid, unref: () => child.unref(), failure: () => failure };
15904
15966
  } finally {
15905
15967
  closeSync3(logFd);
15906
15968
  }
@@ -16020,8 +16082,10 @@ async function start(opts = {}, deps = {}) {
16020
16082
  try {
16021
16083
  return await startScript(opts, deps);
16022
16084
  } catch (err) {
16023
- for (const line of startFailure(err instanceof Error ? err.message : String(err), reloadLine()))
16024
- write(line);
16085
+ const reason = err instanceof Error ? err.message : String(err);
16086
+ if (process.env.CABANE_COMPANION_SUPERVISED === "1")
16087
+ getLogger().error({ err }, `Cabane couldn't start: ${reason}`);
16088
+ for (const line of startFailure(reason, reloadLine())) write(line);
16025
16089
  process.exitCode = 1;
16026
16090
  return null;
16027
16091
  } finally {
@@ -16057,23 +16121,41 @@ async function startScript(opts, deps) {
16057
16121
  mode = "attached";
16058
16122
  }
16059
16123
  } else if (installed) {
16060
- const result = await startService();
16061
- if (!result.ok) throw new Error(result.lines.join(" "));
16062
- const deadline = Date.now() + 8e3;
16063
- while (!running?.socket && Date.now() < deadline) {
16064
- running = await (deps.live ?? liveCompanionState)();
16065
- if (!running?.socket) await new Promise((resolve2) => setTimeout(resolve2, 150));
16124
+ markLaunchRequester();
16125
+ const before = await serviceState();
16126
+ if (!serviceLaunchPending(before)) {
16127
+ const result = await startService();
16128
+ if (!result.ok) {
16129
+ running = await observeStart({ ...deps, readState: deps.live });
16130
+ const current2 = await serviceState();
16131
+ if (!running && !serviceLaunchPending(current2)) throw new Error(result.lines.join(" "));
16132
+ }
16066
16133
  }
16067
- if (!running?.socket) throw new Error("The service did not report ready within 8s.");
16068
- mode = "service";
16134
+ running ??= await observeStart({ ...deps, readState: deps.live });
16135
+ if (!running) {
16136
+ const current2 = await serviceState();
16137
+ if (!current2.nodeOk || current2.state === "failed")
16138
+ throw new Error(
16139
+ !current2.nodeOk ? "The service Node executable is missing." : "The service failed. Check cabane logs."
16140
+ );
16141
+ return reportUnconfirmed(true);
16142
+ }
16143
+ const current = await serviceState();
16144
+ mode = current.state === "running" && current.pid === running.pid ? "service" : "existing";
16069
16145
  } else {
16070
16146
  const result = await startDaemon({ ...opts, report: "silent" });
16147
+ if (result.unconfirmed) return reportUnconfirmed(false);
16071
16148
  if (!result.state)
16072
16149
  throw new Error(result.error ?? "The background process did not report ready.");
16073
16150
  running = result.state;
16074
16151
  mode = result.started ? "background" : "existing";
16075
16152
  }
16076
16153
  }
16154
+ if (!runtime && mode === "existing") {
16155
+ running = await observeStart({ ...deps, readState: deps.live });
16156
+ if (!running) return reportUnconfirmed(installed);
16157
+ }
16158
+ const attached = runtime ? runAttached(runtime) : null;
16077
16159
  const socket = runtime?.socketPath ?? running?.socket;
16078
16160
  let status2 = await (deps.waitAck ?? waitForAcknowledgement)(socket);
16079
16161
  let detection = status2.detection ?? null;
@@ -16087,6 +16169,26 @@ async function startScript(opts, deps) {
16087
16169
  const latest = await readTerminalStatus(socket);
16088
16170
  if (latest.lastHeartbeatAckAt || latest.lastHeartbeatRefused) status2 = latest;
16089
16171
  }
16172
+ if (!runtime) {
16173
+ const current = await (deps.live ?? liveCompanionState)();
16174
+ if (!current || await (deps.verify ?? verifyRuntime)(current) !== "ours") {
16175
+ if (installed) {
16176
+ const service2 = await serviceState();
16177
+ if (!service2.pid && ["not loaded", "dead", "inactive", "failed"].includes(service2.state))
16178
+ throw new Error("The service stopped before startup completed.");
16179
+ }
16180
+ return reportUnconfirmed(installed);
16181
+ }
16182
+ if (current.pid !== running?.pid || current.instanceId !== running?.instanceId) {
16183
+ status2 = await readTerminalStatus(current.socket);
16184
+ detection = status2.detection ?? null;
16185
+ }
16186
+ running = current;
16187
+ if (mode === "service") {
16188
+ const service2 = await serviceState();
16189
+ if (service2.state !== "running" || service2.pid !== current.pid) mode = "existing";
16190
+ }
16191
+ }
16090
16192
  const personalUrl = `${cfg.baseUrl.replace(/\/$/, "")}/settings/devices`;
16091
16193
  const address = opts.pairing ? opts.pairing.devicesUrl : personalUrl;
16092
16194
  if (!daemon)
@@ -16103,10 +16205,18 @@ async function startScript(opts, deps) {
16103
16205
  write(line);
16104
16206
  if (runtime) {
16105
16207
  setConsoleLogging(true);
16106
- await runAttached(runtime);
16208
+ await attached;
16107
16209
  }
16108
16210
  return detection;
16109
16211
  }
16212
+ function serviceLaunchPending(state) {
16213
+ return !!state.pid || ["running", "activating", "auto-restart"].includes(state.state) || state.platform === "launchd" && state.installed && !["not loaded", "unknown", "failed"].includes(state.state);
16214
+ }
16215
+ function reportUnconfirmed(service2) {
16216
+ for (const line of unconfirmedStartLines(service2)) write(line);
16217
+ process.exitCode = 1;
16218
+ return null;
16219
+ }
16110
16220
  async function detectOverSocket(socket) {
16111
16221
  const body = await controlRequest(socket, { cmd: "detect" }, 15e3);
16112
16222
  return body.detection;
@@ -16480,7 +16590,7 @@ init_term();
16480
16590
 
16481
16591
  // src/commands/stop.ts
16482
16592
  var TERM_GRACE_MS = 6e3;
16483
- var POLL_INTERVAL_MS2 = 150;
16593
+ var POLL_INTERVAL_MS = 150;
16484
16594
  async function stop(deps = {}) {
16485
16595
  if ((deps.serviceInstalled ?? serviceInstalled)()) {
16486
16596
  const outcome = await (deps.stopService ?? stopService)();
@@ -16526,7 +16636,7 @@ async function stop(deps = {}) {
16526
16636
  process.stdout.write("companion: stopped.\n");
16527
16637
  return;
16528
16638
  }
16529
- await sleep3(POLL_INTERVAL_MS2);
16639
+ await sleep3(POLL_INTERVAL_MS);
16530
16640
  }
16531
16641
  process.stdout.write(
16532
16642
  `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
@@ -16683,7 +16793,10 @@ function row2(label, value) {
16683
16793
  async function status(deps = {}) {
16684
16794
  const out = (line) => void process.stdout.write(`${line}
16685
16795
  `);
16686
- const running = await liveState();
16796
+ const candidate = readLiveRuntimeState({ sweep: false });
16797
+ const verdict = candidate ? await verifyRuntime(candidate) : null;
16798
+ const running = verdict === "stale" ? null : candidate;
16799
+ const service2 = await serviceState();
16687
16800
  for (const line of pathLines(reloadLine())) out(line);
16688
16801
  const strays = strayStatusLines(running, deps.strays);
16689
16802
  out(` ${versionLine()}`);
@@ -16705,7 +16818,7 @@ async function status(deps = {}) {
16705
16818
  out(row2("Paired with", `${cfg.baseUrl}${cfg.deviceLabel ? ` as ${cfg.deviceLabel}` : ""}`));
16706
16819
  let detection = null;
16707
16820
  if (running) {
16708
- const mode = running.daemon ? "in the background" : "in a terminal";
16821
+ const mode = verdict !== "ours" ? "identity unconfirmed" : runningMode(running, service2);
16709
16822
  const uptime = formatUptime(running.startedAt);
16710
16823
  out(row2("Running", `${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})`));
16711
16824
  detection = await runningDetection(running);
@@ -16723,7 +16836,6 @@ async function status(deps = {}) {
16723
16836
  )
16724
16837
  );
16725
16838
  for (const line of strays) out(line);
16726
- const service2 = await serviceState();
16727
16839
  for (const line of serviceRows(service2)) out(line);
16728
16840
  for (const line of updateRows(readUpdateState(), cfg.update?.auto, {
16729
16841
  version: false,
@@ -16793,10 +16905,13 @@ function otherInstall(install, own) {
16793
16905
  const ownAlias = own ? real(join30(dirname13(own), "cabane-companion")) : null;
16794
16906
  return real(found) === ownAlias ? null : dirname13(found);
16795
16907
  }
16796
- async function liveState() {
16797
- const running = readLiveRuntimeState({ sweep: false });
16798
- if (running && await verifyRuntime(running) === "stale") return null;
16799
- return running;
16908
+ function runningMode(running, service2) {
16909
+ if (service2.installed) {
16910
+ if (service2.state === "running" && service2.pid === running.pid) return "as a service";
16911
+ if (service2.state === "unknown" || service2.pid === running.pid || service2.state === "running" && !service2.pid)
16912
+ return "run mode unconfirmed";
16913
+ }
16914
+ return running.daemon ? "in the background" : "in a terminal";
16800
16915
  }
16801
16916
  async function runningDetection(running) {
16802
16917
  if (!running.socket) return null;
package/dist/runtime.js CHANGED
@@ -2344,14 +2344,19 @@ var DeviceApi = class {
2344
2344
  return parsed;
2345
2345
  }
2346
2346
  // Pull the agents this device should run, across all the owner's workspaces.
2347
- getAssignments() {
2348
- return this.request("GET", "/api/companion/assignments");
2347
+ getAssignments(signal) {
2348
+ return this.request("GET", "/api/companion/assignments", void 0, requestSignal(signal));
2349
2349
  }
2350
2350
  // Ask again for the credential of an agent assigned to this device, when the saved copy is
2351
2351
  // gone. The previous credential stops working; any agent not assigned here is a 404, and a
2352
2352
  // second ask inside a minute a 429.
2353
- reissueAgentCredential(agentId) {
2354
- return this.request("POST", `/api/companion/agents/${encodeURIComponent(agentId)}/credential`);
2353
+ reissueAgentCredential(agentId, signal) {
2354
+ return this.request(
2355
+ "POST",
2356
+ `/api/companion/agents/${encodeURIComponent(agentId)}/credential`,
2357
+ void 0,
2358
+ requestSignal(signal, 3e4)
2359
+ );
2355
2360
  }
2356
2361
  getRuns(cursor) {
2357
2362
  const query = new URLSearchParams({ limit: "100", order: "asc" });
@@ -2391,6 +2396,10 @@ function errorMessage2(status, body) {
2391
2396
  if (typeof body === "string" && body.length > 0) return `${status} ${body.slice(0, 200)}`;
2392
2397
  return `${status} error`;
2393
2398
  }
2399
+ function requestSignal(signal, timeoutMs = 5e3) {
2400
+ const deadline = AbortSignal.timeout(timeoutMs);
2401
+ return signal ? AbortSignal.any([signal, deadline]) : deadline;
2402
+ }
2394
2403
 
2395
2404
  // src/credentials.ts
2396
2405
  import {
@@ -9623,6 +9632,7 @@ var CompanionSupervisor = class {
9623
9632
  pollTimer = null;
9624
9633
  refreshing = false;
9625
9634
  stopped = false;
9635
+ reconciliationAbort = new AbortController();
9626
9636
  // The version-skew warning is logged once, not every heartbeat.
9627
9637
  versionSkewWarned = false;
9628
9638
  // This device's id, captured from the heartbeat / assignments response. The SSE
@@ -9668,6 +9678,7 @@ var CompanionSupervisor = class {
9668
9678
  // heartbeat + poll loops. A companion with no device token (logged out) does
9669
9679
  // nothing but say so.
9670
9680
  async start() {
9681
+ if (this.stopped) return;
9671
9682
  this.log.info(
9672
9683
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
9673
9684
  `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
@@ -9752,6 +9763,7 @@ var CompanionSupervisor = class {
9752
9763
  });
9753
9764
  this.updater.start();
9754
9765
  await this.refreshAssignments();
9766
+ if (this.stopped) return;
9755
9767
  this.deviceSub.start();
9756
9768
  this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
9757
9769
  this.heartbeatTimer.unref?.();
@@ -9864,10 +9876,12 @@ var CompanionSupervisor = class {
9864
9876
  let items;
9865
9877
  let device;
9866
9878
  try {
9867
- const resp = await this.deviceApi.getAssignments();
9879
+ const resp = await this.deviceApi.getAssignments(this.reconciliationAbort.signal);
9880
+ if (this.stopped) return;
9868
9881
  items = resp.assignments;
9869
9882
  device = resp.device;
9870
9883
  } catch (err) {
9884
+ if (this.stopped) return;
9871
9885
  this.log.error(
9872
9886
  { ...apiErrorLogFields(err) },
9873
9887
  "Couldn't load the assigned agents. Check that this device is active in Cabane; trying again shortly."
@@ -9886,6 +9900,7 @@ var CompanionSupervisor = class {
9886
9900
  if (it.credential) setCredential(device.id, it.agentId, it.credential);
9887
9901
  }
9888
9902
  items = await this.restoreMissingCredentials(device.id, items);
9903
+ if (this.stopped) return;
9889
9904
  this.uncredentialed = new Set(
9890
9905
  items.filter((it) => !it.credential && !getCredential(device.id, it.agentId)).map((it) => `${it.workspaceId}|${it.agentId}`)
9891
9906
  );
@@ -9898,6 +9913,7 @@ var CompanionSupervisor = class {
9898
9913
  for (const wsId of [...this.workspaces.keys()]) {
9899
9914
  if (!byWorkspace.has(wsId)) await this.removeWorkspace(wsId);
9900
9915
  }
9916
+ if (this.stopped) return;
9901
9917
  for (const [wsId, list] of byWorkspace) this.reconcileWorkspace(device.id, wsId, list);
9902
9918
  const pruned = pruneCredentials(
9903
9919
  device.id,
@@ -9929,6 +9945,7 @@ var CompanionSupervisor = class {
9929
9945
  async restoreMissingCredentials(deviceId, items) {
9930
9946
  const restored = [];
9931
9947
  for (const it of items) {
9948
+ if (this.stopped) return restored;
9932
9949
  if (it.credential || getCredential(deviceId, it.agentId)) {
9933
9950
  restored.push(it);
9934
9951
  continue;
@@ -9940,7 +9957,11 @@ var CompanionSupervisor = class {
9940
9957
  continue;
9941
9958
  }
9942
9959
  try {
9943
- const { credential } = await this.deviceApi.reissueAgentCredential(it.agentId);
9960
+ const { credential } = await this.deviceApi.reissueAgentCredential(
9961
+ it.agentId,
9962
+ this.reconciliationAbort.signal
9963
+ );
9964
+ if (this.stopped) return restored;
9944
9965
  setCredential(deviceId, it.agentId, credential);
9945
9966
  this.log.info(
9946
9967
  { agentId: it.agentId, agentName: it.agentDisplayName },
@@ -10815,6 +10836,7 @@ var CompanionSupervisor = class {
10815
10836
  // ---- control actions ----
10816
10837
  async shutdown() {
10817
10838
  this.stopped = true;
10839
+ this.reconciliationAbort.abort();
10818
10840
  this.updater?.stop();
10819
10841
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
10820
10842
  if (this.detectTimer) clearInterval(this.detectTimer);
@@ -11090,33 +11112,46 @@ async function createCompanionRuntime(opts = {}) {
11090
11112
  });
11091
11113
  let restartForUpdate = async () => {
11092
11114
  };
11093
- const supervisor = new CompanionSupervisor({
11094
- config: cfg,
11095
- log,
11096
- hub,
11097
- ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
11098
- ...embeddedRunner({
11099
- electron: Boolean(process.versions.electron),
11100
- execPath: process.execPath,
11101
- moduleUrl: import.meta.url,
11102
- env: process.env
11103
- }) ?? {},
11104
- restartForUpdate: () => restartForUpdate()
11105
- });
11106
- await supervisor.start();
11115
+ let supervisor;
11107
11116
  let control = null;
11108
11117
  let dashboard = null;
11118
+ let stopped = false;
11119
+ const stop = async () => {
11120
+ if (stopped) return;
11121
+ stopped = true;
11122
+ clearRuntimeStateIfOurs(instanceId);
11123
+ process.removeListener("exit", clearRuntimeOnExit);
11124
+ await Promise.allSettled([
11125
+ ...supervisor ? [supervisor.shutdown()] : [],
11126
+ ...control ? [control.close()] : [],
11127
+ ...dashboard ? [dashboard.close()] : []
11128
+ ]);
11129
+ };
11109
11130
  try {
11131
+ supervisor = new CompanionSupervisor({
11132
+ config: cfg,
11133
+ log,
11134
+ hub,
11135
+ ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
11136
+ ...embeddedRunner({
11137
+ electron: Boolean(process.versions.electron),
11138
+ execPath: process.execPath,
11139
+ moduleUrl: import.meta.url,
11140
+ env: process.env
11141
+ }) ?? {},
11142
+ restartForUpdate: () => restartForUpdate()
11143
+ });
11144
+ const active2 = supervisor;
11110
11145
  control = await startControlServer({
11111
11146
  status: () => ({
11112
11147
  ...hub.statusJson(),
11113
- ...supervisor.lastHeartbeat(),
11114
- detection: supervisor.currentDetection()
11148
+ ...active2.lastHeartbeat(),
11149
+ detection: active2.currentDetection()
11115
11150
  }),
11116
- detect: () => supervisor.redetect(),
11117
- allow: (runtime) => supervisor.allowHarness(runtime),
11118
- update: (auto) => supervisor.update(auto),
11119
- stop: () => void supervisor.requestStop()
11151
+ detect: () => active2.redetect(),
11152
+ allow: (runtime) => active2.allowHarness(runtime),
11153
+ update: (auto) => active2.update(auto),
11154
+ stop: () => void stop().then(() => active2.requestStop())
11120
11155
  });
11121
11156
  if (opts.dashboard) {
11122
11157
  const preferredPort = opts.port ?? cfg.dashboardPort;
@@ -11127,6 +11162,7 @@ async function createCompanionRuntime(opts = {}) {
11127
11162
  });
11128
11163
  hub.setDashboardUrl(dashboard.url);
11129
11164
  }
11165
+ if (stopped) throw new Error("The companion was stopped during startup.");
11130
11166
  writeRuntimeState({
11131
11167
  pid: process.pid,
11132
11168
  socket: control.path,
@@ -11144,7 +11180,7 @@ async function createCompanionRuntime(opts = {}) {
11144
11180
  clearRuntimeStateIfOurs(instanceId);
11145
11181
  process.removeListener("exit", clearRuntimeOnExit);
11146
11182
  await Promise.allSettled([
11147
- supervisor.shutdown(),
11183
+ ...supervisor ? [supervisor.shutdown()] : [],
11148
11184
  ...control ? [control.close()] : [],
11149
11185
  ...dashboard ? [dashboard.close()] : []
11150
11186
  ]);
@@ -11156,22 +11192,17 @@ async function createCompanionRuntime(opts = {}) {
11156
11192
  });
11157
11193
  throw err;
11158
11194
  }
11159
- let stopped = false;
11160
- const stop = async () => {
11161
- if (stopped) return;
11162
- stopped = true;
11163
- clearRuntimeStateIfOurs(instanceId);
11164
- process.removeListener("exit", clearRuntimeOnExit);
11165
- try {
11166
- await supervisor.shutdown();
11167
- await closeSurfaces(control, dashboard);
11168
- } catch {
11169
- }
11170
- };
11195
+ const active = supervisor;
11171
11196
  restartForUpdate = async () => {
11172
11197
  await stop();
11173
11198
  process.exit(0);
11174
11199
  };
11200
+ void active.start().catch(async (err) => {
11201
+ log.error({ err }, "Couldn't initialize the companion.");
11202
+ recordCrash({ reason: String(err), origin: "startup", at: (/* @__PURE__ */ new Date()).toISOString() });
11203
+ await stop();
11204
+ process.exitCode = 1;
11205
+ });
11175
11206
  return {
11176
11207
  ok: true,
11177
11208
  runtime: {
@@ -11179,16 +11210,12 @@ async function createCompanionRuntime(opts = {}) {
11179
11210
  socketPath: control.path,
11180
11211
  config: cfg,
11181
11212
  stop,
11182
- heartbeatNow: () => supervisor.heartbeatNow(),
11213
+ heartbeatNow: () => active.heartbeatNow(),
11183
11214
  harnesses: () => hub.statusJson().harnesses ?? [],
11184
- detect: () => supervisor.redetect()
11215
+ detect: () => active.redetect()
11185
11216
  }
11186
11217
  };
11187
11218
  }
11188
- async function closeSurfaces(control, dashboard) {
11189
- await control.close();
11190
- if (dashboard) await dashboard.close();
11191
- }
11192
11219
  export {
11193
11220
  createCompanionRuntime,
11194
11221
  embeddedRunner,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.187",
3
+ "version": "0.6.188",
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",