From b00ab3ceea1455c6371cb6eb87a3a020c98a0102 Mon Sep 17 00:00:00 2001 From: Jack Stuart <42309435+JackStuart@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:43:52 +0800 Subject: [PATCH] feat(web): show Codex plan usage in header --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/usage-telemetry.ts | 46 ++++++++- src/utils/codex-cli-resolver.ts | 87 ++++++++++++++++- src/utils/index.ts | 8 +- src/web/plan-usage-latest.ts | 20 ++-- src/web/public/app.js | 28 ++++-- src/web/public/index.html | 2 +- src/web/public/styles.css | 30 +++++- src/web/routes/status-telemetry-routes.ts | 6 +- src/web/schemas.ts | 2 +- src/web/server.ts | 33 ++++++- src/web/sse-events.ts | 2 +- test/codex-plan-usage.test.ts | 110 ++++++++++++++++++++++ test/plan-usage-chip.test.ts | 82 ++++++++++++++++ test/plan-usage-latest.test.ts | 18 ++++ 16 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 test/codex-plan-usage.test.ts create mode 100644 test/plan-usage-chip.test.ts create mode 100644 test/plan-usage-latest.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5dbef0bbd..b38de25e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ea9eae87d..a4ccaae74 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.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 diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index b549147b2..2d8fe4aa2 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -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 | 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 diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index 5cb7221a2..6a3f73c07 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -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; + +const APP_SERVER_TIMEOUT_MS = 10_000; +const APP_SERVER_MAX_OUTPUT_BYTES = 256 * 1024; + +function requestCodexRateLimits(binaryPath: string, clientVersion: string): Promise { + 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 { + return parseCodexRateLimitsResponse(await request(binaryPath, clientVersion)); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 44dab03da..9b64d63fc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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, diff --git a/src/web/plan-usage-latest.ts b/src/web/plan-usage-latest.ts index eb497aafa..66f92409e 100644 --- a/src/web/plan-usage-latest.ts +++ b/src/web/plan-usage-latest.ts @@ -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 | null = null; -export function setLatestPlanUsage(value: Record): void { - latest = value; +export function setLatestPlanUsage(value: Record): Record { + const codex = latest?.codex; + latest = { ...value, ...(codex !== undefined ? { codex } : {}) }; + return latest; +} + +export function setLatestCodexPlanUsage(value: object | null): Record { + latest = { ...(latest ?? {}), codex: value }; + return latest; } export function getLatestPlanUsage(): Record | null { diff --git a/src/web/public/app.js b/src/web/public/app.js index 27ef7a495..063205844 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 `${label}${n}%`; }; - chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('·'); + const row = (provider, usage) => { + const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean); + if (!windows.length) return ''; + return `${provider}${windows.join('·')}`; + }; + 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 diff --git a/src/web/public/index.html b/src/web/public/index.html index a5c76bd78..d9491d452 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -192,7 +192,7 @@ -
+