@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
@@ -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 (!Array.isArray(sink?.command) || sink.command.length === 0)
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
- * Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
274
- * that fails or is not installed is reported, never thrown the post is already
275
- * written and must not be undone by a mirror that could not be reached.
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 runFeedBroadcast(planned, timeoutMs = 20_000) {
278
- return planned.map(({ name, argv }) => {
279
- const result = spawnSync(argv[0], argv.slice(1), {
280
- encoding: 'utf-8',
281
- timeout: timeoutMs,
282
- stdio: ['ignore', 'pipe', 'pipe'],
283
- });
284
- if (result.error) {
285
- return { name, ok: false, error: result.error.message };
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
- if (result.status !== 0) {
288
- const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
289
- return { name, ok: false, error: tail || `exited ${result.status}` };
395
+ else {
396
+ outcomes.push(runCommandSink(sink.name, sink.argv ?? [], timeoutMs));
290
397
  }
291
- return { name, ok: true };
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
+ }
@@ -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
- * Only hooks declared with `cache:` get instrumented today, because only those
7
- * are wrapped by a generated shim. Hooks without `cache:` are not in the
8
- * profile output that's deliberate: opting into the primitive is what
9
- * surfaces the data.
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);