@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.
@@ -28,7 +28,21 @@ import type { AgentLogEntry } from "./types.js";
28
28
  * wrong file (e.g., an active human session).
29
29
  */
30
30
  export declare const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
31
- export type EntryConsumer = (entry: AgentLogEntry) => void;
31
+ /**
32
+ * A consumer of transcript lines.
33
+ *
34
+ * DX-2954 added the second argument. A consumer that ignores it behaves exactly
35
+ * as before; a consumer with a SIDE EFFECT must read `meta.replay` and skip a
36
+ * re-read line (see {@link EntryMeta.replay}).
37
+ */
38
+ export type EntryConsumer = (entry: AgentLogEntry, meta: EntryMeta) => void;
39
+ /**
40
+ * DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
41
+ * through (Slack progress, the gpt-manager forwarder): it hands on only lines
42
+ * read for the FIRST time. A replay was already handed to the previous
43
+ * incarnation's subscribers, so acting on it again is a duplicate post/event.
44
+ */
45
+ export declare function skipReplays(consumer: (entry: AgentLogEntry) => void): EntryConsumer;
32
46
  export interface SessionLogWatcherOptions {
33
47
  /** Working directory the agent was launched in. Used to derive the session directory. */
34
48
  cwd: string;
@@ -46,32 +60,125 @@ export interface SessionLogWatcherOptions {
46
60
  */
47
61
  dispatchId?: string;
48
62
  /**
49
- * Skip every byte on disk at attach time and tail strictly forward from
50
- * EOF. Set whenever the bytes already on disk belong to a PRIOR dispatch's
51
- * accounting, so replaying them would double-count that dispatch's usage /
52
- * tool calls against this one:
53
- * - the Phase 2c (DX-209) reattach pass — a prior worker incarnation
54
- * already accumulated the JSONL into the row's counters; and
55
- * - a `claude --resume <id>` resume (DX-2583) — claude appends this
56
- * dispatch's turns to the SAME session JSONL the prior dispatch(es)
57
- * already filled, so tailing from byte 0 re-sums the whole session's
58
- * history onto a fresh row (`attach-monitoring-stack.ts` sets this via
59
- * `seedFromEof` when `resumeSessionId` is present).
60
- * Default `false` — a genuinely fresh spawn writes a brand-new file whose
61
- * EOF is 0, so it ingests its own full file from the start.
63
+ * DX-2954 — where each source resumes: the cursors the SERVER has stored for
64
+ * this dispatch, keyed by source ({@link MAIN_SOURCE} for the main transcript,
65
+ * the sub-agent id for a sub-agent file).
66
+ *
67
+ * An ABSENT source key means no cursor is stored for it — never "start at 0".
68
+ * The two are different: 0 is a legitimate stored cursor meaning "start of
69
+ * file", and CP1 forbids defaulting a missing persisted value. See
70
+ * {@link SessionLogWatcherOptions.startExistingFilesAtEof} and the start-order
71
+ * table on {@link SessionLogWatcher} for what happens when a source has none.
72
+ *
73
+ * This REPLACED the pre-DX-2954 `fromEof` / `existingSubagentFilesFromEof`
74
+ * pair, which skipped straight to a file's end on every reattach and every
75
+ * `--resume` because there was nowhere to record how far a reader had got.
76
+ * That lost every line between the last write and the restart; a cursor does
77
+ * not.
78
+ */
79
+ startCursors?: Record<string, SourceCursorStart>;
80
+ /**
81
+ * DX-2954 — rule 4 of the start order: start every ALREADY-EXISTING file at
82
+ * its current end.
83
+ *
84
+ * Set ONLY by a WORKER dispatch that continues a session and has no cursor
85
+ * for ANY source — a row from before this card shipped, or a `--resume` whose
86
+ * predecessor predates it. Those files hold a prior dispatch's accounting with
87
+ * no ledger rows to dedupe against, so reading them from 0 re-sums the whole
88
+ * session (the 421M-cache-read inflation of DX-2583). A file that appears
89
+ * LATER is genuinely new and still starts at 0.
90
+ *
91
+ * It must never be set alongside cursors for some sources: rule 5 says a
92
+ * sub-agent file that appeared after the last stats write, on a dispatch that
93
+ * HAS cursors, starts at 0 so none of its lines are lost.
62
94
  */
63
- fromEof?: boolean;
95
+ startExistingFilesAtEof?: boolean;
64
96
  /**
65
- * DX-2963 — the end-of-file rule for PRE-CARD lineage. When set, every
66
- * sub-agent file that ALREADY EXISTS on the first sub-agent discovery pass
67
- * is opened at its end (like the main file under `fromEof`); a sub-agent
68
- * file that appears later is read from byte 0. Set by the worker when its
69
- * own dispatch row, or its predecessor's, is pre-card (stored totals with
70
- * no ledger rows to dedupe against), so replies those totals already count
71
- * are never credited again. DX-2954 replaces this with its cursor start
72
- * order and deletes it.
97
+ * DX-2954 — a stored cursor was PAST the end of its file (the file was
98
+ * replaced or truncated), so that source restarted at 0.
99
+ *
100
+ * Without this the watcher would silently tail nothing for the rest of the
101
+ * dispatch: its `size <= byteOffset` early return is indistinguishable from
102
+ * "no new bytes". The caller records a visible `transcript_cursor_past_eof`
103
+ * event with both numbers and marks the reset in its next stats write, which
104
+ * is the ONE sanctioned backwards move of a stored `acked`.
73
105
  */
74
- existingSubagentFilesFromEof?: boolean;
106
+ onCursorPastEof?: (info: CursorPastEofInfo) => void;
107
+ /**
108
+ * DX-2954 — a sub-agent transcript just came under tail for the first time,
109
+ * i.e. a sub-agent of this dispatch is RUNNING.
110
+ *
111
+ * This is the transcript-side producer of the dispatch's running set. It
112
+ * reports the sub-agent's ledger source key, which is the same value its
113
+ * `dispatch_activities.activity_id` and its per-source token total join on —
114
+ * so a hook-side producer reporting the same sub-agent by its
115
+ * `transcript_path` resolves to this same id and upserts the SAME row rather
116
+ * than a duplicate.
117
+ *
118
+ * Fired once per source, from the discovery pass, for sub-agent files only —
119
+ * never for the main transcript, which is the dispatch itself, not an activity
120
+ * within it.
121
+ */
122
+ onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
123
+ }
124
+ /** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
125
+ export interface SourceCursorStart {
126
+ /** The resume point: read on from here. */
127
+ acked: number;
128
+ /**
129
+ * The highest byte already handed to subscribers. Lines between `acked` and
130
+ * this are re-read and handed on marked as REPLAYS, so side-effecting
131
+ * subscribers skip them while the ledger and the log shipper still see them.
132
+ */
133
+ delivered: number;
134
+ }
135
+ /** DX-2954 — reported when a stored cursor was past its file's end. */
136
+ export interface CursorPastEofInfo {
137
+ source: string;
138
+ storedOffset: number;
139
+ fileSize: number;
140
+ }
141
+ /** DX-2954 — reported when a sub-agent's transcript comes under tail. */
142
+ export interface SourceDiscoveredInfo {
143
+ /** The sub-agent's ledger source key (its transcript file's basename). */
144
+ source: string;
145
+ /**
146
+ * The sub-agent's declared type, read from its own transcript. `null` when it
147
+ * has not written a line naming one yet — a hook-side report of the same
148
+ * sub-agent fills it in later without clobbering a value already stored.
149
+ */
150
+ agentType: string | null;
151
+ /** Epoch ms this watcher first saw the file. */
152
+ discoveredAt: number;
153
+ /**
154
+ * The file already existed when this watcher attached (found on its FIRST
155
+ * discovery pass). A consumer attached to a dispatch that CONTINUES an earlier
156
+ * one (a `--resume`, a dispatcher or thread resume) uses this to tell its
157
+ * predecessor's sub-agents, which ended with that process, from its own.
158
+ */
159
+ preexisting: boolean;
160
+ }
161
+ /**
162
+ * DX-2954 — what a consumer learns about the line it is being handed, beyond
163
+ * the entry itself.
164
+ */
165
+ export interface EntryMeta {
166
+ /** {@link MAIN_SOURCE} for the main transcript, else the sub-agent id. */
167
+ source: string;
168
+ /** Byte offset of the line's first byte in its file. */
169
+ byteOffset: number;
170
+ /**
171
+ * This line was already handed to a PREVIOUS incarnation's subscribers (it
172
+ * falls below the source's stored `delivered` cursor) and is being handed
173
+ * again after a restart.
174
+ *
175
+ * Every subscriber with a SIDE EFFECT must skip a replay — re-posting Slack
176
+ * progress, re-forwarding to gpt-manager, or re-running the API-error
177
+ * detector on a predecessor's last lines are all real, visible damage. The
178
+ * ledger and the log shipper DO process replays: both are idempotent, and
179
+ * skipping them is what would lose data.
180
+ */
181
+ replay: boolean;
75
182
  }
76
183
  /**
77
184
  * Encode a working directory to the subdir name Claude Code writes JSONL
@@ -186,6 +293,29 @@ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTime
186
293
  entry: AgentLogEntry;
187
294
  timestamp: number;
188
295
  } | null;
296
+ /**
297
+ * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
298
+ * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
299
+ * the right `startCursors` / `startExistingFilesAtEof`.
300
+ *
301
+ * | # | Condition | Start |
302
+ * |---|---|---|
303
+ * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
304
+ * | 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` |
305
+ * | 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 |
306
+ * | 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 |
307
+ * | 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 |
308
+ *
309
+ * Rules 1 and 2 arrive identically because "where does this source resume" has
310
+ * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
311
+ * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
312
+ * late sub-agent file's lines).
313
+ *
314
+ * Rules 3 and 5 both read from 0, but for different reasons, and both are
315
+ * correct: a fresh session's full usage SHOULD be counted, and a forked
316
+ * session's copied history is read and credited nothing (the ledger's excess
317
+ * rule already covers it).
318
+ */
189
319
  export declare class SessionLogWatcher {
190
320
  private consumers;
191
321
  private pollTimer;
@@ -197,13 +327,62 @@ export declare class SessionLogWatcher {
197
327
  private sessionId;
198
328
  private dispatchId;
199
329
  private pollIntervalMs;
200
- private fromEof;
201
- private existingSubagentFilesFromEof;
330
+ private readonly startCursors;
331
+ private readonly startExistingFilesAtEof;
332
+ private readonly onCursorPastEof;
333
+ private readonly onSourceDiscovered;
202
334
  private subagentDiscoveryDone;
203
335
  private running;
204
336
  private polling;
205
337
  private initSynthesized;
206
338
  constructor(options: SessionLogWatcherOptions);
339
+ /**
340
+ * DX-2954 — resolve one file's starting offset and replay boundary, per the
341
+ * start order on {@link SessionLogWatcher}.
342
+ *
343
+ * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
344
+ * attached, on a continuing worker dispatch with no cursor anywhere) from
345
+ * rule 5 (a file that appeared later, which always starts at 0 so none of its
346
+ * lines are lost).
347
+ */
348
+ private resolveStart;
349
+ /**
350
+ * DX-2954 — every open source's cursor state.
351
+ *
352
+ * `delivered` is the byte up to which this watcher has handed lines to
353
+ * subscribers, which the collector reports as the `delivered` half of its
354
+ * stats write. `startOffset` is where this watcher OPENED the file, and it is
355
+ * the floor for the `acked` half: a source that started at EOF under rule 4
356
+ * must never report `acked: 0`, or the next restart would read the whole file
357
+ * from the start and re-sum the history rule 4 exists to skip.
358
+ */
359
+ cursorState(): Record<string, {
360
+ startOffset: number;
361
+ delivered: number;
362
+ }>;
363
+ /**
364
+ * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
365
+ * or 0 when it has none.
366
+ *
367
+ * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
368
+ * watcher attach mid-session?". The offset alone is not enough: because `acked`
369
+ * lags `delivered` by one write, a crash right after the first cursor-bearing
370
+ * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
371
+ * every byte up to D is a re-read.
372
+ */
373
+ mainReplayUntil(): number;
374
+ /**
375
+ * DX-2954 — the main transcript's starting offset once discovery has run, or
376
+ * `null` before that.
377
+ *
378
+ * This is what "is this a mid-session attach?" is derived from now. The
379
+ * declared-loaded observer used to be told `reattached: true` explicitly;
380
+ * a non-zero main start is the same fact stated as an observation rather than
381
+ * a flag, and it is true for every case that matters (a reattach, a
382
+ * `--resume`, a resumed operator session) without a caller having to
383
+ * enumerate them.
384
+ */
385
+ mainStartOffset(): number | null;
207
386
  /** Register a consumer callback that receives each new AgentLogEntry. */
208
387
  onEntry(consumer: EntryConsumer): void;
209
388
  /** Returns the discovered parent session file path, or null if not yet found. */
@@ -264,15 +443,10 @@ export declare class SessionLogWatcher {
264
443
  /**
265
444
  * Adopt a parent session file and derive the sub-agent directory.
266
445
  *
267
- * When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
268
- * the option's doc), seed `byteOffset` to the file's current size so the
269
- * next poll reads strictly forward — historical entries are NOT re-emitted.
270
- * A fresh `stat` failure (race
271
- * with a delete that happens between discover and adopt) falls back to
272
- * 0 so the watcher tails from the start; that is preferable to throwing
273
- * during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
274
- * file claude writes AFTER reattach is genuinely new and should be
275
- * tailed normally.
446
+ * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
447
+ * (the cursor start order), not from a `fromEof` flag: the main source resumes
448
+ * at its stored `acked` cursor when it has one, and only a continuing worker
449
+ * dispatch with no cursor anywhere skips to the file's end.
276
450
  */
277
451
  private setParentFile;
278
452
  /**
@@ -297,6 +471,27 @@ export declare class SessionLogWatcher {
297
471
  * `pollFile` — the inline call is the defense-in-depth that closes the race
298
472
  * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
299
473
  */
474
+ /**
475
+ * DX-2954 — report a running sub-agent to the consumer, normalizing the
476
+ * absent-type shape and CONTAINING any throw.
477
+ *
478
+ * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
479
+ * every consumer for the same reason, and it is load-bearing here because of
480
+ * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
481
+ * `poll()` has `try/finally` without one, so an escaping throw would:
482
+ *
483
+ * - on the first pass (inside `start()`), kill the watcher for the whole
484
+ * dispatch — the caller only logs `start()`'s rejection;
485
+ * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
486
+ * turns into process exit — a worker crash caused by a consumer bug;
487
+ * - either way abort the discovery loop mid-list, skipping
488
+ * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
489
+ * sends later-appearing files down rule 4 instead of rule 5 — losing
490
+ * exactly the lines that rule split exists to preserve.
491
+ *
492
+ * Reporting the running set must never cost the monitoring it rides on.
493
+ */
494
+ private emitSourceDiscovered;
300
495
  private refreshLineage;
301
496
  /**
302
497
  * Read agentType from the `<subagentId>.meta.json` sidecar. Returns