@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.
- package/CHANGELOG.md +198 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +63 -40
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/monitors.js +8 -6
- 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/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- 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/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- 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 +93 -12
- package/dist/lib/feed-broadcast.js +235 -30
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- 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.d.ts +16 -1
- package/dist/lib/hooks.js +133 -18
- 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/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- 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 +14 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- package/package.json +1 -1
|
@@ -0,0 +1,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);
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -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 (
|
|
260
|
-
*
|
|
261
|
-
* as a side effect when `cache:`
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
-
? [
|
|
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;
|