@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.
- package/CHANGELOG.md +172 -0
- package/README.md +1 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +28 -19
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +14 -1
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +18 -0
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/feed-broadcast.d.ts +52 -7
- package/dist/lib/feed-broadcast.js +125 -18
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.js +72 -17
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +39 -0
- package/dist/lib/runner.js +34 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +8 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- 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
|
+
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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:
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
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 N² 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 {
|
|
826
|
-
const
|
|
827
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
146
|
-
* that
|
|
147
|
-
*
|
|
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[]>;
|