mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Merge pull request #346 from JackStuart/codex/show-codex-usage-limits
feat(web): show Codex plan usage in header
This commit is contained in:
+44
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
|
||||
* @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`
|
||||
@@ -15,7 +15,10 @@
|
||||
* 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.
|
||||
*
|
||||
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
|
||||
* 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
|
||||
*/
|
||||
@@ -51,6 +54,22 @@ export interface RawStatuslinePayload {
|
||||
model?: { display_name?: string };
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -88,6 +107,29 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
import { parseCodexRateLimitsResponse, type StatusTelemetry } from '../usage-telemetry.js';
|
||||
|
||||
/** Common directories where the Codex CLI binary may be installed */
|
||||
const CODEX_SEARCH_DIRS = [
|
||||
@@ -21,7 +23,8 @@ const CODEX_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
|
||||
/**
|
||||
@@ -35,6 +38,11 @@ export function resolveCodexDir(): string | null {
|
||||
return codexResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/** Absolute Codex executable path, for direct app-server requests. */
|
||||
export function resolveCodexBinaryPath(): string | null {
|
||||
return codexResolver.resolve()?.binaryPath ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system.
|
||||
*/
|
||||
@@ -45,3 +53,80 @@ export function isCodexAvailable(): boolean {
|
||||
export function getCodexNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||
}
|
||||
|
||||
type CodexRateLimitsRequest = (binaryPath: string, clientVersion: string) => Promise<unknown>;
|
||||
|
||||
const APP_SERVER_TIMEOUT_MS = 10_000;
|
||||
const APP_SERVER_MAX_OUTPUT_BYTES = 256 * 1024;
|
||||
|
||||
function requestCodexRateLimits(binaryPath: string, clientVersion: string): Promise<unknown> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let initialized = false;
|
||||
let buffer = '';
|
||||
const child = spawn(binaryPath, ['app-server', '--stdio'], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const timeout = setTimeout(() => finish(null), APP_SERVER_TIMEOUT_MS);
|
||||
|
||||
const finish = (value: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
resolve(value);
|
||||
};
|
||||
const send = (message: unknown): void => {
|
||||
if (!settled && child.stdin.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
const handleLine = (line: string): void => {
|
||||
if (!line.trim()) return;
|
||||
let message: { id?: number; result?: unknown; error?: unknown };
|
||||
try {
|
||||
message = JSON.parse(line) as { id?: number; result?: unknown; error?: unknown };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.id === 1) {
|
||||
if (message.error) return finish(null);
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
send({ method: 'account/rateLimits/read', id: 2 });
|
||||
}
|
||||
} else if (message.id === 2) {
|
||||
finish(message.error ? null : message.result);
|
||||
}
|
||||
};
|
||||
|
||||
child.on('error', () => finish(null));
|
||||
child.on('close', () => finish(null));
|
||||
child.stdin.on('error', () => finish(null));
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
if (Buffer.byteLength(buffer) > APP_SERVER_MAX_OUTPUT_BYTES) return finish(null);
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) handleLine(line);
|
||||
});
|
||||
|
||||
send({
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: {
|
||||
clientInfo: { name: 'codeman', title: 'Codeman', version: clientVersion },
|
||||
capabilities: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the signed-in host account's main Codex limits without exposing credentials. */
|
||||
export async function readCodexPlanUsage(
|
||||
binaryPath: string,
|
||||
clientVersion: string,
|
||||
request: CodexRateLimitsRequest = requestCodexRateLimits
|
||||
): Promise<StatusTelemetry | null> {
|
||||
return parseCodexRateLimitsResponse(await request(binaryPath, clientVersion));
|
||||
}
|
||||
|
||||
+7
-1
@@ -37,7 +37,13 @@ export {
|
||||
} from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||
export {
|
||||
resolveCodexDir,
|
||||
resolveCodexBinaryPath,
|
||||
isCodexAvailable,
|
||||
getCodexNotFoundMessage,
|
||||
readCodexPlanUsage,
|
||||
} from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||
export {
|
||||
resolveAntigravityDir,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The status-telemetry route writes the latest broadcast value here; the SSE
|
||||
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
|
||||
* chip shows immediately on a fresh page load / SSE reconnect — before any new
|
||||
* statusline render arrives, and without relying on per-browser localStorage.
|
||||
* The Claude status-telemetry route and host Codex poll 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
|
||||
* without relying on per-browser localStorage.
|
||||
*
|
||||
* Null until the first telemetry of the process; cleared naturally on restart.
|
||||
*
|
||||
@@ -13,8 +14,15 @@
|
||||
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): void {
|
||||
latest = value;
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const codex = latest?.codex;
|
||||
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setLatestCodexPlanUsage(value: object | null): Record<string, unknown> {
|
||||
latest = { ...(latest ?? {}), codex: value };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
|
||||
+18
-10
@@ -2597,8 +2597,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
|
||||
// sample from any session drives the shared header chip.
|
||||
// Claude + Codex plan usage limits — account-global, so the latest sample
|
||||
// drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this.updatePlanUsageChip(data);
|
||||
// Persist last-known so the chip shows immediately on the next page load /
|
||||
@@ -2625,9 +2625,6 @@ class CodemanApp {
|
||||
const chip = document.getElementById('planUsageChip');
|
||||
if (!chip || !data) return;
|
||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
||||
const five = pct(data.fiveHour);
|
||||
const seven = pct(data.sevenDay);
|
||||
if (five === null && seven === null) return;
|
||||
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
|
||||
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
|
||||
// innerHTML here is XSS-safe ONLY because every interpolated value is a
|
||||
@@ -2641,12 +2638,23 @@ class CodemanApp {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
|
||||
const row = (provider, usage) => {
|
||||
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
|
||||
if (!windows.length) return '';
|
||||
return `<span class="pu-row"><span class="pu-provider">${provider}</span><span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
};
|
||||
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
|
||||
chip.innerHTML = rows.length ? rows.join('') : '—';
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
chip.title =
|
||||
`Claude plan usage\n` +
|
||||
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
||||
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
||||
const details = (provider, usage) => {
|
||||
const lines = [];
|
||||
const five = pct(usage?.fiveHour);
|
||||
const seven = pct(usage?.sevenDay);
|
||||
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
|
||||
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
|
||||
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
|
||||
};
|
||||
chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="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"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><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="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></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 plan usage limits">—</div>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex 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>
|
||||
|
||||
@@ -12372,17 +12372,20 @@ kbd {
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
|
||||
live Claude and Codex plan limits in provider rows. Ships
|
||||
hidden via the marker class below because display is PER-DEVICE and the
|
||||
server cannot read localStorage; settings-ui.js reveals it on load (desktop
|
||||
default ON, handhelds OFF) and on a live toggle. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 11px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 22px;
|
||||
padding: 3px 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
@@ -12391,6 +12394,23 @@ kbd {
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.header-plan-usage .pu-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
.header-plan-usage .pu-provider {
|
||||
width: 46px;
|
||||
flex: 0 0 46px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.header-plan-usage .pu-windows {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||
.header-plan-usage .pu-win {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -57,9 +57,9 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
||||
}
|
||||
}
|
||||
const payload = { sessionId, ...telemetry };
|
||||
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
|
||||
ctx.broadcast(SessionStatusTelemetry, payload);
|
||||
const update = { sessionId, ...telemetry };
|
||||
const snapshot = setLatestPlanUsage(update); // replayed in the SSE init snapshot for fresh loads
|
||||
ctx.broadcast(SessionStatusTelemetry, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -449,7 +449,7 @@ export const CreateSessionSchema = z.object({
|
||||
effort: effortLevelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
|
||||
/** Inject the Claude statusLine source for the shared plan-usage chip. Claude sessions only; Codex is host-polled. */
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
|
||||
+32
-1
@@ -143,7 +143,9 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
|
||||
import { telemetrySignature } from '../usage-telemetry.js';
|
||||
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { isMultiUserMode } from '../config/multiuser.js';
|
||||
@@ -186,6 +188,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs,
|
||||
// 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;
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
@@ -268,6 +271,8 @@ export class WebServer extends EventEmitter {
|
||||
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
||||
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
||||
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
|
||||
private codexUsageRefreshInFlight = false;
|
||||
private lastCodexUsageSignature: 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)
|
||||
@@ -2394,6 +2399,23 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshCodexPlanUsage(): Promise<void> {
|
||||
if (this.codexUsageRefreshInFlight) return;
|
||||
this.codexUsageRefreshInFlight = true;
|
||||
try {
|
||||
const binaryPath = resolveCodexBinaryPath();
|
||||
const usage = binaryPath ? await readCodexPlanUsage(binaryPath, APP_VERSION) : null;
|
||||
const signature = usage ? telemetrySignature(usage) : '';
|
||||
if (signature === this.lastCodexUsageSignature) return;
|
||||
this.lastCodexUsageSignature = signature;
|
||||
const snapshot = setLatestCodexPlanUsage(usage);
|
||||
this.cachedLightState = null;
|
||||
this.broadcast(SseEvent.SessionStatusTelemetry, snapshot);
|
||||
} finally {
|
||||
this.codexUsageRefreshInFlight = 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).
|
||||
@@ -2531,6 +2553,15 @@ export class WebServer extends EventEmitter {
|
||||
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
|
||||
getHookSecret();
|
||||
|
||||
// Main Codex subscription limits come from the signed-in local CLI. Keep
|
||||
// this read-only and host-scoped; multi-user SSE routing makes it admin-only.
|
||||
if (!this.testMode) {
|
||||
void this.refreshCodexPlanUsage();
|
||||
this.cleanup.setInterval(() => void this.refreshCodexPlanUsage(), CODEX_USAGE_POLL_INTERVAL_MS, {
|
||||
description: 'Codex plan-usage refresh',
|
||||
});
|
||||
}
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
|
||||
@@ -116,7 +116,7 @@ export const SessionMessage = 'session:message' as const;
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
|
||||
/** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user