cursor-opencode-provider 0.2.7 → 0.2.8

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.
@@ -6,11 +6,11 @@ import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
8
  import { decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, } from "./protocol/tools.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, resolveCustomWebToolAlias, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
13
- import { handleInteractionQuery, inspectInteractionQueryWire } from "./protocol/interactions.js";
13
+ import { handleInteractionQuery } from "./protocol/interactions.js";
14
14
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
15
15
  import { conversationBlobCount } from "./protocol/blob-store.js";
16
16
  import { bindConversationId, } from "./protocol/conversation-bind.js";
@@ -23,6 +23,8 @@ import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths
23
23
  import { resolveAgentUrl } from "./agent-url.js";
24
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
25
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
26
+ import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
27
+ import { readAllFieldsStrict } from "./protocol/struct.js";
26
28
  let _availableModels;
27
29
  // mtime of the cache file the last time we loaded it. Compared on each call
28
30
  // so discoverModels' background refresh is picked up without a process restart.
@@ -44,6 +46,28 @@ const DEFAULT_RETRY_POLICY = {
44
46
  };
45
47
  const MAX_RETRY_ATTEMPTS = 10;
46
48
  const MAX_RETRY_DELAY_MS = 30_000;
49
+ const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
50
+ const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
51
+ const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
52
+ const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
53
+ [2, "exec"],
54
+ [4, "kv"],
55
+ [7, "interaction"],
56
+ ]);
57
+ function responseRequiredChannel(payload) {
58
+ const fields = readAllFieldsStrict(payload);
59
+ if (fields) {
60
+ const channels = fields
61
+ .map((field) => RESPONSE_REQUIRED_CHANNEL_BY_FIELD.get(field.fn))
62
+ .filter((channel) => !!channel);
63
+ if (channels.length > 1)
64
+ return "multiple";
65
+ return channels[0];
66
+ }
67
+ // Request tags are single-byte because all must-reply top-level fields are <16.
68
+ const tag = payload[0];
69
+ return tag !== undefined ? RESPONSE_REQUIRED_CHANNEL_BY_FIELD.get(tag >> 3) : undefined;
70
+ }
47
71
  function retryInteger(name, value, fallback) {
48
72
  const resolved = value === undefined ? fallback : value;
49
73
  if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved <= 0) {
@@ -430,6 +454,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
430
454
  isCompaction,
431
455
  });
432
456
  const tools = toolState.advertisedTools;
457
+ const webToolAliases = buildCustomWebToolAliases(tools);
458
+ const cursorTools = webToolAliases.advertisedTools;
459
+ for (const [alias, candidates] of webToolAliases.ambiguous) {
460
+ trace(`web tool alias skipped: alias=${alias} ambiguous=[${candidates.join(",")}]`);
461
+ }
462
+ for (const [alias, original] of webToolAliases.aliases) {
463
+ trace(`web tool alias: ${alias} -> ${original}`);
464
+ }
433
465
  const allowTools = toolState.allowTools;
434
466
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
435
467
  const recovery = startOptions?.recovery;
@@ -451,7 +483,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
451
483
  : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
452
484
  const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
453
485
  const baseSystemPrompt = extractSystemPrompt(prompt);
454
- const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot);
486
+ const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
455
487
  const systemPrompt = interactionGuidance
456
488
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
457
489
  : baseSystemPrompt;
@@ -493,7 +525,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
493
525
  baseURL: agentBaseUrl,
494
526
  headers: options.headers,
495
527
  });
496
- const requestContext = await buildRequestContext({ workspaceRoot, tools });
528
+ const requestContext = await buildRequestContext({ workspaceRoot, tools: cursorTools });
497
529
  // Resolve descriptors once from the merged OpenCode config so MCP identity is
498
530
  // consistent across AgentRunRequest and both request_context reply paths.
499
531
  const toolDescriptors = Array.isArray(requestContext.tools)
@@ -513,7 +545,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
513
545
  conversationState,
514
546
  parameterValues,
515
547
  maxMode,
516
- tools,
548
+ tools: cursorTools,
517
549
  toolDescriptors,
518
550
  requestContext,
519
551
  action: resuming ? "resume" : "user",
@@ -570,6 +602,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
570
602
  nextBridgedExecId: 900_000,
571
603
  blobs: new Map(),
572
604
  toolDescriptors,
605
+ toolAliases: webToolAliases.aliases,
573
606
  requestContext,
574
607
  allowTools,
575
608
  usageEstimate,
@@ -814,11 +847,17 @@ export async function pump(session, controller, ids, abortSignal) {
814
847
  const { textId, reasoningId } = ids;
815
848
  const promptTokens = ids.promptTokens ?? 0;
816
849
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
817
- const advertisedToolNameSet = new Set(advertisedToolNames);
850
+ const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
818
851
  let textStarted = false;
819
852
  let reasoningStarted = false;
820
853
  const requestUsage = ids.requestUsage ?? { outputChars: 0 };
821
- let replaySafe = true;
854
+ const replaySafety = new AttemptReplaySafety(session.sessionId);
855
+ const failRunProtocol = (message, code) => {
856
+ replaySafety.markBarrier("unknown-or-malformed-frame");
857
+ const error = new CursorProtocolError(message, { code });
858
+ sessionManager.close(session, "remote-error", error);
859
+ throw error;
860
+ };
822
861
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
823
862
  // in doStreamImpl). The frames iterator can still yield a final `done` after
824
863
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -933,7 +972,7 @@ export async function pump(session, controller, ids, abortSignal) {
933
972
  const emitText = (text) => {
934
973
  if (!text)
935
974
  return;
936
- replaySafe = false;
975
+ replaySafety.markBarrier("visible-text");
937
976
  // Close reasoning before text (hosts expect reasoning-end before text-start).
938
977
  if (reasoningStarted && !textStarted) {
939
978
  safeEnqueue({ type: "reasoning-end", id: reasoningId });
@@ -951,7 +990,7 @@ export async function pump(session, controller, ids, abortSignal) {
951
990
  const emitReasoning = (text) => {
952
991
  if (!text)
953
992
  return;
954
- replaySafe = false;
993
+ replaySafety.markBarrier("visible-reasoning");
955
994
  if (!reasoningStarted) {
956
995
  safeEnqueue({ type: "reasoning-start", id: reasoningId });
957
996
  reasoningStarted = true;
@@ -1029,15 +1068,13 @@ export async function pump(session, controller, ids, abortSignal) {
1029
1068
  const failure = error instanceof CursorProviderError
1030
1069
  ? error
1031
1070
  : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
1032
- failure.replaySafe = replaySafe && failure.replaySafe;
1033
- throw failure;
1071
+ throw replaySafety.applyTo(failure);
1034
1072
  }
1035
1073
  if (next.done) {
1036
1074
  closeOpenSpans();
1037
1075
  trace("pump: frames iterator ended before turn_ended");
1038
1076
  const failure = new CursorRunInterruptedError();
1039
- failure.replaySafe = replaySafe;
1040
- throw failure;
1077
+ throw replaySafety.applyTo(failure);
1041
1078
  }
1042
1079
  const frame = next.value;
1043
1080
  if (frame.flags & 0x02) {
@@ -1049,14 +1086,15 @@ export async function pump(session, controller, ids, abortSignal) {
1049
1086
  try {
1050
1087
  payload = new TextDecoder().decode(decodeFramePayload(frame));
1051
1088
  }
1052
- catch { /* not decodable */ }
1089
+ catch {
1090
+ replaySafety.markBarrier("unknown-or-malformed-frame");
1091
+ }
1053
1092
  }
1054
1093
  closeOpenSpans();
1055
1094
  const failure = payload
1056
1095
  ? connectFrameError(payload)
1057
1096
  : new CursorRunInterruptedError();
1058
- failure.replaySafe = replaySafe && failure.replaySafe;
1059
- throw failure;
1097
+ throw replaySafety.applyTo(failure);
1060
1098
  }
1061
1099
  // decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
1062
1100
  // Skip the frame rather than abort the whole turn.
@@ -1065,6 +1103,7 @@ export async function pump(session, controller, ids, abortSignal) {
1065
1103
  payload = decodeFramePayload(frame);
1066
1104
  }
1067
1105
  catch (e) {
1106
+ replaySafety.markBarrier("unknown-or-malformed-frame");
1068
1107
  trace(`gunzip FAILED (skipping frame): flags=0x${frame.flags.toString(16)} len=${frame.payload.length} err=${e.message}`);
1069
1108
  continue;
1070
1109
  }
@@ -1072,39 +1111,46 @@ export async function pump(session, controller, ids, abortSignal) {
1072
1111
  try {
1073
1112
  asm = decodeMessage("AgentServerMessage", payload);
1074
1113
  }
1075
- catch (e) {
1114
+ catch {
1076
1115
  // A single malformed/truncated frame must not abort the whole turn
1077
1116
  // (protobufjs throws "index out of range: …" on length overruns). Log it
1078
1117
  // and keep pumping.
1079
- replaySafe = false;
1080
- const preview = Array.from(payload.subarray(0, 32))
1081
- .map((x) => x.toString(16).padStart(2, "0"))
1082
- .join("");
1083
- trace(`decode FAILED (skipping): flags=0x${frame.flags.toString(16)} len=${payload.length} ` +
1084
- `topField=${payload.length ? payload[0] >> 3 : "-"} err=${e.message} hex=${preview}`);
1118
+ replaySafety.markBarrier("unknown-or-malformed-frame");
1119
+ const channel = responseRequiredChannel(payload);
1120
+ if (channel) {
1121
+ failRunProtocol(`Cursor ${channel} request could not be decoded`, RUN_REQUEST_DECODE_FAILED);
1122
+ }
1123
+ trace(`decode FAILED (skipping non-request frame): flags=0x${frame.flags.toString(16)} len=${payload.length}`);
1085
1124
  continue;
1086
1125
  }
1087
1126
  const iu = asm.interaction_update;
1088
1127
  const esm = asm.exec_server_message;
1089
1128
  const kv = asm.kv_server_message;
1129
+ const execControl = asm.exec_server_control_message;
1090
1130
  const interactionQuery = asm.interaction_query;
1091
1131
  const checkpointRaw = asm.conversation_checkpoint_update;
1092
1132
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
1093
- const textProgress = iu?.text_delta?.text;
1094
- const thinkingProgress = iu?.thinking_delta?.text;
1095
1133
  const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
1096
- if ((typeof textProgress === "string" && textProgress.length > 0) ||
1097
- (typeof thinkingProgress === "string" && thinkingProgress.length > 0) ||
1098
- !!iu?.turn_ended ||
1099
- !!iu?.tool_call_started ||
1100
- !!iu?.tool_call_completed ||
1101
- !!esm ||
1102
- !!kv ||
1103
- !!interactionQuery ||
1104
- !!checkpointProgress?.length) {
1105
- replaySafe = false;
1134
+ const requiredChannel = responseRequiredChannel(payload);
1135
+ if (requiredChannel === "multiple" ||
1136
+ (requiredChannel === "exec" && !esm) ||
1137
+ (requiredChannel === "kv" && !kv) ||
1138
+ (requiredChannel === "interaction" && !interactionQuery)) {
1139
+ failRunProtocol("Cursor response-requiring request could not be decoded", RUN_REQUEST_DECODE_FAILED);
1140
+ }
1141
+ const replayFrame = analyzeReplayFrame(payload, {
1142
+ interactionUpdate: iu,
1143
+ exec: esm,
1144
+ kv,
1145
+ execControl,
1146
+ interactionQuery,
1147
+ checkpointBytes: checkpointProgress,
1148
+ });
1149
+ if (replayFrame.semanticProgress) {
1106
1150
  sessionManager.recordSemanticProgress(session);
1107
1151
  }
1152
+ if (replayFrame.barrier)
1153
+ replaySafety.markBarrier(replayFrame.barrier);
1108
1154
  {
1109
1155
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
1110
1156
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
@@ -1229,12 +1275,8 @@ export async function pump(session, controller, ids, abortSignal) {
1229
1275
  session.stream.write(buildRequestContextResult(esmId, session.requestContext));
1230
1276
  trace(`exec request_context: replied`);
1231
1277
  }
1232
- catch (e) {
1233
- const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
1234
- trace(`exec request_context: write FAILED ${error.message}`);
1235
- safeError(error);
1236
- sessionManager.close(session);
1237
- return;
1278
+ catch {
1279
+ failRunProtocol("Cursor request-context reply failed", RUN_REPLY_FAILED);
1238
1280
  }
1239
1281
  }
1240
1282
  else if (esm.mcp_state_exec_args) {
@@ -1249,16 +1291,20 @@ export async function pump(session, controller, ids, abortSignal) {
1249
1291
  session.stream.write(buildMcpStateResult(esmId, stateArgs, session.requestContext));
1250
1292
  trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
1251
1293
  }
1252
- catch (e) {
1253
- const error = new Error(`Failed to answer Cursor MCP state probe: ${e.message}`);
1254
- trace(`exec mcp_state: write FAILED ${error.message}`);
1255
- safeError(error);
1256
- sessionManager.close(session);
1257
- return;
1294
+ catch {
1295
+ failRunProtocol("Cursor MCP-state reply failed", RUN_REPLY_FAILED);
1258
1296
  }
1259
1297
  }
1260
1298
  else {
1299
+ replaySafety.markBarrier("non-control-exec");
1261
1300
  const parsed = parseExecServerMessage(esm);
1301
+ if (parsed) {
1302
+ const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
1303
+ if (executableToolName !== parsed.toolName) {
1304
+ trace(`web tool alias resolved: ${parsed.toolName} -> ${executableToolName}`);
1305
+ parsed.toolName = executableToolName;
1306
+ }
1307
+ }
1262
1308
  const displayCallId = extractExecDisplayCallId(esm);
1263
1309
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
1264
1310
  if (parsed) {
@@ -1332,10 +1378,7 @@ export async function pump(session, controller, ids, abortSignal) {
1332
1378
  .map((x) => x.toString(16).padStart(2, "0"))
1333
1379
  .join("");
1334
1380
  trace(`exec UNMAPPED: id=${esmId} variant=${variantDescription} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
1335
- const err = new Error(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`);
1336
- safeError(err);
1337
- sessionManager.close(session);
1338
- return;
1381
+ failRunProtocol(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`, RUN_REQUEST_UNSUPPORTED);
1339
1382
  }
1340
1383
  }
1341
1384
  else if (interactionQuery) {
@@ -1343,20 +1386,24 @@ export async function pump(session, controller, ids, abortSignal) {
1343
1386
  // SDK has no Cursor-specific UI callback, so answer immediately with the
1344
1387
  // conservative headless policy from protocol/interactions.ts (including
1345
1388
  // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
1389
+ const handled = (() => {
1390
+ try {
1391
+ return handleInteractionQuery(interactionQuery, payload);
1392
+ }
1393
+ catch {
1394
+ return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
1395
+ }
1396
+ })();
1346
1397
  try {
1347
- const handled = handleInteractionQuery(interactionQuery, payload);
1398
+ if (handled.outcome === "acknowledged") {
1399
+ replaySafety.markBarrier("stateful-interaction");
1400
+ }
1348
1401
  session.stream.write(handled.reply);
1349
1402
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
1350
1403
  `field=${handled.variantField} outcome=${handled.outcome}`);
1351
1404
  }
1352
- catch (e) {
1353
- const info = inspectInteractionQueryWire(payload);
1354
- const err = e instanceof Error ? e : new Error(String(e));
1355
- trace(`interaction_query: FAILED id=${info.id ?? "?"} ` +
1356
- `variantField=${info.variantField ?? "?"} err=${err.message}`);
1357
- safeError(err);
1358
- sessionManager.close(session);
1359
- return;
1405
+ catch {
1406
+ failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
1360
1407
  }
1361
1408
  }
1362
1409
  else if (kv) {
@@ -1375,20 +1422,24 @@ export async function pump(session, controller, ids, abortSignal) {
1375
1422
  `found=${handled.found} echoed=${!!handled.echoed} ` +
1376
1423
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
1377
1424
  }
1378
- catch (e) {
1379
- const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
1380
- trace(`kv: write FAILED ${error.message}`);
1381
- safeError(error);
1382
- sessionManager.close(session);
1383
- return;
1425
+ catch {
1426
+ failRunProtocol("Cursor KV reply failed", RUN_REPLY_FAILED);
1384
1427
  }
1385
1428
  }
1429
+ else {
1430
+ failRunProtocol("Cursor KV request could not be handled", RUN_REQUEST_UNSUPPORTED);
1431
+ }
1386
1432
  }
1387
1433
  }
1388
1434
  catch (e) {
1435
+ if (e instanceof CursorProtocolError)
1436
+ throw e;
1437
+ if (requiredChannel) {
1438
+ failRunProtocol(`Cursor ${requiredChannel} request could not be handled`, RUN_REQUEST_UNSUPPORTED);
1439
+ }
1389
1440
  // Any per-frame dispatch throw (e.g. protobufjs length overrun in
1390
1441
  // exec/args decode) must not abort the whole turn — log and skip.
1391
- trace(`frame dispatch FAILED (skipping): topField=${topField} err=${e.message}`);
1442
+ trace(`frame dispatch FAILED (skipping): topField=${topField}`);
1392
1443
  }
1393
1444
  // heartbeat / step / partial_tool_call → ignore (partial args are
1394
1445
  // display-only; the exec channel is authoritative. Checkpoints and
@@ -1511,8 +1562,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1511
1562
  if (names.has("plan_exit")) {
1512
1563
  instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
1513
1564
  }
1514
- if (names.has("webfetch")) {
1515
- instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1565
+ if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
1566
+ instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
1567
+ }
1568
+ if (names.has(CUSTOM_WEBFETCH_TOOL)) {
1569
+ instructions.push(`- To fetch a known URL, call \`${CUSTOM_WEBFETCH_TOOL}\`; do not use Cursor's native WebFetch interaction.`);
1516
1570
  }
1517
1571
  if (names.has("write")) {
1518
1572
  instructions.push(names.has("edit")
package/dist/models.d.ts CHANGED
@@ -73,9 +73,11 @@ export declare function resolveVariantParameters(model: ModelInfo | undefined, o
73
73
  export declare function fetchModels(token: string, options?: {
74
74
  baseURL?: string;
75
75
  headers?: Record<string, string>;
76
+ timeoutMs?: number;
76
77
  }): Promise<ModelInfo[]>;
77
78
  export declare function refreshModelCache(cacheDir: string, fetcher: () => Promise<ModelInfo[]>): Promise<ModelInfo[]>;
78
79
  export declare function discoverModels(token: string, cacheDir: string, options?: {
79
80
  baseURL?: string;
80
81
  headers?: Record<string, string>;
82
+ timeoutMs?: number;
81
83
  }): Promise<ModelInfo[]>;
package/dist/plugin.js CHANGED
@@ -6,6 +6,7 @@ import { readStoredAuth } from "./context/auth-store.js";
6
6
  import { resolveAgentUrl } from "./agent-url.js";
7
7
  import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
8
8
  import { sessionActivity } from "./activity.js";
9
+ import { openCodeWebSearchTool } from "./web-tools.js";
9
10
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
10
11
  /**
11
12
  * Strip characters and markup that break rendering in the OpenCode TUI/GUI from
@@ -194,7 +195,7 @@ function cursorGetServerConfigTelemetryEnabled() {
194
195
  process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
195
196
  }
196
197
  export async function CursorPlugin(input) {
197
- // Prefer OCP HostProfile.cacheDir when the compat package is present; else XDG heuristic.
198
+ // Prefer strong OCP host identity when available; otherwise resolve from explicit host signals/install path.
198
199
  await adoptCompatHostCacheDir();
199
200
  const cacheDir = opencodeGlobalCacheDir();
200
201
  const apiBaseURL = cursorApiBaseURL();
@@ -315,6 +316,12 @@ export async function CursorPlugin(input) {
315
316
  return cached?.models.length ? modelsToConfig(cached.models) : {};
316
317
  }
317
318
  return {
319
+ tool: {
320
+ // `websearch` is a reserved OpenCode id and is filtered for third-party
321
+ // providers after plugin tools are merged. Use the collision-safe id
322
+ // Cursor already sees so this host-side fallback survives that filter.
323
+ custom_websearch: openCodeWebSearchTool,
324
+ },
318
325
  async event({ event }) {
319
326
  switch (event.type) {
320
327
  case "session.created":
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { FALLBACK_CLIENT_VERSION, MODEL_CACHE_TTL_MS, VERSION_CACHE_FILE, } from "../shared.js";
4
4
  import { opencodeGlobalCacheDir } from "../context/paths.js";
5
+ import { withAbortDeadline } from "../deadline.js";
5
6
  const INSTALL_URL = "https://cursor.com/install";
6
7
  const REMOTE_TIMEOUT_MS = 5_000;
7
8
  const BUILD_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z][0-9A-Za-z.-]*$/;
@@ -103,13 +104,13 @@ function isCacheFresh(cache, now = Date.now()) {
103
104
  return age >= 0 && age < MODEL_CACHE_TTL_MS;
104
105
  }
105
106
  async function fetchInstallerVersion() {
106
- const response = await fetch(INSTALL_URL, {
107
- signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
107
+ return withAbortDeadline(REMOTE_TIMEOUT_MS, () => new Error("Cursor installer version request timed out"), async (signal) => {
108
+ const response = await fetch(INSTALL_URL, { signal });
109
+ if (!response.ok)
110
+ return undefined;
111
+ const build = extractVersionFromInstaller(await response.text());
112
+ return build ? `cli-${build}` : undefined;
108
113
  });
109
- if (!response.ok)
110
- return undefined;
111
- const build = extractVersionFromInstaller(await response.text());
112
- return build ? `cli-${build}` : undefined;
113
114
  }
114
115
  async function refreshVersionCache() {
115
116
  const version = await fetchInstallerVersion();
@@ -724,8 +724,10 @@ export function createMessageTypes() {
724
724
  { id: 7, name: "tools", type: "McpToolDefinition", repeated: true },
725
725
  { id: 11, name: "git_repos", type: "GitRepoInfo", repeated: true },
726
726
  { id: 13, name: "project_layouts", type: "LsDirectoryTreeNode", repeated: true },
727
+ { id: 17, name: "web_search_enabled", type: "bool" },
727
728
  { id: 22, name: "custom_subagents", type: "CustomSubagent", repeated: true },
728
729
  { id: 23, name: "mcp_file_system_options", type: "McpFileSystemOptions" },
730
+ { id: 24, name: "web_fetch_enabled", type: "bool" },
729
731
  { id: 25, name: "hooks_additional_context", type: "string" },
730
732
  { id: 29, name: "agent_skills", type: "AgentSkill", repeated: true },
731
733
  { id: 33, name: "git_repo_info_complete", type: "bool" },
@@ -883,8 +885,10 @@ export function createMessageTypes() {
883
885
  { id: 7, name: "tools", type: "McpToolDefinition", repeated: true },
884
886
  { id: 11, name: "git_repos", type: "GitRepoInfo", repeated: true },
885
887
  { id: 13, name: "project_layouts", type: "LsDirectoryTreeNode", repeated: true },
888
+ { id: 17, name: "web_search_enabled", type: "bool" },
886
889
  { id: 22, name: "custom_subagents", type: "CustomSubagent", repeated: true },
887
890
  { id: 23, name: "mcp_file_system_options", type: "McpFileSystemOptions" },
891
+ { id: 24, name: "web_fetch_enabled", type: "bool" },
888
892
  { id: 25, name: "hooks_additional_context", type: "string" },
889
893
  { id: 29, name: "agent_skills", type: "AgentSkill", repeated: true },
890
894
  { id: 33, name: "git_repo_info_complete", type: "bool" },
@@ -7,6 +7,24 @@ export type RawField = {
7
7
  };
8
8
  /** Walk a protobuf message's top-level fields off the raw wire bytes. */
9
9
  export declare function readAllFields(b: Uint8Array): RawField[];
10
+ export type StrictRawField = {
11
+ fn: number;
12
+ wt: number;
13
+ varint?: bigint;
14
+ bytes?: Uint8Array;
15
+ fixed64?: Uint8Array;
16
+ fixed32?: Uint8Array;
17
+ };
18
+ /**
19
+ * Strictly walk a protobuf message for security/replay decisions.
20
+ *
21
+ * Unlike `readAllFields`, this parser consumes the complete input, preserves
22
+ * uint64 varints as bigint, represents fixed-width fields, and rejects invalid
23
+ * tags, truncation, overflow, groups, and unsupported wire types. It deliberately
24
+ * returns `undefined` instead of a partial result: callers must fail closed when
25
+ * deciding whether replay is safe.
26
+ */
27
+ export declare function readAllFieldsStrict(bytes: Uint8Array): StrictRawField[] | undefined;
10
28
  /** Decode a google.protobuf.Value message (bytes) back into a JSON value. */
11
29
  export declare function decodeValueToJson(bytes: Uint8Array): unknown;
12
30
  /**
@@ -82,6 +82,88 @@ export function readAllFields(b) {
82
82
  }
83
83
  return out;
84
84
  }
85
+ const MAX_UINT32 = 0xffffffffn;
86
+ const MAX_UINT64 = 0xffffffffffffffffn;
87
+ const MAX_PROTOBUF_FIELD_NUMBER = 0x1fff_ffff;
88
+ /**
89
+ * Decode one unsigned protobuf varint without JavaScript's 32-bit bitwise
90
+ * truncation. `maximum` also enforces the protocol width: tags and lengths are
91
+ * uint32, while wire-type 0 scalar values may use the full uint64 range.
92
+ */
93
+ function readBoundedVarint(bytes, offset, maximum) {
94
+ let value = 0n;
95
+ let shift = 0n;
96
+ for (let index = offset; index < bytes.length && index < offset + 10; index++) {
97
+ const byte = bytes[index];
98
+ value |= BigInt(byte & 0x7f) << shift;
99
+ if (value > maximum)
100
+ return undefined;
101
+ if ((byte & 0x80) === 0)
102
+ return { value, nextOffset: index + 1 };
103
+ shift += 7n;
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Strictly walk a protobuf message for security/replay decisions.
109
+ *
110
+ * Unlike `readAllFields`, this parser consumes the complete input, preserves
111
+ * uint64 varints as bigint, represents fixed-width fields, and rejects invalid
112
+ * tags, truncation, overflow, groups, and unsupported wire types. It deliberately
113
+ * returns `undefined` instead of a partial result: callers must fail closed when
114
+ * deciding whether replay is safe.
115
+ */
116
+ export function readAllFieldsStrict(bytes) {
117
+ let offset = 0;
118
+ const fields = [];
119
+ while (offset < bytes.length) {
120
+ const tag = readBoundedVarint(bytes, offset, MAX_UINT32);
121
+ if (!tag)
122
+ return undefined;
123
+ offset = tag.nextOffset;
124
+ const fieldNumber = Number(tag.value >> 3n);
125
+ const wireType = Number(tag.value & 7n);
126
+ if (fieldNumber === 0 || fieldNumber > MAX_PROTOBUF_FIELD_NUMBER)
127
+ return undefined;
128
+ if (wireType === 0) {
129
+ const scalar = readBoundedVarint(bytes, offset, MAX_UINT64);
130
+ if (!scalar)
131
+ return undefined;
132
+ fields.push({ fn: fieldNumber, wt: wireType, varint: scalar.value });
133
+ offset = scalar.nextOffset;
134
+ continue;
135
+ }
136
+ if (wireType === 1) {
137
+ if (offset + 8 > bytes.length)
138
+ return undefined;
139
+ fields.push({ fn: fieldNumber, wt: wireType, fixed64: bytes.subarray(offset, offset + 8) });
140
+ offset += 8;
141
+ continue;
142
+ }
143
+ if (wireType === 2) {
144
+ const encodedLength = readBoundedVarint(bytes, offset, MAX_UINT32);
145
+ if (!encodedLength)
146
+ return undefined;
147
+ offset = encodedLength.nextOffset;
148
+ const length = Number(encodedLength.value);
149
+ if (offset + length > bytes.length)
150
+ return undefined;
151
+ fields.push({ fn: fieldNumber, wt: wireType, bytes: bytes.subarray(offset, offset + length) });
152
+ offset += length;
153
+ continue;
154
+ }
155
+ if (wireType === 5) {
156
+ if (offset + 4 > bytes.length)
157
+ return undefined;
158
+ fields.push({ fn: fieldNumber, wt: wireType, fixed32: bytes.subarray(offset, offset + 4) });
159
+ offset += 4;
160
+ continue;
161
+ }
162
+ // Groups (3/4) are deprecated and recursive; no replay-safe frame uses them.
163
+ return undefined;
164
+ }
165
+ return fields;
166
+ }
85
167
  function decodeStructBytes(b) {
86
168
  const obj = {};
87
169
  for (const f of readAllFields(b)) {
@@ -4,6 +4,17 @@ export type OpencodeToolDef = {
4
4
  name: string;
5
5
  description?: string;
6
6
  inputSchema?: unknown;
7
+ /** Original flattened identity when `name` is a Cursor-facing alias. */
8
+ sourceName?: string;
9
+ };
10
+ export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
11
+ export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
12
+ /** Cursor-facing alias → exact host tool name accepted by the AI SDK call. */
13
+ export type ToolAliasRegistry = ReadonlyMap<string, string>;
14
+ export type AliasedToolCatalog = {
15
+ advertisedTools: OpencodeToolDef[];
16
+ aliases: ToolAliasRegistry;
17
+ ambiguous: ReadonlyMap<string, string[]>;
7
18
  };
8
19
  export type ToolServerIdentity = {
9
20
  /** Cursor MCP server id / provider_identifier (e.g. opencode, github). */
@@ -32,6 +43,13 @@ export declare function resolveToolServerIdentity(opencodeName: string, defaultS
32
43
  * reconstructed in `mcpRealToolName`.
33
44
  */
34
45
  export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
46
+ /**
47
+ * Give collision-prone web capabilities names Cursor will not confuse with its
48
+ * native UI-bound WebSearch/WebFetch interactions. Exact host tools win; a
49
+ * flattened MCP suffix is accepted only when it identifies one unique tool.
50
+ */
51
+ export declare function buildCustomWebToolAliases(tools: OpencodeToolDef[]): AliasedToolCatalog;
52
+ export declare function resolveCustomWebToolAlias(toolName: string, aliases: ToolAliasRegistry | undefined): string;
35
53
  /**
36
54
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
37
55
  * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins