@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,616 @@
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 type { AgentLogEntry } from "./types.js";
24
+ /**
25
+ * Tag prefix injected into the piped prompt by the launcher. The watcher scans
26
+ * JSONL files for this tag to deterministically find the correct session file
27
+ * for a given dispatch, avoiding mtime-based heuristics that can pick up the
28
+ * wrong file (e.g., an active human session).
29
+ */
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;
39
+ /**
40
+ * DX-3964 - a consumer of the usage feed: the PARSED RAW line (assistant, user, attachment or system - nothing
41
+ * else) with the same {@link EntryMeta} an `onEntry` consumer gets. Collector-only (the usage counter and the
42
+ * activity observer): see
43
+ * {@link SessionLogWatcher.onUsageLine}.
44
+ */
45
+ export type UsageLineConsumer = (raw: Record<string, unknown>, meta: EntryMeta) => void;
46
+ /**
47
+ * DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
48
+ * through (Slack progress, the gpt-manager forwarder): it hands on only lines
49
+ * read for the FIRST time. A replay was already handed to the previous
50
+ * incarnation's subscribers, so acting on it again is a duplicate post/event.
51
+ */
52
+ export declare function skipReplays(consumer: (entry: AgentLogEntry) => void): EntryConsumer;
53
+ export interface SessionLogWatcherOptions {
54
+ /** Working directory the agent was launched in. Used to derive the session directory. */
55
+ cwd: string;
56
+ /** How often to poll for new entries in milliseconds. Default: 5000. */
57
+ pollIntervalMs?: number;
58
+ /** Specific session ID to watch. If omitted, watches the newest .jsonl file. */
59
+ sessionId?: string;
60
+ /** Override the session directory path (for testing). */
61
+ sessionDir?: string;
62
+ /**
63
+ * Dispatch job ID to search for in JSONL file content.
64
+ * When set, the watcher scans files for the `<!-- danxbot-dispatch:<id> -->` tag
65
+ * instead of picking the newest file by mtime. This prevents picking up the wrong
66
+ * session file (e.g., an active human session) when the agent's file doesn't exist yet.
67
+ */
68
+ dispatchId?: string;
69
+ /**
70
+ * DX-2954 — where each source resumes: the cursors the SERVER has stored for
71
+ * this dispatch, keyed by source ({@link MAIN_SOURCE} for the main transcript,
72
+ * the sub-agent id for a sub-agent file).
73
+ *
74
+ * An ABSENT source key means no cursor is stored for it — never "start at 0".
75
+ * The two are different: 0 is a legitimate stored cursor meaning "start of
76
+ * file", and CP1 forbids defaulting a missing persisted value. See
77
+ * {@link SessionLogWatcherOptions.startExistingFilesAtEof} and the start-order
78
+ * table on {@link SessionLogWatcher} for what happens when a source has none.
79
+ *
80
+ * This REPLACED the pre-DX-2954 `fromEof` / `existingSubagentFilesFromEof`
81
+ * pair, which skipped straight to a file's end on every reattach and every
82
+ * `--resume` because there was nowhere to record how far a reader had got.
83
+ * That lost every line between the last write and the restart; a cursor does
84
+ * not.
85
+ */
86
+ startCursors: Record<string, SourceCursorStart>;
87
+ /**
88
+ * DX-2954 — rule 4 of the start order: start every ALREADY-EXISTING file at
89
+ * its current end.
90
+ *
91
+ * Set ONLY by a WORKER dispatch that continues a session and has no cursor
92
+ * for ANY source — a row from before this card shipped, or a `--resume` whose
93
+ * predecessor predates it. Those files hold a prior dispatch's accounting with
94
+ * no ledger rows to dedupe against, so reading them from 0 re-sums the whole
95
+ * session (the 421M-cache-read inflation of DX-2583). A file that appears
96
+ * LATER is genuinely new and still starts at 0.
97
+ *
98
+ * It must never be set alongside cursors for some sources: rule 5 says a
99
+ * sub-agent file that appeared after the last stats write, on a dispatch that
100
+ * HAS cursors, starts at 0 so none of its lines are lost.
101
+ */
102
+ startExistingFilesAtEof?: boolean;
103
+ /**
104
+ * DX-2954 — a stored cursor was PAST the end of its file (the file was
105
+ * replaced or truncated), so that source restarted at 0.
106
+ *
107
+ * Without this the watcher would silently tail nothing for the rest of the
108
+ * dispatch: its `size <= byteOffset` early return is indistinguishable from
109
+ * "no new bytes". The caller records a visible `transcript_cursor_past_eof`
110
+ * event with both numbers and marks the reset in its next stats write, which
111
+ * is the ONE sanctioned backwards move of a stored `acked`.
112
+ */
113
+ onCursorPastEof?: (info: CursorPastEofInfo) => void;
114
+ /**
115
+ * DX-2954 — a sub-agent transcript just came under tail for the first time,
116
+ * i.e. a sub-agent of this dispatch is RUNNING.
117
+ *
118
+ * This is the transcript-side producer of the dispatch's running set. It
119
+ * reports the sub-agent's ledger source key, which is the same value its
120
+ * `dispatch_activities.activity_id` and its per-source token total join on —
121
+ * so a hook-side producer reporting the same sub-agent by its `agent_id`
122
+ * (`agent-` + the id, DX-3284) resolves to this same id and upserts the SAME
123
+ * row rather than a duplicate.
124
+ *
125
+ * Fired once per source, from the discovery pass, for sub-agent files only —
126
+ * never for the main transcript, which is the dispatch itself, not an activity
127
+ * within it.
128
+ */
129
+ onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
130
+ /**
131
+ * DX-4508 — one poll (the main transcript, sub-agent discovery, then every sub-agent file) has finished handing its
132
+ * lines to subscribers. The live sub-agent view (`platform-mcp subagents-live`) prints one line per poll from
133
+ * it, so a reader sees every change one poll made together. A throw is contained like every consumer's.
134
+ */
135
+ onPollComplete?: () => void;
136
+ }
137
+ /** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
138
+ export interface SourceCursorStart {
139
+ /** The resume point: read on from here. */
140
+ acked: number;
141
+ /**
142
+ * The highest byte already handed to subscribers. Lines between `acked` and
143
+ * this are re-read and handed on marked as REPLAYS, so side-effecting
144
+ * subscribers skip them while the ledger and the log shipper still see them.
145
+ */
146
+ delivered: number;
147
+ }
148
+ /** DX-2954 — reported when a stored cursor was past its file's end. */
149
+ export interface CursorPastEofInfo {
150
+ source: string;
151
+ storedOffset: number;
152
+ fileSize: number;
153
+ }
154
+ /** DX-2954 — reported when a sub-agent's transcript comes under tail. */
155
+ export interface SourceDiscoveredInfo {
156
+ /** The sub-agent's ledger source key (its transcript file's basename). */
157
+ source: string;
158
+ /**
159
+ * The sub-agent's declared type, read from its `meta.json` sidecar. `null` when
160
+ * Claude Code has not written the sidecar yet — it is reported again once read,
161
+ * and a hook-side report of the same sub-agent fills it in without clobbering a
162
+ * value already stored.
163
+ */
164
+ agentType: string | null;
165
+ /** DX-4498 — the label the spawning `Agent` call gave it (`meta.json` `description`); null until the sidecar is read. */
166
+ description: string | null;
167
+ /** DX-4498 — the source key of the sub-agent that spawned it (`agent-<parentAgentId>`); null for one the session spawned. */
168
+ parentSource: string | null;
169
+ /**
170
+ * DX-5333 — the `tool_use` id of the spawner's call that started this sub-agent, only when its `meta.json` `requestShape` is
171
+ * `foreground` (the spawner then waits for it, and the result of that call is written when the sub-agent is done). Null for a
172
+ * background spawn (its result arrives at launch), a sidecar not yet read, or one without the key.
173
+ */
174
+ foregroundToolUseId: string | null;
175
+ /** Epoch ms this watcher first saw the file. */
176
+ discoveredAt: number;
177
+ /**
178
+ * The file already existed when this watcher attached (found on its FIRST
179
+ * discovery pass). A consumer attached to a dispatch that CONTINUES an earlier
180
+ * one (a `--resume`, a dispatcher or thread resume) uses this to tell its
181
+ * predecessor's sub-agents, which ended with that process, from its own.
182
+ */
183
+ preexisting: boolean;
184
+ /**
185
+ * DX-5110 — epoch ms the sub-agent last wrote: the file's mtime when it already existed at attach (a restarted
186
+ * collector re-discovers every finished sub-agent, and reporting those as active "now" revived them), else the
187
+ * discovery time. A consumer's initial `lastActivityAt`, never the discovery wall clock.
188
+ */
189
+ lastWriteAt: number;
190
+ }
191
+ /**
192
+ * DX-2954 — what a consumer learns about the line it is being handed, beyond
193
+ * the entry itself.
194
+ */
195
+ export interface EntryMeta {
196
+ /** {@link MAIN_SOURCE} for the main transcript, else the sub-agent id. */
197
+ source: string;
198
+ /** Byte offset of the line's first byte in its file. */
199
+ byteOffset: number;
200
+ /**
201
+ * This line was already handed to a PREVIOUS incarnation's subscribers (it
202
+ * falls below the source's stored `delivered` cursor) and is being handed
203
+ * again after a restart.
204
+ *
205
+ * Every subscriber with a SIDE EFFECT must skip a replay — re-posting Slack
206
+ * progress, re-forwarding to gpt-manager, or re-running the API-error
207
+ * detector on a predecessor's last lines are all real, visible damage. The
208
+ * ledger and the log shipper DO process replays: both are idempotent, and
209
+ * skipping them is what would lose data.
210
+ */
211
+ replay: boolean;
212
+ /**
213
+ * DX-4922 - the session whose transcript this line is read from, taken from the file's own location (never from a
214
+ * line: a forked session's file starts with copies of its parent's lines, which keep the PARENT's `sessionId`). A
215
+ * main transcript is `<session id>.jsonl`; a sub-agent's is `<session id>/subagents/agent-*.jsonl`, and the lines of
216
+ * both carry that session id.
217
+ */
218
+ sessionId: string;
219
+ }
220
+ /**
221
+ * DX-4922 - the session id a transcript file belongs to: `<session id>.jsonl` for the main transcript, and for a
222
+ * sub-agent file (`<session id>/subagents/agent-*.jsonl`) the directory above `subagents`. Verified on local
223
+ * transcripts: a sub-agent's lines carry its parent session's id, never its own agent id.
224
+ */
225
+ export declare function transcriptSessionId(filePath: string): string;
226
+ /**
227
+ * Encode a working directory to the subdir name Claude Code writes JSONL
228
+ * under. Replaces `/`, `.`, AND `_` with `-`. Verified empirically against
229
+ * on-disk entries: a dispatched-agent cwd like
230
+ * `/var/tmp/danxbot-clean-room/<hash>/repo__board__work__dani__DX-1` encodes to
231
+ * `-var-tmp-danxbot-clean-room-<hash>-repo--board--work--dani--DX-1` — each
232
+ * `__` key delimiter collapses to `--`.
233
+ *
234
+ * `realpathSync` runs first so the encoding matches what claude's own
235
+ * resolver produces — the agent's cwd is realpath'd by the kernel before
236
+ * claude reads `process.cwd()`. ENOENT falls back to the normalized path
237
+ * so callers can pre-compute encodings before the dir exists (e.g. unit
238
+ * tests that exercise the pure encoding contract).
239
+ *
240
+ * The `.`/`_` encoding matters: a hidden-dir segment (leading `.`) or a
241
+ * clean-room key's `__` delimiter that the encoder does NOT collapse produces
242
+ * a directory Claude Code never writes to — silently breaking
243
+ * SessionLogWatcher attachment. The separator-encoding regression tests
244
+ * below pin this.
245
+ *
246
+ * Claude 2.1.104+ (GitHub #51488 — symlinks under `~/.claude/projects/` are
247
+ * no longer followed) writes its transcript to this exact realpath-encoded
248
+ * dir as a REAL directory; the watcher reads the same path, so they converge
249
+ * with no symlink. A danxbot worker container binds `<repo>/claude-projects/`
250
+ * at `/home/danxbot/.claude/projects/` and relies on the same encoding for
251
+ * the bound subdir name; an operator's own interactive session on its
252
+ * machine uses the native dir. Keep the encoding logic centralized here so
253
+ * every caller stays in lockstep.
254
+ *
255
+ * DX-3069 — a Windows caller (an operator session on a Windows machine)
256
+ * passes an ALREADY-ABSOLUTE Windows cwd (`C:\Users\...`), which never
257
+ * starts with `/`. The prior
258
+ * `cwd.startsWith("/") ? cwd : "/" + cwd` unconditionally prepended `/`,
259
+ * producing a malformed path (`/C:\Users\...`) that `realpathSync` cannot
260
+ * resolve (ENOENT — Windows reads the leading `/C:` as its own root segment),
261
+ * so the code fell into the catch and kept that malformed string. The old
262
+ * regex (`/[/._]/g`) never touched `\` or `:`, so every backslash and the
263
+ * drive-letter colon survived verbatim into the "encoded" dir name, and
264
+ * `join(homedir(), ".claude", "projects", encoded)` then produced a NESTED,
265
+ * DOUBLED path — `C:\Users\...\.claude\projects\-C:\Users\...` — a directory
266
+ * Claude Code never writes to, so every watcher on Windows silently
267
+ * never found its own transcript. Verified empirically against a real Claude
268
+ * Code install on this machine: `C:\Users\newms\projects\danxbot` encodes on
269
+ * disk to `C--Users-newms-projects-danxbot` (`ls ~/.claude/projects`) — no
270
+ * leading dash, `:` and each `\` both become `-`, exactly like `/` and `.` do
271
+ * for a POSIX cwd. `isAbsolute` (platform-native — win32 on Windows, posix
272
+ * elsewhere) replaces the POSIX-only `startsWith("/")` check so an
273
+ * already-absolute Windows path is never re-prefixed, and the replacement
274
+ * regex now also covers `\` and `:` so no separator survives into the
275
+ * encoded name on either platform.
276
+ */
277
+ export declare function encodeClaudeProjectsCwd(cwd: string): string;
278
+ /**
279
+ * Derives the Claude Code session directory from a working directory path.
280
+ * Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/. See
281
+ * `encodeClaudeProjectsCwd` for the encoding contract.
282
+ */
283
+ export declare function deriveSessionDir(cwd: string): string;
284
+ /**
285
+ * On-disk existence gate for a stored, resumable Claude session UUID
286
+ * (migration 061). Given the clean-room `cwd` a dispatch ran in and a
287
+ * session `uuid`, returns `true` iff `<uuid>.jsonl` lives under that cwd's
288
+ * encoded `~/.claude/projects/<encoded>/` dir.
289
+ *
290
+ * This is the deterministic precondition the issue-bound resume gates check
291
+ * BEFORE passing `--resume <uuid>`: a session pinned in cwd A is only
292
+ * resumable from cwd A (Claude Code keys JSONLs by encoded-cwd), so a uuid
293
+ * stored on a card but orphaned by a cwd / agent move resolves here to a
294
+ * different encoded dir, is not found, and the caller fails SOFT to a fresh
295
+ * launch. This is NOT a swallowed error (which fail-loud forbids) — it is a
296
+ * checked precondition: a genuinely-absent session legitimately means
297
+ * "start fresh", and the per-agent isolation DX-1071 enforced falls out for
298
+ * free (agent B's cwd never contains agent A's session file).
299
+ */
300
+ export declare function sessionUuidExistsInCleanRoom(cwd: string, uuid: string): boolean;
301
+ /**
302
+ * Finds the newest .jsonl file in a directory.
303
+ * Returns the full path, or null if no .jsonl files exist.
304
+ */
305
+ export declare function findNewestJsonlFile(dir: string, sessionId?: string): Promise<string | null>;
306
+ /**
307
+ * Finds a JSONL session file that contains the dispatch tag for a specific job ID.
308
+ *
309
+ * Scans each `.jsonl` file for the dispatch tag
310
+ * `<!-- danxbot-dispatch:<dispatchId> -->`, injected into the piped prompt
311
+ * by the launcher.
312
+ *
313
+ * The tag's position depends on whether this is a fresh spawn or a
314
+ * `claude --resume`:
315
+ * - **Fresh** — the tag is in the FIRST `user` entry, near the head of a
316
+ * brand-new file.
317
+ * - **Resume (DX-1106)** — claude appends the new dispatch's tagged
318
+ * prompt to the END of the prior session's already-multi-MB JSONL, so
319
+ * the tag sits in the TAIL, far past any fixed head window. A
320
+ * head-only scan never matches a resumed session → the watcher gives
321
+ * up after 180s → no session_uuid stamp, no heartbeat, no stall
322
+ * detection for that dispatch.
323
+ *
324
+ * So we scan BOTH a head window and a tail window (they overlap, covering
325
+ * the whole file, for any file ≤ 2x`SCAN_BYTES`). A resumed tag always
326
+ * lands in the freshly-appended tail; a fresh tag always lands in the head.
327
+ *
328
+ * Returns the full path to the matching file, or null if no file matches.
329
+ */
330
+ export declare function findSessionFileByDispatchId(dir: string, dispatchId: string): Promise<string | null>;
331
+ /**
332
+ * Converts a raw JSONL entry from Claude Code's session log into an AgentLogEntry
333
+ * used by downstream consumers (stall detection, event forwarding).
334
+ */
335
+ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTimestamp: number): {
336
+ entry: AgentLogEntry;
337
+ timestamp: number;
338
+ } | null;
339
+ /**
340
+ * DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
341
+ * {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
342
+ * the right `startCursors` / `startExistingFilesAtEof`.
343
+ *
344
+ * | # | Condition | Start |
345
+ * |---|---|---|
346
+ * | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
347
+ * | 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` |
348
+ * | 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 |
349
+ * | 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 |
350
+ * | 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 |
351
+ *
352
+ * Rules 1 and 2 arrive identically because "where does this source resume" has
353
+ * ONE answer; rule 4 is the only branch that skips content, and it is narrow by
354
+ * construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
355
+ * late sub-agent file's lines).
356
+ *
357
+ * Rules 3 and 5 both read from 0, but for different reasons, and both are
358
+ * correct: a fresh session's full usage SHOULD be counted, and a forked
359
+ * session's copied history is read and credited nothing (the ledger's excess
360
+ * rule already covers it).
361
+ */
362
+ export declare class SessionLogWatcher {
363
+ private consumers;
364
+ private usageConsumers;
365
+ private pollTimer;
366
+ private parentTail;
367
+ private subagentsDir;
368
+ private subagentTails;
369
+ private parentSessionId;
370
+ private sessionDir;
371
+ private sessionId;
372
+ private dispatchId;
373
+ private pollIntervalMs;
374
+ private readonly startCursors;
375
+ private readonly startExistingFilesAtEof;
376
+ private readonly onCursorPastEof;
377
+ private readonly onSourceDiscovered;
378
+ private readonly onPollComplete;
379
+ private subagentDiscoveryDone;
380
+ private running;
381
+ private polling;
382
+ private initSynthesized;
383
+ constructor(options: SessionLogWatcherOptions);
384
+ /**
385
+ * DX-2954 — resolve one file's starting offset and replay boundary, per the
386
+ * start order on {@link SessionLogWatcher}.
387
+ *
388
+ * `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
389
+ * attached, on a continuing worker dispatch with no cursor anywhere) from
390
+ * rule 5 (a file that appeared later, which always starts at 0 so none of its
391
+ * lines are lost).
392
+ */
393
+ private resolveStart;
394
+ /**
395
+ * DX-2954 — every open source's cursor state.
396
+ *
397
+ * `delivered` is the byte up to which this watcher has handed lines to
398
+ * subscribers, which the collector reports as the `delivered` half of its
399
+ * stats write. `startOffset` is where this watcher OPENED the file, and it is
400
+ * the floor for the `acked` half: a source that started at EOF under rule 4
401
+ * must never report `acked: 0`, or the next restart would read the whole file
402
+ * from the start and re-sum the history rule 4 exists to skip.
403
+ */
404
+ cursorState(): Record<string, {
405
+ startOffset: number;
406
+ delivered: number;
407
+ }>;
408
+ /**
409
+ * DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
410
+ * or 0 when it has none.
411
+ *
412
+ * Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
413
+ * watcher attach mid-session?". The offset alone is not enough: because `acked`
414
+ * lags `delivered` by one write, a crash right after the first cursor-bearing
415
+ * write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
416
+ * every byte up to D is a re-read.
417
+ */
418
+ mainReplayUntil(): number;
419
+ /**
420
+ * DX-2954 — the main transcript's starting offset once discovery has run, or
421
+ * `null` before that.
422
+ *
423
+ * This is what "is this a mid-session attach?" is derived from now. The
424
+ * declared-loaded observer used to be told `reattached: true` explicitly;
425
+ * a non-zero main start is the same fact stated as an observation rather than
426
+ * a flag, and it is true for every case that matters (a reattach, a
427
+ * `--resume`, a resumed operator session) without a caller having to
428
+ * enumerate them.
429
+ */
430
+ mainStartOffset(): number | null;
431
+ /** Register a consumer callback that receives each new AgentLogEntry. */
432
+ onEntry(consumer: EntryConsumer): void;
433
+ /**
434
+ * DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
435
+ * attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
436
+ * the collector's two consumers at each of its two sites (the worker and the operator session's relay): `DispatchUsage`
437
+ * and, DX-4498, the activity observer, which reads the sub-agent's brief and Claude Code's task notifications,
438
+ * neither of which is an entry. `convertJsonlEntry` and every
439
+ * `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
440
+ * thousands of hook records) and system lines are not theirs.
441
+ *
442
+ * Each consumer runs in its own try/catch, as in `emitEntry`: a consumer that throws (a
443
+ * `LedgerLineDataError` on a line with no timestamp) never withholds the line from `onEntry` subscribers.
444
+ */
445
+ onUsageLine(consumer: UsageLineConsumer): void;
446
+ /** Returns the discovered parent session file path, or null if not yet found. */
447
+ getSessionFilePath(): string | null;
448
+ /**
449
+ * Resolve this dispatch's JSONL session file by a one-shot disk scan,
450
+ * independent of the live tail loop (DX-1201).
451
+ *
452
+ * The live watcher records `session_uuid` lazily from the FIRST emitted
453
+ * entry. If discovery ever fails to attach within the dispatch's lifetime —
454
+ * a transient `open`/`stat`/`read` error on a busy shared session dir, or a
455
+ * timing window where the spawn finalizes before the first poll observed an
456
+ * entry — that lazy path never fires and the row's `session_uuid` stays
457
+ * NULL, breaking same-thread Slack resume (the dispatch is not resumable).
458
+ *
459
+ * This method gives the finalize backstop a deterministic, cause-agnostic
460
+ * way to resolve the file: by the time a dispatch finalizes, the agent's
461
+ * the dispatch-complete call's tool_use is already written to the JSONL, so the file
462
+ * is guaranteed present on disk. It scans the watcher's OWN `sessionDir`
463
+ * (the dir derived from `agentCwd` at construction — the same dir claude
464
+ * wrote to) for the dispatch tag. Returns the file path, or null when no
465
+ * dispatchId is set or no file carries the tag.
466
+ *
467
+ * Prefers the already-discovered `parentTail.filePath` when the live loop
468
+ * did attach — no redundant scan on the happy path.
469
+ */
470
+ resolveSessionFileNow(): Promise<string | null>;
471
+ /** Start polling for session log entries. */
472
+ start(): Promise<void>;
473
+ /** Stop polling and clean up. */
474
+ stop(): void;
475
+ /**
476
+ * Read any JSONL bytes appended since the last scheduled poll tick. Used by
477
+ * the launcher's cleanup path to capture the agent's final assistant entry
478
+ * (the one carrying the closing `usage` block + the `tool_use` for
479
+ * the dispatch-complete call) before stop() halts polling. Without this, the row
480
+ * finalized by `DispatchTracker.finalize` snapshots stale `job.usage` and
481
+ * the dispatches table undercounts every token + counter field by exactly
482
+ * what the JSONL grew between ticks.
483
+ *
484
+ * Race contract: if a scheduled `poll()` is already in flight when drain
485
+ * is called, the first poll() invoked here would early-return at the
486
+ * `this.polling` guard and silently miss the tail. To avoid that silent
487
+ * skip we yield until the in-flight poll completes, then run our own
488
+ * poll. The combined effect is "every byte written before drain returns
489
+ * has been observed" — exactly the guarantee finalize depends on.
490
+ *
491
+ * No-op when the parent file has not been discovered (drain before
492
+ * start, or start aborted) — there is nothing to read. Safe to call
493
+ * after `stop()`; returns immediately.
494
+ */
495
+ drain(): Promise<void>;
496
+ /**
497
+ * DX-5413 - the dispatch cleanup's last read, before `stop()`. An end signal (a sub-agent's spawn result or task-notification) read
498
+ * 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
499
+ * appears in it. Two drains read every line written before the first one began, in file order; a signal found by the first has
500
+ * its whole sub-agent read by the second, which confirms it. This holds whether or not the agent process has exited, so no
501
+ * cleanup path has to prove an exit first. A signal first written after the first drain began is not confirmed.
502
+ * The count is tied to the observer's one-poll delay (`endedAwaitingPoll` in activity-observer.ts): change one, change the other.
503
+ */
504
+ drainFinal(): Promise<void>;
505
+ /**
506
+ * Discover the JSONL session file. Polls until found or max attempts reached.
507
+ * For dispatched agents, the file appears shortly after the process starts.
508
+ */
509
+ private discoverSessionFile;
510
+ /**
511
+ * Adopt a parent session file and derive the sub-agent directory.
512
+ *
513
+ * DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
514
+ * (the cursor start order), not from a `fromEof` flag: the main source resumes
515
+ * at its stored `acked` cursor when it has one, and only a continuing worker
516
+ * dispatch with no cursor anywhere skips to the file's end.
517
+ */
518
+ private setParentFile;
519
+ /**
520
+ * Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
521
+ * Guarded against concurrent polls and against stop() racing an in-flight poll.
522
+ */
523
+ private poll;
524
+ /** Read any new bytes from `state.filePath` and emit resulting entries. */
525
+ private pollFile;
526
+ /**
527
+ * Re-enumerate the sub-agent directory and add tail states for any
528
+ * `agent-<hash>.jsonl` files not already being tailed.
529
+ */
530
+ private discoverNewSubagentFiles;
531
+ /** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
532
+ private refreshPendingAgentTypes;
533
+ /**
534
+ * Refresh `state.lineage` by re-reading the meta sidecar and back-filling
535
+ * parent_session_id from the most recent parent observation. Idempotent:
536
+ * once `agent_type` is set it stays set (never read meta twice). Used by
537
+ * both the centralized `refreshPendingAgentTypes` and the inline call from
538
+ * `pollFile` — the inline call is the defense-in-depth that closes the race
539
+ * where bytes appear before `refreshPendingAgentTypes` has a chance to run.
540
+ */
541
+ /**
542
+ * DX-2954 — report a running sub-agent to the consumer, normalizing the
543
+ * absent-type shape and CONTAINING any throw.
544
+ *
545
+ * The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
546
+ * every consumer for the same reason, and it is load-bearing here because of
547
+ * where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
548
+ * `poll()` has `try/finally` without one, so an escaping throw would:
549
+ *
550
+ * - on the first pass (inside `start()`), kill the watcher for the whole
551
+ * dispatch — the caller only logs `start()`'s rejection;
552
+ * - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
553
+ * turns into process exit — a worker crash caused by a consumer bug;
554
+ * - either way abort the discovery loop mid-list, skipping
555
+ * `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
556
+ * sends later-appearing files down rule 4 instead of rule 5 — losing
557
+ * exactly the lines that rule split exists to preserve.
558
+ *
559
+ * Reporting the running set must never cost the monitoring it rides on.
560
+ */
561
+ private emitSourceDiscovered;
562
+ private refreshLineage;
563
+ /**
564
+ * Read the `<subagentId>.meta.json` sidecar (DX-4498: its type, label and spawning sub-agent). Returns
565
+ * undefined only for ENOENT (file not written yet). Malformed JSON or
566
+ * permission errors are logged at WARN and return undefined — the watcher
567
+ * must not crash on bad sidecar files, but the failure is visible in logs.
568
+ * A key the sidecar lacks, or carries as anything but a non-empty string, is `null`.
569
+ */
570
+ private readSubagentMeta;
571
+ /** Process raw text from a file tail, handling partial lines across poll ticks. */
572
+ private processText;
573
+ /**
574
+ * Convert a raw JSONL entry and emit to consumers.
575
+ *
576
+ * Init synthesis (parent stream only): the persisted JSONL transcript never
577
+ * carries a `system/init` line in ANY runtime — Claude Code (2.1.201) only
578
+ * streams that event over stdout (`--output-format stream-json`), which the
579
+ * docker spawn path intentionally never reads (`spawn-docker-mode.ts`), and
580
+ * host's interactive JSONL never wrote one either. So we ALWAYS synthesize
581
+ * one from the first assistant message's sessionId and model, on host AND
582
+ * docker alike. Sub-agent streams never synthesize — their entries flow
583
+ * under the parent dispatch's existing session.
584
+ *
585
+ * IMPORTANT (DX-651): synthesized init events carry `tools: []` and
586
+ * `data.synthesized: true` — this watcher never observes the real
587
+ * MCP-registered tool / skill / agent list for EITHER runtime. Any consumer
588
+ * that wants to check what actually loaded (tools, skills, agents) MUST
589
+ * skip an entry carrying `data.synthesized === true` — treating it as a
590
+ * truthful docker `system/init` (as `declared-loaded-observer.ts` used to,
591
+ * gating on runtime alone) manufactures a guaranteed false
592
+ * declared-not-loaded drift for every dispatch with a non-empty declared
593
+ * skill/agent set. The pre-launch probe in `mcp-server-probe.ts` is the
594
+ * tool-registration check that works across both runtimes.
595
+ */
596
+ private handleRawEntry;
597
+ /**
598
+ * DX-2954 — a line whose FIRST byte lies below the source's stored
599
+ * `delivered` cursor was already handed to a previous incarnation's
600
+ * subscribers. Strictly `<`: `delivered` is an exclusive end (the next
601
+ * unread byte), so a line starting exactly AT it is new.
602
+ */
603
+ private entryMeta;
604
+ /**
605
+ * Emit an entry to registered consumers. For sub-agent streams,
606
+ * `state.lineage` decorates `entry.data` so the emitted EventPayload
607
+ * (via mapEntryToEvents) inherits subagent_id/parent_session_id/agent_type.
608
+ *
609
+ * DX-2962: no longer also pushes onto an internal `this.entries` array —
610
+ * the pre-extraction watcher kept every entry alive for the life of the
611
+ * process purely so `getEntries()` (deleted) could hand it back to three
612
+ * tests; nothing else read it. Every real consumer subscribes via
613
+ * `onEntry` instead.
614
+ */
615
+ private emitEntry;
616
+ }