@phnx-labs/agents-cli 1.21.0 → 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 (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  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/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. package/package.json +1 -1
@@ -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);
@@ -198,6 +198,19 @@ export declare function installHooksCentrally(source: string): Promise<{
198
198
  * User dir takes priority; deduplication preserves first occurrence.
199
199
  */
200
200
  export declare function listCentralHooks(): HookEntry[];
201
+ /**
202
+ * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
203
+ *
204
+ * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
205
+ * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
206
+ * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
207
+ * routines.ts — that one returns milliseconds, has no seconds (`s`) unit, and
208
+ * floors at one minute, none of which fit hook timeouts (typically 5–600s).
209
+ *
210
+ * Returns the seconds value, or `null` when the input is not a positive number
211
+ * or a parseable duration string — the caller decides how to surface that.
212
+ */
213
+ export declare function normalizeHookTimeoutSeconds(value: unknown): number | null;
201
214
  /**
202
215
  * Parse hook manifests. Reads system hooks from ~/.agents/.system/hooks.yaml
203
216
  * (npm-shipped defaults) and user hooks from the `hooks:` section of
@@ -205,7 +218,9 @@ export declare function listCentralHooks(): HookEntry[];
205
218
  * A user entry with `enabled: false` disables the system-shipped hook of
206
219
  * the same name without forking the system file.
207
220
  *
208
- * Hooks marked `enabled: false` are dropped from the returned map.
221
+ * Hooks marked `enabled: false` are dropped from the returned map. A hook
222
+ * `timeout` written as a duration string (`5s`, `2m`) is normalized to a
223
+ * seconds number here, so every downstream serializer keeps reading a number.
209
224
  */
210
225
  export declare function parseHookManifest(opts?: {
211
226
  warn?: boolean;
package/dist/lib/hooks.js CHANGED
@@ -15,7 +15,7 @@ import * as yaml from 'yaml';
15
15
  import * as TOML from 'smol-toml';
16
16
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
17
17
  import { supports, explainSkip, capableAgents } from './capabilities.js';
18
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
18
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir } from './state.js';
19
19
  import { collectSubruleHooksFromState } from './rules/compose.js';
20
20
  function getCentralHooksDir() { return getUserHooksDir(); }
21
21
  /**
@@ -256,12 +256,20 @@ export function inspectDuplicateVersionHooks(cwd = process.cwd()) {
256
256
  /**
257
257
  * Resolve the command path to register for a hook.
258
258
  *
259
- * Returns either the raw script path (neither `cache:` nor `matches:` set,
260
- * legacy behavior) or the path to a generated wrapper shim. The shim is written
261
- * as a side effect when `cache:` and/or `matches:` is configured — it enforces
262
- * the `matches:` gate at fire time and layers the caching/timing machinery when
263
- * `cache:` is set. The agent-native settings file gets the same shape either
264
- * way — just a different command path.
259
+ * Returns either the raw script path (no `cache:`, `matches:`, or `matcher:`
260
+ * set a bare lifecycle hook with nothing to gate or time) or the path to a
261
+ * generated wrapper shim. The shim is written as a side effect when `cache:`
262
+ * and/or `matches:` is configured it enforces the `matches:` gate at fire
263
+ * time and layers the caching/timing machinery when `cache:` is set.
264
+ *
265
+ * A hook that declares only `matcher:` (e.g. git-guard/rm-guard scoped to the
266
+ * `Bash` tool, no `cache:`/`matches:`) also gets a shim now — a pass-through
267
+ * one with no gate and no cache, whose only job is the trailing timing sample
268
+ * (see PASSTHROUGH_TAIL in hooks/cache.ts). Before this, a matcher-only hook
269
+ * took the raw-path branch and fired completely uninstrumented: `agents perf
270
+ * hooks` showed zero samples for it no matter how often it ran. The agent-
271
+ * native settings file gets the same shape either way — just a different
272
+ * command path.
265
273
  */
266
274
  function resolveHookCommand(name, hookDef, resolveScript) {
267
275
  const scriptPath = resolveScript(hookDef.script);
@@ -272,17 +280,20 @@ function resolveHookCommand(name, hookDef, resolveScript) {
272
280
  const cache = parseCacheConfig(hookDef.cache);
273
281
  const matches = hookDef.matches;
274
282
  const hasMatches = matches != null && Object.keys(matches).length > 0;
275
- if (!cache && !hasMatches) {
276
- // No caching and no matches: gate opted in — make sure a previously
277
- // generated shim from an earlier `cache:`/`matches:` config is gone so the
278
- // JSONL doesn't keep claiming hits.
283
+ const hasMatcher = !!hookDef.matcher;
284
+ if (!cache && !hasMatches && !hasMatcher) {
285
+ // Nothing to gate, cache, or time: make sure a previously generated shim
286
+ // from an earlier cache:/matches:/matcher config is gone so the JSONL
287
+ // doesn't keep claiming hits.
279
288
  removeHookShim(name);
280
289
  return toPortableCommand(scriptPath);
281
290
  }
282
- // A shim is generated when the hook opts into caching and/or declares
283
- // `matches:` predicates. The shim enforces the `matches:` gate at fire time
284
- // (skipping the script when predicates don't hold) and, when `cache:` is set,
285
- // layers the cache/timing machinery on top.
291
+ // A shim is generated when the hook opts into caching, declares `matches:`
292
+ // predicates, or declares a `matcher:` (even alone see the doc comment
293
+ // above). The shim enforces the `matches:` gate at fire time (skipping the
294
+ // script when predicates don't hold) and, when `cache:` is set, layers the
295
+ // cache/timing machinery on top; with neither, it is a pure pass-through
296
+ // timing wrapper.
286
297
  return toPortableCommand(generateHookShim({ name, scriptPath, cache, matches }));
287
298
  }
288
299
  /**
@@ -645,7 +656,7 @@ export function checkVersionHookWiring(agent, version) {
645
656
  return null;
646
657
  const cache = parseCacheConfig(hookDef.cache);
647
658
  const hasMatches = hookDef.matches != null && Object.keys(hookDef.matches).length > 0;
648
- if (!cache && !hasMatches)
659
+ if (!cache && !hasMatches && !hookDef.matcher)
649
660
  return toPortableCommand(scriptPath);
650
661
  return toPortableCommand(getHookShimPath(name));
651
662
  };
@@ -952,6 +963,44 @@ export function listCentralHooks() {
952
963
  }
953
964
  return results;
954
965
  }
966
+ /**
967
+ * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
968
+ *
969
+ * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
970
+ * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
971
+ * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
972
+ * routines.ts — that one returns milliseconds, has no seconds (`s`) unit, and
973
+ * floors at one minute, none of which fit hook timeouts (typically 5–600s).
974
+ *
975
+ * Returns the seconds value, or `null` when the input is not a positive number
976
+ * or a parseable duration string — the caller decides how to surface that.
977
+ */
978
+ export function normalizeHookTimeoutSeconds(value) {
979
+ if (typeof value === 'number') {
980
+ return Number.isFinite(value) && value > 0 ? value : null;
981
+ }
982
+ if (typeof value === 'string') {
983
+ const s = value.trim();
984
+ if (s === '')
985
+ return null;
986
+ // A bare integer string means seconds, matching the bare-number form.
987
+ if (/^\d+$/.test(s)) {
988
+ const n = Number(s);
989
+ return n > 0 ? n : null;
990
+ }
991
+ const m = s.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
992
+ if (!m)
993
+ return null;
994
+ const weeks = Number(m[1] || 0);
995
+ const days = Number(m[2] || 0);
996
+ const hours = Number(m[3] || 0);
997
+ const minutes = Number(m[4] || 0);
998
+ const seconds = Number(m[5] || 0);
999
+ const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
1000
+ return total > 0 ? total : null;
1001
+ }
1002
+ return null;
1003
+ }
955
1004
  /**
956
1005
  * Parse hook manifests. Reads system hooks from ~/.agents/.system/hooks.yaml
957
1006
  * (npm-shipped defaults) and user hooks from the `hooks:` section of
@@ -959,7 +1008,9 @@ export function listCentralHooks() {
959
1008
  * A user entry with `enabled: false` disables the system-shipped hook of
960
1009
  * the same name without forking the system file.
961
1010
  *
962
- * Hooks marked `enabled: false` are dropped from the returned map.
1011
+ * Hooks marked `enabled: false` are dropped from the returned map. A hook
1012
+ * `timeout` written as a duration string (`5s`, `2m`) is normalized to a
1013
+ * seconds number here, so every downstream serializer keeps reading a number.
963
1014
  */
964
1015
  export function parseHookManifest(opts = {}) {
965
1016
  const warn = opts.warn !== false;
@@ -1026,6 +1077,26 @@ export function parseHookManifest(opts = {}) {
1026
1077
  if (def.enabled === false)
1027
1078
  delete merged[name];
1028
1079
  }
1080
+ // Normalize each surviving hook's timeout to a seconds number, so the raw
1081
+ // agents.yaml can express it as a duration string (`5s`, `2m`) while every
1082
+ // downstream serializer keeps consuming a plain number. An unparseable value
1083
+ // is dropped with a warning rather than silently coerced to a wrong duration.
1084
+ for (const [name, def] of Object.entries(merged)) {
1085
+ const raw = def.timeout;
1086
+ if (raw === undefined)
1087
+ continue;
1088
+ const seconds = normalizeHookTimeoutSeconds(raw);
1089
+ if (seconds === null) {
1090
+ if (warn) {
1091
+ console.warn(`[agents hooks] Hook '${name}' has an invalid timeout ${JSON.stringify(raw)}; ` +
1092
+ `expected seconds or a duration string like '5s', '2m', '1h30m'. Ignoring it.`);
1093
+ }
1094
+ delete def.timeout;
1095
+ }
1096
+ else {
1097
+ def.timeout = seconds;
1098
+ }
1099
+ }
1029
1100
  return merged;
1030
1101
  }
1031
1102
  export function selectHookManifest(manifest, selected) {
@@ -1204,7 +1275,17 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1204
1275
  return resolveHookScriptPath(script);
1205
1276
  };
1206
1277
  const managedPrefixes = overrideRoots
1207
- ? [path.join(overrideRoots[0], 'hooks') + path.sep]
1278
+ ? [
1279
+ path.join(overrideRoots[0], 'hooks') + path.sep,
1280
+ // The shim dir is one global location regardless of which hooks
1281
+ // source (agentsDirOverride vs the normal user/system dirs) resolved
1282
+ // the underlying script, so it belongs in every managedPrefixes
1283
+ // shape — omitting it here left a shim path unrecognized as managed
1284
+ // under the override branch, so a hook's matcher/event change never
1285
+ // GC'd its stale shim-path entry (only reachable via a caller that
1286
+ // passes agentsDirOverride; no production call site does today).
1287
+ getHookShimsDir() + path.sep,
1288
+ ]
1208
1289
  : [
1209
1290
  ...getManagedHookPrefixes(),
1210
1291
  ...(localHooksDir ? [localHooksDir + path.sep] : []),
@@ -1313,9 +1394,42 @@ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1313
1394
  }
1314
1395
  const serializedDirect = JSON.stringify(Object.fromEntries(direct), null, 2);
1315
1396
  const serializedLifecycle = JSON.stringify(Object.fromEntries(lifecycle), null, 2);
1397
+ // Same disposable perf spool the bash shims (hooks/cache.ts) append to — see
1398
+ // the timedOut branch below for why OpenCode needs its own writer.
1399
+ const perfSpoolPath = path.join(getPerfDir(), 'spool.jsonl');
1316
1400
  const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1401
+ import fs from "node:fs"
1402
+ import os from "node:os"
1403
+
1317
1404
  const directHooks = ${serializedDirect}
1318
1405
  const lifecycleHooks = ${serializedLifecycle}
1406
+ const PERF_SPOOL = ${JSON.stringify(perfSpoolPath)}
1407
+
1408
+ function recordTimeoutSample(hook, payload) {
1409
+ // hook.command already ran through a generated shim (hooks/cache.ts) that
1410
+ // writes its own hook.fire sample on exit — but Bun.spawn's child.kill()
1411
+ // below (SIGTERM) tears the shim down before it reaches that trailing
1412
+ // printf, so a timed-out fire would otherwise leave ZERO trace in the
1413
+ // warehouse. Write the sample ourselves from the side that knows it timed out.
1414
+ try {
1415
+ fs.mkdirSync(PERF_SPOOL.slice(0, PERF_SPOOL.lastIndexOf("/")), { recursive: true })
1416
+ const line = JSON.stringify({
1417
+ ts_ms: Date.now(),
1418
+ kind: "hook.fire",
1419
+ label: hook.name,
1420
+ duration_ms: hook.timeout * 1000,
1421
+ cache: "none",
1422
+ exit_code: null,
1423
+ status: "timeout",
1424
+ cwd: payload && typeof payload.cwd === "string" ? payload.cwd : undefined,
1425
+ session_id: payload && typeof payload.session_id === "string" ? payload.session_id : undefined,
1426
+ hostname: (() => { try { return os.hostname() } catch { return "unknown" } })(),
1427
+ }) + "\\n"
1428
+ fs.appendFileSync(PERF_SPOOL, line)
1429
+ } catch {
1430
+ // best effort — never let sample recording break the timeout error path
1431
+ }
1432
+ }
1319
1433
 
1320
1434
  function matches(hook, tool) {
1321
1435
  if (!hook.matcher) return true
@@ -1355,6 +1469,7 @@ async function runHooks(hooks, payload, $, matchTool = false) {
1355
1469
  const exitCode = await child.exited.finally(() => clearTimeout(timer))
1356
1470
  const stderr = await new Response(child.stderr).text()
1357
1471
  if (timedOut) {
1472
+ recordTimeoutSample(hook, payload)
1358
1473
  throw new Error(\`\${hook.name} timed out after \${hook.timeout} seconds\`)
1359
1474
  }
1360
1475
  if (exitCode !== 0) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Disk TTL cache for the Linear answers behind the `agents projects` card.
3
+ *
4
+ * Linear meters two budgets independently, and only one of them binds. Observed
5
+ * on this account's response headers:
6
+ *
7
+ * x-ratelimit-requests-limit: 2500 remaining: 2
8
+ * x-ratelimit-complexity-limit: 3000000 remaining: 2999987
9
+ *
10
+ * Requests are scarce; complexity is 99.999% untouched. So the thing to
11
+ * optimize is the NUMBER of calls, not their cost — and the way to spend 2500
12
+ * of them is an agent (or a watch loop) running `projects status` repeatedly.
13
+ * A human typing it is not the exhauster.
14
+ *
15
+ * The CLI is a short-lived process, so an in-memory memo would only help within
16
+ * one invocation, which is the case that never needed help. This caches to disk.
17
+ *
18
+ * **One file per key, written by atomic rename.** A single JSON document holding
19
+ * every entry has to be read, modified, and written back, and that sequence is
20
+ * not atomic across processes — measured on this machine, two concurrent writers
21
+ * of 40 distinct keys each left **8 of 80** surviving. This box routinely runs a
22
+ * dozen agent sessions, so that is the normal case, not a corner. Per-key files
23
+ * remove the shared mutable document entirely: two processes caching different
24
+ * projects never touch the same path, and two caching the SAME project race only
25
+ * to write identical data. `writeFileSync` to a temp path followed by `rename`
26
+ * makes each file appear whole or not at all, so a reader never sees a partial
27
+ * write.
28
+ *
29
+ * The load-bearing behavior is what happens on FAILURE: a stale entry keeps
30
+ * being served, marked stale, instead of the line vanishing. That rule is
31
+ * borrowed from `mergeAuthHealthEntries` — one 8s timeout must not flip a
32
+ * populated chip to empty — and it is the fix for the card silently losing its
33
+ * Linear line mid-session when the request budget ran out.
34
+ */
35
+ /** Matches `SKILL_INDEX_TTL_MS` (`lib/registry.ts`) — the repo's TTL convention. */
36
+ export declare const LINEAR_CACHE_TTL_MS: number;
37
+ /** What a lookup found, and how much to trust it. */
38
+ export interface CacheHit<T> {
39
+ value: T;
40
+ /** Age in ms. Past the TTL the value is still returned, flagged stale. */
41
+ ageMs: number;
42
+ stale: boolean;
43
+ }
44
+ /** Look up a project's cached answer. Returns stale entries too — the caller decides. */
45
+ export declare function readCached<T>(projectId: string, nowMs: number): CacheHit<T> | undefined;
46
+ /** Store a freshly fetched answer. */
47
+ export declare function writeCached<T>(projectId: string, value: T, nowMs: number): void;
48
+ /** Drop one project's entry — used when `projects link` re-points a definition. */
49
+ export declare function invalidateCached(projectId: string): void;
50
+ /** True when a prior 429 said the budget is exhausted and has not yet reset. */
51
+ export declare function isRateLimited(nowMs: number): boolean;
52
+ /**
53
+ * Read a 429's `x-ratelimit-requests-reset` header into an epoch-ms instant.
54
+ * Linear sends epoch milliseconds; anything absent, non-numeric, or already in
55
+ * the past is not usable and the caller backs off a TTL instead. Pure, so the
56
+ * parsing is testable without a live 429.
57
+ */
58
+ export declare function parseRateLimitReset(header: string | null, nowMs: number): number | undefined;
59
+ /**
60
+ * Record a 429 so the next runs don't spend a request learning the same thing.
61
+ * `resetAtMs` comes from {@link parseRateLimitReset}; without it, back off one TTL.
62
+ */
63
+ export declare function noteRateLimited(resetAtMs: number | undefined, nowMs: number): void;