watchmyagents 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -105,14 +105,15 @@ Each entry carries: `id`, `agent_id`, `framework`, `timestamp`, `action_type`, `
105
105
  ### `wma-fetch` — pull events from Anthropic Managed Agents
106
106
 
107
107
  ```bash
108
- wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
108
+ wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--since 1h]
109
109
  [--log-dir ./watchmyagents-logs] [--dump-raw]
110
110
  [--watch [--interval 5m] [--upload]]
111
111
  ```
112
112
 
113
113
  | Flag | Effect |
114
114
  |---|---|
115
- | `--agent-id agent_xxx` | Required — Anthropic agent identifier |
115
+ | `--agent-id agent_xxx` | Anthropic agent identifier (required unless `--all-agents`) |
116
+ | `--all-agents` | **Fleet mode** (requires `--watch`) — discover ALL agents under the key and watch them in a single process |
116
117
  | `--since 1h` / `24h` / `7d` | Fetch window (default: all) |
117
118
  | `--session-id sesn_xxx` | Limit to a single session |
118
119
  | `--log-dir ./logs` | Where to write NDJSON (default `./watchmyagents-logs`) |
@@ -167,6 +168,21 @@ wma-inspect [path]
167
168
 
168
169
  Outputs sections aligned with security audit needs: tokens summary, by-tool / by-action-type breakdowns, top tool destinations (URLs / queries), action-sequence transitions, tool error rates, p50/p95/max latency per tool, rate metrics.
169
170
 
171
+ ### `wma-agents` — discover + classify your agents (typology)
172
+
173
+ Lists every Managed Agent under your key and classifies each one's **typology**
174
+ (one of 10 Guardian Core archetypes) from its OBSERVED behaviour in your local
175
+ logs — which drives the cold-start Shield template. Modèle C: reads local logs
176
+ only (tool-category fractions, never raw content) and transmits nothing.
177
+
178
+ ```bash
179
+ wma-agents list [--log-dir ~/.watchmyagents/logs] [--json]
180
+ ```
181
+
182
+ With fewer than ~50 observed events an agent stays `generic` (cold start) and
183
+ refines as activity accumulates. Re-classification to a *less strict* type is
184
+ gated (raised confidence + longer window) to resist mimicry-evasion.
185
+
170
186
  ## Automating — continuous monitoring
171
187
 
172
188
  ### `wma-service` — install as an always-on service (recommended)
@@ -180,7 +196,7 @@ export WMA_API_KEY="wma_..."
180
196
  export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
181
197
  export WMA_SIGNALS_SALT="..." # stable per-customer salt
182
198
 
183
- wma-service install --agent-id agent_01ABC... --interval 5m [--with-shield]
199
+ wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 5m] [--with-shield]
184
200
  wma-service status
185
201
  wma-service uninstall [--with-shield]
186
202
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": [
@@ -11,6 +11,7 @@
11
11
  "scripts/anonymize.js",
12
12
  "scripts/upload-fortress.js",
13
13
  "scripts/service.js",
14
+ "scripts/agents.js",
14
15
  "README.md",
15
16
  "SECURITY.md",
16
17
  "LICENSE"
@@ -21,23 +22,24 @@
21
22
  "wma-shield": "scripts/shield.js",
22
23
  "wma-anonymize": "scripts/anonymize.js",
23
24
  "wma-upload-fortress": "scripts/upload-fortress.js",
24
- "wma-service": "scripts/service.js"
25
+ "wma-service": "scripts/service.js",
26
+ "wma-agents": "scripts/agents.js"
25
27
  },
26
28
  "scripts": {
29
+ "test": "node --test",
27
30
  "inspect": "node scripts/inspect.js",
28
31
  "fetch": "node scripts/fetch-anthropic.js",
29
32
  "shield": "node scripts/shield.js",
30
33
  "anonymize": "node scripts/anonymize.js",
31
34
  "upload-fortress": "node scripts/upload-fortress.js",
32
- "service": "node scripts/service.js"
35
+ "service": "node scripts/service.js",
36
+ "agents": "node scripts/agents.js"
33
37
  },
34
38
  "engines": {
35
39
  "node": ">=18.0.0"
36
40
  },
37
41
  "dependencies": {},
38
- "devDependencies": {
39
- "@anthropic-ai/sdk": "^0.42.0"
40
- },
42
+ "devDependencies": {},
41
43
  "keywords": [
42
44
  "ai",
43
45
  "agents",
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // wma-agents — discover all Managed Agents under your key and classify each
3
+ // agent's typology from its OBSERVED behaviour (for Shield template selection).
4
+ //
5
+ // Usage:
6
+ // wma-agents [list] [--log-dir ~/.watchmyagents/logs] [--json]
7
+ //
8
+ // Reads the local Watch logs (NEVER leaves the machine — Modèle C) and derives
9
+ // the anonymized behavioural FEATURE VECTOR per the typology spec:
10
+ // per-tool-category FRACTIONS (f_*), boolean local flags (flag_*), aux ratios
11
+ // (aux_*), and n_events. It then calls classifyAgentType() and prints the
12
+ // schema-conformant result. With <50 events an agent is `generic` (cold start)
13
+ // and refines as activity accumulates.
14
+ //
15
+ // Modèle C invariant: only counts/ratios/flags are computed here — never raw
16
+ // prompt/output content, never the agent display name. Nothing is transmitted.
17
+ //
18
+ // ANTHROPIC_API_KEY from env (or --api-key, discouraged).
19
+
20
+ import os from 'node:os';
21
+ import { readdir, stat } from 'node:fs/promises';
22
+ import { createReadStream } from 'node:fs';
23
+ import { createInterface } from 'node:readline';
24
+ import { join, resolve } from 'node:path';
25
+ import { listAgents } from '../src/sources/anthropic-managed.js';
26
+ import { classifyAgentType } from '../src/typology.js';
27
+ import { isValidAgentId, assertSafePathSegment } from '../src/validate.js';
28
+
29
+ function parseArgs(argv) {
30
+ const out = { _: [] };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a.startsWith('--')) {
34
+ const k = a.slice(2); const n = argv[i + 1];
35
+ if (n == null || n.startsWith('--')) out[k] = true; else { out[k] = n; i++; }
36
+ } else out._.push(a);
37
+ }
38
+ return out;
39
+ }
40
+ function die(msg, code = 1) { process.stderr.write(`error: ${msg}\n`); process.exit(code); }
41
+ function info(msg) { process.stdout.write(`[wma-agents] ${msg}\n`); }
42
+
43
+ // Action types that represent a TOOL invocation (the denominator for f_* tool
44
+ // fractions). Confirmed produced by src/sources/anthropic-managed.js.
45
+ const TOOL_ACTIONS = new Set(['tool_use', 'mcp_tool_use', 'custom_tool_use']);
46
+
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+ // Tool-name → category mapping (Modèle C: name-based, no content). Managed
49
+ // Agents expose tools as an opaque bundle, so tool_name is free-text. We match
50
+ // the confirmed built-ins (web_search, web_fetch, bash) plus best-effort
51
+ // regexes for common tool names. A tool that matches nothing contributes to the
52
+ // denominator but to no category (honest: unknown ≠ inferred).
53
+ // ──────────────────────────────────────────────────────────────────────────
54
+ const CATEGORY_RULES = [
55
+ // category, matcher (lower-cased tool_name)
56
+ ['search', (n) => /(^|_)web_search$|(^|_)search($|_)|google|brave/.test(n)],
57
+ ['browser', (n) => /web_fetch|browser|playwright|puppeteer|navigate|screenshot/.test(n)],
58
+ ['http', (n) => /(^|_)http|fetch_url|curl|request|webhook|api_call/.test(n)],
59
+ ['code', (n) => /bash|shell|terminal|code_exec|exec_|python|node_run|run_code|interpreter/.test(n)],
60
+ ['database', (n) => /sql|query_db|database|postgres|mysql|mongo|redis|bigquery|snowflake/.test(n)],
61
+ ['email', (n) => /email|gmail|smtp|sendmail|mailgun|outlook/.test(n)],
62
+ ['payment', (n) => /payment|charge|transfer|invoice|stripe|paypal|payout|refund|checkout/.test(n)],
63
+ ['secret', (n) => /secret|vault|credential|kms|keychain|token_get/.test(n)],
64
+ ['memory', (n) => /memory|retriev|vector|(^|_)rag($|_)|knowledge|embed|pinecone|chroma/.test(n)],
65
+ ['file', (n) => /editor|str_replace|read_file|write_file|create_file|file_io|(^|_)file($|_)|fs_/.test(n)],
66
+ ];
67
+
68
+ // Best-effort deploy detection (spec discriminator devops_infra vs coding).
69
+ const DEPLOY_RE = /deploy|terraform|kubectl|helm|(^|_)release($|_)|ansible|pulumi|cloudformation/;
70
+
71
+ function categoryOf(toolName) {
72
+ const n = String(toolName || '').toLowerCase();
73
+ for (const [cat, m] of CATEGORY_RULES) if (m(n)) return cat;
74
+ return null;
75
+ }
76
+
77
+ // Aggregate raw counts from an agent's local NDJSON logs (Modèle C: counts only).
78
+ async function aggregate(logDir, agentId) {
79
+ const actionCounts = {}; // action_type → count
80
+ const categoryCounts = {}; // tool category → count
81
+ let toolEvents = 0; // denominator for f_* fractions
82
+ let deployUses = 0;
83
+ const dir = join(logDir, agentId);
84
+ const s = await stat(dir).catch(() => null);
85
+ if (!s || !s.isDirectory()) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
86
+ let names;
87
+ try { names = await readdir(dir); } catch { return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false }; }
88
+ const files = names.filter((n) => n.endsWith('.ndjson') && !n.startsWith('raw-'));
89
+ if (files.length === 0) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
90
+
91
+ for (const f of files) {
92
+ await new Promise((res) => {
93
+ const rl = createInterface({ input: createReadStream(join(dir, f), { encoding: 'utf8' }), crlfDelay: Infinity });
94
+ rl.on('line', (line) => {
95
+ if (!line.trim()) return;
96
+ let e; try { e = JSON.parse(line); } catch { return; }
97
+ if (e.action_type) actionCounts[e.action_type] = (actionCounts[e.action_type] || 0) + 1;
98
+ if (TOOL_ACTIONS.has(e.action_type)) {
99
+ toolEvents += 1;
100
+ const cat = categoryOf(e.tool_name);
101
+ if (cat) categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
102
+ if (DEPLOY_RE.test(String(e.tool_name || '').toLowerCase())) deployUses += 1;
103
+ }
104
+ });
105
+ rl.on('close', res); rl.on('error', res);
106
+ });
107
+ }
108
+ return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: true };
109
+ }
110
+
111
+ // Features that the WMA NDJSON logs CANNOT reliably expose today (opaque tool
112
+ // names / no behavioural signal / content off-limits under Modèle C). They
113
+ // default to 0/false; the caller prints a one-line note.
114
+ const NON_DERIVABLE = [
115
+ 'f_database', 'f_email', 'f_payment', 'f_secret', 'f_memory',
116
+ 'flag_internal_sys', 'flag_on_behalf', 'aux_untrusted', 'aux_sensitive',
117
+ ];
118
+
119
+ // Build the canonical anonymized FEATURE VECTOR from the aggregated counts.
120
+ // Fractions = category_count / toolEvents. n_events = total observed events.
121
+ function buildFeatures(agg) {
122
+ const { actionCounts, categoryCounts, toolEvents, deployUses } = agg;
123
+ const nEvents = Object.values(actionCounts).reduce((a, b) => a + b, 0);
124
+ const frac = (c) => (toolEvents > 0 ? (categoryCounts[c] || 0) / toolEvents : 0);
125
+ const eventFrac = (...types) => (nEvents > 0
126
+ ? types.reduce((a, t) => a + (actionCounts[t] || 0), 0) / nEvents
127
+ : 0);
128
+
129
+ // f_handoff / f_user_msg are derived from event TYPE (not tool category):
130
+ // confirmed action_types thread_message_* and user_message.
131
+ const handoff = eventFrac('thread_message_sent', 'thread_message_received', 'thread_created');
132
+ const userMsg = eventFrac('user_message');
133
+
134
+ // aux_autonomy ≈ 1 − (human-in-the-loop event share). Confirmed action_types
135
+ // user_message / user_interrupt / tool_confirmation mark human involvement; an
136
+ // agent that proceeds without them is more autonomous. Heuristic — documented.
137
+ const hitlShare = eventFrac('user_message', 'user_interrupt', 'tool_confirmation');
138
+ const auxAutonomy = nEvents > 0 ? Math.max(0, 1 - hitlShare) : 0;
139
+
140
+ return {
141
+ // tool-category fractions (over tool uses)
142
+ f_code: frac('code'),
143
+ f_browser: frac('browser'),
144
+ f_database: frac('database'), // non-derivable in practice → ~0
145
+ f_http: frac('http'),
146
+ f_email: frac('email'), // non-derivable in practice → ~0
147
+ f_payment: frac('payment'), // non-derivable in practice → ~0
148
+ f_secret: frac('secret'), // non-derivable in practice → ~0
149
+ f_search: frac('search'),
150
+ f_memory: frac('memory'), // non-derivable in practice → ~0
151
+ f_file: frac('file'),
152
+ // event-type fractions (over all events)
153
+ f_handoff: handoff,
154
+ f_user_msg: userMsg,
155
+ // discriminator flags (best-effort; only flag_deploy has any behavioural
156
+ // signal — and only if the agent literally names a deploy tool).
157
+ flag_deploy: deployUses > 0 ? 1 : 0,
158
+ flag_internal_sys: 0, // no behavioural signal in logs
159
+ flag_on_behalf: 0, // no behavioural signal in logs
160
+ // aux ratios
161
+ aux_autonomy: auxAutonomy, // heuristic (HITL-frequency)
162
+ aux_untrusted: 0, // no honest source in logs
163
+ aux_sensitive: 0, // no honest source in logs
164
+ // window size
165
+ n_events: nEvents,
166
+ };
167
+ }
168
+
169
+ async function main() {
170
+ const args = parseArgs(process.argv.slice(2));
171
+ if (args._[0] && args._[0] !== 'list') die(`unknown command "${args._[0]}" (only "list" supported)`);
172
+ const apiKey = args['api-key'] || process.env.ANTHROPIC_API_KEY;
173
+ if (!apiKey) die('--api-key or ANTHROPIC_API_KEY required');
174
+ if (args['api-key']) process.stderr.write('[wma-agents] WARNING: --api-key is visible in shell history; prefer ANTHROPIC_API_KEY env\n');
175
+ const logDir = resolve(args['log-dir'] || join(os.homedir(), '.watchmyagents', 'logs'));
176
+ const asJson = !!args.json;
177
+
178
+ let agents;
179
+ try { agents = await listAgents(apiKey); }
180
+ catch (e) { die(`failed to list agents: ${e.message}`); }
181
+
182
+ const results = [];
183
+ for (const a of agents) {
184
+ if (!a.id || !isValidAgentId(a.id)) continue;
185
+ assertSafePathSegment(a.id, 'agent id');
186
+ const agg = await aggregate(logDir, a.id);
187
+ const features = buildFeatures(agg);
188
+ features.agent_id = a.id;
189
+ // No prior state threaded here (single-shot CLI snapshot); the continuous
190
+ // Watch daemon is responsible for threading window state across runs.
191
+ const cls = classifyAgentType(features);
192
+ results.push({
193
+ id: a.id,
194
+ name: a.name || '(unnamed)', // shown for the human only — NOT a classification signal
195
+ hasLogs: agg.hasLogs,
196
+ ...cls,
197
+ });
198
+ }
199
+
200
+ if (asJson) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
201
+
202
+ info(`discovered ${results.length} agent(s) - classified from local logs in ${logDir}`);
203
+ info(`Modele C: features below default to 0 (logs don't expose them): ${NON_DERIVABLE.join(', ')}`);
204
+ process.stdout.write('\n');
205
+ for (const r of results) {
206
+ const mods = (r.modifiers && r.modifiers.length) ? ` [+${r.modifiers.join(',')}]` : '';
207
+ const overlay = r.evidence?.payment_overlay ? ' (+transactional overlay)' : '';
208
+ process.stdout.write(` ${r.name}\n`);
209
+ process.stdout.write(` ${r.id}\n`);
210
+ process.stdout.write(` -> ${r.classified_type} (conf ${Math.round(r.confidence * 100)}%, ${r.stage})${mods}${overlay}\n`);
211
+ process.stdout.write(` evidence: ${r.evidence.window_events} events, top2=${r.evidence.top2_type}, margin=${r.evidence.margin}\n`);
212
+ if (!r.hasLogs) process.stdout.write(' (no local logs yet - cold start)\n');
213
+ process.stdout.write('\n');
214
+ }
215
+ info('type drives the cold-start Shield template (Guardian Core §8). The global-baseline floor applies regardless of classification.');
216
+ }
217
+
218
+ main().catch((e) => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
@@ -31,7 +31,7 @@ import { SignalsAggregator } from '../src/anonymizer.js';
31
31
  import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
32
32
  import { isValidAgentId, isValidSessionId, assertSafePathSegment } from '../src/validate.js';
33
33
  import {
34
- getAgent, listSessions, fetchSessionEntries, fetchRawEvents,
34
+ getAgent, listAgents, listSessions, fetchSessionEntries, fetchRawEvents,
35
35
  } from '../src/sources/anthropic-managed.js';
36
36
 
37
37
  function parseArgs(argv) {
@@ -70,6 +70,9 @@ function parseSince(s) {
70
70
  function die(msg, code = 1) { process.stderr.write(`${msg}\n`); process.exit(code); }
71
71
  function info(msg) { process.stdout.write(`[wma-fetch] ${msg}\n`); }
72
72
  function warn(msg) { process.stderr.write(`[wma-fetch] ⚠️ ${msg}\n`); }
73
+ // Strip control chars + truncate a customer-set agent name before it goes into
74
+ // a log line or the Fortress display_name (defense-in-depth vs log/payload injection).
75
+ function cleanLabel(s) { return [...String(s ?? '')].filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join('').slice(0, 60).trim(); }
73
76
 
74
77
  function resolveModel(agent) {
75
78
  const raw = agent.model || agent.config?.model || null;
@@ -198,10 +201,14 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
198
201
  process.stdout.write(`[wma-fetch] inspect with: npx wma-inspect ${logDir}\n`);
199
202
  }
200
203
 
201
- // ── CONTINUOUS / DAEMON ─────────────────────────────────────────────────────
202
- async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalMs, uploadCtx }) {
203
- const seenIds = await preloadSeenIds(logDir, agentId);
204
- const loggers = new Map(); // sessionId Logger (persists sequence across cycles)
204
+ // ── CONTINUOUS / DAEMON (single agent or whole fleet) ───────────────────────
205
+ // `agents` = [{ agentId, model, displayName }]. One process watches them all.
206
+ async function runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx }) {
207
+ const seenIds = new Set(); // stable Anthropic event ids already captured
208
+ for (const ag of agents) {
209
+ for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
210
+ }
211
+ const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
205
212
  const ended = new Set(); // sessions we've already closed with session_end
206
213
 
207
214
  const ac = new AbortController();
@@ -209,56 +216,62 @@ async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalM
209
216
  process.on('SIGINT', shutdown);
210
217
  process.on('SIGTERM', shutdown);
211
218
 
212
- info(`watch mode interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
219
+ const fleet = agents.length > 1;
220
+ info(`watch mode — ${agents.length} agent(s), interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
213
221
 
214
222
  while (!ac.signal.aborted) {
215
223
  const since = new Date(Date.now() - 24 * 3600 * 1000);
216
- let sessions = [];
217
- try { sessions = await listSessions(apiKey, { agentId, since }); }
218
- catch (e) { warn(`listSessions failed: ${e.message}`); }
219
-
220
224
  let cycleNew = 0;
221
- for (const s of sessions) {
222
- if (!s.id || ended.has(s.id)) continue;
223
- let logger = loggers.get(s.id);
224
- if (!logger) { logger = new Logger({ logDir, agentId, sessionId: s.id, silent: true }); loggers.set(s.id, logger); }
225
225
 
226
- const fresh = [];
227
- let sawTerminated = false;
228
- try {
229
- for await (const entry of fetchSessionEntries({ apiKey, agentId, sessionId: s.id, model })) {
230
- if (entry.id && seenIds.has(entry.id)) continue;
231
- if (entry.id) seenIds.add(entry.id);
232
- const written = await logger.write(entry);
233
- fresh.push(written);
234
- if (entry.action_type === 'state_transition'
235
- && entry.output?.scope === 'session'
236
- && entry.output?.state === 'terminated') sawTerminated = true;
237
- }
238
- } catch (e) { warn(`session ${s.id}: fetch failed: ${e.message}`); continue; }
226
+ for (const ag of agents) {
227
+ if (ac.signal.aborted) break;
228
+ const tag = fleet ? `[${ag.displayName}] ` : '';
229
+ let sessions = [];
230
+ try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
231
+ catch (e) { warn(`${tag}listSessions failed: ${e.message}`); continue; }
239
232
 
240
- if (fresh.length === 0) continue;
241
- cycleNew += fresh.length;
242
- info(`session ${s.id.slice(0, 16)}…: +${fresh.length} new event(s)`);
233
+ for (const s of sessions) {
234
+ if (!s.id || ended.has(s.id)) continue;
235
+ let logger = loggers.get(s.id);
236
+ if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: s.id, silent: true }); loggers.set(s.id, logger); }
243
237
 
244
- if (uploadCtx) {
238
+ const fresh = [];
239
+ let sawTerminated = false;
245
240
  try {
246
- const resp = await uploadSignals(uploadCtx, agentId, displayName, fresh);
247
- if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
248
- } catch (e) { warn(` signals upload failed: ${e.message}`); }
249
- }
241
+ for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: s.id, model: ag.model })) {
242
+ if (entry.id && seenIds.has(entry.id)) continue;
243
+ if (entry.id) seenIds.add(entry.id);
244
+ const written = await logger.write(entry);
245
+ fresh.push(written);
246
+ if (entry.action_type === 'state_transition'
247
+ && entry.output?.scope === 'session'
248
+ && entry.output?.state === 'terminated') sawTerminated = true;
249
+ }
250
+ } catch (e) { warn(`${tag}session ${s.id.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
251
+
252
+ if (fresh.length === 0) continue;
253
+ cycleNew += fresh.length;
254
+ info(`${tag}session ${s.id.slice(0, 16)}…: +${fresh.length} new event(s)`);
250
255
 
251
- if (sawTerminated) {
252
- const tracker = new TokenTracker();
253
- for (const e of fresh) tracker.record(e);
254
- const stats = tracker.stats().total;
255
- await logger.write({
256
- action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model,
257
- session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
258
- session_cost_usd: stats.cost_usd || null,
259
- });
260
- ended.add(s.id);
261
- info(`session ${s.id.slice(0, 16)}… terminated — closed`);
256
+ if (uploadCtx) {
257
+ try {
258
+ const resp = await uploadSignals(uploadCtx, ag.agentId, ag.displayName, fresh);
259
+ if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
260
+ } catch (e) { warn(` signals upload failed: ${e.message}`); }
261
+ }
262
+
263
+ if (sawTerminated) {
264
+ const tracker = new TokenTracker();
265
+ for (const e of fresh) tracker.record(e);
266
+ const stats = tracker.stats().total;
267
+ await logger.write({
268
+ action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
269
+ session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
270
+ session_cost_usd: stats.cost_usd || null,
271
+ });
272
+ ended.add(s.id);
273
+ info(`${tag}session ${s.id.slice(0, 16)}… terminated — closed`);
274
+ }
262
275
  }
263
276
  }
264
277
 
@@ -275,10 +288,12 @@ async function main() {
275
288
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
276
289
  const watch = !!args.watch;
277
290
  const upload = !!args.upload;
291
+ const allAgents = !!args['all-agents'];
278
292
 
279
293
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
280
- if (!agentId) die('error: --agent-id required (e.g. agent_01ABC...)');
281
- if (!isValidAgentId(agentId)) {
294
+ if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
295
+ if (allAgents && !watch) die('error: --all-agents requires --watch (fleet daemon). For a one-shot, target a single --agent-id.');
296
+ if (agentId && !isValidAgentId(agentId)) {
282
297
  die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
283
298
  }
284
299
  const sessionIdArg = args['session-id'];
@@ -303,18 +318,30 @@ async function main() {
303
318
  uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
304
319
  }
305
320
 
306
- info(`resolving agent ${agentId}…`);
307
- const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
308
- const model = resolveModel(agent);
309
- const displayName = agent.name || agentId;
310
- info(`model: ${model || '(unknown)'}`);
321
+ // Resolve the agent list: the whole fleet (--all-agents) or a single agent.
322
+ let agents;
323
+ if (allAgents) {
324
+ info('discovering agents (fleet mode)…');
325
+ const all = await listAgents(apiKey).catch((e) => die(`failed to list agents: ${e.message}`));
326
+ agents = all
327
+ .filter((a) => a.id && isValidAgentId(a.id))
328
+ .map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
329
+ if (agents.length === 0) die('error: no agents found under this API key');
330
+ info(`fleet: ${agents.length} agent(s) — ${agents.map((a) => a.displayName).join(', ')}`);
331
+ } else {
332
+ info(`resolving agent ${agentId}…`);
333
+ const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
334
+ agents = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
335
+ info(`model: ${agents[0].model || '(unknown)'}`);
336
+ }
311
337
 
312
338
  if (watch) {
313
339
  const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
314
- await runWatch({ apiKey, agentId, model, displayName, logDir, intervalMs, uploadCtx });
340
+ await runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx });
315
341
  } else {
316
342
  const since = args.since ? parseSince(args.since) : null;
317
- await fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
343
+ const a = agents[0];
344
+ await fetchOneShot({ apiKey, agentId: a.agentId, model: a.model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
318
345
  }
319
346
  }
320
347
 
@@ -94,11 +94,15 @@ function launcherPath(label) { return join(CONFIG_DIR, `${label}.launcher.sh`);
94
94
 
95
95
  function writeLauncher(label, scriptPath, args) {
96
96
  const argLine = args.map(sh).join(' ');
97
+ // Load secrets with a read-loop, NOT '. file' / source. Sourcing would
98
+ // shell-evaluate each value, so a secret like FOO=https://x/$(cmd) would
99
+ // execute cmd at launch. A read-loop assigns the value literally — the
100
+ // content is never re-scanned for command substitution.
97
101
  const body = `#!/bin/sh
98
- # Generated by wma-service. Loads secrets then runs the WMA daemon.
99
- set -a
100
- . ${sh(ENV_FILE)}
101
- set +a
102
+ # Generated by wma-service. Loads secrets WITHOUT shell-evaluating their values.
103
+ while IFS='=' read -r __k __v; do
104
+ [ -n "$__k" ] && export "$__k=$__v"
105
+ done < ${sh(ENV_FILE)}
102
106
  exec ${sh(NODE)} ${sh(scriptPath)} ${argLine}
103
107
  `;
104
108
  const p = launcherPath(label);
@@ -147,15 +151,35 @@ function launchctl(args, { ignoreError = false } = {}) {
147
151
  }
148
152
  }
149
153
 
154
+ // Synchronous sleep (installer CLI — blocking is fine). Used to let launchd's
155
+ // asynchronous bootout finish before we bootstrap again.
156
+ function syncSleep(ms) {
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
158
+ }
159
+ function macLoaded(label) {
160
+ try { execFileSync('launchctl', ['print', `gui/${UID}/${label}`], { stdio: 'pipe' }); return true; }
161
+ catch { return false; }
162
+ }
163
+
150
164
  function macLoad(label, plist) {
151
165
  const domain = `gui/${UID}`;
152
- launchctl(['bootout', `${domain}/${label}`], { ignoreError: true }); // unload prior
153
- const ok = launchctl(['bootstrap', domain, plist]);
166
+ // bootout is async: on reinstall, bootstrapping again before the old instance
167
+ // is gone races and silently fails (symptom: reinstall = dead services).
168
+ // Wait for the prior instance to disappear, then retry bootstrap.
169
+ if (macLoaded(label)) {
170
+ launchctl(['bootout', `${domain}/${label}`], { ignoreError: true });
171
+ for (let i = 0; i < 20 && macLoaded(label); i++) syncSleep(150);
172
+ }
173
+ let ok = false;
174
+ for (let attempt = 0; attempt < 5 && !ok; attempt++) {
175
+ ok = launchctl(['bootstrap', domain, plist], { ignoreError: attempt < 4 });
176
+ if (!ok) syncSleep(250);
177
+ }
154
178
  launchctl(['enable', `${domain}/${label}`], { ignoreError: true });
155
179
  if (ok) info(`loaded ${label} (launchd) — running now + at every login`);
156
180
  else {
157
181
  warn(`could not auto-load ${label}. Load it manually:`);
158
- process.stdout.write(` launchctl bootstrap gui/${UID} ${plist}\n`);
182
+ process.stdout.write(` launchctl bootout gui/${UID}/${label} 2>/dev/null; launchctl bootstrap gui/${UID} ${plist}\n`);
159
183
  }
160
184
  }
161
185
 
@@ -227,9 +251,10 @@ function linuxUninstallOne(label) {
227
251
 
228
252
  // ── Commands ────────────────────────────────────────────────────────────--
229
253
  function cmdInstall(args) {
254
+ const allAgents = !!args['all-agents'];
230
255
  const agentId = args['agent-id'];
231
- if (!agentId) die('--agent-id required (e.g. agent_01ABC...)');
232
- if (!isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
256
+ if (!allAgents && !agentId) die('--agent-id required (or --all-agents to cover the whole fleet)');
257
+ if (agentId && !isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
233
258
  const interval = args.interval || '5m';
234
259
  if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
235
260
  const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
@@ -238,14 +263,15 @@ function cmdInstall(args) {
238
263
  if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
239
264
  die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
240
265
  ' Run the daemon manually or wrap it in your own process manager:\n' +
241
- ` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
266
+ ` wma-fetch ${allAgents ? '--all-agents' : `--agent-id ${agentId}`} --watch --upload --interval ${interval}`);
242
267
  }
243
268
 
244
269
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
245
270
  writeEnvFile();
246
271
 
247
- const watchArgs = ['--agent-id', agentId, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
248
- const shieldArgs = ['--agent-id', agentId, '--policies-source', 'fortress', '--log-dir', logDir];
272
+ const target = allAgents ? ['--all-agents'] : ['--agent-id', agentId];
273
+ const watchArgs = [...target, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
274
+ const shieldArgs = [...target, '--policies-source', 'fortress', '--log-dir', logDir];
249
275
 
250
276
  if (PLATFORM === 'darwin') {
251
277
  macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);