@runuai/host 0.9.166 → 0.9.167

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.
@@ -32,9 +32,9 @@ ENV ASDF_VERSION=v0.14.1
32
32
 
33
33
  # Qualified agent builds. These are execution-contract pins: per-command env
34
34
  # freshness is requalified before any of them moves.
35
- ENV UAI_CODEX_CLI_VERSION=0.157.0
36
- ENV UAI_CURSOR_AGENT_VERSION=2026.08.25-3e8eec8
37
- ENV UAI_GROK_CLI_VERSION=1.0.67
35
+ ENV UAI_CODEX_CLI_VERSION=0.164.0
36
+ ENV UAI_CURSOR_AGENT_VERSION=2026.10.22-27dcf5f
37
+ ENV UAI_GROK_CLI_VERSION=1.0.70
38
38
 
39
39
  # ---------------------------------------------------------------------------
40
40
  # 1. System packages.
@@ -56,11 +56,11 @@ import type {
56
56
 
57
57
  // The model list is host-agent/src/engine-models.ts (CODEX_MODELS).
58
58
 
59
- // gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
59
+ // gpt-6.1-sol is Codex's current workhorse coding model (the CLI default).
60
60
  // Update alongside CODEX_MODELS. When an agent's model is null the adapter
61
61
  // omits the override entirely and Codex uses the user's own configured
62
62
  // default.
63
- const CODEX_DEFAULT_MODEL = "gpt-5.6-sol";
63
+ const CODEX_DEFAULT_MODEL = "gpt-6.1-sol";
64
64
 
65
65
  // Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
66
66
  // the picker's "Extra high"; gpt-5.6 added "max" and "ultra" (ultra =
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Cursor account room, read from the same dashboard calls the CLI's `/usage`
3
+ * screen uses. The headless stream does not report it.
4
+ *
5
+ * The CLI session access token (not the user API key, and not a team admin
6
+ * key) calls `aiserver.v1.DashboardService/GetPlanInfo` and
7
+ * `GetCurrentPeriodUsage` on api2.cursor.sh. A failure, a missing token, or a
8
+ * shape we do not recognise produces no report. Nothing here logs the token,
9
+ * an email, or a user id.
10
+ */
11
+
12
+ import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
13
+ import type { AgentAccountUsage } from "./types";
14
+
15
+ /** Same lines Codex uses for a window that is full or close to full. */
16
+ export const CURSOR_PERIOD_WARNING_PERCENT = 90;
17
+ export const CURSOR_PERIOD_LIMITED_PERCENT = 100;
18
+
19
+ /** One probe per leased credential, including a failed one. */
20
+ export const CURSOR_PERIOD_PROBE_INTERVAL_MS = 5 * 60 * 1000;
21
+
22
+ /** One bound for the whole probe: reading auth.json and both dashboard calls.
23
+ * A hung read must not hold turn_complete after the CLI has finished. */
24
+ export const CURSOR_PERIOD_PROBE_TIMEOUT_MS = 4_000;
25
+
26
+ export const CURSOR_PERIOD_USAGE_LOG = "cursor period usage unavailable";
27
+
28
+ export const CURSOR_AUTH_JSON = "/home/node/.config/cursor/auth.json";
29
+
30
+ const DASHBOARD_ORIGIN = "https://api2.cursor.sh";
31
+ const WEEKLY_PERIOD = "INCLUDED_USAGE_PERIOD_WEEKLY";
32
+ const WEEK_MINUTES = 7 * 24 * 60;
33
+
34
+ const probedAt = new Map<string, number>();
35
+
36
+ /** Whether this credential may be probed at `now`. */
37
+ export function cursorPeriodProbeDue(credentialId: string, now: number): boolean {
38
+ const at = probedAt.get(credentialId);
39
+ return at === undefined || now - at >= CURSOR_PERIOD_PROBE_INTERVAL_MS;
40
+ }
41
+
42
+ /** Count a probe, successful or not, so the next one waits out the interval. */
43
+ export function markCursorPeriodProbed(credentialId: string, now: number): void {
44
+ probedAt.set(credentialId, now);
45
+ }
46
+
47
+ /** Test hook. Production never clears the gate except by process restart. */
48
+ export function resetCursorPeriodProbes(): void {
49
+ probedAt.clear();
50
+ }
51
+
52
+ /** The CLI session access token from `auth.json`, or null. The raw file is not returned. */
53
+ export function cursorAccessTokenFromAuthJson(text: string): string | null {
54
+ try {
55
+ const parsed: unknown = JSON.parse(text);
56
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
57
+ const token = (parsed as { accessToken?: unknown }).accessToken;
58
+ return typeof token === "string" && token.length > 0 ? token : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function isObj(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function epochMs(value: unknown): number | undefined {
69
+ const n =
70
+ typeof value === "number"
71
+ ? value
72
+ : typeof value === "string" && /^[0-9]+$/.test(value)
73
+ ? Number(value)
74
+ : Number.NaN;
75
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
76
+ }
77
+
78
+ /** A 0–100 share, as the dashboard sends it. Anything else is not a window. */
79
+ function percent(value: unknown): number | undefined {
80
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
81
+ return undefined;
82
+ }
83
+ return value;
84
+ }
85
+
86
+ function onDemandSpend(period: Record<string, unknown>): boolean {
87
+ if (!isObj(period.spendLimitUsage)) return false;
88
+ for (const key of ["individualUsed", "pooledUsed", "totalSpend", "overallUsed"]) {
89
+ const value = period.spendLimitUsage[key];
90
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Map a GetPlanInfo body and a GetCurrentPeriodUsage body into one account
97
+ * observation. Null when the percent fields are missing or not 0–100, so a
98
+ * schema change sends nothing.
99
+ */
100
+ export function mapCursorPeriodUsage(
101
+ plan: unknown,
102
+ period: unknown,
103
+ observedAt: number,
104
+ ): AgentAccountUsage | null {
105
+ if (!isObj(period) || !isObj(period.planUsage)) return null;
106
+ const total = percent(period.planUsage.totalPercentUsed);
107
+ const api = percent(period.planUsage.apiPercentUsed);
108
+ if (total === undefined || api === undefined) return null;
109
+
110
+ const planInfo = isObj(plan) && isObj(plan.planInfo) ? plan.planInfo : {};
111
+ const weekly = planInfo.includedUsagePeriod === WEEKLY_PERIOD;
112
+ const resetsAt = epochMs(planInfo.includedUsageResetsAt);
113
+ const window = (name: string, usedPercent: number) => ({
114
+ window: name,
115
+ ...(weekly ? { windowMinutes: WEEK_MINUTES } : {}),
116
+ usedPercent,
117
+ ...(resetsAt === undefined ? {} : { resetsAt }),
118
+ });
119
+ const windows = [window("included", total), window("api", api)];
120
+ const limited = windows.filter((item) => item.usedPercent >= CURSOR_PERIOD_LIMITED_PERCENT);
121
+ const status =
122
+ limited.length > 0
123
+ ? "limited"
124
+ : windows.some((item) => item.usedPercent >= CURSOR_PERIOD_WARNING_PERCENT)
125
+ ? "warning"
126
+ : "allowed";
127
+ const limitedResets = limited
128
+ .map((item) => item.resetsAt)
129
+ .filter((reset): reset is number => reset !== undefined);
130
+ const usage: AgentAccountUsage = {
131
+ status,
132
+ ...(limitedResets.length > 0 ? { resetsAt: Math.max(...limitedResets) } : {}),
133
+ ...(onDemandSpend(period) ? { usingOverage: true } : {}),
134
+ windows,
135
+ source: "cursor-period-usage",
136
+ observedAt,
137
+ coverage: { kind: "all-windows" },
138
+ };
139
+ return usage;
140
+ }
141
+
142
+ export async function readCursorAuthFromEnvironment(
143
+ environment: TaskEnvironmentAgentSessionSurface,
144
+ signal?: AbortSignal,
145
+ ): Promise<string | null> {
146
+ if (signal?.aborted) return null;
147
+ let child: Awaited<ReturnType<TaskEnvironmentAgentSessionSurface["spawnSession"]>> | undefined;
148
+ const kill = (): void => {
149
+ if (!child) return;
150
+ void child.terminate("SIGKILL").catch(() => {});
151
+ };
152
+ signal?.addEventListener("abort", kill);
153
+ try {
154
+ child = await environment.spawnSession({
155
+ argv: ["cat", "--", CURSOR_AUTH_JSON],
156
+ user: "node",
157
+ projectEnvProfile: "task",
158
+ env: {},
159
+ maxOutputBytes: 32_768,
160
+ });
161
+ if (signal?.aborted) {
162
+ kill();
163
+ return null;
164
+ }
165
+ let text = "";
166
+ for await (const chunk of child.stdout) {
167
+ if (signal?.aborted) {
168
+ kill();
169
+ return null;
170
+ }
171
+ text += Buffer.from(chunk).toString("utf8");
172
+ if (text.length > 32_768) {
173
+ kill();
174
+ return null;
175
+ }
176
+ }
177
+ if (signal?.aborted) {
178
+ kill();
179
+ return null;
180
+ }
181
+ const exit = await child.completion;
182
+ return !signal?.aborted && exit.exitCode === 0 ? text : null;
183
+ } catch {
184
+ return null;
185
+ } finally {
186
+ signal?.removeEventListener("abort", kill);
187
+ }
188
+ }
189
+
190
+ async function postDashboard(
191
+ fetchImpl: typeof fetch,
192
+ token: string,
193
+ method: string,
194
+ signal: AbortSignal,
195
+ ): Promise<unknown | null> {
196
+ if (signal.aborted) return null;
197
+ try {
198
+ const response = await fetchImpl(
199
+ `${DASHBOARD_ORIGIN}/aiserver.v1.DashboardService/${method}`,
200
+ {
201
+ method: "POST",
202
+ headers: {
203
+ Authorization: `Bearer ${token}`,
204
+ "Content-Type": "application/json",
205
+ "Connect-Protocol-Version": "1",
206
+ },
207
+ body: "{}",
208
+ signal,
209
+ },
210
+ );
211
+ if (response.status !== 200) return null;
212
+ return (await response.json()) as unknown;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Read both dashboard methods and map them. Null on a non-200, a timeout, a
220
+ * bad body, or a shape we do not recognise. The token is sent only as the
221
+ * bearer for these two calls.
222
+ */
223
+ export async function readCursorPeriodUsage(args: {
224
+ token: string;
225
+ fetchImpl?: typeof fetch;
226
+ timeoutMs?: number;
227
+ /** When set, this call does not start a second timer. The caller's deadline covers it. */
228
+ signal?: AbortSignal;
229
+ now?: number;
230
+ }): Promise<AgentAccountUsage | null> {
231
+ const fetchImpl = args.fetchImpl ?? fetch;
232
+ const owned = new AbortController();
233
+ const onParentAbort = (): void => owned.abort();
234
+ if (args.signal) {
235
+ if (args.signal.aborted) owned.abort();
236
+ else args.signal.addEventListener("abort", onParentAbort);
237
+ }
238
+ const timer =
239
+ args.signal === undefined
240
+ ? setTimeout(() => owned.abort(), args.timeoutMs ?? CURSOR_PERIOD_PROBE_TIMEOUT_MS)
241
+ : undefined;
242
+ try {
243
+ const [plan, period] = await Promise.all([
244
+ postDashboard(fetchImpl, args.token, "GetPlanInfo", owned.signal),
245
+ postDashboard(fetchImpl, args.token, "GetCurrentPeriodUsage", owned.signal),
246
+ ]);
247
+ if (owned.signal.aborted || plan === null || period === null) return null;
248
+ return mapCursorPeriodUsage(plan, period, args.now ?? Date.now());
249
+ } finally {
250
+ if (timer !== undefined) clearTimeout(timer);
251
+ args.signal?.removeEventListener("abort", onParentAbort);
252
+ }
253
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * CURSOR_API_KEY=… /home/node/.local/bin/cursor-agent -p "<prompt>" \
6
6
  * --output-format stream-json --stream-partial-output --force --trust \
7
- * -m <model> [--resume <sessionId>]
7
+ * --model <model> [--resume <sessionId>]
8
8
  *
9
9
  * One-shot per turn (like Kimi/Grok); sends serialized; continuity via
10
10
  * `--resume <sessionId>` captured from the stream. Cursor's stream-json is
@@ -26,6 +26,9 @@
26
26
  * `--trust` (skip the workspace-trust prompt). Cursor has no system-prompt
27
27
  * flag, so the briefing folds into the first turn's prompt.
28
28
  */
29
+ import { stripVTControlCharacters } from "node:util";
30
+
31
+ import { ENGINE_ACCOUNT_ENV } from "../credential-consumers/engine-anchor";
29
32
  import { requireContainerRuntimeOperational } from "../runtime-guard";
30
33
  import type {
31
34
  TaskEnvironmentAgentSessionSurface,
@@ -38,8 +41,19 @@ import {
38
41
  spawnEnvironmentProcess,
39
42
  type AgentEnvironmentProcess,
40
43
  } from "./proc";
44
+ import {
45
+ CURSOR_PERIOD_PROBE_TIMEOUT_MS,
46
+ CURSOR_PERIOD_USAGE_LOG,
47
+ cursorAccessTokenFromAuthJson,
48
+ cursorPeriodProbeDue,
49
+ markCursorPeriodProbed,
50
+ readCursorAuthFromEnvironment,
51
+ readCursorPeriodUsage,
52
+ } from "./cursor-period-usage";
53
+ import { rateLimitErrorUsage } from "./rate-limit";
41
54
  import { extractResultUsage } from "./usage";
42
55
  import type {
56
+ AgentAccountUsage,
43
57
  AgentEvent,
44
58
  AgentEventHandler,
45
59
  AgentKind,
@@ -57,19 +71,25 @@ const CURSOR_BIN = "/home/node/.local/bin/cursor-agent";
57
71
  export const CURSOR_SANDBOX_ENV_RESTORE =
58
72
  ". /usr/local/share/uai/env-hook.sh; __uai_env_sync";
59
73
 
60
- // A curated slice of Cursor's ~32 models (full ids from `cursor-agent
61
- // --list-models`). `auto` lets Cursor pick; the rest are the frontier. The UI
62
- // also accepts any typed id (unverified round-trip). UPDATE as Cursor's lineup
63
- // changes.
64
- const CURSOR_MODELS = [
65
- "auto",
74
+ // A curated fallback slice of Cursor's signed-in 2026.10.22 catalog. The
75
+ // newest version of each tier comes first; older known-good ids follow.
76
+ export const CURSOR_MODELS = [
77
+ "grok-4.7-high",
78
+ "grok-4.7-high-fast",
66
79
  "composer-2.5",
67
- "claude-opus-4-8-thinking-high",
80
+ "composer-2.5-fast",
81
+ "claude-opus-5-5-high",
82
+ "claude-fable-5-1-thinking-high",
83
+ "claude-sonnet-5-5-high",
68
84
  "gpt-5.6-sol-high",
85
+ "gemini-4-argon-high",
86
+ "kimi-k3-high",
87
+ "auto",
88
+ "claude-opus-4-8-thinking-high",
69
89
  "cursor-grok-4.5-high",
70
90
  "claude-fable-5-thinking-high",
71
91
  ];
72
- const CURSOR_DEFAULT_MODEL = "auto";
92
+ const CURSOR_DEFAULT_MODEL = "grok-4.7-high";
73
93
  const CURSOR_EFFORTS: string[] = []; // effort is baked into the model id
74
94
 
75
95
  // ---------------------------------------------------------------------------
@@ -99,12 +119,30 @@ export interface MappedCursorLine {
99
119
  finalText?: string;
100
120
  sessionId?: string;
101
121
  errorText?: string;
102
- /** Token/cost from the result envelope — Cursor uses the Claude-compatible
103
- * shape (usage + total_cost_usd + modelUsage). ADR-071 metering. */
122
+ /** This model needs paid Cursor credits; the account itself is still usable. */
123
+ creditRefusal?: boolean;
124
+ /** Set when `errorText` is a usage cap. Secret-free; source `error-text`. */
125
+ accountUsage?: AgentAccountUsage;
126
+ /** Tokens from the result envelope. Claude-shaped lines carry cost;
127
+ * cursor-agent 2026.10.x carries camelCase counts and no cost. */
104
128
  usage?: AgentUsage;
105
129
  }
106
130
 
107
- export function mapCursorLine(line: string): MappedCursorLine {
131
+ /** Cursor Premium can refuse one paid-credit model while the same account can
132
+ * still run Cursor's Grok and Composer models. This is not account exhaustion:
133
+ * keep it non-retryable so the orchestrator neither rotates nor cools the
134
+ * account, and give the person an actionable model-specific error. */
135
+ export function cursorCreditsRequiredMessage(message: string): string | null {
136
+ const match = stripVTControlCharacters(message).match(
137
+ /(?:ActionRequiredError:\s*)?([^\r\n]+?)\s+is not included in your weekly usage\b/i,
138
+ );
139
+ const model = match?.[1]?.trim();
140
+ return model
141
+ ? `${model} needs Cursor credits on this plan; use a Grok or Composer model`
142
+ : null;
143
+ }
144
+
145
+ export function mapCursorLine(line: string, now: number = Date.now()): MappedCursorLine {
108
146
  const t = line.trim();
109
147
  if (!t.startsWith("{")) return {};
110
148
  let m: Record<string, unknown>;
@@ -141,15 +179,23 @@ export function mapCursorLine(line: string): MappedCursorLine {
141
179
  }
142
180
  if (m.type === "result") {
143
181
  const errored = m.is_error === true;
182
+ const rawErrorText = errored
183
+ ? typeof m.result === "string"
184
+ ? m.result
185
+ : "cursor turn failed"
186
+ : undefined;
187
+ const creditError = rawErrorText
188
+ ? cursorCreditsRequiredMessage(rawErrorText)
189
+ : null;
190
+ const errorText = creditError ?? rawErrorText;
191
+ const accountUsage = errorText ? rateLimitErrorUsage(errorText, now) : null;
144
192
  return {
145
193
  end: true,
146
194
  sessionId: typeof m.session_id === "string" ? m.session_id : undefined,
147
195
  finalText: typeof m.result === "string" ? m.result : undefined,
148
- errorText: errored
149
- ? typeof m.result === "string"
150
- ? m.result
151
- : "cursor turn failed"
152
- : undefined,
196
+ errorText,
197
+ ...(creditError ? { creditRefusal: true } : {}),
198
+ ...(accountUsage ? { accountUsage } : {}),
153
199
  usage: extractResultUsage(m),
154
200
  };
155
201
  }
@@ -175,6 +221,9 @@ export class CursorSession implements AgentSession {
175
221
  private readonly agentEnv: Record<string, string>;
176
222
  private readonly spawner: Spawner;
177
223
  private readonly apiKey: string;
224
+ private readonly readAuthFile: (signal: AbortSignal) => Promise<string | null>;
225
+ private readonly fetchImpl: typeof fetch;
226
+ private readonly probeTimeoutMs: number;
178
227
 
179
228
  private readonly handlers = new Set<AgentEventHandler>();
180
229
  private sessionId: string | null = null;
@@ -191,6 +240,14 @@ export class CursorSession implements AgentSession {
191
240
  agentEnv?: Record<string, string>;
192
241
  spawner?: Spawner;
193
242
  apiKey?: string;
243
+ /** Reads the CLI's auth.json from the task environment. Tests inject this.
244
+ * The signal aborts when the probe deadline passes; the default reader
245
+ * kills the auth process. */
246
+ readAuthFile?: (signal: AbortSignal) => Promise<string | null>;
247
+ /** Dashboard fetch. Tests inject this. Production uses the host's fetch. */
248
+ fetchImpl?: typeof fetch;
249
+ /** Whole-probe bound, including the auth.json read. Tests shrink it. */
250
+ probeTimeoutMs?: number;
194
251
  }) {
195
252
  this.agentId = args.agent.id;
196
253
  this.environment = args.environment;
@@ -205,6 +262,68 @@ export class CursorSession implements AgentSession {
205
262
  // key is usually absent anyway.
206
263
  this.apiKey =
207
264
  args.apiKey ?? this.agentEnv.CURSOR_API_KEY ?? process.env.CURSOR_API_KEY ?? "";
265
+ this.readAuthFile =
266
+ args.readAuthFile ?? ((signal) => readCursorAuthFromEnvironment(this.environment, signal));
267
+ this.fetchImpl = args.fetchImpl ?? fetch;
268
+ this.probeTimeoutMs = args.probeTimeoutMs ?? CURSOR_PERIOD_PROBE_TIMEOUT_MS;
269
+ }
270
+
271
+ /**
272
+ * One dashboard probe for a leased account, overlapped with the turn. A
273
+ * missing lease, a probe still inside the five-minute gate, or any failure
274
+ * sends nothing. The token never leaves this method except as the bearer
275
+ * of the two dashboard calls.
276
+ *
277
+ * The credential is marked before the first await. Two sessions that start
278
+ * together must not both pass the gate.
279
+ */
280
+ private probePeriodUsage(): Promise<void> {
281
+ const credentialId = this.agentEnv[ENGINE_ACCOUNT_ENV];
282
+ if (!credentialId || this.closed) return Promise.resolve();
283
+ const now = Date.now();
284
+ if (!cursorPeriodProbeDue(credentialId, now)) return Promise.resolve();
285
+ markCursorPeriodProbed(credentialId, now);
286
+ return this.readPeriodUsage();
287
+ }
288
+
289
+ /** Auth read and both dashboard calls share one deadline. On expiry the
290
+ * auth reader is aborted (the default reader kills its process) and this
291
+ * promise settles so turn_complete is not held. */
292
+ private readPeriodUsage(): Promise<void> {
293
+ const controller = new AbortController();
294
+ const timer = setTimeout(() => controller.abort(), this.probeTimeoutMs);
295
+ let finished = false;
296
+ const usagePromise = this.collectPeriodUsage(controller.signal);
297
+ return new Promise((resolve) => {
298
+ const finish = (usage: AgentAccountUsage | null): void => {
299
+ if (finished) return;
300
+ finished = true;
301
+ clearTimeout(timer);
302
+ if (!this.closed) {
303
+ if (!usage) console.warn(CURSOR_PERIOD_USAGE_LOG);
304
+ else this.emit({ type: "account_usage", usage });
305
+ }
306
+ resolve();
307
+ };
308
+ controller.signal.addEventListener("abort", () => finish(null));
309
+ void usagePromise.then(
310
+ (usage) => finish(controller.signal.aborted ? null : usage),
311
+ () => finish(null),
312
+ );
313
+ });
314
+ }
315
+
316
+ private async collectPeriodUsage(signal: AbortSignal): Promise<AgentAccountUsage | null> {
317
+ if (signal.aborted) return null;
318
+ const raw = await this.readAuthFile(signal);
319
+ if (signal.aborted) return null;
320
+ const token = raw ? cursorAccessTokenFromAuthJson(raw) : null;
321
+ if (!token) return null;
322
+ return readCursorPeriodUsage({
323
+ token,
324
+ fetchImpl: this.fetchImpl,
325
+ signal,
326
+ });
208
327
  }
209
328
 
210
329
  onEvent(handler: AgentEventHandler): () => void {
@@ -227,6 +346,7 @@ export class CursorSession implements AgentSession {
227
346
  private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
228
347
  const argv = [
229
348
  CURSOR_BIN,
349
+ "--disable-auto-update",
230
350
  "-p",
231
351
  prompt,
232
352
  "--output-format",
@@ -236,7 +356,9 @@ export class CursorSession implements AgentSession {
236
356
  "--trust", // skip the workspace-trust prompt in headless
237
357
  "--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
238
358
  ];
239
- if (this.model && this.model !== "auto") argv.push("-m", this.model);
359
+ // Long form only: the pinned CLI (2026.08.25) and its successors have no
360
+ // `-m` short flag and exit on it before the turn starts.
361
+ if (this.model && this.model !== "auto") argv.push("--model", this.model);
240
362
  if (this.sessionId) argv.push("--resume", this.sessionId);
241
363
  return {
242
364
  argv: argv as [string, ...string[]],
@@ -266,6 +388,7 @@ export class CursorSession implements AgentSession {
266
388
 
267
389
  const child = this.spawner(this.buildRequest(prompt));
268
390
  this.current = child;
391
+ const periodUsage = this.probePeriodUsage();
269
392
 
270
393
  let acc = "";
271
394
  let sawText = false;
@@ -273,6 +396,13 @@ export class CursorSession implements AgentSession {
273
396
  // Usage rides the result line but turn_complete fires on process exit;
274
397
  // capture it here and attach it below (ADR-071 metering).
275
398
  let turnUsage: AgentUsage | undefined;
399
+ // A result line that already explained the failure owns the error. The
400
+ // exit path must not add a second "cursor exited N" for the same turn.
401
+ // A usage cap is tracked on its own: a generic JSON error must not hide
402
+ // a cap that only shows up on stderr.
403
+ let sawError = false;
404
+ let reportedRateLimit = false;
405
+ let reportedCreditRefusal = false;
276
406
  const consume = (chunk: string): void => {
277
407
  buf += chunk;
278
408
  let nl: number;
@@ -318,7 +448,24 @@ export class CursorSession implements AgentSession {
318
448
  }
319
449
  if (m.end) {
320
450
  if (m.usage) turnUsage = m.usage;
321
- if (m.errorText) this.emit({ type: "error", message: m.errorText });
451
+ if (m.errorText) {
452
+ sawError = true;
453
+ if (m.creditRefusal) reportedCreditRefusal = true;
454
+ if (m.accountUsage) {
455
+ reportedRateLimit = true;
456
+ this.emit({ type: "account_usage", usage: m.accountUsage });
457
+ this.emit({
458
+ type: "error",
459
+ message: m.errorText,
460
+ retryable: "rate_limit",
461
+ ...(m.accountUsage.resetsAt === undefined
462
+ ? {}
463
+ : { resetsAt: m.accountUsage.resetsAt }),
464
+ });
465
+ } else {
466
+ this.emit({ type: "error", message: m.errorText });
467
+ }
468
+ }
322
469
  const finalText = m.finalText ?? acc;
323
470
  if (sawText || finalText) {
324
471
  this.emit({ type: "message_complete", text: finalText });
@@ -340,18 +487,41 @@ export class CursorSession implements AgentSession {
340
487
  if (spawnErr) {
341
488
  this.emit({ type: "error", message: `cursor spawn failed: ${spawnErr}` });
342
489
  } else if (code !== 0) {
343
- const tail = stderr.trim().slice(-500);
344
- this.emit({
345
- type: "error",
346
- message: `cursor exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
347
- });
490
+ // Some cursor-agent errors (e.g. an invalid key) are coloured; strip
491
+ // the escape codes so the cap check and the error a human sees are
492
+ // plain text. A generic result error must not skip this check.
493
+ const plain = stripVTControlCharacters(stderr);
494
+ const tail = plain.trim().slice(-500);
495
+ const creditRefusal = cursorCreditsRequiredMessage(plain);
496
+ const message =
497
+ creditRefusal ??
498
+ `cursor exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
499
+ const accountUsage = reportedRateLimit ? undefined : rateLimitErrorUsage(plain);
500
+ if (accountUsage) {
501
+ this.emit({ type: "account_usage", usage: accountUsage });
502
+ }
503
+ if (!sawError || (creditRefusal && !reportedCreditRefusal)) {
504
+ if (accountUsage) {
505
+ this.emit({
506
+ type: "error",
507
+ message,
508
+ retryable: "rate_limit",
509
+ ...(accountUsage.resetsAt === undefined
510
+ ? {}
511
+ : { resetsAt: accountUsage.resetsAt }),
512
+ });
513
+ } else {
514
+ this.emit({ type: "error", message });
515
+ }
516
+ }
348
517
  }
349
- this.emit({ type: "turn_complete", usage: turnUsage });
350
518
  resolve();
351
519
  };
352
520
  child.on("exit", (code) => finish(code));
353
521
  child.on("error", (err) => finish(null, err.message));
354
522
  });
523
+ await periodUsage;
524
+ if (!this.closed) this.emit({ type: "turn_complete", usage: turnUsage });
355
525
  }
356
526
 
357
527
  async interrupt(): Promise<void> {
@@ -44,6 +44,7 @@ import {
44
44
  spawnEnvironmentProcess,
45
45
  type AgentEnvironmentProcess,
46
46
  } from "./proc";
47
+ import { rateLimitErrorUsage } from "./rate-limit";
47
48
  import { register } from "./registry";
48
49
  import { GROK_MODELS } from "../../src/engine-models";
49
50
  import type {
@@ -58,10 +59,9 @@ import type {
58
59
  const GROK_BIN = "/home/node/.grok/bin/grok";
59
60
  export const GROK_LOADER_LOGIN_ENV = "0";
60
61
 
61
- // `grok models` lists what the account can run. grok-4.5 is the current
62
- // default; UPDATE WHEN xAI CHANGES the lineup.
63
- // The model list is host-agent/src/engine-models.ts (GROK_MODELS).
64
- const GROK_DEFAULT_MODEL = "grok-4.5";
62
+ // Static fallback catalog verified against `grok models`; keep the shared list
63
+ // in host-agent/src/engine-models.ts (GROK_MODELS) current with the pinned CLI.
64
+ const GROK_DEFAULT_MODEL = "grok-4.7";
65
65
  // Grok has no per-invocation reasoning-effort flag.
66
66
  const GROK_EFFORTS: string[] = [];
67
67
 
@@ -313,10 +313,23 @@ export class GrokSession implements AgentSession {
313
313
  this.emit({ type: "error", message: `grok spawn failed: ${spawnErr}` });
314
314
  } else if (code !== 0) {
315
315
  const tail = stderr.trim().slice(-500);
316
- this.emit({
317
- type: "error",
318
- message: `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
319
- });
316
+ const message = `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
317
+ // Same path as Claude/Codex/OpenCode: a usage-cap / 429 error emits
318
+ // account_usage (source error-text) so the orchestrator can rotate.
319
+ const accountUsage = rateLimitErrorUsage(message);
320
+ if (accountUsage) {
321
+ this.emit({ type: "account_usage", usage: accountUsage });
322
+ this.emit({
323
+ type: "error",
324
+ message,
325
+ retryable: "rate_limit",
326
+ ...(accountUsage.resetsAt === undefined
327
+ ? {}
328
+ : { resetsAt: accountUsage.resetsAt }),
329
+ });
330
+ } else {
331
+ this.emit({ type: "error", message });
332
+ }
320
333
  }
321
334
  // Finalize the streamed message (no-op text if the turn produced none).
322
335
  if (sawText) this.emit({ type: "message_complete", text: acc });
@@ -144,25 +144,25 @@ export function capabilities(): AgentKindCapability[] {
144
144
  : (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
145
145
  )
146
146
  .map((adapter) => {
147
- const out: AgentKindCapability = {
148
- kind: adapter.kind,
149
- label: adapter.label,
150
- supportedModels: adapter.supportedModels(),
151
- supportedEfforts: adapter.supportedEfforts(),
152
- };
153
- if (adapter.defaultModel !== undefined) {
154
- out.defaultModel = adapter.defaultModel;
155
- }
156
- if (adapter.defaultEffort !== undefined) {
157
- out.defaultEffort = adapter.defaultEffort;
158
- }
159
- if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
160
- out.executionProfiles = adapter.executionProfiles.map((profile) => ({
161
- ...profile,
162
- }));
163
- }
164
- return out;
165
- });
147
+ const out: AgentKindCapability = {
148
+ kind: adapter.kind,
149
+ label: adapter.label,
150
+ supportedModels: adapter.supportedModels(),
151
+ supportedEfforts: adapter.supportedEfforts(),
152
+ };
153
+ if (adapter.defaultModel !== undefined) {
154
+ out.defaultModel = adapter.defaultModel;
155
+ }
156
+ if (adapter.defaultEffort !== undefined) {
157
+ out.defaultEffort = adapter.defaultEffort;
158
+ }
159
+ if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
160
+ out.executionProfiles = adapter.executionProfiles.map((profile) => ({
161
+ ...profile,
162
+ }));
163
+ }
164
+ return out;
165
+ });
166
166
  }
167
167
 
168
168
  /**
@@ -124,7 +124,7 @@ export interface AgentAccountUsage {
124
124
  resetsAt?: number;
125
125
  usingOverage?: boolean;
126
126
  windows: EngineUsageWindow[];
127
- source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
127
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text" | "cursor-period-usage";
128
128
  observedAt: number;
129
129
  /** Which provider capacity this reading can prove usable again. Claude emits
130
130
  * one event per named window; Codex snapshots cover every window. Internal
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Shared token/cost extraction for the Claude-Code-compatible stream-json
3
- * `result` envelope (ADR-071 metering). Claude Code AND Cursor Agent both emit
4
- * this exact shape — `usage` token breakdown, an exact `total_cost_usd`, and
5
- * per-model `modelUsage` — so both engines share this one extractor.
2
+ * Shared token/cost extraction for a stream-json `result` envelope (ADR-071
3
+ * metering). Claude Code reports snake_case `usage` plus `total_cost_usd` and
4
+ * per-model `modelUsage`. cursor-agent 2026.10.x reports camelCase counts and
5
+ * no cost: `{inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens}`.
6
+ * Snake_case wins when a field is present, so Claude's path is unchanged.
6
7
  *
7
8
  * Codex (app-server protocol) and Kimi/Grok (plain text streams) report no
8
- * token/cost data, so they have no extractor.
9
+ * token/cost data through this extractor.
9
10
  */
10
11
 
11
12
  import type { AgentUsage } from "./types";
@@ -18,6 +19,12 @@ function num(v: unknown): number | undefined {
18
19
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
19
20
  }
20
21
 
22
+ /** Claude's name when it is a number, otherwise Cursor 2026.10's. */
23
+ function firstNum(claude: unknown, cursor: unknown): number | undefined {
24
+ const primary = num(claude);
25
+ return primary !== undefined ? primary : num(cursor);
26
+ }
27
+
21
28
  /** Pull AgentUsage from a `result` line, or undefined when none is reported. */
22
29
  export function extractResultUsage(
23
30
  json: Record<string, unknown>,
@@ -26,10 +33,10 @@ export function extractResultUsage(
26
33
  const models = isObj(json.modelUsage) ? Object.keys(json.modelUsage) : [];
27
34
  const usage: AgentUsage = {
28
35
  model: models.length ? models.join(", ") : undefined,
29
- inputTokens: num(u.input_tokens),
30
- outputTokens: num(u.output_tokens),
31
- cacheReadTokens: num(u.cache_read_input_tokens),
32
- cacheCreateTokens: num(u.cache_creation_input_tokens),
36
+ inputTokens: firstNum(u.input_tokens, u.inputTokens),
37
+ outputTokens: firstNum(u.output_tokens, u.outputTokens),
38
+ cacheReadTokens: firstNum(u.cache_read_input_tokens, u.cacheReadTokens),
39
+ cacheCreateTokens: firstNum(u.cache_creation_input_tokens, u.cacheWriteTokens),
33
40
  costUsd: num(json.total_cost_usd),
34
41
  };
35
42
  // Nothing usable reported → omit rather than send an empty object.
@@ -5409,12 +5409,26 @@ export class Orchestrator {
5409
5409
  // ADR-076: a turn that completed means the current account is healthy —
5410
5410
  // reset its rotation budget so a LATER rate-limit gets a fresh failover.
5411
5411
  channel.rotations.delete(agentId);
5412
+ // Name the leased AI account on the turn usage report so the cloud
5413
+ // ledger can roll up observed spend per credential (host 0.9.167+).
5414
+ // Only a cloud-leased account identity is reportable; runner-default
5415
+ // and Managed Engine identities stay unnamed (same rule as account_usage).
5416
+ const leasedIdentity = channel.leasedIdentityByAgent.get(agentId);
5417
+ const usage =
5418
+ event.usage === undefined
5419
+ ? undefined
5420
+ : {
5421
+ ...event.usage,
5422
+ ...(leasedIdentity?.subject === "account"
5423
+ ? { credentialId: leasedIdentity.credentialId }
5424
+ : {}),
5425
+ };
5412
5426
  this.emitHost({
5413
5427
  kind: "agent.turn_complete",
5414
5428
  taskId: channel.taskId,
5415
5429
  agentId,
5416
5430
  aborted,
5417
- usage: event.usage,
5431
+ usage,
5418
5432
  });
5419
5433
  if (remainingTurns === 0) {
5420
5434
  channel.lastPrompt.delete(agentId);
@@ -5487,7 +5501,12 @@ export class Orchestrator {
5487
5501
  /** An engine error can race a live launch edit after a partial turn. Keep
5488
5502
  * the obsolete adapter owned until the stale recycler closes it, but close
5489
5503
  * the cloud turn boundary immediately so restarting/ready cannot leave the
5490
- * crew UI stuck in a working state. */
5504
+ * crew UI stuck in a working state.
5505
+ *
5506
+ * The error is still reported, as the exit notice every other engine error
5507
+ * sends: whatever the agent was doing is lost, and without it nobody (the
5508
+ * human, or a Secretary waiting on the agent) was told why. Sent before the
5509
+ * reconcile below, so the cloud sees it ahead of the respawn's `restarting`. */
5491
5510
  private reconcileErroredRosterChange(
5492
5511
  channel: Channel,
5493
5512
  agentId: string,
@@ -5501,6 +5520,12 @@ export class Orchestrator {
5501
5520
  if (change) this.publishRosterChangeAbort(channel, agentId, change);
5502
5521
  else this.emitRetiredTurnAbort(channel, agentId);
5503
5522
  }
5523
+ this.emitHost({
5524
+ kind: "agent.exit",
5525
+ taskId: channel.taskId,
5526
+ agentId,
5527
+ reason,
5528
+ });
5504
5529
  void this.reconcileAfterRosterRefresh(channel).catch((err: unknown) => {
5505
5530
  console.warn(
5506
5531
  `[orchestrator] ${channel.taskId}/${agentId}: roster-change error (${reason}) reconcile failed: ${
@@ -67,7 +67,7 @@ export const ASDF_DATA_VOLUME = "uai-asdf-data";
67
67
  export const STANDARD_DEFAULT_NODE_VERSION = "22.11.0";
68
68
 
69
69
  /** Exact Codex CLI release exposed to task runtimes through the shared volume. */
70
- export const STANDARD_CODEX_CLI_VERSION = "0.157.0";
70
+ export const STANDARD_CODEX_CLI_VERSION = "0.164.0";
71
71
 
72
72
  /**
73
73
  * Runtimes the standard image's asdf plugins can satisfy, with a sensible set
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.166",
3
+ "version": "0.9.167",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -5,20 +5,21 @@
5
5
  * here has no guide entry. Pure data: no runtime imports.
6
6
  */
7
7
 
8
- // Claude Code's aliases, which follow its own model table (2.1.307:
8
+ // Claude Code's aliases, which follow its own model table (2.1.310:
9
9
  // fable = Claude Fable 5.1, opus = Opus 5.5, sonnet = Sonnet 5.5,
10
10
  // haiku = Haiku 5.5). Update alongside the CLI.
11
11
  export const CLAUDE_MODELS: readonly string[] = ["fable", "opus", "sonnet", "haiku"];
12
12
 
13
- // Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
14
- // machine-readable "list models" command — the canonical list is the
15
- // interactive `codex` model picker (and `codex -m <slug>` / config.toml).
16
- // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
17
- // in the cloud picker. Legacy models remain reachable via config.toml.
13
+ // Codex model slugs accepted via `-c model=<slug>`. Keep the newest version of
14
+ // each tier first; older models remain available at the end.
18
15
  export const CODEX_MODELS: readonly string[] = [
19
16
  "gpt-6-astra",
20
- "gpt-5.6-sol",
17
+ "gpt-6.1-sol",
18
+ // OpenAI has not released a GPT-6 Terra; 5.6 remains the balanced tier.
21
19
  "gpt-5.6-terra",
20
+ "gpt-6-luna",
21
+ "gpt-6-sol",
22
+ "gpt-5.6-sol",
22
23
  "gpt-5.6-luna",
23
24
  "gpt-5.5",
24
25
  "gpt-5.4",
@@ -28,4 +29,11 @@ export const CODEX_MODELS: readonly string[] = [
28
29
  "gpt-5.2",
29
30
  ];
30
31
 
31
- export const GROK_MODELS: readonly string[] = ["grok-4.5"];
32
+ // Grok model slugs accepted via `-m <slug>`. Keep the current models
33
+ // first and older working models at the end.
34
+ export const GROK_MODELS: readonly string[] = [
35
+ "grok-4.7",
36
+ "grok-4.7-build-fast",
37
+ "grok-4.6",
38
+ "grok-4.5",
39
+ ];
package/src/protocol.ts CHANGED
@@ -212,7 +212,7 @@ export interface EngineUsageFrame {
212
212
  usingOverage?: boolean;
213
213
  windows: EngineUsageWindow[];
214
214
  /** What the report was read from. */
215
- source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
215
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text" | "cursor-period-usage";
216
216
  /** When the engine said it, epoch ms (host clock). The cloud keeps the
217
217
  * newest per account, so a late report never overwrites a newer one. */
218
218
  observedAt: number;
@@ -5059,7 +5059,9 @@ export type HostEvent =
5059
5059
  agentId: string;
5060
5060
  aborted?: boolean;
5061
5061
  /** Token/cost accounting for the turn, when the engine reports it
5062
- * (ADR-071 usage metering — powers per-task cost + managed AI billing). */
5062
+ * (ADR-071 usage metering — powers per-task cost + managed AI billing).
5063
+ * `credentialId` is the leased AI account the turn ran on (host
5064
+ * 0.9.167+); older runners omit it and the cloud stores null. */
5063
5065
  usage?: {
5064
5066
  model?: string;
5065
5067
  inputTokens?: number;
@@ -5067,6 +5069,7 @@ export type HostEvent =
5067
5069
  cacheReadTokens?: number;
5068
5070
  cacheCreateTokens?: number;
5069
5071
  costUsd?: number;
5072
+ credentialId?: string;
5070
5073
  };
5071
5074
  }
5072
5075
  /**