@runuai/host 0.8.4 → 0.8.5
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 +9 -0
- package/lib/agents/cursor.ts +324 -0
- package/lib/agents/factory.ts +1 -0
- package/lib/standard-image.ts +11 -2
- package/package.json +1 -1
|
@@ -237,6 +237,15 @@ RUN if [ "$INSTALL_GROK" = "1" ]; then \
|
|
|
237
237
|
|| echo "[warn] grok install failed — grok engine unavailable in this image"; \
|
|
238
238
|
fi
|
|
239
239
|
|
|
240
|
+
# Cursor Agent (Anysphere) — installs to /home/node/.local/bin/cursor-agent.
|
|
241
|
+
# Auth is CURSOR_API_KEY (env-injected by the adapter), so nothing to copy at
|
|
242
|
+
# task-up — just the binary.
|
|
243
|
+
ARG INSTALL_CURSOR=0
|
|
244
|
+
RUN if [ "$INSTALL_CURSOR" = "1" ]; then \
|
|
245
|
+
bash -lc 'curl https://cursor.com/install -fsS | bash' \
|
|
246
|
+
|| echo "[warn] cursor install failed — cursor engine unavailable in this image"; \
|
|
247
|
+
fi
|
|
248
|
+
|
|
240
249
|
ENV PATH=/home/node/.local/bin:$PATH
|
|
241
250
|
|
|
242
251
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorSession — a real AgentSession backed by **Cursor Agent** (`cursor-agent`),
|
|
3
|
+
* run inside the task container:
|
|
4
|
+
*
|
|
5
|
+
* docker exec -i -e CURSOR_API_KEY=… task-<id>-app-1 \
|
|
6
|
+
* /home/node/.local/bin/cursor-agent -p "<prompt>" \
|
|
7
|
+
* --output-format stream-json --stream-partial-output --force --trust \
|
|
8
|
+
* -m <model> [--resume <sessionId>]
|
|
9
|
+
*
|
|
10
|
+
* One-shot per turn (like Kimi/Grok); sends serialized; continuity via
|
|
11
|
+
* `--resume <sessionId>` captured from the stream. Cursor's stream-json is
|
|
12
|
+
* essentially Claude Code's — the RICHEST of the extra engines (streaming text
|
|
13
|
+
* AND tool cards):
|
|
14
|
+
*
|
|
15
|
+
* {"type":"system","subtype":"init","session_id":…} → session id
|
|
16
|
+
* {"type":"thinking","subtype":"delta"|"completed",…} → skipped
|
|
17
|
+
* {"type":"assistant","message":{content:[{text}]},"timestamp_ms":…}
|
|
18
|
+
* → message_delta
|
|
19
|
+
* {"type":"tool_call","subtype":"started","call_id","tool_call":{…}}
|
|
20
|
+
* → tool_call card
|
|
21
|
+
* {"type":"result","subtype":"success"|"error","result",session_id}
|
|
22
|
+
* → message_complete + end
|
|
23
|
+
*
|
|
24
|
+
* AUTH is a Cursor API key (`CURSOR_API_KEY`) — env-injected at exec, like the
|
|
25
|
+
* Claude token (no config-dir copy). Cloud never sees it (ADR-015). `-p` runs
|
|
26
|
+
* headless with `--force` (auto-run tools; the container is the sandbox) and
|
|
27
|
+
* `--trust` (skip the workspace-trust prompt). Cursor has no system-prompt
|
|
28
|
+
* flag, so the briefing folds into the first turn's prompt.
|
|
29
|
+
*/
|
|
30
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
import { newId } from "../ulid";
|
|
33
|
+
import { register } from "./registry";
|
|
34
|
+
import type {
|
|
35
|
+
AgentEvent,
|
|
36
|
+
AgentEventHandler,
|
|
37
|
+
AgentKind,
|
|
38
|
+
AgentSession,
|
|
39
|
+
RosterAgent,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
const CURSOR_BIN = "/home/node/.local/bin/cursor-agent";
|
|
43
|
+
|
|
44
|
+
// A curated slice of Cursor's ~32 models (full ids from `cursor-agent
|
|
45
|
+
// --list-models`). `auto` lets Cursor pick; the rest are the frontier. The UI
|
|
46
|
+
// also accepts any typed id (unverified round-trip). UPDATE as Cursor's lineup
|
|
47
|
+
// changes.
|
|
48
|
+
const CURSOR_MODELS = [
|
|
49
|
+
"auto",
|
|
50
|
+
"composer-2.5",
|
|
51
|
+
"claude-opus-4-8-thinking-high",
|
|
52
|
+
"gpt-5.6-sol-high",
|
|
53
|
+
"cursor-grok-4.5-high",
|
|
54
|
+
"claude-fable-5-thinking-high",
|
|
55
|
+
];
|
|
56
|
+
const CURSOR_DEFAULT_MODEL = "auto";
|
|
57
|
+
const CURSOR_EFFORTS: string[] = []; // effort is baked into the model id
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Pure protocol mapping.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function assistantText(msg: Record<string, unknown>): string {
|
|
64
|
+
const message = msg.message as { content?: unknown } | undefined;
|
|
65
|
+
const content = Array.isArray(message?.content) ? message!.content : [];
|
|
66
|
+
let out = "";
|
|
67
|
+
for (const raw of content) {
|
|
68
|
+
const block = raw as { type?: unknown; text?: unknown };
|
|
69
|
+
if (block.type === "text" && typeof block.text === "string") out += block.text;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** "shellToolCall" → "shell", "readFileToolCall" → "readFile". */
|
|
75
|
+
function prettyTool(name: string): string {
|
|
76
|
+
return name.replace(/ToolCall$/, "") || "tool";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MappedCursorLine {
|
|
80
|
+
textDelta?: string;
|
|
81
|
+
toolCall?: { id: string; title: string; detail: string };
|
|
82
|
+
end?: boolean;
|
|
83
|
+
finalText?: string;
|
|
84
|
+
sessionId?: string;
|
|
85
|
+
errorText?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mapCursorLine(line: string): MappedCursorLine {
|
|
89
|
+
const t = line.trim();
|
|
90
|
+
if (!t.startsWith("{")) return {};
|
|
91
|
+
let m: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
m = JSON.parse(t) as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (m.type === "system") {
|
|
99
|
+
return typeof m.session_id === "string" ? { sessionId: m.session_id } : {};
|
|
100
|
+
}
|
|
101
|
+
if (m.type === "assistant") {
|
|
102
|
+
// Streaming partials carry timestamp_ms; the final full message (finalized
|
|
103
|
+
// by `result`) does not — only stream the partials to avoid doubling.
|
|
104
|
+
if (typeof m.timestamp_ms === "number") {
|
|
105
|
+
const text = assistantText(m);
|
|
106
|
+
if (text) return { textDelta: text };
|
|
107
|
+
}
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
if (m.type === "tool_call" && m.subtype === "started") {
|
|
111
|
+
const tc = (m.tool_call ?? {}) as Record<string, unknown>;
|
|
112
|
+
const [name, spec] = Object.entries(tc)[0] ?? ["tool", {}];
|
|
113
|
+
const args = ((spec as Record<string, unknown>)?.args ?? {}) as Record<string, unknown>;
|
|
114
|
+
const pick = args.command ?? args.path ?? args.filePath ?? args.file_path ?? args.query;
|
|
115
|
+
return {
|
|
116
|
+
toolCall: {
|
|
117
|
+
id: typeof m.call_id === "string" ? m.call_id : newId(),
|
|
118
|
+
title: prettyTool(name),
|
|
119
|
+
detail: typeof pick === "string" ? pick : "",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (m.type === "result") {
|
|
124
|
+
const errored = m.is_error === true;
|
|
125
|
+
return {
|
|
126
|
+
end: true,
|
|
127
|
+
sessionId: typeof m.session_id === "string" ? m.session_id : undefined,
|
|
128
|
+
finalText: typeof m.result === "string" ? m.result : undefined,
|
|
129
|
+
errorText: errored
|
|
130
|
+
? typeof m.result === "string"
|
|
131
|
+
? m.result
|
|
132
|
+
: "cursor turn failed"
|
|
133
|
+
: undefined,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// thinking / user / other → nothing.
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Session
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export type Spawner = (args: string[]) => ChildProcess;
|
|
145
|
+
const defaultSpawn: Spawner = (args) =>
|
|
146
|
+
spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
147
|
+
|
|
148
|
+
export class CursorSession implements AgentSession {
|
|
149
|
+
readonly agentId: string;
|
|
150
|
+
readonly kind: AgentKind = "cursor";
|
|
151
|
+
|
|
152
|
+
private readonly containerName: string;
|
|
153
|
+
private readonly model?: string;
|
|
154
|
+
private readonly systemPreamble: string;
|
|
155
|
+
private readonly agentEnv: Record<string, string>;
|
|
156
|
+
private readonly spawner: Spawner;
|
|
157
|
+
private readonly apiKey: string;
|
|
158
|
+
|
|
159
|
+
private readonly handlers = new Set<AgentEventHandler>();
|
|
160
|
+
private sessionId: string | null = null;
|
|
161
|
+
private current: ChildProcess | null = null;
|
|
162
|
+
private queue: Promise<void> = Promise.resolve();
|
|
163
|
+
private sentPreamble = false;
|
|
164
|
+
private closed = false;
|
|
165
|
+
|
|
166
|
+
constructor(args: {
|
|
167
|
+
taskId: string;
|
|
168
|
+
agent: RosterAgent;
|
|
169
|
+
containerName: string;
|
|
170
|
+
systemPreamble: string;
|
|
171
|
+
agentEnv?: Record<string, string>;
|
|
172
|
+
spawner?: Spawner;
|
|
173
|
+
apiKey?: string;
|
|
174
|
+
}) {
|
|
175
|
+
this.agentId = args.agent.id;
|
|
176
|
+
this.containerName = args.containerName;
|
|
177
|
+
this.model = args.agent.model;
|
|
178
|
+
this.systemPreamble = args.systemPreamble;
|
|
179
|
+
this.agentEnv = args.agentEnv ?? {};
|
|
180
|
+
this.spawner = args.spawner ?? defaultSpawn;
|
|
181
|
+
this.apiKey = args.apiKey ?? process.env.CURSOR_API_KEY ?? "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
onEvent(handler: AgentEventHandler): () => void {
|
|
185
|
+
this.handlers.add(handler);
|
|
186
|
+
return () => this.handlers.delete(handler);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private emit(event: AgentEvent): void {
|
|
190
|
+
if (this.closed && event.type !== "exit") return;
|
|
191
|
+
for (const h of this.handlers) h(event);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async send(text: string): Promise<void> {
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
this.queue = this.queue.then(() => this.runTurn(text));
|
|
197
|
+
return this.queue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private buildArgs(prompt: string): string[] {
|
|
201
|
+
const args = ["exec", "-i", "-u", "node", "-e", `CURSOR_API_KEY=${this.apiKey}`];
|
|
202
|
+
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
203
|
+
args.push("-e", `${k}=${v}`);
|
|
204
|
+
}
|
|
205
|
+
args.push(
|
|
206
|
+
this.containerName,
|
|
207
|
+
CURSOR_BIN,
|
|
208
|
+
"-p",
|
|
209
|
+
prompt,
|
|
210
|
+
"--output-format",
|
|
211
|
+
"stream-json",
|
|
212
|
+
"--stream-partial-output",
|
|
213
|
+
"--force", // container is the sandbox — auto-run tools
|
|
214
|
+
"--trust", // skip the workspace-trust prompt in headless
|
|
215
|
+
);
|
|
216
|
+
if (this.model && this.model !== "auto") args.push("-m", this.model);
|
|
217
|
+
if (this.sessionId) args.push("--resume", this.sessionId);
|
|
218
|
+
return args;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async runTurn(text: string): Promise<void> {
|
|
222
|
+
if (this.closed) return;
|
|
223
|
+
// No system-prompt flag — fold the briefing into the first turn; later
|
|
224
|
+
// turns carry it via the resumed session.
|
|
225
|
+
const prompt =
|
|
226
|
+
!this.sentPreamble && this.systemPreamble.trim().length > 0
|
|
227
|
+
? `${this.systemPreamble}\n\n---\n\n${text}`
|
|
228
|
+
: text;
|
|
229
|
+
this.sentPreamble = true;
|
|
230
|
+
|
|
231
|
+
const child = this.spawner(this.buildArgs(prompt));
|
|
232
|
+
this.current = child;
|
|
233
|
+
|
|
234
|
+
let acc = "";
|
|
235
|
+
let sawText = false;
|
|
236
|
+
let buf = "";
|
|
237
|
+
const consume = (chunk: string): void => {
|
|
238
|
+
buf += chunk;
|
|
239
|
+
let nl: number;
|
|
240
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
241
|
+
const line = buf.slice(0, nl);
|
|
242
|
+
buf = buf.slice(nl + 1);
|
|
243
|
+
const m = mapCursorLine(line);
|
|
244
|
+
if (m.sessionId) this.sessionId = m.sessionId;
|
|
245
|
+
if (typeof m.textDelta === "string") {
|
|
246
|
+
sawText = true;
|
|
247
|
+
acc += m.textDelta;
|
|
248
|
+
this.emit({ type: "message_delta", text: m.textDelta });
|
|
249
|
+
}
|
|
250
|
+
if (m.toolCall) {
|
|
251
|
+
this.emit({
|
|
252
|
+
type: "tool_call",
|
|
253
|
+
id: m.toolCall.id,
|
|
254
|
+
title: m.toolCall.title,
|
|
255
|
+
detail: m.toolCall.detail,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (m.end) {
|
|
259
|
+
if (m.errorText) this.emit({ type: "error", message: m.errorText });
|
|
260
|
+
const finalText = m.finalText ?? acc;
|
|
261
|
+
if (sawText || finalText) {
|
|
262
|
+
this.emit({ type: "message_complete", text: finalText });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
child.stdout?.on("data", (b: Buffer) => consume(b.toString("utf8")));
|
|
268
|
+
let stderr = "";
|
|
269
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
270
|
+
stderr += b.toString("utf8");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await new Promise<void>((resolve) => {
|
|
274
|
+
const finish = (code: number | null, spawnErr?: string): void => {
|
|
275
|
+
this.current = null;
|
|
276
|
+
if (buf.trim().length > 0) consume("\n");
|
|
277
|
+
if (this.closed) return resolve();
|
|
278
|
+
if (spawnErr) {
|
|
279
|
+
this.emit({ type: "error", message: `cursor spawn failed: ${spawnErr}` });
|
|
280
|
+
} else if (code !== 0) {
|
|
281
|
+
const tail = stderr.trim().slice(-500);
|
|
282
|
+
this.emit({
|
|
283
|
+
type: "error",
|
|
284
|
+
message: `cursor exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
this.emit({ type: "turn_complete" });
|
|
288
|
+
resolve();
|
|
289
|
+
};
|
|
290
|
+
child.on("exit", (code) => finish(code));
|
|
291
|
+
child.on("error", (err) => finish(null, err.message));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async interrupt(): Promise<void> {
|
|
296
|
+
this.current?.kill("SIGKILL");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async resolvePermission(): Promise<void> {
|
|
300
|
+
/* --force auto-approves; no permission requests are emitted */
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async close(): Promise<void> {
|
|
304
|
+
if (this.closed) return;
|
|
305
|
+
this.closed = true;
|
|
306
|
+
this.current?.kill("SIGKILL");
|
|
307
|
+
this.current = null;
|
|
308
|
+
this.emit({ type: "exit", code: 0 });
|
|
309
|
+
this.handlers.clear();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
register({
|
|
314
|
+
kind: "cursor",
|
|
315
|
+
label: "Cursor",
|
|
316
|
+
supportedModels: () => [...CURSOR_MODELS],
|
|
317
|
+
defaultModel: CURSOR_DEFAULT_MODEL,
|
|
318
|
+
supportedEfforts: () => [...CURSOR_EFFORTS],
|
|
319
|
+
// Gated on a Cursor API key in the host env (loaded from .env.local); the
|
|
320
|
+
// adapter injects it into the container. No key → engine not advertised.
|
|
321
|
+
available: () => Boolean(process.env.CURSOR_API_KEY),
|
|
322
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
323
|
+
new CursorSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
324
|
+
});
|
package/lib/agents/factory.ts
CHANGED
package/lib/standard-image.ts
CHANGED
|
@@ -76,11 +76,18 @@ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
|
76
76
|
* into containers at task-up; claude/codex are always baked). Folded into the
|
|
77
77
|
* build hash below, so logging into a new engine triggers a rebuild.
|
|
78
78
|
*/
|
|
79
|
-
export function configuredOptionalEngines(): {
|
|
79
|
+
export function configuredOptionalEngines(): {
|
|
80
|
+
kimi: boolean;
|
|
81
|
+
grok: boolean;
|
|
82
|
+
cursor: boolean;
|
|
83
|
+
} {
|
|
80
84
|
const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
81
85
|
return {
|
|
82
86
|
kimi: existsSync(join(home, ".kimi-code", "credentials", "kimi-code.json")),
|
|
83
87
|
grok: existsSync(join(home, ".grok", "auth.json")),
|
|
88
|
+
// Cursor auths via CURSOR_API_KEY (env, loaded from .env.local), not a
|
|
89
|
+
// config file — install it when the key is present.
|
|
90
|
+
cursor: Boolean(process.env.CURSOR_API_KEY),
|
|
84
91
|
};
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -333,9 +340,11 @@ export async function ensureStandardImage(): Promise<void> {
|
|
|
333
340
|
`INSTALL_KIMI=${engines.kimi ? 1 : 0}`,
|
|
334
341
|
"--build-arg",
|
|
335
342
|
`INSTALL_GROK=${engines.grok ? 1 : 0}`,
|
|
343
|
+
"--build-arg",
|
|
344
|
+
`INSTALL_CURSOR=${engines.cursor ? 1 : 0}`,
|
|
336
345
|
];
|
|
337
346
|
const contextHash = await hashBuildContext(
|
|
338
|
-
`kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0}`,
|
|
347
|
+
`kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0}`,
|
|
339
348
|
);
|
|
340
349
|
const inspect = await run("docker", [
|
|
341
350
|
"image",
|
package/package.json
CHANGED