@50arms/session-telemetry 0.0.0-stage → 0.1.66

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.
Files changed (45) hide show
  1. package/README.md +68 -2
  2. package/dist/.source-hash +1 -0
  3. package/dist/activity-observer.d.ts +186 -0
  4. package/dist/activity-observer.js +917 -0
  5. package/dist/api-tool-names.d.ts +12 -0
  6. package/dist/api-tool-names.js +19 -0
  7. package/dist/browser.d.ts +29 -0
  8. package/dist/browser.js +10 -0
  9. package/dist/catalog-prices.d.ts +27 -0
  10. package/dist/catalog-prices.js +31 -0
  11. package/dist/claim-tracker.d.ts +53 -0
  12. package/dist/claim-tracker.js +213 -0
  13. package/dist/dispatch-usage.d.ts +321 -0
  14. package/dist/dispatch-usage.js +657 -0
  15. package/dist/git-head.d.ts +1 -0
  16. package/dist/git-head.js +19 -0
  17. package/dist/index.d.ts +35 -0
  18. package/dist/index.js +49 -0
  19. package/dist/internal-logger.d.ts +16 -0
  20. package/dist/internal-logger.js +27 -0
  21. package/dist/ledger-timing.d.ts +26 -0
  22. package/dist/ledger-timing.js +31 -0
  23. package/dist/pricing.d.ts +93 -0
  24. package/dist/pricing.js +77 -0
  25. package/dist/real-work.d.ts +13 -0
  26. package/dist/real-work.js +57 -0
  27. package/dist/session-log-watcher.d.ts +616 -0
  28. package/dist/session-log-watcher.js +1285 -0
  29. package/dist/session-title-marks.d.ts +2 -0
  30. package/dist/session-title-marks.js +16 -0
  31. package/dist/stats-wire.d.ts +345 -0
  32. package/dist/stats-wire.js +636 -0
  33. package/dist/subagent-live.d.ts +36 -0
  34. package/dist/subagent-live.js +153 -0
  35. package/dist/tool-summary.d.ts +19 -0
  36. package/dist/tool-summary.js +58 -0
  37. package/dist/transcript-timing.d.ts +100 -0
  38. package/dist/transcript-timing.js +231 -0
  39. package/dist/types.d.ts +25 -0
  40. package/dist/types.js +1 -0
  41. package/dist/usage-ledger.d.ts +256 -0
  42. package/dist/usage-ledger.js +581 -0
  43. package/dist/write-queue.d.ts +9 -0
  44. package/dist/write-queue.js +16 -0
  45. package/package.json +43 -4
@@ -0,0 +1,1285 @@
1
+ /**
2
+ * SessionLogWatcher — Polls Claude Code JSONL session files for new entries.
3
+ *
4
+ * DX-2962: moved unchanged from danxbot root `src/agent/session-log-watcher.ts`
5
+ * into this shared published package, so the danxbot worker and the
6
+ * `platform-mcp` MCP server's relay (DX-2954) run identical collector
7
+ * code. Only the imports changed — `../logger.js` -> `./internal-logger.js`
8
+ * (this package's own tiny logging shim, since root's `logger.ts` has ~300
9
+ * unrelated consumers and does not move), `../types.js` -> `./types.js`
10
+ * (moved — see that file's own header).
11
+ *
12
+ * Claude Code writes:
13
+ * - Parent session: ~/.claude/projects/<cwd-path>/<session-uuid>.jsonl
14
+ * - Sub-agent sessions: ~/.claude/projects/<cwd-path>/<session-uuid>/subagents/agent-<hash>.jsonl
15
+ * + agent-<hash>.meta.json sidecar with {agentType, description, parentAgentId (nested only), ...}
16
+ *
17
+ * The watcher tails the parent file and all sub-agent files in parallel, parses
18
+ * new lines, converts them to AgentLogEntry, and emits to registered consumers.
19
+ * Sub-agent entries are decorated with lineage metadata (subagent_id,
20
+ * parent_session_id, agent_type) inside `data` so downstream consumers can
21
+ * attribute usage correctly — they flow under the SAME dispatch, not a separate session.
22
+ */
23
+ import { existsSync, realpathSync } from "node:fs";
24
+ import { open, readFile, readdir, stat } from "node:fs/promises";
25
+ import { homedir } from "node:os";
26
+ import { basename, dirname, isAbsolute, join } from "node:path";
27
+ import { createLogger } from "./internal-logger.js";
28
+ import { buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
29
+ import { MAIN_SOURCE } from "./usage-ledger.js";
30
+ const log = createLogger("session-log-watcher");
31
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
32
+ const FILE_DISCOVERY_POLL_MS = 1_000;
33
+ // 180s max wait for JSONL to appear. claude --resume on a clean-room with
34
+ // slow pre-turn setup (SFC-deps provision, vitest cold start, mcp probe)
35
+ // can take 60-90s before the first assistant turn writes to the JSONL.
36
+ // 60s was too tight — matches DX-825 stall-threshold raise (5s→120s).
37
+ const MAX_FILE_DISCOVERY_ATTEMPTS = 180;
38
+ /**
39
+ * Tag prefix injected into the piped prompt by the launcher. The watcher scans
40
+ * JSONL files for this tag to deterministically find the correct session file
41
+ * for a given dispatch, avoiding mtime-based heuristics that can pick up the
42
+ * wrong file (e.g., an active human session).
43
+ */
44
+ export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
45
+ /** The line `type`s the usage feed carries; every other type never reaches a {@link UsageLineConsumer}. */
46
+ const USAGE_FEED_TYPES = new Set(["assistant", "user", "attachment", "system"]);
47
+ /**
48
+ * DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
49
+ * through (Slack progress, the gpt-manager forwarder): it hands on only lines
50
+ * read for the FIRST time. A replay was already handed to the previous
51
+ * incarnation's subscribers, so acting on it again is a duplicate post/event.
52
+ */
53
+ export function skipReplays(consumer) {
54
+ return (entry, meta) => {
55
+ if (meta.replay)
56
+ return;
57
+ consumer(entry);
58
+ };
59
+ }
60
+ /**
61
+ * DX-4922 - the session id a transcript file belongs to: `<session id>.jsonl` for the main transcript, and for a
62
+ * sub-agent file (`<session id>/subagents/agent-*.jsonl`) the directory above `subagents`. Verified on local
63
+ * transcripts: a sub-agent's lines carry its parent session's id, never its own agent id.
64
+ */
65
+ export function transcriptSessionId(filePath) {
66
+ const dir = dirname(filePath);
67
+ if (basename(dir) === "subagents")
68
+ return basename(dirname(dir));
69
+ return basename(filePath, ".jsonl");
70
+ }
71
+ /**
72
+ * Encode a working directory to the subdir name Claude Code writes JSONL
73
+ * under. Replaces `/`, `.`, AND `_` with `-`. Verified empirically against
74
+ * on-disk entries: a dispatched-agent cwd like
75
+ * `/var/tmp/danxbot-clean-room/<hash>/repo__board__work__dani__DX-1` encodes to
76
+ * `-var-tmp-danxbot-clean-room-<hash>-repo--board--work--dani--DX-1` — each
77
+ * `__` key delimiter collapses to `--`.
78
+ *
79
+ * `realpathSync` runs first so the encoding matches what claude's own
80
+ * resolver produces — the agent's cwd is realpath'd by the kernel before
81
+ * claude reads `process.cwd()`. ENOENT falls back to the normalized path
82
+ * so callers can pre-compute encodings before the dir exists (e.g. unit
83
+ * tests that exercise the pure encoding contract).
84
+ *
85
+ * The `.`/`_` encoding matters: a hidden-dir segment (leading `.`) or a
86
+ * clean-room key's `__` delimiter that the encoder does NOT collapse produces
87
+ * a directory Claude Code never writes to — silently breaking
88
+ * SessionLogWatcher attachment. The separator-encoding regression tests
89
+ * below pin this.
90
+ *
91
+ * Claude 2.1.104+ (GitHub #51488 — symlinks under `~/.claude/projects/` are
92
+ * no longer followed) writes its transcript to this exact realpath-encoded
93
+ * dir as a REAL directory; the watcher reads the same path, so they converge
94
+ * with no symlink. A danxbot worker container binds `<repo>/claude-projects/`
95
+ * at `/home/danxbot/.claude/projects/` and relies on the same encoding for
96
+ * the bound subdir name; an operator's own interactive session on its
97
+ * machine uses the native dir. Keep the encoding logic centralized here so
98
+ * every caller stays in lockstep.
99
+ *
100
+ * DX-3069 — a Windows caller (an operator session on a Windows machine)
101
+ * passes an ALREADY-ABSOLUTE Windows cwd (`C:\Users\...`), which never
102
+ * starts with `/`. The prior
103
+ * `cwd.startsWith("/") ? cwd : "/" + cwd` unconditionally prepended `/`,
104
+ * producing a malformed path (`/C:\Users\...`) that `realpathSync` cannot
105
+ * resolve (ENOENT — Windows reads the leading `/C:` as its own root segment),
106
+ * so the code fell into the catch and kept that malformed string. The old
107
+ * regex (`/[/._]/g`) never touched `\` or `:`, so every backslash and the
108
+ * drive-letter colon survived verbatim into the "encoded" dir name, and
109
+ * `join(homedir(), ".claude", "projects", encoded)` then produced a NESTED,
110
+ * DOUBLED path — `C:\Users\...\.claude\projects\-C:\Users\...` — a directory
111
+ * Claude Code never writes to, so every watcher on Windows silently
112
+ * never found its own transcript. Verified empirically against a real Claude
113
+ * Code install on this machine: `C:\Users\newms\projects\danxbot` encodes on
114
+ * disk to `C--Users-newms-projects-danxbot` (`ls ~/.claude/projects`) — no
115
+ * leading dash, `:` and each `\` both become `-`, exactly like `/` and `.` do
116
+ * for a POSIX cwd. `isAbsolute` (platform-native — win32 on Windows, posix
117
+ * elsewhere) replaces the POSIX-only `startsWith("/")` check so an
118
+ * already-absolute Windows path is never re-prefixed, and the replacement
119
+ * regex now also covers `\` and `:` so no separator survives into the
120
+ * encoded name on either platform.
121
+ */
122
+ export function encodeClaudeProjectsCwd(cwd) {
123
+ const normalized = isAbsolute(cwd) ? cwd : `/${cwd}`;
124
+ let resolved;
125
+ try {
126
+ resolved = realpathSync(normalized);
127
+ }
128
+ catch {
129
+ // Path doesn't exist yet — use as-is (agent may not have started)
130
+ resolved = normalized;
131
+ }
132
+ // Claude Code encodes its cwd into ~/.claude/projects/<dir> by replacing
133
+ // EVERY non-alphanumeric separator with `-`: `/`, `.`, and `_` on every
134
+ // platform, plus `\` and the drive-letter `:` on Windows. The `_` matters:
135
+ // the clean-room key (`cleanRoomKey`, DX-1511) joins its components with
136
+ // `__`, so Claude writes the dir as `--` on disk. Omitting `_` here made
137
+ // the watcher wait on a `__` path Claude never creates → "session file not
138
+ // found after 180s" on every clean-room dispatch.
139
+ return resolved.replace(/[/\\:._]/g, "-");
140
+ }
141
+ /**
142
+ * Derives the Claude Code session directory from a working directory path.
143
+ * Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/. See
144
+ * `encodeClaudeProjectsCwd` for the encoding contract.
145
+ */
146
+ export function deriveSessionDir(cwd) {
147
+ return join(homedir(), ".claude", "projects", encodeClaudeProjectsCwd(cwd));
148
+ }
149
+ /**
150
+ * On-disk existence gate for a stored, resumable Claude session UUID
151
+ * (migration 061). Given the clean-room `cwd` a dispatch ran in and a
152
+ * session `uuid`, returns `true` iff `<uuid>.jsonl` lives under that cwd's
153
+ * encoded `~/.claude/projects/<encoded>/` dir.
154
+ *
155
+ * This is the deterministic precondition the issue-bound resume gates check
156
+ * BEFORE passing `--resume <uuid>`: a session pinned in cwd A is only
157
+ * resumable from cwd A (Claude Code keys JSONLs by encoded-cwd), so a uuid
158
+ * stored on a card but orphaned by a cwd / agent move resolves here to a
159
+ * different encoded dir, is not found, and the caller fails SOFT to a fresh
160
+ * launch. This is NOT a swallowed error (which fail-loud forbids) — it is a
161
+ * checked precondition: a genuinely-absent session legitimately means
162
+ * "start fresh", and the per-agent isolation DX-1071 enforced falls out for
163
+ * free (agent B's cwd never contains agent A's session file).
164
+ */
165
+ export function sessionUuidExistsInCleanRoom(cwd, uuid) {
166
+ return existsSync(join(deriveSessionDir(cwd), `${uuid}.jsonl`));
167
+ }
168
+ /**
169
+ * Finds the newest .jsonl file in a directory.
170
+ * Returns the full path, or null if no .jsonl files exist.
171
+ */
172
+ export async function findNewestJsonlFile(dir, sessionId) {
173
+ try {
174
+ const files = await readdir(dir);
175
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
176
+ if (jsonlFiles.length === 0)
177
+ return null;
178
+ // If a specific session ID is requested, look for that exact file
179
+ if (sessionId) {
180
+ const target = `${sessionId}.jsonl`;
181
+ if (jsonlFiles.includes(target)) {
182
+ return join(dir, target);
183
+ }
184
+ return null;
185
+ }
186
+ // Find the newest by mtime
187
+ let newest = null;
188
+ for (const file of jsonlFiles) {
189
+ const filePath = join(dir, file);
190
+ try {
191
+ const stats = await stat(filePath);
192
+ const mtime = stats.mtimeMs;
193
+ if (!newest || mtime > newest.mtime) {
194
+ newest = { path: filePath, mtime };
195
+ }
196
+ }
197
+ catch (err) {
198
+ // DX-1201 — one unreadable/rotated sibling must NOT abort the whole
199
+ // mtime scan (see the matching guard in
200
+ // `findSessionFileByDispatchId`). A sibling deleted mid-scan
201
+ // (ENOENT) or otherwise unstattable just gets skipped.
202
+ const code = err.code;
203
+ if (code !== "ENOENT") {
204
+ log.warn(`Skipping unstattable session file during newest-file scan: ${filePath} (${code ?? String(err)})`);
205
+ }
206
+ continue;
207
+ }
208
+ }
209
+ return newest?.path ?? null;
210
+ }
211
+ catch (err) {
212
+ // Directory doesn't exist yet — expected during agent startup
213
+ if (err.code === "ENOENT")
214
+ return null;
215
+ throw err;
216
+ }
217
+ }
218
+ /**
219
+ * Finds a JSONL session file that contains the dispatch tag for a specific job ID.
220
+ *
221
+ * Scans each `.jsonl` file for the dispatch tag
222
+ * `<!-- danxbot-dispatch:<dispatchId> -->`, injected into the piped prompt
223
+ * by the launcher.
224
+ *
225
+ * The tag's position depends on whether this is a fresh spawn or a
226
+ * `claude --resume`:
227
+ * - **Fresh** — the tag is in the FIRST `user` entry, near the head of a
228
+ * brand-new file.
229
+ * - **Resume (DX-1106)** — claude appends the new dispatch's tagged
230
+ * prompt to the END of the prior session's already-multi-MB JSONL, so
231
+ * the tag sits in the TAIL, far past any fixed head window. A
232
+ * head-only scan never matches a resumed session → the watcher gives
233
+ * up after 180s → no session_uuid stamp, no heartbeat, no stall
234
+ * detection for that dispatch.
235
+ *
236
+ * So we scan BOTH a head window and a tail window (they overlap, covering
237
+ * the whole file, for any file ≤ 2x`SCAN_BYTES`). A resumed tag always
238
+ * lands in the freshly-appended tail; a fresh tag always lands in the head.
239
+ *
240
+ * Returns the full path to the matching file, or null if no file matches.
241
+ */
242
+ export async function findSessionFileByDispatchId(dir, dispatchId) {
243
+ const tag = `${DISPATCH_TAG_PREFIX}${dispatchId} -->`;
244
+ const SCAN_BYTES = 65_536; // 64KB — enough to cover a fresh prompt entry
245
+ // (head) or the appended resume prompt (tail).
246
+ try {
247
+ const files = await readdir(dir);
248
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
249
+ // DX-3395 — scan every sibling CONCURRENTLY instead of one at a time.
250
+ // A dispatched agent's cwd is a busy, SHARED session dir (DX-1201's own
251
+ // comment below: every issue-worker dispatch of one card writes under
252
+ // the same encoded `~/.claude/projects/<cwd>/` dir), so this directory
253
+ // accumulates one multi-MB `.jsonl` per distinct session ever launched
254
+ // there. The previous `for...of` paid each sibling's bounded
255
+ // open+stat+read (<=2*SCAN_BYTES) back-to-back even though they are
256
+ // independent filesystem operations the OS can service in parallel —
257
+ // this is the ONE stage DX-3395's resume-latency investigation found
258
+ // that is both real, sequential, and entirely danxbot-owned (unlike the
259
+ // dominant cost, `claude --resume`'s own session reload, which lives
260
+ // outside this codebase — see the card for the measured comparison).
261
+ // `Promise.all` lets every sibling's I/O overlap; each check keeps its
262
+ // own try/catch (unchanged from the DX-1201 per-file isolation below),
263
+ // so one unreadable sibling still can never abort the others. At most
264
+ // ONE sibling can ever contain a given dispatch's unique tag, so which
265
+ // promise settles first is never ambiguous — the result is looked up by
266
+ // `files`' ORIGINAL index, so the return value stays deterministic
267
+ // regardless of resolution order (proven by the multi-sibling test in
268
+ // `session-log-watcher.test.ts`).
269
+ const hits = await Promise.all(jsonlFiles.map((file) => fileContainsTag(dir, file, tag, SCAN_BYTES)));
270
+ const index = hits.findIndex(Boolean);
271
+ return index === -1 ? null : join(dir, jsonlFiles[index]);
272
+ }
273
+ catch (err) {
274
+ if (err.code === "ENOENT")
275
+ return null;
276
+ throw err;
277
+ }
278
+ }
279
+ /**
280
+ * One sibling's head+tail scan for `findSessionFileByDispatchId`'s
281
+ * concurrent `Promise.all` — factored out so each file's try/catch/finally
282
+ * is self-contained and never affects any other sibling's check.
283
+ */
284
+ async function fileContainsTag(dir, file, tag, scanBytes) {
285
+ const filePath = join(dir, file);
286
+ let fd;
287
+ try {
288
+ fd = await open(filePath, "r");
289
+ const fileStats = await stat(filePath);
290
+ if (await windowIncludesTag(fd, 0, fileStats.size, scanBytes, tag)) {
291
+ return true;
292
+ }
293
+ // Resume: the new tag is at the tail of a large prior-session file.
294
+ // Skip when the file fits in one head window (already fully read).
295
+ if (fileStats.size > scanBytes) {
296
+ const tailStart = fileStats.size - scanBytes;
297
+ if (await windowIncludesTag(fd, tailStart, fileStats.size, scanBytes, tag)) {
298
+ return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
303
+ catch (err) {
304
+ // DX-1201 — a single unreadable SIBLING file must NOT abort the
305
+ // whole scan. Dispatched agents share a busy session dir (e.g.
306
+ // every issue-worker dispatch of one repo writing under the same
307
+ // encoded `~/.claude/projects/<cwd>/` dir), so any one of the
308
+ // co-located multi-MB files can momentarily fail an `open`/`stat`/
309
+ // `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
310
+ // worktree torn down under the agent), EACCES (perms race), EMFILE
311
+ // (fd pressure across N concurrent watchers). Before this guard the
312
+ // throw propagated out of the per-file body and rejected the entire
313
+ // `findSessionFileByDispatchId` call → `discoverSessionFile` →
314
+ // `start()` (fire-and-forget, no `.catch`) → the watcher silently
315
+ // never attached → null `session_uuid` + zero usage/tool-call
316
+ // counters on ~20% of host dispatches (intermittent because the
317
+ // outcome hinged on `readdir` ordering vs the failing sibling).
318
+ // Report this sibling as a non-match and let the others resolve.
319
+ const code = err.code;
320
+ if (code !== "ENOENT") {
321
+ log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
322
+ }
323
+ return false;
324
+ }
325
+ finally {
326
+ await fd?.close();
327
+ }
328
+ }
329
+ /**
330
+ * Read a `<= maxBytes` window starting at `from` (clamped to the file) and
331
+ * report whether `tag` appears in it. Factored out so the head + tail scans
332
+ * in `findSessionFileByDispatchId` share one read path.
333
+ */
334
+ async function windowIncludesTag(fd, from, fileSize, maxBytes, tag) {
335
+ const len = Math.min(maxBytes, fileSize - from);
336
+ if (len <= 0)
337
+ return false;
338
+ const buffer = Buffer.alloc(len);
339
+ await fd.read(buffer, 0, len, from);
340
+ return buffer.toString("utf-8", 0, len).includes(tag);
341
+ }
342
+ /**
343
+ * Converts a raw JSONL entry from Claude Code's session log into an AgentLogEntry
344
+ * used by downstream consumers (stall detection, event forwarding).
345
+ */
346
+ export function convertJsonlEntry(raw, lastTimestamp) {
347
+ const type = raw.type;
348
+ const timestamp = raw.timestamp
349
+ ? new Date(raw.timestamp).getTime()
350
+ : Date.now();
351
+ const deltaMs = lastTimestamp > 0 ? timestamp - lastTimestamp : 0;
352
+ const message = raw.message;
353
+ switch (type) {
354
+ case "assistant": {
355
+ if (!message)
356
+ return null;
357
+ const content = (message.content ?? []);
358
+ const usage = message.usage;
359
+ // Claude Code splits multi-block assistant turns (text + tool_use,
360
+ // thinking + text + tool_use, etc.) into one JSONL line per content
361
+ // block, but stamps the SAME response-level `message.usage` on every
362
+ // line. Surface `messageId` so downstream usage accumulators can dedupe
363
+ // — without it, every multi-block turn over-counts tokens 2-5x. See
364
+ // `.claude/rules/agent-dispatch.md`.
365
+ const messageId = typeof message.id === "string" && message.id.length > 0
366
+ ? message.id
367
+ : undefined;
368
+ // DX-1720 — surface the turn's model EXPLICITLY on `entry.data` so the
369
+ // usage accumulator buckets per-model usage without reaching into
370
+ // `raw`. `message.model` is the JSONL assistant turn's resolved model
371
+ // id (an exact version, not a line); undefined on the rare malformed turn.
372
+ const model = typeof message.model === "string" && message.model.length > 0
373
+ ? message.model
374
+ : undefined;
375
+ return {
376
+ entry: {
377
+ timestamp,
378
+ type: "assistant",
379
+ summary: buildAssistantSummary(content),
380
+ data: {
381
+ messageId,
382
+ model,
383
+ content,
384
+ usage,
385
+ delta_ms: deltaMs,
386
+ raw,
387
+ },
388
+ },
389
+ timestamp,
390
+ };
391
+ }
392
+ case "user": {
393
+ if (!message)
394
+ return null;
395
+ const content = message.content;
396
+ if (typeof content === "string")
397
+ return null;
398
+ const contentArr = (content ?? []);
399
+ if (contentArr.length === 0)
400
+ return null;
401
+ const hasToolResults = contentArr.some((b) => b.type === "tool_result");
402
+ if (!hasToolResults)
403
+ return null;
404
+ return {
405
+ entry: {
406
+ timestamp,
407
+ type: "user",
408
+ summary: buildToolResultSummary(contentArr),
409
+ data: {
410
+ content: contentArr,
411
+ delta_ms: deltaMs,
412
+ raw: raw,
413
+ },
414
+ },
415
+ timestamp,
416
+ };
417
+ }
418
+ case "system": {
419
+ const subtype = raw.subtype;
420
+ if (subtype === "init") {
421
+ return {
422
+ entry: {
423
+ timestamp,
424
+ type: "system",
425
+ subtype: "init",
426
+ summary: `Session initialized: ${raw.model || "unknown"}`,
427
+ data: {
428
+ session_id: raw.session_id,
429
+ model: raw.model,
430
+ tools: raw.tools,
431
+ delta_ms: deltaMs,
432
+ raw,
433
+ },
434
+ },
435
+ timestamp,
436
+ };
437
+ }
438
+ return null;
439
+ }
440
+ case "result": {
441
+ const subtype = raw.subtype || "success";
442
+ const costUsd = raw.cost_usd || 0;
443
+ const numTurns = raw.num_turns || 0;
444
+ return {
445
+ entry: {
446
+ timestamp,
447
+ type: "result",
448
+ subtype,
449
+ summary: `${subtype}: ${numTurns} turns, $${costUsd.toFixed(4)}`,
450
+ data: {
451
+ subtype,
452
+ result_text: raw.result || "",
453
+ total_cost_usd: costUsd,
454
+ num_turns: numTurns,
455
+ duration_ms: raw.duration_ms || 0,
456
+ duration_api_ms: raw.duration_api_ms || 0,
457
+ is_error: Boolean(raw.is_error),
458
+ delta_ms: deltaMs,
459
+ raw,
460
+ },
461
+ },
462
+ timestamp,
463
+ };
464
+ }
465
+ default:
466
+ // Skip metadata types: permission-mode, attachment, file-history-snapshot, queue-operation, last-prompt
467
+ return null;
468
+ }
469
+ }
470
+ /**
471
+ * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
472
+ * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
473
+ * the right `startCursors` / `startExistingFilesAtEof`.
474
+ *
475
+ * | # | Condition | Start |
476
+ * |---|---|---|
477
+ * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
478
+ * | 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` |
479
+ * | 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 |
480
+ * | 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 |
481
+ * | 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 |
482
+ *
483
+ * Rules 1 and 2 arrive identically because "where does this source resume" has
484
+ * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
485
+ * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
486
+ * late sub-agent file's lines).
487
+ *
488
+ * Rules 3 and 5 both read from 0, but for different reasons, and both are
489
+ * correct: a fresh session's full usage SHOULD be counted, and a forked
490
+ * session's copied history is read and credited nothing (the ledger's excess
491
+ * rule already covers it).
492
+ */
493
+ export class SessionLogWatcher {
494
+ consumers = [];
495
+ usageConsumers = [];
496
+ pollTimer;
497
+ parentTail = null;
498
+ subagentsDir = null;
499
+ subagentTails = new Map();
500
+ parentSessionId = null;
501
+ sessionDir;
502
+ sessionId;
503
+ dispatchId;
504
+ pollIntervalMs;
505
+ startCursors;
506
+ startExistingFilesAtEof;
507
+ onCursorPastEof;
508
+ onSourceDiscovered;
509
+ onPollComplete;
510
+ subagentDiscoveryDone = false;
511
+ running = false;
512
+ polling = false;
513
+ // DX-2962: entries are NOT accumulated here — see `emitEntry`. Only
514
+ // consumers registered via `onEntry` observe entries; the watcher itself
515
+ // never holds a whole-process-lifetime list (that was the old
516
+ // `getEntries()` surface, deleted along with its accumulator — the
517
+ // pre-extraction contract kept an unbounded `AgentLogEntry[]` alive for
518
+ // the life of every dispatch, purely so three tests could read it back).
519
+ initSynthesized = false;
520
+ constructor(options) {
521
+ this.sessionDir = options.sessionDir ?? deriveSessionDir(options.cwd);
522
+ this.sessionId = options.sessionId;
523
+ this.dispatchId = options.dispatchId;
524
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
525
+ this.startCursors = options.startCursors;
526
+ this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
527
+ this.onCursorPastEof = options.onCursorPastEof;
528
+ this.onSourceDiscovered = options.onSourceDiscovered;
529
+ this.onPollComplete = options.onPollComplete;
530
+ }
531
+ /**
532
+ * DX-2954 — resolve one file's starting offset and replay boundary, per the
533
+ * start order on {@link SessionLogWatcher}.
534
+ *
535
+ * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
536
+ * attached, on a continuing worker dispatch with no cursor anywhere) from
537
+ * rule 5 (a file that appeared later, which always starts at 0 so none of its
538
+ * lines are lost).
539
+ */
540
+ async resolveStart(source, filePath, fileAlreadyExisted) {
541
+ const stored = this.startCursors[source];
542
+ // Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
543
+ // the caller already resolved into `startCursors`. Both arrive here the
544
+ // same way, because "where does this source resume" has ONE answer.
545
+ if (stored !== undefined) {
546
+ return { byteOffset: stored.acked, replayUntil: stored.delivered };
547
+ }
548
+ // Rule 4: no cursor for ANY source on a continuing worker dispatch.
549
+ if (this.startExistingFilesAtEof && fileAlreadyExisted) {
550
+ try {
551
+ const size = (await stat(filePath)).size;
552
+ return { byteOffset: size, replayUntil: 0 };
553
+ }
554
+ catch (err) {
555
+ // NOT a fall-back to 0. This branch fires precisely on the rows whose
556
+ // stored totals already count this file, so reading it from the start
557
+ // re-sums the whole session — the 421M-cache-read inflation (DX-2583)
558
+ // that rule 4 exists to prevent. `null` means "do not open this source
559
+ // yet"; the caller leaves it undiscovered and the next poll retries,
560
+ // still treating it as pre-existing.
561
+ log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
562
+ return null;
563
+ }
564
+ }
565
+ // Rules 3 and 5: the first dispatch of a session, and any file that appeared
566
+ // after the last stats write, both read from the start.
567
+ return { byteOffset: 0, replayUntil: 0 };
568
+ }
569
+ /**
570
+ * DX-2954 — every open source's cursor state.
571
+ *
572
+ * `delivered` is the byte up to which this watcher has handed lines to
573
+ * subscribers, which the collector reports as the `delivered` half of its
574
+ * stats write. `startOffset` is where this watcher OPENED the file, and it is
575
+ * the floor for the `acked` half: a source that started at EOF under rule 4
576
+ * must never report `acked: 0`, or the next restart would read the whole file
577
+ * from the start and re-sum the history rule 4 exists to skip.
578
+ */
579
+ cursorState() {
580
+ // `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
581
+ //
582
+ // `pollFile` advances `byteOffset` by every byte it READ, and `processText`
583
+ // deliberately buffers a trailing newline-less line (the whole reason
584
+ // `lineBuffer` exists — a poll routinely catches a line mid-append). So
585
+ // `byteOffset` can sit PAST the first byte of a line that was never emitted;
586
+ // promoting that to `acked` makes a restart open the file mid-JSON, and the
587
+ // fragment is dropped as a malformed line — losing that reply's usage, its
588
+ // tool-use ids and its side effects permanently.
589
+ //
590
+ // `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
591
+ // exactly the resume point. It equals `byteOffset` whenever the buffer is
592
+ // empty, so the happy path is unchanged.
593
+ const at = (s) => ({
594
+ startOffset: s.startOffset,
595
+ delivered: s.lineBufferStartOffset,
596
+ });
597
+ const out = {};
598
+ if (this.parentTail)
599
+ out[this.parentTail.source] = at(this.parentTail);
600
+ for (const state of this.subagentTails.values())
601
+ out[state.source] = at(state);
602
+ return out;
603
+ }
604
+ /**
605
+ * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
606
+ * or 0 when it has none.
607
+ *
608
+ * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
609
+ * watcher attach mid-session?". The offset alone is not enough: because `acked`
610
+ * lags `delivered` by one write, a crash right after the first cursor-bearing
611
+ * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
612
+ * every byte up to D is a re-read.
613
+ */
614
+ mainReplayUntil() {
615
+ return this.parentTail?.replayUntil ?? 0;
616
+ }
617
+ /**
618
+ * DX-2954 — the main transcript's starting offset once discovery has run, or
619
+ * `null` before that.
620
+ *
621
+ * This is what "is this a mid-session attach?" is derived from now. The
622
+ * declared-loaded observer used to be told `reattached: true` explicitly;
623
+ * a non-zero main start is the same fact stated as an observation rather than
624
+ * a flag, and it is true for every case that matters (a reattach, a
625
+ * `--resume`, a resumed operator session) without a caller having to
626
+ * enumerate them.
627
+ */
628
+ mainStartOffset() {
629
+ return this.parentTail?.startOffset ?? null;
630
+ }
631
+ /** Register a consumer callback that receives each new AgentLogEntry. */
632
+ onEntry(consumer) {
633
+ this.consumers.push(consumer);
634
+ }
635
+ /**
636
+ * DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
637
+ * attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
638
+ * the collector's two consumers at each of its two sites (the worker and the operator session's relay): `DispatchUsage`
639
+ * and, DX-4498, the activity observer, which reads the sub-agent's brief and Claude Code's task notifications,
640
+ * neither of which is an entry. `convertJsonlEntry` and every
641
+ * `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
642
+ * thousands of hook records) and system lines are not theirs.
643
+ *
644
+ * Each consumer runs in its own try/catch, as in `emitEntry`: a consumer that throws (a
645
+ * `LedgerLineDataError` on a line with no timestamp) never withholds the line from `onEntry` subscribers.
646
+ */
647
+ onUsageLine(consumer) {
648
+ this.usageConsumers.push(consumer);
649
+ }
650
+ /** Returns the discovered parent session file path, or null if not yet found. */
651
+ getSessionFilePath() {
652
+ return this.parentTail?.filePath ?? null;
653
+ }
654
+ /**
655
+ * Resolve this dispatch's JSONL session file by a one-shot disk scan,
656
+ * independent of the live tail loop (DX-1201).
657
+ *
658
+ * The live watcher records `session_uuid` lazily from the FIRST emitted
659
+ * entry. If discovery ever fails to attach within the dispatch's lifetime —
660
+ * a transient `open`/`stat`/`read` error on a busy shared session dir, or a
661
+ * timing window where the spawn finalizes before the first poll observed an
662
+ * entry — that lazy path never fires and the row's `session_uuid` stays
663
+ * NULL, breaking same-thread Slack resume (the dispatch is not resumable).
664
+ *
665
+ * This method gives the finalize backstop a deterministic, cause-agnostic
666
+ * way to resolve the file: by the time a dispatch finalizes, the agent's
667
+ * the dispatch-complete call's tool_use is already written to the JSONL, so the file
668
+ * is guaranteed present on disk. It scans the watcher's OWN `sessionDir`
669
+ * (the dir derived from `agentCwd` at construction — the same dir claude
670
+ * wrote to) for the dispatch tag. Returns the file path, or null when no
671
+ * dispatchId is set or no file carries the tag.
672
+ *
673
+ * Prefers the already-discovered `parentTail.filePath` when the live loop
674
+ * did attach — no redundant scan on the happy path.
675
+ */
676
+ async resolveSessionFileNow() {
677
+ if (this.parentTail?.filePath)
678
+ return this.parentTail.filePath;
679
+ if (!this.dispatchId)
680
+ return null;
681
+ return findSessionFileByDispatchId(this.sessionDir, this.dispatchId);
682
+ }
683
+ /** Start polling for session log entries. */
684
+ async start() {
685
+ if (this.running)
686
+ return;
687
+ this.running = true;
688
+ await this.discoverSessionFile();
689
+ if (this.running && this.parentTail) {
690
+ this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
691
+ await this.poll();
692
+ }
693
+ }
694
+ /** Stop polling and clean up. */
695
+ stop() {
696
+ this.running = false;
697
+ if (this.pollTimer) {
698
+ clearInterval(this.pollTimer);
699
+ this.pollTimer = undefined;
700
+ }
701
+ }
702
+ /**
703
+ * Read any JSONL bytes appended since the last scheduled poll tick. Used by
704
+ * the launcher's cleanup path to capture the agent's final assistant entry
705
+ * (the one carrying the closing `usage` block + the `tool_use` for
706
+ * the dispatch-complete call) before stop() halts polling. Without this, the row
707
+ * finalized by `DispatchTracker.finalize` snapshots stale `job.usage` and
708
+ * the dispatches table undercounts every token + counter field by exactly
709
+ * what the JSONL grew between ticks.
710
+ *
711
+ * Race contract: if a scheduled `poll()` is already in flight when drain
712
+ * is called, the first poll() invoked here would early-return at the
713
+ * `this.polling` guard and silently miss the tail. To avoid that silent
714
+ * skip we yield until the in-flight poll completes, then run our own
715
+ * poll. The combined effect is "every byte written before drain returns
716
+ * has been observed" — exactly the guarantee finalize depends on.
717
+ *
718
+ * No-op when the parent file has not been discovered (drain before
719
+ * start, or start aborted) — there is nothing to read. Safe to call
720
+ * after `stop()`; returns immediately.
721
+ */
722
+ async drain() {
723
+ if (!this.running || !this.parentTail)
724
+ return;
725
+ // Wait for any in-flight scheduled poll to finish so our subsequent
726
+ // poll() doesn't hit the `this.polling` guard. The await yields so
727
+ // setInterval-driven polls can complete; we only loop a few microtasks
728
+ // in the worst case (one scheduled poll cycle).
729
+ while (this.polling) {
730
+ await new Promise((resolve) => setImmediate(resolve));
731
+ }
732
+ await this.poll();
733
+ }
734
+ /**
735
+ * DX-5413 - the dispatch cleanup's last read, before `stop()`. An end signal (a sub-agent's spawn result or task-notification) read
736
+ * by one poll confirms the held final only at the NEXT poll's end, so a single drain would lose a final whose end signal first
737
+ * appears in it. Two drains read every line written before the first one began, in file order; a signal found by the first has
738
+ * its whole sub-agent read by the second, which confirms it. This holds whether or not the agent process has exited, so no
739
+ * cleanup path has to prove an exit first. A signal first written after the first drain began is not confirmed.
740
+ * The count is tied to the observer's one-poll delay (`endedAwaitingPoll` in activity-observer.ts): change one, change the other.
741
+ */
742
+ async drainFinal() {
743
+ await this.drain();
744
+ await this.drain();
745
+ }
746
+ /**
747
+ * Discover the JSONL session file. Polls until found or max attempts reached.
748
+ * For dispatched agents, the file appears shortly after the process starts.
749
+ */
750
+ async discoverSessionFile() {
751
+ for (let attempt = 0; attempt < MAX_FILE_DISCOVERY_ATTEMPTS; attempt++) {
752
+ if (!this.running)
753
+ return;
754
+ const filePath = this.dispatchId
755
+ ? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
756
+ : await findNewestJsonlFile(this.sessionDir, this.sessionId);
757
+ if (filePath && (await this.setParentFile(filePath))) {
758
+ const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
759
+ log.info(`${tag}Watching session file: ${filePath}`);
760
+ return;
761
+ }
762
+ await new Promise((resolve) => setTimeout(resolve, FILE_DISCOVERY_POLL_MS));
763
+ }
764
+ log.error(`Session file not found after ${MAX_FILE_DISCOVERY_ATTEMPTS}s in ${this.sessionDir}`);
765
+ }
766
+ /**
767
+ * Adopt a parent session file and derive the sub-agent directory.
768
+ *
769
+ * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
770
+ * (the cursor start order), not from a `fromEof` flag: the main source resumes
771
+ * at its stored `acked` cursor when it has one, and only a continuing worker
772
+ * dispatch with no cursor anywhere skips to the file's end.
773
+ */
774
+ async setParentFile(filePath) {
775
+ // DX-2954 — the main transcript's source key is always MAIN_SOURCE, and it
776
+ // ALWAYS counts as already-existing: by the time discovery found this file
777
+ // it is on disk, which is exactly what rule 4 asks about.
778
+ const start = await this.resolveStart(MAIN_SOURCE, filePath, true);
779
+ // `null` = its start offset could not be established safely (see
780
+ // `resolveStart`). Report the failure so discovery retries rather than
781
+ // adopting the file at a guessed offset.
782
+ if (start === null)
783
+ return false;
784
+ const { byteOffset, replayUntil } = start;
785
+ this.parentTail = {
786
+ filePath,
787
+ byteOffset,
788
+ lineBuffer: "",
789
+ lineBufferStartOffset: byteOffset,
790
+ lastTimestamp: 0,
791
+ source: MAIN_SOURCE,
792
+ startOffset: byteOffset,
793
+ replayUntil,
794
+ discoveredAt: Date.now(),
795
+ lastWriteAt: Date.now(),
796
+ preexisting: false,
797
+ };
798
+ this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
799
+ return true;
800
+ }
801
+ /**
802
+ * Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
803
+ * Guarded against concurrent polls and against stop() racing an in-flight poll.
804
+ */
805
+ async poll() {
806
+ if (this.polling || !this.parentTail || !this.running)
807
+ return;
808
+ this.polling = true;
809
+ try {
810
+ await this.pollFile(this.parentTail);
811
+ if (!this.running)
812
+ return;
813
+ await this.discoverNewSubagentFiles();
814
+ await this.refreshPendingAgentTypes();
815
+ for (const state of this.subagentTails.values()) {
816
+ if (!this.running)
817
+ return;
818
+ await this.pollFile(state);
819
+ }
820
+ }
821
+ finally {
822
+ this.polling = false;
823
+ }
824
+ // DX-4508 — only a poll that ran to its end: a stop mid-poll returns above and reports nothing.
825
+ try {
826
+ this.onPollComplete?.();
827
+ }
828
+ catch (err) {
829
+ log.error("onPollComplete consumer error:", err);
830
+ }
831
+ }
832
+ /** Read any new bytes from `state.filePath` and emit resulting entries. */
833
+ async pollFile(state) {
834
+ const isSubagent = state.lineage !== undefined;
835
+ // Refresh lineage inline before reading new bytes so emissions reflect
836
+ // the latest meta.json + parent session id even if the central refresh
837
+ // missed this cycle. Closes the race in production where claude writes
838
+ // `.jsonl` then `.meta.json` and a poll catches the new bytes between
839
+ // those writes — without this, the entry would emit permanently stamped
840
+ // with agent_type=undefined. archived investigation.
841
+ if (isSubagent) {
842
+ await this.refreshLineage(state);
843
+ }
844
+ try {
845
+ const fileStats = await stat(state.filePath);
846
+ if (!this.running)
847
+ return;
848
+ // DX-2954 — a cursor PAST the end of the file means the file was replaced
849
+ // or truncated under us. `size === byteOffset` is the ordinary "no new
850
+ // bytes" case; only a STRICTLY smaller file is the pathological one, and
851
+ // before this card it fell into the same early return and silently tailed
852
+ // nothing for the rest of the dispatch. Restart that source at 0 and
853
+ // report it, so the caller records a visible event and marks the reset in
854
+ // its next stats write (the one sanctioned backwards move of `acked`).
855
+ if (fileStats.size < state.byteOffset) {
856
+ this.onCursorPastEof?.({
857
+ source: state.source,
858
+ storedOffset: state.byteOffset,
859
+ fileSize: fileStats.size,
860
+ });
861
+ state.byteOffset = 0;
862
+ state.lineBufferStartOffset = 0;
863
+ state.lineBuffer = "";
864
+ // `startOffset` moves to 0 with it: it is the FLOOR the collector uses
865
+ // for this source's reported `acked`, so leaving it at the old (now
866
+ // impossible) offset would make the next write claim to have
867
+ // acknowledged bytes the file no longer has.
868
+ state.startOffset = 0;
869
+ // Nothing below the new position was ever delivered from THIS file's
870
+ // current content, so nothing in it is a replay.
871
+ state.replayUntil = 0;
872
+ // The replacement file's first entry must not have its `delta_ms`
873
+ // computed against the OLD file's last timestamp (which can produce a
874
+ // negative or absurd gap). This is a different file; its timeline starts
875
+ // fresh.
876
+ state.lastTimestamp = 0;
877
+ if (fileStats.size === 0)
878
+ return;
879
+ }
880
+ else if (fileStats.size === state.byteOffset) {
881
+ return;
882
+ }
883
+ const fd = await open(state.filePath, "r");
884
+ try {
885
+ if (!this.running)
886
+ return;
887
+ const bytesToRead = fileStats.size - state.byteOffset;
888
+ const buffer = Buffer.alloc(bytesToRead);
889
+ const { bytesRead } = await fd.read(buffer, 0, bytesToRead, state.byteOffset);
890
+ if (!this.running)
891
+ return;
892
+ if (bytesRead === 0)
893
+ return;
894
+ state.byteOffset += bytesRead;
895
+ const text = buffer.toString("utf-8", 0, bytesRead);
896
+ this.processText(text, state);
897
+ }
898
+ finally {
899
+ await fd.close();
900
+ }
901
+ }
902
+ catch (err) {
903
+ if (err.code === "ENOENT") {
904
+ if (isSubagent) {
905
+ // Sub-agent file gone — drop it; next discoverNewSubagentFiles
906
+ // will pick up any replacement.
907
+ this.subagentTails.delete(state.filePath);
908
+ }
909
+ else {
910
+ log.warn("Parent session file disappeared, attempting rediscovery");
911
+ this.parentTail = null;
912
+ this.subagentsDir = null;
913
+ this.subagentTails.clear();
914
+ // DX-5333: the adopted file's sub-agent directory gets its own first pass (its existing files are pre-existing).
915
+ this.subagentDiscoveryDone = false;
916
+ this.parentSessionId = null;
917
+ await this.discoverSessionFile();
918
+ }
919
+ }
920
+ else {
921
+ log.error("Poll error:", err);
922
+ }
923
+ }
924
+ }
925
+ /**
926
+ * Re-enumerate the sub-agent directory and add tail states for any
927
+ * `agent-<hash>.jsonl` files not already being tailed.
928
+ */
929
+ async discoverNewSubagentFiles() {
930
+ if (!this.subagentsDir)
931
+ return;
932
+ let files;
933
+ try {
934
+ files = await readdir(this.subagentsDir);
935
+ }
936
+ catch (err) {
937
+ if (err.code === "ENOENT") {
938
+ // DX-5333 — a session's `subagents/` directory is created by its first spawn. An absent directory holds no
939
+ // pre-existing file, so the first pass is settled: the file that later appears started after attach and must
940
+ // not be reported `preexisting` (its start commit would go unstamped).
941
+ this.subagentDiscoveryDone = true;
942
+ return;
943
+ }
944
+ log.error("Failed to enumerate sub-agent directory:", err);
945
+ return;
946
+ }
947
+ // DX-2954 — the FIRST discovery pass sees exactly the sub-agent files that
948
+ // existed when this watcher attached. Only those can satisfy rule 4's
949
+ // "already existing"; a file found on a LATER pass appeared after the attach
950
+ // and always reads from 0 (rule 5), so none of its lines are lost.
951
+ const firstPass = !this.subagentDiscoveryDone;
952
+ // Marked done only if this pass established a start offset for EVERY file it
953
+ // saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
954
+ // the retry — treating it as "appeared later" would send it to rule 5 (read
955
+ // from 0) and re-sum exactly the history rule 4 skipped.
956
+ let allResolved = true;
957
+ for (const file of files) {
958
+ if (!file.endsWith(".jsonl"))
959
+ continue;
960
+ const fullPath = join(this.subagentsDir, file);
961
+ if (this.subagentTails.has(fullPath))
962
+ continue;
963
+ // A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
964
+ // the same value `usage-ledger.ts` credits usage under and the same value
965
+ // `dispatch_activities.activity_id` holds — so a cursor, a per-source
966
+ // token total and a running-set row all join on one key.
967
+ const subagentId = basename(file, ".jsonl");
968
+ const meta = await this.readSubagentMeta(subagentId);
969
+ const start = await this.resolveStart(subagentId, fullPath, firstPass);
970
+ if (start === null) {
971
+ allResolved = false;
972
+ continue;
973
+ }
974
+ const { byteOffset, replayUntil } = start;
975
+ const discoveredAt = Date.now();
976
+ // DX-5110: a pre-existing file's last write, never the discovery time (see `SourceDiscoveredInfo.lastWriteAt`).
977
+ // The wire and the column are integers, and a real `mtimeMs` is fractional: floor it. A file that vanished between
978
+ // the `readdir` and here is retried next poll, like a `resolveStart` that could not settle.
979
+ let lastWriteAt = discoveredAt;
980
+ if (firstPass) {
981
+ try {
982
+ lastWriteAt = Math.min(discoveredAt, Math.floor((await stat(fullPath)).mtimeMs));
983
+ }
984
+ catch {
985
+ allResolved = false;
986
+ continue;
987
+ }
988
+ }
989
+ this.subagentTails.set(fullPath, {
990
+ filePath: fullPath,
991
+ byteOffset,
992
+ lineBufferStartOffset: byteOffset,
993
+ lineBuffer: "",
994
+ lastTimestamp: 0,
995
+ source: subagentId,
996
+ startOffset: byteOffset,
997
+ replayUntil,
998
+ discoveredAt,
999
+ preexisting: firstPass,
1000
+ lastWriteAt,
1001
+ meta,
1002
+ lineage: {
1003
+ subagent_id: subagentId,
1004
+ parent_session_id: this.parentSessionId,
1005
+ agent_type: meta?.agentType ?? undefined,
1006
+ },
1007
+ });
1008
+ // DX-2954 — fired AFTER the tail is registered, so a consumer that throws
1009
+ // cannot leave a half-registered source, and the `subagentTails.has()`
1010
+ // guard above makes it exactly-once per source for this watcher's life. A
1011
+ // re-discovery by a RESTARTED watcher fires again by design: the row it
1012
+ // reports is an idempotent upsert keyed on this same id, and re-reporting
1013
+ // is how a reattached collector re-learns the sub-agents that were already
1014
+ // running before it existed.
1015
+ this.emitSourceDiscovered(subagentId, meta, discoveredAt, firstPass, lastWriteAt);
1016
+ }
1017
+ if (allResolved)
1018
+ this.subagentDiscoveryDone = true;
1019
+ }
1020
+ /** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
1021
+ async refreshPendingAgentTypes() {
1022
+ for (const state of this.subagentTails.values()) {
1023
+ await this.refreshLineage(state);
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Refresh `state.lineage` by re-reading the meta sidecar and back-filling
1028
+ * parent_session_id from the most recent parent observation. Idempotent:
1029
+ * once `agent_type` is set it stays set (never read meta twice). Used by
1030
+ * both the centralized `refreshPendingAgentTypes` and the inline call from
1031
+ * `pollFile` — the inline call is the defense-in-depth that closes the race
1032
+ * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
1033
+ */
1034
+ /**
1035
+ * DX-2954 — report a running sub-agent to the consumer, normalizing the
1036
+ * absent-type shape and CONTAINING any throw.
1037
+ *
1038
+ * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
1039
+ * every consumer for the same reason, and it is load-bearing here because of
1040
+ * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
1041
+ * `poll()` has `try/finally` without one, so an escaping throw would:
1042
+ *
1043
+ * - on the first pass (inside `start()`), kill the watcher for the whole
1044
+ * dispatch — the caller only logs `start()`'s rejection;
1045
+ * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
1046
+ * turns into process exit — a worker crash caused by a consumer bug;
1047
+ * - either way abort the discovery loop mid-list, skipping
1048
+ * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
1049
+ * sends later-appearing files down rule 4 instead of rule 5 — losing
1050
+ * exactly the lines that rule split exists to preserve.
1051
+ *
1052
+ * Reporting the running set must never cost the monitoring it rides on.
1053
+ */
1054
+ emitSourceDiscovered(source, meta, discoveredAt, preexisting, lastWriteAt) {
1055
+ try {
1056
+ this.onSourceDiscovered?.({
1057
+ source,
1058
+ // ONE absent-shape on the wire, never two: this class's own reader
1059
+ // answers `undefined` for "no meta file yet", while the stats payload and
1060
+ // the `dispatch_activities` columns all spell absent as `null`.
1061
+ // Normalizing here keeps every consumer from accepting both.
1062
+ agentType: meta?.agentType ?? null,
1063
+ description: meta?.description ?? null,
1064
+ parentSource: meta?.parentSource ?? null,
1065
+ foregroundToolUseId: meta?.foregroundToolUseId ?? null,
1066
+ discoveredAt,
1067
+ preexisting,
1068
+ lastWriteAt,
1069
+ });
1070
+ }
1071
+ catch (err) {
1072
+ log.error("onSourceDiscovered consumer error:", err);
1073
+ }
1074
+ }
1075
+ async refreshLineage(state) {
1076
+ if (!state.lineage)
1077
+ return;
1078
+ let { agent_type, parent_session_id } = state.lineage;
1079
+ // DX-2954 — remember whether the type was still unknown coming in, so the
1080
+ // `undefined -> string` transition can be reported. Discovery reads the
1081
+ // sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
1082
+ // BEFORE the `.meta.json` (the race this method exists for), so "no type
1083
+ // yet" at discovery is the COMMON case, not an edge. Without a report here
1084
+ // the running-set row's `agent_type` would stay NULL for the whole dispatch
1085
+ // even though the watcher learned the real value one poll later.
1086
+ // DX-4498 — the whole sidecar (type, label, parent) is learned in one read, so it is re-read only until it exists.
1087
+ const metaWasUnknown = state.meta === undefined;
1088
+ if (metaWasUnknown) {
1089
+ state.meta = await this.readSubagentMeta(state.lineage.subagent_id);
1090
+ agent_type = state.meta?.agentType ?? undefined;
1091
+ }
1092
+ if (metaWasUnknown && state.meta !== undefined && state.source !== MAIN_SOURCE) {
1093
+ // The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
1094
+ // same activity, learning its type late, not a new one starting now.
1095
+ this.emitSourceDiscovered(state.source, state.meta, state.discoveredAt, state.preexisting, state.lastWriteAt);
1096
+ }
1097
+ if (parent_session_id === null && this.parentSessionId !== null) {
1098
+ parent_session_id = this.parentSessionId;
1099
+ }
1100
+ state.lineage = {
1101
+ subagent_id: state.lineage.subagent_id,
1102
+ parent_session_id,
1103
+ agent_type,
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Read the `<subagentId>.meta.json` sidecar (DX-4498: its type, label and spawning sub-agent). Returns
1108
+ * undefined only for ENOENT (file not written yet). Malformed JSON or
1109
+ * permission errors are logged at WARN and return undefined — the watcher
1110
+ * must not crash on bad sidecar files, but the failure is visible in logs.
1111
+ * A key the sidecar lacks, or carries as anything but a non-empty string, is `null`.
1112
+ */
1113
+ async readSubagentMeta(subagentId) {
1114
+ if (!this.subagentsDir)
1115
+ return undefined;
1116
+ const metaPath = join(this.subagentsDir, `${subagentId}.meta.json`);
1117
+ const text = (v) => (typeof v === "string" && v !== "" ? v : null);
1118
+ try {
1119
+ const raw = JSON.parse(await readFile(metaPath, "utf-8"));
1120
+ const parentAgentId = text(raw.parentAgentId);
1121
+ return {
1122
+ agentType: text(raw.agentType),
1123
+ description: text(raw.description),
1124
+ // The parent's own transcript is `agent-<parentAgentId>.jsonl`, so this is its source key.
1125
+ parentSource: parentAgentId === null ? null : `agent-${parentAgentId}`,
1126
+ // DX-5333: only a foreground spawn's result marks its end; a background spawn's `async_launched` result arrives at launch.
1127
+ foregroundToolUseId: raw.requestShape === "foreground" ? text(raw.toolUseId) : null,
1128
+ };
1129
+ }
1130
+ catch (err) {
1131
+ const code = err.code;
1132
+ if (code === "ENOENT")
1133
+ return undefined;
1134
+ log.warn(`Failed to read sub-agent meta for ${subagentId}: ${String(err)}`);
1135
+ return undefined;
1136
+ }
1137
+ }
1138
+ /** Process raw text from a file tail, handling partial lines across poll ticks. */
1139
+ processText(text, state) {
1140
+ state.lineBuffer += text;
1141
+ let newlineIdx;
1142
+ while ((newlineIdx = state.lineBuffer.indexOf("\n")) !== -1) {
1143
+ const rawLine = state.lineBuffer.substring(0, newlineIdx);
1144
+ const lineStart = state.lineBufferStartOffset;
1145
+ state.lineBufferStartOffset += Buffer.byteLength(rawLine, "utf-8") + 1;
1146
+ const line = rawLine.trim();
1147
+ state.lineBuffer = state.lineBuffer.substring(newlineIdx + 1);
1148
+ if (!line)
1149
+ continue;
1150
+ try {
1151
+ const raw = JSON.parse(line);
1152
+ this.handleRawEntry(raw, state, lineStart);
1153
+ }
1154
+ catch {
1155
+ log.warn(`Skipping malformed JSONL line: ${line.substring(0, 100)}`);
1156
+ }
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Convert a raw JSONL entry and emit to consumers.
1161
+ *
1162
+ * Init synthesis (parent stream only): the persisted JSONL transcript never
1163
+ * carries a `system/init` line in ANY runtime — Claude Code (2.1.201) only
1164
+ * streams that event over stdout (`--output-format stream-json`), which the
1165
+ * docker spawn path intentionally never reads (`spawn-docker-mode.ts`), and
1166
+ * host's interactive JSONL never wrote one either. So we ALWAYS synthesize
1167
+ * one from the first assistant message's sessionId and model, on host AND
1168
+ * docker alike. Sub-agent streams never synthesize — their entries flow
1169
+ * under the parent dispatch's existing session.
1170
+ *
1171
+ * IMPORTANT (DX-651): synthesized init events carry `tools: []` and
1172
+ * `data.synthesized: true` — this watcher never observes the real
1173
+ * MCP-registered tool / skill / agent list for EITHER runtime. Any consumer
1174
+ * that wants to check what actually loaded (tools, skills, agents) MUST
1175
+ * skip an entry carrying `data.synthesized === true` — treating it as a
1176
+ * truthful docker `system/init` (as `declared-loaded-observer.ts` used to,
1177
+ * gating on runtime alone) manufactures a guaranteed false
1178
+ * declared-not-loaded drift for every dispatch with a non-empty declared
1179
+ * skill/agent set. The pre-launch probe in `mcp-server-probe.ts` is the
1180
+ * tool-registration check that works across both runtimes.
1181
+ */
1182
+ handleRawEntry(raw, state, lineStart) {
1183
+ const isSubagent = state.lineage !== undefined;
1184
+ if (typeof raw.type === "string" && USAGE_FEED_TYPES.has(raw.type)) {
1185
+ const meta = this.entryMeta(state, lineStart);
1186
+ for (const consumer of this.usageConsumers) {
1187
+ try {
1188
+ consumer(raw, meta);
1189
+ }
1190
+ catch (err) {
1191
+ log.error("Usage line consumer error:", err);
1192
+ }
1193
+ }
1194
+ }
1195
+ if (!isSubagent && !this.initSynthesized && raw.type === "assistant") {
1196
+ const message = raw.message;
1197
+ const model = message?.model;
1198
+ const sessionId = raw.sessionId;
1199
+ if (sessionId || model) {
1200
+ this.parentSessionId = sessionId ?? this.parentSessionId;
1201
+ const initEntry = {
1202
+ timestamp: raw.timestamp
1203
+ ? new Date(raw.timestamp).getTime()
1204
+ : Date.now(),
1205
+ type: "system",
1206
+ subtype: "init",
1207
+ summary: `Session initialized: ${model || "unknown"}`,
1208
+ data: {
1209
+ session_id: sessionId || "",
1210
+ model: model || "",
1211
+ tools: [],
1212
+ delta_ms: 0,
1213
+ raw: {},
1214
+ synthesized: true,
1215
+ },
1216
+ };
1217
+ this.emitEntry(initEntry, state, lineStart);
1218
+ this.initSynthesized = true;
1219
+ }
1220
+ }
1221
+ const result = convertJsonlEntry(raw, state.lastTimestamp);
1222
+ if (!result)
1223
+ return;
1224
+ if (!isSubagent &&
1225
+ result.entry.type === "system" &&
1226
+ result.entry.subtype === "init") {
1227
+ this.initSynthesized = true;
1228
+ const sid = result.entry.data.session_id;
1229
+ if (sid)
1230
+ this.parentSessionId = sid;
1231
+ }
1232
+ state.lastTimestamp = result.timestamp;
1233
+ // DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
1234
+ result.entry.data = { ...result.entry.data, byte_offset: lineStart };
1235
+ this.emitEntry(result.entry, state, lineStart);
1236
+ }
1237
+ /**
1238
+ * DX-2954 — a line whose FIRST byte lies below the source's stored
1239
+ * `delivered` cursor was already handed to a previous incarnation's
1240
+ * subscribers. Strictly `<`: `delivered` is an exclusive end (the next
1241
+ * unread byte), so a line starting exactly AT it is new.
1242
+ */
1243
+ entryMeta(state, byteOffset) {
1244
+ return {
1245
+ source: state.source,
1246
+ byteOffset,
1247
+ replay: byteOffset < state.replayUntil,
1248
+ sessionId: transcriptSessionId(state.filePath),
1249
+ };
1250
+ }
1251
+ /**
1252
+ * Emit an entry to registered consumers. For sub-agent streams,
1253
+ * `state.lineage` decorates `entry.data` so the emitted EventPayload
1254
+ * (via mapEntryToEvents) inherits subagent_id/parent_session_id/agent_type.
1255
+ *
1256
+ * DX-2962: no longer also pushes onto an internal `this.entries` array —
1257
+ * the pre-extraction watcher kept every entry alive for the life of the
1258
+ * process purely so `getEntries()` (deleted) could hand it back to three
1259
+ * tests; nothing else read it. Every real consumer subscribes via
1260
+ * `onEntry` instead.
1261
+ */
1262
+ emitEntry(entry, state, byteOffset) {
1263
+ if (state.lineage) {
1264
+ entry.data = {
1265
+ ...entry.data,
1266
+ subagent_id: state.lineage.subagent_id,
1267
+ parent_session_id: state.lineage.parent_session_id,
1268
+ agent_type: state.lineage.agent_type,
1269
+ };
1270
+ }
1271
+ // DX-2954 — a line whose FIRST byte lies below the source's stored
1272
+ // `delivered` cursor was already handed to a previous incarnation's
1273
+ // subscribers. Strictly `<`: `delivered` is an exclusive end (the next
1274
+ // unread byte), so a line starting exactly AT it is new.
1275
+ const meta = this.entryMeta(state, byteOffset);
1276
+ for (const consumer of this.consumers) {
1277
+ try {
1278
+ consumer(entry, meta);
1279
+ }
1280
+ catch (err) {
1281
+ log.error("Consumer error:", err);
1282
+ }
1283
+ }
1284
+ }
1285
+ }