@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 +28 -15
- package/lib/agent.ts +9 -0
- package/lib/agents/claude.ts +110 -4
- package/lib/agents/codex.ts +175 -8
- package/lib/agents/opencode.ts +35 -12
- package/lib/agents/rate-limit.ts +299 -3
- package/lib/agents/types.ts +29 -1
- package/lib/agents/uai.ts +3 -4
- package/lib/engine-accounts.ts +82 -4
- package/lib/orchestrator.ts +189 -17
- package/lib/task-credential-cleanup.ts +12 -2
- package/lib/task-environment/machine-task-up.ts +6 -2
- package/lib/task-environment/machine.ts +2 -1
- package/lib/task-environment/types.ts +4 -0
- package/package.json +1 -1
- package/scripts/agent/task-down.sh +13 -0
- package/scripts/agent/task-up.sh +30 -0
- package/src/main.ts +8 -0
- package/src/protocol.ts +55 -0
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.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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 + " —
|
|
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);
|
package/lib/agents/claude.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
310
|
-
? {
|
|
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;
|
package/lib/agents/codex.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
148
|
-
? { type: "
|
|
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
|
-
|
|
161
|
-
|
|
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):
|
|
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
|
|
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
|
}
|
package/lib/agents/opencode.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
spawnEnvironmentProcess,
|
|
45
45
|
type AgentEnvironmentProcess,
|
|
46
46
|
} from "./proc";
|
|
47
|
-
import {
|
|
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(
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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",
|