agent-relay-runner 0.73.1 → 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.73.1",
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.48"
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.73.1",
4
+ "version": "0.75.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,56 @@
1
+ type EmitAgentMessage = (body: string) => void;
2
+
3
+ export class CodexAgentMessageCapture {
4
+ private segments: string[][] = [];
5
+ private currentSegment: string[] = [];
6
+ private readonly buffers = new Map<string, string>();
7
+ private readonly flushedLengths = new Map<string, number>();
8
+
9
+ reset(): void {
10
+ this.segments = [];
11
+ this.currentSegment = [];
12
+ this.buffers.clear();
13
+ this.flushedLengths.clear();
14
+ }
15
+
16
+ appendDelta(itemId: string | undefined, delta: string): void {
17
+ if (!itemId || !delta) return;
18
+ this.buffers.set(itemId, `${this.buffers.get(itemId) ?? ""}${delta}`);
19
+ }
20
+
21
+ completeItem(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage): void {
22
+ this.flushText(itemId, text ?? (itemId ? this.buffers.get(itemId) : undefined), emit);
23
+ if (!itemId) return;
24
+ this.buffers.delete(itemId);
25
+ this.flushedLengths.delete(itemId);
26
+ }
27
+
28
+ closeSegment(emit: EmitAgentMessage): void {
29
+ this.flushPending(emit);
30
+ if (!this.currentSegment.length) return;
31
+ this.segments.push(this.currentSegment);
32
+ this.currentSegment = [];
33
+ }
34
+
35
+ finish(emit: EmitAgentMessage): string | undefined {
36
+ this.closeSegment(emit);
37
+ const finalSegment = this.segments.at(-1);
38
+ this.segments = [];
39
+ return finalSegment?.length ? finalSegment.join("\n\n").trim() : undefined;
40
+ }
41
+
42
+ private flushPending(emit: EmitAgentMessage): void {
43
+ for (const [itemId, text] of [...this.buffers.entries()]) this.flushText(itemId, text, emit);
44
+ }
45
+
46
+ private flushText(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage): void {
47
+ if (!text) return;
48
+ const offset = itemId ? (this.flushedLengths.get(itemId) ?? 0) : 0;
49
+ const chunk = text.slice(offset);
50
+ if (itemId) this.flushedLengths.set(itemId, text.length);
51
+ const body = chunk.trim();
52
+ if (!body) return;
53
+ this.currentSegment.push(body);
54
+ emit(body);
55
+ }
56
+ }
@@ -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");
@@ -10,6 +10,7 @@ import { tomlString } from "../relay-mcp";
10
10
  import { assembleLaunch, bundledCodexSkillDirs, bundledSkillConfigArgs, materializeLaunchAssembly } from "../launch-assembly";
11
11
  import { logger } from "../logger";
12
12
  import type { SessionEvent } from "../session-insights";
13
+ import { CodexAgentMessageCapture } from "./codex-agent-message-capture";
13
14
 
14
15
  /** Relay context prepended to a Codex agent's first turn: the standard relay
15
16
  * blurb plus, when running in an isolated workspace, the deps caveat (#159). */
@@ -49,12 +50,9 @@ export class CodexAdapter implements ProviderAdapter {
49
50
  // Active turn id for the main thread, captured from turn/started so an interrupt
50
51
  // can target the in-flight turn. Cleared on turn/completed.
51
52
  private activeTurnId?: string;
52
- // Assistant message text accumulated across the current turn's agentMessage items,
53
- // flushed as one session response on turn/completed (mirrors Claude's chatCaptureMode).
54
- private turnMessages: string[] = [];
53
+ private readonly agentMessageCapture = new CodexAgentMessageCapture();
55
54
  private readonly itemTextBuffers = new Map<string, string>();
56
55
  private readonly itemTextBufferTypes = new Map<string, string>();
57
- private captureMode: "final" | "full" = "final";
58
56
  // #183/#184: the normalized session-event log for the current process lifetime, fed
59
57
  // from the same completed-item stream that drives the chat mirror. The runner slices
60
58
  // this per-segment (since the last compact/clear/restart) via its own cursor, so we
@@ -81,7 +79,7 @@ export class CodexAdapter implements ProviderAdapter {
81
79
  this.subagentThreads.clear();
82
80
  this.pendingApprovals.clear();
83
81
  this.activeTurnId = undefined;
84
- this.turnMessages = [];
82
+ this.agentMessageCapture.reset();
85
83
  this.itemTextBuffers.clear();
86
84
  this.itemTextBufferTypes.clear();
87
85
  }
@@ -133,7 +131,6 @@ export class CodexAdapter implements ProviderAdapter {
133
131
 
134
132
  async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
135
133
  this.resetProcessState();
136
- this.captureMode = (config.providerConfig as ProviderConfig).chatCaptureMode ?? "final";
137
134
  const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
138
135
  const appServer = Bun.spawn([args.command, ...args.args], {
139
136
  cwd: args.cwd,
@@ -459,8 +456,9 @@ export class CodexAdapter implements ProviderAdapter {
459
456
  } else {
460
457
  const turn = isRecord(params?.turn) ? params.turn : undefined;
461
458
  this.activeTurnId = stringValue(turn?.id);
462
- this.turnMessages = [];
459
+ this.agentMessageCapture.reset();
463
460
  this.itemTextBuffers.clear();
461
+ this.itemTextBufferTypes.clear();
464
462
  this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
465
463
  }
466
464
  }
@@ -492,29 +490,21 @@ export class CodexAdapter implements ProviderAdapter {
492
490
  }
493
491
  }
494
492
 
495
- // Turn one completed Codex thread item into a session-mirror event. agentMessage
496
- // text is accumulated and flushed as a single response on turn/completed; the rest
497
- // (user prompt echo, reasoning, tool steps) is surfaced as it lands.
493
+ // Turn one completed Codex thread item into a session-mirror event. agentMessage text is
494
+ // mirrored as narration as it lands; only the final contiguous assistant segment is repeated
495
+ // as the turn-final response. The rest (user prompt echo, reasoning, tool steps) is surfaced
496
+ // as it lands.
498
497
  private handleCodexItem(item: Record<string, unknown> | undefined): void {
499
498
  if (!item) return;
500
499
  const type = stringValue(item.type);
501
500
  const turnId = this.activeTurnId;
502
501
  const itemId = codexItemId(item);
502
+ if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
503
503
  // A completed non-reasoning item ends the reasoning segment that preceded it — flush the
504
504
  // buffered reasoning first so it lands in transcript order, ahead of this tool/message.
505
505
  if (type !== "reasoning") this.flushBufferedReasoning();
506
506
  if (type === "agentMessage") {
507
- const text = (stringValue(item.text) ?? (itemId ? this.itemTextBuffers.get(itemId) : undefined))?.trim();
508
- if (text) {
509
- this.turnMessages.push(text);
510
- this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
511
- // Stream the assistant text into the trace as narration (Claude parity). The closing
512
- // response bubble at turn end repeats the final block; the dashboard suppresses the
513
- // matching narration so it isn't shown twice.
514
- this.sessionEventCb({ type: "narration", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
515
- }
516
- if (itemId) this.itemTextBuffers.delete(itemId);
517
- if (itemId) this.itemTextBufferTypes.delete(itemId);
507
+ this.agentMessageCapture.completeItem(itemId, stringValue(item.text), (body) => this.emitAgentNarration(body));
518
508
  return;
519
509
  }
520
510
  if (type === "userMessage") {
@@ -549,6 +539,14 @@ export class CodexAdapter implements ProviderAdapter {
549
539
  if (itemId) this.itemTextBufferTypes.delete(itemId);
550
540
  }
551
541
 
542
+ private emitAgentNarration(body: string): void {
543
+ this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
544
+ // Stream the assistant text into the trace as narration (Claude parity). The closing
545
+ // response bubble at turn end repeats the final block; the dashboard suppresses the
546
+ // matching narration so it isn't shown twice.
547
+ this.sessionEventCb({ type: "narration", origin: "provider", body, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
548
+ }
549
+
552
550
  // Emit any buffered reasoning as discrete trace blocks (appended, in transcript order). Codex
553
551
  // streams reasoning as deltas with no reliable completion event, so we flush coarsely at item
554
552
  // and turn boundaries — the signal the Claude reasoning tail uses — never a codex-only timer.
@@ -588,6 +586,7 @@ export class CodexAdapter implements ProviderAdapter {
588
586
  const turnId = this.activeTurnId;
589
587
 
590
588
  if (method.includes("/started") || method.includes(".started")) {
589
+ if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
591
590
  // A new item starting ends the prior reasoning segment — flush it ahead of this step.
592
591
  this.flushBufferedReasoning();
593
592
  const tool = codexToolSummary(type, item ?? params ?? {});
@@ -596,7 +595,14 @@ export class CodexAdapter implements ProviderAdapter {
596
595
  return;
597
596
  }
598
597
 
599
- if (type === "agentMessage" || type === "reasoning" || type === "plan") {
598
+ if (type === "agentMessage") {
599
+ const delta = codexDeltaText(params);
600
+ if (delta) this.agentMessageCapture.appendDelta(itemId, delta);
601
+ return;
602
+ }
603
+
604
+ if (type === "reasoning" || type === "plan") {
605
+ this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
600
606
  const delta = codexDeltaText(params);
601
607
  if (delta && itemId) {
602
608
  this.itemTextBuffers.set(itemId, `${this.itemTextBuffers.get(itemId) ?? ""}${delta}`);
@@ -611,15 +617,7 @@ export class CodexAdapter implements ProviderAdapter {
611
617
  }
612
618
 
613
619
  private flushTurnResponse(): void {
614
- const pendingAgentMessages = [...this.itemTextBuffers.entries()]
615
- .filter(([itemId]) => this.itemTextBufferTypes.get(itemId) === "agentMessage")
616
- .map(([, text]) => text.trim())
617
- .filter(Boolean);
618
- const messages = [...this.turnMessages, ...pendingAgentMessages];
619
- if (!messages.length) return;
620
- const joined = this.captureMode === "full" ? messages.join("\n\n") : messages[messages.length - 1]!;
621
- this.turnMessages = [];
622
- const text = joined.trim();
620
+ const text = this.agentMessageCapture.finish((body) => this.emitAgentNarration(body));
623
621
  if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
624
622
  }
625
623
 
@@ -631,6 +629,7 @@ export class CodexAdapter implements ProviderAdapter {
631
629
  this.pendingApprovals.clear();
632
630
  this.itemTextBuffers.clear();
633
631
  this.itemTextBufferTypes.clear();
632
+ this.agentMessageCapture.reset();
634
633
  this.statusCb({ status: "idle", reason: "provider-turn", id: turnId });
635
634
  }
636
635
 
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;