agent-relay-runner 0.74.0 → 0.75.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.74.0",
3
+ "version": "0.75.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.49"
23
+ "agent-relay-sdk": "0.2.50"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.74.0",
4
+ "version": "0.75.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -74,6 +74,11 @@ interface ThreadLoadedListResponse {
74
74
  nextCursor: string | null;
75
75
  }
76
76
 
77
+ interface AccountRateLimitsResponse {
78
+ rateLimits?: unknown;
79
+ rate_limits?: unknown;
80
+ }
81
+
77
82
  export const CODEX_APP_CLIENT_EVENT_CAP = 5_000;
78
83
 
79
84
  export class CodexAppClient {
@@ -193,6 +198,10 @@ export class CodexAppClient {
193
198
  return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
194
199
  }
195
200
 
201
+ async accountRateLimitsRead(): Promise<AccountRateLimitsResponse> {
202
+ return this.request<AccountRateLimitsResponse>("account/rateLimits/read");
203
+ }
204
+
196
205
  respondToServerRequest(id: JsonRpcId, result: unknown): void {
197
206
  if (!this.connected) {
198
207
  throw new Error("websocket not connected");
package/src/quota.ts ADDED
@@ -0,0 +1,283 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ContextProbeMetrics, QuotaState, QuotaWindowName } from "agent-relay-sdk";
5
+ import { errMessage, isRecord, quotaStateFromProbeMetrics, stringValue } from "agent-relay-sdk";
6
+ import type { ManagedProcess } from "./adapter";
7
+
8
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
9
+ const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
10
+ const QUOTA_POLL_INTERVAL_MS = 15 * 60 * 1000;
11
+ const QUOTA_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000;
12
+
13
+ type CodexQuotaClient = {
14
+ isConnected?: () => boolean;
15
+ accountRateLimitsRead?: () => Promise<unknown>;
16
+ };
17
+
18
+ export class RunnerQuotaPoller {
19
+ private quotaState?: QuotaState;
20
+ private timer?: Timer;
21
+ private inFlight = false;
22
+ private active = false;
23
+ private lastLog?: { key: string; at: number };
24
+
25
+ constructor(private readonly options: {
26
+ provider: string;
27
+ agentId: string;
28
+ getProcess: () => ManagedProcess | undefined;
29
+ onUpdate: () => void;
30
+ log: (message: string) => void;
31
+ }) {}
32
+
33
+ get current(): QuotaState | undefined {
34
+ return this.quotaState;
35
+ }
36
+
37
+ start(): void {
38
+ this.stop();
39
+ if (!quotaProviderSupported(this.options.provider)) return;
40
+ this.active = true;
41
+ void this.refresh();
42
+ }
43
+
44
+ stop(): void {
45
+ this.active = false;
46
+ if (this.timer) clearTimeout(this.timer);
47
+ this.timer = undefined;
48
+ }
49
+
50
+ clear(): void {
51
+ this.quotaState = undefined;
52
+ }
53
+
54
+ private schedule(delayMs = QUOTA_POLL_INTERVAL_MS): void {
55
+ if (this.timer) clearTimeout(this.timer);
56
+ this.timer = undefined;
57
+ if (!this.active || !quotaProviderSupported(this.options.provider)) return;
58
+ this.timer = setTimeout(() => {
59
+ this.timer = undefined;
60
+ void this.refresh();
61
+ }, Math.max(1_000, delayMs));
62
+ }
63
+
64
+ private async refresh(): Promise<void> {
65
+ if (this.inFlight) return;
66
+ this.inFlight = true;
67
+ try {
68
+ const quota = await collectRunnerQuotaState({
69
+ provider: this.options.provider,
70
+ agentId: this.options.agentId,
71
+ process: this.options.getProcess(),
72
+ });
73
+ if (quota && this.active) {
74
+ this.quotaState = quota;
75
+ this.options.onUpdate();
76
+ }
77
+ this.schedule();
78
+ } catch (error) {
79
+ const retryAfterMs = quotaRetryAfterMs(error);
80
+ if (this.active) this.logFailure(error, retryAfterMs);
81
+ this.schedule(retryAfterMs ?? QUOTA_POLL_INTERVAL_MS);
82
+ } finally {
83
+ this.inFlight = false;
84
+ }
85
+ }
86
+
87
+ private logFailure(error: unknown, retryAfterMs: number | undefined): void {
88
+ const key = retryAfterMs !== undefined ? `retry-after:${retryAfterMs}` : errMessage(error);
89
+ const now = Date.now();
90
+ if (this.lastLog?.key === key && now - this.lastLog.at < QUOTA_FAILURE_LOG_INTERVAL_MS) return;
91
+ this.lastLog = { key, at: now };
92
+ const suffix = retryAfterMs !== undefined ? `; retrying in ${Math.round(retryAfterMs / 1000)}s` : "";
93
+ this.options.log(`quota refresh failed${suffix}: ${errMessage(error)}`);
94
+ }
95
+ }
96
+
97
+ export class QuotaRetryAfterError extends Error {
98
+ constructor(readonly retryAfterMs: number, message = "quota source requested retry backoff") {
99
+ super(message);
100
+ this.name = "QuotaRetryAfterError";
101
+ }
102
+ }
103
+
104
+ export function quotaRetryAfterMs(error: unknown): number | undefined {
105
+ return error instanceof QuotaRetryAfterError ? error.retryAfterMs : undefined;
106
+ }
107
+
108
+ export async function collectRunnerQuotaState(input: {
109
+ provider: string;
110
+ agentId: string;
111
+ process?: ManagedProcess;
112
+ }): Promise<QuotaState | undefined> {
113
+ if (input.provider === "claude") return collectClaudeQuotaState({ agentId: input.agentId });
114
+ if (input.provider !== "codex") return undefined;
115
+ const client = input.process?.meta?.client as CodexQuotaClient | undefined;
116
+ if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return undefined;
117
+ return collectCodexQuotaState({
118
+ agentId: codexProcessAgentId(input.process, input.agentId),
119
+ rateLimitsRead: () => client.accountRateLimitsRead!(),
120
+ });
121
+ }
122
+
123
+ export async function collectClaudeQuotaState(options: {
124
+ agentId: string;
125
+ credentialsPath?: string;
126
+ fetchImpl?: typeof fetch;
127
+ now?: number;
128
+ }): Promise<QuotaState | undefined> {
129
+ const accessToken = await readClaudeOAuthAccessToken(options.credentialsPath ?? defaultClaudeCredentialsPath());
130
+ if (!accessToken) return undefined;
131
+ const fetchImpl = options.fetchImpl ?? fetch;
132
+ const response = await fetchImpl(CLAUDE_USAGE_URL, {
133
+ method: "GET",
134
+ headers: {
135
+ authorization: `Bearer ${accessToken}`,
136
+ "anthropic-beta": CLAUDE_OAUTH_BETA,
137
+ },
138
+ signal: AbortSignal.timeout(10_000),
139
+ });
140
+ if (response.status === 429) {
141
+ throw new QuotaRetryAfterError(retryAfterHeaderMs(response.headers.get("retry-after")) ?? 60_000, "Claude quota source returned 429");
142
+ }
143
+ if (!response.ok) return undefined;
144
+ return claudeUsageQuotaState(await response.json(), options.agentId, options.now ?? Date.now());
145
+ }
146
+
147
+ export function claudeUsageQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
148
+ return quotaStateFromProbeMetrics({
149
+ agentId,
150
+ contextPercent: 0,
151
+ quotaWindows: [
152
+ quotaWindowFromAnthropicUsage("five_hour", payload),
153
+ quotaWindowFromAnthropicUsage("seven_day", payload),
154
+ ].filter((window): window is NonNullable<ContextProbeMetrics["quotaWindows"]>[number] => Boolean(window)),
155
+ source: "api",
156
+ confidence: "reported",
157
+ timestamp: now,
158
+ });
159
+ }
160
+
161
+ export async function collectCodexQuotaState(input: {
162
+ agentId: string;
163
+ rateLimitsRead: () => Promise<unknown>;
164
+ now?: number;
165
+ }): Promise<QuotaState | undefined> {
166
+ try {
167
+ return codexRateLimitsQuotaState(await input.rateLimitsRead(), input.agentId, input.now ?? Date.now());
168
+ } catch (error) {
169
+ const retryAfter = codexRetryAfterMs(error);
170
+ if (retryAfter !== undefined) throw new QuotaRetryAfterError(retryAfter, "Codex quota source returned 429");
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ export function codexRateLimitsQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
176
+ const rateLimits = isRecord(payload) && isRecord(payload.rateLimits) ? payload.rateLimits
177
+ : isRecord(payload) && isRecord(payload.rate_limits) ? payload.rate_limits
178
+ : payload;
179
+ return quotaStateFromProbeMetrics({
180
+ agentId,
181
+ contextPercent: 0,
182
+ quotaWindows: [
183
+ quotaWindowFromCodexRateLimit("primary", rateLimits),
184
+ quotaWindowFromCodexRateLimit("secondary", rateLimits),
185
+ ].filter((window): window is NonNullable<ContextProbeMetrics["quotaWindows"]>[number] => Boolean(window)),
186
+ source: "api",
187
+ confidence: "reported",
188
+ timestamp: now,
189
+ });
190
+ }
191
+
192
+ function defaultClaudeCredentialsPath(): string {
193
+ return join(homedir(), ".claude", ".credentials.json");
194
+ }
195
+
196
+ function quotaProviderSupported(provider: string): boolean {
197
+ return provider === "claude" || provider === "codex";
198
+ }
199
+
200
+ function codexProcessAgentId(process: ManagedProcess | undefined, fallback: string): string {
201
+ const config = isRecord(process?.meta?.config) ? process.meta.config : undefined;
202
+ return stringValue(config?.agentId) ?? fallback;
203
+ }
204
+
205
+ async function readClaudeOAuthAccessToken(path: string): Promise<string | undefined> {
206
+ try {
207
+ const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
208
+ const oauth = isRecord(parsed) && isRecord(parsed.claudeAiOauth) ? parsed.claudeAiOauth : undefined;
209
+ const token = oauth?.accessToken;
210
+ return typeof token === "string" && token.trim() ? token : undefined;
211
+ } catch {
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ function quotaWindowFromAnthropicUsage(name: QuotaWindowName, payload: unknown): NonNullable<ContextProbeMetrics["quotaWindows"]>[number] | undefined {
217
+ if (!isRecord(payload)) return undefined;
218
+ const raw = isRecord(payload[name]) ? payload[name] : undefined;
219
+ const utilization = normalizePercentLike(raw?.utilization);
220
+ if (utilization === undefined) return undefined;
221
+ return {
222
+ name,
223
+ utilization,
224
+ ...(resetValueMs(raw?.resets_at) !== undefined ? { resetsAt: resetValueMs(raw?.resets_at)! } : {}),
225
+ unit: "%",
226
+ };
227
+ }
228
+
229
+ function quotaWindowFromCodexRateLimit(name: QuotaWindowName, payload: unknown): NonNullable<ContextProbeMetrics["quotaWindows"]>[number] | undefined {
230
+ if (!isRecord(payload)) return undefined;
231
+ const raw = isRecord(payload[name]) ? payload[name] : undefined;
232
+ const utilization = normalizePercentLike(raw?.usedPercent ?? raw?.used_percent ?? raw?.utilization);
233
+ if (utilization === undefined) return undefined;
234
+ return {
235
+ name,
236
+ utilization,
237
+ ...(resetValueMs(raw?.resetsAt ?? raw?.resets_at) !== undefined ? { resetsAt: resetValueMs(raw?.resetsAt ?? raw?.resets_at)! } : {}),
238
+ unit: "%",
239
+ };
240
+ }
241
+
242
+ function normalizePercentLike(value: unknown): number | undefined {
243
+ const number = numberValue(value);
244
+ if (number === undefined) return undefined;
245
+ const fraction = number > 1 ? number / 100 : number;
246
+ return Math.max(0, Math.min(1, fraction));
247
+ }
248
+
249
+ function resetValueMs(value: unknown): number | undefined {
250
+ const number = numberValue(value);
251
+ if (number !== undefined) return number < 10_000_000_000 ? number * 1000 : number;
252
+ if (typeof value !== "string" || !value.trim()) return undefined;
253
+ const parsed = Date.parse(value);
254
+ return Number.isFinite(parsed) ? parsed : undefined;
255
+ }
256
+
257
+ function retryAfterHeaderMs(value: string | null): number | undefined {
258
+ if (!value) return undefined;
259
+ const seconds = Number(value);
260
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
261
+ const date = Date.parse(value);
262
+ if (!Number.isFinite(date)) return undefined;
263
+ return Math.max(0, date - Date.now());
264
+ }
265
+
266
+ function codexRetryAfterMs(error: unknown): number | undefined {
267
+ const message = errMessage(error);
268
+ if (!/\b429\b/.test(message)) return undefined;
269
+ return retryAfterHeaderMs(retryAfterFromText(message)) ?? 60_000;
270
+ }
271
+
272
+ function retryAfterFromText(message: string): string | null {
273
+ return /retry-after[:=]\s*([^,;\s]+)/i.exec(message)?.[1] ?? null;
274
+ }
275
+
276
+ function numberValue(value: unknown): number | undefined {
277
+ if (typeof value === "number" && Number.isFinite(value)) return value;
278
+ if (typeof value === "string" && value.trim()) {
279
+ const parsed = Number(value);
280
+ return Number.isFinite(parsed) ? parsed : undefined;
281
+ }
282
+ return undefined;
283
+ }
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
- import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
7
+ import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
8
8
  import { providerAttachmentText, providerMessageText } from "./adapter";
9
9
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
10
  import { messagesWithCachedAttachments } from "./attachment-cache";
@@ -24,6 +24,7 @@ import { RunnerInsights } from "./runner-insights";
24
24
  import { BusyReconciler } from "./busy-reconciler";
25
25
  import { publishCapturedResponse } from "./response-capture-report";
26
26
  import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } from "./relay-injection-events";
27
+ import { RunnerQuotaPoller } from "./quota";
27
28
  import { capsFromEnv, contextStateDirFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
28
29
  import {
29
30
  appliedAgentProfileMetadata,
@@ -204,6 +205,7 @@ export class AgentRunner {
204
205
  private readonly sessionOutbox: Outbox;
205
206
  private readonly insights: RunnerInsights;
206
207
  private readonly busyReconciler: BusyReconciler;
208
+ private readonly quotaPoller: RunnerQuotaPoller;
207
209
  private currentToken?: string;
208
210
  private currentTokenJti?: string;
209
211
  private currentTokenProfileId?: string;
@@ -351,6 +353,7 @@ export class AgentRunner {
351
353
  clearProviderTurn: (reason) => this.forceClearProviderTurn(reason),
352
354
  sessionDebug: (message) => this.sessionDebug(message),
353
355
  });
356
+ this.quotaPoller = new RunnerQuotaPoller({ provider: options.provider, agentId: this.agentId, getProcess: () => this.process, onUpdate: () => this.publishStatus(), log: (message) => this.logRunnerDiagnostic(message) });
354
357
  this.bus = new RelayBusClient({
355
358
  url: relayBusUrl(options.relayUrl),
356
359
  role: "provider",
@@ -461,7 +464,7 @@ export class AgentRunner {
461
464
  void this.sweepStaleScratch();
462
465
  this.process = await this.spawnProvider();
463
466
  this.writeRunnerInfoFile();
464
- this.processStartedAt = Date.now();
467
+ this.processStartedAt = Date.now(); this.quotaPoller.start();
465
468
  this.publishStatus();
466
469
  await this.deliverInitialPrompt();
467
470
  await this.bootstrapUnreadMessages();
@@ -493,7 +496,7 @@ export class AgentRunner {
493
496
  if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
494
497
  this.httpLivenessTimer = undefined;
495
498
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
496
- this.tokenRenewTimer = undefined;
499
+ this.tokenRenewTimer = undefined; this.quotaPoller.stop();
497
500
  this.busyReconciler.disarm();
498
501
  this.stopReasoningTail();
499
502
  this.obligationCache.stop();
@@ -934,6 +937,7 @@ export class AgentRunner {
934
937
  if (this.stopped) return;
935
938
  this.process = await this.spawnProvider();
936
939
  this.processStartedAt = Date.now();
940
+ this.quotaPoller.clear(); this.quotaPoller.start();
937
941
  } finally {
938
942
  this.restartInProgress = false;
939
943
  }
@@ -2110,21 +2114,19 @@ export class AgentRunner {
2110
2114
  const probeMetrics = this.latestProbeMetrics();
2111
2115
  const probeContext = probeMetrics ? contextStateFromProbeMetrics(probeMetrics) : undefined;
2112
2116
  const context = processContext ?? probeContext;
2117
+ const quota = this.quotaPoller.current ?? (probeMetrics ? quotaStateFromProbeMetrics(probeMetrics) : undefined);
2113
2118
  const terminalSession = this.providerTerminalSession();
2114
2119
  const terminalSocket = this.providerTerminalSocket();
2115
2120
  const probeModel: ProbeModelInfo | undefined = probeMetrics?.model || probeMetrics?.effort
2116
2121
  ? { model: probeMetrics.model, effort: probeMetrics.effort }
2117
2122
  : undefined;
2118
2123
  const meta: Record<string, unknown> = {
2119
- providerCapabilities: runtimeProviderCapabilities(
2120
- this.options,
2121
- context,
2122
- probeModel,
2123
- ),
2124
+ providerCapabilities: runtimeProviderCapabilities(this.options, context, probeModel, quota),
2124
2125
  ...(terminalSession ? { tmuxSession: terminalSession } : {}),
2125
2126
  ...(terminalSocket ? { tmuxSocket: terminalSocket } : {}),
2126
2127
  };
2127
2128
  if (context) meta.context = context;
2129
+ if (quota) meta.quota = quota;
2128
2130
  return meta;
2129
2131
  }
2130
2132
 
@@ -1,5 +1,5 @@
1
1
  import { closeSync, openSync, readSync, statSync } from "node:fs";
2
- import type { AgentProfile, ContextState, Message, ProviderCapabilities } from "agent-relay-sdk";
2
+ import type { AgentProfile, ContextState, Message, ProviderCapabilities, QuotaState } from "agent-relay-sdk";
3
3
  import { errMessage } from "agent-relay-sdk";
4
4
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
5
5
  import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
@@ -192,7 +192,7 @@ export function lifecycleCapabilities(): Record<string, true> {
192
192
  };
193
193
  }
194
194
 
195
- export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo): ProviderCapabilities {
195
+ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo, quotaState?: QuotaState): ProviderCapabilities {
196
196
  const model = options.model ?? probeModel?.model;
197
197
  const effort = options.effort ?? probeModel?.effort;
198
198
  const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
@@ -225,6 +225,7 @@ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, con
225
225
  lastUpdatedAt: options.startedAt,
226
226
  },
227
227
  ...runtimeProviderContextCapabilities(options, contextState),
228
+ ...runtimeProviderQuotaCapabilities(options, quotaState),
228
229
  ...runtimeProviderTerminalCapabilities(options),
229
230
  liveSession: {
230
231
  capture: true,
@@ -324,6 +325,25 @@ function runtimeProviderContextCapabilities(options: RuntimeProviderOptions, con
324
325
  return Object.keys(context).length ? { context } : {};
325
326
  }
326
327
 
328
+ function runtimeProviderQuotaCapabilities(options: RuntimeProviderOptions, quotaState?: QuotaState): Pick<ProviderCapabilities, "quota"> {
329
+ if (options.provider !== "claude" && options.provider !== "codex") return {};
330
+ const fallbackWindows = options.provider === "claude"
331
+ ? [{ name: "five_hour", unit: "%" as const, reset: true }, { name: "seven_day", unit: "%" as const, reset: true }]
332
+ : [{ name: "primary", unit: "%" as const, reset: true }, { name: "secondary", unit: "%" as const, reset: true }];
333
+ return {
334
+ quota: {
335
+ supported: true,
336
+ source: quotaState?.source ?? "probe",
337
+ unit: quotaState?.windows[0]?.unit ?? "%",
338
+ windows: quotaState?.windows.map((window) => ({
339
+ name: window.name,
340
+ unit: window.unit,
341
+ reset: window.resetsAt !== undefined,
342
+ })) ?? fallbackWindows,
343
+ },
344
+ };
345
+ }
346
+
327
347
  export function providerStateFromActiveWork(activeWork: Array<{ kind: string; metadata?: Record<string, unknown> }>): Record<string, unknown> | null {
328
348
  const providerTurn = activeWork.find((item) => item.kind === "provider-turn");
329
349
  const state = providerTurn?.metadata?.providerState;