@cabane/companion 0.6.38 → 0.6.39

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
@@ -2679,6 +2679,19 @@ var DeviceApi = class {
2679
2679
  beginDrain() {
2680
2680
  return this.request("POST", "/api/companion/drain", {});
2681
2681
  }
2682
+ // CT1146: report that a dispatch addressed to THIS device can't be run, because
2683
+ // the agent isn't one this device runs (and still wasn't after a forced
2684
+ // assignments refresh). The server clears the Working flag, retires the dispatch
2685
+ // and posts an explanatory system message.
2686
+ //
2687
+ // This rides the DEVICE token deliberately: the agent's own credential is
2688
+ // delivered through the device's assignments, so an agent this device doesn't
2689
+ // roster is exactly an agent it holds no credential for. The device token is the
2690
+ // only thing it can present, which is why this lives on `DeviceApi` rather than
2691
+ // the per-agent `CabaneApi`.
2692
+ reportDispatchDrop(body) {
2693
+ return this.request("POST", "/api/companion/dispatch-drop", body);
2694
+ }
2682
2695
  // Per-device liveness ping. Reports the companion build version and the env-var
2683
2696
  // names the operator's secret store exposes (never values), so CT30's UI can
2684
2697
  // warn pre-emptively about an agent that needs a secret this device lacks.
@@ -3458,18 +3471,24 @@ function summarizeToolResult(content) {
3458
3471
  }
3459
3472
  if (Array.isArray(content)) {
3460
3473
  const parts = [];
3474
+ let sawContentEntry = false;
3461
3475
  for (const raw of content) {
3462
3476
  if (typeof raw === "string") {
3477
+ sawContentEntry = true;
3463
3478
  parts.push(raw);
3464
3479
  } else if (raw && typeof raw === "object") {
3465
3480
  const block = raw;
3481
+ if (typeof block.type === "string") sawContentEntry = true;
3466
3482
  if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
3467
3483
  }
3468
3484
  }
3469
3485
  const joined = parts.join("\n").trim();
3470
- return joined.length > 0 ? truncate(joined, TOOL_IO_MAX_CHARS) : "";
3486
+ if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
3487
+ if (sawContentEntry || content.length === 0) return "";
3471
3488
  }
3472
- return "";
3489
+ if (typeof content !== "object") return "";
3490
+ if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
3491
+ return summarizeToolIo(content);
3473
3492
  }
3474
3493
  function truncate(s, max) {
3475
3494
  if (s.length <= max) return s;
@@ -9498,6 +9517,10 @@ var CompanionSupervisor = class {
9498
9517
  if (!agent) {
9499
9518
  agent = await this.recoverRacedAgent(wr, payload);
9500
9519
  if (!agent) {
9520
+ if (this.deviceId && payload.deviceId === this.deviceId) {
9521
+ const reported = await this.reportUnrunnableDispatch(wr, payload);
9522
+ if (!reported) return;
9523
+ }
9501
9524
  if (ev.id) wr.cursor.settle(ev.id);
9502
9525
  return;
9503
9526
  }
@@ -9519,6 +9542,59 @@ var CompanionSupervisor = class {
9519
9542
  await tail;
9520
9543
  if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
9521
9544
  }
9545
+ // CT1146: tell the server this device cannot run a dispatch it was addressed to.
9546
+ //
9547
+ // Returns whether the report LANDED, because that answer decides whether the
9548
+ // caller may settle the cursor. Nothing else on either side ends this turn: the
9549
+ // server's sweep resolves the agent's effective connector, which in a roster
9550
+ // desync still points at this device, so it is never classified `no_device` and
9551
+ // only the silent 12h age backstop touches it. An unreported drop is therefore
9552
+ // indistinguishable from the original defect.
9553
+ //
9554
+ // Bounded retry, because the common failure is transient (a 503 mid-deploy, a
9555
+ // dropped socket) and one more attempt a second later usually lands. Bounded
9556
+ // hard — this runs inline on the event loop, and an unbounded wait here would
9557
+ // wedge the workspace's whole stream.
9558
+ async reportUnrunnableDispatch(wr, payload) {
9559
+ if (!this.deviceApi) return false;
9560
+ const backoffMs = [0, 1e3, 3e3];
9561
+ let lastErr;
9562
+ for (const wait of backoffMs) {
9563
+ if (this.stopped) return false;
9564
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
9565
+ try {
9566
+ const { cleared } = await this.deviceApi.reportDispatchDrop({
9567
+ workspaceId: wr.workspaceId,
9568
+ conversationId: payload.conversationId,
9569
+ agentId: payload.agentId,
9570
+ messageId: payload.messageId,
9571
+ reason: "agent_not_on_device"
9572
+ });
9573
+ this.log.warn(
9574
+ {
9575
+ workspaceId: wr.workspaceId,
9576
+ conversationId: payload.conversationId,
9577
+ agentId: payload.agentId,
9578
+ cleared
9579
+ },
9580
+ "companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
9581
+ );
9582
+ return true;
9583
+ } catch (err) {
9584
+ lastErr = err;
9585
+ }
9586
+ }
9587
+ this.log.error(
9588
+ {
9589
+ workspaceId: wr.workspaceId,
9590
+ conversationId: payload.conversationId,
9591
+ agentId: payload.agentId,
9592
+ err: lastErr instanceof Error ? lastErr.message : String(lastErr)
9593
+ },
9594
+ "companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
9595
+ );
9596
+ return false;
9597
+ }
9522
9598
  // Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
9523
9599
  // we don't yet run. If it's addressed to THIS device, the assignment just
9524
9600
  // changed under us (the poll hasn't caught it) — force a fresh assignments pull
package/dist/runtime.js CHANGED
@@ -2107,6 +2107,19 @@ var DeviceApi = class {
2107
2107
  beginDrain() {
2108
2108
  return this.request("POST", "/api/companion/drain", {});
2109
2109
  }
2110
+ // CT1146: report that a dispatch addressed to THIS device can't be run, because
2111
+ // the agent isn't one this device runs (and still wasn't after a forced
2112
+ // assignments refresh). The server clears the Working flag, retires the dispatch
2113
+ // and posts an explanatory system message.
2114
+ //
2115
+ // This rides the DEVICE token deliberately: the agent's own credential is
2116
+ // delivered through the device's assignments, so an agent this device doesn't
2117
+ // roster is exactly an agent it holds no credential for. The device token is the
2118
+ // only thing it can present, which is why this lives on `DeviceApi` rather than
2119
+ // the per-agent `CabaneApi`.
2120
+ reportDispatchDrop(body) {
2121
+ return this.request("POST", "/api/companion/dispatch-drop", body);
2122
+ }
2110
2123
  // Per-device liveness ping. Reports the companion build version and the env-var
2111
2124
  // names the operator's secret store exposes (never values), so CT30's UI can
2112
2125
  // warn pre-emptively about an agent that needs a secret this device lacks.
@@ -2965,18 +2978,24 @@ function summarizeToolResult(content) {
2965
2978
  }
2966
2979
  if (Array.isArray(content)) {
2967
2980
  const parts = [];
2981
+ let sawContentEntry = false;
2968
2982
  for (const raw of content) {
2969
2983
  if (typeof raw === "string") {
2984
+ sawContentEntry = true;
2970
2985
  parts.push(raw);
2971
2986
  } else if (raw && typeof raw === "object") {
2972
2987
  const block = raw;
2988
+ if (typeof block.type === "string") sawContentEntry = true;
2973
2989
  if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
2974
2990
  }
2975
2991
  }
2976
2992
  const joined = parts.join("\n").trim();
2977
- return joined.length > 0 ? truncate(joined, TOOL_IO_MAX_CHARS) : "";
2993
+ if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
2994
+ if (sawContentEntry || content.length === 0) return "";
2978
2995
  }
2979
- return "";
2996
+ if (typeof content !== "object") return "";
2997
+ if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
2998
+ return summarizeToolIo(content);
2980
2999
  }
2981
3000
  function truncate(s, max) {
2982
3001
  if (s.length <= max) return s;
@@ -9005,6 +9024,10 @@ var CompanionSupervisor = class {
9005
9024
  if (!agent) {
9006
9025
  agent = await this.recoverRacedAgent(wr, payload);
9007
9026
  if (!agent) {
9027
+ if (this.deviceId && payload.deviceId === this.deviceId) {
9028
+ const reported = await this.reportUnrunnableDispatch(wr, payload);
9029
+ if (!reported) return;
9030
+ }
9008
9031
  if (ev.id) wr.cursor.settle(ev.id);
9009
9032
  return;
9010
9033
  }
@@ -9026,6 +9049,59 @@ var CompanionSupervisor = class {
9026
9049
  await tail;
9027
9050
  if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
9028
9051
  }
9052
+ // CT1146: tell the server this device cannot run a dispatch it was addressed to.
9053
+ //
9054
+ // Returns whether the report LANDED, because that answer decides whether the
9055
+ // caller may settle the cursor. Nothing else on either side ends this turn: the
9056
+ // server's sweep resolves the agent's effective connector, which in a roster
9057
+ // desync still points at this device, so it is never classified `no_device` and
9058
+ // only the silent 12h age backstop touches it. An unreported drop is therefore
9059
+ // indistinguishable from the original defect.
9060
+ //
9061
+ // Bounded retry, because the common failure is transient (a 503 mid-deploy, a
9062
+ // dropped socket) and one more attempt a second later usually lands. Bounded
9063
+ // hard — this runs inline on the event loop, and an unbounded wait here would
9064
+ // wedge the workspace's whole stream.
9065
+ async reportUnrunnableDispatch(wr, payload) {
9066
+ if (!this.deviceApi) return false;
9067
+ const backoffMs = [0, 1e3, 3e3];
9068
+ let lastErr;
9069
+ for (const wait of backoffMs) {
9070
+ if (this.stopped) return false;
9071
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
9072
+ try {
9073
+ const { cleared } = await this.deviceApi.reportDispatchDrop({
9074
+ workspaceId: wr.workspaceId,
9075
+ conversationId: payload.conversationId,
9076
+ agentId: payload.agentId,
9077
+ messageId: payload.messageId,
9078
+ reason: "agent_not_on_device"
9079
+ });
9080
+ this.log.warn(
9081
+ {
9082
+ workspaceId: wr.workspaceId,
9083
+ conversationId: payload.conversationId,
9084
+ agentId: payload.agentId,
9085
+ cleared
9086
+ },
9087
+ "companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
9088
+ );
9089
+ return true;
9090
+ } catch (err) {
9091
+ lastErr = err;
9092
+ }
9093
+ }
9094
+ this.log.error(
9095
+ {
9096
+ workspaceId: wr.workspaceId,
9097
+ conversationId: payload.conversationId,
9098
+ agentId: payload.agentId,
9099
+ err: lastErr instanceof Error ? lastErr.message : String(lastErr)
9100
+ },
9101
+ "companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
9102
+ );
9103
+ return false;
9104
+ }
9029
9105
  // Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
9030
9106
  // we don't yet run. If it's addressed to THIS device, the assignment just
9031
9107
  // changed under us (the poll hasn't caught it) — force a fresh assignments pull
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.38",
3
+ "version": "0.6.39",
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",