@phnx-labs/agents-cli 1.20.51 → 1.20.52

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 (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/commands/browser.js +215 -7
  3. package/dist/commands/cloud.js +6 -0
  4. package/dist/commands/events.d.ts +1 -1
  5. package/dist/commands/events.js +2 -3
  6. package/dist/commands/exec.js +17 -2
  7. package/dist/commands/factory.js +8 -0
  8. package/dist/commands/feed.d.ts +9 -0
  9. package/dist/commands/feed.js +69 -0
  10. package/dist/commands/logs.d.ts +5 -1
  11. package/dist/commands/logs.js +248 -3
  12. package/dist/commands/mcp.js +7 -0
  13. package/dist/commands/secrets.d.ts +22 -0
  14. package/dist/commands/secrets.js +173 -42
  15. package/dist/commands/teams.js +4 -0
  16. package/dist/index.js +6 -2
  17. package/dist/lib/browser/login-detection.d.ts +94 -0
  18. package/dist/lib/browser/login-detection.js +274 -0
  19. package/dist/lib/browser/profiles.d.ts +17 -8
  20. package/dist/lib/browser/profiles.js +27 -8
  21. package/dist/lib/browser/secret-ref.d.ts +10 -0
  22. package/dist/lib/browser/secret-ref.js +14 -0
  23. package/dist/lib/browser/service.js +14 -12
  24. package/dist/lib/cloud/rush.d.ts +15 -0
  25. package/dist/lib/cloud/rush.js +7 -1
  26. package/dist/lib/crabbox/lease.d.ts +6 -0
  27. package/dist/lib/crabbox/lease.js +11 -9
  28. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  29. package/dist/lib/crabbox/runtimes.js +98 -5
  30. package/dist/lib/daemon.d.ts +12 -9
  31. package/dist/lib/daemon.js +32 -17
  32. package/dist/lib/events.d.ts +31 -5
  33. package/dist/lib/events.js +288 -101
  34. package/dist/lib/exec.js +1 -0
  35. package/dist/lib/feed.d.ts +56 -0
  36. package/dist/lib/feed.js +251 -0
  37. package/dist/lib/hooks.js +7 -2
  38. package/dist/lib/hosts/passthrough.js +1 -0
  39. package/dist/lib/rotate.js +2 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/agent.d.ts +21 -0
  43. package/dist/lib/secrets/agent.js +63 -1
  44. package/dist/lib/secrets/bundles.d.ts +33 -1
  45. package/dist/lib/secrets/bundles.js +38 -8
  46. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  47. package/dist/lib/secrets/icloud-import.js +173 -0
  48. package/dist/lib/secrets/index.d.ts +36 -0
  49. package/dist/lib/secrets/index.js +99 -9
  50. package/dist/lib/secrets/remote.js +1 -1
  51. package/dist/lib/secrets/sync.js +1 -1
  52. package/dist/lib/session/discover.js +1 -2
  53. package/dist/lib/session/state.js +13 -1
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +2 -0
  56. package/dist/lib/state.d.ts +2 -0
  57. package/dist/lib/state.js +25 -8
  58. package/dist/lib/teams/agents.js +6 -3
  59. package/dist/lib/types.d.ts +10 -0
  60. package/dist/lib/whats-new.d.ts +5 -3
  61. package/dist/lib/whats-new.js +25 -5
  62. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Centralized event logging for agents-cli.
3
3
  *
4
- * Structured JSONL logs at ~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl
5
- * with automatic daily rotation and rich metadata for debugging/auditing.
4
+ * Structured JSONL audit log at ~/.agents/events.jsonl with lossless numbered
5
+ * gzip rotation at 10 MB and rich metadata for debugging/auditing.
6
6
  *
7
7
  * Features:
8
8
  * - Rich metadata: hostname, platform, arch, pid, timezone
@@ -15,30 +15,26 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
+ import { gzipSync, gunzipSync } from 'node:zlib';
18
19
  import { parseSshConnection } from './session/provenance.js';
19
- import { getLogsDir } from './state.js';
20
+ import { ensureLockTarget, withFileLock } from './fs-atomic.js';
21
+ import { getUserAgentsDir } from './state.js';
20
22
  // ─── Constants ────────────────────────────────────────────────────────────────
21
- // Logs live under the cache bucket they're regenerable telemetry. Route
22
- // through state's canonical home anchor (HOME override os.homedir()) rather
23
- // than a bare os.homedir(): on Windows os.homedir() reads USERPROFILE and
24
- // ignores a HOME override, so a test (or any caller) that redirects HOME would
25
- // have its events silently written to the real profile instead. state.getLogsDir()
26
- // honors the HOME override on every platform while falling back to os.homedir()
27
- // (== USERPROFILE on Windows) in production where HOME is unset.
28
- //
29
- // Resolved lazily + memoized: importing this module must NOT call getLogsDir()
30
- // at eval time. events.ts is pulled in transitively (skills/versions/exec/
31
- // runner), and several tests mock './state.js' with partial factories that omit
32
- // getLogsDir — an eager call would crash those on import. Deferring to first use
33
- // keeps a bare import side-effect-free while staying a one-time resolution.
34
- let _logsDir;
35
- function logsDir() {
36
- return (_logsDir ??= getLogsDir());
23
+ // Resolved lazily: events.ts is imported transitively by most CLI surfaces, and
24
+ // import itself must stay side-effect free. Tests may override the exact path.
25
+ let _eventsPath;
26
+ function eventsPath() {
27
+ return (_eventsPath ??= path.join(getUserAgentsDir(), 'events.jsonl'));
28
+ }
29
+ function eventsDir() {
30
+ return path.dirname(eventsPath());
37
31
  }
38
32
  /** Default retention period in days. */
39
33
  const DEFAULT_RETENTION_DAYS = 7;
40
34
  /** Default max length for truncated strings. */
41
35
  const DEFAULT_TRUNCATE_LENGTH = 500;
36
+ /** Gzip rotation threshold in bytes (10 MB). */
37
+ const GZIP_ROTATION_BYTES = 10 * 1024 * 1024;
42
38
  /** Environment variable to disable event logging. */
43
39
  const DISABLE_ENV_VAR = 'AGENTS_DISABLE_EVENT_LOG';
44
40
  /** Check if audit logging is disabled via environment variable. */
@@ -50,6 +46,26 @@ function isDisabled() {
50
46
  const DIR_MODE = 0o700;
51
47
  /** File permissions (owner read/write only). */
52
48
  const FILE_MODE = 0o600;
49
+ const AUDIT_EVENTS = new Set([
50
+ 'command.start', 'command.end',
51
+ 'secrets.get', 'secrets.set', 'secrets.delete', 'secrets.rename',
52
+ 'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
53
+ 'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
54
+ 'version.install', 'version.switch', 'version.remove',
55
+ 'skill.install', 'skill.remove',
56
+ 'mcp.add', 'mcp.remove', 'mcp.register',
57
+ 'rotation.resolved',
58
+ 'session.start', 'session.end',
59
+ ]);
60
+ export function levelFor(event) {
61
+ if (event === 'warn')
62
+ return 'warn';
63
+ if (event === 'debug')
64
+ return 'debug';
65
+ if (AUDIT_EVENTS.has(event))
66
+ return 'audit';
67
+ return 'info';
68
+ }
53
69
  // ─── Helpers ──────────────────────────────────────────────────────────────────
54
70
  function getTimezoneOffset() {
55
71
  const offset = new Date().getTimezoneOffset();
@@ -66,20 +82,14 @@ function getTimezoneName() {
66
82
  return 'Unknown';
67
83
  }
68
84
  }
69
- function getLogFilePath(date = new Date()) {
70
- const yyyy = date.getFullYear();
71
- const mm = String(date.getMonth() + 1).padStart(2, '0');
72
- const dd = String(date.getDate()).padStart(2, '0');
73
- return path.join(logsDir(), `events-${yyyy}-${mm}-${dd}.jsonl`);
74
- }
75
85
  function ensureLogsDir() {
76
- if (!fs.existsSync(logsDir())) {
77
- fs.mkdirSync(logsDir(), { recursive: true, mode: DIR_MODE });
86
+ if (!fs.existsSync(eventsDir())) {
87
+ fs.mkdirSync(eventsDir(), { recursive: true, mode: DIR_MODE });
78
88
  }
79
89
  else {
80
90
  // Ensure permissions are correct on existing dir
81
91
  try {
82
- fs.chmodSync(logsDir(), DIR_MODE);
92
+ fs.chmodSync(eventsDir(), DIR_MODE);
83
93
  }
84
94
  catch {
85
95
  // May fail if not owner
@@ -101,6 +111,16 @@ export function redactPrompt(prompt) {
101
111
  }
102
112
  const TOKEN_LIKE = /(sk_(?:live|test)_|pk_(?:live|test)_|ghp_|gho_|ghu_|ghs_|xox[bpars]-|AKIA|ASIA|AIza|Bearer\s+|eyJ[A-Za-z0-9_-]+\.)/i;
103
113
  const SECRET_PATH = /\/(secrets|credentials|\.env|user\.yaml)\b/i;
114
+ const SENSITIVE_ARG_NAME = /password|secret|token|key|api[-_]?key|auth/i;
115
+ const SENSITIVE_PAYLOAD_KEY = /password|secret|token|api[-_]?key|auth/i;
116
+ const RESERVED_META_KEYS = new Set([
117
+ 'ts', 'tz', 'tzName', 'hostname', 'platform', 'arch', 'pid', 'ppid',
118
+ 'event', 'level', 'caller', 'session', 'osUser', 'transport', 'sshClientIp',
119
+ ]);
120
+ function promptMarker(value) {
121
+ const { prompt_length, prompt_sha256 } = redactPrompt(value);
122
+ return `[REDACTED prompt length=${prompt_length} sha256=${prompt_sha256}]`;
123
+ }
104
124
  /**
105
125
  * Mask argv entries that look like tokens or secret paths. Preserves structure
106
126
  * for debugging but drops the sensitive substring.
@@ -108,13 +128,59 @@ const SECRET_PATH = /\/(secrets|credentials|\.env|user\.yaml)\b/i;
108
128
  export function redactArgs(args) {
109
129
  if (!args)
110
130
  return undefined;
111
- return args.map(a => {
112
- if (typeof a !== 'string')
113
- return a;
114
- if (TOKEN_LIKE.test(a) || SECRET_PATH.test(a))
115
- return '[REDACTED]';
116
- return a;
117
- });
131
+ const result = [];
132
+ let redactNext = false;
133
+ let promptNext = false;
134
+ for (const arg of args) {
135
+ if (redactNext) {
136
+ if (arg.startsWith('-')) {
137
+ redactNext = false;
138
+ }
139
+ else {
140
+ result.push('[REDACTED]');
141
+ redactNext = false;
142
+ continue;
143
+ }
144
+ }
145
+ if (promptNext) {
146
+ if (arg.startsWith('-')) {
147
+ promptNext = false;
148
+ }
149
+ else {
150
+ result.push(arg.length > 200 ? promptMarker(arg) :
151
+ TOKEN_LIKE.test(arg) || SECRET_PATH.test(arg) ? '[REDACTED]' : arg);
152
+ promptNext = false;
153
+ continue;
154
+ }
155
+ }
156
+ const equals = arg.indexOf('=');
157
+ const flag = equals >= 0 ? arg.slice(0, equals) : arg;
158
+ const value = equals >= 0 ? arg.slice(equals + 1) : undefined;
159
+ if (flag.startsWith('-') && SENSITIVE_ARG_NAME.test(flag)) {
160
+ result.push(value === undefined ? flag : `${flag}=[REDACTED]`);
161
+ redactNext = value === undefined;
162
+ continue;
163
+ }
164
+ if (flag === '--body' || flag === '--value') {
165
+ result.push(value === undefined ? flag : `${flag}=[REDACTED]`);
166
+ redactNext = value === undefined;
167
+ continue;
168
+ }
169
+ if (flag === '--prompt') {
170
+ if (value === undefined) {
171
+ result.push(flag);
172
+ promptNext = true;
173
+ }
174
+ else {
175
+ const safe = value.length > 200 ? promptMarker(value) :
176
+ TOKEN_LIKE.test(value) || SECRET_PATH.test(value) ? '[REDACTED]' : value;
177
+ result.push(`${flag}=${safe}`);
178
+ }
179
+ continue;
180
+ }
181
+ result.push(TOKEN_LIKE.test(arg) || SECRET_PATH.test(arg) ? '[REDACTED]' : arg);
182
+ }
183
+ return result;
118
184
  }
119
185
  // ─── Truncation ───────────────────────────────────────────────────────────────
120
186
  /**
@@ -131,22 +197,65 @@ export function truncate(str, maxLength = DEFAULT_TRUNCATE_LENGTH) {
131
197
  /**
132
198
  * Truncate all string values in a payload object.
133
199
  */
134
- function truncatePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
200
+ function sanitizeNested(value, key, maxLength) {
201
+ if (SENSITIVE_PAYLOAD_KEY.test(key))
202
+ return '[REDACTED]';
203
+ if (typeof value === 'string') {
204
+ if (TOKEN_LIKE.test(value) || SECRET_PATH.test(value))
205
+ return '[REDACTED]';
206
+ return truncate(value, maxLength);
207
+ }
208
+ if (Array.isArray(value)) {
209
+ return value.slice(0, 10).map((item) => sanitizeNested(item, '', maxLength));
210
+ }
211
+ if (value && typeof value === 'object') {
212
+ const result = {};
213
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
214
+ result[nestedKey] = sanitizeNested(nestedValue, nestedKey, maxLength);
215
+ }
216
+ return result;
217
+ }
218
+ return value;
219
+ }
220
+ function sanitizePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
135
221
  const result = {};
136
222
  for (const [key, value] of Object.entries(payload)) {
137
- if (typeof value === 'string') {
138
- result[key] = truncate(value, maxLength);
139
- }
140
- else if (Array.isArray(value)) {
141
- // Truncate array to first 10 items, truncate each string item
142
- result[key] = value.slice(0, 10).map(v => typeof v === 'string' ? truncate(v, maxLength) : v);
223
+ if (RESERVED_META_KEYS.has(key))
224
+ continue;
225
+ if (key === 'args' && Array.isArray(value)) {
226
+ result.args = redactArgs(value.filter((item) => typeof item === 'string'));
227
+ continue;
143
228
  }
144
- else {
145
- result[key] = value;
229
+ if (key.toLowerCase() === 'prompt' && typeof value === 'string') {
230
+ Object.assign(result, redactPrompt(value));
231
+ continue;
146
232
  }
233
+ result[key] = sanitizeNested(value, key, maxLength);
147
234
  }
148
235
  return result;
149
236
  }
237
+ const TERMINAL_CALLERS = {
238
+ cc: 'claude', cl: 'claude',
239
+ cx: 'codex',
240
+ gx: 'gemini', gm: 'gemini',
241
+ cr: 'cursor',
242
+ oc: 'opencode',
243
+ sh: 'shell',
244
+ ag: 'antigravity',
245
+ gk: 'grok',
246
+ };
247
+ /** Identify the environment that invoked agents-cli, not the source callsite. */
248
+ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.stdout.isTTY)) {
249
+ const session = env.AGENT_SESSION_ID?.slice(0, 8) || undefined;
250
+ if (env.CLAUDECODE === '1')
251
+ return { kind: 'claude-code', ...(session ? { session } : {}) };
252
+ const terminalId = env.AGENT_TERMINAL_ID;
253
+ if (terminalId) {
254
+ const prefix = terminalId.split('-')[0].toLowerCase();
255
+ return { kind: TERMINAL_CALLERS[prefix] ?? 'agent', ...(session ? { session } : {}) };
256
+ }
257
+ return { kind: stdoutIsTTY ? 'terminal' : 'script' };
258
+ }
150
259
  /**
151
260
  * Who is running this process and from where. Derived once per process from the
152
261
  * OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
@@ -173,7 +282,7 @@ function auditOrigin() {
173
282
  }
174
283
  // ─── Core API ─────────────────────────────────────────────────────────────────
175
284
  /**
176
- * Emit a structured event to the daily log file.
285
+ * Emit a structured event to the append-only audit log.
177
286
  *
178
287
  * @param event - The event type
179
288
  * @param payload - Event-specific data (agent, version, cwd, etc.)
@@ -183,7 +292,10 @@ export function emit(event, payload = {}) {
183
292
  return;
184
293
  try {
185
294
  ensureLogsDir();
295
+ const caller = detectCaller();
296
+ const safePayload = sanitizePayload(payload);
186
297
  const record = {
298
+ ...safePayload,
187
299
  ts: new Date().toISOString(),
188
300
  tz: getTimezoneOffset(),
189
301
  tzName: getTimezoneName(),
@@ -193,26 +305,28 @@ export function emit(event, payload = {}) {
193
305
  pid: process.pid,
194
306
  ppid: process.ppid,
195
307
  event,
308
+ level: levelFor(event),
309
+ caller: caller.kind,
310
+ ...(caller.session ? { session: caller.session } : {}),
196
311
  ...auditOrigin(),
197
- ...truncatePayload(payload),
198
312
  };
199
313
  const line = JSON.stringify(record) + '\n';
200
- const logPath = getLogFilePath();
314
+ const logPath = eventsPath();
201
315
  const isNew = !fs.existsSync(logPath);
202
- fs.appendFileSync(logPath, line, { mode: FILE_MODE });
203
- // appendFileSync's mode only applies when it CREATES the file, so we chmod
204
- // to guarantee 0600 but only on first write to a given path this process.
205
- // command.start/end fire on every invocation; chmod-per-append would double
206
- // the syscalls on the hot path for no gain (perms don't drift mid-run).
207
- if (isNew || logPath !== _chmoddedPath) {
208
- _chmoddedPath = logPath;
209
- try {
210
- fs.chmodSync(logPath, FILE_MODE);
211
- }
212
- catch {
213
- // May fail if not owner
316
+ ensureLockTarget(logPath, '', DIR_MODE);
317
+ withFileLock(logPath, () => {
318
+ fs.appendFileSync(logPath, line, { mode: FILE_MODE });
319
+ if (isNew || logPath !== _chmoddedPath) {
320
+ _chmoddedPath = logPath;
321
+ try {
322
+ fs.chmodSync(logPath, FILE_MODE);
323
+ }
324
+ catch {
325
+ // May fail if not owner
326
+ }
214
327
  }
215
- }
328
+ maybeGzipRotateLocked(logPath);
329
+ });
216
330
  }
217
331
  catch {
218
332
  // Silent failure - logging should never break the CLI
@@ -401,29 +515,54 @@ export function emitError(err, payload = {}) {
401
515
  errorStack: truncate(error.stack, 1000),
402
516
  });
403
517
  }
518
+ // ─── Gzip rotation ──────────────────────────────────────────────────────────
519
+ /** Rotate the active file while its append lock is held. */
520
+ function maybeGzipRotateLocked(logPath) {
521
+ const stat = fs.statSync(logPath);
522
+ if (stat.size < GZIP_ROTATION_BYTES)
523
+ return;
524
+ const raw = fs.readFileSync(logPath);
525
+ const tmpArchive = path.join(eventsDir(), `.events.1.jsonl.gz.${process.pid}.tmp`);
526
+ fs.writeFileSync(tmpArchive, gzipSync(raw), { mode: FILE_MODE });
527
+ try {
528
+ const archives = fs.readdirSync(eventsDir())
529
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
530
+ .filter((entry) => entry.match !== null)
531
+ .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
532
+ .sort((a, b) => b.number - a.number);
533
+ for (const archive of archives) {
534
+ fs.renameSync(path.join(eventsDir(), archive.file), path.join(eventsDir(), `events.${archive.number + 1}.jsonl.gz`));
535
+ }
536
+ fs.renameSync(tmpArchive, path.join(eventsDir(), 'events.1.jsonl.gz'));
537
+ fs.truncateSync(logPath, 0);
538
+ }
539
+ catch (err) {
540
+ try {
541
+ fs.unlinkSync(tmpArchive);
542
+ }
543
+ catch { /* best-effort cleanup */ }
544
+ throw err;
545
+ }
546
+ }
404
547
  // ─── Rotation ─────────────────────────────────────────────────────────────────
405
548
  /**
406
549
  * Remove log files older than the retention period.
407
- * Called lazily on emit or explicitly via CLI.
550
+ * Removes numbered gzip archives whose filesystem mtime exceeds retention.
408
551
  *
409
552
  * @param retentionDays - Number of days to keep (default 7, from DEFAULT_RETENTION_DAYS)
410
553
  * @returns Number of files removed
411
554
  */
412
555
  export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
413
556
  try {
414
- if (!fs.existsSync(logsDir()))
557
+ if (!fs.existsSync(eventsDir()))
415
558
  return 0;
416
559
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
417
- const files = fs.readdirSync(logsDir()).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
560
+ const files = fs.readdirSync(eventsDir()).filter(f => /^events\.\d+\.jsonl\.gz$/.test(f));
418
561
  let removed = 0;
419
562
  for (const file of files) {
420
- const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
421
- if (!match)
422
- continue;
423
- const [, yyyy, mm, dd] = match;
424
- const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
425
- if (fileDate.getTime() < cutoff) {
426
- fs.unlinkSync(path.join(logsDir(), file));
563
+ const filePath = path.join(eventsDir(), file);
564
+ if (fs.statSync(filePath).mtimeMs < cutoff) {
565
+ fs.unlinkSync(filePath);
427
566
  removed++;
428
567
  }
429
568
  }
@@ -453,42 +592,40 @@ export function maybeRotate() {
453
592
  * @returns Array of event records
454
593
  */
455
594
  export function query(options) {
456
- const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
595
+ const { startDate, endDate = new Date(), eventTypes, level, agent, caller, command, module, limit } = options;
457
596
  const results = [];
458
- if (!fs.existsSync(logsDir()))
597
+ if (!fs.existsSync(eventsDir()))
459
598
  return results;
460
- const files = fs.readdirSync(logsDir())
461
- .filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
462
- .sort()
463
- .reverse();
464
- // Coarse file skip works on whole days, so floor the bounds to midnight —
465
- // otherwise a sub-day window (`--since 2h` at 15:00 → startDate 13:00) would
466
- // drop *today's* file, whose date stamps to 00:00. Precise filtering below is
467
- // per-record on `ts`.
468
- const startDay = startDate
469
- ? new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
470
- : undefined;
471
- const endDay = endDate
472
- ? new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
473
- : undefined;
599
+ const files = [];
600
+ if (fs.existsSync(eventsPath()))
601
+ files.push({ path: eventsPath(), gzip: false });
602
+ const archives = fs.readdirSync(eventsDir())
603
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
604
+ .filter((entry) => entry.match !== null)
605
+ .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
606
+ .sort((a, b) => a.number - b.number);
607
+ for (const archive of archives) {
608
+ files.push({ path: path.join(eventsDir(), archive.file), gzip: true });
609
+ }
474
610
  const startMs = startDate?.getTime();
475
611
  const endMs = endDate?.getTime();
476
612
  for (const file of files) {
477
- const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
478
- if (!match)
479
- continue;
480
- const [, yyyy, mm, dd] = match;
481
- const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
482
- if (startDay && fileDate < startDay)
483
- continue;
484
- if (endDay && fileDate > endDay)
485
- continue;
486
- const content = fs.readFileSync(path.join(logsDir(), file), 'utf-8');
613
+ let content;
614
+ if (file.gzip) {
615
+ try {
616
+ content = gunzipSync(fs.readFileSync(file.path)).toString('utf-8');
617
+ }
618
+ catch {
619
+ continue;
620
+ }
621
+ }
622
+ else {
623
+ content = fs.readFileSync(file.path, 'utf-8');
624
+ }
487
625
  const lines = content.trim().split('\n').filter(Boolean);
488
626
  for (const line of lines.reverse()) {
489
627
  try {
490
628
  const record = JSON.parse(line);
491
- // Precise per-record window — the file skip above is day-granular only.
492
629
  const recMs = Date.parse(record.ts);
493
630
  if (startMs !== undefined && !isNaN(recMs) && recMs < startMs)
494
631
  continue;
@@ -496,11 +633,12 @@ export function query(options) {
496
633
  continue;
497
634
  if (eventTypes && !eventTypes.includes(record.event))
498
635
  continue;
636
+ if (level && (record.level ?? levelFor(record.event)) !== level)
637
+ continue;
499
638
  if (agent && record.agent !== agent)
500
639
  continue;
501
- // `--command` matches by path prefix on a word boundary, so a coarse
502
- // "teams" catches "teams create"/"teams remove" while an exact
503
- // "teams create" stays exact.
640
+ if (caller && record.caller !== caller)
641
+ continue;
504
642
  if (command && record.command !== command &&
505
643
  !(typeof record.command === 'string' && record.command.startsWith(command + ' ')))
506
644
  continue;
@@ -543,7 +681,56 @@ export function getTimingStats(label, options = {}) {
543
681
  p95Ms: durations[Math.floor(durations.length * 0.95)],
544
682
  };
545
683
  }
684
+ export function stats(options = {}) {
685
+ const days = options.days ?? 7;
686
+ const startDate = new Date();
687
+ startDate.setDate(startDate.getDate() - days);
688
+ const records = query({ startDate, limit: 100_000 });
689
+ const byLevel = {};
690
+ const byEvent = {};
691
+ const byModule = {};
692
+ const byUser = {};
693
+ for (const r of records) {
694
+ const lvl = r.level ?? levelFor(r.event);
695
+ byLevel[lvl] = (byLevel[lvl] ?? 0) + 1;
696
+ byEvent[r.event] = (byEvent[r.event] ?? 0) + 1;
697
+ if (r.module)
698
+ byModule[r.module] = (byModule[r.module] ?? 0) + 1;
699
+ const user = `${r.osUser ?? '?'}@${r.hostname}`;
700
+ byUser[user] = (byUser[user] ?? 0) + 1;
701
+ }
702
+ let fileCount = 0;
703
+ let totalBytes = 0;
704
+ try {
705
+ if (fs.existsSync(eventsDir())) {
706
+ const files = fs.readdirSync(eventsDir()).filter(f => f === 'events.jsonl' || /^events\.\d+\.jsonl\.gz$/.test(f));
707
+ fileCount = files.length;
708
+ for (const f of files) {
709
+ try {
710
+ totalBytes += fs.statSync(path.join(eventsDir(), f)).size;
711
+ }
712
+ catch { /* skip */ }
713
+ }
714
+ }
715
+ }
716
+ catch { /* skip */ }
717
+ return {
718
+ totalEvents: records.length,
719
+ byLevel,
720
+ byEvent,
721
+ byModule,
722
+ byUser,
723
+ fileCount,
724
+ totalBytes,
725
+ };
726
+ }
546
727
  // ─── Exports ──────────────────────────────────────────────────────────────────
547
728
  export function getLogsPath() {
548
- return logsDir();
729
+ return eventsPath();
730
+ }
731
+ export function _resetForTest(overrideEventsPath) {
732
+ _eventsPath = overrideEventsPath;
733
+ _origin = undefined;
734
+ _chmoddedPath = undefined;
735
+ lastRotationCheck = 0;
549
736
  }
package/dist/lib/exec.js CHANGED
@@ -269,6 +269,7 @@ export function buildExecEnv(options) {
269
269
  if (options.sessionId && isValidMailboxId(options.sessionId)) {
270
270
  result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
271
271
  }
272
+ result.AGENTS_RUNTIME = resolveInteractive(options) ? 'terminal' : 'headless';
272
273
  // Export the run's durable name (companion to AGENT_SESSION_ID) so a
273
274
  // SessionStart hook / the agent can associate its transcript with the handle
274
275
  // the user gave the run. Only set when --name was passed.
@@ -0,0 +1,56 @@
1
+ export interface BlockOption {
2
+ label: string;
3
+ description?: string;
4
+ }
5
+ export interface BlockQuestion {
6
+ text: string;
7
+ header?: string;
8
+ options?: BlockOption[];
9
+ multiSelect?: boolean;
10
+ }
11
+ export interface OpenBlock {
12
+ blockId: string;
13
+ sessionId: string;
14
+ mailboxId: string;
15
+ host: string;
16
+ runtime: string;
17
+ ts: string;
18
+ questions: BlockQuestion[];
19
+ ticket?: string;
20
+ pr?: string;
21
+ }
22
+ /**
23
+ * Stable block id for a session. One block per session -- a new question
24
+ * replaces the previous one (the agent can only ask one question at a time).
25
+ */
26
+ export declare function blockIdForSession(sessionId: string): string;
27
+ /** Atomic write a block record to the feed store. */
28
+ export declare function publishBlock(block: OpenBlock, root?: string): void;
29
+ /** Read all block records. Returns them sorted by stable block filename. */
30
+ export declare function listBlocks(root?: string): OpenBlock[];
31
+ /** Remove a block record. Returns true if the file was deleted. */
32
+ export declare function removeBlock(blockId: string, root?: string): boolean;
33
+ /**
34
+ * The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
35
+ * Embedded so it ships with the compiled CLI and can be installed to the
36
+ * CLI-writable user hooks dir without a separate file in the npm tarball.
37
+ */
38
+ export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"PreToolUse hook: publish an open-block record when the agent calls\nAskUserQuestion, so `agents feed` can aggregate pending decisions.\n\nOutbound counterpart to the inbound mailbox-inject hook. Fires only on\nAskUserQuestion (matcher-gated in agents.yaml). Writes one block per session\nto ~/.agents/.history/feed/. A new question replaces the previous block.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"questions\": normalized_questions,\n }\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n os.makedirs(feed_dir, exist_ok=True)\n\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.rename(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
39
+ /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
40
+ export declare const FEED_PUBLISH_HOOK_MANIFEST: {
41
+ name: string;
42
+ events: string[];
43
+ matcher: string;
44
+ script: string;
45
+ timeout: number;
46
+ };
47
+ /**
48
+ * Install the feed-publish hook script into the user hooks dir and add its
49
+ * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
50
+ * read-only mirror, so runtime-managed hooks must never write there.
51
+ * Idempotent -- skips if the script is already present and up to date.
52
+ */
53
+ export declare function ensureFeedPublishHook(userAgentsDir?: string): {
54
+ installed: boolean;
55
+ error?: string;
56
+ };