@inixiative/agent-session 0.2.0 → 0.3.1

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.
@@ -10,9 +10,14 @@
10
10
  // HarnessSession is stateful: it owns a running process, tracks turns,
11
11
  // captures every event for Oracle, and supports fork/interrupt.
12
12
  //
13
- // Implementations: ClaudeCodeSession (now), CodexSession / CursorSession (future)
13
+ // Implementations: ClaudeCodeSession, CodexMcpSession, CodexAppServerSession,
14
+ // ClaudeAgentSdkSession. How a model is reached is the session's transport
15
+ // (see transport.ts); every transport implements this one contract.
14
16
  // ---------------------------------------------------------------------------
15
17
 
18
+ import type { LimitSnapshot } from './limits';
19
+ import type { TransportDescriptor } from './transport';
20
+
16
21
  // ---------------------------------------------------------------------------
17
22
  // Event taxonomy — classified events from the agent stream
18
23
  // ---------------------------------------------------------------------------
@@ -27,9 +32,9 @@ export interface SessionIdentity {
27
32
  readonly callId?: string;
28
33
  readonly messageId?: string;
29
34
  readonly rpcRequestId?: number;
30
- readonly correlation?: "native-turn" | "ordered-stream" | "unknown";
35
+ readonly correlation?: 'native-turn' | 'ordered-stream' | 'unknown';
31
36
  }
32
- export type NativeOutcome = "unknown" | "completed" | "failed";
37
+ export type NativeOutcome = 'unknown' | 'completed' | 'failed';
33
38
  export interface NativeTerminal {
34
39
  readonly type: string;
35
40
  readonly eventId?: string;
@@ -40,37 +45,54 @@ export interface NativeTerminal {
40
45
  }
41
46
  export interface SessionAttempt extends SessionIdentity {
42
47
  readonly nativeOutcome: NativeOutcome;
43
- readonly localOutcome: "pending" | "resolved" | "rejected";
44
- readonly dispatch: "not-dispatched" | "attempted";
45
- readonly transportOutcome: "open" | "failed" | "closed";
48
+ readonly localOutcome: 'pending' | 'resolved' | 'rejected';
49
+ readonly dispatch: 'not-dispatched' | 'attempted';
50
+ readonly transportOutcome: 'open' | 'failed' | 'closed';
46
51
  readonly terminal?: NativeTerminal;
47
- readonly rpcOutcome?: "pending" | "resolved" | "failed" | "unknown";
48
- readonly localFailure?: "timeout" | "interrupt-request" | "transport" | "rpc" | "validation" | "blocked" | "killed" | "unrecognized-terminal" | "registration";
52
+ readonly rpcOutcome?: 'pending' | 'resolved' | 'failed' | 'unknown';
53
+ readonly localFailure?:
54
+ | 'timeout'
55
+ | 'interrupt-request'
56
+ | 'transport'
57
+ | 'rpc'
58
+ | 'validation'
59
+ | 'blocked'
60
+ | 'killed'
61
+ | 'unrecognized-terminal'
62
+ | 'registration';
49
63
  readonly content: string;
50
64
  readonly events: readonly SessionEvent[];
51
65
  readonly tokens?: SessionTokens;
52
66
  }
53
67
  /** A rejected local send can still have a later native terminal; read attempt again to reconcile. */
54
68
  export class SessionTurnError extends Error {
55
- constructor(message: string, private readonly evidence: () => SessionAttempt, options?: ErrorOptions) {
56
- super(message, options); this.name = "SessionTurnError";
69
+ constructor(
70
+ message: string,
71
+ private readonly evidence: () => SessionAttempt,
72
+ options?: ErrorOptions,
73
+ ) {
74
+ super(message, options);
75
+ this.name = 'SessionTurnError';
76
+ }
77
+ get attempt(): SessionAttempt {
78
+ return this.evidence();
57
79
  }
58
- get attempt(): SessionAttempt { return this.evidence(); }
59
80
  }
60
81
 
61
82
  export type SessionEventKind =
62
- | "text_delta"
63
- | "native_status"
64
- | "session_start"
65
- | "session_end"
66
- | "session_compact"
67
- | "text"
68
- | "tool_use"
69
- | "tool_result"
70
- | "thinking"
71
- | "usage"
72
- | "result"
73
- | "error";
83
+ | 'text_delta'
84
+ | 'native_status'
85
+ | 'session_start'
86
+ | 'session_end'
87
+ | 'session_compact'
88
+ | 'text'
89
+ | 'tool_use'
90
+ | 'tool_result'
91
+ | 'thinking'
92
+ | 'usage'
93
+ | 'rate_limit'
94
+ | 'result'
95
+ | 'error';
74
96
 
75
97
  /** Input excludes cache reads/writes when those disjoint counters are supplied.
76
98
  * Thinking is a subset of output; TTL counters are subsets of cacheWrite.
@@ -96,10 +118,21 @@ export interface SessionEvent extends SessionIdentity {
96
118
  * repeated one is not evidence of compaction or restart. "duplicate-boundary": an explicit
97
119
  * compaction boundary whose native uuid was already recorded.
98
120
  */
99
- readonly unattributedReason?: "no-admission" | "foreign-session" | "foreign-turn" | "duplicate-terminal" | "after-terminal"
100
- | "unrecognized-event" | "session-configuration" | "duplicate-boundary" | "duplicate-tool" | "unmatched-tool" | "malformed-tool";
121
+ readonly unattributedReason?:
122
+ | 'no-admission'
123
+ | 'foreign-session'
124
+ | 'foreign-turn'
125
+ | 'duplicate-terminal'
126
+ | 'after-terminal'
127
+ | 'unrecognized-event'
128
+ | 'session-configuration'
129
+ | 'duplicate-boundary'
130
+ | 'duplicate-tool'
131
+ | 'unmatched-tool'
132
+ | 'malformed-tool'
133
+ | 'account-status';
101
134
  /** Local transport observation, not a native terminal acknowledgment. */
102
- readonly transportOutcome?: SessionAttempt["transportOutcome"];
135
+ readonly transportOutcome?: SessionAttempt['transportOutcome'];
103
136
  readonly nativeOutcome?: NativeOutcome;
104
137
  readonly terminal?: NativeTerminal;
105
138
  readonly kind: SessionEventKind;
@@ -137,15 +170,17 @@ export interface SessionEvent extends SessionIdentity {
137
170
  readonly externalSessionId?: string;
138
171
  /** Turn totals on result; request snapshots on usage. Do not sum both. */
139
172
  readonly tokens?: SessionTokens;
173
+ /** Account-level usage limits (for rate_limit). Never attributed to an admission. */
174
+ readonly limits?: LimitSnapshot;
140
175
  /** Owned, deeply frozen native JSON data (for Oracle introspection). */
141
176
  readonly raw?: unknown;
142
177
  }
143
178
 
144
179
  /** Result of a single send() — the turn's content plus all classified events. */
145
180
  export interface SessionResult extends SessionIdentity {
146
- readonly localOutcome?: SessionAttempt["localOutcome"];
147
- readonly transportOutcome?: SessionAttempt["transportOutcome"];
148
- readonly rpcOutcome?: SessionAttempt["rpcOutcome"];
181
+ readonly localOutcome?: SessionAttempt['localOutcome'];
182
+ readonly transportOutcome?: SessionAttempt['transportOutcome'];
183
+ readonly rpcOutcome?: SessionAttempt['rpcOutcome'];
149
184
  readonly nativeOutcome?: NativeOutcome;
150
185
  readonly terminal?: NativeTerminal;
151
186
  readonly content: string;
@@ -164,7 +199,7 @@ export interface SessionArtifact {
164
199
  readonly diagnostics?: SessionDiagnostics;
165
200
  readonly attempts?: readonly SessionAttempt[];
166
201
  /** Legacy totalTokens contains observed counts only, not proof of complete accounting. */
167
- readonly accounting?: "observed-only";
202
+ readonly accounting?: 'observed-only';
168
203
  /** The runtime's native session ID. See SessionEvent.externalSessionId. */
169
204
  readonly externalSessionId?: string;
170
205
  readonly events: readonly SessionEvent[];
@@ -189,9 +224,7 @@ export type SessionEventHandler = (event: SessionEvent) => void;
189
224
  * computes "what's new since the last injection," formats it as a prefix,
190
225
  * and the composed message goes to send().
191
226
  */
192
- export type BeforeSendHook = (
193
- message: string,
194
- ) => string | Promise<string>;
227
+ export type BeforeSendHook = (message: string) => string | Promise<string>;
195
228
 
196
229
  export interface SessionSendOptions {
197
230
  timeout?: number;
@@ -203,13 +236,27 @@ export interface SessionSendOptions {
203
236
  // HarnessSession interface
204
237
  // ---------------------------------------------------------------------------
205
238
 
239
+ /** `acknowledged`: the runtime confirmed the stop and the turn reached a native terminal. */
240
+ export type NativeInterruptOutcome = 'acknowledged' | 'no-turn' | 'unacknowledged';
241
+
206
242
  export interface HarnessSession {
243
+ /** How this session reaches its model, with the capabilities that transport declares. */
244
+ readonly transport?: TransportDescriptor;
245
+ /**
246
+ * Ask the runtime to stop the in-flight turn and wait (bounded) for its
247
+ * acknowledgment. Present only where the transport declares
248
+ * `interrupt: "acknowledged"`. Unlike interrupt(), a confirmed stop settles
249
+ * native ownership through the turn's own terminal.
250
+ */
251
+ interruptNative?(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome>;
252
+ /** Poll account usage limits without a model turn. Present where the transport declares `limits.poll`. */
253
+ readLimits?(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined>;
207
254
  /** One immutable admission snapshot, without copying unrelated session history. */
208
255
  inspectAttempt?(admissionId: string): SessionAttempt | undefined;
209
- readonly admissionProtocol?: "prewrite-v1";
256
+ readonly admissionProtocol?: 'prewrite-v1';
210
257
  readonly diagnostics?: SessionDiagnostics;
211
258
  readonly attempts?: readonly SessionAttempt[];
212
- readonly accounting?: "observed-only";
259
+ readonly accounting?: 'observed-only';
213
260
  readonly alive: boolean;
214
261
  /**
215
262
  * The agent runtime's native session ID — external to Foundry.
package/src/index.ts CHANGED
@@ -1,23 +1,117 @@
1
- // @inixiative/agent-session — drive coding-agent CLIs as persistent,
2
- // streaming, event-captured sessions.
3
-
4
- // The provider-agnostic interface + event taxonomy.
5
- export * from "./harness-session";
1
+ // @inixiative/agent-session — drive coding-agent runtimes as persistent,
2
+ // streaming, event-captured sessions, over interchangeable transports.
6
3
 
4
+ // Claude through the Agent SDK (caller supplies `query`; zero dependencies here).
5
+ export {
6
+ type ClaudeAgentSdkCanUseTool,
7
+ type ClaudeAgentSdkQuery,
8
+ type ClaudeAgentSdkQueryFunction,
9
+ ClaudeAgentSdkSession,
10
+ type ClaudeAgentSdkSessionConfig,
11
+ sdkOptionsFromArgv,
12
+ } from './claude-agent-sdk-session';
7
13
  // Claude Code adapter (persistent stream-json session, subscription auth).
8
14
  export {
15
+ CLAUDE_TEXT_ONLY_ARGS,
9
16
  ClaudeCodeSession,
10
17
  type ClaudeCodeSessionConfig,
11
- } from "./claude-code-session";
18
+ } from './claude-code-session';
19
+ export { type ClaudePrimedConfig, ClaudePrimedSessions } from './claude-primed';
20
+ export { parseClaudeUsage } from './claude-usage';
21
+ export {
22
+ CODEX_DECISION_DISABLED_FEATURES,
23
+ CODEX_DECISION_THREAD_CONFIG,
24
+ type CodexPrimedConfig,
25
+ CodexPrimedSessions,
26
+ codexDecisionLaunchArgs,
27
+ codexTokens,
28
+ } from './codex-primed';
12
29
 
13
30
  // Codex CLI adapter (persistent JSON-RPC session). CodexSession defaults to the
14
- // mcp-server variant; CodexAppServerSession is the experimental app-server one.
31
+ // mcp-server variant; CodexAppServerSession is the app-server one.
15
32
  export {
16
- CodexSession,
17
- CodexMcpSession,
18
33
  CodexAppServerSession,
34
+ CodexMcpSession,
35
+ CodexSession,
19
36
  type CodexSessionConfig,
20
37
  type CodexSpawn,
21
- } from "./codex-session";
22
-
23
- export { parseClaudeUsage } from "./claude-usage";
38
+ } from './codex-session';
39
+ // The provider-agnostic interface + event taxonomy.
40
+ export * from './harness-session';
41
+ export {
42
+ JsonRpcClosedError,
43
+ JsonRpcConnection,
44
+ JsonRpcError,
45
+ JsonRpcTimeoutError,
46
+ } from './json-rpc';
47
+ // Subscription limits, polling, routing and pools.
48
+ export {
49
+ claudeRateLimitSnapshot,
50
+ claudeUsageSnapshot,
51
+ codexLimitSnapshot,
52
+ type LimitRuntime,
53
+ type LimitSnapshot,
54
+ type LimitWindow,
55
+ limitUtilization,
56
+ mergeLimits,
57
+ } from './limits';
58
+ export { type LimitProbeOptions, probeClaudeLimits, probeCodexLimits } from './limits-probe';
59
+ export {
60
+ ContinuityError,
61
+ type ContinuityRefusal,
62
+ type FailureKind,
63
+ type InstanceConfig,
64
+ type InstanceStatus,
65
+ type Lease,
66
+ type PoolEvent,
67
+ PoolExhaustedError,
68
+ type PooledSessionConfig,
69
+ type PooledTransport,
70
+ type PoolRequest,
71
+ SubscriptionPool,
72
+ type SubscriptionPoolOptions,
73
+ } from './pool';
74
+ // Primed decision sessions: one live, warm session per middleware role.
75
+ export {
76
+ type DecisionAdmission,
77
+ DecisionError,
78
+ type DecisionFailure,
79
+ type DecisionRequest,
80
+ type DecisionResult,
81
+ type PrimedEvent,
82
+ type PrimedSessions,
83
+ type PrimedSnapshot,
84
+ type PrimeSpec,
85
+ primeHash,
86
+ } from './primed';
87
+ export {
88
+ assessCandidate,
89
+ type CandidateRequest,
90
+ type ExclusionReason,
91
+ type RankedCandidate,
92
+ type RepositoryRoute,
93
+ type RoutingMode,
94
+ type RoutingRequest,
95
+ rankCandidates,
96
+ rankSubscriptionAccounts,
97
+ repositoryIdentity,
98
+ type SubscriptionCandidate,
99
+ } from './routing';
100
+ // Transports: how a session reaches its model, with declared capabilities.
101
+ export {
102
+ TRANSPORTS,
103
+ type TransportCapabilities,
104
+ type TransportDescriptor,
105
+ type TransportKind,
106
+ type TransportRuntime,
107
+ TransportUnavailableError,
108
+ } from './transport';
109
+ export {
110
+ AcpSession,
111
+ type AcpSessionConfig,
112
+ ApiSession,
113
+ type ApiSessionConfig,
114
+ createSession,
115
+ describeTransport,
116
+ type TransportConfigs,
117
+ } from './transports';
@@ -0,0 +1,240 @@
1
+ // Minimal JSON-RPC 2.0 over a child's stdio, one JSON object per line.
2
+ // Used where a caller multiplexes many threads over one native process
3
+ // (Codex app-server) or makes short control reads (limit polls).
4
+
5
+ import type { PipedSubprocess } from './codex-session';
6
+
7
+ export class JsonRpcError extends Error {
8
+ constructor(
9
+ readonly code: number,
10
+ message: string,
11
+ ) {
12
+ super(message);
13
+ this.name = 'JsonRpcError';
14
+ }
15
+ }
16
+ /** No response within the caller's bound; the request may still be processed. */
17
+ export class JsonRpcTimeoutError extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = 'JsonRpcTimeoutError';
21
+ }
22
+ }
23
+ export class JsonRpcClosedError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = 'JsonRpcClosedError';
27
+ }
28
+ }
29
+
30
+ export interface JsonRpcHandlers {
31
+ onNotification?(method: string, params: Record<string, unknown> | undefined): void;
32
+ /** Return true when the request was answered; unanswered requests are refused (-32601). */
33
+ onRequest?(
34
+ id: string | number,
35
+ method: string,
36
+ params: Record<string, unknown> | undefined,
37
+ ): boolean;
38
+ onClose?(error: Error): void;
39
+ }
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
+
46
+ export class JsonRpcConnection {
47
+ private _id = 0;
48
+ private _pending = new Map<
49
+ number,
50
+ {
51
+ resolve(value: unknown): void;
52
+ reject(error: Error): void;
53
+ timer?: ReturnType<typeof setTimeout>;
54
+ }
55
+ >();
56
+ private _stderr = '';
57
+ private _closeError?: Error;
58
+ readonly exited: Promise<number>;
59
+
60
+ constructor(
61
+ private readonly _proc: PipedSubprocess,
62
+ private readonly _handlers: JsonRpcHandlers = {},
63
+ ) {
64
+ this.exited = _proc.exited;
65
+ void this._read();
66
+ void this._readStderr();
67
+ _proc.exited.then(
68
+ (code) =>
69
+ this._close(
70
+ new JsonRpcClosedError(
71
+ `Native process exited (code ${code})${this._stderr.trim() ? `: ${this._stderr.trim().slice(-300)}` : ''}`,
72
+ ),
73
+ ),
74
+ () => this._close(new JsonRpcClosedError('Native process exit unobserved')),
75
+ );
76
+ }
77
+
78
+ get closed(): boolean {
79
+ return this._closeError !== undefined;
80
+ }
81
+ get stderrTail(): string {
82
+ return this._stderr.slice(-2_000);
83
+ }
84
+
85
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
86
+ if (this._closeError) return Promise.reject(this._closeError);
87
+ const id = ++this._id;
88
+ return new Promise((resolve, reject) => {
89
+ const entry: {
90
+ resolve(value: unknown): void;
91
+ reject(error: Error): void;
92
+ timer?: ReturnType<typeof setTimeout>;
93
+ } = { resolve, reject };
94
+ if (timeoutMs !== undefined && timeoutMs > 0)
95
+ entry.timer = setTimeout(() => {
96
+ this._pending.delete(id);
97
+ reject(new JsonRpcTimeoutError(`${method} timed out after ${timeoutMs}ms`));
98
+ }, timeoutMs);
99
+ this._pending.set(id, entry);
100
+ try {
101
+ this._write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
102
+ } catch (error) {
103
+ clearTimeout(entry.timer);
104
+ this._pending.delete(id);
105
+ reject(error as Error);
106
+ }
107
+ });
108
+ }
109
+
110
+ notify(method: string, params?: unknown): void {
111
+ this._write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) });
112
+ }
113
+
114
+ /** Kill the process. Pending requests reject once its exit is observed. */
115
+ kill(): void {
116
+ try {
117
+ this._proc.stdin.end();
118
+ } catch {
119
+ /* already closed */
120
+ }
121
+ try {
122
+ this._proc.kill();
123
+ } catch {
124
+ /* already dead */
125
+ }
126
+ }
127
+
128
+ private _write(message: unknown): void {
129
+ if (this._closeError) throw this._closeError;
130
+ this._proc.stdin.write(`${JSON.stringify(message)}\n`);
131
+ this._proc.stdin.flush();
132
+ }
133
+
134
+ private _close(error: Error): void {
135
+ if (this._closeError) return;
136
+ this._closeError = error;
137
+ for (const entry of this._pending.values()) {
138
+ clearTimeout(entry.timer);
139
+ entry.reject(error);
140
+ }
141
+ this._pending.clear();
142
+ try {
143
+ this._handlers.onClose?.(error);
144
+ } catch {
145
+ /* observer */
146
+ }
147
+ }
148
+
149
+ private async _read(): Promise<void> {
150
+ const reader = this._proc.stdout.getReader();
151
+ const decoder = new TextDecoder();
152
+ let buffer = '';
153
+ try {
154
+ while (true) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+ buffer += decoder.decode(value, { stream: true });
158
+ const lines = buffer.split('\n');
159
+ buffer = lines.pop()!;
160
+ for (const line of lines) this._line(line);
161
+ }
162
+ this._line(buffer + decoder.decode());
163
+ this._close(
164
+ new JsonRpcClosedError(
165
+ `Native stdout closed${this._stderr.trim() ? `: ${this._stderr.trim().slice(-300)}` : ''}`,
166
+ ),
167
+ );
168
+ } catch (error) {
169
+ this._close(error instanceof Error ? error : new JsonRpcClosedError(String(error)));
170
+ }
171
+ }
172
+
173
+ private async _readStderr(): Promise<void> {
174
+ const reader = this._proc.stderr.getReader();
175
+ const decoder = new TextDecoder();
176
+ try {
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+ if (done) break;
180
+ this._stderr = (this._stderr + decoder.decode(value, { stream: true })).slice(-8_192);
181
+ }
182
+ } catch {
183
+ /* diagnostics only */
184
+ }
185
+ }
186
+
187
+ private _line(line: string): void {
188
+ if (!line.trim()) return;
189
+ let message: Record<string, unknown> | undefined;
190
+ try {
191
+ message = object(JSON.parse(line));
192
+ } catch {
193
+ return;
194
+ }
195
+ if (!message) return;
196
+ const id = message.id,
197
+ method = typeof message.method === 'string' ? message.method : undefined;
198
+ if (method && (typeof id === 'string' || typeof id === 'number')) {
199
+ let answered = false;
200
+ try {
201
+ answered = this._handlers.onRequest?.(id, method, object(message.params)) === true;
202
+ } catch {
203
+ answered = false;
204
+ }
205
+ if (!answered)
206
+ this.respondError(id, -32601, 'Client interaction is unsupported by this adapter');
207
+ return;
208
+ }
209
+ if (method) {
210
+ try {
211
+ this._handlers.onNotification?.(method, object(message.params));
212
+ } catch {
213
+ /* observer */
214
+ }
215
+ return;
216
+ }
217
+ if (typeof id !== 'number') return;
218
+ const entry = this._pending.get(id);
219
+ if (!entry) return;
220
+ this._pending.delete(id);
221
+ clearTimeout(entry.timer);
222
+ const error = object(message.error);
223
+ if (error)
224
+ entry.reject(
225
+ new JsonRpcError(
226
+ typeof error.code === 'number' ? error.code : -32000,
227
+ typeof error.message === 'string' ? error.message : 'JSON-RPC error',
228
+ ),
229
+ );
230
+ else entry.resolve(message.result);
231
+ }
232
+
233
+ respondError(id: string | number, code: number, message: string): void {
234
+ try {
235
+ this._write({ jsonrpc: '2.0', id, error: { code, message } });
236
+ } catch {
237
+ /* closing */
238
+ }
239
+ }
240
+ }
@@ -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
+ }