@runuai/host 0.9.161 → 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.
package/lib/agent-cli.ts CHANGED
@@ -183,11 +183,15 @@ export async function writeAgentCliViaEnvironment(
183
183
 
184
184
  /**
185
185
  * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
186
- * task token (carrying only ITS permissions) + the API url. Empty when the agent
187
- * has no permissions (or no owner/url) — unless `allowPermissionless` is set
188
- * for the designated Secretary, whose dispatch route is role-gated rather than
189
- * permission-gated. That token still carries an empty permission set and
190
- * cannot borrow anyone else's powers.
186
+ * task token (carrying only ITS permissions) + the API url. Every agent gets
187
+ * one, permissions or not: it is the agent's identity — the Uai Managed
188
+ * Engine's gateway authenticates and bills by it (ADR-128), and the
189
+ * Secretary's dispatch is role-gated on it. Until 2026-10-20 an agent with no
190
+ * CLI permissions got none, so such a persona could not run on the managed
191
+ * engine at all. An empty-permission token cannot borrow anyone else's powers:
192
+ * every permissioned route refuses it, and the self-scoped ones
193
+ * (`requireAnyPersonaPermission`) do too. Empty only when the task has no
194
+ * owner, API url or CLI secret.
191
195
  */
192
196
  export function agentCliEnv(
193
197
  taskId: string,
@@ -195,18 +199,10 @@ export function agentCliEnv(
195
199
  ownerUserId: string | null,
196
200
  apiUrl: string | null,
197
201
  cliSecret: string | null,
198
- allowPermissionless = false,
199
202
  ): Record<string, string> {
200
203
  const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
201
204
  const permissions = agent.permissions ?? [];
202
- if (
203
- !ownerUserId ||
204
- !apiUrl ||
205
- !cliSecret ||
206
- (permissions.length === 0 && !allowPermissionless)
207
- ) {
208
- return runtimeAuthority;
209
- }
205
+ if (!ownerUserId || !apiUrl || !cliSecret) return runtimeAuthority;
210
206
  return {
211
207
  ...runtimeAuthority,
212
208
  UAI_TASK_TOKEN: signTaskToken(
@@ -498,7 +494,22 @@ async function main() {
498
494
  const port = Number(pos[1] || flags.port);
499
495
  if (!name || !Number.isInteger(port)) { console.error("uai: preview add <name> <containerPort>"); process.exit(1); }
500
496
  const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
501
- out("preview '" + r.name + "' on port " + r.containerPort + " — enable it in the task's preview menu to get a URL");
497
+ out("preview '" + r.name + "' on port " + r.containerPort + " — turn it on with: uai preview enable " + r.name);
498
+ break;
499
+ }
500
+ case "preview enable":
501
+ case "preview disable":
502
+ case "preview public":
503
+ case "preview private": {
504
+ const name = pos[0] || flags.name;
505
+ if (!name) { console.error("uai: preview " + action + " <name>"); process.exit(1); }
506
+ const body = action === "enable" || action === "disable"
507
+ ? { enabled: action === "enable" }
508
+ : { public: action === "public" };
509
+ const r = await api("PATCH", "/api/agent/previews/" + encodeURIComponent(name), body);
510
+ const state = (r.enabled ? "on" : "off") + ", " + (r.public ? "public (anyone with the link)" : "private (sign-in)");
511
+ out("preview '" + r.name + "' is " + state + (r.url ? " — " + r.url : ""));
512
+ if (!r.enabled && r.public) console.error("uai: it's public but off — it serves nothing until: uai preview enable " + r.name);
502
513
  break;
503
514
  }
504
515
  case "project create": {
@@ -551,6 +562,8 @@ async function main() {
551
562
  " uai todo remove <#id>",
552
563
  " uai todo claim|done|reopen|unclaim <#id>",
553
564
  " uai preview add <name> <containerPort>",
565
+ " uai preview enable|disable <name> (turn it on; prints its URL)",
566
+ " uai preview public|private <name> (public: a link anyone can open)",
554
567
  " uai project create --name <n> --repo <git-url> [--prompt <p>]",
555
568
  " uai task search <query> (your own past tasks)",
556
569
  " uai task history (list your past tasks)",
package/lib/agent.ts CHANGED
@@ -157,6 +157,13 @@ const TaskUpData = z.object({
157
157
  export type TaskUpResult = z.infer<typeof TaskUpData>;
158
158
 
159
159
  // task-down
160
+ /** task-down.sh destroys the task's runtime AND its directory (worktree +
161
+ * task-private Git data), so it refuses without this explicit terminal
162
+ * intent. Only the cloud taskDown reaches these two calls: the handler in
163
+ * src/index.ts, and a credential cleanup under that same command
164
+ * (`reason: "teardown"`, task-credential-cleanup.ts). */
165
+ const TASK_DOWN_TERMINAL_ENV = Object.freeze({ UAI_TASK_DOWN_TERMINAL: "1" });
166
+
160
167
  const TaskDownData = z
161
168
  .object({
162
169
  status: z.string().optional(),
@@ -598,6 +605,7 @@ export const agent = {
598
605
  [input.taskId],
599
606
  TaskDownData,
600
607
  commandDbPath,
608
+ TASK_DOWN_TERMINAL_ENV,
601
609
  );
602
610
  } finally {
603
611
  removeCommandDb(commandDbPath);
@@ -617,6 +625,7 @@ export const agent = {
617
625
  [taskId],
618
626
  TaskDownData,
619
627
  commandDbPath,
628
+ TASK_DOWN_TERMINAL_ENV,
620
629
  );
621
630
  } finally {
622
631
  removeCommandDb(commandDbPath);
@@ -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",