@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.
- package/images/standard/Dockerfile +3 -3
- package/lib/agent-cli.ts +789 -21
- package/lib/agents/codex.ts +2 -2
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +201 -25
- package/lib/agents/durable-proc.ts +12 -5
- package/lib/agents/grok.ts +21 -8
- package/lib/agents/machine-durable.ts +12 -5
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/registry.ts +19 -19
- package/lib/agents/transport.ts +5 -0
- package/lib/agents/types.ts +1 -1
- package/lib/agents/usage.ts +16 -9
- package/lib/assignment-evidence.ts +93 -0
- package/lib/cloud-credential-leases.ts +23 -6
- package/lib/cloud-credential-runtime.ts +10 -1
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/engine-accounts.ts +4 -6
- package/lib/host-env.ts +2 -27
- package/lib/orchestrator.ts +124 -18
- package/lib/project-env-loader.ts +5 -0
- package/lib/secrets-proxy/policy.ts +12 -3
- package/lib/standard-image.ts +1 -1
- package/lib/task-env-source.ts +2 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1052 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-token.ts +3 -0
- package/package.json +14 -2
- package/src/cloud-credentials.ts +41 -4
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +16 -8
- package/src/index.ts +66 -3
- package/src/main.ts +125 -0
- package/src/protocol.ts +190 -4
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/workspace-path-policy.ts +41 -0
package/lib/agents/codex.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
+
}
|
package/lib/agents/cursor.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
"
|
|
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 = "
|
|
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
|
-
/**
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
303
|
+
return this.lineOffset;
|
|
297
304
|
}
|
|
298
305
|
|
|
299
306
|
/**
|