Files
Codeman/src/usage-telemetry.ts
T
DevvynandClaude Opus 5.5 34cf798b10 feat(copilot): plan-usage chip row for the monthly premium-request quota (#662)
Squash of PR #662. The header plan-usage chip gains a Copilot row: the
calendar-month premium-request quota, with the request counts and the
reset in the tooltip, read every 10 minutes from GitHub's
copilot_internal/user endpoint with the Copilot CLI's own sign-in token.
It rides showPlanUsageLimits and is on by default while the CLI is
installed and signed in; any failure leaves the chip without the row.

Merge-time fixes by the maintainer: the gates (setting, then token, then
the CLI probe) live in readCopilotPlanUsageIfEnabled() so a host with no
Copilot sign-in never runs the login-shell probe each tick, with a test
that Plan Usage Limits off means no request; the X-GitHub-Api-Version
header the Copilot CLI itself does not send is dropped; and CLAUDE.md,
architecture-invariants, README and copilot-integration describe the read.
The hooks-config import in server.ts was merged with #666's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 19:32:17 +02:00

360 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Pure parsing + formatting of Claude and Codex plan telemetry.
*
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
* object with the 5-hour rolling and 7-day weekly plan windows. The
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
* Codeman displays and format the compact in-terminal footer string.
*
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
* `docs/usage-limits-display-plan.md`):
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
* resets_at: number EPOCH-SECONDS}
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
* so the footer flips to cold on its own.
*
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
* response and selects only the main `codex` bucket, excluding model-specific
* buckets. All functions are pure for testability. See
* `test/usage-telemetry.test.ts` and `test/codex-plan-usage.test.ts`.
*
* @module usage-telemetry
*/
import type { PromptCacheStatus } from './types/index.js';
export type { PromptCacheStatus };
/** A single normalized plan-usage window. */
export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
usedPercentage: number;
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
resetAt: number;
/**
* Absolute counts, for a window measured in requests rather than as a bare percent (Copilot's
* monthly premium-request quota). Absent for the Claude and Codex windows.
*/
used?: number;
limit?: number;
}
/** Normalized telemetry Codeman broadcasts to the UI. */
export interface StatusTelemetry {
fiveHour?: UsageWindow;
sevenDay?: UsageWindow;
/** A calendar-month quota (GitHub Copilot's premium requests); not a rolling 5h/7d window. */
monthly?: UsageWindow;
/** Context-window percent used, 0–100 (bonus field from the same payload). */
contextUsedPercentage?: number;
/** Session cost in USD (bonus field). */
costUsd?: number;
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
modelDisplayName?: string;
}
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
export interface RawStatuslinePayload {
rate_limits?: {
five_hour?: { used_percentage?: number; resets_at?: number };
seven_day?: { used_percentage?: number; resets_at?: number };
};
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
prompt_cache?: RawPromptCache | null;
}
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
export interface RawPromptCache {
warm?: boolean | null;
ttl?: string | null;
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
expires_at?: number | null;
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
recache_tokens_if_cold?: number | null;
misses?: number | null;
/** Cache rebuilds that followed a compaction or a clearing of old tool results (counted at the request after one; not read for the compaction stamp). */
expected_rebuilds?: number | null;
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
hit_ratio?: number | null;
last_miss_cause?: { causes?: unknown } | null;
}
interface RawCodexRateLimitWindow {
usedPercent?: unknown;
windowDurationMins?: unknown;
resetsAt?: unknown;
}
interface RawCodexRateLimitSnapshot {
primary?: RawCodexRateLimitWindow | null;
secondary?: RawCodexRateLimitWindow | null;
}
interface RawCodexRateLimitsResponse {
rateLimits?: RawCodexRateLimitSnapshot | null;
rateLimitsByLimitId?: Record<string, RawCodexRateLimitSnapshot | null> | null;
}
function clampPct(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
}
/**
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
* null when there is no plan-limit data to show (pre-first-response or a
* non-subscriber account) so the caller can skip broadcasting.
*/
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
if (!data) return null;
const fiveHour = parseWindow(data.rate_limits?.five_hour);
const sevenDay = parseWindow(data.rate_limits?.seven_day);
if (!fiveHour && !sevenDay) return null;
const t: StatusTelemetry = {};
if (fiveHour) t.fiveHour = fiveHour;
if (sevenDay) t.sevenDay = sevenDay;
if (typeof data.context_window?.used_percentage === 'number') {
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
}
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
t.costUsd = data.cost.total_cost_usd;
}
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
t.modelDisplayName = data.model.display_name.slice(0, 60);
}
return t;
}
/**
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
* a boolean, so the caller can treat the cache state as unknown rather than cold.
*/
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
const pc = data?.prompt_cache;
if (!pc || typeof pc.warm !== 'boolean') return null;
const s: PromptCacheStatus = { warm: pc.warm };
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
// Infinity or lands outside the Date range after *1000, which renders "cache:until
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
const ms = Math.round(pc.expires_at * 1000);
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
}
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
}
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
s.misses = Math.max(0, Math.round(pc.misses));
}
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
}
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
}
const causes = pc.last_miss_cause?.causes;
if (Array.isArray(causes)) {
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
if (names.length) s.lastMissCauses = names;
}
return s;
}
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null;
const response = value as RawCodexRateLimitsResponse;
const snapshot = response.rateLimitsByLimitId?.codex ?? response.rateLimits;
if (!snapshot || typeof snapshot !== 'object') return null;
const telemetry: StatusTelemetry = {};
for (const window of [snapshot.primary, snapshot.secondary]) {
if (!window || typeof window.usedPercent !== 'number' || !Number.isFinite(window.usedPercent)) continue;
if (window.windowDurationMins !== 300 && window.windowDurationMins !== 10_080) continue;
const resetsAt =
typeof window.resetsAt === 'number' && Number.isFinite(window.resetsAt) && window.resetsAt > 0
? Math.round(window.resetsAt * 1000)
: 0;
const normalized = { usedPercentage: clampPct(window.usedPercent), resetAt: resetsAt };
if (window.windowDurationMins === 300) telemetry.fiveHour = normalized;
if (window.windowDurationMins === 10_080) telemetry.sevenDay = normalized;
}
return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null;
}
/**
* Normalize GitHub Copilot's quota snapshot (`GET /copilot_internal/user`) into the chip's monthly
* window. Only `premium_interactions` is a metered quota: `chat` and `completions` report
* `unlimited: true` on every plan seen, and an unlimited premium quota has nothing to meter, so it
* yields null and the chip shows no Copilot row rather than a bar stuck at 0%.
*/
export function parseCopilotQuotaResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null;
const raw = value as {
quota_snapshots?: {
premium_interactions?: {
entitlement?: unknown;
remaining?: unknown;
percent_remaining?: unknown;
unlimited?: unknown;
};
};
quota_reset_date_utc?: unknown;
quota_reset_date?: unknown;
};
const snap = raw.quota_snapshots?.premium_interactions;
if (!snap || typeof snap !== 'object' || snap.unlimited === true) return null;
// A zero (or negative) quota that is not 'unlimited' would show a stuck 0% or 100% row.
if (typeof snap.entitlement === 'number' && snap.entitlement <= 0) return null;
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const limit = finite(snap.entitlement) && snap.entitlement > 0 ? snap.entitlement : undefined;
let usedPct: number | null = null;
if (finite(snap.percent_remaining)) usedPct = 100 - snap.percent_remaining;
else if (limit !== undefined && finite(snap.remaining)) usedPct = (1 - snap.remaining / limit) * 100;
if (usedPct === null) return null;
const resetRaw = typeof raw.quota_reset_date_utc === 'string' ? raw.quota_reset_date_utc : raw.quota_reset_date;
const parsedReset = typeof resetRaw === 'string' ? Date.parse(resetRaw) : NaN;
const monthly: UsageWindow = {
usedPercentage: clampPct(usedPct),
resetAt: Number.isFinite(parsedReset) && parsedReset > 0 ? parsedReset : 0,
};
if (limit !== undefined && finite(snap.remaining)) {
monthly.limit = limit;
monthly.used = Math.max(0, Math.round(limit - snap.remaining));
}
return { monthly };
}
/**
* Current-session status for the in-terminal statusline footer. This is the
* "status of the current session" the user sees in Claude's footer — distinct
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
*/
export interface SessionStatus {
modelDisplayName?: string;
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
/** Prompt-cache state of the main conversation, when the statusline reports it. */
cache?: PromptCacheStatus;
}
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
function withCommas(n: number): string {
return Math.max(0, Math.round(n))
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** Extract current-session status (footer) from the raw payload. */
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
if (!data) return null;
const s: SessionStatus = {};
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
s.modelDisplayName = data.model.display_name.slice(0, 60);
}
const cw = data.context_window;
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
s.inputTokens = Math.max(0, cw.total_input_tokens);
}
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
s.outputTokens = Math.max(0, cw.total_output_tokens);
}
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
const cache = parsePromptCache(data);
if (cache) s.cache = cache;
return Object.keys(s).length ? s : null;
}
/**
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
* once Claude reports it cold or the render happened past `expires_at`. A clock
* time rather than a countdown because the footer only re-renders on statusline
* triggers, so a countdown would sit stale on screen between turns.
*/
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
const d = new Date(c.expiresAt);
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
* the plan limits, which live in the Codeman header chip. Claude requires a
* statusLine command to emit the rate_limits JSON at all, so this is what that
* command prints back when it has no statusline of the user's own to wrap. With
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
* statusline is the symptom discussion #405 opened with. `now` is injectable so
* the cache group is testable.
*/
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
if (!s) return '';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = [];
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
return groups.length ? groups.join(' ') : '';
}
/**
* Stable signature for change-detection — the statusline fires on every
* assistant message, so the route only rebroadcasts when this value changes.
*
* Keys on EXACTLY the values the header chip displays: the two windows' ROUNDED
* percentages (the chip renders `Math.round`) + their reset times. Deliberately
* excludes contextUsedPercentage / costUsd / modelDisplayName — none are shown
* in the chip, and contextUsedPercentage in particular drifts on every assistant
* message, which would defeat the dedup and fan out a redundant SSE broadcast +
* localStorage write + identical chip re-render each time.
*/
export function telemetrySignature(t: StatusTelemetry): string {
const parts: Array<number | null> = [
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
t.fiveHour?.resetAt ?? null,
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
t.sevenDay?.resetAt ?? null,
];
// Appended only when present, so the Claude and Codex signatures are byte-identical to before.
// The limit is part of it: a plan change mid-month (300 to 1500 with 100 used) keeps `used` and the reset.
if (t.monthly) {
parts.push(
Math.round(t.monthly.usedPercentage),
t.monthly.used ?? null,
t.monthly.limit ?? null,
t.monthly.resetAt
);
}
return JSON.stringify(parts);
}