@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.
- package/CHANGELOG.md +16 -0
- package/dist/commands/browser.js +215 -7
- package/dist/commands/cloud.js +6 -0
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +2 -3
- package/dist/commands/exec.js +17 -2
- package/dist/commands/factory.js +8 -0
- package/dist/commands/feed.d.ts +9 -0
- package/dist/commands/feed.js +69 -0
- package/dist/commands/logs.d.ts +5 -1
- package/dist/commands/logs.js +248 -3
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/secrets.d.ts +22 -0
- package/dist/commands/secrets.js +173 -42
- package/dist/commands/teams.js +4 -0
- package/dist/index.js +6 -2
- package/dist/lib/browser/login-detection.d.ts +94 -0
- package/dist/lib/browser/login-detection.js +274 -0
- package/dist/lib/browser/profiles.d.ts +17 -8
- package/dist/lib/browser/profiles.js +27 -8
- package/dist/lib/browser/secret-ref.d.ts +10 -0
- package/dist/lib/browser/secret-ref.js +14 -0
- package/dist/lib/browser/service.js +14 -12
- package/dist/lib/cloud/rush.d.ts +15 -0
- package/dist/lib/cloud/rush.js +7 -1
- package/dist/lib/crabbox/lease.d.ts +6 -0
- package/dist/lib/crabbox/lease.js +11 -9
- package/dist/lib/crabbox/runtimes.d.ts +38 -1
- package/dist/lib/crabbox/runtimes.js +98 -5
- package/dist/lib/daemon.d.ts +12 -9
- package/dist/lib/daemon.js +32 -17
- package/dist/lib/events.d.ts +31 -5
- package/dist/lib/events.js +288 -101
- package/dist/lib/exec.js +1 -0
- package/dist/lib/feed.d.ts +56 -0
- package/dist/lib/feed.js +251 -0
- package/dist/lib/hooks.js +7 -2
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/rotate.js +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +21 -0
- package/dist/lib/secrets/agent.js +63 -1
- package/dist/lib/secrets/bundles.d.ts +33 -1
- package/dist/lib/secrets/bundles.js +38 -8
- package/dist/lib/secrets/icloud-import.d.ts +70 -0
- package/dist/lib/secrets/icloud-import.js +173 -0
- package/dist/lib/secrets/index.d.ts +36 -0
- package/dist/lib/secrets/index.js +99 -9
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/secrets/sync.js +1 -1
- package/dist/lib/session/discover.js +1 -2
- package/dist/lib/session/state.js +13 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +25 -8
- package/dist/lib/teams/agents.js +6 -3
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/whats-new.d.ts +5 -3
- package/dist/lib/whats-new.js +25 -5
- package/package.json +1 -1
package/dist/lib/events.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized event logging for agents-cli.
|
|
3
3
|
*
|
|
4
|
-
* Structured JSONL
|
|
5
|
-
*
|
|
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 {
|
|
20
|
+
import { ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
21
|
+
import { getUserAgentsDir } from './state.js';
|
|
20
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
77
|
-
fs.mkdirSync(
|
|
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(
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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 (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
result
|
|
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
|
|
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 =
|
|
314
|
+
const logPath = eventsPath();
|
|
201
315
|
const isNew = !fs.existsSync(logPath);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
421
|
-
if (
|
|
422
|
-
|
|
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(
|
|
597
|
+
if (!fs.existsSync(eventsDir()))
|
|
459
598
|
return results;
|
|
460
|
-
const files =
|
|
461
|
-
|
|
462
|
-
.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
|
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
|
+
};
|