@neta-art/cohub-cli 10.6.0 → 10.8.0

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.
Files changed (37) hide show
  1. package/README.md +8 -5
  2. package/dist/board-kernel.js +207 -119
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/generations.js +23 -2
  6. package/dist/commands/runtime.js +3 -14
  7. package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
  8. package/dist/runtime/harnesses/chained-transcript.js +62 -0
  9. package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
  10. package/dist/runtime/harnesses/claude-transcript.js +138 -0
  11. package/dist/runtime/harnesses/claude.js +79 -2
  12. package/dist/runtime/harnesses/codex.js +1 -1
  13. package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
  14. package/dist/runtime/harnesses/cursor-transcript.js +183 -0
  15. package/dist/runtime/harnesses/cursor.d.ts +24 -0
  16. package/dist/runtime/harnesses/cursor.js +620 -0
  17. package/dist/runtime/harnesses/index.d.ts +1 -3
  18. package/dist/runtime/harnesses/index.js +4 -10
  19. package/dist/runtime/harnesses/pi.js +1 -1
  20. package/dist/runtime/harnesses/stream-json.d.ts +7 -1
  21. package/dist/runtime/harnesses/stream-json.js +35 -12
  22. package/dist/runtime/harnesses/types.d.ts +10 -5
  23. package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
  24. package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
  26. package/dist/runtime/harnesses/workbuddy.js +31 -7
  27. package/dist/runtime/json-rpc.d.ts +2 -0
  28. package/dist/runtime/json-rpc.js +9 -0
  29. package/dist/runtime/native/attach.d.ts +1 -1
  30. package/dist/runtime/native/attach.js +1 -4
  31. package/dist/runtime/native/execution.js +14 -3
  32. package/dist/runtime/native/ingest.d.ts +14 -0
  33. package/dist/runtime/native/ingest.js +51 -5
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +19 -0
  36. package/dist/runtime/native/transcript.js +33 -2
  37. package/package.json +3 -3
@@ -0,0 +1,620 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { basename, dirname, extname, join } from "node:path";
7
+ import { promisify, stripVTControlCharacters } from "node:util";
8
+ import { describeError } from "../../network-error.js";
9
+ import { JsonRpcProcess, record } from "../json-rpc.js";
10
+ import { projectChainedSession, projectionToolId } from "./chained-projection.js";
11
+ import { acceptsCursorDirectory, acceptsCursorTranscript, cursorChatsRoot, cursorConfigDirectory, cursorProjectsRoot, cursorSessionHeader, cursorTranscriptPath, readCursorTranscript, } from "./cursor-transcript.js";
12
+ import { array, harnessCommand, harnessExecutableCandidates, inlineImage, missing, text, turnEnvironment, watchHost } from "./process.js";
13
+ const BINARY = "cursor-agent";
14
+ const PROVIDER = "cursor";
15
+ /** Runs without `--model`, so the model chosen in Cursor applies. */
16
+ const DEFAULT_MODEL = "default";
17
+ /**
18
+ * Every flag a Turn passes, some of them hidden from `--help`. The CLI rejects an unknown option
19
+ * before anything else and an invalid session id right after, so this probe starts no chat.
20
+ */
21
+ const PROBE = ["-p", "--output-format", "stream-json", "--stream-partial-output", "--trust", "--new-session-id", "probe", "--conversation-history-file", "probe", "--image", "probe", "--resume", "probe", "--model", "probe", "--", "probe"];
22
+ /** Keys `--model` writes to `cli-config.json` as the user's default (Cursor persists any model it starts with). */
23
+ const MODEL_PREFERENCE = ["model", "hasChangedDefaultModel", "maxMode", "maxModeAutoEnabled"];
24
+ /** Waiting longer than this on a live owner (or a reused pid) fails the Turn instead of stalling it. */
25
+ const LOCK_WAIT_MS = 30_000;
26
+ const ABORT_ESCALATION_MS = 5_000;
27
+ const FLUSH_TIMEOUT_MS = 10_000;
28
+ /** The prompt is an argument: Linux caps one at 128 KiB, Windows a whole command line at 32K characters. */
29
+ const MAX_PROMPT_BYTES = process.platform === "win32" ? 30_000 : 120_000;
30
+ const IMAGE_EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
31
+ const HISTORY_KINDS = ["user", "assistant", "tool"];
32
+ const VERSION_DIRECTORY = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-\d{2}-\d{2}-\d{2})?-[a-f0-9]+$/;
33
+ /**
34
+ * Cursor's Windows install puts `cursor-agent.cmd` on PATH, which starts PowerShell to run the newest
35
+ * `versions\<version>\node.exe index.js`. A shell would re-quote the prompt, so this resolves the
36
+ * same Node entry the launcher would and runs it directly.
37
+ */
38
+ function cursorLauncher(shim) {
39
+ const runnable = (directory) => {
40
+ const node = join(directory, "node.exe"), entry = join(directory, "index.js");
41
+ return existsSync(node) && existsSync(entry) ? [node, entry] : null;
42
+ };
43
+ const beside = runnable(dirname(shim));
44
+ if (beside)
45
+ return beside;
46
+ const versions = join(dirname(shim), "versions");
47
+ const day = (name) => { const match = VERSION_DIRECTORY.exec(name); return match ? Number(`${match[1]}${match[2]?.padStart(2, "0")}${match[3]?.padStart(2, "0")}`) : -1; };
48
+ let entries = [];
49
+ try {
50
+ entries = readdirSync(versions).filter((name) => day(name) >= 0 && statSync(join(versions, name)).isDirectory());
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ entries.sort((left, right) => day(right) - day(left) || right.localeCompare(left));
56
+ for (const name of entries) {
57
+ const found = runnable(join(versions, name));
58
+ if (found)
59
+ return found;
60
+ }
61
+ return null;
62
+ }
63
+ /** The process a Cursor CLI invocation runs, and the variables its launcher would set. */
64
+ export function cursorCommand(executable, args, cwd) {
65
+ if (process.platform !== "win32")
66
+ return { command: harnessCommand(executable, args, cwd), env: {} };
67
+ const shim = harnessExecutableCandidates(executable, cwd, process.env.PATH ?? "").find((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile());
68
+ const launcher = shim && [".cmd", ".ps1", ".bat"].includes(extname(shim).toLowerCase()) ? cursorLauncher(shim) : null;
69
+ if (!shim || !launcher)
70
+ return { command: harnessCommand(executable, args, cwd), env: {} };
71
+ return {
72
+ command: [launcher[0], [launcher[1], ...args]],
73
+ env: { CURSOR_INVOKED_AS: basename(shim), ...(process.env.NODE_COMPILE_CACHE ? {} : { NODE_COMPILE_CACHE: join(process.env.LOCALAPPDATA ?? dirname(dirname(shim)), "cursor-compile-cache") }) },
74
+ };
75
+ }
76
+ const exec = promisify(execFile);
77
+ const command = (executable, args, cwd, timeout) => {
78
+ const run = cursorCommand(executable, args, cwd);
79
+ return exec(...run.command, { cwd, encoding: "utf8", timeout, maxBuffer: 4 * 1024 * 1024, windowsHide: true, env: { ...process.env, ...run.env, NO_COLOR: "1" } });
80
+ };
81
+ const exists = (path) => access(path).then(() => true, () => false);
82
+ /** `cursor-agent models`: `<id> - <name> (current, default)` per line. */
83
+ export function cursorModels(output) {
84
+ const models = new Map();
85
+ for (const line of stripVTControlCharacters(output).split(/\r?\n/)) {
86
+ const match = /^(\S{1,255}) - (.+?)(?: \((?:current|default)(?:, (?:current|default))?\))?$/.exec(line.trim());
87
+ if (match?.[1] && match[1] !== DEFAULT_MODEL && !models.has(match[1]))
88
+ models.set(match[1], (match[2] ?? match[1]).slice(0, 255));
89
+ }
90
+ if (!models.size)
91
+ throw new Error("Cursor lists no models; sign in with `cursor-agent login` or set CURSOR_API_KEY");
92
+ return [[DEFAULT_MODEL, "Default"], ...models].map(([id, name]) => ({ harness: "cursor", provider: PROVIDER, id, name, ...(id === DEFAULT_MODEL ? { isDefault: true } : {}) }));
93
+ }
94
+ /** Shared by every Runtime process on this machine, one per Cursor config file. */
95
+ const preferenceRecord = (config) => join(process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state"), "cohub", `cursor-model-${createHash("sha256").update(config).digest("hex").slice(0, 16)}.json`);
96
+ const alive = (pid) => { try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ return error.code === "EPERM";
102
+ } };
103
+ const readJson = (path) => readFile(path, "utf8").then((raw) => record(JSON.parse(raw)), () => null);
104
+ async function writeJson(path, value) {
105
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
106
+ const temporary = `${path}.${randomUUID()}.tmp`;
107
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
108
+ await rename(temporary, path).catch(async (error) => { await rm(temporary, { force: true }); throw error; });
109
+ }
110
+ /**
111
+ * A lock directory naming its owner's pid, published by renaming a prepared one into place, so it
112
+ * never exists without its owner. It is taken over only once that process is gone, by one waiter
113
+ * that renames it aside, so a live owner always releases its own lock.
114
+ */
115
+ async function locked(path, run) {
116
+ const lock = `${path}.lock`, draft = `${lock}.${randomUUID()}`;
117
+ await mkdir(draft, { recursive: true, mode: 0o700 });
118
+ await writeFile(join(draft, String(process.pid)), "");
119
+ try {
120
+ for (const deadline = Date.now() + LOCK_WAIT_MS;;) {
121
+ // Renaming onto an existing lock (which always holds its owner) fails.
122
+ if (await rename(draft, lock).then(() => true, () => false))
123
+ break;
124
+ const [owner] = await readdir(lock).catch(() => []);
125
+ if (!owner || !alive(Number(owner))) {
126
+ const aside = `${lock}.${randomUUID()}`;
127
+ if (await rename(lock, aside).then(() => true, () => false))
128
+ await rm(aside, { recursive: true, force: true });
129
+ continue;
130
+ }
131
+ if (Date.now() > deadline)
132
+ throw new Error(`Another process (pid ${owner}) holds ${lock}; if no Cohub Runtime is running, remove it and retry`);
133
+ await new Promise((resolve) => setTimeout(resolve, 25));
134
+ }
135
+ }
136
+ catch (error) {
137
+ await rm(draft, { recursive: true, force: true });
138
+ throw error;
139
+ }
140
+ // Released by renaming it aside first, so the lock never exists without its owner.
141
+ try {
142
+ return await run();
143
+ }
144
+ finally {
145
+ const aside = `${lock}.${randomUUID()}`;
146
+ if (await rename(lock, aside).then(() => true, () => false))
147
+ await rm(aside, { recursive: true, force: true });
148
+ }
149
+ }
150
+ const savedModel = (config) => { const model = record(config?.model); return text(model.displayModelId) || text(model.modelId); };
151
+ /** Put the baseline back once no Turn holds it, unless the saved model is none the holders chose. Returns the config now saved. */
152
+ async function restoreBaseline(config, stored) {
153
+ const [before, after] = [stored.baseline, await readJson(config)];
154
+ const saved = record(after?.model);
155
+ const ours = [text(saved.modelId), text(saved.displayModelId)].some((value) => stored.requested.includes(value));
156
+ if (!before || !after || !ours || MODEL_PREFERENCE.every((key) => JSON.stringify(after[key]) === JSON.stringify(before[key])))
157
+ return after;
158
+ const restored = { ...after };
159
+ for (const key of MODEL_PREFERENCE) {
160
+ if (key in before)
161
+ restored[key] = before[key];
162
+ else
163
+ delete restored[key];
164
+ }
165
+ await writeJson(config, restored);
166
+ return restored;
167
+ }
168
+ /**
169
+ * Cursor saves the model a run starts with as the user's default. Turns that choose a model hold the
170
+ * preference from before the first of them; once the last one ends it is put back, unless the saved
171
+ * model is none of theirs (the user picked another meanwhile). A Default Turn that overlaps them runs
172
+ * on that preference rather than on another Turn's model. Returns the model to pass, if any.
173
+ */
174
+ async function holdModelPreference(requested) {
175
+ const config = join(cursorConfigDirectory(), "cli-config.json"), path = preferenceRecord(config), id = randomUUID();
176
+ const model = await locked(path, async () => {
177
+ const stored = await readJson(path);
178
+ const holders = stored ? stored.holders.filter((hold) => alive(hold.pid)) : [];
179
+ // Holders that died (a crashed Runtime) left Cursor on their model; their baseline comes back first.
180
+ const current = holders.length && stored ? stored : { config, baseline: stored ? await restoreBaseline(config, stored) : await readJson(config), requested: [], holders: [] };
181
+ const model = requested ?? (holders.length ? savedModel(current.baseline) || null : null);
182
+ if (!model) {
183
+ if (stored && !holders.length)
184
+ await rm(path, { force: true });
185
+ return null;
186
+ }
187
+ // Parameterized models are saved without their `[…]` overrides.
188
+ await writeJson(path, { ...current, requested: [...new Set([...current.requested, model.split("[")[0] ?? model])], holders: [...holders, { id, pid: process.pid }] });
189
+ return model;
190
+ });
191
+ const release = async () => {
192
+ if (!model)
193
+ return;
194
+ await locked(path, async () => {
195
+ const stored = await readJson(path);
196
+ if (!stored)
197
+ return;
198
+ const holders = stored.holders.filter((hold) => hold.id !== id && alive(hold.pid));
199
+ if (holders.length) {
200
+ await writeJson(path, { ...stored, holders });
201
+ return;
202
+ }
203
+ await restoreBaseline(config, stored);
204
+ await rm(path, { force: true });
205
+ });
206
+ };
207
+ return { model, release };
208
+ }
209
+ /** A chat Cursor stored, wherever its workspace hash put it. */
210
+ async function chatStored(nativeSessionId) {
211
+ const hashes = await readdir(cursorChatsRoot()).catch((error) => { if (missing(error))
212
+ return []; throw error; });
213
+ for (const hash of hashes)
214
+ if (await exists(join(cursorChatsRoot(), hash, nativeSessionId, "store.db")))
215
+ return true;
216
+ return false;
217
+ }
218
+ /** The transcript Cursor wrote for a chat; its project name is the fast path. */
219
+ async function findTranscript(nativeSessionId, cwd) {
220
+ const preferred = cursorTranscriptPath(nativeSessionId, cwd);
221
+ if (await exists(preferred))
222
+ return preferred;
223
+ const projects = await readdir(cursorProjectsRoot()).catch((error) => { if (missing(error))
224
+ return []; throw error; });
225
+ for (const project of projects) {
226
+ const candidate = join(cursorProjectsRoot(), project, "agent-transcripts", nativeSessionId, `${nativeSessionId}.jsonl`);
227
+ if (await exists(candidate))
228
+ return candidate;
229
+ }
230
+ return null;
231
+ }
232
+ function historyContent(block) {
233
+ if (block.type === "text")
234
+ return [{ text: { text: block.text } }];
235
+ if (block.type === "image" && block.source.type === "base64")
236
+ return [{ image: { data: block.source.data, mimeType: block.source.media_type } }];
237
+ return [];
238
+ }
239
+ /** Cursor calls are `{<kind>ToolCall: {args, result}}`; MCP calls name their tool in the arguments. */
240
+ function cursorTool(value) {
241
+ const call = record(value);
242
+ const [kind, body] = Object.entries(call).find(([key, entry]) => key.endsWith("ToolCall") && entry && typeof entry === "object") ?? [];
243
+ if (!kind) {
244
+ const fn = record(call.function);
245
+ let input;
246
+ try {
247
+ input = record(JSON.parse(text(fn.arguments)));
248
+ }
249
+ catch {
250
+ input = { raw: fn.arguments };
251
+ }
252
+ return { name: text(fn.name) || "tool", input, result: null };
253
+ }
254
+ const tool = record(body), args = record(tool.args);
255
+ const name = kind === "mcpToolCall" ? text(args.toolName) || text(args.name) || "mcp" : kind.slice(0, -"ToolCall".length);
256
+ return { name, input: args, result: tool.result && typeof tool.result === "object" ? record(tool.result) : null };
257
+ }
258
+ function cursorResult(result) {
259
+ const status = "success" in result ? "success" : Object.keys(result).find((key) => key !== "sandboxPolicy" && result[key] && typeof result[key] === "object") ?? "success";
260
+ const body = record(result[status]);
261
+ const output = text(body.interleavedOutput) || [text(body.stdout), text(body.stderr)].filter(Boolean).join("\n") || text(body.content)
262
+ || array(body.content).map((item) => text(record(record(item).text).text)).filter(Boolean).join("\n")
263
+ || text(body.error) || text(body.reason) || text(body.message);
264
+ return { content: output || JSON.stringify(result[status] ?? null), is_error: status !== "success" };
265
+ }
266
+ function cursorUsage(value) {
267
+ const usage = record(value);
268
+ const count = (key) => typeof usage[key] === "number" ? usage[key] : 0;
269
+ const input = count("inputTokens"), output = count("outputTokens"), cacheRead = count("cacheReadTokens"), cacheWrite = count("cacheWriteTokens");
270
+ return input || output || cacheRead || cacheWrite ? { input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite } : undefined;
271
+ }
272
+ /**
273
+ * Maps `--output-format stream-json --stream-partial-output` onto Cohub's streaming protocol. A reply
274
+ * step ends once its tool calls returned. Cursor repeats a step's streamed text as one line right
275
+ * before a tool call, question or retry (with a timestamp) and at the end (without one); such a line
276
+ * is held until the next event says whether it was a repeat.
277
+ */
278
+ export function cursorTranslator(emit, identity) {
279
+ const steps = [];
280
+ const owners = new Map();
281
+ let current = null;
282
+ let open = null;
283
+ let streamed = "";
284
+ let held = null;
285
+ let model = identity.model;
286
+ let final = null;
287
+ const settled = (step) => step.content.some((block) => block.type === "tool_use")
288
+ && step.content.every((block) => block.type !== "tool_use" || step.content.some((result) => result.type === "tool_result" && result.tool_use_id === block.id));
289
+ const snapshot = (step) => ({
290
+ ordinal: step.ordinal, content: [...step.content], provider: PROVIDER, model,
291
+ stopReason: step.content.some((block) => block.type === "tool_use") ? "tool_use" : "stop",
292
+ });
293
+ const target = (modelCallId = "") => {
294
+ if (current && !(settled(current) && (!modelCallId || modelCallId !== current.modelCallId)))
295
+ return current;
296
+ if (current)
297
+ emit({ type: "message.commit", message: snapshot(current) });
298
+ current = { ordinal: steps.length, content: [], modelCallId };
299
+ steps.push(current);
300
+ open = null;
301
+ emit({ type: "message.start", ordinal: current.ordinal });
302
+ return current;
303
+ };
304
+ const replace = (step) => emit({ type: "content.replace", ordinal: step.ordinal, content: [...step.content] });
305
+ const append = (kind, chunk) => {
306
+ if (!chunk)
307
+ return;
308
+ const step = target();
309
+ if (!open || open.kind !== kind || open.index !== step.content.length - 1) {
310
+ step.content.push(kind === "text" ? { type: "text", text: "" } : { type: "thinking", thinking: "" });
311
+ open = { index: step.content.length - 1, kind };
312
+ }
313
+ const block = step.content[open.index];
314
+ step.content[open.index] = block?.type === "thinking" ? { type: "thinking", thinking: block.thinking + chunk } : { type: "text", text: (block?.type === "text" ? block.text : "") + chunk };
315
+ emit({ type: "text.delta", ordinal: step.ordinal, index: open.index, kind, delta: chunk });
316
+ };
317
+ const streamText = (chunk) => { append("text", chunk); streamed += chunk; };
318
+ const release = () => { if (held !== null) {
319
+ const chunk = held;
320
+ held = null;
321
+ streamText(chunk);
322
+ } };
323
+ /** The whole of a step's text: new in non-partial output, otherwise authoritative over what streamed. */
324
+ const whole = (value) => {
325
+ release();
326
+ const step = current;
327
+ const last = step?.content.at(-1);
328
+ if (!streamed)
329
+ append("text", value);
330
+ else if (value !== streamed && step && last?.type === "text" && last.text.endsWith(streamed)) {
331
+ step.content[step.content.length - 1] = { type: "text", text: last.text.slice(0, last.text.length - streamed.length) + value };
332
+ replace(step);
333
+ }
334
+ streamed = "";
335
+ };
336
+ /** A line still held when the stream ends was no repeat; it stays in the result. */
337
+ const last = () => { release(); return current ? snapshot(current) : { ordinal: 0, content: [], provider: PROVIDER, model }; };
338
+ return {
339
+ result: () => final,
340
+ last,
341
+ push(event) {
342
+ if (event.type === "assistant") {
343
+ const value = array(record(event.message).content).map((block) => text(record(block).text)).join("");
344
+ if (typeof event.timestamp_ms !== "number" || text(event.model_call_id))
345
+ whole(value);
346
+ // A repeat equals all the step's text so far, which includes a line held as a possible repeat.
347
+ else if (streamed && value === streamed + (held ?? "")) {
348
+ release();
349
+ held = value;
350
+ }
351
+ else {
352
+ release();
353
+ streamText(value);
354
+ }
355
+ return false;
356
+ }
357
+ if ((event.type === "tool_call" && event.subtype === "started") || (event.type === "interaction_query" && event.subtype === "request") || event.type === "retry") {
358
+ held = null;
359
+ streamed = "";
360
+ }
361
+ else
362
+ release();
363
+ if (event.type === "system" && event.subtype === "init") {
364
+ model ??= text(event.model) || null;
365
+ return false;
366
+ }
367
+ if (event.type === "thinking") {
368
+ if (event.subtype === "delta")
369
+ append("thinking", text(event.text));
370
+ else
371
+ open = null;
372
+ return false;
373
+ }
374
+ if (event.type === "tool_call") {
375
+ const id = text(event.call_id);
376
+ if (!id)
377
+ return false;
378
+ const tool = cursorTool(event.tool_call);
379
+ const step = event.subtype === "started" ? target(text(event.model_call_id)) : owners.get(id);
380
+ if (!step)
381
+ return false;
382
+ if (event.subtype === "started") {
383
+ step.content.push({ type: "tool_use", id, name: tool.name, input: tool.input });
384
+ step.modelCallId ||= text(event.model_call_id);
385
+ owners.set(id, step);
386
+ open = null;
387
+ }
388
+ else if (event.subtype === "completed") {
389
+ const index = step.content.findIndex((block) => block.type === "tool_use" && block.id === id);
390
+ if (index < 0)
391
+ return false;
392
+ step.content[index] = { type: "tool_use", id, name: tool.name, input: tool.input };
393
+ const result = cursorResult(tool.result ?? {});
394
+ const next = step.content[index + 1];
395
+ step.content.splice(index + 1, next?.type === "tool_result" && next.tool_use_id === id ? 1 : 0, { type: "tool_result", tool_use_id: id, ...result });
396
+ }
397
+ replace(step);
398
+ return false;
399
+ }
400
+ if (event.type !== "result")
401
+ return false;
402
+ const failed = event.is_error === true || event.subtype !== "success";
403
+ final = { ...last(), stopReason: failed ? "error" : "stop", errorMessage: failed ? text(event.result) || "Turn failed" : null, usage: cursorUsage(event.usage) };
404
+ return true;
405
+ },
406
+ };
407
+ }
408
+ /** The prompt is an argument; images go beside it as files. */
409
+ async function prompt(input, directory) {
410
+ const parts = [], images = [];
411
+ for (const block of input.messages.flatMap((message) => message.content)) {
412
+ if (block.type !== "image") {
413
+ parts.push(block.type === "text" ? block.text : JSON.stringify(block));
414
+ continue;
415
+ }
416
+ const image = await inlineImage(block);
417
+ const path = join(directory, `image-${images.length + 1}.${IMAGE_EXTENSIONS[image.mimeType] ?? "png"}`);
418
+ await writeFile(path, Buffer.from(image.data, "base64"), { mode: 0o600 });
419
+ images.push(path);
420
+ }
421
+ const value = parts.join("\n\n").trim() || (images.length ? "See the attached image." : "");
422
+ if (Buffer.byteLength(value) > MAX_PROMPT_BYTES)
423
+ throw new Error("Prompt is too long for the Cursor CLI command line; shorten the message");
424
+ return { text: value, images };
425
+ }
426
+ /** Projected history becomes the CLI's conversation history file; the projection itself is not Cursor's transcript. */
427
+ async function handoff(path, directory) {
428
+ const messages = (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => {
429
+ const value = record(JSON.parse(line));
430
+ return Object.fromEntries(HISTORY_KINDS.flatMap((kind) => kind in value ? [[kind, value[kind]]] : []));
431
+ });
432
+ await rm(path, { force: true });
433
+ const file = join(directory, "history.json");
434
+ await writeFile(file, JSON.stringify({ messages }), { mode: 0o600 });
435
+ return file;
436
+ }
437
+ /**
438
+ * One `cursor-agent -p` process per Turn. Without `--force`, commands outside the user's allowlist are
439
+ * refused by the CLI itself; it answers its own questions and nothing is ever granted. Cohub's
440
+ * history starts a chat through `--conversation-history-file`; later Turns `--resume` it.
441
+ */
442
+ async function runCursor(executor, turn) {
443
+ const { input, signal, context } = turn;
444
+ const prepare = (resumable) => executor.sessions.prepare({
445
+ sessionId: input.sessionId, turnId: input.turnId, harness: "cursor", cwd: executor.cwd,
446
+ provider: input.provider, context: input.context, signal, resumable,
447
+ });
448
+ // Without a control plane, a terminal chat is read-only: Cohub continues only chats it started.
449
+ let prepared = await prepare((transcript) => transcript.path !== turn.diverged && transcript.origin === "cohub");
450
+ if (prepared.resume === "native" && !await chatStored(prepared.session.nativeSessionId))
451
+ prepared = await prepare(() => false);
452
+ let session = prepared.session;
453
+ const translator = cursorTranslator(turn.emit, { model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
454
+ let message = { ordinal: 0, content: [] };
455
+ let host = null;
456
+ let directory = null;
457
+ let preference = null;
458
+ let sent = null;
459
+ // Cursor records a prompt only after its first output line.
460
+ let spoke = false;
461
+ let exited = null;
462
+ // Bytes already in a continued transcript; a Turn recorded past them is this one. Others start empty.
463
+ const before = prepared.resume === "native" ? await stat(session.path).then((info) => info.size, () => 0) : 0;
464
+ try {
465
+ directory = await mkdtemp(join(tmpdir(), "cohub-cursor-"));
466
+ // The projection sits where Cursor's transcript will be; it is moved out before anything else can fail.
467
+ const history = prepared.resume === "handoff" ? await handoff(session.path, directory) : null;
468
+ const content = await prompt(input, directory);
469
+ sent = content.text;
470
+ signal.throwIfAborted();
471
+ // Without coordination a Turn could run on, or leave behind, another Turn's model; it fails instead.
472
+ preference = await holdModelPreference(input.model && input.model !== DEFAULT_MODEL ? input.model : null);
473
+ const args = [
474
+ "-p", "--output-format", "stream-json", "--stream-partial-output", "--trust",
475
+ ...(prepared.resume === "native" ? ["--resume", session.nativeSessionId] : ["--new-session-id", session.nativeSessionId]),
476
+ ...(history ? ["--conversation-history-file", history] : []),
477
+ ...(preference.model ? ["--model", preference.model] : []),
478
+ ...content.images.flatMap((path) => ["--image", path]),
479
+ "--", content.text,
480
+ ];
481
+ // The prompt is sent when the process starts.
482
+ await turn.started(session);
483
+ try {
484
+ const run = cursorCommand(executor.executables.cursor || BINARY, args, executor.cwd);
485
+ host = new JsonRpcProcess(...run.command, executor.cwd, "stream", { ...run.env, ...turnEnvironment(input) });
486
+ watchHost(host, executor.diagnostics, context);
487
+ const child = host;
488
+ await new Promise((resolve, reject) => {
489
+ let escalation;
490
+ let done = false;
491
+ const finish = (error) => {
492
+ if (done)
493
+ return;
494
+ done = true;
495
+ off();
496
+ offFailure();
497
+ signal.removeEventListener("abort", abort);
498
+ clearTimeout(escalation);
499
+ if (error)
500
+ reject(error);
501
+ else
502
+ resolve();
503
+ };
504
+ const offFailure = child.onFailure((error) => finish(error));
505
+ const off = child.onEvent((event) => { spoke = true; try {
506
+ if (translator.push(event))
507
+ finish();
508
+ }
509
+ catch (error) {
510
+ finish(error);
511
+ } });
512
+ const abort = () => {
513
+ // The exit that follows a stop is expected.
514
+ child.endInput();
515
+ child.interrupt();
516
+ escalation = setTimeout(() => void child.close().catch(() => undefined), ABORT_ESCALATION_MS);
517
+ };
518
+ signal.addEventListener("abort", abort, { once: true });
519
+ if (signal.aborted)
520
+ abort();
521
+ });
522
+ message = translator.result() ?? translator.last();
523
+ if (signal.aborted)
524
+ message = { ...message, stopReason: "aborted", errorMessage: null };
525
+ }
526
+ catch (error) {
527
+ message = { ...translator.last(), stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
528
+ }
529
+ host?.endInput();
530
+ await host?.exited(FLUSH_TIMEOUT_MS);
531
+ const written = await findTranscript(session.nativeSessionId, executor.cwd);
532
+ if (written)
533
+ exited = { path: written, size: await stat(written).then((info) => info.size, () => 0) };
534
+ }
535
+ finally {
536
+ await preference?.release().catch((error) => {
537
+ executor.diagnostics?.log("warn", "harness.cursor_model_preference_failed", { error: describeError(error) }, { ...context, component: "harness" });
538
+ });
539
+ if (directory)
540
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
541
+ // A Turn that failed before Cursor started leaves no projection in Cursor's transcripts.
542
+ if (!host && prepared.resume === "handoff")
543
+ await rm(session.path, { force: true }).catch(() => undefined);
544
+ }
545
+ const path = exited?.path ?? await findTranscript(session.nativeSessionId, executor.cwd);
546
+ if (path) {
547
+ session = { ...session, path };
548
+ // The transcript cannot name Cohub's Turn: it was recorded between the earlier bytes and the size at
549
+ // exit, once the CLI spoke, with this prompt and the reply this run streamed. Another client may write
550
+ // the same chat meanwhile; a Turn the window does not single out keeps the boundary before it, so a
551
+ // Turn identical in prompt and reply may sync twice but no terminal Turn is lost. The server has the
552
+ // file through that boundary, so terminal Turns after it sync even if the Runtime was offline.
553
+ const turns = (await readCursorTranscript(path, session.nativeSessionId).catch(() => null))?.turns ?? [];
554
+ const recorded = spoke && exited ? turns.filter((entry) => entry.startBytes >= before && entry.startBytes < (exited?.size ?? 0)) : [];
555
+ const words = (blocks) => blocks.flatMap((block) => block.type === "text" ? [block.text] : []).join("").trim();
556
+ const matching = recorded.filter((entry) => words(entry.userContent) === sent?.trim());
557
+ const replied = words(message.content) ? matching.filter((entry) => words(entry.messages.at(-1)?.content ?? []) === words(message.content)) : [];
558
+ const own = replied.length === 1 ? replied[0] : matching.length === 1 ? matching[0] : !matching.length && recorded.length === 1 ? recorded[0] : undefined;
559
+ const earlier = turns.filter((entry) => entry.startBytes < before).at(-1);
560
+ executor.ingest.adopt(session, input.sessionId, { turnKey: (own ?? earlier)?.key ?? null, turnId: input.turnId });
561
+ }
562
+ return { session, resume: prepared.resume, message, host };
563
+ }
564
+ export const cursorHarness = {
565
+ id: "cursor",
566
+ binary: BINARY,
567
+ async models(executable, cwd) {
568
+ const probe = await command(executable, PROBE, cwd, 30_000).then(({ stdout, stderr }) => stdout + stderr, (error) => `${error.stdout ?? ""}${error.stderr ?? ""}`);
569
+ if (!/Invalid --new-session-id/.test(probe)) {
570
+ const unknown = /unknown option '([^']+)'/.exec(probe)?.[1];
571
+ throw new Error(`${executable} is not a supported Cursor CLI${unknown ? ` (no ${unknown})` : ""}; update Cursor`);
572
+ }
573
+ const listed = await command(executable, ["models"], cwd, 60_000).catch((error) => {
574
+ throw new Error(`Cursor cannot list models: ${stripVTControlCharacters(error.stderr ?? "").trim() || error.message}`);
575
+ });
576
+ return cursorModels(listed.stdout);
577
+ },
578
+ run: runCursor,
579
+ session: {
580
+ path: cursorTranscriptPath,
581
+ startsWithoutFile: true,
582
+ // Records are Cursor's ConversationHistory messages, turned into the history file when the Turn starts.
583
+ project: (input) => {
584
+ const names = new Map();
585
+ return projectChainedSession(input, "cursor", { id: "cohubId", parent: "cohubParentId" }, (segment) => {
586
+ if (segment.role === "user" && segment.blocks.some((block) => block.type === "tool_result")) {
587
+ return segment.blocks.flatMap((block) => block.type !== "tool_result" ? [] : [{ tool: {
588
+ toolCallId: projectionToolId(block.tool_use_id), toolName: names.get(block.tool_use_id) ?? "tool", isError: block.is_error ?? false,
589
+ content: typeof block.content === "string" ? [{ text: { text: block.content } }] : block.content.flatMap(historyContent),
590
+ } }]);
591
+ }
592
+ if (segment.role === "user") {
593
+ const content = segment.blocks.flatMap(historyContent);
594
+ return content.length ? [{ user: { content } }] : [];
595
+ }
596
+ const content = segment.blocks.flatMap((block) => {
597
+ if (block.type !== "tool_use")
598
+ return historyContent(block);
599
+ names.set(block.id, block.name);
600
+ return [{ toolCall: { toolCallId: projectionToolId(block.id), toolName: block.name, argsJson: JSON.stringify(block.input) } }];
601
+ });
602
+ return content.length ? [{ assistant: { content } }] : [];
603
+ });
604
+ },
605
+ rebind: (records) => records,
606
+ },
607
+ transcript: {
608
+ roots: () => [cursorProjectsRoot()],
609
+ acceptsDirectory: acceptsCursorDirectory,
610
+ acceptsTranscript: acceptsCursorTranscript,
611
+ header: (path) => cursorSessionHeader(path),
612
+ read: (path, signal) => readCursorTranscript(path, basename(path, ".jsonl"), signal),
613
+ boundaries: ["<user_query>", '"type":"turn_ended"'],
614
+ turnEndRecorded: true,
615
+ userFinalMarked: false,
616
+ globalTurnKeys: false,
617
+ // The transcript keeps no ids, so the ledger records which Turn Cohub is running.
618
+ marksCohubTurns: false,
619
+ },
620
+ };
@@ -4,9 +4,7 @@ export type { HarnessExecutables, HarnessModel, HarnessResult, LocalHarnessAdapt
4
4
  export { harnessCommand, harnessExecutableCandidates } from "./process.js";
5
5
  export declare const harnessAdapter: (harness: LocalHarness) => LocalHarnessAdapter;
6
6
  export declare const harnessExecutable: (harness: LocalHarness, executables: HarnessExecutables) => string;
7
- /** Harnesses whose terminal conversations can sync to the Space. */
8
- export declare const nativeSyncHarnesses: (harnesses: readonly LocalHarness[]) => ("claude" | "codex" | "pi" | "workbuddy")[];
9
- export declare function transcriptFormat(harness: LocalHarness): NativeTranscriptFormat;
7
+ export declare const transcriptFormat: (harness: LocalHarness) => NativeTranscriptFormat;
10
8
  /** Discover installed executables only; authentication/capability errors stay explicit. */
11
9
  export declare function installedHarnesses(cwd: string, executables: HarnessExecutables, path?: string): Promise<LocalHarness[]>;
12
10
  /**
@@ -1,25 +1,19 @@
1
1
  import { access, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
- import { LOCAL_HARNESS_SPECS, LOCAL_HARNESSES } from "@neta-art/cohub";
3
+ import { LOCAL_HARNESSES } from "@neta-art/cohub";
4
4
  import { harnessExecutableCandidates } from "./process.js";
5
5
  import { piHarness } from "./pi.js";
6
6
  import { codexHarness } from "./codex.js";
7
7
  import { claudeHarness } from "./claude.js";
8
8
  import { workbuddyHarness } from "./workbuddy.js";
9
+ import { cursorHarness } from "./cursor.js";
9
10
  export { harnessCommand, harnessExecutableCandidates } from "./process.js";
10
11
  /** Every local harness is one adapter; the protocol registry lists their identities. Built lazily to tolerate import cycles. */
11
12
  let registry = null;
12
- const adapters = () => registry ??= { pi: piHarness, codex: codexHarness, claude: claudeHarness, workbuddy: workbuddyHarness };
13
+ const adapters = () => registry ??= { pi: piHarness, codex: codexHarness, claude: claudeHarness, workbuddy: workbuddyHarness, cursor: cursorHarness };
13
14
  export const harnessAdapter = (harness) => adapters()[harness];
14
15
  export const harnessExecutable = (harness, executables) => executables[harness] || adapters()[harness].binary;
15
- /** Harnesses whose terminal conversations can sync to the Space. */
16
- export const nativeSyncHarnesses = (harnesses) => harnesses.filter((harness) => adapters()[harness].nativeSync);
17
- export function transcriptFormat(harness) {
18
- const format = adapters()[harness].transcript;
19
- if (!format)
20
- throw new Error(`${LOCAL_HARNESS_SPECS[harness].label} transcripts cannot be read`);
21
- return format;
22
- }
16
+ export const transcriptFormat = (harness) => adapters()[harness].transcript;
23
17
  /** Discover installed executables only; authentication/capability errors stay explicit. */
24
18
  export async function installedHarnesses(cwd, executables, path = process.env.PATH ?? "") {
25
19
  const explicit = LOCAL_HARNESSES.filter((harness) => executables[harness]);