@thehammer/danx-session-telemetry 0.1.14 → 0.1.15

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.
@@ -26,6 +26,7 @@ import { homedir } from "node:os";
26
26
  import { basename, dirname, isAbsolute, join } from "node:path";
27
27
  import { createLogger } from "./internal-logger.js";
28
28
  import { buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
29
+ import { MAIN_SOURCE } from "./usage-ledger.js";
29
30
  const log = createLogger("session-log-watcher");
30
31
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
31
32
  const FILE_DISCOVERY_POLL_MS = 1_000;
@@ -41,6 +42,19 @@ const MAX_FILE_DISCOVERY_ATTEMPTS = 180;
41
42
  * wrong file (e.g., an active human session).
42
43
  */
43
44
  export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
45
+ /**
46
+ * DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
47
+ * through (Slack progress, the gpt-manager forwarder): it hands on only lines
48
+ * read for the FIRST time. A replay was already handed to the previous
49
+ * incarnation's subscribers, so acting on it again is a duplicate post/event.
50
+ */
51
+ export function skipReplays(consumer) {
52
+ return (entry, meta) => {
53
+ if (meta.replay)
54
+ return;
55
+ consumer(entry);
56
+ };
57
+ }
44
58
  /**
45
59
  * Encode a working directory to the subdir name Claude Code writes JSONL
46
60
  * under. Replaces `/`, `.`, AND `_` with `-`. Verified empirically against
@@ -440,6 +454,29 @@ export function convertJsonlEntry(raw, lastTimestamp) {
440
454
  return null;
441
455
  }
442
456
  }
457
+ /**
458
+ * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
459
+ * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
460
+ * the right `startCursors` / `startExistingFilesAtEof`.
461
+ *
462
+ * | # | Condition | Start |
463
+ * |---|---|---|
464
+ * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
465
+ * | 2 | a worker dispatch continuing a session: its predecessor's cursor for this source (the caller resolves it INTO `startCursors`, so it arrives as rule 1) | the predecessor's final `acked`, replaying to its final `delivered` |
466
+ * | 3 | the FIRST dispatch of its Claude session — a new worker session, or any operator dispatch (always the only one for its session id, including one opened by a mid-session `plan_connect` and a forked session's new id) | 0 |
467
+ * | 4 | a WORKER dispatch continuing a session with NO cursor for ANY source (a row from before this card; a `--resume` whose predecessor predates it) — `startExistingFilesAtEof` | every ALREADY-EXISTING file at its current end |
468
+ * | 5 | otherwise — in particular a sub-agent file that appeared AFTER the last stats write, on a dispatch that has cursors for other sources | 0 |
469
+ *
470
+ * Rules 1 and 2 arrive identically because "where does this source resume" has
471
+ * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
472
+ * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
473
+ * late sub-agent file's lines).
474
+ *
475
+ * Rules 3 and 5 both read from 0, but for different reasons, and both are
476
+ * correct: a fresh session's full usage SHOULD be counted, and a forked
477
+ * session's copied history is read and credited nothing (the ledger's excess
478
+ * rule already covers it).
479
+ */
443
480
  export class SessionLogWatcher {
444
481
  consumers = [];
445
482
  pollTimer;
@@ -451,8 +488,10 @@ export class SessionLogWatcher {
451
488
  sessionId;
452
489
  dispatchId;
453
490
  pollIntervalMs;
454
- fromEof;
455
- existingSubagentFilesFromEof;
491
+ startCursors;
492
+ startExistingFilesAtEof;
493
+ onCursorPastEof;
494
+ onSourceDiscovered;
456
495
  subagentDiscoveryDone = false;
457
496
  running = false;
458
497
  polling = false;
@@ -468,9 +507,110 @@ export class SessionLogWatcher {
468
507
  this.sessionId = options.sessionId;
469
508
  this.dispatchId = options.dispatchId;
470
509
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
471
- this.fromEof = options.fromEof ?? false;
472
- this.existingSubagentFilesFromEof =
473
- options.existingSubagentFilesFromEof ?? false;
510
+ this.startCursors = options.startCursors ?? {};
511
+ this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
512
+ this.onCursorPastEof = options.onCursorPastEof;
513
+ this.onSourceDiscovered = options.onSourceDiscovered;
514
+ }
515
+ /**
516
+ * DX-2954 — resolve one file's starting offset and replay boundary, per the
517
+ * start order on {@link SessionLogWatcher}.
518
+ *
519
+ * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
520
+ * attached, on a continuing worker dispatch with no cursor anywhere) from
521
+ * rule 5 (a file that appeared later, which always starts at 0 so none of its
522
+ * lines are lost).
523
+ */
524
+ async resolveStart(source, filePath, fileAlreadyExisted) {
525
+ const stored = this.startCursors[source];
526
+ // Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
527
+ // the caller already resolved into `startCursors`. Both arrive here the
528
+ // same way, because "where does this source resume" has ONE answer.
529
+ if (stored !== undefined) {
530
+ return { byteOffset: stored.acked, replayUntil: stored.delivered };
531
+ }
532
+ // Rule 4: no cursor for ANY source on a continuing worker dispatch.
533
+ if (this.startExistingFilesAtEof && fileAlreadyExisted) {
534
+ try {
535
+ const size = (await stat(filePath)).size;
536
+ return { byteOffset: size, replayUntil: 0 };
537
+ }
538
+ catch (err) {
539
+ // NOT a fall-back to 0. This branch fires precisely on the rows whose
540
+ // stored totals already count this file, so reading it from the start
541
+ // re-sums the whole session — the 421M-cache-read inflation (DX-2583)
542
+ // that rule 4 exists to prevent. `null` means "do not open this source
543
+ // yet"; the caller leaves it undiscovered and the next poll retries,
544
+ // still treating it as pre-existing.
545
+ log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
546
+ return null;
547
+ }
548
+ }
549
+ // Rules 3 and 5: the first dispatch of a session, and any file that appeared
550
+ // after the last stats write, both read from the start.
551
+ return { byteOffset: 0, replayUntil: 0 };
552
+ }
553
+ /**
554
+ * DX-2954 — every open source's cursor state.
555
+ *
556
+ * `delivered` is the byte up to which this watcher has handed lines to
557
+ * subscribers, which the collector reports as the `delivered` half of its
558
+ * stats write. `startOffset` is where this watcher OPENED the file, and it is
559
+ * the floor for the `acked` half: a source that started at EOF under rule 4
560
+ * must never report `acked: 0`, or the next restart would read the whole file
561
+ * from the start and re-sum the history rule 4 exists to skip.
562
+ */
563
+ cursorState() {
564
+ // `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
565
+ //
566
+ // `pollFile` advances `byteOffset` by every byte it READ, and `processText`
567
+ // deliberately buffers a trailing newline-less line (the whole reason
568
+ // `lineBuffer` exists — a poll routinely catches a line mid-append). So
569
+ // `byteOffset` can sit PAST the first byte of a line that was never emitted;
570
+ // promoting that to `acked` makes a restart open the file mid-JSON, and the
571
+ // fragment is dropped as a malformed line — losing that reply's usage, its
572
+ // tool-use ids and its side effects permanently.
573
+ //
574
+ // `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
575
+ // exactly the resume point. It equals `byteOffset` whenever the buffer is
576
+ // empty, so the happy path is unchanged.
577
+ const at = (s) => ({
578
+ startOffset: s.startOffset,
579
+ delivered: s.lineBufferStartOffset,
580
+ });
581
+ const out = {};
582
+ if (this.parentTail)
583
+ out[this.parentTail.source] = at(this.parentTail);
584
+ for (const state of this.subagentTails.values())
585
+ out[state.source] = at(state);
586
+ return out;
587
+ }
588
+ /**
589
+ * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
590
+ * or 0 when it has none.
591
+ *
592
+ * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
593
+ * watcher attach mid-session?". The offset alone is not enough: because `acked`
594
+ * lags `delivered` by one write, a crash right after the first cursor-bearing
595
+ * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
596
+ * every byte up to D is a re-read.
597
+ */
598
+ mainReplayUntil() {
599
+ return this.parentTail?.replayUntil ?? 0;
600
+ }
601
+ /**
602
+ * DX-2954 — the main transcript's starting offset once discovery has run, or
603
+ * `null` before that.
604
+ *
605
+ * This is what "is this a mid-session attach?" is derived from now. The
606
+ * declared-loaded observer used to be told `reattached: true` explicitly;
607
+ * a non-zero main start is the same fact stated as an observation rather than
608
+ * a flag, and it is true for every case that matters (a reattach, a
609
+ * `--resume`, a resumed operator session) without a caller having to
610
+ * enumerate them.
611
+ */
612
+ mainStartOffset() {
613
+ return this.parentTail?.startOffset ?? null;
474
614
  }
475
615
  /** Register a consumer callback that receives each new AgentLogEntry. */
476
616
  onEntry(consumer) {
@@ -571,8 +711,7 @@ export class SessionLogWatcher {
571
711
  const filePath = this.dispatchId
572
712
  ? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
573
713
  : await findNewestJsonlFile(this.sessionDir, this.sessionId);
574
- if (filePath) {
575
- await this.setParentFile(filePath);
714
+ if (filePath && (await this.setParentFile(filePath))) {
576
715
  const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
577
716
  log.info(`${tag}Watching session file: ${filePath}`);
578
717
  return;
@@ -584,35 +723,36 @@ export class SessionLogWatcher {
584
723
  /**
585
724
  * Adopt a parent session file and derive the sub-agent directory.
586
725
  *
587
- * When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
588
- * the option's doc), seed `byteOffset` to the file's current size so the
589
- * next poll reads strictly forward — historical entries are NOT re-emitted.
590
- * A fresh `stat` failure (race
591
- * with a delete that happens between discover and adopt) falls back to
592
- * 0 so the watcher tails from the start; that is preferable to throwing
593
- * during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
594
- * file claude writes AFTER reattach is genuinely new and should be
595
- * tailed normally.
726
+ * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
727
+ * (the cursor start order), not from a `fromEof` flag: the main source resumes
728
+ * at its stored `acked` cursor when it has one, and only a continuing worker
729
+ * dispatch with no cursor anywhere skips to the file's end.
596
730
  */
597
731
  async setParentFile(filePath) {
598
- let byteOffset = 0;
599
- if (this.fromEof) {
600
- try {
601
- const fileStats = await stat(filePath);
602
- byteOffset = fileStats.size;
603
- }
604
- catch (err) {
605
- log.warn(`fromEof: stat failed for ${filePath}; tailing from 0: ${String(err)}`);
606
- }
607
- }
732
+ // DX-2954 — the main transcript's source key is always MAIN_SOURCE, and it
733
+ // ALWAYS counts as already-existing: by the time discovery found this file
734
+ // it is on disk, which is exactly what rule 4 asks about.
735
+ const start = await this.resolveStart(MAIN_SOURCE, filePath, true);
736
+ // `null` = its start offset could not be established safely (see
737
+ // `resolveStart`). Report the failure so discovery retries rather than
738
+ // adopting the file at a guessed offset.
739
+ if (start === null)
740
+ return false;
741
+ const { byteOffset, replayUntil } = start;
608
742
  this.parentTail = {
609
743
  filePath,
610
744
  byteOffset,
611
745
  lineBuffer: "",
612
746
  lineBufferStartOffset: byteOffset,
613
747
  lastTimestamp: 0,
748
+ source: MAIN_SOURCE,
749
+ startOffset: byteOffset,
750
+ replayUntil,
751
+ discoveredAt: Date.now(),
752
+ preexisting: false,
614
753
  };
615
754
  this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
755
+ return true;
616
756
  }
617
757
  /**
618
758
  * Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
@@ -654,8 +794,41 @@ export class SessionLogWatcher {
654
794
  const fileStats = await stat(state.filePath);
655
795
  if (!this.running)
656
796
  return;
657
- if (fileStats.size <= state.byteOffset)
797
+ // DX-2954 — a cursor PAST the end of the file means the file was replaced
798
+ // or truncated under us. `size === byteOffset` is the ordinary "no new
799
+ // bytes" case; only a STRICTLY smaller file is the pathological one, and
800
+ // before this card it fell into the same early return and silently tailed
801
+ // nothing for the rest of the dispatch. Restart that source at 0 and
802
+ // report it, so the caller records a visible event and marks the reset in
803
+ // its next stats write (the one sanctioned backwards move of `acked`).
804
+ if (fileStats.size < state.byteOffset) {
805
+ this.onCursorPastEof?.({
806
+ source: state.source,
807
+ storedOffset: state.byteOffset,
808
+ fileSize: fileStats.size,
809
+ });
810
+ state.byteOffset = 0;
811
+ state.lineBufferStartOffset = 0;
812
+ state.lineBuffer = "";
813
+ // `startOffset` moves to 0 with it: it is the FLOOR the collector uses
814
+ // for this source's reported `acked`, so leaving it at the old (now
815
+ // impossible) offset would make the next write claim to have
816
+ // acknowledged bytes the file no longer has.
817
+ state.startOffset = 0;
818
+ // Nothing below the new position was ever delivered from THIS file's
819
+ // current content, so nothing in it is a replay.
820
+ state.replayUntil = 0;
821
+ // The replacement file's first entry must not have its `delta_ms`
822
+ // computed against the OLD file's last timestamp (which can produce a
823
+ // negative or absurd gap). This is a different file; its timeline starts
824
+ // fresh.
825
+ state.lastTimestamp = 0;
826
+ if (fileStats.size === 0)
827
+ return;
828
+ }
829
+ else if (fileStats.size === state.byteOffset) {
658
830
  return;
831
+ }
659
832
  const fd = await open(state.filePath, "r");
660
833
  try {
661
834
  if (!this.running)
@@ -713,40 +886,63 @@ export class SessionLogWatcher {
713
886
  log.error("Failed to enumerate sub-agent directory:", err);
714
887
  return;
715
888
  }
716
- // DX-2963 — the FIRST discovery pass sees exactly the sub-agent files
717
- // that existed when this watcher attached; only those may open at EOF.
718
- const openExistingAtEof = this.existingSubagentFilesFromEof && !this.subagentDiscoveryDone;
719
- this.subagentDiscoveryDone = true;
889
+ // DX-2954 — the FIRST discovery pass sees exactly the sub-agent files that
890
+ // existed when this watcher attached. Only those can satisfy rule 4's
891
+ // "already existing"; a file found on a LATER pass appeared after the attach
892
+ // and always reads from 0 (rule 5), so none of its lines are lost.
893
+ const firstPass = !this.subagentDiscoveryDone;
894
+ // Marked done only if this pass established a start offset for EVERY file it
895
+ // saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
896
+ // the retry — treating it as "appeared later" would send it to rule 5 (read
897
+ // from 0) and re-sum exactly the history rule 4 skipped.
898
+ let allResolved = true;
720
899
  for (const file of files) {
721
900
  if (!file.endsWith(".jsonl"))
722
901
  continue;
723
902
  const fullPath = join(this.subagentsDir, file);
724
903
  if (this.subagentTails.has(fullPath))
725
904
  continue;
905
+ // A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
906
+ // the same value `usage-ledger.ts` credits usage under and the same value
907
+ // `dispatch_activities.activity_id` holds — so a cursor, a per-source
908
+ // token total and a running-set row all join on one key.
726
909
  const subagentId = basename(file, ".jsonl");
727
910
  const agentType = await this.readAgentType(subagentId);
728
- let startOffset = 0;
729
- if (openExistingAtEof) {
730
- try {
731
- startOffset = (await stat(fullPath)).size;
732
- }
733
- catch (err) {
734
- log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
735
- }
911
+ const start = await this.resolveStart(subagentId, fullPath, firstPass);
912
+ if (start === null) {
913
+ allResolved = false;
914
+ continue;
736
915
  }
916
+ const { byteOffset, replayUntil } = start;
917
+ const discoveredAt = Date.now();
737
918
  this.subagentTails.set(fullPath, {
738
919
  filePath: fullPath,
739
- byteOffset: startOffset,
740
- lineBufferStartOffset: startOffset,
920
+ byteOffset,
921
+ lineBufferStartOffset: byteOffset,
741
922
  lineBuffer: "",
742
923
  lastTimestamp: 0,
924
+ source: subagentId,
925
+ startOffset: byteOffset,
926
+ replayUntil,
927
+ discoveredAt,
928
+ preexisting: firstPass,
743
929
  lineage: {
744
930
  subagent_id: subagentId,
745
931
  parent_session_id: this.parentSessionId,
746
932
  agent_type: agentType,
747
933
  },
748
934
  });
935
+ // DX-2954 — fired AFTER the tail is registered, so a consumer that throws
936
+ // cannot leave a half-registered source, and the `subagentTails.has()`
937
+ // guard above makes it exactly-once per source for this watcher's life. A
938
+ // re-discovery by a RESTARTED watcher fires again by design: the row it
939
+ // reports is an idempotent upsert keyed on this same id, and re-reporting
940
+ // is how a reattached collector re-learns the sub-agents that were already
941
+ // running before it existed.
942
+ this.emitSourceDiscovered(subagentId, agentType, discoveredAt, firstPass);
749
943
  }
944
+ if (allResolved)
945
+ this.subagentDiscoveryDone = true;
750
946
  }
751
947
  /** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
752
948
  async refreshPendingAgentTypes() {
@@ -762,13 +958,63 @@ export class SessionLogWatcher {
762
958
  * `pollFile` — the inline call is the defense-in-depth that closes the race
763
959
  * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
764
960
  */
961
+ /**
962
+ * DX-2954 — report a running sub-agent to the consumer, normalizing the
963
+ * absent-type shape and CONTAINING any throw.
964
+ *
965
+ * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
966
+ * every consumer for the same reason, and it is load-bearing here because of
967
+ * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
968
+ * `poll()` has `try/finally` without one, so an escaping throw would:
969
+ *
970
+ * - on the first pass (inside `start()`), kill the watcher for the whole
971
+ * dispatch — the caller only logs `start()`'s rejection;
972
+ * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
973
+ * turns into process exit — a worker crash caused by a consumer bug;
974
+ * - either way abort the discovery loop mid-list, skipping
975
+ * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
976
+ * sends later-appearing files down rule 4 instead of rule 5 — losing
977
+ * exactly the lines that rule split exists to preserve.
978
+ *
979
+ * Reporting the running set must never cost the monitoring it rides on.
980
+ */
981
+ emitSourceDiscovered(source, agentType, discoveredAt, preexisting) {
982
+ try {
983
+ this.onSourceDiscovered?.({
984
+ source,
985
+ // ONE absent-shape on the wire, never two: this class's own reader
986
+ // answers `undefined` for "no meta file yet", while the stats payload and
987
+ // the `dispatch_activities.agent_type` column both spell absent as
988
+ // `null`. Normalizing here keeps every consumer from accepting both.
989
+ agentType: agentType ?? null,
990
+ discoveredAt,
991
+ preexisting,
992
+ });
993
+ }
994
+ catch (err) {
995
+ log.error("onSourceDiscovered consumer error:", err);
996
+ }
997
+ }
765
998
  async refreshLineage(state) {
766
999
  if (!state.lineage)
767
1000
  return;
768
1001
  let { agent_type, parent_session_id } = state.lineage;
1002
+ // DX-2954 — remember whether the type was still unknown coming in, so the
1003
+ // `undefined -> string` transition can be reported. Discovery reads the
1004
+ // sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
1005
+ // BEFORE the `.meta.json` (the race this method exists for), so "no type
1006
+ // yet" at discovery is the COMMON case, not an edge. Without a report here
1007
+ // the running-set row's `agent_type` would stay NULL for the whole dispatch
1008
+ // even though the watcher learned the real value one poll later.
1009
+ const typeWasUnknown = agent_type === undefined;
769
1010
  if (agent_type === undefined) {
770
1011
  agent_type = await this.readAgentType(state.lineage.subagent_id);
771
1012
  }
1013
+ if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
1014
+ // The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
1015
+ // same activity, learning its type late, not a new one starting now.
1016
+ this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt, state.preexisting);
1017
+ }
772
1018
  if (parent_session_id === null && this.parentSessionId !== null) {
773
1019
  parent_session_id = this.parentSessionId;
774
1020
  }
@@ -869,7 +1115,7 @@ export class SessionLogWatcher {
869
1115
  synthesized: true,
870
1116
  },
871
1117
  };
872
- this.emitEntry(initEntry, state);
1118
+ this.emitEntry(initEntry, state, lineStart);
873
1119
  this.initSynthesized = true;
874
1120
  }
875
1121
  }
@@ -887,7 +1133,7 @@ export class SessionLogWatcher {
887
1133
  state.lastTimestamp = result.timestamp;
888
1134
  // DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
889
1135
  result.entry.data = { ...result.entry.data, byte_offset: lineStart };
890
- this.emitEntry(result.entry, state);
1136
+ this.emitEntry(result.entry, state, lineStart);
891
1137
  }
892
1138
  /**
893
1139
  * Emit an entry to registered consumers. For sub-agent streams,
@@ -900,7 +1146,7 @@ export class SessionLogWatcher {
900
1146
  * tests; nothing else read it. Every real consumer subscribes via
901
1147
  * `onEntry` instead.
902
1148
  */
903
- emitEntry(entry, state) {
1149
+ emitEntry(entry, state, byteOffset) {
904
1150
  if (state.lineage) {
905
1151
  entry.data = {
906
1152
  ...entry.data,
@@ -909,9 +1155,18 @@ export class SessionLogWatcher {
909
1155
  agent_type: state.lineage.agent_type,
910
1156
  };
911
1157
  }
1158
+ // DX-2954 — a line whose FIRST byte lies below the source's stored
1159
+ // `delivered` cursor was already handed to a previous incarnation's
1160
+ // subscribers. Strictly `<`: `delivered` is an exclusive end (the next
1161
+ // unread byte), so a line starting exactly AT it is new.
1162
+ const meta = {
1163
+ source: state.source,
1164
+ byteOffset,
1165
+ replay: byteOffset < state.replayUntil,
1166
+ };
912
1167
  for (const consumer of this.consumers) {
913
1168
  try {
914
- consumer(entry);
1169
+ consumer(entry, meta);
915
1170
  }
916
1171
  catch (err) {
917
1172
  log.error("Consumer error:", err);