@cabane/companion 0.6.63 → 0.6.65

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 +182 -55
  2. package/dist/runtime.js +182 -55
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1735,7 +1735,7 @@ function getLogger() {
1735
1735
  }
1736
1736
 
1737
1737
  // src/runtime.ts
1738
- import { randomUUID as randomUUID2 } from "crypto";
1738
+ import { randomUUID as randomUUID3 } from "crypto";
1739
1739
 
1740
1740
  // src/dashboard/server.ts
1741
1741
  import { dirname as dirname4, join as join7 } from "path";
@@ -2278,6 +2278,9 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2278
2278
  );
2279
2279
  }
2280
2280
 
2281
+ // src/supervisor.ts
2282
+ import { randomUUID as randomUUID2 } from "crypto";
2283
+
2281
2284
  // src/api.ts
2282
2285
  var RETRY_BACKOFF_MS = [250, 750];
2283
2286
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -2522,12 +2525,20 @@ var CabaneApi = class {
2522
2525
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
2523
2526
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
2524
2527
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
2525
- setActiveRun(workspaceId, conversationId, agentId, body) {
2528
+ async setActiveRun(workspaceId, conversationId, agentId, body) {
2526
2529
  const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
2527
2530
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
2528
- if (touchesFlag && this.opts.outbox) {
2531
+ const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
2532
+ if (touchesFlag && !startsRun && this.opts.outbox) {
2529
2533
  return this.durableActiveRunWrite(path, conversationId, agentId, body);
2530
2534
  }
2535
+ if (startsRun) {
2536
+ const res = await this.request("PATCH", path, body, {
2537
+ retry: true
2538
+ });
2539
+ this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
2540
+ return res;
2541
+ }
2531
2542
  return this.request("PATCH", path, body);
2532
2543
  }
2533
2544
  // CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
@@ -2586,15 +2597,16 @@ var CabaneApi = class {
2586
2597
  // (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
2587
2598
  // double-posted. The caller threads its turn abort signal so a cancel
2588
2599
  // mid-commit aborts the in-flight POST rather than letting it land.
2589
- // CT11: `kind` now includes `'stopped'` for the terminal marker the
2590
- // dispatcher writes when a turn is cancelled same wire shape as
2591
- // `progress`/`final`, distinguished only by `kind` so the chat drawer's
2592
- // turn-group renderer treats it as a closing row. `seq` is the companion's
2593
- // per-turn monotonic counter, stamped on the row so the merged timeline
2594
- // orders the commit deterministically against the persisted tool/thinking
2595
- // rows. Both fields are optional on the wire an older companion that didn't
2596
- // mint seq still validates (the server defaults to 0); `stopped` is only
2597
- // emitted by post-CT11 companions.
2600
+ // CT11: `kind` includes `'stopped'` for the terminal cancel marker — same
2601
+ // wire shape as `progress`/`final`, distinguished only by `kind` so the chat
2602
+ // drawer's turn-group renderer treats it as a closing row. CT1295: this
2603
+ // companion no longer WRITES that kind the server does, in the transaction
2604
+ // that terminalizes the stopped turn so the value survives here only as
2605
+ // wire vocabulary the server also speaks. `seq` is the companion's per-turn
2606
+ // monotonic counter, stamped on the row so the merged timeline orders the
2607
+ // commit deterministically against the persisted tool/thinking rows. Both
2608
+ // fields are optional on the wire — an older companion that didn't mint seq
2609
+ // still validates (the server defaults to 0).
2598
2610
  postTurnMessage(workspaceId, conversationId, body, signal) {
2599
2611
  return this.durableCommit(
2600
2612
  "message",
@@ -2819,7 +2831,7 @@ var CursorTracker = class {
2819
2831
  };
2820
2832
 
2821
2833
  // src/dispatch-dedupe.ts
2822
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2834
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2823
2835
  import { join as join9 } from "path";
2824
2836
  var MAX_IDS = 256;
2825
2837
  function dir(log) {
@@ -2859,6 +2871,7 @@ function hasCompleted(workspaceId, eventId) {
2859
2871
  }
2860
2872
  function markCompleted(workspaceId, eventId) {
2861
2873
  mark("completed", workspaceId, eventId);
2874
+ forgetTurnId(workspaceId, eventId);
2862
2875
  }
2863
2876
  var MAX_RESUME_ATTEMPTS = 3;
2864
2877
  function resumeDir() {
@@ -2900,6 +2913,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2900
2913
  );
2901
2914
  return next;
2902
2915
  }
2916
+ function turnDir() {
2917
+ return join9(cabaneDir(), "turns");
2918
+ }
2919
+ function turnPathFor(workspaceId) {
2920
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2921
+ }
2922
+ var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2923
+ function readTurnIds(workspaceId) {
2924
+ const out = /* @__PURE__ */ new Map();
2925
+ const path = turnPathFor(workspaceId);
2926
+ if (!existsSync7(path)) return out;
2927
+ try {
2928
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2929
+ const trimmed = line.trim();
2930
+ if (!trimmed) continue;
2931
+ const tab = trimmed.lastIndexOf(" ");
2932
+ if (tab <= 0) continue;
2933
+ const id = trimmed.slice(0, tab);
2934
+ const turnId = trimmed.slice(tab + 1);
2935
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2936
+ }
2937
+ } catch {
2938
+ return out;
2939
+ }
2940
+ return out;
2941
+ }
2942
+ function turnIdForEvent(workspaceId, eventId) {
2943
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2944
+ }
2945
+ var TURN_ID_OVERFLOW_WARN = 8192;
2946
+ function writeTurnIds(workspaceId, turns) {
2947
+ const entries = [...turns.entries()];
2948
+ mkdirSync7(turnDir(), { recursive: true });
2949
+ const path = turnPathFor(workspaceId);
2950
+ const tmp = `${path}.${process.pid}.tmp`;
2951
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2952
+ renameSync3(tmp, path);
2953
+ return entries.length;
2954
+ }
2955
+ function rememberTurnId(workspaceId, eventId, turnId) {
2956
+ const turns = readTurnIds(workspaceId);
2957
+ if (turns.get(eventId) === turnId) return turns.size;
2958
+ turns.set(eventId, turnId);
2959
+ return writeTurnIds(workspaceId, turns);
2960
+ }
2961
+ function forgetTurnId(workspaceId, eventId) {
2962
+ const turns = readTurnIds(workspaceId);
2963
+ if (!turns.delete(eventId)) return;
2964
+ writeTurnIds(workspaceId, turns);
2965
+ }
2903
2966
  function noResume() {
2904
2967
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2905
2968
  }
@@ -4317,7 +4380,7 @@ var BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES = [
4317
4380
  {
4318
4381
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result;
4319
4382
  // the adapter stops before the closing reply — no final text, no `result`
4320
- // event. The host writes the `kind:'stopped'` marker off the signal.
4383
+ // event. CT1295: the server writes the `kind:'stopped'` marker.
4321
4384
  name: "cancel mid-stream",
4322
4385
  request: makeRequest(),
4323
4386
  nativeStream: [
@@ -5438,7 +5501,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5438
5501
  {
5439
5502
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
5440
5503
  // adapter stops before the closing reply — no final text, no `result` event.
5441
- // The host writes the `kind:'stopped'` marker off the signal.
5504
+ // CT1295: the server writes the `kind:'stopped'` marker.
5442
5505
  name: "cancel mid-stream",
5443
5506
  request: makeRequest2(),
5444
5507
  nativeStream: [
@@ -6730,7 +6793,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
6730
6793
  {
6731
6794
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
6732
6795
  // adapter stops before the closing reply — no final text, no `result` event.
6733
- // The host writes the `kind:'stopped'` marker off the signal.
6796
+ // CT1295: the server writes the `kind:'stopped'` marker.
6734
6797
  name: "cancel mid-stream",
6735
6798
  request: makeRequest3(),
6736
6799
  nativeStream: [
@@ -7886,8 +7949,10 @@ var TurnCommitter = class {
7886
7949
  // End-of-turn progress promotion. The held-text flush is now the adapter's
7887
7950
  // job (it emits the closing reply as a `text` event before `result`), so this
7888
7951
  // only asks the pump to promote agent-authored interim text. A wordless turn
7889
- // stays wordless and the dispatcher closes it with a marker. Guards on the
7890
- // abort signal, so on cancel no `final` is forced.
7952
+ // stays wordless and the dispatcher closes it with a `silent`/`skipped`
7953
+ // marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
7954
+ // marker, not from this side). Guards on the abort signal, so on cancel no
7955
+ // `final` is forced.
7891
7956
  async finalize(okResult) {
7892
7957
  await this.pump.finalize(okResult);
7893
7958
  }
@@ -8005,7 +8070,6 @@ var TurnCommitter = class {
8005
8070
  var PREPARING_TOOL_NAME = "preparing";
8006
8071
  var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
8007
8072
  var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
8008
- var STOPPED_MARKER_BODY = "(stopped)";
8009
8073
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
8010
8074
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
8011
8075
  var SKIPPED_MARKER_BODY = "(skipped)";
@@ -8040,6 +8104,18 @@ function checkoutState(cwd) {
8040
8104
  function runKey(conversationId, agentId) {
8041
8105
  return `${conversationId}|${agentId}`;
8042
8106
  }
8107
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8108
+ "dispatch_not_admitted",
8109
+ "turn_already_ended",
8110
+ "turn_belongs_elsewhere"
8111
+ ]);
8112
+ function leaseRefusal(err) {
8113
+ if (!(err instanceof ApiError)) return null;
8114
+ const body = err.body;
8115
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8116
+ if (code && LEASE_REFUSALS.has(code)) return code;
8117
+ return null;
8118
+ }
8043
8119
  var ERROR_BODY_LOG_CAP = 2e3;
8044
8120
  function describeErrorBody(body) {
8045
8121
  if (body === void 0 || body === null) return void 0;
@@ -8074,6 +8150,16 @@ var Dispatcher = class {
8074
8150
  }
8075
8151
  opts;
8076
8152
  // SJ383: per-(conversation, agent) abort registry.
8153
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
8154
+ // per (conversation, agent), which quietly encoded the invariant the whole
8155
+ // task exists to enforce: that a pair can only ever have one live loop. When
8156
+ // that assumption broke, the second `set` EVICTED the first controller and
8157
+ // the first loop became permanently uncancellable — no other code path can
8158
+ // reach into a running turn. So the registry that Stop depends on failed
8159
+ // exactly when Stop was the thing you needed.
8160
+ //
8161
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
8162
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
8077
8163
  aborts;
8078
8164
  // CT1109: pairs already told, in the conversation, that their session state is
8079
8165
  // being refused. The failure repeats every single turn until someone fixes the
@@ -8136,7 +8222,12 @@ var Dispatcher = class {
8136
8222
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8137
8223
  return { ok: false, durationMs, reason };
8138
8224
  }
8139
- async handle(payload) {
8225
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
8226
+ // under before the companion died. Passing it makes the replay ask the
8227
+ // mailroom to re-admit the turn it already owns, which the server explicitly
8228
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
8229
+ // dispatch, where minting below is correct.
8230
+ async handle(payload, opts = {}) {
8140
8231
  const startedAt = Date.now();
8141
8232
  const dispatchId = payload.messageId;
8142
8233
  const workspaceId = this.opts.workspaceId;
@@ -8146,7 +8237,7 @@ var Dispatcher = class {
8146
8237
  agentId: payload.agentId,
8147
8238
  messageId: payload.messageId
8148
8239
  });
8149
- const turnId = randomUUID();
8240
+ const turnId = opts.turnId ?? randomUUID();
8150
8241
  let turnContext;
8151
8242
  try {
8152
8243
  turnContext = await this.opts.api.getTurnContext(
@@ -8368,7 +8459,18 @@ ${reason}`,
8368
8459
  };
8369
8460
  const key = runKey(payload.conversationId, payload.agentId);
8370
8461
  const abortController = new AbortController();
8371
- this.aborts.set(key, abortController);
8462
+ let pairAborts = this.aborts.get(key);
8463
+ if (!pairAborts) {
8464
+ pairAborts = /* @__PURE__ */ new Map();
8465
+ this.aborts.set(key, pairAborts);
8466
+ }
8467
+ pairAborts.set(turnId, abortController);
8468
+ const releaseAbort = () => {
8469
+ const pair2 = this.aborts.get(key);
8470
+ if (pair2?.get(turnId) !== abortController) return;
8471
+ pair2.delete(turnId);
8472
+ if (pair2.size === 0) this.aborts.delete(key);
8473
+ };
8372
8474
  let timeoutReason = null;
8373
8475
  try {
8374
8476
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -8381,10 +8483,21 @@ ${reason}`,
8381
8483
  turnId
8382
8484
  });
8383
8485
  } catch (err) {
8384
- turnLog.warn(
8385
- { err: err instanceof Error ? err.message : String(err) },
8386
- "dispatcher: active-run flag set failed terminally; proceeding"
8486
+ const refusal = leaseRefusal(err);
8487
+ releaseAbort();
8488
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8489
+ turnLog.error(
8490
+ { refusal, turnId },
8491
+ "dispatcher: refused a turn lease; not running the model"
8492
+ );
8493
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
8494
+ }
8495
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
8496
+ turnLog.error(
8497
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
8498
+ "dispatcher: turn lease not confirmed; not running the model"
8387
8499
  );
8500
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
8388
8501
  }
8389
8502
  const sendState = createSendState();
8390
8503
  const skipState = createSkipState();
@@ -8488,6 +8601,7 @@ ${reason}`,
8488
8601
  "dispatcher: runtime-unavailable notice post failed"
8489
8602
  );
8490
8603
  }
8604
+ releaseAbort();
8491
8605
  return this.concludeBeforeRun(
8492
8606
  payload,
8493
8607
  turnLog,
@@ -8788,23 +8902,6 @@ ${reason}`,
8788
8902
  }
8789
8903
  }
8790
8904
  }
8791
- if (userCancelled) {
8792
- try {
8793
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8794
- body: STOPPED_MARKER_BODY,
8795
- kind: "stopped",
8796
- turnId,
8797
- seq: nextSeq(),
8798
- // CT113: the stopped marker is still "about" the triggering message.
8799
- parentMessageId: payload.messageId
8800
- });
8801
- } catch (err) {
8802
- turnLog.warn(
8803
- { err: err instanceof Error ? err.message : String(err) },
8804
- "dispatcher: stopped-marker commit failed"
8805
- );
8806
- }
8807
- }
8808
8905
  const body = {
8809
8906
  activeRunStartedAt: null,
8810
8907
  // CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
@@ -8887,9 +8984,7 @@ ${reason}`,
8887
8984
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8888
8985
  );
8889
8986
  }
8890
- if (this.aborts.get(key) === abortController) {
8891
- this.aborts.delete(key);
8892
- }
8987
+ releaseAbort();
8893
8988
  }
8894
8989
  const durationMs = Date.now() - startedAt;
8895
8990
  const finalReplyBody = committer.replyBody;
@@ -8933,11 +9028,13 @@ ${reason}`,
8933
9028
  // THIS companion process. Returns true if an in-flight run was aborted.
8934
9029
  cancel(conversationId, agentId) {
8935
9030
  const key = runKey(conversationId, agentId);
8936
- const ac = this.aborts.get(key);
8937
- if (!ac) return false;
8938
- try {
8939
- ac.abort();
8940
- } catch {
9031
+ const pair2 = this.aborts.get(key);
9032
+ if (!pair2 || pair2.size === 0) return false;
9033
+ for (const ac of [...pair2.values()]) {
9034
+ try {
9035
+ ac.abort();
9036
+ } catch {
9037
+ }
8941
9038
  }
8942
9039
  return true;
8943
9040
  }
@@ -9001,7 +9098,7 @@ import {
9001
9098
  mkdirSync as mkdirSync10,
9002
9099
  readdirSync as readdirSync3,
9003
9100
  readFileSync as readFileSync8,
9004
- renameSync as renameSync3,
9101
+ renameSync as renameSync4,
9005
9102
  rmSync as rmSync7,
9006
9103
  writeFileSync as writeFileSync7
9007
9104
  } from "fs";
@@ -9033,7 +9130,7 @@ var Outbox = class {
9033
9130
  const tmp = `${target}.${process.pid}.tmp`;
9034
9131
  try {
9035
9132
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
9036
- renameSync3(tmp, target);
9133
+ renameSync4(tmp, target);
9037
9134
  } catch (err) {
9038
9135
  try {
9039
9136
  rmSync7(tmp, { force: true });
@@ -9834,6 +9931,19 @@ var CompanionSupervisor = class {
9834
9931
  type: "device:dispatch_requested",
9835
9932
  ...wire.payload
9836
9933
  };
9934
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9935
+ this.log.debug(
9936
+ {
9937
+ workspaceId: wr.workspaceId,
9938
+ conversationId: payload.conversationId,
9939
+ agentId: payload.agentId,
9940
+ addressedTo: payload.deviceId
9941
+ },
9942
+ "companion: dispatch addressed to another device; ignoring"
9943
+ );
9944
+ if (ev.id) wr.cursor.settle(ev.id);
9945
+ return;
9946
+ }
9837
9947
  let agent = wr.agents.get(payload.agentId);
9838
9948
  if (!agent) {
9839
9949
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9965,8 +10075,25 @@ var CompanionSupervisor = class {
9965
10075
  "companion: re-dispatching interrupted turn (resume after restart)"
9966
10076
  );
9967
10077
  }
9968
- if (ev.id) markDispatched(workspaceId, ev.id);
9969
- const result = await agent.dispatcher.handle(payload);
10078
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
10079
+ const turnId = resumedTurnId ?? randomUUID2();
10080
+ if (ev.id) {
10081
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
10082
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
10083
+ this.log.error(
10084
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
10085
+ "companion: in-flight turn-id map is implausibly large \u2014 completion pruning is likely broken. Keeping every mapping; dropping one would silently lose a resumable turn."
10086
+ );
10087
+ }
10088
+ markDispatched(workspaceId, ev.id);
10089
+ }
10090
+ if (resumedTurnId) {
10091
+ this.log.info(
10092
+ { workspaceId, eventId: ev.id, turnId },
10093
+ "companion: resuming an interrupted turn under its original id"
10094
+ );
10095
+ }
10096
+ const result = await agent.dispatcher.handle(payload, { turnId });
9970
10097
  if (ev.id) markCompleted(workspaceId, ev.id);
9971
10098
  if (ev.id) wr.cursor.settle(ev.id);
9972
10099
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -10357,7 +10484,7 @@ async function createCompanionRuntime(opts = {}) {
10357
10484
  opencodeServerUrl: cfg.opencode?.serverUrl,
10358
10485
  codex: isCodexEnabled(cfg)
10359
10486
  });
10360
- const instanceId = randomUUID2();
10487
+ const instanceId = randomUUID3();
10361
10488
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10362
10489
  const pre = readLiveRuntimeState();
10363
10490
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/runtime.ts
2
- import { randomUUID as randomUUID2 } from "crypto";
2
+ import { randomUUID as randomUUID3 } from "crypto";
3
3
 
4
4
  // src/config.ts
5
5
  import {
@@ -1698,6 +1698,9 @@ async function verifyRuntime(state, requestImpl = controlRequest) {
1698
1698
  return body.instance_id === state.instanceId ? "ours" : "stale";
1699
1699
  }
1700
1700
 
1701
+ // src/supervisor.ts
1702
+ import { randomUUID as randomUUID2 } from "crypto";
1703
+
1701
1704
  // src/api.ts
1702
1705
  var RETRY_BACKOFF_MS = [250, 750];
1703
1706
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -1942,12 +1945,20 @@ var CabaneApi = class {
1942
1945
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
1943
1946
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
1944
1947
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
1945
- setActiveRun(workspaceId, conversationId, agentId, body) {
1948
+ async setActiveRun(workspaceId, conversationId, agentId, body) {
1946
1949
  const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
1947
1950
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
1948
- if (touchesFlag && this.opts.outbox) {
1951
+ const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
1952
+ if (touchesFlag && !startsRun && this.opts.outbox) {
1949
1953
  return this.durableActiveRunWrite(path, conversationId, agentId, body);
1950
1954
  }
1955
+ if (startsRun) {
1956
+ const res = await this.request("PATCH", path, body, {
1957
+ retry: true
1958
+ });
1959
+ this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
1960
+ return res;
1961
+ }
1951
1962
  return this.request("PATCH", path, body);
1952
1963
  }
1953
1964
  // CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
@@ -2006,15 +2017,16 @@ var CabaneApi = class {
2006
2017
  // (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
2007
2018
  // double-posted. The caller threads its turn abort signal so a cancel
2008
2019
  // mid-commit aborts the in-flight POST rather than letting it land.
2009
- // CT11: `kind` now includes `'stopped'` for the terminal marker the
2010
- // dispatcher writes when a turn is cancelled same wire shape as
2011
- // `progress`/`final`, distinguished only by `kind` so the chat drawer's
2012
- // turn-group renderer treats it as a closing row. `seq` is the companion's
2013
- // per-turn monotonic counter, stamped on the row so the merged timeline
2014
- // orders the commit deterministically against the persisted tool/thinking
2015
- // rows. Both fields are optional on the wire an older companion that didn't
2016
- // mint seq still validates (the server defaults to 0); `stopped` is only
2017
- // emitted by post-CT11 companions.
2020
+ // CT11: `kind` includes `'stopped'` for the terminal cancel marker — same
2021
+ // wire shape as `progress`/`final`, distinguished only by `kind` so the chat
2022
+ // drawer's turn-group renderer treats it as a closing row. CT1295: this
2023
+ // companion no longer WRITES that kind the server does, in the transaction
2024
+ // that terminalizes the stopped turn so the value survives here only as
2025
+ // wire vocabulary the server also speaks. `seq` is the companion's per-turn
2026
+ // monotonic counter, stamped on the row so the merged timeline orders the
2027
+ // commit deterministically against the persisted tool/thinking rows. Both
2028
+ // fields are optional on the wire — an older companion that didn't mint seq
2029
+ // still validates (the server defaults to 0).
2018
2030
  postTurnMessage(workspaceId, conversationId, body, signal) {
2019
2031
  return this.durableCommit(
2020
2032
  "message",
@@ -2318,7 +2330,7 @@ var CursorTracker = class {
2318
2330
  };
2319
2331
 
2320
2332
  // src/dispatch-dedupe.ts
2321
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2333
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2322
2334
  import { join as join9 } from "path";
2323
2335
  var MAX_IDS = 256;
2324
2336
  function dir(log) {
@@ -2358,6 +2370,7 @@ function hasCompleted(workspaceId, eventId) {
2358
2370
  }
2359
2371
  function markCompleted(workspaceId, eventId) {
2360
2372
  mark("completed", workspaceId, eventId);
2373
+ forgetTurnId(workspaceId, eventId);
2361
2374
  }
2362
2375
  var MAX_RESUME_ATTEMPTS = 3;
2363
2376
  function resumeDir() {
@@ -2399,6 +2412,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2399
2412
  );
2400
2413
  return next;
2401
2414
  }
2415
+ function turnDir() {
2416
+ return join9(cabaneDir(), "turns");
2417
+ }
2418
+ function turnPathFor(workspaceId) {
2419
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2420
+ }
2421
+ var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2422
+ function readTurnIds(workspaceId) {
2423
+ const out = /* @__PURE__ */ new Map();
2424
+ const path = turnPathFor(workspaceId);
2425
+ if (!existsSync7(path)) return out;
2426
+ try {
2427
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2428
+ const trimmed = line.trim();
2429
+ if (!trimmed) continue;
2430
+ const tab = trimmed.lastIndexOf(" ");
2431
+ if (tab <= 0) continue;
2432
+ const id = trimmed.slice(0, tab);
2433
+ const turnId = trimmed.slice(tab + 1);
2434
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2435
+ }
2436
+ } catch {
2437
+ return out;
2438
+ }
2439
+ return out;
2440
+ }
2441
+ function turnIdForEvent(workspaceId, eventId) {
2442
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2443
+ }
2444
+ var TURN_ID_OVERFLOW_WARN = 8192;
2445
+ function writeTurnIds(workspaceId, turns) {
2446
+ const entries = [...turns.entries()];
2447
+ mkdirSync7(turnDir(), { recursive: true });
2448
+ const path = turnPathFor(workspaceId);
2449
+ const tmp = `${path}.${process.pid}.tmp`;
2450
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2451
+ renameSync3(tmp, path);
2452
+ return entries.length;
2453
+ }
2454
+ function rememberTurnId(workspaceId, eventId, turnId) {
2455
+ const turns = readTurnIds(workspaceId);
2456
+ if (turns.get(eventId) === turnId) return turns.size;
2457
+ turns.set(eventId, turnId);
2458
+ return writeTurnIds(workspaceId, turns);
2459
+ }
2460
+ function forgetTurnId(workspaceId, eventId) {
2461
+ const turns = readTurnIds(workspaceId);
2462
+ if (!turns.delete(eventId)) return;
2463
+ writeTurnIds(workspaceId, turns);
2464
+ }
2402
2465
  function noResume() {
2403
2466
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2404
2467
  }
@@ -3816,7 +3879,7 @@ var BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES = [
3816
3879
  {
3817
3880
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result;
3818
3881
  // the adapter stops before the closing reply — no final text, no `result`
3819
- // event. The host writes the `kind:'stopped'` marker off the signal.
3882
+ // event. CT1295: the server writes the `kind:'stopped'` marker.
3820
3883
  name: "cancel mid-stream",
3821
3884
  request: makeRequest(),
3822
3885
  nativeStream: [
@@ -4937,7 +5000,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
4937
5000
  {
4938
5001
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
4939
5002
  // adapter stops before the closing reply — no final text, no `result` event.
4940
- // The host writes the `kind:'stopped'` marker off the signal.
5003
+ // CT1295: the server writes the `kind:'stopped'` marker.
4941
5004
  name: "cancel mid-stream",
4942
5005
  request: makeRequest2(),
4943
5006
  nativeStream: [
@@ -6229,7 +6292,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
6229
6292
  {
6230
6293
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
6231
6294
  // adapter stops before the closing reply — no final text, no `result` event.
6232
- // The host writes the `kind:'stopped'` marker off the signal.
6295
+ // CT1295: the server writes the `kind:'stopped'` marker.
6233
6296
  name: "cancel mid-stream",
6234
6297
  request: makeRequest3(),
6235
6298
  nativeStream: [
@@ -7385,8 +7448,10 @@ var TurnCommitter = class {
7385
7448
  // End-of-turn progress promotion. The held-text flush is now the adapter's
7386
7449
  // job (it emits the closing reply as a `text` event before `result`), so this
7387
7450
  // only asks the pump to promote agent-authored interim text. A wordless turn
7388
- // stays wordless and the dispatcher closes it with a marker. Guards on the
7389
- // abort signal, so on cancel no `final` is forced.
7451
+ // stays wordless and the dispatcher closes it with a `silent`/`skipped`
7452
+ // marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
7453
+ // marker, not from this side). Guards on the abort signal, so on cancel no
7454
+ // `final` is forced.
7390
7455
  async finalize(okResult) {
7391
7456
  await this.pump.finalize(okResult);
7392
7457
  }
@@ -7504,7 +7569,6 @@ var TurnCommitter = class {
7504
7569
  var PREPARING_TOOL_NAME = "preparing";
7505
7570
  var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
7506
7571
  var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
7507
- var STOPPED_MARKER_BODY = "(stopped)";
7508
7572
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
7509
7573
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
7510
7574
  var SKIPPED_MARKER_BODY = "(skipped)";
@@ -7539,6 +7603,18 @@ function checkoutState(cwd) {
7539
7603
  function runKey(conversationId, agentId) {
7540
7604
  return `${conversationId}|${agentId}`;
7541
7605
  }
7606
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7607
+ "dispatch_not_admitted",
7608
+ "turn_already_ended",
7609
+ "turn_belongs_elsewhere"
7610
+ ]);
7611
+ function leaseRefusal(err) {
7612
+ if (!(err instanceof ApiError)) return null;
7613
+ const body = err.body;
7614
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7615
+ if (code && LEASE_REFUSALS.has(code)) return code;
7616
+ return null;
7617
+ }
7542
7618
  var ERROR_BODY_LOG_CAP = 2e3;
7543
7619
  function describeErrorBody(body) {
7544
7620
  if (body === void 0 || body === null) return void 0;
@@ -7573,6 +7649,16 @@ var Dispatcher = class {
7573
7649
  }
7574
7650
  opts;
7575
7651
  // SJ383: per-(conversation, agent) abort registry.
7652
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
7653
+ // per (conversation, agent), which quietly encoded the invariant the whole
7654
+ // task exists to enforce: that a pair can only ever have one live loop. When
7655
+ // that assumption broke, the second `set` EVICTED the first controller and
7656
+ // the first loop became permanently uncancellable — no other code path can
7657
+ // reach into a running turn. So the registry that Stop depends on failed
7658
+ // exactly when Stop was the thing you needed.
7659
+ //
7660
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
7661
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
7576
7662
  aborts;
7577
7663
  // CT1109: pairs already told, in the conversation, that their session state is
7578
7664
  // being refused. The failure repeats every single turn until someone fixes the
@@ -7635,7 +7721,12 @@ var Dispatcher = class {
7635
7721
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7636
7722
  return { ok: false, durationMs, reason };
7637
7723
  }
7638
- async handle(payload) {
7724
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
7725
+ // under before the companion died. Passing it makes the replay ask the
7726
+ // mailroom to re-admit the turn it already owns, which the server explicitly
7727
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
7728
+ // dispatch, where minting below is correct.
7729
+ async handle(payload, opts = {}) {
7639
7730
  const startedAt = Date.now();
7640
7731
  const dispatchId = payload.messageId;
7641
7732
  const workspaceId = this.opts.workspaceId;
@@ -7645,7 +7736,7 @@ var Dispatcher = class {
7645
7736
  agentId: payload.agentId,
7646
7737
  messageId: payload.messageId
7647
7738
  });
7648
- const turnId = randomUUID();
7739
+ const turnId = opts.turnId ?? randomUUID();
7649
7740
  let turnContext;
7650
7741
  try {
7651
7742
  turnContext = await this.opts.api.getTurnContext(
@@ -7867,7 +7958,18 @@ ${reason}`,
7867
7958
  };
7868
7959
  const key = runKey(payload.conversationId, payload.agentId);
7869
7960
  const abortController = new AbortController();
7870
- this.aborts.set(key, abortController);
7961
+ let pairAborts = this.aborts.get(key);
7962
+ if (!pairAborts) {
7963
+ pairAborts = /* @__PURE__ */ new Map();
7964
+ this.aborts.set(key, pairAborts);
7965
+ }
7966
+ pairAborts.set(turnId, abortController);
7967
+ const releaseAbort = () => {
7968
+ const pair = this.aborts.get(key);
7969
+ if (pair?.get(turnId) !== abortController) return;
7970
+ pair.delete(turnId);
7971
+ if (pair.size === 0) this.aborts.delete(key);
7972
+ };
7871
7973
  let timeoutReason = null;
7872
7974
  try {
7873
7975
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -7880,10 +7982,21 @@ ${reason}`,
7880
7982
  turnId
7881
7983
  });
7882
7984
  } catch (err) {
7883
- turnLog.warn(
7884
- { err: err instanceof Error ? err.message : String(err) },
7885
- "dispatcher: active-run flag set failed terminally; proceeding"
7985
+ const refusal = leaseRefusal(err);
7986
+ releaseAbort();
7987
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
7988
+ turnLog.error(
7989
+ { refusal, turnId },
7990
+ "dispatcher: refused a turn lease; not running the model"
7991
+ );
7992
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
7993
+ }
7994
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
7995
+ turnLog.error(
7996
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
7997
+ "dispatcher: turn lease not confirmed; not running the model"
7886
7998
  );
7999
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
7887
8000
  }
7888
8001
  const sendState = createSendState();
7889
8002
  const skipState = createSkipState();
@@ -7987,6 +8100,7 @@ ${reason}`,
7987
8100
  "dispatcher: runtime-unavailable notice post failed"
7988
8101
  );
7989
8102
  }
8103
+ releaseAbort();
7990
8104
  return this.concludeBeforeRun(
7991
8105
  payload,
7992
8106
  turnLog,
@@ -8287,23 +8401,6 @@ ${reason}`,
8287
8401
  }
8288
8402
  }
8289
8403
  }
8290
- if (userCancelled) {
8291
- try {
8292
- await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8293
- body: STOPPED_MARKER_BODY,
8294
- kind: "stopped",
8295
- turnId,
8296
- seq: nextSeq(),
8297
- // CT113: the stopped marker is still "about" the triggering message.
8298
- parentMessageId: payload.messageId
8299
- });
8300
- } catch (err) {
8301
- turnLog.warn(
8302
- { err: err instanceof Error ? err.message : String(err) },
8303
- "dispatcher: stopped-marker commit failed"
8304
- );
8305
- }
8306
- }
8307
8404
  const body = {
8308
8405
  activeRunStartedAt: null,
8309
8406
  // CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
@@ -8386,9 +8483,7 @@ ${reason}`,
8386
8483
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8387
8484
  );
8388
8485
  }
8389
- if (this.aborts.get(key) === abortController) {
8390
- this.aborts.delete(key);
8391
- }
8486
+ releaseAbort();
8392
8487
  }
8393
8488
  const durationMs = Date.now() - startedAt;
8394
8489
  const finalReplyBody = committer.replyBody;
@@ -8432,11 +8527,13 @@ ${reason}`,
8432
8527
  // THIS companion process. Returns true if an in-flight run was aborted.
8433
8528
  cancel(conversationId, agentId) {
8434
8529
  const key = runKey(conversationId, agentId);
8435
- const ac = this.aborts.get(key);
8436
- if (!ac) return false;
8437
- try {
8438
- ac.abort();
8439
- } catch {
8530
+ const pair = this.aborts.get(key);
8531
+ if (!pair || pair.size === 0) return false;
8532
+ for (const ac of [...pair.values()]) {
8533
+ try {
8534
+ ac.abort();
8535
+ } catch {
8536
+ }
8440
8537
  }
8441
8538
  return true;
8442
8539
  }
@@ -8500,7 +8597,7 @@ import {
8500
8597
  mkdirSync as mkdirSync10,
8501
8598
  readdirSync as readdirSync3,
8502
8599
  readFileSync as readFileSync8,
8503
- renameSync as renameSync3,
8600
+ renameSync as renameSync4,
8504
8601
  rmSync as rmSync7,
8505
8602
  writeFileSync as writeFileSync7
8506
8603
  } from "fs";
@@ -8532,7 +8629,7 @@ var Outbox = class {
8532
8629
  const tmp = `${target}.${process.pid}.tmp`;
8533
8630
  try {
8534
8631
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
8535
- renameSync3(tmp, target);
8632
+ renameSync4(tmp, target);
8536
8633
  } catch (err) {
8537
8634
  try {
8538
8635
  rmSync7(tmp, { force: true });
@@ -9333,6 +9430,19 @@ var CompanionSupervisor = class {
9333
9430
  type: "device:dispatch_requested",
9334
9431
  ...wire.payload
9335
9432
  };
9433
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9434
+ this.log.debug(
9435
+ {
9436
+ workspaceId: wr.workspaceId,
9437
+ conversationId: payload.conversationId,
9438
+ agentId: payload.agentId,
9439
+ addressedTo: payload.deviceId
9440
+ },
9441
+ "companion: dispatch addressed to another device; ignoring"
9442
+ );
9443
+ if (ev.id) wr.cursor.settle(ev.id);
9444
+ return;
9445
+ }
9336
9446
  let agent = wr.agents.get(payload.agentId);
9337
9447
  if (!agent) {
9338
9448
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9464,8 +9574,25 @@ var CompanionSupervisor = class {
9464
9574
  "companion: re-dispatching interrupted turn (resume after restart)"
9465
9575
  );
9466
9576
  }
9467
- if (ev.id) markDispatched(workspaceId, ev.id);
9468
- const result = await agent.dispatcher.handle(payload);
9577
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
9578
+ const turnId = resumedTurnId ?? randomUUID2();
9579
+ if (ev.id) {
9580
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
9581
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
9582
+ this.log.error(
9583
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
9584
+ "companion: in-flight turn-id map is implausibly large \u2014 completion pruning is likely broken. Keeping every mapping; dropping one would silently lose a resumable turn."
9585
+ );
9586
+ }
9587
+ markDispatched(workspaceId, ev.id);
9588
+ }
9589
+ if (resumedTurnId) {
9590
+ this.log.info(
9591
+ { workspaceId, eventId: ev.id, turnId },
9592
+ "companion: resuming an interrupted turn under its original id"
9593
+ );
9594
+ }
9595
+ const result = await agent.dispatcher.handle(payload, { turnId });
9469
9596
  if (ev.id) markCompleted(workspaceId, ev.id);
9470
9597
  if (ev.id) wr.cursor.settle(ev.id);
9471
9598
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -9856,7 +9983,7 @@ async function createCompanionRuntime(opts = {}) {
9856
9983
  opencodeServerUrl: cfg.opencode?.serverUrl,
9857
9984
  codex: isCodexEnabled(cfg)
9858
9985
  });
9859
- const instanceId = randomUUID2();
9986
+ const instanceId = randomUUID3();
9860
9987
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9861
9988
  const pre = readLiveRuntimeState();
9862
9989
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.63",
3
+ "version": "0.6.65",
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",