cyrus-cursor-runner 0.2.49 → 0.2.50

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.
@@ -1,16 +1,12 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
1
  import crypto from "node:crypto";
3
2
  import { EventEmitter } from "node:events";
4
- import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
5
- import { join, parse as pathParse, relative as pathRelative, resolve, } from "node:path";
3
+ import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
6
5
  import { cwd } from "node:process";
7
- import { createInterface } from "node:readline";
6
+ import { fileURLToPath } from "node:url";
8
7
  import { CursorMessageFormatter } from "./formatter.js";
9
- const CURSOR_MCP_CONFIG_DOCS_URL = "https://cursor.com/docs/context/mcp#configuration-locations";
10
- const CURSOR_CLI_PERMISSIONS_DOCS_URL = "https://cursor.com/docs/cli/reference/permissions";
11
- function toFiniteNumber(value) {
12
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
13
- }
8
+ import { buildCyrusPermissionsConfig, } from "./permissions.js";
9
+ import { buildCursorSandboxJson, buildSandboxEnv } from "./sandbox.js";
14
10
  function safeStringify(value) {
15
11
  try {
16
12
  return JSON.stringify(value, null, 2);
@@ -19,14 +15,27 @@ function safeStringify(value) {
19
15
  return String(value);
20
16
  }
21
17
  }
18
+ function normalizeError(error) {
19
+ if (error instanceof Error)
20
+ return error.message;
21
+ if (typeof error === "string")
22
+ return error;
23
+ return "Cursor execution failed";
24
+ }
25
+ function normalizeCursorModel(model) {
26
+ if (!model)
27
+ return model;
28
+ // Map legacy CLI aliases to SDK model IDs. The SDK rejects `auto` and bare
29
+ // `gpt-5`; use `default` (server-side resolution) as a forward-compatible
30
+ // fallback for both. Discover real ids via `Cursor.models.list()`.
31
+ const lowered = model.toLowerCase();
32
+ if (lowered === "gpt-5" || lowered === "auto")
33
+ return "default";
34
+ return model;
35
+ }
22
36
  function createAssistantToolUseMessage(toolUseId, toolName, toolInput, messageId = crypto.randomUUID()) {
23
37
  const contentBlocks = [
24
- {
25
- type: "tool_use",
26
- id: toolUseId,
27
- name: toolName,
28
- input: toolInput,
29
- },
38
+ { type: "tool_use", id: toolUseId, name: toolName, input: toolInput },
30
39
  ];
31
40
  return {
32
41
  id: messageId,
@@ -47,21 +56,7 @@ function createAssistantToolUseMessage(toolUseId, toolName, toolInput, messageId
47
56
  context_management: null,
48
57
  };
49
58
  }
50
- function createUserToolResultMessage(toolUseId, result, isError) {
51
- const contentBlocks = [
52
- {
53
- type: "tool_result",
54
- tool_use_id: toolUseId,
55
- content: result,
56
- is_error: isError,
57
- },
58
- ];
59
- return {
60
- role: "user",
61
- content: contentBlocks,
62
- };
63
- }
64
- function createAssistantBetaMessage(content, messageId = crypto.randomUUID()) {
59
+ function createAssistantTextMessage(content, messageId = crypto.randomUUID()) {
65
60
  const contentBlocks = [
66
61
  { type: "text", text: content },
67
62
  ];
@@ -84,572 +79,190 @@ function createAssistantBetaMessage(content, messageId = crypto.randomUUID()) {
84
79
  context_management: null,
85
80
  };
86
81
  }
87
- function createResultUsage(parsed) {
82
+ function createUserToolResultMessage(toolUseId, result, isError) {
83
+ const contentBlocks = [
84
+ {
85
+ type: "tool_result",
86
+ tool_use_id: toolUseId,
87
+ content: result,
88
+ is_error: isError,
89
+ },
90
+ ];
91
+ return { role: "user", content: contentBlocks };
92
+ }
93
+ function toFiniteNumber(value) {
94
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
95
+ }
96
+ function createResultUsage(totals) {
88
97
  return {
89
- input_tokens: parsed.inputTokens,
90
- output_tokens: parsed.outputTokens,
91
- cache_creation_input_tokens: 0,
92
- cache_read_input_tokens: parsed.cachedInputTokens,
98
+ input_tokens: totals?.inputTokens ?? 0,
99
+ output_tokens: totals?.outputTokens ?? 0,
100
+ // Cursor's `turn-ended` delta exposes `cacheWriteTokens` as a single
101
+ // counter that maps onto Anthropic's `cache_creation_input_tokens`. The
102
+ // SDK does not split ephemeral 1h vs 5m — we report 0 for both buckets
103
+ // and put the full count in the parent field (which is what Cyrus
104
+ // formatters and Linear's cost display read first).
105
+ cache_creation_input_tokens: totals?.cacheWriteTokens ?? 0,
106
+ cache_read_input_tokens: totals?.cacheReadTokens ?? 0,
93
107
  cache_creation: {
94
108
  ephemeral_1h_input_tokens: 0,
95
109
  ephemeral_5m_input_tokens: 0,
96
110
  },
97
111
  };
98
112
  }
99
- function normalizeError(error) {
100
- if (error instanceof Error) {
101
- return error.message;
102
- }
103
- if (typeof error === "string") {
104
- return error;
105
- }
106
- return "Cursor execution failed";
107
- }
108
- function normalizeCursorModel(model) {
109
- if (!model) {
110
- return model;
111
- }
112
- // Preserve backward compatibility for selector aliases that Cursor CLI no longer accepts.
113
- if (model.toLowerCase() === "gpt-5") {
114
- return "auto";
115
- }
116
- return model;
117
- }
118
- function extractTextFromMessageContent(content) {
119
- if (!Array.isArray(content)) {
120
- return "";
121
- }
122
- const text = content
123
- .map((block) => {
124
- if (!block || typeof block !== "object") {
125
- return "";
126
- }
127
- const blockObj = block;
128
- return getStringValue(blockObj, "text") || "";
129
- })
130
- .join("")
131
- .trim();
132
- return text;
133
- }
134
- function inferCommandToolName(command) {
135
- const normalized = command.toLowerCase();
136
- if (/\brg\b|\bgrep\b/.test(normalized)) {
137
- return "Grep";
138
- }
139
- if (/\bglob\.glob\b|\bfind\b.+\s-name\s/.test(normalized)) {
140
- return "Glob";
141
- }
142
- if (/\bcat\b/.test(normalized) && !/>/.test(normalized)) {
143
- return "Read";
144
- }
145
- if (/<<\s*['"]?eof['"]?\s*>/i.test(command) ||
146
- /\becho\b.+>/.test(normalized)) {
147
- return "Write";
148
- }
149
- return "Bash";
150
- }
151
- function normalizeFilePath(path, workingDirectory) {
152
- if (!path) {
153
- return path;
154
- }
155
- if (workingDirectory && path.startsWith(workingDirectory)) {
156
- const relativePath = pathRelative(workingDirectory, path);
157
- if (relativePath && relativePath !== ".") {
158
- return relativePath;
159
- }
160
- }
161
- return path;
162
- }
163
- function summarizeFileChanges(item, workingDirectory) {
164
- const changes = Array.isArray(item.changes) ? item.changes : [];
165
- if (!changes.length) {
166
- return item.status === "failed" ? "Patch failed" : "No file changes";
167
- }
168
- return changes
169
- .map((change) => {
170
- if (!change || typeof change !== "object") {
171
- return null;
172
- }
173
- const mapped = change;
174
- const path = typeof mapped.path === "string" ? mapped.path : "";
175
- const kind = typeof mapped.kind === "string" ? mapped.kind : "update";
176
- const filePath = normalizeFilePath(path, workingDirectory);
177
- return `${kind} ${filePath}`;
178
- })
179
- .filter((line) => Boolean(line))
180
- .join("\n");
181
- }
182
- function isTodoCompleted(status) {
183
- const s = status.toLowerCase();
184
- return s === "completed" || s === "todo_status_completed";
185
- }
186
- function isTodoInProgress(status) {
187
- const s = status.toLowerCase();
188
- return s === "in_progress" || s === "todo_status_in_progress";
189
- }
190
- function summarizeTodoList(item) {
191
- const todos = Array.isArray(item.items) ? item.items : [];
192
- if (!todos.length) {
193
- return "No todos";
194
- }
195
- return todos
196
- .map((todo) => {
197
- if (!todo || typeof todo !== "object") {
198
- return "- [ ] task";
199
- }
200
- const mapped = todo;
201
- const text = typeof mapped.content === "string"
202
- ? mapped.content
203
- : typeof mapped.description === "string"
204
- ? mapped.description
205
- : "task";
206
- const status = typeof mapped.status === "string"
207
- ? mapped.status.toLowerCase()
208
- : "pending";
209
- const marker = isTodoCompleted(status) ? "[x]" : "[ ]";
210
- const suffix = isTodoInProgress(status) ? " (in progress)" : "";
211
- return `- ${marker} ${text}${suffix}`;
212
- })
213
- .join("\n");
214
- }
215
- function getStringValue(object, ...keys) {
216
- for (const key of keys) {
217
- const value = object[key];
218
- if (typeof value === "string" && value.trim().length > 0) {
219
- return value;
220
- }
221
- }
222
- return undefined;
223
- }
224
- function parseToolPattern(toolPattern) {
225
- const trimmed = toolPattern.trim();
226
- if (!trimmed) {
227
- return null;
228
- }
229
- const match = trimmed.match(/^([A-Za-z]+)(?:\((.*)\))?$/);
230
- if (!match) {
231
- return null;
232
- }
233
- return {
234
- name: match[1] || "",
235
- argument: match[2]?.trim() ?? null,
236
- };
237
- }
238
- function normalizeShellCommandBase(argument) {
239
- if (!argument || argument === "*" || argument === "**") {
240
- return "*";
241
- }
242
- const firstRule = argument.split(",")[0]?.trim();
243
- if (!firstRule) {
244
- return "*";
245
- }
246
- const beforeColon = firstRule.split(":")[0]?.trim();
247
- return beforeColon || "*";
248
- }
249
- function normalizePathPattern(argument) {
250
- if (!argument) {
251
- // Keep file access scoped to workspace paths by default.
252
- return "./**";
253
- }
254
- const trimmed = argument.trim();
255
- if (!trimmed) {
256
- return "./**";
257
- }
258
- // Cursor treats broad globs as permissive; anchor wildcard defaults to workspace.
259
- if (trimmed === "**") {
260
- return "./**";
261
- }
262
- return trimmed;
263
- }
264
- function toCursorPath(path) {
265
- return path.replace(/\\/g, "/");
266
- }
267
- function isWildcardPathArgument(argument) {
268
- if (!argument) {
269
- return true;
270
- }
271
- const trimmed = argument.trim();
272
- return trimmed.length === 0 || trimmed === "**";
273
- }
274
- function isBroadReadToolPattern(toolPattern) {
275
- const parsed = parseToolPattern(toolPattern);
276
- if (!parsed) {
277
- return false;
278
- }
279
- const toolName = parsed.name.toLowerCase();
280
- if (!(toolName === "read" || toolName === "glob" || toolName === "grep")) {
281
- return false;
282
- }
283
- return isWildcardPathArgument(parsed.argument);
284
- }
285
- function isBroadWriteToolPattern(toolPattern) {
286
- const parsed = parseToolPattern(toolPattern);
287
- if (!parsed) {
288
- return false;
289
- }
290
- const toolName = parsed.name.toLowerCase();
291
- if (!(toolName === "edit" ||
292
- toolName === "write" ||
293
- toolName === "multiedit" ||
294
- toolName === "notebookedit" ||
295
- toolName === "todowrite")) {
296
- return false;
297
- }
298
- return isWildcardPathArgument(parsed.argument);
299
- }
300
- function buildWorkspaceSiblingDenyPermissions(workspacePath, permission) {
301
- const resolvedWorkspacePath = resolve(workspacePath);
302
- const parsed = pathParse(resolvedWorkspacePath);
303
- if (!parsed.root) {
304
- return [];
305
- }
306
- const segments = resolvedWorkspacePath
307
- .slice(parsed.root.length)
308
- .split(/[\\/]+/)
309
- .filter(Boolean);
310
- if (segments.length === 0) {
311
- return [];
312
- }
313
- const denyPermissions = new Set();
314
- let parentPath = parsed.root;
315
- for (const segment of segments) {
316
- let siblingEntries;
317
- try {
318
- siblingEntries = readdirSync(parentPath, { withFileTypes: true });
319
- }
320
- catch {
321
- break;
322
- }
323
- for (const sibling of siblingEntries) {
324
- if (!sibling.isDirectory() || sibling.name === segment) {
325
- continue;
326
- }
327
- const siblingPath = join(parentPath, sibling.name);
328
- denyPermissions.add(`${permission}(${toCursorPath(siblingPath)}/**)`);
329
- }
330
- parentPath = join(parentPath, segment);
331
- }
332
- return [...denyPermissions];
333
- }
334
- function buildSystemRootDenyPermissions(workspacePath, permission) {
335
- const workspace = toCursorPath(resolve(workspacePath));
336
- const rootCandidates = [
337
- "/etc",
338
- "/bin",
339
- "/sbin",
340
- "/usr",
341
- "/opt",
342
- "/System",
343
- "/Library",
344
- "/Applications",
345
- "/dev",
346
- "/proc",
347
- "/sys",
348
- "/Volumes",
349
- "/home",
350
- ];
351
- const denies = [];
352
- for (const rootPath of rootCandidates) {
353
- if (workspace === rootPath || workspace.startsWith(`${rootPath}/`)) {
113
+ /**
114
+ * Convert the Cyrus inline MCP config (potentially containing in-process
115
+ * SDK servers) into the SDK's serializable McpServerConfig format. Skips
116
+ * entries that aren't transportable.
117
+ */
118
+ function mapCyrusMcpToSdk(mcpConfig) {
119
+ const servers = {};
120
+ if (!mcpConfig)
121
+ return servers;
122
+ for (const [name, raw] of Object.entries(mcpConfig)) {
123
+ const cfg = raw;
124
+ if (typeof cfg.listTools === "function" ||
125
+ typeof cfg.callTool === "function") {
126
+ console.warn(`[CursorRunner] Skipping MCP server '${name}' because in-process SDK server instances cannot be serialized for @cursor/sdk`);
354
127
  continue;
355
128
  }
356
- denies.push(`${permission}(${rootPath}/**)`);
357
- }
358
- return denies;
359
- }
360
- function normalizeMcpPermissionPart(value) {
361
- if (!value) {
362
- return "*";
363
- }
364
- const trimmed = value.trim();
365
- return trimmed || "*";
366
- }
367
- function mapClaudeMcpToolPatternToCursorPermission(toolPattern) {
368
- const trimmed = toolPattern.trim();
369
- if (!trimmed.toLowerCase().startsWith("mcp__")) {
370
- return null;
371
- }
372
- const parts = trimmed.split("__");
373
- if (parts.length < 2) {
374
- return null;
375
- }
376
- const server = normalizeMcpPermissionPart(parts[1] || null);
377
- const tool = parts.length >= 3
378
- ? normalizeMcpPermissionPart(parts.slice(2).join("__"))
379
- : "*";
380
- return `Mcp(${server}:${tool})`;
381
- }
382
- function mapClaudeToolPatternToCursorPermission(toolPattern) {
383
- const mappedMcpPermission = mapClaudeMcpToolPatternToCursorPermission(toolPattern);
384
- if (mappedMcpPermission) {
385
- return mappedMcpPermission;
386
- }
387
- const parsed = parseToolPattern(toolPattern);
388
- if (!parsed) {
389
- return null;
390
- }
391
- const toolName = parsed.name.toLowerCase();
392
- if (toolName === "bash" || toolName === "shell") {
393
- return `Shell(${normalizeShellCommandBase(parsed.argument)})`;
394
- }
395
- if (toolName === "read" || toolName === "glob" || toolName === "grep") {
396
- return `Read(${normalizePathPattern(parsed.argument)})`;
397
- }
398
- if (toolName === "edit" ||
399
- toolName === "write" ||
400
- toolName === "multiedit" ||
401
- toolName === "notebookedit" ||
402
- toolName === "todowrite") {
403
- return `Write(${normalizePathPattern(parsed.argument)})`;
404
- }
405
- return null;
406
- }
407
- function parseMcpServersFromCursorListOutput(output) {
408
- const servers = new Set();
409
- for (const line of output.split(/\r?\n/)) {
410
- const match = line.match(/^\s*([A-Za-z0-9._-]+)\s*:/);
411
- const serverName = match?.[1]?.trim();
412
- if (serverName) {
413
- servers.add(serverName);
129
+ if (typeof cfg.url === "string" && cfg.url.length > 0) {
130
+ const headers = cfg.headers &&
131
+ typeof cfg.headers === "object" &&
132
+ !Array.isArray(cfg.headers)
133
+ ? cfg.headers
134
+ : undefined;
135
+ const type = (cfg.type === "sse" ? "sse" : "http");
136
+ servers[name] = {
137
+ type,
138
+ url: cfg.url,
139
+ ...(headers ? { headers } : {}),
140
+ };
141
+ continue;
414
142
  }
415
- }
416
- return [...servers];
417
- }
418
- function getProjectionForItem(item, workingDirectory) {
419
- const itemId = getStringValue(item, "id", "tool_id", "item_id");
420
- if (!itemId) {
421
- return null;
422
- }
423
- const itemType = getStringValue(item, "type");
424
- const status = getStringValue(item, "status") || "completed";
425
- const isError = status === "failed";
426
- if (itemType === "command_execution") {
427
- const command = getStringValue(item, "command") || "";
428
- const output = getStringValue(item, "aggregated_output", "output") || "";
429
- const exitCodeValue = item.exit_code;
430
- const exitCode = toFiniteNumber(exitCodeValue);
431
- const toolName = inferCommandToolName(command);
432
- const toolInput = {
433
- command,
434
- description: command,
435
- };
436
- const result = output ||
437
- (isError
438
- ? `Command failed${exitCode ? ` (exit ${exitCode})` : ""}`
439
- : "Command completed");
440
- return {
441
- toolUseId: itemId,
442
- toolName,
443
- toolInput,
444
- result,
445
- isError,
446
- };
447
- }
448
- if (itemType === "file_change") {
449
- const summary = summarizeFileChanges(item, workingDirectory);
450
- return {
451
- toolUseId: itemId,
452
- toolName: "Edit",
453
- toolInput: { description: summary },
454
- result: summary,
455
- isError,
456
- };
457
- }
458
- if (itemType === "web_search") {
459
- const query = getStringValue(item, "query") || "web search";
460
- const actionValue = item.action;
461
- let toolInput = { query };
462
- let result = query;
463
- if (actionValue && typeof actionValue === "object") {
464
- const action = actionValue;
465
- const url = getStringValue(action, "url");
466
- if (url) {
467
- toolInput = { url };
468
- result = url;
469
- }
143
+ if (typeof cfg.command === "string" && cfg.command.length > 0) {
144
+ const args = Array.isArray(cfg.args) ? cfg.args : undefined;
145
+ const env = cfg.env && typeof cfg.env === "object" && !Array.isArray(cfg.env)
146
+ ? cfg.env
147
+ : undefined;
148
+ servers[name] = {
149
+ type: "stdio",
150
+ command: cfg.command,
151
+ ...(args ? { args } : {}),
152
+ ...(env ? { env } : {}),
153
+ ...(typeof cfg.cwd === "string" ? { cwd: cfg.cwd } : {}),
154
+ };
155
+ continue;
470
156
  }
471
- return {
472
- toolUseId: itemId,
473
- toolName: "WebSearch",
474
- toolInput,
475
- result,
476
- isError,
477
- };
478
- }
479
- if (itemType === "mcp_tool_call") {
480
- const server = getStringValue(item, "server") || "mcp";
481
- const tool = getStringValue(item, "tool") || "tool";
482
- const args = item.arguments && typeof item.arguments === "object"
483
- ? item.arguments
484
- : {};
485
- const result = getStringValue(item, "result") ||
486
- safeStringify(item.result || "MCP tool completed");
487
- return {
488
- toolUseId: itemId,
489
- toolName: `mcp__${server}__${tool}`,
490
- toolInput: args,
491
- result,
492
- isError,
493
- };
494
- }
495
- if (itemType === "todo_list") {
496
- const summary = summarizeTodoList(item);
497
- return {
498
- toolUseId: itemId,
499
- toolName: "TodoWrite",
500
- toolInput: { todos: item.items },
501
- result: summary,
502
- isError,
503
- };
504
- }
505
- return null;
506
- }
507
- function extractToolResultFromPayload(payload) {
508
- const resultValue = payload.result && typeof payload.result === "object"
509
- ? payload.result
510
- : null;
511
- if (!resultValue) {
512
- return { text: "Tool completed", isError: false };
513
- }
514
- if (resultValue.success && typeof resultValue.success === "object") {
515
- const success = resultValue.success;
516
- const output = getStringValue(success, "interleavedOutput", "stdout", "markdown", "text") || safeStringify(success);
517
- return { text: output, isError: false };
518
- }
519
- const failure = resultValue.failure && typeof resultValue.failure === "object"
520
- ? resultValue.failure
521
- : null;
522
- if (failure) {
523
- return {
524
- text: getStringValue(failure, "message", "stderr") || safeStringify(failure),
525
- isError: true,
526
- };
527
- }
528
- return { text: safeStringify(resultValue), isError: false };
529
- }
530
- function getProjectionForToolCallEvent(event, workingDirectory) {
531
- const toolUseId = getStringValue(event, "call_id");
532
- if (!toolUseId) {
533
- return null;
534
- }
535
- const toolCallRaw = event.tool_call && typeof event.tool_call === "object"
536
- ? event.tool_call
537
- : null;
538
- if (!toolCallRaw) {
539
- return null;
540
- }
541
- const variantKey = Object.keys(toolCallRaw)[0];
542
- if (!variantKey) {
543
- return null;
544
- }
545
- const payloadValue = toolCallRaw[variantKey];
546
- if (!payloadValue || typeof payloadValue !== "object") {
547
- return null;
548
- }
549
- const payload = payloadValue;
550
- const args = payload.args && typeof payload.args === "object"
551
- ? payload.args
552
- : {};
553
- let toolName = "Tool";
554
- let toolInput = {};
555
- let resultText = "Tool completed";
556
- if (variantKey === "shellToolCall") {
557
- const command = getStringValue(args, "command") || "";
558
- toolName = inferCommandToolName(command);
157
+ console.warn(`[CursorRunner] Skipping MCP server '${name}' because it has no serializable command/url transport`);
158
+ }
159
+ return servers;
160
+ }
161
+ /**
162
+ * Project an SDK `tool_call` event into the Claude-shaped tool_use /
163
+ * tool_result pair that the Cyrus formatter and timeline expect.
164
+ *
165
+ * MCP tool calls surface as the generic `name: "mcp"` in the SDK stream;
166
+ * this inspects `args` to extract the actual `<server>:<tool>` and
167
+ * re-projects them as `mcp__<server>__<tool>` to match the Claude
168
+ * runner's convention.
169
+ */
170
+ function projectToolCall(event, workingDirectory) {
171
+ const args = (event.args ?? {});
172
+ const rawName = (event.name ?? "").toLowerCase();
173
+ let toolName = event.name ?? "Tool";
174
+ let toolInput = args;
175
+ if (rawName === "shell") {
176
+ toolName = "Bash";
177
+ const command = typeof args.command === "string" ? args.command : "";
559
178
  toolInput = { command, description: command };
560
179
  }
561
- else if (variantKey === "readToolCall") {
180
+ else if (rawName === "read") {
562
181
  toolName = "Read";
563
182
  toolInput = {
564
- path: normalizeFilePath(getStringValue(args, "path") || "", workingDirectory),
183
+ file_path: typeof args.path === "string" ? args.path : args.file_path,
184
+ offset: args.offset,
565
185
  limit: args.limit,
566
186
  };
567
187
  }
568
- else if (variantKey === "grepToolCall") {
188
+ else if (rawName === "grep") {
569
189
  toolName = "Grep";
570
190
  toolInput = {
571
- pattern: getStringValue(args, "pattern") || "",
572
- path: normalizeFilePath(getStringValue(args, "path") || "", workingDirectory),
191
+ pattern: typeof args.pattern === "string" ? args.pattern : "",
192
+ path: typeof args.path === "string" ? args.path : undefined,
573
193
  };
574
194
  }
575
- else if (variantKey === "globToolCall") {
195
+ else if (rawName === "glob") {
576
196
  toolName = "Glob";
577
197
  toolInput = {
578
- glob: getStringValue(args, "globPattern") || "",
579
- path: normalizeFilePath(getStringValue(args, "targetDirectory") || "", workingDirectory),
580
- };
581
- }
582
- else if (variantKey === "editToolCall") {
583
- toolName = "Edit";
584
- toolInput = {
585
- path: normalizeFilePath(getStringValue(args, "path") || "", workingDirectory),
586
- };
587
- }
588
- else if (variantKey === "deleteToolCall") {
589
- toolName = "Edit";
590
- toolInput = {
591
- description: `delete ${normalizeFilePath(getStringValue(args, "path") || "", workingDirectory)}`,
198
+ pattern: typeof args.globPattern === "string" ? args.globPattern : args.pattern,
199
+ path: typeof args.targetDirectory === "string"
200
+ ? args.targetDirectory
201
+ : undefined,
592
202
  };
593
203
  }
594
- else if (variantKey === "semSearchToolCall") {
595
- toolName = "ToolSearch";
596
- toolInput = { query: getStringValue(args, "query") || "" };
597
- }
598
- else if (variantKey === "readLintsToolCall") {
599
- toolName = "Read";
600
- toolInput = { paths: args.paths };
601
- }
602
- else if (variantKey === "mcpToolCall") {
603
- const provider = getStringValue(args, "providerIdentifier") || "mcp";
604
- const namedTool = getStringValue(args, "toolName") ||
605
- getStringValue(args, "name") ||
606
- "tool";
607
- toolName = `mcp__${provider}__${namedTool}`;
204
+ else if (rawName === "edit" ||
205
+ rawName === "write" ||
206
+ rawName === "delete") {
207
+ toolName =
208
+ rawName === "delete" ? "Edit" : rawName === "write" ? "Write" : "Edit";
209
+ toolInput = { file_path: typeof args.path === "string" ? args.path : "" };
210
+ }
211
+ else if (rawName === "mcp") {
212
+ const provider = typeof args.providerIdentifier === "string"
213
+ ? args.providerIdentifier
214
+ : typeof args.server === "string"
215
+ ? args.server
216
+ : "mcp";
217
+ const innerTool = typeof args.toolName === "string"
218
+ ? args.toolName
219
+ : typeof args.name === "string"
220
+ ? args.name
221
+ : "tool";
222
+ toolName = `mcp__${provider}__${innerTool}`;
608
223
  toolInput =
609
224
  args.args && typeof args.args === "object"
610
225
  ? args.args
611
226
  : {};
612
227
  }
613
- else if (variantKey === "listMcpResourcesToolCall") {
614
- toolName = "mcp__list_resources";
615
- toolInput = {};
228
+ else if (rawName === "update_todos" || rawName === "updatetodos") {
229
+ toolName = "TodoWrite";
230
+ toolInput = { todos: args.todos };
616
231
  }
617
- else if (variantKey === "webFetchToolCall") {
232
+ else if (rawName === "web_fetch" || rawName === "webfetch") {
618
233
  toolName = "WebFetch";
619
- toolInput = { url: getStringValue(args, "url") || "" };
234
+ toolInput = { url: typeof args.url === "string" ? args.url : "" };
620
235
  }
621
- else if (variantKey === "updateTodosToolCall") {
622
- toolName = "TodoWrite";
623
- toolInput = { todos: args.todos };
624
- resultText = summarizeTodoList({ items: args.todos });
236
+ let resultText = "Tool completed";
237
+ const isError = event.status === "error";
238
+ if (event.result !== undefined && event.result !== null) {
239
+ if (typeof event.result === "string") {
240
+ resultText = event.result;
241
+ }
242
+ else {
243
+ resultText = safeStringify(event.result);
244
+ }
625
245
  }
626
- else {
627
- toolName = variantKey.replace(/ToolCall$/, "");
628
- toolInput = args;
246
+ else if (isError) {
247
+ resultText = "Tool failed";
629
248
  }
630
- const extracted = extractToolResultFromPayload(payload);
631
- if (resultText === "Tool completed" || extracted.isError) {
632
- resultText = extracted.text;
249
+ // Light path normalization: trim workingDirectory prefix on read targets.
250
+ if (workingDirectory &&
251
+ toolName === "Read" &&
252
+ typeof toolInput.file_path === "string" &&
253
+ toolInput.file_path.startsWith(workingDirectory)) {
254
+ const rel = toolInput.file_path
255
+ .slice(workingDirectory.length)
256
+ .replace(/^\//, "");
257
+ if (rel)
258
+ toolInput = { ...toolInput, file_path: rel };
633
259
  }
634
260
  return {
635
- toolUseId,
261
+ toolUseId: event.call_id,
636
262
  toolName,
637
263
  toolInput,
638
264
  result: resultText,
639
- isError: extracted.isError,
640
- };
641
- }
642
- function extractUsageFromEvent(event) {
643
- const usageRaw = event.usage && typeof event.usage === "object"
644
- ? event.usage
645
- : null;
646
- if (!usageRaw) {
647
- return null;
648
- }
649
- return {
650
- inputTokens: toFiniteNumber(usageRaw.input_tokens),
651
- outputTokens: toFiniteNumber(usageRaw.output_tokens),
652
- cachedInputTokens: toFiniteNumber(usageRaw.cached_input_tokens),
265
+ isError,
653
266
  };
654
267
  }
655
268
  export class CursorRunner extends EventEmitter {
@@ -658,24 +271,27 @@ export class CursorRunner extends EventEmitter {
658
271
  sessionInfo = null;
659
272
  messages = [];
660
273
  formatter;
661
- process = null;
662
- readlineInterface = null;
274
+ agent = null;
275
+ currentRun = null;
663
276
  pendingResultMessage = null;
664
277
  hasInitMessage = false;
665
278
  lastAssistantText = null;
666
- wasStopped = false;
667
- startTimestampMs = 0;
668
- lastUsage = {
279
+ assistantTextBuffer = "";
280
+ tokenTotals = {
669
281
  inputTokens: 0,
670
282
  outputTokens: 0,
671
- cachedInputTokens: 0,
283
+ cacheReadTokens: 0,
284
+ cacheWriteTokens: 0,
672
285
  };
286
+ wasStopped = false;
287
+ startTimestampMs = 0;
673
288
  errorMessages = [];
674
289
  emittedToolUseIds = new Set();
675
- fallbackOutputLines = [];
676
290
  logStream = null;
677
- mcpConfigRestoreState = null;
678
- permissionsConfigRestoreState = null;
291
+ hooksRestoreState = null;
292
+ sandboxRestoreState = null;
293
+ sandboxEnvRestoreState = null;
294
+ permissionsArtifactsInstalled = false;
679
295
  constructor(config) {
680
296
  super();
681
297
  this.config = config;
@@ -688,24 +304,12 @@ export class CursorRunner extends EventEmitter {
688
304
  this.on("complete", config.onComplete);
689
305
  }
690
306
  async start(prompt) {
691
- return this.startWithPrompt(prompt);
692
- }
693
- async startStreaming(initialPrompt) {
694
- return this.startWithPrompt(null, initialPrompt);
695
- }
696
- addStreamMessage(_content) {
697
- throw new Error("CursorRunner does not support streaming input messages");
698
- }
699
- completeStream() {
700
- // No-op: CursorRunner does not support streaming input.
701
- }
702
- async startWithPrompt(stringPrompt, streamingInitialPrompt) {
703
307
  if (this.isRunning()) {
704
308
  throw new Error("Cursor session already running");
705
309
  }
706
- const sessionId = this.config.resumeSessionId || crypto.randomUUID();
310
+ const initialSessionId = this.config.resumeSessionId || crypto.randomUUID();
707
311
  this.sessionInfo = {
708
- sessionId,
312
+ sessionId: initialSessionId,
709
313
  startedAt: new Date(),
710
314
  isRunning: true,
711
315
  };
@@ -713,579 +317,452 @@ export class CursorRunner extends EventEmitter {
713
317
  this.pendingResultMessage = null;
714
318
  this.hasInitMessage = false;
715
319
  this.lastAssistantText = null;
716
- this.wasStopped = false;
717
- this.startTimestampMs = Date.now();
718
- this.lastUsage = {
320
+ this.assistantTextBuffer = "";
321
+ this.tokenTotals = {
719
322
  inputTokens: 0,
720
323
  outputTokens: 0,
721
- cachedInputTokens: 0,
324
+ cacheReadTokens: 0,
325
+ cacheWriteTokens: 0,
722
326
  };
327
+ this.wasStopped = false;
328
+ this.startTimestampMs = Date.now();
723
329
  this.errorMessages = [];
724
330
  this.emittedToolUseIds.clear();
725
- this.fallbackOutputLines = [];
726
- this.setupLogging(sessionId);
727
- this.syncProjectMcpConfig();
728
- this.enableCursorMcpServers();
729
- this.syncProjectPermissionsConfig();
730
- // Test/CI fallback: allow deterministic mock runs when cursor-agent cannot execute.
731
- if (process.env.CYRUS_CURSOR_MOCK === "1") {
732
- this.emitInitMessage();
733
- this.handleEvent({
734
- type: "message",
735
- role: "assistant",
736
- content: "Cursor mock session completed",
737
- });
738
- this.pendingResultMessage = this.createSuccessResultMessage("Cursor mock session completed");
739
- this.finalizeSession();
740
- return this.sessionInfo;
741
- }
742
- const cursorPath = this.config.cursorPath || "cursor-agent";
743
- const prompt = (stringPrompt ?? streamingInitialPrompt ?? "").trim();
744
- const args = this.buildArgs(prompt);
745
- const spawnLine = `[CursorRunner] Spawn: ${cursorPath} ${args.join(" ")}`;
746
- console.log(spawnLine);
747
- if (this.logStream) {
748
- this.logStream.write(`${spawnLine}\n`);
749
- }
750
- const child = spawn(cursorPath, args, {
751
- cwd: this.config.workingDirectory || cwd(),
752
- env: this.buildEnv(),
753
- stdio: ["ignore", "pipe", "pipe"],
754
- });
755
- this.process = child;
756
- this.readlineInterface = createInterface({
757
- input: child.stdout,
758
- crlfDelay: Infinity,
759
- });
760
- this.readlineInterface.on("line", (line) => this.handleStdoutLine(line));
761
- child.stderr?.on("data", (data) => {
762
- const text = data.toString().trim();
763
- if (!text)
764
- return;
765
- this.errorMessages.push(text);
766
- });
767
- let caughtError;
331
+ this.setupLogging(initialSessionId);
332
+ const workspace = resolve(this.config.workingDirectory || cwd());
768
333
  try {
769
- await new Promise((resolve, reject) => {
770
- child.on("close", (code) => {
771
- if (code === 0 || this.wasStopped) {
772
- resolve();
773
- return;
774
- }
775
- reject(new Error(`cursor-agent exited with code ${code}`));
776
- });
777
- child.on("error", reject);
778
- });
779
- }
780
- catch (error) {
781
- caughtError = error;
782
- }
783
- finally {
784
- this.finalizeSession(caughtError);
785
- }
786
- return this.sessionInfo;
787
- }
788
- buildCursorPermissionsConfig() {
789
- // Cursor CLI permission tokens reference:
790
- // https://cursor.com/docs/cli/reference/permissions
791
- const allowedTools = this.config.allowedTools || [];
792
- const disallowedTools = this.config.disallowedTools || [];
793
- const workspacePath = this.config.workingDirectory;
794
- const allow = [
795
- ...new Set(allowedTools
796
- .map(mapClaudeToolPatternToCursorPermission)
797
- .filter((value) => Boolean(value))),
798
- ];
799
- const autoScopeDenyPermissions = new Set();
800
- if (workspacePath) {
801
- if (allowedTools.some(isBroadReadToolPattern)) {
802
- for (const permission of buildWorkspaceSiblingDenyPermissions(workspacePath, "Read")) {
803
- autoScopeDenyPermissions.add(permission);
804
- }
805
- for (const permission of buildSystemRootDenyPermissions(workspacePath, "Read")) {
806
- autoScopeDenyPermissions.add(permission);
807
- }
808
- }
809
- if (allowedTools.some(isBroadWriteToolPattern)) {
810
- for (const permission of buildWorkspaceSiblingDenyPermissions(workspacePath, "Write")) {
811
- autoScopeDenyPermissions.add(permission);
812
- }
813
- for (const permission of buildSystemRootDenyPermissions(workspacePath, "Write")) {
814
- autoScopeDenyPermissions.add(permission);
815
- }
816
- }
817
- }
818
- const mappedDisallowedPermissions = disallowedTools
819
- .map(mapClaudeToolPatternToCursorPermission)
820
- .filter((value) => Boolean(value));
821
- const deny = [
822
- ...new Set([...mappedDisallowedPermissions, ...autoScopeDenyPermissions].flat()),
823
- ];
824
- return {
825
- permissions: { allow, deny },
826
- };
827
- }
828
- buildCursorMcpServersConfig() {
829
- const servers = {};
830
- for (const [serverName, rawConfig] of Object.entries(this.config.mcpConfig || {})) {
831
- const configAny = rawConfig;
832
- if (typeof configAny.listTools === "function" ||
833
- typeof configAny.callTool === "function") {
834
- console.warn(`[CursorRunner] Skipping MCP server '${serverName}' because in-process SDK server instances cannot be serialized to .cursor/mcp.json`);
835
- continue;
836
- }
837
- const mapped = {};
838
- if (typeof configAny.command === "string") {
839
- mapped.command = configAny.command;
840
- }
841
- if (Array.isArray(configAny.args)) {
842
- mapped.args = configAny.args;
843
- }
844
- if (configAny.env &&
845
- typeof configAny.env === "object" &&
846
- !Array.isArray(configAny.env)) {
847
- mapped.env = configAny.env;
334
+ this.installPermissionsArtifacts(workspace);
335
+ // Test/CI fallback for environments where the SDK can't run.
336
+ if (process.env.CYRUS_CURSOR_MOCK === "1") {
337
+ this.emitInitMessage();
338
+ this.pushAssistantText("Cursor mock session completed");
339
+ this.pendingResultMessage = this.createSuccessResultMessage("Cursor mock session completed");
340
+ this.finalizeSession();
341
+ return this.sessionInfo;
848
342
  }
849
- if (typeof configAny.cwd === "string") {
850
- mapped.cwd = configAny.cwd;
851
- }
852
- if (typeof configAny.url === "string") {
853
- mapped.url = configAny.url;
854
- }
855
- if (configAny.headers &&
856
- typeof configAny.headers === "object" &&
857
- !Array.isArray(configAny.headers)) {
858
- mapped.headers = configAny.headers;
343
+ const apiKey = this.config.cursorApiKey ?? process.env.CURSOR_API_KEY;
344
+ const normalizedModel = normalizeCursorModel(this.config.model);
345
+ const mcpServers = mapCyrusMcpToSdk(this.config.mcpConfig);
346
+ const sandboxEnabled = Boolean(this.config.sandboxSettings?.enabled);
347
+ const baseAgentOptions = {
348
+ apiKey,
349
+ ...(normalizedModel ? { model: { id: normalizedModel } } : {}),
350
+ local: {
351
+ // `cwd` is passed as a string[] per Cyrus convention; the SDK
352
+ // types accept `string | string[]`.
353
+ cwd: [workspace],
354
+ settingSources: ["project"],
355
+ // SDK ≥1.0.11 auto-discovers the bundled `cursorsandbox`
356
+ // helper from the platform-specific optionalDependency
357
+ // (e.g. `@cursor/sdk-darwin-arm64`). The corresponding
358
+ // `.cursor/sandbox.json` policy is written by
359
+ // `installPermissionsArtifacts` so it is in place before
360
+ // the SDK reads it during agent startup.
361
+ sandboxOptions: { enabled: sandboxEnabled },
362
+ },
363
+ ...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
364
+ };
365
+ const { Agent } = await import("@cursor/sdk");
366
+ let agent;
367
+ if (this.config.resumeSessionId) {
368
+ console.log(`[CursorRunner] Resuming agent ${this.config.resumeSessionId}`);
369
+ agent = await Agent.resume(this.config.resumeSessionId, baseAgentOptions);
859
370
  }
860
- if (typeof configAny.timeout === "number") {
861
- mapped.timeout = configAny.timeout;
371
+ else {
372
+ agent = await Agent.create(baseAgentOptions);
862
373
  }
863
- if (!mapped.command && !mapped.url) {
864
- console.warn(`[CursorRunner] Skipping MCP server '${serverName}' because it has no serializable command/url transport`);
865
- continue;
374
+ this.agent = agent;
375
+ if (this.sessionInfo) {
376
+ this.sessionInfo.sessionId = agent.agentId;
866
377
  }
867
- servers[serverName] = mapped;
868
- }
869
- return servers;
870
- }
871
- syncProjectMcpConfig() {
872
- const workspacePath = this.config.workingDirectory;
873
- if (!workspacePath) {
874
- return;
875
- }
876
- const inlineServers = this.buildCursorMcpServersConfig();
877
- if (Object.keys(inlineServers).length === 0) {
878
- return;
879
- }
880
- const cursorDir = join(workspacePath, ".cursor");
881
- const configPath = join(cursorDir, "mcp.json");
882
- let existingConfig = { mcpServers: {} };
883
- try {
884
- if (existsSync(configPath)) {
885
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
886
- if (parsed && typeof parsed === "object") {
887
- existingConfig = parsed;
378
+ this.emitInitMessage();
379
+ console.log(`[CursorRunner] Sending prompt to agent ${agent.agentId} (resume=${Boolean(this.config.resumeSessionId)})`);
380
+ let caughtError;
381
+ try {
382
+ const run = await agent.send(prompt, {
383
+ onDelta: ({ update }) => {
384
+ // `turn-ended` is the only delta carrying token totals.
385
+ // Each fire is a per-turn snapshot — accumulate across
386
+ // turns so the final result reports the run total.
387
+ if (update &&
388
+ typeof update === "object" &&
389
+ update.type === "turn-ended") {
390
+ const usage = update
391
+ .usage;
392
+ if (usage) {
393
+ this.tokenTotals.inputTokens += toFiniteNumber(usage.inputTokens);
394
+ this.tokenTotals.outputTokens += toFiniteNumber(usage.outputTokens);
395
+ this.tokenTotals.cacheReadTokens += toFiniteNumber(usage.cacheReadTokens);
396
+ this.tokenTotals.cacheWriteTokens += toFiniteNumber(usage.cacheWriteTokens);
397
+ }
398
+ }
399
+ },
400
+ });
401
+ this.currentRun = run;
402
+ for await (const event of run.stream()) {
403
+ if (this.wasStopped)
404
+ break;
405
+ this.handleSdkEvent(event);
888
406
  }
889
407
  }
890
- }
891
- catch {
892
- // If existing config is malformed, overwrite with a valid mcpServers object.
893
- }
894
- const existingServers = existingConfig.mcpServers &&
895
- typeof existingConfig.mcpServers === "object" &&
896
- !Array.isArray(existingConfig.mcpServers)
897
- ? existingConfig.mcpServers
898
- : {};
899
- const nextConfig = {
900
- ...existingConfig,
901
- mcpServers: {
902
- ...existingServers,
903
- ...inlineServers,
904
- },
905
- };
906
- mkdirSync(cursorDir, { recursive: true });
907
- const backupPath = existsSync(configPath)
908
- ? `${configPath}.cyrus-backup-${Date.now()}-${process.pid}`
909
- : null;
910
- try {
911
- if (backupPath) {
912
- renameSync(configPath, backupPath);
408
+ catch (error) {
409
+ caughtError = error;
913
410
  }
914
- writeFileSync(configPath, `${JSON.stringify(nextConfig, null, "\t")}\n`, "utf8");
915
- this.mcpConfigRestoreState = {
916
- configPath,
917
- backupPath,
918
- };
411
+ this.finalizeSession(caughtError);
919
412
  }
920
413
  catch (error) {
921
- if (backupPath && existsSync(backupPath)) {
922
- try {
923
- renameSync(backupPath, configPath);
924
- }
925
- catch {
926
- // Best effort rollback; start() will surface the original failure.
927
- }
928
- }
929
- throw error;
414
+ this.finalizeSession(error);
930
415
  }
931
- console.log(`[CursorRunner] Synced project MCP servers at ${configPath} (servers=${Object.keys(nextConfig.mcpServers).length}, backup=${backupPath ? "yes" : "no"}; docs: ${CURSOR_MCP_CONFIG_DOCS_URL})`);
416
+ return this.sessionInfo;
932
417
  }
933
- enableCursorMcpServers() {
934
- const workspacePath = this.config.workingDirectory;
935
- if (!workspacePath) {
936
- return;
937
- }
938
- const mcpCommand = process.env.CURSOR_MCP_COMMAND || "agent";
939
- const listResult = spawnSync(mcpCommand, ["mcp", "list"], {
940
- cwd: workspacePath,
941
- env: this.buildEnv(),
942
- encoding: "utf8",
943
- });
944
- if (listResult.error?.code === "ENOENT") {
945
- console.warn(`[CursorRunner] Skipping MCP enable preflight: '${mcpCommand}' command not found`);
946
- return;
947
- }
948
- const discoveredServers = (listResult.status ?? 1) === 0
949
- ? parseMcpServersFromCursorListOutput(typeof listResult.stdout === "string" ? listResult.stdout : "")
950
- : [];
951
- if ((listResult.status ?? 1) !== 0 && !listResult.error) {
952
- const detail = typeof listResult.stderr === "string" && listResult.stderr.trim()
953
- ? listResult.stderr.trim()
954
- : `exit ${listResult.status ?? "unknown"}`;
955
- console.warn(`[CursorRunner] MCP list preflight failed: '${mcpCommand} mcp list' (${detail})`);
956
- }
957
- // Cursor MCP enable preflight combines discovered servers and run-time inline config names.
958
- // MCP location/reference: https://cursor.com/docs/context/mcp#configuration-locations
959
- const inlineServers = Object.keys(this.config.mcpConfig || {});
960
- const allServers = [
961
- ...new Set([...discoveredServers, ...inlineServers]),
962
- ].sort((a, b) => a.localeCompare(b));
963
- for (const serverName of allServers) {
964
- const enableResult = spawnSync(mcpCommand, ["mcp", "enable", serverName], {
965
- cwd: workspacePath,
966
- env: this.buildEnv(),
967
- encoding: "utf8",
968
- });
969
- if (enableResult.error?.code ===
970
- "ENOENT") {
971
- console.warn(`[CursorRunner] Failed enabling MCP server '${serverName}': '${mcpCommand}' command not found`);
972
- return;
973
- }
974
- if ((enableResult.status ?? 1) !== 0 || enableResult.error) {
975
- const detail = enableResult.error
976
- ? enableResult.error.message
977
- : typeof enableResult.stderr === "string" &&
978
- enableResult.stderr.trim()
979
- ? enableResult.stderr.trim()
980
- : `exit ${enableResult.status ?? "unknown"}`;
981
- console.warn(`[CursorRunner] Failed enabling MCP server '${serverName}' via '${mcpCommand} mcp enable ${serverName}': ${detail}`);
982
- continue;
983
- }
984
- console.log(`[CursorRunner] Enabled MCP server '${serverName}' via '${mcpCommand} mcp enable ${serverName}'`);
985
- }
418
+ async startStreaming(_initialPrompt) {
419
+ throw new Error("CursorRunner does not support streaming input");
986
420
  }
987
- syncProjectPermissionsConfig() {
988
- const workspacePath = this.config.workingDirectory;
989
- if (!workspacePath) {
990
- return;
991
- }
992
- const mappedPermissions = this.buildCursorPermissionsConfig();
993
- const cursorDir = join(workspacePath, ".cursor");
994
- const configPath = join(cursorDir, "cli.json");
995
- let existingConfig = {
996
- permissions: { allow: [], deny: [] },
997
- };
998
- try {
999
- if (existsSync(configPath)) {
1000
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
1001
- if (parsed && typeof parsed === "object") {
1002
- existingConfig = parsed;
1003
- }
1004
- }
1005
- }
1006
- catch {
1007
- // If existing config is malformed, overwrite with a valid permissions object.
1008
- }
1009
- const nextConfig = {
1010
- ...existingConfig,
1011
- permissions: mappedPermissions.permissions,
1012
- };
1013
- mkdirSync(cursorDir, { recursive: true });
1014
- const backupPath = existsSync(configPath)
1015
- ? `${configPath}.cyrus-backup-${Date.now()}-${process.pid}`
1016
- : null;
1017
- try {
1018
- if (backupPath) {
1019
- renameSync(configPath, backupPath);
421
+ addStreamMessage(_content) {
422
+ throw new Error("CursorRunner does not support streaming input messages");
423
+ }
424
+ completeStream() {
425
+ // No-op: CursorRunner does not support streaming input.
426
+ }
427
+ stop() {
428
+ this.wasStopped = true;
429
+ const run = this.currentRun;
430
+ if (run && typeof run.cancel === "function") {
431
+ void run.cancel().catch(() => { });
432
+ }
433
+ const agent = this.agent;
434
+ if (agent && typeof agent.close === "function") {
435
+ try {
436
+ agent.close();
1020
437
  }
1021
- writeFileSync(configPath, `${JSON.stringify(nextConfig, null, "\t")}\n`, "utf8");
1022
- this.permissionsConfigRestoreState = {
1023
- configPath,
1024
- backupPath,
1025
- };
438
+ catch { }
1026
439
  }
1027
- catch (error) {
1028
- if (backupPath && existsSync(backupPath)) {
1029
- try {
1030
- renameSync(backupPath, configPath);
1031
- }
1032
- catch {
1033
- // Best effort rollback; start() will surface the original failure.
1034
- }
1035
- }
1036
- throw error;
440
+ if (this.sessionInfo) {
441
+ this.sessionInfo.isRunning = false;
1037
442
  }
1038
- console.log(`[CursorRunner] Synced project permissions at ${configPath} (allow=${nextConfig.permissions.allow.length}, deny=${nextConfig.permissions.deny.length}, backup=${backupPath ? "yes" : "no"}; docs: ${CURSOR_CLI_PERMISSIONS_DOCS_URL})`);
1039
443
  }
1040
- restoreProjectPermissionsConfig() {
1041
- const restoreState = this.permissionsConfigRestoreState;
1042
- if (!restoreState) {
1043
- return;
1044
- }
1045
- try {
1046
- if (restoreState.backupPath) {
1047
- if (existsSync(restoreState.configPath)) {
1048
- unlinkSync(restoreState.configPath);
1049
- }
1050
- if (existsSync(restoreState.backupPath)) {
1051
- renameSync(restoreState.backupPath, restoreState.configPath);
1052
- console.log(`[CursorRunner] Restored original project permissions at ${restoreState.configPath}`);
444
+ isRunning() {
445
+ return this.sessionInfo?.isRunning ?? false;
446
+ }
447
+ getMessages() {
448
+ return [...this.messages];
449
+ }
450
+ getFormatter() {
451
+ return this.formatter;
452
+ }
453
+ // ---------- SDK event handling ----------
454
+ handleSdkEvent(event) {
455
+ switch (event.type) {
456
+ case "system":
457
+ if (event.subtype === "init" && this.sessionInfo) {
458
+ this.sessionInfo.sessionId = event.agent_id;
1053
459
  }
460
+ this.emitInitMessage();
461
+ return;
462
+ case "assistant":
463
+ this.handleAssistantEvent(event);
464
+ return;
465
+ case "user":
466
+ this.flushAssistantTextBuffer();
467
+ this.handleUserEvent(event);
468
+ return;
469
+ case "tool_call":
470
+ this.flushAssistantTextBuffer();
471
+ this.handleToolCallEvent(event);
472
+ return;
473
+ case "thinking":
474
+ this.flushAssistantTextBuffer();
475
+ this.handleThinkingEvent(event);
476
+ return;
477
+ case "status":
478
+ this.flushAssistantTextBuffer();
479
+ this.handleStatusEvent(event);
480
+ return;
481
+ default:
1054
482
  return;
1055
- }
1056
- if (existsSync(restoreState.configPath)) {
1057
- unlinkSync(restoreState.configPath);
1058
- }
1059
- }
1060
- catch (error) {
1061
- const detail = error instanceof Error ? error.message : String(error);
1062
- console.warn(`[CursorRunner] Failed to restore project permissions config at ${restoreState.configPath}: ${detail}`);
1063
- }
1064
- finally {
1065
- this.permissionsConfigRestoreState = null;
1066
483
  }
1067
484
  }
1068
- restoreProjectMcpConfig() {
1069
- const restoreState = this.mcpConfigRestoreState;
1070
- if (!restoreState) {
1071
- return;
1072
- }
1073
- try {
1074
- if (restoreState.backupPath) {
1075
- if (existsSync(restoreState.configPath)) {
1076
- unlinkSync(restoreState.configPath);
1077
- }
1078
- if (existsSync(restoreState.backupPath)) {
1079
- renameSync(restoreState.backupPath, restoreState.configPath);
1080
- console.log(`[CursorRunner] Restored original project MCP config at ${restoreState.configPath}`);
1081
- }
1082
- return;
485
+ handleAssistantEvent(event) {
486
+ this.emitInitMessage();
487
+ const content = event.message?.content ?? [];
488
+ for (const block of content) {
489
+ if (!block || typeof block !== "object")
490
+ continue;
491
+ if (block.type === "text" && typeof block.text === "string") {
492
+ if (block.text.length === 0)
493
+ continue;
494
+ // Coalesce consecutive text deltas into one assistant message —
495
+ // the SDK streams partial text across multiple `assistant` events
496
+ // per turn. We flush before any non-text block (tool_use below or
497
+ // non-assistant event in handleSdkEvent) and at end of stream.
498
+ this.assistantTextBuffer += block.text;
1083
499
  }
1084
- if (existsSync(restoreState.configPath)) {
1085
- unlinkSync(restoreState.configPath);
500
+ else if (block.type === "tool_use") {
501
+ this.flushAssistantTextBuffer();
502
+ const rawName = String(block.name || "Tool");
503
+ const lowered = rawName.toLowerCase();
504
+ let toolName = rawName;
505
+ let toolInput = (block.input ?? {});
506
+ // MCP tool_use blocks surface as name="mcp" — extract real id.
507
+ if (lowered === "mcp") {
508
+ const args = toolInput;
509
+ const provider = typeof args.providerIdentifier === "string"
510
+ ? args.providerIdentifier
511
+ : typeof args.server === "string"
512
+ ? args.server
513
+ : "mcp";
514
+ const innerTool = typeof args.toolName === "string"
515
+ ? args.toolName
516
+ : typeof args.name === "string"
517
+ ? args.name
518
+ : "tool";
519
+ toolName = `mcp__${provider}__${innerTool}`;
520
+ toolInput =
521
+ args.args && typeof args.args === "object"
522
+ ? args.args
523
+ : {};
524
+ }
525
+ this.emitToolUse({
526
+ toolUseId: block.id,
527
+ toolName,
528
+ toolInput,
529
+ result: "",
530
+ isError: false,
531
+ });
1086
532
  }
1087
533
  }
1088
- catch (error) {
1089
- const detail = error instanceof Error ? error.message : String(error);
1090
- console.warn(`[CursorRunner] Failed to restore project MCP config at ${restoreState.configPath}: ${detail}`);
1091
- }
1092
- finally {
1093
- this.mcpConfigRestoreState = null;
1094
- }
1095
- }
1096
- buildArgs(prompt) {
1097
- const args = ["--print", "--output-format", "stream-json"];
1098
- const normalizedModel = normalizeCursorModel(this.config.model);
1099
- // needed or else it errors
1100
- args.push("--trust");
1101
- if (normalizedModel) {
1102
- args.push("--model", normalizedModel);
1103
- }
1104
- if (this.config.resumeSessionId) {
1105
- args.push("--resume", this.config.resumeSessionId);
1106
- }
1107
- if (this.config.workingDirectory) {
1108
- args.push("--workspace", this.config.workingDirectory);
1109
- }
1110
- if (this.config.sandbox) {
1111
- args.push("--sandbox", this.config.sandbox);
1112
- }
1113
- if (this.config.approveMcps ?? true) {
1114
- args.push("--approve-mcps");
1115
- }
1116
- if (prompt) {
1117
- args.push(prompt);
1118
- }
1119
- return args;
1120
534
  }
1121
- buildEnv() {
1122
- const env = { ...process.env };
1123
- if (this.config.cursorApiKey) {
1124
- env.CURSOR_API_KEY = this.config.cursorApiKey;
1125
- }
1126
- return env;
1127
- }
1128
- handleStdoutLine(line) {
1129
- const trimmed = line.trim();
1130
- if (!trimmed) {
535
+ handleUserEvent(event) {
536
+ this.emitInitMessage();
537
+ const content = event.message?.content ?? [];
538
+ const text = content
539
+ .filter((b) => Boolean(b) &&
540
+ typeof b === "object" &&
541
+ b.type === "text")
542
+ .map((b) => b.text)
543
+ .join("")
544
+ .trim();
545
+ if (!text)
1131
546
  return;
1132
- }
1133
- if (this.logStream) {
1134
- this.logStream.write(`${trimmed}\n`);
1135
- }
1136
- const parsed = this.parseJsonLine(trimmed);
1137
- if (!parsed) {
1138
- this.fallbackOutputLines.push(trimmed);
547
+ const message = {
548
+ type: "user",
549
+ message: {
550
+ role: "user",
551
+ content: [{ type: "text", text }],
552
+ },
553
+ parent_tool_use_id: null,
554
+ session_id: this.sessionInfo?.sessionId || "pending",
555
+ };
556
+ this.pushMessage(message);
557
+ }
558
+ handleToolCallEvent(event) {
559
+ this.emitInitMessage();
560
+ const projection = projectToolCall(event, this.config.workingDirectory);
561
+ if (event.status === "running") {
562
+ this.emitToolUse(projection);
1139
563
  return;
1140
564
  }
1141
- this.handleEvent(parsed);
565
+ this.emitToolUse(projection);
566
+ this.emitToolResult(projection);
567
+ }
568
+ handleThinkingEvent(_event) {
569
+ // cyrus-core's SDKAssistantMessage content blocks don't yet include
570
+ // "thinking"; intentionally drop these to avoid invalid shapes.
1142
571
  }
1143
- parseJsonLine(line) {
1144
- if (!(line.startsWith("{") || line.startsWith("["))) {
1145
- return null;
572
+ handleStatusEvent(event) {
573
+ if (event.status === "ERROR") {
574
+ const message = event.message || "Cursor session errored";
575
+ this.errorMessages.push(message);
576
+ this.pendingResultMessage = this.createErrorResultMessage(message);
1146
577
  }
1147
- try {
1148
- const parsed = JSON.parse(line);
1149
- if (!parsed || typeof parsed !== "object") {
1150
- return null;
1151
- }
1152
- return parsed;
578
+ else if (event.status === "CANCELLED") {
579
+ this.pendingResultMessage = this.createErrorResultMessage("Cursor session cancelled");
1153
580
  }
1154
- catch {
1155
- return null;
581
+ else if (event.status === "EXPIRED") {
582
+ this.pendingResultMessage = this.createErrorResultMessage("Cursor session expired");
1156
583
  }
1157
584
  }
1158
- handleEvent(event) {
1159
- this.emit("streamEvent", event);
1160
- const eventObj = event;
1161
- const type = getStringValue(eventObj, "type");
1162
- if (!type) {
585
+ // ---------- Permission artifacts ----------
586
+ installPermissionsArtifacts(workspace) {
587
+ const cursorDir = join(workspace, ".cursor");
588
+ mkdirSync(cursorDir, { recursive: true });
589
+ // 1. Permissions config (auto-deny is merged in by the helper). Pass
590
+ // the SDK-shaped MCP server map so the helper can derive a logical
591
+ // server name (e.g. "linear") from the command/url that the
592
+ // `beforeMCPExecution` payload exposes — patterns like
593
+ // `Mcp(linear:save_comment)` only match when we provide that lookup.
594
+ const sdkMcpServers = mapCyrusMcpToSdk(this.config.mcpConfig);
595
+ const cfg = buildCyrusPermissionsConfig({
596
+ workspace,
597
+ allowedTools: this.config.allowedTools,
598
+ disallowedTools: this.config.disallowedTools,
599
+ mcpServers: sdkMcpServers,
600
+ });
601
+ writeFileSync(join(cursorDir, "cyrus-permissions.json"), `${JSON.stringify(cfg, null, "\t")}\n`, "utf8");
602
+ // 2. Permission helper script (copied from package's bundled .mjs)
603
+ const helperDst = join(cursorDir, "cyrus-permission-check.mjs");
604
+ const helperSrc = this.locatePermissionCheckSource();
605
+ copyFileSync(helperSrc, helperDst);
606
+ try {
607
+ chmodSync(helperDst, 0o755);
608
+ }
609
+ catch { }
610
+ // 3. Hooks config (back up any existing one)
611
+ const hooksPath = join(cursorDir, "hooks.json");
612
+ const existed = existsSync(hooksPath);
613
+ const backupPath = existed
614
+ ? `${hooksPath}.cyrus-backup-${Date.now()}-${process.pid}`
615
+ : null;
616
+ if (existed && backupPath) {
617
+ renameSync(hooksPath, backupPath);
618
+ }
619
+ const hooksConfig = {
620
+ version: 1,
621
+ hooks: {
622
+ preToolUse: [
623
+ { command: "./.cursor/cyrus-permission-check.mjs", failClosed: true },
624
+ ],
625
+ beforeShellExecution: [
626
+ { command: "./.cursor/cyrus-permission-check.mjs", failClosed: true },
627
+ ],
628
+ beforeReadFile: [
629
+ { command: "./.cursor/cyrus-permission-check.mjs", failClosed: true },
630
+ ],
631
+ beforeMCPExecution: [
632
+ { command: "./.cursor/cyrus-permission-check.mjs", failClosed: true },
633
+ ],
634
+ },
635
+ };
636
+ writeFileSync(hooksPath, `${JSON.stringify(hooksConfig, null, "\t")}\n`, "utf8");
637
+ this.hooksRestoreState = { hooksPath, backupPath };
638
+ this.permissionsArtifactsInstalled = true;
639
+ console.log(`[CursorRunner] Installed Cyrus permission hooks at ${hooksPath} (allow=${cfg.allow.length}, deny=${cfg.deny.length}, backup=${backupPath ? "yes" : "no"})`);
640
+ // 4. Sandbox policy file (only when sandbox is enabled). Cursor's
641
+ // `local.sandboxOptions.enabled: true` engages Apple Seatbelt /
642
+ // Linux Landlock; the policy below extends the default
643
+ // `workspace_readwrite` profile with allow/deny lists translated
644
+ // from the Cyrus / Claude SandboxSettings shape.
645
+ this.installSandboxArtifacts(workspace);
646
+ }
647
+ installSandboxArtifacts(workspace) {
648
+ const sandboxJson = buildCursorSandboxJson({
649
+ workspace,
650
+ sandboxSettings: this.config.sandboxSettings,
651
+ egressCaCertPath: this.config.egressCaCertPath,
652
+ additionalReadwritePaths: this.config.allowedDirectories ?? [],
653
+ });
654
+ if (!sandboxJson)
1163
655
  return;
1164
- }
1165
- if (type === "init" ||
1166
- (type === "system" && getStringValue(eventObj, "subtype") === "init")) {
1167
- const sessionId = getStringValue(eventObj, "session_id") || this.sessionInfo?.sessionId;
1168
- if (sessionId && this.sessionInfo) {
1169
- this.sessionInfo.sessionId = sessionId;
656
+ const cursorDir = join(workspace, ".cursor");
657
+ const sandboxPath = join(cursorDir, "sandbox.json");
658
+ const existed = existsSync(sandboxPath);
659
+ const backupPath = existed
660
+ ? `${sandboxPath}.cyrus-backup-${Date.now()}-${process.pid}`
661
+ : null;
662
+ if (existed && backupPath) {
663
+ renameSync(sandboxPath, backupPath);
664
+ }
665
+ writeFileSync(sandboxPath, `${JSON.stringify(sandboxJson, null, "\t")}\n`, "utf8");
666
+ this.sandboxRestoreState = { sandboxPath, backupPath };
667
+ // Apply env vars on `process.env` so any child shell process spawned
668
+ // by the SDK inherits them. We snapshot the previous values and
669
+ // restore them in `uninstallSandboxArtifacts`.
670
+ const env = buildSandboxEnv({
671
+ sandboxSettings: this.config.sandboxSettings,
672
+ egressCaCertPath: this.config.egressCaCertPath,
673
+ });
674
+ const restore = new Map();
675
+ for (const k of Object.keys(env)) {
676
+ restore.set(k, process.env[k]);
677
+ process.env[k] = env[k];
678
+ }
679
+ this.sandboxEnvRestoreState = restore;
680
+ console.log(`[CursorRunner] Installed Cursor sandbox policy at ${sandboxPath} (allowReadwrite=${sandboxJson.additionalReadwritePaths.length}, allowReadonly=${sandboxJson.additionalReadonlyPaths.length}, networkAllow=${sandboxJson.networkPolicy.allow.length}, backup=${backupPath ? "yes" : "no"})`);
681
+ }
682
+ uninstallSandboxArtifacts() {
683
+ const restore = this.sandboxRestoreState;
684
+ if (restore) {
685
+ try {
686
+ if (existsSync(restore.sandboxPath))
687
+ unlinkSync(restore.sandboxPath);
688
+ if (restore.backupPath && existsSync(restore.backupPath)) {
689
+ renameSync(restore.backupPath, restore.sandboxPath);
690
+ }
1170
691
  }
1171
- this.emitInitMessage();
1172
- return;
1173
- }
1174
- if (type === "message") {
1175
- this.emitInitMessage();
1176
- this.handleMessageEvent(eventObj);
1177
- return;
1178
- }
1179
- if (type === "assistant") {
1180
- this.emitInitMessage();
1181
- const messageObj = eventObj.message;
1182
- const content = messageObj && typeof messageObj === "object"
1183
- ? extractTextFromMessageContent(messageObj.content)
1184
- : "";
1185
- if (content) {
1186
- this.handleMessageEvent({
1187
- role: "assistant",
1188
- content,
1189
- });
692
+ catch (error) {
693
+ const detail = error instanceof Error ? error.message : String(error);
694
+ console.warn(`[CursorRunner] Failed to restore sandbox.json at ${restore.sandboxPath}: ${detail}`);
1190
695
  }
1191
- return;
696
+ this.sandboxRestoreState = null;
1192
697
  }
1193
- if (type === "item.started" || type === "item.completed") {
1194
- this.emitInitMessage();
1195
- const item = eventObj.item;
1196
- if (item && typeof item === "object") {
1197
- this.handleItemEvent(type, item);
698
+ const envRestore = this.sandboxEnvRestoreState;
699
+ if (envRestore) {
700
+ for (const [k, v] of envRestore.entries()) {
701
+ if (v === undefined) {
702
+ delete process.env[k];
703
+ }
704
+ else {
705
+ process.env[k] = v;
706
+ }
1198
707
  }
708
+ this.sandboxEnvRestoreState = null;
709
+ }
710
+ }
711
+ locatePermissionCheckSource() {
712
+ const here = dirname(fileURLToPath(import.meta.url));
713
+ // When built, the .mjs sits next to the compiled JS in dist/.
714
+ const built = join(here, "permission-check.mjs");
715
+ if (existsSync(built))
716
+ return built;
717
+ // During tests against src/, fall back to the source file.
718
+ const fromSrc = join(here, "permission-check.mjs");
719
+ if (existsSync(fromSrc))
720
+ return fromSrc;
721
+ // Last-ditch: package root.
722
+ const pkgRoot = join(here, "..", "src", "permission-check.mjs");
723
+ if (existsSync(pkgRoot))
724
+ return pkgRoot;
725
+ throw new Error("[CursorRunner] could not locate cyrus permission-check.mjs helper");
726
+ }
727
+ uninstallPermissionsArtifacts() {
728
+ this.uninstallSandboxArtifacts();
729
+ if (!this.permissionsArtifactsInstalled)
1199
730
  return;
731
+ const workspace = resolve(this.config.workingDirectory || cwd());
732
+ const cursorDir = join(workspace, ".cursor");
733
+ const cfgPath = join(cursorDir, "cyrus-permissions.json");
734
+ const helperPath = join(cursorDir, "cyrus-permission-check.mjs");
735
+ try {
736
+ if (existsSync(cfgPath))
737
+ unlinkSync(cfgPath);
1200
738
  }
1201
- if (type === "tool_call") {
1202
- this.emitInitMessage();
1203
- this.handleToolCallEvent(eventObj);
1204
- return;
1205
- }
1206
- if (type === "turn.completed" || type === "result") {
1207
- const usage = extractUsageFromEvent(eventObj);
1208
- if (usage) {
1209
- this.lastUsage = usage;
739
+ catch { }
740
+ try {
741
+ if (existsSync(helperPath))
742
+ unlinkSync(helperPath);
743
+ }
744
+ catch { }
745
+ const hooks = this.hooksRestoreState;
746
+ if (hooks) {
747
+ try {
748
+ if (existsSync(hooks.hooksPath))
749
+ unlinkSync(hooks.hooksPath);
750
+ if (hooks.backupPath && existsSync(hooks.backupPath)) {
751
+ renameSync(hooks.backupPath, hooks.hooksPath);
752
+ }
1210
753
  }
1211
- const stopReason = getStringValue(eventObj, "stop_reason");
1212
- if (stopReason?.toLowerCase().includes("max")) {
1213
- const result = this.createErrorResultMessage(`Cursor turn limit reached: ${stopReason}`);
1214
- this.pendingResultMessage = result;
754
+ catch (error) {
755
+ const detail = error instanceof Error ? error.message : String(error);
756
+ console.warn(`[CursorRunner] Failed to restore hooks at ${hooks.hooksPath}: ${detail}`);
1215
757
  }
1216
- return;
1217
- }
1218
- if (type === "error") {
1219
- const message = getStringValue(eventObj, "message") || "Cursor execution failed";
1220
- this.errorMessages.push(message);
1221
- this.pendingResultMessage = this.createErrorResultMessage(message);
1222
- }
1223
- }
1224
- handleMessageEvent(event) {
1225
- const role = getStringValue(event, "role");
1226
- const content = getStringValue(event, "content") || "";
1227
- if (!content) {
1228
- return;
1229
- }
1230
- if (role === "assistant") {
1231
- this.lastAssistantText = content;
1232
- const message = {
1233
- type: "assistant",
1234
- message: createAssistantBetaMessage(content),
1235
- parent_tool_use_id: null,
1236
- uuid: crypto.randomUUID(),
1237
- session_id: this.sessionInfo?.sessionId || "pending",
1238
- };
1239
- this.pushMessage(message);
1240
- return;
1241
- }
1242
- if (role === "user") {
1243
- const message = {
1244
- type: "user",
1245
- message: {
1246
- role: "user",
1247
- content: [{ type: "text", text: content }],
1248
- },
1249
- parent_tool_use_id: null,
1250
- session_id: this.sessionInfo?.sessionId || "pending",
1251
- };
1252
- this.pushMessage(message);
1253
- }
1254
- }
1255
- handleItemEvent(type, item) {
1256
- const projection = getProjectionForItem(item, this.config.workingDirectory);
1257
- if (!projection) {
1258
- return;
1259
- }
1260
- if (type === "item.started") {
1261
- this.emitToolUse(projection);
1262
- return;
1263
- }
1264
- this.emitToolUse(projection);
1265
- this.emitToolResult(projection);
1266
- }
1267
- handleToolCallEvent(event) {
1268
- const projection = getProjectionForToolCallEvent(event, this.config.workingDirectory);
1269
- if (!projection) {
1270
- return;
1271
- }
1272
- const subtype = getStringValue(event, "subtype") || "started";
1273
- if (subtype === "started") {
1274
- this.emitToolUse(projection);
1275
- return;
1276
- }
1277
- if (subtype === "completed" || subtype === "failed") {
1278
- this.emitToolUse(projection);
1279
- this.emitToolResult({
1280
- ...projection,
1281
- isError: projection.isError || subtype === "failed",
1282
- });
1283
758
  }
759
+ this.hooksRestoreState = null;
760
+ this.permissionsArtifactsInstalled = false;
1284
761
  }
762
+ // ---------- Internal helpers ----------
1285
763
  emitToolUse(projection) {
1286
- if (this.emittedToolUseIds.has(projection.toolUseId)) {
764
+ if (this.emittedToolUseIds.has(projection.toolUseId))
1287
765
  return;
1288
- }
1289
766
  this.emittedToolUseIds.add(projection.toolUseId);
1290
767
  const message = {
1291
768
  type: "assistant",
@@ -1299,24 +776,35 @@ export class CursorRunner extends EventEmitter {
1299
776
  emitToolResult(projection) {
1300
777
  const message = {
1301
778
  type: "user",
1302
- message: createUserToolResultMessage(projection.toolUseId, projection.result, projection.isError),
779
+ message: createUserToolResultMessage(projection.toolUseId, projection.result || "Tool completed", projection.isError),
1303
780
  parent_tool_use_id: projection.toolUseId,
1304
781
  session_id: this.sessionInfo?.sessionId || "pending",
1305
782
  };
1306
783
  this.pushMessage(message);
1307
784
  }
785
+ pushAssistantText(text) {
786
+ const message = {
787
+ type: "assistant",
788
+ message: createAssistantTextMessage(text),
789
+ parent_tool_use_id: null,
790
+ uuid: crypto.randomUUID(),
791
+ session_id: this.sessionInfo?.sessionId || "pending",
792
+ };
793
+ this.pushMessage(message);
794
+ }
795
+ flushAssistantTextBuffer() {
796
+ const text = this.assistantTextBuffer;
797
+ this.assistantTextBuffer = "";
798
+ if (text.trim().length === 0)
799
+ return;
800
+ this.lastAssistantText = text;
801
+ this.pushAssistantText(text);
802
+ }
1308
803
  emitInitMessage() {
1309
- if (this.hasInitMessage) {
804
+ if (this.hasInitMessage)
1310
805
  return;
1311
- }
1312
806
  this.hasInitMessage = true;
1313
807
  const sessionId = this.sessionInfo?.sessionId || crypto.randomUUID();
1314
- const permissionModeByCursorConfig = {
1315
- never: "dontAsk",
1316
- "on-request": "default",
1317
- "on-failure": "default",
1318
- untrusted: "default",
1319
- };
1320
808
  const initMessage = {
1321
809
  type: "system",
1322
810
  subtype: "init",
@@ -1325,9 +813,7 @@ export class CursorRunner extends EventEmitter {
1325
813
  tools: this.config.allowedTools || [],
1326
814
  mcp_servers: [],
1327
815
  model: this.config.model || "gpt-5",
1328
- permissionMode: this.config.askForApproval
1329
- ? permissionModeByCursorConfig[this.config.askForApproval]
1330
- : "default",
816
+ permissionMode: "default",
1331
817
  apiKeySource: this.config.cursorApiKey ? "user" : "project",
1332
818
  claude_code_version: "cursor-agent",
1333
819
  slash_commands: [],
@@ -1351,7 +837,7 @@ export class CursorRunner extends EventEmitter {
1351
837
  result,
1352
838
  stop_reason: null,
1353
839
  total_cost_usd: 0,
1354
- usage: createResultUsage(this.lastUsage),
840
+ usage: createResultUsage(this.tokenTotals),
1355
841
  modelUsage: {},
1356
842
  permission_denials: [],
1357
843
  uuid: crypto.randomUUID(),
@@ -1370,7 +856,7 @@ export class CursorRunner extends EventEmitter {
1370
856
  errors: [errorMessage],
1371
857
  stop_reason: null,
1372
858
  total_cost_usd: 0,
1373
- usage: createResultUsage(this.lastUsage),
859
+ usage: createResultUsage(this.tokenTotals),
1374
860
  modelUsage: {},
1375
861
  permission_denials: [],
1376
862
  uuid: crypto.randomUUID(),
@@ -1385,20 +871,23 @@ export class CursorRunner extends EventEmitter {
1385
871
  try {
1386
872
  const logsDir = join(this.config.cyrusHome, "logs");
1387
873
  mkdirSync(logsDir, { recursive: true });
1388
- this.logStream = createWriteStream(join(logsDir, `cursor-${sessionId}.jsonl`), { flags: "a" });
874
+ const stream = createWriteStream(join(logsDir, `cursor-${sessionId}.jsonl`), { flags: "a" });
875
+ stream.on("error", () => {
876
+ // Swallow — logging is best-effort and must not crash the runner.
877
+ });
878
+ this.logStream = stream;
1389
879
  }
1390
880
  catch {
1391
881
  this.logStream = null;
1392
882
  }
1393
883
  }
1394
884
  finalizeSession(error) {
1395
- if (!this.sessionInfo) {
885
+ if (!this.sessionInfo)
1396
886
  return;
1397
- }
1398
887
  this.emitInitMessage();
888
+ this.flushAssistantTextBuffer();
1399
889
  this.sessionInfo.isRunning = false;
1400
- this.restoreProjectMcpConfig();
1401
- this.restoreProjectPermissionsConfig();
890
+ this.uninstallPermissionsArtifacts();
1402
891
  let resultMessage;
1403
892
  if (this.pendingResultMessage) {
1404
893
  resultMessage = this.pendingResultMessage;
@@ -1410,10 +899,7 @@ export class CursorRunner extends EventEmitter {
1410
899
  resultMessage = this.createErrorResultMessage(message);
1411
900
  }
1412
901
  else {
1413
- const fallbackOutput = this.fallbackOutputLines.join("\n").trim();
1414
- resultMessage = this.createSuccessResultMessage(this.lastAssistantText ||
1415
- fallbackOutput ||
1416
- "Cursor session completed successfully");
902
+ resultMessage = this.createSuccessResultMessage(this.lastAssistantText || "Cursor session completed successfully");
1417
903
  }
1418
904
  this.pushMessage(resultMessage);
1419
905
  this.emit("complete", [...this.messages]);
@@ -1426,34 +912,16 @@ export class CursorRunner extends EventEmitter {
1426
912
  this.cleanupRuntimeState();
1427
913
  }
1428
914
  cleanupRuntimeState() {
1429
- if (this.readlineInterface) {
1430
- this.readlineInterface.close();
1431
- this.readlineInterface = null;
1432
- }
1433
915
  if (this.logStream) {
1434
- this.logStream.end();
916
+ try {
917
+ this.logStream.end();
918
+ }
919
+ catch { }
1435
920
  this.logStream = null;
1436
921
  }
1437
- this.process = null;
922
+ this.currentRun = null;
923
+ this.agent = null;
1438
924
  this.pendingResultMessage = null;
1439
925
  }
1440
- stop() {
1441
- this.wasStopped = true;
1442
- if (this.process && !this.process.killed) {
1443
- this.process.kill();
1444
- }
1445
- if (this.sessionInfo) {
1446
- this.sessionInfo.isRunning = false;
1447
- }
1448
- }
1449
- isRunning() {
1450
- return this.sessionInfo?.isRunning ?? false;
1451
- }
1452
- getMessages() {
1453
- return [...this.messages];
1454
- }
1455
- getFormatter() {
1456
- return this.formatter;
1457
- }
1458
926
  }
1459
927
  //# sourceMappingURL=CursorRunner.js.map