sentinelayer-cli 0.30.4 → 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.4",
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,
@@ -703,6 +707,80 @@ function mergeSessionActionEvents(events = [], actionEvents = []) {
703
707
  .map((entry) => entry.event);
704
708
  }
705
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
+
706
784
  async function appendActionEventIfMissing(sessionId, actionEvent, { targetPath } = {}) {
707
785
  if (!actionEvent) return { appended: false, reason: "no_event", event: null };
708
786
  const existingEvents = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
@@ -1604,6 +1682,54 @@ function compactPayload(record = {}) {
1604
1682
  );
1605
1683
  }
1606
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
+
1607
1733
  async function publishListenerPresenceEvent({
1608
1734
  sessionId,
1609
1735
  targetPath,
@@ -1653,6 +1779,8 @@ async function publishListenerPresenceEvent({
1653
1779
  idleIntervalSeconds: lifecycle.idleIntervalSeconds,
1654
1780
  activeIntervalSeconds: lifecycle.activeIntervalSeconds,
1655
1781
  activeWindowSeconds: lifecycle.activeWindowSeconds,
1782
+ presenceIntervalSeconds: lifecycle.presenceIntervalSeconds,
1783
+ presenceKeepaliveSeconds: lifecycle.presenceKeepaliveSeconds,
1656
1784
  lastHumanActivityAt: lifecycle.lastHumanActivityAt,
1657
1785
  lastSleepMs: lifecycle.lastSleepMs,
1658
1786
  nextPollMs: lifecycle.nextPollMs,
@@ -2117,13 +2245,6 @@ function formatEventLine(event = {}) {
2117
2245
  return `${ts} ${agentId} ${type}`;
2118
2246
  }
2119
2247
 
2120
- function isSessionControlEvent(event = {}) {
2121
- const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
2122
- const type = normalizeString(event.event || event.type).toLowerCase();
2123
- if (type.startsWith("session_listener_")) return true;
2124
- return normalizeString(payload.source).toLowerCase() === "session_listen";
2125
- }
2126
-
2127
2248
  function summarizeSessionTailEvent(event = null) {
2128
2249
  if (!event || typeof event !== "object") return null;
2129
2250
  return {
@@ -4059,6 +4180,11 @@ export function registerSessionCommand(program) {
4059
4180
  "Minimum seconds between remote listener heartbeat events (default 60)",
4060
4181
  "60",
4061
4182
  )
4183
+ .option(
4184
+ "--presence-keepalive <seconds>",
4185
+ "Maximum seconds between unchanged remote listener heartbeat events (default 180)",
4186
+ "180",
4187
+ )
4062
4188
  .option(
4063
4189
  "--no-presence",
4064
4190
  "Do not publish durable listener lifecycle/heartbeat events",
@@ -4111,6 +4237,11 @@ export function registerSessionCommand(program) {
4111
4237
  "presence-interval",
4112
4238
  60,
4113
4239
  );
4240
+ const presenceKeepaliveSeconds = parsePositiveInteger(
4241
+ options.presenceKeepalive,
4242
+ "presence-keepalive",
4243
+ 180,
4244
+ );
4114
4245
  const listenerIdentity = inferSessionAgentIdentity({
4115
4246
  agentId,
4116
4247
  model: options.model,
@@ -4193,7 +4324,10 @@ export function registerSessionCommand(program) {
4193
4324
  const durablePresenceEnabled = options.presence !== false;
4194
4325
  const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
4195
4326
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
4327
+ const presenceKeepaliveMs =
4328
+ Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, 1) * 1000;
4196
4329
  let lastPresenceHeartbeatMs = 0;
4330
+ let lastPresenceHeartbeatFingerprint = "";
4197
4331
 
4198
4332
  // Periodic in-session success reminders (ack, claim work, reply
4199
4333
  // in-thread). Long-running interactive listeners only — skipped under
@@ -4375,11 +4509,25 @@ export function registerSessionCommand(program) {
4375
4509
  }
4376
4510
  if (!publishPresence) return;
4377
4511
  const lifecycleType = normalizeString(lifecycle?.type);
4512
+ const publishLifecycle = {
4513
+ ...lifecycle,
4514
+ presenceIntervalSeconds,
4515
+ presenceKeepaliveSeconds: Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds),
4516
+ };
4378
4517
  if (lifecycleType === "heartbeat") {
4379
4518
  const nowMs = Date.now();
4380
- 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) {
4381
4528
  return;
4382
4529
  }
4530
+ lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
4383
4531
  lastPresenceHeartbeatMs = nowMs;
4384
4532
  }
4385
4533
  await publishListenerPresenceEvent({
@@ -4391,7 +4539,7 @@ export function registerSessionCommand(program) {
4391
4539
  provider,
4392
4540
  clientKind,
4393
4541
  listenerId,
4394
- lifecycle,
4542
+ lifecycle: publishLifecycle,
4395
4543
  });
4396
4544
  },
4397
4545
  });
@@ -4914,9 +5062,6 @@ export function registerSessionCommand(program) {
4914
5062
  const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
4915
5063
  const emitJson = shouldEmitJson(options, command);
4916
5064
  const includeControlEvents = Boolean(options.includeControlEvents);
4917
- const remoteTailLimit = includeControlEvents
4918
- ? tail
4919
- : Math.min(200, Math.max(tail, tail + 20));
4920
5065
  const readAgentId = await defaultAgentId(options.agent, targetPath);
4921
5066
 
4922
5067
  let hydration = null;
@@ -4935,11 +5080,13 @@ export function registerSessionCommand(program) {
4935
5080
  hydration = await hydrateSessionFromRemote({
4936
5081
  sessionId: normalizedSessionId,
4937
5082
  targetPath,
5083
+ includeControlEvents,
4938
5084
  });
4939
- remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
5085
+ remoteTail = await pollSessionEventsBeforeTranscriptTail(normalizedSessionId, {
4940
5086
  targetPath,
4941
5087
  beforeSequence,
4942
- limit: remoteTailLimit,
5088
+ tail,
5089
+ includeControlEvents,
4943
5090
  timeoutMs: 15_000,
4944
5091
  });
4945
5092
  if (options.actions !== false) {
@@ -5016,14 +5163,17 @@ export function registerSessionCommand(program) {
5016
5163
  let remoteTailAppended = 0;
5017
5164
  let remoteTailDisplayedOnly = 0;
5018
5165
  let remoteTailUpgraded = 0;
5019
- if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
5166
+ const remoteTailAppendEvents = includeControlEvents
5167
+ ? remoteTail?.events
5168
+ : filterSessionMaterialEvents(remoteTail?.events || []);
5169
+ if (remoteTail?.ok && Array.isArray(remoteTailAppendEvents) && remoteTailAppendEvents.length > 0) {
5020
5170
  const knownKeys = new Set();
5021
5171
  const displayIndexByKey = new Map();
5022
5172
  for (const [index, event] of displayEvents.entries()) {
5023
5173
  addSessionEventIdentityKeys(knownKeys, event);
5024
5174
  indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, index);
5025
5175
  }
5026
- for (const event of remoteTail.events) {
5176
+ for (const event of remoteTailAppendEvents) {
5027
5177
  const existingIndex = findSessionEventIdentityIndex(displayIndexByKey, event);
5028
5178
  if (existingIndex >= 0) {
5029
5179
  const existing = displayEvents[existingIndex];
@@ -5115,6 +5265,8 @@ export function registerSessionCommand(program) {
5115
5265
  count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
5116
5266
  cursor: remoteTail.cursor || null,
5117
5267
  beforeSequence: remoteTail.beforeSequence || null,
5268
+ pageCount: Number(remoteTail.pageCount || 0),
5269
+ materialBackfillComplete: Boolean(remoteTail.materialBackfillComplete),
5118
5270
  verified: Boolean(remoteTail.ok),
5119
5271
  appended: remoteTailAppended,
5120
5272
  displayedOnly: remoteTailDisplayedOnly,
@@ -5190,6 +5342,7 @@ export function registerSessionCommand(program) {
5190
5342
  signal: ac.signal,
5191
5343
  })) {
5192
5344
  if (item.event) {
5345
+ if (!includeControlEvents && isSessionControlEvent(item.event)) continue;
5193
5346
  if (emitJson) {
5194
5347
  console.log(JSON.stringify({ source: item.source, event: item.event }));
5195
5348
  } else {
@@ -5218,6 +5371,9 @@ export function registerSessionCommand(program) {
5218
5371
  continue;
5219
5372
  }
5220
5373
  addSessionEventIdentityKeys(seenFollowEvents, event);
5374
+ if (!includeControlEvents && isSessionControlEvent(event)) {
5375
+ continue;
5376
+ }
5221
5377
  if (emitJson) {
5222
5378
  console.log(JSON.stringify(event));
5223
5379
  } else {
@@ -5659,6 +5815,7 @@ export function registerSessionCommand(program) {
5659
5815
  "Hydrate from the SentinelLayer API before exporting and include remote message actions",
5660
5816
  )
5661
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")
5662
5819
  .option("--path <path>", "Workspace path for the session", ".")
5663
5820
  .action(async (sessionId, options) => {
5664
5821
  const normalizedSessionId = normalizeString(sessionId);
@@ -5676,6 +5833,7 @@ export function registerSessionCommand(program) {
5676
5833
  hydration = await hydrateSessionFromRemote({
5677
5834
  sessionId: normalizedSessionId,
5678
5835
  targetPath,
5836
+ includeControlEvents: Boolean(options.includeControlEvents),
5679
5837
  }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
5680
5838
  if (options.actions !== false) {
5681
5839
  remoteActions = await listSessionMessageActions(normalizedSessionId, {
@@ -5708,7 +5866,12 @@ export function registerSessionCommand(program) {
5708
5866
  const actionEvents = remoteActions?.ok
5709
5867
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
5710
5868
  : [];
5711
- 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);
5712
5875
  const stats = computeTranscriptStats({
5713
5876
  sessionMeta: sessionPayload,
5714
5877
  events: exportEvents,
@@ -5722,6 +5885,15 @@ export function registerSessionCommand(program) {
5722
5885
  if (format === "ndjson") {
5723
5886
  const lines = [];
5724
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
+ }));
5725
5897
  for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
5726
5898
  for (const participant of participants) {
5727
5899
  lines.push(JSON.stringify({ kind: "participant", value: participant }));
@@ -5763,10 +5935,12 @@ export function registerSessionCommand(program) {
5763
5935
  registeredAgents: agents.length,
5764
5936
  events: exportEvents.length,
5765
5937
  rawEvents: events.length,
5938
+ hiddenControlEvents: hiddenControlEventCount,
5766
5939
  actions: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
5767
5940
  actionEvents: actionEvents.length,
5768
5941
  tasks: (tasks.tasks || []).length,
5769
5942
  },
5943
+ includeControlEvents,
5770
5944
  totals: stats.totals,
5771
5945
  },
5772
5946
  null,
@@ -5783,7 +5957,7 @@ export function registerSessionCommand(program) {
5783
5957
  pc.gray(
5784
5958
  `Exported ${exportEvents.length} events / ${participants.length} participants (${agents.length} registered agents) / ${
5785
5959
  (tasks.tasks || []).length
5786
- } tasks → ${outPath}`,
5960
+ } tasks${hiddenControlEventCount ? ` (${hiddenControlEventCount} control events omitted)` : ""} → ${outPath}`,
5787
5961
  ),
5788
5962
  );
5789
5963
  } else {
@@ -5806,6 +5980,7 @@ export function registerSessionCommand(program) {
5806
5980
  "Hydrate from the SentinelLayer API before rendering (pulls web-posted messages into the local NDJSON)",
5807
5981
  )
5808
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")
5809
5984
  .option("--path <path>", "Workspace path for the session", ".")
5810
5985
  .option("--json", "Emit machine-readable output")
5811
5986
  .action(async (sessionId, options, command) => {
@@ -5822,6 +5997,7 @@ export function registerSessionCommand(program) {
5822
5997
  hydration = await hydrateSessionFromRemote({
5823
5998
  sessionId: normalizedSessionId,
5824
5999
  targetPath,
6000
+ includeControlEvents: Boolean(options.includeControlEvents),
5825
6001
  }).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
5826
6002
  if (options.actions !== false) {
5827
6003
  remoteActions = await listSessionMessageActions(normalizedSessionId, {
@@ -5844,7 +6020,12 @@ export function registerSessionCommand(program) {
5844
6020
  const actionEvents = remoteActions?.ok
5845
6021
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
5846
6022
  : [];
5847
- 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);
5848
6029
 
5849
6030
  // Pull GitHub/Google avatar + display name from the active auth
5850
6031
  // session so any human-id seen in the stream renders with the
@@ -5909,6 +6090,8 @@ export function registerSessionCommand(program) {
5909
6090
  rawEventCount: events.length,
5910
6091
  actionCount: Array.isArray(remoteActions?.actions) ? remoteActions.actions.length : 0,
5911
6092
  actionEventCount: actionEvents.length,
6093
+ includeControlEvents,
6094
+ hiddenControlEventCount,
5912
6095
  agentCount: participants.length,
5913
6096
  participantCount: participants.length,
5914
6097
  derivedAgentCount: stats.agents.length,
@@ -5940,6 +6123,9 @@ export function registerSessionCommand(program) {
5940
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)}`,
5941
6124
  ),
5942
6125
  );
6126
+ if (hiddenControlEventCount > 0) {
6127
+ console.log(pc.gray(`${hiddenControlEventCount} control events omitted; rerun with --include-control-events to inspect them.`));
6128
+ }
5943
6129
  });
5944
6130
 
5945
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
+ }
@@ -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,
@@ -33,6 +33,7 @@ import {
33
33
  sessionEventHasKnownIdentity,
34
34
  sessionEventUpgradesExisting,
35
35
  } from "./event-identity.js";
36
+ import { isSessionControlEvent } from "./control-events.js";
36
37
 
37
38
  const EVENTS_CURSOR_SUFFIX = "events";
38
39
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
@@ -273,6 +274,7 @@ export async function hydrateSessionFromRemote({
273
274
  probeOpenCircuit = true,
274
275
  eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
275
276
  maxEventPages = DEFAULT_MAX_EVENT_PAGES,
277
+ includeControlEvents = false,
276
278
  } = {}) {
277
279
  if (!sessionId || typeof sessionId !== "string") {
278
280
  return {
@@ -392,7 +394,17 @@ export async function hydrateSessionFromRemote({
392
394
  newEvents.push(event);
393
395
  }
394
396
  }
395
- if (newEvents.length > 0) {
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) {
396
408
  try {
397
409
  const localSession = await _ensureLocalSession(sessionId, { targetPath });
398
410
  materializedLocalSession = Boolean(localSession?.materialized);
@@ -403,10 +415,11 @@ export async function hydrateSessionFromRemote({
403
415
  // returns a structured result.
404
416
  }
405
417
  }
418
+
406
419
  const appendEvents =
407
420
  remoteStatus && !["active", "pending"].includes(remoteStatus)
408
- ? newEvents.map((event) => markPostKillEvent(event))
409
- : newEvents;
421
+ ? materialNewEvents.map((event) => markPostKillEvent(event))
422
+ : materialNewEvents;
410
423
  const failedRelayKeys = new Set();
411
424
  for (const event of appendEvents) {
412
425
  try {
@@ -451,6 +464,7 @@ export async function hydrateSessionFromRemote({
451
464
  persistedCursor,
452
465
  humanRelayed: (humanResult?.events || []).length,
453
466
  eventsRelayed: (eventsResult?.events || []).length,
467
+ controlEventsSkipped: skippedControlEventCount,
454
468
  eventsCursor:
455
469
  typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
456
470
  eventsPageCount: Number(eventsResult?.pageCount || 0),