@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
|
@@ -23,8 +23,20 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Delivery is best-effort and reported: a sink that fails prints a warning and
|
|
25
25
|
* the post still stands. Losing a mirror must never cost the operator the post.
|
|
26
|
+
*
|
|
27
|
+
* A second sink shape (RUSH-2123) delivers **in-process** through the same
|
|
28
|
+
* channel-provider registry `agents send` uses (`channel:` instead of
|
|
29
|
+
* `command:`) — no spawn, no argv templating. `channel: owner` is the address
|
|
30
|
+
* alias that expands to `notify.owner.{channel,to}`, matching `agents notify`.
|
|
31
|
+
* When the operator has never written a `feed.broadcast` block at all, an
|
|
32
|
+
* important-level post falls back to that owner address implicitly
|
|
33
|
+
* ({@link effectiveBroadcastConfig}) rather than reaching nobody — see that
|
|
34
|
+
* function's doc for why this was a silent failure before.
|
|
26
35
|
*/
|
|
27
36
|
import { spawnSync } from 'child_process';
|
|
37
|
+
import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
|
|
38
|
+
import { lookupTransport } from './channels/resolve.js';
|
|
39
|
+
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
28
40
|
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
29
41
|
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
30
42
|
export function parseFeedPostLevel(raw) {
|
|
@@ -251,17 +263,39 @@ export function renderSinkArgv(template, ctx) {
|
|
|
251
263
|
/**
|
|
252
264
|
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
253
265
|
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
266
|
+
*
|
|
267
|
+
* A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
|
|
268
|
+
* one level check for both shapes, so a dry-run plan is truthful regardless of
|
|
269
|
+
* which shape an operator's sink uses.
|
|
254
270
|
*/
|
|
255
271
|
export function planFeedBroadcast(config, ctx) {
|
|
256
272
|
if (!config)
|
|
257
273
|
return [];
|
|
258
274
|
const planned = [];
|
|
259
275
|
for (const [name, sink] of Object.entries(config)) {
|
|
260
|
-
if (!
|
|
276
|
+
if (!sink)
|
|
261
277
|
continue;
|
|
262
278
|
const min = sink.minLevel ?? 'milestone';
|
|
263
279
|
if (LEVEL_RANK[ctx.level] < LEVEL_RANK[min])
|
|
264
280
|
continue;
|
|
281
|
+
const channel = sink.channel?.trim();
|
|
282
|
+
if (channel) {
|
|
283
|
+
// The owner alias resolves its recipient from notify.owner at delivery
|
|
284
|
+
// time; any other channel name needs an explicit recipient now, or the
|
|
285
|
+
// sink can never fire with a hole in it (same contract as a missing argv
|
|
286
|
+
// placeholder below).
|
|
287
|
+
if (!isOwnerAlias(channel) && !sink.to?.trim())
|
|
288
|
+
continue;
|
|
289
|
+
planned.push({
|
|
290
|
+
name,
|
|
291
|
+
channel,
|
|
292
|
+
to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
|
|
293
|
+
text: composeBroadcastMessage(ctx),
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (!Array.isArray(sink.command) || sink.command.length === 0)
|
|
298
|
+
continue;
|
|
265
299
|
const argv = renderSinkArgv(sink.command, ctx);
|
|
266
300
|
if (!argv)
|
|
267
301
|
continue;
|
|
@@ -270,24 +304,97 @@ export function planFeedBroadcast(config, ctx) {
|
|
|
270
304
|
return planned;
|
|
271
305
|
}
|
|
272
306
|
/**
|
|
273
|
-
*
|
|
274
|
-
* that
|
|
275
|
-
*
|
|
307
|
+
* The effective sink config for a post: the operator's `feed.broadcast`, or —
|
|
308
|
+
* when that is unset or empty — an implicit fallback straight to
|
|
309
|
+
* `notify.owner`, for a post worth interrupting someone over.
|
|
310
|
+
*
|
|
311
|
+
* Before this, `broadcastPostedEvent`/`broadcastBlock` returned early the
|
|
312
|
+
* moment `feed.broadcast` was empty, even when `notify.owner` was fully
|
|
313
|
+
* configured — so the common case (an operator who set up owner notifications
|
|
314
|
+
* but never wrote a `feed.broadcast` block) produced a `--blocked` post that
|
|
315
|
+
* looked recorded and reached nobody. `agents notify` already treats
|
|
316
|
+
* `notify.owner` as the default human destination; this makes an important
|
|
317
|
+
* feed post/block use that same default instead of requiring a second,
|
|
318
|
+
* redundant config block that says the same thing.
|
|
319
|
+
*
|
|
320
|
+
* The fallback only fires for `important` — a routine `milestone` post stays
|
|
321
|
+
* record-only, matching the `minLevel` contract every declared sink already
|
|
322
|
+
* follows. An operator-declared `feed.broadcast` (any non-empty config)
|
|
323
|
+
* always wins outright; the fallback never layers on top of it.
|
|
276
324
|
*/
|
|
277
|
-
export function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
325
|
+
export function effectiveBroadcastConfig(config, level, meta) {
|
|
326
|
+
if (config && Object.keys(config).length > 0)
|
|
327
|
+
return config;
|
|
328
|
+
if (level !== 'important')
|
|
329
|
+
return undefined;
|
|
330
|
+
if (!readOwnerDest(meta))
|
|
331
|
+
return undefined;
|
|
332
|
+
return { owner: { channel: 'owner' } };
|
|
333
|
+
}
|
|
334
|
+
function runCommandSink(name, argv, timeoutMs) {
|
|
335
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
336
|
+
encoding: 'utf-8',
|
|
337
|
+
timeout: timeoutMs,
|
|
338
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
339
|
+
});
|
|
340
|
+
if (result.error) {
|
|
341
|
+
return { name, ok: false, error: result.error.message };
|
|
342
|
+
}
|
|
343
|
+
if (result.status !== 0) {
|
|
344
|
+
const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
|
|
345
|
+
return { name, ok: false, error: tail || `exited ${result.status}` };
|
|
346
|
+
}
|
|
347
|
+
return { name, ok: true };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Deliver one `channel:` sink through the real provider registry —
|
|
351
|
+
* `resolveSendEnvelope` reuses `agents notify`'s owner-alias expansion, and
|
|
352
|
+
* `deliverEnvelope` is the same seam `agents send` calls. A bad channel name
|
|
353
|
+
* is checked with `lookupTransport` (the non-throwing lookup) BEFORE handing
|
|
354
|
+
* off to `deliverEnvelope`: that function's own resolution `die()`s on an
|
|
355
|
+
* unregistered provider, which is the right answer for an interactive `agents
|
|
356
|
+
* send` typo but would take the whole broadcast fan-out down with it here —
|
|
357
|
+
* one misconfigured sink must report a failure, not kill the process running
|
|
358
|
+
* every other sink.
|
|
359
|
+
*/
|
|
360
|
+
async function runChannelSink(sink, meta) {
|
|
361
|
+
const name = sink.name;
|
|
362
|
+
// Registration is idempotent and normally happens inside deliverEnvelope();
|
|
363
|
+
// it has to happen before the lookupTransport pre-check below too, or the
|
|
364
|
+
// very first channel sink in a process would report "no channel provider"
|
|
365
|
+
// for a name that is, in fact, registered.
|
|
366
|
+
registerBuiltinProviders();
|
|
367
|
+
const owner = isOwnerAlias(sink.channel);
|
|
368
|
+
const resolved = resolveSendEnvelope({
|
|
369
|
+
text: sink.text ?? '',
|
|
370
|
+
channel: owner ? undefined : sink.channel,
|
|
371
|
+
to: owner ? 'owner' : sink.to,
|
|
372
|
+
ownerMode: owner,
|
|
373
|
+
}, meta);
|
|
374
|
+
if (!resolved.ok)
|
|
375
|
+
return { name, ok: false, error: resolved.error };
|
|
376
|
+
const { provider, error } = lookupTransport(resolved.envelope.channel, meta);
|
|
377
|
+
if (!provider)
|
|
378
|
+
return { name, ok: false, error };
|
|
379
|
+
const result = await deliverEnvelope(resolved.envelope, meta);
|
|
380
|
+
return result.ok ? { name, ok: true } : { name, ok: false, error: result.error };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Run the planned sinks. A `command:` sink is a direct spawn with a bounded
|
|
384
|
+
* lifetime; a `channel:` sink delivers in-process. Either way a sink that
|
|
385
|
+
* fails or is not installed/registered is reported, never thrown — the post
|
|
386
|
+
* is already written and must not be undone by a mirror that could not be
|
|
387
|
+
* reached.
|
|
388
|
+
*/
|
|
389
|
+
export async function runFeedBroadcast(planned, meta, timeoutMs = 20_000) {
|
|
390
|
+
const outcomes = [];
|
|
391
|
+
for (const sink of planned) {
|
|
392
|
+
if (sink.channel) {
|
|
393
|
+
outcomes.push(await runChannelSink(sink, meta));
|
|
286
394
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return { name, ok: false, error: tail || `exited ${result.status}` };
|
|
395
|
+
else {
|
|
396
|
+
outcomes.push(runCommandSink(sink.name, sink.argv ?? [], timeoutMs));
|
|
290
397
|
}
|
|
291
|
-
|
|
292
|
-
|
|
398
|
+
}
|
|
399
|
+
return outcomes;
|
|
293
400
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One synchronous, disk-only read facade over the fleet/usage caches.
|
|
3
|
+
*
|
|
4
|
+
* These readers NEVER touch the network and NEVER SSH — they read the caches
|
|
5
|
+
* the daemon keeps warm (usage snapshot, projected headroom, fleet status) and
|
|
6
|
+
* return instantly. That is the whole point: the routing hot path
|
|
7
|
+
* (`agents run` → rotate.ts), device affinity (smart-launch.ts), and Factory can
|
|
8
|
+
* consult live-ish fleet state without paying a provider fetch or an ssh probe
|
|
9
|
+
* on a latency-sensitive path.
|
|
10
|
+
*
|
|
11
|
+
* Writers live elsewhere (the daemon's `runUsageRefresh` for usage/headroom,
|
|
12
|
+
* `runFleetStatusPublish` for the fleet-status mirror); this module is the read
|
|
13
|
+
* side only.
|
|
14
|
+
*/
|
|
15
|
+
import { type UsageSnapshot } from './usage.js';
|
|
16
|
+
import { type FleetStatusRow } from './fleet-status.js';
|
|
17
|
+
/**
|
|
18
|
+
* The fleet-status union the daemon publishes (own row) and the fleet-status
|
|
19
|
+
* command unions (peer rows) — this host's stats + agent workload for every
|
|
20
|
+
* known host, keyed by host. Cache-only: a cold mirror yields an empty map.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readFleetStatus(): Record<string, FleetStatusRow>;
|
|
23
|
+
export type { FleetStatusRow };
|
|
24
|
+
/** The last cached usage snapshot for an account, or null. Cache-only. */
|
|
25
|
+
export declare function readAccountUsage(usageKey: string): UsageSnapshot | null;
|
|
26
|
+
/** An account's projected headroom, as published by the daemon refresher. */
|
|
27
|
+
export interface AccountHeadroom {
|
|
28
|
+
status: 'available' | 'rate_limited' | null;
|
|
29
|
+
/** Projected minutes until the session window caps; null = unknown/idle. */
|
|
30
|
+
minutesToLimit: number | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The daemon-computed headroom for an account, or null when nothing has been
|
|
34
|
+
* published yet. Cache-only — a cold cache simply yields null, and callers
|
|
35
|
+
* degrade to snapshot-only behavior.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readAccountHeadroom(usageKey: string): AccountHeadroom | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One synchronous, disk-only read facade over the fleet/usage caches.
|
|
3
|
+
*
|
|
4
|
+
* These readers NEVER touch the network and NEVER SSH — they read the caches
|
|
5
|
+
* the daemon keeps warm (usage snapshot, projected headroom, fleet status) and
|
|
6
|
+
* return instantly. That is the whole point: the routing hot path
|
|
7
|
+
* (`agents run` → rotate.ts), device affinity (smart-launch.ts), and Factory can
|
|
8
|
+
* consult live-ish fleet state without paying a provider fetch or an ssh probe
|
|
9
|
+
* on a latency-sensitive path.
|
|
10
|
+
*
|
|
11
|
+
* Writers live elsewhere (the daemon's `runUsageRefresh` for usage/headroom,
|
|
12
|
+
* `runFleetStatusPublish` for the fleet-status mirror); this module is the read
|
|
13
|
+
* side only.
|
|
14
|
+
*/
|
|
15
|
+
import { readClaudeUsageCache } from './usage.js';
|
|
16
|
+
import { readHeadroomEntry } from './usage-refresh.js';
|
|
17
|
+
import { readFleetStatus as readFleetStatusMirror } from './fleet-status.js';
|
|
18
|
+
/**
|
|
19
|
+
* The fleet-status union the daemon publishes (own row) and the fleet-status
|
|
20
|
+
* command unions (peer rows) — this host's stats + agent workload for every
|
|
21
|
+
* known host, keyed by host. Cache-only: a cold mirror yields an empty map.
|
|
22
|
+
*/
|
|
23
|
+
export function readFleetStatus() {
|
|
24
|
+
return readFleetStatusMirror();
|
|
25
|
+
}
|
|
26
|
+
/** The last cached usage snapshot for an account, or null. Cache-only. */
|
|
27
|
+
export function readAccountUsage(usageKey) {
|
|
28
|
+
return readClaudeUsageCache(usageKey);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The daemon-computed headroom for an account, or null when nothing has been
|
|
32
|
+
* published yet. Cache-only — a cold cache simply yields null, and callers
|
|
33
|
+
* degrade to snapshot-only behavior.
|
|
34
|
+
*/
|
|
35
|
+
export function readAccountHeadroom(usageKey) {
|
|
36
|
+
const entry = readHeadroomEntry(usageKey);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return null;
|
|
39
|
+
return { status: entry.status, minutesToLimit: entry.minutesToLimit };
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type DeviceStats } from './devices/health.js';
|
|
2
|
+
/** Live agent workload on a host. */
|
|
3
|
+
export interface FleetAgentCounts {
|
|
4
|
+
/** Sessions actively working (status === 'running'). */
|
|
5
|
+
running: number;
|
|
6
|
+
/** Total live sessions regardless of status (running/idle/input-required/…). */
|
|
7
|
+
live: number;
|
|
8
|
+
/** Running count broken down by context: terminal / teams / cloud / headless. */
|
|
9
|
+
byContext: Record<string, number>;
|
|
10
|
+
/** Running count broken down by agent CLI: claude / codex / cursor / … */
|
|
11
|
+
byAgent: Record<string, number>;
|
|
12
|
+
}
|
|
13
|
+
/** One host's published status row. */
|
|
14
|
+
export interface FleetStatusRow {
|
|
15
|
+
host: string;
|
|
16
|
+
agents: FleetAgentCounts;
|
|
17
|
+
/** Resource stats from the local probe; null when the probe produced nothing. */
|
|
18
|
+
stats: DeviceStats | null;
|
|
19
|
+
/** Epoch ms this row was computed. */
|
|
20
|
+
capturedAt: number;
|
|
21
|
+
}
|
|
22
|
+
/** Minimal shape needed to count workload — a subset of ActiveSession. */
|
|
23
|
+
type CountableSession = {
|
|
24
|
+
status?: string;
|
|
25
|
+
context?: string;
|
|
26
|
+
kind?: string;
|
|
27
|
+
pidAlive?: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Tally running-agent workload from a host's live sessions. "running" is the
|
|
31
|
+
* actively-working set (`status === 'running'`); `live` is every session the
|
|
32
|
+
* host is tracking. Pure so the tally is unit-tested without a live scan.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computeAgentCounts(sessions: ReadonlyArray<CountableSession>): FleetAgentCounts;
|
|
35
|
+
/**
|
|
36
|
+
* Probe THIS host's status — resource stats locally (no SSH) and agent workload
|
|
37
|
+
* from the local live-session set (`getActiveSessions({ localOnly: true })`,
|
|
38
|
+
* which never dials a remote host). Never throws: a failed sub-probe degrades to
|
|
39
|
+
* null stats / zero counts.
|
|
40
|
+
*/
|
|
41
|
+
export declare function probeLocalFleetStatus(host: string, now?: number): Promise<FleetStatusRow>;
|
|
42
|
+
export declare function setFleetStatusMirrorPathForTest(mirrorPath: string | null): string | null;
|
|
43
|
+
/** Read the whole fleet-status mirror (best-effort; missing/corrupt → empty). */
|
|
44
|
+
export declare function readFleetStatus(): Record<string, FleetStatusRow>;
|
|
45
|
+
/** Merge rows into the mirror (best-effort; preserves other hosts' rows). */
|
|
46
|
+
export declare function writeFleetStatusRows(entries: Record<string, FleetStatusRow>): void;
|
|
47
|
+
/**
|
|
48
|
+
* Publish THIS host's row into the mirror (probe self, no SSH). The daemon calls
|
|
49
|
+
* this on its warm tick; it is the whole of the daemon's fleet-status duty now
|
|
50
|
+
* that cross-host probing is gone.
|
|
51
|
+
*/
|
|
52
|
+
export declare function publishLocalFleetStatus(host: string): Promise<FleetStatusRow>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-host fleet-status rows: this machine's resource stats PLUS its live agent
|
|
3
|
+
* workload (running-agent count + per-context / per-agent breakdown), published
|
|
4
|
+
* to a shared local mirror.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists (RUSH-2061)
|
|
7
|
+
*
|
|
8
|
+
* The daemon's fleet-cache warm used to `loadFleetStats({ forceRefresh: true })`
|
|
9
|
+
* every 3 minutes — an SSH resource probe of EVERY device. With N daemons each
|
|
10
|
+
* probing N devices that is N² SSH round trips across the fleet every 3 minutes,
|
|
11
|
+
* each a remote `uptime;vm_stat;nproc` compute with a timeout that (pre-RUSH-2114)
|
|
12
|
+
* could orphan the remote child. This module replaces that with a
|
|
13
|
+
* **publish-own / read-union** model:
|
|
14
|
+
*
|
|
15
|
+
* - Each daemon probes ONLY ITSELF (`probeLocalFleetStatus`, no SSH) and writes
|
|
16
|
+
* its own row into the mirror. Zero cross-host SSH from the daemon → the N²
|
|
17
|
+
* probe is gone.
|
|
18
|
+
* - A READER (the `agents fleet status` command) unions the fleet's rows on
|
|
19
|
+
* demand — a bounded, kill-on-timeout SSH read of each peer's already-computed
|
|
20
|
+
* `--local` row (a cheap `cat`-equivalent, not a fresh remote probe) — and
|
|
21
|
+
* writes them into the same mirror. `readFleetStatus` then serves the union
|
|
22
|
+
* synchronously with no network at all.
|
|
23
|
+
*
|
|
24
|
+
* The mirror file matches the `stats-cache` / `auth-health` convention exactly:
|
|
25
|
+
* `{ version: 1, entries: Record<host, FleetStatusRow> }` under `getCacheDir()`,
|
|
26
|
+
* keyed by `machineId()`, best-effort read/write that never throws.
|
|
27
|
+
*/
|
|
28
|
+
import * as fs from 'fs';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
import { getCacheDir } from './state.js';
|
|
31
|
+
import { probeLocalStats } from './devices/health.js';
|
|
32
|
+
import { getActiveSessions } from './session/active.js';
|
|
33
|
+
/**
|
|
34
|
+
* Tally running-agent workload from a host's live sessions. "running" is the
|
|
35
|
+
* actively-working set (`status === 'running'`); `live` is every session the
|
|
36
|
+
* host is tracking. Pure so the tally is unit-tested without a live scan.
|
|
37
|
+
*/
|
|
38
|
+
export function computeAgentCounts(sessions) {
|
|
39
|
+
const byContext = {};
|
|
40
|
+
const byAgent = {};
|
|
41
|
+
let running = 0;
|
|
42
|
+
let live = 0;
|
|
43
|
+
for (const s of sessions) {
|
|
44
|
+
live += 1;
|
|
45
|
+
if (s.status !== 'running')
|
|
46
|
+
continue;
|
|
47
|
+
running += 1;
|
|
48
|
+
const ctx = s.context ?? 'unknown';
|
|
49
|
+
const agent = s.kind ?? 'unknown';
|
|
50
|
+
byContext[ctx] = (byContext[ctx] ?? 0) + 1;
|
|
51
|
+
byAgent[agent] = (byAgent[agent] ?? 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
return { running, live, byContext, byAgent };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Probe THIS host's status — resource stats locally (no SSH) and agent workload
|
|
57
|
+
* from the local live-session set (`getActiveSessions({ localOnly: true })`,
|
|
58
|
+
* which never dials a remote host). Never throws: a failed sub-probe degrades to
|
|
59
|
+
* null stats / zero counts.
|
|
60
|
+
*/
|
|
61
|
+
export async function probeLocalFleetStatus(host, now = Date.now()) {
|
|
62
|
+
const [stats, sessions] = await Promise.all([
|
|
63
|
+
probeLocalStats(host).catch(() => null),
|
|
64
|
+
getActiveSessions({ localOnly: true }).catch(() => []),
|
|
65
|
+
]);
|
|
66
|
+
return {
|
|
67
|
+
host,
|
|
68
|
+
agents: computeAgentCounts(sessions),
|
|
69
|
+
stats: stats ?? null,
|
|
70
|
+
capturedAt: now,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Test seam for the mirror path (see usage.ts `setClaudeUsageCachePathForTest`). */
|
|
74
|
+
let mirrorPathOverride = null;
|
|
75
|
+
export function setFleetStatusMirrorPathForTest(mirrorPath) {
|
|
76
|
+
const prev = mirrorPathOverride;
|
|
77
|
+
mirrorPathOverride = mirrorPath;
|
|
78
|
+
return prev;
|
|
79
|
+
}
|
|
80
|
+
function mirrorPath() {
|
|
81
|
+
return mirrorPathOverride ?? path.join(getCacheDir(), '.fleet-status.json');
|
|
82
|
+
}
|
|
83
|
+
/** Read the whole fleet-status mirror (best-effort; missing/corrupt → empty). */
|
|
84
|
+
export function readFleetStatus() {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(fs.readFileSync(mirrorPath(), 'utf-8'));
|
|
87
|
+
if (parsed && parsed.entries && typeof parsed.entries === 'object')
|
|
88
|
+
return parsed.entries;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// missing or corrupt — treat as empty
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
/** Merge rows into the mirror (best-effort; preserves other hosts' rows). */
|
|
96
|
+
export function writeFleetStatusRows(entries) {
|
|
97
|
+
try {
|
|
98
|
+
const dir = getCacheDir();
|
|
99
|
+
if (!fs.existsSync(dir))
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const merged = {
|
|
102
|
+
version: 1,
|
|
103
|
+
entries: { ...readFleetStatus(), ...entries },
|
|
104
|
+
};
|
|
105
|
+
fs.writeFileSync(mirrorPath(), JSON.stringify(merged, null, 2));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// best-effort; a failed write just means the reader sees an older union
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Publish THIS host's row into the mirror (probe self, no SSH). The daemon calls
|
|
113
|
+
* this on its warm tick; it is the whole of the daemon's fleet-status duty now
|
|
114
|
+
* that cross-host probing is gone.
|
|
115
|
+
*/
|
|
116
|
+
export async function publishLocalFleetStatus(host) {
|
|
117
|
+
const row = await probeLocalFleetStatus(host);
|
|
118
|
+
writeFleetStatusRows({ [host]: row });
|
|
119
|
+
return row;
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap heuristic readers over the `friction` event sink (emitFriction in
|
|
3
|
+
* events.ts). Guard hooks (git-guard, rm-guard, git-require-clean-tree) call
|
|
4
|
+
* `agents _internal friction --surface guard --id <failureId>` when they block
|
|
5
|
+
* a destructive command, so it exists in the log — but nothing reads it back
|
|
6
|
+
* yet. This is a starting point: one detector for the most actionable pattern,
|
|
7
|
+
* an agent stuck retrying the SAME denied action instead of adapting.
|
|
8
|
+
*/
|
|
9
|
+
import type { EventRecord } from './events.js';
|
|
10
|
+
export interface RepeatedGuardBlockFinding {
|
|
11
|
+
/** Session id the repeated blocks happened in, or 'unknown' when the
|
|
12
|
+
* friction event carried no session (e.g. a guard fired outside any
|
|
13
|
+
* tracked agent session). */
|
|
14
|
+
session: string;
|
|
15
|
+
surface: string;
|
|
16
|
+
failureId: string;
|
|
17
|
+
/** Number of times this exact (session, surface, failureId) blocked. */
|
|
18
|
+
count: number;
|
|
19
|
+
firstTs: string;
|
|
20
|
+
lastTs: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Group `friction` events by (session, surface, failureId) and flag groups
|
|
24
|
+
* that repeat at least `minRepeats` times — the signature of an agent hitting
|
|
25
|
+
* the same guard over and over rather than changing approach after the first
|
|
26
|
+
* block. `events` is expected to already be filtered/queried for
|
|
27
|
+
* `eventTypes: ['friction']` (see events.ts `query()`); non-friction records
|
|
28
|
+
* are ignored defensively rather than assumed absent.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectRepeatedGuardBlocks(events: EventRecord[], opts?: {
|
|
31
|
+
minRepeats?: number;
|
|
32
|
+
}): RepeatedGuardBlockFinding[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function asNonEmptyString(v) {
|
|
2
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Group `friction` events by (session, surface, failureId) and flag groups
|
|
6
|
+
* that repeat at least `minRepeats` times — the signature of an agent hitting
|
|
7
|
+
* the same guard over and over rather than changing approach after the first
|
|
8
|
+
* block. `events` is expected to already be filtered/queried for
|
|
9
|
+
* `eventTypes: ['friction']` (see events.ts `query()`); non-friction records
|
|
10
|
+
* are ignored defensively rather than assumed absent.
|
|
11
|
+
*/
|
|
12
|
+
export function detectRepeatedGuardBlocks(events, opts = {}) {
|
|
13
|
+
const minRepeats = opts.minRepeats ?? 3;
|
|
14
|
+
const groups = new Map();
|
|
15
|
+
for (const e of events) {
|
|
16
|
+
if (e.event !== 'friction')
|
|
17
|
+
continue;
|
|
18
|
+
const surface = asNonEmptyString(e.surface);
|
|
19
|
+
const failureId = asNonEmptyString(e.failureId);
|
|
20
|
+
if (!surface || !failureId)
|
|
21
|
+
continue;
|
|
22
|
+
const session = e.session ?? 'unknown';
|
|
23
|
+
const key = `${session}\0${surface}\0${failureId}`;
|
|
24
|
+
const bucket = groups.get(key);
|
|
25
|
+
if (bucket)
|
|
26
|
+
bucket.push(e);
|
|
27
|
+
else
|
|
28
|
+
groups.set(key, [e]);
|
|
29
|
+
}
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const [key, evs] of groups) {
|
|
32
|
+
if (evs.length < minRepeats)
|
|
33
|
+
continue;
|
|
34
|
+
const [session, surface, failureId] = key.split('\0');
|
|
35
|
+
const sortedTs = evs.map((e) => e.ts).sort();
|
|
36
|
+
out.push({
|
|
37
|
+
session,
|
|
38
|
+
surface,
|
|
39
|
+
failureId,
|
|
40
|
+
count: evs.length,
|
|
41
|
+
firstTs: sortedTs[0],
|
|
42
|
+
lastTs: sortedTs[sortedTs.length - 1],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
out.sort((a, b) => b.count - a.count);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -308,8 +308,8 @@ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%
|
|
|
308
308
|
mkdir -p "$PERF_DIR" 2>/dev/null || true
|
|
309
309
|
TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
310
310
|
HOST=$(hostname 2>/dev/null || echo unknown)
|
|
311
|
-
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
|
|
312
|
-
"$TS_MS" "$HOOK_NAME" "$MS" "none" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
311
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"%s}\\n' \\
|
|
312
|
+
"$TS_MS" "$HOOK_NAME" "$MS" "none" "$EXIT" "$HOST" "$HOOK_EXTRA_JSON" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
313
313
|
|
|
314
314
|
exit "$EXIT"`;
|
|
315
315
|
/**
|
|
@@ -375,6 +375,28 @@ done
|
|
|
375
375
|
# Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
|
|
376
376
|
STDIN_PAYLOAD="$(cat || true)"
|
|
377
377
|
|
|
378
|
+
# cwd + session_id from the hook's own stdin JSON, pre-escaped as a ready-to-
|
|
379
|
+
# splice JSON fragment (e.g. ,"cwd":"/repo","session_id":"abc") so every
|
|
380
|
+
# hook.fire perf-spool line below can carry them without re-parsing stdin per
|
|
381
|
+
# site. This is what lets \`agents perf --project\` (project-key.ts resolves
|
|
382
|
+
# cwd -> project) and session-scoped rollups work for hook.fire samples.
|
|
383
|
+
# Fail-safe: any parse error yields an empty fragment, never breaks the write.
|
|
384
|
+
HOOK_EXTRA_JSON="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c '
|
|
385
|
+
import json, sys
|
|
386
|
+
try:
|
|
387
|
+
d = json.load(sys.stdin)
|
|
388
|
+
except Exception:
|
|
389
|
+
d = {}
|
|
390
|
+
out = {}
|
|
391
|
+
cwd = d.get("cwd")
|
|
392
|
+
if isinstance(cwd, str) and cwd:
|
|
393
|
+
out["cwd"] = cwd
|
|
394
|
+
sid = d.get("session_id") or d.get("sessionId")
|
|
395
|
+
if isinstance(sid, str) and sid:
|
|
396
|
+
out["session_id"] = sid
|
|
397
|
+
print("".join("," + json.dumps(k) + ":" + json.dumps(v) for k, v in out.items()))
|
|
398
|
+
' 2>/dev/null || true)"
|
|
399
|
+
|
|
378
400
|
# --- matches: gate (issue #744 / RUSH-1506) -------------------------------
|
|
379
401
|
# Enforce the hook's declared \`matches:\` predicates at fire time. Mirrors
|
|
380
402
|
# shouldFire() in src/lib/hooks/match.ts: all declared predicates AND together;
|
|
@@ -393,8 +415,8 @@ if [ -n "$MATCHES_JSON" ]; then
|
|
|
393
415
|
mkdir -p "$PERF_DIR" 2>/dev/null || true
|
|
394
416
|
_TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
395
417
|
_HOST=$(hostname 2>/dev/null || echo unknown)
|
|
396
|
-
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":0,"cache":"skip","exit_code":0,"hostname":"%s"}\\n' \\
|
|
397
|
-
"$_TS_MS" "$HOOK_NAME" "$_HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
418
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":0,"cache":"skip","exit_code":0,"hostname":"%s"%s}\\n' \\
|
|
419
|
+
"$_TS_MS" "$HOOK_NAME" "$_HOST" "$HOOK_EXTRA_JSON" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
398
420
|
exit 0
|
|
399
421
|
fi
|
|
400
422
|
fi
|
|
@@ -495,8 +517,8 @@ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%
|
|
|
495
517
|
# Disposable perf spool → drained into perf.db (see lib/perf/db.ts). Soft keys only.
|
|
496
518
|
TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
497
519
|
HOST=$(hostname 2>/dev/null || echo unknown)
|
|
498
|
-
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
|
|
499
|
-
"$TS_MS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
520
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"%s}\\n' \\
|
|
521
|
+
"$TS_MS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" "$HOST" "$HOOK_EXTRA_JSON" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
500
522
|
|
|
501
523
|
exit "$EXIT"
|
|
502
524
|
`;
|
|
@@ -2,6 +2,7 @@ export interface HookProfileRow {
|
|
|
2
2
|
hook: string;
|
|
3
3
|
n: number;
|
|
4
4
|
p50Ms: number;
|
|
5
|
+
p95Ms: number;
|
|
5
6
|
p99Ms: number;
|
|
6
7
|
meanMs: number;
|
|
7
8
|
maxMs: number;
|
|
@@ -9,6 +10,13 @@ export interface HookProfileRow {
|
|
|
9
10
|
cacheStalePct: number;
|
|
10
11
|
cacheMissPct: number;
|
|
11
12
|
errorCount: number;
|
|
13
|
+
/** Fraction (0-1) of fires with a nonzero exit code. */
|
|
14
|
+
errorRate?: number;
|
|
15
|
+
/** Fraction (0-1) of fires that hit their configured timeout. */
|
|
16
|
+
timeoutRate?: number;
|
|
17
|
+
/** Project key the row is scoped to (see project-key.ts) — set only when
|
|
18
|
+
* a `--project` filter narrowed the underlying query. */
|
|
19
|
+
project?: string;
|
|
12
20
|
}
|
|
13
21
|
interface RawFireEvent {
|
|
14
22
|
event?: string;
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* generated shims (see `cache.ts`) emit on every invocation, and aggregates
|
|
4
4
|
* per-hook timing + cache stats.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Every hook gets a generated shim now (resolveHookCommand in hooks.ts) —
|
|
7
|
+
* `cache:`, `matches:`, or a bare `matcher:` (e.g. git-guard/rm-guard) are all
|
|
8
|
+
* enough to opt in. The only hooks NOT in this profile are ones with none of
|
|
9
|
+
* the three, since a pure lifecycle hook with nothing to gate/cache/match runs
|
|
10
|
+
* the raw script path with no timing wrapper at all.
|
|
10
11
|
*/
|
|
11
12
|
import * as fs from 'fs';
|
|
12
13
|
import * as path from 'path';
|
|
@@ -89,6 +90,7 @@ export function aggregateHookProfile(events) {
|
|
|
89
90
|
hook,
|
|
90
91
|
n,
|
|
91
92
|
p50Ms: Math.round(percentile(sortedMs, 50)),
|
|
93
|
+
p95Ms: Math.round(percentile(sortedMs, 95)),
|
|
92
94
|
p99Ms: Math.round(percentile(sortedMs, 99)),
|
|
93
95
|
meanMs: Math.round(sum / n),
|
|
94
96
|
maxMs: sortedMs[sortedMs.length - 1],
|
|
@@ -96,6 +98,14 @@ export function aggregateHookProfile(events) {
|
|
|
96
98
|
cacheStalePct: Math.round((stale / n) * 100),
|
|
97
99
|
cacheMissPct: Math.round((misses / n) * 100),
|
|
98
100
|
errorCount: errors,
|
|
101
|
+
...(errors > 0 ? { errorRate: Math.round((errors / n) * 1000) / 1000 } : {}),
|
|
102
|
+
// timeoutRate is not derivable here: the daily JSONL a shim writes only
|
|
103
|
+
// covers fires that reached their own trailing printf — an externally
|
|
104
|
+
// enforced timeout (the agent harness killing the process) never gets
|
|
105
|
+
// that far, so this log has no timeout signal at all. The warehouse
|
|
106
|
+
// path (asHookRows in commands/perf.ts) is the one that can see it,
|
|
107
|
+
// via the perf-spool `status:"timeout"` sample OpenCode's generated
|
|
108
|
+
// plugin writes directly (hooks.ts's recordTimeoutSample).
|
|
99
109
|
});
|
|
100
110
|
}
|
|
101
111
|
rows.sort((a, b) => b.p99Ms - a.p99Ms);
|