@inixiative/agent-session 0.2.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.
- package/README.md +77 -6
- package/package.json +10 -4
- package/src/claude-agent-sdk-session.ts +316 -0
- package/src/claude-code-session.ts +584 -207
- package/src/claude-primed.ts +655 -0
- package/src/claude-usage.ts +3 -4
- package/src/codex-primed.ts +1482 -0
- package/src/codex-session.ts +1080 -395
- package/src/harness-session.ts +82 -35
- package/src/index.ts +106 -12
- package/src/json-rpc.ts +240 -0
- package/src/limits-probe.ts +78 -0
- package/src/limits.ts +216 -0
- package/src/pool.ts +652 -0
- package/src/primed.ts +284 -0
- package/src/retained-evidence.ts +2 -2
- package/src/routing.ts +273 -0
- package/src/transport.ts +215 -0
- package/src/transports.ts +97 -0
- package/src/turn-state.ts +55 -25
package/src/harness-session.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
35
|
+
readonly correlation?: 'native-turn' | 'ordered-stream' | 'unknown';
|
|
31
36
|
}
|
|
32
|
-
export type NativeOutcome =
|
|
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:
|
|
44
|
-
readonly dispatch:
|
|
45
|
-
readonly transportOutcome:
|
|
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?:
|
|
48
|
-
readonly localFailure?:
|
|
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(
|
|
56
|
-
|
|
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
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
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?:
|
|
100
|
-
|
|
|
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[
|
|
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[
|
|
147
|
-
readonly transportOutcome?: SessionAttempt[
|
|
148
|
-
readonly rpcOutcome?: SessionAttempt[
|
|
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?:
|
|
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?:
|
|
256
|
+
readonly admissionProtocol?: 'prewrite-v1';
|
|
210
257
|
readonly diagnostics?: SessionDiagnostics;
|
|
211
258
|
readonly attempts?: readonly SessionAttempt[];
|
|
212
|
-
readonly accounting?:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
export
|
|
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';
|
package/src/json-rpc.ts
ADDED
|
@@ -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
|
+
}
|