@runuai/host 0.9.166 → 0.9.168

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.
@@ -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,72 @@ 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. The read runs after the CLI
273
+ * child has exited: the CLI rewrites ~/.config/cursor/auth.json at
274
+ * startup, and a probe overlapped with the turn reads the expired access
275
+ * token. A missing lease, a probe still inside the five-minute gate, or
276
+ * any failure sends nothing. The token never leaves this method except
277
+ * as the bearer of the two dashboard calls.
278
+ *
279
+ * The credential is marked before the first await. Two sessions that start
280
+ * together must not both pass the gate. The returned function is the read;
281
+ * call it once the child has exited, still before turn_complete.
282
+ */
283
+ private probePeriodUsage(): () => Promise<void> {
284
+ const skip = (): Promise<void> => Promise.resolve();
285
+ const credentialId = this.agentEnv[ENGINE_ACCOUNT_ENV];
286
+ if (!credentialId || this.closed) return skip;
287
+ const now = Date.now();
288
+ if (!cursorPeriodProbeDue(credentialId, now)) return skip;
289
+ markCursorPeriodProbed(credentialId, now);
290
+ return () => this.readPeriodUsage();
291
+ }
292
+
293
+ /** Auth read and both dashboard calls share one deadline. On expiry the
294
+ * auth reader is aborted (the default reader kills its process) and this
295
+ * promise settles so turn_complete is not held. */
296
+ private readPeriodUsage(): Promise<void> {
297
+ const controller = new AbortController();
298
+ const timer = setTimeout(() => controller.abort(), this.probeTimeoutMs);
299
+ let finished = false;
300
+ const usagePromise = this.collectPeriodUsage(controller.signal);
301
+ return new Promise((resolve) => {
302
+ const finish = (usage: AgentAccountUsage | null): void => {
303
+ if (finished) return;
304
+ finished = true;
305
+ clearTimeout(timer);
306
+ if (!this.closed) {
307
+ if (!usage) console.warn(CURSOR_PERIOD_USAGE_LOG);
308
+ else this.emit({ type: "account_usage", usage });
309
+ }
310
+ resolve();
311
+ };
312
+ controller.signal.addEventListener("abort", () => finish(null));
313
+ void usagePromise.then(
314
+ (usage) => finish(controller.signal.aborted ? null : usage),
315
+ () => finish(null),
316
+ );
317
+ });
318
+ }
319
+
320
+ private async collectPeriodUsage(signal: AbortSignal): Promise<AgentAccountUsage | null> {
321
+ if (signal.aborted) return null;
322
+ const raw = await this.readAuthFile(signal);
323
+ if (signal.aborted) return null;
324
+ const token = raw ? cursorAccessTokenFromAuthJson(raw) : null;
325
+ if (!token) return null;
326
+ return readCursorPeriodUsage({
327
+ token,
328
+ fetchImpl: this.fetchImpl,
329
+ signal,
330
+ });
208
331
  }
209
332
 
210
333
  onEvent(handler: AgentEventHandler): () => void {
@@ -227,6 +350,7 @@ export class CursorSession implements AgentSession {
227
350
  private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
228
351
  const argv = [
229
352
  CURSOR_BIN,
353
+ "--disable-auto-update",
230
354
  "-p",
231
355
  prompt,
232
356
  "--output-format",
@@ -236,7 +360,9 @@ export class CursorSession implements AgentSession {
236
360
  "--trust", // skip the workspace-trust prompt in headless
237
361
  "--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
238
362
  ];
239
- if (this.model && this.model !== "auto") argv.push("-m", this.model);
363
+ // Long form only: the pinned CLI (2026.08.25) and its successors have no
364
+ // `-m` short flag and exit on it before the turn starts.
365
+ if (this.model && this.model !== "auto") argv.push("--model", this.model);
240
366
  if (this.sessionId) argv.push("--resume", this.sessionId);
241
367
  return {
242
368
  argv: argv as [string, ...string[]],
@@ -266,6 +392,9 @@ export class CursorSession implements AgentSession {
266
392
 
267
393
  const child = this.spawner(this.buildRequest(prompt));
268
394
  this.current = child;
395
+ // Mark the gate now, before the child-exit await. The read itself waits
396
+ // until the CLI has rewritten auth.json.
397
+ const periodUsage = this.probePeriodUsage();
269
398
 
270
399
  let acc = "";
271
400
  let sawText = false;
@@ -273,6 +402,13 @@ export class CursorSession implements AgentSession {
273
402
  // Usage rides the result line but turn_complete fires on process exit;
274
403
  // capture it here and attach it below (ADR-071 metering).
275
404
  let turnUsage: AgentUsage | undefined;
405
+ // A result line that already explained the failure owns the error. The
406
+ // exit path must not add a second "cursor exited N" for the same turn.
407
+ // A usage cap is tracked on its own: a generic JSON error must not hide
408
+ // a cap that only shows up on stderr.
409
+ let sawError = false;
410
+ let reportedRateLimit = false;
411
+ let reportedCreditRefusal = false;
276
412
  const consume = (chunk: string): void => {
277
413
  buf += chunk;
278
414
  let nl: number;
@@ -318,7 +454,24 @@ export class CursorSession implements AgentSession {
318
454
  }
319
455
  if (m.end) {
320
456
  if (m.usage) turnUsage = m.usage;
321
- if (m.errorText) this.emit({ type: "error", message: m.errorText });
457
+ if (m.errorText) {
458
+ sawError = true;
459
+ if (m.creditRefusal) reportedCreditRefusal = true;
460
+ if (m.accountUsage) {
461
+ reportedRateLimit = true;
462
+ this.emit({ type: "account_usage", usage: m.accountUsage });
463
+ this.emit({
464
+ type: "error",
465
+ message: m.errorText,
466
+ retryable: "rate_limit",
467
+ ...(m.accountUsage.resetsAt === undefined
468
+ ? {}
469
+ : { resetsAt: m.accountUsage.resetsAt }),
470
+ });
471
+ } else {
472
+ this.emit({ type: "error", message: m.errorText });
473
+ }
474
+ }
322
475
  const finalText = m.finalText ?? acc;
323
476
  if (sawText || finalText) {
324
477
  this.emit({ type: "message_complete", text: finalText });
@@ -340,18 +493,41 @@ export class CursorSession implements AgentSession {
340
493
  if (spawnErr) {
341
494
  this.emit({ type: "error", message: `cursor spawn failed: ${spawnErr}` });
342
495
  } 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
- });
496
+ // Some cursor-agent errors (e.g. an invalid key) are coloured; strip
497
+ // the escape codes so the cap check and the error a human sees are
498
+ // plain text. A generic result error must not skip this check.
499
+ const plain = stripVTControlCharacters(stderr);
500
+ const tail = plain.trim().slice(-500);
501
+ const creditRefusal = cursorCreditsRequiredMessage(plain);
502
+ const message =
503
+ creditRefusal ??
504
+ `cursor exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`;
505
+ const accountUsage = reportedRateLimit ? undefined : rateLimitErrorUsage(plain);
506
+ if (accountUsage) {
507
+ this.emit({ type: "account_usage", usage: accountUsage });
508
+ }
509
+ if (!sawError || (creditRefusal && !reportedCreditRefusal)) {
510
+ if (accountUsage) {
511
+ this.emit({
512
+ type: "error",
513
+ message,
514
+ retryable: "rate_limit",
515
+ ...(accountUsage.resetsAt === undefined
516
+ ? {}
517
+ : { resetsAt: accountUsage.resetsAt }),
518
+ });
519
+ } else {
520
+ this.emit({ type: "error", message });
521
+ }
522
+ }
348
523
  }
349
- this.emit({ type: "turn_complete", usage: turnUsage });
350
524
  resolve();
351
525
  };
352
526
  child.on("exit", (code) => finish(code));
353
527
  child.on("error", (err) => finish(null, err.message));
354
528
  });
529
+ await periodUsage();
530
+ if (!this.closed) this.emit({ type: "turn_complete", usage: turnUsage });
355
531
  }
356
532
 
357
533
  async interrupt(): Promise<void> {
@@ -1,3 +1,5 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { agentOutputSource } from "./output-source";
1
3
  /**
2
4
  * DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
3
5
  * the agent CLI's pipes, it talks to the in-container runner
@@ -84,6 +86,8 @@ export class DurableProcess {
84
86
  private readonly inbox: readonly string[];
85
87
  private readonly heartbeat: readonly string[];
86
88
 
89
+ private readonly decoder = new StringDecoder("utf8");
90
+ private lineOffset = 0;
87
91
  private readonly lineHandlers = new Set<LineHandler>();
88
92
  private readonly exitHandlers = new Set<ExitHandler>();
89
93
 
@@ -108,6 +112,7 @@ export class DurableProcess {
108
112
  this.inbox = [...opts.sessionFiles.parts, "inbox.jsonl"];
109
113
  this.heartbeat = [...opts.sessionFiles.parts, "heartbeat"];
110
114
  this.offset = opts.initialOutboxOffset ?? 0;
115
+ this.lineOffset = this.offset;
111
116
  this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
112
117
  this.onCloseRequested = opts.onCloseRequested ?? null;
113
118
  if (opts.ready) {
@@ -156,7 +161,7 @@ export class DurableProcess {
156
161
  if (read === null) break; // outbox not created yet
157
162
  if (read.bytes.length === 0) break;
158
163
  this.offset += read.bytes.length;
159
- this.lineBuf += read.bytes.toString("utf8");
164
+ this.lineBuf += this.decoder.write(read.bytes);
160
165
  this.drainLines();
161
166
  if (this.offset >= read.size) break;
162
167
  }
@@ -171,7 +176,7 @@ export class DurableProcess {
171
176
  }
172
177
  if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
173
178
  try {
174
- this.onOffsetAdvance(this.offset);
179
+ this.onOffsetAdvance(this.lineOffset);
175
180
  } catch {
176
181
  // Persistence hiccup — the next batch retries with a larger offset.
177
182
  }
@@ -182,7 +187,9 @@ export class DurableProcess {
182
187
  private drainLines(): void {
183
188
  let nl: number;
184
189
  while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
185
- const line = this.lineBuf.slice(0, nl).trim();
190
+ const rawLine = this.lineBuf.slice(0, nl);
191
+ this.lineOffset += Buffer.byteLength(rawLine) + 1;
192
+ const line = rawLine.trim();
186
193
  this.lineBuf = this.lineBuf.slice(nl + 1);
187
194
  if (line.length === 0) continue;
188
195
  if (line.startsWith('{"__uai"')) {
@@ -192,7 +199,7 @@ export class DurableProcess {
192
199
  if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
193
200
  for (const h of this.lineHandlers) {
194
201
  try {
195
- h(line);
202
+ agentOutputSource.run(this.durableSessionDir + ":" + this.lineOffset, () => h(line));
196
203
  } catch {
197
204
  // A broken handler must not wedge the tail loop.
198
205
  }
@@ -293,7 +300,7 @@ export class DurableProcess {
293
300
 
294
301
  /** Consumed-outbox byte offset — persist this to survive host restarts. */
295
302
  get outboxOffset(): number {
296
- return this.offset;
303
+ return this.lineOffset;
297
304
  }
298
305
 
299
306
  /**