Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 44 additions & 2 deletions src/usage-telemetry.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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
*/
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down
87 changes: 86 additions & 1 deletion src/utils/codex-cli-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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';

/**
Expand All @@ -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.
*/
Expand All @@ -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));
}
8 changes: 7 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 14 additions & 6 deletions src/web/plan-usage-latest.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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 {
Expand Down
28 changes: 18 additions & 10 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading