@phnx-labs/agents-cli 1.21.0 → 1.21.2

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 (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. package/package.json +1 -1
@@ -964,6 +964,45 @@ export function getLatestCompletedRun(jobName) {
964
964
  }
965
965
  return null;
966
966
  }
967
+ /** Percentile of a sorted-ascending array (linear interpolation). p in [0,100]. */
968
+ function percentile(sorted, p) {
969
+ if (sorted.length === 0)
970
+ return 0;
971
+ if (sorted.length === 1)
972
+ return sorted[0];
973
+ const rank = (p / 100) * (sorted.length - 1);
974
+ const lo = Math.floor(rank);
975
+ const hi = Math.ceil(rank);
976
+ if (lo === hi)
977
+ return sorted[lo];
978
+ const frac = rank - lo;
979
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
980
+ }
981
+ /**
982
+ * Fold a job's run history (`listRuns`) into a duration + outcome summary.
983
+ * `missed` fires (no process ever ran) carry no `duration` and are excluded
984
+ * from the latency percentiles but still counted in `count`/`missed`.
985
+ */
986
+ export function routineStats(jobName) {
987
+ const runs = listRuns(jobName);
988
+ const failed = runs.filter((r) => r.status === 'failed' || r.status === 'timeout').length;
989
+ const missed = runs.filter((r) => r.status === 'missed').length;
990
+ const durations = runs
991
+ .map((r) => r.duration)
992
+ .filter((d) => typeof d === 'number' && Number.isFinite(d))
993
+ .sort((a, b) => a - b);
994
+ const avgMs = durations.length > 0
995
+ ? Math.round(durations.reduce((sum, d) => sum + d, 0) / durations.length)
996
+ : 0;
997
+ return {
998
+ count: runs.length,
999
+ failed,
1000
+ missed,
1001
+ avgMs,
1002
+ p50: Math.round(percentile(durations, 50)),
1003
+ p95: Math.round(percentile(durations, 95)),
1004
+ };
1005
+ }
967
1006
  /** Persist run metadata to its run directory as meta.json. */
968
1007
  export function writeRunMeta(meta) {
969
1008
  ensureAgentsDir();
@@ -46,10 +46,28 @@ const AGENT_COMMANDS = {
46
46
  /** Agents the daemon can actually run, derived from the command table above
47
47
  * so the `--agent` help and any validation can never drift from it. */
48
48
  export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(AGENT_COMMANDS));
49
+ /**
50
+ * Where each agent's transcript files live under an overlay HOME, mirroring
51
+ * `SESSION_ROOT_SPECS` (session/discover.ts) — the CLI's own source of truth
52
+ * for which on-disk trees hold live session files. Kept in this shape (not a
53
+ * shared import) because `archiveRoutineTranscripts` only needs a flat
54
+ * root+ext pair to `walkForFiles`, not the version-home/backup fan-out
55
+ * `getAgentSessionDirs` does for live discovery.
56
+ *
57
+ * `opencode` is deliberately absent: `SESSION_ROOT_SPECS` itself has no entry
58
+ * for it — its transcripts live in one incrementally-scanned SQLite db
59
+ * (`scanOpenCodeIncremental`), not a per-session file tree — so there is
60
+ * nothing here to mirror without inventing a new discovery path.
61
+ */
49
62
  const ROUTINE_TRANSCRIPT_SPECS = {
50
63
  claude: [{ root: ['.claude', 'projects'], ext: '.jsonl' }],
51
64
  codex: [{ root: ['.codex', 'sessions'], ext: '.jsonl' }],
52
65
  cursor: [{ root: ['.cursor', 'projects'], ext: '.jsonl' }],
66
+ gemini: [{ root: ['.gemini', 'tmp'], ext: '.json' }],
67
+ antigravity: [{ root: ['.gemini', 'antigravity-cli', 'conversations'], ext: '.db' }],
68
+ droid: [{ root: ['.factory', 'sessions'], ext: '.jsonl' }],
69
+ kimi: [{ root: ['.kimi-code', 'sessions'], ext: '.json' }],
70
+ grok: [{ root: ['.grok', 'sessions'], ext: '.json' }],
53
71
  };
54
72
  /** Stable working directory for routine children, independent of the daemon's launch cwd. */
55
73
  export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
@@ -1080,6 +1098,14 @@ export async function executeJobDetached(config, hooks) {
1080
1098
  // wait); the next schedule tick re-selects if this attempt still fails.
1081
1099
  const launch = await resolveRoutineLaunch(config);
1082
1100
  const version = launch.chain[0]?.version ?? config.version;
1101
+ const timer = createTimer('agent.run', {
1102
+ agent: config.agent,
1103
+ version,
1104
+ jobName: config.name,
1105
+ mode: config.mode,
1106
+ ...redactPrompt(config.prompt),
1107
+ schedule: config.schedule,
1108
+ });
1083
1109
  const resolvedPrompt = resolveJobPrompt(config);
1084
1110
  let cmd = buildJobCommand(config, resolvedPrompt);
1085
1111
  // workflow AND resume dispatch through `agents run` — never binary-pin them (pinning
@@ -1143,6 +1169,7 @@ export async function executeJobDetached(config, hooks) {
1143
1169
  finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1144
1170
  writeRunMeta(meta);
1145
1171
  archiveRoutineTranscripts(meta, runDir, overlayHome);
1172
+ timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
1146
1173
  return meta;
1147
1174
  }
1148
1175
  }
@@ -1164,6 +1191,7 @@ export async function executeJobDetached(config, hooks) {
1164
1191
  const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
1165
1192
  if (status !== 'timeout' && !isAuthFailure)
1166
1193
  extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
1194
+ timer.end({ status, exitCode: exitCode ?? undefined, runId, ...(errorMessage ? { error: errorMessage } : {}) });
1167
1195
  // Fire the finish/output notification AFTER the report is written so the hook
1168
1196
  // can read report.md (RUSH-2030). Best-effort; never breaks finalization.
1169
1197
  safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
@@ -1214,6 +1242,11 @@ export async function executeJobDetached(config, hooks) {
1214
1242
  * `monitorRunningJobs` reaps the record on the next tick.
1215
1243
  */
1216
1244
  function executeCommandJobDetached(config, hooks) {
1245
+ const timer = createTimer('agent.run', {
1246
+ jobName: config.name,
1247
+ mode: config.mode,
1248
+ schedule: config.schedule,
1249
+ });
1217
1250
  const runId = generateRunId();
1218
1251
  const runDir = getRunDir(config.name, runId);
1219
1252
  fs.mkdirSync(runDir, { recursive: true });
@@ -1259,6 +1292,7 @@ function executeCommandJobDetached(config, hooks) {
1259
1292
  settled = true;
1260
1293
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
1261
1294
  writeRunMeta(meta);
1295
+ timer.end({ status, exitCode, runId, ...(errorMessage ? { error: errorMessage } : {}) });
1262
1296
  // Finish notification (RUSH-2030). For command routines the threshold only
1263
1297
  // surfaces failures, decided in routine-notify.ts. Best-effort.
1264
1298
  safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
@@ -1,69 +1,34 @@
1
1
  /**
2
- * SQLite-backed usage read-model for `agents secrets`.
3
- *
4
- * A small local database at ~/.agents/secrets/secrets.db that records one
5
- * value-free row for every secret lifecycle/access event a bundle accrues over
6
- * its life — created, imported, exported, viewed, accessed (read for injection),
7
- * unlocked. It is the queryable, per-bundle counterpart to the append-only
8
- * ~/.agents/events.jsonl audit log: the SAME chokepoint (`emitSecretAudit`,
9
- * lib/secrets/audit.ts) feeds both, and this store answers "how often / how
10
- * recently / by whom was THIS bundle used?" without scanning the whole event
11
- * stream. It is a DERIVED index fed off the real access flow — the way
12
- * sessions.db indexes session metadata — never a second write path an operation
13
- * has to remember to call.
14
- *
15
- * Contract, mirroring the audit log: NEVER a secret value. Only metadata — the
16
- * bundle name, the event kind, key counts, the resolving agent/host, a status.
17
- *
18
- * Every write is best-effort: a failure here (missing runtime SQLite, a locked
19
- * db, a read-only fs) is swallowed so usage telemetry can never break secret
20
- * resolution. Set AGENTS_NO_USAGE_TRACK=1 to disable recording entirely (used by
21
- * tests and by callers that must stay perfectly silent).
2
+ * Secrets usage read-model thin adapter over the analytics usage warehouse
3
+ * (`kind=secret`). Kept so `secrets view` / `list` / `activity` keep their API
4
+ * while the durable store lives at ~/.agents/.history/analytics/usage.db.
22
5
  */
23
- /** The lifecycle/access events a bundle accrues over its life. */
24
6
  export type SecretUsageEvent = 'access' | 'unlock' | 'import' | 'export' | 'create' | 'view';
25
- /** All event kinds, in the order `view` prints them. */
26
7
  export declare const SECRET_USAGE_EVENTS: readonly SecretUsageEvent[];
27
8
  export interface RecordUsageParams {
28
- /** Bundle the event applies to (required — usage is always per-bundle). */
29
9
  bundle: string;
30
- /** What happened. */
31
10
  event: SecretUsageEvent;
32
- /** Resolving agent/harness identity, when known (`*` = a global grant). */
33
11
  agent?: string;
34
- /** Remote host the value was pulled from / pushed to, when applicable. */
35
12
  host?: string;
36
- /** Free-form origin label, e.g. 'agent', 'reveal', 'ssh', '1password'. */
37
13
  source?: string;
38
- /** Outcome; defaults to 'success'. */
39
14
  status?: 'success' | 'error';
40
- /** How many keys the event touched (names only are ever known here). */
41
15
  keyCount?: number;
42
16
  }
43
- /** One event kind's rollup for a bundle. */
44
17
  export interface UsageStat {
45
18
  count: number;
46
- /** ISO 8601 timestamp of the most recent occurrence, or null if never. */
47
19
  last: string | null;
48
20
  }
49
- /** Per-bundle usage summary for the `view` / `list` surfaces. */
50
21
  export interface BundleUsageSummary {
51
22
  bundle: string;
52
- /** Every recorded event, all kinds. */
53
23
  total: number;
54
- /** Rollup per event kind (every kind present, zeroed when unused). */
55
24
  events: Record<SecretUsageEvent, UsageStat>;
56
- /** Most recent event across all kinds, or null. */
57
25
  lastUsedAt: string | null;
58
- /** Earliest event across all kinds, or null. */
59
26
  firstUsedAt: string | null;
60
- /** Event count grouped by resolving agent, most-first. `*` = a global grant. */
61
27
  byAgent: Array<{
62
28
  agent: string;
63
29
  count: number;
64
30
  }>;
65
31
  }
66
- /** One recorded event, for the `secrets activity` timeline. */
67
32
  export interface SecretUsageHistoryEntry {
68
33
  ts: string;
69
34
  bundle: string;
@@ -74,33 +39,8 @@ export interface SecretUsageHistoryEntry {
74
39
  status: string | null;
75
40
  keyCount: number | null;
76
41
  }
77
- /**
78
- * Record one usage event. Best-effort and value-free — swallows every error and
79
- * honors AGENTS_NO_USAGE_TRACK so telemetry never blocks or slows a read. Rows
80
- * with an empty bundle name are ignored (usage is per-bundle by definition).
81
- *
82
- * This is called from ONE place only — `emitSecretAudit` (lib/secrets/audit.ts)
83
- * — so every recorded event has already been written to the events.jsonl audit
84
- * log through the same chokepoint. Do not call it from a command handler; emit
85
- * the audit event instead.
86
- */
87
42
  export declare function recordSecretUsage(p: RecordUsageParams): void;
88
- /**
89
- * Usage summary for one bundle, or undefined when nothing has ever been
90
- * recorded (or the DB is unavailable). Never throws.
91
- */
92
43
  export declare function getBundleUsage(bundle: string): BundleUsageSummary | undefined;
93
- /**
94
- * Usage summaries for every bundle that has any recorded event, keyed by bundle
95
- * name. Powers `secrets list --sort uses|used`. Empty map when the DB is
96
- * unavailable or has no rows. Never throws.
97
- */
98
44
  export declare function getAllBundleUsage(): Map<string, BundleUsageSummary>;
99
- /**
100
- * Recent events for the `secrets activity` timeline — one bundle when named,
101
- * else across all bundles — newest first. Empty when the DB is unavailable.
102
- * Never throws.
103
- */
104
45
  export declare function getUsageHistory(bundle: string | undefined, limit?: number): SecretUsageHistoryEntry[];
105
- /** Close the cached handle. Used by tests between temp-db swaps. */
106
46
  export declare function closeSecretsUsageDb(): void;
@@ -1,30 +1,9 @@
1
1
  /**
2
- * SQLite-backed usage read-model for `agents secrets`.
3
- *
4
- * A small local database at ~/.agents/secrets/secrets.db that records one
5
- * value-free row for every secret lifecycle/access event a bundle accrues over
6
- * its life — created, imported, exported, viewed, accessed (read for injection),
7
- * unlocked. It is the queryable, per-bundle counterpart to the append-only
8
- * ~/.agents/events.jsonl audit log: the SAME chokepoint (`emitSecretAudit`,
9
- * lib/secrets/audit.ts) feeds both, and this store answers "how often / how
10
- * recently / by whom was THIS bundle used?" without scanning the whole event
11
- * stream. It is a DERIVED index fed off the real access flow — the way
12
- * sessions.db indexes session metadata — never a second write path an operation
13
- * has to remember to call.
14
- *
15
- * Contract, mirroring the audit log: NEVER a secret value. Only metadata — the
16
- * bundle name, the event kind, key counts, the resolving agent/host, a status.
17
- *
18
- * Every write is best-effort: a failure here (missing runtime SQLite, a locked
19
- * db, a read-only fs) is swallowed so usage telemetry can never break secret
20
- * resolution. Set AGENTS_NO_USAGE_TRACK=1 to disable recording entirely (used by
21
- * tests and by callers that must stay perfectly silent).
2
+ * Secrets usage read-model thin adapter over the analytics usage warehouse
3
+ * (`kind=secret`). Kept so `secrets view` / `list` / `activity` keep their API
4
+ * while the durable store lives at ~/.agents/.history/analytics/usage.db.
22
5
  */
23
- import * as fs from 'fs';
24
- import * as path from 'path';
25
- import Database from '../sqlite.js';
26
- import { getSecretsDbPath } from '../state.js';
27
- /** All event kinds, in the order `view` prints them. */
6
+ import { recordUsage, getSecretBundleRollup, getSecretBundleAgents, getAllSecretBundleRollups, getSecretHistory, closeUsageDb, } from '../analytics/usage-db.js';
28
7
  export const SECRET_USAGE_EVENTS = [
29
8
  'access',
30
9
  'unlock',
@@ -33,28 +12,6 @@ export const SECRET_USAGE_EVENTS = [
33
12
  'create',
34
13
  'view',
35
14
  ];
36
- /** Events older than this are pruned on open so the history table stays bounded. */
37
- const EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
38
- const SCHEMA = `
39
- CREATE TABLE IF NOT EXISTS usage_events (
40
- id INTEGER PRIMARY KEY AUTOINCREMENT,
41
- ts TEXT NOT NULL,
42
- bundle TEXT NOT NULL,
43
- event TEXT NOT NULL,
44
- agent TEXT,
45
- host TEXT,
46
- source TEXT,
47
- status TEXT,
48
- key_count INTEGER
49
- );
50
- CREATE INDEX IF NOT EXISTS idx_usage_bundle ON usage_events(bundle);
51
- CREATE INDEX IF NOT EXISTS idx_usage_bundle_event ON usage_events(bundle, event);
52
- CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts DESC);
53
- `;
54
- // Cached handle keyed by the resolved path, so a test that redirects
55
- // AGENTS_SECRETS_DB to a fresh temp file transparently reopens instead of
56
- // reusing a stale handle pointed at the previous path.
57
- let cached = null;
58
15
  function emptyEvents() {
59
16
  return {
60
17
  access: { count: 0, last: null },
@@ -65,68 +22,6 @@ function emptyEvents() {
65
22
  view: { count: 0, last: null },
66
23
  };
67
24
  }
68
- /**
69
- * Open (creating if needed) the usage DB, returning null on any failure so
70
- * every caller degrades to a no-op rather than throwing into secret resolution.
71
- */
72
- function open() {
73
- const dbPath = getSecretsDbPath();
74
- if (cached && cached.path === dbPath)
75
- return cached.db;
76
- if (cached) {
77
- try {
78
- cached.db.close();
79
- }
80
- catch { /* ignore */ }
81
- cached = null;
82
- }
83
- try {
84
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
85
- const db = new Database(dbPath);
86
- // WAL + a busy timeout so concurrent agent runs writing usage rows don't
87
- // fail each other under load; every write is still best-effort besides.
88
- db.pragma('journal_mode = WAL');
89
- db.pragma('busy_timeout = 2000');
90
- db.exec(SCHEMA);
91
- // Bounded retention: this is a usage history for operators, not a compliance
92
- // log (that is events.jsonl). Prune once per open on a 90-day window.
93
- try {
94
- db.prepare(`DELETE FROM usage_events WHERE ts < ?`).run(new Date(Date.now() - EVENT_RETENTION_MS).toISOString());
95
- }
96
- catch { /* prune is best-effort */ }
97
- cached = { path: dbPath, db };
98
- return db;
99
- }
100
- catch {
101
- return null;
102
- }
103
- }
104
- /**
105
- * Record one usage event. Best-effort and value-free — swallows every error and
106
- * honors AGENTS_NO_USAGE_TRACK so telemetry never blocks or slows a read. Rows
107
- * with an empty bundle name are ignored (usage is per-bundle by definition).
108
- *
109
- * This is called from ONE place only — `emitSecretAudit` (lib/secrets/audit.ts)
110
- * — so every recorded event has already been written to the events.jsonl audit
111
- * log through the same chokepoint. Do not call it from a command handler; emit
112
- * the audit event instead.
113
- */
114
- export function recordSecretUsage(p) {
115
- if (process.env.AGENTS_NO_USAGE_TRACK)
116
- return;
117
- if (!p.bundle)
118
- return;
119
- const db = open();
120
- if (!db)
121
- return;
122
- try {
123
- db.prepare(`INSERT INTO usage_events (ts, bundle, event, agent, host, source, status, key_count)
124
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), p.bundle, p.event, p.agent ?? null, p.host ?? null, p.source ?? null, p.status ?? 'success', p.keyCount ?? null);
125
- }
126
- catch {
127
- // Telemetry must never break secret resolution.
128
- }
129
- }
130
25
  function toSummary(bundle, rows, byAgent) {
131
26
  const events = emptyEvents();
132
27
  let total = 0;
@@ -146,91 +41,56 @@ function toSummary(bundle, rows, byAgent) {
146
41
  }
147
42
  return { bundle, total, events, lastUsedAt, firstUsedAt, byAgent };
148
43
  }
149
- /**
150
- * Usage summary for one bundle, or undefined when nothing has ever been
151
- * recorded (or the DB is unavailable). Never throws.
152
- */
44
+ export function recordSecretUsage(p) {
45
+ if (!p.bundle)
46
+ return;
47
+ const meta = {};
48
+ if (p.keyCount != null)
49
+ meta.keyCount = p.keyCount;
50
+ if (p.host)
51
+ meta.host = p.host;
52
+ recordUsage({
53
+ kind: 'secret',
54
+ name: p.bundle,
55
+ event: p.event,
56
+ agent: p.agent,
57
+ source: p.source,
58
+ status: p.status,
59
+ meta: Object.keys(meta).length ? meta : undefined,
60
+ });
61
+ }
153
62
  export function getBundleUsage(bundle) {
154
- const db = open();
155
- if (!db)
63
+ const rows = getSecretBundleRollup(bundle);
64
+ if (rows.length === 0)
156
65
  return undefined;
157
- try {
158
- const rows = db
159
- .prepare(`SELECT event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
160
- FROM usage_events WHERE bundle = ? GROUP BY event`)
161
- .all(bundle);
162
- if (rows.length === 0)
163
- return undefined;
164
- const agents = db
165
- .prepare(`SELECT agent, COUNT(*) AS n FROM usage_events
166
- WHERE bundle = ? AND agent IS NOT NULL GROUP BY agent ORDER BY n DESC`)
167
- .all(bundle);
168
- return toSummary(bundle, rows, agents.map((a) => ({ agent: a.agent, count: a.n })));
169
- }
170
- catch {
171
- return undefined;
172
- }
66
+ const agents = getSecretBundleAgents(bundle);
67
+ return toSummary(bundle, rows, agents.map((a) => ({ agent: a.agent, count: a.n })));
173
68
  }
174
- /**
175
- * Usage summaries for every bundle that has any recorded event, keyed by bundle
176
- * name. Powers `secrets list --sort uses|used`. Empty map when the DB is
177
- * unavailable or has no rows. Never throws.
178
- */
179
69
  export function getAllBundleUsage() {
180
70
  const out = new Map();
181
- const db = open();
182
- if (!db)
183
- return out;
184
- try {
185
- const rows = db
186
- .prepare(`SELECT bundle, event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
187
- FROM usage_events GROUP BY bundle, event`)
188
- .all();
189
- const byBundle = new Map();
190
- for (const r of rows) {
191
- const list = byBundle.get(r.bundle) ?? [];
192
- list.push(r);
193
- byBundle.set(r.bundle, list);
194
- }
195
- for (const [bundle, list] of byBundle)
196
- out.set(bundle, toSummary(bundle, list, []));
197
- return out;
198
- }
199
- catch {
200
- return out;
71
+ const rows = getAllSecretBundleRollups();
72
+ const byBundle = new Map();
73
+ for (const r of rows) {
74
+ const list = byBundle.get(r.name) ?? [];
75
+ list.push(r);
76
+ byBundle.set(r.name, list);
201
77
  }
78
+ for (const [bundle, list] of byBundle)
79
+ out.set(bundle, toSummary(bundle, list, []));
80
+ return out;
202
81
  }
203
- /**
204
- * Recent events for the `secrets activity` timeline — one bundle when named,
205
- * else across all bundles — newest first. Empty when the DB is unavailable.
206
- * Never throws.
207
- */
208
82
  export function getUsageHistory(bundle, limit = 20) {
209
- const db = open();
210
- if (!db)
211
- return [];
212
- try {
213
- const sql = bundle
214
- ? `SELECT ts, bundle, event, agent, host, source, status, key_count AS keyCount
215
- FROM usage_events WHERE bundle = ? ORDER BY ts DESC, id DESC LIMIT ?`
216
- : `SELECT ts, bundle, event, agent, host, source, status, key_count AS keyCount
217
- FROM usage_events ORDER BY ts DESC, id DESC LIMIT ?`;
218
- const rows = bundle
219
- ? db.prepare(sql).all(bundle, limit)
220
- : db.prepare(sql).all(limit);
221
- return rows;
222
- }
223
- catch {
224
- return [];
225
- }
83
+ return getSecretHistory(bundle, limit).map((r) => ({
84
+ ts: r.ts,
85
+ bundle: r.bundle,
86
+ event: r.event,
87
+ agent: r.agent,
88
+ host: r.host,
89
+ source: r.source,
90
+ status: r.status,
91
+ keyCount: r.keyCount,
92
+ }));
226
93
  }
227
- /** Close the cached handle. Used by tests between temp-db swaps. */
228
94
  export function closeSecretsUsageDb() {
229
- if (cached) {
230
- try {
231
- cached.db.close();
232
- }
233
- catch { /* ignore */ }
234
- cached = null;
235
- }
95
+ closeUsageDb();
236
96
  }
@@ -11,7 +11,7 @@ import type { SessionAgentId, SessionMeta } from './types.js';
11
11
  /** Current schema version; bumped when migrations are added. Exported so tests
12
12
  * assert against the constant instead of hardcoding a number that every bump
13
13
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
14
- export declare const SCHEMA_VERSION = 21;
14
+ export declare const SCHEMA_VERSION = 22;
15
15
  /** Raw row shape returned from the sessions table. */
16
16
  export interface SessionRow {
17
17
  id: string;
@@ -35,6 +35,7 @@ export interface SessionRow {
35
35
  cost_usd: number | null;
36
36
  duration_ms: number | null;
37
37
  model: string | null;
38
+ tool_call_count: number | null;
38
39
  file_path: string;
39
40
  file_mtime_ms: number | null;
40
41
  file_size: number | null;
@@ -19,7 +19,7 @@ const DB_PATH = getSessionsDbPath();
19
19
  /** Current schema version; bumped when migrations are added. Exported so tests
20
20
  * assert against the constant instead of hardcoding a number that every bump
21
21
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
22
- export const SCHEMA_VERSION = 21;
22
+ export const SCHEMA_VERSION = 22;
23
23
  /**
24
24
  * Canonicalize a file path for use as a scan_ledger key. The same physical
25
25
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sessions (
67
67
  cost_usd REAL,
68
68
  duration_ms INTEGER,
69
69
  model TEXT,
70
+ tool_call_count INTEGER,
70
71
  file_path TEXT NOT NULL,
71
72
  file_mtime_ms INTEGER,
72
73
  file_size INTEGER,
@@ -399,6 +400,12 @@ function migrateSchema(db, fromVersion) {
399
400
  db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
400
401
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
401
402
  }
403
+ if (fromVersion < 22) {
404
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
405
+ if (!cols.some(c => c.name === 'tool_call_count'))
406
+ db.exec(`ALTER TABLE sessions ADD COLUMN tool_call_count INTEGER`);
407
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
408
+ }
402
409
  }
403
410
  /** Open (or return the cached) sessions database, applying migrations as needed. */
404
411
  export function getDB() {
@@ -758,7 +765,7 @@ const upsertSessionStmt = (db) => db.prepare(`
758
765
  id, short_id, agent, origin, routine_name, routine_run_id,
759
766
  version, account, timestamp, last_activity,
760
767
  project, cwd, git_branch, topic, label, message_count, token_count,
761
- output_tokens, cost_usd, duration_ms, model,
768
+ output_tokens, cost_usd, duration_ms, model, tool_call_count,
762
769
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
763
770
  pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
764
771
  recent_directories_touched, linear_project, linear_project_url, machine,
@@ -767,7 +774,7 @@ const upsertSessionStmt = (db) => db.prepare(`
767
774
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
768
775
  @version, @account, @timestamp, @last_activity,
769
776
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
770
- @output_tokens, @cost_usd, @duration_ms, @model,
777
+ @output_tokens, @cost_usd, @duration_ms, @model, @tool_call_count,
771
778
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
772
779
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
773
780
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
@@ -802,6 +809,7 @@ const upsertSessionStmt = (db) => db.prepare(`
802
809
  cost_usd = excluded.cost_usd,
803
810
  duration_ms = excluded.duration_ms,
804
811
  model = excluded.model,
812
+ tool_call_count = excluded.tool_call_count,
805
813
  file_path = excluded.file_path,
806
814
  file_mtime_ms = excluded.file_mtime_ms,
807
815
  file_size = excluded.file_size,
@@ -919,6 +927,7 @@ export function upsertSession(meta, content, scan) {
919
927
  cost_usd: meta.costUsd ?? null,
920
928
  duration_ms: meta.durationMs ?? null,
921
929
  model: meta.model ?? null,
930
+ tool_call_count: meta.toolCallCount ?? null,
922
931
  file_path: meta.filePath,
923
932
  file_mtime_ms: scan?.fileMtimeMs ?? null,
924
933
  file_size: scan?.fileSize ?? null,
@@ -1040,6 +1049,7 @@ export function upsertSessionsBatch(entries) {
1040
1049
  cost_usd: meta.costUsd ?? null,
1041
1050
  duration_ms: meta.durationMs ?? null,
1042
1051
  model: meta.model ?? null,
1052
+ tool_call_count: meta.toolCallCount ?? null,
1043
1053
  file_path: meta.filePath,
1044
1054
  file_mtime_ms: scan?.fileMtimeMs ?? null,
1045
1055
  file_size: scan?.fileSize ?? null,
@@ -1229,6 +1239,7 @@ function rowToMeta(row) {
1229
1239
  costUsd: row.cost_usd ?? undefined,
1230
1240
  durationMs: row.duration_ms ?? undefined,
1231
1241
  model: row.model ?? undefined,
1242
+ toolCallCount: row.tool_call_count ?? undefined,
1232
1243
  version: row.version ?? undefined,
1233
1244
  account: row.account ?? undefined,
1234
1245
  topic: row.topic ?? undefined,
@@ -66,6 +66,7 @@ interface ClaudeSessionScan {
66
66
  durationMs?: number;
67
67
  /** ISO time of the last timestamped event — the session's last activity. */
68
68
  lastActivity?: string;
69
+ toolCallCount?: number;
69
70
  /**
70
71
  * Value of the JSONL `entrypoint` field on the first event that carries it.
71
72
  * 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
@@ -374,6 +375,7 @@ export interface ClaudeParseState {
374
375
  aiTitle?: string;
375
376
  entrypoint?: string;
376
377
  messageCount: number;
378
+ toolCallCount: number;
377
379
  tokenCount: number;
378
380
  outputTokens: number;
379
381
  sawTokenCount: boolean;
@@ -436,6 +438,7 @@ export interface ClaudeParserState {
436
438
  plan?: string;
437
439
  lastTsMs?: number;
438
440
  messageCount: number;
441
+ toolCallCount: number;
439
442
  tokenCount: number;
440
443
  outputTokens: number;
441
444
  sawTokenCount: boolean;