@thehammer/danx-session-telemetry 0.1.12 → 0.1.13

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/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * usage display.
16
16
  */
17
17
  export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, } from "./session-log-watcher.js";
18
- export type { EntryConsumer, SessionLogWatcherOptions, SourceCursorStart, CursorPastEofInfo, SourceDiscoveredInfo, EntryMeta, } from "./session-log-watcher.js";
18
+ export type { EntryConsumer, SessionLogWatcherOptions, } from "./session-log-watcher.js";
19
19
  export type { AgentLogEntry, AgentLineage } from "./types.js";
20
20
  export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
21
21
  export * from "./usage-ledger.js";
package/dist/pricing.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * DX-2963 — the ONE pricing function, shared by every consumer that turns
3
3
  * token counts into dollars: the danxbot server's usage-ledger writer (which
4
- * prices each credit once, when it is credited), the router's Haiku calls and
5
- * the skill-eval cost report. Moved here from danxbot root `src/agent/pricing.ts`
4
+ * prices each credit once, when it is credited), and the router's Haiku calls.
5
+ * Moved here from danxbot root `src/agent/pricing.ts`
6
6
  * so the counter and the price list live side by side, and exported through
7
7
  * the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
8
8
  *
package/dist/pricing.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * DX-2963 — the ONE pricing function, shared by every consumer that turns
3
3
  * token counts into dollars: the danxbot server's usage-ledger writer (which
4
- * prices each credit once, when it is credited), the router's Haiku calls and
5
- * the skill-eval cost report. Moved here from danxbot root `src/agent/pricing.ts`
4
+ * prices each credit once, when it is credited), and the router's Haiku calls.
5
+ * Moved here from danxbot root `src/agent/pricing.ts`
6
6
  * so the counter and the price list live side by side, and exported through
7
7
  * the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
8
8
  *
@@ -28,14 +28,7 @@ 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
- /**
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;
31
+ export type EntryConsumer = (entry: AgentLogEntry) => void;
39
32
  export interface SessionLogWatcherOptions {
40
33
  /** Working directory the agent was launched in. Used to derive the session directory. */
41
34
  cwd: string;
@@ -53,118 +46,32 @@ export interface SessionLogWatcherOptions {
53
46
  */
54
47
  dispatchId?: string;
55
48
  /**
56
- * DX-2954 — where each source resumes: the cursors the SERVER has stored for
57
- * this dispatch, keyed by source ({@link MAIN_SOURCE} for the main transcript,
58
- * the sub-agent id for a sub-agent file).
59
- *
60
- * An ABSENT source key means no cursor is stored for it — never "start at 0".
61
- * The two are different: 0 is a legitimate stored cursor meaning "start of
62
- * file", and CP1 forbids defaulting a missing persisted value. See
63
- * {@link SessionLogWatcherOptions.startExistingFilesAtEof} and the start-order
64
- * table on {@link SessionLogWatcher} for what happens when a source has none.
65
- *
66
- * This REPLACED the pre-DX-2954 `fromEof` / `existingSubagentFilesFromEof`
67
- * pair, which skipped straight to a file's end on every reattach and every
68
- * `--resume` because there was nowhere to record how far a reader had got.
69
- * That lost every line between the last write and the restart; a cursor does
70
- * not.
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.
71
62
  */
72
- startCursors?: Record<string, SourceCursorStart>;
63
+ fromEof?: boolean;
73
64
  /**
74
- * DX-2954 — rule 4 of the start order: start every ALREADY-EXISTING file at
75
- * its current end.
76
- *
77
- * Set ONLY by a WORKER dispatch that continues a session and has no cursor
78
- * for ANY source — a row from before this card shipped, or a `--resume` whose
79
- * predecessor predates it. Those files hold a prior dispatch's accounting with
80
- * no ledger rows to dedupe against, so reading them from 0 re-sums the whole
81
- * session (the 421M-cache-read inflation of DX-2583). A file that appears
82
- * LATER is genuinely new and still starts at 0.
83
- *
84
- * It must never be set alongside cursors for some sources: rule 5 says a
85
- * sub-agent file that appeared after the last stats write, on a dispatch that
86
- * HAS cursors, starts at 0 so none of its lines are lost.
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.
87
73
  */
88
- startExistingFilesAtEof?: boolean;
89
- /**
90
- * DX-2954 — a stored cursor was PAST the end of its file (the file was
91
- * replaced or truncated), so that source restarted at 0.
92
- *
93
- * Without this the watcher would silently tail nothing for the rest of the
94
- * dispatch: its `size <= byteOffset` early return is indistinguishable from
95
- * "no new bytes". The caller records a visible `transcript_cursor_past_eof`
96
- * event with both numbers and marks the reset in its next stats write, which
97
- * is the ONE sanctioned backwards move of a stored `acked`.
98
- */
99
- onCursorPastEof?: (info: CursorPastEofInfo) => void;
100
- /**
101
- * DX-2954 — a sub-agent transcript just came under tail for the first time,
102
- * i.e. a sub-agent of this dispatch is RUNNING.
103
- *
104
- * This is the transcript-side producer of the dispatch's running set. It
105
- * reports the sub-agent's ledger source key, which is the same value its
106
- * `dispatch_activities.activity_id` and its per-source token total join on —
107
- * so a hook-side producer reporting the same sub-agent by its
108
- * `transcript_path` resolves to this same id and upserts the SAME row rather
109
- * than a duplicate.
110
- *
111
- * Fired once per source, from the discovery pass, for sub-agent files only —
112
- * never for the main transcript, which is the dispatch itself, not an activity
113
- * within it.
114
- */
115
- onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
116
- }
117
- /** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
118
- export interface SourceCursorStart {
119
- /** The resume point: read on from here. */
120
- acked: number;
121
- /**
122
- * The highest byte already handed to subscribers. Lines between `acked` and
123
- * this are re-read and handed on marked as REPLAYS, so side-effecting
124
- * subscribers skip them while the ledger and the log shipper still see them.
125
- */
126
- delivered: number;
127
- }
128
- /** DX-2954 — reported when a stored cursor was past its file's end. */
129
- export interface CursorPastEofInfo {
130
- source: string;
131
- storedOffset: number;
132
- fileSize: number;
133
- }
134
- /** DX-2954 — reported when a sub-agent's transcript comes under tail. */
135
- export interface SourceDiscoveredInfo {
136
- /** The sub-agent's ledger source key (its transcript file's basename). */
137
- source: string;
138
- /**
139
- * The sub-agent's declared type, read from its own transcript. `null` when it
140
- * has not written a line naming one yet — a hook-side report of the same
141
- * sub-agent fills it in later without clobbering a value already stored.
142
- */
143
- agentType: string | null;
144
- /** Epoch ms this watcher first saw the file. */
145
- discoveredAt: number;
146
- }
147
- /**
148
- * DX-2954 — what a consumer learns about the line it is being handed, beyond
149
- * the entry itself.
150
- */
151
- export interface EntryMeta {
152
- /** {@link MAIN_SOURCE} for the main transcript, else the sub-agent id. */
153
- source: string;
154
- /** Byte offset of the line's first byte in its file. */
155
- byteOffset: number;
156
- /**
157
- * This line was already handed to a PREVIOUS incarnation's subscribers (it
158
- * falls below the source's stored `delivered` cursor) and is being handed
159
- * again after a restart.
160
- *
161
- * Every subscriber with a SIDE EFFECT must skip a replay — re-posting Slack
162
- * progress, re-forwarding to gpt-manager, or re-running the API-error
163
- * detector on a predecessor's last lines are all real, visible damage. The
164
- * ledger and the log shipper DO process replays: both are idempotent, and
165
- * skipping them is what would lose data.
166
- */
167
- replay: boolean;
74
+ existingSubagentFilesFromEof?: boolean;
168
75
  }
169
76
  /**
170
77
  * Encode a working directory to the subdir name Claude Code writes JSONL
@@ -279,29 +186,6 @@ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTime
279
186
  entry: AgentLogEntry;
280
187
  timestamp: number;
281
188
  } | null;
282
- /**
283
- * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
284
- * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
285
- * the right `startCursors` / `startExistingFilesAtEof`.
286
- *
287
- * | # | Condition | Start |
288
- * |---|---|---|
289
- * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
290
- * | 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` |
291
- * | 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 |
292
- * | 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 |
293
- * | 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 |
294
- *
295
- * Rules 1 and 2 arrive identically because "where does this source resume" has
296
- * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
297
- * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
298
- * late sub-agent file's lines).
299
- *
300
- * Rules 3 and 5 both read from 0, but for different reasons, and both are
301
- * correct: a fresh session's full usage SHOULD be counted, and a forked
302
- * session's copied history is read and credited nothing (the ledger's excess
303
- * rule already covers it).
304
- */
305
189
  export declare class SessionLogWatcher {
306
190
  private consumers;
307
191
  private pollTimer;
@@ -313,62 +197,13 @@ export declare class SessionLogWatcher {
313
197
  private sessionId;
314
198
  private dispatchId;
315
199
  private pollIntervalMs;
316
- private readonly startCursors;
317
- private readonly startExistingFilesAtEof;
318
- private readonly onCursorPastEof;
319
- private readonly onSourceDiscovered;
200
+ private fromEof;
201
+ private existingSubagentFilesFromEof;
320
202
  private subagentDiscoveryDone;
321
203
  private running;
322
204
  private polling;
323
205
  private initSynthesized;
324
206
  constructor(options: SessionLogWatcherOptions);
325
- /**
326
- * DX-2954 — resolve one file's starting offset and replay boundary, per the
327
- * start order on {@link SessionLogWatcher}.
328
- *
329
- * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
330
- * attached, on a continuing worker dispatch with no cursor anywhere) from
331
- * rule 5 (a file that appeared later, which always starts at 0 so none of its
332
- * lines are lost).
333
- */
334
- private resolveStart;
335
- /**
336
- * DX-2954 — every open source's cursor state.
337
- *
338
- * `delivered` is the byte up to which this watcher has handed lines to
339
- * subscribers, which the collector reports as the `delivered` half of its
340
- * stats write. `startOffset` is where this watcher OPENED the file, and it is
341
- * the floor for the `acked` half: a source that started at EOF under rule 4
342
- * must never report `acked: 0`, or the next restart would read the whole file
343
- * from the start and re-sum the history rule 4 exists to skip.
344
- */
345
- cursorState(): Record<string, {
346
- startOffset: number;
347
- delivered: number;
348
- }>;
349
- /**
350
- * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
351
- * or 0 when it has none.
352
- *
353
- * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
354
- * watcher attach mid-session?". The offset alone is not enough: because `acked`
355
- * lags `delivered` by one write, a crash right after the first cursor-bearing
356
- * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
357
- * every byte up to D is a re-read.
358
- */
359
- mainReplayUntil(): number;
360
- /**
361
- * DX-2954 — the main transcript's starting offset once discovery has run, or
362
- * `null` before that.
363
- *
364
- * This is what "is this a mid-session attach?" is derived from now. The
365
- * declared-loaded observer used to be told `reattached: true` explicitly;
366
- * a non-zero main start is the same fact stated as an observation rather than
367
- * a flag, and it is true for every case that matters (a reattach, a
368
- * `--resume`, a resumed operator session) without a caller having to
369
- * enumerate them.
370
- */
371
- mainStartOffset(): number | null;
372
207
  /** Register a consumer callback that receives each new AgentLogEntry. */
373
208
  onEntry(consumer: EntryConsumer): void;
374
209
  /** Returns the discovered parent session file path, or null if not yet found. */
@@ -429,10 +264,15 @@ export declare class SessionLogWatcher {
429
264
  /**
430
265
  * Adopt a parent session file and derive the sub-agent directory.
431
266
  *
432
- * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
433
- * (the cursor start order), not from a `fromEof` flag: the main source resumes
434
- * at its stored `acked` cursor when it has one, and only a continuing worker
435
- * dispatch with no cursor anywhere skips to the file's end.
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.
436
276
  */
437
277
  private setParentFile;
438
278
  /**
@@ -457,27 +297,6 @@ export declare class SessionLogWatcher {
457
297
  * `pollFile` — the inline call is the defense-in-depth that closes the race
458
298
  * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
459
299
  */
460
- /**
461
- * DX-2954 — report a running sub-agent to the consumer, normalizing the
462
- * absent-type shape and CONTAINING any throw.
463
- *
464
- * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
465
- * every consumer for the same reason, and it is load-bearing here because of
466
- * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
467
- * `poll()` has `try/finally` without one, so an escaping throw would:
468
- *
469
- * - on the first pass (inside `start()`), kill the watcher for the whole
470
- * dispatch — the caller only logs `start()`'s rejection;
471
- * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
472
- * turns into process exit — a worker crash caused by a consumer bug;
473
- * - either way abort the discovery loop mid-list, skipping
474
- * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
475
- * sends later-appearing files down rule 4 instead of rule 5 — losing
476
- * exactly the lines that rule split exists to preserve.
477
- *
478
- * Reporting the running set must never cost the monitoring it rides on.
479
- */
480
- private emitSourceDiscovered;
481
300
  private refreshLineage;
482
301
  /**
483
302
  * Read agentType from the `<subagentId>.meta.json` sidecar. Returns
@@ -26,7 +26,6 @@ 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";
30
29
  const log = createLogger("session-log-watcher");
31
30
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
32
31
  const FILE_DISCOVERY_POLL_MS = 1_000;
@@ -414,29 +413,6 @@ export function convertJsonlEntry(raw, lastTimestamp) {
414
413
  return null;
415
414
  }
416
415
  }
417
- /**
418
- * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
419
- * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
420
- * the right `startCursors` / `startExistingFilesAtEof`.
421
- *
422
- * | # | Condition | Start |
423
- * |---|---|---|
424
- * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
425
- * | 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` |
426
- * | 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 |
427
- * | 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 |
428
- * | 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 |
429
- *
430
- * Rules 1 and 2 arrive identically because "where does this source resume" has
431
- * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
432
- * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
433
- * late sub-agent file's lines).
434
- *
435
- * Rules 3 and 5 both read from 0, but for different reasons, and both are
436
- * correct: a fresh session's full usage SHOULD be counted, and a forked
437
- * session's copied history is read and credited nothing (the ledger's excess
438
- * rule already covers it).
439
- */
440
416
  export class SessionLogWatcher {
441
417
  consumers = [];
442
418
  pollTimer;
@@ -448,10 +424,8 @@ export class SessionLogWatcher {
448
424
  sessionId;
449
425
  dispatchId;
450
426
  pollIntervalMs;
451
- startCursors;
452
- startExistingFilesAtEof;
453
- onCursorPastEof;
454
- onSourceDiscovered;
427
+ fromEof;
428
+ existingSubagentFilesFromEof;
455
429
  subagentDiscoveryDone = false;
456
430
  running = false;
457
431
  polling = false;
@@ -467,110 +441,9 @@ export class SessionLogWatcher {
467
441
  this.sessionId = options.sessionId;
468
442
  this.dispatchId = options.dispatchId;
469
443
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
470
- this.startCursors = options.startCursors ?? {};
471
- this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
472
- this.onCursorPastEof = options.onCursorPastEof;
473
- this.onSourceDiscovered = options.onSourceDiscovered;
474
- }
475
- /**
476
- * DX-2954 — resolve one file's starting offset and replay boundary, per the
477
- * start order on {@link SessionLogWatcher}.
478
- *
479
- * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
480
- * attached, on a continuing worker dispatch with no cursor anywhere) from
481
- * rule 5 (a file that appeared later, which always starts at 0 so none of its
482
- * lines are lost).
483
- */
484
- async resolveStart(source, filePath, fileAlreadyExisted) {
485
- const stored = this.startCursors[source];
486
- // Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
487
- // the caller already resolved into `startCursors`. Both arrive here the
488
- // same way, because "where does this source resume" has ONE answer.
489
- if (stored !== undefined) {
490
- return { byteOffset: stored.acked, replayUntil: stored.delivered };
491
- }
492
- // Rule 4: no cursor for ANY source on a continuing worker dispatch.
493
- if (this.startExistingFilesAtEof && fileAlreadyExisted) {
494
- try {
495
- const size = (await stat(filePath)).size;
496
- return { byteOffset: size, replayUntil: 0 };
497
- }
498
- catch (err) {
499
- // NOT a fall-back to 0. This branch fires precisely on the rows whose
500
- // stored totals already count this file, so reading it from the start
501
- // re-sums the whole session — the 421M-cache-read inflation (DX-2583)
502
- // that rule 4 exists to prevent. `null` means "do not open this source
503
- // yet"; the caller leaves it undiscovered and the next poll retries,
504
- // still treating it as pre-existing.
505
- log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
506
- return null;
507
- }
508
- }
509
- // Rules 3 and 5: the first dispatch of a session, and any file that appeared
510
- // after the last stats write, both read from the start.
511
- return { byteOffset: 0, replayUntil: 0 };
512
- }
513
- /**
514
- * DX-2954 — every open source's cursor state.
515
- *
516
- * `delivered` is the byte up to which this watcher has handed lines to
517
- * subscribers, which the collector reports as the `delivered` half of its
518
- * stats write. `startOffset` is where this watcher OPENED the file, and it is
519
- * the floor for the `acked` half: a source that started at EOF under rule 4
520
- * must never report `acked: 0`, or the next restart would read the whole file
521
- * from the start and re-sum the history rule 4 exists to skip.
522
- */
523
- cursorState() {
524
- // `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
525
- //
526
- // `pollFile` advances `byteOffset` by every byte it READ, and `processText`
527
- // deliberately buffers a trailing newline-less line (the whole reason
528
- // `lineBuffer` exists — a poll routinely catches a line mid-append). So
529
- // `byteOffset` can sit PAST the first byte of a line that was never emitted;
530
- // promoting that to `acked` makes a restart open the file mid-JSON, and the
531
- // fragment is dropped as a malformed line — losing that reply's usage, its
532
- // tool-use ids and its side effects permanently.
533
- //
534
- // `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
535
- // exactly the resume point. It equals `byteOffset` whenever the buffer is
536
- // empty, so the happy path is unchanged.
537
- const at = (s) => ({
538
- startOffset: s.startOffset,
539
- delivered: s.lineBufferStartOffset,
540
- });
541
- const out = {};
542
- if (this.parentTail)
543
- out[this.parentTail.source] = at(this.parentTail);
544
- for (const state of this.subagentTails.values())
545
- out[state.source] = at(state);
546
- return out;
547
- }
548
- /**
549
- * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
550
- * or 0 when it has none.
551
- *
552
- * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
553
- * watcher attach mid-session?". The offset alone is not enough: because `acked`
554
- * lags `delivered` by one write, a crash right after the first cursor-bearing
555
- * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
556
- * every byte up to D is a re-read.
557
- */
558
- mainReplayUntil() {
559
- return this.parentTail?.replayUntil ?? 0;
560
- }
561
- /**
562
- * DX-2954 — the main transcript's starting offset once discovery has run, or
563
- * `null` before that.
564
- *
565
- * This is what "is this a mid-session attach?" is derived from now. The
566
- * declared-loaded observer used to be told `reattached: true` explicitly;
567
- * a non-zero main start is the same fact stated as an observation rather than
568
- * a flag, and it is true for every case that matters (a reattach, a
569
- * `--resume`, a resumed operator session) without a caller having to
570
- * enumerate them.
571
- */
572
- mainStartOffset() {
573
- return this.parentTail?.startOffset ?? null;
444
+ this.fromEof = options.fromEof ?? false;
445
+ this.existingSubagentFilesFromEof =
446
+ options.existingSubagentFilesFromEof ?? false;
574
447
  }
575
448
  /** Register a consumer callback that receives each new AgentLogEntry. */
576
449
  onEntry(consumer) {
@@ -671,7 +544,8 @@ export class SessionLogWatcher {
671
544
  const filePath = this.dispatchId
672
545
  ? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
673
546
  : await findNewestJsonlFile(this.sessionDir, this.sessionId);
674
- if (filePath && (await this.setParentFile(filePath))) {
547
+ if (filePath) {
548
+ await this.setParentFile(filePath);
675
549
  const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
676
550
  log.info(`${tag}Watching session file: ${filePath}`);
677
551
  return;
@@ -683,35 +557,35 @@ export class SessionLogWatcher {
683
557
  /**
684
558
  * Adopt a parent session file and derive the sub-agent directory.
685
559
  *
686
- * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
687
- * (the cursor start order), not from a `fromEof` flag: the main source resumes
688
- * at its stored `acked` cursor when it has one, and only a continuing worker
689
- * dispatch with no cursor anywhere skips to the file's end.
560
+ * When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
561
+ * the option's doc), seed `byteOffset` to the file's current size so the
562
+ * next poll reads strictly forward — historical entries are NOT re-emitted.
563
+ * A fresh `stat` failure (race
564
+ * with a delete that happens between discover and adopt) falls back to
565
+ * 0 so the watcher tails from the start; that is preferable to throwing
566
+ * during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
567
+ * file claude writes AFTER reattach is genuinely new and should be
568
+ * tailed normally.
690
569
  */
691
570
  async setParentFile(filePath) {
692
- // DX-2954 — the main transcript's source key is always MAIN_SOURCE, and it
693
- // ALWAYS counts as already-existing: by the time discovery found this file
694
- // it is on disk, which is exactly what rule 4 asks about.
695
- const start = await this.resolveStart(MAIN_SOURCE, filePath, true);
696
- // `null` = its start offset could not be established safely (see
697
- // `resolveStart`). Report the failure so discovery retries rather than
698
- // adopting the file at a guessed offset.
699
- if (start === null)
700
- return false;
701
- const { byteOffset, replayUntil } = start;
571
+ let byteOffset = 0;
572
+ if (this.fromEof) {
573
+ try {
574
+ const fileStats = await stat(filePath);
575
+ byteOffset = fileStats.size;
576
+ }
577
+ catch (err) {
578
+ log.warn(`fromEof: stat failed for ${filePath}; tailing from 0: ${String(err)}`);
579
+ }
580
+ }
702
581
  this.parentTail = {
703
582
  filePath,
704
583
  byteOffset,
705
584
  lineBuffer: "",
706
585
  lineBufferStartOffset: byteOffset,
707
586
  lastTimestamp: 0,
708
- source: MAIN_SOURCE,
709
- startOffset: byteOffset,
710
- replayUntil,
711
- discoveredAt: Date.now(),
712
587
  };
713
588
  this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
714
- return true;
715
589
  }
716
590
  /**
717
591
  * Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
@@ -753,41 +627,8 @@ export class SessionLogWatcher {
753
627
  const fileStats = await stat(state.filePath);
754
628
  if (!this.running)
755
629
  return;
756
- // DX-2954 — a cursor PAST the end of the file means the file was replaced
757
- // or truncated under us. `size === byteOffset` is the ordinary "no new
758
- // bytes" case; only a STRICTLY smaller file is the pathological one, and
759
- // before this card it fell into the same early return and silently tailed
760
- // nothing for the rest of the dispatch. Restart that source at 0 and
761
- // report it, so the caller records a visible event and marks the reset in
762
- // its next stats write (the one sanctioned backwards move of `acked`).
763
- if (fileStats.size < state.byteOffset) {
764
- this.onCursorPastEof?.({
765
- source: state.source,
766
- storedOffset: state.byteOffset,
767
- fileSize: fileStats.size,
768
- });
769
- state.byteOffset = 0;
770
- state.lineBufferStartOffset = 0;
771
- state.lineBuffer = "";
772
- // `startOffset` moves to 0 with it: it is the FLOOR the collector uses
773
- // for this source's reported `acked`, so leaving it at the old (now
774
- // impossible) offset would make the next write claim to have
775
- // acknowledged bytes the file no longer has.
776
- state.startOffset = 0;
777
- // Nothing below the new position was ever delivered from THIS file's
778
- // current content, so nothing in it is a replay.
779
- state.replayUntil = 0;
780
- // The replacement file's first entry must not have its `delta_ms`
781
- // computed against the OLD file's last timestamp (which can produce a
782
- // negative or absurd gap). This is a different file; its timeline starts
783
- // fresh.
784
- state.lastTimestamp = 0;
785
- if (fileStats.size === 0)
786
- return;
787
- }
788
- else if (fileStats.size === state.byteOffset) {
630
+ if (fileStats.size <= state.byteOffset)
789
631
  return;
790
- }
791
632
  const fd = await open(state.filePath, "r");
792
633
  try {
793
634
  if (!this.running)
@@ -845,62 +686,40 @@ export class SessionLogWatcher {
845
686
  log.error("Failed to enumerate sub-agent directory:", err);
846
687
  return;
847
688
  }
848
- // DX-2954 — the FIRST discovery pass sees exactly the sub-agent files that
849
- // existed when this watcher attached. Only those can satisfy rule 4's
850
- // "already existing"; a file found on a LATER pass appeared after the attach
851
- // and always reads from 0 (rule 5), so none of its lines are lost.
852
- const firstPass = !this.subagentDiscoveryDone;
853
- // Marked done only if this pass established a start offset for EVERY file it
854
- // saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
855
- // the retry — treating it as "appeared later" would send it to rule 5 (read
856
- // from 0) and re-sum exactly the history rule 4 skipped.
857
- let allResolved = true;
689
+ // DX-2963 — the FIRST discovery pass sees exactly the sub-agent files
690
+ // that existed when this watcher attached; only those may open at EOF.
691
+ const openExistingAtEof = this.existingSubagentFilesFromEof && !this.subagentDiscoveryDone;
692
+ this.subagentDiscoveryDone = true;
858
693
  for (const file of files) {
859
694
  if (!file.endsWith(".jsonl"))
860
695
  continue;
861
696
  const fullPath = join(this.subagentsDir, file);
862
697
  if (this.subagentTails.has(fullPath))
863
698
  continue;
864
- // A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
865
- // the same value `usage-ledger.ts` credits usage under and the same value
866
- // `dispatch_activities.activity_id` holds — so a cursor, a per-source
867
- // token total and a running-set row all join on one key.
868
699
  const subagentId = basename(file, ".jsonl");
869
700
  const agentType = await this.readAgentType(subagentId);
870
- const start = await this.resolveStart(subagentId, fullPath, firstPass);
871
- if (start === null) {
872
- allResolved = false;
873
- continue;
701
+ let startOffset = 0;
702
+ if (openExistingAtEof) {
703
+ try {
704
+ startOffset = (await stat(fullPath)).size;
705
+ }
706
+ catch (err) {
707
+ log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
708
+ }
874
709
  }
875
- const { byteOffset, replayUntil } = start;
876
- const discoveredAt = Date.now();
877
710
  this.subagentTails.set(fullPath, {
878
711
  filePath: fullPath,
879
- byteOffset,
880
- lineBufferStartOffset: byteOffset,
712
+ byteOffset: startOffset,
713
+ lineBufferStartOffset: startOffset,
881
714
  lineBuffer: "",
882
715
  lastTimestamp: 0,
883
- source: subagentId,
884
- startOffset: byteOffset,
885
- replayUntil,
886
- discoveredAt,
887
716
  lineage: {
888
717
  subagent_id: subagentId,
889
718
  parent_session_id: this.parentSessionId,
890
719
  agent_type: agentType,
891
720
  },
892
721
  });
893
- // DX-2954 — fired AFTER the tail is registered, so a consumer that throws
894
- // cannot leave a half-registered source, and the `subagentTails.has()`
895
- // guard above makes it exactly-once per source for this watcher's life. A
896
- // re-discovery by a RESTARTED watcher fires again by design: the row it
897
- // reports is an idempotent upsert keyed on this same id, and re-reporting
898
- // is how a reattached collector re-learns the sub-agents that were already
899
- // running before it existed.
900
- this.emitSourceDiscovered(subagentId, agentType, discoveredAt);
901
722
  }
902
- if (allResolved)
903
- this.subagentDiscoveryDone = true;
904
723
  }
905
724
  /** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
906
725
  async refreshPendingAgentTypes() {
@@ -916,62 +735,13 @@ export class SessionLogWatcher {
916
735
  * `pollFile` — the inline call is the defense-in-depth that closes the race
917
736
  * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
918
737
  */
919
- /**
920
- * DX-2954 — report a running sub-agent to the consumer, normalizing the
921
- * absent-type shape and CONTAINING any throw.
922
- *
923
- * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
924
- * every consumer for the same reason, and it is load-bearing here because of
925
- * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
926
- * `poll()` has `try/finally` without one, so an escaping throw would:
927
- *
928
- * - on the first pass (inside `start()`), kill the watcher for the whole
929
- * dispatch — the caller only logs `start()`'s rejection;
930
- * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
931
- * turns into process exit — a worker crash caused by a consumer bug;
932
- * - either way abort the discovery loop mid-list, skipping
933
- * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
934
- * sends later-appearing files down rule 4 instead of rule 5 — losing
935
- * exactly the lines that rule split exists to preserve.
936
- *
937
- * Reporting the running set must never cost the monitoring it rides on.
938
- */
939
- emitSourceDiscovered(source, agentType, discoveredAt) {
940
- try {
941
- this.onSourceDiscovered?.({
942
- source,
943
- // ONE absent-shape on the wire, never two: this class's own reader
944
- // answers `undefined` for "no meta file yet", while the stats payload and
945
- // the `dispatch_activities.agent_type` column both spell absent as
946
- // `null`. Normalizing here keeps every consumer from accepting both.
947
- agentType: agentType ?? null,
948
- discoveredAt,
949
- });
950
- }
951
- catch (err) {
952
- log.error("onSourceDiscovered consumer error:", err);
953
- }
954
- }
955
738
  async refreshLineage(state) {
956
739
  if (!state.lineage)
957
740
  return;
958
741
  let { agent_type, parent_session_id } = state.lineage;
959
- // DX-2954 — remember whether the type was still unknown coming in, so the
960
- // `undefined -> string` transition can be reported. Discovery reads the
961
- // sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
962
- // BEFORE the `.meta.json` (the race this method exists for), so "no type
963
- // yet" at discovery is the COMMON case, not an edge. Without a report here
964
- // the running-set row's `agent_type` would stay NULL for the whole dispatch
965
- // even though the watcher learned the real value one poll later.
966
- const typeWasUnknown = agent_type === undefined;
967
742
  if (agent_type === undefined) {
968
743
  agent_type = await this.readAgentType(state.lineage.subagent_id);
969
744
  }
970
- if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
971
- // The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
972
- // same activity, learning its type late, not a new one starting now.
973
- this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt);
974
- }
975
745
  if (parent_session_id === null && this.parentSessionId !== null) {
976
746
  parent_session_id = this.parentSessionId;
977
747
  }
@@ -1072,7 +842,7 @@ export class SessionLogWatcher {
1072
842
  synthesized: true,
1073
843
  },
1074
844
  };
1075
- this.emitEntry(initEntry, state, lineStart);
845
+ this.emitEntry(initEntry, state);
1076
846
  this.initSynthesized = true;
1077
847
  }
1078
848
  }
@@ -1090,7 +860,7 @@ export class SessionLogWatcher {
1090
860
  state.lastTimestamp = result.timestamp;
1091
861
  // DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
1092
862
  result.entry.data = { ...result.entry.data, byte_offset: lineStart };
1093
- this.emitEntry(result.entry, state, lineStart);
863
+ this.emitEntry(result.entry, state);
1094
864
  }
1095
865
  /**
1096
866
  * Emit an entry to registered consumers. For sub-agent streams,
@@ -1103,7 +873,7 @@ export class SessionLogWatcher {
1103
873
  * tests; nothing else read it. Every real consumer subscribes via
1104
874
  * `onEntry` instead.
1105
875
  */
1106
- emitEntry(entry, state, byteOffset) {
876
+ emitEntry(entry, state) {
1107
877
  if (state.lineage) {
1108
878
  entry.data = {
1109
879
  ...entry.data,
@@ -1112,18 +882,9 @@ export class SessionLogWatcher {
1112
882
  agent_type: state.lineage.agent_type,
1113
883
  };
1114
884
  }
1115
- // DX-2954 — a line whose FIRST byte lies below the source's stored
1116
- // `delivered` cursor was already handed to a previous incarnation's
1117
- // subscribers. Strictly `<`: `delivered` is an exclusive end (the next
1118
- // unread byte), so a line starting exactly AT it is new.
1119
- const meta = {
1120
- source: state.source,
1121
- byteOffset,
1122
- replay: byteOffset < state.replayUntil,
1123
- };
1124
885
  for (const consumer of this.consumers) {
1125
886
  try {
1126
- consumer(entry, meta);
887
+ consumer(entry);
1127
888
  }
1128
889
  catch (err) {
1129
890
  log.error("Consumer error:", err);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DX-2963 — the ONE usage counter. Turns Claude Code transcript lines into
3
3
  * per-reply usage-ledger entries; every counter in danxbot (the worker's
4
- * accumulator, the skill-eval probe, the stall sweep's productivity signal,
4
+ * accumulator, `claude-probe.ts`'s usage summer, the stall sweep's productivity signal,
5
5
  * the cross-consumer tests) uses it instead of its own summing.
6
6
  *
7
7
  * The rule it implements, measured on real transcripts (DX-2963 card,
@@ -121,7 +121,7 @@ export declare class UsageLedgerCounter {
121
121
  * Pull the counter's {@link LedgerLine} fields out of one raw transcript
122
122
  * line (a parsed JSONL object). Returns `null` for anything that is not an
123
123
  * assistant line. Shared by every counter that reads a transcript from disk
124
- * (the dashboard's reader, the skill-eval probe, the stall sweep).
124
+ * (the dashboard's reader, `claude-probe.ts`, the stall sweep).
125
125
  */
126
126
  export declare function ledgerLineFromRaw(raw: Record<string, unknown>, source: string, byteOffset: number): LedgerLine | null;
127
127
  /**
@@ -214,7 +214,7 @@ function snapshot(e) {
214
214
  * Pull the counter's {@link LedgerLine} fields out of one raw transcript
215
215
  * line (a parsed JSONL object). Returns `null` for anything that is not an
216
216
  * assistant line. Shared by every counter that reads a transcript from disk
217
- * (the dashboard's reader, the skill-eval probe, the stall sweep).
217
+ * (the dashboard's reader, `claude-probe.ts`, the stall sweep).
218
218
  */
219
219
  export function ledgerLineFromRaw(raw, source, byteOffset) {
220
220
  if (raw.type !== "assistant")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",