mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(usage): plan usage limits header chip via statusLine telemetry
Surface Claude subscription plan usage limits (5-hour rolling + 7-day weekly: percent used + reset time) in the header, opt-in via App Settings → Display → "Plan Usage Limits" (default OFF, no behavior change when off). A Codeman-managed Claude statusLine exporter forwards the rate_limits JSON to a new auth-exempt POST /api/status-telemetry (same loopback + hook-secret gate as /api/hook-event); parsed telemetry broadcasts over SSE session:statusTelemetry to a header chip (amber >=80%, red >=95%, reset times on hover). The exporter prints the same summary back as the in-terminal footer (print-through). - src/usage-telemetry.ts: pure parser/formatter (epoch-sec -> ms, clamp, change signature) + test/usage-telemetry.test.ts - hooks-config.ts: generateStatusLineCommand + applyStatusLineConfig (add/remove; never clobbers a user's own statusLine) - session-routes.ts: inject gate (Claude-only, Codeman-managed cases), driven by create-payload statusLineTelemetry (session-ui.js) - schemas.ts: StatusTelemetrySchema + showPlanUsageLimits + payload field - frontend: header chip, applyHeaderVisibilitySettings toggle, renderIndexHtml strip, _onSessionStatusTelemetry handler Schema empirically confirmed against Claude Code 2.1.177 (Claude Max): only five_hour/seven_day windows exist (no Opus-weekly field); rate_limits is absent before the first API response and for non-subscriber auth. Design + verification method in docs/usage-limits-display-plan.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
formatStatusLineText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../src/usage-telemetry.js';
|
||||
|
||||
// Mirrors the real captured statusline payload (CC 2.1.177, Claude Max) — see
|
||||
// docs/usage-limits-display-plan.md. resets_at is epoch SECONDS.
|
||||
const REAL: RawStatuslinePayload = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 2 },
|
||||
cost: { total_cost_usd: 0.0415495 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('parseStatusTelemetry', () => {
|
||||
it('normalizes the real payload, converting resets_at seconds → ms', () => {
|
||||
const t = parseStatusTelemetry(REAL);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
||||
expect(t!.contextUsedPercentage).toBe(2);
|
||||
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
||||
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('returns null when there is no rate_limits (pre-first-response / non-subscriber)', () => {
|
||||
expect(parseStatusTelemetry({})).toBeNull();
|
||||
expect(parseStatusTelemetry(undefined)).toBeNull();
|
||||
expect(parseStatusTelemetry({ context_window: { used_percentage: 5 } })).toBeNull();
|
||||
expect(parseStatusTelemetry({ rate_limits: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a single window when only one is present', () => {
|
||||
const t = parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 50, resets_at: 1781409000 } } });
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(50);
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a window with a missing or non-numeric field', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 20 }, // no resets_at → dropped
|
||||
seven_day: { used_percentage: 40, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toBeUndefined();
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps percentages to 0–100', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 150, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: -5, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(100);
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a zero/negative reset timestamp', () => {
|
||||
expect(parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 10, resets_at: 0 } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatStatusLineText', () => {
|
||||
it('formats both windows compactly', () => {
|
||||
expect(formatStatusLineText(parseStatusTelemetry(REAL))).toBe('⟳ 5h 15% · 7d 34%');
|
||||
});
|
||||
|
||||
it('falls back to a brand string when there is no data', () => {
|
||||
expect(formatStatusLineText(null)).toBe('codeman');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetrySignature', () => {
|
||||
it('is stable for equal telemetry and changes when a percentage moves', () => {
|
||||
const a = parseStatusTelemetry(REAL)!;
|
||||
const b = parseStatusTelemetry(REAL)!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
|
||||
const moved = parseStatusTelemetry({
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
})!;
|
||||
expect(telemetrySignature(moved)).not.toBe(telemetrySignature(a));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user