mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(web): show Codex plan usage in header
This commit is contained in:
@@ -195,7 +195,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
|
||||
|
||||
### Plan-usage chip (statusLine telemetry)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, revealed by `planUsageChipEnabled()` in settings-ui.js, the single resolver behind the checkbox, the chip and the create-time `statusLineTelemetry` flag) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through**. Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox, chip visibility, and Claude create-time exporter flag. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`.
|
||||
|
||||
### Cron jobs
|
||||
|
||||
|
||||
+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
@@ -2575,8 +2575,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 /
|
||||
@@ -2603,9 +2603,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
|
||||
@@ -2619,12 +2616,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>
|
||||
|
||||
@@ -12363,17 +12363,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);
|
||||
@@ -12382,6 +12385,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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview Main Codex subscription usage for the shared header chip.
|
||||
*
|
||||
* Codex can return multiple model buckets. The header deliberately follows the
|
||||
* backward-compatible `codex` bucket only; model-specific buckets such as Spark
|
||||
* are separate limits and are not part of the requested row.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as telemetryModule from '../src/usage-telemetry.js';
|
||||
import * as codexResolverModule from '../src/utils/codex-cli-resolver.js';
|
||||
|
||||
const REAL_RESPONSE = {
|
||||
rateLimits: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
|
||||
secondary: null,
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: 'codex_bengalfox',
|
||||
limitName: 'GPT-5.3-Codex-Spark',
|
||||
primary: { usedPercent: 12, windowDurationMins: 300, resetsAt: 1787750984 },
|
||||
secondary: { usedPercent: 23, windowDurationMins: 10080, resetsAt: 1788337784 },
|
||||
},
|
||||
codex: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
|
||||
secondary: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type ParseCodexRateLimits = (value: unknown) => {
|
||||
fiveHour?: { usedPercentage: number; resetAt: number };
|
||||
sevenDay?: { usedPercentage: number; resetAt: number };
|
||||
} | null;
|
||||
|
||||
function parser(): ParseCodexRateLimits {
|
||||
const candidate = (telemetryModule as Record<string, unknown>).parseCodexRateLimitsResponse;
|
||||
expect(candidate, 'usage telemetry must expose the Codex rate-limit parser').toBeTypeOf('function');
|
||||
return candidate as ParseCodexRateLimits;
|
||||
}
|
||||
|
||||
describe('parseCodexRateLimitsResponse', () => {
|
||||
it('uses only the main codex bucket and maps its duration-tagged weekly window', () => {
|
||||
expect(parser()(REAL_RESPONSE)).toEqual({
|
||||
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps 5-hour and 7-day windows by duration even when their positions are reversed', () => {
|
||||
const result = parser()({
|
||||
rateLimitsByLimitId: {
|
||||
codex: {
|
||||
primary: { usedPercent: 44, windowDurationMins: 10080, resetsAt: 200 },
|
||||
secondary: { usedPercent: 17, windowDurationMins: 300, resetsAt: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
fiveHour: { usedPercentage: 17, resetAt: 100_000 },
|
||||
sevenDay: { usedPercentage: 44, resetAt: 200_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the backward-compatible rateLimits snapshot', () => {
|
||||
expect(
|
||||
parser()({
|
||||
rateLimits: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 8, windowDurationMins: 300, resetsAt: 300 },
|
||||
secondary: null,
|
||||
},
|
||||
})
|
||||
).toEqual({ fiveHour: { usedPercentage: 8, resetAt: 300_000 } });
|
||||
});
|
||||
|
||||
it('ignores unrelated duration buckets and malformed percentages', () => {
|
||||
expect(
|
||||
parser()({
|
||||
rateLimitsByLimitId: {
|
||||
codex: {
|
||||
primary: { usedPercent: '40', windowDurationMins: 10080, resetsAt: 200 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 60, resetsAt: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
type CodexRequest = (
|
||||
binaryPath: string,
|
||||
clientVersion: string,
|
||||
request?: (binaryPath: string, clientVersion: string) => Promise<unknown>
|
||||
) => Promise<ReturnType<ParseCodexRateLimits>>;
|
||||
|
||||
describe('readCodexPlanUsage', () => {
|
||||
it('queries through the supplied app-server boundary and normalizes the result', async () => {
|
||||
const candidate = (codexResolverModule as Record<string, unknown>).readCodexPlanUsage;
|
||||
expect(candidate, 'the Codex resolver must expose a read-only usage query').toBeTypeOf('function');
|
||||
const request = vi.fn(async () => REAL_RESPONSE);
|
||||
|
||||
await expect((candidate as CodexRequest)('/opt/codex', '1.23.0', request)).resolves.toEqual({
|
||||
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith('/opt/codex', '1.23.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/** @fileoverview Header plan-usage chip provider rows (Claude above Codex). */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const chip = { innerHTML: '', title: '' };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: (id: string) => (id === 'planUsageChip' ? chip : null),
|
||||
},
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return {
|
||||
CodemanApp: (context as { __CodemanApp: new () => unknown }).__CodemanApp,
|
||||
chip,
|
||||
};
|
||||
}
|
||||
|
||||
type UsageApp = { updatePlanUsageChip: (data: unknown) => void };
|
||||
|
||||
describe('header plan usage chip', () => {
|
||||
it('renders Claude first and the main Codex limits underneath', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({
|
||||
fiveHour: { usedPercentage: 97, resetAt: 1000 },
|
||||
sevenDay: { usedPercentage: 44, resetAt: 2000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
|
||||
expect(chip.innerHTML).toContain('class="pu-row"');
|
||||
expect(chip.innerHTML).toContain('class="pu-provider">Claude</span>');
|
||||
expect(chip.innerHTML).toContain('class="pu-provider">Codex</span>');
|
||||
expect(chip.innerHTML.indexOf('Claude')).toBeLessThan(chip.innerHTML.indexOf('Codex'));
|
||||
expect(chip.innerHTML).toContain('97%');
|
||||
expect(chip.innerHTML).toContain('44%');
|
||||
expect(chip.innerHTML).toContain('40%');
|
||||
expect(chip.title).toContain('Claude plan usage');
|
||||
expect(chip.title).toContain('Codex plan usage');
|
||||
});
|
||||
|
||||
it('omits unavailable Codex windows instead of inventing zero usage', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({
|
||||
fiveHour: { usedPercentage: 10, resetAt: 1000 },
|
||||
sevenDay: { usedPercentage: 20, resetAt: 2000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
|
||||
const codexRow = chip.innerHTML.slice(chip.innerHTML.indexOf('Codex'));
|
||||
expect(codexRow).not.toContain('5h');
|
||||
expect(codexRow).toContain('7d');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @fileoverview Merging Claude status-line usage with polled Codex usage. */
|
||||
|
||||
import { expect, it } from 'vitest';
|
||||
import * as latestModule from '../src/web/plan-usage-latest.js';
|
||||
|
||||
it('preserves the Codex row when a later Claude status-line sample arrives', () => {
|
||||
const module = latestModule as Record<string, unknown>;
|
||||
expect(module.setLatestCodexPlanUsage).toBeTypeOf('function');
|
||||
const setCodex = module.setLatestCodexPlanUsage as (value: unknown) => unknown;
|
||||
const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown;
|
||||
|
||||
setCodex({ sevenDay: { usedPercentage: 40, resetAt: 3000 } });
|
||||
expect(setClaude({ sessionId: 's1', fiveHour: { usedPercentage: 97, resetAt: 1000 } })).toEqual({
|
||||
sessionId: 's1',
|
||||
fiveHour: { usedPercentage: 97, resetAt: 1000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user