@phnx-labs/agents-cli 1.20.90 → 1.20.91

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 (65) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/feed.js +77 -4
  4. package/dist/commands/hooks.js +22 -6
  5. package/dist/commands/perf.d.ts +14 -0
  6. package/dist/commands/perf.js +221 -0
  7. package/dist/commands/routines.js +30 -24
  8. package/dist/commands/secrets.d.ts +43 -4
  9. package/dist/commands/secrets.js +217 -32
  10. package/dist/commands/send.d.ts +5 -1
  11. package/dist/commands/send.js +1 -1
  12. package/dist/commands/sessions-picker.js +70 -1
  13. package/dist/index.js +18 -3
  14. package/dist/lib/activity.d.ts +11 -1
  15. package/dist/lib/activity.js +1 -0
  16. package/dist/lib/catchup.d.ts +105 -0
  17. package/dist/lib/catchup.js +160 -0
  18. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  19. package/dist/lib/channels/providers/desktop.js +132 -0
  20. package/dist/lib/channels/providers/index.js +2 -0
  21. package/dist/lib/daemon.js +74 -13
  22. package/dist/lib/events.d.ts +12 -0
  23. package/dist/lib/events.js +122 -9
  24. package/dist/lib/exec.js +10 -0
  25. package/dist/lib/feed-broadcast.d.ts +47 -0
  26. package/dist/lib/feed-broadcast.js +65 -1
  27. package/dist/lib/feed-post.d.ts +10 -0
  28. package/dist/lib/feed-post.js +1 -1
  29. package/dist/lib/feed.d.ts +47 -1
  30. package/dist/lib/feed.js +38 -0
  31. package/dist/lib/hooks/cache.d.ts +2 -0
  32. package/dist/lib/hooks/cache.js +24 -4
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  34. package/dist/lib/overdue.d.ts +14 -0
  35. package/dist/lib/overdue.js +37 -1
  36. package/dist/lib/perf/db.d.ts +25 -0
  37. package/dist/lib/perf/db.js +290 -0
  38. package/dist/lib/perf/spool.d.ts +18 -0
  39. package/dist/lib/perf/spool.js +79 -0
  40. package/dist/lib/perf/types.d.ts +45 -0
  41. package/dist/lib/perf/types.js +2 -0
  42. package/dist/lib/routines-project.js +6 -0
  43. package/dist/lib/routines.d.ts +30 -1
  44. package/dist/lib/routines.js +11 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/secrets/list-filter.d.ts +94 -0
  48. package/dist/lib/secrets/list-filter.js +245 -0
  49. package/dist/lib/session/digest.d.ts +7 -0
  50. package/dist/lib/session/digest.js +29 -1
  51. package/dist/lib/session/discover.d.ts +1 -2
  52. package/dist/lib/session/discover.js +7 -24
  53. package/dist/lib/session/highlights.d.ts +82 -0
  54. package/dist/lib/session/highlights.js +251 -0
  55. package/dist/lib/session/parse.js +23 -1
  56. package/dist/lib/session/relative-time.d.ts +14 -0
  57. package/dist/lib/session/relative-time.js +36 -0
  58. package/dist/lib/session/render.d.ts +7 -0
  59. package/dist/lib/session/render.js +87 -17
  60. package/dist/lib/session/types.d.ts +4 -1
  61. package/dist/lib/startup/command-registry.d.ts +1 -0
  62. package/dist/lib/startup/command-registry.js +2 -0
  63. package/dist/lib/state.d.ts +9 -0
  64. package/dist/lib/state.js +11 -0
  65. package/package.json +3 -1
package/dist/lib/feed.js CHANGED
@@ -236,6 +236,44 @@ export function clearBlockLifecycle(blockId, root) {
236
236
  }
237
237
  }
238
238
  }
239
+ /**
240
+ * Build the block record for `agents feed post --blocked` — pure, so the shape is testable
241
+ * without touching the store or the broadcast layer.
242
+ *
243
+ * Class is derived, not asked for: a `--default` means the user could be absent
244
+ * and policy could still resolve it (approval); no default means only a human can
245
+ * choose (decision). `feed-policy.ts` reads exactly that distinction, so deriving
246
+ * it here keeps one rule in one place instead of letting a caller set a class that
247
+ * contradicts its own safeDefault.
248
+ *
249
+ * `costOfDelay: high` because a declared block is, by definition, an agent that
250
+ * has already stopped making progress — that is what makes it worth interrupting
251
+ * someone over, and what `feed --dispatch`'s urgency filter keys off.
252
+ */
253
+ export function buildDeclaredBlock(agent, input) {
254
+ const text = input.text.trim().replace(/\s+/g, ' ');
255
+ if (!text) {
256
+ throw new Error('Block text is empty. Usage: agents feed post "what you need from the user" --blocked');
257
+ }
258
+ const options = (input.options ?? [])
259
+ .map((label) => label.trim())
260
+ .filter(Boolean)
261
+ .map((label) => ({ label }));
262
+ return {
263
+ blockId: blockIdForSession(agent.sessionId),
264
+ sessionId: agent.sessionId,
265
+ mailboxId: agent.mailboxId,
266
+ host: agent.host,
267
+ runtime: agent.runtime,
268
+ ts: input.ts ?? new Date().toISOString(),
269
+ kind: 'declared',
270
+ questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
271
+ blockClass: input.safeDefault ? 'approval' : 'decision',
272
+ costOfDelay: 'high',
273
+ ...(input.safeDefault ? { safeDefault: input.safeDefault } : {}),
274
+ ...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
275
+ };
276
+ }
239
277
  /** Atomic write a block record to the feed store. Clears stale lifecycle state. */
240
278
  export function publishBlock(block, root) {
241
279
  const dir = root ?? getFeedDir();
@@ -26,6 +26,8 @@ export interface HookShimPaths {
26
26
  shimsDir?: string;
27
27
  cacheDir?: string;
28
28
  logsDir?: string;
29
+ /** Directory for the disposable perf warehouse + spool (default ~/.agents/.cache/perf). */
30
+ perfDir?: string;
29
31
  }
30
32
  /**
31
33
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import * as fs from 'fs';
20
20
  import * as path from 'path';
21
- import { getHookCacheDir, getHookShimsDir, getLogsDir } from '../state.js';
21
+ import { getHookCacheDir, getHookShimsDir, getLogsDir, getPerfDir } from '../state.js';
22
22
  /**
23
23
  * Parse a `cache:` value from hooks.yaml into the canonical config form.
24
24
  * Accepts the shorthand string ("5m", "30s-bg") or the full object form.
@@ -116,8 +116,9 @@ export function generateHookShim(args) {
116
116
  const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
117
117
  const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
118
118
  const logsDir = args.paths?.logsDir ?? getLogsDir();
119
+ const perfDir = args.paths?.perfDir ?? getPerfDir();
119
120
  const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
120
- const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir });
121
+ const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir, perfDir });
121
122
  fs.mkdirSync(shimsDir, { recursive: true });
122
123
  let existing = null;
123
124
  if (fs.existsSync(shimPath)) {
@@ -303,6 +304,12 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
303
304
  LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
304
305
  printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
305
306
  "$TS" "$HOOK_NAME" "$MS" "none" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
307
+ # Disposable perf spool → drained into ~/.agents/.cache/perf/perf.db on next agents perf/open.
308
+ mkdir -p "$PERF_DIR" 2>/dev/null || true
309
+ TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
310
+ HOST=$(hostname 2>/dev/null || echo unknown)
311
+ printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
312
+ "$TS_MS" "$HOOK_NAME" "$MS" "none" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
306
313
 
307
314
  exit "$EXIT"`;
308
315
  /**
@@ -322,7 +329,8 @@ function renderShim(name, scriptPath, cache, matches, paths) {
322
329
  const ttl = cache ? (typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0)) : 0;
323
330
  const key = cache?.key ?? 'global';
324
331
  const prefetch = cache?.prefetch ?? 'none';
325
- const { cacheDir, logsDir } = paths;
332
+ const { cacheDir, logsDir, perfDir } = paths;
333
+ const perfSpool = path.join(perfDir, 'spool.jsonl');
326
334
  const hasMatches = matches != null && Object.keys(matches).length > 0;
327
335
  const matchesJson = hasMatches ? JSON.stringify(matches) : '';
328
336
  // sh-escape: wrap in single quotes, escape any embedded single quotes.
@@ -342,12 +350,14 @@ HOOK_NAME=${q(name)}
342
350
  SOURCE=${q(scriptPath)}
343
351
  CACHE_DIR=${q(cacheDir)}
344
352
  LOGS_DIR=${q(logsDir)}
353
+ PERF_DIR=${q(perfDir)}
354
+ PERF_SPOOL=${q(perfSpool)}
345
355
  TTL=${ttl}
346
356
  PREFETCH=${q(prefetch)}
347
357
  KEY_MODE=${q(key)}
348
358
  MATCHES_JSON=${q(matchesJson)}
349
359
 
350
- mkdir -p "$CACHE_DIR" "$LOGS_DIR"
360
+ mkdir -p "$CACHE_DIR" "$LOGS_DIR" "$PERF_DIR"
351
361
 
352
362
  # Resolve a real Python. On Windows, bare python3 is often a Microsoft Store
353
363
  # app-execution alias stub that prints to stderr and exits non-zero (0 bytes on
@@ -380,6 +390,11 @@ if [ -n "$MATCHES_JSON" ]; then
380
390
  _LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
381
391
  printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":0,"cache":"skip","exit":0}\\n' \\
382
392
  "$_TS" "$HOOK_NAME" >>"$_LOG_FILE" 2>/dev/null || true
393
+ mkdir -p "$PERF_DIR" 2>/dev/null || true
394
+ _TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
395
+ _HOST=$(hostname 2>/dev/null || echo unknown)
396
+ printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":0,"cache":"skip","exit_code":0,"hostname":"%s"}\\n' \\
397
+ "$_TS_MS" "$HOOK_NAME" "$_HOST" >>"$PERF_SPOOL" 2>/dev/null || true
383
398
  exit 0
384
399
  fi
385
400
  fi
@@ -477,6 +492,11 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
477
492
  LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
478
493
  printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
479
494
  "$TS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
495
+ # Disposable perf spool → drained into perf.db (see lib/perf/db.ts). Soft keys only.
496
+ TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
497
+ HOST=$(hostname 2>/dev/null || echo unknown)
498
+ printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
499
+ "$TS_MS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
480
500
 
481
501
  exit "$EXIT"
482
502
  `;
@@ -11,6 +11,7 @@
11
11
  * Surfaced two ways: a desktop notification on daemon startup, and a
12
12
  * `agents routines catchup` command that runs them on demand.
13
13
  */
14
+ import { type JobConfig } from './routines.js';
14
15
  export interface OverdueJob {
15
16
  name: string;
16
17
  /** Most recent expected fire time per the cron expression. */
@@ -18,6 +19,19 @@ export interface OverdueJob {
18
19
  /** Start time of the most recent recorded run, or null if never run. */
19
20
  lastRanAt: Date | null;
20
21
  }
22
+ /**
23
+ * When a routine started existing, and therefore the earliest fire it can
24
+ * sensibly be judged against.
25
+ *
26
+ * `createdAt` is stamped by `writeJob`. Routines written before that field
27
+ * existed have none, so the file's own mtime stands in — it is the closest
28
+ * honest answer available on disk, and it only ever moves the floor later,
29
+ * never earlier, so it cannot manufacture a false "overdue".
30
+ *
31
+ * Returns null when neither is available, which leaves the routine unfloored
32
+ * (previous behaviour) rather than silently skipping it.
33
+ */
34
+ export declare function routineEffectiveStart(job: JobConfig): Date | null;
21
35
  /** Return every enabled, recurring job whose most recent expected fire was
22
36
  * missed. One-shot jobs are excluded — they fire at most once. */
23
37
  export declare function detectOverdueJobs(now?: Date): OverdueJob[];
@@ -11,8 +11,9 @@
11
11
  * Surfaced two ways: a desktop notification on daemon startup, and a
12
12
  * `agents routines catchup` command that runs them on demand.
13
13
  */
14
+ import * as fs from 'fs';
14
15
  import { Cron } from 'croner';
15
- import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
16
+ import { listJobs, getLatestRun, getJobPath, jobRunsOnThisDevice } from './routines.js';
16
17
  import { notifyDesktop } from './menubar/notify-desktop.js';
17
18
  // Tolerance between "expected fire" and "recorded run start" — accounts for
18
19
  // the small gap between the cron tick and when the runner writes meta.json.
@@ -36,6 +37,34 @@ function previousExpectedFire(cron, now) {
36
37
  }
37
38
  return last;
38
39
  }
40
+ /**
41
+ * When a routine started existing, and therefore the earliest fire it can
42
+ * sensibly be judged against.
43
+ *
44
+ * `createdAt` is stamped by `writeJob`. Routines written before that field
45
+ * existed have none, so the file's own mtime stands in — it is the closest
46
+ * honest answer available on disk, and it only ever moves the floor later,
47
+ * never earlier, so it cannot manufacture a false "overdue".
48
+ *
49
+ * Returns null when neither is available, which leaves the routine unfloored
50
+ * (previous behaviour) rather than silently skipping it.
51
+ */
52
+ export function routineEffectiveStart(job) {
53
+ if (job.createdAt) {
54
+ const stamped = new Date(job.createdAt);
55
+ if (!isNaN(stamped.getTime()))
56
+ return stamped;
57
+ }
58
+ const path = getJobPath(job.name);
59
+ if (!path)
60
+ return null;
61
+ try {
62
+ return new Date(fs.statSync(path).mtimeMs);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
39
68
  /** Return every enabled, recurring job whose most recent expected fire was
40
69
  * missed. One-shot jobs are excluded — they fire at most once. */
41
70
  export function detectOverdueJobs(now = new Date()) {
@@ -66,6 +95,13 @@ export function detectOverdueJobs(now = new Date()) {
66
95
  }
67
96
  if (!expected)
68
97
  continue;
98
+ // A fire that predates the routine never could have happened, so it is not
99
+ // a miss. Without this, any newly created routine on a daily/weekly cron is
100
+ // instantly "overdue" for the previous occurrence — and with auto-catchup
101
+ // that means `agents routines add` runs the routine once, immediately.
102
+ const start = routineEffectiveStart(job);
103
+ if (start && expected.getTime() < start.getTime())
104
+ continue;
69
105
  const latest = getLatestRun(job.name);
70
106
  const lastRanAt = latest ? new Date(latest.startedAt) : null;
71
107
  const isOverdue = !lastRanAt || lastRanAt.getTime() < expected.getTime() - GRACE_MS;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
3
+ *
4
+ * Opened only by `agents perf` / `hooks profile` (read path). Writers use
5
+ * {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
6
+ */
7
+ import Database from '../sqlite.js';
8
+ import type { AggregateOptions, PerfAggregateRow } from './types.js';
9
+ export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
10
+ export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
11
+ export declare const PERF_SCHEMA_VERSION = 1;
12
+ export declare const DEFAULT_RETENTION_DAYS = 30;
13
+ /** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
14
+ export declare function _resetPerfDbForTest(overridePath?: string | null): void;
15
+ /** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
16
+ export declare function drainSpool(db?: Database.Database): number;
17
+ /** Percentile of a sorted-ascending array. p in [0,100]. */
18
+ export declare function percentile(sorted: number[], p: number): number;
19
+ /**
20
+ * Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
21
+ */
22
+ export declare function aggregateSamples(opts?: AggregateOptions): PerfAggregateRow[];
23
+ export declare function perfDbPath(): string;
24
+ export declare function perfSpoolPath(): string;
25
+ export declare function ensurePerfDir(): string;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
3
+ *
4
+ * Opened only by `agents perf` / `hooks profile` (read path). Writers use
5
+ * {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import Database from '../sqlite.js';
10
+ import { getPerfDbPath, getPerfDir } from '../state.js';
11
+ import { localMachineId } from '../session/origin-machine.js';
12
+ import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
13
+ export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
14
+ export const PERF_SCHEMA_VERSION = 1;
15
+ export const DEFAULT_RETENTION_DAYS = 30;
16
+ const SCHEMA = `
17
+ CREATE TABLE IF NOT EXISTS samples (
18
+ id INTEGER PRIMARY KEY,
19
+ ts_ms INTEGER NOT NULL,
20
+ kind TEXT NOT NULL,
21
+ label TEXT NOT NULL,
22
+ duration_ms REAL NOT NULL,
23
+ session_id TEXT,
24
+ session_short TEXT,
25
+ agent TEXT,
26
+ agent_version TEXT,
27
+ machine TEXT,
28
+ hostname TEXT,
29
+ actor TEXT,
30
+ cwd TEXT,
31
+ cache TEXT,
32
+ exit_code INTEGER,
33
+ status TEXT,
34
+ meta_json TEXT
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_perf_ts ON samples(ts_ms);
37
+ CREATE INDEX IF NOT EXISTS idx_perf_kind_ts ON samples(kind, ts_ms);
38
+ CREATE INDEX IF NOT EXISTS idx_perf_label_ts ON samples(label, ts_ms);
39
+ CREATE INDEX IF NOT EXISTS idx_perf_machine_ts ON samples(machine, ts_ms);
40
+ CREATE INDEX IF NOT EXISTS idx_perf_session ON samples(session_id);
41
+ CREATE TABLE IF NOT EXISTS meta (
42
+ key TEXT PRIMARY KEY,
43
+ value TEXT NOT NULL
44
+ );
45
+ `;
46
+ let _db = null;
47
+ let _dbPath = null;
48
+ let _disabled = false;
49
+ /** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
50
+ export function _resetPerfDbForTest(overridePath) {
51
+ if (_db) {
52
+ try {
53
+ _db.close();
54
+ }
55
+ catch { /* ignore */ }
56
+ }
57
+ _db = null;
58
+ _dbPath = overridePath === undefined ? null : overridePath;
59
+ _disabled = false;
60
+ if (overridePath) {
61
+ _resetPerfSpoolForTest(path.join(path.dirname(overridePath), 'spool.jsonl'));
62
+ }
63
+ else if (overridePath === null) {
64
+ _resetPerfSpoolForTest(null);
65
+ }
66
+ }
67
+ function resolveDbPath() {
68
+ return process.env.AGENTS_PERF_DB || _dbPath || getPerfDbPath();
69
+ }
70
+ function isDisabled() {
71
+ if (_disabled)
72
+ return true;
73
+ const v = process.env.AGENTS_DISABLE_PERF;
74
+ return v === '1' || v === 'true';
75
+ }
76
+ function openDb() {
77
+ if (isDisabled())
78
+ return null;
79
+ const dbPath = resolveDbPath();
80
+ if (_db && _dbPath === dbPath)
81
+ return _db;
82
+ if (_db) {
83
+ try {
84
+ _db.close();
85
+ }
86
+ catch { /* ignore */ }
87
+ _db = null;
88
+ }
89
+ try {
90
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
91
+ const db = new Database(dbPath);
92
+ db.pragma('journal_mode = WAL');
93
+ db.pragma('synchronous = NORMAL');
94
+ db.pragma('busy_timeout = 2000');
95
+ db.exec(SCHEMA);
96
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
97
+ if (!row) {
98
+ db.prepare(`INSERT INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(PERF_SCHEMA_VERSION));
99
+ }
100
+ _db = db;
101
+ _dbPath = dbPath;
102
+ drainSpool(db);
103
+ maybeRetain(db);
104
+ return db;
105
+ }
106
+ catch {
107
+ _disabled = true;
108
+ return null;
109
+ }
110
+ }
111
+ /** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
112
+ export function drainSpool(db) {
113
+ const spool = resolveSpoolPath();
114
+ if (!fs.existsSync(spool))
115
+ return 0;
116
+ let raw;
117
+ try {
118
+ raw = fs.readFileSync(spool, 'utf-8');
119
+ }
120
+ catch {
121
+ return 0;
122
+ }
123
+ if (!raw.trim()) {
124
+ try {
125
+ fs.writeFileSync(spool, '');
126
+ }
127
+ catch { /* ignore */ }
128
+ return 0;
129
+ }
130
+ const target = db ?? openDb();
131
+ if (!target)
132
+ return 0;
133
+ let n = 0;
134
+ const insert = target.prepare(`
135
+ INSERT INTO samples (
136
+ ts_ms, kind, label, duration_ms,
137
+ session_id, session_short, agent, agent_version,
138
+ machine, hostname, actor, cwd,
139
+ cache, exit_code, status, meta_json
140
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
141
+ `);
142
+ const txn = target.transaction((lines) => {
143
+ for (const line of lines) {
144
+ if (!line.trim())
145
+ continue;
146
+ let o;
147
+ try {
148
+ o = JSON.parse(line);
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ const label = String(o.label ?? o.hook ?? '');
154
+ const durationMs = Number(o.duration_ms ?? o.durationMs ?? o.ms);
155
+ if (!label || !Number.isFinite(durationMs))
156
+ continue;
157
+ const sessionId = o.session_id != null ? String(o.session_id)
158
+ : o.sessionId != null ? String(o.sessionId) : null;
159
+ const tsMs = Number(o.ts_ms ?? o.tsMs) || (typeof o.ts === 'string' ? Date.parse(o.ts) : Date.now());
160
+ insert.run(Number.isFinite(tsMs) ? tsMs : Date.now(), String(o.kind ?? 'hook.fire'), label, durationMs, sessionId, o.session_short != null ? String(o.session_short) : shortSessionId(sessionId) ?? null, o.agent != null ? String(o.agent) : null, o.agent_version != null ? String(o.agent_version) : o.agentVersion != null ? String(o.agentVersion) : null, o.machine != null ? String(o.machine) : localMachineId(), o.hostname != null ? String(o.hostname) : null, o.actor != null ? String(o.actor) : null, o.cwd != null ? String(o.cwd) : null, o.cache != null ? String(o.cache) : null, o.exit_code != null ? Number(o.exit_code) : o.exit != null ? Number(o.exit) : null, o.status != null ? String(o.status) : null, o.meta_json != null ? String(o.meta_json) : null);
161
+ n++;
162
+ }
163
+ });
164
+ try {
165
+ txn(raw.split('\n'));
166
+ fs.writeFileSync(spool, '');
167
+ }
168
+ catch {
169
+ return 0;
170
+ }
171
+ return n;
172
+ }
173
+ function maybeRetain(db) {
174
+ try {
175
+ const last = db.prepare(`SELECT value FROM meta WHERE key = 'last_retain_ms'`).get();
176
+ const lastMs = last ? parseInt(last.value, 10) : 0;
177
+ const now = Date.now();
178
+ if (now - lastMs < 3_600_000)
179
+ return;
180
+ const cutoff = now - DEFAULT_RETENTION_DAYS * 86_400_000;
181
+ db.prepare(`DELETE FROM samples WHERE ts_ms < ?`).run(cutoff);
182
+ db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('last_retain_ms', ?)`).run(String(now));
183
+ }
184
+ catch {
185
+ // ignore
186
+ }
187
+ }
188
+ /** Percentile of a sorted-ascending array. p in [0,100]. */
189
+ export function percentile(sorted, p) {
190
+ if (sorted.length === 0)
191
+ return 0;
192
+ if (sorted.length === 1)
193
+ return sorted[0];
194
+ const rank = (p / 100) * (sorted.length - 1);
195
+ const lo = Math.floor(rank);
196
+ const hi = Math.ceil(rank);
197
+ if (lo === hi)
198
+ return sorted[lo];
199
+ const frac = rank - lo;
200
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
201
+ }
202
+ /**
203
+ * Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
204
+ */
205
+ export function aggregateSamples(opts = {}) {
206
+ const db = openDb();
207
+ if (!db)
208
+ return [];
209
+ drainSpool(db);
210
+ const days = opts.days ?? 7;
211
+ const sinceMs = Date.now() - days * 86_400_000;
212
+ const minN = opts.minN ?? 1;
213
+ const clauses = ['ts_ms >= ?'];
214
+ const params = [sinceMs];
215
+ if (opts.kinds && opts.kinds.length > 0) {
216
+ clauses.push(`kind IN (${opts.kinds.map(() => '?').join(',')})`);
217
+ params.push(...opts.kinds);
218
+ }
219
+ if (opts.label) {
220
+ clauses.push('label = ?');
221
+ params.push(opts.label);
222
+ }
223
+ if (opts.machine) {
224
+ clauses.push('machine = ?');
225
+ params.push(opts.machine);
226
+ }
227
+ if (opts.agent) {
228
+ clauses.push('agent = ?');
229
+ params.push(opts.agent);
230
+ }
231
+ const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code
232
+ FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
233
+ const map = new Map();
234
+ for (const r of rows) {
235
+ const key = `${r.kind}\0${r.label}`;
236
+ let b = map.get(key);
237
+ if (!b) {
238
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0 };
239
+ map.set(key, b);
240
+ }
241
+ b.durations.push(Number(r.duration_ms));
242
+ if (r.cache === 'hit')
243
+ b.hits++;
244
+ else if (r.cache === 'stale-prefetch')
245
+ b.stale++;
246
+ else if (r.cache === 'miss' || r.cache === 'none')
247
+ b.misses++;
248
+ if (typeof r.exit_code === 'number' && r.exit_code !== 0)
249
+ b.errors++;
250
+ }
251
+ const out = [];
252
+ for (const b of map.values()) {
253
+ if (b.durations.length < minN)
254
+ continue;
255
+ const sorted = b.durations.slice().sort((a, c) => a - c);
256
+ const n = sorted.length;
257
+ const sum = sorted.reduce((a, c) => a + c, 0);
258
+ const row = {
259
+ kind: b.kind,
260
+ label: b.label,
261
+ n,
262
+ p50Ms: Math.round(percentile(sorted, 50)),
263
+ p99Ms: Math.round(percentile(sorted, 99)),
264
+ meanMs: Math.round(sum / n),
265
+ maxMs: sorted[n - 1],
266
+ minMs: sorted[0],
267
+ };
268
+ if (b.hits + b.stale + b.misses > 0) {
269
+ row.cacheHitPct = Math.round((b.hits / n) * 100);
270
+ row.cacheStalePct = Math.round((b.stale / n) * 100);
271
+ row.cacheMissPct = Math.round((b.misses / n) * 100);
272
+ }
273
+ if (b.errors > 0)
274
+ row.errorCount = b.errors;
275
+ out.push(row);
276
+ }
277
+ out.sort((a, b) => b.p99Ms - a.p99Ms);
278
+ return out;
279
+ }
280
+ export function perfDbPath() {
281
+ return resolveDbPath();
282
+ }
283
+ export function perfSpoolPath() {
284
+ return resolveSpoolPath();
285
+ }
286
+ export function ensurePerfDir() {
287
+ const dir = process.env.AGENTS_PERF_DIR || (_dbPath ? path.dirname(_dbPath) : getPerfDir());
288
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
289
+ return dir;
290
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hot-path perf writers — append-only NDJSON spool, no SQLite.
3
+ *
4
+ * Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
5
+ * Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
6
+ * (which emits ExperimentalWarning on stderr).
7
+ */
8
+ import type { PerfSample } from './types.js';
9
+ export type { PerfSample } from './types.js';
10
+ /** Test seam — pair with db._resetPerfDbForTest. */
11
+ export declare function _resetPerfSpoolForTest(spoolPath?: string | null): void;
12
+ export declare function resolveSpoolPath(): string;
13
+ /** Short session id: first 8 chars (sessions.short_id shape). */
14
+ export declare function shortSessionId(sessionId: string | undefined | null): string | undefined;
15
+ /**
16
+ * Append one sample to the spool. Never throws. Never opens SQLite.
17
+ */
18
+ export declare function recordSample(sample: PerfSample): void;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Hot-path perf writers — append-only NDJSON spool, no SQLite.
3
+ *
4
+ * Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
5
+ * Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
6
+ * (which emits ExperimentalWarning on stderr).
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ import { getPerfSpoolPath } from '../state.js';
12
+ import { localMachineId } from '../session/origin-machine.js';
13
+ let _spoolOverride = null;
14
+ let _disabled = false;
15
+ /** Test seam — pair with db._resetPerfDbForTest. */
16
+ export function _resetPerfSpoolForTest(spoolPath) {
17
+ _spoolOverride = spoolPath === undefined ? null : spoolPath;
18
+ _disabled = false;
19
+ }
20
+ export function resolveSpoolPath() {
21
+ if (process.env.AGENTS_PERF_SPOOL)
22
+ return process.env.AGENTS_PERF_SPOOL;
23
+ if (_spoolOverride)
24
+ return _spoolOverride;
25
+ return getPerfSpoolPath();
26
+ }
27
+ function isDisabled() {
28
+ if (_disabled)
29
+ return true;
30
+ const v = process.env.AGENTS_DISABLE_PERF;
31
+ return v === '1' || v === 'true';
32
+ }
33
+ /** Short session id: first 8 chars (sessions.short_id shape). */
34
+ export function shortSessionId(sessionId) {
35
+ if (!sessionId)
36
+ return undefined;
37
+ const cleaned = sessionId.replace(/^session_/, '');
38
+ return cleaned.length >= 8 ? cleaned.slice(0, 8) : cleaned || undefined;
39
+ }
40
+ /**
41
+ * Append one sample to the spool. Never throws. Never opens SQLite.
42
+ */
43
+ export function recordSample(sample) {
44
+ if (isDisabled())
45
+ return;
46
+ if (!sample.label || !Number.isFinite(sample.durationMs))
47
+ return;
48
+ try {
49
+ const tsMs = sample.tsMs ?? Date.now();
50
+ const sessionId = sample.sessionId;
51
+ const sessionShort = sample.sessionShort ?? shortSessionId(sessionId);
52
+ const machine = sample.machine ?? localMachineId();
53
+ const hostname = sample.hostname ?? os.hostname();
54
+ const line = JSON.stringify({
55
+ ts_ms: tsMs,
56
+ kind: sample.kind,
57
+ label: sample.label,
58
+ duration_ms: sample.durationMs,
59
+ session_id: sessionId,
60
+ session_short: sessionShort,
61
+ agent: sample.agent,
62
+ agent_version: sample.agentVersion,
63
+ machine,
64
+ hostname,
65
+ actor: sample.actor,
66
+ cwd: sample.cwd,
67
+ cache: sample.cache,
68
+ exit_code: sample.exitCode,
69
+ status: sample.status,
70
+ meta_json: sample.metaJson,
71
+ });
72
+ const spool = resolveSpoolPath();
73
+ fs.mkdirSync(path.dirname(spool), { recursive: true, mode: 0o700 });
74
+ fs.appendFileSync(spool, line + '\n', { mode: 0o600 });
75
+ }
76
+ catch {
77
+ // Fail soft.
78
+ }
79
+ }