mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16: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)
|
**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`.
|
**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)
|
||||||
|
|
||||||
**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
|
### 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`
|
* 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`
|
* 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
|
* 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.
|
* 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
|
* @module usage-telemetry
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +54,22 @@ export interface RawStatuslinePayload {
|
|||||||
model?: { display_name?: string };
|
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 {
|
function clampPct(n: number): number {
|
||||||
if (!Number.isFinite(n)) return 0;
|
if (!Number.isFinite(n)) return 0;
|
||||||
return Math.max(0, Math.min(100, n));
|
return Math.max(0, Math.min(100, n));
|
||||||
@@ -88,6 +107,29 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
|||||||
return t;
|
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
|
* 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
|
* "status of the current session" the user sees in Claude's footer — distinct
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
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 */
|
/** Common directories where the Codex CLI binary may be installed */
|
||||||
const CODEX_SEARCH_DIRS = [
|
const CODEX_SEARCH_DIRS = [
|
||||||
@@ -21,7 +23,8 @@ const CODEX_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'), // User bin
|
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';
|
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;
|
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.
|
* Check if Codex CLI is available on the system.
|
||||||
*/
|
*/
|
||||||
@@ -45,3 +53,80 @@ export function isCodexAvailable(): boolean {
|
|||||||
export function getCodexNotFoundMessage(): string {
|
export function getCodexNotFoundMessage(): string {
|
||||||
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
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';
|
} from './claude-cli-resolver.js';
|
||||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||||
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.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 { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||||
export {
|
export {
|
||||||
resolveAntigravityDir,
|
resolveAntigravityDir,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||||
*
|
*
|
||||||
* The status-telemetry route writes the latest broadcast value here; the SSE
|
* The Claude status-telemetry route and host Codex poll merge their latest
|
||||||
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
|
* values here. The SSE init snapshot (`getLightState`) replays the combined
|
||||||
* chip shows immediately on a fresh page load / SSE reconnect — before any new
|
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
|
||||||
* statusline render arrives, and without relying on per-browser localStorage.
|
* 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.
|
* Null until the first telemetry of the process; cleared naturally on restart.
|
||||||
*
|
*
|
||||||
@@ -13,8 +14,15 @@
|
|||||||
|
|
||||||
let latest: Record<string, unknown> | null = null;
|
let latest: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
export function setLatestPlanUsage(value: Record<string, unknown>): void {
|
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
latest = value;
|
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 {
|
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
|
// Claude + Codex plan usage limits — account-global, so the latest sample
|
||||||
// sample from any session drives the shared header chip.
|
// drives the shared header chip.
|
||||||
_onSessionStatusTelemetry(data) {
|
_onSessionStatusTelemetry(data) {
|
||||||
this.updatePlanUsageChip(data);
|
this.updatePlanUsageChip(data);
|
||||||
// Persist last-known so the chip shows immediately on the next page load /
|
// Persist last-known so the chip shows immediately on the next page load /
|
||||||
@@ -2603,9 +2603,6 @@ class CodemanApp {
|
|||||||
const chip = document.getElementById('planUsageChip');
|
const chip = document.getElementById('planUsageChip');
|
||||||
if (!chip || !data) return;
|
if (!chip || !data) return;
|
||||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
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%.
|
// 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');
|
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
|
// innerHTML here is XSS-safe ONLY because every interpolated value is a
|
||||||
@@ -2619,12 +2616,23 @@ class CodemanApp {
|
|||||||
if (!Number.isFinite(n)) return '';
|
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>`;
|
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() : '—');
|
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||||
chip.title =
|
const details = (provider, usage) => {
|
||||||
`Claude plan usage\n` +
|
const lines = [];
|
||||||
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
const five = pct(usage?.fiveHour);
|
||||||
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
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
|
// 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-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-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>
|
<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;">
|
<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>
|
<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>
|
<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
|
/* 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
|
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
|
server cannot read localStorage; settings-ui.js reveals it on load (desktop
|
||||||
default ON, handhelds OFF) and on a live toggle. */
|
default ON, handhelds OFF) and on a live toggle. */
|
||||||
.header-plan-usage {
|
.header-plan-usage {
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
height: 22px;
|
align-items: stretch;
|
||||||
padding: 0 0.5rem;
|
justify-content: center;
|
||||||
border-radius: 11px;
|
min-height: 22px;
|
||||||
|
padding: 3px 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -12382,6 +12385,23 @@ kbd {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
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. */
|
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||||
.header-plan-usage .pu-win {
|
.header-plan-usage .pu-win {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const payload = { sessionId, ...telemetry };
|
const update = { sessionId, ...telemetry };
|
||||||
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
|
const snapshot = setLatestPlanUsage(update); // replayed in the SSE init snapshot for fresh loads
|
||||||
ctx.broadcast(SessionStatusTelemetry, payload);
|
ctx.broadcast(SessionStatusTelemetry, snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -449,7 +449,7 @@ export const CreateSessionSchema = z.object({
|
|||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||||
modelOverride: z.string().max(50).optional(),
|
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(),
|
statusLineTelemetry: z.boolean().optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
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 { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||||
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||||
import { SseEvent } from './sse-events.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 type { ScheduledRun } from './ports/index.js';
|
||||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||||
import { isMultiUserMode } from '../config/multiuser.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,
|
// Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs,
|
||||||
// while capping growth of `sseClientsById` and blocking pathological inputs.
|
// while capping growth of `sseClientsById` and blocking pathological inputs.
|
||||||
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
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 {
|
function escapeHtmlText(value: string): string {
|
||||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||||
@@ -268,6 +271,8 @@ export class WebServer extends EventEmitter {
|
|||||||
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
||||||
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
// 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 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
|
// Server startup time for respawn grace period calculation
|
||||||
private readonly serverStartTime: number = Date.now();
|
private readonly serverStartTime: number = Date.now();
|
||||||
// Pending respawn start timers (for cleanup on shutdown)
|
// 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> {
|
async start(): Promise<void> {
|
||||||
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
|
// 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).
|
// 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.
|
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
|
||||||
getHookSecret();
|
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
|
// Start scheduled runs cleanup timer
|
||||||
this.cleanup.setInterval(
|
this.cleanup.setInterval(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const SessionMessage = 'session:message' as const;
|
|||||||
export const SessionInteractive = 'session:interactive' as const;
|
export const SessionInteractive = 'session:interactive' as const;
|
||||||
/** Prompt sent to session for execution. */
|
/** Prompt sent to session for execution. */
|
||||||
export const SessionRunning = 'session:running' as const;
|
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;
|
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||||
|
|
||||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
// ─── 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