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..c33b7ab5 --- /dev/null +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -0,0 +1,53 @@ +--- +phase: design +title: Capacity Command Design +description: Thin CLI over a Codex capacity model owned by agent-manager +--- + +# Capacity Command Design + +## Architecture + +```mermaid +flowchart LR + CLI[CLI registration and validation] --> Manager[agent-manager getCodexCapacityReport] + Manager --> Detect[Codex config and PATH detection] + Manager --> Probe[PAT then OAuth then read-only app-server] + Probe --> Normalize[CapacityReport v1] + Normalize --> Render[CLI text or JSON rendering] +``` + +`packages/agent-manager/src/capacity/` is the domain boundary. `types.ts` defines normalized output, `codex.ts` owns credential-safe probing and mapping, and `index.ts` detects Codex, calls the probe once, redacts unexpected failures, and builds the report. The root package export exposes the report function and types. + +`packages/cli/src/commands/capacity.ts` registers `capacity [provider]`, validates that an explicit provider is `codex`, calls agent-manager, and delegates rendering. `capacity/render.ts` contains presentation only. + +## Fresh Probe Flow + +Each invocation checks `~/.codex` and PATH, then probes once. No filesystem cache, freshness key, TTL, bypass option, multi-provider selection, parallel grouping, or orchestration timeout exists. + +The Codex probe remains tiered: + +1. Resolve `CODEX_HOME/auth.json`, falling back to `~/.codex/auth.json`. +2. If a PAT exists, use `whoami` then the usage endpoint. +3. Otherwise use a fresh OAuth token and stored account ID. +4. On missing, stale, unauthorized, or failed credentials, run `codex -s read-only -a untrusted app-server` and call only `account/rateLimits/read` and `account/read`. + +Network and app-server calls remain bounded inside the probe. Results are normalized into schema v1; raw inputs and exceptions are never returned. + +## Simplification Decisions + +| Opportunity | Decision | Reason | +|---|---|---| +| Remove normalized cache and cache tests | Acted | Every run must be fresh; TTL, permissions, keying, atomic writes, and bypass paths no longer serve behavior. | +| Remove `--max-age` and `--refresh` | Acted | They only controlled the removed cache. | +| Remove Claude, Pi, GLM, and generic stubs/tests | Acted | Codex is the only supported capacity provider. | +| Replace provider registry and configured-provider scan | Acted | A direct Codex config/PATH check is clearer than generic mappings for one provider. | +| Remove parallel orchestration, provider arrays, sorting, and outer timeout | Acted | One probe has no concurrency or partial-result problem; probe boundaries already time out. | +| Move model/probe/types into agent-manager | Acted | Capacity informs agent dispatch and is reusable independently of CLI presentation. | +| Flatten the report to the minimal JSON shape (provider, generatedAt, authenticated, available, windows, creditsRemaining) | Acted | Owner decision before merge: the unmerged contract carried multi-provider-era fields (schemaVersion, providers[], status, configured, installed, agentType, plan, checkedAt, source, aliases, resetCredits wrapper, usage snapshot, warnings, error) with no current consumer; fields can return when a second provider lands. | +| Drop derived fields (`remainingPercent`, aliases) and the credit-limit fallback chain | Acted | Derivable from `usedPercent`/`durationMinutes` by consumers; the chain fed only removed output fields. | +| Collapse PAT, OAuth, and CLI probing to app-server only | Rejected | The fallbacks have distinct availability/authentication value and preserve credential-safe behavior. | +| Merge renderer into command | Rejected | Rendering has separate behavior and tests; keeping it isolated makes the CLI flow linear. | +| Add a new package dependency/helper library | Rejected | Node APIs and the existing agent-manager dependency are sufficient. | + +All acted changes pass the readability guide's Reading Test: the command path is linear, names are explicit, functions stay at one abstraction level, and no speculative abstraction remains. 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..446f750c --- /dev/null +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -0,0 +1,52 @@ +--- +phase: implementation +title: Capacity Command Implementation Record +description: Codex-only capacity model and thin CLI integration +--- + +# Capacity Command Implementation Record + +## Module Map + +```text +packages/agent-manager/src/ +├── capacity/ +│ ├── index.ts # detection, one fresh probe, report construction +│ ├── codex.ts # PAT/OAuth/app-server probing and normalization +│ └── types.ts # capacity report model +└── __tests__/capacity/ + ├── index.test.ts + └── codex.test.ts + +packages/cli/src/commands/ +├── capacity.ts # Commander registration, provider validation, manager call +└── capacity/render.ts # human and JSON presentation +``` + +Agent-manager's root `index.ts` exports `getCodexCapacityReport` and the public capacity types. No package dependency was added because the CLI already depends on `@ai-devkit/agent-manager`. + +## Runtime Behavior + +`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks installation, invokes the Codex probe on every call, catches unexpected probe failures into a fixed unknown result, and returns one flat report: provider, generatedAt, authenticated, available, native windows, creditsRemaining. + +The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, keeps unknown values null, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. + +## Removed Implementation + +- `capacity/cache.ts` and its cache test. +- `capacity/detection.ts` generic environment discovery and its test. +- `capacity/orchestrate.ts` provider selection, grouping, sorting, caching, dependency graph, and its tests. +- Claude, Pi/GLM, and unsupported stub providers plus provider tests. +- CLI max-age parsing and refresh forwarding. + +## Simplification Review + +The complete opportunity ledger is in the design document. Acted changes remove unused feature surface and abstractions. Rejected changes retain the stable JSON contract, meaningful tiered probing, normalized usage details, and isolated rendering because deleting them would reduce behavior or clarity rather than complexity. + +## Security Invariants + +- Only normalized allowlisted data crosses the agent-manager boundary. +- PATs, access/refresh tokens, account IDs, headers, bodies, stderr, and raw exceptions are not emitted. +- The CLI fallback uses read-only/untrusted app-server flags and account-only methods. +- Missing or failed data remains unknown; reset credits are never redeemed. +- Every run is read-only and fresh, with no AI DevKit capacity cache writes. 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..41ab724f --- /dev/null +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -0,0 +1,38 @@ +--- +phase: planning +title: Capacity Command Simplification Plan +description: Completed plan for a fresh Codex-only capacity command +--- + +# Capacity Command Simplification Plan + +## Completed Tasks + +- [x] Remove cache implementation, cache tests, cache calls, `--max-age`, and `--refresh`. +- [x] Remove Claude, Pi, GLM, unsupported-provider adapters, and their tests. +- [x] Replace generic provider detection and multi-provider orchestration with one fresh Codex report function. +- [x] Move Codex probing, normalization, types, and report construction to `@ai-devkit/agent-manager` using `src/capacity/` and `src/__tests__/capacity/` conventions. +- [x] Export the capacity API and types from agent-manager's root entry point. +- [x] Reduce CLI integration to registration, Codex argument validation, one agent-manager call, and rendering. +- [x] Relocate behavioral tests to the owning workspace and remove tests whose only behavior was deleted. +- [x] Update CLI README and all 2026-08-09 lifecycle documents. + +## Order and Dependencies + +1. Preserve the normalized contract while moving it and the Codex probe. +2. Add the agent-manager report boundary and tests. +3. Switch the CLI to that boundary. +4. Delete superseded provider/cache/orchestration modules and tests. +5. Update lifecycle records, then run build and test validation. + +## Risk Controls + +- Root agent-manager exports preserve one supported import path. +- Probe exceptions become fixed normalized failures; raw provider details remain redacted. +- Existing mocked PAT/OAuth/app-server tests move with the domain code. +- Commander tests prove non-Codex rejection and the absence of cache-option forwarding. +- Full workspace build/tests catch package-boundary and declaration-generation errors. + +## Deferred Scope + +Future providers should be added only with a verified, read-only capacity mechanism and a concrete product requirement. Do not restore generic provider scaffolding or caching speculatively. 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..1db52340 --- /dev/null +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -0,0 +1,46 @@ +--- +phase: requirements +title: Capacity Command Requirements +description: Define fresh, read-only Codex capacity reporting +--- + +# Capacity Command Requirements + +## Problem + +Codex users need a factual capacity report before dispatching work. The command must obtain current data without starting a model turn, exposing credentials, or carrying provider and cache machinery that has no supported use. + +## Command Surface + +```text +ai-devkit capacity +ai-devkit capacity codex +ai-devkit capacity [codex] --json +``` + +The optional provider argument exists for discoverability and accepts only `codex`, case-insensitively. Any other value fails before probing. Every invocation probes fresh; there is no cache, `--max-age`, or `--refresh` option. + +## Acceptance Criteria + +- Capacity supports Codex only and always emits exactly one Codex row. +- `@ai-devkit/agent-manager` owns probing, normalization, detection, and public capacity types. +- The CLI owns only command registration, provider validation, the agent-manager call, and text/JSON rendering. +- JSON reports the provider, generation time, authentication, availability, native usage windows (`id`, `label`, `durationMinutes`, `usedPercent`, `resetsAt`), and remaining credits in one flat object. Derived values and provider-internals are omitted; fields may be added when a second provider lands. +- Codex configuration and executable presence are reported independently. +- Probing prefers PAT, then fresh OAuth, then the hardened read-only Codex app-server fallback. +- Missing data is `unknown`, never inferred as available; explicit exhaustion may report `no`. +- Probing never starts a model turn, refreshes credentials, writes provider data, or exposes secrets/raw failures. +- Existing meaningful normalization, fallback, redaction, rendering, and command-contract tests remain covered in their owning packages. + +## Non-Goals + +- Claude, Pi, GLM, generic provider stubs, or future-provider scaffolding. +- Cross-provider selection, parallel orchestration, partial multi-provider results, or scheduling policy. +- Cached or historical capacity, forecasting, cost prediction, token-history estimation, or reset-credit redemption. +- OAuth refresh, TUI scraping, or inference-based probes. + +## Constraints + +- Keep the schema stable where it still describes Codex truthfully. +- Use provider-owned credentials read-only and discard raw exception details. +- Do not add a dependency: the CLI already depends on agent-manager. 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..55554940 --- /dev/null +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -0,0 +1,40 @@ +--- +phase: testing +title: Capacity Command Test Record +description: Coverage and validation for the Codex-only implementation +--- + +# Capacity Command Test Record + +## Agent-manager Capacity Coverage + +- [x] Resolve `CODEX_HOME` before the home fallback. +- [x] Normalize API and CLI windows without converting missing values to zero. +- [x] Preserve session, weekly, credit, individual-limit, and additional-window data. +- [x] Prefer PAT, then fresh OAuth, then CLI; fall back on stale credentials, 401s, and request failures. +- [x] Use read-only/untrusted app-server arguments and account-only methods. +- [x] Distinguish logged-out account state and keep unknown/unavailable semantics. +- [x] Prevent token and raw failure leakage. +- [x] Detect Codex configuration and installation independently before probing. +- [x] Build exactly one Codex report and redact unexpected probe failures. + +## CLI Coverage + +- [x] Render the JSON report exactly. +- [x] Render human headers, windows, and credits. +- [x] Accept omitted provider and `codex`, forwarding no cache options. +- [x] Reject non-Codex providers before probing. + +## Removed Coverage + +Cache freshness/permissions, generic provider detection, parallel/partial multi-provider orchestration, and Claude/Pi/stub tests were removed with their behavior. They provided no unique coverage of the simplified contract. + +## Required Fresh Validation + +- `npm ci` only if `node_modules` is absent. +- `npm run build` at repository root. +- `npm test --workspace=@ai-devkit/agent-manager`. +- `npm test --workspace=ai-devkit`. +- `npm test` for the complete repository suite. + +Final command output and pass/fail counts are recorded in the implementation handoff for this uncommitted worktree change. diff --git a/packages/agent-manager/src/__tests__/capacity/codex.test.ts b/packages/agent-manager/src/__tests__/capacity/codex.test.ts new file mode 100644 index 00000000..bf2494de --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/codex.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CODEX_APP_SERVER_ARGS, + parseUsage, + probeCodexCapacity, + resolveCodexAuthPath, + toRateWindow +} from '../../capacity/codex.js'; + +const checkedAt = '2026-08-20T10:00:00.000Z'; +const context = { installed: true, checkedAt }; + +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 }, + 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('falls back to ~/.codex/auth.json', () => { + expect(resolveCodexAuthPath({ HOME: '/users/test' })).toBe('/users/test/.codex/auth.json'); + }); +}); + +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, + resetsAt: '2026-08-20T10:00:00.000Z' + }); + expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: null }); + }); + + it('maps session, weekly, credits, and extra limits', () => { + const snapshot = parseUsage(apiUsage(), 'pat'); + expect(snapshot).toMatchObject({ source: 'pat', creditsRemaining: 12.5 }); + expect(snapshot.windows).toEqual([ + expect.objectContaining({ id: 'session', durationMinutes: 300 }), + expect.objectContaining({ id: 'weekly', durationMinutes: 10080 }), + expect.objectContaining({ id: 'reviews:primary', durationMinutes: 60 }) + ]); + }); + + it('represents missing limits as unavailable rather than zero', () => { + const snapshot = parseUsage({ credits: {} }, 'oauth'); + expect(snapshot.windows).toEqual([]); + expect(snapshot.creditsRemaining).toBeNull(); + }); +}); + +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({ provider: 'codex', available: 'yes', creditsRemaining: 12.5, authenticated: true }); + expect(result.windows.map(window => window.id)).toEqual(['session', 'weekly', 'reviews:primary']); + }); + + 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.available).toBe('yes'); + }); + + 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: { 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.windows).toHaveLength(1); + }); + + it('falls back to CLI if PAT requests fail', async () => { + const rpc = vi.fn(async () => ({ rateLimits: {}, account: { account: null } })); + const result = await probeCodexCapacity({ + ...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.available).toBe('yes'); + 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', 'account/read' + ]); + expect(JSON.stringify(messages)).not.toMatch(/prompt|turn\/start/); + expect(CODEX_APP_SERVER_ARGS).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']); + }); + + it('uses account/read to distinguish logged-out CLI state', async () => { + const result = await probeCodexCapacity({ + ...context, + readFile: async () => '{}', + rpc: async () => ({ rateLimits: {}, account: { account: null } }) + }); + expect(result).toMatchObject({ authenticated: false, available: 'unknown' }); + }); + + 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/agent-manager/src/__tests__/capacity/index.test.ts b/packages/agent-manager/src/__tests__/capacity/index.test.ts new file mode 100644 index 00000000..d0ece7ce --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCodexCapacityReport } from '../../capacity/index.js'; + +const checkedAt = '2026-08-09T10:00:00.000Z'; + +describe('getCodexCapacityReport', () => { + it('checks Codex installation before probing', async () => { + const probe = vi.fn(async context => ({ + provider: 'codex', generatedAt: context.checkedAt, + authenticated: true, available: 'yes' as const, windows: [], creditsRemaining: null + })); + + const report = await getCodexCapacityReport({ + now: () => new Date(checkedAt), + path: '/usr/bin:/opt/bin', + access: async target => { + if (target !== '/opt/bin/codex') throw new Error('missing'); + }, + probe + }); + + expect(probe).toHaveBeenCalledWith({ installed: true, checkedAt }); + expect(report).toMatchObject({ provider: 'codex', generatedAt: checkedAt, available: 'yes' }); + }); + + it('redacts unexpected probe failures into a stable unknown result', async () => { + const report = await getCodexCapacityReport({ + now: () => new Date(checkedAt), + path: '', + probe: async () => { throw new Error('private provider response'); } + }); + + expect(report).toMatchObject({ + provider: 'codex', available: 'unknown', authenticated: null, windows: [] + }); + expect(JSON.stringify(report)).not.toContain('private provider response'); + }); +}); diff --git a/packages/agent-manager/src/capacity/codex.ts b/packages/agent-manager/src/capacity/codex.ts new file mode 100644 index 00000000..47675afe --- /dev/null +++ b/packages/agent-manager/src/capacity/codex.ts @@ -0,0 +1,320 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CapacityReport, CapacityWindow } from './types.js'; + +type CodexUsageSource = 'pat' | 'oauth' | 'cli'; +type UsageSnapshot = { windows: CapacityWindow[]; creditsRemaining: number | null; source: CodexUsageSource }; + +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 CodexProbeOptions = { + installed: boolean; + checkedAt: string; + 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 { + 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 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 = 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; +} + +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 +): 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, + resetsAt: resetTime(input.reset_at) + }; +} + +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`), + toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`) + ].filter((window): window is CapacityWindow => window !== null); + }); +} + +export function parseUsage(raw: unknown, source: 'pat' | 'oauth'): UsageSnapshot { + const response = record(raw) ?? {}; + const limits = record(response.rate_limit) ?? {}; + const credits = record(response.credits) ?? {}; + return { + windows: [ + toRateWindow(limits.primary_window, 'session', 'Session'), + toRateWindow(limits.secondary_window, 'weekly', 'Weekly'), + ...extraWindows(response.additional_rate_limits) + ].filter((window): window is CapacityWindow => window !== null), + creditsRemaining: finiteNumber(credits.balance), + source + }; +} + +function cliWindow(value: unknown, id: string, label: string): CapacityWindow | null { + const input = record(value); + if (!input) return null; + return { + id, + label, + durationMinutes: finiteNumber(input.windowDurationMins), + usedPercent: finiteNumber(input.usedPercent), + resetsAt: resetTime(input.resetsAt) + }; +} + +function cliSnapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { + const snapshot = record(value); + if (!snapshot) return []; + const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex'; + return [ + cliWindow(snapshot.primary, `${scope}:primary`, `${scope} primary`), + cliWindow(snapshot.secondary, `${scope}:secondary`, `${scope} secondary`) + ].filter((item): item is CapacityWindow => item !== null); +} + +export function parseCliUsage(raw: unknown): UsageSnapshot { + const response = record(raw) ?? {}; + const primary = record(response.rateLimits); + const windows = primary ? 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 { windows: unique, creditsRemaining: null, source: 'cli' }; +} + +function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): CapacityReport { + const hasUsage = snapshot.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', + generatedAt: context.checkedAt, + authenticated: true, + available: reached ? 'no' : hasUsage ? 'yes' : 'unknown', + windows: snapshot.windows, + creditsRemaining: snapshot.creditsRemaining ?? finiteNumber(resetCredits?.availableCount) + }; +} + +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); +} + +function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('codex', CODEX_APP_SERVER_ARGS, { + stdio: ['pipe', 'pipe', 'ignore'] + }); + const results: Partial = {}; + let buffer = ''; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + if (error) reject(error); + 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', () => { if (!settled) finish(new Error('codex app-server exited')); }); + 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`); + } else if (message.id === 2) { + if (message.error) finish(new Error('codex rate-limit method failed')); + else results.rateLimits = message.result; + } else if (message.id === 3) { + if (message.error) finish(new Error('codex account method failed')); + else results.account = message.result; + } + if ('rateLimits' in results && 'account' in results) finish(); + } + }); + child.stdin.write(`${JSON.stringify(messages[0])}\n`); + }); +} + +function unavailable(options: CodexProbeOptions): CapacityReport { + return { + provider: 'codex', + generatedAt: options.checkedAt, + authenticated: null, + available: 'unknown', + windows: [], + creditsRemaining: null + }; +} + +async function cliFallback(options: CodexProbeOptions): Promise { + if (!options.installed) return unavailable(options); + 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: 3, method: 'account/read' } + ]; + try { + const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); + const response = await rpc(messages); + const result = capacityFromSnapshot(parseCliUsage(response.rateLimits), options, response.rateLimits); + const accountEnvelope = record(response.account); + if (accountEnvelope && Object.hasOwn(accountEnvelope, 'account') && !record(accountEnvelope.account)) { + result.authenticated = false; + result.available = 'unknown'; + } + return result; + } catch { + 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/agent-manager/src/capacity/index.ts b/packages/agent-manager/src/capacity/index.ts new file mode 100644 index 00000000..5ac73554 --- /dev/null +++ b/packages/agent-manager/src/capacity/index.ts @@ -0,0 +1,57 @@ +import { constants } from 'node:fs'; +import { access as fsAccess } from 'node:fs/promises'; +import path from 'node:path'; +import { probeCodexCapacity } from './codex.js'; +import type { CapacityReport } from './types.js'; + +export type { CapacityReport, CapacityWindow } from './types.js'; + +export type CapacityProbeOptions = { + now?: () => Date; + path?: string; + access?: (target: string) => Promise; + probe?: typeof probeCodexCapacity; +}; + +async function canAccess(target: string, mode: number): Promise { + try { + await fsAccess(target, mode); + return true; + } catch { + return false; + } +} + +async function isCodexInstalled(pathValue: string, checkAccess?: (target: string) => Promise): Promise { + const directories = pathValue.split(path.delimiter).filter(Boolean); + for (const directory of directories) { + const executable = path.join(directory, 'codex'); + if (checkAccess) { + try { + await checkAccess(executable); + return true; + } catch { + continue; + } + } + if (await canAccess(executable, constants.X_OK)) return true; + } + return false; +} + +export async function getCodexCapacityReport(options: CapacityProbeOptions = {}): Promise { + const generatedAt = (options.now?.() ?? new Date()).toISOString(); + const installed = await isCodexInstalled(options.path ?? process.env.PATH ?? '', options.access); + try { + return await (options.probe ?? probeCodexCapacity)({ installed, checkedAt: generatedAt }); + } catch { + return { + provider: 'codex', + generatedAt, + authenticated: null, + available: 'unknown', + windows: [], + creditsRemaining: null + }; + } +} diff --git a/packages/agent-manager/src/capacity/types.ts b/packages/agent-manager/src/capacity/types.ts new file mode 100644 index 00000000..f5a2cc3b --- /dev/null +++ b/packages/agent-manager/src/capacity/types.ts @@ -0,0 +1,18 @@ +export type Availability = 'yes' | 'no' | 'unknown'; + +export interface CapacityWindow { + id: string; + label: string; + durationMinutes: number | null; + usedPercent: number | null; + resetsAt: string | null; +} + +export interface CapacityReport { + provider: string; + generatedAt: string; + authenticated: boolean | null; + available: Availability; + windows: CapacityWindow[]; + creditsRemaining: number | null; +} diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eef400cb..eccf4441 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -1,4 +1,10 @@ export { AgentManager, AgentNotRunningError } from './AgentManager.js'; +export { getCodexCapacityReport } from './capacity/index.js'; +export type { + CapacityProbeOptions, + CapacityReport, + CapacityWindow, +} from './capacity/index.js'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; export { CodexAdapter } from './adapters/CodexAdapter.js'; diff --git a/packages/cli/README.md b/packages/cli/README.md index f6dd07bf..95cb17ac 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 +# Probe current AI provider capacity (currently Codex) +ai-devkit capacity + +# Emit the JSON report +ai-devkit capacity codex --json + # 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..28a1e96f --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -0,0 +1,66 @@ +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 '@ai-devkit/agent-manager'; +import { ui } from '../../../util/terminal-ui.js'; + +vi.mock('../../../util/terminal-ui.js', () => ({ + ui: { text: vi.fn(), table: vi.fn(), warning: vi.fn(), breakline: vi.fn() }, +})); + +const report: CapacityReport = { + provider: 'codex', + generatedAt: '2026-08-09T10:00:00.000Z', + authenticated: true, + available: 'yes', + windows: [ + { id: 'session', label: 'Session', durationMinutes: 300, usedPercent: 20, + resetsAt: '2026-08-09T12:00:00.000Z' }, + { id: 'weekly', label: 'Weekly', durationMinutes: 10080, usedPercent: 60, + resetsAt: '2026-08-16T10:00:00.000Z' } + ], + creditsRemaining: 1, +}; + +describe('capacity command', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders the JSON report exactly', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + renderCapacityReport(report, { json: true }); + expect(log).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + log.mockRestore(); + }); + + it('renders the table through the shared terminal UI', () => { + renderCapacityReport(report); + expect(ui.text).toHaveBeenCalledWith('Capacity:', { breakline: true }); + expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Credits'], + rows: [[ + 'codex', 'yes', 'yes', '80% left · resets 2026-08-09T12:00:00.000Z', + '40% left · resets 2026-08-16T10:00:00.000Z', '1', + ]], + })); + }); + + it('wires the Codex-only command surface', async () => { + const getReport = vi.fn(async () => report); + const program = new Command(); + program.exitOverride(); + registerCapacityCommand(program, getReport); + await program.parseAsync(['node', 'test', 'capacity', 'codex', '--json']); + + expect(getReport).toHaveBeenCalledWith(); + expect(ui.table).not.toHaveBeenCalled(); + }); + + it('rejects non-Codex providers before probing', async () => { + const getReport = vi.fn(async () => report); + await expect(capacityCommand('claude', {}, getReport)).rejects.toThrow( + 'Only "codex" is supported' + ); + 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..295b9b46 --- /dev/null +++ b/packages/cli/src/commands/capacity.ts @@ -0,0 +1,28 @@ +import type { Command } from 'commander'; +import { getCodexCapacityReport } from '@ai-devkit/agent-manager'; +import { renderCapacityReport } from './capacity/render.js'; +import type { CapacityReport } from '@ai-devkit/agent-manager'; + +type CapacityOptions = { json?: boolean }; +type ReportReader = () => Promise; + +export async function capacityCommand( + provider: string | undefined, + options: CapacityOptions, + readReport: ReportReader = getCodexCapacityReport +): Promise { + if (provider !== undefined && provider.toLowerCase() !== 'codex') { + throw new Error(`Unknown capacity provider "${provider}". Only "codex" is supported.`); + } + const report = await readReport(); + renderCapacityReport(report, options); +} + +export function registerCapacityCommand(program: Command, readReport: ReportReader = getCodexCapacityReport): void { + program + .command('capacity [provider]') + .description('Report AI provider capacity') + .option('-j, --json', 'Output as JSON') + .action((provider: string | undefined, options: CapacityOptions) => + 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..a2b973ce --- /dev/null +++ b/packages/cli/src/commands/capacity/render.ts @@ -0,0 +1,53 @@ +import chalk from 'chalk'; +import { ui } from '../../util/terminal-ui.js'; +import type { CapacityReport, CapacityWindow } from '@ai-devkit/agent-manager'; + +function authLabel(value: boolean | null): string { + return value === true ? 'yes' : value === false ? 'no' : 'unknown'; +} + +function formatWindow(window: CapacityWindow | undefined): string { + if (!window || window.usedPercent === null) return 'unknown'; + const remaining = Math.max(0, Math.min(100, 100 - window.usedPercent)); + const reset = window.resetsAt ? ` · resets ${window.resetsAt}` : ''; + return `${remaining}% 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) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + ui.text('Capacity:', { breakline: true }); + + const [shortWindow, longWindow] = windowPair(report.windows); + ui.table({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Credits'], + rows: [[ + report.provider, + authLabel(report.authenticated), + report.available, + formatWindow(shortWindow), + formatWindow(longWindow), + report.creditsRemaining === null || report.creditsRemaining === undefined + ? '—' : String(report.creditsRemaining), + ]], + maxWidth: process.stdout.columns ?? 120, + columnStyles: [ + (text) => chalk.cyan(text), + (text) => chalk.dim(text), + (text) => (text === 'yes' ? chalk.green(text) : text === 'no' ? chalk.yellow(text) : chalk.gray(text)), + (text) => text, + (text) => chalk.dim(text), + (text) => chalk.dim(text), + ], + }); +}