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

+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