watchmyagents 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
5
5
  "type": "module",
6
6
  "files": [
@@ -383,17 +383,25 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
383
383
  let agents = await resolveAgents();
384
384
  // v1.4.3 F-51: bounded dedup. Preloaded on-disk ids are static; runtime ids
385
385
  // are tracked per-session and dropped on terminate (see src/watch-state.js).
386
- const preloaded = [];
387
- for (const ag of agents) {
388
- for (const id of await preloadSeenIds(logDir, ag.agentId)) preloaded.push(id);
386
+ const seen = new SeenTracker();
387
+ // v1.4.4 F-53: preload each agent's on-disk history the FIRST time we see it
388
+ // including agents that --all-agents discovers after startup. Pre-fix the
389
+ // preload ran once for the initial fleet only, so a late-appearing agent with
390
+ // existing logs re-appended + re-uploaded already-captured events.
391
+ const preloadedAgents = new Set();
392
+ async function ensurePreloaded(agentId) {
393
+ if (preloadedAgents.has(agentId)) return;
394
+ preloadedAgents.add(agentId);
395
+ for (const id of await preloadSeenIds(logDir, agentId)) seen.addPreloaded(id);
389
396
  }
390
- const seen = new SeenTracker(preloaded);
397
+ for (const ag of agents) await ensurePreloaded(ag.agentId);
391
398
  const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
392
- const ended = new Set(); // terminated sessions (skip). Grows one id per
393
- // terminated session over the daemon's life
394
- // bounded by session COUNT (ids only, the
395
- // smallest term); the per-EVENT growth that
396
- // actually drove OOM is fixed via SeenTracker.
399
+ // terminated sessions (skip). v1.4.4 F-54: a Map<sid → terminatedAtMs> so we
400
+ // can TTL-prune. A session terminated more than windowMs ago has created_at
401
+ // older still, so listSessions (which filters created_at < since, since =
402
+ // now - windowMs) can no longer return it — its id is then safe to forget,
403
+ // bounding this map's growth on a long daemon with many short sessions.
404
+ const ended = new Map();
397
405
  const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
398
406
  const priors = new Map(); // agentId → previous classification (threads the
399
407
  // typology state machine across upload cycles)
@@ -413,9 +421,20 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
413
421
  if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
414
422
  const since = new Date(Date.now() - windowMs);
415
423
 
424
+ // F-54: forget sessions terminated longer ago than the discovery window —
425
+ // listSessions can no longer surface them (created_at < since), so their
426
+ // skip-marker is no longer needed. Bounds `ended` on a long-running daemon.
427
+ const ttlCutoff = Date.now() - windowMs;
428
+ for (const [sid, terminatedAt] of ended) {
429
+ if (terminatedAt < ttlCutoff) ended.delete(sid);
430
+ }
431
+
416
432
  // (1) Discover sessions in the window; register the owning agent for each.
417
433
  for (const ag of agents) {
418
434
  if (ac.signal.aborted) break;
435
+ // F-53: a late-discovered agent (fleet mode) gets its disk history
436
+ // preloaded before we fetch any of its sessions, so dedup holds.
437
+ await ensurePreloaded(ag.agentId);
419
438
  const tag = fleet ? `[${ag.displayName}] ` : '';
420
439
  let sessions = [];
421
440
  try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
@@ -507,7 +526,7 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
507
526
  session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
508
527
  session_cost_usd: stats.cost_usd || null,
509
528
  });
510
- ended.add(sid);
529
+ ended.set(sid, Date.now());
511
530
  // v1.4.3 F-51: bound memory — terminated sessions aren't re-fetched,
512
531
  // so drop their per-session dedup set AND their Logger object (the
513
532
  // latter was never deleted pre-fix → unbounded growth of Logger
@@ -265,6 +265,18 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
265
265
  // distinct API-level IDs will populate parent_agent_id natively.
266
266
  const subThreadIds = new Set();
267
267
 
268
+ // v1.4.4 F-52 (P2 audit): only emit the end-of-stream "no_result_observed"
269
+ // flush when the session ACTUALLY terminated in this stream. In watch mode
270
+ // fetchRawEvents re-fetches the FULL session every cycle, so a tool whose
271
+ // start lands in cycle N but whose result lands in cycle N+1 would, with an
272
+ // unconditional flush, get a phantom no_result_observed row (id = start id)
273
+ // in cycle N AND the real paired row (id = result id) in cycle N+1 — a
274
+ // double-count + persistent phantom error skewing error_rate_by_tool. While
275
+ // the session is still running an unpaired start is in-flight, not
276
+ // "no result observed"; it stays pending and pairs (or flushes) once the
277
+ // session's terminal state is observed.
278
+ let sessionTerminated = false;
279
+
268
280
  // `provider` is the canonical field per src/sources/contract.js (no
269
281
  // other consumer ever read the previous `framework` field, so it was
270
282
  // dropped in PR-B with zero downstream impact).
@@ -632,6 +644,8 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
632
644
  const prefix = isThread ? 'session.thread_status_' : 'session.status_';
633
645
  const state = type.slice(prefix.length); // 'running' | 'idle' | 'rescheduled' | 'terminated'
634
646
  const fatal = state === 'terminated';
647
+ // F-52: a SESSION-scope terminate (not a sub-thread one) gates the flush.
648
+ if (fatal && !isThread) sessionTerminated = true;
635
649
  yield {
636
650
  ...base,
637
651
  ...subAgentMeta,
@@ -666,6 +680,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
666
680
  // explicitly with status='error' keeps the local NDJSON, anonymizer
667
681
  // signals (counts, IoC hashes, tool_counts), and Fortress decisions
668
682
  // honest about what actually happened.
683
+ //
684
+ // v1.4.4 F-52: GATED on sessionTerminated. If the session is still running
685
+ // (watch mode re-fetches the full session every cycle), an unpaired start is
686
+ // in-flight, not lost — flushing it now would create a phantom row that the
687
+ // next cycle's real paired row double-counts. We only flush once the
688
+ // session's terminal state proves the calls genuinely never resolved. (Both
689
+ // F-8 and F-50 flush tests feed a session.status_terminated event, so this
690
+ // gate is satisfied for them.)
691
+ if (!sessionTerminated) return;
692
+
669
693
  for (const [toolUseId, pending] of pendingToolUse) {
670
694
  yield {
671
695
  ...base,
@@ -24,6 +24,15 @@ export class SeenTracker {
24
24
  this._bySession = new Map(); // sessionId -> Set<eventId>
25
25
  }
26
26
 
27
+ // v1.4.4 F-53: fold an agent's on-disk history into the static preloaded
28
+ // set. Called lazily the first time an agent is seen (including agents that
29
+ // --all-agents discovers AFTER startup) so their already-captured NDJSON ids
30
+ // are deduped against — otherwise a late-appearing agent with existing logs
31
+ // would re-append and re-upload events it already has.
32
+ addPreloaded(id) {
33
+ if (id) this._preloaded.add(id);
34
+ }
35
+
27
36
  has(sessionId, eventId) {
28
37
  if (this._preloaded.has(eventId)) return true;
29
38
  const s = this._bySession.get(sessionId);