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>
This commit is contained in:
DevvynandClaude Opus 5.5 authored and Codeman maintainer committed 2026-10-22 19:32:17 +02:00
1 parent f80f107445
commit 34cf798b10
22 files changed
+642 -26

No files matched your search

+66 -2
View File
@@ -40,12 +40,20 @@ export interface UsageWindow {
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). */
@@ -196,6 +204,51 @@ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry |
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
@@ -286,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null, now: number = D
* localStorage write + identical chip re-render each time.
*/
export function telemetrySignature(t: StatusTelemetry): string {
return JSON.stringify([
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);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip.
*
* The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but
* the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`,
* readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API,
* so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields
* `null` and the chip simply has no Copilot row.
*
* ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS
* (`redirect: 'error'`, so a redirect cannot carry it elsewhere).
*
* @module utils/copilot-plan-usage
*/
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js';
export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user';
const REQUEST_TIMEOUT_MS = 10_000;
/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */
const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const;
/** The CLI writes `// ...` header comments above the JSON in config.json. */
function parseCommentedJson(raw: string): unknown {
return JSON.parse(
raw
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
);
}
/**
* The sign-in token Copilot would use: a token in the environment first, then the plain-text
* `authTokens` entry the CLI keeps in `<COPILOT_HOME or ~/.copilot>/config.json` when no OS keychain
* is available. Only github.com sign-ins are read from the file (the quota URL is github.com's).
*/
export async function resolveCopilotToken(
env: Record<string, string | undefined> = process.env,
home: string = homedir()
): Promise<string | null> {
for (const key of TOKEN_ENV_KEYS) {
const value = env[key]?.trim();
if (value) return value;
}
const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot');
try {
const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as {
lastLoggedInUser?: { host?: string; login?: string };
authTokens?: Record<string, { token?: unknown } | undefined>;
};
const { host, login } = config.lastLoggedInUser ?? {};
if (host !== 'https://github.com' || !login || !config.authTokens) return null;
for (const key of [`${host}:${login}`, `${host}:${login}:github`]) {
const token = config.authTokens[key]?.token;
if (typeof token === 'string' && token.trim()) return token.trim();
}
} catch {
/* no config, unreadable, or not JSON: no token */
}
return null;
}
export interface ReadCopilotPlanUsageOptions {
/** Defaults to `resolveCopilotToken()`. */
token?: string | null;
/** Injectable for tests. */
fetchFn?: typeof fetch;
}
/** The monthly premium-request window, or null when there is no token, no meter or any failure. */
export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise<StatusTelemetry | null> {
const token = opts.token === undefined ? await resolveCopilotToken() : opts.token;
if (!token) return null;
const doFetch = opts.fetchFn ?? fetch;
try {
const response = await doFetch(COPILOT_QUOTA_URL, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'User-Agent': 'codeman',
},
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
return parseCopilotQuotaResponse(await response.json());
} catch {
return null;
}
}
export interface CopilotPlanUsageGates {
/** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */
planUsageEnabled: () => Promise<boolean>;
/** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */
resolveToken: () => Promise<string | null>;
/** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */
copilotInstalled: () => boolean;
read: (token: string) => Promise<StatusTelemetry | null>;
}
/**
* The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in
* token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never
* runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off.
*/
export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise<StatusTelemetry | null> {
if (!(await gates.planUsageEnabled())) return null;
const token = await gates.resolveToken();
if (!token || !gates.copilotInstalled()) return null;
return gates.read(token);
}
+1
View File
@@ -76,6 +76,7 @@ export {
getCopilotNotFoundMessage,
getCopilotCliVersion,
} from './copilot-cli-resolver.js';
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
export {
boundedPathExists,
describeUnknownPath,
+13 -2
View File
@@ -1,7 +1,7 @@
/**
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
*
* The Claude status-telemetry route and host Codex poll merge their latest
* The Claude status-telemetry route and the host Codex and Copilot polls merge their latest
* values here. The SSE init snapshot (`getLightState`) replays the combined
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
* page load / SSE reconnect — before either source emits another sample, and
@@ -15,8 +15,14 @@
let latest: Record<string, unknown> | null = null;
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
// The Claude status route replaces the Claude part; the host-polled providers ride along.
const codex = latest?.codex;
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
const copilot = latest?.copilot;
latest = {
...value,
...(codex !== undefined ? { codex } : {}),
...(copilot !== undefined ? { copilot } : {}),
};
return latest;
}
@@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record<string, un
return latest;
}
export function setLatestCopilotPlanUsage(value: object | null): Record<string, unknown> {
latest = { ...(latest ?? {}), copilot: value };
return latest;
}
export function getLatestPlanUsage(): Record<string, unknown> | null {
return latest;
}
+24 -6
View File
@@ -3605,7 +3605,7 @@ class CodemanApp {
}
}
// Claude + Codex plan usage limits — account-global, so the latest sample
// Claude, Codex and Copilot plan usage limits — account-global, so the latest sample
// drives the shared header chip.
_onSessionStatusTelemetry(data) {
this.updatePlanUsageChip(data);
@@ -3665,19 +3665,27 @@ class CodemanApp {
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
const hasWindows = (usage) =>
pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null || pct(usage?.monthly) !== null;
const labelled = [data, data.codex, data.copilot].filter(hasWindows).length > 1;
const row = (provider, usage, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
// `mo` is Copilot's calendar-month premium-request quota; it is the only window that provider has.
const windows = [
seg('5h', pct(usage?.fiveHour), idle),
seg('7d', pct(usage?.sevenDay), idle),
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
Boolean
);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage, idle) => {
@@ -3687,10 +3695,20 @@ class CodemanApp {
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
const month = pct(usage?.monthly);
if (month !== null) {
// Counts are coerced finite numbers, so interpolating them stays XSS-safe like the rest.
const m = usage.monthly;
const counts =
Number.isFinite(m.used) && Number.isFinite(m.limit) ? `${Math.round(m.used)} of ${Math.round(m.limit)} requests, ` : '';
lines.push(`Premium requests this month: ${month}% used (${counts}resets ${resetStr(m)})`);
}
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title =
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
[details('Claude', data, true), details('Codex', data.codex, false), details('Copilot', data.copilot, false)]
.filter(Boolean)
.join('\n\n') ||
'Plan usage limits';
}
+1 -1
View File
@@ -197,7 +197,7 @@
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude, Codex and Copilot plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
+44 -1
View File
@@ -100,6 +100,7 @@ import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
removeAgentSessionPreamble,
} from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
@@ -170,9 +171,15 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
import { isCopilotAvailable } from '../utils/copilot-cli-resolver.js';
import {
readCopilotPlanUsage,
readCopilotPlanUsageIfEnabled,
resolveCopilotToken,
} from '../utils/copilot-plan-usage.js';
import type { ScheduledRun } from './ports/index.js';
import {
registerAuthMiddleware,
@@ -237,6 +244,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// while capping growth of `sseClientsById` and blocking pathological inputs.
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
// Copilot's quota is read over the network from GitHub, and a monthly figure moves slowly.
const COPILOT_USAGE_POLL_INTERVAL_MS = 10 * 60_000;
const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000;
// Much shorter than the model-LIST refresh above on purpose: this catches an actual
// eviction (a session's model no longer loaded, silently swapped out by another
@@ -406,6 +415,8 @@ export class WebServer extends EventEmitter {
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private codexUsageRefreshInFlight = false;
private lastCodexUsageSignature: string | null = null;
private copilotUsageRefreshInFlight = false;
private lastCopilotUsageSignature: string | null = null;
// Server startup time for respawn grace period calculation
private readonly serverStartTime: number = Date.now();
// Pending respawn start timers (for cleanup on shutdown)
@@ -2958,6 +2969,34 @@ export class WebServer extends EventEmitter {
}
}
/**
* Copilot's monthly premium-request quota for the shared plan-usage chip. Host-scoped and read-only
* like the Codex poll, and additionally gated by `readCopilotPlanUsageIfEnabled()`: only while
* plan-usage display is on (so turning the chip off also stops the outbound request), only with a
* sign-in token and only with the Copilot CLI installed; nothing is sent to GitHub otherwise.
*/
private async refreshCopilotPlanUsage(): Promise<void> {
if (this.copilotUsageRefreshInFlight) return;
this.copilotUsageRefreshInFlight = true;
try {
// A null clears the row, so switching the chip off also takes the last quota off the page.
const usage = await readCopilotPlanUsageIfEnabled({
planUsageEnabled: readPlanUsageTelemetryEnabled,
resolveToken: () => resolveCopilotToken(),
copilotInstalled: isCopilotAvailable,
read: (token) => readCopilotPlanUsage({ token }),
});
const signature = usage ? telemetrySignature(usage) : '';
if (signature === this.lastCopilotUsageSignature) return;
this.lastCopilotUsageSignature = signature;
const snapshot = setLatestCopilotPlanUsage(usage);
this.cachedLightState = null;
this.broadcast(SseEvent.SessionStatusTelemetry, snapshot);
} finally {
this.copilotUsageRefreshInFlight = false;
}
}
async start(): Promise<void> {
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
// if there are no users yet, else refuse to start (there would be no way in).
@@ -3132,6 +3171,10 @@ export class WebServer extends EventEmitter {
this.cleanup.setInterval(() => void this.refreshCodexPlanUsage(), CODEX_USAGE_POLL_INTERVAL_MS, {
description: 'Codex plan-usage refresh',
});
void this.refreshCopilotPlanUsage();
this.cleanup.setInterval(() => void this.refreshCopilotPlanUsage(), COPILOT_USAGE_POLL_INTERVAL_MS, {
description: 'Copilot plan-usage refresh',
});
}
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): keeps