From e630b269c559752f5ae88665f3b18a6c98e4abb6 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:09:42 +0000 Subject: [PATCH 01/12] feat(capacity): add provider detection foundation --- .../commands/capacity/detection.test.ts | 26 +++++++++ .../cli/src/commands/capacity/detection.ts | 55 +++++++++++++++++++ packages/cli/src/commands/capacity/types.ts | 37 +++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/detection.test.ts create mode 100644 packages/cli/src/commands/capacity/detection.ts create mode 100644 packages/cli/src/commands/capacity/types.ts diff --git a/packages/cli/src/__tests__/commands/capacity/detection.test.ts b/packages/cli/src/__tests__/commands/capacity/detection.test.ts new file mode 100644 index 00000000..5198e493 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/detection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { detectConfiguredProviders, isBinaryInstalled } from '../../../commands/capacity/detection.js'; + +describe('capacity provider detection', () => { + it('derives configured providers from ENVIRONMENT_DEFINITIONS config directories', async () => { + const exists = vi.fn(async (path: string) => + path === '/users/test/.codex' || path === '/users/test/.config/opencode' + ); + + await expect(detectConfiguredProviders({ homeDir: '/users/test', exists })).resolves.toEqual([ + 'codex', + 'opencode' + ]); + expect(exists).toHaveBeenCalledWith('/users/test/.codex'); + expect(exists).toHaveBeenCalledWith('/users/test/.config/opencode'); + }); + + it('checks PATH without running a provider command', async () => { + const access = vi.fn(async (path: string) => { + if (path !== '/opt/bin/codex') throw new Error('missing'); + }); + + await expect(isBinaryInstalled('codex', { path: '/usr/bin:/opt/bin', access })).resolves.toBe(true); + await expect(isBinaryInstalled('claude', { path: '/usr/bin:/opt/bin', access })).resolves.toBe(false); + }); +}); diff --git a/packages/cli/src/commands/capacity/detection.ts b/packages/cli/src/commands/capacity/detection.ts new file mode 100644 index 00000000..e85a5091 --- /dev/null +++ b/packages/cli/src/commands/capacity/detection.ts @@ -0,0 +1,55 @@ +import { constants } from 'node:fs'; +import { access as fsAccess } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { ENVIRONMENT_DEFINITIONS } from '../../util/env.js'; + +const PROVIDER_NAMES: Record = { github: 'copilot' }; + +type DetectionOptions = { + homeDir?: string; + exists?: (path: string) => Promise; +}; + +type BinaryOptions = { + path?: string; + access?: (path: string) => Promise; +}; + +function configDirectory(globalSkillPath: string): string { + const parts = globalSkillPath.split('/').filter(Boolean); + return parts[0] === '.config' && parts[1] ? path.join(parts[0], parts[1]) : parts[0]; +} + +async function defaultExists(target: string): Promise { + try { + await fsAccess(target, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function detectConfiguredProviders(options: DetectionOptions = {}): Promise { + const home = options.homeDir ?? homedir(); + const exists = options.exists ?? defaultExists; + const providers = await Promise.all(Object.values(ENVIRONMENT_DEFINITIONS).map(async definition => ({ + provider: PROVIDER_NAMES[definition.code] ?? definition.code, + configured: await exists(path.join(home, configDirectory(definition.globalSkillPath))) + }))); + return [...new Set(providers.filter(item => item.configured).map(item => item.provider))].sort(); +} + +export async function isBinaryInstalled(binary: string, options: BinaryOptions = {}): Promise { + const pathValue = options.path ?? process.env.PATH ?? ''; + const access = options.access ?? ((target: string) => fsAccess(target, constants.X_OK)); + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + try { + await access(path.join(directory, binary)); + return true; + } catch { + // Continue searching PATH. + } + } + return false; +} diff --git a/packages/cli/src/commands/capacity/types.ts b/packages/cli/src/commands/capacity/types.ts new file mode 100644 index 00000000..fbc0915f --- /dev/null +++ b/packages/cli/src/commands/capacity/types.ts @@ -0,0 +1,37 @@ +export type ProviderStatus = 'supported' | 'unsupported' | 'unauthenticated' | 'unavailable' | 'unknown'; +export type Availability = 'yes' | 'no' | 'unknown'; +export type CapacitySource = 'provider-cli' | 'provider-api' | 'local-observation' | 'none'; + +export interface CapacityWindow { + id: string; + label: string; + durationMinutes: number | null; + usedPercent: number | null; + remainingPercent: number | null; + resetsAt: string | null; + scope: string | null; +} + +export interface ProviderCapacity { + provider: string; + agentType: string | null; + configured: boolean; + installed: boolean; + authenticated: boolean | null; + status: ProviderStatus; + available: Availability; + plan: string | null; + checkedAt: string; + source: CapacitySource; + windows: CapacityWindow[]; + aliases: { dailyWindowId: string | null; weeklyWindowId: string | null }; + resetCredits?: { available: number | null }; + warnings: Array<{ code: string; message: string }>; + error?: { code: string; retryable: boolean }; +} + +export interface CapacityReport { + schemaVersion: 1; + generatedAt: string; + providers: ProviderCapacity[]; +} From 923264d6039d88e9f0767de70b34dd5d76bedda0 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:12:25 +0000 Subject: [PATCH 02/12] feat(capacity): probe Codex rate limits safely --- .../__tests__/commands/capacity/codex.test.ts | 95 +++++++++ .../cli/src/commands/capacity/detection.ts | 6 +- .../src/commands/capacity/providers/codex.ts | 187 ++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/commands/capacity/codex.test.ts create mode 100644 packages/cli/src/commands/capacity/providers/codex.ts diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts new file mode 100644 index 00000000..d2004ba7 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mapCodexRateLimits, probeCodexCapacity } from '../../../commands/capacity/providers/codex.js'; + +describe('Codex capacity mapping', () => { + it('normalizes arbitrary windows, aliases, and unredeemed reset credits', () => { + const result = mapCodexRateLimits({ + rateLimits: { + limitId: 'codex', + limitName: 'Codex', + planType: 'pro', + rateLimitReachedType: null, + primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, + secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } + }, + rateLimitsByLimitId: { + reviews: { + limitId: 'reviews', + limitName: 'Code reviews', + primary: { usedPercent: 10, windowDurationMins: 1440, resetsAt: 1786320000 }, + secondary: null + } + }, + usageLimitResetCredits: { availableCount: 2 } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.available).toBe('yes'); + expect(result.plan).toBe('pro'); + expect(result.windows).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'codex:primary', durationMinutes: 300, remainingPercent: 80 }), + expect.objectContaining({ id: 'codex:secondary', durationMinutes: 10080, remainingPercent: 39 }), + expect.objectContaining({ id: 'reviews:primary', durationMinutes: 1440, scope: 'reviews' }) + ])); + expect(result.aliases).toEqual({ dailyWindowId: 'reviews:primary', weeklyWindowId: 'codex:secondary' }); + expect(result.resetCredits).toEqual({ available: 2 }); + }); + + it('does not turn missing capacity into available yes', () => { + const result = mapCodexRateLimits({}, { + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z' + }); + + expect(result.available).toBe('unknown'); + expect(result.status).toBe('unknown'); + expect(result.windows).toEqual([]); + }); + + it('reports explicit exhaustion as unavailable without exposing response details', () => { + const result = mapCodexRateLimits({ + rateLimits: { rateLimitReachedType: 'rate-limit-secret-detail', planType: 'team' } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.available).toBe('no'); + expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); + }); + + it('uses only app-server account methods and never invokes a model turn', async () => { + const rpc = vi.fn(async () => ({ + rateLimits: { + primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } + } + })); + + const result = await probeCodexCapacity({ + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z', + rpc + }); + + expect(rpc).toHaveBeenCalledOnce(); + const messages = rpc.mock.calls[0][0]; + expect(messages.map(message => message.method)).toEqual([ + 'initialize', + 'initialized', + 'account/rateLimits/read' + ]); + expect(JSON.stringify(messages)).not.toMatch(/model|prompt|turn/i); + expect(result.available).toBe('yes'); + }); + + it('redacts all transport failures', async () => { + const result = await probeCodexCapacity({ + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z', + rpc: async () => { throw new Error('token=secret https://private.example/account/123'); } + }); + + expect(result.available).toBe('unknown'); + expect(result.error).toEqual({ code: 'codex-probe-failed', retryable: true }); + expect(JSON.stringify(result)).not.toMatch(/secret|private\.example|account\/123/); + }); +}); diff --git a/packages/cli/src/commands/capacity/detection.ts b/packages/cli/src/commands/capacity/detection.ts index e85a5091..2c927060 100644 --- a/packages/cli/src/commands/capacity/detection.ts +++ b/packages/cli/src/commands/capacity/detection.ts @@ -33,7 +33,11 @@ async function defaultExists(target: string): Promise { export async function detectConfiguredProviders(options: DetectionOptions = {}): Promise { const home = options.homeDir ?? homedir(); const exists = options.exists ?? defaultExists; - const providers = await Promise.all(Object.values(ENVIRONMENT_DEFINITIONS).map(async definition => ({ + const definitions = Object.values(ENVIRONMENT_DEFINITIONS).filter( + (definition): definition is typeof definition & { globalSkillPath: string } => + typeof definition.globalSkillPath === 'string' + ); + const providers = await Promise.all(definitions.map(async definition => ({ provider: PROVIDER_NAMES[definition.code] ?? definition.code, configured: await exists(path.join(home, configDirectory(definition.globalSkillPath))) }))); diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts new file mode 100644 index 00000000..4fab91e0 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process'; +import type { CapacityWindow, ProviderCapacity } from '../types.js'; + +type UnknownRecord = Record; + +type CodexMappingContext = { + configured: boolean; + installed: boolean; + checkedAt: string; +}; + +type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; +type CodexRpc = (messages: RpcMessage[]) => Promise; + +type CodexProbeOptions = CodexMappingContext & { + rpc?: CodexRpc; + timeoutMs?: number; +}; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as UnknownRecord + : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function resetTime(value: unknown): string | null { + const seconds = finiteNumber(value); + if (seconds !== null) return new Date(seconds * 1000).toISOString(); + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString(); + return null; +} + +function windowFrom(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { + const input = record(value); + if (!input) return null; + const used = finiteNumber(input.usedPercent); + const duration = finiteNumber(input.windowDurationMins); + return { + id, + label, + durationMinutes: duration, + usedPercent: used, + remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetsAt: resetTime(input.resetsAt), + scope + }; +} + +function snapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { + const snapshot = record(value); + if (!snapshot) return []; + const scope = text(snapshot.limitId) ?? fallbackId; + const name = text(snapshot.limitName) ?? scope; + return [ + windowFrom(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), + windowFrom(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) + ].filter((item): item is CapacityWindow => item !== null); +} + +function aliasFor(windows: CapacityWindow[], target: number, tolerance: number): string | null { + return windows.find(window => + window.durationMinutes !== null && Math.abs(window.durationMinutes - target) <= tolerance + )?.id ?? null; +} + +export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): ProviderCapacity { + const response = record(raw) ?? {}; + const primarySnapshot = record(response.rateLimits); + const windows = snapshotWindows(primarySnapshot, 'codex'); + const buckets = record(response.rateLimitsByLimitId); + if (buckets) { + for (const [id, snapshot] of Object.entries(buckets)) { + windows.push(...snapshotWindows(snapshot, id)); + } + } + const reached = text(primarySnapshot?.rateLimitReachedType); + const resetCredits = record(response.usageLimitResetCredits); + const availableCount = finiteNumber(resetCredits?.availableCount); + const hasCapacity = windows.some(window => window.remainingPercent !== null); + + return { + provider: 'codex', + agentType: 'codex', + configured: context.configured, + installed: context.installed, + authenticated: true, + status: reached || hasCapacity ? 'supported' : 'unknown', + available: reached ? 'no' : hasCapacity ? 'yes' : 'unknown', + plan: text(primarySnapshot?.planType), + checkedAt: context.checkedAt, + source: 'provider-cli', + windows, + aliases: { + dailyWindowId: aliasFor(windows, 1440, 120), + weeklyWindowId: aliasFor(windows, 10080, 720) + }, + resetCredits: { available: availableCount }, + warnings: hasCapacity || reached ? [] : [{ + code: 'capacity-unavailable', + message: 'Codex did not return authoritative capacity windows.' + }] + }; +} + +function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] }); + let buffer = ''; + let settled = false; + const finish = (error?: Error, result?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + if (error) reject(error); + else resolve(result); + }; + const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs); + child.once('error', () => finish(new Error('codex app-server unavailable'))); + child.once('exit', code => { + if (!settled) finish(new Error(`codex app-server exited (${code ?? 'unknown'})`)); + }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf('\n'); + if (newline < 0) break; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + let message: UnknownRecord; + try { + message = JSON.parse(line) as UnknownRecord; + } catch { + continue; + } + if (message.id === 1) { + for (const request of messages.slice(1)) child.stdin.write(`${JSON.stringify(request)}\n`); + } + if (message.id === 2) { + if (message.error) finish(new Error('codex rate-limit method failed')); + else finish(undefined, message.result); + } + } + }); + child.stdin.write(`${JSON.stringify(messages[0])}\n`); + }); +} + +export async function probeCodexCapacity(options: CodexProbeOptions): Promise { + if (!options.installed) { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed: false, + authenticated: null, status: 'unavailable', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ code: 'cli-not-installed', message: 'Codex CLI is not installed.' }] + }; + } + const messages: RpcMessage[] = [ + { id: 1, method: 'initialize', params: { clientInfo: { name: 'ai-devkit', version: '1' } } }, + { method: 'initialized' }, + { id: 2, method: 'account/rateLimits/read', params: {} } + ]; + try { + const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); + return mapCodexRateLimits(await rpc(messages), options); + } catch { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed: true, + authenticated: null, status: 'unknown', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ code: 'probe-failed', message: 'Codex capacity could not be read safely.' }], + error: { code: 'codex-probe-failed', retryable: true } + }; + } +} From 319b07ebca032efb9ed473bea0e487ca8bf90113 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:13:48 +0000 Subject: [PATCH 03/12] feat(capacity): add truthful provider adapters --- .../commands/capacity/providers.test.ts | 58 +++++++++++++++++++ .../src/commands/capacity/providers/claude.ts | 55 ++++++++++++++++++ .../cli/src/commands/capacity/providers/pi.ts | 35 +++++++++++ .../src/commands/capacity/providers/stub.ts | 31 ++++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/providers.test.ts create mode 100644 packages/cli/src/commands/capacity/providers/claude.ts create mode 100644 packages/cli/src/commands/capacity/providers/pi.ts create mode 100644 packages/cli/src/commands/capacity/providers/stub.ts diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts new file mode 100644 index 00000000..d2d31ce8 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { probeClaudeCapacity } from '../../../commands/capacity/providers/claude.js'; +import { probePiCapacity } from '../../../commands/capacity/providers/pi.js'; +import { buildUnsupportedCapacity } from '../../../commands/capacity/providers/stub.js'; + +const checkedAt = '2026-08-09T10:00:00.000Z'; + +describe('non-Codex capacity adapters', () => { + it('detects Claude authentication but keeps undocumented live usage guarded off', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => ({ loggedIn: true, subscriptionType: 'max' }) + }); + + expect(result).toMatchObject({ + provider: 'claude', authenticated: true, status: 'supported', + available: 'unknown', plan: 'max', source: 'provider-cli' + }); + expect(result.warnings[0].code).toBe('live-usage-unavailable'); + }); + + it('redacts Claude authentication failures', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => { throw new Error('oauth-token secret response body'); } + }); + + expect(result.authenticated).toBeNull(); + expect(JSON.stringify(result)).not.toMatch(/oauth-token|secret|response body/); + }); + + it('detects Pi and GLM authentication only from provider key names', async () => { + const results = await probePiCapacity({ + configured: true, + installed: true, + checkedAt, + readAuth: async () => JSON.stringify({ zai: { type: 'api_key', key: 'must-not-leak' } }) + }); + + expect(results.map(result => result.provider)).toEqual(['pi', 'glm']); + expect(results.every(result => result.authenticated === true)).toBe(true); + expect(results.every(result => result.available === 'unknown')).toBe(true); + expect(JSON.stringify(results)).not.toContain('must-not-leak'); + }); + + it('returns truthful unknown capacity for other configured providers', () => { + expect(buildUnsupportedCapacity('gemini', { + configured: true, installed: false, checkedAt + })).toMatchObject({ + provider: 'gemini', configured: true, installed: false, + authenticated: null, status: 'unsupported', available: 'unknown', source: 'none' + }); + }); +}); diff --git a/packages/cli/src/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts new file mode 100644 index 00000000..f8c0d0cc --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -0,0 +1,55 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { ProviderCapacity } from '../types.js'; + +const execFileAsync = promisify(execFile); +type UnknownRecord = Record; +type ClaudeContext = { configured: boolean; installed: boolean; checkedAt: string }; +type ClaudeOptions = ClaudeContext & { authStatus?: () => Promise; timeoutMs?: number }; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; +} + +async function defaultAuthStatus(timeoutMs: number): Promise { + const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { + timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' + }); + return JSON.parse(stdout); +} + +function base(context: ClaudeContext): ProviderCapacity { + return { + provider: 'claude', agentType: 'claude', configured: context.configured, + installed: context.installed, authenticated: null, status: 'unknown', + available: 'unknown', plan: null, checkedAt: context.checkedAt, source: 'none', + windows: [], aliases: { dailyWindowId: null, weeklyWindowId: null }, warnings: [] + }; +} + +export async function probeClaudeCapacity(options: ClaudeOptions): Promise { + const result = base(options); + if (!options.installed) { + result.status = 'unavailable'; + result.warnings.push({ code: 'cli-not-installed', message: 'Claude CLI is not installed.' }); + return result; + } + try { + const raw = await (options.authStatus ?? (() => defaultAuthStatus(options.timeoutMs ?? 3000)))(); + const auth = record(raw); + const authenticated = auth?.loggedIn === true || auth?.authenticated === true; + result.authenticated = authenticated; + result.status = authenticated ? 'supported' : 'unauthenticated'; + result.source = 'provider-cli'; + result.plan = typeof auth?.subscriptionType === 'string' ? auth.subscriptionType : null; + result.warnings.push({ + code: 'live-usage-unavailable', + message: 'Claude live capacity is unknown because no safe provider-owned usage command is available.' + }); + return result; + } catch { + result.error = { code: 'claude-auth-probe-failed', retryable: true }; + result.warnings.push({ code: 'probe-failed', message: 'Claude authentication could not be checked safely.' }); + return result; + } +} diff --git a/packages/cli/src/commands/capacity/providers/pi.ts b/packages/cli/src/commands/capacity/providers/pi.ts new file mode 100644 index 00000000..5e567c66 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/pi.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import type { ProviderCapacity } from '../types.js'; +import { buildUnsupportedCapacity } from './stub.js'; + +type PiOptions = { + configured: boolean; + installed: boolean; + checkedAt: string; + readAuth?: () => Promise; + homeDir?: string; +}; + +export async function probePiCapacity(options: PiOptions): Promise { + let providers: string[] = []; + try { + const raw = await (options.readAuth ?? (() => + readFile(path.join(options.homeDir ?? homedir(), '.pi', 'agent', 'auth.json'), 'utf8')))(); + const parsed: unknown = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + providers = Object.keys(parsed); + } + } catch { + // Authentication remains unknown; never surface file contents or parser errors. + } + const piAuthenticated = providers.length > 0; + const results = [buildUnsupportedCapacity('pi', options, piAuthenticated || null, + 'Pi is an agent harness and does not expose account-wide capacity.')]; + if (providers.some(provider => provider === 'zai' || provider === 'zai-coding-cn')) { + results.push(buildUnsupportedCapacity('glm', options, true, + 'GLM authentication is configured through Pi, but no verified quota mechanism is available.')); + } + return results; +} diff --git a/packages/cli/src/commands/capacity/providers/stub.ts b/packages/cli/src/commands/capacity/providers/stub.ts new file mode 100644 index 00000000..a237852f --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/stub.ts @@ -0,0 +1,31 @@ +import type { ProviderCapacity } from '../types.js'; + +type StubContext = { configured: boolean; installed: boolean; checkedAt: string }; + +const AGENT_TYPES: Record = { + claude: 'claude', codex: 'codex', copilot: 'github', gemini: 'gemini', + glm: 'pi', grok: 'grok', opencode: 'opencode', pi: 'pi' +}; + +export function buildUnsupportedCapacity( + provider: string, + context: StubContext, + authenticated: boolean | null = null, + warning = 'Authoritative capacity discovery is not supported for this provider.' +): ProviderCapacity { + return { + provider, + agentType: AGENT_TYPES[provider] ?? null, + configured: context.configured, + installed: context.installed, + authenticated, + status: 'unsupported', + available: 'unknown', + plan: null, + checkedAt: context.checkedAt, + source: 'none', + windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, + warnings: [{ code: 'capacity-unsupported', message: warning }] + }; +} From f57901b917d00d8000960899a4cfb549fd38a0df Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:15:58 +0000 Subject: [PATCH 04/12] feat(capacity): orchestrate probes with secure cache --- .../__tests__/commands/capacity/cache.test.ts | 24 ++++ .../commands/capacity/orchestrate.test.ts | 66 +++++++++++ packages/cli/src/commands/capacity/cache.ts | 46 ++++++++ .../cli/src/commands/capacity/orchestrate.ts | 109 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/cache.test.ts create mode 100644 packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts create mode 100644 packages/cli/src/commands/capacity/cache.ts create mode 100644 packages/cli/src/commands/capacity/orchestrate.ts diff --git a/packages/cli/src/__tests__/commands/capacity/cache.test.ts b/packages/cli/src/__tests__/commands/capacity/cache.test.ts new file mode 100644 index 00000000..a441546c --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/cache.test.ts @@ -0,0 +1,24 @@ +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readCapacityCache, writeCapacityCache } from '../../../commands/capacity/cache.js'; + +describe('capacity cache', () => { + it('stores only normalized reports with restrictive permissions', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'capacity-cache-')); + const cachePath = path.join(directory, 'nested', 'capacity.json'); + const report = { schemaVersion: 1 as const, generatedAt: '2026-08-09T10:00:00.000Z', providers: [] }; + + await writeCapacityCache('configured:codex', report, cachePath); + + expect((await stat(cachePath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(cachePath, 'utf8'))).toEqual({ key: 'configured:codex', report }); + await expect(readCapacityCache( + 'configured:codex', 60, new Date('2026-08-09T10:00:30.000Z'), cachePath + )).resolves.toEqual(report); + await expect(readCapacityCache( + 'configured:codex', 60, new Date('2026-08-09T10:02:00.000Z'), cachePath + )).resolves.toBeNull(); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts b/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts new file mode 100644 index 00000000..46d9e78d --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCapacityReport } from '../../../commands/capacity/orchestrate.js'; +import { buildUnsupportedCapacity } from '../../../commands/capacity/providers/stub.js'; + +const now = () => new Date('2026-08-09T10:00:00.000Z'); + +describe('capacity orchestration', () => { + it('probes only configured providers by default, in parallel, and preserves partial results', async () => { + const started: string[] = []; + const report = await getCapacityReport({}, { + now, + detectConfigured: async () => ['codex', 'gemini'], + isInstalled: async provider => provider === 'codex', + probe: async (provider, context) => { + started.push(provider); + if (provider === 'codex') throw new Error('private raw response'); + return [buildUnsupportedCapacity(provider, context)]; + }, + readCache: async () => null, + writeCache: async () => undefined + }); + + expect(started.sort()).toEqual(['codex', 'gemini']); + expect(report.providers.map(provider => provider.provider)).toEqual(['codex', 'gemini']); + expect(report.providers[0]).toMatchObject({ available: 'unknown', error: { code: 'probe-failed' } }); + expect(JSON.stringify(report)).not.toContain('private raw response'); + }); + + it('uses a fresh cache unless --refresh is requested', async () => { + const cached = { + schemaVersion: 1 as const, + generatedAt: '2026-08-09T09:59:30.000Z', + providers: [buildUnsupportedCapacity('gemini', { + configured: true, installed: true, checkedAt: '2026-08-09T09:59:30.000Z' + })] + }; + const probe = vi.fn(); + const dependencies = { + now, + detectConfigured: async () => ['gemini'], + isInstalled: async () => true, + probe, + readCache: async () => cached, + writeCache: async () => undefined + }; + + await expect(getCapacityReport({ maxAge: 60 }, dependencies)).resolves.toEqual(cached); + expect(probe).not.toHaveBeenCalled(); + + dependencies.readCache = async () => cached; + dependencies.probe = vi.fn(async (provider, context) => [buildUnsupportedCapacity(provider, context)]); + await getCapacityReport({ maxAge: 60, refresh: true }, dependencies); + expect(dependencies.probe).toHaveBeenCalledOnce(); + }); + + it('rejects unknown provider names', async () => { + await expect(getCapacityReport({ provider: 'made-up' }, { + now, + detectConfigured: async () => [], + isInstalled: async () => false, + probe: async () => [], + readCache: async () => null, + writeCache: async () => undefined + })).rejects.toThrow('Unknown capacity provider'); + }); +}); diff --git a/packages/cli/src/commands/capacity/cache.ts b/packages/cli/src/commands/capacity/cache.ts new file mode 100644 index 00000000..2856771f --- /dev/null +++ b/packages/cli/src/commands/capacity/cache.ts @@ -0,0 +1,46 @@ +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import type { CapacityReport } from './types.js'; + +function defaultCachePath(): string { + return path.join(homedir(), '.ai-devkit', 'cache', 'capacity.json'); +} + +function isReport(value: unknown): value is CapacityReport { + if (value === null || typeof value !== 'object') return false; + const report = value as Partial; + return report.schemaVersion === 1 && typeof report.generatedAt === 'string' && Array.isArray(report.providers); +} + +export async function readCapacityCache( + key: string, + maxAgeSeconds: number, + now = new Date(), + cachePath = defaultCachePath() +): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(cachePath, 'utf8')); + if (parsed === null || typeof parsed !== 'object') return null; + const entry = parsed as { key?: unknown; report?: unknown }; + if (entry.key !== key || !isReport(entry.report)) return null; + const age = now.getTime() - Date.parse(entry.report.generatedAt); + return age >= 0 && age <= maxAgeSeconds * 1000 ? entry.report : null; + } catch { + return null; + } +} + +export async function writeCapacityCache( + key: string, + report: CapacityReport, + cachePath = defaultCachePath() +): Promise { + const directory = path.dirname(cachePath); + const temporary = `${cachePath}.${process.pid}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + await writeFile(temporary, JSON.stringify({ key, report }), { encoding: 'utf8', mode: 0o600 }); + await chmod(temporary, 0o600); + await rename(temporary, cachePath); +} diff --git a/packages/cli/src/commands/capacity/orchestrate.ts b/packages/cli/src/commands/capacity/orchestrate.ts new file mode 100644 index 00000000..6a3bc8b2 --- /dev/null +++ b/packages/cli/src/commands/capacity/orchestrate.ts @@ -0,0 +1,109 @@ +import { ENVIRONMENT_DEFINITIONS } from '../../util/env.js'; +import { readCapacityCache, writeCapacityCache } from './cache.js'; +import { detectConfiguredProviders, isBinaryInstalled } from './detection.js'; +import { probeClaudeCapacity } from './providers/claude.js'; +import { probeCodexCapacity } from './providers/codex.js'; +import { probePiCapacity } from './providers/pi.js'; +import { buildUnsupportedCapacity } from './providers/stub.js'; +import type { CapacityReport, ProviderCapacity } from './types.js'; + +type ProbeContext = { configured: boolean; installed: boolean; checkedAt: string }; +type CapacityOptions = { provider?: string; maxAge?: number; refresh?: boolean }; +type Dependencies = { + now: () => Date; + detectConfigured: () => Promise; + isInstalled: (provider: string) => Promise; + probe: (provider: string, context: ProbeContext) => Promise; + readCache: (key: string, maxAge: number, now: Date) => Promise; + writeCache: (key: string, report: CapacityReport) => Promise; +}; + +const providerNames = Object.keys(ENVIRONMENT_DEFINITIONS).map(name => name === 'github' ? 'copilot' : name); +export const CAPACITY_PROVIDERS = [...new Set([...providerNames, 'glm'])].sort(); + +const BINARIES: Record = { + 'antigravity-cli': 'agy', copilot: 'copilot', gemini: 'gemini', github: 'copilot', glm: 'pi' +}; + +async function defaultProbe(provider: string, context: ProbeContext): Promise { + if (provider === 'codex') return [await probeCodexCapacity(context)]; + if (provider === 'claude') return [await probeClaudeCapacity(context)]; + if (provider === 'pi' || provider === 'glm') { + const results = await probePiCapacity(context); + if (provider === 'pi') return results; + return [results.find(result => result.provider === 'glm') ?? + buildUnsupportedCapacity('glm', context, null, + 'GLM capacity is unknown because no verified quota mechanism is available.')]; + } + return [buildUnsupportedCapacity(provider, context)]; +} + +const defaults: Dependencies = { + now: () => new Date(), + detectConfigured: detectConfiguredProviders, + isInstalled: provider => isBinaryInstalled(BINARIES[provider] ?? provider), + probe: defaultProbe, + readCache: readCapacityCache, + writeCache: writeCapacityCache +}; + +function failure(provider: string, context: ProbeContext, code = 'probe-failed'): ProviderCapacity { + const result = buildUnsupportedCapacity(provider, context, null, 'Capacity could not be checked safely.'); + result.status = 'unknown'; + result.error = { code, retryable: true }; + return result; +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function getCapacityReport( + options: CapacityOptions = {}, + dependencies: Dependencies = defaults +): Promise { + const requested = options.provider?.toLowerCase(); + if (requested && !CAPACITY_PROVIDERS.includes(requested)) { + throw new Error(`Unknown capacity provider "${options.provider}".`); + } + const now = dependencies.now(); + const configured = await dependencies.detectConfigured(); + const selected = requested ? [requested] : configured; + const cacheKey = `${requested ? 'provider' : 'configured'}:${selected.slice().sort().join(',')}`; + const maxAge = options.maxAge ?? 300; + if (!options.refresh && maxAge > 0) { + const cached = await dependencies.readCache(cacheKey, maxAge, now); + if (cached) return cached; + } + + const groups = await Promise.all(selected.map(async provider => { + const binaryProvider = provider === 'glm' ? 'pi' : provider; + const context: ProbeContext = { + configured: configured.includes(provider) || (provider === 'glm' && configured.includes('pi')), + installed: await dependencies.isInstalled(binaryProvider), + checkedAt: now.toISOString() + }; + try { + const results = await withTimeout(dependencies.probe(provider, context), 6000); + return requested === 'pi' ? results.filter(result => result.provider === 'pi') : results; + } catch { + return [failure(provider, context)]; + } + })); + const providers = groups.flat().sort((left, right) => left.provider.localeCompare(right.provider)); + const report: CapacityReport = { schemaVersion: 1, generatedAt: now.toISOString(), providers }; + try { + await dependencies.writeCache(cacheKey, report); + } catch { + // Cache failures must not prevent a capacity report. + } + return report; +} From dd3c4057c64cd36ef0e5648ae8db6d67dab74f5f Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:17:34 +0000 Subject: [PATCH 05/12] feat(cli): expose capacity command --- packages/cli/README.md | 6 ++ .../commands/capacity/command.test.ts | 68 +++++++++++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/capacity.ts | 33 +++++++++ packages/cli/src/commands/capacity/render.ts | 52 ++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/command.test.ts create mode 100644 packages/cli/src/commands/capacity.ts create mode 100644 packages/cli/src/commands/capacity/render.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index f6dd07bf..6e22dc4c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -85,6 +85,12 @@ ai-devkit lint --feature lint-command # Emit machine-readable output for CI ai-devkit lint --feature lint-command --json +# Report capacity for configured providers (read-only; cached for 300 seconds) +ai-devkit capacity + +# Refresh one provider and emit the stable schema-v1 JSON report +ai-devkit capacity codex --json --refresh + # Install a skill ai-devkit skill add [skill-name] diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts new file mode 100644 index 00000000..954125e9 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { capacityCommand, registerCapacityCommand } from '../../../commands/capacity.js'; +import { renderCapacityReport } from '../../../commands/capacity/render.js'; +import type { CapacityReport } from '../../../commands/capacity/types.js'; +import { ui } from '../../../util/terminal-ui.js'; + +vi.mock('../../../util/terminal-ui.js', () => ({ ui: { text: vi.fn() } })); + +const report: CapacityReport = { + schemaVersion: 1, + generatedAt: '2026-08-09T10:00:00.000Z', + providers: [{ + provider: 'codex', agentType: 'codex', configured: true, installed: true, + authenticated: true, status: 'supported', available: 'yes', plan: 'pro', + checkedAt: '2026-08-09T10:00:00.000Z', source: 'provider-cli', + windows: [ + { id: 'short', label: '5 hour', durationMinutes: 300, usedPercent: 20, + remainingPercent: 80, resetsAt: '2026-08-09T12:00:00.000Z', scope: 'codex' }, + { id: 'long', label: '7 day', durationMinutes: 10080, usedPercent: 60, + remainingPercent: 40, resetsAt: '2026-08-16T10:00:00.000Z', scope: 'codex' } + ], + aliases: { dailyWindowId: null, weeklyWindowId: 'long' }, + resetCredits: { available: 1 }, + warnings: [{ code: 'sample-warning', message: 'A safe normalized warning.' }] + }] +}; + +describe('capacity command', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders schema-v1 JSON exactly through terminal UI', () => { + renderCapacityReport(report, { json: true }); + expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + }); + + it('renders text labels, arbitrary short/long windows, credits, and warnings', () => { + renderCapacityReport(report); + const output = vi.mocked(ui.text).mock.calls.map(call => call[0]).join('\n'); + expect(output).toContain('Provider'); + expect(output).toContain('Auth'); + expect(output).toContain('Available'); + expect(output).toContain('80% left'); + expect(output).toContain('40% left'); + expect(output).toContain('1'); + expect(output).toContain('Warnings:'); + expect(output).toContain('A safe normalized warning.'); + }); + + it('wires the locked command surface and forwards parsed options', async () => { + const getReport = vi.fn(async () => report); + const program = new Command(); + program.exitOverride(); + registerCapacityCommand(program, getReport); + await program.parseAsync(['node', 'test', 'capacity', 'codex', '--json', '--max-age', '120', '--refresh']); + + expect(getReport).toHaveBeenCalledWith({ provider: 'codex', maxAge: 120, refresh: true }); + expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + }); + + it('rejects invalid max-age values before probing', async () => { + const getReport = vi.fn(async () => report); + await expect(capacityCommand(undefined, { maxAge: '-1' }, getReport)).rejects.toThrow( + '--max-age must be a non-negative integer' + ); + expect(getReport).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0c9e86e..8e2045cb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { registerChannelCommand } from './commands/channel.js'; import { registerDocsCommand } from './commands/docs.js'; import { registerPluginCommand } from './commands/plugin.js'; import { registerSetupCommand } from './commands/setup.js'; +import { registerCapacityCommand } from './commands/capacity.js'; import { registerConfiguredPluginCommands } from './services/plugin/plugin-loader.service.js'; import { createAiDevkitRuntime } from './services/plugin/runtime.js'; import { handleCliError } from './util/errors.js'; @@ -64,6 +65,7 @@ registerChannelCommand(program); registerDocsCommand(program); registerPluginCommand(program); registerSetupCommand(program); +registerCapacityCommand(program); await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts new file mode 100644 index 00000000..2b603e10 --- /dev/null +++ b/packages/cli/src/commands/capacity.ts @@ -0,0 +1,33 @@ +import type { Command } from 'commander'; +import { getCapacityReport } from './capacity/orchestrate.js'; +import { renderCapacityReport } from './capacity/render.js'; +import type { CapacityReport } from './capacity/types.js'; + +type RawCapacityOptions = { json?: boolean; maxAge?: string; refresh?: boolean }; +type ReportReader = (options: { + provider?: string; maxAge: number; refresh: boolean; +}) => Promise; + +export async function capacityCommand( + provider: string | undefined, + options: RawCapacityOptions, + readReport: ReportReader = getCapacityReport +): Promise { + const maxAge = options.maxAge === undefined ? 300 : Number(options.maxAge); + if (!Number.isInteger(maxAge) || maxAge < 0) { + throw new Error('--max-age must be a non-negative integer.'); + } + const report = await readReport({ provider, maxAge, refresh: options.refresh === true }); + renderCapacityReport(report, options); +} + +export function registerCapacityCommand(program: Command, readReport: ReportReader = getCapacityReport): void { + program + .command('capacity [provider]') + .description('Report configured AI provider capacity without consuming model quota') + .option('--json', 'Output a schema-v1 JSON report') + .option('--max-age ', 'Maximum cache age in seconds', '300') + .option('--refresh', 'Bypass cached capacity data') + .action((provider: string | undefined, options: RawCapacityOptions) => + capacityCommand(provider, options, readReport)); +} diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts new file mode 100644 index 00000000..9a690743 --- /dev/null +++ b/packages/cli/src/commands/capacity/render.ts @@ -0,0 +1,52 @@ +import { ui } from '../../util/terminal-ui.js'; +import type { CapacityReport, CapacityWindow } from './types.js'; + +function authLabel(value: boolean | null): string { + return value === true ? 'yes' : value === false ? 'no' : 'unknown'; +} + +function formatWindow(window: CapacityWindow | undefined): string { + if (!window || window.remainingPercent === null) return 'unknown'; + const reset = window.resetsAt ? ` · resets ${window.resetsAt}` : ''; + return `${window.remainingPercent}% left${reset}`; +} + +function windowPair(windows: CapacityWindow[]): [CapacityWindow | undefined, CapacityWindow | undefined] { + const known = windows.slice().sort((left, right) => + (left.durationMinutes ?? Number.MAX_SAFE_INTEGER) - (right.durationMinutes ?? Number.MAX_SAFE_INTEGER) + ); + return [known[0], known.length > 1 ? known[known.length - 1] : undefined]; +} + +export function renderCapacityReport(report: CapacityReport, options: { json?: boolean } = {}): void { + if (options.json) { + ui.text(JSON.stringify(report, null, 2)); + return; + } + const rows = report.providers.map(provider => { + const [shortWindow, longWindow] = windowPair(provider.windows); + return [ + provider.provider, + authLabel(provider.authenticated), + provider.available, + formatWindow(shortWindow), + formatWindow(longWindow), + provider.resetCredits?.available === null || provider.resetCredits?.available === undefined + ? '—' : String(provider.resetCredits.available) + ]; + }); + const headers = ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits']; + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index].length))); + const line = (cells: string[]) => cells.map((cell, index) => cell.padEnd(widths[index])).join(' ').trimEnd(); + ui.text(line(headers)); + ui.text(line(widths.map(width => '─'.repeat(width)))); + for (const row of rows) ui.text(line(row)); + const warnings = report.providers.flatMap(provider => provider.warnings.map(warning => + `${provider.provider}: ${warning.message}` + )); + if (warnings.length > 0) { + ui.text(''); + ui.text('Warnings:'); + for (const warning of warnings) ui.text(` ${warning}`); + } +} From 1ca1f244d0eaf55d350e7b883aa0ee97eb69276a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:23:28 +0000 Subject: [PATCH 06/12] fix(capacity): align Codex app-server protocol --- .../__tests__/commands/capacity/codex.test.ts | 29 ++++++++++++++- .../src/commands/capacity/providers/codex.ts | 35 ++++++++++++++----- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index d2004ba7..bd5044fd 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -13,6 +13,12 @@ describe('Codex capacity mapping', () => { secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } }, rateLimitsByLimitId: { + codex: { + limitId: 'codex', + limitName: 'Codex', + primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, + secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } + }, reviews: { limitId: 'reviews', limitName: 'Code reviews', @@ -20,7 +26,7 @@ describe('Codex capacity mapping', () => { secondary: null } }, - usageLimitResetCredits: { availableCount: 2 } + rateLimitResetCredits: { availableCount: 2 } }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); expect(result.available).toBe('yes'); @@ -31,6 +37,7 @@ describe('Codex capacity mapping', () => { expect.objectContaining({ id: 'reviews:primary', durationMinutes: 1440, scope: 'reviews' }) ])); expect(result.aliases).toEqual({ dailyWindowId: 'reviews:primary', weeklyWindowId: 'codex:secondary' }); + expect(result.windows).toHaveLength(3); expect(result.resetCredits).toEqual({ available: 2 }); }); @@ -55,6 +62,19 @@ describe('Codex capacity mapping', () => { expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); }); + it('never exposes URL-like or account-like provider identifiers', () => { + const result = mapCodexRateLimits({ + rateLimits: { + limitId: 'https://private.example/account/123', + limitName: 'account_1234567890', + primary: { usedPercent: 10, windowDurationMins: 60, resetsAt: null } + } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(JSON.stringify(result)).not.toMatch(/private\.example|account_1234567890|account\/123/); + expect(result.windows[0]).toMatchObject({ id: 'codex:primary', scope: 'codex' }); + }); + it('uses only app-server account methods and never invokes a model turn', async () => { const rpc = vi.fn(async () => ({ rateLimits: { @@ -76,6 +96,13 @@ describe('Codex capacity mapping', () => { 'initialized', 'account/rateLimits/read' ]); + expect(messages[0]).toEqual({ + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null } + }); + expect(messages[1]).toEqual({ method: 'initialized' }); + expect(messages[2]).toEqual({ id: 2, method: 'account/rateLimits/read' }); expect(JSON.stringify(messages)).not.toMatch(/model|prompt|turn/i); expect(result.available).toBe('yes'); }); diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts index 4fab91e0..09e92d1d 100644 --- a/packages/cli/src/commands/capacity/providers/codex.ts +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -31,6 +31,20 @@ function text(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } +function safeIdentifier(value: unknown): string | null { + const candidate = text(value); + if (!candidate || !/^[a-z][a-z0-9_-]{0,63}$/i.test(candidate)) return null; + if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; + return candidate; +} + +function safeLabel(value: unknown): string | null { + const candidate = text(value); + if (!candidate || candidate.length > 80 || !/^[a-z0-9 _-]+$/i.test(candidate)) return null; + if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; + return candidate; +} + function resetTime(value: unknown): string | null { const seconds = finiteNumber(value); if (seconds !== null) return new Date(seconds * 1000).toISOString(); @@ -57,8 +71,8 @@ function windowFrom(value: unknown, id: string, label: string, scope: string | n function snapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { const snapshot = record(value); if (!snapshot) return []; - const scope = text(snapshot.limitId) ?? fallbackId; - const name = text(snapshot.limitName) ?? scope; + const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex'; + const name = safeLabel(snapshot.limitName) ?? scope; return [ windowFrom(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), windowFrom(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) @@ -81,10 +95,11 @@ export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): windows.push(...snapshotWindows(snapshot, id)); } } + const normalizedWindows = [...new Map(windows.map(window => [window.id, window])).values()]; const reached = text(primarySnapshot?.rateLimitReachedType); - const resetCredits = record(response.usageLimitResetCredits); + const resetCredits = record(response.rateLimitResetCredits) ?? record(response.usageLimitResetCredits); const availableCount = finiteNumber(resetCredits?.availableCount); - const hasCapacity = windows.some(window => window.remainingPercent !== null); + const hasCapacity = normalizedWindows.some(window => window.remainingPercent !== null); return { provider: 'codex', @@ -97,10 +112,10 @@ export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): plan: text(primarySnapshot?.planType), checkedAt: context.checkedAt, source: 'provider-cli', - windows, + windows: normalizedWindows, aliases: { - dailyWindowId: aliasFor(windows, 1440, 120), - weeklyWindowId: aliasFor(windows, 10080, 720) + dailyWindowId: aliasFor(normalizedWindows, 1440, 120), + weeklyWindowId: aliasFor(normalizedWindows, 10080, 720) }, resetCredits: { available: availableCount }, warnings: hasCapacity || reached ? [] : [{ @@ -167,9 +182,11 @@ export async function probeCodexCapacity(options: CodexProbeOptions): Promise appServerRpc(requests, options.timeoutMs)); From 83eb37672ba424ddb04daf6cc6bcc660596d2e2e Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:25:34 +0000 Subject: [PATCH 07/12] fix(capacity): harden normalized provider metadata --- .../__tests__/commands/capacity/codex.test.ts | 9 +++++++++ .../commands/capacity/providers.test.ts | 18 +++++++++++++++++- .../src/commands/capacity/providers/claude.ts | 7 ++++++- .../src/commands/capacity/providers/codex.ts | 7 ++++++- .../src/commands/capacity/providers/stub.ts | 4 ++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index bd5044fd..15c10947 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -75,6 +75,15 @@ describe('Codex capacity mapping', () => { expect(result.windows[0]).toMatchObject({ id: 'codex:primary', scope: 'codex' }); }); + it('rejects unexpected plan metadata', () => { + const result = mapCodexRateLimits({ + rateLimits: { planType: 'account_1234567890' } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.plan).toBeNull(); + expect(JSON.stringify(result)).not.toContain('account_1234567890'); + }); + it('uses only app-server account methods and never invokes a model turn', async () => { const rpc = vi.fn(async () => ({ rateLimits: { diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts index d2d31ce8..dbe3eff7 100644 --- a/packages/cli/src/__tests__/commands/capacity/providers.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -33,6 +33,18 @@ describe('non-Codex capacity adapters', () => { expect(JSON.stringify(result)).not.toMatch(/oauth-token|secret|response body/); }); + it('does not expose unexpected Claude subscription metadata', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => ({ loggedIn: true, subscriptionType: 'token_secret_1234567890' }) + }); + + expect(result.plan).toBeNull(); + expect(JSON.stringify(result)).not.toContain('token_secret_1234567890'); + }); + it('detects Pi and GLM authentication only from provider key names', async () => { const results = await probePiCapacity({ configured: true, @@ -52,7 +64,11 @@ describe('non-Codex capacity adapters', () => { configured: true, installed: false, checkedAt })).toMatchObject({ provider: 'gemini', configured: true, installed: false, - authenticated: null, status: 'unsupported', available: 'unknown', source: 'none' + agentType: 'gemini_cli', authenticated: null, status: 'unsupported', + available: 'unknown', source: 'none' }); + expect(buildUnsupportedCapacity('copilot', { + configured: true, installed: true, checkedAt + }).agentType).toBe('copilot'); }); }); diff --git a/packages/cli/src/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts index f8c0d0cc..626cbc73 100644 --- a/packages/cli/src/commands/capacity/providers/claude.ts +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -11,6 +11,11 @@ function record(value: unknown): UnknownRecord | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; } +function safePlan(value: unknown): string | null { + if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/i.test(value)) return null; + return /(?:account|token|secret|key|oauth)/i.test(value) ? null : value; +} + async function defaultAuthStatus(timeoutMs: number): Promise { const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' @@ -41,7 +46,7 @@ export async function probeClaudeCapacity(options: ClaudeOptions): Promise = { - claude: 'claude', codex: 'codex', copilot: 'github', gemini: 'gemini', - glm: 'pi', grok: 'grok', opencode: 'opencode', pi: 'pi' + claude: 'claude', codex: 'codex', copilot: 'copilot', gemini: 'gemini_cli', + glm: 'pi', grok: 'grok_cli', opencode: 'opencode', pi: 'pi' }; export function buildUnsupportedCapacity( From 0e202d4629642fe2f1e20b6625b2965ec3e50655 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:29:50 +0000 Subject: [PATCH 08/12] fix(capacity): classify logged-out Claude safely --- .../commands/capacity/providers.test.ts | 22 ++++++++++++++-- .../cli/src/commands/capacity/orchestrate.ts | 2 +- .../src/commands/capacity/providers/claude.ts | 26 +++++++++++++++---- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts index dbe3eff7..4802e50e 100644 --- a/packages/cli/src/__tests__/commands/capacity/providers.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -1,18 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { probeClaudeCapacity } from '../../../commands/capacity/providers/claude.js'; +import { probeClaudeCapacity, readClaudeAuthStatus } from '../../../commands/capacity/providers/claude.js'; import { probePiCapacity } from '../../../commands/capacity/providers/pi.js'; import { buildUnsupportedCapacity } from '../../../commands/capacity/providers/stub.js'; const checkedAt = '2026-08-09T10:00:00.000Z'; describe('non-Codex capacity adapters', () => { + it('reads logged-out Claude JSON even when the CLI exits nonzero', async () => { + const execute = async () => { + throw Object.assign(new Error('must not leak'), { + stdout: JSON.stringify({ loggedIn: false, subscriptionType: null }), + stderr: 'credential-bearing stderr must not leak' + }); + }; + + await expect(readClaudeAuthStatus(6000, execute)).resolves.toEqual({ + loggedIn: false, subscriptionType: null + }); + }); + it('detects Claude authentication but keeps undocumented live usage guarded off', async () => { + let receivedTimeout = 0; const result = await probeClaudeCapacity({ configured: true, installed: true, checkedAt, - authStatus: async () => ({ loggedIn: true, subscriptionType: 'max' }) + authStatus: async timeoutMs => { + receivedTimeout = timeoutMs; + return { loggedIn: true, subscriptionType: 'max' }; + } }); + expect(receivedTimeout).toBe(6000); expect(result).toMatchObject({ provider: 'claude', authenticated: true, status: 'supported', diff --git a/packages/cli/src/commands/capacity/orchestrate.ts b/packages/cli/src/commands/capacity/orchestrate.ts index 6a3bc8b2..b0577f9a 100644 --- a/packages/cli/src/commands/capacity/orchestrate.ts +++ b/packages/cli/src/commands/capacity/orchestrate.ts @@ -92,7 +92,7 @@ export async function getCapacityReport( checkedAt: now.toISOString() }; try { - const results = await withTimeout(dependencies.probe(provider, context), 6000); + const results = await withTimeout(dependencies.probe(provider, context), 7000); return requested === 'pi' ? results.filter(result => result.provider === 'pi') : results; } catch { return [failure(provider, context)]; diff --git a/packages/cli/src/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts index 626cbc73..50cbf9c9 100644 --- a/packages/cli/src/commands/capacity/providers/claude.ts +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -5,7 +5,7 @@ import type { ProviderCapacity } from '../types.js'; const execFileAsync = promisify(execFile); type UnknownRecord = Record; type ClaudeContext = { configured: boolean; installed: boolean; checkedAt: string }; -type ClaudeOptions = ClaudeContext & { authStatus?: () => Promise; timeoutMs?: number }; +type ClaudeOptions = ClaudeContext & { authStatus?: (timeoutMs: number) => Promise; timeoutMs?: number }; function record(value: unknown): UnknownRecord | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; @@ -16,11 +16,26 @@ function safePlan(value: unknown): string | null { return /(?:account|token|secret|key|oauth)/i.test(value) ? null : value; } -async function defaultAuthStatus(timeoutMs: number): Promise { - const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { +type AuthStatusExecutor = (timeoutMs: number) => Promise<{ stdout: string }>; + +async function executeClaudeAuthStatus(timeoutMs: number): Promise<{ stdout: string }> { + const result = await execFileAsync('claude', ['auth', 'status', '--json'], { timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' }); - return JSON.parse(stdout); + return { stdout: String(result.stdout) }; +} + +export async function readClaudeAuthStatus( + timeoutMs: number, + execute: AuthStatusExecutor = executeClaudeAuthStatus +): Promise { + try { + return JSON.parse((await execute(timeoutMs)).stdout); + } catch (error) { + const output = record(error)?.stdout; + if (typeof output === 'string' && output.length <= 64 * 1024) return JSON.parse(output); + throw new Error('Claude authentication status unavailable'); + } } function base(context: ClaudeContext): ProviderCapacity { @@ -40,7 +55,8 @@ export async function probeClaudeCapacity(options: ClaudeOptions): Promise defaultAuthStatus(options.timeoutMs ?? 3000)))(); + const timeoutMs = options.timeoutMs ?? 6000; + const raw = await (options.authStatus ?? readClaudeAuthStatus)(timeoutMs); const auth = record(raw); const authenticated = auth?.loggedIn === true || auth?.authenticated === true; result.authenticated = authenticated; From 5d3e49d487db24f47fbc03d84fe66f073115b81a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 12:01:03 +0000 Subject: [PATCH 09/12] docs(capacity): add dev-lifecycle feature docs --- .../2026-08-09-feature-capacity-command.md | 156 ++++++++++++++++++ .../2026-08-09-feature-capacity-command.md | 100 +++++++++++ .../2026-08-09-feature-capacity-command.md | 69 ++++++++ .../2026-08-09-feature-capacity-command.md | 85 ++++++++++ .../2026-08-09-feature-capacity-command.md | 98 +++++++++++ 5 files changed, 508 insertions(+) create mode 100644 docs/ai/design/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/implementation/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/planning/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/requirements/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/testing/2026-08-09-feature-capacity-command.md diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..994dd567 --- /dev/null +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -0,0 +1,156 @@ +--- +phase: design +title: Capacity Command Design +description: Architecture and security design for normalized provider capacity reporting +--- + +# Capacity Command Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[capacity command] --> Detect[Configured-provider detection] + Detect --> Orchestrator[Parallel orchestrator] + Orchestrator --> Cache[(Normalized cache)] + Orchestrator --> Codex[Codex adapter] + Orchestrator --> Claude[Claude adapter] + Orchestrator --> Pi[Pi / GLM adapter] + Orchestrator --> Stub[Unsupported-provider stub] + Codex --> AppServer[codex app-server] + Claude --> AuthStatus[claude auth status] + Pi --> PiAuth[Pi auth provider names] + Orchestrator --> Report[CapacityReport v1] + Report --> Human[Human table] + Report --> JSON[JSON output] +``` + +The Commander registration layer delegates to a report orchestrator. Detection, provider adapters, normalization, cache, and rendering are separate modules with dependency injection at subprocess and orchestration boundaries. + +## Command API + +```text +capacity [provider] [--json] [--max-age ] [--refresh] +``` + +- No provider: detect only configured providers. +- Provider: request one known provider even if it is not configured, while reporting its actual state. +- `--json`: serialize the report with two-space indentation. +- `--max-age`: accept a non-negative integer; default 300 seconds. +- `--refresh`: skip cache lookup. + +Invalid arguments fail before probing. A constructed report exits successfully even if some rows are unknown. + +## State Model + +These signals are independent: + +| Signal | Meaning | Source | +|---|---|---| +| `configured` | Provider configuration directory exists | `ENVIRONMENT_DEFINITIONS.globalSkillPath` | +| `installed` | Expected executable exists and is executable on PATH | executable access check | +| `authenticated` | Provider-specific probe found valid authentication | app-server/auth status/Pi provider keys | + +Provider status is one of `supported`, `unsupported`, `unauthenticated`, `unavailable`, or `unknown`. Availability is separately `yes`, `no`, or `unknown`. + +## Data Model + +```ts +type CapacityWindow = { + id: string; + label: string; + durationMinutes: number | null; + usedPercent: number | null; + remainingPercent: number | null; + resetsAt: string | null; + scope: string | null; +}; + +type ProviderCapacity = { + provider: string; + agentType: string | null; + configured: boolean; + installed: boolean; + authenticated: boolean | null; + status: 'supported' | 'unsupported' | 'unauthenticated' | 'unavailable' | 'unknown'; + available: 'yes' | 'no' | 'unknown'; + plan: string | null; + checkedAt: string; + source: 'provider-cli' | 'provider-api' | 'local-observation' | 'none'; + windows: CapacityWindow[]; + aliases: { dailyWindowId: string | null; weeklyWindowId: string | null }; + resetCredits?: { available: number | null }; + warnings: Array<{ code: string; message: string }>; + error?: { code: string; retryable: boolean }; +}; + +type CapacityReport = { + schemaVersion: 1; + generatedAt: string; + providers: ProviderCapacity[]; +}; +``` + +`windows` is canonical. Aliases are derived by duration tolerance around 1,440 and 10,080 minutes. Native scoped windows remain separate, duplicate compatibility buckets are removed by normalized ID, and `remainingPercent` is derived only from an authoritative numeric `usedPercent`. + +## Configured-Provider Detection + +`detection.ts` reuses `ENVIRONMENT_DEFINITIONS`; it does not maintain a second provider-to-config mapping. The root is derived from `globalSkillPath` (including nested `.config/` roots), joined to the user home directory, and checked for existence. GitHub environment naming is normalized to provider name `copilot`. Binary detection is a separate executable-access scan over PATH and never establishes configuration. + +## Provider Adapters + +### Codex + +```mermaid +sequenceDiagram + participant C as capacity + participant A as codex app-server --stdio + C->>A: initialize(clientInfo, capabilities=null) + A-->>C: initialize result + C->>A: initialized + C->>A: account/rateLimits/read + A-->>C: rateLimits + buckets + reset-credit summary + C->>C: sanitize, normalize, deduplicate, derive aliases +``` + +The JSON-line transport is injectable in tests. It ignores stderr, bounds execution with a timeout, kills the child after completion, and exposes only normalized fields. It never invokes `turn/start`, `codex exec`, or another model method. The mapper supports the current `rateLimitResetCredits` field plus the older compatibility name, reports `availableCount`, and has no consume/redeem operation. + +### Claude + +The adapter runs `claude auth status --json` with bounded stdout and a timeout. Claude may return valid logged-out JSON with a nonzero exit, so that bounded stdout is parsed while stderr and exception text are discarded. The undocumented OAuth usage endpoint is not called; capacity remains unknown even when authentication succeeds. + +### Pi and GLM + +The adapter reads `~/.pi/agent/auth.json`, retains only top-level provider names, and never emits credential values. Any configured Pi credential establishes Pi authentication. `zai` or `zai-coding-cn` additionally establishes GLM authentication. Both remain unsupported/unknown because no verified account-quota reader exists. + +### Other Providers + +Configured providers without an authoritative adapter use the common stub. The stub preserves configured/installed state, maps to the correct AI DevKit `agentType` when available, and returns `status: unsupported`, `available: unknown`. + +## Orchestration and Cache + +- Provider probes execute with `Promise.all` and a seven-second orchestration timeout; adapters also apply their own subprocess timeouts. +- Exceptions become fixed-code unknown rows. Raw exception data is discarded. +- Cache keys distinguish explicit-provider and configured-provider sets. +- The default cache path is `~/.ai-devkit/cache/capacity.json`. +- Cache directory mode is `0700`; file and temporary file mode is `0600`; writes use rename. +- Cache failures never prevent a report, and `--refresh` bypasses reads. + +## Security and Reliability Decisions + +- Provider CLIs own OAuth/session authentication; secrets are not passed on command lines. +- Output and cache contain normalized allowlisted data, not raw responses. +- Codex identifiers, labels, and plan metadata are validated and credential/account-like values are rejected. +- Claude plan metadata is similarly constrained. +- Error output uses fixed codes/messages; stderr, URLs, headers, bodies, and exception text are never rendered. +- Unknown data remains unknown. Stubs and probe failures cannot claim availability. +- Partial failure is isolated so one provider cannot suppress other results. + +## Alternatives Rejected + +- Direct private HTTP calls: excessive credential exposure and undocumented coupling. +- TUI scraping: brittle and capable of accidentally starting model activity. +- Local token-history estimation: not authoritative for subscription limits. +- Forced daily/weekly schema: loses provider-native rolling and scoped windows. + +The original structured capacity brainstorm supplied the deeper provider feasibility analysis; this document records the architecture that actually shipped. diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..41cb235a --- /dev/null +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -0,0 +1,100 @@ +--- +phase: implementation +title: Capacity Command Implementation Record +description: Shipped modules, integration points, invariants, and operational behavior +--- + +# Capacity Command Implementation Record + +## Shipped Module Map + +```text +packages/cli/src/ +├── cli.ts +└── commands/ + ├── capacity.ts + └── capacity/ + ├── types.ts + ├── detection.ts + ├── orchestrate.ts + ├── cache.ts + ├── render.ts + └── providers/ + ├── codex.ts + ├── claude.ts + ├── pi.ts + └── stub.ts +``` + +Tests live in `packages/cli/src/__tests__/commands/capacity/`. + +## CLI Registration + +`cli.ts` imports and calls `registerCapacityCommand(program)`. `commands/capacity.ts` owns Commander configuration, validates `--max-age`, calls `getCapacityReport`, and hands the result to `renderCapacityReport`. It exposes only: + +```text +capacity [provider] [--json] [--max-age ] [--refresh] +``` + +## Module Responsibilities + +- `types.ts`: exact schema-v1 TypeScript contract. +- `detection.ts`: derives provider config directories from `ENVIRONMENT_DEFINITIONS.globalSkillPath` and independently checks executable access on PATH. +- `orchestrate.ts`: validates provider names, selects configured providers by default, runs probes concurrently, isolates failures/timeouts, reads/writes cache, sorts rows, and constructs the report. +- `cache.ts`: reads freshness-keyed normalized reports and performs atomic restrictive writes under `~/.ai-devkit/cache/capacity.json` (`0700` directory, `0600` file). +- `render.ts`: emits exact pretty JSON or a text table with Auth, Available, shortest/longest native windows, reset credits, and warnings. +- `providers/codex.ts`: drives app-server JSON-RPC and sanitizes/normalizes rate-limit snapshots. +- `providers/claude.ts`: invokes and safely parses `claude auth status --json`; does not fetch live quota. +- `providers/pi.ts`: reads only Pi auth provider names and derives Pi/GLM authentication. +- `providers/stub.ts`: builds truthful unsupported/unknown rows for providers without adapters. + +## Codex JSON-RPC Client + +The adapter spawns `codex app-server --stdio` with piped stdin/stdout and ignored stderr. It writes newline-delimited JSON: + +1. `initialize` with `clientInfo` and `capabilities: null`. +2. After response id 1, `initialized`. +3. `account/rateLimits/read` with request id 2 and no parameters. + +Response id 2 is normalized and the subprocess is terminated. A five-second adapter timer bounds the exchange. The transport function is injectable, so CI tests use no subprocess or network. + +Mapping behavior: + +- Normalize backward-compatible `rateLimits` and `rateLimitsByLimitId` snapshots. +- Preserve primary/secondary windows by scoped ID and remove duplicates. +- Convert epoch reset timestamps to ISO-8601. +- Clamp derived remaining percent to 0–100. +- Derive daily/weekly aliases by duration tolerance only. +- Treat a reported reached type as explicit `available: no`; missing windows remain unknown. +- Report only reset-credit `availableCount`; no consume method exists. + +## Provider Detection and Unknown Semantics + +The default row set is determined before binary checks. Configured, installed, and authenticated are stored independently. A configured but uninstalled provider remains visible. An installed but unconfigured provider does not enter the default report. Explicitly requested known providers are reported even when unconfigured. + +Only authoritative Codex utilization can establish `available: yes`. Claude, Pi, GLM, and unsupported providers remain `available: unknown` without verified quota data. + +## Failure Handling + +- Adapter exceptions never escape into report text. +- Orchestration catches each provider independently and emits a retryable fixed-code unknown row. +- Cache read/write failures are non-fatal. +- Unknown providers and invalid max-age values are command errors. +- Claude logged-out JSON is accepted from bounded stdout even when the CLI returns nonzero; stderr remains unused. +- A report, including a partial report, exits successfully. + +## Security Invariants + +- No tokens, account IDs, refresh tokens, endpoint URLs, headers, raw bodies, stderr, or raw exception messages are emitted or cached. +- No credential is placed on a subprocess command line. +- Codex authentication and refresh remain inside Codex app-server. +- Codex labels, IDs, scopes, and plans are constrained before output; Claude plan metadata is constrained too. +- Pi credential values are parsed only to discover top-level provider names and are never retained in normalized output. +- Cache contains only normalized report data with restrictive permissions. +- Capacity checks contain no model-start/inference method and never redeem reset credits. + +## Design Alignment and Deviations + +The shipped implementation matches the locked design. The brainstorm considered guarded use of Claude's undocumented OAuth usage endpoint; implementation review rejected that risk and shipped authentication-only Claude support. The brainstorm's broader draft schema contained fields such as transport provider and stale-after metadata; schema v1 intentionally uses the smaller contract in `types.ts`. + +No code change, data migration, new dependency, or rollout flag is required for these lifecycle documents. diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..521034f9 --- /dev/null +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -0,0 +1,69 @@ +--- +phase: planning +title: Capacity Command Implementation Plan +description: Completed task record for the shipped capacity command +--- + +# Capacity Command Implementation Plan + +All tasks are complete. The list reflects execution order and the pushed commit that delivered each outcome. + +## Milestone 1: Detection and Core Contract + +- [x] Define schema-v1 capacity types and configuration/PATH detection — `c6c386b`. + - Outcome: `CapacityReport`, `ProviderCapacity`, arbitrary `CapacityWindow[]`, and independent configured/installed checks. + - Validation: detection derives config roots from `ENVIRONMENT_DEFINITIONS` and never runs provider binaries. +- [x] Build the Codex app-server adapter under TDD — `d57813a`. + - Outcome: injectable JSON-line transport, normalized windows, aliases, availability, plan, and reset-credit count. + - Validation: mocked protocol sequence contains no model-turn method and failures are redacted. + +## Milestone 2: Provider Coverage and Orchestration + +- [x] Add truthful Claude, Pi, GLM, and unsupported-provider adapters — `c614f27`. + - Outcome: Claude auth detection, Pi provider-name inspection, GLM detection through z.ai keys, and unknown-capacity stubs. + - Validation: injected secrets and thrown response details do not reach reports. +- [x] Add parallel orchestration and secure cache — `5de3a72`. + - Outcome: configured-only default, explicit provider validation, partial-result isolation, timeouts, max-age/refresh behavior, atomic restrictive cache. + - Validation: mocked adapters prove parallel selection, cache reuse/bypass, and partial failure behavior. + +## Milestone 3: CLI and Presentation + +- [x] Register and document the command — `69a201d`. + - Outcome: `registerCapacityCommand` in `cli.ts`, locked options, JSON rendering, human table, warnings, and CLI README examples. + - Validation: Commander integration forwards the provider and parsed cache options; invalid max-age fails before probing. + +## Milestone 4: Live-Protocol and Security Hardening + +- [x] Align with the generated Codex app-server protocol — `c04ea1f`. + - Outcome: exact initialize payload, parameterless rate-limit read, current reset-credit field, duplicate bucket removal, and identifier redaction. + - Validation: generated-protocol assertions and a live read-only Codex smoke test. +- [x] Harden provider metadata and agent-type mappings — `f34dbc3`. + - Outcome: reject credential/account-like plan metadata; map Gemini, Grok, and Copilot to shipped agent types. + - Validation: redaction and mapping regression tests. +- [x] Correct logged-out Claude handling — `5e2cc89`. + - Outcome: accept bounded JSON stdout from Claude's expected nonzero logged-out exit and use a six-second adapter timeout under the seven-second orchestrator guard. + - Validation: mocked nonzero behavior plus live `authenticated: false` classification. + +## Dependencies and Sequencing + +1. Types and detection established the provider/report contract. +2. Provider adapters normalized into that contract. +3. Orchestration composed adapters and added cache/timeout behavior. +4. CLI/rendering exposed the report. +5. Full tests and real read-only probes drove protocol/security fixes. + +Runtime dependencies are Node.js, Commander, provider CLIs already installed by the user, and the existing AI DevKit environment definitions. No new package dependency or migration was introduced. + +## Risks and Mitigations + +- Codex app-server protocol changes: capability failures degrade to unknown; transport and mapping are isolated and tested. +- Undocumented Claude usage endpoint: not used; authentication-only output is explicit. +- Provider failure/latency: parallel probes, subprocess/orchestrator timeouts, and partial results. +- Secret leakage: provider-owned auth, bounded streams, fixed errors, field sanitization, and restrictive normalized cache. +- Misleading capacity: positive availability requires authoritative data; unsupported/missing data remains unknown. + +## Deferred Follow-Ups + +- Add Claude live capacity only if a safe provider-owned command becomes available. +- Add GLM or other provider adapters only after verifying authoritative, non-inference quota mechanisms. +- Add scheduling/recommendation policy separately from factual collection. diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..7bf54c93 --- /dev/null +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -0,0 +1,85 @@ +--- +phase: requirements +title: Capacity Command Requirements +description: Define truthful, read-only provider capacity reporting before agent dispatch +--- + +# Capacity Command Requirements + +## Problem Statement + +AI DevKit can start agents backed by Codex, Claude, Pi, and other providers, but previously could not inspect provider capacity before launch. Humans and orchestrators discovered limits only after starting work, sometimes after a task was already in progress. The workaround was to check provider-specific interfaces manually or launch an agent and react to a rate-limit failure. + +The `capacity` command gives human operators, the agent-management workflow, parent agents, and future schedulers one factual report before dispatch. + +## Goals + +- Provide one fast, read-only command for provider capacity and authentication state. +- Emit stable schema-versioned JSON for automation and a readable human table. +- Show only configured providers by default, detected from provider configuration directories. +- Preserve every authoritative provider window instead of forcing daily/weekly fields. +- Distinguish configured, installed, and authenticated states. +- Treat missing or unsupported capacity as `unknown`, never as positive availability. +- Allow partial provider failures without losing the complete report. +- Report available reset-credit counts without redeeming credits. +- Avoid model inference, prompts, TUI interaction, and model-quota consumption. + +## Non-Goals + +- Automatic provider selection or changes to `agent start`. +- Forecasting, task-cost prediction, billing reconciliation, or local-usage estimation. +- TUI scraping or inference requests used as probes. +- Multiple accounts per provider. +- Automatic reset-credit redemption. +- A first-party live quota adapter for every AI DevKit environment. +- Direct use of undocumented provider credentials or private endpoints. + +## User Stories + +- As a human operator, I want to see which configured providers are authenticated and what authoritative capacity remains before choosing an agent. +- As an orchestrator, I want stable JSON with explicit `yes`, `no`, and `unknown` availability so I can apply my own unknown-data policy. +- As the agent-management workflow, I want provider and `agentType` fields that can be joined to launchable agent types. +- As a security-conscious self-hosted user, I want provider-owned authentication and redacted failures so capacity checks never disclose credentials. +- As a Codex user, I want native rolling windows and reset-credit counts without consuming a model turn or redeeming a credit. + +## Shipped Command Surface + +```text +ai-devkit capacity +ai-devkit capacity [provider] +ai-devkit capacity [provider] --json +ai-devkit capacity [provider] --max-age +ai-devkit capacity [provider] --refresh +``` + +The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown providers and invalid non-negative integer values for `--max-age` are invalid arguments. + +## Acceptance Criteria + +- `capacity` with no provider argument includes only providers whose configuration directory exists according to `ENVIRONMENT_DEFINITIONS.globalSkillPath`; PATH presence alone never adds a row. +- Every row exposes `configured`, `installed`, and nullable `authenticated` separately. +- JSON uses `schemaVersion: 1` and the shipped `CapacityReport` contract. +- Canonical capacity is `CapacityWindow[]`; daily and weekly aliases are conveniences derived from duration. +- Missing data produces `available: "unknown"`; only explicit provider exhaustion/blocking produces `"no"`. +- Codex uses `codex app-server --stdio` with `initialize`, `initialized`, then `account/rateLimits/read`; no model-turn method is called. +- Claude uses `claude auth status --json`; unsafe undocumented live usage is not called. +- Pi and GLM authentication may be detected, but their authoritative capacity remains unknown. +- Other configured providers are represented as unsupported with unknown availability. +- Provider probes run concurrently with isolated timeouts; a report with partial unknown rows exits successfully. +- Cache data is normalized and non-sensitive, with restrictive directory/file permissions. +- Output never contains tokens, account IDs, refresh tokens, endpoint URLs, headers, raw response bodies, stderr, or exception text. + +## Constraints and Locked Decisions + +- Command name is `capacity`. +- Default selection is configuration-directory based, not PATH based. +- Providers may expose arbitrary rolling or scoped windows; daily/weekly are not required. +- `unknown` is never equivalent to `yes`. +- Authentication stays owned by provider CLIs wherever possible. +- Capacity checking must not consume model quota. +- Reset credits are report-only and are never redeemed. +- The implementation remains local-first and self-host friendly. + +## Open Items + +No open item blocks the shipped feature. Future adapters require a documented, non-inference, credential-safe provider mechanism. Claude live subscription usage and z.ai/GLM quota discovery remain deliberately deferred. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..c8c29a47 --- /dev/null +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -0,0 +1,98 @@ +--- +phase: testing +title: Capacity Command Testing Record +description: Automated coverage, fixtures, real smoke checks, and final gate evidence +--- + +# Capacity Command Testing Record + +## Strategy and Isolation + +The feature was built with red-green-refactor cycles. Pure mapping and detection logic are unit tested, subprocess/filesystem boundaries are injected, and orchestration composes mocked adapters. CI never launches a real provider subprocess and never accesses a provider network endpoint. + +## Automated Test Inventory + +### `detection.test.ts` + +- [x] Derive configured providers from `ENVIRONMENT_DEFINITIONS.globalSkillPath`, including nested `.config/opencode`. +- [x] Check executable presence on PATH without running a provider CLI. + +### `codex.test.ts` + +- [x] Normalize primary, secondary, and multi-bucket arbitrary windows. +- [x] Derive daily/weekly aliases by duration and report unredeemed reset-credit counts. +- [x] Deduplicate the compatibility `rateLimits` view against `rateLimitsByLimitId`. +- [x] Keep missing capacity unknown rather than positive. +- [x] Map explicit exhaustion to `available: no` without exposing reached details. +- [x] Reject URL/account-like identifiers and unsafe plan metadata. +- [x] Assert the exact initialize/initialized/rate-limit-read sequence contains no model/prompt/turn method. +- [x] Redact transport exception text. + +The response fixture is synthetic and redacted; it contains no real account data. + +### `providers.test.ts` + +- [x] Parse Claude logged-out JSON from a nonzero CLI exit while ignoring stderr. +- [x] Detect Claude authentication, apply the guarded timeout, and leave live usage unknown. +- [x] Redact Claude failures and unsafe subscription metadata. +- [x] Detect Pi and GLM authentication from provider names without exposing credential values. +- [x] Return correct agent types and truthful unknown capacity for unsupported providers. + +### `orchestrate.test.ts` + +- [x] Probe only configured providers by default. +- [x] Run independent probes and preserve a report when one fails. +- [x] Use a fresh cache and bypass it with `--refresh`. +- [x] Reject unknown explicit provider names. + +### `cache.test.ts` + +- [x] Store only the normalized key/report envelope. +- [x] Write cache files with mode `0600`. +- [x] Accept fresh matching entries and reject stale entries. + +### `command.test.ts` + +- [x] Render exact schema-v1 JSON through terminal UI. +- [x] Render human labels, arbitrary short/long windows, credits, and warnings. +- [x] Exercise Commander wiring with an injected report reader; no live adapter is called. +- [x] Reject invalid max-age values before probing. + +## Coverage + +The full CLI coverage run passed repository thresholds: + +- Statements: 71.47% +- Branches: 62.04% +- Functions: 70.06% +- Lines: 72.77% +- Capacity core modules: 80.59% statements and 85.98% lines + +The lower direct coverage in the default Codex transport is intentional: CI tests the injected protocol contract and mapper rather than spawning a real authenticated provider process. + +## Fresh Final Gates + +| Gate | Result | +|---|---| +| `cd packages/cli && npm run lint` | Exit 0; five pre-existing warnings, zero errors | +| `cd packages/cli && npm test` | 85 test files, 953 tests passed | +| `cd packages/cli && npm run build` | Exit 0; 207 files compiled | +| `cd packages/cli && npm run test:coverage` | Exit 0; repository thresholds passed | +| PR #147 CI | 7/7 checks green | + +## Real-Run Smoke Results + +The built CLI was run on the development machine with configured Claude, Codex, and Pi/z.ai state: + +- [x] `capacity --json --refresh` returned only configured providers: Claude, Codex, Pi, and GLM-through-Pi. +- [x] Codex app-server returned a live authoritative 10,080-minute window and reset-credit count through `account/rateLimits/read`. +- [x] The request sequence contained no model turn and no reset-credit consume operation. +- [x] Claude logged-out state normalized to `authenticated: false`, `status: unauthenticated`, and `available: unknown`. +- [x] Pi and GLM normalized to authenticated but unsupported/unknown. +- [x] Output and test scans contained no tokens, account IDs, endpoint bodies, headers, or credential values. +- [x] `capacity --max-age=-1` exited 1 with a validation error. +- [x] Existing `agent list --json` exited 0, confirming the adjacent command remained functional. + +## Regression Policy + +Any future provider adapter must use a redacted synthetic fixture, mock external transport in CI, prove unknown-data behavior, and add a real read-only smoke procedure that does not consume model quota. Credential-bearing diagnostics must never be added to snapshots or failure assertions. From fe618a22eb4c152f438dc8f9eaa7d605018e67d0 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:02:04 +0000 Subject: [PATCH 10/12] feat(capacity): add tiered Codex usage provider --- .../__tests__/commands/capacity/codex.test.ts | 267 ++++++++------ .../src/commands/capacity/providers/codex.ts | 339 +++++++++++++----- packages/cli/src/commands/capacity/types.ts | 13 + 3 files changed, 437 insertions(+), 182 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index 15c10947..8ed6a479 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -1,131 +1,198 @@ import { describe, expect, it, vi } from 'vitest'; -import { mapCodexRateLimits, probeCodexCapacity } from '../../../commands/capacity/providers/codex.js'; - -describe('Codex capacity mapping', () => { - it('normalizes arbitrary windows, aliases, and unredeemed reset credits', () => { - const result = mapCodexRateLimits({ - rateLimits: { - limitId: 'codex', - limitName: 'Codex', - planType: 'pro', - rateLimitReachedType: null, - primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, - secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } - }, - rateLimitsByLimitId: { - codex: { - limitId: 'codex', - limitName: 'Codex', - primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, - secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } - }, - reviews: { - limitId: 'reviews', - limitName: 'Code reviews', - primary: { usedPercent: 10, windowDurationMins: 1440, resetsAt: 1786320000 }, - secondary: null - } - }, - rateLimitResetCredits: { availableCount: 2 } - }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); - - expect(result.available).toBe('yes'); - expect(result.plan).toBe('pro'); - expect(result.windows).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'codex:primary', durationMinutes: 300, remainingPercent: 80 }), - expect.objectContaining({ id: 'codex:secondary', durationMinutes: 10080, remainingPercent: 39 }), - expect.objectContaining({ id: 'reviews:primary', durationMinutes: 1440, scope: 'reviews' }) - ])); - expect(result.aliases).toEqual({ dailyWindowId: 'reviews:primary', weeklyWindowId: 'codex:secondary' }); - expect(result.windows).toHaveLength(3); - expect(result.resetCredits).toEqual({ available: 2 }); - }); +import { + CODEX_APP_SERVER_ARGS, + parseUsage, + probeCodexCapacity, + resolveCodexAuthPath, + toRateWindow +} from '../../../commands/capacity/providers/codex.js'; - it('does not turn missing capacity into available yes', () => { - const result = mapCodexRateLimits({}, { - configured: true, - installed: true, - checkedAt: '2026-08-09T10:00:00.000Z' - }); +const checkedAt = '2026-08-20T10:00:00.000Z'; +const context = { configured: true, installed: true, checkedAt }; - expect(result.available).toBe('unknown'); - expect(result.status).toBe('unknown'); - expect(result.windows).toEqual([]); +function apiUsage(overrides: Record = {}) { + return { + rate_limit: { + primary_window: { used_percent: 20, limit_window_seconds: 18_000, reset_at: 1_787_220_000 }, + secondary_window: { used_percent: 60, limit_window_seconds: 604_800, reset_at: 1_787_824_800 }, + ...overrides + }, + credits: { balance: 12.5 }, + individual_limit: 100, + additional_rate_limits: [{ + limit_name: 'reviews', + rate_limit: { + primary_window: { used_percent: 10, limit_window_seconds: 3_600, reset_at: 1_787_220_000 } + } + }] + }; +} + +describe('Codex auth resolution', () => { + it('uses CODEX_HOME before HOME', () => { + expect(resolveCodexAuthPath({ CODEX_HOME: '/custom/codex', HOME: '/users/test' })).toBe('/custom/codex/auth.json'); }); - it('reports explicit exhaustion as unavailable without exposing response details', () => { - const result = mapCodexRateLimits({ - rateLimits: { rateLimitReachedType: 'rate-limit-secret-detail', planType: 'team' } - }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + it('falls back to ~/.codex/auth.json', () => { + expect(resolveCodexAuthPath({ HOME: '/users/test' })).toBe('/users/test/.codex/auth.json'); + }); +}); - expect(result.available).toBe('no'); - expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); +describe('Codex API usage mapping', () => { + it('converts an API window without treating missing data as zero', () => { + expect(toRateWindow({ used_percent: 25, limit_window_seconds: 18_000, reset_at: 1_787_220_000 }, 'session', 'Session')).toEqual({ + id: 'session', label: 'Session', durationMinutes: 300, usedPercent: 25, + remainingPercent: 75, resetsAt: '2026-08-20T10:00:00.000Z', scope: null + }); + expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: null, remainingPercent: null }); }); - it('never exposes URL-like or account-like provider identifiers', () => { - const result = mapCodexRateLimits({ - rateLimits: { - limitId: 'https://private.example/account/123', - limitName: 'account_1234567890', - primary: { usedPercent: 10, windowDurationMins: 60, resetsAt: null } - } - }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + it('maps session, weekly, credits, extra limits, and source', () => { + const snapshot = parseUsage(apiUsage(), 'pat', checkedAt); + expect(snapshot).toMatchObject({ + source: 'pat', creditsRemaining: 12.5, codexCreditLimit: 100, updatedAt: checkedAt, + sessionLimit: { durationMinutes: 300, remainingPercent: 80 }, + weeklyLimit: { durationMinutes: 10080, remainingPercent: 40 } + }); + expect(snapshot.extraRateWindows).toEqual([ + expect.objectContaining({ id: 'reviews:primary', remainingPercent: 90 }) + ]); + }); - expect(JSON.stringify(result)).not.toMatch(/private\.example|account_1234567890|account\/123/); - expect(result.windows[0]).toMatchObject({ id: 'codex:primary', scope: 'codex' }); + it.each([ + [{ individual_limit: 111 }, 111], + [{ rate_limit: { individual_limit: 222 } }, 222], + [{ spend_control: { individual_limit: 333 } }, 333] + ])('uses the credit-limit fallback chain', (patch, expected) => { + const usage = apiUsage(); + delete (usage as { individual_limit?: number }).individual_limit; + const input = { ...usage, ...patch, rate_limit: { ...usage.rate_limit, ...('rate_limit' in patch ? patch.rate_limit : {}) } }; + expect(parseUsage(input, 'oauth', checkedAt).codexCreditLimit).toBe(expected); }); - it('rejects unexpected plan metadata', () => { - const result = mapCodexRateLimits({ - rateLimits: { planType: 'account_1234567890' } - }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + it('represents missing limits as unavailable rather than zero', () => { + const snapshot = parseUsage({ credits: {} }, 'oauth', checkedAt); + expect(snapshot.sessionLimit).toBeNull(); + expect(snapshot.weeklyLimit).toBeNull(); + expect(snapshot.creditsRemaining).toBeNull(); + }); +}); - expect(result.plan).toBeNull(); - expect(JSON.stringify(result)).not.toContain('account_1234567890'); +describe('tiered Codex probing', () => { + it('selects PAT, calls whoami then usage, and never invokes the CLI', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ chatgpt_account_id: 'acct-1' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const rpc = vi.fn(); + const result = await probeCodexCapacity({ + ...context, readFile: async () => JSON.stringify({ + personal_access_token: 'pat-secret', + tokens: { access_token: 'ignored-oauth', account_id: 'ignored-account' } + }), fetch, rpc + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][0]).toBe('https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami'); + expect(fetch.mock.calls[1][0]).toBe('https://chatgpt.com/backend-api/wham/usage'); + expect(fetch.mock.calls[1][1].headers).toMatchObject({ Authorization: 'Bearer pat-secret', 'ChatGPT-Account-Id': 'acct-1' }); + expect(rpc).not.toHaveBeenCalled(); + expect(result).toMatchObject({ source: 'provider-api', available: 'yes', usage: { source: 'pat' } }); + }); + + it('selects a fresh OAuth token without calling whoami', async () => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct-2', expires_at: 1_800_000_000 } }), + fetch, + now: () => new Date('2026-08-20T10:00:00.000Z') + }); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer oauth-secret', 'ChatGPT-Account-Id': 'acct-2' }); + expect(result.usage?.source).toBe('oauth'); }); - it('uses only app-server account methods and never invokes a model turn', async () => { + it.each([ + ['missing auth file', async () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); }], + ['stale OAuth token', async () => JSON.stringify({ tokens: { access_token: 'stale-secret', account_id: 'acct', expires_at: 1 } })], + ['OAuth 401', async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 } })] + ])('falls back to the CLI for %s', async (name, readFile) => { + const fetch = vi.fn().mockResolvedValue(new Response('', { status: name === 'OAuth 401' ? 401 : 200 })); const rpc = vi.fn(async () => ({ - rateLimits: { - primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } - } + rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } }, + account: { account: { type: 'chatgpt' } } })); + const result = await probeCodexCapacity({ ...context, readFile, fetch, rpc, now: () => new Date(checkedAt) }); + expect(rpc).toHaveBeenCalledOnce(); + expect(result.usage?.source).toBe('cli'); + }); + it('falls back to CLI if PAT requests fail', async () => { + const rpc = vi.fn(async () => ({ rateLimits: {}, account: { account: null } })); const result = await probeCodexCapacity({ - configured: true, - installed: true, - checkedAt: '2026-08-09T10:00:00.000Z', + ...context, + readFile: async () => JSON.stringify({ personal_access_token: 'pat-secret' }), + fetch: vi.fn().mockRejectedValue(new Error('network failure pat-secret')), rpc }); - expect(rpc).toHaveBeenCalledOnce(); + expect(result.available).toBe('unknown'); + }); + + it('tries fresh OAuth after a PAT request fails', async () => { + const fetch = vi.fn() + .mockRejectedValueOnce(new Error('PAT failed')) + .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const rpc = vi.fn(); + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ + personal_access_token: 'pat-secret', + tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 } + }), + fetch, + rpc, + now: () => new Date(checkedAt) + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(result.usage?.source).toBe('oauth'); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('uses hardened read-only app-server arguments and both account methods', async () => { + const rpc = vi.fn(async () => ({ + rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } }, + account: { account: { type: 'chatgpt' } } + })); + await probeCodexCapacity({ ...context, readFile: async () => '{}', rpc }); const messages = rpc.mock.calls[0][0]; expect(messages.map(message => message.method)).toEqual([ - 'initialize', - 'initialized', - 'account/rateLimits/read' + 'initialize', 'initialized', 'account/rateLimits/read', 'account/read' ]); - expect(messages[0]).toEqual({ - id: 1, - method: 'initialize', - params: { clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null } - }); - expect(messages[1]).toEqual({ method: 'initialized' }); - expect(messages[2]).toEqual({ id: 2, method: 'account/rateLimits/read' }); - expect(JSON.stringify(messages)).not.toMatch(/model|prompt|turn/i); - expect(result.available).toBe('yes'); + expect(JSON.stringify(messages)).not.toMatch(/prompt|turn\/start/); + expect(CODEX_APP_SERVER_ARGS).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']); }); - it('redacts all transport failures', async () => { + it('uses account/read to distinguish logged-out CLI state', async () => { const result = await probeCodexCapacity({ - configured: true, - installed: true, - checkedAt: '2026-08-09T10:00:00.000Z', - rpc: async () => { throw new Error('token=secret https://private.example/account/123'); } + ...context, + readFile: async () => '{}', + rpc: async () => ({ rateLimits: {}, account: { account: null } }) }); + expect(result).toMatchObject({ authenticated: false, status: 'unauthenticated', available: 'unknown' }); + }); - expect(result.available).toBe('unknown'); - expect(result.error).toEqual({ code: 'codex-probe-failed', retryable: true }); - expect(JSON.stringify(result)).not.toMatch(/secret|private\.example|account\/123/); + it('never exposes tokens or raw auth content through failures', async () => { + const secrets = ['pat-secret-value', 'oauth-secret-value', 'refresh-secret-value']; + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ + personal_access_token: secrets[0], + tokens: { access_token: secrets[1], refresh_token: secrets[2] } + }), + fetch: vi.fn().mockRejectedValue(new Error(secrets.join(' '))), + rpc: async () => { throw new Error(secrets.join(' ')); } + }); + const output = JSON.stringify(result); + for (const secret of secrets) expect(output).not.toContain(secret); }); }); diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts index 11b7ffae..62077022 100644 --- a/packages/cli/src/commands/capacity/providers/codex.ts +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -1,20 +1,30 @@ import { spawn } from 'node:child_process'; -import type { CapacityWindow, ProviderCapacity } from '../types.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + CapacityWindow, + CodexUsageSource, + ProviderCapacity, + UsageSnapshot +} from '../types.js'; type UnknownRecord = Record; +type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; +type CliResponses = { rateLimits: unknown; account: unknown }; +type CodexRpc = (messages: RpcMessage[]) => Promise; + +export const CODEX_APP_SERVER_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const; -type CodexMappingContext = { +type CodexProbeOptions = { configured: boolean; installed: boolean; checkedAt: string; -}; - -type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; -type CodexRpc = (messages: RpcMessage[]) => Promise; - -type CodexProbeOptions = CodexMappingContext & { + readFile?: (path: string, encoding: BufferEncoding) => Promise; + fetch?: typeof globalThis.fetch; rpc?: CodexRpc; timeoutMs?: number; + env?: NodeJS.ProcessEnv; + now?: () => Date; }; function record(value: unknown): UnknownRecord | null { @@ -27,19 +37,26 @@ function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -function text(value: unknown): string | null { +function nonEmptyText(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } +function resetTime(value: unknown): string | null { + const seconds = finiteNumber(value); + if (seconds !== null) return new Date(seconds * 1000).toISOString(); + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString(); + return null; +} + function safeIdentifier(value: unknown): string | null { - const candidate = text(value); + const candidate = nonEmptyText(value); if (!candidate || !/^[a-z][a-z0-9_-]{0,63}$/i.test(candidate)) return null; if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; return candidate; } function safeLabel(value: unknown): string | null { - const candidate = text(value); + const candidate = nonEmptyText(value); if (!candidate || candidate.length > 80 || !/^[a-z0-9 _-]+$/i.test(candidate)) return null; if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; return candidate; @@ -50,22 +67,72 @@ function safePlan(value: unknown): string | null { return candidate && !/(?:account|token|secret|key|oauth)/i.test(candidate) ? candidate : null; } -function resetTime(value: unknown): string | null { - const seconds = finiteNumber(value); - if (seconds !== null) return new Date(seconds * 1000).toISOString(); - if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString(); - return null; +export function resolveCodexAuthPath(env: NodeJS.ProcessEnv = process.env): string { + const root = env.CODEX_HOME || join(env.HOME || '', '.codex'); + return join(root, 'auth.json'); +} + +export function toRateWindow( + value: unknown, + id: string, + label: string, + scope: string | null = null +): CapacityWindow | null { + const input = record(value); + if (!input) return null; + const used = finiteNumber(input.used_percent); + const seconds = finiteNumber(input.limit_window_seconds); + return { + id, + label, + durationMinutes: seconds === null ? null : seconds / 60, + usedPercent: used, + remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetsAt: resetTime(input.reset_at), + scope + }; } -function windowFrom(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { +function extraWindows(value: unknown): CapacityWindow[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry, index) => { + const limit = record(entry); + if (!limit) return []; + const scope = safeIdentifier(limit.limit_name) ?? `extra-${index + 1}`; + const windows = record(limit.rate_limit) ?? limit; + return [ + toRateWindow(windows.primary_window, `${scope}:primary`, `${scope} primary`, scope), + toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`, scope) + ].filter((window): window is CapacityWindow => window !== null); + }); +} + +export function parseUsage(raw: unknown, source: Exclude, updatedAt: string): UsageSnapshot { + const response = record(raw) ?? {}; + const limits = record(response.rate_limit) ?? {}; + const credits = record(response.credits) ?? {}; + const spendControl = record(response.spend_control) ?? {}; + return { + sessionLimit: toRateWindow(limits.primary_window, 'session', 'Session'), + weeklyLimit: toRateWindow(limits.secondary_window, 'weekly', 'Weekly'), + creditsRemaining: finiteNumber(credits.balance), + codexCreditLimit: finiteNumber(response.individual_limit) + ?? finiteNumber(limits.individual_limit) + ?? finiteNumber(spendControl.individual_limit), + extraRateWindows: extraWindows(response.additional_rate_limits), + source, + updatedAt + }; +} + +function cliWindow(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { const input = record(value); if (!input) return null; const used = finiteNumber(input.usedPercent); - const duration = finiteNumber(input.windowDurationMins); return { id, label, - durationMinutes: duration, + durationMinutes: finiteNumber(input.windowDurationMins), usedPercent: used, remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), resetsAt: resetTime(input.resetsAt), @@ -73,81 +140,144 @@ function windowFrom(value: unknown, id: string, label: string, scope: string | n }; } -function snapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { +function cliSnapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { const snapshot = record(value); if (!snapshot) return []; const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex'; const name = safeLabel(snapshot.limitName) ?? scope; return [ - windowFrom(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), - windowFrom(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) + cliWindow(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), + cliWindow(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) ].filter((item): item is CapacityWindow => item !== null); } +export function parseCliUsage(raw: unknown, updatedAt: string): UsageSnapshot { + const response = record(raw) ?? {}; + const primary = record(response.rateLimits); + const windows = cliSnapshotWindows(primary, 'codex'); + const buckets = record(response.rateLimitsByLimitId); + if (buckets) { + for (const [id, snapshot] of Object.entries(buckets)) windows.push(...cliSnapshotWindows(snapshot, id)); + } + const unique = [...new Map(windows.map(window => [window.id, window])).values()]; + return { + sessionLimit: unique.find(window => window.id === 'codex:primary') ?? unique[0] ?? null, + weeklyLimit: unique.find(window => window.id === 'codex:secondary') ?? null, + creditsRemaining: null, + codexCreditLimit: null, + extraRateWindows: unique.filter(window => !['codex:primary', 'codex:secondary'].includes(window.id)), + source: 'cli', + updatedAt + }; +} + function aliasFor(windows: CapacityWindow[], target: number, tolerance: number): string | null { return windows.find(window => window.durationMinutes !== null && Math.abs(window.durationMinutes - target) <= tolerance )?.id ?? null; } -export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): ProviderCapacity { - const response = record(raw) ?? {}; - const primarySnapshot = record(response.rateLimits); - const windows = snapshotWindows(primarySnapshot, 'codex'); - const buckets = record(response.rateLimitsByLimitId); - if (buckets) { - for (const [id, snapshot] of Object.entries(buckets)) { - windows.push(...snapshotWindows(snapshot, id)); - } - } - const normalizedWindows = [...new Map(windows.map(window => [window.id, window])).values()]; - const reached = text(primarySnapshot?.rateLimitReachedType); - const resetCredits = record(response.rateLimitResetCredits) ?? record(response.usageLimitResetCredits); - const availableCount = finiteNumber(resetCredits?.availableCount); - const hasCapacity = normalizedWindows.some(window => window.remainingPercent !== null); - +function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): ProviderCapacity { + const windows = [snapshot.sessionLimit, snapshot.weeklyLimit, ...snapshot.extraRateWindows] + .filter((window): window is CapacityWindow => window !== null); + const hasUsage = windows.some(window => window.usedPercent !== null); + const rateLimits = record(record(raw)?.rateLimits); + const reached = nonEmptyText(rateLimits?.rateLimitReachedType); + const resetCredits = record(record(raw)?.rateLimitResetCredits) ?? record(record(raw)?.usageLimitResetCredits); return { provider: 'codex', agentType: 'codex', configured: context.configured, installed: context.installed, authenticated: true, - status: reached || hasCapacity ? 'supported' : 'unknown', - available: reached ? 'no' : hasCapacity ? 'yes' : 'unknown', - plan: safePlan(primarySnapshot?.planType), + status: reached || hasUsage ? 'supported' : 'unknown', + available: reached ? 'no' : hasUsage ? 'yes' : 'unknown', + plan: safePlan(rateLimits?.planType), checkedAt: context.checkedAt, - source: 'provider-cli', - windows: normalizedWindows, + source: snapshot.source === 'cli' ? 'provider-cli' : 'provider-api', + windows, aliases: { - dailyWindowId: aliasFor(normalizedWindows, 1440, 120), - weeklyWindowId: aliasFor(normalizedWindows, 10080, 720) + dailyWindowId: aliasFor(windows, 1440, 120), + weeklyWindowId: aliasFor(windows, 10080, 720) }, - resetCredits: { available: availableCount }, - warnings: hasCapacity || reached ? [] : [{ + resetCredits: { available: finiteNumber(resetCredits?.availableCount) }, + usage: snapshot, + warnings: hasUsage || reached ? [] : [{ code: 'capacity-unavailable', message: 'Codex did not return authoritative capacity windows.' }] }; } -function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { +export function mapCodexRateLimits(raw: unknown, context: Pick): ProviderCapacity { + return capacityFromSnapshot(parseCliUsage(raw, context.checkedAt), context, raw); +} + +function jwtExpiry(token: string): number | null { + const part = token.split('.')[1]; + if (!part) return null; + try { + return finiteNumber(record(JSON.parse(Buffer.from(part, 'base64url').toString('utf8')))?.exp); + } catch { + return null; + } +} + +function staleOAuth(tokens: UnknownRecord, token: string, now: Date): boolean { + const metadata = tokens.expires_at ?? tokens.expiresAt ?? tokens.expiry; + let expiry: number | null = finiteNumber(metadata); + if (typeof metadata === 'string') { + const parsed = Date.parse(metadata); + expiry = Number.isNaN(parsed) ? null : parsed / 1000; + } + expiry ??= jwtExpiry(token); + return expiry !== null && expiry <= now.getTime() / 1000; +} + +async function fetchJson(fetcher: typeof globalThis.fetch, url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetcher(url, { ...init, signal: controller.signal }); + if (!response.ok) throw new Error(response.status === 401 ? 'unauthorized' : 'request failed'); + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +async function apiSnapshot( + token: string, + accountId: string, + source: 'pat' | 'oauth', + options: CodexProbeOptions +): Promise { + const fetcher = options.fetch ?? globalThis.fetch; + const raw = await fetchJson(fetcher, 'https://chatgpt.com/backend-api/wham/usage', { + headers: { Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': accountId } + }, options.timeoutMs ?? 5000); + return parseUsage(raw, source, options.checkedAt); +} + +function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { - const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] }); + const child = spawn('codex', CODEX_APP_SERVER_ARGS, { + stdio: ['pipe', 'pipe', 'ignore'] + }); + const results: Partial = {}; let buffer = ''; let settled = false; - const finish = (error?: Error, result?: unknown) => { + const finish = (error?: Error) => { if (settled) return; settled = true; clearTimeout(timer); child.kill(); if (error) reject(error); - else resolve(result); + else resolve(results as CliResponses); }; const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs); child.once('error', () => finish(new Error('codex app-server unavailable'))); - child.once('exit', code => { - if (!settled) finish(new Error(`codex app-server exited (${code ?? 'unknown'})`)); - }); + child.once('exit', () => { if (!settled) finish(new Error('codex app-server exited')); }); child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { buffer += chunk; @@ -158,52 +288,97 @@ function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { - if (!options.installed) { - return { - provider: 'codex', agentType: 'codex', configured: options.configured, installed: false, - authenticated: null, status: 'unavailable', available: 'unknown', plan: null, - checkedAt: options.checkedAt, source: 'none', windows: [], - aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, - warnings: [{ code: 'cli-not-installed', message: 'Codex CLI is not installed.' }] - }; - } +function unavailable(options: CodexProbeOptions, installed = options.installed): ProviderCapacity { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed, + authenticated: null, status: installed ? 'unknown' : 'unavailable', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ + code: installed ? 'probe-failed' : 'cli-not-installed', + message: installed ? 'Codex capacity could not be read safely.' : 'Codex CLI is not installed.' + }], + ...(installed ? { error: { code: 'codex-probe-failed', retryable: true } } : {}) + }; +} + +async function cliFallback(options: CodexProbeOptions): Promise { + if (!options.installed) return unavailable(options, false); const messages: RpcMessage[] = [ { id: 1, method: 'initialize', params: { clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null } }, { method: 'initialized' }, - { id: 2, method: 'account/rateLimits/read' } + { id: 2, method: 'account/rateLimits/read' }, + { id: 3, method: 'account/read' } ]; try { const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); - return mapCodexRateLimits(await rpc(messages), options); + const response = await rpc(messages); + const result = capacityFromSnapshot(parseCliUsage(response.rateLimits, options.checkedAt), options, response.rateLimits); + const accountEnvelope = record(response.account); + if (accountEnvelope && Object.hasOwn(accountEnvelope, 'account') && !record(accountEnvelope.account)) { + result.authenticated = false; + result.status = 'unauthenticated'; + result.available = 'unknown'; + } + return result; } catch { - return { - provider: 'codex', agentType: 'codex', configured: options.configured, installed: true, - authenticated: null, status: 'unknown', available: 'unknown', plan: null, - checkedAt: options.checkedAt, source: 'none', windows: [], - aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, - warnings: [{ code: 'probe-failed', message: 'Codex capacity could not be read safely.' }], - error: { code: 'codex-probe-failed', retryable: true } - }; + return unavailable(options); + } +} + +export async function probeCodexCapacity(options: CodexProbeOptions): Promise { + let parsed: UnknownRecord | null = null; + try { + const contents = await (options.readFile ?? readFile)(resolveCodexAuthPath(options.env), 'utf8'); + parsed = record(JSON.parse(contents)); + } catch { + return cliFallback(options); + } + + const auth = parsed ?? {}; + const pat = nonEmptyText(auth.personal_access_token); + if (pat) { + try { + const fetcher = options.fetch ?? globalThis.fetch; + const whoami = record(await fetchJson(fetcher, + 'https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami', + { headers: { Authorization: `Bearer ${pat}` } }, options.timeoutMs ?? 5000)); + const accountId = nonEmptyText(whoami?.chatgpt_account_id); + if (!accountId) throw new Error('account unavailable'); + return capacityFromSnapshot(await apiSnapshot(pat, accountId, 'pat', options), options); + } catch { + // Continue to a separately available OAuth credential before using the CLI. + } + } + + const tokens = record(auth.tokens); + const accessToken = nonEmptyText(tokens?.access_token); + const accountId = nonEmptyText(tokens?.account_id); + if (tokens && accessToken && accountId && !staleOAuth(tokens, accessToken, (options.now ?? (() => new Date()))())) { + try { + return capacityFromSnapshot(await apiSnapshot(accessToken, accountId, 'oauth', options), options); + } catch { + return cliFallback(options); + } } + return cliFallback(options); } diff --git a/packages/cli/src/commands/capacity/types.ts b/packages/cli/src/commands/capacity/types.ts index fbc0915f..42b621fc 100644 --- a/packages/cli/src/commands/capacity/types.ts +++ b/packages/cli/src/commands/capacity/types.ts @@ -12,6 +12,18 @@ export interface CapacityWindow { scope: string | null; } +export type CodexUsageSource = 'pat' | 'oauth' | 'cli'; + +export interface UsageSnapshot { + sessionLimit: CapacityWindow | null; + weeklyLimit: CapacityWindow | null; + creditsRemaining: number | null; + codexCreditLimit: number | null; + extraRateWindows: CapacityWindow[]; + source: CodexUsageSource; + updatedAt: string; +} + export interface ProviderCapacity { provider: string; agentType: string | null; @@ -26,6 +38,7 @@ export interface ProviderCapacity { windows: CapacityWindow[]; aliases: { dailyWindowId: string | null; weeklyWindowId: string | null }; resetCredits?: { available: number | null }; + usage?: UsageSnapshot; warnings: Array<{ code: string; message: string }>; error?: { code: string; retryable: boolean }; } From a69dc184537e675eff1d530bdec1df028852f49a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:02:46 +0000 Subject: [PATCH 11/12] docs(capacity): record tiered Codex rework --- .../2026-08-09-feature-capacity-command.md | 33 ++++++++----- .../2026-08-09-feature-capacity-command.md | 20 ++++++-- .../2026-08-09-feature-capacity-command.md | 12 ++++- .../2026-08-09-feature-capacity-command.md | 7 ++- .../2026-08-09-feature-capacity-command.md | 47 +++++++------------ 5 files changed, 70 insertions(+), 49 deletions(-) diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md index 994dd567..197fbc9f 100644 --- a/docs/ai/design/2026-08-09-feature-capacity-command.md +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -17,7 +17,9 @@ flowchart LR Orchestrator --> Claude[Claude adapter] Orchestrator --> Pi[Pi / GLM adapter] Orchestrator --> Stub[Unsupported-provider stub] - Codex --> AppServer[codex app-server] + Codex --> AuthFile[Codex auth.json] + AuthFile --> UsageAPI[whoami / wham usage] + AuthFile --> AppServer[read-only app-server fallback] Claude --> AuthStatus[claude auth status] Pi --> PiAuth[Pi auth provider names] Orchestrator --> Report[CapacityReport v1] @@ -104,16 +106,24 @@ type CapacityReport = { ```mermaid sequenceDiagram participant C as capacity - participant A as codex app-server --stdio - C->>A: initialize(clientInfo, capabilities=null) - A-->>C: initialize result - C->>A: initialized - C->>A: account/rateLimits/read - A-->>C: rateLimits + buckets + reset-credit summary - C->>C: sanitize, normalize, deduplicate, derive aliases + participant F as auth.json + participant H as OpenAI/ChatGPT usage API + participant A as read-only codex app-server + C->>F: read CODEX_HOME or ~/.codex + alt personal_access_token + C->>H: whoami, then wham/usage + else fresh OAuth token + C->>H: wham/usage + else missing/stale/failed credentials + C->>A: initialize + C->>A: account/rateLimits/read + account/read + end + C->>C: normalize into UsageSnapshot ``` -The JSON-line transport is injectable in tests. It ignores stderr, bounds execution with a timeout, kills the child after completion, and exposes only normalized fields. It never invokes `turn/start`, `codex exec`, or another model method. The mapper supports the current `rateLimitResetCredits` field plus the older compatibility name, reports `availableCount`, and has no consume/redeem operation. +The adapter resolves `CODEX_HOME/auth.json` before the home-directory fallback. A PAT performs `whoami` to obtain the account ID and then reads `wham/usage`; a fresh OAuth access token uses its stored account ID directly. Stale tokens and 401s fall back without refresh. API calls are bounded and normalize session, weekly, credit balance, the individual-limit fallback chain, and additional limits. + +The JSON-line fallback transport is injectable in tests. It launches `codex -s read-only -a untrusted app-server`, ignores stderr, bounds execution, and reads both rate limits and account state. It never invokes a model method. Missing limits produce unknown availability rather than zero usage. ### Claude @@ -138,7 +148,8 @@ Configured providers without an authoritative adapter use the common stub. The s ## Security and Reliability Decisions -- Provider CLIs own OAuth/session authentication; secrets are not passed on command lines. +- Codex owns OAuth refresh; AI DevKit only reads the current token and never persists or refreshes it. +- Tokens and raw `auth.json` content are never logged, cached, or included in errors. - Output and cache contain normalized allowlisted data, not raw responses. - Codex identifiers, labels, and plan metadata are validated and credential/account-like values are rejected. - Claude plan metadata is similarly constrained. @@ -148,7 +159,7 @@ Configured providers without an authoritative adapter use the common stub. The s ## Alternatives Rejected -- Direct private HTTP calls: excessive credential exposure and undocumented coupling. +- Direct OAuth refresh: rejected because AI DevKit does not own the credential lifecycle. - TUI scraping: brittle and capable of accidentally starting model activity. - Local token-history estimation: not authoritative for subscription limits. - Forced daily/weekly schema: loses provider-native rolling and scoped windows. diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index 41cb235a..8ba623b5 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -43,20 +43,29 @@ capacity [provider] [--json] [--max-age ] [--refresh] - `orchestrate.ts`: validates provider names, selects configured providers by default, runs probes concurrently, isolates failures/timeouts, reads/writes cache, sorts rows, and constructs the report. - `cache.ts`: reads freshness-keyed normalized reports and performs atomic restrictive writes under `~/.ai-devkit/cache/capacity.json` (`0700` directory, `0600` file). - `render.ts`: emits exact pretty JSON or a text table with Auth, Available, shortest/longest native windows, reset credits, and warnings. -- `providers/codex.ts`: drives app-server JSON-RPC and sanitizes/normalizes rate-limit snapshots. +- `providers/codex.ts`: resolves Codex auth, drives tiered API/CLI reads, and sanitizes normalized usage snapshots. - `providers/claude.ts`: invokes and safely parses `claude auth status --json`; does not fetch live quota. - `providers/pi.ts`: reads only Pi auth provider names and derives Pi/GLM authentication. - `providers/stub.ts`: builds truthful unsupported/unknown rows for providers without adapters. -## Codex JSON-RPC Client +## Tiered Codex Provider -The adapter spawns `codex app-server --stdio` with piped stdin/stdout and ignored stderr. It writes newline-delimited JSON: +The adapter reads `CODEX_HOME/auth.json` when configured, otherwise `~/.codex/auth.json`, and selects exactly one starting tier: + +1. `personal_access_token`: call `whoami`, then `wham/usage` with the returned account ID. +2. Fresh `tokens.access_token`: call `wham/usage` with `tokens.account_id`. +3. Missing, stale, unauthorized, or failed direct credentials: use the CLI fallback without refreshing OAuth. + +API responses become `UsageSnapshot` values containing session/weekly windows, credit balance, the three-step individual-limit fallback, additional rate limits, source, and update time. Missing windows remain nullable and keep availability unknown. + +The CLI fallback spawns `codex -s read-only -a untrusted app-server` with piped stdin/stdout and ignored stderr. It writes newline-delimited JSON: 1. `initialize` with `clientInfo` and `capabilities: null`. 2. After response id 1, `initialized`. 3. `account/rateLimits/read` with request id 2 and no parameters. +4. `account/read` with request id 3 and no parameters. -Response id 2 is normalized and the subprocess is terminated. A five-second adapter timer bounds the exchange. The transport function is injectable, so CI tests use no subprocess or network. +After responses 2 and 3 arrive, the rate limits and authentication state are normalized and the subprocess is terminated. A five-second adapter timer bounds the exchange. The transport function is injectable, so CI tests use no subprocess or network. Mapping behavior: @@ -87,7 +96,8 @@ Only authoritative Codex utilization can establish `available: yes`. Claude, Pi, - No tokens, account IDs, refresh tokens, endpoint URLs, headers, raw bodies, stderr, or raw exception messages are emitted or cached. - No credential is placed on a subprocess command line. -- Codex authentication and refresh remain inside Codex app-server. +- Codex OAuth refresh remains exclusively owned by Codex; this command never refreshes or writes credentials. +- PATs, access/refresh tokens, and raw auth-file content never enter normalized output or errors. - Codex labels, IDs, scopes, and plans are constrained before output; Claude plan metadata is constrained too. - Pi credential values are parsed only to discover top-level provider names and are never retained in normalized output. - Cache contains only normalized report data with restrictive permissions. diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md index 521034f9..8481c376 100644 --- a/docs/ai/planning/2026-08-09-feature-capacity-command.md +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -44,13 +44,23 @@ All tasks are complete. The list reflects execution order and the pushed commit - Outcome: accept bounded JSON stdout from Claude's expected nonzero logged-out exit and use a six-second adapter timeout under the seven-second orchestrator guard. - Validation: mocked nonzero behavior plus live `authenticated: false` classification. +## Milestone 5: Tiered Codex Rework + +- [x] Rebase the feature onto current `origin/main`. +- [x] Add auth-file resolution and PAT/OAuth/CLI tier selection under TDD. +- [x] Normalize API usage into `UsageSnapshot`, including credit-limit fallbacks and additional windows. +- [x] Harden the CLI fallback with read-only/untrusted flags and both account reads. +- [x] Prove stale/401 fallback, unavailable semantics, and token redaction with mocked boundaries. +- [x] Run clean install, build, lifecycle lint, full repository lint/tests, and E2E tests. +- [ ] Publish the reworked branch and update PR #147. + ## Dependencies and Sequencing 1. Types and detection established the provider/report contract. 2. Provider adapters normalized into that contract. 3. Orchestration composed adapters and added cache/timeout behavior. 4. CLI/rendering exposed the report. -5. Full tests and real read-only probes drove protocol/security fixes. +5. Fully mocked network/subprocess tests drove the tiered protocol and security fixes. Runtime dependencies are Node.js, Commander, provider CLIs already installed by the user, and the existing AI DevKit environment definitions. No new package dependency or migration was introduced. diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md index 7bf54c93..c3d62660 100644 --- a/docs/ai/requirements/2026-08-09-feature-capacity-command.md +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -32,7 +32,7 @@ The `capacity` command gives human operators, the agent-management workflow, par - Multiple accounts per provider. - Automatic reset-credit redemption. - A first-party live quota adapter for every AI DevKit environment. -- Direct use of undocumented provider credentials or private endpoints. +- OAuth token refresh or any mutation of provider-owned credentials. ## User Stories @@ -61,7 +61,9 @@ The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown provid - JSON uses `schemaVersion: 1` and the shipped `CapacityReport` contract. - Canonical capacity is `CapacityWindow[]`; daily and weekly aliases are conveniences derived from duration. - Missing data produces `available: "unknown"`; only explicit provider exhaustion/blocking produces `"no"`. -- Codex uses `codex app-server --stdio` with `initialize`, `initialized`, then `account/rateLimits/read`; no model-turn method is called. +- Codex resolves `CODEX_HOME/auth.json` (or `~/.codex/auth.json`) and prefers PAT, then fresh OAuth, then a hardened CLI fallback. +- PAT uses authenticated `whoami` followed by `wham/usage`; OAuth calls `wham/usage` with its account ID and falls back on stale/401 responses. +- The CLI fallback runs `codex -s read-only -a untrusted app-server`, then reads both `account/rateLimits/read` and `account/read`; no model-turn method is called. - Claude uses `claude auth status --json`; unsafe undocumented live usage is not called. - Pi and GLM authentication may be detected, but their authoritative capacity remains unknown. - Other configured providers are represented as unsupported with unknown availability. @@ -77,6 +79,7 @@ The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown provid - `unknown` is never equivalent to `yes`. - Authentication stays owned by provider CLIs wherever possible. - Capacity checking must not consume model quota. +- AI DevKit never refreshes Codex OAuth credentials and never emits auth-file contents or token-bearing errors. - Reset credits are report-only and are never redeemed. - The implementation remains local-first and self-host friendly. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index c8c29a47..0a520100 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -19,6 +19,13 @@ The feature was built with red-green-refactor cycles. Pure mapping and detection ### `codex.test.ts` +- [x] Resolve `CODEX_HOME/auth.json`, home fallback, and missing-file CLI fallback. +- [x] Select PAT before OAuth and exercise PAT `whoami` plus usage calls. +- [x] Exercise fresh OAuth usage plus stale-token and 401 CLI fallback. +- [x] Mock every network and subprocess boundary. +- [x] Map API session/weekly windows, reset timestamps, credit balance, individual-limit fallback chain, and additional limits. +- [x] Launch the fallback contract with read-only/untrusted flags and both account reads. +- [x] Assert PAT, access-token, refresh-token, and raw transport failures never appear in output. - [x] Normalize primary, secondary, and multi-bucket arbitrary windows. - [x] Derive daily/weekly aliases by duration and report unredeemed reset-credit counts. - [x] Deduplicate the compatibility `rateLimits` view against `rateLimitsByLimitId`. @@ -58,40 +65,20 @@ The response fixture is synthetic and redacted; it contains no real account data - [x] Exercise Commander wiring with an injected report reader; no live adapter is called. - [x] Reject invalid max-age values before probing. -## Coverage - -The full CLI coverage run passed repository thresholds: - -- Statements: 71.47% -- Branches: 62.04% -- Functions: 70.06% -- Lines: 72.77% -- Capacity core modules: 80.59% statements and 85.98% lines - -The lower direct coverage in the default Codex transport is intentional: CI tests the injected protocol contract and mapper rather than spawning a real authenticated provider process. - ## Fresh Final Gates | Gate | Result | |---|---| -| `cd packages/cli && npm run lint` | Exit 0; five pre-existing warnings, zero errors | -| `cd packages/cli && npm test` | 85 test files, 953 tests passed | -| `cd packages/cli && npm run build` | Exit 0; 207 files compiled | -| `cd packages/cli && npm run test:coverage` | Exit 0; repository thresholds passed | -| PR #147 CI | 7/7 checks green | - -## Real-Run Smoke Results - -The built CLI was run on the development machine with configured Claude, Codex, and Pi/z.ai state: - -- [x] `capacity --json --refresh` returned only configured providers: Claude, Codex, Pi, and GLM-through-Pi. -- [x] Codex app-server returned a live authoritative 10,080-minute window and reset-credit count through `account/rateLimits/read`. -- [x] The request sequence contained no model turn and no reset-credit consume operation. -- [x] Claude logged-out state normalized to `authenticated: false`, `status: unauthenticated`, and `available: unknown`. -- [x] Pi and GLM normalized to authenticated but unsupported/unknown. -- [x] Output and test scans contained no tokens, account IDs, endpoint bodies, headers, or credential values. -- [x] `capacity --max-age=-1` exited 1 with a validation error. -- [x] Existing `agent list --json` exited 0, confirming the adjacent command remained functional. +| `npm ci` | Exit 0 | +| `npm run build` | Exit 0; six projects built, 217 CLI files compiled | +| `npm run lint` | Exit 0; six pre-existing warnings, zero errors | +| `npm run test` | Exit 0; 145 test files, 1,961 tests passed | +| `npm run test:e2e` | Exit 0; 41 tests passed | +| `npx ai-devkit@latest lint --feature capacity-command` | Exit 0; one branch-name warning | + +## Isolation Policy + +The rework deliberately performs no live credential, network, or app-server smoke test. All HTTP responses, auth-file reads, and subprocess protocol responses are synthetic and mocked so verification cannot consume quota or expose local credentials. ## Regression Policy From 2558c88d9badfa567494666f8da53ece5015174e Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:07:08 +0000 Subject: [PATCH 12/12] docs(capacity): close rework milestone --- docs/ai/planning/2026-08-09-feature-capacity-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md index 8481c376..23cd073f 100644 --- a/docs/ai/planning/2026-08-09-feature-capacity-command.md +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -52,7 +52,7 @@ All tasks are complete. The list reflects execution order and the pushed commit - [x] Harden the CLI fallback with read-only/untrusted flags and both account reads. - [x] Prove stale/401 fallback, unavailable semantics, and token redaction with mocked boundaries. - [x] Run clean install, build, lifecycle lint, full repository lint/tests, and E2E tests. -- [ ] Publish the reworked branch and update PR #147. +- [x] Publish the reworked branch and update PR #147 with the tiered-flow Rework section. ## Dependencies and Sequencing