@ouro.bot/cli 0.1.0-alpha.726 → 0.1.0-alpha.727

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.
@@ -33,11 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.BB_IN_FLIGHT_MAX_AGE_MS = void 0;
37
- exports.resolveReactionTarget = resolveReactionTarget;
36
+ exports.BB_IN_FLIGHT_MAX_AGE_MS = exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = void 0;
37
+ exports.pruneBlueBubblesSemanticObservationGenerations = pruneBlueBubblesSemanticObservationGenerations;
38
38
  exports.tokenizeForDedupe = tokenizeForDedupe;
39
39
  exports.jaccardSimilarity = jaccardSimilarity;
40
- exports.createStatusBatcher = createStatusBatcher;
41
40
  exports.isBlueBubblesMessageInFlight = isBlueBubblesMessageInFlight;
42
41
  exports.beginBlueBubblesMessageInFlight = beginBlueBubblesMessageInFlight;
43
42
  exports.endBlueBubblesMessageInFlight = endBlueBubblesMessageInFlight;
@@ -47,6 +46,7 @@ exports.isAgentSelfHandle = isAgentSelfHandle;
47
46
  exports.getDiscoveredOwnHandles = getDiscoveredOwnHandles;
48
47
  exports.clearDiscoveredOwnHandles = clearDiscoveredOwnHandles;
49
48
  exports.recordDiscoveredOwnHandle = recordDiscoveredOwnHandle;
49
+ exports.seedBlueBubblesStartupSemanticObservations = seedBlueBubblesStartupSemanticObservations;
50
50
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
51
51
  exports.recoverQueuedBlueBubblesMessages = recoverQueuedBlueBubblesMessages;
52
52
  exports.catchUpMissedBlueBubblesMessages = catchUpMissedBlueBubblesMessages;
@@ -83,14 +83,106 @@ const semantic_receipts_1 = require("./semantic-receipts");
83
83
  const runtime_state_1 = require("./runtime-state");
84
84
  const session_cleanup_1 = require("./session-cleanup");
85
85
  const reaction_policy_1 = require("./reaction-policy");
86
+ const latest_turn_1 = require("./latest-turn");
86
87
  const active_turns_1 = require("./active-turns");
87
88
  const trust_gate_1 = require("../trust-gate");
88
89
  const pipeline_1 = require("../pipeline");
89
- const context_packets_1 = require("../context-packets");
90
90
  const context_packet_ledger_1 = require("../context-packet-ledger");
91
91
  const context_packet_1 = require("./context-packet");
92
92
  const autonomy_budget_1 = require("../../heart/autonomy-budget");
93
+ const outbound_state_1 = require("./outbound-state");
93
94
  const bbFailoverStates = new Map();
95
+ const activeSemanticHandlingSlots = new Map();
96
+ const semanticObservationGenerations = new Map();
97
+ const BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES = new Set([
98
+ "accepted",
99
+ "enqueued",
100
+ "local-visible",
101
+ "delivered",
102
+ ]);
103
+ exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = 15 * 60_000;
104
+ function pruneBlueBubblesSemanticObservationGenerations(nowMs = Date.now()) {
105
+ let pruned = 0;
106
+ for (const [keyHash, generation] of semanticObservationGenerations) {
107
+ if (generation.retainedAtMs === null
108
+ || nowMs - generation.retainedAtMs < exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS)
109
+ continue;
110
+ (0, latest_turn_1.clearPending)(generation.reservation);
111
+ semanticObservationGenerations.delete(keyHash);
112
+ pruned += 1;
113
+ }
114
+ return pruned;
115
+ }
116
+ function adoptBlueBubblesSemanticObservation(keyHash, observedReservation, activate) {
117
+ pruneBlueBubblesSemanticObservationGenerations();
118
+ const existing = semanticObservationGenerations.get(keyHash);
119
+ if (!existing) {
120
+ const generation = { reservation: observedReservation, retry: false, retainedAtMs: null };
121
+ semanticObservationGenerations.set(keyHash, generation);
122
+ return generation;
123
+ }
124
+ (0, latest_turn_1.mergeObservationReservations)(existing.reservation, observedReservation);
125
+ if (activate) {
126
+ existing.retainedAtMs = null;
127
+ (0, latest_turn_1.reactivateObservation)(existing.reservation);
128
+ }
129
+ return existing;
130
+ }
131
+ function suspendBlueBubblesSemanticObservation(generation) {
132
+ generation.retry = true;
133
+ generation.retainedAtMs = Date.now();
134
+ (0, latest_turn_1.reactivateObservation)(generation.reservation);
135
+ }
136
+ function completeBlueBubblesSemanticObservation(keyHash, generation) {
137
+ (0, latest_turn_1.clearPending)(generation.reservation);
138
+ semanticObservationGenerations.delete(keyHash);
139
+ }
140
+ async function acquireBlueBubblesSemanticHandlingSlot(keyHash, generation) {
141
+ const active = activeSemanticHandlingSlots.get(keyHash);
142
+ if (!active) {
143
+ const slot = {
144
+ generation,
145
+ reservation: generation.reservation,
146
+ waiters: [],
147
+ };
148
+ activeSemanticHandlingSlots.set(keyHash, slot);
149
+ return {
150
+ status: "owner",
151
+ slot,
152
+ reservation: generation.reservation,
153
+ allowSameGenerationRetry: generation.retry,
154
+ };
155
+ }
156
+ return await new Promise((resolve) => {
157
+ active.waiters.push(resolve);
158
+ });
159
+ }
160
+ function releaseBlueBubblesSemanticHandlingSlot(keyHash, slot, outcome) {
161
+ if (outcome === "retryable") {
162
+ slot.generation.retry = true;
163
+ const next = slot.waiters.shift();
164
+ if (next) {
165
+ (0, latest_turn_1.reactivateObservation)(slot.reservation);
166
+ next({
167
+ status: "owner",
168
+ slot,
169
+ reservation: slot.reservation,
170
+ allowSameGenerationRetry: true,
171
+ });
172
+ return;
173
+ }
174
+ }
175
+ activeSemanticHandlingSlots.delete(keyHash);
176
+ if (outcome === "terminal") {
177
+ completeBlueBubblesSemanticObservation(keyHash, slot.generation);
178
+ }
179
+ else {
180
+ suspendBlueBubblesSemanticObservation(slot.generation);
181
+ }
182
+ for (const resolve of slot.waiters.splice(0)) {
183
+ resolve({ status: "handled_by_owner" });
184
+ }
185
+ }
94
186
  /**
95
187
  * In-flight message tracker.
96
188
  *
@@ -118,20 +210,6 @@ const bbInFlightMessageClaims = new Map();
118
210
  * a leaked marker from blocking forward progress for hours.
119
211
  */
120
212
  exports.BB_IN_FLIGHT_MAX_AGE_MS = 15 * 60_000;
121
- // Resolve the message a reaction points at, so the agent is told *what* was
122
- // reacted to and *whose* message it was rather than a bare "reacted with love".
123
- // Prefers the authorship-carrying lookup; falls back to text-only when the client
124
- // does not provide one. Every failure path returns an explicit "unknown" rather
125
- // than a value the caller could mistake for a resolved target.
126
- async function resolveReactionTarget(client, targetMessageGuid) {
127
- const base = { guid: targetMessageGuid };
128
- if (client.getMessageDetails) {
129
- const details = await client.getMessageDetails(targetMessageGuid).catch(() => null);
130
- return { ...base, text: details?.text ?? null, fromMe: details?.fromMe ?? null };
131
- }
132
- const text = await client.getMessageText(targetMessageGuid).catch(() => null);
133
- return { ...base, text, fromMe: null };
134
- }
135
213
  // ── Near-duplicate outward-text detection ────────────────────────
136
214
  // Used by createBlueBubblesCallbacks to collapse mid-turn rephrasings of the
137
215
  // same answer/status into a single delivery. Exposed for direct unit testing
@@ -155,44 +233,6 @@ function jaccardSimilarity(a, b) {
155
233
  const union = a.size + b.size - intersection;
156
234
  return intersection / union;
157
235
  }
158
- /**
159
- * Accumulates status descriptions and debounces them.
160
- * If multiple descriptions arrive within `delayMs`, they are joined with ` · `
161
- * and sent as a single message. Flush sends immediately and clears the timer.
162
- */
163
- function createStatusBatcher(send, delayMs) {
164
- (0, runtime_1.emitNervesEvent)({
165
- component: "senses",
166
- event: "senses.bluebubbles_status_batcher_created",
167
- message: "status batcher initialized",
168
- meta: { delayMs },
169
- });
170
- let pending = [];
171
- let timer = null;
172
- function fire() {
173
- if (pending.length === 0)
174
- return;
175
- const combined = pending.join(" \u00b7 ");
176
- pending = [];
177
- timer = null;
178
- send(combined);
179
- }
180
- return {
181
- add(text) {
182
- pending.push(text);
183
- if (timer !== null)
184
- clearTimeout(timer);
185
- timer = setTimeout(fire, delayMs);
186
- },
187
- flush() {
188
- if (timer !== null) {
189
- clearTimeout(timer);
190
- timer = null;
191
- }
192
- fire();
193
- },
194
- };
195
- }
196
236
  function blueBubblesMessageKey(sessionKey, messageGuid) {
197
237
  return `${sessionKey}:${messageGuid.trim()}`;
198
238
  }
@@ -249,8 +289,9 @@ const defaultDeps = {
249
289
  buildSystem: prompt_1.buildSystem,
250
290
  runAgent: core_1.runAgent,
251
291
  loadSession: context_1.loadSession,
292
+ saveSession: context_1.saveSession,
252
293
  postTurnTrim: context_1.postTurnTrim,
253
- deferPostTurnPersist: context_1.deferPostTurnPersist,
294
+ postTurnPersist: context_1.postTurnPersist,
254
295
  sessionPath: config_1.sessionPath,
255
296
  accumulateFriendTokens: friends_1.accumulateFriendTokens,
256
297
  createClient: () => (0, client_1.createBlueBubblesClient)(),
@@ -259,6 +300,10 @@ const defaultDeps = {
259
300
  createFriendResolver: (store, params) => new friends_1.FriendResolver(store, params),
260
301
  createServer: http.createServer,
261
302
  getOwnHandles: () => [...(0, config_1.getBlueBubblesConfig)().ownHandles, ...discoveredOwnHandles],
303
+ promoteFailoverState: (sessionKey, state) => {
304
+ bbFailoverStates.set(sessionKey, structuredClone(state));
305
+ },
306
+ recordProcessed: processed_log_1.recordProcessedBlueBubblesMessage,
262
307
  };
263
308
  const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
264
309
  const BLUEBUBBLES_RECOVERY_PASS_DELAY_MS = 1_000;
@@ -266,7 +311,6 @@ const BLUEBUBBLES_RECOVERY_PASS_INTERVAL_MS = 30_000;
266
311
  const BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS = 2 * 60_000;
267
312
  const BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS = 10 * 60_000;
268
313
  const BLUEBUBBLES_LIVE_TURN_STALLED_MS = 90_000;
269
- const BLUEBUBBLES_SILENCE_WATCHDOG_MS = 75_000;
270
314
  const BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS = 2_000;
271
315
  const BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS = 20_000;
272
316
  const BLUEBUBBLES_CATCHUP_PAGE_SIZE = 50;
@@ -280,32 +324,31 @@ class BlueBubblesRecoveryTurnTimeoutError extends Error {
280
324
  this.name = "BlueBubblesRecoveryTurnTimeoutError";
281
325
  }
282
326
  }
283
- function getBlueBubblesTimeoutReason(timeoutReason, timeoutMs) {
284
- /* v8 ignore next 3 -- timeoutReason is set atomically with recoveryTimedOut; fallback keeps the invariant fail-closed @preserve */
285
- if (!timeoutReason) {
286
- return new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
287
- }
288
- return timeoutReason;
289
- }
290
327
  class BlueBubblesActivityTimeoutError extends Error {
291
328
  constructor(operation, timeoutMs) {
292
329
  super(`bluebubbles ${operation} activity timed out after ${timeoutMs}ms`);
293
330
  this.name = "BlueBubblesActivityTimeoutError";
294
331
  }
295
332
  }
296
- function isBlueBubblesRecoveryTurnTimeout(error) {
297
- return error instanceof BlueBubblesRecoveryTurnTimeoutError
298
- || (error instanceof Error
299
- && error.name === "BlueBubblesRecoveryTurnTimeoutError"
300
- && error.message.startsWith("bluebubbles recovery turn timed out after "));
301
- }
302
- function withBlueBubblesActivityTimeout(operation, timeoutMs, task) {
333
+ function withBlueBubblesActivityTimeout(operation, timeoutMs, controller, task) {
303
334
  let timer = null;
335
+ controller.signal.throwIfAborted();
336
+ let removeAbortListener;
337
+ const aborted = new Promise((_, reject) => {
338
+ const rejectFromAbort = () => {
339
+ reject(controller.signal.reason);
340
+ };
341
+ controller.signal.addEventListener("abort", rejectFromAbort, { once: true });
342
+ removeAbortListener = () => controller.signal.removeEventListener("abort", rejectFromAbort);
343
+ });
304
344
  return Promise.race([
305
- task(),
345
+ Promise.resolve().then(() => task(controller.signal)),
346
+ aborted,
306
347
  new Promise((_, reject) => {
307
348
  timer = setTimeout(() => {
308
- reject(new BlueBubblesActivityTimeoutError(operation, timeoutMs));
349
+ const error = new BlueBubblesActivityTimeoutError(operation, timeoutMs);
350
+ controller.abort(error);
351
+ reject(error);
309
352
  }, timeoutMs);
310
353
  /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
311
354
  if (typeof timer.unref === "function") {
@@ -313,6 +356,7 @@ function withBlueBubblesActivityTimeout(operation, timeoutMs, task) {
313
356
  }
314
357
  }),
315
358
  ]).finally(() => {
359
+ removeAbortListener();
316
360
  /* v8 ignore next -- timer is assigned unless a callback task throws synchronously; callback tasks are async @preserve */
317
361
  if (timer !== null)
318
362
  clearTimeout(timer);
@@ -339,7 +383,7 @@ async function waitForBlueBubblesCallbackCleanup(queue, timeoutMs, meta) {
339
383
  clearTimeout(timer);
340
384
  });
341
385
  if (!timedOut)
342
- return;
386
+ return true;
343
387
  (0, runtime_1.emitNervesEvent)({
344
388
  level: "warn",
345
389
  component: "senses",
@@ -347,13 +391,13 @@ async function waitForBlueBubblesCallbackCleanup(queue, timeoutMs, meta) {
347
391
  message: "bluebubbles callback cleanup timed out; releasing live turn lane",
348
392
  meta: { ...meta, timeoutMs },
349
393
  });
394
+ return false;
350
395
  }
351
396
  function resolveFriendParams(event) {
352
397
  if (event.chat.isGroup) {
353
- const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
354
398
  return {
355
399
  provider: "imessage-handle",
356
- externalId: `group:${groupKey}`,
400
+ externalId: `group:${event.chat.chatGuid}`,
357
401
  displayName: event.chat.displayName ?? "Unknown Group",
358
402
  channel: "bluebubbles",
359
403
  };
@@ -366,8 +410,7 @@ function resolveFriendParams(event) {
366
410
  };
367
411
  }
368
412
  function resolveGroupExternalId(event) {
369
- const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
370
- return `group:${groupKey}`;
413
+ return `group:${event.chat.chatGuid}`;
371
414
  }
372
415
  /**
373
416
  * Check if any participant in a group chat is a known family member.
@@ -376,7 +419,7 @@ function resolveGroupExternalId(event) {
376
419
  async function checkGroupHasFamilyMember(store, event) {
377
420
  if (!event.chat.isGroup)
378
421
  return false;
379
- for (const handle of event.chat.participantHandles ?? []) {
422
+ for (const handle of event.chat.participantHandles) {
380
423
  const friend = await store.findByExternalId("imessage-handle", handle);
381
424
  if (friend?.trustLevel === "family")
382
425
  return true;
@@ -477,8 +520,6 @@ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, co
477
520
  externalId: participant.externalId,
478
521
  displayName: participant.displayName,
479
522
  }));
480
- const targetGuid = evidence.targetGuid;
481
- const targetAuthorship = evidence.targetAuthorship;
482
523
  const presentation = {
483
524
  authority: "presentation_only",
484
525
  conversationKind,
@@ -495,23 +536,7 @@ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, co
495
536
  displayName: actor.displayName,
496
537
  },
497
538
  participants,
498
- ...(targetGuid
499
- ? {
500
- target: {
501
- messageGuid: targetGuid,
502
- authorship: targetAuthorship ?? "unknown",
503
- },
504
- }
505
- : {}),
506
539
  };
507
- if (event.kind !== "message") {
508
- return {
509
- kind: "bluebubbles",
510
- ...presentation,
511
- lane: "mutation",
512
- ...(repairNotice ? { repairNotice } : {}),
513
- };
514
- }
515
540
  const summaries = extractHistoricalLaneSummary(existingMessages);
516
541
  const threadId = event.threadOriginatorGuid?.trim() || undefined;
517
542
  const hasThreadRoute = !!threadId || summaries.some((summary) => summary.key.startsWith("thread:"));
@@ -534,14 +559,11 @@ function buildInboundText(event) {
534
559
  if (!event.chat.isGroup) {
535
560
  return baseText;
536
561
  }
537
- if (event.kind === "mutation") {
538
- return `${event.sender.displayName} ${baseText}`;
539
- }
540
562
  return `${event.sender.displayName}: ${baseText}`;
541
563
  }
542
564
  function buildInboundContent(event) {
543
565
  const text = buildInboundText(event);
544
- if (event.kind !== "message" || !event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
566
+ if (!event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
545
567
  return text;
546
568
  }
547
569
  return [
@@ -560,8 +582,6 @@ function sessionLikelyContainsMessage(event, existingMessages) {
560
582
  });
561
583
  }
562
584
  function getBlueBubblesContinuityIngressTexts(event) {
563
- if (event.kind !== "message")
564
- return [];
565
585
  const text = event.textForAgent.trim();
566
586
  if (text.length > 0)
567
587
  return [text];
@@ -629,10 +649,11 @@ function recordBlueBubblesRecoveryFailureForBudget(agentName, event, source) {
629
649
  // Autonomy failure accounting must not turn a recovery error into a broader sense outage.
630
650
  }
631
651
  }
632
- function insertEphemeralContextMessages(messages, contextMessages) {
652
+ function insertEphemeralContextMessages(messages, contextMessages, currentUserMessage) {
633
653
  if (contextMessages.length === 0)
634
654
  return messages;
635
- const insertionIndex = Math.max(0, messages.length - 1);
655
+ const requiredCurrentIndex = currentUserMessage ? messages.indexOf(currentUserMessage) : -1;
656
+ const insertionIndex = requiredCurrentIndex >= 0 ? requiredCurrentIndex : Math.max(0, messages.length - 1);
636
657
  return [
637
658
  ...messages.slice(0, insertionIndex),
638
659
  ...contextMessages,
@@ -646,23 +667,41 @@ function knownProviderMessageTexts(messages) {
646
667
  .filter((text) => text.length > 0);
647
668
  }
648
669
  async function buildBlueBubblesContextMessages(input) {
649
- if (input.event.kind !== "message")
650
- return { messages: [], contextPacketIds: [] };
651
670
  const event = input.event;
652
671
  if (!Number.isFinite(event.timestamp))
653
672
  return { messages: [], contextPacketIds: [] };
654
- const chatGuidHash = (0, context_packet_1.blueBubblesContextChatKeyHash)(event);
655
- const anchorTimestamp = new Date(event.timestamp).toISOString();
673
+ let result;
656
674
  try {
657
- const result = await (0, context_packet_1.buildBlueBubblesContextPacket)({
675
+ result = await (0, context_packet_1.buildBlueBubblesContextPacket)({
658
676
  agentName: input.agentName,
659
677
  client: input.client,
660
678
  event,
661
- knownMessageTexts: knownProviderMessageTexts(input.knownMessages ?? []),
679
+ knownMessageTexts: knownProviderMessageTexts(input.knownMessages),
662
680
  });
663
- if (!result)
664
- return { messages: [], contextPacketIds: [] };
665
- const { packet, rendered, historyCount } = result;
681
+ }
682
+ catch (error) {
683
+ (0, runtime_1.emitNervesEvent)({
684
+ level: "warn",
685
+ component: "senses",
686
+ event: "senses.bluebubbles_context_packet_error",
687
+ message: "failed to build live bluebubbles context packet",
688
+ meta: {
689
+ messageGuid: event.messageGuid,
690
+ reason: error instanceof Error ? error.message : String(error),
691
+ },
692
+ });
693
+ return { messages: [], contextPacketIds: [] };
694
+ }
695
+ if (!result)
696
+ return { messages: [], contextPacketIds: [] };
697
+ const { packet, optionalRendered, verifiedPredecessorMessage, historyCount } = result;
698
+ const prepared = {
699
+ messages: optionalRendered
700
+ ? [{ role: "system", content: optionalRendered.text }]
701
+ : [],
702
+ verifiedPredecessorMessage,
703
+ };
704
+ try {
666
705
  (0, context_packet_ledger_1.writeSenseContextPacket)(input.agentRoot, packet);
667
706
  (0, runtime_1.emitNervesEvent)({
668
707
  component: "senses",
@@ -675,7 +714,7 @@ async function buildBlueBubblesContextMessages(input) {
675
714
  },
676
715
  });
677
716
  return {
678
- messages: [{ role: "system", content: rendered.text }],
717
+ ...prepared,
679
718
  contextPacketIds: [packet.packetId],
680
719
  };
681
720
  }
@@ -690,30 +729,16 @@ async function buildBlueBubblesContextMessages(input) {
690
729
  reason: error instanceof Error ? error.message : String(error),
691
730
  },
692
731
  });
693
- const fallback = (0, context_packet_ledger_1.readLatestVisibleSenseContextPacket)(input.agentRoot, {
694
- sense: "bluebubbles",
695
- chatKeyHash: chatGuidHash,
696
- beforeAnchorTimestamp: anchorTimestamp,
697
- maxAgeMs: 24 * 60 * 60 * 1000,
698
- });
699
- if (!fallback)
700
- return { messages: [], contextPacketIds: [] };
701
- const rendered = (0, context_packets_1.renderSenseContextPacketForPrompt)(fallback);
702
- return {
703
- messages: [{ role: "system", content: rendered.text }],
704
- contextPacketIds: [fallback.packetId],
705
- };
732
+ return { ...prepared, contextPacketIds: [] };
706
733
  }
707
734
  }
708
735
  function createReplyTargetController(event) {
709
- const defaultTargetLabel = event.kind === "message" && event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
710
- let selection = event.kind === "message" && event.threadOriginatorGuid?.trim()
736
+ const defaultTargetLabel = event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
737
+ let selection = event.threadOriginatorGuid?.trim()
711
738
  ? { target: "current_lane" }
712
739
  : { target: "top_level" };
713
740
  return {
714
741
  getReplyToMessageGuid() {
715
- if (event.kind !== "message")
716
- return undefined;
717
742
  if (selection.target === "top_level")
718
743
  return undefined;
719
744
  if (selection.target === "thread")
@@ -747,10 +772,12 @@ function emitBlueBubblesMarkReadWarning(chat, error) {
747
772
  function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVisibleActivity, options = {}) {
748
773
  let textBuffer = "";
749
774
  let typingActive = false;
775
+ let typingCouldBeActive = false;
750
776
  let queue = Promise.resolve();
751
- let lastVisibleActivityMs = Date.now();
752
- let silenceWatchdog = null;
753
777
  let outboundClosed = false;
778
+ let nextCleanupToken = 0;
779
+ const activeCleanupTokens = new Set();
780
+ const activeActivityControllers = new Set();
754
781
  // Per-turn outward-send dedupe. A single createBlueBubblesCallbacks lifetime
755
782
  // serves one inbound turn, so collapsing identical outward bodies inside
756
783
  // this closure is scoped tightly: each fresh inbound turn starts with an
@@ -769,13 +796,37 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
769
796
  // check catches near-duplicates from the same turn.
770
797
  const sentOutwardTextNorms = new Set();
771
798
  const sentOutwardTokenSets = [];
772
- function enqueue(operation, task, options = {}) {
773
- /* v8 ignore next -- defensive guard: public callback entrypoints already drop after outbound close @preserve */
774
- if (outboundClosed && !options.allowAfterClose)
775
- return;
799
+ async function canRunOutbound() {
800
+ if (outboundClosed)
801
+ return false;
802
+ if (options.isOutboundCurrent && !options.isOutboundCurrent())
803
+ return false;
804
+ if (options.admitOutbound && !await options.admitOutbound())
805
+ return false;
806
+ if (outboundClosed)
807
+ return false;
808
+ if (options.isOutboundCurrent && !options.isOutboundCurrent())
809
+ return false;
810
+ return true;
811
+ }
812
+ function enqueue(operation, task, enqueueOptions = {}) {
813
+ const cleanupToken = enqueueOptions.cleanupToken;
814
+ const controller = new AbortController();
815
+ activeActivityControllers.add(controller);
776
816
  queue = queue
777
- .then(() => withBlueBubblesActivityTimeout(operation, BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS, task))
817
+ .then(async () => {
818
+ if (cleanupToken === undefined) {
819
+ if (!await canRunOutbound())
820
+ return;
821
+ }
822
+ else if (!activeCleanupTokens.has(cleanupToken)) {
823
+ return;
824
+ }
825
+ await withBlueBubblesActivityTimeout(operation, BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS, controller, task);
826
+ })
778
827
  .catch((error) => {
828
+ if (controller.signal.aborted && !(error instanceof BlueBubblesActivityTimeoutError))
829
+ return;
779
830
  (0, runtime_1.emitNervesEvent)({
780
831
  level: "warn",
781
832
  component: "senses",
@@ -783,8 +834,30 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
783
834
  message: "bluebubbles activity transport failed",
784
835
  meta: { operation, reason: error instanceof Error ? error.message : String(error) },
785
836
  });
837
+ })
838
+ .finally(() => {
839
+ activeActivityControllers.delete(controller);
786
840
  });
787
841
  }
842
+ function abortActiveActivity(reason) {
843
+ for (const controller of activeActivityControllers) {
844
+ controller.abort(new Error(reason));
845
+ }
846
+ }
847
+ function enqueueTypingStop() {
848
+ typingActive = false;
849
+ const cleanupToken = ++nextCleanupToken;
850
+ activeCleanupTokens.add(cleanupToken);
851
+ enqueue("typing_stop", async (signal) => {
852
+ try {
853
+ await client.setTyping(chat, false, signal);
854
+ typingCouldBeActive = false;
855
+ }
856
+ finally {
857
+ activeCleanupTokens.delete(cleanupToken);
858
+ }
859
+ }, { cleanupToken });
860
+ }
788
861
  function startTypingNow() {
789
862
  /* v8 ignore next -- defensive guard: callers already check typingActive @preserve */
790
863
  if (typingActive)
@@ -793,10 +866,11 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
793
866
  if (outboundClosed)
794
867
  return;
795
868
  typingActive = true;
796
- enqueue("typing_start", async () => {
869
+ typingCouldBeActive = true;
870
+ enqueue("typing_start", async (signal) => {
797
871
  const [markReadResult, typingResult] = await Promise.allSettled([
798
- client.markChatRead(chat),
799
- client.setTyping(chat, true),
872
+ client.markChatRead(chat, signal),
873
+ client.setTyping(chat, true, signal),
800
874
  ]);
801
875
  if (markReadResult.status === "rejected") {
802
876
  emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
@@ -807,7 +881,6 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
807
881
  });
808
882
  }
809
883
  function recordVisibleActivity() {
810
- lastVisibleActivityMs = Date.now();
811
884
  onVisibleActivity?.();
812
885
  }
813
886
  function isDuplicateOutwardText(trimmed) {
@@ -848,62 +921,11 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
848
921
  },
849
922
  });
850
923
  }
851
- function stopSilenceWatchdog() {
852
- if (silenceWatchdog === null)
853
- return;
854
- clearInterval(silenceWatchdog);
855
- silenceWatchdog = null;
856
- }
857
- function startSilenceWatchdog() {
858
- if (silenceWatchdog !== null)
859
- return;
860
- silenceWatchdog = setInterval(() => {
861
- if (Date.now() - lastVisibleActivityMs < BLUEBUBBLES_SILENCE_WATCHDOG_MS)
862
- return;
863
- sendStatus("still working on this...");
864
- }, BLUEBUBBLES_SILENCE_WATCHDOG_MS);
865
- /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
866
- if (typeof silenceWatchdog.unref === "function") {
867
- silenceWatchdog.unref();
868
- }
869
- }
870
- function sendStatus(text) {
871
- /* v8 ignore next -- defensive guard: cancelOutbound stops the only status caller before it can fire @preserve */
872
- if (outboundClosed)
873
- return;
874
- const trimmed = text.trim();
875
- /* v8 ignore next -- defensive guard; current status callers always provide non-empty text @preserve */
876
- if (!trimmed)
877
- return;
878
- // Status surfaces share the per-turn dedupe set so a status-style
879
- // outward message (tool description, error notice, watchdog ping)
880
- // can't deliver a near-duplicate of an already-sent answer or status
881
- // (post-#699 evidence: evt-001820 + evt-001821 + evt-001823 on
882
- // 2026-05-09 — overlapping status surface updates about the same
883
- // duplicate issue, which the flushNow/flush-only guard could not catch
884
- // because sendStatus writes directly via client.sendText).
885
- if (isDuplicateOutwardText(trimmed)) {
886
- emitDuplicateOutwardSuppressed("status", trimmed.length);
887
- return;
888
- }
889
- enqueue("send_status", async () => {
890
- await client.sendText({
891
- chat,
892
- text: trimmed,
893
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
894
- });
895
- recordVisibleActivity();
896
- // Re-enable typing indicator — sending a message clears the typing bubble
897
- await client.setTyping(chat, true);
898
- });
899
- }
900
924
  return {
901
925
  settleOutputMode: "retractable_buffer",
902
926
  onModelStart() {
903
927
  if (outboundClosed)
904
928
  return;
905
- if (options.enableSilenceWatchdog !== false)
906
- startSilenceWatchdog();
907
929
  if (options.enableActivitySignals !== false && !isGroupChat)
908
930
  startTypingNow();
909
931
  (0, runtime_1.emitNervesEvent)({
@@ -934,8 +956,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
934
956
  return;
935
957
  // Tool activity is a reply commitment, but iMessage is not a tool-progress
936
958
  // console. Keep the human-facing thread quiet until the agent has real
937
- // text (or the generic silence watchdog fires), while preserving native
938
- // read/typing signals and nerves telemetry for debugging.
959
+ // text, while preserving native read/typing signals and nerves telemetry
960
+ // for debugging.
939
961
  if (options.enableActivitySignals !== false && !typingActive && name !== "observe" && name !== "speak")
940
962
  startTypingNow();
941
963
  (0, runtime_1.emitNervesEvent)({
@@ -993,6 +1015,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
993
1015
  emitDuplicateOutwardSuppressed("flushNow", trimmed.length);
994
1016
  return;
995
1017
  }
1018
+ if (!await canRunOutbound())
1019
+ return;
996
1020
  await client.sendText({
997
1021
  chat,
998
1022
  text: trimmed,
@@ -1015,23 +1039,21 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
1015
1039
  operation: "flush_after_close",
1016
1040
  chatGuid: chat.chatGuid ?? null,
1017
1041
  });
1018
- return;
1042
+ return { status: "not_invoked", reason: "closed" };
1019
1043
  }
1020
1044
  await queue;
1021
1045
  const trimmed = textBuffer.trim();
1022
1046
  if (!trimmed) {
1023
1047
  if (typingActive) {
1024
- typingActive = false;
1025
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1048
+ enqueueTypingStop();
1026
1049
  await queue;
1027
1050
  }
1028
- return;
1051
+ return { status: "not_invoked", reason: "empty" };
1029
1052
  }
1030
1053
  textBuffer = "";
1031
1054
  /* v8 ignore next 4 -- branch: typing may already be stopped before flush @preserve */
1032
1055
  if (typingActive) {
1033
- typingActive = false;
1034
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1056
+ enqueueTypingStop();
1035
1057
  await queue;
1036
1058
  }
1037
1059
  if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(trimmed)) {
@@ -1043,35 +1065,181 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
1043
1065
  messageLength: trimmed.length,
1044
1066
  },
1045
1067
  });
1046
- return;
1068
+ return { status: "not_invoked", reason: "blocked_meta" };
1047
1069
  }
1048
1070
  if (isDuplicateOutwardText(trimmed)) {
1049
1071
  emitDuplicateOutwardSuppressed("flush", trimmed.length);
1050
- return;
1072
+ return { status: "not_invoked", reason: "duplicate" };
1051
1073
  }
1052
- await client.sendText({
1053
- chat,
1054
- text: trimmed,
1055
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
1056
- ...(flushOptions.signal ? { signal: flushOptions.signal } : {}),
1057
- });
1074
+ if (!await canRunOutbound())
1075
+ return { status: "not_invoked", reason: "not_current" };
1076
+ const replyToMessageGuid = replyTarget.getReplyToMessageGuid();
1077
+ const durableOutbound = options.durableOutbound;
1078
+ const durableTempGuid = durableOutbound
1079
+ ? `ouro-inbound-${(0, node_crypto_1.createHash)("sha256")
1080
+ .update(durableOutbound.idempotencyKey)
1081
+ .digest("hex")
1082
+ .slice(0, 32)}`
1083
+ : null;
1084
+ const boundaryState = { durableRecord: null, denialReason: "not_current" };
1085
+ const admitFinalTransport = () => {
1086
+ if (outboundClosed
1087
+ || (options.isOutboundCurrent && !options.isOutboundCurrent())
1088
+ || (options.isOutboundAdmittedNow && !options.isOutboundAdmittedNow())) {
1089
+ boundaryState.denialReason = "not_current";
1090
+ return false;
1091
+ }
1092
+ if (!durableOutbound || boundaryState.durableRecord)
1093
+ return true;
1094
+ const reservation = (0, outbound_state_1.reserveBlueBubblesOutbound)({
1095
+ agentRoot: durableOutbound.agentRoot,
1096
+ idempotencyKey: durableOutbound.idempotencyKey,
1097
+ chat,
1098
+ attachment: durableOutbound.attachment,
1099
+ text: trimmed,
1100
+ tempGuid: durableTempGuid,
1101
+ ...(replyToMessageGuid ? { replyToMessageGuid } : {}),
1102
+ });
1103
+ if (reservation.status !== "reserved") {
1104
+ boundaryState.denialReason = "durable_duplicate";
1105
+ return false;
1106
+ }
1107
+ boundaryState.durableRecord = reservation.record;
1108
+ return true;
1109
+ };
1110
+ const markDurableFailure = (error) => {
1111
+ const durableRecord = boundaryState.durableRecord;
1112
+ if (!durableRecord || !durableOutbound)
1113
+ return;
1114
+ try {
1115
+ (0, outbound_state_1.markBlueBubblesOutboundFailed)({
1116
+ agentRoot: durableOutbound.agentRoot,
1117
+ recordId: durableRecord.recordId,
1118
+ failedAt: new Date().toISOString(),
1119
+ reason: error instanceof Error ? error.message : String(error),
1120
+ });
1121
+ }
1122
+ catch (stateError) {
1123
+ (0, runtime_1.emitNervesEvent)({
1124
+ level: "error",
1125
+ component: "senses",
1126
+ event: "senses.bluebubbles_outbound_state_error",
1127
+ message: "failed to record bluebubbles outbound transport failure",
1128
+ meta: {
1129
+ recordId: durableRecord.recordId,
1130
+ reason: String(stateError),
1131
+ },
1132
+ });
1133
+ }
1134
+ };
1135
+ let response;
1136
+ while (true) {
1137
+ try {
1138
+ flushOptions.signal?.throwIfAborted();
1139
+ response = await client.sendText({
1140
+ chat,
1141
+ text: trimmed,
1142
+ replyToMessageGuid,
1143
+ ...(durableTempGuid ? { tempGuid: durableTempGuid } : {}),
1144
+ ...(flushOptions.signal ? { signal: flushOptions.signal } : {}),
1145
+ beforeTransportInvocation: admitFinalTransport,
1146
+ onTransportInvocation: options.onFinalTransportInvoked,
1147
+ });
1148
+ break;
1149
+ }
1150
+ catch (error) {
1151
+ if (error instanceof client_1.BlueBubblesSendError
1152
+ && error.transportInvoked === false
1153
+ && error.errorCode === "admission_denied") {
1154
+ if (boundaryState.denialReason === "durable_duplicate") {
1155
+ return { status: "not_invoked", reason: "durable_duplicate" };
1156
+ }
1157
+ // A newer observation may be only a temporary fence (duplicate,
1158
+ // audit-only, or otherwise non-promoting). Re-await admission and
1159
+ // retry the same durable reservation if this turn remains current.
1160
+ if (!await canRunOutbound()) {
1161
+ markDurableFailure(error);
1162
+ return { status: "not_invoked", reason: "not_current" };
1163
+ }
1164
+ continue;
1165
+ }
1166
+ markDurableFailure(error);
1167
+ throw error;
1168
+ }
1169
+ }
1170
+ const durableRecord = boundaryState.durableRecord;
1171
+ if (durableRecord && durableOutbound) {
1172
+ try {
1173
+ (0, outbound_state_1.markBlueBubblesOutboundAccepted)({
1174
+ agentRoot: durableOutbound.agentRoot,
1175
+ recordId: durableRecord.recordId,
1176
+ acceptedAt: new Date().toISOString(),
1177
+ ...(response.messageGuid ? { messageGuid: response.messageGuid } : {}),
1178
+ });
1179
+ }
1180
+ catch (stateError) {
1181
+ (0, runtime_1.emitNervesEvent)({
1182
+ level: "error",
1183
+ component: "senses",
1184
+ event: "senses.bluebubbles_outbound_state_error",
1185
+ message: "failed to record accepted bluebubbles outbound transport",
1186
+ meta: {
1187
+ recordId: durableRecord.recordId,
1188
+ reason: String(stateError),
1189
+ },
1190
+ });
1191
+ }
1192
+ }
1193
+ options.onFinalTransportAccepted?.();
1058
1194
  recordVisibleActivity();
1195
+ return { status: "accepted" };
1059
1196
  },
1060
1197
  async finish(options = {}) {
1061
- stopSilenceWatchdog();
1062
- if (typingActive) {
1063
- typingActive = false;
1064
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1198
+ const cleanupTimeoutMs = options.timeoutMs ?? BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS;
1199
+ const needsTypingStop = typingActive;
1200
+ if (needsTypingStop) {
1201
+ enqueueTypingStop();
1065
1202
  }
1066
- await waitForBlueBubblesCallbackCleanup(queue, options.timeoutMs ?? BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS, {
1203
+ const drained = await waitForBlueBubblesCallbackCleanup(queue, cleanupTimeoutMs, {
1067
1204
  operation: "finish",
1068
1205
  chatGuid: chat.chatGuid ?? null,
1069
1206
  });
1207
+ if (!drained) {
1208
+ // The queued start/read request may outlive this turn's bounded lane.
1209
+ // Invalidate its deferred stop, then issue one stop request now while A
1210
+ // still owns the canonical lock so B cannot start typing ahead of it.
1211
+ activeCleanupTokens.clear();
1212
+ abortActiveActivity("bluebubbles activity superseded by bounded cleanup");
1213
+ await waitForBlueBubblesCallbackCleanup(queue, cleanupTimeoutMs, {
1214
+ operation: "finish_after_activity_abort",
1215
+ chatGuid: chat.chatGuid ?? null,
1216
+ });
1217
+ }
1218
+ if (typingCouldBeActive) {
1219
+ const fallbackController = new AbortController();
1220
+ try {
1221
+ await withBlueBubblesActivityTimeout("typing_stop_fallback", cleanupTimeoutMs, fallbackController, (signal) => client.setTyping(chat, false, signal));
1222
+ typingCouldBeActive = false;
1223
+ }
1224
+ catch (error) {
1225
+ (0, runtime_1.emitNervesEvent)({
1226
+ level: "warn",
1227
+ component: "senses",
1228
+ event: "senses.bluebubbles_activity_error",
1229
+ message: "bluebubbles activity transport failed",
1230
+ meta: {
1231
+ operation: "typing_stop_fallback",
1232
+ reason: error instanceof Error ? error.message : String(error),
1233
+ },
1234
+ });
1235
+ }
1236
+ }
1237
+ activeCleanupTokens.clear();
1070
1238
  },
1071
1239
  cancelOutbound(reason) {
1072
1240
  outboundClosed = true;
1073
1241
  textBuffer = "";
1074
- stopSilenceWatchdog();
1242
+ abortActiveActivity(`bluebubbles outbound ${reason}`);
1075
1243
  (0, runtime_1.emitNervesEvent)({
1076
1244
  level: "warn",
1077
1245
  component: "senses",
@@ -1204,62 +1372,98 @@ async function shouldFilterAgentSelfHandle(event, resolvedDeps) {
1204
1372
  }
1205
1373
  return true;
1206
1374
  }
1207
- function hasObservedBlueBubblesDirection(event) {
1208
- return typeof event.fromMe === "boolean";
1375
+ function createBlueBubblesTurnStage(sessionKey) {
1376
+ return {
1377
+ failoverState: structuredClone(bbFailoverStates.get(sessionKey) ?? { pending: null }),
1378
+ };
1209
1379
  }
1210
- async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, options) {
1211
- const client = resolvedDeps.createClient();
1212
- const agentName = resolvedDeps.getAgentName();
1213
- (0, runtime_cwd_1.recoverRuntimeCwd)();
1214
- if (event.fromMe) {
1215
- (0, runtime_1.emitNervesEvent)({
1216
- component: "senses",
1217
- event: "senses.bluebubbles_from_me_ignored",
1218
- message: "ignored from-me bluebubbles event",
1219
- meta: {
1220
- messageGuid: event.messageGuid,
1221
- kind: event.kind,
1222
- },
1223
- });
1224
- if (event.chat.isGroup)
1225
- recordDiscoveredOwnHandle(event.sender.externalId);
1226
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1227
- }
1228
- // Fallback self-detection: BlueBubbles sometimes broadcasts a group-chat
1229
- // outbound message back through the webhook with `isFromMe` missing/false.
1230
- // Without this guard the agent ingests its own message and replies to it
1231
- // ("the agent talking to itself"). Compare the sender's externalId against
1232
- // the agent's known iMessage handles. Keep this group-only: 1:1 outbound
1233
- // echoes can be attributed to the peer handle, and stale ownHandles entries
1234
- // must not make real DMs disappear.
1235
- if (await shouldFilterAgentSelfHandle(event, resolvedDeps)) {
1380
+ function persistAcceptedBlueBubblesProjection(input) {
1381
+ const { stage, deps, event } = input;
1382
+ if (stage.postTurn) {
1383
+ try {
1384
+ deps.postTurnPersist(stage.postTurn.sessionPath, stage.postTurn.prepared, stage.postTurn.usage, stage.postTurn.state);
1385
+ }
1386
+ catch (error) {
1387
+ (0, runtime_1.emitNervesEvent)({
1388
+ level: "error",
1389
+ component: "senses",
1390
+ event: "senses.bluebubbles_accepted_projection_failed",
1391
+ message: "accepted bluebubbles reply could not be projected into canonical session history",
1392
+ meta: {
1393
+ messageGuid: event.messageGuid,
1394
+ sessionKey: event.chat.sessionKey,
1395
+ reason: error instanceof Error ? error.message : String(error),
1396
+ },
1397
+ });
1398
+ }
1399
+ }
1400
+ }
1401
+ function promoteAcceptedBlueBubblesAncillaryState(input) {
1402
+ const { stage, deps, agentName, event, source } = input;
1403
+ try {
1404
+ deps.promoteFailoverState(event.chat.sessionKey, stage.failoverState);
1405
+ }
1406
+ catch (error) {
1236
1407
  (0, runtime_1.emitNervesEvent)({
1237
1408
  level: "warn",
1238
1409
  component: "senses",
1239
- event: "senses.bluebubbles_self_handle_filtered",
1240
- message: "filtered bluebubbles event whose sender matched an agent-owned handle (isFromMe was missing/false)",
1410
+ event: "senses.bluebubbles_failover_state_promotion_failed",
1411
+ message: "accepted bluebubbles reply could not promote staged failover state",
1241
1412
  meta: {
1242
1413
  messageGuid: event.messageGuid,
1243
- kind: event.kind,
1244
- senderExternalId: event.sender.externalId,
1414
+ sessionKey: event.chat.sessionKey,
1415
+ reason: error instanceof Error ? error.message : String(error),
1245
1416
  },
1246
1417
  });
1247
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1248
1418
  }
1249
- if (event.kind === "mutation" && !event.shouldNotifyAgent) {
1419
+ if (stage.tokens) {
1420
+ const emitTokenPromotionFailure = (error) => {
1421
+ (0, runtime_1.emitNervesEvent)({
1422
+ level: "warn",
1423
+ component: "senses",
1424
+ event: "senses.bluebubbles_token_promotion_failed",
1425
+ message: "accepted bluebubbles reply could not promote staged token usage",
1426
+ meta: {
1427
+ messageGuid: event.messageGuid,
1428
+ sessionKey: event.chat.sessionKey,
1429
+ reason: error instanceof Error ? error.message : String(error),
1430
+ },
1431
+ });
1432
+ };
1433
+ try {
1434
+ void deps.accumulateFriendTokens(stage.tokens.store, stage.tokens.friendId, stage.tokens.usage).catch(emitTokenPromotionFailure);
1435
+ }
1436
+ catch (error) {
1437
+ emitTokenPromotionFailure(error);
1438
+ }
1439
+ }
1440
+ try {
1441
+ deps.recordProcessed(agentName, event, source, input.processedOutcome);
1442
+ }
1443
+ catch (error) {
1250
1444
  (0, runtime_1.emitNervesEvent)({
1445
+ level: "warn",
1251
1446
  component: "senses",
1252
- event: "senses.bluebubbles_state_mutation_recorded",
1253
- message: "recorded non-notify bluebubbles mutation",
1447
+ event: "senses.bluebubbles_processed_promotion_failed",
1448
+ message: "accepted bluebubbles reply could not promote processed sidecar state",
1254
1449
  meta: {
1255
1450
  messageGuid: event.messageGuid,
1256
- mutationType: event.mutationType,
1451
+ sessionKey: event.chat.sessionKey,
1452
+ reason: error instanceof Error ? error.message : String(error),
1257
1453
  },
1258
1454
  });
1259
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
1260
1455
  }
1456
+ }
1457
+ function hasObservedBlueBubblesDirection(event) {
1458
+ return typeof event.fromMe === "boolean";
1459
+ }
1460
+ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, options) {
1461
+ options.lifecycleSignal?.throwIfAborted();
1462
+ const client = resolvedDeps.createClient();
1463
+ const agentName = resolvedDeps.getAgentName();
1464
+ (0, runtime_cwd_1.recoverRuntimeCwd)();
1261
1465
  let activeTurnId = null;
1262
- if (event.kind === "message" && shouldUseBlueBubblesRecoveryBudget(source, options)) {
1466
+ if (shouldUseBlueBubblesRecoveryBudget(source, options)) {
1263
1467
  const decision = (0, autonomy_budget_1.reserveAutonomyBudget)((0, identity_1.getAgentRoot)(agentName), {
1264
1468
  agent: agentName,
1265
1469
  triggerType: "recovery",
@@ -1275,66 +1479,100 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1275
1479
  }
1276
1480
  }
1277
1481
  try {
1278
- // ── Adapter setup: friend, session, content, callbacks ──────────
1279
- const store = resolvedDeps.createFriendStore();
1280
- const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
1281
- const baseContext = await resolver.resolve();
1282
- const context = { ...baseContext, isGroupChat: event.chat.isGroup };
1283
- const replyTarget = createReplyTargetController(event);
1284
- const friendId = context.friend.id;
1285
- const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
1286
- try {
1287
- (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
1288
- }
1289
- catch (error) {
1290
- (0, runtime_1.emitNervesEvent)({
1291
- level: "warn",
1292
- component: "senses",
1293
- event: "senses.bluebubbles_thread_lane_cleanup_error",
1294
- message: "failed to inspect obsolete bluebubbles thread-lane sessions",
1295
- meta: {
1296
- sessionPath: sessPath,
1297
- reason: error instanceof Error ? error.message : String(error),
1298
- },
1299
- });
1300
- }
1301
- return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
1302
- if (event.kind === "message" && activeTurnId === null) {
1303
- activeTurnId = (0, active_turns_1.beginBlueBubblesActiveTurn)(agentName, event);
1482
+ // Reserve the canonical chat lane before any asynchronous adapter setup.
1483
+ // Every admitted inbound is projected in observation order even when a
1484
+ // newer turn supersedes its inference before provider work begins.
1485
+ return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", `${agentName}:${event.chat.sessionKey}`, async () => {
1486
+ options.lifecycleSignal?.throwIfAborted();
1487
+ // Fallback self-detection: BlueBubbles sometimes broadcasts a group-chat
1488
+ // outbound message back through the webhook with `isFromMe` missing/false.
1489
+ // Keep this group-only: stale ownHandles entries must not hide real DMs.
1490
+ if (await shouldFilterAgentSelfHandle(event, resolvedDeps)) {
1491
+ (0, runtime_1.emitNervesEvent)({
1492
+ level: "warn",
1493
+ component: "senses",
1494
+ event: "senses.bluebubbles_self_handle_filtered",
1495
+ message: "filtered bluebubbles event whose sender matched an agent-owned handle (isFromMe was missing/false)",
1496
+ meta: {
1497
+ messageGuid: event.messageGuid,
1498
+ kind: event.kind,
1499
+ senderExternalId: event.sender.externalId,
1500
+ },
1501
+ });
1502
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1304
1503
  }
1305
- // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
1504
+ options.lifecycleSignal?.throwIfAborted();
1505
+ // ── Adapter setup: friend, session, content, callbacks ────────
1506
+ const store = resolvedDeps.createFriendStore();
1507
+ const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
1508
+ const baseContext = await resolver.resolve();
1509
+ options.lifecycleSignal?.throwIfAborted();
1510
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
1511
+ const replyTarget = createReplyTargetController(event);
1512
+ const friendId = context.friend.id;
1513
+ const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
1514
+ try {
1515
+ (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
1516
+ }
1517
+ catch (error) {
1518
+ (0, runtime_1.emitNervesEvent)({
1519
+ level: "warn",
1520
+ component: "senses",
1521
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
1522
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
1523
+ meta: {
1524
+ sessionPath: sessPath,
1525
+ reason: error instanceof Error ? error.message : String(error),
1526
+ },
1527
+ });
1528
+ }
1529
+ // Pre-load session inside the canonical turn lock so same-chat
1530
+ // deliveries cannot race on stale trunk state.
1306
1531
  const existing = resolvedDeps.loadSession(sessPath);
1307
- const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
1308
1532
  const sessionMessages = existing?.messages && existing.messages.length > 0
1309
- ? existing.messages
1533
+ ? structuredClone(existing.messages)
1310
1534
  : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await resolvedDeps.buildSystem("bluebubbles", {}, context)) }];
1311
- if (event.kind === "message") {
1312
- // Record EARLY for audit and crash recovery. This is capture truth, not
1313
- // a claim that the agent turn completed successfully.
1314
- const inboundSource = (options.semanticRecovery || source !== "webhook")
1315
- && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
1316
- ? "recovery-bootstrap"
1317
- : source;
1318
- (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
1319
- if (inboundSource === "recovery-bootstrap") {
1320
- (0, runtime_1.emitNervesEvent)({
1321
- component: "senses",
1322
- event: "senses.bluebubbles_recovery_skip",
1323
- message: "skipped bluebubbles recovery because the session already contains the message text",
1324
- meta: {
1325
- messageGuid: event.messageGuid,
1326
- sessionKey: event.chat.sessionKey,
1327
- source,
1328
- },
1329
- });
1330
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
1331
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
1332
- }
1535
+ // Record EARLY for audit and crash recovery. This is capture truth, not
1536
+ // a claim that the agent turn completed successfully.
1537
+ const inboundSource = (options.semanticRecovery || source !== "webhook")
1538
+ && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
1539
+ ? "recovery-bootstrap"
1540
+ : source;
1541
+ (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
1542
+ if (inboundSource === "recovery-bootstrap") {
1543
+ (0, runtime_1.emitNervesEvent)({
1544
+ component: "senses",
1545
+ event: "senses.bluebubbles_recovery_skip",
1546
+ message: "skipped bluebubbles recovery because the session already contains the message text",
1547
+ meta: {
1548
+ messageGuid: event.messageGuid,
1549
+ sessionKey: event.chat.sessionKey,
1550
+ source,
1551
+ },
1552
+ });
1553
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
1554
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
1333
1555
  }
1334
- if (event.kind === "message" && event.chat.isGroup) {
1556
+ // The inbound event is canonical truth as soon as its canonical lane is
1557
+ // reserved. Persist it before any supersedable orientation/provider work.
1558
+ const priorMessages = sessionMessages;
1559
+ const userMessage = {
1560
+ role: "user",
1561
+ content: buildInboundContent(event),
1562
+ };
1563
+ resolvedDeps.saveSession(sessPath, [...structuredClone(sessionMessages), structuredClone(userMessage)], existing?.lastUsage, existing?.state);
1564
+ if (!await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1565
+ || !(0, latest_turn_1.isCurrent)(options.latestTurnCapability)) {
1566
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "superseded" };
1567
+ }
1568
+ options.lifecycleSignal?.throwIfAborted();
1569
+ const liveTurnId = (0, active_turns_1.beginBlueBubblesActiveTurn)(agentName, event);
1570
+ activeTurnId = liveTurnId;
1571
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
1572
+ if (event.chat.isGroup) {
1335
1573
  await (0, friends_1.upsertGroupContextParticipants)({
1336
1574
  store,
1337
- participants: (event.chat.participantHandles ?? []).map((externalId) => ({
1575
+ participants: event.chat.participantHandles.map((externalId) => ({
1338
1576
  provider: "imessage-handle",
1339
1577
  externalId,
1340
1578
  })),
@@ -1342,10 +1580,10 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1342
1580
  });
1343
1581
  }
1344
1582
  // Fetch the text of the message being replied to (if this is a threaded reply)
1345
- const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
1583
+ const threadGuid = event.threadOriginatorGuid?.trim();
1346
1584
  let repliedToText = null;
1347
1585
  if (threadGuid) {
1348
- repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
1586
+ repliedToText = await client.getMessageText(threadGuid).catch(() => null);
1349
1587
  (0, runtime_1.emitNervesEvent)({
1350
1588
  component: "senses",
1351
1589
  event: "senses.bluebubbles_reply_context",
@@ -1353,12 +1591,9 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1353
1591
  meta: { threadGuid, hasText: !!repliedToText },
1354
1592
  });
1355
1593
  }
1356
- // Build inbound user message as user-visible speech only; source/routing facts live in the orientation frame.
1357
- const priorMessages = existing?.messages ?? sessionMessages;
1358
- const userMessage = {
1359
- role: "user",
1360
- content: buildInboundContent(event),
1361
- };
1594
+ // Shutdown can land during MCP/group/reply-context awaits above. Recheck
1595
+ // before the remaining synchronous setup installs its lifecycle listener.
1596
+ options.lifecycleSignal?.throwIfAborted();
1362
1597
  const orientationFrame = (0, orientation_frame_1.buildOrientationFrame)({
1363
1598
  channel: "bluebubbles",
1364
1599
  messages: [...priorMessages, userMessage],
@@ -1366,26 +1601,76 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1366
1601
  structuredOutputs: existing?.structuredOutputs ?? [],
1367
1602
  source: buildBlueBubblesOrientationSource(event, priorMessages, options.orientationEvidence, options.orientationConversationKind, repliedToText),
1368
1603
  });
1369
- const visibleActivityTurnId = activeTurnId;
1370
- const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, visibleActivityTurnId
1371
- ? () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, visibleActivityTurnId)
1372
- : undefined);
1604
+ if (!(0, latest_turn_1.isCurrent)(options.latestTurnCapability)) {
1605
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "superseded" };
1606
+ }
1607
+ const turnStage = createBlueBubblesTurnStage(event.chat.sessionKey);
1608
+ let finalTransportInvoked = false;
1609
+ let finalTransportAccepted = false;
1610
+ const outboundRuntimeConfig = (0, config_1.getBlueBubblesConfig)();
1611
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId), {
1612
+ admitOutbound: async () => (!options.lifecycleSignal?.aborted
1613
+ && await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1614
+ && !options.lifecycleSignal?.aborted),
1615
+ isOutboundCurrent: () => (!options.lifecycleSignal?.aborted
1616
+ && (0, latest_turn_1.isCurrent)(options.latestTurnCapability)),
1617
+ isOutboundAdmittedNow: () => (!options.lifecycleSignal?.aborted
1618
+ && (0, latest_turn_1.isDeliveryAdmittedNow)(options.latestTurnCapability)),
1619
+ onFinalTransportInvoked: () => {
1620
+ finalTransportInvoked = true;
1621
+ },
1622
+ onFinalTransportAccepted: () => {
1623
+ finalTransportAccepted = true;
1624
+ },
1625
+ durableOutbound: {
1626
+ agentRoot: (0, identity_1.getAgentRoot)(),
1627
+ idempotencyKey: blueBubblesInboundReplyIdempotencyKey(options.currentIngressEvidence.captureKeyHash),
1628
+ attachment: {
1629
+ serverUrl: outboundRuntimeConfig.serverUrl,
1630
+ accountId: outboundRuntimeConfig.accountId,
1631
+ },
1632
+ },
1633
+ });
1373
1634
  const controller = new AbortController();
1635
+ const lifecycleSignal = options.lifecycleSignal;
1636
+ let rejectLifecycle;
1637
+ const lifecyclePromise = new Promise((_resolve, reject) => {
1638
+ rejectLifecycle = reject;
1639
+ });
1640
+ let resolveSupersession;
1641
+ const supersessionPromise = new Promise((resolve) => {
1642
+ resolveSupersession = resolve;
1643
+ });
1644
+ const cancelForSupersession = () => {
1645
+ callbacks.cancelOutbound("superseded");
1646
+ if (!finalTransportInvoked) {
1647
+ controller.abort(options.latestTurnCapability.signal.reason);
1648
+ resolveSupersession({
1649
+ handled: true,
1650
+ notifiedAgent: false,
1651
+ kind: event.kind,
1652
+ reason: "superseded",
1653
+ });
1654
+ }
1655
+ };
1656
+ options.latestTurnCapability.signal.addEventListener("abort", cancelForSupersession, { once: true });
1657
+ const cancelForShutdown = () => {
1658
+ callbacks.cancelOutbound("shutdown");
1659
+ if (!finalTransportInvoked) {
1660
+ const reason = lifecycleSignal.reason;
1661
+ controller.abort(reason);
1662
+ rejectLifecycle(reason);
1663
+ }
1664
+ };
1665
+ lifecycleSignal?.addEventListener("abort", cancelForShutdown, { once: true });
1374
1666
  let timeoutTimer;
1375
1667
  let timeoutPromise;
1376
- let timeoutReject;
1668
+ let resolveTimeout;
1377
1669
  let recoveryTimedOut = false;
1378
- let timeoutReason = null;
1379
1670
  // BB-specific tool context wrappers
1380
1671
  const summarize = (0, core_1.createSummarize)("human");
1381
1672
  const bbCapabilities = (0, friends_1.getChannelCapabilities)("bluebubbles");
1382
1673
  const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
1383
- // ── Compute trust gate context for group/acquaintance rules ─────
1384
- const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
1385
- const hasExistingGroupWithFamily = event.chat.isGroup
1386
- ? false
1387
- : await checkHasExistingGroupWithFamily(store, context.friend);
1388
- // ── Call shared pipeline ──────────────────────────────────────────
1389
1674
  // Buffer terminal errors so failover can suppress them.
1390
1675
  // If failover produces a message, the buffered error is skipped.
1391
1676
  // If failover doesn't fire, the buffered error is replayed.
@@ -1403,17 +1688,50 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1403
1688
  };
1404
1689
  /* v8 ignore stop */
1405
1690
  try {
1691
+ // ── Compute trust gate context for group/acquaintance rules ─────
1692
+ // These store reads can block on filesystem I/O. Race them against the
1693
+ // runtime lifecycle before constructing the shared pipeline so a closed
1694
+ // worker cannot begin new durable or outward work after the read settles.
1695
+ const trustContext = await Promise.race([
1696
+ Promise.all([
1697
+ checkGroupHasFamilyMember(store, event),
1698
+ event.chat.isGroup
1699
+ ? Promise.resolve(false)
1700
+ : checkHasExistingGroupWithFamily(store, context.friend),
1701
+ ]),
1702
+ lifecyclePromise,
1703
+ supersessionPromise.then(() => null),
1704
+ ]);
1705
+ if (trustContext === null) {
1706
+ return {
1707
+ handled: true,
1708
+ notifiedAgent: false,
1709
+ kind: event.kind,
1710
+ reason: "superseded",
1711
+ };
1712
+ }
1713
+ const [groupHasFamilyMember, hasExistingGroupWithFamily] = trustContext;
1714
+ lifecycleSignal?.throwIfAborted();
1715
+ // ── Call shared pipeline ────────────────────────────────────────
1406
1716
  const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS;
1407
- timeoutPromise = new Promise((_, reject) => {
1408
- timeoutReject = reject;
1717
+ timeoutPromise = new Promise((resolve) => {
1718
+ resolveTimeout = resolve;
1409
1719
  });
1410
1720
  timeoutTimer = setTimeout(() => {
1411
1721
  const reason = new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
1412
- timeoutReason = reason;
1413
1722
  recoveryTimedOut = true;
1723
+ if (!finalTransportInvoked) {
1724
+ resolveTimeout?.({
1725
+ handled: true,
1726
+ notifiedAgent: false,
1727
+ kind: event.kind,
1728
+ reason: "turn_timeout",
1729
+ });
1730
+ }
1414
1731
  callbacks.cancelOutbound("turn_timeout");
1415
- controller.abort(reason);
1416
- timeoutReject?.(reason);
1732
+ (0, latest_turn_1.cancel)(options.latestTurnCapability, "turn_timeout");
1733
+ if (!finalTransportInvoked)
1734
+ controller.abort(reason);
1417
1735
  (0, runtime_1.emitNervesEvent)({
1418
1736
  level: "warn",
1419
1737
  component: "senses",
@@ -1426,6 +1744,18 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1426
1744
  timeoutMs,
1427
1745
  },
1428
1746
  });
1747
+ (0, runtime_1.emitNervesEvent)({
1748
+ level: "warn",
1749
+ component: "senses",
1750
+ event: "senses.bluebubbles_timeout_notice_suppressed",
1751
+ message: "bluebubbles timeout notice suppressed from iMessage",
1752
+ meta: {
1753
+ messageGuid: event.messageGuid,
1754
+ sessionKey: event.chat.sessionKey,
1755
+ source,
1756
+ timeoutMs,
1757
+ },
1758
+ });
1429
1759
  }, timeoutMs);
1430
1760
  /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
1431
1761
  if (typeof timeoutTimer.unref === "function") {
@@ -1461,7 +1791,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1461
1791
  enforceTrustGate: trust_gate_1.enforceTrustGate,
1462
1792
  drainPending: pending_1.drainPending,
1463
1793
  drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
1464
- prepareRunAgentOptions: async ({ messages, runAgentOptions }) => {
1794
+ prepareRunAgentOptions: async ({ messages = [], currentUserMessages = [], runAgentOptions }) => {
1465
1795
  preparedBlueBubblesContext = await buildBlueBubblesContextMessages({
1466
1796
  agentName,
1467
1797
  agentRoot: (0, identity_1.getAgentRoot)(),
@@ -1469,41 +1799,75 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1469
1799
  event,
1470
1800
  knownMessages: messages,
1471
1801
  });
1472
- if (preparedBlueBubblesContext.contextPacketIds.length === 0)
1802
+ const currentUserMessage = currentUserMessages.findLast((message) => message.role === "user")
1803
+ ?? messages.findLast((message) => message.role === "user");
1804
+ if (!currentUserMessage)
1473
1805
  return undefined;
1806
+ Object.freeze(currentUserMessage);
1807
+ const { verifiedPredecessorMessage } = preparedBlueBubblesContext;
1474
1808
  return {
1475
- contextPacketIds: [
1476
- ...(runAgentOptions.contextPacketIds ?? []),
1477
- ...preparedBlueBubblesContext.contextPacketIds,
1478
- ],
1809
+ ...(preparedBlueBubblesContext.contextPacketIds.length > 0 ? {
1810
+ contextPacketIds: [
1811
+ ...(runAgentOptions.contextPacketIds ?? []),
1812
+ ...preparedBlueBubblesContext.contextPacketIds,
1813
+ ],
1814
+ } : {}),
1815
+ ...(preparedBlueBubblesContext.messages.length > 0 ? {
1816
+ promptOnlyEvidenceMessages: preparedBlueBubblesContext.messages,
1817
+ } : {}),
1818
+ requiredPromptEvidence: {
1819
+ currentUserMessage,
1820
+ ...(verifiedPredecessorMessage ? { verifiedPredecessorMessage } : {}),
1821
+ },
1479
1822
  };
1480
1823
  },
1481
- runAgent: async (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(insertEphemeralContextMessages(msgs, preparedBlueBubblesContext.messages), cb, channel, sig, {
1482
- ...opts,
1483
- toolContext: {
1484
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1485
- signin: async () => undefined,
1486
- ...opts?.toolContext,
1487
- summarize,
1488
- bluebubblesReplyTarget: {
1489
- setSelection: (selection) => replyTarget.setSelection(selection),
1824
+ runAgent: async (msgs, cb, channel, sig, opts) => {
1825
+ const { codingFeedback: _omittedCodingFeedback, ...safeToolContext } = opts?.toolContext ?? {};
1826
+ const requiredCurrent = opts?.requiredPromptEvidence?.currentUserMessage;
1827
+ const providerMessages = insertEphemeralContextMessages(msgs.map((message) => message === requiredCurrent ? message : structuredClone(message)), [
1828
+ ...preparedBlueBubblesContext.messages,
1829
+ ...(preparedBlueBubblesContext.verifiedPredecessorMessage
1830
+ ? [preparedBlueBubblesContext.verifiedPredecessorMessage]
1831
+ : []),
1832
+ ], requiredCurrent);
1833
+ let generatedMessages = [];
1834
+ const agentResult = await resolvedDeps.runAgent(providerMessages, cb, channel, sig, {
1835
+ ...opts,
1836
+ captureGeneratedMessages: (generated) => {
1837
+ opts?.captureGeneratedMessages?.(generated);
1838
+ generatedMessages = structuredClone(generated);
1490
1839
  },
1491
- codingFeedback: {
1492
- send: async (message) => {
1493
- await client.sendText({
1494
- chat: event.chat,
1495
- text: message,
1496
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
1497
- });
1840
+ toolContext: {
1841
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1842
+ signin: async () => undefined,
1843
+ ...safeToolContext,
1844
+ summarize,
1845
+ bluebubblesReplyTarget: {
1846
+ setSelection: (selection) => replyTarget.setSelection(selection),
1498
1847
  },
1499
1848
  },
1500
- },
1501
- }),
1849
+ });
1850
+ if (generatedMessages.length > 0) {
1851
+ msgs.push(...generatedMessages);
1852
+ }
1853
+ return agentResult;
1854
+ },
1502
1855
  postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
1503
1856
  const prepared = resolvedDeps.postTurnTrim(turnMessages, usage, hooks);
1504
- resolvedDeps.deferPostTurnPersist(sessionPathArg, prepared, usage, state);
1857
+ turnStage.postTurn = {
1858
+ sessionPath: sessionPathArg,
1859
+ prepared,
1860
+ usage,
1861
+ state,
1862
+ };
1863
+ },
1864
+ accumulateFriendTokens: async (tokenStore, tokenFriendId, usage) => {
1865
+ turnStage.tokens = {
1866
+ store: tokenStore,
1867
+ friendId: tokenFriendId,
1868
+ usage,
1869
+ };
1505
1870
  },
1506
- accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
1507
1871
  signal: controller.signal,
1508
1872
  runAgentOptions: {
1509
1873
  mcpManager,
@@ -1515,12 +1879,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1515
1879
  },
1516
1880
  },
1517
1881
  callbacks: failoverAwareCallbacks,
1518
- failoverState: (() => {
1519
- if (!bbFailoverStates.has(event.chat.sessionKey)) {
1520
- bbFailoverStates.set(event.chat.sessionKey, { pending: null });
1521
- }
1522
- return bbFailoverStates.get(event.chat.sessionKey);
1523
- })(),
1882
+ failoverState: turnStage.failoverState,
1524
1883
  });
1525
1884
  /* v8 ignore start -- detached late-rejection telemetry is asserted in timeout tests, but V8 does not reliably attribute Promise.catch callbacks @preserve */
1526
1885
  void turnPromise
@@ -1543,43 +1902,108 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1543
1902
  /* v8 ignore stop */
1544
1903
  const runTurn = (async () => {
1545
1904
  const result = await turnPromise;
1546
- if (recoveryTimedOut)
1547
- throw getBlueBubblesTimeoutReason(timeoutReason, timeoutMs);
1905
+ if (recoveryTimedOut && !finalTransportInvoked) {
1906
+ (0, runtime_1.emitNervesEvent)({
1907
+ level: "warn",
1908
+ component: "senses",
1909
+ event: "senses.bluebubbles_timed_out_turn_suppressed",
1910
+ message: "suppressed late bluebubbles turn result after timeout",
1911
+ meta: {
1912
+ messageGuid: event.messageGuid,
1913
+ sessionKey: event.chat.sessionKey,
1914
+ source,
1915
+ reason: "turn completed after its final-transport boundary closed",
1916
+ },
1917
+ });
1918
+ return {
1919
+ handled: true,
1920
+ notifiedAgent: false,
1921
+ kind: event.kind,
1922
+ reason: "turn_timeout",
1923
+ };
1924
+ }
1548
1925
  /* v8 ignore start -- failover display + error replay @preserve */
1549
1926
  if (result.failoverMessage) {
1550
- // Failover handled it — show the failover message, skip the buffered error
1551
- await client.sendText({ chat: event.chat, text: result.failoverMessage });
1927
+ callbacks.onClearText?.();
1928
+ callbacks.onTextChunk(result.failoverMessage);
1929
+ bufferedTerminalError = null;
1552
1930
  }
1553
1931
  else if (bufferedTerminalError) {
1554
- // No failover — replay the buffered terminal error
1555
1932
  callbacks.onError(bufferedTerminalError, "terminal");
1933
+ bufferedTerminalError = null;
1556
1934
  }
1557
1935
  /* v8 ignore stop */
1558
1936
  // ── Handle gate result ────────────────────────────────────────
1937
+ let processedOutcome = "turn-complete";
1559
1938
  if (!result.gateResult.allowed) {
1560
- // Send auto-reply via BB API if the gate provides one
1939
+ callbacks.onClearText?.();
1561
1940
  if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1562
- await client.sendText({
1563
- chat: event.chat,
1564
- text: result.gateResult.autoReply,
1565
- });
1566
- }
1567
- if (event.kind === "message") {
1568
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "trust-gated");
1941
+ callbacks.onTextChunk(result.gateResult.autoReply);
1569
1942
  }
1943
+ processedOutcome = "trust-gated";
1944
+ }
1945
+ let delivery;
1946
+ try {
1947
+ delivery = await callbacks.flush({ signal: controller.signal });
1948
+ }
1949
+ catch (error) {
1950
+ (0, runtime_1.emitNervesEvent)({
1951
+ level: "warn",
1952
+ component: "senses",
1953
+ event: "senses.bluebubbles_final_transport_failed",
1954
+ message: "bluebubbles final transport failed after invocation",
1955
+ meta: {
1956
+ messageGuid: event.messageGuid,
1957
+ sessionKey: event.chat.sessionKey,
1958
+ reason: error instanceof Error ? error.message : String(error),
1959
+ },
1960
+ });
1961
+ return {
1962
+ handled: true,
1963
+ notifiedAgent: false,
1964
+ kind: event.kind,
1965
+ reason: "delivery_failed",
1966
+ };
1967
+ }
1968
+ if (delivery.status !== "accepted" || !finalTransportAccepted) {
1570
1969
  return {
1571
1970
  handled: true,
1572
1971
  notifiedAgent: false,
1573
1972
  kind: event.kind,
1973
+ reason: !(0, latest_turn_1.isCurrent)(options.latestTurnCapability)
1974
+ ? "superseded"
1975
+ : "no_visible_reply",
1574
1976
  };
1575
1977
  }
1576
- // Gate allowed — flush the agent's reply
1577
- await callbacks.flush();
1578
- if (recoveryTimedOut)
1579
- throw getBlueBubblesTimeoutReason(timeoutReason, timeoutMs);
1580
- if (event.kind === "message") {
1581
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "turn-complete");
1978
+ // Acceptance is the visibility boundary. Publish the terminal dedupe
1979
+ // receipt first, then project the private generated tail while this
1980
+ // chat's canonical lock remains held. Even if receipt publication
1981
+ // fails, make the best possible canonical projection before surfacing
1982
+ // the retained-claim failure. Ancillary accounting follows both.
1983
+ let receiptFailed = false;
1984
+ let receiptFailure;
1985
+ try {
1986
+ options.publishAcceptedReceipt();
1987
+ }
1988
+ catch (error) {
1989
+ receiptFailed = true;
1990
+ receiptFailure = error;
1582
1991
  }
1992
+ persistAcceptedBlueBubblesProjection({
1993
+ stage: turnStage,
1994
+ deps: resolvedDeps,
1995
+ event,
1996
+ });
1997
+ if (receiptFailed)
1998
+ throw receiptFailure;
1999
+ promoteAcceptedBlueBubblesAncillaryState({
2000
+ stage: turnStage,
2001
+ deps: resolvedDeps,
2002
+ agentName,
2003
+ event,
2004
+ source,
2005
+ processedOutcome,
2006
+ });
1583
2007
  (0, runtime_1.emitNervesEvent)({
1584
2008
  component: "senses",
1585
2009
  event: "senses.bluebubbles_turn_end",
@@ -1614,28 +2038,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1614
2038
  });
1615
2039
  });
1616
2040
  /* v8 ignore stop */
1617
- return await (async () => {
1618
- try {
1619
- return await Promise.race([runTurn, timeoutPromise]);
1620
- }
1621
- catch (error) {
1622
- if (isBlueBubblesRecoveryTurnTimeout(error)) {
1623
- (0, runtime_1.emitNervesEvent)({
1624
- level: "warn",
1625
- component: "senses",
1626
- event: "senses.bluebubbles_timeout_notice_suppressed",
1627
- message: "bluebubbles timeout notice suppressed from iMessage",
1628
- meta: {
1629
- messageGuid: event.messageGuid,
1630
- sessionKey: event.chat.sessionKey,
1631
- source,
1632
- timeoutMs,
1633
- },
1634
- });
1635
- }
1636
- throw error;
1637
- }
1638
- })();
2041
+ return await Promise.race([runTurn, timeoutPromise, supersessionPromise, lifecyclePromise]);
1639
2042
  }
1640
2043
  finally {
1641
2044
  // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
@@ -1647,10 +2050,10 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1647
2050
  }
1648
2051
  /* v8 ignore stop */
1649
2052
  clearTimeout(timeoutTimer);
1650
- if (activeTurnId) {
1651
- (0, active_turns_1.finishBlueBubblesActiveTurn)(agentName, activeTurnId);
1652
- activeTurnId = null;
1653
- }
2053
+ options.latestTurnCapability.signal.removeEventListener("abort", cancelForSupersession);
2054
+ lifecycleSignal?.removeEventListener("abort", cancelForShutdown);
2055
+ (0, active_turns_1.finishBlueBubblesActiveTurn)(agentName, liveTurnId);
2056
+ activeTurnId = null;
1654
2057
  await callbacks.finish({ timeoutMs: BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS });
1655
2058
  }
1656
2059
  });
@@ -1683,11 +2086,9 @@ function recordBlueBubblesAuditSidecar(agentName, event, source, resolvedDeps) {
1683
2086
  });
1684
2087
  }
1685
2088
  }
1686
- function reactionTargetAuthorship(target) {
1687
- return target.fromMe === true ? "agent" : "non_agent_unknown";
1688
- }
1689
- async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source) {
2089
+ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source, observationReservation) {
1690
2090
  const agentName = resolvedDeps.getAgentName();
2091
+ const capturedAt = new Date().toISOString();
1691
2092
  if (!hasObservedBlueBubblesDirection(normalized)) {
1692
2093
  (0, runtime_1.emitNervesEvent)({
1693
2094
  level: "warn",
@@ -1705,14 +2106,6 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1705
2106
  }
1706
2107
  const observed = normalized;
1707
2108
  const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
1708
- let resolvedReactionTarget;
1709
- let targetAuthorship = null;
1710
- if (observed.kind === "mutation"
1711
- && observed.mutationType === "reaction"
1712
- && observed.targetMessageGuid) {
1713
- resolvedReactionTarget = await resolveReactionTarget(resolvedDeps.createClient(), observed.targetMessageGuid);
1714
- targetAuthorship = reactionTargetAuthorship(resolvedReactionTarget);
1715
- }
1716
2109
  let coordinateGeneration;
1717
2110
  if (observed.kind === "mutation"
1718
2111
  && observed.mutationType === "reaction"
@@ -1741,9 +2134,11 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1741
2134
  }
1742
2135
  const capture = (0, semantic_receipts_1.buildBlueBubblesSemanticCapture)({
1743
2136
  cutover,
1744
- capturedAt: new Date().toISOString(),
2137
+ capturedAt,
2138
+ observationEpoch: observationReservation.observationEpoch,
2139
+ observationOrdinal: observationReservation.ordinal,
1745
2140
  event: observed,
1746
- targetAuthorship,
2141
+ targetAuthorship: null,
1747
2142
  coordinateGeneration,
1748
2143
  });
1749
2144
  if (!capture) {
@@ -1761,7 +2156,6 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1761
2156
  capture,
1762
2157
  normalized: observed,
1763
2158
  writeResult,
1764
- ...(resolvedReactionTarget ? { resolvedReactionTarget } : {}),
1765
2159
  };
1766
2160
  }
1767
2161
  function semanticCaptureChat(capture) {
@@ -1792,11 +2186,58 @@ function semanticCaptureChat(capture) {
1792
2186
  participantHandles: event.participants.map((participant) => participant.externalId),
1793
2187
  };
1794
2188
  }
1795
- function semanticCaptureRequiresRoutingRepair(capture) {
1796
- return !capture.event.chatGuid?.trim() && !capture.event.chatIdentifier?.trim();
2189
+ function semanticCaptureRequiresRoutingRepair(capture) {
2190
+ return !capture.event.chatGuid?.trim() && !capture.event.chatIdentifier?.trim();
2191
+ }
2192
+ function hasResolvedBlueBubblesRouting(event) {
2193
+ return Boolean(event.chat.chatGuid?.trim() || event.chat.chatIdentifier?.trim());
2194
+ }
2195
+ function reserveBlueBubblesObservation(event) {
2196
+ return (0, latest_turn_1.reserveObservation)({
2197
+ chatGuid: event.chat.chatGuid,
2198
+ chatIdentifier: event.chat.chatIdentifier,
2199
+ });
2200
+ }
2201
+ function groupBlueBubblesObservationsByLane(candidates) {
2202
+ let groups = [];
2203
+ for (const candidate of candidates) {
2204
+ const keys = new Set((0, latest_turn_1.observationSchedulingKeys)(candidate.observationReservation));
2205
+ const destination = { keys: new Set(keys), candidates: [candidate] };
2206
+ const independentGroups = [];
2207
+ for (const group of groups) {
2208
+ const intersects = (keys.has("unresolved:*")
2209
+ || group.keys.has("unresolved:*")
2210
+ || [...keys].some((key) => group.keys.has(key)));
2211
+ if (!intersects) {
2212
+ independentGroups.push(group);
2213
+ continue;
2214
+ }
2215
+ destination.candidates.push(...group.candidates);
2216
+ for (const key of group.keys)
2217
+ destination.keys.add(key);
2218
+ }
2219
+ groups = [...independentGroups, destination];
2220
+ }
2221
+ return groups.map((group) => group.candidates.sort((left, right) => (right.observationReservation.ordinal - left.observationReservation.ordinal)));
2222
+ }
2223
+ function canonicalizeBlueBubblesEvent(event, capability) {
2224
+ const canonical = capability.canonicalChat;
2225
+ const { chatIdentifier: _observedIdentifier, ...observedChat } = event.chat;
2226
+ return {
2227
+ ...event,
2228
+ chat: {
2229
+ ...observedChat,
2230
+ chatGuid: canonical.chatGuid,
2231
+ ...(canonical.chatIdentifier ? { chatIdentifier: canonical.chatIdentifier } : {}),
2232
+ sessionKey: canonical.sessionKey,
2233
+ sendTarget: { kind: "chat_guid", value: canonical.chatGuid },
2234
+ },
2235
+ };
1797
2236
  }
1798
- function hasResolvedBlueBubblesRouting(event) {
1799
- return Boolean(event.chat.chatGuid?.trim() || event.chat.chatIdentifier?.trim());
2237
+ function isCaptureOnlyBlueBubblesMutation(capture) {
2238
+ return capture.event.kind === "reaction"
2239
+ || capture.event.kind === "edit"
2240
+ || capture.event.kind === "unsend";
1800
2241
  }
1801
2242
  function isAuditOnlySemanticCapture(capture) {
1802
2243
  return capture.event.kind === "read" || capture.event.kind === "delivery";
@@ -1872,43 +2313,30 @@ function semanticCaptureToNormalizedEvent(capture) {
1872
2313
  : {}),
1873
2314
  };
1874
2315
  }
1875
- function semanticHandledOutcome(event, result) {
1876
- if (result.reason === "from_me")
1877
- return "ignored_self";
1878
- if (event.kind === "message") {
1879
- return result.notifiedAgent ? "message_completed" : "message_observed";
1880
- }
1881
- switch (event.mutationType) {
1882
- case "edit": return "edit_capture_only";
1883
- case "unsend": return "unsend_capture_only";
1884
- case "read": return "read_audit_only";
1885
- case "delivery": return "delivery_audit_only";
1886
- case "reaction":
1887
- if (result.reason === "restricted_feedback_settled")
1888
- return "restricted_feedback_settled";
1889
- if (result.reason === "restricted_feedback_observed")
1890
- return "restricted_feedback_observed";
1891
- return "restricted_feedback_failed";
1892
- }
1893
- }
1894
- function materializeRestrictedBlueBubblesReactionEvent(captured, repaired) {
1895
- // Classification reached `restricted_feedback` from the validated v1
1896
- // capture's reaction tuple. Direct capture and recovery both construct the
1897
- // paired normalized event from that same tuple, so it is the authoritative
1898
- // reaction object; repair contributes routing only and may legitimately
1899
- // promote its own transport-shaped result to `message`.
1900
- const event = captured.normalized;
1901
- return {
1902
- ...event,
1903
- mutationType: "reaction",
1904
- reaction: event.reaction,
1905
- targetMessageGuid: event.targetMessageGuid,
1906
- chat: repaired.chat,
1907
- requiresRepair: repaired.requiresRepair,
1908
- repairNotice: repaired.repairNotice,
1909
- };
2316
+ function seedBlueBubblesStartupSemanticObservations(deps = {}) {
2317
+ const resolvedDeps = { ...defaultDeps, ...deps };
2318
+ const agentName = resolvedDeps.getAgentName();
2319
+ const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
2320
+ let seeded = 0;
2321
+ for (const capture of (0, semantic_receipts_1.listPendingBlueBubblesSemanticCaptures)(agentName)
2322
+ .sort(semantic_receipts_1.compareBlueBubblesSemanticCaptureOrder)) {
2323
+ const disposition = (0, semantic_receipts_1.classifyBlueBubblesRecoveryRecord)(capture, cutover);
2324
+ if (disposition.disposition === "audit_only")
2325
+ continue;
2326
+ const normalized = semanticCaptureToNormalizedEvent(capture);
2327
+ if (!normalized)
2328
+ continue;
2329
+ const generation = adoptBlueBubblesSemanticObservation(capture.keyHash, reserveBlueBubblesObservation(normalized), false);
2330
+ // Startup seeding runs synchronously before listen. Retain each fence so
2331
+ // persisted pre-restart observations remain causally older than any live
2332
+ // webhook accepted by this process and can reuse the same generation when
2333
+ // the delayed recovery pass begins.
2334
+ suspendBlueBubblesSemanticObservation(generation);
2335
+ seeded += 1;
2336
+ }
2337
+ return seeded;
1910
2338
  }
1911
- async function classifyCapturedBlueBubblesReaction(captured, resolvedDeps) {
2339
+ async function classifyCapturedBlueBubblesReaction(captured) {
1912
2340
  const event = captured.capture.event;
1913
2341
  if (event.kind !== "reaction" || !event.canonicalAction || !event.canonicalValue)
1914
2342
  return null;
@@ -1916,151 +2344,78 @@ async function classifyCapturedBlueBubblesReaction(captured, resolvedDeps) {
1916
2344
  fromMe: event.fromMe,
1917
2345
  action: event.canonicalAction,
1918
2346
  canonicalValue: event.canonicalValue,
1919
- targetAuthorship: event.targetAuthorship,
1920
- };
1921
- const initial = (0, reaction_policy_1.classifyBlueBubblesReaction)(input);
1922
- if (initial.route !== "trust_required") {
1923
- return { decision: initial, trustedFriend: null };
1924
- }
1925
- let trustedActor = false;
1926
- let trustedFriend = null;
1927
- try {
1928
- const friend = await resolvedDeps.createFriendStore().findByExternalId("imessage-handle", event.actor.externalId);
1929
- trustedActor = friends_1.TRUSTED_LEVELS.has(friend?.trustLevel ?? "stranger");
1930
- if (trustedActor && friend)
1931
- trustedFriend = friend;
1932
- }
1933
- catch (error) {
1934
- (0, runtime_1.emitNervesEvent)({
1935
- level: "warn",
1936
- component: "senses",
1937
- event: "senses.bluebubbles_reaction_trust_lookup_error",
1938
- message: "failed direct actor trust lookup for bluebubbles reaction",
1939
- meta: {
1940
- keyHash: captured.capture.keyHash,
1941
- provider: event.actor.provider,
1942
- reason: error instanceof Error ? error.message : String(error),
1943
- },
1944
- });
1945
- }
1946
- return {
1947
- decision: (0, reaction_policy_1.classifyBlueBubblesReaction)({ ...input, trustedActor }),
1948
- trustedFriend,
1949
2347
  };
2348
+ return { decision: (0, reaction_policy_1.classifyBlueBubblesReaction)(input) };
1950
2349
  }
1951
- async function handleRestrictedBlueBubblesReactionFeedback(input) {
1952
- const { event, trustedFriend, client, resolvedDeps, options } = input;
1953
- const target = input.resolvedReactionTarget
1954
- ?? await resolveReactionTarget(client, event.targetMessageGuid);
1955
- event.textForAgent = (0, model_1.renderBlueBubblesReactionText)(event.reaction, target);
1956
- const channel = (0, friends_1.getChannelCapabilities)("bluebubbles");
1957
- const context = { friend: trustedFriend, channel, isGroupChat: event.chat.isGroup };
1958
- const userMessage = {
1959
- role: "user",
1960
- content: buildInboundContent(event),
1961
- };
1962
- const orientationFrame = (0, orientation_frame_1.buildOrientationFrame)({
1963
- channel: "bluebubbles",
1964
- messages: [userMessage],
1965
- currentUserMessages: [userMessage],
1966
- source: buildBlueBubblesOrientationSource(event, [], options.orientationEvidence, options.orientationConversationKind),
1967
- speechKind: "reaction",
1968
- });
1969
- const callbacks = createBlueBubblesCallbacks(client, event.chat, createReplyTargetController(event), event.chat.isGroup, undefined, { enableSilenceWatchdog: false, enableActivitySignals: false });
1970
- const controller = new AbortController();
1971
- const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS;
1972
- let timeoutTimer;
1973
- const timeoutPromise = new Promise((_, reject) => {
1974
- timeoutTimer = setTimeout(() => {
1975
- const error = new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
1976
- callbacks.cancelOutbound("turn_timeout");
1977
- controller.abort(error);
1978
- reject(error);
1979
- }, timeoutMs);
1980
- if (typeof timeoutTimer.unref === "function") {
1981
- timeoutTimer.unref();
1982
- }
1983
- });
1984
- try {
1985
- const restrictedTurnAndDelivery = async () => {
1986
- const result = await resolvedDeps.runAgent([userMessage], callbacks, "bluebubbles", controller.signal, {
1987
- isReactionSignal: true,
1988
- restrictedReactionFeedback: true,
1989
- skipKeptNotes: true,
1990
- orientationFrame,
1991
- toolContext: {
1992
- signin: async () => undefined,
1993
- context,
1994
- currentIngressEvidence: options.currentIngressEvidence,
1995
- },
1996
- });
1997
- if (result.outcome === "settled" || result.outcome === "blocked") {
1998
- await callbacks.flush({ signal: controller.signal });
1999
- }
2000
- return result;
2001
- };
2002
- const result = await Promise.race([
2003
- restrictedTurnAndDelivery(),
2004
- timeoutPromise,
2005
- ]);
2006
- if (result.outcome === "settled" || result.outcome === "blocked") {
2007
- return {
2008
- handled: true,
2009
- notifiedAgent: true,
2010
- kind: event.kind,
2011
- reason: "restricted_feedback_settled",
2012
- };
2013
- }
2014
- if (result.outcome === "observed") {
2015
- return {
2016
- handled: true,
2017
- notifiedAgent: false,
2018
- kind: event.kind,
2019
- reason: "restricted_feedback_observed",
2020
- };
2021
- }
2350
+ function captureOnlyHandledDisposition(capture, reactionDecision) {
2351
+ if (capture.event.kind !== "reaction") {
2022
2352
  return {
2023
- handled: true,
2024
- notifiedAgent: false,
2025
- kind: event.kind,
2026
- reason: "restricted_feedback_failed",
2353
+ outcome: capture.event.kind === "edit" ? "edit_capture_only" : "unsend_capture_only",
2354
+ detailCode: null,
2027
2355
  };
2028
2356
  }
2029
- catch (error) {
2030
- callbacks.onError(error instanceof Error ? error : new Error(String(error)), "terminal");
2357
+ const classifiedOutcome = reactionDecision.outcome;
2358
+ if (classifiedOutcome === "capture_only_negative" || classifiedOutcome === "capture_only_question") {
2031
2359
  return {
2032
- handled: true,
2033
- notifiedAgent: false,
2034
- kind: event.kind,
2035
- reason: "restricted_feedback_failed",
2360
+ outcome: "capture_only_unknown",
2361
+ detailCode: classifiedOutcome,
2036
2362
  };
2037
2363
  }
2038
- finally {
2039
- clearTimeout(timeoutTimer);
2040
- await callbacks.finish({ timeoutMs: BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS });
2041
- }
2364
+ return { outcome: classifiedOutcome, detailCode: null };
2042
2365
  }
2043
- function writeCapturedBlueBubblesHandled(agentName, capture, outcome) {
2366
+ function auditOnlyHandledOutcome(kind) {
2367
+ return kind === "read" ? "read_audit_only" : "delivery_audit_only";
2368
+ }
2369
+ function writeCapturedBlueBubblesHandled(agentName, capture, outcome, detailCode = null) {
2044
2370
  const handledResult = (0, semantic_receipts_1.writeBlueBubblesSemanticHandled)(agentName, {
2045
2371
  schemaVersion: 1,
2046
2372
  canonicalKey: capture.canonicalKey,
2047
2373
  keyHash: capture.keyHash,
2048
2374
  handledAt: new Date().toISOString(),
2049
2375
  outcome,
2050
- detailCode: null,
2376
+ detailCode,
2051
2377
  });
2052
2378
  if (handledResult === "semantic_handled_collision") {
2053
2379
  throw new Error("semantic_handled_collision");
2054
2380
  }
2055
2381
  }
2056
- async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, source, options = {}) {
2382
+ function blueBubblesInboundReplyIdempotencyKey(captureKeyHash) {
2383
+ return `bluebubbles-inbound-reply:${captureKeyHash}`;
2384
+ }
2385
+ function blueBubblesRecoveredOutboundOutcome(record) {
2386
+ return BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES.has(record.status)
2387
+ ? "message_completed"
2388
+ : "message_observed";
2389
+ }
2390
+ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, source, options) {
2391
+ options.lifecycleSignal?.throwIfAborted();
2057
2392
  const agentName = resolvedDeps.getAgentName();
2058
2393
  const identity = {
2059
2394
  canonicalKey: captured.capture.canonicalKey,
2060
2395
  keyHash: captured.capture.keyHash,
2061
2396
  };
2062
- const claim = await (0, semantic_receipts_1.acquireBlueBubblesSemanticClaim)(agentName, identity);
2397
+ const generation = adoptBlueBubblesSemanticObservation(identity.keyHash, options.observationReservation, true);
2398
+ const semanticHandling = await acquireBlueBubblesSemanticHandlingSlot(identity.keyHash, generation);
2399
+ if (semanticHandling.status === "handled_by_owner") {
2400
+ return {
2401
+ handled: true,
2402
+ notifiedAgent: false,
2403
+ kind: captured.normalized.kind,
2404
+ reason: "already_processed",
2405
+ };
2406
+ }
2407
+ const reservation = semanticHandling.reservation;
2408
+ let claim;
2409
+ try {
2410
+ options.lifecycleSignal?.throwIfAborted();
2411
+ claim = await (0, semantic_receipts_1.acquireBlueBubblesSemanticClaim)(agentName, identity);
2412
+ }
2413
+ catch (error) {
2414
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "retryable");
2415
+ throw error;
2416
+ }
2063
2417
  if (claim.status === "already_handled") {
2418
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "terminal");
2064
2419
  return {
2065
2420
  handled: true,
2066
2421
  notifiedAgent: false,
@@ -2069,6 +2424,7 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2069
2424
  };
2070
2425
  }
2071
2426
  if (claim.status === "timeout") {
2427
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "retryable");
2072
2428
  return {
2073
2429
  handled: false,
2074
2430
  notifiedAgent: false,
@@ -2076,32 +2432,97 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2076
2432
  reason: "semantic_claim_timeout",
2077
2433
  };
2078
2434
  }
2435
+ let latestTurnCapability = null;
2436
+ let semanticHandlingCompleted = false;
2437
+ let retainSemanticClaim = false;
2438
+ let acceptedReceiptPublished = false;
2439
+ const publishTerminalReceipt = (outcome, detailCode = null) => {
2440
+ try {
2441
+ writeCapturedBlueBubblesHandled(agentName, captured.capture, outcome, detailCode);
2442
+ }
2443
+ catch (error) {
2444
+ // A missing terminal receipt must never reopen this semantic event. Keep
2445
+ // the durable claim live so recovery cannot resurrect a canceled turn or
2446
+ // duplicate a reply whose transport is already externally visible.
2447
+ retainSemanticClaim = true;
2448
+ semanticHandlingCompleted = true;
2449
+ (0, runtime_1.emitNervesEvent)({
2450
+ level: "error",
2451
+ component: "senses",
2452
+ event: "senses.bluebubbles_terminal_receipt_failed",
2453
+ message: "bluebubbles event could not publish its terminal semantic receipt",
2454
+ meta: {
2455
+ messageGuid: captured.normalized.messageGuid,
2456
+ keyHash: captured.capture.keyHash,
2457
+ outcome,
2458
+ reason: error instanceof Error ? error.message : String(error),
2459
+ },
2460
+ });
2461
+ throw error;
2462
+ }
2463
+ };
2464
+ const publishAcceptedReceipt = () => {
2465
+ if (acceptedReceiptPublished)
2466
+ return;
2467
+ publishTerminalReceipt("message_completed");
2468
+ acceptedReceiptPublished = true;
2469
+ };
2079
2470
  try {
2080
- const capturedOptions = {
2081
- ...options,
2082
- currentIngressEvidence: Object.freeze({
2083
- schemaVersion: 1,
2084
- provider: "bluebubbles",
2085
- captureKeyHash: captured.capture.keyHash,
2086
- }),
2087
- orientationEvidence: captured.capture.event,
2088
- orientationConversationKind: captured.normalized.chat.isGroup ? "group" : "one_to_one",
2089
- };
2090
- const reactionClassification = await classifyCapturedBlueBubblesReaction(captured, resolvedDeps);
2471
+ options.lifecycleSignal?.throwIfAborted();
2472
+ const reactionClassification = await classifyCapturedBlueBubblesReaction(captured);
2473
+ options.lifecycleSignal?.throwIfAborted();
2091
2474
  const reactionDecision = reactionClassification?.decision ?? null;
2092
- if (reactionDecision?.route === "capture_only") {
2475
+ if (captured.capture.event.fromMe) {
2476
+ (0, runtime_1.emitNervesEvent)({
2477
+ component: "senses",
2478
+ event: "senses.bluebubbles_from_me_ignored",
2479
+ message: "ignored from-me bluebubbles event",
2480
+ meta: {
2481
+ messageGuid: captured.normalized.messageGuid,
2482
+ kind: captured.normalized.kind,
2483
+ },
2484
+ });
2485
+ if (captured.normalized.chat.isGroup) {
2486
+ recordDiscoveredOwnHandle(captured.normalized.sender.externalId);
2487
+ }
2093
2488
  const result = {
2094
2489
  handled: true,
2095
2490
  notifiedAgent: false,
2096
2491
  kind: captured.normalized.kind,
2097
- reason: reactionDecision.outcome === "ignored_self" ? "from_me" : "mutation_state_only",
2492
+ reason: "from_me",
2098
2493
  };
2099
- writeCapturedBlueBubblesHandled(agentName, captured.capture, reactionDecision.outcome);
2494
+ publishTerminalReceipt("ignored_self");
2495
+ semanticHandlingCompleted = true;
2100
2496
  return result;
2101
2497
  }
2102
- let handledEvent = captured.normalized;
2498
+ const durableOutbound = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2499
+ if (durableOutbound) {
2500
+ const outcome = blueBubblesRecoveredOutboundOutcome(durableOutbound);
2501
+ (0, runtime_1.emitNervesEvent)({
2502
+ level: "warn",
2503
+ component: "senses",
2504
+ event: "senses.bluebubbles_inbound_reply_recovery_suppressed",
2505
+ message: "suppressed bluebubbles inbound reply recovery at its durable send boundary",
2506
+ meta: {
2507
+ messageGuid: captured.normalized.messageGuid,
2508
+ recordId: durableOutbound.recordId,
2509
+ outboundStatus: durableOutbound.status,
2510
+ outcome,
2511
+ },
2512
+ });
2513
+ publishTerminalReceipt(outcome);
2514
+ semanticHandlingCompleted = true;
2515
+ return {
2516
+ handled: true,
2517
+ notifiedAgent: false,
2518
+ kind: captured.normalized.kind,
2519
+ reason: "already_processed",
2520
+ };
2521
+ }
2522
+ let handledOutcome = "message_observed";
2103
2523
  let result;
2104
2524
  if (isAuditOnlySemanticCapture(captured.capture)) {
2525
+ handledOutcome = auditOnlyHandledOutcome(captured.capture.event.kind);
2105
2526
  result = {
2106
2527
  handled: true,
2107
2528
  notifiedAgent: false,
@@ -2110,8 +2531,11 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2110
2531
  };
2111
2532
  }
2112
2533
  else {
2113
- const client = resolvedDeps.createClient();
2114
- const repaired = await client.repairEvent(captured.normalized);
2534
+ const captureOnlyMutation = isCaptureOnlyBlueBubblesMutation(captured.capture);
2535
+ const repaired = captureOnlyMutation && captured.normalized.chat.chatGuid?.trim()
2536
+ ? captured.normalized
2537
+ : await resolvedDeps.createClient().repairEvent(captured.normalized);
2538
+ options.lifecycleSignal?.throwIfAborted();
2115
2539
  if (semanticCaptureRequiresRoutingRepair(captured.capture)
2116
2540
  && !hasResolvedBlueBubblesRouting(repaired)) {
2117
2541
  throw new Error("semantic_capture_routing_invalid");
@@ -2121,31 +2545,89 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2121
2545
  fromMe: captured.normalized.fromMe,
2122
2546
  sender: captured.normalized.sender,
2123
2547
  };
2124
- handledEvent = identityGroundedRepaired;
2125
- if (reactionDecision?.route === "restricted_feedback") {
2126
- const restrictedEvent = materializeRestrictedBlueBubblesReactionEvent(captured, repaired);
2127
- handledEvent = restrictedEvent;
2128
- result = await handleRestrictedBlueBubblesReactionFeedback({
2129
- event: restrictedEvent,
2130
- trustedFriend: reactionClassification.trustedFriend,
2131
- client,
2132
- resolvedDeps,
2133
- options: capturedOptions,
2134
- resolvedReactionTarget: captured.resolvedReactionTarget ?? options.resolvedReactionTarget,
2135
- });
2548
+ const promotion = (0, latest_turn_1.promote)(reservation, {
2549
+ chatGuid: identityGroundedRepaired.chat.chatGuid,
2550
+ chatIdentifier: identityGroundedRepaired.chat.chatIdentifier,
2551
+ }, {
2552
+ allowSameGenerationRetry: semanticHandling.allowSameGenerationRetry,
2553
+ });
2554
+ if (promotion.status !== "promoted") {
2555
+ if (!captureOnlyMutation && promotion.status === "unresolved") {
2556
+ throw new Error("semantic_capture_routing_invalid");
2557
+ }
2558
+ const disposition = captureOnlyMutation
2559
+ ? captureOnlyHandledDisposition(captured.capture, reactionDecision)
2560
+ : { outcome: "message_observed", detailCode: null };
2561
+ publishTerminalReceipt(disposition.outcome, disposition.detailCode);
2562
+ semanticHandlingCompleted = true;
2563
+ return {
2564
+ handled: true,
2565
+ notifiedAgent: false,
2566
+ kind: captured.normalized.kind,
2567
+ reason: captureOnlyMutation ? "mutation_state_only" : "superseded",
2568
+ };
2136
2569
  }
2137
- else {
2138
- result = await handleBlueBubblesNormalizedEvent(identityGroundedRepaired, resolvedDeps, source, {
2139
- ...capturedOptions,
2140
- resolvedReactionTarget: captured.resolvedReactionTarget ?? options.resolvedReactionTarget,
2141
- });
2570
+ latestTurnCapability = promotion.capability;
2571
+ if (captureOnlyMutation) {
2572
+ const disposition = captureOnlyHandledDisposition(captured.capture, reactionDecision);
2573
+ publishTerminalReceipt(disposition.outcome, disposition.detailCode);
2574
+ semanticHandlingCompleted = true;
2575
+ return {
2576
+ handled: true,
2577
+ notifiedAgent: false,
2578
+ kind: captured.normalized.kind,
2579
+ reason: "mutation_state_only",
2580
+ };
2581
+ }
2582
+ if (identityGroundedRepaired.kind === "mutation") {
2583
+ const outcome = auditOnlyHandledOutcome(identityGroundedRepaired.mutationType);
2584
+ publishTerminalReceipt(outcome);
2585
+ semanticHandlingCompleted = true;
2586
+ return {
2587
+ handled: true,
2588
+ notifiedAgent: false,
2589
+ kind: captured.normalized.kind,
2590
+ reason: "mutation_state_only",
2591
+ };
2142
2592
  }
2593
+ const canonicalEvent = canonicalizeBlueBubblesEvent(identityGroundedRepaired, latestTurnCapability);
2594
+ const capturedOptions = {
2595
+ ...options,
2596
+ latestTurnCapability,
2597
+ currentIngressEvidence: Object.freeze({
2598
+ schemaVersion: 1,
2599
+ provider: "bluebubbles",
2600
+ captureKeyHash: captured.capture.keyHash,
2601
+ }),
2602
+ orientationEvidence: captured.capture.event,
2603
+ orientationConversationKind: canonicalEvent.chat.isGroup ? "group" : "one_to_one",
2604
+ publishAcceptedReceipt,
2605
+ };
2606
+ result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2607
+ handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2608
+ }
2609
+ if (result.notifiedAgent) {
2610
+ publishAcceptedReceipt();
2143
2611
  }
2144
- writeCapturedBlueBubblesHandled(agentName, captured.capture, semanticHandledOutcome(handledEvent, result));
2612
+ else {
2613
+ publishTerminalReceipt(handledOutcome);
2614
+ }
2615
+ semanticHandlingCompleted = true;
2145
2616
  return result;
2146
2617
  }
2147
2618
  finally {
2148
- (0, semantic_receipts_1.releaseBlueBubblesSemanticClaim)(agentName, claim);
2619
+ if (latestTurnCapability)
2620
+ (0, latest_turn_1.finish)(latestTurnCapability);
2621
+ try {
2622
+ if (!retainSemanticClaim) {
2623
+ (0, semantic_receipts_1.releaseBlueBubblesSemanticClaim)(agentName, claim);
2624
+ }
2625
+ }
2626
+ finally {
2627
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, retainSemanticClaim
2628
+ ? "retained"
2629
+ : semanticHandlingCompleted ? "terminal" : "retryable");
2630
+ }
2149
2631
  }
2150
2632
  }
2151
2633
  async function handleBlueBubblesEvent(payload, deps = {}) {
@@ -2172,11 +2654,22 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
2172
2654
  }
2173
2655
  throw error;
2174
2656
  }
2175
- const captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook");
2657
+ const observationReservation = reserveBlueBubblesObservation(normalized);
2658
+ let captured;
2659
+ try {
2660
+ captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook", observationReservation);
2661
+ }
2662
+ catch (error) {
2663
+ (0, latest_turn_1.clearPending)(observationReservation);
2664
+ throw error;
2665
+ }
2176
2666
  if (captured.status === "audit_only") {
2667
+ (0, latest_turn_1.clearPending)(observationReservation);
2177
2668
  return { handled: true, notifiedAgent: false, kind: normalized.kind, reason: "ignored" };
2178
2669
  }
2179
- return handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook");
2670
+ return handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook", {
2671
+ observationReservation,
2672
+ });
2180
2673
  }
2181
2674
  function parseTimestampMs(value) {
2182
2675
  if (!value)
@@ -2220,14 +2713,20 @@ function blueBubblesPendingRecoverySnapshot(agentName, nowMs = Date.now()) {
2220
2713
  oldestPendingRecoveryAgeMs: oldest ? Math.max(0, nowMs - oldest.ms) : undefined,
2221
2714
  };
2222
2715
  }
2223
- async function syncBlueBubblesRuntime(deps = {}) {
2716
+ async function syncBlueBubblesRuntime(deps = {}, options = {}) {
2717
+ options.lifecycleSignal?.throwIfAborted();
2224
2718
  const resolvedDeps = { ...defaultDeps, ...deps };
2225
2719
  const agentName = resolvedDeps.getAgentName();
2720
+ // Allocate before the first health-probe await. Any live webhook accepted
2721
+ // while that probe is pending must remain causally newer than this pass.
2722
+ const catchUpObservationBatch = options.catchUpObservationBatch ?? (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
2723
+ pruneBlueBubblesSemanticObservationGenerations();
2226
2724
  const client = resolvedDeps.createClient();
2227
2725
  const checkedAt = new Date().toISOString();
2228
2726
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
2229
2727
  try {
2230
2728
  await client.checkHealth();
2729
+ options.lifecycleSignal?.throwIfAborted();
2231
2730
  const pendingBeforeCatchup = blueBubblesPendingRecoverySnapshot(agentName);
2232
2731
  const activeBeforeCatchup = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
2233
2732
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
@@ -2242,7 +2741,10 @@ async function syncBlueBubblesRuntime(deps = {}) {
2242
2741
  });
2243
2742
  const catchUp = await catchUpMissedBlueBubblesMessages(resolvedDeps, previousState, {
2244
2743
  processTurns: false,
2744
+ observationBatch: catchUpObservationBatch,
2745
+ lifecycleSignal: options.lifecycleSignal,
2245
2746
  });
2747
+ options.lifecycleSignal?.throwIfAborted();
2246
2748
  const failed = catchUp.failed;
2247
2749
  const pendingAfterCatchup = blueBubblesPendingRecoverySnapshot(agentName);
2248
2750
  const activeAfterCatchup = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
@@ -2265,6 +2767,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
2265
2767
  });
2266
2768
  }
2267
2769
  catch (error) {
2770
+ if (options.lifecycleSignal?.aborted)
2771
+ return;
2268
2772
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2269
2773
  upstreamStatus: "error",
2270
2774
  detail: error instanceof Error ? error.message : String(error),
@@ -2276,7 +2780,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
2276
2780
  });
2277
2781
  }
2278
2782
  }
2279
- async function recoverQueuedBlueBubblesMessages(deps = {}) {
2783
+ async function recoverQueuedBlueBubblesMessages(deps = {}, options = {}) {
2784
+ options.lifecycleSignal?.throwIfAborted();
2280
2785
  const resolvedDeps = { ...defaultDeps, ...deps };
2281
2786
  const agentName = resolvedDeps.getAgentName();
2282
2787
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
@@ -2284,8 +2789,10 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2284
2789
  if (initialPending === 0) {
2285
2790
  return { recovered: 0, skipped: 0, failed: 0, pendingRecoveryCount: 0 };
2286
2791
  }
2287
- const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps);
2792
+ const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps, options);
2793
+ options.lifecycleSignal?.throwIfAborted();
2288
2794
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
2795
+ options.lifecycleSignal?.throwIfAborted();
2289
2796
  const pendingSnapshot = blueBubblesPendingRecoverySnapshot(agentName);
2290
2797
  const pendingRecoveryCount = pendingSnapshot.pendingRecoveryCount;
2291
2798
  const failed = captured.failed + recovery.failed;
@@ -2294,6 +2801,7 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2294
2801
  const checkedAt = new Date().toISOString();
2295
2802
  try {
2296
2803
  await resolvedDeps.createClient().checkHealth();
2804
+ options.lifecycleSignal?.throwIfAborted();
2297
2805
  const activeSnapshot = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
2298
2806
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2299
2807
  upstreamStatus: "ok",
@@ -2308,6 +2816,8 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2308
2816
  });
2309
2817
  }
2310
2818
  catch (error) {
2819
+ if (options.lifecycleSignal?.aborted)
2820
+ throw options.lifecycleSignal.reason;
2311
2821
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2312
2822
  upstreamStatus: "error",
2313
2823
  detail: error instanceof Error ? error.message : String(error),
@@ -2323,6 +2833,7 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2323
2833
  return { recovered, skipped, failed, pendingRecoveryCount };
2324
2834
  }
2325
2835
  async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, options = {}) {
2836
+ options.lifecycleSignal?.throwIfAborted();
2326
2837
  const resolvedDeps = { ...defaultDeps, ...deps };
2327
2838
  const agentName = resolvedDeps.getAgentName();
2328
2839
  const client = resolvedDeps.createClient();
@@ -2344,16 +2855,21 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2344
2855
  maxPages: BLUEBUBBLES_CATCHUP_MAX_PAGES,
2345
2856
  },
2346
2857
  });
2347
- const recentEvents = [];
2858
+ const preparedCandidates = [];
2859
+ const seenMessageGuids = new Set();
2860
+ const observationBatch = options.observationBatch ?? (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
2348
2861
  for (let page = 0; page < BLUEBUBBLES_CATCHUP_MAX_PAGES; page++) {
2349
2862
  let pageEvents;
2350
2863
  try {
2351
2864
  pageEvents = await client.listRecentMessages({
2352
2865
  limit: BLUEBUBBLES_CATCHUP_PAGE_SIZE,
2353
2866
  offset: page * BLUEBUBBLES_CATCHUP_PAGE_SIZE,
2867
+ beforeTimestamp: observationBatch.beforeTimestamp,
2354
2868
  });
2355
2869
  }
2356
2870
  catch (error) {
2871
+ if (options.lifecycleSignal?.aborted)
2872
+ throw options.lifecycleSignal.reason;
2357
2873
  result.failed++;
2358
2874
  (0, runtime_1.emitNervesEvent)({
2359
2875
  level: "warn",
@@ -2367,11 +2883,36 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2367
2883
  });
2368
2884
  break;
2369
2885
  }
2370
- recentEvents.push(...pageEvents);
2886
+ options.lifecycleSignal?.throwIfAborted();
2887
+ const pageMessages = pageEvents
2888
+ .filter((event) => event.kind === "message")
2889
+ .sort((left, right) => right.timestamp - left.timestamp);
2890
+ for (let pageIndex = 0; pageIndex < pageMessages.length; pageIndex++) {
2891
+ const event = pageMessages[pageIndex];
2892
+ if (seenMessageGuids.has(event.messageGuid))
2893
+ continue;
2894
+ seenMessageGuids.add(event.messageGuid);
2895
+ if (event.fromMe
2896
+ || event.timestamp < catchUpSince
2897
+ || (Number.isFinite(event.timestamp) && event.timestamp > observationBatch.beforeTimestamp)) {
2898
+ preparedCandidates.push({ status: "skip", event });
2899
+ continue;
2900
+ }
2901
+ // The bounded ordinal range and upstream snapshot were fixed before the
2902
+ // first query. Each returned page can therefore publish its fences
2903
+ // before the next await, while later ingress stays outside this pass.
2904
+ preparedCandidates.push({
2905
+ status: "ready",
2906
+ event,
2907
+ observationReservation: (0, latest_turn_1.reserveObservationFromBatch)(observationBatch, page * BLUEBUBBLES_CATCHUP_PAGE_SIZE + pageIndex, {
2908
+ chatGuid: event.chat.chatGuid,
2909
+ chatIdentifier: event.chat.chatIdentifier,
2910
+ }),
2911
+ });
2912
+ }
2371
2913
  if (pageEvents.length < BLUEBUBBLES_CATCHUP_PAGE_SIZE)
2372
2914
  break;
2373
- const oldestMessageTimestamp = pageEvents
2374
- .filter((event) => event.kind === "message")
2915
+ const oldestMessageTimestamp = pageMessages
2375
2916
  .reduce((oldest, event) => Math.min(oldest, event.timestamp), Number.POSITIVE_INFINITY);
2376
2917
  if (oldestMessageTimestamp <= catchUpSince)
2377
2918
  break;
@@ -2389,68 +2930,91 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2389
2930
  });
2390
2931
  }
2391
2932
  }
2392
- const seenMessageGuids = new Set();
2393
- const candidates = recentEvents
2394
- .filter((event) => event.kind === "message")
2395
- .filter((event) => {
2396
- if (seenMessageGuids.has(event.messageGuid))
2397
- return false;
2398
- seenMessageGuids.add(event.messageGuid);
2399
- return true;
2400
- })
2401
- .sort((left, right) => left.timestamp - right.timestamp);
2402
- for (const event of candidates) {
2933
+ for (const prepared of preparedCandidates) {
2403
2934
  result.inspected++;
2404
- if (event.fromMe
2405
- || event.timestamp < catchUpSince) {
2935
+ if (prepared.status === "skip") {
2406
2936
  result.skipped++;
2407
- continue;
2408
2937
  }
2409
- try {
2410
- const captured = await captureBlueBubblesSemanticEvent(event, resolvedDeps, "upstream-catchup");
2411
- if (captured.status === "audit_only") {
2412
- result.skipped++;
2413
- continue;
2414
- }
2415
- if (!processTurns) {
2416
- if (captured.writeResult === "semantic_capture_duplicate") {
2938
+ }
2939
+ const readyCandidates = preparedCandidates.filter((prepared) => prepared.status === "ready");
2940
+ let lastRecoveredOrdinal = -1;
2941
+ await Promise.all(groupBlueBubblesObservationsByLane(readyCandidates).map(async (lane) => {
2942
+ for (const prepared of lane) {
2943
+ const { event, observationReservation } = prepared;
2944
+ let semanticGenerationOwned = false;
2945
+ try {
2946
+ options.lifecycleSignal?.throwIfAborted();
2947
+ const captured = await captureBlueBubblesSemanticEvent(event, resolvedDeps, "upstream-catchup", observationReservation);
2948
+ if (captured.status === "audit_only") {
2949
+ (0, latest_turn_1.clearPending)(observationReservation);
2950
+ result.skipped++;
2951
+ continue;
2952
+ }
2953
+ if (!processTurns) {
2954
+ const generation = adoptBlueBubblesSemanticObservation(captured.capture.keyHash, observationReservation, false);
2955
+ semanticGenerationOwned = true;
2956
+ if (captured.writeResult === "semantic_capture_duplicate") {
2957
+ if (!activeSemanticHandlingSlots.has(captured.capture.keyHash)) {
2958
+ suspendBlueBubblesSemanticObservation(generation);
2959
+ }
2960
+ result.skipped++;
2961
+ }
2962
+ else {
2963
+ // Runtime sync only captures work for the delayed recovery pass,
2964
+ // but its original generation must revoke older work now and be
2965
+ // reused by the queued recovery pass after any intervening turn.
2966
+ const promotion = (0, latest_turn_1.promote)(generation.reservation, {
2967
+ chatGuid: event.chat.chatGuid,
2968
+ chatIdentifier: event.chat.chatIdentifier,
2969
+ });
2970
+ if (promotion.status === "promoted")
2971
+ (0, latest_turn_1.finish)(promotion.capability);
2972
+ suspendBlueBubblesSemanticObservation(generation);
2973
+ result.queued = (result.queued ?? 0) + 1;
2974
+ }
2975
+ continue;
2976
+ }
2977
+ semanticGenerationOwned = true;
2978
+ const handled = await handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "upstream-catchup", {
2979
+ timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2980
+ autonomyBudgetTrigger: "recovery",
2981
+ observationReservation,
2982
+ lifecycleSignal: options.lifecycleSignal,
2983
+ });
2984
+ if (handled.reason === "semantic_claim_timeout") {
2985
+ continue;
2986
+ }
2987
+ if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2417
2988
  result.skipped++;
2418
2989
  }
2419
2990
  else {
2420
- result.queued = (result.queued ?? 0) + 1;
2991
+ result.recovered++;
2992
+ if (observationReservation.ordinal > lastRecoveredOrdinal) {
2993
+ lastRecoveredOrdinal = observationReservation.ordinal;
2994
+ result.lastRecoveredMessageGuid = event.messageGuid;
2995
+ }
2421
2996
  }
2422
- continue;
2423
- }
2424
- const handled = await handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "upstream-catchup", {
2425
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2426
- autonomyBudgetTrigger: "recovery",
2427
- });
2428
- if (handled.reason === "semantic_claim_timeout") {
2429
- continue;
2430
2997
  }
2431
- if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2432
- result.skipped++;
2433
- }
2434
- else {
2435
- result.recovered++;
2436
- result.lastRecoveredMessageGuid = event.messageGuid;
2998
+ catch (error) {
2999
+ if (!semanticGenerationOwned)
3000
+ (0, latest_turn_1.clearPending)(observationReservation);
3001
+ if (options.lifecycleSignal?.aborted)
3002
+ throw options.lifecycleSignal.reason;
3003
+ recordBlueBubblesRecoveryFailureForBudget(agentName, event, "upstream-catchup");
3004
+ result.failed++;
3005
+ (0, runtime_1.emitNervesEvent)({
3006
+ level: "warn",
3007
+ component: "senses",
3008
+ event: "senses.bluebubbles_catchup_error",
3009
+ message: "bluebubbles upstream catch-up message failed",
3010
+ meta: {
3011
+ messageGuid: event.messageGuid,
3012
+ reason: error instanceof Error ? error.message : String(error),
3013
+ },
3014
+ });
2437
3015
  }
2438
3016
  }
2439
- catch (error) {
2440
- recordBlueBubblesRecoveryFailureForBudget(agentName, event, "upstream-catchup");
2441
- result.failed++;
2442
- (0, runtime_1.emitNervesEvent)({
2443
- level: "warn",
2444
- component: "senses",
2445
- event: "senses.bluebubbles_catchup_error",
2446
- message: "bluebubbles upstream catch-up message failed",
2447
- meta: {
2448
- messageGuid: event.messageGuid,
2449
- reason: error instanceof Error ? error.message : String(error),
2450
- },
2451
- });
2452
- }
2453
- }
3017
+ }));
2454
3018
  if (result.inspected > 0 || result.recovered > 0 || result.skipped > 0 || result.failed > 0) {
2455
3019
  (0, runtime_1.emitNervesEvent)({
2456
3020
  component: "senses",
@@ -2461,52 +3025,47 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2461
3025
  }
2462
3026
  return result;
2463
3027
  }
2464
- async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
3028
+ async function recoverCapturedBlueBubblesInboundMessages(deps = {}, options = {}) {
3029
+ options.lifecycleSignal?.throwIfAborted();
2465
3030
  const resolvedDeps = { ...defaultDeps, ...deps };
2466
3031
  const agentName = resolvedDeps.getAgentName();
2467
3032
  const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
2468
3033
  const result = { recovered: 0, skipped: 0, failed: 0 };
2469
3034
  const seenKeyHashes = new Set();
2470
3035
  const candidates = (0, semantic_receipts_1.listPendingBlueBubblesSemanticCaptures)(agentName)
2471
- .sort((left, right) => left.capturedAt.localeCompare(right.capturedAt));
3036
+ .sort(semantic_receipts_1.compareBlueBubblesSemanticCaptureOrder);
3037
+ const preparedCandidates = [];
2472
3038
  for (const capture of candidates) {
3039
+ options.lifecycleSignal?.throwIfAborted();
2473
3040
  if (seenKeyHashes.has(capture.keyHash)) {
2474
- result.skipped++;
3041
+ preparedCandidates.push({ status: "skip", capture });
2475
3042
  continue;
2476
3043
  }
2477
3044
  seenKeyHashes.add(capture.keyHash);
2478
3045
  const disposition = (0, semantic_receipts_1.classifyBlueBubblesRecoveryRecord)(capture, cutover);
2479
3046
  if (disposition.disposition === "audit_only" && disposition.reason !== "audit_event") {
2480
- result.skipped++;
3047
+ preparedCandidates.push({ status: "skip", capture });
2481
3048
  continue;
2482
3049
  }
2483
3050
  const normalized = semanticCaptureToNormalizedEvent(capture);
2484
- try {
2485
- if (!normalized)
2486
- throw new Error("semantic_capture_routing_invalid");
2487
- const handled = await handleCapturedBlueBubblesSemanticEvent({
2488
- status: "captured",
2489
- capture,
2490
- normalized,
2491
- writeResult: "semantic_capture_duplicate",
2492
- }, resolvedDeps, "webhook", {
2493
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2494
- autonomyBudgetTrigger: "recovery",
2495
- semanticRecovery: true,
2496
- });
2497
- if (handled.reason === "semantic_claim_timeout") {
2498
- continue;
2499
- }
2500
- if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2501
- result.skipped++;
2502
- }
2503
- else {
2504
- result.recovered++;
2505
- }
3051
+ if (!normalized) {
3052
+ preparedCandidates.push({ status: "invalid", capture });
3053
+ continue;
2506
3054
  }
2507
- catch (error) {
2508
- if (normalized)
2509
- recordBlueBubblesRecoveryFailureForBudget(agentName, normalized, "webhook");
3055
+ preparedCandidates.push({
3056
+ status: "ready",
3057
+ capture,
3058
+ normalized,
3059
+ observationReservation: reserveBlueBubblesObservation(normalized),
3060
+ });
3061
+ }
3062
+ for (const prepared of preparedCandidates) {
3063
+ const { capture } = prepared;
3064
+ if (prepared.status === "skip") {
3065
+ result.skipped++;
3066
+ continue;
3067
+ }
3068
+ if (prepared.status === "invalid") {
2510
3069
  result.failed++;
2511
3070
  (0, runtime_1.emitNervesEvent)({
2512
3071
  level: "warn",
@@ -2516,11 +3075,58 @@ async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
2516
3075
  meta: {
2517
3076
  messageGuid: capture.event.eventGuid,
2518
3077
  sessionKey: capture.event.sessionKey,
2519
- reason: error instanceof Error ? error.message : String(error),
3078
+ reason: "semantic_capture_routing_invalid",
2520
3079
  },
2521
3080
  });
2522
3081
  }
2523
3082
  }
3083
+ const readyCandidates = preparedCandidates.filter((prepared) => prepared.status === "ready");
3084
+ await Promise.all(groupBlueBubblesObservationsByLane(readyCandidates).map(async (lane) => {
3085
+ for (const prepared of lane) {
3086
+ const { capture, normalized, observationReservation } = prepared;
3087
+ try {
3088
+ options.lifecycleSignal?.throwIfAborted();
3089
+ const handled = await handleCapturedBlueBubblesSemanticEvent({
3090
+ status: "captured",
3091
+ capture,
3092
+ normalized,
3093
+ writeResult: "semantic_capture_duplicate",
3094
+ }, resolvedDeps, "webhook", {
3095
+ timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
3096
+ autonomyBudgetTrigger: "recovery",
3097
+ semanticRecovery: true,
3098
+ observationReservation,
3099
+ lifecycleSignal: options.lifecycleSignal,
3100
+ });
3101
+ if (handled.reason === "semantic_claim_timeout") {
3102
+ continue;
3103
+ }
3104
+ if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
3105
+ result.skipped++;
3106
+ }
3107
+ else {
3108
+ result.recovered++;
3109
+ }
3110
+ }
3111
+ catch (error) {
3112
+ if (options.lifecycleSignal?.aborted)
3113
+ throw options.lifecycleSignal.reason;
3114
+ recordBlueBubblesRecoveryFailureForBudget(agentName, normalized, "webhook");
3115
+ result.failed++;
3116
+ (0, runtime_1.emitNervesEvent)({
3117
+ level: "warn",
3118
+ component: "senses",
3119
+ event: "senses.bluebubbles_capture_recovery_error",
3120
+ message: "captured bluebubbles message recovery failed",
3121
+ meta: {
3122
+ messageGuid: capture.event.eventGuid,
3123
+ sessionKey: capture.event.sessionKey,
3124
+ reason: error instanceof Error ? error.message : String(error),
3125
+ },
3126
+ });
3127
+ }
3128
+ }
3129
+ }));
2524
3130
  const semanticEventGuids = new Set(candidates
2525
3131
  .map((capture) => capture.event.eventGuid)
2526
3132
  .filter((eventGuid) => typeof eventGuid === "string")
@@ -2552,7 +3158,7 @@ async function recoverMissedBlueBubblesMessages(deps = {}) {
2552
3158
  }
2553
3159
  return result;
2554
3160
  }
2555
- function createBlueBubblesWebhookHandler(deps = {}) {
3161
+ function createBlueBubblesWebhookHandler(deps = {}, options = {}) {
2556
3162
  return async (req, res) => {
2557
3163
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
2558
3164
  if (url.pathname === "/health") {
@@ -2633,11 +3239,13 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2633
3239
  return;
2634
3240
  }
2635
3241
  const resolvedDeps = { ...defaultDeps, ...deps };
3242
+ const observationReservation = reserveBlueBubblesObservation(normalized);
2636
3243
  let captured;
2637
3244
  try {
2638
- captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook");
3245
+ captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook", observationReservation);
2639
3246
  }
2640
3247
  catch (error) {
3248
+ (0, latest_turn_1.clearPending)(observationReservation);
2641
3249
  (0, runtime_1.emitNervesEvent)({
2642
3250
  level: "error",
2643
3251
  component: "senses",
@@ -2652,6 +3260,7 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2652
3260
  return;
2653
3261
  }
2654
3262
  if (captured.status === "audit_only") {
3263
+ (0, latest_turn_1.clearPending)(observationReservation);
2655
3264
  writeJson(res, 200, {
2656
3265
  handled: true,
2657
3266
  notifiedAgent: false,
@@ -2669,7 +3278,16 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2669
3278
  reason: "queued",
2670
3279
  });
2671
3280
  setTimeout(() => {
2672
- void handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook").catch((error) => {
3281
+ if (options.lifecycleSignal?.aborted) {
3282
+ (0, latest_turn_1.clearPending)(observationReservation);
3283
+ return;
3284
+ }
3285
+ void handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook", {
3286
+ observationReservation,
3287
+ lifecycleSignal: options.lifecycleSignal,
3288
+ }).catch((error) => {
3289
+ if (options.lifecycleSignal?.aborted)
3290
+ return;
2673
3291
  (0, runtime_1.emitNervesEvent)({
2674
3292
  level: "error",
2675
3293
  component: "senses",
@@ -3088,20 +3706,34 @@ async function drainAndSendPendingBlueBubbles(deps = {}, pendingRoot) {
3088
3706
  }
3089
3707
  function startBlueBubblesApp(deps = {}) {
3090
3708
  const resolvedDeps = { ...defaultDeps, ...deps };
3709
+ const lifecycleController = new AbortController();
3091
3710
  (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(resolvedDeps.getAgentName());
3092
3711
  resolvedDeps.createClient();
3712
+ seedBlueBubblesStartupSemanticObservations(resolvedDeps);
3713
+ const startupCatchUpObservationBatch = (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
3093
3714
  const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
3094
- const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps));
3715
+ const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps, {
3716
+ lifecycleSignal: lifecycleController.signal,
3717
+ }));
3095
3718
  let recoveryPassRunning = false;
3719
+ let runtimeSyncRunning = false;
3096
3720
  let recoveryDelayTimer = null;
3721
+ let closed = false;
3097
3722
  function triggerRecoveryPass() {
3723
+ /* v8 ignore next -- close clears both recovery timers; guard covers an already-queued host callback @preserve */
3724
+ if (closed)
3725
+ return;
3098
3726
  /* v8 ignore next -- re-entrant timer guard; difficult to force deterministically without timing the turn lock @preserve */
3099
3727
  if (recoveryPassRunning)
3100
3728
  return;
3101
3729
  recoveryPassRunning = true;
3102
- void recoverQueuedBlueBubblesMessages(resolvedDeps)
3730
+ void recoverQueuedBlueBubblesMessages(resolvedDeps, {
3731
+ lifecycleSignal: lifecycleController.signal,
3732
+ })
3103
3733
  /* v8 ignore start -- defensive wrapper; expected per-message failures are handled inside recovery helpers @preserve */
3104
3734
  .catch((error) => {
3735
+ if (closed || lifecycleController.signal.aborted)
3736
+ return;
3105
3737
  (0, runtime_1.emitNervesEvent)({
3106
3738
  level: "warn",
3107
3739
  component: "senses",
@@ -3116,6 +3748,8 @@ function startBlueBubblesApp(deps = {}) {
3116
3748
  });
3117
3749
  }
3118
3750
  function scheduleRecoveryPass() {
3751
+ if (closed)
3752
+ return;
3119
3753
  /* v8 ignore next -- duplicate scheduling guard for overlapping health sync completions @preserve */
3120
3754
  if (recoveryDelayTimer !== null)
3121
3755
  return;
@@ -3124,11 +3758,40 @@ function startBlueBubblesApp(deps = {}) {
3124
3758
  triggerRecoveryPass();
3125
3759
  }, BLUEBUBBLES_RECOVERY_PASS_DELAY_MS);
3126
3760
  }
3761
+ function triggerRuntimeSync(catchUpObservationBatch) {
3762
+ if (closed || runtimeSyncRunning)
3763
+ return;
3764
+ runtimeSyncRunning = true;
3765
+ void syncBlueBubblesRuntime(resolvedDeps, {
3766
+ ...(catchUpObservationBatch ? { catchUpObservationBatch } : {}),
3767
+ lifecycleSignal: lifecycleController.signal,
3768
+ })
3769
+ .then(scheduleRecoveryPass)
3770
+ .catch((error) => {
3771
+ if (closed)
3772
+ return;
3773
+ (0, runtime_1.emitNervesEvent)({
3774
+ level: "warn",
3775
+ component: "senses",
3776
+ event: "senses.bluebubbles_recovery_error",
3777
+ message: "bluebubbles runtime sync failed",
3778
+ meta: { reason: error instanceof Error ? error.message : String(error) },
3779
+ });
3780
+ })
3781
+ .finally(() => {
3782
+ runtimeSyncRunning = false;
3783
+ });
3784
+ }
3127
3785
  const runtimeTimer = setInterval(() => {
3128
- void syncBlueBubblesRuntime(resolvedDeps).then(scheduleRecoveryPass);
3786
+ /* v8 ignore next -- close clears this interval; guard covers an already-queued host callback @preserve */
3787
+ if (closed)
3788
+ return;
3789
+ triggerRuntimeSync();
3129
3790
  }, BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS);
3130
3791
  const recoveryTimer = setInterval(triggerRecoveryPass, BLUEBUBBLES_RECOVERY_PASS_INTERVAL_MS);
3131
3792
  server.on?.("close", () => {
3793
+ closed = true;
3794
+ lifecycleController.abort(new Error("bluebubbles_runtime_shutdown"));
3132
3795
  clearInterval(runtimeTimer);
3133
3796
  clearInterval(recoveryTimer);
3134
3797
  if (recoveryDelayTimer !== null) {
@@ -3144,6 +3807,6 @@ function startBlueBubblesApp(deps = {}) {
3144
3807
  meta: { port: channelConfig.port, webhookPath: channelConfig.webhookPath },
3145
3808
  });
3146
3809
  });
3147
- void syncBlueBubblesRuntime(resolvedDeps).then(scheduleRecoveryPass);
3810
+ triggerRuntimeSync(startupCatchUpObservationBatch);
3148
3811
  return server;
3149
3812
  }