@inixiative/agent-session 0.1.0 → 0.3.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,78 @@
1
+ // One-shot account limit reads without a model turn: a short-lived native
2
+ // process asks the runtime for its usage, then exits. Account identity is not read.
3
+
4
+ import { ClaudeCodeSession, type ClaudeCodeSessionConfig } from './claude-code-session';
5
+ import type { CodexSpawn, PipedSubprocess } from './codex-session';
6
+ import { JsonRpcConnection } from './json-rpc';
7
+ import { codexLimitSnapshot, type LimitSnapshot } from './limits';
8
+
9
+ export interface LimitProbeOptions {
10
+ bin?: string;
11
+ /** Merged over process.env (CODEX_HOME / CLAUDE_CONFIG_DIR for a profile). */
12
+ env?: Record<string, string | undefined>;
13
+ cwd?: string;
14
+ timeoutMs?: number;
15
+ }
16
+
17
+ /** `codex app-server` → account/rateLimits/read. */
18
+ export async function probeCodexLimits(
19
+ options: LimitProbeOptions & { spawn?: CodexSpawn } = {},
20
+ ): Promise<LimitSnapshot | undefined> {
21
+ const timeoutMs = options.timeoutMs ?? 15_000;
22
+ const argv = [options.bin ?? 'codex', 'app-server', '--listen', 'stdio://'];
23
+ const env: Record<string, string | undefined> = {
24
+ ...process.env,
25
+ ...options.env,
26
+ DISABLE_AUTOUPDATER: '1',
27
+ };
28
+ delete env.OPENAI_API_KEY;
29
+ delete env.CODEX_API_KEY;
30
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
31
+ const cwd = options.cwd ?? process.cwd();
32
+ const proc = options.spawn
33
+ ? options.spawn(argv, { cwd, env })
34
+ : (Bun.spawn(argv, {
35
+ cwd,
36
+ env,
37
+ stdin: 'pipe',
38
+ stdout: 'pipe',
39
+ stderr: 'pipe',
40
+ }) as unknown as PipedSubprocess);
41
+ const conn = new JsonRpcConnection(proc);
42
+ try {
43
+ await conn.request(
44
+ 'initialize',
45
+ { clientInfo: { name: 'agent-session-limits', version: '0.2.0' }, capabilities: {} },
46
+ timeoutMs,
47
+ );
48
+ conn.notify('initialized');
49
+ return codexLimitSnapshot(
50
+ await conn.request('account/rateLimits/read', { excludeResetCreditDetails: true }, timeoutMs),
51
+ 'poll',
52
+ );
53
+ } finally {
54
+ conn.kill();
55
+ await Promise.race([conn.exited.catch(() => 0), Bun.sleep(2_000)]);
56
+ }
57
+ }
58
+
59
+ /** Text-only `claude` process → control request get_usage. */
60
+ export async function probeClaudeLimits(
61
+ options: LimitProbeOptions & { spawn?: ClaudeCodeSessionConfig['spawn'] } = {},
62
+ ): Promise<LimitSnapshot | undefined> {
63
+ const session = new ClaudeCodeSession({
64
+ bin: options.bin,
65
+ env: options.env,
66
+ cwd: options.cwd,
67
+ spawn: options.spawn,
68
+ textOnly: true,
69
+ persistSession: false,
70
+ maxTurns: 1,
71
+ });
72
+ try {
73
+ await session.start();
74
+ return await session.readLimits({ timeoutMs: options.timeoutMs ?? 15_000 });
75
+ } finally {
76
+ session.kill();
77
+ }
78
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,216 @@
1
+ // Subscription usage limits, normalized across runtimes. Account identity
2
+ // (email, account/organization IDs) is never read into a snapshot.
3
+
4
+ export type LimitRuntime = 'claude' | 'codex';
5
+
6
+ export interface LimitWindow {
7
+ /** Stable per runtime: Claude `five_hour`/`seven_day`/…, Codex `primary`/`secondary`. */
8
+ readonly id: string;
9
+ /** 0–100. */
10
+ readonly usedPercent: number;
11
+ /** Epoch ms. */
12
+ readonly resetsAt?: number;
13
+ readonly windowMinutes?: number;
14
+ }
15
+
16
+ export interface LimitSnapshot {
17
+ readonly runtime: LimitRuntime;
18
+ /** `poll`: an explicit read; `stream`: observed during a turn (may be sparse). */
19
+ readonly source: 'poll' | 'stream';
20
+ /** Epoch ms. */
21
+ readonly observedAt: number;
22
+ readonly windows: readonly LimitWindow[];
23
+ /** The provider reports ordinary usage blocked now. Absent means not reported. */
24
+ readonly blocked?: boolean;
25
+ /** Provider's reason label (Codex `rateLimitReachedType`, Claude `rateLimitType` when rejected). */
26
+ readonly reachedType?: string;
27
+ readonly plan?: string;
28
+ }
29
+
30
+ const percent = (value: unknown, scale = 1): number | undefined =>
31
+ typeof value === 'number' && Number.isFinite(value)
32
+ ? Math.max(0, Math.min(100, value * scale))
33
+ : undefined;
34
+ const epochSeconds = (value: unknown): number | undefined =>
35
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value * 1000 : undefined;
36
+ const isoTime = (value: unknown): number | undefined => {
37
+ if (typeof value !== 'string' || !value) return undefined;
38
+ const ms = Date.parse(value);
39
+ return Number.isFinite(ms) ? ms : undefined;
40
+ };
41
+ const object = (value: unknown): Record<string, unknown> | undefined =>
42
+ value !== null && typeof value === 'object' && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : undefined;
45
+ const window = (
46
+ id: string,
47
+ used: number | undefined,
48
+ resetsAt?: number,
49
+ windowMinutes?: number,
50
+ ): LimitWindow[] =>
51
+ used === undefined
52
+ ? []
53
+ : [
54
+ {
55
+ id,
56
+ usedPercent: used,
57
+ ...(resetsAt ? { resetsAt } : {}),
58
+ ...(windowMinutes ? { windowMinutes } : {}),
59
+ },
60
+ ];
61
+
62
+ /**
63
+ * Codex `account/rateLimits/read` result or `account/rateLimits/updated` params.
64
+ * Only the main `codex` bucket is read; model-specific buckets are separate allowances.
65
+ */
66
+ export function codexLimitSnapshot(
67
+ value: unknown,
68
+ source: LimitSnapshot['source'],
69
+ observedAt = Date.now(),
70
+ ): LimitSnapshot | undefined {
71
+ const outer = object(value);
72
+ const snapshot = object(object(outer?.rateLimitsByLimitId)?.codex) ?? object(outer?.rateLimits);
73
+ if (!snapshot || (typeof snapshot.limitId === 'string' && snapshot.limitId !== 'codex'))
74
+ return undefined;
75
+ const windows: LimitWindow[] = [];
76
+ for (const id of ['primary', 'secondary'] as const) {
77
+ const w = object(snapshot[id]);
78
+ windows.push(
79
+ ...window(
80
+ id,
81
+ percent(w?.usedPercent),
82
+ epochSeconds(w?.resetsAt),
83
+ typeof w?.windowDurationMins === 'number' ? w.windowDurationMins : undefined,
84
+ ),
85
+ );
86
+ }
87
+ const reached =
88
+ typeof snapshot.rateLimitReachedType === 'string' ? snapshot.rateLimitReachedType : undefined;
89
+ const ordinary = outer?.ordinaryUsageAllowed;
90
+ const blocked =
91
+ reached !== undefined || ordinary === false || windows.some((w) => w.usedPercent >= 100)
92
+ ? true
93
+ : ordinary === true
94
+ ? false
95
+ : undefined;
96
+ return {
97
+ runtime: 'codex',
98
+ source,
99
+ observedAt,
100
+ windows,
101
+ ...(blocked !== undefined ? { blocked } : {}),
102
+ ...(reached ? { reachedType: reached } : {}),
103
+ ...(typeof snapshot.planType === 'string' ? { plan: snapshot.planType } : {}),
104
+ };
105
+ }
106
+
107
+ /** Claude CLI stream-json `rate_limit_event.rate_limit_info`. Utilization is a 0–1 fraction. */
108
+ export function claudeRateLimitSnapshot(
109
+ info: unknown,
110
+ observedAt = Date.now(),
111
+ ): LimitSnapshot | undefined {
112
+ const i = object(info);
113
+ if (!i) return undefined;
114
+ const windows = new Map<string, LimitWindow>();
115
+ for (const [id, w] of Object.entries(object(i.unifiedWindows) ?? {})) {
116
+ const entry = object(w);
117
+ for (const item of window(id, percent(entry?.utilization, 100), epochSeconds(entry?.resetsAt)))
118
+ windows.set(id, item);
119
+ }
120
+ const type = typeof i.rateLimitType === 'string' ? i.rateLimitType : undefined;
121
+ const overage =
122
+ i.overageStatus === 'allowed' ||
123
+ i.overageStatus === 'allowed_warning' ||
124
+ i.isUsingOverage === true;
125
+ const blocked =
126
+ i.status === 'rejected'
127
+ ? !overage
128
+ : i.status === 'allowed' || i.status === 'allowed_warning'
129
+ ? false
130
+ : undefined;
131
+ // A rejection names its window; without a utilization figure it is at its limit, and its reset still applies.
132
+ const utilization = percent(i.utilization, 100) ?? (blocked && type ? 100 : undefined);
133
+ if (type && !windows.has(type))
134
+ for (const item of window(type, utilization, epochSeconds(i.resetsAt))) windows.set(type, item);
135
+ if (!windows.size && blocked === undefined) return undefined;
136
+ return {
137
+ runtime: 'claude',
138
+ source: 'stream',
139
+ observedAt,
140
+ windows: [...windows.values()],
141
+ ...(blocked !== undefined ? { blocked } : {}),
142
+ ...(blocked && type ? { reachedType: type } : {}),
143
+ };
144
+ }
145
+
146
+ /** Claude control-protocol `get_usage` response. Utilization is 0–100 here. */
147
+ export function claudeUsageSnapshot(
148
+ response: unknown,
149
+ observedAt = Date.now(),
150
+ ): LimitSnapshot | undefined {
151
+ const r = object(response);
152
+ if (r?.rate_limits_available !== true) return undefined;
153
+ const limits = object(r.rate_limits);
154
+ const windows: LimitWindow[] = [];
155
+ for (const [id, minutes] of [
156
+ ['five_hour', 300],
157
+ ['seven_day', 10_080],
158
+ ] as const) {
159
+ const w = object(limits?.[id]);
160
+ windows.push(...window(id, percent(w?.utilization), isoTime(w?.resets_at), minutes));
161
+ }
162
+ for (const [id, value] of Object.entries(limits ?? {})) {
163
+ if (id === 'five_hour' || id === 'seven_day' || !id.startsWith('seven_day_')) continue;
164
+ const w = object(value);
165
+ windows.push(...window(id, percent(w?.utilization), isoTime(w?.resets_at), 10_080));
166
+ }
167
+ const locked = [limits?.five_hour, limits?.seven_day].some(
168
+ (w) => typeof object(w)?.locked_reason === 'string',
169
+ );
170
+ return {
171
+ runtime: 'claude',
172
+ source: 'poll',
173
+ observedAt,
174
+ windows,
175
+ ...(locked || windows.some((w) => w.usedPercent >= 100) ? { blocked: true } : {}),
176
+ ...(typeof r.subscription_type === 'string' ? { plan: r.subscription_type } : {}),
177
+ };
178
+ }
179
+
180
+ /** Fold a sparse update into the previous snapshot: windows upsert by id and keep a known reset time. */
181
+ export function mergeLimits(
182
+ previous: LimitSnapshot | undefined,
183
+ update: LimitSnapshot,
184
+ ): LimitSnapshot {
185
+ if (!previous || previous.runtime !== update.runtime || update.source === 'poll') return update;
186
+ const windows = new Map(previous.windows.map((w) => [w.id, w] as const));
187
+ for (const w of update.windows) {
188
+ const prior = windows.get(w.id);
189
+ windows.set(w.id, {
190
+ ...prior,
191
+ ...w,
192
+ ...(w.resetsAt === undefined && prior?.resetsAt ? { resetsAt: prior.resetsAt } : {}),
193
+ });
194
+ }
195
+ const { blocked: _b, reachedType: _r, ...base } = previous;
196
+ return {
197
+ ...base,
198
+ ...update,
199
+ windows: [...windows.values()],
200
+ ...(update.blocked === undefined && previous.blocked !== undefined
201
+ ? { blocked: previous.blocked }
202
+ : {}),
203
+ ...(update.reachedType === undefined && update.blocked !== false && previous.reachedType
204
+ ? { reachedType: previous.reachedType }
205
+ : {}),
206
+ };
207
+ }
208
+
209
+ /** Highest used percentage across windows that have not reset by `now`; undefined when nothing is known. */
210
+ export function limitUtilization(
211
+ snapshot: LimitSnapshot | undefined,
212
+ now = Date.now(),
213
+ ): number | undefined {
214
+ const live = snapshot?.windows.filter((w) => w.resetsAt === undefined || w.resetsAt > now) ?? [];
215
+ return live.length ? Math.max(...live.map((w) => w.usedPercent)) : undefined;
216
+ }