@runuai/host 0.9.162 → 0.9.163

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.
@@ -23,7 +23,7 @@ import { requireContainerRuntimeOperational } from "../runtime-guard";
23
23
  import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
24
24
  import { newId } from "../ulid";
25
25
  import { createAgentTransport, type LineTransport } from "./transport";
26
- import { isRateLimitMessage } from "./rate-limit";
26
+ import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
27
27
  import { register } from "./registry";
28
28
  import {
29
29
  DISPATCH_INPUT_SCHEMA,
@@ -39,6 +39,7 @@ import { extractResultUsage } from "./usage";
39
39
  import type {
40
40
  AgentEvent,
41
41
  AgentEventHandler,
42
+ AgentAccountUsage,
42
43
  AgentKind,
43
44
  AgentSession,
44
45
  RosterAgent,
@@ -251,12 +252,20 @@ function parseLineObject(raw: string): Record<string, unknown> | null {
251
252
  * A malformed line yields []. The mapper is intentionally total and
252
253
  * side-effect free.
253
254
  */
254
- export function mapClaudeLine(raw: string): AgentEvent[] {
255
+ export function mapClaudeLine(raw: string, now: number = Date.now()): AgentEvent[] {
255
256
  const json = parseLineObject(raw);
256
257
  if (!json) return [];
257
258
 
258
259
  const type = json.type;
259
260
 
261
+ // Claude Code 2.1.306 emits this exact stream-json envelope. Its embedded
262
+ // schema defines reset timestamps as Unix seconds and utilization as a
263
+ // fraction (normally 0..1).
264
+ if (type === "rate_limit_event" && isObj(json.rate_limit_info)) {
265
+ const usage = mapClaudeRateLimitInfo(json.rate_limit_info, now);
266
+ return usage ? [{ type: "account_usage", usage }] : [];
267
+ }
268
+
260
269
  // --- streaming text deltas --------------------------------------------
261
270
  if (type === "stream_event" && isObj(json.event)) {
262
271
  const event = json.event;
@@ -303,11 +312,22 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
303
312
  if (json.is_error === true) {
304
313
  // An errored turn still cost tokens — meter it.
305
314
  const message = text || "claude returned an error";
315
+ const accountUsage = rateLimitErrorUsage(message, now);
306
316
  return [
317
+ ...(accountUsage
318
+ ? [{ type: "account_usage", usage: accountUsage } as const]
319
+ : []),
307
320
  // ADR-076: a rate-limit/usage-cap error is failed over to another
308
321
  // account by the orchestrator rather than surfaced to the user.
309
- isRateLimitMessage(message)
310
- ? { type: "error", message, retryable: "rate_limit" }
322
+ accountUsage
323
+ ? {
324
+ type: "error",
325
+ message,
326
+ retryable: "rate_limit",
327
+ ...(accountUsage.resetsAt === undefined
328
+ ? {}
329
+ : { resetsAt: accountUsage.resetsAt }),
330
+ }
311
331
  : { type: "error", message },
312
332
  { type: "turn_complete", usage },
313
333
  ];
@@ -341,6 +361,92 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
341
361
  return [];
342
362
  }
343
363
 
364
+ const CLAUDE_WINDOW_MINUTES: Readonly<Record<string, number>> = {
365
+ five_hour: 5 * 60,
366
+ seven_day: 7 * 24 * 60,
367
+ seven_day_opus: 7 * 24 * 60,
368
+ seven_day_sonnet: 7 * 24 * 60,
369
+ seven_day_overage_included: 7 * 24 * 60,
370
+ };
371
+
372
+ function utilizationPercent(value: unknown): number | undefined {
373
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
374
+ return undefined;
375
+ }
376
+ return Math.min(100, value * 100);
377
+ }
378
+
379
+ /** Map Claude Code 2.1.306's embedded `RateLimitInfo` schema. */
380
+ export function mapClaudeRateLimitInfo(
381
+ info: Record<string, unknown>,
382
+ observedAt: number = Date.now(),
383
+ ): AgentAccountUsage | null {
384
+ const status =
385
+ info.status === "allowed"
386
+ ? "allowed"
387
+ : info.status === "allowed_warning"
388
+ ? "warning"
389
+ : info.status === "rejected"
390
+ ? "limited"
391
+ : null;
392
+ if (!status) return null;
393
+
394
+ const windows: AgentAccountUsage["windows"] = [];
395
+ const seen = new Set<string>();
396
+ if (isObj(info.unifiedWindows)) {
397
+ for (const window of [
398
+ "five_hour",
399
+ "seven_day",
400
+ "seven_day_overage_included",
401
+ ] as const) {
402
+ const value = info.unifiedWindows[window];
403
+ if (!isObj(value)) continue;
404
+ const usedPercent = utilizationPercent(value.utilization);
405
+ const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
406
+ windows.push({
407
+ window,
408
+ windowMinutes: CLAUDE_WINDOW_MINUTES[window],
409
+ ...(usedPercent === undefined ? {} : { usedPercent }),
410
+ ...(resetsAt === undefined ? {} : { resetsAt }),
411
+ });
412
+ seen.add(window);
413
+ }
414
+ }
415
+
416
+ const rateLimitType =
417
+ typeof info.rateLimitType === "string" &&
418
+ info.rateLimitType.length > 0 &&
419
+ info.rateLimitType.length <= 64
420
+ ? info.rateLimitType
421
+ : undefined;
422
+ const resetsAt = resetSecondsToEpochMs(info.resetsAt, observedAt);
423
+ if (rateLimitType && !seen.has(rateLimitType)) {
424
+ const usedPercent = utilizationPercent(info.utilization);
425
+ windows.push({
426
+ window: rateLimitType,
427
+ ...(CLAUDE_WINDOW_MINUTES[rateLimitType] === undefined
428
+ ? {}
429
+ : { windowMinutes: CLAUDE_WINDOW_MINUTES[rateLimitType] }),
430
+ ...(usedPercent === undefined ? {} : { usedPercent }),
431
+ ...(resetsAt === undefined ? {} : { resetsAt }),
432
+ });
433
+ }
434
+
435
+ return {
436
+ status,
437
+ ...(resetsAt === undefined ? {} : { resetsAt }),
438
+ ...(typeof info.isUsingOverage === "boolean"
439
+ ? { usingOverage: info.isUsingOverage }
440
+ : {}),
441
+ windows,
442
+ source: "claude-rate-limit-event",
443
+ observedAt,
444
+ ...(rateLimitType === undefined
445
+ ? {}
446
+ : { coverage: { kind: "window" as const, window: rateLimitType } }),
447
+ };
448
+ }
449
+
344
450
  function safeStringify(v: unknown): string {
345
451
  if (v === undefined) return "";
346
452
  if (typeof v === "string") return v;
@@ -40,10 +40,11 @@ import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/typ
40
40
  import { newId } from "../ulid";
41
41
  import { attachCompatibilityKeysMatch } from "./attach-compatibility";
42
42
  import { createAgentTransport, type LineTransport } from "./transport";
43
- import { isRateLimitMessage } from "./rate-limit";
43
+ import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
44
44
  import { register } from "./registry";
45
45
  import type {
46
46
  AgentEvent,
47
+ AgentAccountUsage,
47
48
  AgentEventHandler,
48
49
  AgentKind,
49
50
  AgentSession,
@@ -84,6 +85,11 @@ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
84
85
  // agentic task work). Update alongside CODEX_EFFORTS.
85
86
  const CODEX_DEFAULT_EFFORT = "medium";
86
87
 
88
+ /** Account headroom bands are product policy, not protocol magic numbers. */
89
+ export const CODEX_USAGE_WARNING_PERCENT = 90;
90
+ export const CODEX_USAGE_LIMITED_PERCENT = 100;
91
+ export const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
92
+
87
93
  // ---------------------------------------------------------------------------
88
94
  // Pure protocol mapping — JSON-RPC notification → AgentEvent[].
89
95
  // ---------------------------------------------------------------------------
@@ -112,9 +118,15 @@ const TOOL_ITEM_TYPES = new Set([
112
118
  export function mapCodexNotification(
113
119
  method: string,
114
120
  params: unknown,
121
+ now: number = Date.now(),
115
122
  ): AgentEvent[] {
116
123
  const p = isObj(params) ? params : {};
117
124
 
125
+ if (method === "account/rateLimits/updated") {
126
+ const usage = mapCodexRateLimits(p.rateLimits, now);
127
+ return usage ? [{ type: "account_usage", usage }] : [];
128
+ }
129
+
118
130
  if (method === "item/agentMessage/delta") {
119
131
  const text = str(p.delta);
120
132
  return text ? [{ type: "message_delta", text }] : [];
@@ -143,9 +155,20 @@ export function mapCodexNotification(
143
155
  const turn = isObj(p.turn) ? p.turn : {};
144
156
  if (turn.status === "failed" && isObj(turn.error)) {
145
157
  const message = str(turn.error.message, "codex turn failed");
158
+ const accountUsage = rateLimitErrorUsage(message, now);
146
159
  return [
147
- isRateLimitMessage(message)
148
- ? { type: "error", message, retryable: "rate_limit" }
160
+ ...(accountUsage
161
+ ? [{ type: "account_usage", usage: accountUsage } as const]
162
+ : []),
163
+ accountUsage
164
+ ? {
165
+ type: "error",
166
+ message,
167
+ retryable: "rate_limit",
168
+ ...(accountUsage.resetsAt === undefined
169
+ ? {}
170
+ : { resetsAt: accountUsage.resetsAt }),
171
+ }
149
172
  : { type: "error", message },
150
173
  { type: "turn_complete" },
151
174
  ];
@@ -157,14 +180,88 @@ export function mapCodexNotification(
157
180
  const message = isObj(p.error)
158
181
  ? str(p.error.message, "codex error")
159
182
  : "codex error";
160
- return isRateLimitMessage(message)
161
- ? [{ type: "error", message, retryable: "rate_limit" }]
183
+ const accountUsage = rateLimitErrorUsage(message, now);
184
+ return accountUsage
185
+ ? [
186
+ { type: "account_usage", usage: accountUsage },
187
+ {
188
+ type: "error",
189
+ message,
190
+ retryable: "rate_limit",
191
+ ...(accountUsage.resetsAt === undefined
192
+ ? {}
193
+ : { resetsAt: accountUsage.resetsAt }),
194
+ },
195
+ ]
162
196
  : [{ type: "error", message }];
163
197
  }
164
198
 
165
199
  return [];
166
200
  }
167
201
 
202
+ function codexWindow(
203
+ name: "primary" | "secondary",
204
+ value: unknown,
205
+ observedAt: number,
206
+ ): AgentAccountUsage["windows"][number] | null {
207
+ if (!isObj(value) || typeof value.usedPercent !== "number") return null;
208
+ const usedPercent = Math.min(100, Math.max(0, value.usedPercent));
209
+ if (!Number.isFinite(usedPercent)) return null;
210
+ const windowMinutes =
211
+ typeof value.windowDurationMins === "number" &&
212
+ Number.isSafeInteger(value.windowDurationMins) &&
213
+ value.windowDurationMins > 0
214
+ ? value.windowDurationMins
215
+ : undefined;
216
+ // The 0.157 schema says int64; the live app-server read confirms seconds.
217
+ const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
218
+ return {
219
+ window: name,
220
+ ...(windowMinutes === undefined ? {} : { windowMinutes }),
221
+ usedPercent,
222
+ ...(resetsAt === undefined ? {} : { resetsAt }),
223
+ };
224
+ }
225
+
226
+ /** Map the shared RateLimitSnapshot used by the read response and update
227
+ * notification in Codex app-server 0.157. */
228
+ export function mapCodexRateLimits(
229
+ value: unknown,
230
+ observedAt: number = Date.now(),
231
+ ): AgentAccountUsage | null {
232
+ if (!isObj(value)) return null;
233
+ const windows = (["primary", "secondary"] as const)
234
+ .map((name) => codexWindow(name, value[name], observedAt))
235
+ .filter((window): window is NonNullable<typeof window> => window !== null);
236
+ if (windows.length === 0) return null;
237
+ const limited = windows.filter(
238
+ (window) =>
239
+ (window.usedPercent ?? 0) >= CODEX_USAGE_LIMITED_PERCENT,
240
+ );
241
+ const status =
242
+ limited.length > 0
243
+ ? "limited"
244
+ : windows.some(
245
+ (window) =>
246
+ (window.usedPercent ?? 0) >= CODEX_USAGE_WARNING_PERCENT,
247
+ )
248
+ ? "warning"
249
+ : "allowed";
250
+ const limitedResets = limited
251
+ .map((window) => window.resetsAt)
252
+ .filter((reset): reset is number => reset !== undefined);
253
+ const resetsAt =
254
+ limitedResets.length > 0 ? Math.max(...limitedResets) : undefined;
255
+ return {
256
+ status,
257
+ ...(resetsAt === undefined ? {} : { resetsAt }),
258
+ windows,
259
+ source: "codex-rate-limits",
260
+ observedAt,
261
+ coverage: { kind: "all-windows" },
262
+ };
263
+ }
264
+
168
265
  function codexToolTitle(
169
266
  itemType: string,
170
267
  item: Record<string, unknown>,
@@ -377,10 +474,15 @@ export class CodexSession implements AgentSession {
377
474
  >();
378
475
  /** permission_request id → the JSON-RPC request id to answer with. */
379
476
  private readonly approvals = new Map<string, number | string>();
477
+ /** Fire-and-forget turn/start ids whose error responses end turn polling. */
478
+ private readonly turnStartRpcIds = new Set<number>();
380
479
 
381
480
  private threadId: string | null = null;
382
481
  private readonly ready: Promise<void>;
383
482
  private readonly attachCompatibilityKey: string | undefined;
483
+ private turnRunning = false;
484
+ private lastRateLimitsReadAt = 0;
485
+ private rateLimitsTimer: ReturnType<typeof setTimeout> | null = null;
384
486
 
385
487
  constructor(args: {
386
488
  taskId: string;
@@ -458,6 +560,9 @@ export class CodexSession implements AgentSession {
458
560
  this.proc.onLine((line) => this.onLine(line));
459
561
  this.proc.onExit((code) => {
460
562
  if (this.closed) return;
563
+ this.turnRunning = false;
564
+ this.clearRateLimitsTimer();
565
+ this.turnStartRpcIds.clear();
461
566
  if (code !== 0) {
462
567
  const tail = this.proc.stderrTail.trim();
463
568
  this.emit({
@@ -507,6 +612,13 @@ export class CodexSession implements AgentSession {
507
612
  // Response to one of our requests (id, no method).
508
613
  if (hasId && !("method" in msg)) {
509
614
  const id = typeof msg.id === "number" ? msg.id : -1;
615
+ if (this.turnStartRpcIds.delete(id)) {
616
+ if (isObj(msg.error)) {
617
+ this.turnRunning = false;
618
+ this.clearRateLimitsTimer();
619
+ }
620
+ return;
621
+ }
510
622
  const waiter = this.pending.get(id);
511
623
  if (waiter) {
512
624
  this.pending.delete(id);
@@ -540,6 +652,11 @@ export class CodexSession implements AgentSession {
540
652
  // Notification (method, no id).
541
653
  if ("method" in msg) {
542
654
  const method = str(msg.method);
655
+ if (method === "turn/completed") {
656
+ this.turnRunning = false;
657
+ this.clearRateLimitsTimer();
658
+ this.turnStartRpcIds.clear();
659
+ }
543
660
  // The thread id arrives on `thread/started`; also captured from
544
661
  // the `thread/start` response in handshake().
545
662
  if (method === "thread/started" && isObj(msg.params)) {
@@ -612,13 +729,15 @@ export class CodexSession implements AgentSession {
612
729
  /** Send a JSON-RPC request without awaiting it — for `turn/start`,
613
730
  * whose output streams as notifications and whose response can take
614
731
  * minutes. The eventual response is ignored (no pending waiter). */
615
- private fireRequest(method: string, params: unknown): void {
732
+ private fireRequest(method: string, params: unknown): number {
733
+ const id = this.nextRpcId++;
616
734
  this.proc.writeLine({
617
735
  jsonrpc: "2.0",
618
- id: this.nextRpcId++,
736
+ id,
619
737
  method,
620
738
  params,
621
739
  });
740
+ return id;
622
741
  }
623
742
 
624
743
  private notify(method: string, params: unknown): void {
@@ -654,10 +773,52 @@ export class CodexSession implements AgentSession {
654
773
  // (thread gone/corrupt, or the old writer never released the lock).
655
774
  if (this.resumeThreadId && (await this.tryResumeThread(this.resumeThreadId))) {
656
775
  this.handshakeOk = true;
776
+ this.readRateLimits();
657
777
  return;
658
778
  }
659
779
  await this.startThread();
660
780
  this.handshakeOk = true;
781
+ this.readRateLimits();
782
+ }
783
+
784
+ /** Best-effort account snapshot. It is intentionally outside `ready`: a
785
+ * slow usage endpoint must not delay the agent's first turn. */
786
+ private readRateLimits(): void {
787
+ if (this.closed) return;
788
+ this.lastRateLimitsReadAt = Date.now();
789
+ void this.request("account/rateLimits/read", null)
790
+ .then((result) => {
791
+ const usage =
792
+ isObj(result) && "rateLimits" in result
793
+ ? mapCodexRateLimits(result.rateLimits)
794
+ : null;
795
+ if (usage) this.emit({ type: "account_usage", usage });
796
+ })
797
+ .catch(() => {
798
+ // Usage is advisory. Authentication/turn errors still follow their
799
+ // normal app-server notifications and must not be duplicated here.
800
+ });
801
+ }
802
+
803
+ private clearRateLimitsTimer(): void {
804
+ if (this.rateLimitsTimer !== null) clearTimeout(this.rateLimitsTimer);
805
+ this.rateLimitsTimer = null;
806
+ }
807
+
808
+ private scheduleRateLimitsRead(): void {
809
+ this.clearRateLimitsTimer();
810
+ if (!this.turnRunning || this.closed) return;
811
+ const wait = Math.max(
812
+ 0,
813
+ this.lastRateLimitsReadAt + CODEX_USAGE_POLL_INTERVAL_MS - Date.now(),
814
+ );
815
+ this.rateLimitsTimer = setTimeout(() => {
816
+ this.rateLimitsTimer = null;
817
+ if (!this.turnRunning || this.closed) return;
818
+ this.readRateLimits();
819
+ this.scheduleRateLimitsRead();
820
+ }, wait);
821
+ this.rateLimitsTimer.unref?.();
661
822
  }
662
823
 
663
824
  private async startThread(): Promise<void> {
@@ -758,10 +919,13 @@ export class CodexSession implements AgentSession {
758
919
  // `ready` may have waited across a runtime generation change. Recheck at
759
920
  // the turn/start write rather than trusting session construction time.
760
921
  requireContainerRuntimeOperational();
761
- this.fireRequest("turn/start", {
922
+ const turnStartId = this.fireRequest("turn/start", {
762
923
  threadId: this.threadId,
763
924
  input: [{ type: "text", text }],
764
925
  });
926
+ this.turnStartRpcIds.add(turnStartId);
927
+ this.turnRunning = true;
928
+ this.scheduleRateLimitsRead();
765
929
  }
766
930
 
767
931
  async interrupt(): Promise<void> {
@@ -791,6 +955,9 @@ export class CodexSession implements AgentSession {
791
955
  return;
792
956
  }
793
957
  this.closed = true;
958
+ this.turnRunning = false;
959
+ this.clearRateLimitsTimer();
960
+ this.turnStartRpcIds.clear();
794
961
  for (const waiter of this.pending.values()) {
795
962
  waiter.reject(new Error("session closed"));
796
963
  }
@@ -44,7 +44,7 @@ import {
44
44
  spawnEnvironmentProcess,
45
45
  type AgentEnvironmentProcess,
46
46
  } from "./proc";
47
- import { isRateLimitMessage } from "./rate-limit";
47
+ import { rateLimitErrorUsage } from "./rate-limit";
48
48
  import { register } from "./registry";
49
49
  import type {
50
50
  AgentEvent,
@@ -191,7 +191,10 @@ function toolDetail(state: unknown): string {
191
191
  * Map a single OpenCode `run --format json` line. Non-JSON lines and events
192
192
  * with no user-facing meaning (step_start) map to no events. Total + pure.
193
193
  */
194
- export function mapOpencodeLine(line: string): MappedOpencodeLine {
194
+ export function mapOpencodeLine(
195
+ line: string,
196
+ now: number = Date.now(),
197
+ ): MappedOpencodeLine {
195
198
  const trimmed = line.trim();
196
199
  if (!trimmed.startsWith("{")) return { events: [] };
197
200
  let msg: Record<string, unknown>;
@@ -248,12 +251,23 @@ export function mapOpencodeLine(line: string): MappedOpencodeLine {
248
251
  const data = isObj(err.data) ? err.data : {};
249
252
  const message =
250
253
  str(data.message) || str(err.name) || "opencode error";
254
+ const accountUsage = rateLimitErrorUsage(message, now);
251
255
  return {
252
- events: [
253
- isRateLimitMessage(message)
254
- ? { type: "error", message, retryable: "rate_limit" }
255
- : { type: "error", message },
256
- ],
256
+ events: accountUsage
257
+ ? [
258
+ { type: "account_usage", usage: accountUsage },
259
+ {
260
+ type: "error",
261
+ message,
262
+ retryable: "rate_limit",
263
+ ...(accountUsage.resetsAt === undefined
264
+ ? {}
265
+ : { resetsAt: accountUsage.resetsAt }),
266
+ },
267
+ ]
268
+ : [
269
+ { type: "error", message },
270
+ ],
257
271
  sessionId,
258
272
  };
259
273
  }
@@ -421,11 +435,20 @@ export class OpencodeSession implements AgentSession {
421
435
  if (code !== 0 && !sawError) {
422
436
  const tail = stderr.trim().slice(-500);
423
437
  const message = `opencode exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
424
- this.emit(
425
- isRateLimitMessage(message)
426
- ? { type: "error", message, retryable: "rate_limit" }
427
- : { type: "error", message },
428
- );
438
+ const accountUsage = rateLimitErrorUsage(message);
439
+ if (accountUsage) {
440
+ this.emit({ type: "account_usage", usage: accountUsage });
441
+ this.emit({
442
+ type: "error",
443
+ message,
444
+ retryable: "rate_limit",
445
+ ...(accountUsage.resetsAt === undefined
446
+ ? {}
447
+ : { resetsAt: accountUsage.resetsAt }),
448
+ });
449
+ } else {
450
+ this.emit({ type: "error", message });
451
+ }
429
452
  }
430
453
  this.emit({
431
454
  type: "turn_complete",