@cabane/companion 0.6.28 → 0.6.30

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
@@ -7200,6 +7200,9 @@ var TurnCommitter = class {
7200
7200
 
7201
7201
  // src/workspace-readiness.ts
7202
7202
  var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
7203
+ var INITIALIZE_RETRY_DELAYS_MS = [250, 750, 1500];
7204
+ var INITIALIZE_ATTEMPT_TIMEOUT_MS = 4e3;
7205
+ var INITIALIZE_RETRY_BUDGET_MS = 1e4;
7203
7206
  async function proveWorkspaceTools(req, runtime, opts = {}) {
7204
7207
  const base = {
7205
7208
  ok: false,
@@ -7225,7 +7228,7 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
7225
7228
  "x-cabane-active-conversation": req.cabane.activeConversationId
7226
7229
  };
7227
7230
  try {
7228
- const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
7231
+ const initialized = await initializeWithRetry(fetchImpl, req.cabane.mcpUrl, headers, {
7229
7232
  jsonrpc: "2.0",
7230
7233
  id: 1,
7231
7234
  method: "initialize",
@@ -7237,6 +7240,8 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
7237
7240
  });
7238
7241
  if (initialized.status === 401 || initialized.status === 403)
7239
7242
  return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
7243
+ if (initialized.transient)
7244
+ return fail(base, "workspace_endpoint_unreachable", initialized.detail);
7240
7245
  if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
7241
7246
  base.initialized = true;
7242
7247
  base.authenticated = true;
@@ -7271,11 +7276,47 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
7271
7276
  } catch (error) {
7272
7277
  return fail(
7273
7278
  base,
7274
- "initialization_failed",
7279
+ "workspace_endpoint_unreachable",
7275
7280
  error instanceof Error ? error.message : String(error)
7276
7281
  );
7277
7282
  }
7278
7283
  }
7284
+ async function initializeWithRetry(fetchImpl, url, headers, body) {
7285
+ let lastFailure = null;
7286
+ const deadline = Date.now() + INITIALIZE_RETRY_BUDGET_MS;
7287
+ for (let attempt = 0; attempt <= INITIALIZE_RETRY_DELAYS_MS.length; attempt += 1) {
7288
+ try {
7289
+ const remainingMs = deadline - Date.now();
7290
+ if (remainingMs <= 0) break;
7291
+ const result = await rpc(
7292
+ fetchImpl,
7293
+ url,
7294
+ headers,
7295
+ body,
7296
+ Math.min(INITIALIZE_ATTEMPT_TIMEOUT_MS, remainingMs)
7297
+ );
7298
+ if (result.status === 401 || result.status === 403 || result.ok) return result;
7299
+ if (result.status < 500) return result;
7300
+ lastFailure = { ...result, transient: true };
7301
+ } catch (error) {
7302
+ lastFailure = {
7303
+ ok: false,
7304
+ status: 0,
7305
+ sessionId: null,
7306
+ value: null,
7307
+ detail: error instanceof Error ? error.message : String(error),
7308
+ transient: true
7309
+ };
7310
+ }
7311
+ const retryDelayMs = INITIALIZE_RETRY_DELAYS_MS[attempt];
7312
+ if (retryDelayMs === void 0 || Date.now() + retryDelayMs >= deadline) break;
7313
+ await delay(retryDelayMs);
7314
+ }
7315
+ return lastFailure;
7316
+ }
7317
+ function delay(ms) {
7318
+ return new Promise((resolve) => setTimeout(resolve, ms));
7319
+ }
7279
7320
  function fail(proof, capability, detail) {
7280
7321
  proof.failedCapability = capability;
7281
7322
  proof.detail = detail.slice(0, 300);
@@ -7289,8 +7330,13 @@ function safeEndpoint(value) {
7289
7330
  return null;
7290
7331
  }
7291
7332
  }
7292
- async function rpc(fetchImpl, url, headers, body) {
7293
- const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
7333
+ async function rpc(fetchImpl, url, headers, body, timeoutMs) {
7334
+ const response = await fetchImpl(url, {
7335
+ method: "POST",
7336
+ headers,
7337
+ body: JSON.stringify(body),
7338
+ ...timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}
7339
+ });
7294
7340
  const text = await response.text();
7295
7341
  const value = parseRpcBody(text);
7296
7342
  return {
@@ -7927,7 +7973,8 @@ ${reason}`,
7927
7973
  );
7928
7974
  }
7929
7975
  if (!proof.ok) {
7930
- const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
7976
+ const recovery = proof.failedCapability === "workspace_endpoint_unreachable" ? "the Cabane workspace endpoint was unreachable; retry this dispatch" : "restart the connector after restoring the Cabane workspace tool mount";
7977
+ const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd}; runtime=${adapter.name}; recovery=${recovery}`;
7931
7978
  closeTurnReceipt(false, reason);
7932
7979
  try {
7933
7980
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
@@ -8760,6 +8807,7 @@ var SseSubscriber = class {
8760
8807
  try {
8761
8808
  await this.connect();
8762
8809
  backoff = 500;
8810
+ if (!this.aborted) await sleep3(backoff);
8763
8811
  } catch (err) {
8764
8812
  if (this.aborted) return;
8765
8813
  if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
@@ -8785,8 +8833,8 @@ var SseSubscriber = class {
8785
8833
  }
8786
8834
  async connect() {
8787
8835
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
8788
- const query = this.lastEventId ? "" : "?replayPending=1";
8789
- const url = `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
8836
+ const query = this.opts.path ? "" : this.lastEventId ? "" : "?replayPending=1";
8837
+ const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
8790
8838
  this.controller = new AbortController();
8791
8839
  const headers = {
8792
8840
  Authorization: `Bearer ${this.opts.token}`,
@@ -8878,6 +8926,7 @@ var CompanionSupervisor = class {
8878
8926
  reexecFn;
8879
8927
  dispatcherFactory;
8880
8928
  deviceApi = null;
8929
+ deviceSub = null;
8881
8930
  heartbeatTimer = null;
8882
8931
  inFlightHeartbeat = null;
8883
8932
  pollTimer = null;
@@ -8931,6 +8980,24 @@ var CompanionSupervisor = class {
8931
8980
  baseUrl: this.config.baseUrl,
8932
8981
  deviceToken: this.config.deviceToken
8933
8982
  });
8983
+ this.deviceSub = new SseSubscriber({
8984
+ baseUrl: this.config.baseUrl,
8985
+ workspaceId: "device-control",
8986
+ path: "/api/companion/events",
8987
+ token: this.config.deviceToken,
8988
+ log: this.log,
8989
+ lastEventId: null,
8990
+ onMessage: async (ev) => {
8991
+ if (ev.event === "assignments_changed") {
8992
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
8993
+ await this.refreshAssignments();
8994
+ }
8995
+ },
8996
+ onAuthFailure: () => {
8997
+ this.log.error("companion: device control stream authentication failed");
8998
+ }
8999
+ });
9000
+ this.deviceSub.start();
8934
9001
  await this.refreshAssignments();
8935
9002
  this.kickHeartbeat();
8936
9003
  this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
@@ -8943,7 +9010,10 @@ var CompanionSupervisor = class {
8943
9010
  // at least the loops keeping the event loop alive; the SSE promises let a
8944
9011
  // clean teardown resolve.
8945
9012
  finishedPromises() {
8946
- return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
9013
+ return [
9014
+ ...this.deviceSub ? [this.deviceSub.finished] : [],
9015
+ ...[...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : [])
9016
+ ];
8947
9017
  }
8948
9018
  // ---- device-level loops ----
8949
9019
  kickHeartbeat() {
@@ -9627,13 +9697,15 @@ var CompanionSupervisor = class {
9627
9697
  this.stopped = true;
9628
9698
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
9629
9699
  if (this.pollTimer) clearInterval(this.pollTimer);
9700
+ this.deviceSub?.stop();
9630
9701
  for (const wr of this.workspaces.values()) {
9631
9702
  for (const a of wr.agents.values()) a.cancelDrain();
9632
9703
  if (wr.sub) wr.sub.stop();
9633
9704
  }
9634
- await Promise.all(
9635
- [...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
9636
- );
9705
+ await Promise.all([
9706
+ ...this.deviceSub ? [this.deviceSub.finished] : [],
9707
+ ...[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
9708
+ ]);
9637
9709
  }
9638
9710
  async drainForRestart(graceMs) {
9639
9711
  this.restartPending = true;
package/dist/runtime.js CHANGED
@@ -6312,6 +6312,9 @@ var TurnCommitter = class {
6312
6312
 
6313
6313
  // src/workspace-readiness.ts
6314
6314
  var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
6315
+ var INITIALIZE_RETRY_DELAYS_MS = [250, 750, 1500];
6316
+ var INITIALIZE_ATTEMPT_TIMEOUT_MS = 4e3;
6317
+ var INITIALIZE_RETRY_BUDGET_MS = 1e4;
6315
6318
  async function proveWorkspaceTools(req, runtime, opts = {}) {
6316
6319
  const base = {
6317
6320
  ok: false,
@@ -6337,7 +6340,7 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
6337
6340
  "x-cabane-active-conversation": req.cabane.activeConversationId
6338
6341
  };
6339
6342
  try {
6340
- const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
6343
+ const initialized = await initializeWithRetry(fetchImpl, req.cabane.mcpUrl, headers, {
6341
6344
  jsonrpc: "2.0",
6342
6345
  id: 1,
6343
6346
  method: "initialize",
@@ -6349,6 +6352,8 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
6349
6352
  });
6350
6353
  if (initialized.status === 401 || initialized.status === 403)
6351
6354
  return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
6355
+ if (initialized.transient)
6356
+ return fail(base, "workspace_endpoint_unreachable", initialized.detail);
6352
6357
  if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
6353
6358
  base.initialized = true;
6354
6359
  base.authenticated = true;
@@ -6383,11 +6388,47 @@ async function proveWorkspaceTools(req, runtime, opts = {}) {
6383
6388
  } catch (error) {
6384
6389
  return fail(
6385
6390
  base,
6386
- "initialization_failed",
6391
+ "workspace_endpoint_unreachable",
6387
6392
  error instanceof Error ? error.message : String(error)
6388
6393
  );
6389
6394
  }
6390
6395
  }
6396
+ async function initializeWithRetry(fetchImpl, url, headers, body) {
6397
+ let lastFailure = null;
6398
+ const deadline = Date.now() + INITIALIZE_RETRY_BUDGET_MS;
6399
+ for (let attempt = 0; attempt <= INITIALIZE_RETRY_DELAYS_MS.length; attempt += 1) {
6400
+ try {
6401
+ const remainingMs = deadline - Date.now();
6402
+ if (remainingMs <= 0) break;
6403
+ const result = await rpc(
6404
+ fetchImpl,
6405
+ url,
6406
+ headers,
6407
+ body,
6408
+ Math.min(INITIALIZE_ATTEMPT_TIMEOUT_MS, remainingMs)
6409
+ );
6410
+ if (result.status === 401 || result.status === 403 || result.ok) return result;
6411
+ if (result.status < 500) return result;
6412
+ lastFailure = { ...result, transient: true };
6413
+ } catch (error) {
6414
+ lastFailure = {
6415
+ ok: false,
6416
+ status: 0,
6417
+ sessionId: null,
6418
+ value: null,
6419
+ detail: error instanceof Error ? error.message : String(error),
6420
+ transient: true
6421
+ };
6422
+ }
6423
+ const retryDelayMs = INITIALIZE_RETRY_DELAYS_MS[attempt];
6424
+ if (retryDelayMs === void 0 || Date.now() + retryDelayMs >= deadline) break;
6425
+ await delay(retryDelayMs);
6426
+ }
6427
+ return lastFailure;
6428
+ }
6429
+ function delay(ms) {
6430
+ return new Promise((resolve) => setTimeout(resolve, ms));
6431
+ }
6391
6432
  function fail(proof, capability, detail) {
6392
6433
  proof.failedCapability = capability;
6393
6434
  proof.detail = detail.slice(0, 300);
@@ -6401,8 +6442,13 @@ function safeEndpoint(value) {
6401
6442
  return null;
6402
6443
  }
6403
6444
  }
6404
- async function rpc(fetchImpl, url, headers, body) {
6405
- const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
6445
+ async function rpc(fetchImpl, url, headers, body, timeoutMs) {
6446
+ const response = await fetchImpl(url, {
6447
+ method: "POST",
6448
+ headers,
6449
+ body: JSON.stringify(body),
6450
+ ...timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}
6451
+ });
6406
6452
  const text = await response.text();
6407
6453
  const value = parseRpcBody(text);
6408
6454
  return {
@@ -7039,7 +7085,8 @@ ${reason}`,
7039
7085
  );
7040
7086
  }
7041
7087
  if (!proof.ok) {
7042
- const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
7088
+ const recovery = proof.failedCapability === "workspace_endpoint_unreachable" ? "the Cabane workspace endpoint was unreachable; retry this dispatch" : "restart the connector after restoring the Cabane workspace tool mount";
7089
+ const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd}; runtime=${adapter.name}; recovery=${recovery}`;
7043
7090
  closeTurnReceipt(false, reason);
7044
7091
  try {
7045
7092
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
@@ -7872,6 +7919,7 @@ var SseSubscriber = class {
7872
7919
  try {
7873
7920
  await this.connect();
7874
7921
  backoff = 500;
7922
+ if (!this.aborted) await sleep2(backoff);
7875
7923
  } catch (err) {
7876
7924
  if (this.aborted) return;
7877
7925
  if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
@@ -7897,8 +7945,8 @@ var SseSubscriber = class {
7897
7945
  }
7898
7946
  async connect() {
7899
7947
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
7900
- const query = this.lastEventId ? "" : "?replayPending=1";
7901
- const url = `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
7948
+ const query = this.opts.path ? "" : this.lastEventId ? "" : "?replayPending=1";
7949
+ const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
7902
7950
  this.controller = new AbortController();
7903
7951
  const headers = {
7904
7952
  Authorization: `Bearer ${this.opts.token}`,
@@ -7990,6 +8038,7 @@ var CompanionSupervisor = class {
7990
8038
  reexecFn;
7991
8039
  dispatcherFactory;
7992
8040
  deviceApi = null;
8041
+ deviceSub = null;
7993
8042
  heartbeatTimer = null;
7994
8043
  inFlightHeartbeat = null;
7995
8044
  pollTimer = null;
@@ -8043,6 +8092,24 @@ var CompanionSupervisor = class {
8043
8092
  baseUrl: this.config.baseUrl,
8044
8093
  deviceToken: this.config.deviceToken
8045
8094
  });
8095
+ this.deviceSub = new SseSubscriber({
8096
+ baseUrl: this.config.baseUrl,
8097
+ workspaceId: "device-control",
8098
+ path: "/api/companion/events",
8099
+ token: this.config.deviceToken,
8100
+ log: this.log,
8101
+ lastEventId: null,
8102
+ onMessage: async (ev) => {
8103
+ if (ev.event === "assignments_changed") {
8104
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
8105
+ await this.refreshAssignments();
8106
+ }
8107
+ },
8108
+ onAuthFailure: () => {
8109
+ this.log.error("companion: device control stream authentication failed");
8110
+ }
8111
+ });
8112
+ this.deviceSub.start();
8046
8113
  await this.refreshAssignments();
8047
8114
  this.kickHeartbeat();
8048
8115
  this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
@@ -8055,7 +8122,10 @@ var CompanionSupervisor = class {
8055
8122
  // at least the loops keeping the event loop alive; the SSE promises let a
8056
8123
  // clean teardown resolve.
8057
8124
  finishedPromises() {
8058
- return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
8125
+ return [
8126
+ ...this.deviceSub ? [this.deviceSub.finished] : [],
8127
+ ...[...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : [])
8128
+ ];
8059
8129
  }
8060
8130
  // ---- device-level loops ----
8061
8131
  kickHeartbeat() {
@@ -8739,13 +8809,15 @@ var CompanionSupervisor = class {
8739
8809
  this.stopped = true;
8740
8810
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
8741
8811
  if (this.pollTimer) clearInterval(this.pollTimer);
8812
+ this.deviceSub?.stop();
8742
8813
  for (const wr of this.workspaces.values()) {
8743
8814
  for (const a of wr.agents.values()) a.cancelDrain();
8744
8815
  if (wr.sub) wr.sub.stop();
8745
8816
  }
8746
- await Promise.all(
8747
- [...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
8748
- );
8817
+ await Promise.all([
8818
+ ...this.deviceSub ? [this.deviceSub.finished] : [],
8819
+ ...[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
8820
+ ]);
8749
8821
  }
8750
8822
  async drainForRestart(graceMs) {
8751
8823
  this.restartPending = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding 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",