@evanovation/open-cursor 2.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,2802 @@
1
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
2
+ import { tool } from "@opencode-ai/plugin/tool";
3
+ import type { Auth } from "@opencode-ai/sdk";
4
+ import { spawn, spawnSync } from "child_process";
5
+ import { realpathSync } from "fs";
6
+ import { mkdir } from "fs/promises";
7
+ import { homedir } from "os";
8
+ import { isAbsolute, join, relative, resolve } from "path";
9
+ import { ToolMapper, type ToolUpdate } from "./acp/tools.js";
10
+ import { LineBuffer } from "./streaming/line-buffer.js";
11
+ import { MixedDeltaTracker } from "./streaming/delta-tracker.js";
12
+ import { StreamToSseConverter, formatSseDone } from "./streaming/openai-sse.js";
13
+ import { parseStreamJsonLine } from "./streaming/parser.js";
14
+ import {
15
+ extractText,
16
+ extractThinking,
17
+ isAssistantText,
18
+ isResult,
19
+ isThinking,
20
+ type StreamJsonEvent,
21
+ } from "./streaming/types.js";
22
+ import {
23
+ createChatCompletionUsageChunk,
24
+ extractOpenAiUsageFromResult,
25
+ type OpenAiUsage,
26
+ } from "./usage.js";
27
+ import { createLogger } from "./utils/logger.js";
28
+ import { RequestPerf } from "./utils/perf.js";
29
+ import { parseAgentError, formatErrorForUser, stripAnsi, isResumeSpecificFailure } from "./utils/errors.js";
30
+ import { buildPromptFromMessages, buildToolFingerprint } from "./proxy/prompt-builder.js";
31
+ import { buildIncrementalPrompt, type ProxyMessage } from "./proxy/incremental-prompt.js";
32
+ import {
33
+ buildSessionKey,
34
+ clearResumeChatId,
35
+ deriveConversationAnchor,
36
+ deriveConversationResumePrefixes,
37
+ getResumeChatId,
38
+ hasResumeChatId,
39
+ hashForLog,
40
+ isSessionResumeEnabled,
41
+ recordResumeChatId,
42
+ sanitizeSessionKey,
43
+ RESUME_CHAT_ID_SAFE_RE,
44
+ } from "./proxy/session-resume.js";
45
+ import {
46
+ extractAllowedToolNames,
47
+ type OpenAiToolCall,
48
+ } from "./proxy/tool-loop.js";
49
+ import { OpenCodeToolDiscovery } from "./tools/discovery.js";
50
+ import { toOpenAiParameters, describeTool } from "./tools/schema.js";
51
+ import { ToolRouter } from "./tools/router.js";
52
+ import { SkillLoader } from "./tools/skills/loader.js";
53
+ import { SkillResolver } from "./tools/skills/resolver.js";
54
+ import { autoRefreshModels } from "./models/sync.js";
55
+ import { readMcpConfigs, readSubagentNames } from "./mcp/config.js";
56
+ import { McpClientManager } from "./mcp/client-manager.js";
57
+ import {
58
+ MCP_TOOL_PREFIX,
59
+ buildMcpToolHookEntries,
60
+ buildMcpToolDefinitions,
61
+ namespaceMcpTool,
62
+ } from "./mcp/tool-bridge.js";
63
+ import { createOpencodeClient } from "@opencode-ai/sdk";
64
+ import { ToolRegistry as CoreRegistry } from "./tools/core/registry.js";
65
+ import { LocalExecutor } from "./tools/executors/local.js";
66
+ import { SdkExecutor } from "./tools/executors/sdk.js";
67
+ import { McpExecutor } from "./tools/executors/mcp.js";
68
+ import { executeWithChain } from "./tools/core/executor.js";
69
+ import { registerDefaultTools } from "./tools/defaults.js";
70
+ import type { IToolExecutor } from "./tools/core/types.js";
71
+ import {
72
+ createProviderBoundary,
73
+ parseProviderBoundaryMode,
74
+ type ProviderBoundary,
75
+ type ToolLoopMode,
76
+ type ToolOptionResolution,
77
+ } from "./provider/boundary.js";
78
+ import { handleToolLoopEventWithFallback } from "./provider/runtime-interception.js";
79
+ import { PassThroughTracker } from "./provider/passthrough-tracker.js";
80
+ import { toastService } from "./services/toast-service.js";
81
+ import { buildToolSchemaMap } from "./provider/tool-schema-compat.js";
82
+ import {
83
+ createToolLoopGuard,
84
+ parseToolLoopMaxRepeat,
85
+ type ToolLoopGuard,
86
+ } from "./provider/tool-loop-guard.js";
87
+ import { createSdkBunChild, createSdkNodeChild } from "./client/sdk-child.js";
88
+ import { createCursorAgentPoolNodeChild, isAgentPoolEnabled } from "./client/cursor-agent-child.js";
89
+ import {
90
+ parseCursorBackendPreference,
91
+ resolveSdkApiKey,
92
+ selectBackendForRequest,
93
+ type CursorRuntimeBackend,
94
+ } from "./provider/backend.js";
95
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from "./utils/binary.js";
96
+
97
+ const log = createLogger("plugin");
98
+
99
+ interface McpToolSummary {
100
+ serverName: string;
101
+ toolName: string;
102
+ callName?: string;
103
+ description?: string;
104
+ params?: string[];
105
+ }
106
+
107
+ function getMcpToolDefinitionName(mcpToolDefs: any[], index: number): string | undefined {
108
+ const name = mcpToolDefs[index]?.function?.name;
109
+ return typeof name === "string" && name.length > 0 ? name : undefined;
110
+ }
111
+
112
+ export function buildAvailableToolsSystemMessage(
113
+ lastToolNames: string[],
114
+ lastToolMap: Array<{ id: string; name: string }>,
115
+ mcpToolDefs: any[],
116
+ mcpToolSummaries?: McpToolSummary[],
117
+ subagentNames: string[] = [],
118
+ ): string | null {
119
+ const parts: string[] = [];
120
+
121
+ if (lastToolNames.length > 0 || lastToolMap.length > 0) {
122
+ const names = lastToolNames.join(", ");
123
+ const mapping = lastToolMap.map((m) => `${m.id} -> ${m.name}`).join("; ");
124
+ parts.push(`Available OpenCode tools (use via tool calls): ${names}. Original skill ids mapped as: ${mapping}. Aliases include oc_skill_* and oc_superskill_* when applicable.`);
125
+ }
126
+
127
+ if (mcpToolSummaries && mcpToolSummaries.length > 0) {
128
+ const summariesWithCallNames = mcpToolSummaries.map((summary, index) => ({
129
+ ...summary,
130
+ callName: summary.callName
131
+ ?? getMcpToolDefinitionName(mcpToolDefs, index)
132
+ ?? namespaceMcpTool(summary.serverName, summary.toolName),
133
+ }));
134
+
135
+ const servers = new Map<string, Array<McpToolSummary & { callName: string }>>();
136
+ for (const s of summariesWithCallNames) {
137
+ const list = servers.get(s.serverName) ?? [];
138
+ list.push(s);
139
+ servers.set(s.serverName, list);
140
+ }
141
+
142
+ const lines: string[] = [
143
+ `MCP TOOLS — Call these tools by their FULL exact name (e.g. mcp__filesystem__read_file).`,
144
+ `Important: There is NO tool named 'mcp'. Every MCP tool has the format mcp__<server>__<tool>.`,
145
+ "Do NOT call a tool named 'mcp' with parameters. Always use the complete tool name below.",
146
+ "",
147
+ ];
148
+
149
+ for (const [server, tools] of servers) {
150
+ lines.push(`Server: ${server}`);
151
+ for (const t of tools) {
152
+ const paramHint = t.params?.length ? ` (params: ${t.params.join(", ")})` : "";
153
+ const sourceHint = t.callName === t.toolName ? "" : ` (server: ${t.serverName}; tool: ${t.toolName})`;
154
+ lines.push(` - ${t.callName}${paramHint}${t.description ? " — " + t.description : ""}${sourceHint}`);
155
+ }
156
+ lines.push("");
157
+ }
158
+
159
+ parts.push(lines.join("\n"));
160
+ }
161
+
162
+ if (subagentNames.length > 0) {
163
+ parts.push(
164
+ `When calling the task tool, set subagent_type to one of: ${subagentNames.join(", ")}. Do not omit this parameter.`
165
+ );
166
+ }
167
+
168
+ return parts.length > 0 ? parts.join("\n\n") : null;
169
+ }
170
+
171
+ export async function ensurePluginDirectory(): Promise<void> {
172
+ const configHome = process.env.XDG_CONFIG_HOME
173
+ ? resolve(process.env.XDG_CONFIG_HOME)
174
+ : join(homedir(), ".config");
175
+ const pluginDir = join(configHome, "opencode", "plugin");
176
+ try {
177
+ await mkdir(pluginDir, { recursive: true });
178
+ log.debug("Plugin directory ensured", { path: pluginDir });
179
+ } catch (error) {
180
+ log.warn("Failed to create plugin directory", { error: String(error) });
181
+ }
182
+ }
183
+
184
+ const CURSOR_PROVIDER_ID = "cursor-acp";
185
+ const CURSOR_PROVIDER_PREFIX = `${CURSOR_PROVIDER_ID}/`;
186
+
187
+ export function shouldProcessModel(model: string | undefined): boolean {
188
+ if (!model) return false;
189
+ return model.startsWith(CURSOR_PROVIDER_PREFIX);
190
+ }
191
+
192
+ const CURSOR_PROXY_HOST = "127.0.0.1";
193
+ const CURSOR_PROXY_DEFAULT_PORT = 32124;
194
+ const CURSOR_PROXY_DEFAULT_BASE_URL = `http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/v1`;
195
+ const CURSOR_PROXY_HEALTH_TIMEOUT_MS = 3000;
196
+ const REUSE_EXISTING_PROXY = process.env.CURSOR_ACP_REUSE_EXISTING_PROXY !== "false";
197
+
198
+ // Stored API key from auth loader (OpenCode auth store)
199
+ let storedApiKey: string | undefined;
200
+ let cursorAgentAvailabilityCache: boolean | undefined;
201
+
202
+ function getGlobalKey(): string {
203
+ return "__opencode_cursor_proxy_server__";
204
+ }
205
+
206
+ function isCursorAgentAvailable(): boolean {
207
+ if (cursorAgentAvailabilityCache !== undefined) {
208
+ return cursorAgentAvailabilityCache;
209
+ }
210
+
211
+ const binary = resolveCursorAgentBinary();
212
+ const result = spawnSync(formatShellCommandForPlatform(binary), ["--version"], {
213
+ stdio: "ignore",
214
+ timeout: 1000,
215
+ shell: process.platform === "win32",
216
+ });
217
+ const error = result.error as NodeJS.ErrnoException | undefined;
218
+
219
+ // ENOENT is the one signal that the binary is clearly absent. Other failures
220
+ // mean the command path exists but the probe could not complete cleanly.
221
+ cursorAgentAvailabilityCache = error?.code === "ENOENT" ? false : true;
222
+ return cursorAgentAvailabilityCache;
223
+ }
224
+
225
+ function resolveBackendForRequest(sdkApiKey: string | undefined): CursorRuntimeBackend {
226
+ const parsed = parseCursorBackendPreference(process.env.CURSOR_ACP_BACKEND);
227
+ if (!parsed.valid) {
228
+ log.warn("Invalid CURSOR_ACP_BACKEND value; falling back to auto", {
229
+ value: process.env.CURSOR_ACP_BACKEND,
230
+ });
231
+ }
232
+
233
+ return selectBackendForRequest({
234
+ preference: parsed.preference,
235
+ cursorAgentAvailable: isCursorAgentAvailable(),
236
+ sdkApiKey,
237
+ });
238
+ }
239
+
240
+
241
+
242
+ /**
243
+ * Build the command array for invoking cursor-agent.
244
+ * Appends `--resume <chatId>` only when a chat ID is supplied.
245
+ */
246
+ export function buildCursorAgentCommand(
247
+ model: string,
248
+ workspaceDirectory: string,
249
+ resumeChatId?: string,
250
+ ): string[] {
251
+ const cmd = [
252
+ resolveCursorAgentBinary(),
253
+ "--print",
254
+ "--output-format",
255
+ "stream-json",
256
+ "--stream-partial-output",
257
+ "--workspace",
258
+ workspaceDirectory,
259
+ "--model",
260
+ model,
261
+ ];
262
+ if (resumeChatId) {
263
+ if (RESUME_CHAT_ID_SAFE_RE.test(resumeChatId)) {
264
+ cmd.push("--resume", resumeChatId);
265
+ } else {
266
+ log.warn("Refusing to pass unsafe resume chat ID to cursor-agent; --resume omitted", {
267
+ resumeChatIdHash: hashForLog(resumeChatId),
268
+ model,
269
+ });
270
+ }
271
+ }
272
+ if (FORCE_TOOL_MODE) {
273
+ cmd.push("--force");
274
+ }
275
+ return cmd;
276
+ }
277
+
278
+ /**
279
+ * Resolved prompt metadata returned by {@link resolvePromptForBackend}.
280
+ */
281
+ export interface ResolvedPrompt {
282
+ prompt: string;
283
+ resumeChatId?: string;
284
+ sessionKey?: string;
285
+ usedIncremental: boolean;
286
+ contentPrefix?: string;
287
+ recordContentPrefix?: string;
288
+ toolFingerprint?: string;
289
+ subagentFingerprint?: string;
290
+ }
291
+
292
+ /**
293
+ * Resolve the prompt to send to the backend.
294
+ *
295
+ * Only the cursor-agent backend supports `--resume`. When a chatId is available
296
+ * and the last message can be expressed as a safe delta, an incremental prompt
297
+ * is returned; otherwise the full flattened prompt is used. Even when the
298
+ * incremental prompt is unavailable, `--resume` is still passed so cursor-agent
299
+ * conversation state is reused.
300
+ *
301
+ * The returned `sessionKey`/`contentPrefix` are always populated on the
302
+ * cursor-agent + resume-enabled path so the response can seed the cache.
303
+ */
304
+ export function resolvePromptForBackend(input: {
305
+ backend: CursorRuntimeBackend;
306
+ messages: Array<ProxyMessage>;
307
+ tools: Array<any>;
308
+ subagentNames: string[];
309
+ model: string;
310
+ workspaceDirectory: string;
311
+ }): ResolvedPrompt {
312
+ let fullPrompt: string | undefined;
313
+ const getFullPrompt = () =>
314
+ fullPrompt ??= buildPromptFromMessages(input.messages, input.tools, input.subagentNames);
315
+
316
+ if (input.backend !== "cursor-agent" || !isSessionResumeEnabled()) {
317
+ return { prompt: getFullPrompt(), usedIncremental: false };
318
+ }
319
+
320
+ const anchorResult = deriveConversationAnchor(input.messages);
321
+ if (!anchorResult) {
322
+ log.warn("Session resume enabled but no usable conversation anchor; skipping resume", {
323
+ model: input.model,
324
+ workspaceDirectoryHash: sanitizeSessionKey(input.workspaceDirectory),
325
+ });
326
+ return { prompt: getFullPrompt(), usedIncremental: false };
327
+ }
328
+ const { anchor, contentPrefix: anchorContentPrefix } = anchorResult;
329
+ const resumePrefixes = deriveConversationResumePrefixes(input.messages);
330
+ const contentPrefix = resumePrefixes?.lookupContentPrefix ?? anchorContentPrefix;
331
+ const recordContentPrefix = resumePrefixes?.recordContentPrefix ?? contentPrefix;
332
+ const sessionKey = buildSessionKey(input.workspaceDirectory, input.model, anchor);
333
+ const sessionKeyHash = sanitizeSessionKey(sessionKey);
334
+ const toolFingerprint = buildToolFingerprint(input.tools);
335
+ const subagentFingerprint = input.subagentNames.slice().sort().join(",");
336
+ const resumeChatId = getResumeChatId(sessionKey, contentPrefix, toolFingerprint, subagentFingerprint);
337
+ const resumeChatIdHash = resumeChatId ? sanitizeSessionKey(resumeChatId) : undefined;
338
+ if (!resumeChatId) {
339
+ const isContinuation = input.messages.some((m: any) => m?.role === "assistant");
340
+ if (isContinuation) {
341
+ log.warn("Session resume enabled but no chatId found for sessionKey; falling back to full prompt", {
342
+ sessionKeyHash,
343
+ });
344
+ }
345
+ return { prompt: getFullPrompt(), sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint };
346
+ }
347
+
348
+ const incremental = buildIncrementalPrompt(input.messages);
349
+ if (incremental) {
350
+ // Guard the debug log behind isDebugEnabled() so getFullPrompt() is not
351
+ // eagerly evaluated on the incremental hot path. JS evaluates call
352
+ // arguments before log.debug's own level check runs, so without this
353
+ // guard the full prompt would be built on every resumed turn and negate
354
+ // M3's skip-full-flattening optimization. Mirrors buildPromptFromMessages.
355
+ if (log.isDebugEnabled()) {
356
+ log.debug("Using incremental prompt with session resume", {
357
+ sessionKeyHash,
358
+ resumeChatIdHash,
359
+ promptChars: incremental.length,
360
+ fullPromptChars: getFullPrompt().length,
361
+ });
362
+ }
363
+ return { prompt: incremental, resumeChatId, sessionKey, usedIncremental: true, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint };
364
+ }
365
+
366
+ log.info("Session resume active but incremental prompt unavailable; using full prompt", {
367
+ sessionKeyHash,
368
+ resumeChatIdHash,
369
+ });
370
+ return { prompt: getFullPrompt(), resumeChatId, sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint };
371
+ }
372
+
373
+ /**
374
+ * Capture `session_id` from a cursor-agent NDJSON stream event.
375
+ * cursor-agent stream events may carry `session_id`; when present, that value is
376
+ * what `--resume` accepts, so any such event may seed/refresh the cache.
377
+ */
378
+ export function captureResumeChatIdFromEvent(
379
+ event: StreamJsonEvent,
380
+ sessionKey: string | undefined,
381
+ model: string,
382
+ workspaceDirectory: string,
383
+ contentPrefix?: string,
384
+ toolFingerprint?: string,
385
+ subagentFingerprint?: string,
386
+ ): void {
387
+ if (!sessionKey || !isSessionResumeEnabled()) return;
388
+ const chatId = event.session_id;
389
+ if (chatId == null) return;
390
+ if (typeof chatId === "string" && chatId.trim()) {
391
+ recordResumeChatId(
392
+ sessionKey,
393
+ chatId.trim(),
394
+ contentPrefix ?? "",
395
+ toolFingerprint,
396
+ subagentFingerprint,
397
+ );
398
+ return;
399
+ }
400
+ log.warn("cursor-agent emitted invalid session_id", {
401
+ type: typeof chatId,
402
+ length: String(chatId).length,
403
+ sessionKeyHash: sanitizeSessionKey(sessionKey),
404
+ });
405
+ }
406
+
407
+ /**
408
+ * Scan raw cursor-agent NDJSON output (stdout) and capture the first valid
409
+ * `session_id`. Each line is parsed independently and delegated to the
410
+ * event-level capture. cursor-agent emits `session_id` on stdout; stderr is
411
+ * intentionally not scanned here so error text cannot spoof a session ID.
412
+ */
413
+ export function captureResumeChatIdFromOutput(
414
+ output: string,
415
+ sessionKey: string | undefined,
416
+ model: string,
417
+ workspaceDirectory: string,
418
+ contentPrefix?: string,
419
+ toolFingerprint?: string,
420
+ subagentFingerprint?: string,
421
+ ): void {
422
+ if (!sessionKey || !isSessionResumeEnabled() || !output) return;
423
+ for (const line of output.split(/\r?\n/)) {
424
+ const event = parseStreamJsonLine(line);
425
+ if (event) {
426
+ captureResumeChatIdFromEvent(
427
+ event,
428
+ sessionKey,
429
+ model,
430
+ workspaceDirectory,
431
+ contentPrefix,
432
+ toolFingerprint,
433
+ subagentFingerprint,
434
+ );
435
+ }
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Evict a cached resume chat ID when the cursor-agent error indicates the
441
+ * resumed session itself is gone. Transient errors (network, auth, OOM,
442
+ * signals) are ignored so a valid resume ID survives a flaky turn.
443
+ */
444
+ export function maybeEvictResumeChatId(
445
+ errSource: unknown,
446
+ resumeChatId: string | undefined,
447
+ sessionKey: string | undefined,
448
+ logFields: { code?: number | null; spawnError?: boolean; failureTextHash?: string } = {},
449
+ ): boolean {
450
+ if (!resumeChatId || !sessionKey || !isSessionResumeEnabled() || !isResumeSpecificFailure(errSource)) {
451
+ return false;
452
+ }
453
+ clearResumeChatId(sessionKey);
454
+ log.warn("Evicting resume chatId after resume-specific cursor-agent failure", {
455
+ code: logFields.code,
456
+ spawnError: logFields.spawnError,
457
+ sessionKeyHash: sanitizeSessionKey(sessionKey),
458
+ resumeChatIdHash: sanitizeSessionKey(resumeChatId),
459
+ failureTextHash: logFields.failureTextHash,
460
+ hadResume: true,
461
+ });
462
+ return true;
463
+ }
464
+
465
+ function isSuccessfulResultEvent(event: StreamJsonEvent): boolean {
466
+ return isResult(event) && event.is_error !== true && event.subtype !== "error";
467
+ }
468
+
469
+ function shouldTreatCursorAgentFailureAsDiagnostic(
470
+ errSource: string,
471
+ sawSuccessfulStreamOutput: boolean,
472
+ ): boolean {
473
+ if (!sawSuccessfulStreamOutput) {
474
+ return false;
475
+ }
476
+ return parseAgentError(errSource).type === "quota";
477
+ }
478
+
479
+ /**
480
+ * Warn once per request when session resume is enabled but cursor-agent did
481
+ * not emit a usable `session_id`. Keeps the warning logic in one place across
482
+ * the Bun/Node stream/non-stream paths.
483
+ */
484
+ function warnIfResumeNotCaptured(
485
+ sessionResumeKey: string | undefined,
486
+ sessionResumeKeyHash: string | undefined,
487
+ sessionResumeContentPrefix: string | undefined,
488
+ sessionResumeToolFingerprint: string | undefined,
489
+ sessionResumeSubagentFingerprint: string | undefined,
490
+ model: string,
491
+ ): void {
492
+ if (
493
+ sessionResumeKey
494
+ && isSessionResumeEnabled()
495
+ && !hasResumeChatId(
496
+ sessionResumeKey,
497
+ sessionResumeContentPrefix,
498
+ sessionResumeToolFingerprint,
499
+ sessionResumeSubagentFingerprint,
500
+ )
501
+ ) {
502
+ log.warn("Session resume enabled but no session_id captured from cursor-agent response; resume will not activate on the next turn", {
503
+ sessionKeyHash: sessionResumeKeyHash,
504
+ model,
505
+ });
506
+ }
507
+ }
508
+
509
+ function createCursorAgentBunChild(
510
+ model: string,
511
+ prompt: string,
512
+ workspaceDirectory: string,
513
+ resumeChatId?: string,
514
+ ): any {
515
+ const bunAny = globalThis as any;
516
+ if (!bunAny.Bun?.spawn) {
517
+ throw new Error("This provider requires Bun runtime.");
518
+ }
519
+
520
+ const child = bunAny.Bun.spawn({
521
+ cmd: buildCursorAgentCommand(model, workspaceDirectory, resumeChatId),
522
+ stdin: "pipe",
523
+ stdout: "pipe",
524
+ stderr: "pipe",
525
+ env: bunAny.Bun.env,
526
+ });
527
+
528
+ child.stdin.write(prompt);
529
+ child.stdin.end();
530
+ return child;
531
+ }
532
+
533
+ function createBunChildForBackend(input: {
534
+ backend: CursorRuntimeBackend;
535
+ sdkApiKey?: string;
536
+ model: string;
537
+ prompt: string;
538
+ workspaceDirectory: string;
539
+ resumeChatId?: string;
540
+ }): any {
541
+ if (input.backend === "sdk") {
542
+ if (!input.sdkApiKey) {
543
+ throw new Error("SDK backend requires CURSOR_API_KEY or OpenCode auth.");
544
+ }
545
+ return createSdkBunChild({
546
+ apiKey: input.sdkApiKey,
547
+ model: input.model,
548
+ prompt: input.prompt,
549
+ cwd: input.workspaceDirectory,
550
+ });
551
+ }
552
+
553
+ return createCursorAgentBunChild(
554
+ input.model,
555
+ input.prompt,
556
+ input.workspaceDirectory,
557
+ input.resumeChatId,
558
+ );
559
+ }
560
+
561
+ function createCursorAgentNodeChild(
562
+ model: string,
563
+ prompt: string,
564
+ workspaceDirectory: string,
565
+ resumeChatId?: string,
566
+ ): any {
567
+ const cmd = buildCursorAgentCommand(model, workspaceDirectory, resumeChatId);
568
+ const child = spawn(formatShellCommandForPlatform(cmd[0]), cmd.slice(1), {
569
+ stdio: ["pipe", "pipe", "pipe"],
570
+ shell: process.platform === "win32",
571
+ });
572
+
573
+ child.stdin.write(prompt);
574
+ child.stdin.end();
575
+ return child;
576
+ }
577
+
578
+ function createNodeChildForBackend(input: {
579
+ backend: CursorRuntimeBackend;
580
+ sdkApiKey?: string;
581
+ model: string;
582
+ prompt: string;
583
+ workspaceDirectory: string;
584
+ resumeChatId?: string;
585
+ }): any {
586
+ if (input.backend === "sdk") {
587
+ if (!input.sdkApiKey) {
588
+ throw new Error("SDK backend requires CURSOR_API_KEY or OpenCode auth.");
589
+ }
590
+ return createSdkNodeChild({
591
+ apiKey: input.sdkApiKey,
592
+ model: input.model,
593
+ prompt: input.prompt,
594
+ cwd: input.workspaceDirectory,
595
+ });
596
+ }
597
+
598
+ if (isAgentPoolEnabled()) {
599
+ log.debug("Using cursor-agent pool for request", {
600
+ model: input.model,
601
+ resume: !!input.resumeChatId,
602
+ });
603
+ return createCursorAgentPoolNodeChild({
604
+ model: input.model,
605
+ prompt: input.prompt,
606
+ cwd: input.workspaceDirectory,
607
+ resumeChatId: input.resumeChatId,
608
+ force: FORCE_TOOL_MODE,
609
+ });
610
+ }
611
+
612
+ return createCursorAgentNodeChild(
613
+ input.model,
614
+ input.prompt,
615
+ input.workspaceDirectory,
616
+ input.resumeChatId,
617
+ );
618
+ }
619
+
620
+ function getOpenCodeConfigPrefix(): string {
621
+ const configHome = process.env.XDG_CONFIG_HOME
622
+ ? resolve(process.env.XDG_CONFIG_HOME)
623
+ : join(homedir(), ".config");
624
+ return join(configHome, "opencode");
625
+ }
626
+
627
+ function canonicalizePathForCompare(pathValue: string): string {
628
+ const resolvedPath = resolve(pathValue);
629
+ let normalizedPath = resolvedPath;
630
+
631
+ try {
632
+ normalizedPath = typeof realpathSync.native === "function"
633
+ ? realpathSync.native(resolvedPath)
634
+ : realpathSync(resolvedPath);
635
+ } catch {
636
+ normalizedPath = resolvedPath;
637
+ }
638
+
639
+ if (process.platform === "darwin" || process.platform === "win32") {
640
+ return normalizedPath.toLowerCase();
641
+ }
642
+
643
+ return normalizedPath;
644
+ }
645
+
646
+ function isWithinPath(root: string, candidate: string): boolean {
647
+ const normalizedRoot = canonicalizePathForCompare(root);
648
+ const normalizedCandidate = canonicalizePathForCompare(candidate);
649
+ const rel = relative(normalizedRoot, normalizedCandidate);
650
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
651
+ }
652
+
653
+ function resolveCandidate(value: string | undefined): string {
654
+ if (!value || value.trim().length === 0) {
655
+ return "";
656
+ }
657
+ return resolve(value);
658
+ }
659
+
660
+ function isNonConfigPath(pathValue: string): boolean {
661
+ if (!pathValue) {
662
+ return false;
663
+ }
664
+ return !isWithinPath(getOpenCodeConfigPrefix(), pathValue);
665
+ }
666
+
667
+ // Filesystem roots are never a meaningful workspace: accepting "/" (or a bare
668
+ // Windows drive root like "C:\") makes every tool treat the whole machine as
669
+ // the project, which is both unsafe and a common symptom of a daemon that
670
+ // was launched without a real cwd (e.g. systemd unit without WorkingDirectory).
671
+ export function isRootPath(pathValue: string): boolean {
672
+ if (!pathValue) {
673
+ return false;
674
+ }
675
+ const resolved = resolve(pathValue);
676
+ if (resolved === "/") {
677
+ return true;
678
+ }
679
+ return /^[A-Za-z]:[\\/]?$/.test(resolved);
680
+ }
681
+
682
+ function isAcceptableWorkspace(pathValue: string, configPrefix: string): boolean {
683
+ if (!pathValue) {
684
+ return false;
685
+ }
686
+ if (isRootPath(pathValue)) {
687
+ return false;
688
+ }
689
+ if (isWithinPath(configPrefix, pathValue)) {
690
+ return false;
691
+ }
692
+ return true;
693
+ }
694
+
695
+ const SESSION_WORKSPACE_CACHE_LIMIT = 200;
696
+
697
+ export function resolveWorkspaceDirectory(
698
+ worktree: string | undefined,
699
+ directory: string | undefined,
700
+ ): string {
701
+ const configPrefix = getOpenCodeConfigPrefix();
702
+
703
+ const envWorkspace = resolveCandidate(process.env.CURSOR_ACP_WORKSPACE);
704
+ if (envWorkspace && !isRootPath(envWorkspace)) {
705
+ return envWorkspace;
706
+ }
707
+
708
+ const envProjectDir = resolveCandidate(process.env.OPENCODE_CURSOR_PROJECT_DIR);
709
+ if (envProjectDir && !isRootPath(envProjectDir)) {
710
+ return envProjectDir;
711
+ }
712
+
713
+ const worktreeCandidate = resolveCandidate(worktree);
714
+ if (isAcceptableWorkspace(worktreeCandidate, configPrefix)) {
715
+ return worktreeCandidate;
716
+ }
717
+
718
+ const dirCandidate = resolveCandidate(directory);
719
+ if (isAcceptableWorkspace(dirCandidate, configPrefix)) {
720
+ return dirCandidate;
721
+ }
722
+
723
+ const cwd = resolve(process.cwd());
724
+ if (isAcceptableWorkspace(cwd, configPrefix)) {
725
+ return cwd;
726
+ }
727
+
728
+ // Fall back to the user's home directory rather than "/" when every other
729
+ // signal is unusable. $HOME is always writable for the current user and
730
+ // keeps tool scopes sane even when the daemon was spawned from root.
731
+ const home = resolveCandidate(homedir());
732
+ if (home && !isRootPath(home)) {
733
+ return home;
734
+ }
735
+
736
+ return configPrefix;
737
+ }
738
+
739
+ type ProxyRuntimeState = {
740
+ baseURL?: string;
741
+ baseURLByWorkspace?: Record<string, string>;
742
+ };
743
+
744
+ export function normalizeWorkspaceForCompare(pathValue: string): string {
745
+ const resolved = resolve(pathValue);
746
+ if (process.platform === "darwin" || process.platform === "win32") {
747
+ return resolved.toLowerCase();
748
+ }
749
+ return resolved;
750
+ }
751
+
752
+ export function isReusableProxyHealthPayload(payload: any, workspaceDirectory: string): boolean {
753
+ if (!payload || payload.ok !== true) {
754
+ return false;
755
+ }
756
+ if (typeof payload.workspaceDirectory !== "string" || payload.workspaceDirectory.length === 0) {
757
+ // Legacy proxies that do not expose workspace cannot be safely reused.
758
+ return false;
759
+ }
760
+ return normalizeWorkspaceForCompare(payload.workspaceDirectory) === normalizeWorkspaceForCompare(workspaceDirectory);
761
+ }
762
+
763
+ export async function fetchProxyHealthWithTimeout(
764
+ url: string,
765
+ timeoutMs: number = CURSOR_PROXY_HEALTH_TIMEOUT_MS,
766
+ ): Promise<Response | null> {
767
+ const controller = new AbortController();
768
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
769
+ if (typeof (timeout as any).unref === "function") {
770
+ (timeout as any).unref();
771
+ }
772
+
773
+ try {
774
+ return await fetch(url, { signal: controller.signal }).catch(() => null);
775
+ } finally {
776
+ clearTimeout(timeout);
777
+ }
778
+ }
779
+
780
+ const FORCE_TOOL_MODE = process.env.CURSOR_ACP_FORCE !== "false";
781
+ const EMIT_TOOL_UPDATES = process.env.CURSOR_ACP_EMIT_TOOL_UPDATES === "true";
782
+ const FORWARD_TOOL_CALLS = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false";
783
+
784
+ function parseToolLoopMode(value: string | undefined): { mode: ToolLoopMode; valid: boolean } {
785
+ const normalized = (value ?? "opencode").trim().toLowerCase();
786
+ if (normalized === "opencode" || normalized === "proxy-exec" || normalized === "off") {
787
+ return { mode: normalized, valid: true };
788
+ }
789
+ return { mode: "opencode", valid: false };
790
+ }
791
+
792
+ const TOOL_LOOP_MODE_RAW = process.env.CURSOR_ACP_TOOL_LOOP_MODE;
793
+ const { mode: TOOL_LOOP_MODE, valid: TOOL_LOOP_MODE_VALID } = parseToolLoopMode(TOOL_LOOP_MODE_RAW);
794
+ const PROVIDER_BOUNDARY_MODE_RAW = process.env.CURSOR_ACP_PROVIDER_BOUNDARY;
795
+ const {
796
+ mode: PROVIDER_BOUNDARY_MODE,
797
+ valid: PROVIDER_BOUNDARY_MODE_VALID,
798
+ } = parseProviderBoundaryMode(PROVIDER_BOUNDARY_MODE_RAW);
799
+ const LEGACY_PROVIDER_BOUNDARY = createProviderBoundary("legacy", CURSOR_PROVIDER_ID);
800
+ const PROVIDER_BOUNDARY =
801
+ PROVIDER_BOUNDARY_MODE === "legacy"
802
+ ? LEGACY_PROVIDER_BOUNDARY
803
+ : createProviderBoundary(PROVIDER_BOUNDARY_MODE, CURSOR_PROVIDER_ID);
804
+ const ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK =
805
+ process.env.CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK !== "false";
806
+ const TOOL_LOOP_MAX_REPEAT_RAW = process.env.CURSOR_ACP_TOOL_LOOP_MAX_REPEAT;
807
+ const {
808
+ value: TOOL_LOOP_MAX_REPEAT,
809
+ valid: TOOL_LOOP_MAX_REPEAT_VALID,
810
+ } = parseToolLoopMaxRepeat(TOOL_LOOP_MAX_REPEAT_RAW);
811
+ const {
812
+ proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
813
+ suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
814
+ shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
815
+ } = PROVIDER_BOUNDARY.computeToolLoopFlags(
816
+ TOOL_LOOP_MODE,
817
+ FORWARD_TOOL_CALLS,
818
+ EMIT_TOOL_UPDATES,
819
+ );
820
+
821
+ export function resolveChatParamTools(
822
+ mode: ToolLoopMode,
823
+ existingTools: unknown,
824
+ refreshedTools: Array<any>,
825
+ ): ToolOptionResolution {
826
+ return PROVIDER_BOUNDARY.resolveChatParamTools(mode, existingTools, refreshedTools);
827
+ }
828
+
829
+ function createChatCompletionResponse(
830
+ model: string,
831
+ content: string,
832
+ reasoningContent?: string,
833
+ usage?: OpenAiUsage,
834
+ ) {
835
+ const message: { role: "assistant"; content: string; reasoning_content?: string } = {
836
+ role: "assistant",
837
+ content,
838
+ };
839
+
840
+ if (reasoningContent && reasoningContent.length > 0) {
841
+ message.reasoning_content = reasoningContent;
842
+ }
843
+
844
+ const response: {
845
+ id: string;
846
+ object: string;
847
+ created: number;
848
+ model: string;
849
+ choices: Array<{
850
+ index: number;
851
+ message: typeof message;
852
+ finish_reason: string;
853
+ }>;
854
+ usage?: OpenAiUsage;
855
+ } = {
856
+ id: `cursor-acp-${Date.now()}`,
857
+ object: "chat.completion",
858
+ created: Math.floor(Date.now() / 1000),
859
+ model,
860
+ choices: [
861
+ {
862
+ index: 0,
863
+ message,
864
+ finish_reason: "stop",
865
+ },
866
+ ],
867
+ };
868
+
869
+ if (usage) {
870
+ response.usage = usage;
871
+ }
872
+
873
+ return response;
874
+ }
875
+
876
+ function createChatCompletionChunk(id: string, created: number, model: string, deltaContent: string, done = false) {
877
+ return {
878
+ id,
879
+ object: "chat.completion.chunk",
880
+ created,
881
+ model,
882
+ choices: [
883
+ {
884
+ index: 0,
885
+ delta: deltaContent ? { content: deltaContent } : {},
886
+ finish_reason: done ? "stop" : null,
887
+ },
888
+ ],
889
+ };
890
+ }
891
+
892
+ export function extractCompletionFromStream(output: string): {
893
+ assistantText: string;
894
+ reasoningText: string;
895
+ usage?: OpenAiUsage;
896
+ } {
897
+ const lines = output.split("\n");
898
+ let assistantText = "";
899
+ let reasoningText = "";
900
+ let usage: OpenAiUsage | undefined;
901
+ let sawAssistantPartials = false;
902
+ let sawThinkingPartials = false;
903
+ const tracker = new MixedDeltaTracker();
904
+
905
+ for (const line of lines) {
906
+ const event = parseStreamJsonLine(line);
907
+ if (!event) {
908
+ continue;
909
+ }
910
+
911
+ if (isAssistantText(event)) {
912
+ const text = extractText(event);
913
+ if (!text) continue;
914
+
915
+ const isPartial = typeof (event as any).timestamp_ms === "number";
916
+ if (isPartial) {
917
+ sawAssistantPartials = true;
918
+ assistantText += tracker.nextText(text);
919
+ } else if (!sawAssistantPartials) {
920
+ assistantText = text;
921
+ }
922
+ }
923
+
924
+ if (isThinking(event)) {
925
+ const thinking = extractThinking(event);
926
+ if (thinking) {
927
+ const isPartial = typeof (event as any).timestamp_ms === "number";
928
+ if (isPartial) {
929
+ sawThinkingPartials = true;
930
+ reasoningText += tracker.nextThinking(thinking);
931
+ } else if (!sawThinkingPartials) {
932
+ reasoningText = thinking;
933
+ }
934
+ }
935
+ }
936
+
937
+ if (isResult(event)) {
938
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
939
+ }
940
+ }
941
+
942
+ return { assistantText, reasoningText, usage };
943
+ }
944
+
945
+ function formatToolUpdateEvent(update: ToolUpdate): string {
946
+ return `event: tool_update\ndata: ${JSON.stringify(update)}\n\n`;
947
+ }
948
+
949
+ function toErrorMessage(error: unknown): string {
950
+ if (error instanceof Error) {
951
+ return error.message;
952
+ }
953
+ return String(error);
954
+ }
955
+
956
+ function createBoundaryRuntimeContext(scope: string) {
957
+ let activeBoundary = PROVIDER_BOUNDARY;
958
+ let fallbackActive = false;
959
+
960
+ const canAutoFallback = ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK && PROVIDER_BOUNDARY.mode === "v1";
961
+
962
+ const activateLegacyFallback = (operation: string, error: unknown): boolean => {
963
+ if (!canAutoFallback || activeBoundary.mode === "legacy") {
964
+ return false;
965
+ }
966
+
967
+ activeBoundary = LEGACY_PROVIDER_BOUNDARY;
968
+ const details = {
969
+ scope,
970
+ operation,
971
+ error: toErrorMessage(error),
972
+ };
973
+ if (!fallbackActive) {
974
+ log.warn("Provider boundary v1 failed; switching to legacy for this request", details);
975
+ } else {
976
+ log.debug("Provider boundary fallback already active", details);
977
+ }
978
+ fallbackActive = true;
979
+ return true;
980
+ };
981
+
982
+ return {
983
+ getBoundary(): ProviderBoundary {
984
+ return activeBoundary;
985
+ },
986
+
987
+ run<T>(operation: string, fn: (boundary: ProviderBoundary) => T): T {
988
+ try {
989
+ return fn(activeBoundary);
990
+ } catch (error) {
991
+ if (!activateLegacyFallback(operation, error)) {
992
+ throw error;
993
+ }
994
+ return fn(activeBoundary);
995
+ }
996
+ },
997
+
998
+ async runAsync<T>(operation: string, fn: (boundary: ProviderBoundary) => Promise<T>): Promise<T> {
999
+ try {
1000
+ return await fn(activeBoundary);
1001
+ } catch (error) {
1002
+ if (!activateLegacyFallback(operation, error)) {
1003
+ throw error;
1004
+ }
1005
+ return fn(activeBoundary);
1006
+ }
1007
+ },
1008
+
1009
+ activateLegacyFallback(operation: string, error: unknown) {
1010
+ activateLegacyFallback(operation, error);
1011
+ },
1012
+
1013
+ isFallbackActive(): boolean {
1014
+ return fallbackActive;
1015
+ },
1016
+ };
1017
+ }
1018
+
1019
+ async function findFirstAllowedToolCallInOutput(
1020
+ output: string,
1021
+ options: {
1022
+ toolLoopMode: ToolLoopMode;
1023
+ allowedToolNames: Set<string>;
1024
+ toolSchemaMap: Map<string, unknown>;
1025
+ toolLoopGuard: ToolLoopGuard;
1026
+ boundaryContext: ReturnType<typeof createBoundaryRuntimeContext>;
1027
+ responseMeta: { id: string; created: number; model: string };
1028
+ },
1029
+ ): Promise<{ toolCall: OpenAiToolCall | null; terminationMessage: string | null }> {
1030
+ if (options.allowedToolNames.size === 0 || !output) {
1031
+ return { toolCall: null, terminationMessage: null };
1032
+ }
1033
+
1034
+ const toolMapper = new ToolMapper();
1035
+ const toolSessionId = options.responseMeta.id;
1036
+
1037
+ for (const line of output.split("\n")) {
1038
+ const event = parseStreamJsonLine(line);
1039
+ if (!event || event.type !== "tool_call") {
1040
+ continue;
1041
+ }
1042
+
1043
+ let interceptedToolCall: OpenAiToolCall | null = null;
1044
+ const result = await handleToolLoopEventWithFallback({
1045
+ event: event as any,
1046
+ boundary: options.boundaryContext.getBoundary(),
1047
+ boundaryMode: options.boundaryContext.getBoundary().mode,
1048
+ autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
1049
+ toolLoopMode: options.toolLoopMode,
1050
+ allowedToolNames: options.allowedToolNames,
1051
+ toolSchemaMap: options.toolSchemaMap,
1052
+ toolLoopGuard: options.toolLoopGuard,
1053
+ toolMapper,
1054
+ toolSessionId,
1055
+ shouldEmitToolUpdates: false,
1056
+ proxyExecuteToolCalls: false,
1057
+ suppressConverterToolEvents: false,
1058
+ responseMeta: options.responseMeta,
1059
+ onToolUpdate: () => {},
1060
+ onToolResult: () => {},
1061
+ onInterceptedToolCall: (toolCall) => {
1062
+ interceptedToolCall = toolCall;
1063
+ },
1064
+ onFallbackToLegacy: (error) => {
1065
+ options.boundaryContext.activateLegacyFallback("findFirstAllowedToolCallInOutput", error);
1066
+ },
1067
+ });
1068
+
1069
+ if (result.terminate) {
1070
+ return {
1071
+ toolCall: null,
1072
+ terminationMessage: result.terminate.silent ? null : result.terminate.message,
1073
+ };
1074
+ }
1075
+ if (result.intercepted && interceptedToolCall) {
1076
+ return {
1077
+ toolCall: interceptedToolCall,
1078
+ terminationMessage: null,
1079
+ };
1080
+ }
1081
+ }
1082
+
1083
+ return { toolCall: null, terminationMessage: null };
1084
+ }
1085
+
1086
+ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: ToolRouter): Promise<string> {
1087
+ const key = getGlobalKey();
1088
+ const g = globalThis as any;
1089
+ const normalizedWorkspace = normalizeWorkspaceForCompare(workspaceDirectory);
1090
+ const state: ProxyRuntimeState = g[key] ?? { baseURL: "", baseURLByWorkspace: {} };
1091
+ state.baseURLByWorkspace = state.baseURLByWorkspace ?? {};
1092
+ g[key] = state;
1093
+
1094
+ const existingBaseURL = state.baseURLByWorkspace[normalizedWorkspace] ?? state.baseURL;
1095
+ if (typeof existingBaseURL === "string" && existingBaseURL.length > 0) {
1096
+ return existingBaseURL;
1097
+ }
1098
+
1099
+ // Mark as starting to avoid duplicate starts in-process.
1100
+ state.baseURL = "";
1101
+
1102
+ const resolveRequestSdkApiKey = (authHeader?: string | null): string | undefined =>
1103
+ resolveSdkApiKey({
1104
+ env: process.env,
1105
+ storedApiKey,
1106
+ authorizationHeader: authHeader,
1107
+ });
1108
+
1109
+ const handler = async (req: Request): Promise<Response> => {
1110
+ try {
1111
+ const url = new URL(req.url);
1112
+
1113
+ if (url.pathname === "/health") {
1114
+ return new Response(JSON.stringify({ ok: true, workspaceDirectory }), {
1115
+ status: 200,
1116
+ headers: { "Content-Type": "application/json" },
1117
+ });
1118
+ }
1119
+
1120
+ // Model list via ModelDiscoveryService (has built-in fallback models)
1121
+ if (url.pathname === "/v1/models" || url.pathname === "/models") {
1122
+ try {
1123
+ const { ModelDiscoveryService } = await import("./models/discovery.js");
1124
+ const discovery = new ModelDiscoveryService();
1125
+ const modelList = await discovery.discover(resolveRequestSdkApiKey());
1126
+ const models = modelList.map((m: any) => ({
1127
+ id: typeof m === "string" ? m : m.id,
1128
+ object: "model",
1129
+ created: Math.floor(Date.now() / 1000),
1130
+ owned_by: "cursor",
1131
+ }));
1132
+ return new Response(JSON.stringify({ object: "list", data: models }), {
1133
+ status: 200,
1134
+ headers: { "Content-Type": "application/json" },
1135
+ });
1136
+ } catch (err) {
1137
+ log.error("Failed to list models", { error: String(err) });
1138
+ return new Response(JSON.stringify({ error: "Failed to fetch models" }), {
1139
+ status: 500,
1140
+ headers: { "Content-Type": "application/json" },
1141
+ });
1142
+ }
1143
+ }
1144
+
1145
+ if (url.pathname !== "/v1/chat/completions" && url.pathname !== "/chat/completions") {
1146
+ return new Response(JSON.stringify({ error: `Unsupported path: ${url.pathname}` }), {
1147
+ status: 404,
1148
+ headers: { "Content-Type": "application/json" },
1149
+ });
1150
+ }
1151
+
1152
+ log.debug("Proxy request (bun)", { method: req.method, path: url.pathname });
1153
+ const reqPerf = new RequestPerf(`bun-${Date.now()}`);
1154
+ const body: any = await req.json().catch(() => ({}));
1155
+ reqPerf.mark("body-read");
1156
+ reqPerf.mark("body-parsed");
1157
+ const messages: Array<any> = Array.isArray(body?.messages) ? body.messages : [];
1158
+ const stream = body?.stream === true;
1159
+ const tools = Array.isArray(body?.tools) ? body.tools : [];
1160
+
1161
+ log.debug("raw request body", {
1162
+ model: body?.model,
1163
+ cursorModel: body?.cursorModel,
1164
+ stream,
1165
+ toolCount: tools.length,
1166
+ toolNames: tools.map((t: any) => t?.function?.name ?? t?.name ?? "unknown"),
1167
+ messageCount: messages.length,
1168
+ messageRoles: messages.map((m: any) => m?.role),
1169
+ hasMessagesWithToolCalls: messages.some((m: any) => Array.isArray(m?.tool_calls) && m.tool_calls.length > 0),
1170
+ hasToolResultMessages: messages.some((m: any) => m?.role === "tool"),
1171
+ });
1172
+
1173
+ const allowedToolNames = extractAllowedToolNames(tools);
1174
+ const toolSchemaMap = buildToolSchemaMap(tools);
1175
+ const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT);
1176
+ const boundaryContext = createBoundaryRuntimeContext("bun-handler");
1177
+
1178
+ const subagentNames = readSubagentNames();
1179
+ const model = boundaryContext.run("resolveRuntimeModel", (boundary) =>
1180
+ boundary.resolveRuntimeModel(body?.model, body?.cursorModel),
1181
+ );
1182
+ const authHeader = req.headers.get("authorization");
1183
+ const sdkApiKey = resolveRequestSdkApiKey(authHeader);
1184
+ const backend = resolveBackendForRequest(sdkApiKey);
1185
+ reqPerf.mark("backend-resolved");
1186
+ const {
1187
+ prompt,
1188
+ resumeChatId,
1189
+ sessionKey: sessionResumeKey,
1190
+ usedIncremental,
1191
+ contentPrefix: sessionResumeContentPrefix,
1192
+ recordContentPrefix: sessionResumeRecordContentPrefix,
1193
+ toolFingerprint: sessionResumeToolFingerprint,
1194
+ subagentFingerprint: sessionResumeSubagentFingerprint,
1195
+ } = resolvePromptForBackend({
1196
+ backend,
1197
+ messages,
1198
+ tools,
1199
+ subagentNames,
1200
+ model,
1201
+ workspaceDirectory,
1202
+ });
1203
+ reqPerf.mark("prompt-built");
1204
+ const sessionResumeKeyHash = sessionResumeKey ? sanitizeSessionKey(sessionResumeKey) : undefined;
1205
+ const resumeChatIdHash = resumeChatId ? sanitizeSessionKey(resumeChatId) : undefined;
1206
+ const msgSummaryBun = messages.map((m: any, i: number) => {
1207
+ const role = m?.role ?? "?";
1208
+ const hasTc = Array.isArray(m?.tool_calls) ? m.tool_calls.length : 0;
1209
+ const clen = typeof m?.content === "string" ? m.content.length : Array.isArray(m?.content) ? `arr${(m.content as any[]).length}` : typeof m?.content;
1210
+ return `${i}:${role}${hasTc ? `(tc:${hasTc})` : ""}(clen:${clen})`;
1211
+ });
1212
+ log.debug("Proxy chat request (bun)", {
1213
+ stream,
1214
+ model,
1215
+ messages: messages.length,
1216
+ tools: tools.length,
1217
+ promptChars: prompt.length,
1218
+ msgRoles: msgSummaryBun.join(","),
1219
+ sessionResume: resumeChatId ? { chatIdHash: resumeChatIdHash, incremental: usedIncremental } : undefined,
1220
+ });
1221
+
1222
+ if (backend === "sdk" && !sdkApiKey) {
1223
+ return new Response(
1224
+ JSON.stringify({ error: "Cursor SDK backend requires a real Cursor API key. Set CURSOR_API_KEY or run `opencode auth login`; the legacy `cursor-agent` placeholder is not valid SDK auth." }),
1225
+ { status: 401, headers: { "Content-Type": "application/json" } },
1226
+ );
1227
+ }
1228
+
1229
+ reqPerf.mark("child-create-start");
1230
+ const child = createBunChildForBackend({
1231
+ backend,
1232
+ sdkApiKey,
1233
+ model,
1234
+ prompt,
1235
+ workspaceDirectory,
1236
+ resumeChatId,
1237
+ });
1238
+ reqPerf.mark("child-created");
1239
+
1240
+ if (!stream) {
1241
+ const [stdoutText, stderrText] = await Promise.all([
1242
+ new Response(child.stdout).text(),
1243
+ new Response(child.stderr).text(),
1244
+ ]);
1245
+
1246
+ const stdout = (stdoutText || "").trim();
1247
+ const stderr = (stderrText || "").trim();
1248
+ const exitCode = await child.exited;
1249
+ log.debug("cursor-agent completed (bun non-stream)", {
1250
+ exitCode,
1251
+ stdoutChars: stdout.length,
1252
+ stderrChars: stderr.length,
1253
+ });
1254
+ captureResumeChatIdFromOutput(
1255
+ stdout,
1256
+ sessionResumeKey,
1257
+ model,
1258
+ workspaceDirectory,
1259
+ sessionResumeRecordContentPrefix,
1260
+ sessionResumeToolFingerprint,
1261
+ sessionResumeSubagentFingerprint,
1262
+ );
1263
+ warnIfResumeNotCaptured(
1264
+ sessionResumeKey,
1265
+ sessionResumeKeyHash,
1266
+ sessionResumeRecordContentPrefix,
1267
+ sessionResumeToolFingerprint,
1268
+ sessionResumeSubagentFingerprint,
1269
+ model,
1270
+ );
1271
+ const meta = {
1272
+ id: `cursor-acp-${Date.now()}`,
1273
+ created: Math.floor(Date.now() / 1000),
1274
+ model,
1275
+ };
1276
+ const intercepted = await findFirstAllowedToolCallInOutput(stdout, {
1277
+ toolLoopMode: TOOL_LOOP_MODE,
1278
+ allowedToolNames,
1279
+ toolSchemaMap,
1280
+ toolLoopGuard,
1281
+ boundaryContext,
1282
+ responseMeta: meta,
1283
+ });
1284
+ if (intercepted.terminationMessage) {
1285
+ const payload = createChatCompletionResponse(model, intercepted.terminationMessage);
1286
+ return new Response(JSON.stringify(payload), {
1287
+ status: 200,
1288
+ headers: { "Content-Type": "application/json" },
1289
+ });
1290
+ }
1291
+
1292
+ if (intercepted.toolCall) {
1293
+ log.debug("Intercepted OpenCode tool call (non-stream)", {
1294
+ name: intercepted.toolCall.function.name,
1295
+ callId: intercepted.toolCall.id,
1296
+ });
1297
+ const payload = boundaryContext.run(
1298
+ "createNonStreamToolCallResponse",
1299
+ (boundary) => boundary.createNonStreamToolCallResponse(meta, intercepted.toolCall),
1300
+ );
1301
+ return new Response(JSON.stringify(payload), {
1302
+ status: 200,
1303
+ headers: { "Content-Type": "application/json" },
1304
+ });
1305
+ }
1306
+
1307
+ if (exitCode !== 0) {
1308
+ const errSource =
1309
+ stderr
1310
+ || stdout
1311
+ || `cursor-agent exited with code ${String(exitCode ?? "unknown")} and no output`;
1312
+ // Only evict the cached chat ID when the failure indicates the resumed
1313
+ // session itself is gone. Transient errors (network/auth/OOM/signals)
1314
+ // should not discard a valid resume ID.
1315
+ maybeEvictResumeChatId(errSource, resumeChatId, sessionResumeKey, {
1316
+ code: exitCode,
1317
+ failureTextHash: hashForLog(errSource),
1318
+ });
1319
+ const parsed = parseAgentError(errSource);
1320
+ const userError = formatErrorForUser(parsed);
1321
+ log.error("cursor-cli failed", {
1322
+ type: parsed.type,
1323
+ failureTextHash: hashForLog(parsed.message),
1324
+ code: exitCode,
1325
+ });
1326
+ // Return error as chat completion so user always sees it
1327
+ const errorPayload = createChatCompletionResponse(model, userError);
1328
+ return new Response(JSON.stringify(errorPayload), {
1329
+ status: 200,
1330
+ headers: { "Content-Type": "application/json" },
1331
+ });
1332
+ }
1333
+
1334
+ const completion = extractCompletionFromStream(stdout);
1335
+ const payload = createChatCompletionResponse(
1336
+ model,
1337
+ completion.assistantText || stdout || stderr,
1338
+ completion.reasoningText || undefined,
1339
+ completion.usage,
1340
+ );
1341
+ return new Response(JSON.stringify(payload), {
1342
+ status: 200,
1343
+ headers: { "Content-Type": "application/json" },
1344
+ });
1345
+ }
1346
+
1347
+ // Streaming.
1348
+ const encoder = new TextEncoder();
1349
+ const id = `cursor-acp-${Date.now()}`;
1350
+ const created = Math.floor(Date.now() / 1000);
1351
+ const perf = reqPerf;
1352
+ const toolMapper = new ToolMapper();
1353
+ const toolSessionId = id;
1354
+ const passThroughTracker = new PassThroughTracker();
1355
+
1356
+ perf.mark("child-dispatched");
1357
+ const sse = new ReadableStream({
1358
+ async start(controller) {
1359
+ let streamTerminated = false;
1360
+ let firstTokenReceived = false;
1361
+ let firstStdoutByteReceived = false;
1362
+ let firstSseWritten = false;
1363
+ let sawSuccessfulStreamOutput = false;
1364
+ let usage: OpenAiUsage | undefined;
1365
+ const enqueueSse = (payload: string) => {
1366
+ if (!firstSseWritten) {
1367
+ perf.mark("first-sse-write");
1368
+ firstSseWritten = true;
1369
+ }
1370
+ controller.enqueue(encoder.encode(payload));
1371
+ };
1372
+ try {
1373
+ const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
1374
+ const converter = new StreamToSseConverter(model, { id, created });
1375
+ const lineBuffer = new LineBuffer();
1376
+ const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => {
1377
+ log.debug("Intercepted OpenCode tool call (stream)", {
1378
+ name: toolCall.function.name,
1379
+ callId: toolCall.id,
1380
+ });
1381
+ const streamChunks = boundaryContext.run(
1382
+ "createStreamToolCallChunks",
1383
+ (boundary) =>
1384
+ boundary.createStreamToolCallChunks({ id, created, model }, toolCall),
1385
+ );
1386
+ for (const chunk of streamChunks) {
1387
+ enqueueSse(`data: ${JSON.stringify(chunk)}\n\n`);
1388
+ }
1389
+ enqueueSse(formatSseDone());
1390
+ streamTerminated = true;
1391
+ try {
1392
+ child.kill();
1393
+ } catch {
1394
+ // ignore
1395
+ }
1396
+ };
1397
+ const emitTerminalAssistantErrorAndTerminate = (message: string) => {
1398
+ if (streamTerminated) {
1399
+ return;
1400
+ }
1401
+ const errChunk = createChatCompletionChunk(id, created, model, message, true);
1402
+ enqueueSse(`data: ${JSON.stringify(errChunk)}\n\n`);
1403
+ enqueueSse(formatSseDone());
1404
+ streamTerminated = true;
1405
+ try {
1406
+ child.kill();
1407
+ } catch {
1408
+ // ignore
1409
+ }
1410
+ };
1411
+
1412
+ while (true) {
1413
+ if (streamTerminated) break;
1414
+ const { value, done } = await reader.read();
1415
+ if (done) break;
1416
+ if (!value || value.length === 0) continue;
1417
+ if (!firstStdoutByteReceived) { perf.mark("first-stdout-byte"); firstStdoutByteReceived = true; }
1418
+ if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
1419
+
1420
+ for (const line of lineBuffer.push(value)) {
1421
+ if (streamTerminated) break;
1422
+ const event = parseStreamJsonLine(line);
1423
+ if (!event) {
1424
+ continue;
1425
+ }
1426
+ captureResumeChatIdFromEvent(
1427
+ event,
1428
+ sessionResumeKey,
1429
+ model,
1430
+ workspaceDirectory,
1431
+ sessionResumeRecordContentPrefix,
1432
+ sessionResumeToolFingerprint,
1433
+ sessionResumeSubagentFingerprint,
1434
+ );
1435
+
1436
+ if (isResult(event)) {
1437
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
1438
+ if (isSuccessfulResultEvent(event)) {
1439
+ sawSuccessfulStreamOutput = true;
1440
+ }
1441
+ }
1442
+
1443
+ if (event.type === "tool_call") {
1444
+ perf.mark("tool-call");
1445
+ const result = await handleToolLoopEventWithFallback({
1446
+ event: event as any,
1447
+ boundary: boundaryContext.getBoundary(),
1448
+ boundaryMode: boundaryContext.getBoundary().mode,
1449
+ autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
1450
+ toolLoopMode: TOOL_LOOP_MODE,
1451
+ allowedToolNames,
1452
+ toolSchemaMap,
1453
+ toolLoopGuard,
1454
+ toolMapper,
1455
+ toolSessionId,
1456
+ shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
1457
+ proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
1458
+ suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
1459
+ toolRouter,
1460
+ responseMeta: { id, created, model },
1461
+ passThroughTracker,
1462
+ onToolUpdate: (update) => {
1463
+ enqueueSse(formatToolUpdateEvent(update));
1464
+ },
1465
+ onToolResult: (toolResult) => {
1466
+ enqueueSse(`data: ${JSON.stringify(toolResult)}\n\n`);
1467
+ },
1468
+ onInterceptedToolCall: (toolCall) => {
1469
+ emitToolCallAndTerminate(toolCall);
1470
+ },
1471
+ onFallbackToLegacy: (error) => {
1472
+ boundaryContext.activateLegacyFallback("handleToolLoopEvent", error);
1473
+ },
1474
+ });
1475
+ if (result.terminate) {
1476
+ if (!result.terminate.silent) {
1477
+ emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1478
+ } else {
1479
+ // Silent termination: just end the stream without an error message
1480
+ enqueueSse(formatSseDone());
1481
+ streamTerminated = true;
1482
+ try { child.kill(); } catch { /* ignore */ }
1483
+ }
1484
+ break;
1485
+ }
1486
+ if (result.intercepted) {
1487
+ break;
1488
+ }
1489
+ if (result.skipConverter) {
1490
+ continue;
1491
+ }
1492
+ }
1493
+
1494
+ const sseChunks = converter.handleEvent(event);
1495
+ if (sseChunks.length > 0 && (isAssistantText(event) || isThinking(event))) {
1496
+ sawSuccessfulStreamOutput = true;
1497
+ }
1498
+ for (const sse of sseChunks) {
1499
+ enqueueSse(sse);
1500
+ }
1501
+ }
1502
+ }
1503
+ if (streamTerminated) {
1504
+ return;
1505
+ }
1506
+
1507
+ for (const line of lineBuffer.flush()) {
1508
+ if (streamTerminated) break;
1509
+ const event = parseStreamJsonLine(line);
1510
+ if (!event) {
1511
+ continue;
1512
+ }
1513
+ captureResumeChatIdFromEvent(
1514
+ event,
1515
+ sessionResumeKey,
1516
+ model,
1517
+ workspaceDirectory,
1518
+ sessionResumeRecordContentPrefix,
1519
+ sessionResumeToolFingerprint,
1520
+ sessionResumeSubagentFingerprint,
1521
+ );
1522
+ if (isResult(event)) {
1523
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
1524
+ if (isSuccessfulResultEvent(event)) {
1525
+ sawSuccessfulStreamOutput = true;
1526
+ }
1527
+ }
1528
+ if (event.type === "tool_call") {
1529
+ const result = await handleToolLoopEventWithFallback({
1530
+ event: event as any,
1531
+ boundary: boundaryContext.getBoundary(),
1532
+ boundaryMode: boundaryContext.getBoundary().mode,
1533
+ autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
1534
+ toolLoopMode: TOOL_LOOP_MODE,
1535
+ allowedToolNames,
1536
+ toolSchemaMap,
1537
+ toolLoopGuard,
1538
+ toolMapper,
1539
+ toolSessionId,
1540
+ shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
1541
+ proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
1542
+ suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
1543
+ toolRouter,
1544
+ responseMeta: { id, created, model },
1545
+ passThroughTracker,
1546
+ onToolUpdate: (update) => {
1547
+ enqueueSse(formatToolUpdateEvent(update));
1548
+ },
1549
+ onToolResult: (toolResult) => {
1550
+ enqueueSse(`data: ${JSON.stringify(toolResult)}\n\n`);
1551
+ },
1552
+ onInterceptedToolCall: (toolCall) => {
1553
+ emitToolCallAndTerminate(toolCall);
1554
+ },
1555
+ onFallbackToLegacy: (error) => {
1556
+ boundaryContext.activateLegacyFallback("handleToolLoopEvent.flush", error);
1557
+ },
1558
+ });
1559
+ if (result.terminate) {
1560
+ if (!result.terminate.silent) {
1561
+ emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1562
+ } else {
1563
+ enqueueSse(formatSseDone());
1564
+ streamTerminated = true;
1565
+ try { child.kill(); } catch { /* ignore */ }
1566
+ }
1567
+ break;
1568
+ }
1569
+ if (result.intercepted) {
1570
+ break;
1571
+ }
1572
+ if (result.skipConverter) {
1573
+ continue;
1574
+ }
1575
+ }
1576
+ const sseChunks = converter.handleEvent(event);
1577
+ if (sseChunks.length > 0 && (isAssistantText(event) || isThinking(event))) {
1578
+ sawSuccessfulStreamOutput = true;
1579
+ }
1580
+ for (const sse of sseChunks) {
1581
+ enqueueSse(sse);
1582
+ }
1583
+ }
1584
+ if (streamTerminated) {
1585
+ return;
1586
+ }
1587
+
1588
+ const exitCode = await child.exited;
1589
+ if (exitCode !== 0) {
1590
+ const stderrText = await new Response(child.stderr).text();
1591
+ const errSource = (stderrText || "").trim()
1592
+ || `cursor-agent exited with code ${String(exitCode ?? "unknown")} and no output`;
1593
+ if (shouldTreatCursorAgentFailureAsDiagnostic(errSource, sawSuccessfulStreamOutput)) {
1594
+ log.warn("cursor-agent exited non-zero after successful streamed output; treating quota text as diagnostic", {
1595
+ code: exitCode,
1596
+ failureTextHash: hashForLog(errSource),
1597
+ });
1598
+ } else {
1599
+ // Only evict the cached chat ID when the failure indicates the resumed
1600
+ // session itself is gone. Transient errors (network/auth/OOM/signals)
1601
+ // should not discard a valid resume ID.
1602
+ maybeEvictResumeChatId(errSource, resumeChatId, sessionResumeKey, {
1603
+ code: exitCode,
1604
+ failureTextHash: hashForLog(errSource),
1605
+ });
1606
+ const parsed = parseAgentError(errSource);
1607
+ const msg = formatErrorForUser(parsed);
1608
+ log.error("cursor-cli streaming failed", {
1609
+ type: parsed.type,
1610
+ code: exitCode,
1611
+ failureTextHash: hashForLog(parsed.message),
1612
+ });
1613
+ const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1614
+ enqueueSse(`data: ${JSON.stringify(errChunk)}\n\n`);
1615
+ enqueueSse(formatSseDone());
1616
+ return;
1617
+ }
1618
+ }
1619
+
1620
+ log.debug("cursor-agent completed (bun stream)", {
1621
+ exitCode,
1622
+ });
1623
+ warnIfResumeNotCaptured(
1624
+ sessionResumeKey,
1625
+ sessionResumeKeyHash,
1626
+ sessionResumeRecordContentPrefix,
1627
+ sessionResumeToolFingerprint,
1628
+ sessionResumeSubagentFingerprint,
1629
+ model,
1630
+ );
1631
+
1632
+ // Emit toast for passed-through MCP tools
1633
+ const passThroughSummary = passThroughTracker.getSummary();
1634
+ if (passThroughSummary.hasActivity) {
1635
+ await toastService.showPassThroughSummary(passThroughSummary.tools);
1636
+ }
1637
+ if (passThroughSummary.errors.length > 0) {
1638
+ await toastService.showErrorSummary(passThroughSummary.errors);
1639
+ }
1640
+
1641
+ const doneChunk = createChatCompletionChunk(id, created, model, "", true);
1642
+ enqueueSse(`data: ${JSON.stringify(doneChunk)}\n\n`);
1643
+ if (usage) {
1644
+ const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1645
+ enqueueSse(`data: ${JSON.stringify(usageChunk)}\n\n`);
1646
+ }
1647
+ enqueueSse(formatSseDone());
1648
+ } finally {
1649
+ perf.mark("request:done");
1650
+ perf.summarize();
1651
+ controller.close();
1652
+ }
1653
+ },
1654
+ });
1655
+
1656
+ return new Response(sse, {
1657
+ status: 200,
1658
+ headers: {
1659
+ "Content-Type": "text/event-stream",
1660
+ "Cache-Control": "no-cache",
1661
+ Connection: "keep-alive",
1662
+ },
1663
+ });
1664
+ } catch (error) {
1665
+ const message = error instanceof Error ? error.message : String(error);
1666
+ return new Response(JSON.stringify({ error: message }), {
1667
+ status: 500,
1668
+ headers: { "Content-Type": "application/json" },
1669
+ });
1670
+ }
1671
+ };
1672
+
1673
+ if (REUSE_EXISTING_PROXY) {
1674
+ // Check if another process already started a proxy on the default port
1675
+ try {
1676
+ const res = await fetchProxyHealthWithTimeout(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`);
1677
+ if (res && res.ok) {
1678
+ const payload = await res.json().catch(() => null);
1679
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
1680
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
1681
+ state.baseURLByWorkspace![normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
1682
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
1683
+ }
1684
+ }
1685
+ } catch {
1686
+ // ignore
1687
+ }
1688
+ }
1689
+
1690
+ // Use Node.js http server (works in both Node and Bun)
1691
+ const http = await import("http");
1692
+
1693
+ const requestHandler = async (req: any, res: any) => {
1694
+ try{
1695
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
1696
+
1697
+ if (url.pathname === "/health") {
1698
+ res.writeHead(200, { "Content-Type": "application/json" });
1699
+ res.end(JSON.stringify({ ok: true, workspaceDirectory }));
1700
+ return;
1701
+ }
1702
+
1703
+ // Model list via ModelDiscoveryService (has built-in fallback models)
1704
+ if (url.pathname === "/v1/models" || url.pathname === "/models") {
1705
+ try {
1706
+ const { ModelDiscoveryService } = await import("./models/discovery.js");
1707
+ const discovery = new ModelDiscoveryService();
1708
+ const modelList = await discovery.discover(resolveRequestSdkApiKey());
1709
+ const models = modelList.map((m: any) => ({
1710
+ id: typeof m === "string" ? m : m.id,
1711
+ object: "model",
1712
+ created: Math.floor(Date.now() / 1000),
1713
+ owned_by: "cursor",
1714
+ }));
1715
+ res.writeHead(200, { "Content-Type": "application/json" });
1716
+ res.end(JSON.stringify({ object: "list", data: models }));
1717
+ } catch (err) {
1718
+ log.error("Failed to list models", { error: String(err) });
1719
+ res.writeHead(500, { "Content-Type": "application/json" });
1720
+ res.end(JSON.stringify({ error: "Failed to fetch models" }));
1721
+ }
1722
+ return;
1723
+ }
1724
+
1725
+ if (url.pathname !== "/v1/chat/completions" && url.pathname !== "/chat/completions") {
1726
+ res.writeHead(404, { "Content-Type": "application/json" });
1727
+ res.end(JSON.stringify({ error: `Unsupported path: ${url.pathname}` }));
1728
+ return;
1729
+ }
1730
+
1731
+ log.debug("Proxy request (node)", { method: req.method, path: url.pathname });
1732
+ const reqPerf = new RequestPerf(`node-${Date.now()}`);
1733
+ const bodyChunks: Buffer[] = [];
1734
+ for await (const chunk of req) {
1735
+ bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1736
+ }
1737
+ reqPerf.mark("body-read");
1738
+ const body = Buffer.concat(bodyChunks).toString("utf8");
1739
+
1740
+ const bodyData: any = JSON.parse(body || "{}");
1741
+ reqPerf.mark("body-parsed");
1742
+ const messages: Array<any> = Array.isArray(bodyData?.messages) ? bodyData.messages : [];
1743
+ const stream = bodyData?.stream === true;
1744
+ const tools = Array.isArray(bodyData?.tools) ? bodyData.tools : [];
1745
+ const allowedToolNames = extractAllowedToolNames(tools);
1746
+ const toolSchemaMap = buildToolSchemaMap(tools);
1747
+ const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT);
1748
+ const boundaryContext = createBoundaryRuntimeContext("node-handler");
1749
+
1750
+ const subagentNames = readSubagentNames();
1751
+ const model = boundaryContext.run("resolveRuntimeModel", (boundary) =>
1752
+ boundary.resolveRuntimeModel(bodyData?.model, bodyData?.cursorModel),
1753
+ );
1754
+ const authHeaderNode = req.headers["authorization"] as string | undefined;
1755
+ const sdkApiKeyNode = resolveRequestSdkApiKey(authHeaderNode);
1756
+ const backend = resolveBackendForRequest(sdkApiKeyNode);
1757
+ reqPerf.mark("backend-resolved");
1758
+ const {
1759
+ prompt,
1760
+ resumeChatId,
1761
+ sessionKey: sessionResumeKey,
1762
+ usedIncremental,
1763
+ contentPrefix: sessionResumeContentPrefix,
1764
+ recordContentPrefix: sessionResumeRecordContentPrefix,
1765
+ toolFingerprint: sessionResumeToolFingerprint,
1766
+ subagentFingerprint: sessionResumeSubagentFingerprint,
1767
+ } = resolvePromptForBackend({
1768
+ backend,
1769
+ messages,
1770
+ tools,
1771
+ subagentNames,
1772
+ model,
1773
+ workspaceDirectory,
1774
+ });
1775
+ reqPerf.mark("prompt-built");
1776
+ const sessionResumeKeyHashNode = sessionResumeKey ? sanitizeSessionKey(sessionResumeKey) : undefined;
1777
+ const resumeChatIdHashNode = resumeChatId ? sanitizeSessionKey(resumeChatId) : undefined;
1778
+ const msgSummary = messages.map((m: any, i: number) => {
1779
+ const role = m?.role ?? "?";
1780
+ const hasTc = Array.isArray(m?.tool_calls) ? m.tool_calls.length : 0;
1781
+ const tcId = m?.tool_call_id ? "yes" : "no";
1782
+ const tcName = m?.name ?? "";
1783
+ const contentLen = typeof m?.content === "string" ? m.content.length : Array.isArray(m?.content) ? `arr${m.content.length}` : typeof m?.content;
1784
+ return `${i}:${role}${hasTc ? `(tc:${hasTc})` : ""}${role === "tool" ? `(tcid:${tcId},name:${tcName},clen:${contentLen})` : `(clen:${contentLen})`}`;
1785
+ });
1786
+ log.debug("Proxy chat request (node)", {
1787
+ stream,
1788
+ model,
1789
+ messages: messages.length,
1790
+ tools: tools.length,
1791
+ promptChars: prompt.length,
1792
+ msgRoles: msgSummary.join(","),
1793
+ sessionResume: resumeChatId ? { chatIdHash: resumeChatIdHashNode, incremental: usedIncremental } : undefined,
1794
+ });
1795
+
1796
+ if (backend === "sdk" && !sdkApiKeyNode) {
1797
+ res.writeHead(401, { "Content-Type": "application/json" });
1798
+ res.end(JSON.stringify({ error: "Cursor SDK backend requires a real Cursor API key. Set CURSOR_API_KEY or run `opencode auth login`; the legacy `cursor-agent` placeholder is not valid SDK auth." }));
1799
+ return;
1800
+ }
1801
+
1802
+ reqPerf.mark("child-create-start");
1803
+ const child = createNodeChildForBackend({
1804
+ backend,
1805
+ sdkApiKey: sdkApiKeyNode,
1806
+ model,
1807
+ prompt,
1808
+ workspaceDirectory,
1809
+ resumeChatId,
1810
+ });
1811
+ reqPerf.mark("child-created");
1812
+
1813
+ if (!stream) {
1814
+ const stdoutChunks: Buffer[] = [];
1815
+ const stderrChunks: Buffer[] = [];
1816
+ let spawnErrorText: string | null = null;
1817
+
1818
+ child.on("error", (error: any) => {
1819
+ spawnErrorText = String(error?.message || error);
1820
+ log.error("Failed to spawn cursor-agent", { errorHash: hashForLog(spawnErrorText), model });
1821
+ });
1822
+
1823
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
1824
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
1825
+
1826
+ child.on("close", async (code) => {
1827
+ const stdout = Buffer.concat(stdoutChunks).toString().trim();
1828
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
1829
+ log.debug("cursor-agent completed (node non-stream)", {
1830
+ code,
1831
+ stdoutChars: stdout.length,
1832
+ stderrChars: stderr.length,
1833
+ spawnError: spawnErrorText != null,
1834
+ });
1835
+ captureResumeChatIdFromOutput(
1836
+ stdout,
1837
+ sessionResumeKey,
1838
+ model,
1839
+ workspaceDirectory,
1840
+ sessionResumeRecordContentPrefix,
1841
+ sessionResumeToolFingerprint,
1842
+ sessionResumeSubagentFingerprint,
1843
+ );
1844
+ warnIfResumeNotCaptured(
1845
+ sessionResumeKey,
1846
+ sessionResumeKeyHashNode,
1847
+ sessionResumeRecordContentPrefix,
1848
+ sessionResumeToolFingerprint,
1849
+ sessionResumeSubagentFingerprint,
1850
+ model,
1851
+ );
1852
+ const meta = {
1853
+ id: `cursor-acp-${Date.now()}`,
1854
+ created: Math.floor(Date.now() / 1000),
1855
+ model,
1856
+ };
1857
+ const intercepted = await findFirstAllowedToolCallInOutput(stdout, {
1858
+ toolLoopMode: TOOL_LOOP_MODE,
1859
+ allowedToolNames,
1860
+ toolSchemaMap,
1861
+ toolLoopGuard,
1862
+ boundaryContext,
1863
+ responseMeta: meta,
1864
+ });
1865
+ if (intercepted.terminationMessage) {
1866
+ const terminationResponse = createChatCompletionResponse(model, intercepted.terminationMessage);
1867
+ res.writeHead(200, { "Content-Type": "application/json" });
1868
+ res.end(JSON.stringify(terminationResponse));
1869
+ return;
1870
+ }
1871
+
1872
+ if (intercepted.toolCall) {
1873
+ log.debug("Intercepted OpenCode tool call (non-stream)", {
1874
+ name: intercepted.toolCall.function.name,
1875
+ callId: intercepted.toolCall.id,
1876
+ });
1877
+ const payload = boundaryContext.run(
1878
+ "createNonStreamToolCallResponse",
1879
+ (boundary) => boundary.createNonStreamToolCallResponse(meta, intercepted.toolCall),
1880
+ );
1881
+ res.writeHead(200, { "Content-Type": "application/json" });
1882
+ res.end(JSON.stringify(payload));
1883
+ return;
1884
+ }
1885
+
1886
+ const completion = extractCompletionFromStream(stdout);
1887
+
1888
+ if (code !== 0 || spawnErrorText) {
1889
+ const errSource =
1890
+ stderr
1891
+ || stdout
1892
+ || spawnErrorText
1893
+ || `cursor-agent exited with code ${String(code ?? "unknown")} and no output`;
1894
+ // Only evict the cached chat ID when the failure indicates the resumed
1895
+ // session itself is gone. Transient errors (network/auth/OOM/signals)
1896
+ // should not discard a valid resume ID.
1897
+ maybeEvictResumeChatId(errSource, resumeChatId, sessionResumeKey, {
1898
+ code,
1899
+ spawnError: spawnErrorText != null,
1900
+ failureTextHash: hashForLog(errSource),
1901
+ });
1902
+ const parsed = parseAgentError(errSource);
1903
+ const userError = formatErrorForUser(parsed);
1904
+ log.error("cursor-cli failed", {
1905
+ type: parsed.type,
1906
+ failureTextHash: hashForLog(parsed.message),
1907
+ code,
1908
+ });
1909
+ // Return error as chat completion so user always sees it
1910
+ const errorResponse = createChatCompletionResponse(model, userError);
1911
+ res.writeHead(200, { "Content-Type": "application/json" });
1912
+ res.end(JSON.stringify(errorResponse));
1913
+ return;
1914
+ }
1915
+
1916
+ const response = createChatCompletionResponse(
1917
+ model,
1918
+ completion.assistantText || stdout || stderr,
1919
+ completion.reasoningText || undefined,
1920
+ completion.usage,
1921
+ );
1922
+
1923
+ res.writeHead(200, { "Content-Type": "application/json" });
1924
+ res.end(JSON.stringify(response));
1925
+ });
1926
+ } else {
1927
+ // Streaming
1928
+ if (res.socket) res.socket.setNoDelay(true);
1929
+ res.writeHead(200, {
1930
+ "Content-Type": "text/event-stream",
1931
+ "Cache-Control": "no-cache",
1932
+ Connection: "keep-alive",
1933
+ });
1934
+ res.flushHeaders();
1935
+
1936
+ const id = `cursor-acp-${Date.now()}`;
1937
+ const created = Math.floor(Date.now() / 1000);
1938
+ const perf = reqPerf;
1939
+ perf.mark("child-dispatched");
1940
+
1941
+ const converter = new StreamToSseConverter(model, { id, created });
1942
+ const lineBuffer = new LineBuffer();
1943
+ const toolMapper = new ToolMapper();
1944
+ const toolSessionId = id;
1945
+ const passThroughTracker = new PassThroughTracker();
1946
+ const stderrChunks: Buffer[] = [];
1947
+ let streamTerminated = false;
1948
+ let firstTokenReceived = false;
1949
+ let firstStdoutByteReceived = false;
1950
+ let firstSseWritten = false;
1951
+ let sawSuccessfulStreamOutput = false;
1952
+ let usage: OpenAiUsage | undefined;
1953
+ const writeSse = (payload: string) => {
1954
+ if (!firstSseWritten) {
1955
+ perf.mark("first-sse-write");
1956
+ firstSseWritten = true;
1957
+ }
1958
+ res.write(payload);
1959
+ };
1960
+ child.stderr.on("data", (chunk) => {
1961
+ stderrChunks.push(Buffer.from(chunk));
1962
+ });
1963
+ child.on("error", (error: any) => {
1964
+ if (streamTerminated || res.writableEnded) {
1965
+ return;
1966
+ }
1967
+ const errSource = String(error?.message || error);
1968
+ log.error("Failed to spawn cursor-agent (stream)", { errorHash: hashForLog(errSource), model });
1969
+ const parsed = parseAgentError(errSource);
1970
+ const msg = formatErrorForUser(parsed);
1971
+ const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1972
+ writeSse(`data: ${JSON.stringify(errChunk)}\n\n`);
1973
+ writeSse(formatSseDone());
1974
+ streamTerminated = true;
1975
+ res.end();
1976
+ });
1977
+ const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => {
1978
+ if (streamTerminated || res.writableEnded) {
1979
+ return;
1980
+ }
1981
+ log.debug("Intercepted OpenCode tool call (stream)", {
1982
+ name: toolCall.function.name,
1983
+ callId: toolCall.id,
1984
+ });
1985
+ const streamChunks = boundaryContext.run(
1986
+ "createStreamToolCallChunks",
1987
+ (boundary) =>
1988
+ boundary.createStreamToolCallChunks({ id, created, model }, toolCall),
1989
+ );
1990
+ for (const chunk of streamChunks) {
1991
+ writeSse(`data: ${JSON.stringify(chunk)}\n\n`);
1992
+ }
1993
+ writeSse(formatSseDone());
1994
+ streamTerminated = true;
1995
+ res.end();
1996
+ try {
1997
+ child.kill();
1998
+ } catch {
1999
+ // ignore
2000
+ }
2001
+ };
2002
+ const emitTerminalAssistantErrorAndTerminate = (message: string) => {
2003
+ if (streamTerminated || res.writableEnded) {
2004
+ return;
2005
+ }
2006
+ const errChunk = createChatCompletionChunk(id, created, model, message, true);
2007
+ writeSse(`data: ${JSON.stringify(errChunk)}\n\n`);
2008
+ writeSse(formatSseDone());
2009
+ streamTerminated = true;
2010
+ res.end();
2011
+ try {
2012
+ child.kill();
2013
+ } catch {
2014
+ // ignore
2015
+ }
2016
+ };
2017
+
2018
+ const chunkQueue: Buffer[] = [];
2019
+ let draining = false;
2020
+ let childClosed = false;
2021
+ let childCloseHandled = false;
2022
+ let childExitCode: number | null = null;
2023
+
2024
+ const processLines = async (lines: string[]) => {
2025
+ for (const line of lines) {
2026
+ if (streamTerminated || res.writableEnded) break;
2027
+ const event = parseStreamJsonLine(line);
2028
+ if (!event) continue;
2029
+ captureResumeChatIdFromEvent(
2030
+ event,
2031
+ sessionResumeKey,
2032
+ model,
2033
+ workspaceDirectory,
2034
+ sessionResumeRecordContentPrefix,
2035
+ sessionResumeToolFingerprint,
2036
+ sessionResumeSubagentFingerprint,
2037
+ );
2038
+
2039
+ if (isResult(event)) {
2040
+ usage = extractOpenAiUsageFromResult(event) ?? usage;
2041
+ if (isSuccessfulResultEvent(event)) {
2042
+ sawSuccessfulStreamOutput = true;
2043
+ }
2044
+ }
2045
+
2046
+ if (event.type === "tool_call") {
2047
+ perf.mark("tool-call");
2048
+ const result = await handleToolLoopEventWithFallback({
2049
+ event: event as any,
2050
+ boundary: boundaryContext.getBoundary(),
2051
+ boundaryMode: boundaryContext.getBoundary().mode,
2052
+ autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
2053
+ toolLoopMode: TOOL_LOOP_MODE,
2054
+ allowedToolNames,
2055
+ toolSchemaMap,
2056
+ toolLoopGuard,
2057
+ toolMapper,
2058
+ toolSessionId,
2059
+ shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
2060
+ proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
2061
+ suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
2062
+ toolRouter,
2063
+ responseMeta: { id, created, model },
2064
+ passThroughTracker,
2065
+ onToolUpdate: (update) => {
2066
+ writeSse(formatToolUpdateEvent(update));
2067
+ },
2068
+ onToolResult: (toolResult) => {
2069
+ writeSse(`data: ${JSON.stringify(toolResult)}\n\n`);
2070
+ },
2071
+ onInterceptedToolCall: (toolCall) => {
2072
+ emitToolCallAndTerminate(toolCall);
2073
+ },
2074
+ onFallbackToLegacy: (error) => {
2075
+ boundaryContext.activateLegacyFallback("handleToolLoopEvent", error);
2076
+ },
2077
+ });
2078
+ if (result.terminate) {
2079
+ if (!result.terminate.silent) {
2080
+ emitTerminalAssistantErrorAndTerminate(result.terminate.message);
2081
+ } else {
2082
+ streamTerminated = true;
2083
+ try { child.kill(); } catch { /* ignore */ }
2084
+ }
2085
+ break;
2086
+ }
2087
+ if (result.intercepted) break;
2088
+ if (result.skipConverter) continue;
2089
+ }
2090
+
2091
+ if (streamTerminated || res.writableEnded) break;
2092
+ const sseChunks = converter.handleEvent(event);
2093
+ if (sseChunks.length > 0 && (isAssistantText(event) || isThinking(event))) {
2094
+ sawSuccessfulStreamOutput = true;
2095
+ }
2096
+ for (const sse of sseChunks) {
2097
+ writeSse(sse);
2098
+ }
2099
+ }
2100
+ };
2101
+
2102
+ const drainQueue = async () => {
2103
+ if (draining) return;
2104
+ draining = true;
2105
+ try {
2106
+ while (chunkQueue.length > 0) {
2107
+ if (streamTerminated || res.writableEnded) break;
2108
+ const chunk = chunkQueue.shift()!;
2109
+ if (!firstStdoutByteReceived) { perf.mark("first-stdout-byte"); firstStdoutByteReceived = true; }
2110
+ if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
2111
+ await processLines(lineBuffer.push(chunk));
2112
+ }
2113
+
2114
+ if (childClosed && !childCloseHandled && !streamTerminated && !res.writableEnded) {
2115
+ childCloseHandled = true;
2116
+ await processLines(lineBuffer.flush());
2117
+ if (streamTerminated || res.writableEnded) return;
2118
+
2119
+ perf.mark("request:done");
2120
+ perf.summarize();
2121
+ const stderrText = Buffer.concat(stderrChunks).toString().trim();
2122
+ log.debug("cursor-agent completed (node stream)", {
2123
+ code: childExitCode,
2124
+ stderrChars: stderrText.length,
2125
+ });
2126
+ if (childExitCode !== 0) {
2127
+ const errSource =
2128
+ stderrText
2129
+ || `cursor-agent exited with code ${String(childExitCode ?? "unknown")} and no output`;
2130
+ if (shouldTreatCursorAgentFailureAsDiagnostic(errSource, sawSuccessfulStreamOutput)) {
2131
+ log.warn("cursor-agent exited non-zero after successful streamed output; treating quota text as diagnostic", {
2132
+ code: childExitCode,
2133
+ failureTextHash: hashForLog(errSource),
2134
+ });
2135
+ } else {
2136
+ // Only evict the cached chat ID when the failure indicates the resumed
2137
+ // session itself is gone. Transient errors (network/auth/OOM/signals)
2138
+ // should not discard a valid resume ID.
2139
+ maybeEvictResumeChatId(errSource, resumeChatId, sessionResumeKey, {
2140
+ code: childExitCode,
2141
+ failureTextHash: hashForLog(errSource),
2142
+ });
2143
+ const parsed = parseAgentError(errSource);
2144
+ const msg = formatErrorForUser(parsed);
2145
+ const errChunk = createChatCompletionChunk(id, created, model, msg, true);
2146
+ writeSse(`data: ${JSON.stringify(errChunk)}\n\n`);
2147
+ writeSse(formatSseDone());
2148
+ streamTerminated = true;
2149
+ res.end();
2150
+ return;
2151
+ }
2152
+ }
2153
+
2154
+ warnIfResumeNotCaptured(
2155
+ sessionResumeKey,
2156
+ sessionResumeKeyHashNode,
2157
+ sessionResumeRecordContentPrefix,
2158
+ sessionResumeToolFingerprint,
2159
+ sessionResumeSubagentFingerprint,
2160
+ model,
2161
+ );
2162
+
2163
+ const passThroughSummary = passThroughTracker.getSummary();
2164
+ if (passThroughSummary.hasActivity) {
2165
+ await toastService.showPassThroughSummary(passThroughSummary.tools);
2166
+ }
2167
+ if (passThroughSummary.errors.length > 0) {
2168
+ await toastService.showErrorSummary(passThroughSummary.errors);
2169
+ }
2170
+
2171
+ const doneChunk = {
2172
+ id,
2173
+ object: "chat.completion.chunk",
2174
+ created,
2175
+ model,
2176
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
2177
+ };
2178
+ writeSse(`data: ${JSON.stringify(doneChunk)}\n\n`);
2179
+ if (usage) {
2180
+ const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
2181
+ writeSse(`data: ${JSON.stringify(usageChunk)}\n\n`);
2182
+ }
2183
+ writeSse(formatSseDone());
2184
+ streamTerminated = true;
2185
+ res.end();
2186
+ }
2187
+ } finally {
2188
+ draining = false;
2189
+ if (
2190
+ !streamTerminated
2191
+ && !res.writableEnded
2192
+ && (chunkQueue.length > 0 || (childClosed && !childCloseHandled))
2193
+ ) {
2194
+ drainQueue();
2195
+ }
2196
+ }
2197
+ };
2198
+
2199
+ child.stdout.on("data", (chunk) => {
2200
+ chunkQueue.push(Buffer.from(chunk));
2201
+ drainQueue();
2202
+ });
2203
+
2204
+ child.on("close", (code) => {
2205
+ childClosed = true;
2206
+ childExitCode = code;
2207
+ drainQueue();
2208
+ });
2209
+ }
2210
+ } catch (error) {
2211
+ const message = error instanceof Error ? error.message : String(error);
2212
+ res.writeHead(500, { "Content-Type": "application/json" });
2213
+ res.end(JSON.stringify({ error: message }));
2214
+ }
2215
+ };
2216
+
2217
+ let server = http.createServer(requestHandler);
2218
+
2219
+ // Try to start on default port
2220
+ try {
2221
+ await new Promise<void>((resolve, reject) => {
2222
+ server.listen(CURSOR_PROXY_DEFAULT_PORT, CURSOR_PROXY_HOST, () => resolve());
2223
+ server.once("error", reject);
2224
+ });
2225
+
2226
+ const baseURL = `http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/v1`;
2227
+ state.baseURL = baseURL;
2228
+ state.baseURLByWorkspace![normalizedWorkspace] = baseURL;
2229
+ return baseURL;
2230
+ } catch (error: any) {
2231
+ if (error?.code !== "EADDRINUSE") {
2232
+ throw error;
2233
+ }
2234
+
2235
+ if (REUSE_EXISTING_PROXY) {
2236
+ // Port in use - check if it's our proxy
2237
+ try {
2238
+ const res = await fetchProxyHealthWithTimeout(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`);
2239
+ if (res && res.ok) {
2240
+ const payload = await res.json().catch(() => null);
2241
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
2242
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
2243
+ state.baseURLByWorkspace![normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
2244
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
2245
+ }
2246
+ }
2247
+ } catch {
2248
+ // ignore
2249
+ }
2250
+ }
2251
+
2252
+ // Start on random port
2253
+ server = http.createServer(requestHandler);
2254
+ await new Promise<void>((resolve, reject) => {
2255
+ server.listen(0, CURSOR_PROXY_HOST, () => resolve());
2256
+ server.once("error", reject);
2257
+ });
2258
+
2259
+ const addr = server.address() as any;
2260
+ const baseURL = `http://${CURSOR_PROXY_HOST}:${addr.port}/v1`;
2261
+ state.baseURL = baseURL;
2262
+ state.baseURLByWorkspace![normalizedWorkspace] = baseURL;
2263
+ return baseURL;
2264
+ }
2265
+ }
2266
+
2267
+ /**
2268
+ * Convert JSON Schema parameters to Zod schemas for plugin tool hook
2269
+ */
2270
+ function jsonSchemaToZod(jsonSchema: any): any {
2271
+ const z = tool.schema;
2272
+ const properties = jsonSchema.properties || {};
2273
+ const required = jsonSchema.required || [];
2274
+
2275
+ const zodShape: any = {};
2276
+
2277
+ for (const [key, prop] of Object.entries(properties)) {
2278
+ const p = prop as any;
2279
+ let zodType: any;
2280
+
2281
+ switch (p.type) {
2282
+ case "string":
2283
+ zodType = z.string();
2284
+ if (p.description) {
2285
+ zodType = zodType.describe(p.description);
2286
+ }
2287
+ break;
2288
+ case "number":
2289
+ zodType = z.number();
2290
+ if (p.description) {
2291
+ zodType = zodType.describe(p.description);
2292
+ }
2293
+ break;
2294
+ case "boolean":
2295
+ zodType = z.boolean();
2296
+ if (p.description) {
2297
+ zodType = zodType.describe(p.description);
2298
+ }
2299
+ break;
2300
+ case "object":
2301
+ zodType = z.record(z.string(), z.any());
2302
+ if (p.description) {
2303
+ zodType = zodType.describe(p.description);
2304
+ }
2305
+ break;
2306
+ case "array":
2307
+ zodType = z.array(z.any());
2308
+ if (p.description) {
2309
+ zodType = zodType.describe(p.description);
2310
+ }
2311
+ break;
2312
+ default:
2313
+ zodType = z.any();
2314
+ break;
2315
+ }
2316
+
2317
+ // Make optional if not in required array
2318
+ if (!required.includes(key)) {
2319
+ zodType = zodType.optional();
2320
+ }
2321
+
2322
+ zodShape[key] = zodType;
2323
+ }
2324
+
2325
+ return zodShape;
2326
+ }
2327
+
2328
+ function resolveToolContextBaseDirWithSession(
2329
+ context: any,
2330
+ fallbackBaseDir?: string,
2331
+ sessionWorkspaceBySession?: Map<string, string>,
2332
+ ): string | null {
2333
+ const sessionID = typeof context?.sessionID === "string" && context.sessionID.trim().length > 0
2334
+ ? context.sessionID.trim()
2335
+ : "";
2336
+
2337
+ const worktree = resolveCandidate(typeof context?.worktree === "string" ? context.worktree : undefined);
2338
+ const directory = resolveCandidate(typeof context?.directory === "string" ? context.directory : undefined);
2339
+ const fallback = resolveCandidate(fallbackBaseDir);
2340
+ const pinned = sessionID && sessionWorkspaceBySession
2341
+ ? resolveCandidate(sessionWorkspaceBySession.get(sessionID))
2342
+ : "";
2343
+
2344
+ const pinSession = (candidate: string) => {
2345
+ if (sessionID && sessionWorkspaceBySession && isNonConfigPath(candidate)) {
2346
+ if (!sessionWorkspaceBySession.has(sessionID) && sessionWorkspaceBySession.size >= SESSION_WORKSPACE_CACHE_LIMIT) {
2347
+ const oldestSession = sessionWorkspaceBySession.keys().next().value;
2348
+ if (typeof oldestSession === "string") {
2349
+ sessionWorkspaceBySession.delete(oldestSession);
2350
+ }
2351
+ }
2352
+ sessionWorkspaceBySession.set(sessionID, candidate);
2353
+ }
2354
+ };
2355
+
2356
+ if (isNonConfigPath(worktree)) {
2357
+ pinSession(worktree);
2358
+ return worktree;
2359
+ }
2360
+
2361
+ if (isNonConfigPath(pinned)) {
2362
+ return pinned;
2363
+ }
2364
+
2365
+ if (isNonConfigPath(directory)) {
2366
+ pinSession(directory);
2367
+ return directory;
2368
+ }
2369
+
2370
+ if (isNonConfigPath(fallback)) {
2371
+ pinSession(fallback);
2372
+ return fallback;
2373
+ }
2374
+
2375
+ return null;
2376
+ }
2377
+
2378
+ function toAbsoluteWithBase(value: unknown, baseDir: string): unknown {
2379
+ if (typeof value !== "string") {
2380
+ return value;
2381
+ }
2382
+ const trimmed = value.trim();
2383
+ if (trimmed.length === 0 || isAbsolute(trimmed)) {
2384
+ return value;
2385
+ }
2386
+ return resolve(baseDir, trimmed);
2387
+ }
2388
+
2389
+ function applyToolContextDefaults(
2390
+ toolName: string,
2391
+ rawArgs: Record<string, unknown>,
2392
+ context: any,
2393
+ fallbackBaseDir?: string,
2394
+ sessionWorkspaceBySession?: Map<string, string>,
2395
+ ): Record<string, unknown> {
2396
+ const baseDir = resolveToolContextBaseDirWithSession(context, fallbackBaseDir, sessionWorkspaceBySession);
2397
+ if (!baseDir) {
2398
+ return rawArgs;
2399
+ }
2400
+
2401
+ const args: Record<string, unknown> = { ...rawArgs };
2402
+
2403
+ for (const key of [
2404
+ "path",
2405
+ "filePath",
2406
+ "targetPath",
2407
+ "directory",
2408
+ "dir",
2409
+ "folder",
2410
+ "targetDirectory",
2411
+ "targetFile",
2412
+ "cwd",
2413
+ "workdir",
2414
+ ]) {
2415
+ args[key] = toAbsoluteWithBase(args[key], baseDir);
2416
+ }
2417
+
2418
+ const baseName = toolName.startsWith("oc_") ? toolName.slice(3) : toolName;
2419
+
2420
+ if ((baseName === "bash" || baseName === "shell") && args.cwd === undefined && args.workdir === undefined) {
2421
+ args.cwd = baseDir;
2422
+ }
2423
+
2424
+ if ((baseName === "grep" || baseName === "glob" || baseName === "ls") && args.path === undefined) {
2425
+ args.path = baseDir;
2426
+ }
2427
+
2428
+ return args;
2429
+ }
2430
+
2431
+ /**
2432
+ * Build tool hook entries from local registry
2433
+ */
2434
+ const NATIVE_TOOL_HOOK_EXCLUSIONS = new Set(["grep"]);
2435
+
2436
+ function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): Record<string, any> {
2437
+ const entries: Record<string, any> = {};
2438
+ const sessionWorkspaceBySession = new Map<string, string>();
2439
+ const tools = registry.list();
2440
+ for (const t of tools) {
2441
+ if (NATIVE_TOOL_HOOK_EXCLUSIONS.has(t.name)) continue;
2442
+
2443
+ const handler = registry.getHandler(t.name);
2444
+ if (!handler) continue;
2445
+
2446
+ const zodArgs = jsonSchemaToZod(t.parameters);
2447
+ const createEntry = (toolName: string) =>
2448
+ tool({
2449
+ description: t.description,
2450
+ args: zodArgs,
2451
+ async execute(args: any, context: any) {
2452
+ try {
2453
+ const normalizedArgs = applyToolContextDefaults(
2454
+ toolName,
2455
+ args,
2456
+ context,
2457
+ fallbackBaseDir,
2458
+ sessionWorkspaceBySession,
2459
+ );
2460
+ return await handler(normalizedArgs);
2461
+ } catch (error: any) {
2462
+ log.debug("Tool hook execution failed", { tool: toolName, error: String(error?.message || error) });
2463
+ throw error;
2464
+ }
2465
+ },
2466
+ });
2467
+
2468
+ entries[t.name] = createEntry(t.name);
2469
+
2470
+ const ocAlias = `oc_${t.id}`;
2471
+ if (!entries[ocAlias]) {
2472
+ entries[ocAlias] = createEntry(ocAlias);
2473
+ }
2474
+
2475
+ // Some agent variants emit "shell" instead of "bash".
2476
+ if (t.name === "bash" && !entries.shell) {
2477
+ entries.shell = createEntry("shell");
2478
+ }
2479
+ }
2480
+
2481
+ return entries;
2482
+ }
2483
+
2484
+ /**
2485
+ * OpenCode plugin for Cursor Agent
2486
+ */
2487
+ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, serverUrl }: PluginInput) => {
2488
+ const workspaceDirectory = resolveWorkspaceDirectory(worktree, directory);
2489
+ log.debug("Plugin initializing", {
2490
+ directory,
2491
+ worktree,
2492
+ workspaceDirectory,
2493
+ cwd: process.cwd(),
2494
+ serverUrl: serverUrl?.toString(),
2495
+ });
2496
+ if (!TOOL_LOOP_MODE_VALID) {
2497
+ log.warn("Invalid CURSOR_ACP_TOOL_LOOP_MODE; defaulting to opencode", { value: TOOL_LOOP_MODE_RAW });
2498
+ }
2499
+ if (!PROVIDER_BOUNDARY_MODE_VALID) {
2500
+ log.warn("Invalid CURSOR_ACP_PROVIDER_BOUNDARY; defaulting to v1", {
2501
+ value: PROVIDER_BOUNDARY_MODE_RAW,
2502
+ });
2503
+ }
2504
+ if (!TOOL_LOOP_MAX_REPEAT_VALID) {
2505
+ log.warn("Invalid CURSOR_ACP_TOOL_LOOP_MAX_REPEAT; defaulting to 3", {
2506
+ value: TOOL_LOOP_MAX_REPEAT_RAW,
2507
+ });
2508
+ }
2509
+ if (ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK && PROVIDER_BOUNDARY.mode !== "v1") {
2510
+ log.debug("Provider boundary auto-fallback is enabled but inactive unless mode=v1");
2511
+ }
2512
+ log.info("Tool loop mode configured", {
2513
+ mode: TOOL_LOOP_MODE,
2514
+ providerBoundary: PROVIDER_BOUNDARY.mode,
2515
+ proxyExecToolCalls: PROXY_EXECUTE_TOOL_CALLS,
2516
+ providerBoundaryAutoFallback: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
2517
+ toolLoopMaxRepeat: TOOL_LOOP_MAX_REPEAT,
2518
+ });
2519
+ await ensurePluginDirectory();
2520
+
2521
+ // Auto-refresh model list from cursor-agent (non-blocking, fire-and-forget)
2522
+ autoRefreshModels().catch(() => {});
2523
+
2524
+ // Tools (skills) discovery/execution wiring. In native OpenCode mode this
2525
+ // plugin stays provider-only: OpenCode owns built-in tools and MCP.
2526
+ const toolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS !== "false"; // default ON
2527
+ const legacyProxyToolPathsEnabled = toolsEnabled && TOOL_LOOP_MODE === "proxy-exec";
2528
+
2529
+ // MCP tool bridge: connect to MCP servers and register their tools only for
2530
+ // legacy proxy execution. Native OpenCode mode already handles MCP itself.
2531
+ const mcpManager = new McpClientManager();
2532
+ let mcpToolEntries: Record<string, any> = {};
2533
+ let mcpToolDefs: any[] = [];
2534
+ let mcpToolSummaries: McpToolSummary[] = [];
2535
+ const mcpEnabled = legacyProxyToolPathsEnabled && process.env.CURSOR_ACP_MCP_BRIDGE !== "false"; // default ON for legacy mode
2536
+
2537
+ if (mcpEnabled) {
2538
+ try {
2539
+ const configs = readMcpConfigs();
2540
+ if (configs.length === 0) {
2541
+ log.debug("No MCP servers configured, skipping MCP bridge");
2542
+ } else {
2543
+ log.debug("MCP bridge: connecting to servers", { count: configs.length });
2544
+
2545
+ await Promise.allSettled(configs.map((c) => mcpManager.connectServer(c)));
2546
+
2547
+ const tools = mcpManager.listTools();
2548
+ if (tools.length === 0) {
2549
+ log.debug("MCP bridge: no tools discovered");
2550
+ } else {
2551
+ mcpToolEntries = buildMcpToolHookEntries(tools, mcpManager);
2552
+ mcpToolDefs = buildMcpToolDefinitions(tools);
2553
+ mcpToolSummaries = tools.map((t) => ({
2554
+ serverName: t.serverName,
2555
+ toolName: t.name,
2556
+ callName: namespaceMcpTool(t.serverName, t.name),
2557
+ description: t.description,
2558
+ params: t.inputSchema
2559
+ ? Object.keys((t.inputSchema as any).properties ?? {})
2560
+ : undefined,
2561
+ }));
2562
+ log.info("MCP bridge: registered tools", {
2563
+ servers: mcpManager.connectedServers.length,
2564
+ tools: Object.keys(mcpToolEntries).length,
2565
+ });
2566
+ }
2567
+ }
2568
+ } catch (err) {
2569
+ log.debug("MCP bridge init failed", { error: String(err) });
2570
+ }
2571
+ }
2572
+
2573
+ // Initialize toast service for MCP pass-through notifications
2574
+ toastService.setClient(client);
2575
+
2576
+ if (toolsEnabled && TOOL_LOOP_MODE === "opencode") {
2577
+ log.debug("OpenCode mode active; skipping legacy SDK/MCP discovery and proxy-side tool execution");
2578
+ } else if (toolsEnabled && TOOL_LOOP_MODE === "off") {
2579
+ log.debug("Tool loop mode off; proxy-side tool execution disabled");
2580
+ }
2581
+ // FORWARD_TOOL_CALLS is only used when TOOL_LOOP_MODE=proxy-exec.
2582
+ // Build a client with serverUrl so SDK tool.list works even if the injected client isn't fully configured.
2583
+ const serverClient = legacyProxyToolPathsEnabled
2584
+ ? createOpencodeClient({ baseUrl: serverUrl.toString(), directory: workspaceDirectory })
2585
+ : null;
2586
+ const discovery = legacyProxyToolPathsEnabled ? new OpenCodeToolDiscovery(serverClient ?? client) : null;
2587
+
2588
+ // Build executor chain: Local -> SDK -> MCP
2589
+ const localRegistry = new CoreRegistry();
2590
+ registerDefaultTools(localRegistry);
2591
+
2592
+ const timeoutMs = Number(process.env.CURSOR_ACP_TOOL_TIMEOUT_MS || 30000);
2593
+ const localExec = new LocalExecutor(localRegistry);
2594
+ const sdkExec = legacyProxyToolPathsEnabled ? new SdkExecutor(serverClient ?? client, timeoutMs) : null;
2595
+ const mcpExec = legacyProxyToolPathsEnabled ? new McpExecutor(serverClient ?? client, timeoutMs) : null;
2596
+
2597
+ const executorChain: IToolExecutor[] = [localExec];
2598
+ if (sdkExec) executorChain.push(sdkExec);
2599
+ if (mcpExec) executorChain.push(mcpExec);
2600
+
2601
+ const toolsByName = new Map<string, any>();
2602
+ const skillLoader = new SkillLoader();
2603
+ let skillResolver: SkillResolver | null = null;
2604
+
2605
+ const router = legacyProxyToolPathsEnabled
2606
+ ? new ToolRouter({
2607
+ execute: (toolId, args) => executeWithChain(executorChain, toolId, args),
2608
+ toolsByName,
2609
+ resolveName: (name) => skillResolver?.resolve(name),
2610
+ })
2611
+ : null;
2612
+ let lastToolNames: string[] = [];
2613
+ let lastToolMap: Array<{ id: string; name: string }> = [];
2614
+
2615
+ async function refreshTools() {
2616
+ toolsByName.clear();
2617
+
2618
+ const toolEntries: any[] = [];
2619
+ const add = (name: string, t: any) => {
2620
+ if (!toolsByName.has(name)) {
2621
+ toolsByName.set(name, t);
2622
+ }
2623
+ toolEntries.push({
2624
+ type: "function" as const,
2625
+ function: {
2626
+ name,
2627
+ description: `${describeTool(t)} (skill id: ${t.id})`,
2628
+ parameters: toOpenAiParameters(t.parameters),
2629
+ },
2630
+ });
2631
+ };
2632
+
2633
+ // Always include local tools — these work regardless of SDK connectivity
2634
+ const localTools = localRegistry.list().map((t) => ({ ...t, name: `oc_${t.id}` }));
2635
+ for (const asTool of localTools) {
2636
+ const nsName = asTool.name;
2637
+ add(nsName, asTool);
2638
+ }
2639
+
2640
+ // Layer SDK/MCP-discovered tools on top (best-effort)
2641
+ let discoveredList: any[] = [];
2642
+ if (discovery) {
2643
+ try {
2644
+ discoveredList = await discovery.listTools();
2645
+ discoveredList.forEach((t) => toolsByName.set(t.name, t));
2646
+ } catch (err) {
2647
+ log.debug("Tool discovery failed, using local tools only", { error: String(err) });
2648
+ }
2649
+ }
2650
+
2651
+ // Load skills and initialize resolver for alias resolution
2652
+ const allTools = [...localTools, ...discoveredList];
2653
+ const skills = skillLoader.load(allTools);
2654
+ skillResolver = new SkillResolver(skills);
2655
+
2656
+ // Populate executors with their respective tool IDs
2657
+ if (sdkExec) {
2658
+ sdkExec.setToolIds(discoveredList.filter((t) => t.source === "sdk").map((t) => t.id));
2659
+ }
2660
+ if (mcpExec) {
2661
+ mcpExec.setToolIds(discoveredList.filter((t) => t.source === "mcp").map((t) => t.id));
2662
+ }
2663
+
2664
+ for (const t of discoveredList) {
2665
+ add(t.name, t);
2666
+
2667
+ if (t.name === "bash" && !toolsByName.has("shell")) {
2668
+ add("shell", t);
2669
+ }
2670
+
2671
+ const baseId = t.id.replace(/[^a-zA-Z0-9_\\-]/g, "_");
2672
+ const skillAlias = `oc_skill_${baseId}`.slice(0, 64);
2673
+ if (!toolsByName.has(skillAlias)) add(skillAlias, t);
2674
+ const superAlias = `oc_superskill_${baseId}`.slice(0, 64);
2675
+ if (!toolsByName.has(superAlias)) add(superAlias, t);
2676
+ const spAlias = `oc_superpowers_${baseId}`.slice(0, 64);
2677
+ if (!toolsByName.has(spAlias)) add(spAlias, t);
2678
+ }
2679
+
2680
+ lastToolNames = toolEntries.map((e) => e.function.name);
2681
+ lastToolMap = allTools.map((t) => ({ id: t.id, name: t.name }));
2682
+ log.debug("Tools refreshed", { local: localTools.length, discovered: discoveredList.length, total: toolEntries.length });
2683
+ return toolEntries;
2684
+ }
2685
+
2686
+ const proxyBaseURL = await ensureCursorProxyServer(workspaceDirectory, router);
2687
+ log.debug("Proxy server started", { baseURL: proxyBaseURL });
2688
+
2689
+ // In native OpenCode mode, let OpenCode own its built-in tools so desktop
2690
+ // renderers keep seeing edit/write/apply_patch instead of plugin aliases.
2691
+ const toolHookEntries = legacyProxyToolPathsEnabled
2692
+ ? buildToolHookEntries(localRegistry, workspaceDirectory)
2693
+ : {};
2694
+
2695
+ return {
2696
+ tool: { ...toolHookEntries, ...mcpToolEntries },
2697
+ auth: {
2698
+ provider: CURSOR_PROVIDER_ID,
2699
+ async loader(getAuth: () => Promise<Auth>) {
2700
+ // Load API key from OpenCode auth store and cache it.
2701
+ // Never throw: a missing/unreadable auth entry must not break plugin load.
2702
+ try {
2703
+ const auth = await getAuth();
2704
+ if (auth?.type === "api" && auth.key) {
2705
+ storedApiKey = auth.key;
2706
+ log.debug("Stored API key from auth loader");
2707
+ }
2708
+ } catch (err) {
2709
+ log.debug("No stored auth available", { error: String(err) });
2710
+ }
2711
+ return {};
2712
+ },
2713
+ methods: [
2714
+ {
2715
+ type: "api" as const,
2716
+ label: "Cursor API Key (cursor.com/settings)",
2717
+ },
2718
+ ],
2719
+ },
2720
+
2721
+ async "chat.params"(input: any, output: any) {
2722
+ const boundaryContext = createBoundaryRuntimeContext("chat.params");
2723
+
2724
+ const providerMatch = boundaryContext.run("matchesProvider", (boundary) =>
2725
+ boundary.matchesProvider(input.model),
2726
+ );
2727
+ if (!providerMatch) {
2728
+ return;
2729
+ }
2730
+
2731
+ boundaryContext.run("applyChatParamDefaults", (boundary) =>
2732
+ boundary.applyChatParamDefaults(
2733
+ output,
2734
+ proxyBaseURL,
2735
+ CURSOR_PROXY_DEFAULT_BASE_URL,
2736
+ "cursor-agent",
2737
+ ),
2738
+ );
2739
+
2740
+ // Tool definitions handling:
2741
+ // - proxy-exec mode: provider injects tool definitions directly.
2742
+ // - opencode mode: preserve OpenCode-provided tools and do not advertise
2743
+ // local aliases like oc_edit/oc_write over native edit/apply_patch.
2744
+ if (toolsEnabled) {
2745
+ try {
2746
+ const existingTools = output.options.tools;
2747
+ const shouldRefresh =
2748
+ TOOL_LOOP_MODE === "proxy-exec";
2749
+ const refreshedTools = shouldRefresh ? await refreshTools() : [];
2750
+ const resolved = boundaryContext.run("resolveChatParamTools", (boundary) =>
2751
+ boundary.resolveChatParamTools(TOOL_LOOP_MODE, existingTools, refreshedTools),
2752
+ );
2753
+
2754
+ if (resolved.action === "override") {
2755
+ output.options.tools = resolved.tools;
2756
+ } else if (resolved.action === "preserve") {
2757
+ const count = Array.isArray(existingTools) ? existingTools.length : 0;
2758
+ log.debug("Using OpenCode-provided tools from chat.params", { count });
2759
+ }
2760
+ } catch (err) {
2761
+ log.debug("Failed to refresh tools", { error: String(err) });
2762
+ }
2763
+ }
2764
+
2765
+ // Append MCP bridge tool definitions so the model can call them
2766
+ if (mcpToolDefs.length > 0) {
2767
+ const beforeTools = Array.isArray(output.options.tools) ? output.options.tools : [];
2768
+ if (Array.isArray(output.options.tools)) {
2769
+ output.options.tools = [...output.options.tools, ...mcpToolDefs];
2770
+ } else {
2771
+ output.options.tools = mcpToolDefs;
2772
+ }
2773
+ const afterTools = Array.isArray(output.options.tools) ? output.options.tools : [];
2774
+ log.debug("Injected MCP tool definitions into chat.params", {
2775
+ injectedCount: mcpToolDefs.length,
2776
+ beforeCount: beforeTools.length,
2777
+ afterCount: afterTools.length,
2778
+ mcpNames: mcpToolDefs.slice(0, 10).map((t: any) => t?.function?.name ?? t?.name ?? "unknown"),
2779
+ tailNames: afterTools.slice(-10).map((t: any) => t?.function?.name ?? t?.name ?? "unknown"),
2780
+ });
2781
+ }
2782
+ },
2783
+
2784
+ async "experimental.chat.system.transform"(input: any, output: { system: string[] }) {
2785
+ if (!toolsEnabled) return;
2786
+ if (TOOL_LOOP_MODE !== "proxy-exec") return;
2787
+ const subagentNames = readSubagentNames();
2788
+ const systemMessage = buildAvailableToolsSystemMessage(
2789
+ lastToolNames,
2790
+ lastToolMap,
2791
+ mcpToolDefs,
2792
+ mcpToolSummaries,
2793
+ subagentNames,
2794
+ );
2795
+ if (!systemMessage) return;
2796
+ output.system = output.system || [];
2797
+ output.system.push(systemMessage);
2798
+ },
2799
+ };
2800
+ };
2801
+
2802
+ export default CursorPlugin;