From 21a9c9909d5229cc8f8d9b50bcf58b04f1abc5cb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 17 May 2026 17:23:35 -0400 Subject: [PATCH] Handle Claude 2.1 usage output (#2162) Co-authored-by: Orca --- src/main/rate-limits/claude-fetcher.test.ts | 3 +- src/main/rate-limits/claude-fetcher.ts | 6 +- src/main/rate-limits/claude-pty.test.ts | 83 +++++++++++++++++++++ src/main/rate-limits/claude-pty.ts | 70 ++++++++++++++--- 4 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 7b86f4765..46631c59f 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -135,7 +135,8 @@ describe('fetchClaudeRateLimits', () => { 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ headers: expect.objectContaining({ - Authorization: 'Bearer oauth-token' + Authorization: 'Bearer oauth-token', + 'User-Agent': 'claude-code/2.1.0' }) }) ) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 8622d4205..9bce76428 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -17,6 +17,7 @@ import { const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' +const CLAUDE_CODE_USER_AGENT = 'claude-code/2.1.0' const API_TIMEOUT_MS = 10_000 let proxyConfigured = false @@ -266,7 +267,10 @@ async function fetchViaOAuth(token: string): Promise { const res = await net.fetch(OAUTH_USAGE_URL, { headers: { Authorization: `Bearer ${token}`, - 'anthropic-beta': OAUTH_BETA_HEADER + 'anthropic-beta': OAUTH_BETA_HEADER, + // Why: Claude's OAuth usage endpoint is the Claude Code usage API; + // matching the CLI user-agent keeps Orca aligned with that contract. + 'User-Agent': CLAUDE_CODE_USER_AGENT }, signal: controller.signal }) diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a3e0d80f8..4466f0d0b 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -19,6 +19,29 @@ function makeDisposable() { return { dispose: vi.fn() } } +type MockTerm = { + onData: ReturnType + onExit: ReturnType + write: ReturnType + kill: ReturnType +} + +function makeMockTerm(): MockTerm & { + emitData: (data: string) => void +} { + let dataHandler: ((data: string) => void) | null = null + return { + onData: vi.fn((handler: (data: string) => void) => { + dataHandler = handler + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data) + } +} + describe('fetchViaPty', () => { beforeEach(() => { vi.useFakeTimers() @@ -49,4 +72,64 @@ describe('fetchViaPty', () => { term.kill.mock.invocationCallOrder[0] ) }) + + it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + expect(term.write).toHaveBeenCalledWith('/usage\r') + + term.emitData(` + Settings Status Config Usage Stats + + Session + Total cost: $0.0000 + Usage: 0 input, 0 output, 0 cache read, 0 cache write + `) + + await vi.advanceTimersByTimeAsync(8_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + session: null, + weekly: null, + error: 'Claude plan usage is unavailable for this Claude CLI session.' + }) + expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') + }) + + it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Settings Status Config Usage Stats + Session + Total cost: $0.0000 + `) + + await vi.advanceTimersByTimeAsync(1_000) + term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12, + resetDescription: '4:00pm' + }, + weekly: { + usedPercent: 34 + }, + error: null + }) + }) }) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 53a4b4e55..8948e4245 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -17,6 +17,14 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /current\s*week/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') +const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') + +function stripTerminalControlSequences(output: string): string { + return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') +} /** * Extract percent-left from lines following a label match. @@ -60,7 +68,7 @@ function parsePtyUsage(output: string): { session: RateLimitWindow | null weekly: RateLimitWindow | null } { - const lines = output.split(/\r?\n/) + const lines = output.split(/\r\n|\n|\r/) const sessionPct = extractPercentAfterLabel(lines, SESSION_RE) const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE) @@ -110,8 +118,11 @@ const COMMAND_PALETTE_RE = /show plan|usage limits/i const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i const LOAD_FAILED_RE = /failed to load usage data/i +const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i +const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 +const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 function describeClaudeUsageFailure(output: string): string { if (RATE_LIMITED_RE.test(output)) { @@ -122,6 +133,10 @@ function describeClaudeUsageFailure(output: string): string { return 'Claude usage is unavailable right now.' } + if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { + return 'Claude plan usage is unavailable for this Claude CLI session.' + } + // Why: parser failures are an implementation detail of Orca's PTY fallback. // The UI should explain the user-visible outcome, not leak internal parsing // mechanics that the user cannot act on. @@ -138,6 +153,8 @@ export async function fetchViaPty(options?: { let resolved = false let sentUsage = false let stopDetected = false + let claude21UsageDetected = false + let claude21UsageSettleTimer: ReturnType | null = null const claudeCommand = resolveClaudeCommand() @@ -173,11 +190,18 @@ export async function fetchViaPty(options?: { // Why: node-pty's NAPI callbacks can outlive the Electron JS // environment if we kill the hidden PTY without disposing them first, // which matches Orca's documented SIGABRT failure mode on shutdown. + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } disposeTermListeners() term.kill() // Even on timeout, try to parse whatever we collected - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) if (session || weekly) { resolve({ @@ -194,7 +218,10 @@ export async function fetchViaPty(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: 'PTY timeout — /usage panel did not render', + error: + CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean) + ? describeClaudeUsageFailure(clean) + : 'PTY timeout — /usage panel did not render', status: 'error' }) } @@ -222,14 +249,17 @@ export async function fetchViaPty(options?: { } resolved = true clearTimeout(timeout) + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } if (enterInterval) { clearInterval(enterInterval) } disposeTermListeners() term.kill() - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) if (!session && !weekly) { @@ -271,8 +301,7 @@ export async function fetchViaPty(options?: { output = output.slice(-MAX_OUTPUT_LENGTH) } - // eslint-disable-next-line no-control-regex - const cleanChunk = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const cleanChunk = stripTerminalControlSequences(data) // Why: the Claude CLI may prompt for first-run setup (trust files, // workspace directory). Auto-accept so we can reach /usage. @@ -290,8 +319,21 @@ export async function fetchViaPty(options?: { // Check if we've hit a stop substring indicating the panel rendered if (sentUsage && !stopDetected) { - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) + if ( + !claude21UsageDetected && + (CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)) + ) { + claude21UsageDetected = true + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } + // Why: Claude 2.1 may render session stats without subscription + // plan windows. Give async usage loading a grace period, then finish + // with a user-facing unavailable state instead of a false PTY timeout. + claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) + } for (const sub of STOP_SUBSTRINGS) { if (clean.includes(sub)) { stopDetected = true @@ -309,14 +351,18 @@ export async function fetchViaPty(options?: { const onExitDisposable = term.onExit(() => { disposeTermListeners() + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } if (enterInterval) { clearInterval(enterInterval) + enterInterval = null } if (!resolved) { resolved = true clearTimeout(timeout) - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) resolve({ provider: 'claude',