@kill-switch/agent-guard 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -115,8 +115,33 @@ scarce resource isn't dollars — it's your plan's rate-limit quota, in two roll
115
115
  - a **5-hour** window (burst protection), and
116
116
  - a **weekly** (7-day) window — the real lockout risk, "resets a couple times a month".
117
117
 
118
- Anthropic reports exactly where you stand on every response via `anthropic-ratelimit-unified-*`
119
- headers. Run Claude Code **through the proxy** and agent-guard reads them — no estimation:
118
+ ### Easiest: `agent-guard usage`
119
+
120
+ Pull your **real** limits straight from Anthropic — the same data `/usage` shows — no proxy, no
121
+ workflow change:
122
+
123
+ ```sh
124
+ agent-guard usage # → 5-hour, weekly, and per-model (Sonnet/Opus) weekly utilization + resets
125
+ ```
126
+
127
+ It reads your Claude Code OAuth token from the OS credential store (macOS Keychain
128
+ `Claude Code-credentials`, or `~/.claude/.credentials.json` on Linux — used only as a Bearer
129
+ header, **never logged or stored**) and GETs the `/api/oauth/usage` endpoint. `status`
130
+ auto-refreshes this (throttled to 120s). The endpoint is **undocumented**, so every call fails
131
+ soft — if it's unavailable, agent-guard falls back to the proxy or "unknown".
132
+
133
+ ```
134
+ 🟢 Claude Code plan limits · observed just now
135
+ [██░░░░░░░░░░░░░░░░░░] 5-hour limit 12% used, resets 9:19 AM
136
+ [███░░░░░░░░░░░░░░░░░] weekly limit 17% used, resets Tue 7:59 PM
137
+ [░░░░░░░░░░░░░░░░░░░░] weekly · Sonnet 1%
138
+ ```
139
+
140
+ ### Alternative: the proxy
141
+
142
+ Anthropic also reports your standing on every response via `anthropic-ratelimit-unified-*`
143
+ headers (5h + weekly only — no per-model). Run Claude Code **through the proxy** and agent-guard
144
+ reads them in-flight:
120
145
 
121
146
  ```sh
122
147
  agent-guard proxy # meters Anthropic + reads limit headers
@@ -136,18 +161,24 @@ your alert channels:
136
161
  ```
137
162
 
138
163
  `status` shows it; the hook injects it into the session even when only the hook is running
139
- (it reads the snapshot the proxy persisted). No proxy and want a rough read? Pin your tier and
140
- agent-guard *estimates* from the ledger (clearly labelled, never blocks):
164
+ (it reads the snapshot the proxy persisted).
165
+
166
+ **Without the proxy, real percentages are unknowable** — Claude Code fetches them from Anthropic
167
+ and never writes them to disk, and local cost does *not* map to Anthropic's internal rate-limit
168
+ units (so we don't fake a "% of limit"). Instead, hook-only mode shows what's honestly knowable:
169
+ your **auto-detected plan tier** (read from `~/.claude.json`) plus **absolute rolling cost** —
170
+ and points you at the proxy for the real numbers. Your tier needs no flag; `--plan` only
171
+ overrides the auto-detection.
141
172
 
142
173
  ```sh
143
- ks guard config --plan max5 # auto | pro | max5 | max20
174
+ ks guard config --plan max5 # auto (detect) | pro | max5 | max20
144
175
  ```
145
176
 
146
- Tune the thresholds (0–1 utilization) if the defaults are too eager:
177
+ Tune the thresholds (0–1 utilization) if the proxy's pacing is too eager:
147
178
 
148
179
  | Setting | Meaning | Default |
149
180
  |---|---|---|
150
- | `--plan` (`AGENT_GUARD_PLAN`) | `auto` (headers only) or a tier for estimation | `auto` |
181
+ | `--plan` (`AGENT_GUARD_PLAN`) | `auto` (detect from ~/.claude.json) or pin a tier | `auto` |
151
182
  | `--weekly-soft` / `--weekly-danger` | weekly warn / danger utilization | 0.6 / 0.85 |
152
183
  | `--5h-soft` / `--5h-danger` | 5-hour warn / danger utilization | 0.7 / 0.9 |
153
184
  | `--burn-ratio` | pace multiplier that triggers a warning | 1.5 |
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Read what Claude Code persists locally about the account.
3
+ *
4
+ * Claude Code stores the user's plan tier in `~/.claude.json` under
5
+ * `oauthAccount` — so we can auto-detect it instead of making the user pass
6
+ * `--plan`. Note: Claude Code does NOT persist the live `/usage` rate-limit
7
+ * percentages anywhere readable (they're fetched from Anthropic and rendered in
8
+ * the TUI only) — the proxy's `unified-*` response headers remain the single
9
+ * source of truth for real utilization. This module is best-effort and never
10
+ * throws; a missing/unreadable file just means "unknown".
11
+ */
12
+ /** The subscription tiers agent-guard knows about (a subset of LimitsConfig.plan). */
13
+ export type SubscriptionTier = "pro" | "max5" | "max20";
14
+ /** Map a Claude Code rate-limit-tier string (e.g. "default_claude_max_20x") to our tier. */
15
+ export declare function mapRateLimitTier(raw: string | null | undefined): SubscriptionTier | null;
16
+ /**
17
+ * Best-effort read of the user's plan tier from `~/.claude.json`. Prefers the
18
+ * per-user tier over the org default when present. Returns null if absent.
19
+ */
20
+ export declare function detectPlanTier(claudeJsonPath?: string): SubscriptionTier | null;
21
+ /** Human label for a tier. */
22
+ export declare function tierLabel(tier: SubscriptionTier): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Read what Claude Code persists locally about the account.
3
+ *
4
+ * Claude Code stores the user's plan tier in `~/.claude.json` under
5
+ * `oauthAccount` — so we can auto-detect it instead of making the user pass
6
+ * `--plan`. Note: Claude Code does NOT persist the live `/usage` rate-limit
7
+ * percentages anywhere readable (they're fetched from Anthropic and rendered in
8
+ * the TUI only) — the proxy's `unified-*` response headers remain the single
9
+ * source of truth for real utilization. This module is best-effort and never
10
+ * throws; a missing/unreadable file just means "unknown".
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ /** Map a Claude Code rate-limit-tier string (e.g. "default_claude_max_20x") to our tier. */
16
+ export function mapRateLimitTier(raw) {
17
+ if (!raw)
18
+ return null;
19
+ const s = raw.toLowerCase();
20
+ if (/max[_-]?20x/.test(s))
21
+ return "max20";
22
+ if (/max[_-]?5x/.test(s))
23
+ return "max5";
24
+ if (s.includes("pro"))
25
+ return "pro";
26
+ // Anything else (free, team, enterprise, unknown) → no estimate-tier mapping.
27
+ return null;
28
+ }
29
+ /**
30
+ * Best-effort read of the user's plan tier from `~/.claude.json`. Prefers the
31
+ * per-user tier over the org default when present. Returns null if absent.
32
+ */
33
+ export function detectPlanTier(claudeJsonPath = join(homedir(), ".claude.json")) {
34
+ try {
35
+ const j = JSON.parse(readFileSync(claudeJsonPath, "utf8"));
36
+ const acct = j.oauthAccount;
37
+ if (!acct)
38
+ return null;
39
+ return mapRateLimitTier(acct.userRateLimitTier) ?? mapRateLimitTier(acct.organizationRateLimitTier);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Human label for a tier. */
46
+ export function tierLabel(tier) {
47
+ return tier === "max20" ? "Max 20x" : tier === "max5" ? "Max 5x" : "Pro";
48
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Real subscription usage from Anthropic's OAuth usage endpoint.
3
+ *
4
+ * This is the authoritative, structured source for Claude Code Pro/Max limits —
5
+ * the same data the `/usage` command shows — without a proxy, a screen-scrape, or
6
+ * a (hopeless) local estimate. It's the endpoint the polished community status-
7
+ * line tools settled on. One caveat: it's **undocumented**, so Anthropic could
8
+ * change or gate it; every call here fails soft (returns null) and the caller
9
+ * falls back to the proxy / "unknown".
10
+ *
11
+ * Token handling: the OAuth access token is read from the OS credential store
12
+ * (macOS Keychain `Claude Code-credentials`, or `~/.claude/.credentials.json` on
13
+ * Linux), used only as a Bearer header on the GET, and **never logged or
14
+ * persisted** by us.
15
+ */
16
+ import { type LimitSnapshot } from "./limits.js";
17
+ /**
18
+ * Best-effort read of the Claude Code OAuth access token from the OS credential
19
+ * store. Returns null (never throws, never logs the token) if unavailable.
20
+ */
21
+ export declare function readOAuthToken(): string | null;
22
+ interface UsageWindow {
23
+ utilization?: number | null;
24
+ resets_at?: string | null;
25
+ }
26
+ export interface UsageResponse {
27
+ five_hour?: UsageWindow | null;
28
+ seven_day?: UsageWindow | null;
29
+ seven_day_sonnet?: UsageWindow | null;
30
+ seven_day_opus?: UsageWindow | null;
31
+ extra_usage?: {
32
+ is_enabled?: boolean;
33
+ monthly_limit?: number;
34
+ used_credits?: number;
35
+ } | null;
36
+ }
37
+ /** GET the usage endpoint with the given token. Returns null on any failure. */
38
+ export declare function fetchUsage(token: string, timeoutMs?: number): Promise<UsageResponse | null>;
39
+ /** Map the OAuth usage response into a {@link LimitSnapshot}. */
40
+ export declare function usageToSnapshot(u: UsageResponse, now: number): LimitSnapshot;
41
+ /**
42
+ * Fetch real usage and persist it as the live snapshot, throttled. Returns the
43
+ * fresh snapshot on a successful fetch, or null if we skipped (throttled) or
44
+ * couldn't fetch (no token / endpoint down — caller falls back gracefully).
45
+ */
46
+ export declare function refreshUsage(now: number, opts?: {
47
+ force?: boolean;
48
+ throttleMs?: number;
49
+ }): Promise<LimitSnapshot | null>;
50
+ export {};
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Real subscription usage from Anthropic's OAuth usage endpoint.
3
+ *
4
+ * This is the authoritative, structured source for Claude Code Pro/Max limits —
5
+ * the same data the `/usage` command shows — without a proxy, a screen-scrape, or
6
+ * a (hopeless) local estimate. It's the endpoint the polished community status-
7
+ * line tools settled on. One caveat: it's **undocumented**, so Anthropic could
8
+ * change or gate it; every call here fails soft (returns null) and the caller
9
+ * falls back to the proxy / "unknown".
10
+ *
11
+ * Token handling: the OAuth access token is read from the OS credential store
12
+ * (macOS Keychain `Claude Code-credentials`, or `~/.claude/.credentials.json` on
13
+ * Linux), used only as a Bearer header on the GET, and **never logged or
14
+ * persisted** by us.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+ import { homedir, platform } from "node:os";
19
+ import { join } from "node:path";
20
+ import { loadLimitsState, saveLimitsState, parseReset, } from "./limits.js";
21
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
22
+ const OAUTH_BETA = "oauth-2025-04-20";
23
+ const DEFAULT_THROTTLE_MS = 120_000; // don't hammer the endpoint
24
+ /**
25
+ * Best-effort read of the Claude Code OAuth access token from the OS credential
26
+ * store. Returns null (never throws, never logs the token) if unavailable.
27
+ */
28
+ export function readOAuthToken() {
29
+ try {
30
+ let raw;
31
+ if (platform() === "darwin") {
32
+ raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], {
33
+ encoding: "utf8",
34
+ timeout: 4000,
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ });
37
+ }
38
+ else {
39
+ raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf8");
40
+ }
41
+ const j = JSON.parse(raw);
42
+ return j?.claudeAiOauth?.accessToken ?? j?.accessToken ?? null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** GET the usage endpoint with the given token. Returns null on any failure. */
49
+ export async function fetchUsage(token, timeoutMs = 8000) {
50
+ try {
51
+ const ctrl = new AbortController();
52
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
53
+ let res;
54
+ try {
55
+ res = await fetch(USAGE_URL, {
56
+ headers: {
57
+ authorization: `Bearer ${token}`,
58
+ "anthropic-beta": OAUTH_BETA,
59
+ "content-type": "application/json",
60
+ },
61
+ signal: ctrl.signal,
62
+ });
63
+ }
64
+ finally {
65
+ clearTimeout(t);
66
+ }
67
+ if (!res.ok)
68
+ return null;
69
+ return (await res.json());
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Convert one endpoint window to a {@link WindowState}. The endpoint's
77
+ * `utilization` is an integer **percent** (0–100) — including small values like
78
+ * 1 (= 1%), so we always divide by 100 (NOT the header parser's ambiguous
79
+ * >1.5 heuristic, which would read 1 as 100%).
80
+ */
81
+ function toWindow(u, now) {
82
+ if (!u || typeof u.utilization !== "number")
83
+ return null;
84
+ return {
85
+ utilization: Math.max(0, Math.min(1, u.utilization / 100)),
86
+ resetAt: parseReset(u.resets_at ?? null, now),
87
+ };
88
+ }
89
+ /** Map the OAuth usage response into a {@link LimitSnapshot}. */
90
+ export function usageToSnapshot(u, now) {
91
+ const extras = [];
92
+ const addExtra = (label, w) => {
93
+ const s = toWindow(w, now);
94
+ if (s)
95
+ extras.push({ label, utilization: s.utilization, resetAt: s.resetAt });
96
+ };
97
+ addExtra("weekly · Sonnet", u.seven_day_sonnet);
98
+ addExtra("weekly · Opus", u.seven_day_opus);
99
+ return {
100
+ fiveHour: toWindow(u.five_hour, now),
101
+ weekly: toWindow(u.seven_day, now),
102
+ status: "oauth-usage",
103
+ observedAt: now,
104
+ extras: extras.length ? extras : undefined,
105
+ };
106
+ }
107
+ /**
108
+ * Fetch real usage and persist it as the live snapshot, throttled. Returns the
109
+ * fresh snapshot on a successful fetch, or null if we skipped (throttled) or
110
+ * couldn't fetch (no token / endpoint down — caller falls back gracefully).
111
+ */
112
+ export async function refreshUsage(now, opts = {}) {
113
+ const throttle = opts.throttleMs ?? DEFAULT_THROTTLE_MS;
114
+ const state = loadLimitsState();
115
+ if (!opts.force && state.lastFetchAt && now - state.lastFetchAt < throttle && state.snapshot) {
116
+ return null; // recent enough — use the cached snapshot
117
+ }
118
+ const token = readOAuthToken();
119
+ if (!token)
120
+ return null;
121
+ const usage = await fetchUsage(token);
122
+ if (!usage) {
123
+ // mark the attempt so we don't retry every call when the endpoint is down
124
+ saveLimitsState({ ...loadLimitsState(), lastFetchAt: now });
125
+ return null;
126
+ }
127
+ const snapshot = usageToSnapshot(usage, now);
128
+ const fresh = loadLimitsState();
129
+ saveLimitsState({ ...fresh, subscriptionDetected: true, snapshot, lastFetchAt: now });
130
+ return snapshot;
131
+ }
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { evaluate } from "./budget.js";
19
19
  import { fmtUSD } from "./cost.js";
20
20
  import { installHook, setBudget, setLimits, resetLedger } from "./ops.js";
21
21
  import { buildStatusReport, formatLimitsLines } from "./report.js";
22
+ import { refreshUsage } from "./claude-usage.js";
22
23
  const program = new Command();
23
24
  program
24
25
  .name("agent-guard")
@@ -59,9 +60,15 @@ program
59
60
  // ── status ───────────────────────────────────────────────────────────────────
60
61
  program
61
62
  .command("status")
62
- .description("Show current session + daily spend against the budget")
63
+ .description("Show current session + daily spend, and real Claude Code plan limits")
63
64
  .option("--json", "Output as JSON")
64
- .action((opts) => {
65
+ .action(async (opts) => {
66
+ try {
67
+ await refreshUsage(Date.now()); // throttled real-limits pull; never fails status
68
+ }
69
+ catch {
70
+ /* offline / no token */
71
+ }
65
72
  const cfg = loadConfig();
66
73
  const ledger = loadLedger();
67
74
  const now = Date.now();
@@ -119,6 +126,34 @@ program
119
126
  console.log(line);
120
127
  }
121
128
  });
129
+ // ── usage (real Claude Code plan limits) ─────────────────────────────────────
130
+ program
131
+ .command("usage")
132
+ .description("Fetch your REAL Claude Code plan limits (5h + weekly + per-model) from Anthropic")
133
+ .option("--json", "Output as JSON")
134
+ .action(async (opts) => {
135
+ let snap;
136
+ try {
137
+ snap = await refreshUsage(Date.now(), { force: true });
138
+ }
139
+ catch {
140
+ snap = null;
141
+ }
142
+ const report = buildStatusReport();
143
+ if (opts.json) {
144
+ console.log(JSON.stringify({ fetched: !!snap, limits: report.limits }, null, 2));
145
+ return;
146
+ }
147
+ if (!snap && report.limits.source !== "headers") {
148
+ console.log("Couldn't fetch usage — need a logged-in Claude Code (token in the macOS Keychain or ~/.claude/.credentials.json).");
149
+ console.log("The /api/oauth/usage endpoint is undocumented and may be unavailable.");
150
+ return;
151
+ }
152
+ console.log("");
153
+ for (const line of formatLimitsLines(report.limits))
154
+ console.log(line);
155
+ console.log("");
156
+ });
122
157
  // ── pause / resume (escape hatch) ────────────────────────────────────────────
123
158
  program
124
159
  .command("pause")
package/dist/index.d.ts CHANGED
@@ -16,8 +16,9 @@ export { loadConfig, DEFAULT_BUDGET, DEFAULT_LIMITS, guardDir, ensureGuardDir, c
16
16
  export { dispatchAlert, type AlertEvent, type AlertLevel } from "./alert.js";
17
17
  export { startProxy, resolveUpstream, type ProxyOptions } from "./proxy.js";
18
18
  export { runHook } from "./hook.js";
19
- export { buildStatusReport, formatLimitsLines, type StatusReport, type LimitsReport } from "./report.js";
20
- export { parseUnifiedHeaders, parseUtilization, parseReset, recordHeaders, loadLimitsState, saveLimitsState, emptyLimitsState, limitNotifyKey, unifiedHeaderDump, logUnifiedHeaders, WINDOW_MS, type LimitSnapshot, type WindowState, type LimitsState, type LimitWindow, type HeaderGetter, } from "./limits.js";
19
+ export { buildStatusReport, buildLimitsReport, formatLimitsLines, type StatusReport, type LimitsReport } from "./report.js";
20
+ export { detectPlanTier, mapRateLimitTier, tierLabel, type SubscriptionTier } from "./claude-code.js";
21
+ export { readOAuthToken, fetchUsage, usageToSnapshot, refreshUsage, type UsageResponse } from "./claude-usage.js";
22
+ export { parseUnifiedHeaders, parseUtilization, parseReset, recordHeaders, loadLimitsState, saveLimitsState, emptyLimitsState, limitNotifyKey, unifiedHeaderDump, logUnifiedHeaders, WINDOW_MS, type LimitSnapshot, type WindowState, type ExtraWindow, type LimitsState, type LimitWindow, type HeaderGetter, } from "./limits.js";
21
23
  export { assessWindow, assessSnapshot, worstLevel, type PacingAssessment, type PacingLevel, type PacingThresholds, } from "./pacing.js";
22
- export { estimateSnapshot, isEstimated, TIER_BUDGETS, type PlanTier, type TierBudget, } from "./estimate.js";
23
24
  export { installHook, setBudget, setLimits, resetLedger, type InstallOptions, type InstallResult, type BudgetPatch, type LimitsPatch, } from "./ops.js";
package/dist/index.js CHANGED
@@ -16,8 +16,9 @@ export { loadConfig, DEFAULT_BUDGET, DEFAULT_LIMITS, guardDir, ensureGuardDir, c
16
16
  export { dispatchAlert } from "./alert.js";
17
17
  export { startProxy, resolveUpstream } from "./proxy.js";
18
18
  export { runHook } from "./hook.js";
19
- export { buildStatusReport, formatLimitsLines } from "./report.js";
19
+ export { buildStatusReport, buildLimitsReport, formatLimitsLines } from "./report.js";
20
+ export { detectPlanTier, mapRateLimitTier, tierLabel } from "./claude-code.js";
21
+ export { readOAuthToken, fetchUsage, usageToSnapshot, refreshUsage } from "./claude-usage.js";
20
22
  export { parseUnifiedHeaders, parseUtilization, parseReset, recordHeaders, loadLimitsState, saveLimitsState, emptyLimitsState, limitNotifyKey, unifiedHeaderDump, logUnifiedHeaders, WINDOW_MS, } from "./limits.js";
21
23
  export { assessWindow, assessSnapshot, worstLevel, } from "./pacing.js";
22
- export { estimateSnapshot, isEstimated, TIER_BUDGETS, } from "./estimate.js";
23
24
  export { installHook, setBudget, setLimits, resetLedger, } from "./ops.js";
package/dist/limits.d.ts CHANGED
@@ -32,6 +32,12 @@ export interface WindowState {
32
32
  /** Raw per-window status string from Anthropic (e.g. "allowed" / "warning"), if any. */
33
33
  status?: string;
34
34
  }
35
+ /** A labelled extra window (e.g. per-model weekly) — display-only, no pacing. */
36
+ export interface ExtraWindow {
37
+ label: string;
38
+ utilization: number;
39
+ resetAt: number | null;
40
+ }
35
41
  /** A point-in-time read of the account's subscription rate-limit standing. */
36
42
  export interface LimitSnapshot {
37
43
  fiveHour: WindowState | null;
@@ -40,6 +46,8 @@ export interface LimitSnapshot {
40
46
  status: string | null;
41
47
  /** Epoch ms when this snapshot was observed. */
42
48
  observedAt: number;
49
+ /** Extra windows from the OAuth usage endpoint (per-model weekly, etc.) — display only. */
50
+ extras?: ExtraWindow[];
43
51
  }
44
52
  /** Persisted global state (account-wide, not per-session). */
45
53
  export interface LimitsState {
@@ -51,6 +59,8 @@ export interface LimitsState {
51
59
  notified: Record<string, boolean>;
52
60
  /** Epoch ms we first logged the raw unified-* headers (write-once diagnostic). */
53
61
  headersLoggedAt?: number;
62
+ /** Epoch ms of the last OAuth usage-endpoint fetch (for throttling). */
63
+ lastFetchAt?: number;
54
64
  }
55
65
  /** Nominal window durations, used for pacing math when a reset time is unknown. */
56
66
  export declare const WINDOW_MS: Record<LimitWindow, number>;
package/dist/limits.js CHANGED
@@ -42,6 +42,7 @@ export function loadLimitsState() {
42
42
  snapshot: data.snapshot ?? null,
43
43
  notified: data.notified ?? {},
44
44
  headersLoggedAt: data.headersLoggedAt,
45
+ lastFetchAt: data.lastFetchAt,
45
46
  };
46
47
  }
47
48
  }
package/dist/report.d.ts CHANGED
@@ -4,24 +4,42 @@
4
4
  *
5
5
  * Two halves:
6
6
  * - the dollar budget (session + daily-rolling), always present; and
7
- * - the subscription rate-limit standing (5-hour + weekly pacing), present
8
- * once we've seen Anthropic's unified headers via the proxy, or estimated
9
- * when the user has pinned a plan tier. Alert-only never blocks.
7
+ * - the subscription rate-limit standing. Real 5-hour/weekly utilization is
8
+ * only knowable from Anthropic's `unified-*` headers, which the proxy
9
+ * captures so when we have a fresh snapshot we show real percentages, and
10
+ * otherwise we show what's honestly knowable locally: the auto-detected plan
11
+ * tier plus absolute rolling cost (NOT a fabricated "% of limit" — local cost
12
+ * does not map to Anthropic's internal rate-limit units). Alert-only.
10
13
  */
11
- import { type SessionRecord } from "./ledger.js";
14
+ import { type GuardConfig } from "./config.js";
15
+ import { type SessionRecord, type Ledger } from "./ledger.js";
12
16
  import { type Budget, type VerdictLevel } from "./budget.js";
17
+ import { type ExtraWindow } from "./limits.js";
13
18
  import { type PacingAssessment, type PacingLevel } from "./pacing.js";
14
- import type { GuardConfig } from "./config.js";
15
- import type { Ledger } from "./ledger.js";
19
+ import { type SubscriptionTier } from "./claude-code.js";
16
20
  export interface LimitsReport {
17
- /** Where the numbers came from. "none" = no data and no pinned plan to estimate from. */
18
- source: "headers" | "estimated" | "none";
21
+ /** "headers" = real proxy data; "none" = no live data (show tier + cost only). */
22
+ source: "headers" | "none";
23
+ /** Configured plan ("auto" | tier). */
19
24
  plan: string;
25
+ /** Resolved subscription tier (from config or auto-detected from ~/.claude.json). */
26
+ tier: SubscriptionTier | null;
27
+ /** True when the tier came from ~/.claude.json rather than an explicit --plan. */
28
+ tierDetected: boolean;
20
29
  subscriptionDetected: boolean;
21
- /** Epoch ms the snapshot was observed (headers) or computed (estimated). */
30
+ /** Epoch ms the headers snapshot was observed, or null. */
22
31
  observedAt: number | null;
32
+ /** Real per-window pacing (only when source === "headers"). */
23
33
  windows: PacingAssessment[];
34
+ /** Extra display-only windows (per-model weekly) from the OAuth usage endpoint. */
35
+ extras: ExtraWindow[];
24
36
  level: PacingLevel;
37
+ /** Absolute rolling cost (API-equivalent USD) — honest local signal, not a limit %. */
38
+ cost: {
39
+ fiveHourUSD: number;
40
+ dailyUSD: number;
41
+ weeklyUSD: number;
42
+ };
25
43
  }
26
44
  export interface StatusReport {
27
45
  budget: Budget;
@@ -29,12 +47,10 @@ export interface StatusReport {
29
47
  verdict: VerdictLevel;
30
48
  reasons: string[];
31
49
  paused: boolean;
32
- /** Epoch ms the pause auto-expires, or null (indefinite / not paused). */
33
50
  pauseUntil: number | null;
34
51
  sessions: Array<{
35
52
  id: string;
36
53
  } & SessionRecord>;
37
- /** Subscription rate-limit pacing — present whenever we have data to show. */
38
54
  limits: LimitsReport;
39
55
  }
40
56
  /**
@@ -45,8 +61,7 @@ export interface StatusReport {
45
61
  export declare function buildLimitsReport(cfg: GuardConfig, ledger: Ledger, now: number): LimitsReport;
46
62
  /**
47
63
  * Render the subscription rate-limit section as plain text lines (no color), so
48
- * both the `agent-guard` and `ks guard` status views stay identical. Returns an
49
- * empty array when there's nothing useful to show.
64
+ * both the `agent-guard` and `ks guard` status views stay identical.
50
65
  */
51
66
  export declare function formatLimitsLines(limits: LimitsReport, now?: number): string[];
52
67
  /** Build the current status report from the on-disk config + ledger. */
package/dist/report.js CHANGED
@@ -4,18 +4,30 @@
4
4
  *
5
5
  * Two halves:
6
6
  * - the dollar budget (session + daily-rolling), always present; and
7
- * - the subscription rate-limit standing (5-hour + weekly pacing), present
8
- * once we've seen Anthropic's unified headers via the proxy, or estimated
9
- * when the user has pinned a plan tier. Alert-only never blocks.
7
+ * - the subscription rate-limit standing. Real 5-hour/weekly utilization is
8
+ * only knowable from Anthropic's `unified-*` headers, which the proxy
9
+ * captures so when we have a fresh snapshot we show real percentages, and
10
+ * otherwise we show what's honestly knowable locally: the auto-detected plan
11
+ * tier plus absolute rolling cost (NOT a fabricated "% of limit" — local cost
12
+ * does not map to Anthropic's internal rate-limit units). Alert-only.
10
13
  */
11
- import { loadConfig } from "./config.js";
12
- import { isPaused, pauseExpiry } from "./config.js";
14
+ import { loadConfig, isPaused, pauseExpiry } from "./config.js";
13
15
  import { loadLedger, rollingDailyCost } from "./ledger.js";
14
16
  import { evaluate } from "./budget.js";
17
+ import { fmtUSD } from "./cost.js";
15
18
  import { loadLimitsState, WINDOW_MS } from "./limits.js";
16
19
  import { assessSnapshot, worstLevel } from "./pacing.js";
17
- import { estimateSnapshot } from "./estimate.js";
20
+ import { detectPlanTier, tierLabel } from "./claude-code.js";
18
21
  const DAY_MS = 24 * 60 * 60 * 1000;
22
+ /** Sum metered cost across sessions whose last activity is within `windowMs`. */
23
+ function costInWindow(ledger, now, windowMs) {
24
+ let total = 0;
25
+ for (const s of Object.values(ledger.sessions)) {
26
+ if (now - s.lastAt < windowMs)
27
+ total += s.costUSD || 0;
28
+ }
29
+ return total;
30
+ }
19
31
  /**
20
32
  * Compute the subscription rate-limit section. Exported so the Claude Code hook
21
33
  * can reuse its already-loaded cfg + ledger instead of paying for a second
@@ -23,48 +35,54 @@ const DAY_MS = 24 * 60 * 60 * 1000;
23
35
  */
24
36
  export function buildLimitsReport(cfg, ledger, now) {
25
37
  const state = loadLimitsState();
26
- const thresholds = cfg.limits;
27
38
  const plan = cfg.limits.plan;
28
- // Prefer real header data but only while it's still usable. A snapshot older
29
- // than the weekly window is too stale to trust at all; and any single window
30
- // whose reset time has already passed has since rolled over (its utilization is
31
- // from a prior window), so we drop it rather than present expired numbers — and
32
- // a reset time in the past — as if they were live. If nothing usable remains we
33
- // fall through to the estimate (or "none").
39
+ // Resolve the effective tier: an explicit --plan wins; otherwise auto-detect
40
+ // from ~/.claude.json (oauthAccount.organizationRateLimitTier).
41
+ let tier = plan === "pro" || plan === "max5" || plan === "max20" ? plan : null;
42
+ let tierDetected = false;
43
+ if (!tier) {
44
+ const detected = detectPlanTier();
45
+ if (detected) {
46
+ tier = detected;
47
+ tierDetected = true;
48
+ }
49
+ }
50
+ const cost = {
51
+ fiveHourUSD: costInWindow(ledger, now, WINDOW_MS["5h"]),
52
+ dailyUSD: costInWindow(ledger, now, DAY_MS),
53
+ weeklyUSD: costInWindow(ledger, now, WINDOW_MS.weekly),
54
+ };
55
+ // Real data: a fresh snapshot, with any already-reset window dropped (stale).
34
56
  const snap = state.snapshot;
35
57
  if (snap && now - snap.observedAt < WINDOW_MS.weekly) {
36
- const windows = assessSnapshot(snap, thresholds, now).filter((w) => !(w.resetAt != null && w.resetAt <= now));
58
+ const windows = assessSnapshot(snap, cfg.limits, now).filter((w) => !(w.resetAt != null && w.resetAt <= now));
37
59
  if (windows.length) {
38
60
  return {
39
61
  source: "headers",
40
62
  plan,
63
+ tier,
64
+ tierDetected,
41
65
  subscriptionDetected: state.subscriptionDetected,
42
66
  observedAt: snap.observedAt,
43
67
  windows,
68
+ extras: snap.extras ?? [],
44
69
  level: worstLevel(windows),
70
+ cost,
45
71
  };
46
72
  }
47
73
  }
48
- // Otherwise estimate, but only when the user pinned a tier (opt-in, fuzzy).
49
- if (plan === "pro" || plan === "max5" || plan === "max20") {
50
- const snap = estimateSnapshot(ledger, plan, now);
51
- const windows = assessSnapshot(snap, thresholds, now);
52
- return {
53
- source: "estimated",
54
- plan,
55
- subscriptionDetected: state.subscriptionDetected,
56
- observedAt: snap.observedAt,
57
- windows,
58
- level: worstLevel(windows),
59
- };
60
- }
74
+ // No live data — honest local signal only (tier + absolute cost, never a fake %).
61
75
  return {
62
76
  source: "none",
63
77
  plan,
78
+ tier,
79
+ tierDetected,
64
80
  subscriptionDetected: state.subscriptionDetected,
65
81
  observedAt: null,
66
82
  windows: [],
83
+ extras: [],
67
84
  level: "ok",
85
+ cost,
68
86
  };
69
87
  }
70
88
  function bar(frac) {
@@ -84,30 +102,32 @@ function ageString(observedAt, now) {
84
102
  }
85
103
  /**
86
104
  * Render the subscription rate-limit section as plain text lines (no color), so
87
- * both the `agent-guard` and `ks guard` status views stay identical. Returns an
88
- * empty array when there's nothing useful to show.
105
+ * both the `agent-guard` and `ks guard` status views stay identical.
89
106
  */
90
107
  export function formatLimitsLines(limits, now = Date.now()) {
91
- if (limits.source === "none") {
92
- // Only nudge if they haven't opted into either path.
93
- if (!limits.subscriptionDetected) {
94
- return [
95
- "Claude Code plan limits: unknown.",
96
- " Run `ks guard proxy` and point Claude Code at it for exact 5-hour + weekly usage,",
97
- " or set your tier (`ks guard config --plan max5`) for an estimate.",
98
- ];
108
+ // Real proxy data → real percentages.
109
+ if (limits.source === "headers") {
110
+ const icon = limits.level === "danger" ? "🟥" : limits.level === "warn" ? "🟡" : "🟢";
111
+ const lines = [`${icon} Claude Code plan limits · observed ${limits.observedAt ? ageString(limits.observedAt, now) : "—"}`];
112
+ for (const w of limits.windows)
113
+ lines.push(` ${bar(w.utilization)} ${w.message}`);
114
+ for (const e of limits.extras) {
115
+ const pct = `${Math.round(e.utilization * 100)}%`.padStart(4);
116
+ lines.push(` ${bar(e.utilization)} ${e.label} ${pct}`);
99
117
  }
100
- return [];
101
- }
102
- const icon = limits.level === "danger" ? "🟥" : limits.level === "warn" ? "🟡" : "🟢";
103
- const tag = limits.source === "estimated" ? " (estimated — run the proxy for exact)" : "";
104
- const lines = [`${icon} Claude Code plan limits${tag} · observed ${limits.observedAt ? ageString(limits.observedAt, now) : "—"}`];
105
- for (const w of limits.windows) {
106
- // w.message already leads with "<window> limit NN% used, …", so the bar
107
- // carries the visual and the message carries the numbers + pacing.
108
- lines.push(` ${bar(w.utilization)} ${w.message}`);
118
+ return lines;
109
119
  }
110
- return lines;
120
+ // No live data: show what's honestly knowable — the tier and absolute cost,
121
+ // and steer to the proxy for the real percentages. Never a fabricated % bar.
122
+ const planStr = limits.tier
123
+ ? `Claude Code plan: ${tierLabel(limits.tier)}${limits.tierDetected ? " (detected)" : ""}`
124
+ : "Claude Code plan: unknown";
125
+ const c = limits.cost;
126
+ return [
127
+ `📊 ${planStr} — real 5-hour/weekly limit % needs the proxy`,
128
+ ` local rolling cost (API-equivalent, NOT a limit %): 5h ${fmtUSD(c.fiveHourUSD)} · 24h ${fmtUSD(c.dailyUSD)} · 7d ${fmtUSD(c.weeklyUSD)}`,
129
+ ` for exact limits: \`ks guard proxy\` → ANTHROPIC_BASE_URL=http://localhost:8787 claude`,
130
+ ];
111
131
  }
112
132
  /** Build the current status report from the on-disk config + ledger. */
113
133
  export function buildStatusReport(now = Date.now()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kill-switch/agent-guard",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Kill Switch for coding agents — stop runaway Claude Code / Cursor / Aider sessions from racking up an LLM bill. Native hook + token-metering proxy with per-session and daily-rolling budgets.",
5
5
  "type": "module",
6
6
  "bin": {