@phnx-labs/agents-cli 1.20.51 → 1.20.53

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 (142) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +215 -7
  5. package/dist/commands/cloud.d.ts +3 -0
  6. package/dist/commands/cloud.js +8 -1
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +82 -3
  10. package/dist/commands/factory.js +8 -0
  11. package/dist/commands/feed.d.ts +38 -0
  12. package/dist/commands/feed.js +274 -0
  13. package/dist/commands/logs.d.ts +5 -1
  14. package/dist/commands/logs.js +248 -3
  15. package/dist/commands/mcp.js +7 -0
  16. package/dist/commands/memory.d.ts +9 -0
  17. package/dist/commands/memory.js +164 -0
  18. package/dist/commands/message.d.ts +11 -6
  19. package/dist/commands/message.js +140 -5
  20. package/dist/commands/routines.js +12 -0
  21. package/dist/commands/secrets-migrate.d.ts +2 -1
  22. package/dist/commands/secrets-migrate.js +88 -13
  23. package/dist/commands/secrets.d.ts +22 -0
  24. package/dist/commands/secrets.js +176 -42
  25. package/dist/commands/sessions.js +10 -1
  26. package/dist/commands/teams.js +4 -0
  27. package/dist/commands/worktree.js +4 -2
  28. package/dist/index.js +21 -22
  29. package/dist/lib/agents.js +249 -17
  30. package/dist/lib/answer-router.d.ts +75 -0
  31. package/dist/lib/answer-router.js +149 -0
  32. package/dist/lib/ask-classifier.d.ts +71 -0
  33. package/dist/lib/ask-classifier.js +197 -0
  34. package/dist/lib/browser/login-detection.d.ts +94 -0
  35. package/dist/lib/browser/login-detection.js +274 -0
  36. package/dist/lib/browser/profiles.d.ts +17 -8
  37. package/dist/lib/browser/profiles.js +27 -8
  38. package/dist/lib/browser/secret-ref.d.ts +10 -0
  39. package/dist/lib/browser/secret-ref.js +14 -0
  40. package/dist/lib/browser/service.js +14 -12
  41. package/dist/lib/cloud/antigravity.d.ts +0 -2
  42. package/dist/lib/cloud/antigravity.js +2 -17
  43. package/dist/lib/cloud/codex.js +3 -18
  44. package/dist/lib/cloud/rush.d.ts +15 -0
  45. package/dist/lib/cloud/rush.js +10 -16
  46. package/dist/lib/cloud/stream.js +2 -0
  47. package/dist/lib/cloud/types.d.ts +21 -0
  48. package/dist/lib/cloud/types.js +81 -0
  49. package/dist/lib/crabbox/cli.d.ts +1 -1
  50. package/dist/lib/crabbox/cli.js +12 -2
  51. package/dist/lib/crabbox/lease.d.ts +19 -0
  52. package/dist/lib/crabbox/lease.js +22 -11
  53. package/dist/lib/crabbox/progress.d.ts +62 -0
  54. package/dist/lib/crabbox/progress.js +129 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  56. package/dist/lib/crabbox/runtimes.js +98 -5
  57. package/dist/lib/daemon.d.ts +12 -9
  58. package/dist/lib/daemon.js +32 -17
  59. package/dist/lib/events.d.ts +31 -5
  60. package/dist/lib/events.js +291 -101
  61. package/dist/lib/exec.js +20 -1
  62. package/dist/lib/feed-outcome.d.ts +101 -0
  63. package/dist/lib/feed-outcome.js +244 -0
  64. package/dist/lib/feed-policy.d.ts +30 -0
  65. package/dist/lib/feed-policy.js +133 -0
  66. package/dist/lib/feed.d.ts +180 -0
  67. package/dist/lib/feed.js +627 -0
  68. package/dist/lib/git.d.ts +17 -1
  69. package/dist/lib/git.js +20 -1
  70. package/dist/lib/hooks.js +529 -14
  71. package/dist/lib/hosts/passthrough.d.ts +3 -3
  72. package/dist/lib/hosts/passthrough.js +3 -3
  73. package/dist/lib/mailbox-gc.d.ts +22 -0
  74. package/dist/lib/mailbox-gc.js +161 -0
  75. package/dist/lib/mailbox.d.ts +26 -2
  76. package/dist/lib/mailbox.js +80 -5
  77. package/dist/lib/mcp.js +82 -0
  78. package/dist/lib/memory.d.ts +55 -0
  79. package/dist/lib/memory.js +274 -0
  80. package/dist/lib/notify.d.ts +16 -0
  81. package/dist/lib/notify.js +61 -0
  82. package/dist/lib/operator.d.ts +26 -0
  83. package/dist/lib/operator.js +107 -0
  84. package/dist/lib/plugins.d.ts +35 -0
  85. package/dist/lib/plugins.js +217 -0
  86. package/dist/lib/remote-agents-json.d.ts +14 -0
  87. package/dist/lib/remote-agents-json.js +94 -0
  88. package/dist/lib/resources/mcp.js +44 -0
  89. package/dist/lib/resources/memory.d.ts +15 -0
  90. package/dist/lib/resources/memory.js +46 -0
  91. package/dist/lib/resources/types.d.ts +2 -2
  92. package/dist/lib/rotate.js +2 -0
  93. package/dist/lib/runner.d.ts +43 -0
  94. package/dist/lib/runner.js +323 -74
  95. package/dist/lib/sandbox.js +6 -0
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.d.ts +21 -0
  99. package/dist/lib/secrets/agent.js +63 -1
  100. package/dist/lib/secrets/bundles.d.ts +33 -1
  101. package/dist/lib/secrets/bundles.js +76 -21
  102. package/dist/lib/secrets/icloud-import.d.ts +79 -0
  103. package/dist/lib/secrets/icloud-import.js +203 -0
  104. package/dist/lib/secrets/index.d.ts +142 -2
  105. package/dist/lib/secrets/index.js +695 -30
  106. package/dist/lib/secrets/remote.js +1 -1
  107. package/dist/lib/secrets/sync.js +1 -1
  108. package/dist/lib/session/active.d.ts +18 -0
  109. package/dist/lib/session/active.js +47 -17
  110. package/dist/lib/session/db.d.ts +9 -1
  111. package/dist/lib/session/db.js +18 -3
  112. package/dist/lib/session/discover.d.ts +13 -0
  113. package/dist/lib/session/discover.js +32 -2
  114. package/dist/lib/session/parse.d.ts +8 -0
  115. package/dist/lib/session/parse.js +42 -21
  116. package/dist/lib/session/remote-active.js +8 -89
  117. package/dist/lib/session/state.d.ts +11 -0
  118. package/dist/lib/session/state.js +50 -1
  119. package/dist/lib/session/tail.d.ts +23 -4
  120. package/dist/lib/session/tail.js +34 -16
  121. package/dist/lib/session/throughput.d.ts +30 -0
  122. package/dist/lib/session/throughput.js +86 -0
  123. package/dist/lib/shim-heal.d.ts +12 -3
  124. package/dist/lib/shim-heal.js +12 -6
  125. package/dist/lib/staleness/detectors/subagents.js +57 -3
  126. package/dist/lib/staleness/writers/hooks.js +7 -3
  127. package/dist/lib/staleness/writers/subagents.js +37 -6
  128. package/dist/lib/startup/command-registry.d.ts +2 -0
  129. package/dist/lib/startup/command-registry.js +4 -0
  130. package/dist/lib/state.d.ts +2 -0
  131. package/dist/lib/state.js +25 -8
  132. package/dist/lib/subagents.d.ts +52 -0
  133. package/dist/lib/subagents.js +315 -12
  134. package/dist/lib/teams/agents.js +6 -3
  135. package/dist/lib/teams/worktree.d.ts +8 -0
  136. package/dist/lib/teams/worktree.js +8 -0
  137. package/dist/lib/types.d.ts +20 -2
  138. package/dist/lib/versions.js +38 -48
  139. package/dist/lib/whats-new.d.ts +5 -3
  140. package/dist/lib/whats-new.js +25 -5
  141. package/package.json +4 -3
  142. package/scripts/postinstall.js +61 -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,29 @@ 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
+ // AGENTS_EVENTS_PATH redirects the sink a test seam like AGENTS_SECRETS_AGENT_DIR:
26
+ // unlike _resetForTest it survives a bare reset AND propagates to CLI subprocesses
27
+ // a test spawns, so fixture events can never land in the user's real log (#910).
28
+ let _eventsPath;
29
+ function eventsPath() {
30
+ return (_eventsPath ??= process.env.AGENTS_EVENTS_PATH || path.join(getUserAgentsDir(), 'events.jsonl'));
31
+ }
32
+ function eventsDir() {
33
+ return path.dirname(eventsPath());
37
34
  }
38
35
  /** Default retention period in days. */
39
36
  const DEFAULT_RETENTION_DAYS = 7;
40
37
  /** Default max length for truncated strings. */
41
38
  const DEFAULT_TRUNCATE_LENGTH = 500;
39
+ /** Gzip rotation threshold in bytes (10 MB). */
40
+ const GZIP_ROTATION_BYTES = 10 * 1024 * 1024;
42
41
  /** Environment variable to disable event logging. */
43
42
  const DISABLE_ENV_VAR = 'AGENTS_DISABLE_EVENT_LOG';
44
43
  /** Check if audit logging is disabled via environment variable. */
@@ -50,6 +49,26 @@ function isDisabled() {
50
49
  const DIR_MODE = 0o700;
51
50
  /** File permissions (owner read/write only). */
52
51
  const FILE_MODE = 0o600;
52
+ const AUDIT_EVENTS = new Set([
53
+ 'command.start', 'command.end',
54
+ 'secrets.get', 'secrets.set', 'secrets.delete', 'secrets.rename',
55
+ 'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
56
+ 'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
57
+ 'version.install', 'version.switch', 'version.remove',
58
+ 'skill.install', 'skill.remove',
59
+ 'mcp.add', 'mcp.remove', 'mcp.register',
60
+ 'rotation.resolved',
61
+ 'session.start', 'session.end',
62
+ ]);
63
+ export function levelFor(event) {
64
+ if (event === 'warn')
65
+ return 'warn';
66
+ if (event === 'debug')
67
+ return 'debug';
68
+ if (AUDIT_EVENTS.has(event))
69
+ return 'audit';
70
+ return 'info';
71
+ }
53
72
  // ─── Helpers ──────────────────────────────────────────────────────────────────
54
73
  function getTimezoneOffset() {
55
74
  const offset = new Date().getTimezoneOffset();
@@ -66,20 +85,14 @@ function getTimezoneName() {
66
85
  return 'Unknown';
67
86
  }
68
87
  }
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
88
  function ensureLogsDir() {
76
- if (!fs.existsSync(logsDir())) {
77
- fs.mkdirSync(logsDir(), { recursive: true, mode: DIR_MODE });
89
+ if (!fs.existsSync(eventsDir())) {
90
+ fs.mkdirSync(eventsDir(), { recursive: true, mode: DIR_MODE });
78
91
  }
79
92
  else {
80
93
  // Ensure permissions are correct on existing dir
81
94
  try {
82
- fs.chmodSync(logsDir(), DIR_MODE);
95
+ fs.chmodSync(eventsDir(), DIR_MODE);
83
96
  }
84
97
  catch {
85
98
  // May fail if not owner
@@ -101,6 +114,16 @@ export function redactPrompt(prompt) {
101
114
  }
102
115
  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
116
  const SECRET_PATH = /\/(secrets|credentials|\.env|user\.yaml)\b/i;
117
+ const SENSITIVE_ARG_NAME = /password|secret|token|key|api[-_]?key|auth/i;
118
+ const SENSITIVE_PAYLOAD_KEY = /password|secret|token|api[-_]?key|auth/i;
119
+ const RESERVED_META_KEYS = new Set([
120
+ 'ts', 'tz', 'tzName', 'hostname', 'platform', 'arch', 'pid', 'ppid',
121
+ 'event', 'level', 'caller', 'session', 'osUser', 'transport', 'sshClientIp',
122
+ ]);
123
+ function promptMarker(value) {
124
+ const { prompt_length, prompt_sha256 } = redactPrompt(value);
125
+ return `[REDACTED prompt length=${prompt_length} sha256=${prompt_sha256}]`;
126
+ }
104
127
  /**
105
128
  * Mask argv entries that look like tokens or secret paths. Preserves structure
106
129
  * for debugging but drops the sensitive substring.
@@ -108,13 +131,59 @@ const SECRET_PATH = /\/(secrets|credentials|\.env|user\.yaml)\b/i;
108
131
  export function redactArgs(args) {
109
132
  if (!args)
110
133
  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
- });
134
+ const result = [];
135
+ let redactNext = false;
136
+ let promptNext = false;
137
+ for (const arg of args) {
138
+ if (redactNext) {
139
+ if (arg.startsWith('-')) {
140
+ redactNext = false;
141
+ }
142
+ else {
143
+ result.push('[REDACTED]');
144
+ redactNext = false;
145
+ continue;
146
+ }
147
+ }
148
+ if (promptNext) {
149
+ if (arg.startsWith('-')) {
150
+ promptNext = false;
151
+ }
152
+ else {
153
+ result.push(arg.length > 200 ? promptMarker(arg) :
154
+ TOKEN_LIKE.test(arg) || SECRET_PATH.test(arg) ? '[REDACTED]' : arg);
155
+ promptNext = false;
156
+ continue;
157
+ }
158
+ }
159
+ const equals = arg.indexOf('=');
160
+ const flag = equals >= 0 ? arg.slice(0, equals) : arg;
161
+ const value = equals >= 0 ? arg.slice(equals + 1) : undefined;
162
+ if (flag.startsWith('-') && SENSITIVE_ARG_NAME.test(flag)) {
163
+ result.push(value === undefined ? flag : `${flag}=[REDACTED]`);
164
+ redactNext = value === undefined;
165
+ continue;
166
+ }
167
+ if (flag === '--body' || flag === '--value') {
168
+ result.push(value === undefined ? flag : `${flag}=[REDACTED]`);
169
+ redactNext = value === undefined;
170
+ continue;
171
+ }
172
+ if (flag === '--prompt') {
173
+ if (value === undefined) {
174
+ result.push(flag);
175
+ promptNext = true;
176
+ }
177
+ else {
178
+ const safe = value.length > 200 ? promptMarker(value) :
179
+ TOKEN_LIKE.test(value) || SECRET_PATH.test(value) ? '[REDACTED]' : value;
180
+ result.push(`${flag}=${safe}`);
181
+ }
182
+ continue;
183
+ }
184
+ result.push(TOKEN_LIKE.test(arg) || SECRET_PATH.test(arg) ? '[REDACTED]' : arg);
185
+ }
186
+ return result;
118
187
  }
119
188
  // ─── Truncation ───────────────────────────────────────────────────────────────
120
189
  /**
@@ -131,22 +200,65 @@ export function truncate(str, maxLength = DEFAULT_TRUNCATE_LENGTH) {
131
200
  /**
132
201
  * Truncate all string values in a payload object.
133
202
  */
134
- function truncatePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
203
+ function sanitizeNested(value, key, maxLength) {
204
+ if (SENSITIVE_PAYLOAD_KEY.test(key))
205
+ return '[REDACTED]';
206
+ if (typeof value === 'string') {
207
+ if (TOKEN_LIKE.test(value) || SECRET_PATH.test(value))
208
+ return '[REDACTED]';
209
+ return truncate(value, maxLength);
210
+ }
211
+ if (Array.isArray(value)) {
212
+ return value.slice(0, 10).map((item) => sanitizeNested(item, '', maxLength));
213
+ }
214
+ if (value && typeof value === 'object') {
215
+ const result = {};
216
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
217
+ result[nestedKey] = sanitizeNested(nestedValue, nestedKey, maxLength);
218
+ }
219
+ return result;
220
+ }
221
+ return value;
222
+ }
223
+ function sanitizePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
135
224
  const result = {};
136
225
  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);
226
+ if (RESERVED_META_KEYS.has(key))
227
+ continue;
228
+ if (key === 'args' && Array.isArray(value)) {
229
+ result.args = redactArgs(value.filter((item) => typeof item === 'string'));
230
+ continue;
143
231
  }
144
- else {
145
- result[key] = value;
232
+ if (key.toLowerCase() === 'prompt' && typeof value === 'string') {
233
+ Object.assign(result, redactPrompt(value));
234
+ continue;
146
235
  }
236
+ result[key] = sanitizeNested(value, key, maxLength);
147
237
  }
148
238
  return result;
149
239
  }
240
+ const TERMINAL_CALLERS = {
241
+ cc: 'claude', cl: 'claude',
242
+ cx: 'codex',
243
+ gx: 'gemini', gm: 'gemini',
244
+ cr: 'cursor',
245
+ oc: 'opencode',
246
+ sh: 'shell',
247
+ ag: 'antigravity',
248
+ gk: 'grok',
249
+ };
250
+ /** Identify the environment that invoked agents-cli, not the source callsite. */
251
+ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.stdout.isTTY)) {
252
+ const session = env.AGENT_SESSION_ID?.slice(0, 8) || undefined;
253
+ if (env.CLAUDECODE === '1')
254
+ return { kind: 'claude-code', ...(session ? { session } : {}) };
255
+ const terminalId = env.AGENT_TERMINAL_ID;
256
+ if (terminalId) {
257
+ const prefix = terminalId.split('-')[0].toLowerCase();
258
+ return { kind: TERMINAL_CALLERS[prefix] ?? 'agent', ...(session ? { session } : {}) };
259
+ }
260
+ return { kind: stdoutIsTTY ? 'terminal' : 'script' };
261
+ }
150
262
  /**
151
263
  * Who is running this process and from where. Derived once per process from the
152
264
  * OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
@@ -173,7 +285,7 @@ function auditOrigin() {
173
285
  }
174
286
  // ─── Core API ─────────────────────────────────────────────────────────────────
175
287
  /**
176
- * Emit a structured event to the daily log file.
288
+ * Emit a structured event to the append-only audit log.
177
289
  *
178
290
  * @param event - The event type
179
291
  * @param payload - Event-specific data (agent, version, cwd, etc.)
@@ -183,7 +295,10 @@ export function emit(event, payload = {}) {
183
295
  return;
184
296
  try {
185
297
  ensureLogsDir();
298
+ const caller = detectCaller();
299
+ const safePayload = sanitizePayload(payload);
186
300
  const record = {
301
+ ...safePayload,
187
302
  ts: new Date().toISOString(),
188
303
  tz: getTimezoneOffset(),
189
304
  tzName: getTimezoneName(),
@@ -193,26 +308,28 @@ export function emit(event, payload = {}) {
193
308
  pid: process.pid,
194
309
  ppid: process.ppid,
195
310
  event,
311
+ level: levelFor(event),
312
+ caller: caller.kind,
313
+ ...(caller.session ? { session: caller.session } : {}),
196
314
  ...auditOrigin(),
197
- ...truncatePayload(payload),
198
315
  };
199
316
  const line = JSON.stringify(record) + '\n';
200
- const logPath = getLogFilePath();
317
+ const logPath = eventsPath();
201
318
  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
319
+ ensureLockTarget(logPath, '', DIR_MODE);
320
+ withFileLock(logPath, () => {
321
+ fs.appendFileSync(logPath, line, { mode: FILE_MODE });
322
+ if (isNew || logPath !== _chmoddedPath) {
323
+ _chmoddedPath = logPath;
324
+ try {
325
+ fs.chmodSync(logPath, FILE_MODE);
326
+ }
327
+ catch {
328
+ // May fail if not owner
329
+ }
214
330
  }
215
- }
331
+ maybeGzipRotateLocked(logPath);
332
+ });
216
333
  }
217
334
  catch {
218
335
  // Silent failure - logging should never break the CLI
@@ -401,29 +518,54 @@ export function emitError(err, payload = {}) {
401
518
  errorStack: truncate(error.stack, 1000),
402
519
  });
403
520
  }
521
+ // ─── Gzip rotation ──────────────────────────────────────────────────────────
522
+ /** Rotate the active file while its append lock is held. */
523
+ function maybeGzipRotateLocked(logPath) {
524
+ const stat = fs.statSync(logPath);
525
+ if (stat.size < GZIP_ROTATION_BYTES)
526
+ return;
527
+ const raw = fs.readFileSync(logPath);
528
+ const tmpArchive = path.join(eventsDir(), `.events.1.jsonl.gz.${process.pid}.tmp`);
529
+ fs.writeFileSync(tmpArchive, gzipSync(raw), { mode: FILE_MODE });
530
+ try {
531
+ const archives = fs.readdirSync(eventsDir())
532
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
533
+ .filter((entry) => entry.match !== null)
534
+ .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
535
+ .sort((a, b) => b.number - a.number);
536
+ for (const archive of archives) {
537
+ fs.renameSync(path.join(eventsDir(), archive.file), path.join(eventsDir(), `events.${archive.number + 1}.jsonl.gz`));
538
+ }
539
+ fs.renameSync(tmpArchive, path.join(eventsDir(), 'events.1.jsonl.gz'));
540
+ fs.truncateSync(logPath, 0);
541
+ }
542
+ catch (err) {
543
+ try {
544
+ fs.unlinkSync(tmpArchive);
545
+ }
546
+ catch { /* best-effort cleanup */ }
547
+ throw err;
548
+ }
549
+ }
404
550
  // ─── Rotation ─────────────────────────────────────────────────────────────────
405
551
  /**
406
552
  * Remove log files older than the retention period.
407
- * Called lazily on emit or explicitly via CLI.
553
+ * Removes numbered gzip archives whose filesystem mtime exceeds retention.
408
554
  *
409
555
  * @param retentionDays - Number of days to keep (default 7, from DEFAULT_RETENTION_DAYS)
410
556
  * @returns Number of files removed
411
557
  */
412
558
  export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
413
559
  try {
414
- if (!fs.existsSync(logsDir()))
560
+ if (!fs.existsSync(eventsDir()))
415
561
  return 0;
416
562
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
417
- const files = fs.readdirSync(logsDir()).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
563
+ const files = fs.readdirSync(eventsDir()).filter(f => /^events\.\d+\.jsonl\.gz$/.test(f));
418
564
  let removed = 0;
419
565
  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));
566
+ const filePath = path.join(eventsDir(), file);
567
+ if (fs.statSync(filePath).mtimeMs < cutoff) {
568
+ fs.unlinkSync(filePath);
427
569
  removed++;
428
570
  }
429
571
  }
@@ -453,42 +595,40 @@ export function maybeRotate() {
453
595
  * @returns Array of event records
454
596
  */
455
597
  export function query(options) {
456
- const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
598
+ const { startDate, endDate = new Date(), eventTypes, level, agent, caller, command, module, limit } = options;
457
599
  const results = [];
458
- if (!fs.existsSync(logsDir()))
600
+ if (!fs.existsSync(eventsDir()))
459
601
  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;
602
+ const files = [];
603
+ if (fs.existsSync(eventsPath()))
604
+ files.push({ path: eventsPath(), gzip: false });
605
+ const archives = fs.readdirSync(eventsDir())
606
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
607
+ .filter((entry) => entry.match !== null)
608
+ .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
609
+ .sort((a, b) => a.number - b.number);
610
+ for (const archive of archives) {
611
+ files.push({ path: path.join(eventsDir(), archive.file), gzip: true });
612
+ }
474
613
  const startMs = startDate?.getTime();
475
614
  const endMs = endDate?.getTime();
476
615
  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');
616
+ let content;
617
+ if (file.gzip) {
618
+ try {
619
+ content = gunzipSync(fs.readFileSync(file.path)).toString('utf-8');
620
+ }
621
+ catch {
622
+ continue;
623
+ }
624
+ }
625
+ else {
626
+ content = fs.readFileSync(file.path, 'utf-8');
627
+ }
487
628
  const lines = content.trim().split('\n').filter(Boolean);
488
629
  for (const line of lines.reverse()) {
489
630
  try {
490
631
  const record = JSON.parse(line);
491
- // Precise per-record window — the file skip above is day-granular only.
492
632
  const recMs = Date.parse(record.ts);
493
633
  if (startMs !== undefined && !isNaN(recMs) && recMs < startMs)
494
634
  continue;
@@ -496,11 +636,12 @@ export function query(options) {
496
636
  continue;
497
637
  if (eventTypes && !eventTypes.includes(record.event))
498
638
  continue;
639
+ if (level && (record.level ?? levelFor(record.event)) !== level)
640
+ continue;
499
641
  if (agent && record.agent !== agent)
500
642
  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.
643
+ if (caller && record.caller !== caller)
644
+ continue;
504
645
  if (command && record.command !== command &&
505
646
  !(typeof record.command === 'string' && record.command.startsWith(command + ' ')))
506
647
  continue;
@@ -543,7 +684,56 @@ export function getTimingStats(label, options = {}) {
543
684
  p95Ms: durations[Math.floor(durations.length * 0.95)],
544
685
  };
545
686
  }
687
+ export function stats(options = {}) {
688
+ const days = options.days ?? 7;
689
+ const startDate = new Date();
690
+ startDate.setDate(startDate.getDate() - days);
691
+ const records = query({ startDate, limit: 100_000 });
692
+ const byLevel = {};
693
+ const byEvent = {};
694
+ const byModule = {};
695
+ const byUser = {};
696
+ for (const r of records) {
697
+ const lvl = r.level ?? levelFor(r.event);
698
+ byLevel[lvl] = (byLevel[lvl] ?? 0) + 1;
699
+ byEvent[r.event] = (byEvent[r.event] ?? 0) + 1;
700
+ if (r.module)
701
+ byModule[r.module] = (byModule[r.module] ?? 0) + 1;
702
+ const user = `${r.osUser ?? '?'}@${r.hostname}`;
703
+ byUser[user] = (byUser[user] ?? 0) + 1;
704
+ }
705
+ let fileCount = 0;
706
+ let totalBytes = 0;
707
+ try {
708
+ if (fs.existsSync(eventsDir())) {
709
+ const files = fs.readdirSync(eventsDir()).filter(f => f === 'events.jsonl' || /^events\.\d+\.jsonl\.gz$/.test(f));
710
+ fileCount = files.length;
711
+ for (const f of files) {
712
+ try {
713
+ totalBytes += fs.statSync(path.join(eventsDir(), f)).size;
714
+ }
715
+ catch { /* skip */ }
716
+ }
717
+ }
718
+ }
719
+ catch { /* skip */ }
720
+ return {
721
+ totalEvents: records.length,
722
+ byLevel,
723
+ byEvent,
724
+ byModule,
725
+ byUser,
726
+ fileCount,
727
+ totalBytes,
728
+ };
729
+ }
546
730
  // ─── Exports ──────────────────────────────────────────────────────────────────
547
731
  export function getLogsPath() {
548
- return logsDir();
732
+ return eventsPath();
733
+ }
734
+ export function _resetForTest(overrideEventsPath) {
735
+ _eventsPath = overrideEventsPath;
736
+ _origin = undefined;
737
+ _chmoddedPath = undefined;
738
+ lastRotationCheck = 0;
549
739
  }
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.
@@ -397,7 +398,10 @@ export const AGENT_COMMANDS = {
397
398
  modelFlag: '--model',
398
399
  },
399
400
  kiro: {
400
- base: ['kiro-cli'],
401
+ // Standalone hooks live under ~/.kiro/hooks/*.json and only fire on the
402
+ // v3 engine (opt-in via --v3; see https://kiro.dev/docs/cli/v3/hooks/).
403
+ // Without this flag agents-cli would write v3 hook files that never run.
404
+ base: ['kiro-cli', '--v3'],
401
405
  promptFlag: 'positional',
402
406
  modeFlags: {
403
407
  // kiro-cli has no permission flags — edit is the default behavior.
@@ -470,6 +474,21 @@ export const AGENT_COMMANDS = {
470
474
  jsonFlags: ['-o', 'stream-json'],
471
475
  modelFlag: '-m',
472
476
  },
477
+ hermes: {
478
+ base: ['hermes', 'chat'],
479
+ promptFlag: 'positional',
480
+ modeFlags: {
481
+ edit: [],
482
+ },
483
+ modelFlag: '--model',
484
+ },
485
+ forge: {
486
+ base: ['forge'],
487
+ promptFlag: 'positional',
488
+ modeFlags: {
489
+ edit: [],
490
+ },
491
+ },
473
492
  };
474
493
  /**
475
494
  * Whether `agent` has a native resume form (Tier 1). Derived solely from the