@ai-devkit/agent-manager 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ CODEX_APP_SERVER_ARGS,
4
+ parseUsage,
5
+ probeCodexCapacity,
6
+ resolveCodexAuthPath,
7
+ toRateWindow
8
+ } from '../../capacity/codex.js';
9
+
10
+ const checkedAt = '2026-08-20T10:00:00.000Z';
11
+ const context = { installed: true, checkedAt };
12
+
13
+ function apiUsage(overrides: Record<string, unknown> = {}) {
14
+ return {
15
+ rate_limit: {
16
+ primary_window: { used_percent: 20, limit_window_seconds: 18_000, reset_at: 1_787_220_000 },
17
+ secondary_window: { used_percent: 60, limit_window_seconds: 604_800, reset_at: 1_787_824_800 },
18
+ ...overrides
19
+ },
20
+ credits: { balance: 12.5 },
21
+ additional_rate_limits: [{
22
+ limit_name: 'reviews',
23
+ rate_limit: {
24
+ primary_window: { used_percent: 10, limit_window_seconds: 3_600, reset_at: 1_787_220_000 }
25
+ }
26
+ }]
27
+ };
28
+ }
29
+
30
+ describe('Codex auth resolution', () => {
31
+ it('uses CODEX_HOME before HOME', () => {
32
+ expect(resolveCodexAuthPath({ CODEX_HOME: '/custom/codex', HOME: '/users/test' })).toBe('/custom/codex/auth.json');
33
+ });
34
+
35
+ it('falls back to ~/.codex/auth.json', () => {
36
+ expect(resolveCodexAuthPath({ HOME: '/users/test' })).toBe('/users/test/.codex/auth.json');
37
+ });
38
+ });
39
+
40
+ describe('Codex API usage mapping', () => {
41
+ it('converts an API window without treating missing data as zero', () => {
42
+ expect(toRateWindow({ used_percent: 25, limit_window_seconds: 18_000, reset_at: 1_787_220_000 }, 'session', 'Session')).toEqual({
43
+ id: 'session', label: 'Session', durationMinutes: 300, usedPercent: 25,
44
+ resetsAt: '2026-08-20T10:00:00.000Z'
45
+ });
46
+ expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: null });
47
+ });
48
+
49
+ it('maps session, weekly, credits, and extra limits', () => {
50
+ const snapshot = parseUsage(apiUsage(), 'pat');
51
+ expect(snapshot).toMatchObject({ source: 'pat', creditsRemaining: 12.5 });
52
+ expect(snapshot.windows).toEqual([
53
+ expect.objectContaining({ id: 'session', durationMinutes: 300 }),
54
+ expect.objectContaining({ id: 'weekly', durationMinutes: 10080 }),
55
+ expect.objectContaining({ id: 'reviews:primary', durationMinutes: 60 })
56
+ ]);
57
+ });
58
+
59
+ it('represents missing limits as unavailable rather than zero', () => {
60
+ const snapshot = parseUsage({ credits: {} }, 'oauth');
61
+ expect(snapshot.windows).toEqual([]);
62
+ expect(snapshot.creditsRemaining).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe('tiered Codex probing', () => {
67
+ it('selects PAT, calls whoami then usage, and never invokes the CLI', async () => {
68
+ const fetch = vi.fn()
69
+ .mockResolvedValueOnce(new Response(JSON.stringify({ chatgpt_account_id: 'acct-1' }), { status: 200 }))
70
+ .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 }));
71
+ const rpc = vi.fn();
72
+ const result = await probeCodexCapacity({
73
+ ...context, readFile: async () => JSON.stringify({
74
+ personal_access_token: 'pat-secret',
75
+ tokens: { access_token: 'ignored-oauth', account_id: 'ignored-account' }
76
+ }), fetch, rpc
77
+ });
78
+ expect(fetch).toHaveBeenCalledTimes(2);
79
+ expect(fetch.mock.calls[0][0]).toBe('https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami');
80
+ expect(fetch.mock.calls[1][0]).toBe('https://chatgpt.com/backend-api/wham/usage');
81
+ expect(fetch.mock.calls[1][1].headers).toMatchObject({ Authorization: 'Bearer pat-secret', 'ChatGPT-Account-Id': 'acct-1' });
82
+ expect(rpc).not.toHaveBeenCalled();
83
+ expect(result).toMatchObject({ provider: 'codex', available: 'yes', creditsRemaining: 12.5, authenticated: true });
84
+ expect(result.windows.map(window => window.id)).toEqual(['session', 'weekly', 'reviews:primary']);
85
+ });
86
+
87
+ it('selects a fresh OAuth token without calling whoami', async () => {
88
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(apiUsage()), { status: 200 }));
89
+ const result = await probeCodexCapacity({
90
+ ...context,
91
+ readFile: async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct-2', expires_at: 1_800_000_000 } }),
92
+ fetch,
93
+ now: () => new Date('2026-08-20T10:00:00.000Z')
94
+ });
95
+ expect(fetch).toHaveBeenCalledOnce();
96
+ expect(fetch.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer oauth-secret', 'ChatGPT-Account-Id': 'acct-2' });
97
+ expect(result.available).toBe('yes');
98
+ });
99
+
100
+ it.each([
101
+ ['missing auth file', async () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); }],
102
+ ['stale OAuth token', async () => JSON.stringify({ tokens: { access_token: 'stale-secret', account_id: 'acct', expires_at: 1 } })],
103
+ ['OAuth 401', async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 } })]
104
+ ])('falls back to the CLI for %s', async (name, readFile) => {
105
+ const fetch = vi.fn().mockResolvedValue(new Response('', { status: name === 'OAuth 401' ? 401 : 200 }));
106
+ const rpc = vi.fn(async () => ({
107
+ rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } },
108
+ account: { account: { type: 'chatgpt' } }
109
+ }));
110
+ const result = await probeCodexCapacity({ ...context, readFile, fetch, rpc, now: () => new Date(checkedAt) });
111
+ expect(rpc).toHaveBeenCalledOnce();
112
+ expect(result.windows).toHaveLength(1);
113
+ });
114
+
115
+ it('falls back to CLI if PAT requests fail', async () => {
116
+ const rpc = vi.fn(async () => ({ rateLimits: {}, account: { account: null } }));
117
+ const result = await probeCodexCapacity({
118
+ ...context,
119
+ readFile: async () => JSON.stringify({ personal_access_token: 'pat-secret' }),
120
+ fetch: vi.fn().mockRejectedValue(new Error('network failure pat-secret')),
121
+ rpc
122
+ });
123
+ expect(rpc).toHaveBeenCalledOnce();
124
+ expect(result.available).toBe('unknown');
125
+ });
126
+
127
+ it('tries fresh OAuth after a PAT request fails', async () => {
128
+ const fetch = vi.fn()
129
+ .mockRejectedValueOnce(new Error('PAT failed'))
130
+ .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 }));
131
+ const rpc = vi.fn();
132
+ const result = await probeCodexCapacity({
133
+ ...context,
134
+ readFile: async () => JSON.stringify({
135
+ personal_access_token: 'pat-secret',
136
+ tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 }
137
+ }),
138
+ fetch,
139
+ rpc,
140
+ now: () => new Date(checkedAt)
141
+ });
142
+ expect(fetch).toHaveBeenCalledTimes(2);
143
+ expect(result.available).toBe('yes');
144
+ expect(rpc).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('uses hardened read-only app-server arguments and both account methods', async () => {
148
+ const rpc = vi.fn(async () => ({
149
+ rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } },
150
+ account: { account: { type: 'chatgpt' } }
151
+ }));
152
+ await probeCodexCapacity({ ...context, readFile: async () => '{}', rpc });
153
+ const messages = rpc.mock.calls[0][0];
154
+ expect(messages.map(message => message.method)).toEqual([
155
+ 'initialize', 'initialized', 'account/rateLimits/read', 'account/read'
156
+ ]);
157
+ expect(JSON.stringify(messages)).not.toMatch(/prompt|turn\/start/);
158
+ expect(CODEX_APP_SERVER_ARGS).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']);
159
+ });
160
+
161
+ it('uses account/read to distinguish logged-out CLI state', async () => {
162
+ const result = await probeCodexCapacity({
163
+ ...context,
164
+ readFile: async () => '{}',
165
+ rpc: async () => ({ rateLimits: {}, account: { account: null } })
166
+ });
167
+ expect(result).toMatchObject({ authenticated: false, available: 'unknown' });
168
+ });
169
+
170
+ it('never exposes tokens or raw auth content through failures', async () => {
171
+ const secrets = ['pat-secret-value', 'oauth-secret-value', 'refresh-secret-value'];
172
+ const result = await probeCodexCapacity({
173
+ ...context,
174
+ readFile: async () => JSON.stringify({
175
+ personal_access_token: secrets[0],
176
+ tokens: { access_token: secrets[1], refresh_token: secrets[2] }
177
+ }),
178
+ fetch: vi.fn().mockRejectedValue(new Error(secrets.join(' '))),
179
+ rpc: async () => { throw new Error(secrets.join(' ')); }
180
+ });
181
+ const output = JSON.stringify(result);
182
+ for (const secret of secrets) expect(output).not.toContain(secret);
183
+ });
184
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getCodexCapacityReport } from '../../capacity/index.js';
3
+
4
+ const checkedAt = '2026-08-09T10:00:00.000Z';
5
+
6
+ describe('getCodexCapacityReport', () => {
7
+ it('checks Codex installation before probing', async () => {
8
+ const probe = vi.fn(async context => ({
9
+ provider: 'codex', generatedAt: context.checkedAt,
10
+ authenticated: true, available: 'yes' as const, windows: [], creditsRemaining: null
11
+ }));
12
+
13
+ const report = await getCodexCapacityReport({
14
+ now: () => new Date(checkedAt),
15
+ path: '/usr/bin:/opt/bin',
16
+ access: async target => {
17
+ if (target !== '/opt/bin/codex') throw new Error('missing');
18
+ },
19
+ probe
20
+ });
21
+
22
+ expect(probe).toHaveBeenCalledWith({ installed: true, checkedAt });
23
+ expect(report).toMatchObject({ provider: 'codex', generatedAt: checkedAt, available: 'yes' });
24
+ });
25
+
26
+ it('redacts unexpected probe failures into a stable unknown result', async () => {
27
+ const report = await getCodexCapacityReport({
28
+ now: () => new Date(checkedAt),
29
+ path: '',
30
+ probe: async () => { throw new Error('private provider response'); }
31
+ });
32
+
33
+ expect(report).toMatchObject({
34
+ provider: 'codex', available: 'unknown', authenticated: null, windows: []
35
+ });
36
+ expect(JSON.stringify(report)).not.toContain('private provider response');
37
+ });
38
+ });
@@ -8,6 +8,7 @@ import {
8
8
  ClaudePrintAgentService,
9
9
  ClaudePrintRunner,
10
10
  DurableAgentRepository,
11
+ type ProcessInspector,
11
12
  } from '../../index.js';
12
13
 
13
14
  const roots: string[] = [];
@@ -28,11 +29,17 @@ describe('Claude durable-agent fake-provider journey', () => {
28
29
  const capture = path.join(root, 'capture.jsonl');
29
30
  process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = capture;
30
31
  const executable = fileURLToPath(new URL('../fixtures/fake-claude.cjs', import.meta.url));
31
- const repository = new DurableAgentRepository({ dbPath: path.join(root, 'state', 'agents.db') });
32
+ const processInspector: ProcessInspector = {
33
+ getIdentity: (pid) => ({ pid, startedAt: `process-${pid}` }),
34
+ };
35
+ const repository = new DurableAgentRepository({
36
+ dbPath: path.join(root, 'state', 'agents.db'),
37
+ processInspector,
38
+ });
32
39
  const service = new ClaudePrintAgentService({
33
40
  repository,
34
41
  probe: new ClaudeCliProbe({ executable }),
35
- runner: new ClaudePrintRunner(),
42
+ runner: new ClaudePrintRunner({ processInspector }),
36
43
  executable,
37
44
  });
38
45
 
@@ -0,0 +1,320 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { CapacityReport, CapacityWindow } from './types.js';
5
+
6
+ type CodexUsageSource = 'pat' | 'oauth' | 'cli';
7
+ type UsageSnapshot = { windows: CapacityWindow[]; creditsRemaining: number | null; source: CodexUsageSource };
8
+
9
+ type UnknownRecord = Record<string, unknown>;
10
+ type RpcMessage = { id?: number; method: string; params?: UnknownRecord };
11
+ type CliResponses = { rateLimits: unknown; account: unknown };
12
+ type CodexRpc = (messages: RpcMessage[]) => Promise<CliResponses>;
13
+
14
+ export const CODEX_APP_SERVER_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const;
15
+
16
+ type CodexProbeOptions = {
17
+ installed: boolean;
18
+ checkedAt: string;
19
+ readFile?: (path: string, encoding: BufferEncoding) => Promise<string>;
20
+ fetch?: typeof globalThis.fetch;
21
+ rpc?: CodexRpc;
22
+ timeoutMs?: number;
23
+ env?: NodeJS.ProcessEnv;
24
+ now?: () => Date;
25
+ };
26
+
27
+ function record(value: unknown): UnknownRecord | null {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ ? value as UnknownRecord
30
+ : null;
31
+ }
32
+
33
+ function finiteNumber(value: unknown): number | null {
34
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
35
+ }
36
+
37
+ function nonEmptyText(value: unknown): string | null {
38
+ return typeof value === 'string' && value.length > 0 ? value : null;
39
+ }
40
+
41
+ function resetTime(value: unknown): string | null {
42
+ const seconds = finiteNumber(value);
43
+ if (seconds !== null) return new Date(seconds * 1000).toISOString();
44
+ if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
45
+ return null;
46
+ }
47
+
48
+ function safeIdentifier(value: unknown): string | null {
49
+ const candidate = nonEmptyText(value);
50
+ if (!candidate || !/^[a-z][a-z0-9_-]{0,63}$/i.test(candidate)) return null;
51
+ if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null;
52
+ return candidate;
53
+ }
54
+
55
+ export function resolveCodexAuthPath(env: NodeJS.ProcessEnv = process.env): string {
56
+ const root = env.CODEX_HOME || join(env.HOME || '', '.codex');
57
+ return join(root, 'auth.json');
58
+ }
59
+
60
+ export function toRateWindow(
61
+ value: unknown,
62
+ id: string,
63
+ label: string
64
+ ): CapacityWindow | null {
65
+ const input = record(value);
66
+ if (!input) return null;
67
+ const used = finiteNumber(input.used_percent);
68
+ const seconds = finiteNumber(input.limit_window_seconds);
69
+ return {
70
+ id,
71
+ label,
72
+ durationMinutes: seconds === null ? null : seconds / 60,
73
+ usedPercent: used,
74
+ resetsAt: resetTime(input.reset_at)
75
+ };
76
+ }
77
+
78
+ function extraWindows(value: unknown): CapacityWindow[] {
79
+ if (!Array.isArray(value)) return [];
80
+ return value.flatMap((entry, index) => {
81
+ const limit = record(entry);
82
+ if (!limit) return [];
83
+ const scope = safeIdentifier(limit.limit_name) ?? `extra-${index + 1}`;
84
+ const windows = record(limit.rate_limit) ?? limit;
85
+ return [
86
+ toRateWindow(windows.primary_window, `${scope}:primary`, `${scope} primary`),
87
+ toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`)
88
+ ].filter((window): window is CapacityWindow => window !== null);
89
+ });
90
+ }
91
+
92
+ export function parseUsage(raw: unknown, source: 'pat' | 'oauth'): UsageSnapshot {
93
+ const response = record(raw) ?? {};
94
+ const limits = record(response.rate_limit) ?? {};
95
+ const credits = record(response.credits) ?? {};
96
+ return {
97
+ windows: [
98
+ toRateWindow(limits.primary_window, 'session', 'Session'),
99
+ toRateWindow(limits.secondary_window, 'weekly', 'Weekly'),
100
+ ...extraWindows(response.additional_rate_limits)
101
+ ].filter((window): window is CapacityWindow => window !== null),
102
+ creditsRemaining: finiteNumber(credits.balance),
103
+ source
104
+ };
105
+ }
106
+
107
+ function cliWindow(value: unknown, id: string, label: string): CapacityWindow | null {
108
+ const input = record(value);
109
+ if (!input) return null;
110
+ return {
111
+ id,
112
+ label,
113
+ durationMinutes: finiteNumber(input.windowDurationMins),
114
+ usedPercent: finiteNumber(input.usedPercent),
115
+ resetsAt: resetTime(input.resetsAt)
116
+ };
117
+ }
118
+
119
+ function cliSnapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] {
120
+ const snapshot = record(value);
121
+ if (!snapshot) return [];
122
+ const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex';
123
+ return [
124
+ cliWindow(snapshot.primary, `${scope}:primary`, `${scope} primary`),
125
+ cliWindow(snapshot.secondary, `${scope}:secondary`, `${scope} secondary`)
126
+ ].filter((item): item is CapacityWindow => item !== null);
127
+ }
128
+
129
+ export function parseCliUsage(raw: unknown): UsageSnapshot {
130
+ const response = record(raw) ?? {};
131
+ const primary = record(response.rateLimits);
132
+ const windows = primary ? cliSnapshotWindows(primary, 'codex') : [];
133
+ const buckets = record(response.rateLimitsByLimitId);
134
+ if (buckets) {
135
+ for (const [id, snapshot] of Object.entries(buckets)) windows.push(...cliSnapshotWindows(snapshot, id));
136
+ }
137
+ const unique = [...new Map(windows.map(window => [window.id, window])).values()];
138
+ return { windows: unique, creditsRemaining: null, source: 'cli' };
139
+ }
140
+
141
+ function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): CapacityReport {
142
+ const hasUsage = snapshot.windows.some(window => window.usedPercent !== null);
143
+ const rateLimits = record(record(raw)?.rateLimits);
144
+ const reached = nonEmptyText(rateLimits?.rateLimitReachedType);
145
+ const resetCredits = record(record(raw)?.rateLimitResetCredits) ?? record(record(raw)?.usageLimitResetCredits);
146
+ return {
147
+ provider: 'codex',
148
+ generatedAt: context.checkedAt,
149
+ authenticated: true,
150
+ available: reached ? 'no' : hasUsage ? 'yes' : 'unknown',
151
+ windows: snapshot.windows,
152
+ creditsRemaining: snapshot.creditsRemaining ?? finiteNumber(resetCredits?.availableCount)
153
+ };
154
+ }
155
+
156
+ function jwtExpiry(token: string): number | null {
157
+ const part = token.split('.')[1];
158
+ if (!part) return null;
159
+ try {
160
+ return finiteNumber(record(JSON.parse(Buffer.from(part, 'base64url').toString('utf8')))?.exp);
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function staleOAuth(tokens: UnknownRecord, token: string, now: Date): boolean {
167
+ const metadata = tokens.expires_at ?? tokens.expiresAt ?? tokens.expiry;
168
+ let expiry: number | null = finiteNumber(metadata);
169
+ if (typeof metadata === 'string') {
170
+ const parsed = Date.parse(metadata);
171
+ expiry = Number.isNaN(parsed) ? null : parsed / 1000;
172
+ }
173
+ expiry ??= jwtExpiry(token);
174
+ return expiry !== null && expiry <= now.getTime() / 1000;
175
+ }
176
+
177
+ async function fetchJson(fetcher: typeof globalThis.fetch, url: string, init: RequestInit, timeoutMs: number): Promise<unknown> {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
180
+ try {
181
+ const response = await fetcher(url, { ...init, signal: controller.signal });
182
+ if (!response.ok) throw new Error(response.status === 401 ? 'unauthorized' : 'request failed');
183
+ return await response.json();
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
187
+ }
188
+
189
+ async function apiSnapshot(
190
+ token: string,
191
+ accountId: string,
192
+ source: 'pat' | 'oauth',
193
+ options: CodexProbeOptions
194
+ ): Promise<UsageSnapshot> {
195
+ const fetcher = options.fetch ?? globalThis.fetch;
196
+ const raw = await fetchJson(fetcher, 'https://chatgpt.com/backend-api/wham/usage', {
197
+ headers: { Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': accountId }
198
+ }, options.timeoutMs ?? 5000);
199
+ return parseUsage(raw, source);
200
+ }
201
+
202
+ function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise<CliResponses> {
203
+ return new Promise((resolve, reject) => {
204
+ const child = spawn('codex', CODEX_APP_SERVER_ARGS, {
205
+ stdio: ['pipe', 'pipe', 'ignore']
206
+ });
207
+ const results: Partial<CliResponses> = {};
208
+ let buffer = '';
209
+ let settled = false;
210
+ const finish = (error?: Error) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ child.kill();
215
+ if (error) reject(error);
216
+ else resolve(results as CliResponses);
217
+ };
218
+ const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs);
219
+ child.once('error', () => finish(new Error('codex app-server unavailable')));
220
+ child.once('exit', () => { if (!settled) finish(new Error('codex app-server exited')); });
221
+ child.stdout.setEncoding('utf8');
222
+ child.stdout.on('data', (chunk: string) => {
223
+ buffer += chunk;
224
+ for (;;) {
225
+ const newline = buffer.indexOf('\n');
226
+ if (newline < 0) break;
227
+ const line = buffer.slice(0, newline).trim();
228
+ buffer = buffer.slice(newline + 1);
229
+ if (!line) continue;
230
+ let message: UnknownRecord;
231
+ try { message = JSON.parse(line) as UnknownRecord; } catch { continue; }
232
+ if (message.id === 1) {
233
+ for (const request of messages.slice(1)) child.stdin.write(`${JSON.stringify(request)}\n`);
234
+ } else if (message.id === 2) {
235
+ if (message.error) finish(new Error('codex rate-limit method failed'));
236
+ else results.rateLimits = message.result;
237
+ } else if (message.id === 3) {
238
+ if (message.error) finish(new Error('codex account method failed'));
239
+ else results.account = message.result;
240
+ }
241
+ if ('rateLimits' in results && 'account' in results) finish();
242
+ }
243
+ });
244
+ child.stdin.write(`${JSON.stringify(messages[0])}\n`);
245
+ });
246
+ }
247
+
248
+ function unavailable(options: CodexProbeOptions): CapacityReport {
249
+ return {
250
+ provider: 'codex',
251
+ generatedAt: options.checkedAt,
252
+ authenticated: null,
253
+ available: 'unknown',
254
+ windows: [],
255
+ creditsRemaining: null
256
+ };
257
+ }
258
+
259
+ async function cliFallback(options: CodexProbeOptions): Promise<CapacityReport> {
260
+ if (!options.installed) return unavailable(options);
261
+ const messages: RpcMessage[] = [
262
+ { id: 1, method: 'initialize', params: {
263
+ clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null
264
+ } },
265
+ { method: 'initialized' },
266
+ { id: 2, method: 'account/rateLimits/read' },
267
+ { id: 3, method: 'account/read' }
268
+ ];
269
+ try {
270
+ const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs));
271
+ const response = await rpc(messages);
272
+ const result = capacityFromSnapshot(parseCliUsage(response.rateLimits), options, response.rateLimits);
273
+ const accountEnvelope = record(response.account);
274
+ if (accountEnvelope && Object.hasOwn(accountEnvelope, 'account') && !record(accountEnvelope.account)) {
275
+ result.authenticated = false;
276
+ result.available = 'unknown';
277
+ }
278
+ return result;
279
+ } catch {
280
+ return unavailable(options);
281
+ }
282
+ }
283
+
284
+ export async function probeCodexCapacity(options: CodexProbeOptions): Promise<CapacityReport> {
285
+ let parsed: UnknownRecord | null = null;
286
+ try {
287
+ const contents = await (options.readFile ?? readFile)(resolveCodexAuthPath(options.env), 'utf8');
288
+ parsed = record(JSON.parse(contents));
289
+ } catch {
290
+ return cliFallback(options);
291
+ }
292
+
293
+ const auth = parsed ?? {};
294
+ const pat = nonEmptyText(auth.personal_access_token);
295
+ if (pat) {
296
+ try {
297
+ const fetcher = options.fetch ?? globalThis.fetch;
298
+ const whoami = record(await fetchJson(fetcher,
299
+ 'https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami',
300
+ { headers: { Authorization: `Bearer ${pat}` } }, options.timeoutMs ?? 5000));
301
+ const accountId = nonEmptyText(whoami?.chatgpt_account_id);
302
+ if (!accountId) throw new Error('account unavailable');
303
+ return capacityFromSnapshot(await apiSnapshot(pat, accountId, 'pat', options), options);
304
+ } catch {
305
+ // Continue to a separately available OAuth credential before using the CLI.
306
+ }
307
+ }
308
+
309
+ const tokens = record(auth.tokens);
310
+ const accessToken = nonEmptyText(tokens?.access_token);
311
+ const accountId = nonEmptyText(tokens?.account_id);
312
+ if (tokens && accessToken && accountId && !staleOAuth(tokens, accessToken, (options.now ?? (() => new Date()))())) {
313
+ try {
314
+ return capacityFromSnapshot(await apiSnapshot(accessToken, accountId, 'oauth', options), options);
315
+ } catch {
316
+ return cliFallback(options);
317
+ }
318
+ }
319
+ return cliFallback(options);
320
+ }
@@ -0,0 +1,57 @@
1
+ import { constants } from 'node:fs';
2
+ import { access as fsAccess } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { probeCodexCapacity } from './codex.js';
5
+ import type { CapacityReport } from './types.js';
6
+
7
+ export type { CapacityReport, CapacityWindow } from './types.js';
8
+
9
+ export type CapacityProbeOptions = {
10
+ now?: () => Date;
11
+ path?: string;
12
+ access?: (target: string) => Promise<void>;
13
+ probe?: typeof probeCodexCapacity;
14
+ };
15
+
16
+ async function canAccess(target: string, mode: number): Promise<boolean> {
17
+ try {
18
+ await fsAccess(target, mode);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function isCodexInstalled(pathValue: string, checkAccess?: (target: string) => Promise<void>): Promise<boolean> {
26
+ const directories = pathValue.split(path.delimiter).filter(Boolean);
27
+ for (const directory of directories) {
28
+ const executable = path.join(directory, 'codex');
29
+ if (checkAccess) {
30
+ try {
31
+ await checkAccess(executable);
32
+ return true;
33
+ } catch {
34
+ continue;
35
+ }
36
+ }
37
+ if (await canAccess(executable, constants.X_OK)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export async function getCodexCapacityReport(options: CapacityProbeOptions = {}): Promise<CapacityReport> {
43
+ const generatedAt = (options.now?.() ?? new Date()).toISOString();
44
+ const installed = await isCodexInstalled(options.path ?? process.env.PATH ?? '', options.access);
45
+ try {
46
+ return await (options.probe ?? probeCodexCapacity)({ installed, checkedAt: generatedAt });
47
+ } catch {
48
+ return {
49
+ provider: 'codex',
50
+ generatedAt,
51
+ authenticated: null,
52
+ available: 'unknown',
53
+ windows: [],
54
+ creditsRemaining: null
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ export type Availability = 'yes' | 'no' | 'unknown';
2
+
3
+ export interface CapacityWindow {
4
+ id: string;
5
+ label: string;
6
+ durationMinutes: number | null;
7
+ usedPercent: number | null;
8
+ resetsAt: string | null;
9
+ }
10
+
11
+ export interface CapacityReport {
12
+ provider: string;
13
+ generatedAt: string;
14
+ authenticated: boolean | null;
15
+ available: Availability;
16
+ windows: CapacityWindow[];
17
+ creditsRemaining: number | null;
18
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export { AgentManager, AgentNotRunningError } from './AgentManager.js';
2
+ export { getCodexCapacityReport } from './capacity/index.js';
3
+ export type {
4
+ CapacityProbeOptions,
5
+ CapacityReport,
6
+ CapacityWindow,
7
+ } from './capacity/index.js';
2
8
 
3
9
  export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
4
10
  export { CodexAdapter } from './adapters/CodexAdapter.js';