Skip to content
Closed
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
5 changes: 2 additions & 3 deletions config/scripts/node-pty-windows-pty-teardown-patch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
//
// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop
// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where
// upstream already destroys the input socket. The hidden rate-limit probe
// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no
// user-visible pane does. The divergence pinned below is about which
// upstream already destroys the input socket; no desktop site runs this hunk. The divergence
// pinned below is about which
// branch each host runs for terminals -- not about a regression in the panes users open.
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
Expand Down
4 changes: 4 additions & 0 deletions src/main/claude-accounts/runtime-auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync {
return this.getPreparation(target).configDir
}

getCurrentAuthProvenance(target?: ClaudeAccountSelectionTarget): string {
return this.getPreparation(target).provenance
}

private initializeLastSyncedState(): void {
const settings = this.store.getSettings()
this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
Expand Down
35 changes: 34 additions & 1 deletion src/main/claude/claude-agent-sdk-contract-pins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import { spawnProcess } from '../../shared/child-process/run-process'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
import {
claudeQueryControlRequester,
claudeQuerySettingsReader
} from './claude-agent-sdk-control-requests'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'

// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK
Expand Down Expand Up @@ -405,6 +408,36 @@ describe('Claude Agent SDK contract pins', () => {
}
})

it('still exposes the runtime control request the usage login refresh sends get_usage through', async () => {
// 0.3.251's typed get_usage wrapper cannot send skip_behaviors, so the refresh calls the
// undeclared Query.request; this pin fails if a bump drops or reshapes it.
const usage = { rate_limits_available: true, rate_limits: { five_hour: null } }
const scenario = scriptScenario([{ delayMs: 3_000 }], { get_usage: usage })
const session = query({
prompt: singleUserTurn(),
options: {
pathToClaudeCodeExecutable: FAKE_CLI,
cwd: scenario.cwd,
env: scenarioEnv(scenario)
}
})
try {
const request = claudeQueryControlRequester(session)
expect(request, 'the SDK no longer exposes Query.request at runtime').not.toBeNull()
await expect(
request?.({ subtype: 'get_usage', skip_behaviors: true }).then(
(envelope) => envelope.response
)
).resolves.toEqual(usage)
} finally {
await session.return(undefined)
}
expect(scenario.readReport().controlRequests.map((frame) => frame.request)).toContainEqual({
subtype: 'get_usage',
skip_behaviors: true
})
})

it('maps resume identity to --resume and --resume-session-at', async () => {
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
const spawns: SpawnSeen[] = []
Expand Down
34 changes: 34 additions & 0 deletions src/main/claude/claude-agent-sdk-control-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ export function claudeQueryAsyncCanceller(
return typeof cancel === 'function' ? cancel.bind(query) : null
}

/**
* Query.request is the runtime method every typed control call goes through; 0.3.251 omits it
* from the declaration, and its typed get_usage wrapper cannot send skip_behaviors. The typeof
* guard is its degradation path.
*/
type ClaudeQueryControlRequester = {
request?: (request: Record<string, unknown>) => Promise<{ response?: unknown }>
}

export function claudeQueryControlRequester(
query: Query
): ((request: Record<string, unknown>) => Promise<{ response?: unknown }>) | null {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only reads an optional method and checks its type before calling it.
const request = (query as unknown as ClaudeQueryControlRequester).request
return typeof request === 'function' ? request.bind(query) : null
}

export type ClaudeControlOptions = { timeoutMs?: number }

/**
Expand Down Expand Up @@ -109,6 +126,8 @@ export type ClaudeControlSurface = {
getSettings: (options?: ClaudeControlOptions) => Promise<unknown>
/** The `/context` breakdown; older CLIs reject the request and the caller shows nothing. */
getContextUsage: (options?: ClaudeControlOptions) => Promise<unknown>
/** Plan usage (`/usage`) without the local transcript scan; older CLIs reject the subtype. */
getUsage: (options?: ClaudeControlOptions) => Promise<unknown>
}

type InterruptingQuery = {
Expand Down Expand Up @@ -160,6 +179,21 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
runClaudeControl('initialize', () => query.initializationResult(), null),
getContextUsage: (options) =>
runClaudeControl('get_context_usage', () => query.getContextUsage(), options?.timeoutMs),
getUsage: (options) => {
const request = claudeQueryControlRequester(query)
return request
? runClaudeControl(
'get_usage',
() =>
request({ subtype: 'get_usage', skip_behaviors: true }).then(
(envelope) => envelope?.response
),
options?.timeoutMs
)
: Promise.reject(
new ClaudeControlRequestError('get_usage', 'this SDK exposes no control request')
)
},
getSettings: (options) => {
const read = claudeQuerySettingsReader(query)
return read
Expand Down
6 changes: 5 additions & 1 deletion src/main/claude/claude-stream-json-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

/** Sessions use the structured options; a session-less probe adds the isolation ones. */
export type ClaudeStreamJsonSdkOptions = ClaudeStructuredSdkOptions &
Pick<ClaudeAgentSdk.Options, 'persistSession' | 'strictMcpConfig' | 'mcpServers' | 'settings'>

export type ClaudeStreamJsonLaunch = {
/** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */
pathToClaudeCodeExecutable: string
options: ClaudeStructuredSdkOptions
options: ClaudeStreamJsonSdkOptions
cwd: string
env?: Record<string, string>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('Claude structured processless acquisition', () => {
},
getSettings: async () => ({}),
getContextUsage: async () => ({}),
getUsage: async () => ({}),
supportedModels: async () => [],
interrupt: async () => undefined,
cancelAsyncMessage: async () => false,
Expand Down
4 changes: 4 additions & 0 deletions src/main/claude/claude-structured-session-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ export function fakeClaude(
...(options.initAccount === undefined ? {} : { account: options.initAccount })
}
},
getUsage: async () => {
connection.calls.push({ subtype: 'get_usage' })
return {}
},
getContextUsage: async () => {
connection.calls.push({ subtype: 'get_context_usage' })
return options.contextUsage ?? {}
Expand Down
125 changes: 31 additions & 94 deletions src/main/rate-limits/claude-active-usage-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch'
import {
readClaudeOAuthCredentials,
resolveClaudeOAuthCredentialReadOptions
} from './claude-oauth-credentials'
import {
canRetryClaudeOAuthWithLegacyKeychain,
failureKindAfterClaudeLoginRepair,
makeClaudeUsageClassificationError,
makeLiveClaudeUsageDeferredResult,
repairClaudeCredentialsThenRetryOAuth,
Expand All @@ -19,9 +18,10 @@ import {
classifyClaudeOAuthUsageError
} from './claude-usage-error-classification'
import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options'
import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan'
import {
abortedClaudeRateLimitResult,
claudeOAuthUsageSuccess,
isManagedClaudeAuth,
makeClaudeUsageResult,
metadataForClaudeUsageAttempt,
recordClaudeUsageAttempt,
Expand All @@ -35,11 +35,6 @@ export async function fetchActiveClaudeRateLimits(
return abortedClaudeRateLimitResult()
}
const attempts = { attemptedSources: [] }
const allowCliFallback = options?.allowPtyFallback !== false
const plan = resolveClaudeUsageRefreshPlan({
authPreparation: options?.authPreparation,
allowCliFallback
})

if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) {
return makeClaudeUsageResult(
Expand All @@ -60,18 +55,18 @@ export async function fetchActiveClaudeRateLimits(
return abortedClaudeRateLimitResult()
}

if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
if (oauthCredentials.token) {
recordClaudeUsageAttempt(attempts, 'oauth')
try {
const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal)
const limits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal)
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return await completeClaudeOAuthUsageSuccess({
oauthLimits,
return claudeOAuthUsageSuccess({
limits,
oauthCredentials,
attempts,
options
authPreparation: options?.authPreparation
})
} catch (error) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error)
Expand Down Expand Up @@ -102,34 +97,22 @@ export async function fetchActiveClaudeRateLimits(
})
}

if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) {
const repaired = await repairClaudeCredentialsThenRetryOAuth({
let failureKind = classification.failureKind
if (classification.shouldAttemptDelegatedRefresh) {
const repair = await repairClaudeCredentialsThenRetryOAuth({
options,
attempts,
oauthCredentials
})
if (repaired) {
return repaired
}
}

if (classification.shouldAttemptCliFallback && allowCliFallback) {
try {
return await fetchClaudeUsageViaCli({
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (ptyError) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError)
if (repair.kind === 'result') {
return repair.result
}
failureKind = failureKindAfterClaudeLoginRepair(repair, failureKind)
}

return makeClaudeUsageClassificationError({
error,
classification,
classification: { ...classification, failureKind },
attempts,
oauthCredentials,
authPreparation: options?.authPreparation
Expand All @@ -151,54 +134,20 @@ export async function fetchActiveClaudeRateLimits(
})
}

let absenceFailureKind = credentialClassification.failureKind
if (
oauthCredentials.hasRefreshableCredentials &&
credentialClassification.shouldAttemptDelegatedRefresh &&
allowCliFallback
credentialClassification.shouldAttemptDelegatedRefresh
) {
const repaired = await repairClaudeCredentialsThenRetryOAuth({
const repair = await repairClaudeCredentialsThenRetryOAuth({
options,
attempts,
oauthCredentials
})
if (repaired) {
return repaired
}
}

if (
(oauthCredentials.token ||
oauthCredentials.hasRefreshableCredentials ||
oauthCredentials.keychainUnavailable) &&
credentialClassification.shouldAttemptCliFallback &&
allowCliFallback
) {
try {
return await fetchClaudeUsageViaCli({
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (error) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error)
return makeClaudeUsageResult(
'error',
withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'),
{
...metadataForClaudeUsageAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
failureKind:
credentialClassification.failureKind === 'keychain-unavailable'
? 'keychain-unavailable'
: 'cli-unavailable'
})
}
)
if (repair.kind === 'result') {
return repair.result
}
absenceFailureKind = failureKindAfterClaudeLoginRepair(repair, absenceFailureKind)
}

if (oauthCredentials.keychainUnavailable) {
Expand All @@ -218,31 +167,19 @@ export async function fetchActiveClaudeRateLimits(
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
failureKind: credentialClassification.failureKind
failureKind: absenceFailureKind
})
})
}

if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) {
try {
return await fetchClaudeUsageViaCli({
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (error) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error)
}
}

return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', {
...metadataForClaudeUsageAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
failureKind: 'missing-credentials'
})
const metadata = metadataForClaudeUsageAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
failureKind: 'missing-credentials'
})
// A managed account with no login is signed out; without one, the user may bill by API key.
return isManagedClaudeAuth(options?.authPreparation)
? makeClaudeUsageResult('error', 'Claude account is signed out', metadata)
: makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', metadata)
}
Loading
Loading