watchmyagents 1.4.0 → 1.4.3

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.
@@ -22,6 +22,7 @@ import {
22
22
  getAgentConfig, detectAlwaysAsk,
23
23
  confirmAllow, confirmDeny, interruptSession,
24
24
  } from '../shield/enforce.js';
25
+ import { safeNonNegInt } from '../tokens.js';
25
26
 
26
27
  const API_HOST = 'api.anthropic.com';
27
28
  const BETA = 'managed-agents-2026-04-01';
@@ -120,25 +121,48 @@ export async function listAgents(apiKey, { limit = 100 } = {}) {
120
121
  return agents;
121
122
  }
122
123
 
123
- export async function listSessions(apiKey, { agentId, since, limit = 100 } = {}) {
124
+ // v1.4.2 F-45 (P1 audit): page through the FULL session list and filter by
125
+ // `since` per-row — do NOT early-break. The previous version stopped
126
+ // paginating at the first session older than `since`, assuming the API
127
+ // returns sessions newest-first by created_at. Nothing enforces that ordering
128
+ // (no `order=` param is sent), and the doc never promised it. If the API
129
+ // ordered by id / updated_at, a single out-of-window session aborted
130
+ // pagination while newer in-window sessions sat on later pages — they were
131
+ // NEVER enumerated, so that agent's activity went untraced (a security blind
132
+ // spot, the exact failure this tool exists to prevent). We now scan every
133
+ // page; a session older than `since` is skipped, not a stop signal. A null
134
+ // created_at is treated as in-window (we cannot prove it is old). `maxPages`
135
+ // is a pathological-loop backstop; hitting it warns loudly rather than
136
+ // silently truncating.
137
+ export async function listSessions(apiKey, { agentId, since, limit = 100, maxPages = 1000, _fetch } = {}) {
138
+ // _fetch is an injectable page-fetcher seam for tests (path -> response
139
+ // object with { data, has_more }). Production uses the real getWithRetry.
140
+ const fetchPage = _fetch || ((path) => getWithRetry(apiKey, path));
124
141
  const sessions = [];
125
142
  let after = null;
126
- while (true) {
143
+ let pages = 0;
144
+ while (pages < maxPages) {
145
+ pages++;
127
146
  const qs = new URLSearchParams({ limit: String(limit) });
128
147
  if (agentId) qs.set('agent_id', agentId);
129
148
  if (after) qs.set('after_id', after);
130
- const data = await getWithRetry(apiKey, `/v1/sessions?${qs}`);
149
+ const data = await fetchPage(`/v1/sessions?${qs}`);
131
150
  const page = data.data || [];
132
- let stop = false;
133
151
  for (const s of page) {
134
152
  const created = s.created_at ? new Date(s.created_at) : null;
135
- if (since && created && created < since) { stop = true; break; }
153
+ if (since && created && created < since) continue; // skip, do NOT stop
136
154
  sessions.push(s);
137
155
  }
138
- if (stop || !data.has_more || page.length === 0) break;
156
+ if (!data.has_more || page.length === 0) break;
139
157
  after = page[page.length - 1]?.id;
140
158
  if (!after) break;
141
159
  }
160
+ if (pages >= maxPages) {
161
+ process.stderr.write(
162
+ `[wma] listSessions: hit maxPages=${maxPages} for agent ${agentId || '(all)'} — ` +
163
+ 'session enumeration may be INCOMPLETE. Raise maxPages or narrow the agent set.\n',
164
+ );
165
+ }
142
166
  return sessions;
143
167
  }
144
168
 
@@ -211,6 +235,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
211
235
  // Pair-tracking maps: event_id of the "start" → its timestamp + metadata
212
236
  const pendingModelReq = new Map(); // span.model_request_start.id → ts
213
237
  const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
238
+ // v1.4.2 F-50 (P2 audit): pair custom tools like provider/MCP tools so the
239
+ // single yielded row carries the REAL status/error/duration. Pre-F-50
240
+ // agent.custom_tool_use was emitted immediately with a hardcoded status:'ok'
241
+ // and its outcome arrived on a SEPARATE custom_tool_result row with
242
+ // tool_name:null — so a failing custom tool never entered error_rate_by_tool
243
+ // (the aggregator skips null-named rows) and duration was always null. The
244
+ // customer-controlled tool surface — the highest-risk one — was the only
245
+ // family with no error/latency signal. Pairing fixes that with no aggregator
246
+ // special-case (custom_tool_use is already in TOOL_ACTIONS).
247
+ const pendingCustomTool = new Map(); // agent.custom_tool_use.id → { ts, name, input, startTimestamp, meta }
214
248
 
215
249
  // v1.1.3 Phase 1.C — Anthropic sub-agent detection.
216
250
  // Anthropic Task tool: when a parent agent delegates, a NEW thread is
@@ -279,10 +313,13 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
279
313
  const startTs = pendingModelReq.get(ev.model_request_start_id);
280
314
  pendingModelReq.delete(ev.model_request_start_id);
281
315
  const u = ev.model_usage || {};
282
- const i = u.input_tokens || 0;
283
- const o = u.output_tokens || 0;
284
- const cr = u.cache_read_input_tokens || 0;
285
- const cw = u.cache_creation_input_tokens || 0;
316
+ // v1.4.2 F-49 (P2 audit): sanitize to non-negative integers. A
317
+ // malformed/adversarial model_usage (NaN, negative, string) must not
318
+ // zero-out or poison the token totals that drive cost/abuse detection.
319
+ const i = safeNonNegInt(u.input_tokens);
320
+ const o = safeNonNegInt(u.output_tokens);
321
+ const cr = safeNonNegInt(u.cache_read_input_tokens);
322
+ const cw = safeNonNegInt(u.cache_creation_input_tokens);
286
323
  yield {
287
324
  ...base,
288
325
  ...subAgentMeta,
@@ -351,16 +388,36 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
351
388
  }
352
389
 
353
390
  if (type === 'user.custom_tool_result') {
391
+ // v1.4.2 F-50: pair with the stored start and emit ONE completed
392
+ // custom_tool_use row carrying the real status/error/duration + the
393
+ // tool_name from the start (so a failing custom tool now lands in
394
+ // error_rate_by_tool). Mirrors the agent.tool_result handler. An
395
+ // orphan result (no matching start — out-of-order / restart) still
396
+ // emits, with tool_name null and input from the result only, so we
397
+ // never drop an action.
398
+ const cStart = pendingCustomTool.get(ev.custom_tool_use_id);
399
+ pendingCustomTool.delete(ev.custom_tool_use_id);
400
+ const isError = ev.is_error === true;
401
+ const cMeta = cStart ? {
402
+ session_thread_id: cStart.session_thread_id ?? subAgentMeta.session_thread_id,
403
+ agent_name: cStart.agent_name ?? subAgentMeta.agent_name,
404
+ composition_pattern: (cStart.session_thread_id != null && subThreadIds.has(cStart.session_thread_id))
405
+ ? COMPOSITION_PATTERNS.HIERARCHY
406
+ : COMPOSITION_PATTERNS.SOLO,
407
+ parent_agent_id: null,
408
+ } : subAgentMeta;
354
409
  yield {
355
410
  ...base,
356
- ...subAgentMeta,
411
+ ...cMeta,
357
412
  id: ev.id,
358
- action_type: 'custom_tool_result',
359
- tool_name: null,
413
+ action_type: 'custom_tool_use',
414
+ tool_name: cStart?.name ?? null,
360
415
  model: model || null,
361
416
  timestamp: ts,
362
- status: ev.is_error ? 'error' : 'ok',
363
- input: { custom_tool_use_id: ev.custom_tool_use_id },
417
+ duration_ms: (cStart?.ts && tsMillis) ? tsMillis - cStart.ts : null,
418
+ status: isError ? 'error' : 'ok',
419
+ error: isError ? extractText(ev.content).slice(0, 500) : null,
420
+ input: cStart?.input ?? { custom_tool_use_id: ev.custom_tool_use_id },
364
421
  output: { content: ev.content ?? null },
365
422
  };
366
423
  continue;
@@ -399,7 +456,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
399
456
  if (type === 'agent.tool_use' || type === 'agent.mcp_tool_use') {
400
457
  pendingToolUse.set(ev.id, {
401
458
  ts: tsMillis,
402
- name: ev.name || 'unknown',
459
+ // v1.4.2 F-41 (P1 audit): a missing tool name becomes null, NOT the
460
+ // literal string 'unknown'. A 'unknown' fallback (a) let a tool_name
461
+ // DENYLIST silently miss a real tool whose name field was absent
462
+ // (the rule keys on e.g. "bash"; "unknown" !== "bash" → no match →
463
+ // default-allow), and (b) collided two distinct unnamed tools into
464
+ // one forensic bucket. null fails every specific tool_name clause
465
+ // closed and is honestly absent. tool_name is `string|null` per the
466
+ // WMAAction contract; normalizeToolName(null) returns null and the
467
+ // aggregator skips null-named entries from per-tool counts.
468
+ name: ev.name ?? null,
403
469
  isMcp: type === 'agent.mcp_tool_use',
404
470
  input: ev.input ?? null,
405
471
  mcpServer: ev.server_name ?? ev.mcp_server_name ?? null,
@@ -435,7 +501,7 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
435
501
  ...pairedMeta,
436
502
  id: ev.id,
437
503
  action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
438
- tool_name: start?.name || 'unknown',
504
+ tool_name: start?.name ?? null, // F-41: null, never literal 'unknown'
439
505
  timestamp: ts,
440
506
  duration_ms: (start?.ts && tsMillis) ? tsMillis - start.ts : null,
441
507
  status: isError ? 'error' : 'ok',
@@ -447,16 +513,15 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
447
513
  }
448
514
 
449
515
  if (type === 'agent.custom_tool_use') {
450
- yield {
451
- ...base,
452
- ...subAgentMeta,
453
- id: ev.id,
454
- action_type: 'custom_tool_use',
455
- tool_name: ev.name || 'unknown',
456
- timestamp: ts,
457
- status: 'ok',
516
+ // v1.4.2 F-50: store the start; the paired custom_tool_result (or the
517
+ // end-of-session flush) emits the single completed row with real status.
518
+ pendingCustomTool.set(ev.id, {
519
+ ts: tsMillis,
520
+ name: ev.name ?? null, // F-41: null, never literal 'unknown'
458
521
  input: ev.input ?? null,
459
- };
522
+ startTimestamp: ts,
523
+ session_thread_id, agent_name,
524
+ });
460
525
  continue;
461
526
  }
462
527
 
@@ -619,6 +684,28 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
619
684
  };
620
685
  }
621
686
  pendingToolUse.clear();
687
+
688
+ // v1.4.2 F-50: same flush for custom tools that started but never returned
689
+ // a result (Shield-blocked, denied, died mid-flight, session cut). Emitted
690
+ // as custom_tool_use / status=error / no_result_observed — the high-value
691
+ // "started but didn't complete" signal, now also covered for custom tools.
692
+ for (const [useId, pending] of pendingCustomTool) {
693
+ yield {
694
+ ...base,
695
+ session_thread_id: pending.session_thread_id,
696
+ agent_name: pending.agent_name,
697
+ id: useId,
698
+ action_type: 'custom_tool_use',
699
+ tool_name: pending.name,
700
+ model: model || null,
701
+ timestamp: pending.startTimestamp,
702
+ duration_ms: null,
703
+ status: 'error',
704
+ error: 'no_result_observed',
705
+ input: pending.input,
706
+ };
707
+ }
708
+ pendingCustomTool.clear();
622
709
  }
623
710
 
624
711
  // ────────────────────────────────────────────────────────────────────────
@@ -101,6 +101,23 @@ export const ACTION_TYPES = Object.freeze({
101
101
  STATE_TRANSITION: 'state_transition',
102
102
  });
103
103
 
104
+ // v1.4.2 F-38 (P0 audit) — the three tool-INVOCATION action_types form one
105
+ // security-equivalent family. WHICH surface a tool call came through
106
+ // (provider built-in vs MCP server vs customer-wired custom tool) is an
107
+ // adapter implementation detail, not a security distinction: all three are
108
+ // "the agent invoked a tool." A deny/allowlist policy keyed on the generic
109
+ // `tool_use` MUST catch all three, or it silently misses MCP + custom tool
110
+ // calls — exactly where an attacker pivots. The policy matcher
111
+ // (src/shield/policy.js) expands a generic `tool_use` target to this family;
112
+ // matching a SPECIFIC member (mcp_tool_use / custom_tool_use) stays exact so
113
+ // an operator can still target one surface when they mean to.
114
+ // SignalsAggregator (src/anonymizer.js) uses the same set for IoC capture.
115
+ export const TOOL_USE_FAMILY = Object.freeze([
116
+ ACTION_TYPES.TOOL_USE,
117
+ ACTION_TYPES.MCP_TOOL_USE,
118
+ ACTION_TYPES.CUSTOM_TOOL_USE,
119
+ ]);
120
+
104
121
  export const STATUS_VALUES = Object.freeze({
105
122
  OK: 'ok',
106
123
  ERROR: 'error',
@@ -116,24 +116,48 @@ function readEnv(key, fallback) {
116
116
  // Policies that match on tool_name still work; policies that match on
117
117
  // argument values silently miss the truncated suffix — which is the
118
118
  // correct fail-closed behavior for an oversize input.
119
+ // v1.4.1 F-36 (P3 Codex audit on v1.4.0): proper byte-level truncation
120
+ // for UTF-8 strings. Pre-v1.4.1 we used `str.slice(0, maxBytes)`, which
121
+ // slices by CHARACTERS — with multi-byte Unicode (emoji = 4 bytes per
122
+ // char) the cap could be exceeded by up to 3x. This helper cuts on the
123
+ // byte boundary via Buffer and strips the trailing U+FFFD replacement
124
+ // character if the cut landed mid-sequence. We accept the dropped
125
+ // trailing char as acceptable cost: the result is marked _wmaTruncated.
126
+ function truncateUtf8(str, maxBytes) {
127
+ if (typeof str !== 'string') return str;
128
+ const buf = Buffer.from(str, 'utf8');
129
+ if (buf.length <= maxBytes) return str;
130
+ return buf.subarray(0, maxBytes).toString('utf8').replace(/�+$/, '');
131
+ }
132
+
119
133
  function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
120
134
  if (rawArgs == null) return null;
121
135
  if (typeof rawArgs === 'object') {
122
- // Already parsed by the SDK or the customer. We don't deep-walk
123
- // and truncate that would silently change policy match semantics
124
- // on nested fields. Defer the decision to the caller; the size cap
125
- // applies at the string-parse boundary, which is the realistic
126
- // SDK entry point.
136
+ // v1.4.1 F-36 also cap object inputs by their serialized byte
137
+ // size. Pre-v1.4.1 already-parsed objects bypassed the cap entirely,
138
+ // which let a misbehaving tool / customer pass a 5 MB object through
139
+ // safeParseToolArgs unimpeded. We don't deep-walk-and-truncate (that
140
+ // would silently change policy match semantics on nested fields);
141
+ // instead we replace the whole input with a truncation marker if
142
+ // the serialized form exceeds maxBytes.
143
+ try {
144
+ const serialized = JSON.stringify(rawArgs);
145
+ const byteLen = Buffer.byteLength(serialized, 'utf8');
146
+ if (byteLen > maxBytes) {
147
+ return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
148
+ }
149
+ } catch {
150
+ // Unserializable input — return as-is. Policy match against
151
+ // anything in this object is going to fail anyway.
152
+ }
127
153
  return rawArgs;
128
154
  }
129
155
  if (typeof rawArgs !== 'string') return null;
130
156
  // Byte cap: Buffer.byteLength is the safe length on the wire.
131
157
  const byteLen = Buffer.byteLength(rawArgs, 'utf8');
132
158
  if (byteLen > maxBytes) {
133
- // Truncate to maxBytes worth of bytes (substring is char-bounded;
134
- // good enough exact byte truncation isn't required since we mark
135
- // the result as truncated and never feed it back to a real tool).
136
- const head = rawArgs.slice(0, maxBytes);
159
+ // v1.4.1 F-36 byte-level truncation (was char-level before).
160
+ const head = truncateUtf8(rawArgs, maxBytes);
137
161
  try {
138
162
  const parsed = JSON.parse(head);
139
163
  // If by luck the head is valid JSON, return it plus the marker.
@@ -374,8 +398,9 @@ function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
374
398
  if (typeof result === 'string') {
375
399
  const byteLen = Buffer.byteLength(result, 'utf8');
376
400
  if (byteLen > maxBytes) {
401
+ // v1.4.1 F-36 — byte-level truncation (was char-level before).
377
402
  return {
378
- text: result.slice(0, maxBytes) + TRUNCATION_SENTINEL,
403
+ text: truncateUtf8(result, maxBytes) + TRUNCATION_SENTINEL,
379
404
  _wmaTruncated: true,
380
405
  _wmaOriginalBytes: byteLen,
381
406
  };
@@ -445,6 +470,23 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
445
470
  // policy ruleset on first invocation if `policiesPath` is set.
446
471
 
447
472
  export function wmaToolInputGuardrail(options = {}) {
473
+ // v1.4.1 F-35 (P2 Codex audit on v1.4.0): fail-loud at construction
474
+ // time when nothing safety-relevant is configured. Same intent as the
475
+ // openaiAgents() factory's mode:'enforce' check, but applied to the
476
+ // lower-level entry point too — defense in depth. Customers using the
477
+ // direct import path no longer get a silent "allow all" surprise.
478
+ const hasPolicySource =
479
+ options.policiesPath != null ||
480
+ options.ruleset != null ||
481
+ options.allowAllWhenUnconfigured === true;
482
+ if (!hasPolicySource) {
483
+ throw new Error(
484
+ 'wmaToolInputGuardrail: no policy configured. Pass policiesPath or ruleset. ' +
485
+ 'For demos / smoke tests that intentionally allow all tool calls, pass ' +
486
+ '{ allowAllWhenUnconfigured: true } explicitly.',
487
+ );
488
+ }
489
+
448
490
  const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
449
491
  const sessionId = options.sessionId || makeSessionId();
450
492
  const logDir = options.logDir
@@ -480,14 +522,16 @@ export function wmaToolInputGuardrail(options = {}) {
480
522
  ruleset = await loadPolicies(options.policiesPath);
481
523
  return ruleset;
482
524
  }
483
- // No ruleset, no path default to "always allow". The customer
484
- // probably means to use Fortress; we don't ship that wiring in
485
- // v1.3.0 from this entry point. Log a warning once.
525
+ // v1.4.1 F-35: only reachable when the customer passed
526
+ // `allowAllWhenUnconfigured: true` the synchronous check at
527
+ // construction time has already rejected every other un-configured
528
+ // shape. Log a loud warning once so demo deployments don't quietly
529
+ // ship without a real ruleset.
486
530
  if (!ensureRuleset._warned) {
487
531
  ensureRuleset._warned = true;
488
532
  process.stderr.write(
489
- '[wma/openai-agents] no policy ruleset configured — guardrail will allow all. ' +
490
- 'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
533
+ '[wma/openai-agents] allowAllWhenUnconfigured=true — guardrail will allow ALL tool calls. ' +
534
+ 'This is intended for demos only; ship with a real ruleset in production.\n',
491
535
  );
492
536
  }
493
537
  ruleset = { policies: [], default: { action: 'allow' } };
@@ -505,10 +549,26 @@ export function wmaToolInputGuardrail(options = {}) {
505
549
  try {
506
550
  const rs = await ensureRuleset();
507
551
 
552
+ // v1.4.2 F-42 (P1 audit): read the tool name DEFENSIVELY across the
553
+ // shapes @openai/agents may use for the guardrail's toolCall object.
554
+ // The adapter already knows the id lives under a non-obvious key
555
+ // (makeEventId reads callId||id), so the name may likewise sit under
556
+ // .name, .toolName, or .function.name depending on SDK version/tool
557
+ // kind. If we only read .name and the SDK puts it elsewhere, tool_name
558
+ // is null and EVERY tool_name-keyed deny rule silently misses — a
559
+ // total enforcement bypass. Coalesce all known shapes; null only if
560
+ // none are present (then tool_name-keyed rules correctly no-match,
561
+ // and the operator can still deny on action_type alone).
562
+ const toolName = toolCall?.name
563
+ ?? toolCall?.toolName
564
+ ?? toolCall?.function?.name
565
+ ?? data?.tool?.name
566
+ ?? null;
567
+
508
568
  // 1. Normalize the about-to-fire tool_use event.
509
569
  const event = normalizeToolStart({
510
570
  agent,
511
- tool: { name: toolCall?.name },
571
+ tool: { name: toolName },
512
572
  toolCall,
513
573
  sessionId,
514
574
  teamId: getTeamId(),
package/src/tokens.js CHANGED
@@ -4,14 +4,26 @@
4
4
  // token counts (split + by-model) are tracked.
5
5
  export const DEFAULT_PRICING = {};
6
6
 
7
+ // v1.4.2 F-49 (P2 audit): coerce a token/usage value to a safe non-negative
8
+ // integer. The old `x || 0` is a FALSY guard, not a numeric one — it lets
9
+ // negatives through (`-5 || 0` === -5) and a NaN propagates through later
10
+ // arithmetic to poison totals (`NaN + n` → NaN → `|| null` → null), silently
11
+ // ZEROING a real LLM call's tokens. A malformed or adversarial usage payload
12
+ // could thus under-report the security-relevant consumption signal (a runaway
13
+ // / abused agent) or drive aggregate token/cost negative. Anything not a
14
+ // finite non-negative number becomes 0.
15
+ export function safeNonNegInt(x) {
16
+ return Number.isFinite(x) && x >= 0 ? Math.trunc(x) : 0;
17
+ }
18
+
7
19
  export function estimateCost(model, t, pricing) {
8
20
  if (!model) return null;
9
21
  const p = (pricing && pricing[model]) || DEFAULT_PRICING[model];
10
22
  if (!p) return null;
11
- const inT = t.input_tokens || 0;
12
- const outT = t.output_tokens || 0;
13
- const cr = t.cache_read_tokens || 0;
14
- const cw = t.cache_creation_tokens || 0;
23
+ const inT = safeNonNegInt(t.input_tokens);
24
+ const outT = safeNonNegInt(t.output_tokens);
25
+ const cr = safeNonNegInt(t.cache_read_tokens);
26
+ const cw = safeNonNegInt(t.cache_creation_tokens);
15
27
  const cost = ((inT * (p.input || 0)) + (outT * (p.output || 0)) +
16
28
  (cr * (p.cache_read || 0)) + (cw * (p.cache_write || 0))) / 1_000_000;
17
29
  return Math.round(cost * 1_000_000) / 1_000_000;
@@ -38,13 +50,16 @@ export class TokenTracker {
38
50
  if (entry.action_type === 'session_end') return;
39
51
 
40
52
  const t = {
41
- input: entry.input_tokens || 0,
42
- output: entry.output_tokens || 0,
43
- cache_read: entry.cache_read_tokens || 0,
44
- cache_creation: entry.cache_creation_tokens || 0,
53
+ input: safeNonNegInt(entry.input_tokens),
54
+ output: safeNonNegInt(entry.output_tokens),
55
+ cache_read: safeNonNegInt(entry.cache_read_tokens),
56
+ cache_creation: safeNonNegInt(entry.cache_creation_tokens),
45
57
  };
46
- const sum = entry.tokens_used || (t.input + t.output + t.cache_read + t.cache_creation);
47
- const cost = entry.cost_usd || 0;
58
+ // F-49: a provided tokens_used is sanitized too; 0/missing falls back to
59
+ // the (sanitized) component sum.
60
+ const provided = safeNonNegInt(entry.tokens_used);
61
+ const sum = provided || (t.input + t.output + t.cache_read + t.cache_creation);
62
+ const cost = Number.isFinite(entry.cost_usd) && entry.cost_usd >= 0 ? entry.cost_usd : 0;
48
63
 
49
64
  this.total.input += t.input;
50
65
  this.total.output += t.output;
@@ -0,0 +1,56 @@
1
+ // v1.4.3 F-51 (P2 audit) — bounded dedup state for the long-running watch loop.
2
+ //
3
+ // The watch daemon dedupes events by their stable Anthropic event id so a
4
+ // re-fetch of an already-captured session doesn't double-write the NDJSON.
5
+ // Pre-F-51 every id ever seen lived in a single Set that was only ever added
6
+ // to — on a 24/7 daemon at high event volume that Set grows without bound
7
+ // (hundreds of MB within days), and the daemon eventually OOM-kills and stops
8
+ // collecting (a silent monitoring gap).
9
+ //
10
+ // SeenTracker bounds runtime growth WITHOUT weakening dedup:
11
+ // - Preloaded ids (read from on-disk NDJSON at startup) sit in a static set,
12
+ // loaded once. Bounded by what the operator keeps on disk.
13
+ // - Runtime-discovered ids are tracked PER SESSION. When a session
14
+ // terminates it is never re-fetched, so its id set is dropped. Live memory
15
+ // is therefore bounded by ACTIVE sessions, not lifetime event volume.
16
+ //
17
+ // Correctness: Anthropic event ids are globally unique and an event belongs to
18
+ // exactly one session, so checking the preloaded set + that session's set is
19
+ // equivalent to the old global-set membership test (no missed dedup, no
20
+ // missed events).
21
+ export class SeenTracker {
22
+ constructor(preloadedIds = []) {
23
+ this._preloaded = new Set(preloadedIds);
24
+ this._bySession = new Map(); // sessionId -> Set<eventId>
25
+ }
26
+
27
+ has(sessionId, eventId) {
28
+ if (this._preloaded.has(eventId)) return true;
29
+ const s = this._bySession.get(sessionId);
30
+ return s ? s.has(eventId) : false;
31
+ }
32
+
33
+ add(sessionId, eventId) {
34
+ let s = this._bySession.get(sessionId);
35
+ if (!s) { s = new Set(); this._bySession.set(sessionId, s); }
36
+ s.add(eventId);
37
+ }
38
+
39
+ // Drop a terminated session's runtime id set — it will never be re-fetched,
40
+ // so its ids no longer need to be remembered for dedup.
41
+ forgetSession(sessionId) {
42
+ this._bySession.delete(sessionId);
43
+ }
44
+
45
+ // Total tracked ids (preloaded + all live per-session sets). For observability.
46
+ get size() {
47
+ let n = this._preloaded.size;
48
+ for (const s of this._bySession.values()) n += s.size;
49
+ return n;
50
+ }
51
+
52
+ // Number of sessions currently holding a runtime id set.
53
+ get sessionCount() {
54
+ return this._bySession.size;
55
+ }
56
+ }