mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
f80f107445
commit
34cf798b10
22 files changed
+642
-26
No files matched your search
+66
-2
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export {
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user