@cabane/companion 0.6.62 → 0.6.64

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 +222 -34
  2. package/dist/runtime.js +222 -34
  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
@@ -2819,7 +2830,7 @@ var CursorTracker = class {
2819
2830
  };
2820
2831
 
2821
2832
  // src/dispatch-dedupe.ts
2822
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2833
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2823
2834
  import { join as join9 } from "path";
2824
2835
  var MAX_IDS = 256;
2825
2836
  function dir(log) {
@@ -2859,6 +2870,7 @@ function hasCompleted(workspaceId, eventId) {
2859
2870
  }
2860
2871
  function markCompleted(workspaceId, eventId) {
2861
2872
  mark("completed", workspaceId, eventId);
2873
+ forgetTurnId(workspaceId, eventId);
2862
2874
  }
2863
2875
  var MAX_RESUME_ATTEMPTS = 3;
2864
2876
  function resumeDir() {
@@ -2900,6 +2912,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2900
2912
  );
2901
2913
  return next;
2902
2914
  }
2915
+ function turnDir() {
2916
+ return join9(cabaneDir(), "turns");
2917
+ }
2918
+ function turnPathFor(workspaceId) {
2919
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2920
+ }
2921
+ 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;
2922
+ function readTurnIds(workspaceId) {
2923
+ const out = /* @__PURE__ */ new Map();
2924
+ const path = turnPathFor(workspaceId);
2925
+ if (!existsSync7(path)) return out;
2926
+ try {
2927
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2928
+ const trimmed = line.trim();
2929
+ if (!trimmed) continue;
2930
+ const tab = trimmed.lastIndexOf(" ");
2931
+ if (tab <= 0) continue;
2932
+ const id = trimmed.slice(0, tab);
2933
+ const turnId = trimmed.slice(tab + 1);
2934
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2935
+ }
2936
+ } catch {
2937
+ return out;
2938
+ }
2939
+ return out;
2940
+ }
2941
+ function turnIdForEvent(workspaceId, eventId) {
2942
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2943
+ }
2944
+ var TURN_ID_OVERFLOW_WARN = 8192;
2945
+ function writeTurnIds(workspaceId, turns) {
2946
+ const entries = [...turns.entries()];
2947
+ mkdirSync7(turnDir(), { recursive: true });
2948
+ const path = turnPathFor(workspaceId);
2949
+ const tmp = `${path}.${process.pid}.tmp`;
2950
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2951
+ renameSync3(tmp, path);
2952
+ return entries.length;
2953
+ }
2954
+ function rememberTurnId(workspaceId, eventId, turnId) {
2955
+ const turns = readTurnIds(workspaceId);
2956
+ if (turns.get(eventId) === turnId) return turns.size;
2957
+ turns.set(eventId, turnId);
2958
+ return writeTurnIds(workspaceId, turns);
2959
+ }
2960
+ function forgetTurnId(workspaceId, eventId) {
2961
+ const turns = readTurnIds(workspaceId);
2962
+ if (!turns.delete(eventId)) return;
2963
+ writeTurnIds(workspaceId, turns);
2964
+ }
2903
2965
  function noResume() {
2904
2966
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2905
2967
  }
@@ -7173,16 +7235,32 @@ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7173
7235
  var CANCEL_WAKE_TOOL = "cancel_wake";
7174
7236
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7175
7237
  function createReplyState() {
7176
- return { answersMessageId: null };
7238
+ return { answersMessageId: null, order: null };
7177
7239
  }
7178
7240
  function createSendState() {
7179
- return { agentId: null, message: null };
7241
+ return { agentId: null, message: null, order: null };
7242
+ }
7243
+ function createTurnControlOrder() {
7244
+ let calls = 0;
7245
+ return {
7246
+ next: () => {
7247
+ calls += 1;
7248
+ return calls;
7249
+ }
7250
+ };
7180
7251
  }
7181
7252
  function createSkipState() {
7182
7253
  return { skipped: false, reason: null };
7183
7254
  }
7184
7255
  function createAskState() {
7185
- return { targetUserId: null, question: null, headline: null, options: null, questions: null };
7256
+ return {
7257
+ targetUserId: null,
7258
+ question: null,
7259
+ headline: null,
7260
+ options: null,
7261
+ questions: null,
7262
+ order: null
7263
+ };
7186
7264
  }
7187
7265
  function createWakeState() {
7188
7266
  return { afterSeconds: null, at: null, note: null, cancelled: false };
@@ -7212,7 +7290,7 @@ function wakeCommitField(state) {
7212
7290
  }
7213
7291
  };
7214
7292
  }
7215
- function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
7293
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState, controlOrder) {
7216
7294
  return createSdkMcpServer({
7217
7295
  name: COMPANION_LOCAL_MCP_SERVER,
7218
7296
  version: "0.0.0",
@@ -7229,6 +7307,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
7229
7307
  async (args) => {
7230
7308
  sendState.agentId = args.agentId;
7231
7309
  sendState.message = args.message;
7310
+ sendState.order = controlOrder?.next() ?? null;
7232
7311
  return {
7233
7312
  content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
7234
7313
  };
@@ -7244,6 +7323,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
7244
7323
  },
7245
7324
  async (args) => {
7246
7325
  replyState.answersMessageId = args.messageId;
7326
+ replyState.order = controlOrder?.next() ?? null;
7247
7327
  return {
7248
7328
  content: [
7249
7329
  {
@@ -7326,6 +7406,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
7326
7406
  };
7327
7407
  }
7328
7408
  askState.targetUserId = args.targetUserId;
7409
+ askState.order = controlOrder?.next() ?? null;
7329
7410
  if (hasArray) {
7330
7411
  askState.questions = args.questions;
7331
7412
  askState.question = null;
@@ -7889,12 +7970,31 @@ var TurnCommitter = class {
7889
7970
  // a wordless turn has no answer to bind, and the server's mute-settle
7890
7971
  // notice speaks for it.
7891
7972
  turnControlFields(kind) {
7892
- return {
7973
+ const fields = {
7893
7974
  ...this.answersField(kind),
7894
7975
  ...this.sendField(),
7895
7976
  ...this.askField(),
7896
7977
  ...this.wakeField()
7897
7978
  };
7979
+ return { ...fields, ...this.orderField(fields) };
7980
+ }
7981
+ // CT1281: the order the control tools were CALLED, for the addressed rows the
7982
+ // server writes from this one commit. It reports a position only for an intent
7983
+ // that actually made it into the commit — an ask whose payload was incomplete,
7984
+ // or a self-send the committer stripped, contributes no row and so has no place
7985
+ // in the line. An intent with no recorded call (the runtime's auto-declared
7986
+ // reply) is deliberately absent: the server sorts it last, which is what it is.
7987
+ orderField(fields) {
7988
+ const order = {};
7989
+ const replyOrder = this.deps.replyState.order;
7990
+ if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
7991
+ if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
7992
+ order.send = this.deps.sendState.order;
7993
+ }
7994
+ if (fields.ask !== void 0 && this.deps.askState.order !== null) {
7995
+ order.ask = this.deps.askState.order;
7996
+ }
7997
+ return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
7898
7998
  }
7899
7999
  // The declared reply: explicit `reply_to` first, else the runtime's own
7900
8000
  // declaration for the turn that just answers — see
@@ -8002,6 +8102,18 @@ function checkoutState(cwd) {
8002
8102
  function runKey(conversationId, agentId) {
8003
8103
  return `${conversationId}|${agentId}`;
8004
8104
  }
8105
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8106
+ "dispatch_not_admitted",
8107
+ "turn_already_ended",
8108
+ "turn_belongs_elsewhere"
8109
+ ]);
8110
+ function leaseRefusal(err) {
8111
+ if (!(err instanceof ApiError)) return null;
8112
+ const body = err.body;
8113
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8114
+ if (code && LEASE_REFUSALS.has(code)) return code;
8115
+ return null;
8116
+ }
8005
8117
  var ERROR_BODY_LOG_CAP = 2e3;
8006
8118
  function describeErrorBody(body) {
8007
8119
  if (body === void 0 || body === null) return void 0;
@@ -8036,6 +8148,16 @@ var Dispatcher = class {
8036
8148
  }
8037
8149
  opts;
8038
8150
  // SJ383: per-(conversation, agent) abort registry.
8151
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
8152
+ // per (conversation, agent), which quietly encoded the invariant the whole
8153
+ // task exists to enforce: that a pair can only ever have one live loop. When
8154
+ // that assumption broke, the second `set` EVICTED the first controller and
8155
+ // the first loop became permanently uncancellable — no other code path can
8156
+ // reach into a running turn. So the registry that Stop depends on failed
8157
+ // exactly when Stop was the thing you needed.
8158
+ //
8159
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
8160
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
8039
8161
  aborts;
8040
8162
  // CT1109: pairs already told, in the conversation, that their session state is
8041
8163
  // being refused. The failure repeats every single turn until someone fixes the
@@ -8098,7 +8220,12 @@ var Dispatcher = class {
8098
8220
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8099
8221
  return { ok: false, durationMs, reason };
8100
8222
  }
8101
- async handle(payload) {
8223
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
8224
+ // under before the companion died. Passing it makes the replay ask the
8225
+ // mailroom to re-admit the turn it already owns, which the server explicitly
8226
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
8227
+ // dispatch, where minting below is correct.
8228
+ async handle(payload, opts = {}) {
8102
8229
  const startedAt = Date.now();
8103
8230
  const dispatchId = payload.messageId;
8104
8231
  const workspaceId = this.opts.workspaceId;
@@ -8108,7 +8235,7 @@ var Dispatcher = class {
8108
8235
  agentId: payload.agentId,
8109
8236
  messageId: payload.messageId
8110
8237
  });
8111
- const turnId = randomUUID();
8238
+ const turnId = opts.turnId ?? randomUUID();
8112
8239
  let turnContext;
8113
8240
  try {
8114
8241
  turnContext = await this.opts.api.getTurnContext(
@@ -8330,7 +8457,18 @@ ${reason}`,
8330
8457
  };
8331
8458
  const key = runKey(payload.conversationId, payload.agentId);
8332
8459
  const abortController = new AbortController();
8333
- this.aborts.set(key, abortController);
8460
+ let pairAborts = this.aborts.get(key);
8461
+ if (!pairAborts) {
8462
+ pairAborts = /* @__PURE__ */ new Map();
8463
+ this.aborts.set(key, pairAborts);
8464
+ }
8465
+ pairAborts.set(turnId, abortController);
8466
+ const releaseAbort = () => {
8467
+ const pair2 = this.aborts.get(key);
8468
+ if (pair2?.get(turnId) !== abortController) return;
8469
+ pair2.delete(turnId);
8470
+ if (pair2.size === 0) this.aborts.delete(key);
8471
+ };
8334
8472
  let timeoutReason = null;
8335
8473
  try {
8336
8474
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -8343,16 +8481,28 @@ ${reason}`,
8343
8481
  turnId
8344
8482
  });
8345
8483
  } catch (err) {
8346
- turnLog.warn(
8347
- { err: err instanceof Error ? err.message : String(err) },
8348
- "dispatcher: active-run flag set failed terminally; proceeding"
8484
+ const refusal = leaseRefusal(err);
8485
+ releaseAbort();
8486
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8487
+ turnLog.error(
8488
+ { refusal, turnId },
8489
+ "dispatcher: refused a turn lease; not running the model"
8490
+ );
8491
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
8492
+ }
8493
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
8494
+ turnLog.error(
8495
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
8496
+ "dispatcher: turn lease not confirmed; not running the model"
8349
8497
  );
8498
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
8350
8499
  }
8351
8500
  const sendState = createSendState();
8352
8501
  const skipState = createSkipState();
8353
8502
  const askState = createAskState();
8354
8503
  const wakeState = createWakeState();
8355
8504
  const replyState = createReplyState();
8505
+ const controlOrder = createTurnControlOrder();
8356
8506
  let spawnedSubAgent = false;
8357
8507
  const subAgentCreate = async (args) => {
8358
8508
  const dispatchTarget = args.agentId ?? payload.agentId;
@@ -8384,7 +8534,8 @@ ${reason}`,
8384
8534
  askState,
8385
8535
  subAgentCreate,
8386
8536
  wakeState,
8387
- replyState
8537
+ replyState,
8538
+ controlOrder
8388
8539
  );
8389
8540
  const request = buildCompanionTurnRequest({
8390
8541
  turnContext,
@@ -8448,6 +8599,7 @@ ${reason}`,
8448
8599
  "dispatcher: runtime-unavailable notice post failed"
8449
8600
  );
8450
8601
  }
8602
+ releaseAbort();
8451
8603
  return this.concludeBeforeRun(
8452
8604
  payload,
8453
8605
  turnLog,
@@ -8503,11 +8655,12 @@ ${reason}`,
8503
8655
  signal: abortController.signal,
8504
8656
  log: turnLog,
8505
8657
  nextSeq,
8506
- // The committer reads this at commit to attach the addressed send onto the
8507
- // turn's `final` row.
8658
+ // The committer reads this at commit to carry the addressed send on the
8659
+ // terminal row; the server writes the send itself as a distinct message.
8508
8660
  sendState,
8509
- // CT326: likewise the ask payload attached to the `final` row so the
8510
- // server creates the `asks` row atomically with the message it rides on.
8661
+ // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8662
+ // own addressed message to the human which ENQUEUES like any other send
8663
+ // and creates the `asks` row against that carrier, not against turn speech.
8511
8664
  askState,
8512
8665
  // CT442: likewise the wake payload — attached to the `final` row so the
8513
8666
  // server arms the wake schedule atomically with the reply it rode on.
@@ -8532,6 +8685,7 @@ ${reason}`,
8532
8685
  );
8533
8686
  if (intent.ask) {
8534
8687
  askState.targetUserId = intent.ask.targetUserId;
8688
+ askState.order = intent.askOrder ?? null;
8535
8689
  if (intent.ask.questions && intent.ask.questions.length > 0) {
8536
8690
  askState.questions = intent.ask.questions;
8537
8691
  askState.question = null;
@@ -8560,8 +8714,12 @@ ${reason}`,
8560
8714
  if (intent.sendAgentId && intent.sendBody) {
8561
8715
  sendState.agentId = intent.sendAgentId;
8562
8716
  sendState.message = intent.sendBody;
8717
+ sendState.order = intent.sendOrder ?? null;
8718
+ }
8719
+ if (intent.answersMessageId) {
8720
+ replyState.answersMessageId = intent.answersMessageId;
8721
+ replyState.order = intent.replyOrder ?? null;
8563
8722
  }
8564
- if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
8565
8723
  if (intent.skipped) {
8566
8724
  skipState.skipped = true;
8567
8725
  skipState.reason = intent.skipReason;
@@ -8841,9 +8999,7 @@ ${reason}`,
8841
8999
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8842
9000
  );
8843
9001
  }
8844
- if (this.aborts.get(key) === abortController) {
8845
- this.aborts.delete(key);
8846
- }
9002
+ releaseAbort();
8847
9003
  }
8848
9004
  const durationMs = Date.now() - startedAt;
8849
9005
  const finalReplyBody = committer.replyBody;
@@ -8887,11 +9043,13 @@ ${reason}`,
8887
9043
  // THIS companion process. Returns true if an in-flight run was aborted.
8888
9044
  cancel(conversationId, agentId) {
8889
9045
  const key = runKey(conversationId, agentId);
8890
- const ac = this.aborts.get(key);
8891
- if (!ac) return false;
8892
- try {
8893
- ac.abort();
8894
- } catch {
9046
+ const pair2 = this.aborts.get(key);
9047
+ if (!pair2 || pair2.size === 0) return false;
9048
+ for (const ac of [...pair2.values()]) {
9049
+ try {
9050
+ ac.abort();
9051
+ } catch {
9052
+ }
8895
9053
  }
8896
9054
  return true;
8897
9055
  }
@@ -8955,7 +9113,7 @@ import {
8955
9113
  mkdirSync as mkdirSync10,
8956
9114
  readdirSync as readdirSync3,
8957
9115
  readFileSync as readFileSync8,
8958
- renameSync as renameSync3,
9116
+ renameSync as renameSync4,
8959
9117
  rmSync as rmSync7,
8960
9118
  writeFileSync as writeFileSync7
8961
9119
  } from "fs";
@@ -8987,7 +9145,7 @@ var Outbox = class {
8987
9145
  const tmp = `${target}.${process.pid}.tmp`;
8988
9146
  try {
8989
9147
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
8990
- renameSync3(tmp, target);
9148
+ renameSync4(tmp, target);
8991
9149
  } catch (err) {
8992
9150
  try {
8993
9151
  rmSync7(tmp, { force: true });
@@ -9788,6 +9946,19 @@ var CompanionSupervisor = class {
9788
9946
  type: "device:dispatch_requested",
9789
9947
  ...wire.payload
9790
9948
  };
9949
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9950
+ this.log.debug(
9951
+ {
9952
+ workspaceId: wr.workspaceId,
9953
+ conversationId: payload.conversationId,
9954
+ agentId: payload.agentId,
9955
+ addressedTo: payload.deviceId
9956
+ },
9957
+ "companion: dispatch addressed to another device; ignoring"
9958
+ );
9959
+ if (ev.id) wr.cursor.settle(ev.id);
9960
+ return;
9961
+ }
9791
9962
  let agent = wr.agents.get(payload.agentId);
9792
9963
  if (!agent) {
9793
9964
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9919,8 +10090,25 @@ var CompanionSupervisor = class {
9919
10090
  "companion: re-dispatching interrupted turn (resume after restart)"
9920
10091
  );
9921
10092
  }
9922
- if (ev.id) markDispatched(workspaceId, ev.id);
9923
- const result = await agent.dispatcher.handle(payload);
10093
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
10094
+ const turnId = resumedTurnId ?? randomUUID2();
10095
+ if (ev.id) {
10096
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
10097
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
10098
+ this.log.error(
10099
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
10100
+ "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."
10101
+ );
10102
+ }
10103
+ markDispatched(workspaceId, ev.id);
10104
+ }
10105
+ if (resumedTurnId) {
10106
+ this.log.info(
10107
+ { workspaceId, eventId: ev.id, turnId },
10108
+ "companion: resuming an interrupted turn under its original id"
10109
+ );
10110
+ }
10111
+ const result = await agent.dispatcher.handle(payload, { turnId });
9924
10112
  if (ev.id) markCompleted(workspaceId, ev.id);
9925
10113
  if (ev.id) wr.cursor.settle(ev.id);
9926
10114
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -10311,7 +10499,7 @@ async function createCompanionRuntime(opts = {}) {
10311
10499
  opencodeServerUrl: cfg.opencode?.serverUrl,
10312
10500
  codex: isCodexEnabled(cfg)
10313
10501
  });
10314
- const instanceId = randomUUID2();
10502
+ const instanceId = randomUUID3();
10315
10503
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10316
10504
  const pre = readLiveRuntimeState();
10317
10505
  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
@@ -2318,7 +2329,7 @@ var CursorTracker = class {
2318
2329
  };
2319
2330
 
2320
2331
  // src/dispatch-dedupe.ts
2321
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2332
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2322
2333
  import { join as join9 } from "path";
2323
2334
  var MAX_IDS = 256;
2324
2335
  function dir(log) {
@@ -2358,6 +2369,7 @@ function hasCompleted(workspaceId, eventId) {
2358
2369
  }
2359
2370
  function markCompleted(workspaceId, eventId) {
2360
2371
  mark("completed", workspaceId, eventId);
2372
+ forgetTurnId(workspaceId, eventId);
2361
2373
  }
2362
2374
  var MAX_RESUME_ATTEMPTS = 3;
2363
2375
  function resumeDir() {
@@ -2399,6 +2411,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2399
2411
  );
2400
2412
  return next;
2401
2413
  }
2414
+ function turnDir() {
2415
+ return join9(cabaneDir(), "turns");
2416
+ }
2417
+ function turnPathFor(workspaceId) {
2418
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2419
+ }
2420
+ 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;
2421
+ function readTurnIds(workspaceId) {
2422
+ const out = /* @__PURE__ */ new Map();
2423
+ const path = turnPathFor(workspaceId);
2424
+ if (!existsSync7(path)) return out;
2425
+ try {
2426
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2427
+ const trimmed = line.trim();
2428
+ if (!trimmed) continue;
2429
+ const tab = trimmed.lastIndexOf(" ");
2430
+ if (tab <= 0) continue;
2431
+ const id = trimmed.slice(0, tab);
2432
+ const turnId = trimmed.slice(tab + 1);
2433
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2434
+ }
2435
+ } catch {
2436
+ return out;
2437
+ }
2438
+ return out;
2439
+ }
2440
+ function turnIdForEvent(workspaceId, eventId) {
2441
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2442
+ }
2443
+ var TURN_ID_OVERFLOW_WARN = 8192;
2444
+ function writeTurnIds(workspaceId, turns) {
2445
+ const entries = [...turns.entries()];
2446
+ mkdirSync7(turnDir(), { recursive: true });
2447
+ const path = turnPathFor(workspaceId);
2448
+ const tmp = `${path}.${process.pid}.tmp`;
2449
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2450
+ renameSync3(tmp, path);
2451
+ return entries.length;
2452
+ }
2453
+ function rememberTurnId(workspaceId, eventId, turnId) {
2454
+ const turns = readTurnIds(workspaceId);
2455
+ if (turns.get(eventId) === turnId) return turns.size;
2456
+ turns.set(eventId, turnId);
2457
+ return writeTurnIds(workspaceId, turns);
2458
+ }
2459
+ function forgetTurnId(workspaceId, eventId) {
2460
+ const turns = readTurnIds(workspaceId);
2461
+ if (!turns.delete(eventId)) return;
2462
+ writeTurnIds(workspaceId, turns);
2463
+ }
2402
2464
  function noResume() {
2403
2465
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2404
2466
  }
@@ -6672,16 +6734,32 @@ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6672
6734
  var CANCEL_WAKE_TOOL = "cancel_wake";
6673
6735
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6674
6736
  function createReplyState() {
6675
- return { answersMessageId: null };
6737
+ return { answersMessageId: null, order: null };
6676
6738
  }
6677
6739
  function createSendState() {
6678
- return { agentId: null, message: null };
6740
+ return { agentId: null, message: null, order: null };
6741
+ }
6742
+ function createTurnControlOrder() {
6743
+ let calls = 0;
6744
+ return {
6745
+ next: () => {
6746
+ calls += 1;
6747
+ return calls;
6748
+ }
6749
+ };
6679
6750
  }
6680
6751
  function createSkipState() {
6681
6752
  return { skipped: false, reason: null };
6682
6753
  }
6683
6754
  function createAskState() {
6684
- return { targetUserId: null, question: null, headline: null, options: null, questions: null };
6755
+ return {
6756
+ targetUserId: null,
6757
+ question: null,
6758
+ headline: null,
6759
+ options: null,
6760
+ questions: null,
6761
+ order: null
6762
+ };
6685
6763
  }
6686
6764
  function createWakeState() {
6687
6765
  return { afterSeconds: null, at: null, note: null, cancelled: false };
@@ -6711,7 +6789,7 @@ function wakeCommitField(state) {
6711
6789
  }
6712
6790
  };
6713
6791
  }
6714
- function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
6792
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState, controlOrder) {
6715
6793
  return createSdkMcpServer({
6716
6794
  name: COMPANION_LOCAL_MCP_SERVER,
6717
6795
  version: "0.0.0",
@@ -6728,6 +6806,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
6728
6806
  async (args) => {
6729
6807
  sendState.agentId = args.agentId;
6730
6808
  sendState.message = args.message;
6809
+ sendState.order = controlOrder?.next() ?? null;
6731
6810
  return {
6732
6811
  content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
6733
6812
  };
@@ -6743,6 +6822,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
6743
6822
  },
6744
6823
  async (args) => {
6745
6824
  replyState.answersMessageId = args.messageId;
6825
+ replyState.order = controlOrder?.next() ?? null;
6746
6826
  return {
6747
6827
  content: [
6748
6828
  {
@@ -6825,6 +6905,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
6825
6905
  };
6826
6906
  }
6827
6907
  askState.targetUserId = args.targetUserId;
6908
+ askState.order = controlOrder?.next() ?? null;
6828
6909
  if (hasArray) {
6829
6910
  askState.questions = args.questions;
6830
6911
  askState.question = null;
@@ -7388,12 +7469,31 @@ var TurnCommitter = class {
7388
7469
  // a wordless turn has no answer to bind, and the server's mute-settle
7389
7470
  // notice speaks for it.
7390
7471
  turnControlFields(kind) {
7391
- return {
7472
+ const fields = {
7392
7473
  ...this.answersField(kind),
7393
7474
  ...this.sendField(),
7394
7475
  ...this.askField(),
7395
7476
  ...this.wakeField()
7396
7477
  };
7478
+ return { ...fields, ...this.orderField(fields) };
7479
+ }
7480
+ // CT1281: the order the control tools were CALLED, for the addressed rows the
7481
+ // server writes from this one commit. It reports a position only for an intent
7482
+ // that actually made it into the commit — an ask whose payload was incomplete,
7483
+ // or a self-send the committer stripped, contributes no row and so has no place
7484
+ // in the line. An intent with no recorded call (the runtime's auto-declared
7485
+ // reply) is deliberately absent: the server sorts it last, which is what it is.
7486
+ orderField(fields) {
7487
+ const order = {};
7488
+ const replyOrder = this.deps.replyState.order;
7489
+ if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
7490
+ if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
7491
+ order.send = this.deps.sendState.order;
7492
+ }
7493
+ if (fields.ask !== void 0 && this.deps.askState.order !== null) {
7494
+ order.ask = this.deps.askState.order;
7495
+ }
7496
+ return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
7397
7497
  }
7398
7498
  // The declared reply: explicit `reply_to` first, else the runtime's own
7399
7499
  // declaration for the turn that just answers — see
@@ -7501,6 +7601,18 @@ function checkoutState(cwd) {
7501
7601
  function runKey(conversationId, agentId) {
7502
7602
  return `${conversationId}|${agentId}`;
7503
7603
  }
7604
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7605
+ "dispatch_not_admitted",
7606
+ "turn_already_ended",
7607
+ "turn_belongs_elsewhere"
7608
+ ]);
7609
+ function leaseRefusal(err) {
7610
+ if (!(err instanceof ApiError)) return null;
7611
+ const body = err.body;
7612
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7613
+ if (code && LEASE_REFUSALS.has(code)) return code;
7614
+ return null;
7615
+ }
7504
7616
  var ERROR_BODY_LOG_CAP = 2e3;
7505
7617
  function describeErrorBody(body) {
7506
7618
  if (body === void 0 || body === null) return void 0;
@@ -7535,6 +7647,16 @@ var Dispatcher = class {
7535
7647
  }
7536
7648
  opts;
7537
7649
  // SJ383: per-(conversation, agent) abort registry.
7650
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
7651
+ // per (conversation, agent), which quietly encoded the invariant the whole
7652
+ // task exists to enforce: that a pair can only ever have one live loop. When
7653
+ // that assumption broke, the second `set` EVICTED the first controller and
7654
+ // the first loop became permanently uncancellable — no other code path can
7655
+ // reach into a running turn. So the registry that Stop depends on failed
7656
+ // exactly when Stop was the thing you needed.
7657
+ //
7658
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
7659
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
7538
7660
  aborts;
7539
7661
  // CT1109: pairs already told, in the conversation, that their session state is
7540
7662
  // being refused. The failure repeats every single turn until someone fixes the
@@ -7597,7 +7719,12 @@ var Dispatcher = class {
7597
7719
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7598
7720
  return { ok: false, durationMs, reason };
7599
7721
  }
7600
- async handle(payload) {
7722
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
7723
+ // under before the companion died. Passing it makes the replay ask the
7724
+ // mailroom to re-admit the turn it already owns, which the server explicitly
7725
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
7726
+ // dispatch, where minting below is correct.
7727
+ async handle(payload, opts = {}) {
7601
7728
  const startedAt = Date.now();
7602
7729
  const dispatchId = payload.messageId;
7603
7730
  const workspaceId = this.opts.workspaceId;
@@ -7607,7 +7734,7 @@ var Dispatcher = class {
7607
7734
  agentId: payload.agentId,
7608
7735
  messageId: payload.messageId
7609
7736
  });
7610
- const turnId = randomUUID();
7737
+ const turnId = opts.turnId ?? randomUUID();
7611
7738
  let turnContext;
7612
7739
  try {
7613
7740
  turnContext = await this.opts.api.getTurnContext(
@@ -7829,7 +7956,18 @@ ${reason}`,
7829
7956
  };
7830
7957
  const key = runKey(payload.conversationId, payload.agentId);
7831
7958
  const abortController = new AbortController();
7832
- this.aborts.set(key, abortController);
7959
+ let pairAborts = this.aborts.get(key);
7960
+ if (!pairAborts) {
7961
+ pairAborts = /* @__PURE__ */ new Map();
7962
+ this.aborts.set(key, pairAborts);
7963
+ }
7964
+ pairAborts.set(turnId, abortController);
7965
+ const releaseAbort = () => {
7966
+ const pair = this.aborts.get(key);
7967
+ if (pair?.get(turnId) !== abortController) return;
7968
+ pair.delete(turnId);
7969
+ if (pair.size === 0) this.aborts.delete(key);
7970
+ };
7833
7971
  let timeoutReason = null;
7834
7972
  try {
7835
7973
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -7842,16 +7980,28 @@ ${reason}`,
7842
7980
  turnId
7843
7981
  });
7844
7982
  } catch (err) {
7845
- turnLog.warn(
7846
- { err: err instanceof Error ? err.message : String(err) },
7847
- "dispatcher: active-run flag set failed terminally; proceeding"
7983
+ const refusal = leaseRefusal(err);
7984
+ releaseAbort();
7985
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
7986
+ turnLog.error(
7987
+ { refusal, turnId },
7988
+ "dispatcher: refused a turn lease; not running the model"
7989
+ );
7990
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
7991
+ }
7992
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
7993
+ turnLog.error(
7994
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
7995
+ "dispatcher: turn lease not confirmed; not running the model"
7848
7996
  );
7997
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
7849
7998
  }
7850
7999
  const sendState = createSendState();
7851
8000
  const skipState = createSkipState();
7852
8001
  const askState = createAskState();
7853
8002
  const wakeState = createWakeState();
7854
8003
  const replyState = createReplyState();
8004
+ const controlOrder = createTurnControlOrder();
7855
8005
  let spawnedSubAgent = false;
7856
8006
  const subAgentCreate = async (args) => {
7857
8007
  const dispatchTarget = args.agentId ?? payload.agentId;
@@ -7883,7 +8033,8 @@ ${reason}`,
7883
8033
  askState,
7884
8034
  subAgentCreate,
7885
8035
  wakeState,
7886
- replyState
8036
+ replyState,
8037
+ controlOrder
7887
8038
  );
7888
8039
  const request = buildCompanionTurnRequest({
7889
8040
  turnContext,
@@ -7947,6 +8098,7 @@ ${reason}`,
7947
8098
  "dispatcher: runtime-unavailable notice post failed"
7948
8099
  );
7949
8100
  }
8101
+ releaseAbort();
7950
8102
  return this.concludeBeforeRun(
7951
8103
  payload,
7952
8104
  turnLog,
@@ -8002,11 +8154,12 @@ ${reason}`,
8002
8154
  signal: abortController.signal,
8003
8155
  log: turnLog,
8004
8156
  nextSeq,
8005
- // The committer reads this at commit to attach the addressed send onto the
8006
- // turn's `final` row.
8157
+ // The committer reads this at commit to carry the addressed send on the
8158
+ // terminal row; the server writes the send itself as a distinct message.
8007
8159
  sendState,
8008
- // CT326: likewise the ask payload attached to the `final` row so the
8009
- // server creates the `asks` row atomically with the message it rides on.
8160
+ // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8161
+ // own addressed message to the human which ENQUEUES like any other send
8162
+ // and creates the `asks` row against that carrier, not against turn speech.
8010
8163
  askState,
8011
8164
  // CT442: likewise the wake payload — attached to the `final` row so the
8012
8165
  // server arms the wake schedule atomically with the reply it rode on.
@@ -8031,6 +8184,7 @@ ${reason}`,
8031
8184
  );
8032
8185
  if (intent.ask) {
8033
8186
  askState.targetUserId = intent.ask.targetUserId;
8187
+ askState.order = intent.askOrder ?? null;
8034
8188
  if (intent.ask.questions && intent.ask.questions.length > 0) {
8035
8189
  askState.questions = intent.ask.questions;
8036
8190
  askState.question = null;
@@ -8059,8 +8213,12 @@ ${reason}`,
8059
8213
  if (intent.sendAgentId && intent.sendBody) {
8060
8214
  sendState.agentId = intent.sendAgentId;
8061
8215
  sendState.message = intent.sendBody;
8216
+ sendState.order = intent.sendOrder ?? null;
8217
+ }
8218
+ if (intent.answersMessageId) {
8219
+ replyState.answersMessageId = intent.answersMessageId;
8220
+ replyState.order = intent.replyOrder ?? null;
8062
8221
  }
8063
- if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
8064
8222
  if (intent.skipped) {
8065
8223
  skipState.skipped = true;
8066
8224
  skipState.reason = intent.skipReason;
@@ -8340,9 +8498,7 @@ ${reason}`,
8340
8498
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8341
8499
  );
8342
8500
  }
8343
- if (this.aborts.get(key) === abortController) {
8344
- this.aborts.delete(key);
8345
- }
8501
+ releaseAbort();
8346
8502
  }
8347
8503
  const durationMs = Date.now() - startedAt;
8348
8504
  const finalReplyBody = committer.replyBody;
@@ -8386,11 +8542,13 @@ ${reason}`,
8386
8542
  // THIS companion process. Returns true if an in-flight run was aborted.
8387
8543
  cancel(conversationId, agentId) {
8388
8544
  const key = runKey(conversationId, agentId);
8389
- const ac = this.aborts.get(key);
8390
- if (!ac) return false;
8391
- try {
8392
- ac.abort();
8393
- } catch {
8545
+ const pair = this.aborts.get(key);
8546
+ if (!pair || pair.size === 0) return false;
8547
+ for (const ac of [...pair.values()]) {
8548
+ try {
8549
+ ac.abort();
8550
+ } catch {
8551
+ }
8394
8552
  }
8395
8553
  return true;
8396
8554
  }
@@ -8454,7 +8612,7 @@ import {
8454
8612
  mkdirSync as mkdirSync10,
8455
8613
  readdirSync as readdirSync3,
8456
8614
  readFileSync as readFileSync8,
8457
- renameSync as renameSync3,
8615
+ renameSync as renameSync4,
8458
8616
  rmSync as rmSync7,
8459
8617
  writeFileSync as writeFileSync7
8460
8618
  } from "fs";
@@ -8486,7 +8644,7 @@ var Outbox = class {
8486
8644
  const tmp = `${target}.${process.pid}.tmp`;
8487
8645
  try {
8488
8646
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
8489
- renameSync3(tmp, target);
8647
+ renameSync4(tmp, target);
8490
8648
  } catch (err) {
8491
8649
  try {
8492
8650
  rmSync7(tmp, { force: true });
@@ -9287,6 +9445,19 @@ var CompanionSupervisor = class {
9287
9445
  type: "device:dispatch_requested",
9288
9446
  ...wire.payload
9289
9447
  };
9448
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9449
+ this.log.debug(
9450
+ {
9451
+ workspaceId: wr.workspaceId,
9452
+ conversationId: payload.conversationId,
9453
+ agentId: payload.agentId,
9454
+ addressedTo: payload.deviceId
9455
+ },
9456
+ "companion: dispatch addressed to another device; ignoring"
9457
+ );
9458
+ if (ev.id) wr.cursor.settle(ev.id);
9459
+ return;
9460
+ }
9290
9461
  let agent = wr.agents.get(payload.agentId);
9291
9462
  if (!agent) {
9292
9463
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9418,8 +9589,25 @@ var CompanionSupervisor = class {
9418
9589
  "companion: re-dispatching interrupted turn (resume after restart)"
9419
9590
  );
9420
9591
  }
9421
- if (ev.id) markDispatched(workspaceId, ev.id);
9422
- const result = await agent.dispatcher.handle(payload);
9592
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
9593
+ const turnId = resumedTurnId ?? randomUUID2();
9594
+ if (ev.id) {
9595
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
9596
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
9597
+ this.log.error(
9598
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
9599
+ "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."
9600
+ );
9601
+ }
9602
+ markDispatched(workspaceId, ev.id);
9603
+ }
9604
+ if (resumedTurnId) {
9605
+ this.log.info(
9606
+ { workspaceId, eventId: ev.id, turnId },
9607
+ "companion: resuming an interrupted turn under its original id"
9608
+ );
9609
+ }
9610
+ const result = await agent.dispatcher.handle(payload, { turnId });
9423
9611
  if (ev.id) markCompleted(workspaceId, ev.id);
9424
9612
  if (ev.id) wr.cursor.settle(ev.id);
9425
9613
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -9810,7 +9998,7 @@ async function createCompanionRuntime(opts = {}) {
9810
9998
  opencodeServerUrl: cfg.opencode?.serverUrl,
9811
9999
  codex: isCodexEnabled(cfg)
9812
10000
  });
9813
- const instanceId = randomUUID2();
10001
+ const instanceId = randomUUID3();
9814
10002
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9815
10003
  const pre = readLiveRuntimeState();
9816
10004
  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.62",
3
+ "version": "0.6.64",
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",