@kill-switch/agent-guard 0.1.4 → 0.1.6

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
@@ -136,18 +136,24 @@ your alert channels:
136
136
  ```
137
137
 
138
138
  `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):
139
+ (it reads the snapshot the proxy persisted).
140
+
141
+ **Without the proxy, real percentages are unknowable** — Claude Code fetches them from Anthropic
142
+ and never writes them to disk, and local cost does *not* map to Anthropic's internal rate-limit
143
+ units (so we don't fake a "% of limit"). Instead, hook-only mode shows what's honestly knowable:
144
+ your **auto-detected plan tier** (read from `~/.claude.json`) plus **absolute rolling cost** —
145
+ and points you at the proxy for the real numbers. Your tier needs no flag; `--plan` only
146
+ overrides the auto-detection.
141
147
 
142
148
  ```sh
143
- ks guard config --plan max5 # auto | pro | max5 | max20
149
+ ks guard config --plan max5 # auto (detect) | pro | max5 | max20
144
150
  ```
145
151
 
146
- Tune the thresholds (0–1 utilization) if the defaults are too eager:
152
+ Tune the thresholds (0–1 utilization) if the proxy's pacing is too eager:
147
153
 
148
154
  | Setting | Meaning | Default |
149
155
  |---|---|---|
150
- | `--plan` (`AGENT_GUARD_PLAN`) | `auto` (headers only) or a tier for estimation | `auto` |
156
+ | `--plan` (`AGENT_GUARD_PLAN`) | `auto` (detect from ~/.claude.json) or pin a tier | `auto` |
151
157
  | `--weekly-soft` / `--weekly-danger` | weekly warn / danger utilization | 0.6 / 0.85 |
152
158
  | `--5h-soft` / `--5h-danger` | 5-hour warn / danger utilization | 0.7 / 0.9 |
153
159
  | `--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
+ }
@@ -4,40 +4,46 @@
4
4
  * Ground truth for plan limits lives in the `anthropic-ratelimit-unified-*`
5
5
  * response headers, which only the proxy sees. A user running just the Claude
6
6
  * Code hook (the common, zero-config setup) never sees those headers — so when
7
- * they've told us their plan tier, we *estimate* where they stand by summing the
8
- * tokens the ledger recorded inside each rolling window and dividing by a
9
- * per-tier token budget.
7
+ * they've told us their plan tier, we *estimate* where they stand.
10
8
  *
11
- * This is deliberately approximate and always labelled as such:
12
- * - Anthropic meters opaque "prompts" / "active hours", not tokens, so the
13
- * token budgets below are calibrated rough equivalents, not contractual.
14
- * - The ledger stores a session's cumulative tokens against a single
15
- * `lastAt`, not a time series, so a long session is counted wholesale into
16
- * whichever window its last activity falls in.
9
+ * We estimate from the ledger's **cost** (`costUSD`), not its token counts. That
10
+ * matters: a coding agent's volume is dominated by cache reads/writes, and the
11
+ * ledger only stores non-cache input/output token counts — so a token-based
12
+ * estimate undercounts real throughput (and thus rate-limit consumption) by a
13
+ * large factor. `costUSD` is priced from the *full* usage including cache, so it
14
+ * tracks actual consumption far better. We compare it against a rough per-tier
15
+ * **API-equivalent** dollar ceiling for each window.
17
16
  *
18
- * It exists to give hook-only users *a* signal and to nudge them toward
19
- * `ks guard proxy` for exact numbers never to block (subscription mode is
20
- * alert-only). When in doubt it under-claims utilization so it won't cry wolf.
17
+ * This is still deliberately approximate and always labelled as such:
18
+ * - Anthropic meters opaque "prompts" / "active hours", and the dollar ceilings
19
+ * below are rough API-equivalent calibrations, not contractual.
20
+ * - The ledger stores a session's cumulative cost against a single `lastAt`,
21
+ * not a time series, so a long session is counted wholesale into whichever
22
+ * window its last activity falls in.
23
+ *
24
+ * It exists to give hook-only users *a* directional signal and to nudge them
25
+ * toward `ks guard proxy` for exact numbers — never to block (subscription mode
26
+ * is alert-only).
21
27
  */
22
28
  import { type LimitSnapshot } from "./limits.js";
23
29
  import type { Ledger } from "./ledger.js";
24
30
  export type PlanTier = "pro" | "max5" | "max20";
25
31
  /**
26
- * Rough per-tier token-equivalent budgets per window. Pro is the published
27
- * baseline; Max 5x / 20x scale the 5-hour burst ~linearly with the multiplier,
28
- * while the weekly cap scales more conservatively (Anthropic's weekly multiplier
29
- * is smaller than the per-session one). Tune via config if your mileage differs.
32
+ * Rough per-tier **API-equivalent USD** ceilings per window what the plan's
33
+ * rate limit lets you consume before lock-out, expressed in the same list-price
34
+ * dollars the ledger meters. Scaled by plan: Pro is the baseline, Max 5x/20x lift
35
+ * the burst (5h) roughly with the multiplier and the weekly cap more
36
+ * conservatively. These are estimates; the proxy's real headers override them.
30
37
  */
31
38
  export interface TierBudget {
32
- fiveHourTokens: number;
33
- weeklyTokens: number;
39
+ fiveHourUSD: number;
40
+ weeklyUSD: number;
34
41
  }
35
42
  export declare const TIER_BUDGETS: Record<PlanTier, TierBudget>;
36
43
  /**
37
44
  * Build an estimated {@link LimitSnapshot} from the ledger for a known tier.
38
- * Reset times are derived from the rolling window assumption (oldest in-window
39
- * activity + window length is unknowable here, so we report the window end from
40
- * `now` as a conservative upper bound on time remaining).
45
+ * `resetAt` is null (the true rolling reset is unknowable without a per-event
46
+ * time series), so pacing reports utilization only no fabricated reset/lockout.
41
47
  */
42
48
  export declare function estimateSnapshot(ledger: Ledger, tier: PlanTier, now: number, budgets?: Record<PlanTier, TierBudget>): LimitSnapshot;
43
49
  /** True when a snapshot came from {@link estimateSnapshot} rather than real headers. */
package/dist/estimate.js CHANGED
@@ -4,57 +4,57 @@
4
4
  * Ground truth for plan limits lives in the `anthropic-ratelimit-unified-*`
5
5
  * response headers, which only the proxy sees. A user running just the Claude
6
6
  * Code hook (the common, zero-config setup) never sees those headers — so when
7
- * they've told us their plan tier, we *estimate* where they stand by summing the
8
- * tokens the ledger recorded inside each rolling window and dividing by a
9
- * per-tier token budget.
7
+ * they've told us their plan tier, we *estimate* where they stand.
10
8
  *
11
- * This is deliberately approximate and always labelled as such:
12
- * - Anthropic meters opaque "prompts" / "active hours", not tokens, so the
13
- * token budgets below are calibrated rough equivalents, not contractual.
14
- * - The ledger stores a session's cumulative tokens against a single
15
- * `lastAt`, not a time series, so a long session is counted wholesale into
16
- * whichever window its last activity falls in.
9
+ * We estimate from the ledger's **cost** (`costUSD`), not its token counts. That
10
+ * matters: a coding agent's volume is dominated by cache reads/writes, and the
11
+ * ledger only stores non-cache input/output token counts — so a token-based
12
+ * estimate undercounts real throughput (and thus rate-limit consumption) by a
13
+ * large factor. `costUSD` is priced from the *full* usage including cache, so it
14
+ * tracks actual consumption far better. We compare it against a rough per-tier
15
+ * **API-equivalent** dollar ceiling for each window.
17
16
  *
18
- * It exists to give hook-only users *a* signal and to nudge them toward
19
- * `ks guard proxy` for exact numbers never to block (subscription mode is
20
- * alert-only). When in doubt it under-claims utilization so it won't cry wolf.
17
+ * This is still deliberately approximate and always labelled as such:
18
+ * - Anthropic meters opaque "prompts" / "active hours", and the dollar ceilings
19
+ * below are rough API-equivalent calibrations, not contractual.
20
+ * - The ledger stores a session's cumulative cost against a single `lastAt`,
21
+ * not a time series, so a long session is counted wholesale into whichever
22
+ * window its last activity falls in.
23
+ *
24
+ * It exists to give hook-only users *a* directional signal and to nudge them
25
+ * toward `ks guard proxy` for exact numbers — never to block (subscription mode
26
+ * is alert-only).
21
27
  */
22
28
  import { WINDOW_MS } from "./limits.js";
23
29
  export const TIER_BUDGETS = {
24
- // Calibrated rough equivalents Pro ≈ 45 prompts / 5h, modest weekly cap.
25
- pro: { fiveHourTokens: 8_000_000, weeklyTokens: 120_000_000 },
26
- max5: { fiveHourTokens: 40_000_000, weeklyTokens: 480_000_000 },
27
- max20: { fiveHourTokens: 160_000_000, weeklyTokens: 1_400_000_000 },
30
+ pro: { fiveHourUSD: 16, weeklyUSD: 100 },
31
+ max5: { fiveHourUSD: 80, weeklyUSD: 500 },
32
+ max20: { fiveHourUSD: 300, weeklyUSD: 2000 },
28
33
  };
29
34
  const FIVE_HOUR_MS = WINDOW_MS["5h"];
30
35
  const WEEK_MS = WINDOW_MS.weekly;
31
- /** Sum tokens (input+output) across sessions whose last activity is within `windowMs`. */
32
- function tokensInWindow(ledger, now, windowMs) {
36
+ /** Sum metered cost across sessions whose last activity is within `windowMs`. */
37
+ function costInWindow(ledger, now, windowMs) {
33
38
  let total = 0;
34
39
  for (const s of Object.values(ledger.sessions)) {
35
40
  if (now - s.lastAt < windowMs)
36
- total += (s.inputTokens || 0) + (s.outputTokens || 0);
41
+ total += s.costUSD || 0;
37
42
  }
38
43
  return total;
39
44
  }
40
45
  /**
41
46
  * Build an estimated {@link LimitSnapshot} from the ledger for a known tier.
42
- * Reset times are derived from the rolling window assumption (oldest in-window
43
- * activity + window length is unknowable here, so we report the window end from
44
- * `now` as a conservative upper bound on time remaining).
47
+ * `resetAt` is null (the true rolling reset is unknowable without a per-event
48
+ * time series), so pacing reports utilization only no fabricated reset/lockout.
45
49
  */
46
50
  export function estimateSnapshot(ledger, tier, now, budgets = TIER_BUDGETS) {
47
51
  const b = budgets[tier];
48
- const fiveTokens = tokensInWindow(ledger, now, FIVE_HOUR_MS);
49
- const weekTokens = tokensInWindow(ledger, now, WEEK_MS);
52
+ const fiveUSD = costInWindow(ledger, now, FIVE_HOUR_MS);
53
+ const weekUSD = costInWindow(ledger, now, WEEK_MS);
50
54
  const clamp = (n) => Math.max(0, Math.min(1, n));
51
- // resetAt is null, not fabricated: we have no per-event time series, so the
52
- // true rolling reset is unknowable. A null reset means pacing reports
53
- // utilization only (no burn-rate, no lockout projection, no bogus reset time) —
54
- // the honest behaviour for an estimate.
55
55
  return {
56
- fiveHour: { utilization: clamp(fiveTokens / b.fiveHourTokens), resetAt: null, status: "estimated" },
57
- weekly: { utilization: clamp(weekTokens / b.weeklyTokens), resetAt: null, status: "estimated" },
56
+ fiveHour: { utilization: clamp(fiveUSD / b.fiveHourUSD), resetAt: null, status: "estimated" },
57
+ weekly: { utilization: clamp(weekUSD / b.weeklyUSD), resetAt: null, status: "estimated" },
58
58
  status: "estimated",
59
59
  observedAt: now,
60
60
  };
package/dist/index.d.ts CHANGED
@@ -16,8 +16,8 @@ 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";
19
+ export { buildStatusReport, buildLimitsReport, formatLimitsLines, type StatusReport, type LimitsReport } from "./report.js";
20
+ export { detectPlanTier, mapRateLimitTier, tierLabel, type SubscriptionTier } from "./claude-code.js";
20
21
  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";
21
22
  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
23
  export { installHook, setBudget, setLimits, resetLedger, type InstallOptions, type InstallResult, type BudgetPatch, type LimitsPatch, } from "./ops.js";
package/dist/index.js CHANGED
@@ -16,8 +16,8 @@ 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";
20
21
  export { parseUnifiedHeaders, parseUtilization, parseReset, recordHeaders, loadLimitsState, saveLimitsState, emptyLimitsState, limitNotifyKey, unifiedHeaderDump, logUnifiedHeaders, WINDOW_MS, } from "./limits.js";
21
22
  export { assessWindow, assessSnapshot, worstLevel, } from "./pacing.js";
22
- export { estimateSnapshot, isEstimated, TIER_BUDGETS, } from "./estimate.js";
23
23
  export { installHook, setBudget, setLimits, resetLedger, } from "./ops.js";
package/dist/report.d.ts CHANGED
@@ -4,24 +4,39 @@
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";
13
17
  import { type PacingAssessment, type PacingLevel } from "./pacing.js";
14
- import type { GuardConfig } from "./config.js";
15
- import type { Ledger } from "./ledger.js";
18
+ import { type SubscriptionTier } from "./claude-code.js";
16
19
  export interface LimitsReport {
17
- /** Where the numbers came from. "none" = no data and no pinned plan to estimate from. */
18
- source: "headers" | "estimated" | "none";
20
+ /** "headers" = real proxy data; "none" = no live data (show tier + cost only). */
21
+ source: "headers" | "none";
22
+ /** Configured plan ("auto" | tier). */
19
23
  plan: string;
24
+ /** Resolved subscription tier (from config or auto-detected from ~/.claude.json). */
25
+ tier: SubscriptionTier | null;
26
+ /** True when the tier came from ~/.claude.json rather than an explicit --plan. */
27
+ tierDetected: boolean;
20
28
  subscriptionDetected: boolean;
21
- /** Epoch ms the snapshot was observed (headers) or computed (estimated). */
29
+ /** Epoch ms the headers snapshot was observed, or null. */
22
30
  observedAt: number | null;
31
+ /** Real per-window pacing (only when source === "headers"). */
23
32
  windows: PacingAssessment[];
24
33
  level: PacingLevel;
34
+ /** Absolute rolling cost (API-equivalent USD) — honest local signal, not a limit %. */
35
+ cost: {
36
+ fiveHourUSD: number;
37
+ dailyUSD: number;
38
+ weeklyUSD: number;
39
+ };
25
40
  }
26
41
  export interface StatusReport {
27
42
  budget: Budget;
@@ -29,12 +44,10 @@ export interface StatusReport {
29
44
  verdict: VerdictLevel;
30
45
  reasons: string[];
31
46
  paused: boolean;
32
- /** Epoch ms the pause auto-expires, or null (indefinite / not paused). */
33
47
  pauseUntil: number | null;
34
48
  sessions: Array<{
35
49
  id: string;
36
50
  } & SessionRecord>;
37
- /** Subscription rate-limit pacing — present whenever we have data to show. */
38
51
  limits: LimitsReport;
39
52
  }
40
53
  /**
@@ -45,8 +58,7 @@ export interface StatusReport {
45
58
  export declare function buildLimitsReport(cfg: GuardConfig, ledger: Ledger, now: number): LimitsReport;
46
59
  /**
47
60
  * 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.
61
+ * both the `agent-guard` and `ks guard` status views stay identical.
50
62
  */
51
63
  export declare function formatLimitsLines(limits: LimitsReport, now?: number): string[];
52
64
  /** 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,52 @@ 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,
44
68
  level: worstLevel(windows),
69
+ cost,
45
70
  };
46
71
  }
47
72
  }
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
- }
73
+ // No live data — honest local signal only (tier + absolute cost, never a fake %).
61
74
  return {
62
75
  source: "none",
63
76
  plan,
77
+ tier,
78
+ tierDetected,
64
79
  subscriptionDetected: state.subscriptionDetected,
65
80
  observedAt: null,
66
81
  windows: [],
67
82
  level: "ok",
83
+ cost,
68
84
  };
69
85
  }
70
86
  function bar(frac) {
@@ -84,30 +100,28 @@ function ageString(observedAt, now) {
84
100
  }
85
101
  /**
86
102
  * 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.
103
+ * both the `agent-guard` and `ks guard` status views stay identical.
89
104
  */
90
105
  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
- ];
99
- }
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}`);
106
+ // Real proxy data → real percentages.
107
+ if (limits.source === "headers") {
108
+ const icon = limits.level === "danger" ? "🟥" : limits.level === "warn" ? "🟡" : "🟢";
109
+ const lines = [`${icon} Claude Code plan limits · observed ${limits.observedAt ? ageString(limits.observedAt, now) : "—"}`];
110
+ for (const w of limits.windows)
111
+ lines.push(` ${bar(w.utilization)} ${w.message}`);
112
+ return lines;
109
113
  }
110
- return lines;
114
+ // No live data: show what's honestly knowable — the tier and absolute cost,
115
+ // and steer to the proxy for the real percentages. Never a fabricated % bar.
116
+ const planStr = limits.tier
117
+ ? `Claude Code plan: ${tierLabel(limits.tier)}${limits.tierDetected ? " (detected)" : ""}`
118
+ : "Claude Code plan: unknown";
119
+ const c = limits.cost;
120
+ return [
121
+ `📊 ${planStr} — real 5-hour/weekly limit % needs the proxy`,
122
+ ` local rolling cost (API-equivalent, NOT a limit %): 5h ${fmtUSD(c.fiveHourUSD)} · 24h ${fmtUSD(c.dailyUSD)} · 7d ${fmtUSD(c.weeklyUSD)}`,
123
+ ` for exact limits: \`ks guard proxy\` → ANTHROPIC_BASE_URL=http://localhost:8787 claude`,
124
+ ];
111
125
  }
112
126
  /** Build the current status report from the on-disk config + ledger. */
113
127
  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.4",
3
+ "version": "0.1.6",
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": {