@phnx-labs/agents-cli 1.20.92 → 1.21.0

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 (146) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -81,10 +81,67 @@ export function finalizeRunMeta(meta, status, exitCode, opts) {
81
81
  * catchup/overdue, manual run) gates on this.
82
82
  */
83
83
  export function jobRunsOnThisDevice(config) {
84
- if (!config.devices || config.devices.length === 0)
84
+ const owner = routineOwnerDevice(config);
85
+ // Unrestricted: no pin means fleet-wide by design (`watchdog`, `check-updates`).
86
+ if (owner === null)
85
87
  return true;
86
- const self = machineId();
87
- return config.devices.some((d) => normalizeHost(d) === self);
88
+ return owner === machineId();
89
+ }
90
+ /**
91
+ * The ONE device that owns a routine — the single daemon allowed to fire it.
92
+ *
93
+ * `devices` is an allowlist, and every listed device used to fire
94
+ * independently, so a routine pinned to two boxes ran **twice** per schedule:
95
+ * two full agent sessions doing identical work, burning double the quota. On
96
+ * this fleet seven routines were in that state, e.g. `security-sweep` running
97
+ * at 15:30:02 on one box and 15:30:03 on the other, both completing.
98
+ *
99
+ * Ownership is a pure function of the config — the first entry in normalized
100
+ * sort order — so every daemon independently reaches the same answer with no
101
+ * lease, no cross-device coordination, and no split brain when the fleet
102
+ * partitions. A multi-entry pin is a misconfiguration
103
+ * ({@link hasAmbiguousDevicePin}); this keeps such a routine running exactly
104
+ * once instead of silently dropping it, while `validateJob` refuses to create
105
+ * a new one and `agents doctor` surfaces the existing ones.
106
+ *
107
+ * Returns null when the routine is unrestricted (empty or omitted `devices`).
108
+ */
109
+ export function routineOwnerDevice(config) {
110
+ // A non-array `devices` is a separate validation error; don't throw here and
111
+ // don't double-report it.
112
+ if (!Array.isArray(config.devices))
113
+ return null;
114
+ const devices = config.devices.map((d) => normalizeHost(String(d))).filter(Boolean);
115
+ if (devices.length === 0)
116
+ return null;
117
+ return [...devices].sort()[0];
118
+ }
119
+ /**
120
+ * Does this routine name more than one distinct device? Such a pin used to mean
121
+ * "fire on each of them"; it now means "fire only on the first", which is
122
+ * almost certainly not what the author intended either way — so it is reported
123
+ * as a misconfiguration rather than silently reinterpreted.
124
+ */
125
+ export function hasAmbiguousDevicePin(config) {
126
+ if (!Array.isArray(config.devices))
127
+ return false;
128
+ const devices = new Set(config.devices.map((d) => normalizeHost(String(d))).filter(Boolean));
129
+ return devices.size > 1;
130
+ }
131
+ /**
132
+ * Every routine carrying a multi-device pin. Surfaced by `agents doctor` and
133
+ * `agents routines list` so an existing misconfiguration is visible rather than
134
+ * silently reinterpreted: before ownership became singular each of these fired
135
+ * once per listed device, doubling the work and the agent spend.
136
+ */
137
+ export function findAmbiguousDevicePins(cwd) {
138
+ return listJobs(cwd)
139
+ .filter((job) => job.enabled && hasAmbiguousDevicePin(job))
140
+ .map((job) => ({
141
+ name: job.name,
142
+ devices: (job.devices ?? []).map((d) => normalizeHost(d)),
143
+ owner: routineOwnerDevice(job) ?? '',
144
+ }));
88
145
  }
89
146
  /**
90
147
  * Resolve the effective host strategy for a job.
@@ -130,7 +187,10 @@ export function checkJobDeviceEligibility(config) {
130
187
  return null;
131
188
  const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
132
189
  const allowedLabel = allowed.join(', ');
133
- const firstHost = allowed[0] ?? 'HOST';
190
+ // The owner is the lowest normalized name, not the first entry as written —
191
+ // suggesting allowed[0] on an unsorted list points at a device that will
192
+ // refuse the run for exactly the same reason.
193
+ const firstHost = routineOwnerDevice(config) ?? allowed[0] ?? 'HOST';
134
194
  const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
135
195
  const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
136
196
  return { message, suggestion, allowedLabel, firstHost };
@@ -251,6 +311,17 @@ function readJobFile(filePath) {
251
311
  // treated as unavailable/inert rather than unrestricted.
252
312
  if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
253
313
  return null;
314
+ // Fail closed on a malformed `devices` too. Ownership treats a non-array as
315
+ // "no pin", and the daemon's load path never calls validateJob — so a YAML
316
+ // typo (`devices: yosemite-s0` instead of a list) would silently promote the
317
+ // routine to fleet-wide and fire it on EVERY box. Inert-and-loud beats
318
+ // unrestricted-and-silent.
319
+ if (Object.prototype.hasOwnProperty.call(parsed, 'devices')
320
+ && parsed.devices !== undefined
321
+ && parsed.devices !== null
322
+ && !Array.isArray(parsed.devices)) {
323
+ return null;
324
+ }
254
325
  return {
255
326
  ...JOB_DEFAULTS,
256
327
  ...parsed,
@@ -523,6 +594,14 @@ export function validateJob(config) {
523
594
  }
524
595
  }
525
596
  }
597
+ // A routine belongs to exactly one device. Listing several used to fire it
598
+ // once per device — duplicate work, duplicate spend — and making them elect
599
+ // one owner at runtime would need cross-device coordination nobody wants.
600
+ if (hasAmbiguousDevicePin(config)) {
601
+ errors.push(`devices lists ${new Set(config.devices.map((d) => normalizeHost(String(d)))).size} devices; a routine runs on exactly one. ` +
602
+ 'Pin the single device that should own it, e.g. devices: [yosemite-s0]. ' +
603
+ 'Omit devices entirely for a routine that genuinely belongs on every machine.');
604
+ }
526
605
  if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
527
606
  errors.push('catchup must be a boolean (false to skip running a missed fire late)');
528
607
  }
@@ -946,6 +1025,28 @@ export function getJobPath(name) {
946
1025
  }
947
1026
  return null;
948
1027
  }
1028
+ /**
1029
+ * Resolve a routine's YAML across EVERY layer `listJobs`/`readJob` read — user
1030
+ * then system — not just the user dir.
1031
+ *
1032
+ * `getJobPath` is user-layer only because its callers write there. Read paths
1033
+ * that ask "when did this routine come to exist" need the system layer too:
1034
+ * a built-in shipped in the system repo has no user-layer file and no
1035
+ * `createdAt`, so a user-layer-only lookup returns null, the overdue floor is
1036
+ * skipped, and the routine reads as instantly overdue on first daemon start —
1037
+ * exactly the case the floor exists to prevent.
1038
+ */
1039
+ export function resolveJobFilePath(name) {
1040
+ const userPath = getJobPath(name);
1041
+ if (userPath)
1042
+ return userPath;
1043
+ for (const ext of ['.yml', '.yaml']) {
1044
+ const filePath = safeJoin(getSystemRoutinesDir(), name + ext);
1045
+ if (fs.existsSync(filePath))
1046
+ return filePath;
1047
+ }
1048
+ return null;
1049
+ }
949
1050
  /**
950
1051
  * Parse an "at" time string into a one-shot cron expression.
951
1052
  * Supports formats like:
@@ -0,0 +1,18 @@
1
+ import type { AgentId } from '../types.js';
2
+ /**
3
+ * Re-apply the active rules preset for (agent, version) into its version
4
+ * home when the composed source set has drifted since the last time this ran.
5
+ * Returns true when the version-home rules file was (re)written.
6
+ *
7
+ * No-cwd fingerprint on purpose: the version-home rules file never includes
8
+ * the project layer (see `staleness/writers/rules.ts` — project rules are
9
+ * resolved separately, at launch, into the workspace `AGENTS.md` by
10
+ * `compileRulesForProject`). Fingerprinting with a cwd would pull the project
11
+ * layer's subrules into the comparison and trigger a pointless version-home
12
+ * rewrite every time a project's own `.agents/rules/` changes.
13
+ *
14
+ * Silent on any failure (no rules.yaml, unknown preset, unsupported agent) —
15
+ * mirrors `syncResourcesToVersion`'s own catch-and-skip for rules: a bad
16
+ * preset must never block a launch, and this now also runs on the hot path.
17
+ */
18
+ export declare function applyActiveRulesPresetAtRun(agent: AgentId, version: string, versionHome: string): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Rules preset auto-apply at `agents run` launch time.
3
+ *
4
+ * `agents rules switch <agent>@<version> --preset <name>` persists the active
5
+ * preset (`state.ts:setActiveRulesPreset`) and immediately recompiles it into
6
+ * the version home via `syncResourcesToVersion` → the rules writer
7
+ * (`staleness/writers/rules.ts`). Same for `agents add`/`use`. But nothing
8
+ * re-applies the preset on a LATER `agents run` — if `setActiveRulesPreset`
9
+ * is ever called without a follow-up sync (or a subrule file changes after
10
+ * the last sync), the harness launches against a stale rules file until the
11
+ * next explicit `agents rules switch` / `agents sync`.
12
+ *
13
+ * This closes that gap the same way `runLaunchSync` (project-launch.ts)
14
+ * recompiles PROJECT rules on every launch: idempotent, and skip-fast when
15
+ * nothing changed. The skip-fast reuses the staleness checkers' mtime+size
16
+ * fingerprint comparison (`staleness/checkers/rules.ts`,
17
+ * `staleness/fingerprint.ts`) against a small per-(agent,version) sentinel —
18
+ * the same "hash the cheap inputs, compare, skip the write on a match" shape
19
+ * `installScope` (project-launch.ts) uses for plugin marketplaces. The common
20
+ * case (no preset or subrule change since the last run) costs one JSON read
21
+ * plus a handful of `stat()` calls — no recompose, no write.
22
+ *
23
+ * Version-level scope only — the active preset is keyed by (agent, version),
24
+ * matching `getActiveRulesPreset`. Per-model preset scoping is a follow-up.
25
+ */
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import { AGENTS } from '../agents.js';
29
+ import { getActiveRulesPreset, getCacheDir } from '../state.js';
30
+ import { getWriter } from '../staleness/registry.js';
31
+ import { buildRules, isRulesStale } from '../staleness/checkers/rules.js';
32
+ /** ~/.agents/.cache/rules-run-sync/ — regenerable; a lost sentinel just costs one extra compose+write. */
33
+ function sentinelPath(agent, version) {
34
+ const key = `${agent}@${version}`.replace(/[^a-zA-Z0-9@._-]/g, '_');
35
+ return path.join(getCacheDir(), 'rules-run-sync', `${key}.json`);
36
+ }
37
+ function loadSentinel(agent, version) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(sentinelPath(agent, version), 'utf-8'));
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function saveSentinel(agent, version, sentinel) {
46
+ const p = sentinelPath(agent, version);
47
+ try {
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.writeFileSync(p, JSON.stringify(sentinel));
50
+ }
51
+ catch {
52
+ // Best-effort — a failed write just means the next run redoes the compose+write.
53
+ }
54
+ }
55
+ /**
56
+ * Re-apply the active rules preset for (agent, version) into its version
57
+ * home when the composed source set has drifted since the last time this ran.
58
+ * Returns true when the version-home rules file was (re)written.
59
+ *
60
+ * No-cwd fingerprint on purpose: the version-home rules file never includes
61
+ * the project layer (see `staleness/writers/rules.ts` — project rules are
62
+ * resolved separately, at launch, into the workspace `AGENTS.md` by
63
+ * `compileRulesForProject`). Fingerprinting with a cwd would pull the project
64
+ * layer's subrules into the comparison and trigger a pointless version-home
65
+ * rewrite every time a project's own `.agents/rules/` changes.
66
+ *
67
+ * Silent on any failure (no rules.yaml, unknown preset, unsupported agent) —
68
+ * mirrors `syncResourcesToVersion`'s own catch-and-skip for rules: a bad
69
+ * preset must never block a launch, and this now also runs on the hot path.
70
+ */
71
+ export function applyActiveRulesPresetAtRun(agent, version, versionHome) {
72
+ const cap = AGENTS[agent].capabilities.rules;
73
+ if (cap === false)
74
+ return false;
75
+ const rulesWriter = getWriter('rules', agent);
76
+ if (!rulesWriter)
77
+ return false;
78
+ const preset = getActiveRulesPreset(agent, version);
79
+ const current = buildRules(agent, version, '');
80
+ const stored = loadSentinel(agent, version);
81
+ if (stored && stored.preset === preset && !isRulesStale(stored.entry, agent, version, '')) {
82
+ return false; // skip-fast: preset unchanged AND composed source set unchanged
83
+ }
84
+ try {
85
+ rulesWriter.write({ version, versionHome, selection: { preset }, cwd: '' });
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ saveSentinel(agent, version, { preset, entry: current });
91
+ return true;
92
+ }
@@ -34,6 +34,9 @@ export function buildRunFinishNotification(ctx, exitCode) {
34
34
  const n = {
35
35
  title: exitCode === 0 ? `${label} finished` : `${label} failed`,
36
36
  body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
37
+ // The harness that ran becomes the banner's right-hand avatar, so a finished
38
+ // run is identifiable at a glance even when `--name` renamed the title.
39
+ agent: ctx.agent,
37
40
  };
38
41
  if (where)
39
42
  n.subtitle = where;
@@ -6,7 +6,7 @@
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
8
  import { Cron } from 'croner';
9
- import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, } from './routines.js';
9
+ import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './routines.js';
10
10
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
11
11
  export class JobScheduler {
12
12
  jobs = new Map();
@@ -20,7 +20,17 @@ export class JobScheduler {
20
20
  // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
21
  // not the cron loop — skip them here. Jobs pinned to another device
22
22
  // (routines are fleet-synced) never enter this machine's cron loop.
23
- if (!config.enabled || !config.schedule || !jobRunsOnThisDevice(config))
23
+ if (!config.enabled || !config.schedule)
24
+ continue;
25
+ // A multi-device pin is a misconfiguration: it used to fire the routine
26
+ // once per listed device. It now fires only on the owner, but say so —
27
+ // silently reinterpreting someone's config is how this went unnoticed.
28
+ if (hasAmbiguousDevicePin(config)) {
29
+ const owner = routineOwnerDevice(config);
30
+ console.warn(`Job '${config.name}' pins ${config.devices.length} devices; a routine runs on exactly one. ` +
31
+ `Firing only on '${owner}'. Fix with: agents routines devices ${config.name} --set ${owner}`);
32
+ }
33
+ if (!jobRunsOnThisDevice(config))
24
34
  continue;
25
35
  if (shouldPurgeCompletedOneShotRoutine(config)) {
26
36
  deleteJob(config.name);
@@ -1,4 +1,4 @@
1
- export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked';
1
+ export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
2
2
  export interface SecretAuditParams {
3
3
  /** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
4
4
  event: SecretAuditEvent;
@@ -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
  }
@@ -193,11 +193,10 @@ export declare function rekeyStatus(): {
193
193
  * Throws when the item cannot be reached — on macOS, when the signed helper is
194
194
  * unavailable. That is deliberate: this primitive gates destructive writes as
195
195
  * well as reads. Through `bundleExists()` it guards the `--force` overwrite
196
- * checks in `agents secrets create` (`../../commands/secrets.ts`), the
197
- * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
198
- * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
199
- * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
200
- * of them, so an unreachable keychain must fail loudly rather than answer "no".
196
+ * checks in `agents secrets create` (`../../commands/secrets.ts`) and the
197
+ * bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
198
+ * (`./sync.ts`). A false "absent" silently disarms every one of them, so an
199
+ * unreachable keychain must fail loudly rather than answer "no".
201
200
  *
202
201
  * Tests needing this path without a helper install a backend via
203
202
  * `setKeychainBackendForTest()`, which short-circuits on the next line.
@@ -680,11 +680,10 @@ export function rekeyStatus() {
680
680
  * Throws when the item cannot be reached — on macOS, when the signed helper is
681
681
  * unavailable. That is deliberate: this primitive gates destructive writes as
682
682
  * well as reads. Through `bundleExists()` it guards the `--force` overwrite
683
- * checks in `agents secrets create` (`../../commands/secrets.ts`), the
684
- * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
685
- * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
686
- * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
687
- * of them, so an unreachable keychain must fail loudly rather than answer "no".
683
+ * checks in `agents secrets create` (`../../commands/secrets.ts`) and the
684
+ * bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
685
+ * (`./sync.ts`). A false "absent" silently disarms every one of them, so an
686
+ * unreachable keychain must fail loudly rather than answer "no".
688
687
  *
689
688
  * Tests needing this path without a helper install a backend via
690
689
  * `setKeychainBackendForTest()`, which short-circuits on the next line.
@@ -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;