sentinelayer-cli 0.30.3 → 0.30.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -65,6 +65,10 @@ import {
65
65
  updateSessionTitle,
66
66
  } from "../session/store.js";
67
67
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
68
+ import {
69
+ filterSessionMaterialEvents,
70
+ isSessionControlEvent,
71
+ } from "../session/control-events.js";
68
72
  import {
69
73
  acceptSessionInvitation,
70
74
  normalizeSessionOnboarding,
@@ -76,6 +80,8 @@ import { appendToStream, readStream, tailStream } from "../session/stream.js";
76
80
  import {
77
81
  addSessionEventIdentityKeys,
78
82
  dedupeSessionEvents,
83
+ sessionEventIdentityKeys,
84
+ sessionEventUpgradesExisting,
79
85
  sessionEventHasKnownIdentity,
80
86
  } from "../session/event-identity.js";
81
87
  import { readSessionPreview } from "../session/preview.js";
@@ -701,6 +707,80 @@ function mergeSessionActionEvents(events = [], actionEvents = []) {
701
707
  .map((entry) => entry.event);
702
708
  }
703
709
 
710
+ async function pollSessionEventsBeforeTranscriptTail(sessionId, {
711
+ targetPath,
712
+ beforeSequence = null,
713
+ tail = 20,
714
+ includeControlEvents = false,
715
+ timeoutMs = 15_000,
716
+ maxPages = 10,
717
+ } = {}) {
718
+ const targetMaterialCount = Math.max(1, Math.floor(Number(tail) || 20));
719
+ const pageLimit = includeControlEvents
720
+ ? Math.min(200, targetMaterialCount)
721
+ : 200;
722
+ const maxPageCount = Math.max(1, Math.min(25, Math.floor(Number(maxPages) || 10)));
723
+ let nextBeforeSequence = beforeSequence;
724
+ let latestResult = null;
725
+ let combinedEvents = [];
726
+ let pageCount = 0;
727
+
728
+ for (let page = 0; page < maxPageCount; page += 1) {
729
+ const result = await pollSessionEventsBefore(sessionId, {
730
+ targetPath,
731
+ beforeSequence: nextBeforeSequence,
732
+ limit: pageLimit,
733
+ timeoutMs,
734
+ });
735
+ pageCount += 1;
736
+ if (!result?.ok) {
737
+ if (!latestResult) return { ...result, pageCount };
738
+ latestResult = {
739
+ ...latestResult,
740
+ reason: result?.reason || latestResult.reason || "partial_tail_fetch",
741
+ partial: true,
742
+ };
743
+ break;
744
+ }
745
+
746
+ latestResult = result;
747
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
748
+ combinedEvents = [...pageEvents, ...combinedEvents];
749
+ const materialCount = includeControlEvents
750
+ ? combinedEvents.length
751
+ : filterSessionMaterialEvents(combinedEvents).length;
752
+
753
+ if (includeControlEvents || materialCount >= targetMaterialCount) break;
754
+ if (pageEvents.length < pageLimit) break;
755
+
756
+ const candidateBefore = Number(result.beforeSequence || 0);
757
+ if (!Number.isFinite(candidateBefore) || candidateBefore <= 0) break;
758
+ const currentBefore = Number(nextBeforeSequence || 0);
759
+ if (currentBefore > 0 && candidateBefore >= currentBefore) break;
760
+ nextBeforeSequence = candidateBefore;
761
+ }
762
+
763
+ if (!latestResult) {
764
+ return {
765
+ ok: true,
766
+ reason: "",
767
+ events: [],
768
+ cursor: null,
769
+ beforeSequence: beforeSequence || null,
770
+ pageCount,
771
+ };
772
+ }
773
+
774
+ return {
775
+ ...latestResult,
776
+ events: combinedEvents,
777
+ cursor: normalizeString(combinedEvents[combinedEvents.length - 1]?.cursor) || latestResult.cursor || null,
778
+ pageCount,
779
+ materialBackfillComplete:
780
+ includeControlEvents || filterSessionMaterialEvents(combinedEvents).length >= targetMaterialCount,
781
+ };
782
+ }
783
+
704
784
  async function appendActionEventIfMissing(sessionId, actionEvent, { targetPath } = {}) {
705
785
  if (!actionEvent) return { appended: false, reason: "no_event", event: null };
706
786
  const existingEvents = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
@@ -1602,6 +1682,54 @@ function compactPayload(record = {}) {
1602
1682
  );
1603
1683
  }
1604
1684
 
1685
+ function listenerPresenceHeartbeatFingerprint(lifecycle = {}) {
1686
+ return JSON.stringify(
1687
+ compactPayload({
1688
+ active: Boolean(lifecycle.active),
1689
+ state: normalizeString(lifecycle.state) || "heartbeat",
1690
+ transport: normalizeString(lifecycle.transport) || null,
1691
+ reason: normalizeString(lifecycle.reason) || null,
1692
+ idleIntervalSeconds: lifecycle.idleIntervalSeconds ?? null,
1693
+ activeIntervalSeconds: lifecycle.activeIntervalSeconds ?? null,
1694
+ activeWindowSeconds: lifecycle.activeWindowSeconds ?? null,
1695
+ lastHumanActivityAt: lifecycle.lastHumanActivityAt || null,
1696
+ }),
1697
+ );
1698
+ }
1699
+
1700
+ export function shouldPublishListenerPresenceHeartbeat({
1701
+ lifecycle = {},
1702
+ nowMs = Date.now(),
1703
+ lastHeartbeatMs = 0,
1704
+ lastFingerprint = "",
1705
+ presenceIntervalMs = 60_000,
1706
+ presenceKeepaliveMs = 180_000,
1707
+ } = {}) {
1708
+ const lifecycleType = normalizeString(lifecycle.type) || "heartbeat";
1709
+ if (lifecycleType !== "heartbeat") {
1710
+ return { publish: true, fingerprint: lastFingerprint, reason: "lifecycle" };
1711
+ }
1712
+ const fingerprint = listenerPresenceHeartbeatFingerprint(lifecycle);
1713
+ if (lifecycle.stopping) {
1714
+ return { publish: true, fingerprint, reason: "stopping" };
1715
+ }
1716
+ const currentMs = Number(nowMs) || Date.now();
1717
+ const previousMs = Number(lastHeartbeatMs) || 0;
1718
+ if (previousMs <= 0) {
1719
+ return { publish: true, fingerprint, reason: "first" };
1720
+ }
1721
+ if (fingerprint !== lastFingerprint) {
1722
+ return { publish: true, fingerprint, reason: "changed" };
1723
+ }
1724
+ const minIntervalMs = Math.max(1, Number(presenceIntervalMs) || 60_000);
1725
+ const keepaliveMs = Math.max(minIntervalMs, Number(presenceKeepaliveMs) || 180_000);
1726
+ const elapsedMs = currentMs - previousMs;
1727
+ if (elapsedMs >= keepaliveMs) {
1728
+ return { publish: true, fingerprint, reason: "keepalive" };
1729
+ }
1730
+ return { publish: false, fingerprint, reason: elapsedMs < minIntervalMs ? "interval" : "unchanged" };
1731
+ }
1732
+
1605
1733
  async function publishListenerPresenceEvent({
1606
1734
  sessionId,
1607
1735
  targetPath,
@@ -1651,6 +1779,8 @@ async function publishListenerPresenceEvent({
1651
1779
  idleIntervalSeconds: lifecycle.idleIntervalSeconds,
1652
1780
  activeIntervalSeconds: lifecycle.activeIntervalSeconds,
1653
1781
  activeWindowSeconds: lifecycle.activeWindowSeconds,
1782
+ presenceIntervalSeconds: lifecycle.presenceIntervalSeconds,
1783
+ presenceKeepaliveSeconds: lifecycle.presenceKeepaliveSeconds,
1654
1784
  lastHumanActivityAt: lifecycle.lastHumanActivityAt,
1655
1785
  lastSleepMs: lifecycle.lastSleepMs,
1656
1786
  nextPollMs: lifecycle.nextPollMs,
@@ -2115,13 +2245,6 @@ function formatEventLine(event = {}) {
2115
2245
  return `${ts} ${agentId} ${type}`;
2116
2246
  }
2117
2247
 
2118
- function isSessionControlEvent(event = {}) {
2119
- const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
2120
- const type = normalizeString(event.event || event.type).toLowerCase();
2121
- if (type.startsWith("session_listener_")) return true;
2122
- return normalizeString(payload.source).toLowerCase() === "session_listen";
2123
- }
2124
-
2125
2248
  function summarizeSessionTailEvent(event = null) {
2126
2249
  if (!event || typeof event !== "object") return null;
2127
2250
  return {
@@ -2288,6 +2411,7 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2288
2411
  reason: "",
2289
2412
  remoteEvents: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
2290
2413
  appended: appended.appended,
2414
+ upgraded: appended.upgraded,
2291
2415
  skipped: appended.skipped,
2292
2416
  failed: appended.failed,
2293
2417
  cursor: remoteTail.cursor || null,
@@ -2305,6 +2429,34 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2305
2429
  }
2306
2430
  }
2307
2431
 
2432
+ function remoteSessionEventUpgradesLocal(localEvent = {}, remoteEvent = {}) {
2433
+ return sessionEventUpgradesExisting(localEvent, remoteEvent);
2434
+ }
2435
+
2436
+ function indexSessionEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
2437
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
2438
+ for (const key of sessionEventIdentityKeys(event)) {
2439
+ const existingIndex = indexByKey.get(key);
2440
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
2441
+ ? eventList[existingIndex]
2442
+ : null;
2443
+ if (!existingEvent || remoteSessionEventUpgradesLocal(existingEvent, event)) {
2444
+ indexByKey.set(key, index);
2445
+ }
2446
+ }
2447
+ }
2448
+
2449
+ function findSessionEventIdentityIndex(indexByKey, event = {}) {
2450
+ if (!indexByKey) return -1;
2451
+ for (const key of sessionEventIdentityKeys(event)) {
2452
+ const index = indexByKey.get(key);
2453
+ if (Number.isInteger(index) && index >= 0) {
2454
+ return index;
2455
+ }
2456
+ }
2457
+ return -1;
2458
+ }
2459
+
2308
2460
  async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
2309
2461
  const events = Array.isArray(remoteEvents) ? remoteEvents : [];
2310
2462
  if (events.length === 0) {
@@ -2315,18 +2467,47 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2315
2467
  };
2316
2468
  }
2317
2469
  const knownKeys = new Set();
2470
+ const localIndexByKey = new Map();
2318
2471
  const localEvents = await readStream(sessionId, {
2319
2472
  targetPath,
2320
2473
  tail: 0,
2321
2474
  });
2322
- for (const event of localEvents) {
2475
+ for (const [index, event] of localEvents.entries()) {
2323
2476
  addSessionEventIdentityKeys(knownKeys, event);
2477
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, event, index);
2324
2478
  }
2325
2479
 
2326
2480
  let appended = 0;
2481
+ let upgraded = 0;
2327
2482
  let skipped = 0;
2328
2483
  let failed = 0;
2329
2484
  for (const event of events) {
2485
+ const existingIndex = findSessionEventIdentityIndex(localIndexByKey, event);
2486
+ if (existingIndex >= 0) {
2487
+ const existing = localEvents[existingIndex];
2488
+ if (!remoteSessionEventUpgradesLocal(existing, event)) {
2489
+ skipped += 1;
2490
+ continue;
2491
+ }
2492
+ // Keep the richer canonical API row in local history when it
2493
+ // upgrades an optimistic/local post that had no durable cursor yet.
2494
+ // Future reads can then page by sequence instead of rediscovering the
2495
+ // upgrade every time.
2496
+ try {
2497
+ const persisted = await appendToStream(sessionId, event, {
2498
+ targetPath,
2499
+ syncRemote: false,
2500
+ });
2501
+ localEvents.push(persisted);
2502
+ addSessionEventIdentityKeys(knownKeys, persisted);
2503
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, persisted, localEvents.length - 1);
2504
+ upgraded += 1;
2505
+ } catch {
2506
+ addSessionEventIdentityKeys(knownKeys, event);
2507
+ failed += 1;
2508
+ }
2509
+ continue;
2510
+ }
2330
2511
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
2331
2512
  skipped += 1;
2332
2513
  continue;
@@ -2345,6 +2526,7 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2345
2526
  }
2346
2527
  return {
2347
2528
  appended,
2529
+ upgraded,
2348
2530
  skipped,
2349
2531
  failed,
2350
2532
  };
@@ -3998,6 +4180,11 @@ export function registerSessionCommand(program) {
3998
4180
  "Minimum seconds between remote listener heartbeat events (default 60)",
3999
4181
  "60",
4000
4182
  )
4183
+ .option(
4184
+ "--presence-keepalive <seconds>",
4185
+ "Maximum seconds between unchanged remote listener heartbeat events (default 180)",
4186
+ "180",
4187
+ )
4001
4188
  .option(
4002
4189
  "--no-presence",
4003
4190
  "Do not publish durable listener lifecycle/heartbeat events",
@@ -4050,6 +4237,11 @@ export function registerSessionCommand(program) {
4050
4237
  "presence-interval",
4051
4238
  60,
4052
4239
  );
4240
+ const presenceKeepaliveSeconds = parsePositiveInteger(
4241
+ options.presenceKeepalive,
4242
+ "presence-keepalive",
4243
+ 180,
4244
+ );
4053
4245
  const listenerIdentity = inferSessionAgentIdentity({
4054
4246
  agentId,
4055
4247
  model: options.model,
@@ -4132,7 +4324,10 @@ export function registerSessionCommand(program) {
4132
4324
  const durablePresenceEnabled = options.presence !== false;
4133
4325
  const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
4134
4326
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
4327
+ const presenceKeepaliveMs =
4328
+ Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, 1) * 1000;
4135
4329
  let lastPresenceHeartbeatMs = 0;
4330
+ let lastPresenceHeartbeatFingerprint = "";
4136
4331
 
4137
4332
  // Periodic in-session success reminders (ack, claim work, reply
4138
4333
  // in-thread). Long-running interactive listeners only — skipped under
@@ -4314,11 +4509,25 @@ export function registerSessionCommand(program) {
4314
4509
  }
4315
4510
  if (!publishPresence) return;
4316
4511
  const lifecycleType = normalizeString(lifecycle?.type);
4512
+ const publishLifecycle = {
4513
+ ...lifecycle,
4514
+ presenceIntervalSeconds,
4515
+ presenceKeepaliveSeconds: Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds),
4516
+ };
4317
4517
  if (lifecycleType === "heartbeat") {
4318
4518
  const nowMs = Date.now();
4319
- if (lastPresenceHeartbeatMs > 0 && nowMs - lastPresenceHeartbeatMs < presenceIntervalMs) {
4519
+ const heartbeatDecision = shouldPublishListenerPresenceHeartbeat({
4520
+ lifecycle: publishLifecycle,
4521
+ nowMs,
4522
+ lastHeartbeatMs: lastPresenceHeartbeatMs,
4523
+ lastFingerprint: lastPresenceHeartbeatFingerprint,
4524
+ presenceIntervalMs,
4525
+ presenceKeepaliveMs,
4526
+ });
4527
+ if (!heartbeatDecision.publish) {
4320
4528
  return;
4321
4529
  }
4530
+ lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
4322
4531
  lastPresenceHeartbeatMs = nowMs;
4323
4532
  }
4324
4533
  await publishListenerPresenceEvent({
@@ -4330,7 +4539,7 @@ export function registerSessionCommand(program) {
4330
4539
  provider,
4331
4540
  clientKind,
4332
4541
  listenerId,
4333
- lifecycle,
4542
+ lifecycle: publishLifecycle,
4334
4543
  });
4335
4544
  },
4336
4545
  });
@@ -4853,9 +5062,6 @@ export function registerSessionCommand(program) {
4853
5062
  const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
4854
5063
  const emitJson = shouldEmitJson(options, command);
4855
5064
  const includeControlEvents = Boolean(options.includeControlEvents);
4856
- const remoteTailLimit = includeControlEvents
4857
- ? tail
4858
- : Math.min(200, Math.max(tail, tail + 20));
4859
5065
  const readAgentId = await defaultAgentId(options.agent, targetPath);
4860
5066
 
4861
5067
  let hydration = null;
@@ -4874,11 +5080,13 @@ export function registerSessionCommand(program) {
4874
5080
  hydration = await hydrateSessionFromRemote({
4875
5081
  sessionId: normalizedSessionId,
4876
5082
  targetPath,
5083
+ includeControlEvents,
4877
5084
  });
4878
- remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
5085
+ remoteTail = await pollSessionEventsBeforeTranscriptTail(normalizedSessionId, {
4879
5086
  targetPath,
4880
5087
  beforeSequence,
4881
- limit: remoteTailLimit,
5088
+ tail,
5089
+ includeControlEvents,
4882
5090
  timeoutMs: 15_000,
4883
5091
  });
4884
5092
  if (options.actions !== false) {
@@ -4954,12 +5162,29 @@ export function registerSessionCommand(program) {
4954
5162
  const displayEvents = [...allEvents];
4955
5163
  let remoteTailAppended = 0;
4956
5164
  let remoteTailDisplayedOnly = 0;
4957
- if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
5165
+ let remoteTailUpgraded = 0;
5166
+ const remoteTailAppendEvents = includeControlEvents
5167
+ ? remoteTail?.events
5168
+ : filterSessionMaterialEvents(remoteTail?.events || []);
5169
+ if (remoteTail?.ok && Array.isArray(remoteTailAppendEvents) && remoteTailAppendEvents.length > 0) {
4958
5170
  const knownKeys = new Set();
4959
- for (const event of allEvents) {
5171
+ const displayIndexByKey = new Map();
5172
+ for (const [index, event] of displayEvents.entries()) {
4960
5173
  addSessionEventIdentityKeys(knownKeys, event);
5174
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, index);
4961
5175
  }
4962
- for (const event of remoteTail.events) {
5176
+ for (const event of remoteTailAppendEvents) {
5177
+ const existingIndex = findSessionEventIdentityIndex(displayIndexByKey, event);
5178
+ if (existingIndex >= 0) {
5179
+ const existing = displayEvents[existingIndex];
5180
+ if (remoteSessionEventUpgradesLocal(existing, event)) {
5181
+ displayEvents[existingIndex] = event;
5182
+ addSessionEventIdentityKeys(knownKeys, event);
5183
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, existingIndex);
5184
+ remoteTailUpgraded += 1;
5185
+ }
5186
+ continue;
5187
+ }
4963
5188
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
4964
5189
  continue;
4965
5190
  }
@@ -4970,10 +5195,12 @@ export function registerSessionCommand(program) {
4970
5195
  });
4971
5196
  displayEvents.push(appended);
4972
5197
  addSessionEventIdentityKeys(knownKeys, appended);
5198
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, appended, displayEvents.length - 1);
4973
5199
  remoteTailAppended += 1;
4974
5200
  } catch {
4975
5201
  displayEvents.push(event);
4976
5202
  addSessionEventIdentityKeys(knownKeys, event);
5203
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, displayEvents.length - 1);
4977
5204
  remoteTailDisplayedOnly += 1;
4978
5205
  }
4979
5206
  }
@@ -4987,10 +5214,11 @@ export function registerSessionCommand(program) {
4987
5214
  const recentHumanActivity = remoteActions?.ok
4988
5215
  ? readProjectionRecentHumanActivity(remoteActions)
4989
5216
  : [];
5217
+ const dedupedDisplayEvents = dedupeSessionEvents(displayEvents);
4990
5218
  const transcriptEvents = includeControlEvents
4991
- ? displayEvents
4992
- : displayEvents.filter((event) => !isSessionControlEvent(event));
4993
- const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
5219
+ ? dedupedDisplayEvents
5220
+ : dedupedDisplayEvents.filter((event) => !isSessionControlEvent(event));
5221
+ const hiddenControlEventCount = dedupedDisplayEvents.length - transcriptEvents.length;
4994
5222
  const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
4995
5223
  const autoView = recordSessionReadViews(normalizedSessionId, events, {
4996
5224
  targetPath,
@@ -5037,9 +5265,12 @@ export function registerSessionCommand(program) {
5037
5265
  count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
5038
5266
  cursor: remoteTail.cursor || null,
5039
5267
  beforeSequence: remoteTail.beforeSequence || null,
5268
+ pageCount: Number(remoteTail.pageCount || 0),
5269
+ materialBackfillComplete: Boolean(remoteTail.materialBackfillComplete),
5040
5270
  verified: Boolean(remoteTail.ok),
5041
5271
  appended: remoteTailAppended,
5042
5272
  displayedOnly: remoteTailDisplayedOnly,
5273
+ upgraded: remoteTailUpgraded,
5043
5274
  controlEventCount: remoteTailStats?.controlEventCount || 0,
5044
5275
  materialEventCount: remoteTailStats?.materialEventCount || 0,
5045
5276
  latestEvent: remoteTailStats?.latestEvent || null,
@@ -5111,6 +5342,7 @@ export function registerSessionCommand(program) {
5111
5342
  signal: ac.signal,
5112
5343
  })) {
5113
5344
  if (item.event) {
5345
+ if (!includeControlEvents && isSessionControlEvent(item.event)) continue;
5114
5346
  if (emitJson) {
5115
5347
  console.log(JSON.stringify({ source: item.source, event: item.event }));
5116
5348
  } else {
@@ -5139,6 +5371,9 @@ export function registerSessionCommand(program) {
5139
5371
  continue;
5140
5372
  }
5141
5373
  addSessionEventIdentityKeys(seenFollowEvents, event);
5374
+ if (!includeControlEvents && isSessionControlEvent(event)) {
5375
+ continue;
5376
+ }
5142
5377
  if (emitJson) {
5143
5378
  console.log(JSON.stringify(event));
5144
5379
  } else {
@@ -5580,6 +5815,7 @@ export function registerSessionCommand(program) {
5580
5815
  "Hydrate from the SentinelLayer API before exporting and include remote message actions",
5581
5816
  )
5582
5817
  .option("--no-actions", "Do not include remote message actions/replies/reactions")
5818
+ .option("--include-control-events", "Include listener/control-plane events in the exported transcript")
5583
5819
  .option("--path <path>", "Workspace path for the session", ".")
5584
5820
  .action(async (sessionId, options) => {
5585
5821
  const normalizedSessionId = normalizeString(sessionId);
@@ -5597,6 +5833,7 @@ export function registerSessionCommand(program) {
5597
5833
  hydration = await hydrateSessionFromRemote({
5598
5834
  sessionId: normalizedSessionId,
5599
5835
  targetPath,
5836
+ includeControlEvents: Boolean(options.includeControlEvents),
5600
5837
  }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
5601
5838
  if (options.actions !== false) {
5602
5839
  remoteActions = await listSessionMessageActions(normalizedSessionId, {
@@ -5629,7 +5866,12 @@ export function registerSessionCommand(program) {
5629
5866
  const actionEvents = remoteActions?.ok
5630
5867
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
5631
5868
  : [];
5632
- const exportEvents = mergeSessionActionEvents(events, actionEvents);
5869
+ const includeControlEvents = Boolean(options.includeControlEvents);
5870
+ const transcriptBaseEvents = includeControlEvents
5871
+ ? events
5872
+ : filterSessionMaterialEvents(events);
5873
+ const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
5874
+ const exportEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
5633
5875
  const stats = computeTranscriptStats({
5634
5876
  sessionMeta: sessionPayload,
5635
5877
  events: exportEvents,
@@ -5643,6 +5885,15 @@ export function registerSessionCommand(program) {
5643
5885
  if (format === "ndjson") {
5644
5886
  const lines = [];
5645
5887
  lines.push(JSON.stringify({ kind: "session", value: sessionPayload }));
5888
+ lines.push(JSON.stringify({
5889
+ kind: "export_metadata",
5890
+ value: {
5891
+ includeControlEvents,
5892
+ hiddenControlEventCount,
5893
+ rawEventCount: events.length,
5894
+ eventCount: exportEvents.length,
5895
+ },
5896
+ }));
5646
5897
  for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
5647
5898
  for (const participant of participants) {
5648
5899
  lines.push(JSON.stringify({ kind: "participant", value: participant }));
@@ -5684,10 +5935,12 @@ export function registerSessionCommand(program) {
5684
5935
  registeredAgents: agents.length,
5685
5936
  events: exportEvents.length,
5686
5937
  rawEvents: events.length,
5938
+ hiddenControlEvents: hiddenControlEventCount,
5687
5939
  actions: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
5688
5940
  actionEvents: actionEvents.length,
5689
5941
  tasks: (tasks.tasks || []).length,
5690
5942
  },
5943
+ includeControlEvents,
5691
5944
  totals: stats.totals,
5692
5945
  },
5693
5946
  null,
@@ -5704,7 +5957,7 @@ export function registerSessionCommand(program) {
5704
5957
  pc.gray(
5705
5958
  `Exported ${exportEvents.length} events / ${participants.length} participants (${agents.length} registered agents) / ${
5706
5959
  (tasks.tasks || []).length
5707
- } tasks → ${outPath}`,
5960
+ } tasks${hiddenControlEventCount ? ` (${hiddenControlEventCount} control events omitted)` : ""} → ${outPath}`,
5708
5961
  ),
5709
5962
  );
5710
5963
  } else {
@@ -5727,6 +5980,7 @@ export function registerSessionCommand(program) {
5727
5980
  "Hydrate from the SentinelLayer API before rendering (pulls web-posted messages into the local NDJSON)",
5728
5981
  )
5729
5982
  .option("--no-actions", "Do not include remote message actions/replies/reactions")
5983
+ .option("--include-control-events", "Include listener/control-plane events in the Markdown transcript")
5730
5984
  .option("--path <path>", "Workspace path for the session", ".")
5731
5985
  .option("--json", "Emit machine-readable output")
5732
5986
  .action(async (sessionId, options, command) => {
@@ -5743,6 +5997,7 @@ export function registerSessionCommand(program) {
5743
5997
  hydration = await hydrateSessionFromRemote({
5744
5998
  sessionId: normalizedSessionId,
5745
5999
  targetPath,
6000
+ includeControlEvents: Boolean(options.includeControlEvents),
5746
6001
  }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
5747
6002
  if (options.actions !== false) {
5748
6003
  remoteActions = await listSessionMessageActions(normalizedSessionId, {
@@ -5765,7 +6020,12 @@ export function registerSessionCommand(program) {
5765
6020
  const actionEvents = remoteActions?.ok
5766
6021
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
5767
6022
  : [];
5768
- const transcriptEvents = mergeSessionActionEvents(events, actionEvents);
6023
+ const includeControlEvents = Boolean(options.includeControlEvents);
6024
+ const transcriptBaseEvents = includeControlEvents
6025
+ ? events
6026
+ : filterSessionMaterialEvents(events);
6027
+ const hiddenControlEventCount = events.length - transcriptBaseEvents.length;
6028
+ const transcriptEvents = mergeSessionActionEvents(transcriptBaseEvents, actionEvents);
5769
6029
 
5770
6030
  // Pull GitHub/Google avatar + display name from the active auth
5771
6031
  // session so any human-id seen in the stream renders with the
@@ -5830,6 +6090,8 @@ export function registerSessionCommand(program) {
5830
6090
  rawEventCount: events.length,
5831
6091
  actionCount: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
5832
6092
  actionEventCount: actionEvents.length,
6093
+ includeControlEvents,
6094
+ hiddenControlEventCount,
5833
6095
  agentCount: participants.length,
5834
6096
  participantCount: participants.length,
5835
6097
  derivedAgentCount: stats.agents.length,
@@ -5861,6 +6123,9 @@ export function registerSessionCommand(program) {
5861
6123
  `${transcriptEvents.length} events · ${participants.length} participants (${agents.length} registered agents) · actions=${payload.actionCount} · live ${stats.sessionLiveSeconds}s · senti=${stats.sentiActions} · tokens=${stats.totals.tokenTotal} · cost=$${stats.totals.costTotalUsd.toFixed(4)}`,
5862
6124
  ),
5863
6125
  );
6126
+ if (hiddenControlEventCount > 0) {
6127
+ console.log(pc.gray(`${hiddenControlEventCount} control events omitted; rerun with --include-control-events to inspect them.`));
6128
+ }
5864
6129
  });
5865
6130
 
5866
6131
  session
@@ -5,6 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
5
5
  import { createAgentEvent } from "../events/schema.js";
6
6
  import { appendToStream } from "../session/stream.js";
7
7
  import { eventMatchesAgent } from "../session/listener.js";
8
+ import { isSessionControlEvent } from "../session/control-events.js";
8
9
  import {
9
10
  listSessionMessageActions,
10
11
  pollSessionEvents,
@@ -102,20 +103,6 @@ function eventAgentId(event = {}) {
102
103
  return normalizeAgentId(event?.agent?.id || event?.agentId || event?.agent_id, "");
103
104
  }
104
105
 
105
- function eventName(event = {}) {
106
- return normalizeString(event?.event || event?.type).toLowerCase();
107
- }
108
-
109
- function isControlEvent(event = {}) {
110
- const name = eventName(event);
111
- const payload = isPlainObject(event?.payload) ? event.payload : {};
112
- return (
113
- name.startsWith("session_listen_") ||
114
- name === "agent_heartbeat" ||
115
- normalizeString(payload.source).toLowerCase() === "session_listen"
116
- );
117
- }
118
-
119
106
  function safePayload(payload = {}) {
120
107
  if (!isPlainObject(payload)) return {};
121
108
  const next = { ...payload };
@@ -627,7 +614,7 @@ export function createSessionMcpToolHandlers({
627
614
  }
628
615
 
629
616
  const events = (Array.isArray(result.events) ? result.events : []).filter((event) => {
630
- if (!includeControlEvents && isControlEvent(event)) return false;
617
+ if (!includeControlEvents && isSessionControlEvent(event)) return false;
631
618
  if (!includeSelf && eventAgentId(event) === agentId) return false;
632
619
  return eventMatchesAgent(event, agentId);
633
620
  });
@@ -0,0 +1,40 @@
1
+ const SESSION_CONTROL_EVENT_TYPES = new Set([
2
+ "listener_stop",
3
+ "session_coaching",
4
+ "session_listen_catchup",
5
+ "session_listen_error",
6
+ ]);
7
+
8
+ function normalizeString(value) {
9
+ return String(value || "").trim();
10
+ }
11
+
12
+ function eventType(event = {}) {
13
+ return normalizeString(event?.event || event?.type).toLowerCase();
14
+ }
15
+
16
+ function payloadSource(event = {}) {
17
+ const payload = event?.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
18
+ ? event.payload
19
+ : {};
20
+ return normalizeString(payload.source).toLowerCase();
21
+ }
22
+
23
+ export function isSessionListenerLifecycleEvent(event = {}) {
24
+ const type = eventType(event);
25
+ if (type.startsWith("session_listener_")) return true;
26
+ return payloadSource(event) === "session_listen";
27
+ }
28
+
29
+ export function isSessionControlEvent(event = {}) {
30
+ const type = eventType(event);
31
+ return (
32
+ isSessionListenerLifecycleEvent(event) ||
33
+ type.startsWith("session_listen_") ||
34
+ SESSION_CONTROL_EVENT_TYPES.has(type)
35
+ );
36
+ }
37
+
38
+ export function filterSessionMaterialEvents(events = []) {
39
+ return (Array.isArray(events) ? events : []).filter((event) => !isSessionControlEvent(event));
40
+ }
@@ -15,6 +15,24 @@ function timestampKey(...values) {
15
15
  return "";
16
16
  }
17
17
 
18
+ export function readSessionEventSequence(event = {}) {
19
+ const value = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
20
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
21
+ }
22
+
23
+ export function sessionEventHasDurableCursor(event = {}) {
24
+ return Boolean(keyString(event?.cursor));
25
+ }
26
+
27
+ export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
28
+ const existingSequence = readSessionEventSequence(existingEvent);
29
+ const candidateSequence = readSessionEventSequence(candidateEvent);
30
+ if (candidateSequence > 0 && existingSequence <= 0) {
31
+ return true;
32
+ }
33
+ return sessionEventHasDurableCursor(candidateEvent) && !sessionEventHasDurableCursor(existingEvent);
34
+ }
35
+
18
36
  function stableJsonValue(value) {
19
37
  if (Array.isArray(value)) {
20
38
  return value.map((item) => stableJsonValue(item));
@@ -51,6 +69,10 @@ export function sessionEventIdentityKeys(event = {}) {
51
69
  keys.push(`idempotency:${event.idempotencyToken.trim()}`);
52
70
  }
53
71
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
72
+ const clientMessageId = typeof payload.clientMessageId === "string" ? payload.clientMessageId.trim() : "";
73
+ if (clientMessageId) {
74
+ keys.push(`client-message:${clientMessageId}`);
75
+ }
54
76
  const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
55
77
  if (messageId) {
56
78
  keys.push(`message:${messageId}`);
@@ -2,6 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
2
2
 
3
3
  import { pollSessionEvents, pollSessionEventsBefore, streamSessionEvents } from "./sync.js";
4
4
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
+ import { isSessionListenerLifecycleEvent } from "./control-events.js";
5
6
 
6
7
  const BROADCAST_RECIPIENTS = new Set([
7
8
  "*",
@@ -223,14 +224,6 @@ function isRecentActivity(activityMs, nowMs, windowMs) {
223
224
  );
224
225
  }
225
226
 
226
- function isListenerLifecycleEvent(event = {}) {
227
- if (!isPlainObject(event)) return false;
228
- const payload = isPlainObject(event.payload) ? event.payload : {};
229
- const eventType = normalizeString(event.event || event.type).toLowerCase();
230
- if (eventType.startsWith("session_listener_")) return true;
231
- return normalizeString(payload.source).toLowerCase() === "session_listen";
232
- }
233
-
234
227
  /**
235
228
  * Poll session events in the background and emit only events addressed to
236
229
  * the current agent or broadcast to everyone. The loop advances its cursor
@@ -409,7 +402,7 @@ export async function listenSessionEvents({
409
402
  if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
410
403
  lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
411
404
  }
412
- if (isListenerLifecycleEvent(event)) continue;
405
+ if (isSessionListenerLifecycleEvent(event)) continue;
413
406
  if (!eventMatchesAgent(event, normalizedAgentId)) continue;
414
407
  visibleEvents.push(event);
415
408
  }
@@ -56,6 +56,8 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
56
56
  const active = Boolean(payload.active);
57
57
  const idleIntervalSeconds = positiveInt(payload.idleIntervalSeconds);
58
58
  const activeIntervalSeconds = positiveInt(payload.activeIntervalSeconds);
59
+ const presenceKeepaliveSeconds = positiveInt(payload.presenceKeepaliveSeconds);
60
+ const presenceIntervalSeconds = positiveInt(payload.presenceIntervalSeconds);
59
61
  const nextPollSeconds = positiveInt(payload.nextPollMs)
60
62
  ? Math.round(positiveInt(payload.nextPollMs) / 1000)
61
63
  : null;
@@ -64,9 +66,20 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
64
66
  const cadenceSeconds = active
65
67
  ? activeIntervalSeconds || nextPollSeconds
66
68
  : idleIntervalSeconds || nextPollSeconds;
69
+ const expectedPresenceSeconds =
70
+ presenceKeepaliveSeconds ||
71
+ presenceIntervalSeconds ||
72
+ cadenceSeconds ||
73
+ idleIntervalSeconds ||
74
+ activeIntervalSeconds ||
75
+ null;
76
+ const staleAfterForRowMs = Math.max(
77
+ staleAfterMs,
78
+ expectedPresenceSeconds ? expectedPresenceSeconds * 2_500 : 0,
79
+ );
67
80
  let status;
68
81
  if (stopped) status = "stopped";
69
- else if (ageMs !== null && ageMs > staleAfterMs) status = "stale";
82
+ else if (ageMs !== null && ageMs > staleAfterForRowMs) status = "stale";
70
83
  else status = active ? "active" : "idle";
71
84
 
72
85
  rows.push({
@@ -78,6 +91,8 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
78
91
  cadenceSeconds: cadenceSeconds ?? null,
79
92
  idleIntervalSeconds,
80
93
  activeIntervalSeconds,
94
+ presenceIntervalSeconds,
95
+ presenceKeepaliveSeconds,
81
96
  nextPollSeconds,
82
97
  lastSeenAt: epoch ? new Date(epoch).toISOString() : null,
83
98
  lastSeenAgoSeconds: ageMs !== null ? Math.round(ageMs / 1000) : null,
@@ -29,20 +29,49 @@ import {
29
29
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
30
30
  import {
31
31
  addSessionEventIdentityKeys,
32
+ sessionEventIdentityKeys,
32
33
  sessionEventHasKnownIdentity,
34
+ sessionEventUpgradesExisting,
33
35
  } from "./event-identity.js";
36
+ import { isSessionControlEvent } from "./control-events.js";
34
37
 
35
38
  const EVENTS_CURSOR_SUFFIX = "events";
36
39
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
37
40
  const DEFAULT_MAX_EVENT_PAGES = 25;
38
41
 
39
- async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
42
+ function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
43
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
44
+ for (const key of sessionEventIdentityKeys(event)) {
45
+ const existingIndex = indexByKey.get(key);
46
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
47
+ ? eventList[existingIndex]
48
+ : null;
49
+ if (!existingEvent || sessionEventUpgradesExisting(existingEvent, event)) {
50
+ indexByKey.set(key, index);
51
+ }
52
+ }
53
+ }
54
+
55
+ function findRelayEventIdentityIndex(indexByKey, event = {}) {
56
+ if (!indexByKey) return -1;
57
+ for (const key of sessionEventIdentityKeys(event)) {
58
+ const index = indexByKey.get(key);
59
+ if (Number.isInteger(index) && index >= 0) {
60
+ return index;
61
+ }
62
+ }
63
+ return -1;
64
+ }
65
+
66
+ async function readExistingRelayState(sessionId, { targetPath = process.cwd() } = {}) {
40
67
  const knownKeys = new Set();
41
68
  const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
42
- for (const event of events) {
69
+ const indexByKey = new Map();
70
+ for (const [index, event] of events.entries()) {
43
71
  addSessionEventIdentityKeys(knownKeys, event);
72
+ indexRelayEventIdentityKeys(indexByKey, events, event, index);
44
73
  }
45
- return knownKeys;
74
+ return { knownKeys, events, indexByKey };
46
75
  }
47
76
 
48
77
  async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
@@ -92,10 +121,13 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
92
121
  return { materialized: true, refreshed: false, session: created, remoteStatus };
93
122
  }
94
123
 
95
- function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
124
+ function sourceFullyRelayed(events = [], successfulKeys = new Set(), failedKeys = new Set()) {
96
125
  const relayedEvents = Array.isArray(events) ? events : [];
97
126
  if (relayedEvents.length === 0) return true;
98
- return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
127
+ return relayedEvents.every((event) =>
128
+ !sessionEventHasKnownIdentity(event, failedKeys) &&
129
+ sessionEventHasKnownIdentity(event, successfulKeys)
130
+ );
99
131
  }
100
132
 
101
133
  function markPostKillEvent(event = {}) {
@@ -242,6 +274,7 @@ export async function hydrateSessionFromRemote({
242
274
  probeOpenCircuit = true,
243
275
  eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
244
276
  maxEventPages = DEFAULT_MAX_EVENT_PAGES,
277
+ includeControlEvents = false,
245
278
  } = {}) {
246
279
  if (!sessionId || typeof sessionId !== "string") {
247
280
  return {
@@ -341,12 +374,37 @@ export async function hydrateSessionFromRemote({
341
374
  let relayed = 0;
342
375
  let materializedLocalSession = false;
343
376
  let remoteStatus = "";
344
- const successfulRelayKeys =
345
- merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
346
- const newEvents = successfulRelayKeys.size > 0
347
- ? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
348
- : merged;
349
- if (newEvents.length > 0) {
377
+ const relayState = merged.length > 0
378
+ ? await readExistingRelayState(sessionId, { targetPath })
379
+ : { knownKeys: new Set(), events: [], indexByKey: new Map() };
380
+ const successfulRelayKeys = relayState.knownKeys;
381
+ const existingRelayEvents = relayState.events;
382
+ const existingRelayIndexByKey = relayState.indexByKey;
383
+ const newEvents = [];
384
+ for (const event of merged) {
385
+ const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
386
+ if (existingIndex >= 0) {
387
+ const existing = existingRelayEvents[existingIndex];
388
+ if (sessionEventUpgradesExisting(existing, event)) {
389
+ newEvents.push(event);
390
+ }
391
+ continue;
392
+ }
393
+ if (!sessionEventHasKnownIdentity(event, successfulRelayKeys)) {
394
+ newEvents.push(event);
395
+ }
396
+ }
397
+ let skippedControlEventCount = 0;
398
+ const materialNewEvents = [];
399
+ for (const event of newEvents) {
400
+ if (!includeControlEvents && isSessionControlEvent(event)) {
401
+ skippedControlEventCount += 1;
402
+ addSessionEventIdentityKeys(successfulRelayKeys, event);
403
+ continue;
404
+ }
405
+ materialNewEvents.push(event);
406
+ }
407
+ if (materialNewEvents.length > 0) {
350
408
  try {
351
409
  const localSession = await _ensureLocalSession(sessionId, { targetPath });
352
410
  materializedLocalSession = Boolean(localSession?.materialized);
@@ -357,24 +415,35 @@ export async function hydrateSessionFromRemote({
357
415
  // returns a structured result.
358
416
  }
359
417
  }
418
+
360
419
  const appendEvents =
361
420
  remoteStatus && !["active", "pending"].includes(remoteStatus)
362
- ? newEvents.map((event) => markPostKillEvent(event))
363
- : newEvents;
421
+ ? materialNewEvents.map((event) => markPostKillEvent(event))
422
+ : materialNewEvents;
423
+ const failedRelayKeys = new Set();
364
424
  for (const event of appendEvents) {
365
425
  try {
366
- await _append(sessionId, event, { targetPath, syncRemote: false });
426
+ const persisted = await _append(sessionId, event, { targetPath, syncRemote: false });
367
427
  relayed += 1;
368
- addSessionEventIdentityKeys(successfulRelayKeys, event);
428
+ const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
429
+ existingRelayEvents.push(persistedEvent);
430
+ addSessionEventIdentityKeys(successfulRelayKeys, persistedEvent);
431
+ indexRelayEventIdentityKeys(
432
+ existingRelayIndexByKey,
433
+ existingRelayEvents,
434
+ persistedEvent,
435
+ existingRelayEvents.length - 1,
436
+ );
369
437
  } catch {
438
+ addSessionEventIdentityKeys(failedRelayKeys, event);
370
439
  // Append errors are observable via the stream but should not
371
440
  // abort the rest of the batch — partial relay is still progress.
372
441
  }
373
442
  }
374
443
 
375
444
  let persistedCursor = false;
376
- const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
377
- const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
445
+ const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys, failedRelayKeys);
446
+ const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys, failedRelayKeys);
378
447
  if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
379
448
  const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
380
449
  persistedCursor = Boolean(result && result.written);
@@ -395,6 +464,7 @@ export async function hydrateSessionFromRemote({
395
464
  persistedCursor,
396
465
  humanRelayed: (humanResult?.events || []).length,
397
466
  eventsRelayed: (eventsResult?.events || []).length,
467
+ controlEventsSkipped: skippedControlEventCount,
398
468
  eventsCursor:
399
469
  typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
400
470
  eventsPageCount: Number(eventsResult?.pageCount || 0),