sentinelayer-cli 0.18.2 → 0.20.0

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.
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
 
3
- import { pollSessionEvents } from "./sync.js";
3
+ import { pollSessionEvents, pollSessionEventsBefore, streamSessionEvents } from "./sync.js";
4
4
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
5
 
6
6
  const BROADCAST_RECIPIENTS = new Set([
@@ -199,6 +199,21 @@ function humanActivityTimestampMs(event = {}, nowMs = Date.now()) {
199
199
  return eventTimestampMs(event) || nowMs;
200
200
  }
201
201
 
202
+ function eventTimeRange(events = []) {
203
+ let oldestMs = 0;
204
+ let newestMs = 0;
205
+ for (const event of Array.isArray(events) ? events : []) {
206
+ const timeMs = eventTimestampMs(event);
207
+ if (!timeMs) continue;
208
+ if (!oldestMs || timeMs < oldestMs) oldestMs = timeMs;
209
+ if (!newestMs || timeMs > newestMs) newestMs = timeMs;
210
+ }
211
+ return {
212
+ oldestEventAt: oldestMs ? new Date(oldestMs).toISOString() : null,
213
+ newestEventAt: newestMs ? new Date(newestMs).toISOString() : null,
214
+ };
215
+ }
216
+
202
217
  function isRecentActivity(activityMs, nowMs, windowMs) {
203
218
  return (
204
219
  Number.isFinite(activityMs) &&
@@ -208,6 +223,14 @@ function isRecentActivity(activityMs, nowMs, windowMs) {
208
223
  );
209
224
  }
210
225
 
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
+
211
234
  /**
212
235
  * Poll session events in the background and emit only events addressed to
213
236
  * the current agent or broadcast to everyone. The loop advances its cursor
@@ -224,11 +247,18 @@ export async function listenSessionEvents({
224
247
  limit = 200,
225
248
  since = undefined,
226
249
  replay = false,
250
+ fromNow = false,
251
+ persistStartCursor = false,
227
252
  maxPolls = null,
228
253
  signal,
229
254
  onEvent = async () => {},
230
255
  onError = async () => {},
256
+ onCatchup = async () => {},
257
+ onLifecycle = async () => {},
258
+ transport = "poll",
231
259
  _poll = pollSessionEvents,
260
+ _pollLatest = pollSessionEventsBefore,
261
+ _stream = streamSessionEvents,
232
262
  _readCursor = readSyncCursor,
233
263
  _writeCursor = writeSyncCursor,
234
264
  _sleep = defaultSleep,
@@ -240,17 +270,32 @@ export async function listenSessionEvents({
240
270
  throw new Error("session id is required.");
241
271
  }
242
272
 
273
+ if (fromNow && since !== undefined) {
274
+ throw new Error("Use either fromNow or since, not both.");
275
+ }
276
+ const normalizedTransport = normalizeLower(transport) || "poll";
277
+ if (!["auto", "poll", "stream"].includes(normalizedTransport)) {
278
+ throw new Error("transport must be one of: auto, poll, stream.");
279
+ }
280
+
243
281
  const cursorSuffix = listenCursorSuffix(normalizedAgentId);
244
- let cursor =
245
- typeof since === "string" || since === null
246
- ? normalizeString(since) || null
247
- : await _readCursor(normalizedSessionId, { targetPath, suffix: cursorSuffix });
282
+ const explicitSince = typeof since === "string" || since === null;
283
+ let cursor = explicitSince
284
+ ? normalizeString(since) || null
285
+ : await _readCursor(normalizedSessionId, { targetPath, suffix: cursorSuffix });
286
+ let cursorSource = explicitSince ? "explicit" : cursor ? "stored" : "none";
248
287
  let primed = Boolean(cursor) || Boolean(replay);
249
288
  let pollCount = 0;
250
289
  let emitted = 0;
251
290
  let matched = 0;
252
291
  let persistedCursor = false;
253
292
  let lastReason = "";
293
+ let activeTransport = normalizedTransport === "poll" ? "poll" : "stream";
294
+ let streamAttempted = false;
295
+ let streamFallbackReason = "";
296
+ let catchupNotified = false;
297
+ let catchupEventCount = 0;
298
+ let catchupMatchingEventCount = 0;
254
299
  const emittedKeys = new Set();
255
300
  const maxPollCount = normalizePositiveInteger(maxPolls, 0);
256
301
  const pollLimit = normalizePositiveInteger(limit, 200);
@@ -261,82 +306,264 @@ export async function listenSessionEvents({
261
306
  const activeWindowMs =
262
307
  Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
263
308
  1000;
309
+
310
+ if (fromNow) {
311
+ const latest = await _pollLatest(normalizedSessionId, {
312
+ targetPath,
313
+ limit: 1,
314
+ });
315
+ if (!latest?.ok) {
316
+ throw new Error(`Unable to start listener from the latest event (${latest?.reason || "unknown"}).`);
317
+ }
318
+ cursor = normalizeString(latest.cursor) || null;
319
+ cursorSource = cursor ? "from_now" : "none";
320
+ primed = Boolean(cursor) || Boolean(replay);
321
+ if (cursor && persistStartCursor) {
322
+ const writeResult = await _writeCursor(normalizedSessionId, cursor, {
323
+ targetPath,
324
+ suffix: cursorSuffix,
325
+ }).catch(() => null);
326
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
327
+ }
328
+ }
329
+
264
330
  const startedAtMs = Number(_nowMs()) || Date.now();
265
331
  let lastHumanActivityMs = 0;
266
332
  let lastSleepMs = 0;
267
333
 
268
- while (!signal?.aborted) {
269
- pollCount += 1;
270
- const result = await _poll(normalizedSessionId, {
271
- targetPath,
272
- since: cursor,
273
- limit: pollLimit,
274
- });
334
+ const lifecycleSnapshot = (type, extra = {}) => ({
335
+ type,
336
+ sessionId: normalizedSessionId,
337
+ agentId: normalizedAgentId,
338
+ cursor: cursor || null,
339
+ cursorSuffix,
340
+ pollCount,
341
+ matched,
342
+ emitted,
343
+ persistedCursor,
344
+ cursorSource,
345
+ transport: activeTransport,
346
+ idleIntervalSeconds: Math.round(idleSleepMs / 1000),
347
+ activeIntervalSeconds: Math.round(activeSleepMs / 1000),
348
+ activeWindowSeconds: Math.round(activeWindowMs / 1000),
349
+ lastHumanActivityAt: lastHumanActivityMs
350
+ ? new Date(lastHumanActivityMs).toISOString()
351
+ : null,
352
+ lastSleepMs,
353
+ reason: lastReason,
354
+ ...extra,
355
+ });
275
356
 
276
- if (result?.ok) {
277
- lastReason = "";
278
- const events = Array.isArray(result.events) ? result.events : [];
279
- const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
280
- const cursorDidNotAdvance = Boolean(nextCursor && cursor && !cursorAdvances(nextCursor, cursor));
281
- const cursorFault = cursorDidNotAdvance && (nextCursor !== cursor || events.length > 0);
282
- if (cursorFault) {
283
- lastReason = "cursor_not_advanced";
284
- await onError({
285
- ok: false,
286
- reason: lastReason,
287
- cursor: cursor || null,
288
- candidateCursor: nextCursor,
289
- });
290
- } else {
291
- const observedAtMs = Number(_nowMs()) || Date.now();
292
- for (const event of events) {
293
- const activityMs = humanActivityTimestampMs(event, observedAtMs);
294
- if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
295
- lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
296
- }
297
- }
298
- const shouldEmitBatch = primed || Boolean(replay);
299
- for (const event of events) {
300
- if (!eventMatchesAgent(event, normalizedAgentId)) continue;
301
- const key = eventIdentityKey(event);
302
- if (emittedKeys.has(key)) continue;
303
- matched += 1;
304
- if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
305
- await onEvent(event);
306
- emittedKeys.add(key);
307
- emitted += 1;
308
- }
309
-
310
- if (nextCursor && nextCursor !== cursor) {
311
- const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
312
- targetPath,
313
- suffix: cursorSuffix,
314
- }).catch(() => null);
315
- persistedCursor = Boolean(writeResult?.written) || persistedCursor;
316
- cursor = nextCursor;
317
- }
318
- primed = true;
319
- }
320
- } else {
321
- lastReason = normalizeString(result?.reason) || "poll_failed";
357
+ async function notifyCatchup(payload) {
358
+ try {
359
+ await onCatchup(payload);
360
+ } catch (error) {
322
361
  await onError({
323
362
  ok: false,
324
- reason: lastReason,
325
- cursor: result?.cursor || cursor || null,
363
+ reason: "catchup_notice_failed",
364
+ cursor: payload.cursor || cursor || null,
365
+ detail: normalizeString(error?.message),
326
366
  });
327
367
  }
368
+ }
328
369
 
329
- if (maxPollCount > 0 && pollCount >= maxPollCount) break;
330
- const sleepAtMs = Number(_nowMs()) || Date.now();
331
- const humanActive = isRecentActivity(lastHumanActivityMs, sleepAtMs, activeWindowMs);
332
- const nextSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
333
- lastSleepMs = nextSleepMs;
370
+ async function notifyLifecycle(payload) {
334
371
  try {
335
- await _sleep(nextSleepMs, { signal });
372
+ await onLifecycle(payload);
336
373
  } catch (error) {
337
- if (shouldAbort(error, signal)) break;
338
- throw error;
374
+ await onError({
375
+ ok: false,
376
+ reason: `lifecycle_${payload.type}_failed`,
377
+ cursor: payload.cursor || cursor || null,
378
+ detail: normalizeString(error?.message),
379
+ });
380
+ }
381
+ }
382
+
383
+ async function processEventBatch(eventsInput = [], resultCursor = null) {
384
+ const events = Array.isArray(eventsInput) ? eventsInput : [];
385
+ const nextCursor = normalizeString(resultCursor) || cursorFromEvents(events, cursor);
386
+ const cursorDidNotAdvance = Boolean(nextCursor && cursor && !cursorAdvances(nextCursor, cursor));
387
+ const cursorFault = cursorDidNotAdvance && (nextCursor !== cursor || events.length > 0);
388
+ if (cursorFault) {
389
+ lastReason = "cursor_not_advanced";
390
+ await onError({
391
+ ok: false,
392
+ reason: lastReason,
393
+ cursor: cursor || null,
394
+ candidateCursor: nextCursor,
395
+ });
396
+ return false;
397
+ }
398
+
399
+ const observedAtMs = Number(_nowMs()) || Date.now();
400
+ const visibleEvents = [];
401
+ let preStartEventCount = 0;
402
+ for (const event of events) {
403
+ const timestampMs = eventTimestampMs(event);
404
+ if (!timestampMs || timestampMs < startedAtMs) {
405
+ preStartEventCount += 1;
406
+ }
407
+ const activityMs = humanActivityTimestampMs(event, observedAtMs);
408
+ if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
409
+ lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
410
+ }
411
+ if (isListenerLifecycleEvent(event)) continue;
412
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
413
+ visibleEvents.push(event);
414
+ }
415
+
416
+ if (
417
+ !catchupNotified &&
418
+ cursorSource === "stored" &&
419
+ Boolean(cursor) &&
420
+ events.length > 0 &&
421
+ preStartEventCount > 0
422
+ ) {
423
+ catchupNotified = true;
424
+ catchupEventCount = events.length;
425
+ catchupMatchingEventCount = visibleEvents.length;
426
+ await notifyCatchup({
427
+ type: "catchup",
428
+ sessionId: normalizedSessionId,
429
+ agentId: normalizedAgentId,
430
+ cursor: cursor || null,
431
+ candidateCursor: nextCursor || null,
432
+ cursorSuffix,
433
+ cursorSource,
434
+ pollCount,
435
+ eventCount: events.length,
436
+ matchingEventCount: visibleEvents.length,
437
+ preStartEventCount,
438
+ limit: pollLimit,
439
+ replay: Boolean(replay),
440
+ ...eventTimeRange(events),
441
+ });
442
+ }
443
+
444
+ const shouldEmitBatch = primed || Boolean(replay);
445
+ for (const event of visibleEvents) {
446
+ const key = eventIdentityKey(event);
447
+ if (emittedKeys.has(key)) continue;
448
+ matched += 1;
449
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
450
+ await onEvent(event);
451
+ emittedKeys.add(key);
452
+ emitted += 1;
453
+ }
454
+
455
+ if (nextCursor && nextCursor !== cursor) {
456
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
457
+ targetPath,
458
+ suffix: cursorSuffix,
459
+ }).catch(() => null);
460
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
461
+ cursor = nextCursor;
462
+ }
463
+ primed = true;
464
+ return true;
465
+ }
466
+
467
+ async function notifyHeartbeat({ stopping = false, nextPollMs = null } = {}) {
468
+ const heartbeatAtMs = Number(_nowMs()) || Date.now();
469
+ const humanActive = isRecentActivity(lastHumanActivityMs, heartbeatAtMs, activeWindowMs);
470
+ await notifyLifecycle(
471
+ lifecycleSnapshot("heartbeat", {
472
+ active: humanActive,
473
+ state: humanActive ? "active" : "idle",
474
+ nextPollMs,
475
+ stopping,
476
+ transport: activeTransport,
477
+ }),
478
+ );
479
+ return humanActive;
480
+ }
481
+
482
+ await notifyLifecycle(
483
+ lifecycleSnapshot("started", {
484
+ startedAt: new Date(startedAtMs).toISOString(),
485
+ transport: activeTransport,
486
+ }),
487
+ );
488
+
489
+ try {
490
+ if (normalizedTransport !== "poll") {
491
+ streamAttempted = true;
492
+ activeTransport = "stream";
493
+ const streamResult = await _stream(normalizedSessionId, {
494
+ targetPath,
495
+ since: cursor,
496
+ signal,
497
+ onEvent: async (event) => {
498
+ await processEventBatch([event], normalizeString(event?.cursor) || cursor);
499
+ },
500
+ onError: async (error) => {
501
+ lastReason = `stream_${normalizeString(error?.reason) || "error"}`;
502
+ await onError({
503
+ ok: false,
504
+ reason: lastReason,
505
+ cursor: error?.cursor || cursor || null,
506
+ });
507
+ },
508
+ onHeartbeat: async () => {
509
+ await notifyHeartbeat({ nextPollMs: null });
510
+ },
511
+ });
512
+ if (!streamResult?.ok) {
513
+ streamFallbackReason = normalizeString(streamResult?.reason) || lastReason || "stream_failed";
514
+ lastReason = `stream_${streamFallbackReason}`;
515
+ } else if (!signal?.aborted) {
516
+ streamFallbackReason = "stream_closed";
517
+ }
518
+ }
519
+
520
+ const shouldPoll =
521
+ !signal?.aborted && (normalizedTransport === "poll" || normalizedTransport === "auto");
522
+ if (shouldPoll) {
523
+ activeTransport = "poll";
524
+ }
525
+ while (shouldPoll && !signal?.aborted) {
526
+ pollCount += 1;
527
+ const result = await _poll(normalizedSessionId, {
528
+ targetPath,
529
+ since: cursor,
530
+ limit: pollLimit,
531
+ });
532
+
533
+ if (result?.ok) {
534
+ lastReason = "";
535
+ await processEventBatch(result.events, result.cursor);
536
+ } else {
537
+ lastReason = normalizeString(result?.reason) || "poll_failed";
538
+ await onError({
539
+ ok: false,
540
+ reason: lastReason,
541
+ cursor: result?.cursor || cursor || null,
542
+ });
543
+ }
544
+
545
+ const willStop = maxPollCount > 0 && pollCount >= maxPollCount;
546
+ const heartbeatAtMs = Number(_nowMs()) || Date.now();
547
+ const humanActive = isRecentActivity(lastHumanActivityMs, heartbeatAtMs, activeWindowMs);
548
+ const nextSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
549
+ await notifyHeartbeat({ nextPollMs: willStop ? null : nextSleepMs, stopping: willStop });
550
+
551
+ if (willStop) break;
552
+ lastSleepMs = nextSleepMs;
553
+ try {
554
+ await _sleep(nextSleepMs, { signal });
555
+ } catch (error) {
556
+ if (shouldAbort(error, signal)) break;
557
+ throw error;
558
+ }
339
559
  }
560
+ } finally {
561
+ await notifyLifecycle(
562
+ lifecycleSnapshot("stopped", {
563
+ stoppedAt: new Date(Number(_nowMs()) || Date.now()).toISOString(),
564
+ aborted: Boolean(signal?.aborted),
565
+ }),
566
+ );
340
567
  }
341
568
 
342
569
  return {
@@ -345,10 +572,17 @@ export async function listenSessionEvents({
345
572
  agentId: normalizedAgentId,
346
573
  cursor,
347
574
  cursorSuffix,
575
+ cursorSource,
576
+ transport: activeTransport,
577
+ streamAttempted,
578
+ streamFallbackReason,
348
579
  pollCount,
349
580
  matched,
350
581
  emitted,
351
582
  persistedCursor,
583
+ catchupNotified,
584
+ catchupEventCount,
585
+ catchupMatchingEventCount,
352
586
  idleIntervalSeconds: Math.round(idleSleepMs / 1000),
353
587
  activeIntervalSeconds: Math.round(activeSleepMs / 1000),
354
588
  activeWindowSeconds: Math.round(activeWindowMs / 1000),
@@ -14,6 +14,30 @@ const SUPPORTED_SESSION_USAGE_SCHEMAS = new Set([
14
14
  LOCAL_SESSION_USAGE_SCHEMA,
15
15
  ...LEGACY_SESSION_USAGE_SCHEMAS,
16
16
  ]);
17
+ const USAGE_HINT_KEYS = [
18
+ "totalTokens",
19
+ "total_tokens",
20
+ "tokens",
21
+ "tokenTotal",
22
+ "token_total",
23
+ "inputTokens",
24
+ "input_tokens",
25
+ "tokensIn",
26
+ "tokens_in",
27
+ "promptTokens",
28
+ "prompt_tokens",
29
+ "outputTokens",
30
+ "output_tokens",
31
+ "tokensOut",
32
+ "tokens_out",
33
+ "completionTokens",
34
+ "completion_tokens",
35
+ "providerCostUsd",
36
+ "provider_cost_usd",
37
+ "costUsd",
38
+ "cost_usd",
39
+ "cost",
40
+ ];
17
41
 
18
42
  function n(value) {
19
43
  return String(value == null ? "" : value).trim();
@@ -53,6 +77,11 @@ function pickText(sources, keys) {
53
77
  return n(pick(sources, keys));
54
78
  }
55
79
 
80
+ function hasUsageHints(value) {
81
+ const bag = object(value);
82
+ return USAGE_HINT_KEYS.some((key) => bag[key] != null && bag[key] !== "");
83
+ }
84
+
56
85
  function pickInt(sources, keys) {
57
86
  return nonNegativeInt(pick(sources, keys)) ?? 0;
58
87
  }
@@ -109,13 +138,14 @@ export function buildUsageLedgerEntry(
109
138
  { sessionId = "", priceBookVersion = DEFAULT_PRICE_BOOK_VERSION, billingTier = "unknown" } = {},
110
139
  ) {
111
140
  const kind = n(event?.event || event?.type);
112
- if (kind !== SESSION_USAGE_EVENT) return null;
113
-
114
141
  const payload = object(event?.payload);
115
142
  const schema = n(payload.schema);
116
- if (!SUPPORTED_SESSION_USAGE_SCHEMAS.has(schema)) return null;
117
-
118
143
  const usage = object(payload.usage);
144
+ const legacyUsageEvent = kind !== SESSION_USAGE_EVENT && hasUsageHints(usage);
145
+ if (!legacyUsageEvent) {
146
+ if (kind !== SESSION_USAGE_EVENT) return null;
147
+ if (!SUPPORTED_SESSION_USAGE_SCHEMAS.has(schema)) return null;
148
+ }
119
149
  const prompt = object(payload.prompt);
120
150
  const response = object(payload.response);
121
151
  const agent = object(event?.agent);