@runuai/host 0.9.162 → 0.9.164

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/lib/agent-cli.ts CHANGED
@@ -321,6 +321,7 @@ async function main() {
321
321
  const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
322
322
  const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
323
323
  if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
324
+ if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
324
325
  if (action === "add") {
325
326
  const personaId = pos[0];
326
327
  if (!personaId || !flags.why) {
@@ -332,14 +333,15 @@ async function main() {
332
333
  if (flags.engine) body.kind = flags.engine;
333
334
  if (flags.model) body.model = flags.model;
334
335
  if (flags.effort) body.effort = flags.effort;
336
+ if (flags.account) body.account = flags.account === "default" ? null : flags.account;
335
337
  const r = await api("POST", "/api/agent/crew", body);
336
338
  out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
337
339
  return;
338
340
  }
339
341
  if (action === "model") {
340
342
  const id = handleOf(pos[0] || "");
341
- if (!id || !flags.why || !(flags.model || flags.engine || flags.effort)) {
342
- console.error('uai: crew model needs @agent, at least one of --engine, --model and --effort, and --why "<reason>"');
343
+ if (!id || !flags.why || !(flags.model || flags.engine || flags.effort || flags.account)) {
344
+ console.error('uai: crew model needs @agent, at least one of --engine, --model, --effort and --account, and --why "<reason>"');
343
345
  process.exit(1);
344
346
  }
345
347
  // "default" clears the setting back to the engine's own default (null).
@@ -348,6 +350,7 @@ async function main() {
348
350
  if (flags.engine) body.kind = flags.engine;
349
351
  if (flags.model) body.model = cleared(flags.model);
350
352
  if (flags.effort) body.effort = cleared(flags.effort);
353
+ if (flags.account) body.account = cleared(flags.account);
351
354
  const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
352
355
  out(r.changed
353
356
  ? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
@@ -364,7 +367,7 @@ async function main() {
364
367
  out("removed @" + id);
365
368
  return;
366
369
  }
367
- console.error("uai: crew commands are list, add, model and remove (Astronaut only)");
370
+ console.error("uai: crew commands are list, status, add, model and remove (Astronaut only)");
368
371
  process.exit(1);
369
372
  }
370
373
  const key = group + " " + (action || "");
@@ -570,8 +573,9 @@ async function main() {
570
573
  " uai task read <taskId> (full chat of a past task you were on)",
571
574
  ' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
572
575
  " uai crew list (Astronaut only: personas, engines, models, limits, roster)",
573
- ' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] --why "<reason>"',
574
- ' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] --why "<reason>" (at least one of the three)',
576
+ " uai crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
577
+ ' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] [--account <id|label|default>] --why "<reason>"',
578
+ ' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] [--account <id|label|default>] --why "<reason>"',
575
579
  ' uai crew remove @agent --why "<reason>"',
576
580
  " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
577
581
  " uai whoami",
@@ -23,8 +23,9 @@ 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
+ import { CLAUDE_MODELS } from "../../src/engine-models";
28
29
  import {
29
30
  DISPATCH_INPUT_SCHEMA,
30
31
  DISPATCH_RECIPIENT_ID_PATTERN,
@@ -39,6 +40,7 @@ import { extractResultUsage } from "./usage";
39
40
  import type {
40
41
  AgentEvent,
41
42
  AgentEventHandler,
43
+ AgentAccountUsage,
42
44
  AgentKind,
43
45
  AgentSession,
44
46
  RosterAgent,
@@ -48,7 +50,7 @@ import type {
48
50
  // resolves these aliases to the current dated snapshots, so this list is
49
51
  // stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
50
52
  // retired alias). Order is display order in the cloud picker.
51
- const CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
53
+ // The model list is host-agent/src/engine-models.ts (CLAUDE_MODELS).
52
54
 
53
55
  // Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
54
56
  // Fable 5, the Mythos-class flagship) is opt-in per agent. Update
@@ -251,12 +253,20 @@ function parseLineObject(raw: string): Record<string, unknown> | null {
251
253
  * A malformed line yields []. The mapper is intentionally total and
252
254
  * side-effect free.
253
255
  */
254
- export function mapClaudeLine(raw: string): AgentEvent[] {
256
+ export function mapClaudeLine(raw: string, now: number = Date.now()): AgentEvent[] {
255
257
  const json = parseLineObject(raw);
256
258
  if (!json) return [];
257
259
 
258
260
  const type = json.type;
259
261
 
262
+ // Claude Code 2.1.306 emits this exact stream-json envelope. Its embedded
263
+ // schema defines reset timestamps as Unix seconds and utilization as a
264
+ // fraction (normally 0..1).
265
+ if (type === "rate_limit_event" && isObj(json.rate_limit_info)) {
266
+ const usage = mapClaudeRateLimitInfo(json.rate_limit_info, now);
267
+ return usage ? [{ type: "account_usage", usage }] : [];
268
+ }
269
+
260
270
  // --- streaming text deltas --------------------------------------------
261
271
  if (type === "stream_event" && isObj(json.event)) {
262
272
  const event = json.event;
@@ -303,11 +313,22 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
303
313
  if (json.is_error === true) {
304
314
  // An errored turn still cost tokens — meter it.
305
315
  const message = text || "claude returned an error";
316
+ const accountUsage = rateLimitErrorUsage(message, now);
306
317
  return [
318
+ ...(accountUsage
319
+ ? [{ type: "account_usage", usage: accountUsage } as const]
320
+ : []),
307
321
  // ADR-076: a rate-limit/usage-cap error is failed over to another
308
322
  // account by the orchestrator rather than surfaced to the user.
309
- isRateLimitMessage(message)
310
- ? { type: "error", message, retryable: "rate_limit" }
323
+ accountUsage
324
+ ? {
325
+ type: "error",
326
+ message,
327
+ retryable: "rate_limit",
328
+ ...(accountUsage.resetsAt === undefined
329
+ ? {}
330
+ : { resetsAt: accountUsage.resetsAt }),
331
+ }
311
332
  : { type: "error", message },
312
333
  { type: "turn_complete", usage },
313
334
  ];
@@ -341,6 +362,92 @@ export function mapClaudeLine(raw: string): AgentEvent[] {
341
362
  return [];
342
363
  }
343
364
 
365
+ const CLAUDE_WINDOW_MINUTES: Readonly<Record<string, number>> = {
366
+ five_hour: 5 * 60,
367
+ seven_day: 7 * 24 * 60,
368
+ seven_day_opus: 7 * 24 * 60,
369
+ seven_day_sonnet: 7 * 24 * 60,
370
+ seven_day_overage_included: 7 * 24 * 60,
371
+ };
372
+
373
+ function utilizationPercent(value: unknown): number | undefined {
374
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
375
+ return undefined;
376
+ }
377
+ return Math.min(100, value * 100);
378
+ }
379
+
380
+ /** Map Claude Code 2.1.306's embedded `RateLimitInfo` schema. */
381
+ export function mapClaudeRateLimitInfo(
382
+ info: Record<string, unknown>,
383
+ observedAt: number = Date.now(),
384
+ ): AgentAccountUsage | null {
385
+ const status =
386
+ info.status === "allowed"
387
+ ? "allowed"
388
+ : info.status === "allowed_warning"
389
+ ? "warning"
390
+ : info.status === "rejected"
391
+ ? "limited"
392
+ : null;
393
+ if (!status) return null;
394
+
395
+ const windows: AgentAccountUsage["windows"] = [];
396
+ const seen = new Set<string>();
397
+ if (isObj(info.unifiedWindows)) {
398
+ for (const window of [
399
+ "five_hour",
400
+ "seven_day",
401
+ "seven_day_overage_included",
402
+ ] as const) {
403
+ const value = info.unifiedWindows[window];
404
+ if (!isObj(value)) continue;
405
+ const usedPercent = utilizationPercent(value.utilization);
406
+ const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
407
+ windows.push({
408
+ window,
409
+ windowMinutes: CLAUDE_WINDOW_MINUTES[window],
410
+ ...(usedPercent === undefined ? {} : { usedPercent }),
411
+ ...(resetsAt === undefined ? {} : { resetsAt }),
412
+ });
413
+ seen.add(window);
414
+ }
415
+ }
416
+
417
+ const rateLimitType =
418
+ typeof info.rateLimitType === "string" &&
419
+ info.rateLimitType.length > 0 &&
420
+ info.rateLimitType.length <= 64
421
+ ? info.rateLimitType
422
+ : undefined;
423
+ const resetsAt = resetSecondsToEpochMs(info.resetsAt, observedAt);
424
+ if (rateLimitType && !seen.has(rateLimitType)) {
425
+ const usedPercent = utilizationPercent(info.utilization);
426
+ windows.push({
427
+ window: rateLimitType,
428
+ ...(CLAUDE_WINDOW_MINUTES[rateLimitType] === undefined
429
+ ? {}
430
+ : { windowMinutes: CLAUDE_WINDOW_MINUTES[rateLimitType] }),
431
+ ...(usedPercent === undefined ? {} : { usedPercent }),
432
+ ...(resetsAt === undefined ? {} : { resetsAt }),
433
+ });
434
+ }
435
+
436
+ return {
437
+ status,
438
+ ...(resetsAt === undefined ? {} : { resetsAt }),
439
+ ...(typeof info.isUsingOverage === "boolean"
440
+ ? { usingOverage: info.isUsingOverage }
441
+ : {}),
442
+ windows,
443
+ source: "claude-rate-limit-event",
444
+ observedAt,
445
+ ...(rateLimitType === undefined
446
+ ? {}
447
+ : { coverage: { kind: "window" as const, window: rateLimitType } }),
448
+ };
449
+ }
450
+
344
451
  function safeStringify(v: unknown): string {
345
452
  if (v === undefined) return "";
346
453
  if (typeof v === "string") return v;
@@ -40,33 +40,19 @@ 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
+ import { CODEX_MODELS } from "../../src/engine-models";
45
46
  import type {
46
47
  AgentEvent,
48
+ AgentAccountUsage,
47
49
  AgentEventHandler,
48
50
  AgentKind,
49
51
  AgentSession,
50
52
  RosterAgent,
51
53
  } from "./types";
52
54
 
53
- // Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
54
- // machine-readable "list models" command — the canonical list is the
55
- // interactive `codex` model picker (and `codex -m <slug>` / config.toml).
56
- // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
57
- // in the cloud picker. Legacy models remain reachable via config.toml.
58
- const CODEX_MODELS = [
59
- "gpt-6-astra",
60
- "gpt-5.6-sol",
61
- "gpt-5.6-terra",
62
- "gpt-5.6-luna",
63
- "gpt-5.5",
64
- "gpt-5.4",
65
- "gpt-5.4-mini",
66
- "gpt-5.3-codex",
67
- "gpt-5.3-codex-spark",
68
- "gpt-5.2",
69
- ];
55
+ // The model list is host-agent/src/engine-models.ts (CODEX_MODELS).
70
56
 
71
57
  // gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
72
58
  // Update alongside CODEX_MODELS. When an agent's model is null the adapter
@@ -84,6 +70,11 @@ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
84
70
  // agentic task work). Update alongside CODEX_EFFORTS.
85
71
  const CODEX_DEFAULT_EFFORT = "medium";
86
72
 
73
+ /** Account headroom bands are product policy, not protocol magic numbers. */
74
+ export const CODEX_USAGE_WARNING_PERCENT = 90;
75
+ export const CODEX_USAGE_LIMITED_PERCENT = 100;
76
+ export const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
77
+
87
78
  // ---------------------------------------------------------------------------
88
79
  // Pure protocol mapping — JSON-RPC notification → AgentEvent[].
89
80
  // ---------------------------------------------------------------------------
@@ -112,9 +103,15 @@ const TOOL_ITEM_TYPES = new Set([
112
103
  export function mapCodexNotification(
113
104
  method: string,
114
105
  params: unknown,
106
+ now: number = Date.now(),
115
107
  ): AgentEvent[] {
116
108
  const p = isObj(params) ? params : {};
117
109
 
110
+ if (method === "account/rateLimits/updated") {
111
+ const usage = mapCodexRateLimits(p.rateLimits, now);
112
+ return usage ? [{ type: "account_usage", usage }] : [];
113
+ }
114
+
118
115
  if (method === "item/agentMessage/delta") {
119
116
  const text = str(p.delta);
120
117
  return text ? [{ type: "message_delta", text }] : [];
@@ -143,9 +140,20 @@ export function mapCodexNotification(
143
140
  const turn = isObj(p.turn) ? p.turn : {};
144
141
  if (turn.status === "failed" && isObj(turn.error)) {
145
142
  const message = str(turn.error.message, "codex turn failed");
143
+ const accountUsage = rateLimitErrorUsage(message, now);
146
144
  return [
147
- isRateLimitMessage(message)
148
- ? { type: "error", message, retryable: "rate_limit" }
145
+ ...(accountUsage
146
+ ? [{ type: "account_usage", usage: accountUsage } as const]
147
+ : []),
148
+ accountUsage
149
+ ? {
150
+ type: "error",
151
+ message,
152
+ retryable: "rate_limit",
153
+ ...(accountUsage.resetsAt === undefined
154
+ ? {}
155
+ : { resetsAt: accountUsage.resetsAt }),
156
+ }
149
157
  : { type: "error", message },
150
158
  { type: "turn_complete" },
151
159
  ];
@@ -157,14 +165,88 @@ export function mapCodexNotification(
157
165
  const message = isObj(p.error)
158
166
  ? str(p.error.message, "codex error")
159
167
  : "codex error";
160
- return isRateLimitMessage(message)
161
- ? [{ type: "error", message, retryable: "rate_limit" }]
168
+ const accountUsage = rateLimitErrorUsage(message, now);
169
+ return accountUsage
170
+ ? [
171
+ { type: "account_usage", usage: accountUsage },
172
+ {
173
+ type: "error",
174
+ message,
175
+ retryable: "rate_limit",
176
+ ...(accountUsage.resetsAt === undefined
177
+ ? {}
178
+ : { resetsAt: accountUsage.resetsAt }),
179
+ },
180
+ ]
162
181
  : [{ type: "error", message }];
163
182
  }
164
183
 
165
184
  return [];
166
185
  }
167
186
 
187
+ function codexWindow(
188
+ name: "primary" | "secondary",
189
+ value: unknown,
190
+ observedAt: number,
191
+ ): AgentAccountUsage["windows"][number] | null {
192
+ if (!isObj(value) || typeof value.usedPercent !== "number") return null;
193
+ const usedPercent = Math.min(100, Math.max(0, value.usedPercent));
194
+ if (!Number.isFinite(usedPercent)) return null;
195
+ const windowMinutes =
196
+ typeof value.windowDurationMins === "number" &&
197
+ Number.isSafeInteger(value.windowDurationMins) &&
198
+ value.windowDurationMins > 0
199
+ ? value.windowDurationMins
200
+ : undefined;
201
+ // The 0.157 schema says int64; the live app-server read confirms seconds.
202
+ const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
203
+ return {
204
+ window: name,
205
+ ...(windowMinutes === undefined ? {} : { windowMinutes }),
206
+ usedPercent,
207
+ ...(resetsAt === undefined ? {} : { resetsAt }),
208
+ };
209
+ }
210
+
211
+ /** Map the shared RateLimitSnapshot used by the read response and update
212
+ * notification in Codex app-server 0.157. */
213
+ export function mapCodexRateLimits(
214
+ value: unknown,
215
+ observedAt: number = Date.now(),
216
+ ): AgentAccountUsage | null {
217
+ if (!isObj(value)) return null;
218
+ const windows = (["primary", "secondary"] as const)
219
+ .map((name) => codexWindow(name, value[name], observedAt))
220
+ .filter((window): window is NonNullable<typeof window> => window !== null);
221
+ if (windows.length === 0) return null;
222
+ const limited = windows.filter(
223
+ (window) =>
224
+ (window.usedPercent ?? 0) >= CODEX_USAGE_LIMITED_PERCENT,
225
+ );
226
+ const status =
227
+ limited.length > 0
228
+ ? "limited"
229
+ : windows.some(
230
+ (window) =>
231
+ (window.usedPercent ?? 0) >= CODEX_USAGE_WARNING_PERCENT,
232
+ )
233
+ ? "warning"
234
+ : "allowed";
235
+ const limitedResets = limited
236
+ .map((window) => window.resetsAt)
237
+ .filter((reset): reset is number => reset !== undefined);
238
+ const resetsAt =
239
+ limitedResets.length > 0 ? Math.max(...limitedResets) : undefined;
240
+ return {
241
+ status,
242
+ ...(resetsAt === undefined ? {} : { resetsAt }),
243
+ windows,
244
+ source: "codex-rate-limits",
245
+ observedAt,
246
+ coverage: { kind: "all-windows" },
247
+ };
248
+ }
249
+
168
250
  function codexToolTitle(
169
251
  itemType: string,
170
252
  item: Record<string, unknown>,
@@ -377,10 +459,15 @@ export class CodexSession implements AgentSession {
377
459
  >();
378
460
  /** permission_request id → the JSON-RPC request id to answer with. */
379
461
  private readonly approvals = new Map<string, number | string>();
462
+ /** Fire-and-forget turn/start ids whose error responses end turn polling. */
463
+ private readonly turnStartRpcIds = new Set<number>();
380
464
 
381
465
  private threadId: string | null = null;
382
466
  private readonly ready: Promise<void>;
383
467
  private readonly attachCompatibilityKey: string | undefined;
468
+ private turnRunning = false;
469
+ private lastRateLimitsReadAt = 0;
470
+ private rateLimitsTimer: ReturnType<typeof setTimeout> | null = null;
384
471
 
385
472
  constructor(args: {
386
473
  taskId: string;
@@ -458,6 +545,9 @@ export class CodexSession implements AgentSession {
458
545
  this.proc.onLine((line) => this.onLine(line));
459
546
  this.proc.onExit((code) => {
460
547
  if (this.closed) return;
548
+ this.turnRunning = false;
549
+ this.clearRateLimitsTimer();
550
+ this.turnStartRpcIds.clear();
461
551
  if (code !== 0) {
462
552
  const tail = this.proc.stderrTail.trim();
463
553
  this.emit({
@@ -507,6 +597,13 @@ export class CodexSession implements AgentSession {
507
597
  // Response to one of our requests (id, no method).
508
598
  if (hasId && !("method" in msg)) {
509
599
  const id = typeof msg.id === "number" ? msg.id : -1;
600
+ if (this.turnStartRpcIds.delete(id)) {
601
+ if (isObj(msg.error)) {
602
+ this.turnRunning = false;
603
+ this.clearRateLimitsTimer();
604
+ }
605
+ return;
606
+ }
510
607
  const waiter = this.pending.get(id);
511
608
  if (waiter) {
512
609
  this.pending.delete(id);
@@ -540,6 +637,11 @@ export class CodexSession implements AgentSession {
540
637
  // Notification (method, no id).
541
638
  if ("method" in msg) {
542
639
  const method = str(msg.method);
640
+ if (method === "turn/completed") {
641
+ this.turnRunning = false;
642
+ this.clearRateLimitsTimer();
643
+ this.turnStartRpcIds.clear();
644
+ }
543
645
  // The thread id arrives on `thread/started`; also captured from
544
646
  // the `thread/start` response in handshake().
545
647
  if (method === "thread/started" && isObj(msg.params)) {
@@ -612,13 +714,15 @@ export class CodexSession implements AgentSession {
612
714
  /** Send a JSON-RPC request without awaiting it — for `turn/start`,
613
715
  * whose output streams as notifications and whose response can take
614
716
  * minutes. The eventual response is ignored (no pending waiter). */
615
- private fireRequest(method: string, params: unknown): void {
717
+ private fireRequest(method: string, params: unknown): number {
718
+ const id = this.nextRpcId++;
616
719
  this.proc.writeLine({
617
720
  jsonrpc: "2.0",
618
- id: this.nextRpcId++,
721
+ id,
619
722
  method,
620
723
  params,
621
724
  });
725
+ return id;
622
726
  }
623
727
 
624
728
  private notify(method: string, params: unknown): void {
@@ -654,10 +758,52 @@ export class CodexSession implements AgentSession {
654
758
  // (thread gone/corrupt, or the old writer never released the lock).
655
759
  if (this.resumeThreadId && (await this.tryResumeThread(this.resumeThreadId))) {
656
760
  this.handshakeOk = true;
761
+ this.readRateLimits();
657
762
  return;
658
763
  }
659
764
  await this.startThread();
660
765
  this.handshakeOk = true;
766
+ this.readRateLimits();
767
+ }
768
+
769
+ /** Best-effort account snapshot. It is intentionally outside `ready`: a
770
+ * slow usage endpoint must not delay the agent's first turn. */
771
+ private readRateLimits(): void {
772
+ if (this.closed) return;
773
+ this.lastRateLimitsReadAt = Date.now();
774
+ void this.request("account/rateLimits/read", null)
775
+ .then((result) => {
776
+ const usage =
777
+ isObj(result) && "rateLimits" in result
778
+ ? mapCodexRateLimits(result.rateLimits)
779
+ : null;
780
+ if (usage) this.emit({ type: "account_usage", usage });
781
+ })
782
+ .catch(() => {
783
+ // Usage is advisory. Authentication/turn errors still follow their
784
+ // normal app-server notifications and must not be duplicated here.
785
+ });
786
+ }
787
+
788
+ private clearRateLimitsTimer(): void {
789
+ if (this.rateLimitsTimer !== null) clearTimeout(this.rateLimitsTimer);
790
+ this.rateLimitsTimer = null;
791
+ }
792
+
793
+ private scheduleRateLimitsRead(): void {
794
+ this.clearRateLimitsTimer();
795
+ if (!this.turnRunning || this.closed) return;
796
+ const wait = Math.max(
797
+ 0,
798
+ this.lastRateLimitsReadAt + CODEX_USAGE_POLL_INTERVAL_MS - Date.now(),
799
+ );
800
+ this.rateLimitsTimer = setTimeout(() => {
801
+ this.rateLimitsTimer = null;
802
+ if (!this.turnRunning || this.closed) return;
803
+ this.readRateLimits();
804
+ this.scheduleRateLimitsRead();
805
+ }, wait);
806
+ this.rateLimitsTimer.unref?.();
661
807
  }
662
808
 
663
809
  private async startThread(): Promise<void> {
@@ -758,10 +904,13 @@ export class CodexSession implements AgentSession {
758
904
  // `ready` may have waited across a runtime generation change. Recheck at
759
905
  // the turn/start write rather than trusting session construction time.
760
906
  requireContainerRuntimeOperational();
761
- this.fireRequest("turn/start", {
907
+ const turnStartId = this.fireRequest("turn/start", {
762
908
  threadId: this.threadId,
763
909
  input: [{ type: "text", text }],
764
910
  });
911
+ this.turnStartRpcIds.add(turnStartId);
912
+ this.turnRunning = true;
913
+ this.scheduleRateLimitsRead();
765
914
  }
766
915
 
767
916
  async interrupt(): Promise<void> {
@@ -791,6 +940,9 @@ export class CodexSession implements AgentSession {
791
940
  return;
792
941
  }
793
942
  this.closed = true;
943
+ this.turnRunning = false;
944
+ this.clearRateLimitsTimer();
945
+ this.turnStartRpcIds.clear();
794
946
  for (const waiter of this.pending.values()) {
795
947
  waiter.reject(new Error("session closed"));
796
948
  }
@@ -43,6 +43,7 @@ import {
43
43
  type AgentEnvironmentProcess,
44
44
  } from "./proc";
45
45
  import { register } from "./registry";
46
+ import { GROK_MODELS } from "../../src/engine-models";
46
47
  import type {
47
48
  AgentEvent,
48
49
  AgentEventHandler,
@@ -56,7 +57,7 @@ export const GROK_LOADER_LOGIN_ENV = "0";
56
57
 
57
58
  // `grok models` lists what the account can run. grok-4.5 is the current
58
59
  // default; UPDATE WHEN xAI CHANGES the lineup.
59
- const GROK_MODELS = ["grok-4.5"];
60
+ // The model list is host-agent/src/engine-models.ts (GROK_MODELS).
60
61
  const GROK_DEFAULT_MODEL = "grok-4.5";
61
62
  // Grok has no per-invocation reasoning-effort flag.
62
63
  const GROK_EFFORTS: string[] = [];
@@ -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",