@phnx-labs/agents-cli 1.21.1 → 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 (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  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/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. package/package.json +1 -1
@@ -0,0 +1,301 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import Database from '../sqlite.js';
4
+ import { getUsageDbPath, getSecretsDbPath, getAnalyticsDir } from '../state.js';
5
+ import { localMachineId } from '../session/origin-machine.js';
6
+ export const USAGE_KINDS = [
7
+ 'secret',
8
+ 'agent',
9
+ 'skill',
10
+ 'plugin',
11
+ 'browser',
12
+ 'computer',
13
+ ];
14
+ const EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS usage_events (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ ts TEXT NOT NULL,
19
+ kind TEXT NOT NULL,
20
+ name TEXT NOT NULL,
21
+ event TEXT NOT NULL,
22
+ agent TEXT,
23
+ session_id TEXT,
24
+ machine TEXT,
25
+ actor TEXT,
26
+ source TEXT,
27
+ status TEXT,
28
+ meta_json TEXT
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_ts ON usage_events(ts DESC);
31
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_ts ON usage_events(kind, ts DESC);
32
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_name ON usage_events(kind, name);
33
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_event ON usage_events(kind, event);
34
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_machine_ts ON usage_events(machine, ts DESC);
35
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_session ON usage_events(session_id);
36
+ CREATE TABLE IF NOT EXISTS meta (
37
+ key TEXT PRIMARY KEY,
38
+ value TEXT NOT NULL
39
+ );
40
+ `;
41
+ let cached = null;
42
+ function isDisabled() {
43
+ const v = process.env.AGENTS_NO_USAGE_TRACK;
44
+ return v === '1' || v === 'true';
45
+ }
46
+ function open() {
47
+ if (isDisabled())
48
+ return null;
49
+ const dbPath = getUsageDbPath();
50
+ if (cached && cached.path === dbPath)
51
+ return cached.db;
52
+ if (cached) {
53
+ try {
54
+ cached.db.close();
55
+ }
56
+ catch { /* ignore */ }
57
+ cached = null;
58
+ }
59
+ try {
60
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
61
+ const db = new Database(dbPath);
62
+ db.pragma('journal_mode = WAL');
63
+ db.pragma('busy_timeout = 2000');
64
+ db.exec(SCHEMA);
65
+ try {
66
+ db.prepare(`DELETE FROM usage_events WHERE ts < ?`).run(new Date(Date.now() - EVENT_RETENTION_MS).toISOString());
67
+ }
68
+ catch { /* prune best-effort */ }
69
+ migrateSecretsUsageOnce(db);
70
+ cached = { path: dbPath, db };
71
+ return db;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function migrateSecretsUsageOnce(db) {
78
+ try {
79
+ const done = db.prepare(`SELECT value FROM meta WHERE key = 'migrate_secrets_v1'`).get();
80
+ if (done?.value === '1')
81
+ return;
82
+ const secretsPath = getSecretsDbPath();
83
+ if (fs.existsSync(secretsPath)) {
84
+ const legacy = new Database(secretsPath);
85
+ try {
86
+ const rows = legacy.prepare(`SELECT ts, bundle, event, agent, host, source, status, key_count FROM usage_events`).all();
87
+ const insert = db.prepare(`INSERT INTO usage_events (ts, kind, name, event, agent, session_id, machine, actor, source, status, meta_json)
88
+ VALUES (?, 'secret', ?, ?, ?, NULL, ?, NULL, ?, ?, ?)`);
89
+ const machine = localMachineId();
90
+ const txn = db.transaction((items) => {
91
+ for (const r of items) {
92
+ if (!r.bundle)
93
+ continue;
94
+ const meta = r.key_count != null ? JSON.stringify({ keyCount: r.key_count, host: r.host }) : (r.host ? JSON.stringify({ host: r.host }) : null);
95
+ insert.run(r.ts, r.bundle, r.event, r.agent, machine, r.source, r.status, meta);
96
+ }
97
+ });
98
+ txn(rows);
99
+ }
100
+ finally {
101
+ try {
102
+ legacy.close();
103
+ }
104
+ catch { /* ignore */ }
105
+ }
106
+ }
107
+ db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('migrate_secrets_v1', '1')`).run();
108
+ }
109
+ catch {
110
+ /* migrate is best-effort */
111
+ }
112
+ }
113
+ export function recordUsage(p) {
114
+ if (isDisabled())
115
+ return;
116
+ if (!p.kind || !p.name || !p.event)
117
+ return;
118
+ const db = open();
119
+ if (!db)
120
+ return;
121
+ try {
122
+ db.prepare(`INSERT INTO usage_events (ts, kind, name, event, agent, session_id, machine, actor, source, status, meta_json)
123
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(p.ts ?? new Date().toISOString(), p.kind, p.name, p.event, p.agent ?? null, p.sessionId ?? null, p.machine ?? localMachineId(), p.actor ?? null, p.source ?? null, p.status ?? 'success', p.meta != null ? JSON.stringify(p.meta) : null);
124
+ }
125
+ catch {
126
+ /* telemetry must never break callers */
127
+ }
128
+ }
129
+ export function usageDbPath() {
130
+ return getUsageDbPath();
131
+ }
132
+ export function analyticsDir() {
133
+ return getAnalyticsDir();
134
+ }
135
+ export function listUsageKindsWithData(sinceIso) {
136
+ const db = open();
137
+ if (!db)
138
+ return [];
139
+ try {
140
+ const rows = db.prepare(`SELECT DISTINCT kind FROM usage_events WHERE ts >= ?`).all(sinceIso);
141
+ return rows.map((r) => r.kind).filter((k) => USAGE_KINDS.includes(k));
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ export function countUsage(opts) {
148
+ const db = open();
149
+ if (!db)
150
+ return 0;
151
+ try {
152
+ const clauses = ['ts >= ?'];
153
+ const args = [opts.sinceIso];
154
+ if (opts.kind) {
155
+ clauses.push('kind = ?');
156
+ args.push(opts.kind);
157
+ }
158
+ if (opts.name) {
159
+ clauses.push('name = ?');
160
+ args.push(opts.name);
161
+ }
162
+ if (opts.event) {
163
+ clauses.push('event = ?');
164
+ args.push(opts.event);
165
+ }
166
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM usage_events WHERE ${clauses.join(' AND ')}`).get(...args);
167
+ return row?.n ?? 0;
168
+ }
169
+ catch {
170
+ return 0;
171
+ }
172
+ }
173
+ export function queryUsage(opts) {
174
+ const db = open();
175
+ if (!db)
176
+ return [];
177
+ try {
178
+ const clauses = ['ts >= ?'];
179
+ const args = [opts.sinceIso];
180
+ if (opts.kind) {
181
+ clauses.push('kind = ?');
182
+ args.push(opts.kind);
183
+ }
184
+ if (opts.name) {
185
+ clauses.push('name = ?');
186
+ args.push(opts.name);
187
+ }
188
+ if (opts.event) {
189
+ clauses.push('event = ?');
190
+ args.push(opts.event);
191
+ }
192
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 100;
193
+ args.push(limit);
194
+ const rows = db.prepare(`SELECT ts, kind, name, event, agent, session_id AS sessionId, machine, actor, source, status, meta_json AS metaJson
195
+ FROM usage_events WHERE ${clauses.join(' AND ')}
196
+ ORDER BY ts DESC, id DESC LIMIT ?`).all(...args);
197
+ return rows;
198
+ }
199
+ catch {
200
+ return [];
201
+ }
202
+ }
203
+ export function topNamesByKind(kind, sinceIso, limit = 20) {
204
+ const db = open();
205
+ if (!db)
206
+ return [];
207
+ try {
208
+ return db.prepare(`SELECT name, COUNT(*) AS n, MAX(ts) AS last
209
+ FROM usage_events WHERE kind = ? AND ts >= ?
210
+ GROUP BY name ORDER BY n DESC LIMIT ?`).all(kind, sinceIso, limit);
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ }
216
+ export function kindMix(sinceIso) {
217
+ const db = open();
218
+ if (!db)
219
+ return [];
220
+ try {
221
+ return db.prepare(`SELECT kind, COUNT(*) AS n FROM usage_events WHERE ts >= ? GROUP BY kind ORDER BY n DESC`).all(sinceIso);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ }
227
+ export function getSecretBundleRollup(bundle) {
228
+ const db = open();
229
+ if (!db)
230
+ return [];
231
+ try {
232
+ return db.prepare(`SELECT event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
233
+ FROM usage_events WHERE kind = 'secret' AND name = ? GROUP BY event`).all(bundle);
234
+ }
235
+ catch {
236
+ return [];
237
+ }
238
+ }
239
+ export function getSecretBundleAgents(bundle) {
240
+ const db = open();
241
+ if (!db)
242
+ return [];
243
+ try {
244
+ return db.prepare(`SELECT agent, COUNT(*) AS n FROM usage_events
245
+ WHERE kind = 'secret' AND name = ? AND agent IS NOT NULL
246
+ GROUP BY agent ORDER BY n DESC`).all(bundle);
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ export function getAllSecretBundleRollups() {
253
+ const db = open();
254
+ if (!db)
255
+ return [];
256
+ try {
257
+ return db.prepare(`SELECT name, event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
258
+ FROM usage_events WHERE kind = 'secret' GROUP BY name, event`).all();
259
+ }
260
+ catch {
261
+ return [];
262
+ }
263
+ }
264
+ export function getSecretHistory(bundle, limit = 20) {
265
+ const db = open();
266
+ if (!db)
267
+ return [];
268
+ try {
269
+ const sql = bundle
270
+ ? `SELECT ts, name AS bundle, event, agent, source, status, meta_json
271
+ FROM usage_events WHERE kind = 'secret' AND name = ? ORDER BY ts DESC, id DESC LIMIT ?`
272
+ : `SELECT ts, name AS bundle, event, agent, source, status, meta_json
273
+ FROM usage_events WHERE kind = 'secret' ORDER BY ts DESC, id DESC LIMIT ?`;
274
+ const rows = (bundle ? db.prepare(sql).all(bundle, limit) : db.prepare(sql).all(limit));
275
+ return rows.map((r) => {
276
+ let host = null;
277
+ let keyCount = null;
278
+ if (r.meta_json) {
279
+ try {
280
+ const m = JSON.parse(r.meta_json);
281
+ host = m.host ?? null;
282
+ keyCount = typeof m.keyCount === 'number' ? m.keyCount : null;
283
+ }
284
+ catch { /* ignore */ }
285
+ }
286
+ return { ts: r.ts, bundle: r.bundle, event: r.event, agent: r.agent, host, source: r.source, status: r.status, keyCount };
287
+ });
288
+ }
289
+ catch {
290
+ return [];
291
+ }
292
+ }
293
+ export function closeUsageDb() {
294
+ if (cached) {
295
+ try {
296
+ cached.db.close();
297
+ }
298
+ catch { /* ignore */ }
299
+ cached = null;
300
+ }
301
+ }
@@ -346,6 +346,15 @@ export class BrowserService {
346
346
  conn.tasks.set(taskName, task);
347
347
  await this.saveTaskState(effectiveProfileName, conn.tasks);
348
348
  emit('browser.launch', { profile: effectiveProfileName, task: taskName, pid: conn.pid });
349
+ void import('../analytics/usage-db.js').then(({ recordUsage }) => {
350
+ recordUsage({
351
+ kind: 'browser',
352
+ name: effectiveProfileName,
353
+ event: 'launch',
354
+ source: 'browser',
355
+ meta: { task: taskName },
356
+ });
357
+ }).catch(() => { });
349
358
  // If URL provided, create tab directly (no about:blank)
350
359
  let tabId;
351
360
  if (opts.url && !conn.electron) {
@@ -412,6 +421,15 @@ export class BrowserService {
412
421
  conn.tasks.delete(taskName);
413
422
  await this.saveTaskState(profileName, conn.tasks);
414
423
  emit('browser.close', { profile: profileName, task: taskName });
424
+ void import('../analytics/usage-db.js').then(({ recordUsage }) => {
425
+ recordUsage({
426
+ kind: 'browser',
427
+ name: profileName,
428
+ event: 'close',
429
+ source: 'browser',
430
+ meta: { task: taskName },
431
+ });
432
+ }).catch(() => { });
415
433
  if (conn.forkedFrom && conn.tasks.size === 0) {
416
434
  conn.cdp.close();
417
435
  killChrome(conn.pid);
@@ -90,6 +90,15 @@ export declare function hasCommand(cmd: string): boolean;
90
90
  * shell, never interpolates strings into a command line.
91
91
  */
92
92
  export declare function isCliInstalled(manifest: CliManifest): boolean;
93
+ /**
94
+ * Async, non-blocking sibling of {@link isCliInstalled}. Same dispatch and
95
+ * Windows-shim retry, but over `execFile` so many manifests can be checked
96
+ * concurrently — the fix for RUSH-2136, where `agents doctor --json` ran a dozen+
97
+ * blocking 10s-timeout `spawnSync` checks SERIALLY (measured ~136s on an idle
98
+ * box). `execFile`'s `timeout` still SIGKILLs a wedged check, so a single
99
+ * hanging probe can't stall the whole set past 10s.
100
+ */
101
+ export declare function isCliInstalledAsync(manifest: CliManifest): Promise<boolean>;
93
102
  /**
94
103
  * Pick the first install method whose required host tool is available.
95
104
  * Returns null when none of the declared methods can run on this host.
@@ -151,3 +160,14 @@ export declare function listCliStatus(cwd?: string): {
151
160
  statuses: CliStatus[];
152
161
  errors: CliManifestError[];
153
162
  };
163
+ /**
164
+ * Async sibling of {@link listCliStatus} that probes every manifest CONCURRENTLY
165
+ * (RUSH-2136). The sync version runs each blocking `spawnSync` check one after
166
+ * another, so a dozen host CLIs whose checks are slow serialize into a
167
+ * multi-minute stall on `agents doctor --json`. This awaits them in parallel, so
168
+ * total wall time is the slowest single check (bounded at 10s), not their sum.
169
+ */
170
+ export declare function listCliStatusAsync(cwd?: string): Promise<{
171
+ statuses: CliStatus[];
172
+ errors: CliManifestError[];
173
+ }>;
@@ -18,7 +18,7 @@
18
18
  import * as fs from 'fs';
19
19
  import * as os from 'os';
20
20
  import * as path from 'path';
21
- import { spawnSync } from 'child_process';
21
+ import { spawnSync, execFile } from 'child_process';
22
22
  import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
24
  import { composeWin32CommandLine } from './platform/index.js';
@@ -304,6 +304,38 @@ export function isCliInstalled(manifest) {
304
304
  }
305
305
  return false;
306
306
  }
307
+ /**
308
+ * Async, non-blocking sibling of {@link isCliInstalled}. Same dispatch and
309
+ * Windows-shim retry, but over `execFile` so many manifests can be checked
310
+ * concurrently — the fix for RUSH-2136, where `agents doctor --json` ran a dozen+
311
+ * blocking 10s-timeout `spawnSync` checks SERIALLY (measured ~136s on an idle
312
+ * box). `execFile`'s `timeout` still SIGKILLs a wedged check, so a single
313
+ * hanging probe can't stall the whole set past 10s.
314
+ */
315
+ export function isCliInstalledAsync(manifest) {
316
+ const c = manifest.check;
317
+ if (c.kind === 'which') {
318
+ cmdExistsCache.delete(c.cmd);
319
+ return Promise.resolve(hasCommand(c.cmd));
320
+ }
321
+ return new Promise((resolve) => {
322
+ execFile(c.cmd, c.args, { timeout: 10_000 }, (err) => {
323
+ if (!err)
324
+ return resolve(true);
325
+ // A spawn failure (as opposed to a non-zero exit) surfaces as a string
326
+ // errno code (ENOENT/EINVAL); a non-zero exit surfaces as a numeric code.
327
+ // On Windows a `.cmd`/`.bat` shim spawn-fails without a shell — retry once
328
+ // through the shell, exactly as the sync path does.
329
+ const spawnFailed = typeof err.code === 'string';
330
+ if (process.platform === 'win32' && spawnFailed) {
331
+ const line = composeWin32CommandLine(c.cmd, c.args);
332
+ execFile(line, { timeout: 10_000, shell: true }, (retryErr) => resolve(!retryErr));
333
+ return;
334
+ }
335
+ resolve(false);
336
+ });
337
+ });
338
+ }
307
339
  // ─── Method selection ────────────────────────────────────────────────────────
308
340
  /**
309
341
  * Pick the first install method whose required host tool is available.
@@ -550,3 +582,18 @@ export function listCliStatus(cwd) {
550
582
  }));
551
583
  return { statuses, errors };
552
584
  }
585
+ /**
586
+ * Async sibling of {@link listCliStatus} that probes every manifest CONCURRENTLY
587
+ * (RUSH-2136). The sync version runs each blocking `spawnSync` check one after
588
+ * another, so a dozen host CLIs whose checks are slow serialize into a
589
+ * multi-minute stall on `agents doctor --json`. This awaits them in parallel, so
590
+ * total wall time is the slowest single check (bounded at 10s), not their sum.
591
+ */
592
+ export async function listCliStatusAsync(cwd) {
593
+ const { manifests, errors } = listCliManifests(cwd);
594
+ const statuses = await Promise.all(manifests.map(async (manifest) => ({
595
+ manifest,
596
+ installed: await isCliInstalledAsync(manifest),
597
+ })));
598
+ return { statuses, errors };
599
+ }
@@ -803,13 +803,17 @@ export async function runDaemon() {
803
803
  };
804
804
  const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
805
805
  const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
806
- // Fleet cache warm: keep the caches that `agents devices list`, `fleet status`,
807
- // and `agents view` read cache-first actually fresh, so a default read never
808
- // has to ssh out. Two cheap refreshes: (1) this host's auth-health verdicts
809
- // (also feeds the `doctor --json` Auth rollup other hosts read), and (2) the
810
- // fleet resource-stats cache (one bounded parallel probe of the tailnet). Both
811
- // best-effort + overlap-guarded like the probes above. ~every 3 min, plus once
812
- // ~60s after startup (staggered off launch).
806
+ // Fleet cache warm: publish THIS host's row for the caches `agents fleet
807
+ // status` / `agents devices list` read. PUBLISH-OWN / READ-UNION (RUSH-2061):
808
+ // each daemon probes only ITSELF and never SSHes another box, so the fleet no
809
+ // longer pays SSH resource probes every 3 minutes (N daemons × N devices)
810
+ // the source of the fan-out storm AND the orphaned-probe pile-up. Two cheap
811
+ // self-only refreshes: (1) this host's auth-health verdicts (also feeds the
812
+ // `doctor --json` Auth rollup other hosts read), and (2) its fleet-status row
813
+ // (local resource probe + live-agent workload). Cross-host rows are unioned on
814
+ // demand by the reader (`agents fleet status`), not pushed by every daemon.
815
+ // Best-effort + overlap-guarded like the probes above; ~every 3 min, once ~60s
816
+ // after startup.
813
817
  let warmingFleetCache = false;
814
818
  const runFleetCacheWarm = async () => {
815
819
  if (warmingFleetCache)
@@ -822,13 +826,9 @@ export async function runDaemon() {
822
826
  const { getCliVersion } = await import('./version.js');
823
827
  const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
824
828
  writeFleetAuthRows(self, authRows);
825
- const { loadDevices } = await import('./devices/registry.js');
826
- const { planFleetTargets } = await import('./devices/fleet.js');
827
- const { loadFleetStats } = await import('./devices/stats-cache.js');
828
- const reg = await loadDevices();
829
- const probeable = planFleetTargets(reg).filter((t) => !t.skip).map((t) => t.device);
830
- const res = await loadFleetStats(probeable, { forceRefresh: true, selfName: self });
831
- log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${res.stats.size} device stat(s)`);
829
+ const { publishLocalFleetStatus } = await import('./fleet-status.js');
830
+ const row = await publishLocalFleetStatus(self);
831
+ log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${row.agents.running} running agent(s) on ${self}`);
832
832
  }
833
833
  catch (err) {
834
834
  log('ERROR', `fleet cache warm failed: ${err.message}`);
@@ -839,6 +839,41 @@ export async function runDaemon() {
839
839
  };
840
840
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
841
841
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
842
+ // Adaptive usage refresh: keep the usage cache the `agents run` router reads
843
+ // (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
844
+ // This host is the sole writer for its own local accounts. The tick wakes at
845
+ // the 90s floor, but per-account cadence gates the actual live fetches: an
846
+ // account racing toward its 5h cap is polled sooner (down to 90s), an idle one
847
+ // rarely (up to 15min), capped at ~6 provider calls/account/hour and skipped
848
+ // entirely while its provider is under a 429 backoff. Overlap-guarded like the
849
+ // probes above; a box signed into no networked-usage account is a clean no-op.
850
+ let refreshingUsage = false;
851
+ const runUsageRefreshTick = async () => {
852
+ if (refreshingUsage)
853
+ return;
854
+ refreshingUsage = true;
855
+ try {
856
+ const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
857
+ const { writeClaudeUsageCache } = await import('./usage.js');
858
+ const { usageRateLimitedUntil } = await import('./usage-backoff.js');
859
+ const r = await runUsageRefresh({
860
+ listAccounts: buildLocalUsageAccounts,
861
+ writeUsageCache: writeClaudeUsageCache,
862
+ backoffUntil: usageRateLimitedUntil,
863
+ });
864
+ if (r.refreshed > 0 || r.failed > 0) {
865
+ log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
866
+ }
867
+ }
868
+ catch (err) {
869
+ log('ERROR', `usage refresh failed: ${err.message}`);
870
+ }
871
+ finally {
872
+ refreshingUsage = false;
873
+ }
874
+ };
875
+ const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 90_000);
876
+ const usageRefreshKickoff = setTimeout(() => { void runUsageRefreshTick(); }, 30_000);
842
877
  // RUSH-1817: the startup host decision above is one-shot. If a standalone
843
878
  // broker answered agentPing() at daemon start, the daemon declined to host —
844
879
  // but should that standalone later die or crash-loop, nothing takes over and
@@ -928,6 +963,8 @@ export async function runDaemon() {
928
963
  clearTimeout(launchHealthKickoff);
929
964
  clearInterval(fleetCacheInterval);
930
965
  clearTimeout(fleetCacheKickoff);
966
+ clearInterval(usageRefreshInterval);
967
+ clearTimeout(usageRefreshKickoff);
931
968
  clearInterval(brokerSelfHealInterval);
932
969
  hostedBroker?.close();
933
970
  removeDaemonPid();
@@ -2,6 +2,7 @@ import { type DeviceStats } from './health.js';
2
2
  import { type HostAuthSummary } from '../auth-health.js';
3
3
  import type { OnlineState } from './reachability.js';
4
4
  import { type FleetInventory } from './fleet-divergence.js';
5
+ import type { FleetAgentCounts } from '../fleet-status.js';
5
6
  export interface FleetCliStatus {
6
7
  installed: boolean;
7
8
  path: string | null;
@@ -46,6 +47,10 @@ export interface FleetHealthRow {
46
47
  * divergence detection (RUSH-2027). Undefined for an unreachable box or an
47
48
  * older CLI that doesn't emit it. */
48
49
  inventory?: FleetInventory;
50
+ /** This host's live agent workload (running-agent count + per-context / per-
51
+ * agent breakdown), from the fleet-status mirror / read-union (RUSH-2061).
52
+ * Undefined when no row has been published for the host yet. */
53
+ agents?: FleetAgentCounts;
49
54
  }
50
55
  export interface FleetWarning {
51
56
  kind: 'unreachable' | 'drift' | 'cli' | 'version-skew' | 'divergence';
@@ -445,6 +445,9 @@ export function renderFleetSummary(report, opts = {}) {
445
445
  else if (isGenuinelyOffline(row) && row.lastSeen) {
446
446
  marks.push(chalk.gray(`last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}`));
447
447
  }
448
+ if (row.agents && row.agents.running > 0) {
449
+ marks.push(chalk.cyan(`${row.agents.running} agent${row.agents.running === 1 ? '' : 's'}`));
450
+ }
448
451
  if (isSelf)
449
452
  marks.push(chalk.cyan('← this machine'));
450
453
  const note = marks.length ? ` ${marks.join(' ')}` : '';
@@ -1,3 +1,4 @@
1
+ import type { Meta } from './types.js';
1
2
  /** How loudly a post asks to be heard. Ordered — `important` implies milestone. */
2
3
  export type FeedPostLevel = 'milestone' | 'important';
3
4
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -6,9 +7,20 @@ export interface FeedSinkConfig {
6
7
  /**
7
8
  * argv to run, with `{placeholder}` tokens substituted. First element is the
8
9
  * program; it is spawned directly (no shell), so quoting is not a concern and
9
- * post text can never become shell syntax.
10
+ * post text can never become shell syntax. Mutually exclusive with `channel`
11
+ * — a sink is one shape or the other.
10
12
  */
11
- command: string[];
13
+ command?: string[];
14
+ /**
15
+ * In-process delivery through the same channel-provider registry `agents
16
+ * send`/`agents notify` use — the composed `{message}` body, no argv, no
17
+ * spawn. `'owner'` is the address alias (expands to `notify.owner.{channel,to}`
18
+ * in agents.yaml, same as `agents notify`); any other value is a registered
19
+ * channel name (or a `notify.transports` mapping) and requires `to`.
20
+ */
21
+ channel?: string;
22
+ /** Recipient for a `channel` sink. Required unless `channel` is the `owner` alias. */
23
+ to?: string;
12
24
  /** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
13
25
  minLevel?: FeedPostLevel;
14
26
  }
@@ -82,7 +94,14 @@ export declare function blockBroadcastContext(block: {
82
94
  export declare function blockDeliveryFailure(blocked: boolean, outcomes: SinkOutcome[]): string | undefined;
83
95
  export interface PlannedSink {
84
96
  name: string;
85
- argv: string[];
97
+ /** Command sink: argv to spawn (mutually exclusive with `channel`). */
98
+ argv?: string[];
99
+ /** Channel sink: provider channel name, or the `owner` alias. */
100
+ channel?: string;
101
+ /** Channel sink recipient. Unset for the `owner` alias — resolved at delivery. */
102
+ to?: string;
103
+ /** Channel sink body — the composed `{message}` for this post. */
104
+ text?: string;
86
105
  }
87
106
  export interface SinkOutcome {
88
107
  name: string;
@@ -139,11 +158,37 @@ export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastCon
139
158
  /**
140
159
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
141
160
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
161
+ *
162
+ * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
163
+ * one level check for both shapes, so a dry-run plan is truthful regardless of
164
+ * which shape an operator's sink uses.
142
165
  */
143
166
  export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
144
167
  /**
145
- * Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
146
- * that fails or is not installed is reported, never thrown the post is already
147
- * written and must not be undone by a mirror that could not be reached.
168
+ * The effective sink config for a post: the operator's `feed.broadcast`, or
169
+ * when that is unset or empty an implicit fallback straight to
170
+ * `notify.owner`, for a post worth interrupting someone over.
171
+ *
172
+ * Before this, `broadcastPostedEvent`/`broadcastBlock` returned early the
173
+ * moment `feed.broadcast` was empty, even when `notify.owner` was fully
174
+ * configured — so the common case (an operator who set up owner notifications
175
+ * but never wrote a `feed.broadcast` block) produced a `--blocked` post that
176
+ * looked recorded and reached nobody. `agents notify` already treats
177
+ * `notify.owner` as the default human destination; this makes an important
178
+ * feed post/block use that same default instead of requiring a second,
179
+ * redundant config block that says the same thing.
180
+ *
181
+ * The fallback only fires for `important` — a routine `milestone` post stays
182
+ * record-only, matching the `minLevel` contract every declared sink already
183
+ * follows. An operator-declared `feed.broadcast` (any non-empty config)
184
+ * always wins outright; the fallback never layers on top of it.
185
+ */
186
+ export declare function effectiveBroadcastConfig(config: FeedBroadcastConfig | undefined, level: FeedPostLevel, meta: Meta): FeedBroadcastConfig | undefined;
187
+ /**
188
+ * Run the planned sinks. A `command:` sink is a direct spawn with a bounded
189
+ * lifetime; a `channel:` sink delivers in-process. Either way a sink that
190
+ * fails or is not installed/registered is reported, never thrown — the post
191
+ * is already written and must not be undone by a mirror that could not be
192
+ * reached.
148
193
  */
149
- export declare function runFeedBroadcast(planned: PlannedSink[], timeoutMs?: number): SinkOutcome[];
194
+ export declare function runFeedBroadcast(planned: PlannedSink[], meta: Meta, timeoutMs?: number): Promise<SinkOutcome[]>;