@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Canonical audit emitter for `agents secrets` value access and unlock grants.
2
+ * Canonical audit emitter for every `agents secrets` lifecycle/access event.
3
3
  *
4
- * Every path that reads a secret VALUE or grants an unlock funnels its audit
5
- * through here, so the operational event stream `agents events`, backed by the
6
- * append-only `~/.agents/events.jsonl` audit logcarries a uniform, value-free
7
- * provenance record: bundle, key NAMES, the resolving agent/harness identity,
8
- * operation, source, status. The ts / host / session / caller fields are filled
9
- * in by `emit()` itself. The secret VALUE is never part of the payload; this
10
- * helper only ever receives metadata, and `emit()`'s `sanitizePayload` is a
11
- * second redaction layer.
4
+ * This is the ONE write path for secret events. Every path that creates,
5
+ * imports, exports, views, reads a VALUE from, or unlocks a bundle funnels its
6
+ * audit through here, so the operational event stream `agents events`, backed
7
+ * by the append-only `~/.agents/events.jsonl` audit log carries a uniform,
8
+ * value-free provenance record: bundle, key NAMES, the resolving agent/harness
9
+ * identity, operation, source, status. The ts / host / session / caller fields
10
+ * are filled in by `emit()` itself. The secret VALUE is never part of the
11
+ * payload; this helper only ever receives metadata, and `emit()`'s
12
+ * `sanitizePayload` is a second redaction layer.
12
13
  *
13
- * Two event types, both audit-level and non-milestone (so they surface in
14
+ * The same call also mirrors the event into the per-bundle usage read-model DB
15
+ * (`~/.agents/secrets/secrets.db`, lib/secrets/usage-db.ts) so `secrets view` /
16
+ * `list` / `activity` can answer "how often / how recently / by whom was this
17
+ * bundle used?" without scanning the whole event stream — a DERIVED index fed
18
+ * off this chokepoint, not a second write path a caller has to remember.
19
+ *
20
+ * The event vocabulary, all audit-level and non-milestone (so they surface in
14
21
  * `agents events` and the persisted audit trail, but are NOT required in the
15
22
  * curated `agents activity` / `agents feed` surfaces):
16
23
  * - `secrets.get` — a value was READ (exec inject, export, `view --reveal`,
@@ -19,8 +26,27 @@
19
26
  * - `secrets.unlocked` — a bundle was GRANTED into the secrets broker / durable
20
27
  * session by `agents secrets unlock`, then readable
21
28
  * prompt-free for the grant TTL.
29
+ * - `secrets.create` — a new bundle was created.
30
+ * - `secrets.import` — keys were imported into a bundle (file / ssh / 1password).
31
+ * - `secrets.export` — a bundle's values were exported (file / shell / ssh /
32
+ * 1password). Exporting also READS the values, so the
33
+ * underlying resolve emits its own `secrets.get`.
34
+ * - `secrets.view` — a bundle's (masked) metadata was inspected via `view`.
22
35
  */
23
36
  import { emit } from '../events.js';
37
+ import { recordSecretUsage } from './usage-db.js';
38
+ /**
39
+ * Map each audit event onto the usage-DB kind it is counted as. `secrets.get` is
40
+ * the injection/read access; the rest map 1:1 to their lifecycle kind.
41
+ */
42
+ const USAGE_KIND = {
43
+ 'secrets.get': 'access',
44
+ 'secrets.unlocked': 'unlock',
45
+ 'secrets.create': 'create',
46
+ 'secrets.import': 'import',
47
+ 'secrets.export': 'export',
48
+ 'secrets.view': 'view',
49
+ };
24
50
  /**
25
51
  * The agent/harness identity to attribute a secret access to. Explicit callers
26
52
  * (the bundle reader knows the scope it resolved under) win; otherwise fall back
@@ -53,4 +79,21 @@ export function emitSecretAudit(p) {
53
79
  ...(p.ttlMs !== undefined ? { ttlMs: p.ttlMs } : {}),
54
80
  ...(p.error !== undefined ? { error: p.error } : {}),
55
81
  });
82
+ // Mirror the event into the per-bundle usage read-model so `secrets view` /
83
+ // `list` / `activity` can report frequency and recency without scanning the
84
+ // whole event stream. Fed off THIS chokepoint alongside the events.jsonl
85
+ // write, never a second write path. Per-bundle only — a raw `secrets get
86
+ // <item>` has no bundle, so it stays in the events.jsonl audit but is not
87
+ // counted as bundle usage. Best-effort inside usage-db (swallows errors).
88
+ if (p.bundle) {
89
+ recordSecretUsage({
90
+ bundle: p.bundle,
91
+ event: USAGE_KIND[p.event],
92
+ agent,
93
+ host: p.host,
94
+ source: p.source,
95
+ status: p.status,
96
+ keyCount: p.keyCount,
97
+ });
98
+ }
56
99
  }
@@ -79,14 +79,29 @@ export interface FilterContext {
79
79
  }
80
80
  /** Does this bundle satisfy every set axis? Pure. */
81
81
  export declare function bundleMatchesFilter(b: SecretsBundle, f: SecretsListFilter, ctx: FilterContext): boolean;
82
- /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
83
- export declare const SORT_FIELDS: readonly ["name", "used", "created", "updated", "expiry"];
82
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`.
83
+ * `used` is most-recently-used first and `uses` is most-frequently-accessed
84
+ * first; both read the value-free usage read-model (lib/secrets/usage-db.ts). */
85
+ export declare const SORT_FIELDS: readonly ["name", "used", "uses", "created", "updated", "expiry"];
84
86
  export type SortField = typeof SORT_FIELDS[number];
87
+ /**
88
+ * The two value-free usage facts `--sort used|uses` needs, keyed by bundle name.
89
+ * Kept as a minimal shape (not the full BundleUsageSummary) so this module stays
90
+ * pure and unit-testable without opening the SQLite read-model.
91
+ */
92
+ export interface BundleUsageHint {
93
+ /** Most recent recorded event across all kinds, ISO 8601, or null. */
94
+ lastUsedAt: string | null;
95
+ /** Recorded `access` (read/inject) count — what `--sort uses` ranks on. */
96
+ uses: number;
97
+ }
85
98
  export declare function parseSortField(raw: string | undefined): SortField;
86
99
  /** Sort a copy. Time fields are most-recent-first (the useful direction for
87
- * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
88
- * so the order is stable. */
89
- export declare function sortBundles(bundles: SecretsBundle[], field: SortField): SecretsBundle[];
100
+ * "what did I touch lately"); `uses` is most-frequently-accessed first; `expiry`
101
+ * is soonest-first; ties fall back to name so the order is stable. `usage`
102
+ * carries the value-free read-model facts `used`/`uses` rank on — when omitted,
103
+ * `used` falls back to the throttled `last_used` stamp and `uses` sees zero. */
104
+ export declare function sortBundles(bundles: SecretsBundle[], field: SortField, usage?: Map<string, BundleUsageHint>): SecretsBundle[];
90
105
  /** Human summary of the active filters, for the empty state. `sessions` only
91
106
  * echoes --project/--all on a miss, which leaves you guessing which flag emptied
92
107
  * the list; naming every active axis is the difference between "nothing matched"
@@ -171,8 +171,10 @@ export function bundleMatchesFilter(b, f, ctx) {
171
171
  }
172
172
  return true;
173
173
  }
174
- /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
175
- export const SORT_FIELDS = ['name', 'used', 'created', 'updated', 'expiry'];
174
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`.
175
+ * `used` is most-recently-used first and `uses` is most-frequently-accessed
176
+ * first; both read the value-free usage read-model (lib/secrets/usage-db.ts). */
177
+ export const SORT_FIELDS = ['name', 'used', 'uses', 'created', 'updated', 'expiry'];
176
178
  export function parseSortField(raw) {
177
179
  if (!raw)
178
180
  return 'name';
@@ -196,11 +198,17 @@ function soonestExpiry(b) {
196
198
  return soonest;
197
199
  }
198
200
  /** Sort a copy. Time fields are most-recent-first (the useful direction for
199
- * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
200
- * so the order is stable. */
201
- export function sortBundles(bundles, field) {
201
+ * "what did I touch lately"); `uses` is most-frequently-accessed first; `expiry`
202
+ * is soonest-first; ties fall back to name so the order is stable. `usage`
203
+ * carries the value-free read-model facts `used`/`uses` rank on — when omitted,
204
+ * `used` falls back to the throttled `last_used` stamp and `uses` sees zero. */
205
+ export function sortBundles(bundles, field, usage) {
202
206
  const stamp = (iso) => (iso ? new Date(iso).getTime() : 0);
203
207
  const byName = (a, z) => a.name.localeCompare(z.name);
208
+ // `used` combines the throttled keychain stamp with the exact usage-DB recency
209
+ // so a just-recorded access ranks a bundle even before its stamp catches up.
210
+ const usedMs = (b) => Math.max(stamp(b.last_used), stamp(usage?.get(b.name)?.lastUsedAt));
211
+ const usesOf = (b) => usage?.get(b.name)?.uses ?? 0;
204
212
  const out = [...bundles];
205
213
  if (field === 'name')
206
214
  return out.sort(byName);
@@ -209,7 +217,15 @@ export function sortBundles(bundles, field) {
209
217
  const d = soonestExpiry(a) - soonestExpiry(z);
210
218
  return d !== 0 ? d : byName(a, z);
211
219
  }
212
- const key = field === 'used' ? 'last_used' : field === 'created' ? 'created_at' : 'updated_at';
220
+ if (field === 'used') {
221
+ const d = usedMs(z) - usedMs(a);
222
+ return d !== 0 ? d : byName(a, z);
223
+ }
224
+ if (field === 'uses') {
225
+ const d = usesOf(z) - usesOf(a);
226
+ return d !== 0 ? d : byName(a, z);
227
+ }
228
+ const key = field === 'created' ? 'created_at' : 'updated_at';
213
229
  const d = stamp(z[key]) - stamp(a[key]);
214
230
  return d !== 0 ? d : byName(a, z);
215
231
  });
@@ -0,0 +1,106 @@
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).
22
+ */
23
+ /** The lifecycle/access events a bundle accrues over its life. */
24
+ export type SecretUsageEvent = 'access' | 'unlock' | 'import' | 'export' | 'create' | 'view';
25
+ /** All event kinds, in the order `view` prints them. */
26
+ export declare const SECRET_USAGE_EVENTS: readonly SecretUsageEvent[];
27
+ export interface RecordUsageParams {
28
+ /** Bundle the event applies to (required — usage is always per-bundle). */
29
+ bundle: string;
30
+ /** What happened. */
31
+ event: SecretUsageEvent;
32
+ /** Resolving agent/harness identity, when known (`*` = a global grant). */
33
+ agent?: string;
34
+ /** Remote host the value was pulled from / pushed to, when applicable. */
35
+ host?: string;
36
+ /** Free-form origin label, e.g. 'agent', 'reveal', 'ssh', '1password'. */
37
+ source?: string;
38
+ /** Outcome; defaults to 'success'. */
39
+ status?: 'success' | 'error';
40
+ /** How many keys the event touched (names only are ever known here). */
41
+ keyCount?: number;
42
+ }
43
+ /** One event kind's rollup for a bundle. */
44
+ export interface UsageStat {
45
+ count: number;
46
+ /** ISO 8601 timestamp of the most recent occurrence, or null if never. */
47
+ last: string | null;
48
+ }
49
+ /** Per-bundle usage summary for the `view` / `list` surfaces. */
50
+ export interface BundleUsageSummary {
51
+ bundle: string;
52
+ /** Every recorded event, all kinds. */
53
+ total: number;
54
+ /** Rollup per event kind (every kind present, zeroed when unused). */
55
+ events: Record<SecretUsageEvent, UsageStat>;
56
+ /** Most recent event across all kinds, or null. */
57
+ lastUsedAt: string | null;
58
+ /** Earliest event across all kinds, or null. */
59
+ firstUsedAt: string | null;
60
+ /** Event count grouped by resolving agent, most-first. `*` = a global grant. */
61
+ byAgent: Array<{
62
+ agent: string;
63
+ count: number;
64
+ }>;
65
+ }
66
+ /** One recorded event, for the `secrets activity` timeline. */
67
+ export interface SecretUsageHistoryEntry {
68
+ ts: string;
69
+ bundle: string;
70
+ event: SecretUsageEvent;
71
+ agent: string | null;
72
+ host: string | null;
73
+ source: string | null;
74
+ status: string | null;
75
+ keyCount: number | null;
76
+ }
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
+ 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
+ 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
+ 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
+ export declare function getUsageHistory(bundle: string | undefined, limit?: number): SecretUsageHistoryEntry[];
105
+ /** Close the cached handle. Used by tests between temp-db swaps. */
106
+ export declare function closeSecretsUsageDb(): void;
@@ -0,0 +1,236 @@
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).
22
+ */
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. */
28
+ export const SECRET_USAGE_EVENTS = [
29
+ 'access',
30
+ 'unlock',
31
+ 'import',
32
+ 'export',
33
+ 'create',
34
+ 'view',
35
+ ];
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
+ function emptyEvents() {
59
+ return {
60
+ access: { count: 0, last: null },
61
+ unlock: { count: 0, last: null },
62
+ import: { count: 0, last: null },
63
+ export: { count: 0, last: null },
64
+ create: { count: 0, last: null },
65
+ view: { count: 0, last: null },
66
+ };
67
+ }
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
+ function toSummary(bundle, rows, byAgent) {
131
+ const events = emptyEvents();
132
+ let total = 0;
133
+ let lastUsedAt = null;
134
+ let firstUsedAt = null;
135
+ for (const r of rows) {
136
+ if (r.event in events) {
137
+ const stat = events[r.event];
138
+ stat.count = r.n;
139
+ stat.last = r.last;
140
+ }
141
+ total += r.n;
142
+ if (r.last && (!lastUsedAt || r.last > lastUsedAt))
143
+ lastUsedAt = r.last;
144
+ if (r.first && (!firstUsedAt || r.first < firstUsedAt))
145
+ firstUsedAt = r.first;
146
+ }
147
+ return { bundle, total, events, lastUsedAt, firstUsedAt, byAgent };
148
+ }
149
+ /**
150
+ * Usage summary for one bundle, or undefined when nothing has ever been
151
+ * recorded (or the DB is unavailable). Never throws.
152
+ */
153
+ export function getBundleUsage(bundle) {
154
+ const db = open();
155
+ if (!db)
156
+ 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
+ }
173
+ }
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
+ export function getAllBundleUsage() {
180
+ 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;
201
+ }
202
+ }
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
+ 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
+ }
226
+ }
227
+ /** Close the cached handle. Used by tests between temp-db swaps. */
228
+ export function closeSecretsUsageDb() {
229
+ if (cached) {
230
+ try {
231
+ cached.db.close();
232
+ }
233
+ catch { /* ignore */ }
234
+ cached = null;
235
+ }
236
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parses the raw command strings agents pass to Bash tool calls into structured
3
+ * metadata: the executable, category, subcommand, and a display summary. Used by
4
+ * session rendering and the activity-log hook so `agents sessions` and
5
+ * `agents activity` can summarize what actually happened instead of printing a
6
+ * wall of shell.
7
+ */
8
+ export type BashCategory = 'vcs' | 'build-test' | 'install' | 'remote' | 'http' | 'media' | 'upscaling' | 'metadata' | 'probe' | 'search' | 'shell' | 'wait' | 'other';
9
+ export interface BashToolInfo {
10
+ category: BashCategory;
11
+ signal: 'high' | 'mid' | 'low';
12
+ action: string;
13
+ aliases?: string[];
14
+ }
15
+ export interface BashCommandInfo {
16
+ tool: string;
17
+ category: BashCategory;
18
+ subcommand: string;
19
+ action: string;
20
+ summary: string;
21
+ signal: 'high' | 'mid' | 'low';
22
+ }
23
+ export declare function unwrapCommand(cmd: string): string;
24
+ /**
25
+ * Split a possibly-compound Bash command into simple commands, then tokenize each
26
+ * with shlex. Returns an empty array for empty input.
27
+ */
28
+ export declare function tokenizeBash(cmd: string): string[][];
29
+ /**
30
+ * Classify the first simple command in a Bash string. Returns coarse metadata
31
+ * (tool name, category, subcommand, human action) used for summaries and
32
+ * activity logging. Unknown executables fall back to `other`.
33
+ */
34
+ export declare function classifyBashCommand(command: string): BashCommandInfo;
35
+ /**
36
+ * Stable bucket key for grouping similar Bash commands in summaries. Commands run
37
+ * through a remote wrapper (ssh/scp/rsync) get an `ssh→` prefix on the inner key,
38
+ * so `ssh host "git push"` buckets as `ssh→git push`, distinct from a local push.
39
+ */
40
+ export declare function bucketKey(command: string): string;
41
+ /**
42
+ * Detect high-signal Bash-driven milestones (video renders, upscales, metadata
43
+ * edits, git commits/pushes/worktrees, PR opens). Returns null for routine
44
+ * commands.
45
+ */
46
+ export declare function detectBashMilestone(command: string): {
47
+ event: string;
48
+ detail: string;
49
+ } | null;
50
+ /**
51
+ * Human-readable category label for renderers.
52
+ */
53
+ export declare function categoryLabel(category: BashCategory): string;