@rama_nigg/open-cursor 2.3.20 → 2.4.1

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
@@ -10,7 +10,12 @@ import { startCursorOAuth } from "./auth";
10
10
  import { LineBuffer } from "./streaming/line-buffer.js";
11
11
  import { StreamToSseConverter, formatSseDone } from "./streaming/openai-sse.js";
12
12
  import { parseStreamJsonLine } from "./streaming/parser.js";
13
- import { extractText, extractThinking, isAssistantText, isThinking } from "./streaming/types.js";
13
+ import { extractText, extractThinking, isAssistantText, isResult, isThinking } from "./streaming/types.js";
14
+ import {
15
+ createChatCompletionUsageChunk,
16
+ extractOpenAiUsageFromResult,
17
+ type OpenAiUsage,
18
+ } from "./usage.js";
14
19
  import { createLogger } from "./utils/logger";
15
20
  import { RequestPerf } from "./utils/perf";
16
21
  import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors";
@@ -52,6 +57,7 @@ import {
52
57
  parseToolLoopMaxRepeat,
53
58
  type ToolLoopGuard,
54
59
  } from "./provider/tool-loop-guard.js";
60
+ import { resolveCursorAgentBinary } from "./utils/binary.js";
55
61
 
56
62
  const log = createLogger("plugin");
57
63
 
@@ -191,7 +197,7 @@ function canonicalizePathForCompare(pathValue: string): string {
191
197
  normalizedPath = resolvedPath;
192
198
  }
193
199
 
194
- if (process.platform === "darwin") {
200
+ if (process.platform === "darwin" || process.platform === "win32") {
195
201
  return normalizedPath.toLowerCase();
196
202
  }
197
203
 
@@ -219,37 +225,76 @@ function isNonConfigPath(pathValue: string): boolean {
219
225
  return !isWithinPath(getOpenCodeConfigPrefix(), pathValue);
220
226
  }
221
227
 
222
- const SESSION_WORKSPACE_CACHE_LIMIT = 200;
223
-
224
- function resolveWorkspaceDirectory(worktree: string | undefined, directory: string | undefined): string {
225
- const envWorkspace = process.env.CURSOR_ACP_WORKSPACE?.trim();
226
- if (envWorkspace) {
227
- return resolve(envWorkspace);
228
+ // Filesystem roots are never a meaningful workspace: accepting "/" (or a bare
229
+ // Windows drive root like "C:\") makes every tool treat the whole machine as
230
+ // the project, which is both unsafe and a common symptom of a daemon that
231
+ // was launched without a real cwd (e.g. systemd unit without WorkingDirectory).
232
+ export function isRootPath(pathValue: string): boolean {
233
+ if (!pathValue) {
234
+ return false;
228
235
  }
236
+ const resolved = resolve(pathValue);
237
+ if (resolved === "/") {
238
+ return true;
239
+ }
240
+ return /^[A-Za-z]:[\\/]?$/.test(resolved);
241
+ }
229
242
 
230
- const envProjectDir = process.env.OPENCODE_CURSOR_PROJECT_DIR?.trim();
231
- if (envProjectDir) {
232
- return resolve(envProjectDir);
243
+ function isAcceptableWorkspace(pathValue: string, configPrefix: string): boolean {
244
+ if (!pathValue) {
245
+ return false;
233
246
  }
247
+ if (isRootPath(pathValue)) {
248
+ return false;
249
+ }
250
+ if (isWithinPath(configPrefix, pathValue)) {
251
+ return false;
252
+ }
253
+ return true;
254
+ }
255
+
256
+ const SESSION_WORKSPACE_CACHE_LIMIT = 200;
234
257
 
258
+ export function resolveWorkspaceDirectory(
259
+ worktree: string | undefined,
260
+ directory: string | undefined,
261
+ ): string {
235
262
  const configPrefix = getOpenCodeConfigPrefix();
236
263
 
264
+ const envWorkspace = resolveCandidate(process.env.CURSOR_ACP_WORKSPACE);
265
+ if (envWorkspace && !isRootPath(envWorkspace)) {
266
+ return envWorkspace;
267
+ }
268
+
269
+ const envProjectDir = resolveCandidate(process.env.OPENCODE_CURSOR_PROJECT_DIR);
270
+ if (envProjectDir && !isRootPath(envProjectDir)) {
271
+ return envProjectDir;
272
+ }
273
+
237
274
  const worktreeCandidate = resolveCandidate(worktree);
238
- if (worktreeCandidate && !isWithinPath(configPrefix, worktreeCandidate)) {
275
+ if (isAcceptableWorkspace(worktreeCandidate, configPrefix)) {
239
276
  return worktreeCandidate;
240
277
  }
241
278
 
242
279
  const dirCandidate = resolveCandidate(directory);
243
- if (dirCandidate && !isWithinPath(configPrefix, dirCandidate)) {
280
+ if (isAcceptableWorkspace(dirCandidate, configPrefix)) {
244
281
  return dirCandidate;
245
282
  }
246
283
 
247
284
  const cwd = resolve(process.cwd());
248
- if (cwd && !isWithinPath(configPrefix, cwd)) {
285
+ if (isAcceptableWorkspace(cwd, configPrefix)) {
249
286
  return cwd;
250
287
  }
251
288
 
252
- return dirCandidate || cwd || configPrefix;
289
+ // Fall back to the user's home directory rather than "/" when every other
290
+ // signal is unusable. $HOME is always writable for the current user and
291
+ // keeps tool scopes sane even when the daemon was spawned from root.
292
+ const home = resolveCandidate(homedir());
293
+ if (home && !isRootPath(home)) {
294
+ return home;
295
+ }
296
+
297
+ return configPrefix;
253
298
  }
254
299
 
255
300
  type ProxyRuntimeState = {
@@ -258,7 +303,11 @@ type ProxyRuntimeState = {
258
303
  };
259
304
 
260
305
  export function normalizeWorkspaceForCompare(pathValue: string): string {
261
- return resolve(pathValue);
306
+ const resolved = resolve(pathValue);
307
+ if (process.platform === "darwin" || process.platform === "win32") {
308
+ return resolved.toLowerCase();
309
+ }
310
+ return resolved;
262
311
  }
263
312
 
264
313
  export function isReusableProxyHealthPayload(payload: any, workspaceDirectory: string): boolean {
@@ -321,7 +370,12 @@ export function resolveChatParamTools(
321
370
  return PROVIDER_BOUNDARY.resolveChatParamTools(mode, existingTools, refreshedTools);
322
371
  }
323
372
 
324
- function createChatCompletionResponse(model: string, content: string, reasoningContent?: string) {
373
+ function createChatCompletionResponse(
374
+ model: string,
375
+ content: string,
376
+ reasoningContent?: string,
377
+ usage?: OpenAiUsage,
378
+ ) {
325
379
  const message: { role: "assistant"; content: string; reasoning_content?: string } = {
326
380
  role: "assistant",
327
381
  content,
@@ -331,7 +385,18 @@ function createChatCompletionResponse(model: string, content: string, reasoningC
331
385
  message.reasoning_content = reasoningContent;
332
386
  }
333
387
 
334
- return {
388
+ const response: {
389
+ id: string;
390
+ object: string;
391
+ created: number;
392
+ model: string;
393
+ choices: Array<{
394
+ index: number;
395
+ message: typeof message;
396
+ finish_reason: string;
397
+ }>;
398
+ usage?: OpenAiUsage;
399
+ } = {
335
400
  id: `cursor-acp-${Date.now()}`,
336
401
  object: "chat.completion",
337
402
  created: Math.floor(Date.now() / 1000),
@@ -344,6 +409,12 @@ function createChatCompletionResponse(model: string, content: string, reasoningC
344
409
  },
345
410
  ],
346
411
  };
412
+
413
+ if (usage) {
414
+ response.usage = usage;
415
+ }
416
+
417
+ return response;
347
418
  }
348
419
 
349
420
  function createChatCompletionChunk(id: string, created: number, model: string, deltaContent: string, done = false) {
@@ -362,11 +433,17 @@ function createChatCompletionChunk(id: string, created: number, model: string, d
362
433
  };
363
434
  }
364
435
 
365
- function extractCompletionFromStream(output: string): { assistantText: string; reasoningText: string } {
436
+ export function extractCompletionFromStream(output: string): {
437
+ assistantText: string;
438
+ reasoningText: string;
439
+ usage?: OpenAiUsage;
440
+ } {
366
441
  const lines = output.split("\n");
367
442
  let assistantText = "";
368
443
  let reasoningText = "";
444
+ let usage: OpenAiUsage | undefined;
369
445
  let sawAssistantPartials = false;
446
+ let sawThinkingPartials = false;
370
447
 
371
448
  for (const line of lines) {
372
449
  const event = parseStreamJsonLine(line);
@@ -390,12 +467,22 @@ function extractCompletionFromStream(output: string): { assistantText: string; r
390
467
  if (isThinking(event)) {
391
468
  const thinking = extractThinking(event);
392
469
  if (thinking) {
393
- reasoningText += thinking;
470
+ const isPartial = typeof (event as any).timestamp_ms === "number";
471
+ if (isPartial) {
472
+ reasoningText += thinking;
473
+ sawThinkingPartials = true;
474
+ } else if (!sawThinkingPartials) {
475
+ reasoningText = thinking;
476
+ }
394
477
  }
395
478
  }
479
+
480
+ if (isResult(event)) {
481
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
482
+ }
396
483
  }
397
484
 
398
- return { assistantText, reasoningText };
485
+ return { assistantText, reasoningText, usage };
399
486
  }
400
487
 
401
488
  function formatToolUpdateEvent(update: ToolUpdate): string {
@@ -570,7 +657,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
570
657
  if (url.pathname === "/v1/models" || url.pathname === "/models") {
571
658
  try {
572
659
  const bunAny = globalThis as any;
573
- const proc = bunAny.Bun.spawn(["cursor-agent", "models"], {
660
+ const proc = bunAny.Bun.spawn([resolveCursorAgentBinary(), "models"], {
574
661
  stdout: "pipe",
575
662
  stderr: "pipe",
576
663
  });
@@ -621,6 +708,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
621
708
  // DEBUG: Log raw request structure for tool-loop investigation
622
709
  debugLogToFile("raw_request_body", {
623
710
  model: body?.model,
711
+ cursorModel: body?.cursorModel,
624
712
  stream,
625
713
  toolCount: tools.length,
626
714
  toolNames: tools.map((t: any) => t?.function?.name ?? t?.name ?? "unknown"),
@@ -637,8 +725,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
637
725
 
638
726
  const subagentNames = readSubagentNames();
639
727
  const prompt = buildPromptFromMessages(messages, tools, subagentNames);
640
- const model = boundaryContext.run("normalizeRuntimeModel", (boundary) =>
641
- boundary.normalizeRuntimeModel(body?.model),
728
+ const model = boundaryContext.run("resolveRuntimeModel", (boundary) =>
729
+ boundary.resolveRuntimeModel(body?.model, body?.cursorModel),
642
730
  );
643
731
  const msgSummaryBun = messages.map((m: any, i: number) => {
644
732
  const role = m?.role ?? "?";
@@ -664,7 +752,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
664
752
  }
665
753
 
666
754
  const cmd = [
667
- "cursor-agent",
755
+ resolveCursorAgentBinary(),
668
756
  "--print",
669
757
  "--output-format",
670
758
  "stream-json",
@@ -765,6 +853,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
765
853
  model,
766
854
  completion.assistantText || stdout || stderr,
767
855
  completion.reasoningText || undefined,
856
+ completion.usage,
768
857
  );
769
858
  return new Response(JSON.stringify(payload), {
770
859
  status: 200,
@@ -786,6 +875,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
786
875
  async start(controller) {
787
876
  let streamTerminated = false;
788
877
  let firstTokenReceived = false;
878
+ let usage: OpenAiUsage | undefined;
789
879
  try {
790
880
  const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
791
881
  const converter = new StreamToSseConverter(model, { id, created });
@@ -840,6 +930,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
840
930
  continue;
841
931
  }
842
932
 
933
+ if (isResult(event)) {
934
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
935
+ }
936
+
843
937
  if (event.type === "tool_call") {
844
938
  perf.mark("tool-call");
845
939
  const result = await handleToolLoopEventWithFallback({
@@ -906,6 +1000,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
906
1000
  if (!event) {
907
1001
  continue;
908
1002
  }
1003
+ if (isResult(event)) {
1004
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
1005
+ }
909
1006
  if (event.type === "tool_call") {
910
1007
  const result = await handleToolLoopEventWithFallback({
911
1008
  event: event as any,
@@ -994,6 +1091,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
994
1091
 
995
1092
  const doneChunk = createChatCompletionChunk(id, created, model, "", true);
996
1093
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(doneChunk)}\n\n`));
1094
+ if (usage) {
1095
+ const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1096
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`));
1097
+ }
997
1098
  controller.enqueue(encoder.encode(formatSseDone()));
998
1099
  } finally {
999
1100
  perf.mark("request:done");
@@ -1054,8 +1155,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1054
1155
  // Dynamic model discovery via cursor-agent models (Node.js handler)
1055
1156
  if (url.pathname === "/v1/models" || url.pathname === "/models") {
1056
1157
  try {
1057
- const { execSync } = await import("child_process");
1058
- const output = execSync("cursor-agent models", { encoding: "utf-8", timeout: 30000 });
1158
+ const { execFileSync } = await import("child_process");
1159
+ const output = execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf-8", timeout: 30000 });
1059
1160
  const clean = stripAnsi(output);
1060
1161
  const models: Array<{ id: string; object: string; created: number; owned_by: string }> = [];
1061
1162
  for (const line of clean.split("\n")) {
@@ -1102,8 +1203,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1102
1203
 
1103
1204
  const subagentNames = readSubagentNames();
1104
1205
  const prompt = buildPromptFromMessages(messages, tools, subagentNames);
1105
- const model = boundaryContext.run("normalizeRuntimeModel", (boundary) =>
1106
- boundary.normalizeRuntimeModel(bodyData?.model),
1206
+ const model = boundaryContext.run("resolveRuntimeModel", (boundary) =>
1207
+ boundary.resolveRuntimeModel(bodyData?.model, bodyData?.cursorModel),
1107
1208
  );
1108
1209
  const msgSummary = messages.map((m: any, i: number) => {
1109
1210
  const role = m?.role ?? "?";
@@ -1123,7 +1224,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1123
1224
  });
1124
1225
 
1125
1226
  const cmd = [
1126
- "cursor-agent",
1227
+ resolveCursorAgentBinary(),
1127
1228
  "--print",
1128
1229
  "--output-format",
1129
1230
  "stream-json",
@@ -1137,7 +1238,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1137
1238
  cmd.push("--force");
1138
1239
  }
1139
1240
 
1140
- const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
1241
+ const child = spawn(cmd[0], cmd.slice(1), {
1242
+ stdio: ["pipe", "pipe", "pipe"],
1243
+ shell: process.platform === "win32",
1244
+ });
1141
1245
 
1142
1246
  // Write prompt to stdin to avoid E2BIG error
1143
1247
  child.stdin.write(prompt);
@@ -1225,6 +1329,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1225
1329
  model,
1226
1330
  completion.assistantText || stdout || stderr,
1227
1331
  completion.reasoningText || undefined,
1332
+ completion.usage,
1228
1333
  );
1229
1334
 
1230
1335
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1251,6 +1356,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1251
1356
  const stderrChunks: Buffer[] = [];
1252
1357
  let streamTerminated = false;
1253
1358
  let firstTokenReceived = false;
1359
+ let usage: OpenAiUsage | undefined;
1254
1360
  child.stderr.on("data", (chunk) => {
1255
1361
  stderrChunks.push(Buffer.from(chunk));
1256
1362
  });
@@ -1323,6 +1429,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1323
1429
  continue;
1324
1430
  }
1325
1431
 
1432
+ if (isResult(event)) {
1433
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
1434
+ }
1435
+
1326
1436
  if (event.type === "tool_call") {
1327
1437
  perf.mark("tool-call");
1328
1438
  const result = await handleToolLoopEventWithFallback({
@@ -1395,6 +1505,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1395
1505
  continue;
1396
1506
  }
1397
1507
 
1508
+ if (isResult(event)) {
1509
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
1510
+ }
1511
+
1398
1512
  if (event.type === "tool_call") {
1399
1513
  const result = await handleToolLoopEventWithFallback({
1400
1514
  event: event as any,
@@ -1499,6 +1613,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1499
1613
  ],
1500
1614
  };
1501
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
+ }
1502
1620
  res.write(formatSseDone());
1503
1621
  res.end();
1504
1622
  });
@@ -1594,7 +1712,7 @@ function jsonSchemaToZod(jsonSchema: any): any {
1594
1712
  }
1595
1713
  break;
1596
1714
  case "object":
1597
- zodType = z.record(z.any());
1715
+ zodType = z.record(z.string(), z.any());
1598
1716
  if (p.description) {
1599
1717
  zodType = zodType.describe(p.description);
1600
1718
  }
@@ -36,6 +36,7 @@ export interface ProviderBoundary {
36
36
  ): ToolLoopFlags;
37
37
  matchesProvider(inputModel: any): boolean;
38
38
  normalizeRuntimeModel(model: unknown): string;
39
+ resolveRuntimeModel(model: unknown, cursorModel: unknown): string;
39
40
  applyChatParamDefaults(
40
41
  output: any,
41
42
  proxyBaseURL: string | undefined,
@@ -137,6 +138,15 @@ function createSharedBoundary(
137
138
  return raw;
138
139
  },
139
140
 
141
+ resolveRuntimeModel(model, cursorModel) {
142
+ const rawCursorModel = typeof cursorModel === "string" ? cursorModel.trim() : "";
143
+ if (rawCursorModel.length > 0) {
144
+ return this.normalizeRuntimeModel(rawCursorModel);
145
+ }
146
+
147
+ return this.normalizeRuntimeModel(model);
148
+ },
149
+
140
150
  applyChatParamDefaults(output, proxyBaseURL, defaultBaseURL, defaultApiKey) {
141
151
  output.options = output.options || {};
142
152
  output.options.baseURL = proxyBaseURL || defaultBaseURL;
@@ -1,5 +1,22 @@
1
- export function createChatCompletionResponse(model: string, content: string) {
2
- return {
1
+ import type { OpenAiUsage } from "../usage.js";
2
+
3
+ export function createChatCompletionResponse(
4
+ model: string,
5
+ content: string,
6
+ usage?: OpenAiUsage,
7
+ ) {
8
+ const response: {
9
+ id: string;
10
+ object: string;
11
+ created: number;
12
+ model: string;
13
+ choices: Array<{
14
+ index: number;
15
+ message: { role: string; content: string };
16
+ finish_reason: string;
17
+ }>;
18
+ usage?: OpenAiUsage;
19
+ } = {
3
20
  id: `cursor-acp-${Date.now()}`,
4
21
  object: "chat.completion",
5
22
  created: Math.floor(Date.now() / 1000),
@@ -8,15 +25,16 @@ export function createChatCompletionResponse(model: string, content: string) {
8
25
  {
9
26
  index: 0,
10
27
  message: { role: "assistant", content },
11
- finish_reason: "stop"
28
+ finish_reason: "stop",
12
29
  }
13
30
  ],
14
- usage: {
15
- prompt_tokens: 0,
16
- completion_tokens: 0,
17
- total_tokens: 0
18
- }
19
31
  };
32
+
33
+ if (usage) {
34
+ response.usage = usage;
35
+ }
36
+
37
+ return response;
20
38
  }
21
39
 
22
40
  export function createChatCompletionChunk(
@@ -24,7 +42,7 @@ export function createChatCompletionChunk(
24
42
  created: number,
25
43
  model: string,
26
44
  deltaContent: string,
27
- done = false
45
+ done = false,
28
46
  ) {
29
47
  return {
30
48
  id,
@@ -35,8 +53,8 @@ export function createChatCompletionChunk(
35
53
  {
36
54
  index: 0,
37
55
  delta: deltaContent ? { content: deltaContent } : {},
38
- finish_reason: done ? "stop" : null
56
+ finish_reason: done ? "stop" : null,
39
57
  }
40
- ]
58
+ ],
41
59
  };
42
- }
60
+ }
@@ -75,8 +75,13 @@ export type StreamJsonResultEvent = {
75
75
  type: "result";
76
76
  subtype?: "success" | "error" | string;
77
77
  timestamp?: number;
78
+ duration_ms?: number;
79
+ duration_api_ms?: number;
78
80
  session_id?: string;
81
+ request_id?: string;
82
+ result?: string;
79
83
  is_error?: boolean;
84
+ usage?: Record<string, unknown>;
80
85
  error?: {
81
86
  message?: string;
82
87
  code?: number | string;