@rama_nigg/open-cursor 2.4.3 → 2.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -8,6 +8,7 @@ import { isAbsolute, join, relative, resolve } from "path";
8
8
  import { ToolMapper, type ToolUpdate } from "./acp/tools.js";
9
9
  import { startCursorOAuth } from "./auth";
10
10
  import { LineBuffer } from "./streaming/line-buffer.js";
11
+ import { MixedDeltaTracker } from "./streaming/delta-tracker.js";
11
12
  import { StreamToSseConverter, formatSseDone } from "./streaming/openai-sse.js";
12
13
  import { parseStreamJsonLine } from "./streaming/parser.js";
13
14
  import { extractText, extractThinking, isAssistantText, isResult, isThinking } from "./streaming/types.js";
@@ -57,7 +58,7 @@ import {
57
58
  parseToolLoopMaxRepeat,
58
59
  type ToolLoopGuard,
59
60
  } from "./provider/tool-loop-guard.js";
60
- import { resolveCursorAgentBinary } from "./utils/binary.js";
61
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from "./utils/binary.js";
61
62
 
62
63
  const log = createLogger("plugin");
63
64
 
@@ -444,6 +445,7 @@ export function extractCompletionFromStream(output: string): {
444
445
  let usage: OpenAiUsage | undefined;
445
446
  let sawAssistantPartials = false;
446
447
  let sawThinkingPartials = false;
448
+ const tracker = new MixedDeltaTracker();
447
449
 
448
450
  for (const line of lines) {
449
451
  const event = parseStreamJsonLine(line);
@@ -457,8 +459,8 @@ export function extractCompletionFromStream(output: string): {
457
459
 
458
460
  const isPartial = typeof (event as any).timestamp_ms === "number";
459
461
  if (isPartial) {
460
- assistantText += text;
461
462
  sawAssistantPartials = true;
463
+ assistantText += tracker.nextText(text);
462
464
  } else if (!sawAssistantPartials) {
463
465
  assistantText = text;
464
466
  }
@@ -469,8 +471,8 @@ export function extractCompletionFromStream(output: string): {
469
471
  if (thinking) {
470
472
  const isPartial = typeof (event as any).timestamp_ms === "number";
471
473
  if (isPartial) {
472
- reasoningText += thinking;
473
474
  sawThinkingPartials = true;
475
+ reasoningText += tracker.nextThinking(thinking);
474
476
  } else if (!sawThinkingPartials) {
475
477
  reasoningText = thinking;
476
478
  }
@@ -1238,7 +1240,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1238
1240
  cmd.push("--force");
1239
1241
  }
1240
1242
 
1241
- const child = spawn(cmd[0], cmd.slice(1), {
1243
+ const child = spawn(formatShellCommandForPlatform(cmd[0]), cmd.slice(1), {
1242
1244
  stdio: ["pipe", "pipe", "pipe"],
1243
1245
  shell: process.platform === "win32",
1244
1246
  });
@@ -1415,19 +1417,17 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1415
1417
  }
1416
1418
  };
1417
1419
 
1418
- child.stdout.on("data", async (chunk) => {
1419
- if (streamTerminated || res.writableEnded) {
1420
- return;
1421
- }
1422
- if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
1423
- for (const line of lineBuffer.push(chunk)) {
1424
- if (streamTerminated || res.writableEnded) {
1425
- break;
1426
- }
1420
+ const chunkQueue: Buffer[] = [];
1421
+ let draining = false;
1422
+ let childClosed = false;
1423
+ let childCloseHandled = false;
1424
+ let childExitCode: number | null = null;
1425
+
1426
+ const processLines = async (lines: string[]) => {
1427
+ for (const line of lines) {
1428
+ if (streamTerminated || res.writableEnded) break;
1427
1429
  const event = parseStreamJsonLine(line);
1428
- if (!event) {
1429
- continue;
1430
- }
1430
+ if (!event) continue;
1431
1431
 
1432
1432
  if (isResult(event)) {
1433
1433
  usage = extractOpenAiUsageFromResult(event) ?? usage;
@@ -1469,156 +1469,104 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1469
1469
  if (!result.terminate.silent) {
1470
1470
  emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1471
1471
  } else {
1472
- // Silent termination: just end the stream without an error message
1473
1472
  streamTerminated = true;
1474
1473
  try { child.kill(); } catch { /* ignore */ }
1475
1474
  }
1476
1475
  break;
1477
1476
  }
1478
- if (result.intercepted) {
1479
- break;
1480
- }
1481
- if (result.skipConverter) {
1482
- continue;
1483
- }
1477
+ if (result.intercepted) break;
1478
+ if (result.skipConverter) continue;
1484
1479
  }
1485
1480
 
1486
- if (streamTerminated || res.writableEnded) {
1487
- break;
1488
- }
1481
+ if (streamTerminated || res.writableEnded) break;
1489
1482
  for (const sse of converter.handleEvent(event)) {
1490
1483
  res.write(sse);
1491
1484
  }
1492
1485
  }
1493
- });
1494
-
1495
- child.on("close", async (code) => {
1496
- if (streamTerminated || res.writableEnded) {
1497
- return;
1498
- }
1499
- for (const line of lineBuffer.flush()) {
1500
- if (streamTerminated || res.writableEnded) {
1501
- break;
1502
- }
1503
- const event = parseStreamJsonLine(line);
1504
- if (!event) {
1505
- continue;
1506
- }
1486
+ };
1507
1487
 
1508
- if (isResult(event)) {
1509
- usage = extractOpenAiUsageFromResult(event) ?? usage;
1488
+ const drainQueue = async () => {
1489
+ if (draining) return;
1490
+ draining = true;
1491
+ try {
1492
+ while (chunkQueue.length > 0) {
1493
+ if (streamTerminated || res.writableEnded) break;
1494
+ const chunk = chunkQueue.shift()!;
1495
+ if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
1496
+ await processLines(lineBuffer.push(chunk));
1510
1497
  }
1511
1498
 
1512
- if (event.type === "tool_call") {
1513
- const result = await handleToolLoopEventWithFallback({
1514
- event: event as any,
1515
- boundary: boundaryContext.getBoundary(),
1516
- boundaryMode: boundaryContext.getBoundary().mode,
1517
- autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
1518
- toolLoopMode: TOOL_LOOP_MODE,
1519
- allowedToolNames,
1520
- toolSchemaMap,
1521
- toolLoopGuard,
1522
- toolMapper,
1523
- toolSessionId,
1524
- shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
1525
- proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
1526
- suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
1527
- toolRouter,
1528
- responseMeta: { id, created, model },
1529
- passThroughTracker,
1530
- onToolUpdate: (update) => {
1531
- res.write(formatToolUpdateEvent(update));
1532
- },
1533
- onToolResult: (toolResult) => {
1534
- res.write(`data: ${JSON.stringify(toolResult)}\n\n`);
1535
- },
1536
- onInterceptedToolCall: (toolCall) => {
1537
- emitToolCallAndTerminate(toolCall);
1538
- },
1539
- onFallbackToLegacy: (error) => {
1540
- boundaryContext.activateLegacyFallback("handleToolLoopEvent.close", error);
1541
- },
1499
+ if (childClosed && !childCloseHandled && !streamTerminated && !res.writableEnded) {
1500
+ childCloseHandled = true;
1501
+ await processLines(lineBuffer.flush());
1502
+ if (streamTerminated || res.writableEnded) return;
1503
+
1504
+ perf.mark("request:done");
1505
+ perf.summarize();
1506
+ const stderrText = Buffer.concat(stderrChunks).toString().trim();
1507
+ log.debug("cursor-agent completed (node stream)", {
1508
+ code: childExitCode,
1509
+ stderrChars: stderrText.length,
1542
1510
  });
1543
- if (result.terminate) {
1544
- if (!result.terminate.silent) {
1545
- emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1546
- } else {
1547
- // Silent termination: just end the stream without an error message
1548
- streamTerminated = true;
1549
- try { child.kill(); } catch { /* ignore */ }
1550
- }
1551
- break;
1511
+ if (childExitCode !== 0) {
1512
+ const errSource =
1513
+ stderrText
1514
+ || `cursor-agent exited with code ${String(childExitCode ?? "unknown")} and no output`;
1515
+ const parsed = parseAgentError(errSource);
1516
+ const msg = formatErrorForUser(parsed);
1517
+ const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1518
+ res.write(`data: ${JSON.stringify(errChunk)}\n\n`);
1519
+ res.write(formatSseDone());
1520
+ streamTerminated = true;
1521
+ res.end();
1522
+ return;
1552
1523
  }
1553
- if (result.intercepted) {
1554
- break;
1524
+
1525
+ const passThroughSummary = passThroughTracker.getSummary();
1526
+ if (passThroughSummary.hasActivity) {
1527
+ await toastService.showPassThroughSummary(passThroughSummary.tools);
1555
1528
  }
1556
- if (result.skipConverter) {
1557
- continue;
1529
+ if (passThroughSummary.errors.length > 0) {
1530
+ await toastService.showErrorSummary(passThroughSummary.errors);
1558
1531
  }
1559
- }
1560
1532
 
1561
- if (streamTerminated || res.writableEnded) {
1562
- break;
1533
+ const doneChunk = {
1534
+ id,
1535
+ object: "chat.completion.chunk",
1536
+ created,
1537
+ model,
1538
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1539
+ };
1540
+ res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
1541
+ if (usage) {
1542
+ const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1543
+ res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
1544
+ }
1545
+ res.write(formatSseDone());
1546
+ streamTerminated = true;
1547
+ res.end();
1563
1548
  }
1564
- for (const sse of converter.handleEvent(event)) {
1565
- res.write(sse);
1549
+ } finally {
1550
+ draining = false;
1551
+ if (
1552
+ !streamTerminated
1553
+ && !res.writableEnded
1554
+ && (chunkQueue.length > 0 || (childClosed && !childCloseHandled))
1555
+ ) {
1556
+ drainQueue();
1566
1557
  }
1567
1558
  }
1568
- if (streamTerminated || res.writableEnded) {
1569
- return;
1570
- }
1571
-
1572
- perf.mark("request:done");
1573
- perf.summarize();
1574
- const stderrText = Buffer.concat(stderrChunks).toString().trim();
1575
- log.debug("cursor-agent completed (node stream)", {
1576
- code,
1577
- stderrChars: stderrText.length,
1578
- });
1579
- if (code !== 0) {
1580
- const errSource =
1581
- stderrText
1582
- || `cursor-agent exited with code ${String(code ?? "unknown")} and no output`;
1583
- const parsed = parseAgentError(errSource);
1584
- const msg = formatErrorForUser(parsed);
1585
- const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1586
- res.write(`data: ${JSON.stringify(errChunk)}\n\n`);
1587
- res.write(formatSseDone());
1588
- streamTerminated = true;
1589
- res.end();
1590
- return;
1591
- }
1559
+ };
1592
1560
 
1593
- // Emit toast for passed-through MCP tools
1594
- const passThroughSummary = passThroughTracker.getSummary();
1595
- if (passThroughSummary.hasActivity) {
1596
- await toastService.showPassThroughSummary(passThroughSummary.tools);
1597
- }
1598
- if (passThroughSummary.errors.length > 0) {
1599
- await toastService.showErrorSummary(passThroughSummary.errors);
1600
- }
1561
+ child.stdout.on("data", (chunk) => {
1562
+ chunkQueue.push(Buffer.from(chunk));
1563
+ drainQueue();
1564
+ });
1601
1565
 
1602
- const doneChunk = {
1603
- id,
1604
- object: "chat.completion.chunk",
1605
- created,
1606
- model,
1607
- choices: [
1608
- {
1609
- index: 0,
1610
- delta: {},
1611
- finish_reason: "stop",
1612
- },
1613
- ],
1614
- };
1615
- res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
1616
- if (usage) {
1617
- const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1618
- res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
1619
- }
1620
- res.write(formatSseDone());
1621
- res.end();
1566
+ child.on("close", (code) => {
1567
+ childClosed = true;
1568
+ childExitCode = code;
1569
+ drainQueue();
1622
1570
  });
1623
1571
  }
1624
1572
  } catch (error) {
@@ -1843,11 +1791,15 @@ function applyToolContextDefaults(
1843
1791
  /**
1844
1792
  * Build tool hook entries from local registry
1845
1793
  */
1794
+ const NATIVE_TOOL_HOOK_EXCLUSIONS = new Set(["grep"]);
1795
+
1846
1796
  function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): Record<string, any> {
1847
1797
  const entries: Record<string, any> = {};
1848
1798
  const sessionWorkspaceBySession = new Map<string, string>();
1849
1799
  const tools = registry.list();
1850
1800
  for (const t of tools) {
1801
+ if (NATIVE_TOOL_HOOK_EXCLUSIONS.has(t.name)) continue;
1802
+
1851
1803
  const handler = registry.getHandler(t.name);
1852
1804
  if (!handler) continue;
1853
1805
 
@@ -8,7 +8,7 @@ import {
8
8
  type StreamJsonEvent,
9
9
  type StreamJsonToolCallEvent,
10
10
  } from "./types.js";
11
- import { DeltaTracker } from "./delta-tracker.js";
11
+ import { MixedDeltaTracker } from "./delta-tracker.js";
12
12
 
13
13
  export type AiSdkStreamPart =
14
14
  | {
@@ -34,44 +34,22 @@ export type AiSdkStreamPart =
34
34
  };
35
35
 
36
36
  export class StreamToAiSdkParts {
37
- private readonly tracker = new DeltaTracker();
38
37
  private readonly toolArgsById = new Map<string, string>();
39
38
  private readonly startedToolIds = new Set<string>();
40
- private sawAssistantPartials = false;
41
- private sawThinkingPartials = false;
39
+ private readonly tracker = new MixedDeltaTracker();
42
40
 
43
41
  handleEvent(event: StreamJsonEvent): AiSdkStreamPart[] {
44
42
  if (isAssistantText(event)) {
45
- const isPartial = typeof event.timestamp_ms === "number";
46
- if (isPartial) {
47
- const text = extractText(event);
48
- if (text) {
49
- this.sawAssistantPartials = true;
50
- return [{ type: "text-delta", textDelta: text }];
51
- }
52
- return [];
53
- }
54
- if (this.sawAssistantPartials) {
55
- return [];
56
- }
57
- const delta = this.tracker.nextText(extractText(event));
43
+ const text = extractText(event);
44
+ if (!text) return [];
45
+ const delta = this.tracker.nextText(text);
58
46
  return delta ? [{ type: "text-delta", textDelta: delta }] : [];
59
47
  }
60
48
 
61
49
  if (isThinking(event)) {
62
- const isPartial = typeof event.timestamp_ms === "number";
63
- if (isPartial) {
64
- const text = extractThinking(event);
65
- if (text) {
66
- this.sawThinkingPartials = true;
67
- return [{ type: "text-delta", textDelta: text }];
68
- }
69
- return [];
70
- }
71
- if (this.sawThinkingPartials) {
72
- return [];
73
- }
74
- const delta = this.tracker.nextThinking(extractThinking(event));
50
+ const text = extractThinking(event);
51
+ if (!text) return [];
52
+ const delta = this.tracker.nextThinking(text);
75
53
  return delta ? [{ type: "text-delta", textDelta: delta }] : [];
76
54
  }
77
55
 
@@ -45,3 +45,45 @@ export class DeltaTracker {
45
45
  return current.slice(i);
46
46
  }
47
47
  }
48
+
49
+ export class MixedDeltaTracker {
50
+ private emittedText = "";
51
+ private emittedThinking = "";
52
+
53
+ nextText(value: string): string {
54
+ const delta = this.diff(this.emittedText, value);
55
+ if (delta) {
56
+ this.emittedText += delta;
57
+ }
58
+ return delta;
59
+ }
60
+
61
+ nextThinking(value: string): string {
62
+ const delta = this.diff(this.emittedThinking, value);
63
+ if (delta) {
64
+ this.emittedThinking += delta;
65
+ }
66
+ return delta;
67
+ }
68
+
69
+ reset(): void {
70
+ this.emittedText = "";
71
+ this.emittedThinking = "";
72
+ }
73
+
74
+ private diff(emitted: string, current: string): string {
75
+ if (!emitted) {
76
+ return current;
77
+ }
78
+
79
+ if (current.startsWith(emitted)) {
80
+ return current.slice(emitted.length);
81
+ }
82
+
83
+ if (emitted.startsWith(current)) {
84
+ return "";
85
+ }
86
+
87
+ return current;
88
+ }
89
+ }
@@ -8,7 +8,7 @@ import {
8
8
  type StreamJsonEvent,
9
9
  type StreamJsonToolCallEvent,
10
10
  } from "./types.js";
11
- import { DeltaTracker } from "./delta-tracker.js";
11
+ import { MixedDeltaTracker } from "./delta-tracker.js";
12
12
 
13
13
  type OpenAiToolCall = {
14
14
  index: number;
@@ -60,12 +60,7 @@ export class StreamToSseConverter {
60
60
  private readonly id: string;
61
61
  private readonly created: number;
62
62
  private readonly model: string;
63
- private readonly tracker = new DeltaTracker();
64
- // Events with timestamp_ms carry delta text; events without carry accumulated text.
65
- // DeltaTracker handles accumulated text only. When partials (delta) were seen,
66
- // the final accumulated event must be skipped to prevent 2x duplication.
67
- private sawAssistantPartials = false;
68
- private sawThinkingPartials = false;
63
+ private readonly tracker = new MixedDeltaTracker();
69
64
 
70
65
  constructor(model: string, options?: { id?: string; created?: number }) {
71
66
  this.model = model;
@@ -75,36 +70,16 @@ export class StreamToSseConverter {
75
70
 
76
71
  handleEvent(event: StreamJsonEvent): string[] {
77
72
  if (isAssistantText(event)) {
78
- const isPartial = typeof event.timestamp_ms === "number";
79
- if (isPartial) {
80
- const text = extractText(event);
81
- if (text) {
82
- this.sawAssistantPartials = true;
83
- return [this.chunkWith({ content: text })];
84
- }
85
- return [];
86
- }
87
- if (this.sawAssistantPartials) {
88
- return [];
89
- }
90
- const delta = this.tracker.nextText(extractText(event));
73
+ const text = extractText(event);
74
+ if (!text) return [];
75
+ const delta = this.tracker.nextText(text);
91
76
  return delta ? [this.chunkWith({ content: delta })] : [];
92
77
  }
93
78
 
94
79
  if (isThinking(event)) {
95
- const isPartial = typeof event.timestamp_ms === "number";
96
- if (isPartial) {
97
- const text = extractThinking(event);
98
- if (text) {
99
- this.sawThinkingPartials = true;
100
- return [this.chunkWith({ reasoning_content: text })];
101
- }
102
- return [];
103
- }
104
- if (this.sawThinkingPartials) {
105
- return [];
106
- }
107
- const delta = this.tracker.nextThinking(extractThinking(event));
80
+ const text = extractThinking(event);
81
+ if (!text) return [];
82
+ const delta = this.tracker.nextThinking(text);
108
83
  return delta ? [this.chunkWith({ reasoning_content: delta })] : [];
109
84
  }
110
85
 
@@ -274,7 +274,7 @@ export function registerDefaultTools(registry: ToolRegistry): void {
274
274
  if (include) {
275
275
  grepArgs.push(`--include=${include}`);
276
276
  }
277
- grepArgs.push(pattern, path);
277
+ grepArgs.push("-e", pattern, path);
278
278
 
279
279
  const runGrep = async (extraArgs: string[] = []) => {
280
280
  return execFileAsync("grep", [...extraArgs, ...grepArgs], { timeout: 30000 });
@@ -3,9 +3,10 @@
3
3
  // Resolves the cursor-agent executable path. On Windows the binary is a `.cmd`
4
4
  // shim, which Node's spawn cannot execute directly without `shell: true` —
5
5
  // callers therefore pair this resolver with `shell: process.platform === "win32"`
6
- // at every spawn site. That re-enables shell metacharacter interpretation, so
7
- // any user-controlled string passed as an argument on Windows must be treated
8
- // as untrusted; never concatenate user input into argv on win32.
6
+ // and `formatShellCommandForPlatform()` at every Node spawn site. That re-enables
7
+ // shell metacharacter interpretation, so any user-controlled string passed as an
8
+ // argument on Windows must be treated as untrusted; never concatenate user input
9
+ // into argv on win32.
9
10
  import { existsSync as fsExistsSync } from "fs";
10
11
  import * as pathModule from "path";
11
12
  import { homedir as osHomedir } from "os";
@@ -55,3 +56,16 @@ export function resolveCursorAgentBinary(deps: BinaryDeps = {}): string {
55
56
  log.warn("cursor-agent not found at known paths, falling back to PATH", { checkedPaths: knownPaths });
56
57
  return "cursor-agent";
57
58
  }
59
+
60
+ export function formatShellCommandForPlatform(
61
+ command: string,
62
+ platform: NodeJS.Platform = process.platform,
63
+ ): string {
64
+ if (platform !== "win32") {
65
+ return command;
66
+ }
67
+ if (command.startsWith("\"") && command.endsWith("\"")) {
68
+ return command;
69
+ }
70
+ return `"${command}"`;
71
+ }