@newrelic/preflight 1.4.47 → 1.5.0

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 (60) hide show
  1. package/dist/dashboard/index.d.ts +1 -1
  2. package/dist/dashboard/index.d.ts.map +1 -1
  3. package/dist/dashboard/live-event-bus.d.ts +32 -0
  4. package/dist/dashboard/live-event-bus.d.ts.map +1 -1
  5. package/dist/dashboard/live-event-bus.js.map +1 -1
  6. package/dist/dashboard/routes/api-handler.d.ts +46 -0
  7. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  8. package/dist/dashboard/routes/api-handler.js +188 -5
  9. package/dist/dashboard/routes/api-handler.js.map +1 -1
  10. package/dist/dashboard/subagent-timeline-store.d.ts +156 -0
  11. package/dist/dashboard/subagent-timeline-store.d.ts.map +1 -0
  12. package/dist/dashboard/subagent-timeline-store.js +674 -0
  13. package/dist/dashboard/subagent-timeline-store.js.map +1 -0
  14. package/dist/dashboard/workflow-store.d.ts +85 -0
  15. package/dist/dashboard/workflow-store.d.ts.map +1 -0
  16. package/dist/dashboard/workflow-store.js +330 -0
  17. package/dist/dashboard/workflow-store.js.map +1 -0
  18. package/dist/hooks/event-processor.d.ts +86 -1
  19. package/dist/hooks/event-processor.d.ts.map +1 -1
  20. package/dist/hooks/event-processor.js +182 -0
  21. package/dist/hooks/event-processor.js.map +1 -1
  22. package/dist/hooks/subagent-watcher.d.ts +182 -0
  23. package/dist/hooks/subagent-watcher.d.ts.map +1 -0
  24. package/dist/hooks/subagent-watcher.js +765 -0
  25. package/dist/hooks/subagent-watcher.js.map +1 -0
  26. package/dist/hooks/workflow-script-parser.d.ts +44 -0
  27. package/dist/hooks/workflow-script-parser.d.ts.map +1 -0
  28. package/dist/hooks/workflow-script-parser.js +255 -0
  29. package/dist/hooks/workflow-script-parser.js.map +1 -0
  30. package/dist/hooks/workflow-watcher.d.ts +65 -0
  31. package/dist/hooks/workflow-watcher.d.ts.map +1 -0
  32. package/dist/hooks/workflow-watcher.js +402 -0
  33. package/dist/hooks/workflow-watcher.js.map +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +315 -11
  36. package/dist/index.js.map +1 -1
  37. package/dist/metrics/cost-tracker.d.ts +113 -17
  38. package/dist/metrics/cost-tracker.d.ts.map +1 -1
  39. package/dist/metrics/cost-tracker.js +151 -8
  40. package/dist/metrics/cost-tracker.js.map +1 -1
  41. package/dist/metrics/workflow-run-tracker.d.ts +140 -0
  42. package/dist/metrics/workflow-run-tracker.d.ts.map +1 -0
  43. package/dist/metrics/workflow-run-tracker.js +306 -0
  44. package/dist/metrics/workflow-run-tracker.js.map +1 -0
  45. package/dist/storage/session-store.d.ts +8 -0
  46. package/dist/storage/session-store.d.ts.map +1 -1
  47. package/dist/storage/session-store.js +1 -1
  48. package/dist/storage/session-store.js.map +1 -1
  49. package/dist/storage/types.d.ts +40 -1
  50. package/dist/storage/types.d.ts.map +1 -1
  51. package/dist/transport/nr-ingest.d.ts +153 -2
  52. package/dist/transport/nr-ingest.d.ts.map +1 -1
  53. package/dist/transport/nr-ingest.js +276 -1
  54. package/dist/transport/nr-ingest.js.map +1 -1
  55. package/dist/web/assets/index-B4DkT4Go.css +2 -0
  56. package/dist/web/assets/index-CWwZdwYX.js +64 -0
  57. package/dist/web/index.html +2 -2
  58. package/package.json +1 -1
  59. package/dist/web/assets/index-CrBs4WEp.js +0 -64
  60. package/dist/web/assets/index-DwBQxRYb.css +0 -2
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Subagent Watcher — polls Claude Code subagent JSONL transcripts and emits
3
+ * one `mode: 'subagent_token'` line per assistant turn into the parent
4
+ * session's hook buffer.
5
+ *
6
+ * This closes a cost-correctness gap: subagent tokens (visible only
7
+ * inside `~/.claude/projects/<slug>/<sessionId>/subagents/agent-*.jsonl`) never
8
+ * reached `CostTracker.recordTokenUsage()` because the existing collector at
9
+ * `collector-script.ts:178-219 readLastAssistantUsage()` only tails the parent
10
+ * session's transcript.
11
+ *
12
+ * Watches two paths under each session directory:
13
+ * - `subagents/agent-{id}.jsonl` (ad-hoc Task calls)
14
+ * - `subagents/workflows/wf_{runId}/agent-{id}.jsonl` (workflow-spawned)
15
+ *
16
+ * Cursor durability: byte cursors persisted to
17
+ * `~/.newrelic-preflight/.subagent-pos-<parentSessionId>-<agentId>` survive restart.
18
+ * On crash mid-emit the next poll re-reads from the previous cursor →
19
+ * potential duplicates which downstream dedupes by (agent_id, message.id).
20
+ *
21
+ * Startup-discovery budget: only files with mtime in the last 24h are eligible
22
+ * for cold scan (configurable via `NR_AI_WATCHER_DISCOVERY_HOURS`); older
23
+ * files emit `discovery_skipped` once each. Backfill of older files is
24
+ * a separate, future concern.
25
+ */
26
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, join, resolve } from 'node:path';
29
+ import { createHash } from 'node:crypto';
30
+ import { createLogger } from '../shared/index.js';
31
+ const logger = createLogger('subagent-watcher');
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
36
+ const DEFAULT_DISCOVERY_HOURS = 24;
37
+ const MAX_BYTES_PER_POLL = 64 * 1024;
38
+ /**
39
+ * Hard cap on the retained partial-line (un-terminated tail) per file.
40
+ *
41
+ * A single JSONL assistant turn is normally a few tens of KiB; the largest
42
+ * legitimate lines observed in the wild are well under 1 MiB. When a file
43
+ * contains a line longer than this — pathologically large content, a corrupt
44
+ * never-terminated record, or a binary blob that happens to have no `\n` — the
45
+ * watcher must NOT keep accumulating it across polls.
46
+ *
47
+ * Without this cap, any line longer than MAX_BYTES_PER_POLL caused an
48
+ * unbounded leak: the byte cursor could only advance to a newline boundary, so
49
+ * a chunk with no newline left the cursor frozen, and every 2s poll re-read the
50
+ * same bytes and appended them to `partialByPath` forever (~64 KiB / poll / file
51
+ * → multi-GB RSS in minutes). Capping the partial bounds `partialByPath` values
52
+ * to MAX_PARTIAL_LINE_BYTES + one chunk and guarantees forward progress.
53
+ */
54
+ const MAX_PARTIAL_LINE_BYTES = 1024 * 1024; // 1 MiB
55
+ const HEALTH_INTERVAL_MS = 60_000;
56
+ const SCHEMA_FINGERPRINT_REEMIT_MS = 60 * 60 * 1000; // 1h
57
+ const COST_SELF_CHECK_MS = 60 * 60 * 1000; // 1h
58
+ const SESSION_ID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
59
+ const AGENT_ID_RE = /^a[a-f0-9]{16}$/;
60
+ const PROJECTS_DIR_NAME = '.claude/projects';
61
+ export class SubagentWatcher {
62
+ storagePath;
63
+ projectsDir;
64
+ pollIntervalMs;
65
+ discoveryHours;
66
+ parentSessionFilter;
67
+ costSelfCheck;
68
+ intervalId = null;
69
+ healthIntervalId = null;
70
+ running = false;
71
+ // Per-file in-memory partial-line retention. The persisted byte cursor
72
+ // points to the start of the next un-read byte; this map carries any trailing
73
+ // content past the last newline that didn't form a complete line yet. It is
74
+ // an in-memory fast path that mirrors the `partialLine` persisted in the
75
+ // cursor file. Bounded per-entry by MAX_PARTIAL_LINE_BYTES (see processFile)
76
+ // and per-key by the number of files discovered this poll (see poll()).
77
+ partialByPath = new Map();
78
+ // Health counters
79
+ filesWatched = 0;
80
+ linesRead = 0;
81
+ bytesRead = 0;
82
+ parseErrors = 0;
83
+ schemaDrifts = 0;
84
+ lastError = null;
85
+ // Schema-drift dedup across a 1h window. Persisted to
86
+ // ~/.newrelic-preflight/.schema-fingerprints.
87
+ seenFingerprints = new Map();
88
+ // Files that already emitted `discovery_skipped` so we don't re-emit on
89
+ // every poll for the same too-old file.
90
+ discoverySkippedAnnounced = new Set();
91
+ lastCostSelfCheckMs = 0;
92
+ constructor(options = {}) {
93
+ this.storagePath = options.storagePath ?? join(homedir(), '.newrelic-preflight');
94
+ this.projectsDir = options.projectsDir ?? join(homedir(), PROJECTS_DIR_NAME);
95
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
96
+ const envHours = parseInt(process.env.NR_AI_WATCHER_DISCOVERY_HOURS ?? '', 10);
97
+ this.discoveryHours =
98
+ options.discoveryHours ??
99
+ (Number.isFinite(envHours) && envHours > 0 ? envHours : DEFAULT_DISCOVERY_HOURS);
100
+ this.parentSessionFilter = options.parentSessionId ?? null;
101
+ this.costSelfCheck = options.costSelfCheck;
102
+ this.loadFingerprints();
103
+ }
104
+ start() {
105
+ if (this.running) {
106
+ logger.warn('SubagentWatcher already running');
107
+ return;
108
+ }
109
+ this.running = true;
110
+ if (!existsSync(this.storagePath)) {
111
+ mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
112
+ }
113
+ this.intervalId = setInterval(() => this.poll(), this.pollIntervalMs);
114
+ this.intervalId.unref();
115
+ this.healthIntervalId = setInterval(() => this.emitHealth(), HEALTH_INTERVAL_MS);
116
+ this.healthIntervalId.unref();
117
+ logger.info('SubagentWatcher started', {
118
+ pollIntervalMs: this.pollIntervalMs,
119
+ discoveryHours: this.discoveryHours,
120
+ projectsDir: this.projectsDir,
121
+ });
122
+ }
123
+ stop() {
124
+ if (!this.running)
125
+ return;
126
+ this.running = false;
127
+ if (this.intervalId !== null) {
128
+ clearInterval(this.intervalId);
129
+ this.intervalId = null;
130
+ }
131
+ if (this.healthIntervalId !== null) {
132
+ clearInterval(this.healthIntervalId);
133
+ this.healthIntervalId = null;
134
+ }
135
+ this.persistFingerprints();
136
+ logger.info('SubagentWatcher stopped');
137
+ }
138
+ /**
139
+ * Single poll cycle. Public so tests can drive deterministically without
140
+ * waiting on the interval timer.
141
+ */
142
+ poll() {
143
+ try {
144
+ const files = this.discoverFiles();
145
+ this.filesWatched = files.length;
146
+ for (const file of files) {
147
+ this.processFile(file);
148
+ }
149
+ this.evictStalePartials(files);
150
+ this.maybeRunCostSelfCheck();
151
+ }
152
+ catch (err) {
153
+ this.recordError(err);
154
+ }
155
+ }
156
+ /** Reset counters; for tests. */
157
+ resetHealth() {
158
+ this.filesWatched = 0;
159
+ this.linesRead = 0;
160
+ this.bytesRead = 0;
161
+ this.parseErrors = 0;
162
+ this.schemaDrifts = 0;
163
+ this.lastError = null;
164
+ }
165
+ /** Public snapshot of watcher health counters for the dashboard panel. */
166
+ getHealthStats() {
167
+ return {
168
+ filesWatched: this.filesWatched,
169
+ linesRead: this.linesRead,
170
+ bytesRead: this.bytesRead,
171
+ parseErrors: this.parseErrors,
172
+ schemaDrifts: this.schemaDrifts,
173
+ watcherDisabledByLock: false,
174
+ };
175
+ }
176
+ // -------------------------------------------------------------------------
177
+ // Discovery
178
+ // -------------------------------------------------------------------------
179
+ discoverFiles() {
180
+ const out = [];
181
+ if (!existsSync(this.projectsDir))
182
+ return out;
183
+ const cutoffMs = Date.now() - this.discoveryHours * 60 * 60 * 1000;
184
+ let projectEntries;
185
+ try {
186
+ projectEntries = readdirSync(this.projectsDir);
187
+ }
188
+ catch (err) {
189
+ this.recordError(err);
190
+ return out;
191
+ }
192
+ for (const project of projectEntries) {
193
+ const projectPath = join(this.projectsDir, project);
194
+ let stat;
195
+ try {
196
+ stat = statSync(projectPath);
197
+ }
198
+ catch {
199
+ continue;
200
+ }
201
+ if (!stat.isDirectory())
202
+ continue;
203
+ // Each project dir contains <sessionId> subdirs whose name matches
204
+ // SESSION_ID_RE (UUID v4 lower-hex with hyphens).
205
+ let sessionEntries;
206
+ try {
207
+ sessionEntries = readdirSync(projectPath);
208
+ }
209
+ catch {
210
+ continue;
211
+ }
212
+ for (const sessionId of sessionEntries) {
213
+ if (!SESSION_ID_RE.test(sessionId))
214
+ continue;
215
+ if (this.parentSessionFilter && sessionId !== this.parentSessionFilter)
216
+ continue;
217
+ const sessionDir = join(projectPath, sessionId);
218
+ const subDir = join(sessionDir, 'subagents');
219
+ if (!existsSync(subDir))
220
+ continue;
221
+ // Ad-hoc: subagents/agent-*.jsonl
222
+ try {
223
+ for (const name of readdirSync(subDir)) {
224
+ if (!name.startsWith('agent-') || !name.endsWith('.jsonl'))
225
+ continue;
226
+ const agentId = name.slice('agent-'.length, -'.jsonl'.length);
227
+ if (!AGENT_ID_RE.test(agentId))
228
+ continue;
229
+ const path = join(subDir, name);
230
+ const stat = this.filterByMtime(path, cutoffMs);
231
+ if (stat) {
232
+ out.push({ path, parentSessionId: sessionId, agentId, workflowRunId: null, stat });
233
+ }
234
+ }
235
+ }
236
+ catch {
237
+ /* directory unreadable — skip */
238
+ }
239
+ // Workflow-spawned: subagents/workflows/wf_*/agent-*.jsonl
240
+ const wfDir = join(subDir, 'workflows');
241
+ if (!existsSync(wfDir))
242
+ continue;
243
+ try {
244
+ for (const wfName of readdirSync(wfDir)) {
245
+ if (!wfName.startsWith('wf_'))
246
+ continue;
247
+ const wfRunId = wfName;
248
+ const wfRunDir = join(wfDir, wfName);
249
+ let stat2;
250
+ try {
251
+ stat2 = statSync(wfRunDir);
252
+ }
253
+ catch {
254
+ continue;
255
+ }
256
+ if (!stat2.isDirectory())
257
+ continue;
258
+ try {
259
+ for (const name of readdirSync(wfRunDir)) {
260
+ if (!name.startsWith('agent-') || !name.endsWith('.jsonl'))
261
+ continue;
262
+ const agentId = name.slice('agent-'.length, -'.jsonl'.length);
263
+ if (!AGENT_ID_RE.test(agentId))
264
+ continue;
265
+ const path = join(wfRunDir, name);
266
+ const stat = this.filterByMtime(path, cutoffMs);
267
+ if (stat) {
268
+ out.push({
269
+ path,
270
+ parentSessionId: sessionId,
271
+ agentId,
272
+ workflowRunId: wfRunId,
273
+ stat,
274
+ });
275
+ }
276
+ }
277
+ }
278
+ catch {
279
+ /* skip */
280
+ }
281
+ }
282
+ }
283
+ catch {
284
+ /* skip */
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ /** Returns the file's Stats when it passes the mtime cutoff, else null — callers reuse the Stats instead of re-statting the same path. */
291
+ filterByMtime(path, cutoffMs) {
292
+ try {
293
+ const st = statSync(path);
294
+ if (st.mtimeMs < cutoffMs) {
295
+ if (!this.discoverySkippedAnnounced.has(path)) {
296
+ this.discoverySkippedAnnounced.add(path);
297
+ this.appendHealth({
298
+ mode: 'observability_health',
299
+ tool: 'observability_health',
300
+ timestamp: Date.now(),
301
+ watcher: 'subagent',
302
+ filesWatched: 0,
303
+ linesRead: 0,
304
+ bytesRead: 0,
305
+ parseErrors: 0,
306
+ schemaDrifts: 0,
307
+ lastError: null,
308
+ event: 'discovery_skipped',
309
+ });
310
+ }
311
+ return null;
312
+ }
313
+ return st;
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ // -------------------------------------------------------------------------
320
+ // Per-file processing
321
+ // -------------------------------------------------------------------------
322
+ processFile(file) {
323
+ const st = file.stat;
324
+ const cursorPath = this.cursorPath(file.parentSessionId, file.agentId);
325
+ const startCursor = this.readCursor(cursorPath);
326
+ if (startCursor.bytePos >= st.size)
327
+ return;
328
+ const remaining = st.size - startCursor.bytePos;
329
+ const toRead = Math.min(remaining, MAX_BYTES_PER_POLL);
330
+ let buf;
331
+ let actuallyRead = 0;
332
+ let fd = null;
333
+ try {
334
+ fd = openSync(file.path, 'r');
335
+ buf = Buffer.allocUnsafe(toRead);
336
+ actuallyRead = readSync(fd, buf, 0, toRead, startCursor.bytePos);
337
+ }
338
+ catch (err) {
339
+ this.recordError(err);
340
+ if (fd !== null) {
341
+ try {
342
+ closeSync(fd);
343
+ }
344
+ catch {
345
+ /* ignore */
346
+ }
347
+ }
348
+ return;
349
+ }
350
+ finally {
351
+ if (fd !== null) {
352
+ try {
353
+ closeSync(fd);
354
+ }
355
+ catch {
356
+ /* ignore */
357
+ }
358
+ }
359
+ }
360
+ if (actuallyRead === 0)
361
+ return;
362
+ const chunk = buf.subarray(0, actuallyRead).toString('utf-8');
363
+ this.bytesRead += actuallyRead;
364
+ // Carry over any partial-line content from the previous poll. The in-memory
365
+ // map is the fast path; on a cold start (e.g. after restart) we fall back to
366
+ // the partial persisted alongside the byte cursor.
367
+ const carried = this.partialByPath.get(file.path) ?? startCursor.partialLine;
368
+ const combined = carried + chunk;
369
+ // The byte cursor ALWAYS advances by the bytes we just read from the file —
370
+ // those bytes are now folded into `combined` and must never be re-read.
371
+ // (The previous implementation only advanced to the last newline, which
372
+ // froze the cursor whenever a single line exceeded MAX_BYTES_PER_POLL and
373
+ // caused `partialByPath` to grow without bound — the OOM root cause. It also
374
+ // double-counted the partial region, prepending bytes that the cursor had
375
+ // already advanced past.)
376
+ const nextBytePos = startCursor.bytePos + actuallyRead;
377
+ // Split into complete lines (terminated by '\n') and a trailing remainder.
378
+ const lastNewline = combined.lastIndexOf('\n');
379
+ let lines = [];
380
+ let newPartial = combined;
381
+ if (lastNewline >= 0) {
382
+ lines = combined.slice(0, lastNewline).split('\n');
383
+ newPartial = combined.slice(lastNewline + 1);
384
+ }
385
+ // Bound the retained partial. A remainder larger than MAX_PARTIAL_LINE_BYTES
386
+ // is not a line we will ever be able to parse (a line that big is corrupt or
387
+ // pathological); drop it so the partial can't accumulate across polls. The
388
+ // cursor has already advanced past these bytes, so we make forward progress
389
+ // and never revisit them.
390
+ let droppedOversized = false;
391
+ if (newPartial.length > MAX_PARTIAL_LINE_BYTES) {
392
+ droppedOversized = true;
393
+ newPartial = '';
394
+ this.parseErrors += 1;
395
+ }
396
+ // Emit token events for each parsed assistant turn
397
+ for (const line of lines) {
398
+ if (!line)
399
+ continue;
400
+ this.linesRead += 1;
401
+ const parsed = this.tryParseLine(line, file);
402
+ if (parsed === null)
403
+ continue;
404
+ this.handleSchemaFingerprint('usage_keys', parsed.usageKeysFingerprint, file.workflowRunId);
405
+ this.handleSchemaFingerprint('content_block_types', parsed.contentBlockTypesFingerprint, file.workflowRunId);
406
+ const event = {
407
+ mode: 'subagent_token',
408
+ tool: 'subagent',
409
+ timestamp: parsed.timestampMs,
410
+ sessionId: file.parentSessionId,
411
+ agentId: file.agentId,
412
+ workflowRunId: file.workflowRunId,
413
+ messageId: parsed.messageId,
414
+ turnUuid: parsed.turnUuid,
415
+ model: parsed.model,
416
+ inputTokens: parsed.inputTokens,
417
+ outputTokens: parsed.outputTokens,
418
+ cacheReadTokens: parsed.cacheReadTokens,
419
+ cacheCreationTokens: parsed.cacheCreationTokens,
420
+ reasoningTokens: parsed.reasoningTokens,
421
+ stopReason: parsed.stopReason,
422
+ schemaFingerprint: parsed.usageKeysFingerprint,
423
+ };
424
+ this.appendToParentBuffer(file.parentSessionId, event);
425
+ }
426
+ // Persist the advanced cursor plus the (bounded) trailing partial so a
427
+ // restart resumes exactly where we left off. Keep the in-memory mirror in
428
+ // sync; when the partial is empty, drop the key entirely so a fully-consumed
429
+ // file leaves no residual entry in the map.
430
+ this.writeCursor(cursorPath, nextBytePos, newPartial);
431
+ if (newPartial.length > 0) {
432
+ this.partialByPath.set(file.path, newPartial);
433
+ }
434
+ else {
435
+ this.partialByPath.delete(file.path);
436
+ }
437
+ if (droppedOversized) {
438
+ this.appendHealth({
439
+ mode: 'observability_health',
440
+ tool: 'observability_health',
441
+ timestamp: Date.now(),
442
+ watcher: 'subagent',
443
+ filesWatched: this.filesWatched,
444
+ linesRead: this.linesRead,
445
+ bytesRead: this.bytesRead,
446
+ parseErrors: this.parseErrors,
447
+ schemaDrifts: this.schemaDrifts,
448
+ lastError: this.lastError,
449
+ event: 'oversized_line_dropped',
450
+ });
451
+ }
452
+ }
453
+ /**
454
+ * Drop in-memory partial-line state for files that are no longer discovered
455
+ * (e.g. session directory removed, file aged past the discovery window). This
456
+ * keeps `partialByPath` bounded by the live file set rather than the
457
+ * all-time-seen file set. The persisted cursor file is left untouched — if the
458
+ * file reappears, we resume from it.
459
+ */
460
+ evictStalePartials(files) {
461
+ if (this.partialByPath.size === 0)
462
+ return;
463
+ const live = new Set();
464
+ for (const f of files)
465
+ live.add(f.path);
466
+ for (const path of this.partialByPath.keys()) {
467
+ if (!live.has(path))
468
+ this.partialByPath.delete(path);
469
+ }
470
+ }
471
+ /**
472
+ * Parse a JSONL line, return non-null only when it's a valid assistant turn
473
+ * with usage. Sets parseErrors counter on JSON parse failures.
474
+ */
475
+ tryParseLine(line, _file) {
476
+ let parsed;
477
+ try {
478
+ parsed = JSON.parse(line);
479
+ }
480
+ catch {
481
+ this.parseErrors += 1;
482
+ return null;
483
+ }
484
+ if (!parsed || typeof parsed !== 'object')
485
+ return null;
486
+ const obj = parsed;
487
+ if (obj.type !== 'assistant')
488
+ return null;
489
+ const message = obj.message;
490
+ if (!message || typeof message !== 'object')
491
+ return null;
492
+ const m = message;
493
+ const model = typeof m.model === 'string' ? m.model : null;
494
+ if (!model || model === '<synthetic>')
495
+ return null;
496
+ const messageId = typeof m.id === 'string' ? m.id : null;
497
+ if (!messageId)
498
+ return null;
499
+ const usage = m.usage;
500
+ if (!usage || typeof usage !== 'object')
501
+ return null;
502
+ const u = usage;
503
+ const turnUuid = typeof obj.uuid === 'string' ? obj.uuid : '';
504
+ const tsRaw = typeof obj.timestamp === 'string' ? obj.timestamp : null;
505
+ const timestampMs = tsRaw ? Date.parse(tsRaw) : Date.now();
506
+ if (!Number.isFinite(timestampMs))
507
+ return null;
508
+ const inputTokens = num(u.input_tokens);
509
+ const outputTokens = num(u.output_tokens);
510
+ const cacheReadTokens = num(u.cache_read_input_tokens);
511
+ const cacheCreationTokens = num(u.cache_creation_input_tokens);
512
+ let reasoningTokens = 0;
513
+ const otd = u.output_tokens_details;
514
+ if (otd && typeof otd === 'object') {
515
+ reasoningTokens = num(otd.reasoning_tokens);
516
+ }
517
+ const stopReason = typeof m.stop_reason === 'string' ? m.stop_reason : null;
518
+ const usageKeysFingerprint = computeUsageKeysFingerprint(u);
519
+ const contentBlockTypesFingerprint = computeContentBlockTypesFingerprint(m.content);
520
+ return {
521
+ timestampMs,
522
+ messageId,
523
+ turnUuid,
524
+ model,
525
+ inputTokens,
526
+ outputTokens,
527
+ cacheReadTokens,
528
+ cacheCreationTokens,
529
+ reasoningTokens,
530
+ stopReason,
531
+ usageKeysFingerprint,
532
+ contentBlockTypesFingerprint,
533
+ };
534
+ }
535
+ // -------------------------------------------------------------------------
536
+ // Buffer + cursor I/O
537
+ // -------------------------------------------------------------------------
538
+ cursorPath(parentSessionId, agentId) {
539
+ return join(this.storagePath, `.subagent-pos-${parentSessionId}-${agentId}`);
540
+ }
541
+ readCursor(cursorPath) {
542
+ if (!existsSync(cursorPath))
543
+ return { bytePos: 0, partialLine: '' };
544
+ try {
545
+ const raw = readFileSync(cursorPath, 'utf-8').trim();
546
+ const parsed = JSON.parse(raw);
547
+ const bytePos = typeof parsed.bytePos === 'number' && parsed.bytePos >= 0 ? parsed.bytePos : 0;
548
+ const partialLine = typeof parsed.partialLine === 'string' ? parsed.partialLine : '';
549
+ return { bytePos, partialLine };
550
+ }
551
+ catch {
552
+ return { bytePos: 0, partialLine: '' };
553
+ }
554
+ }
555
+ writeCursor(cursorPath, bytePos, partialLine) {
556
+ try {
557
+ if (!existsSync(this.storagePath)) {
558
+ mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
559
+ }
560
+ const dir = dirname(cursorPath);
561
+ if (!existsSync(dir))
562
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
563
+ writeFileSync(cursorPath, JSON.stringify({ bytePos, partialLine }), { mode: 0o600 });
564
+ }
565
+ catch (err) {
566
+ this.recordError(err);
567
+ }
568
+ }
569
+ /**
570
+ * Append to the parent's per-session buffer file. Mirrors the path-naming
571
+ * used by `LocalStore` so `HookEventProcessor.poll()` will pick it up.
572
+ */
573
+ appendToParentBuffer(parentSessionId, event) {
574
+ const path = join(this.storagePath, `buffer-${parentSessionId}.jsonl`);
575
+ try {
576
+ if (!existsSync(this.storagePath)) {
577
+ mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
578
+ }
579
+ appendFileSync(path, JSON.stringify(event) + '\n', { mode: 0o600 });
580
+ }
581
+ catch (err) {
582
+ this.recordError(err);
583
+ }
584
+ }
585
+ appendHealth(event) {
586
+ // Health rides through the same parent-buffer pipeline so the
587
+ // event-processor's poll picks it up uniformly. When no specific session
588
+ // is in scope we fan out to a shared bucket (`buffer-health.jsonl`) which
589
+ // `drainAllBuffers()` covers via its `buffer-*.jsonl` glob; we use a
590
+ // sessionless name when the watcher is unfiltered.
591
+ const sessionId = this.parentSessionFilter ?? 'health';
592
+ this.appendToParentBuffer(sessionId, event);
593
+ }
594
+ // -------------------------------------------------------------------------
595
+ // Schema-drift sentinels
596
+ // -------------------------------------------------------------------------
597
+ handleSchemaFingerprint(dimension, fingerprint, workflowRunId) {
598
+ const key = `${dimension}:${fingerprint}`;
599
+ const lastSeen = this.seenFingerprints.get(key);
600
+ const now = Date.now();
601
+ if (lastSeen !== undefined && now - lastSeen < SCHEMA_FINGERPRINT_REEMIT_MS)
602
+ return;
603
+ this.seenFingerprints.set(key, now);
604
+ this.persistFingerprints();
605
+ if (lastSeen === undefined) {
606
+ this.schemaDrifts += 1;
607
+ }
608
+ this.appendHealth({
609
+ mode: 'observability_health',
610
+ tool: 'observability_health',
611
+ timestamp: now,
612
+ watcher: 'subagent',
613
+ filesWatched: this.filesWatched,
614
+ linesRead: this.linesRead,
615
+ bytesRead: this.bytesRead,
616
+ parseErrors: this.parseErrors,
617
+ schemaDrifts: this.schemaDrifts,
618
+ lastError: this.lastError,
619
+ event: 'schema_drift',
620
+ dimension,
621
+ fingerprint,
622
+ ...(workflowRunId ? { workflowRunId } : {}),
623
+ });
624
+ }
625
+ loadFingerprints() {
626
+ const path = join(this.storagePath, '.schema-fingerprints');
627
+ if (!existsSync(path))
628
+ return;
629
+ try {
630
+ const raw = readFileSync(path, 'utf-8');
631
+ const parsed = JSON.parse(raw);
632
+ if (parsed && typeof parsed === 'object') {
633
+ for (const [k, v] of Object.entries(parsed)) {
634
+ if (typeof v === 'number')
635
+ this.seenFingerprints.set(k, v);
636
+ }
637
+ }
638
+ }
639
+ catch {
640
+ /* ignore — corrupt file means a fresh start */
641
+ }
642
+ }
643
+ persistFingerprints() {
644
+ const path = join(this.storagePath, '.schema-fingerprints');
645
+ try {
646
+ if (!existsSync(this.storagePath)) {
647
+ mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
648
+ }
649
+ // Trim entries older than the re-emission window so the file does not
650
+ // grow unbounded.
651
+ const now = Date.now();
652
+ const out = {};
653
+ for (const [k, v] of this.seenFingerprints) {
654
+ if (now - v < SCHEMA_FINGERPRINT_REEMIT_MS * 24)
655
+ out[k] = v;
656
+ }
657
+ writeFileSync(path, JSON.stringify(out), { mode: 0o600 });
658
+ }
659
+ catch (err) {
660
+ this.recordError(err);
661
+ }
662
+ }
663
+ // -------------------------------------------------------------------------
664
+ // Health emission
665
+ // -------------------------------------------------------------------------
666
+ emitHealth() {
667
+ const event = {
668
+ mode: 'observability_health',
669
+ tool: 'observability_health',
670
+ timestamp: Date.now(),
671
+ watcher: 'subagent',
672
+ filesWatched: this.filesWatched,
673
+ linesRead: this.linesRead,
674
+ bytesRead: this.bytesRead,
675
+ parseErrors: this.parseErrors,
676
+ schemaDrifts: this.schemaDrifts,
677
+ lastError: this.lastError,
678
+ };
679
+ this.appendHealth(event);
680
+ }
681
+ maybeRunCostSelfCheck() {
682
+ if (!this.costSelfCheck)
683
+ return;
684
+ const now = Date.now();
685
+ if (now - this.lastCostSelfCheckMs < COST_SELF_CHECK_MS)
686
+ return;
687
+ this.lastCostSelfCheckMs = now;
688
+ let result;
689
+ try {
690
+ result = this.costSelfCheck();
691
+ }
692
+ catch (err) {
693
+ this.recordError(err);
694
+ return;
695
+ }
696
+ const denom = Math.max(result.groundTruthUsd, 1e-9);
697
+ const deltaPct = ((result.groundTruthUsd - result.trackedUsd) / denom) * 100;
698
+ this.appendHealth({
699
+ mode: 'observability_health',
700
+ tool: 'observability_health',
701
+ timestamp: now,
702
+ watcher: 'subagent',
703
+ filesWatched: this.filesWatched,
704
+ linesRead: this.linesRead,
705
+ bytesRead: this.bytesRead,
706
+ parseErrors: this.parseErrors,
707
+ schemaDrifts: this.schemaDrifts,
708
+ lastError: this.lastError,
709
+ event: 'cost_self_check',
710
+ costSelfCheckDeltaPct: deltaPct,
711
+ });
712
+ }
713
+ recordError(err) {
714
+ const message = err instanceof Error ? err.message : String(err);
715
+ const code = err.code ?? 'UNKNOWN';
716
+ const cls = err instanceof Error ? err.constructor.name : 'Error';
717
+ this.lastError = { code: String(code).slice(0, 80), class: String(cls).slice(0, 80) };
718
+ logger.warn('SubagentWatcher error', { code, message: message.slice(0, 200) });
719
+ }
720
+ }
721
+ // ---------------------------------------------------------------------------
722
+ // Helpers
723
+ // ---------------------------------------------------------------------------
724
+ function num(v) {
725
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
726
+ }
727
+ function computeUsageKeysFingerprint(usage) {
728
+ const keys = [];
729
+ for (const k of Object.keys(usage).sort())
730
+ keys.push(k);
731
+ // Include child keys of `output_tokens_details` so reasoning-token drift
732
+ // produces a distinct fingerprint without inflating the dimension space.
733
+ const otd = usage.output_tokens_details;
734
+ if (otd && typeof otd === 'object') {
735
+ for (const k of Object.keys(otd).sort()) {
736
+ keys.push(`output_tokens_details.${k}`);
737
+ }
738
+ }
739
+ return shortHash(keys.join('|'));
740
+ }
741
+ function computeContentBlockTypesFingerprint(content) {
742
+ if (!Array.isArray(content))
743
+ return shortHash('');
744
+ const set = new Set();
745
+ for (const block of content) {
746
+ if (block &&
747
+ typeof block === 'object' &&
748
+ typeof block.type === 'string') {
749
+ set.add(String(block.type));
750
+ }
751
+ }
752
+ const sorted = Array.from(set).sort();
753
+ return shortHash(sorted.join('|'));
754
+ }
755
+ function shortHash(input) {
756
+ return createHash('sha1').update(input).digest('hex').slice(0, 16);
757
+ }
758
+ /**
759
+ * Stable cursor file path computation, exported for tests that want to
760
+ * pre-create cursor state without instantiating the watcher.
761
+ */
762
+ export function buildSubagentCursorPath(storagePath, parentSessionId, agentId) {
763
+ return resolve(storagePath, `.subagent-pos-${parentSessionId}-${agentId}`);
764
+ }
765
+ //# sourceMappingURL=subagent-watcher.js.map