cursor-opencode-provider 0.7.1 → 0.7.3

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 (50) hide show
  1. package/README.md +8 -5
  2. package/dist/context/agents.js +1 -1
  3. package/dist/context/build.d.ts +2 -0
  4. package/dist/context/build.js +22 -10
  5. package/dist/context/epoch.d.ts +59 -0
  6. package/dist/context/epoch.js +224 -0
  7. package/dist/context/frozen.d.ts +4 -2
  8. package/dist/context/frozen.js +31 -11
  9. package/dist/context/index.d.ts +2 -0
  10. package/dist/context/index.js +2 -0
  11. package/dist/context/overlay.d.ts +44 -0
  12. package/dist/context/overlay.js +96 -0
  13. package/dist/debug.d.ts +9 -1
  14. package/dist/debug.js +46 -5
  15. package/dist/host-event-bridge.d.ts +22 -0
  16. package/dist/host-event-bridge.js +22 -0
  17. package/dist/image-input.js +36 -11
  18. package/dist/index.d.ts +3 -0
  19. package/dist/language-model.d.ts +51 -3
  20. package/dist/language-model.js +569 -140
  21. package/dist/model-config.js +5 -1
  22. package/dist/plugin-opencode2.js +10 -4
  23. package/dist/plugin.js +26 -15
  24. package/dist/pricing-data.d.ts +40 -0
  25. package/dist/pricing-data.js +40 -0
  26. package/dist/protocol/ask-question.d.ts +8 -9
  27. package/dist/protocol/ask-question.js +8 -9
  28. package/dist/protocol/conversation-bind.js +18 -8
  29. package/dist/protocol/create-plan.d.ts +6 -7
  30. package/dist/protocol/create-plan.js +4 -5
  31. package/dist/protocol/messages.js +18 -1
  32. package/dist/protocol/struct.js +1 -1
  33. package/dist/protocol/switch-mode.d.ts +3 -4
  34. package/dist/protocol/switch-mode.js +21 -12
  35. package/dist/protocol/tool-call-bridge.d.ts +4 -1
  36. package/dist/protocol/tool-call-bridge.js +27 -5
  37. package/dist/protocol/tools.d.ts +4 -2
  38. package/dist/protocol/tools.js +346 -55
  39. package/dist/session-directory.d.ts +23 -4
  40. package/dist/session-directory.js +36 -4
  41. package/dist/session.d.ts +23 -3
  42. package/dist/session.js +68 -8
  43. package/dist/shared.d.ts +2 -0
  44. package/dist/shared.js +2 -0
  45. package/dist/transport/connect.js +78 -10
  46. package/dist/transport/https-proxy.d.ts +56 -0
  47. package/dist/transport/https-proxy.js +348 -0
  48. package/dist/usage.d.ts +11 -0
  49. package/dist/usage.js +33 -7
  50. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
1
+ import { OPENCODE_1_TOOL_DIALECT, mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
2
2
  import { decodeStructEntriesToJson } from "./struct.js";
3
3
  import { mapSwitchModeTarget } from "./switch-mode.js";
4
4
  const TODO_STATUS = {
@@ -50,11 +50,22 @@ const TOOL_CALL_VARIANTS = Object.keys(VARIANT_TO_OPENCODE);
50
50
  // Cursor todo merges). Data-returning, interactive, and side-effecting calls
51
51
  // must use an exec/interaction request channel where Cursor can receive their
52
52
  // actual result.
53
+ //
54
+ // `get_mcp_tools_tool_call` is Cursor-native discovery (prompt name
55
+ // GetDynamicTools when dynamic namespaces are on). The agent executes it
56
+ // server-side after mcp_state; oversized catalogs spill through write_args.
53
57
  const DISPLAY_STATE_MIRROR_VARIANTS = new Set([
54
58
  "update_todos_tool_call",
55
59
  "read_todos_tool_call",
56
60
  "create_plan_tool_call",
57
61
  ]);
62
+ const NATIVE_DISPLAY_VARIANTS = new Set([
63
+ "get_mcp_tools_tool_call",
64
+ ]);
65
+ /** Display variants Cursor executes itself (mcp_state + optional write spill). */
66
+ export function isNativeDisplayToolCall(variant) {
67
+ return NATIVE_DISPLAY_VARIANTS.has(variant);
68
+ }
58
69
  function asRecord(v) {
59
70
  return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
60
71
  }
@@ -291,7 +302,13 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
291
302
  if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
292
303
  const result = asRecord(payload.result);
293
304
  const success = asRecord(result?.success);
294
- const completedTodos = Array.isArray(success?.todos) ? success.todos : undefined;
305
+ // Cursor's completed frame may materialize proto-default `todos: []` even
306
+ // when the real final snapshot is present on args.todos. An empty default
307
+ // must not erase that named completion snapshot. A non-empty completed
308
+ // payload remains authoritative (especially for merge calls).
309
+ const completedTodos = Array.isArray(success?.todos) && success.todos.length > 0
310
+ ? success.todos
311
+ : undefined;
295
312
  const isMerge = variant === "update_todos_tool_call" && args.merge === true;
296
313
  // OpenCode todowrite replaces the whole list. Prefer the completed payload;
297
314
  // otherwise a non-merge args list; otherwise merge against the Run snapshot.
@@ -311,7 +328,10 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
311
328
  todos.unshift({
312
329
  id: "plan",
313
330
  content: [name && `Plan: ${name}`, overview, plan].filter(Boolean).join("\n").slice(0, 2000),
314
- status: "pending",
331
+ // The plan file is already written by the time this display mirror
332
+ // runs. Leaving it `pending` parks a forever-open row on hosts that
333
+ // keep todowrite as the user-visible checklist.
334
+ status: "completed",
315
335
  priority: "high",
316
336
  });
317
337
  }
@@ -454,7 +474,9 @@ function shellQuote(s) {
454
474
  * Pick an advertised OpenCode tool for a display call, remapping args as needed.
455
475
  * Returns undefined when nothing compatible is advertised this turn.
456
476
  */
457
- export function resolveBridgedOpenCodeToolCall(display, advertised) {
477
+ export function resolveBridgedOpenCodeToolCall(display, advertised, dialect = OPENCODE_1_TOOL_DIALECT) {
478
+ if (isNativeDisplayToolCall(display.variant))
479
+ return undefined;
458
480
  if (!DISPLAY_STATE_MIRROR_VARIANTS.has(display.variant))
459
481
  return undefined;
460
482
  if (display.bridgeable === false)
@@ -466,7 +488,7 @@ export function resolveBridgedOpenCodeToolCall(display, advertised) {
466
488
  for (const toolName of candidates) {
467
489
  if (!names.has(toolName))
468
490
  continue;
469
- const mapped = mapCursorArgsToOpencode(toolName, display.args);
491
+ const mapped = mapCursorArgsToOpencode(toolName, display.args, undefined, dialect);
470
492
  if (toolName === "todowrite") {
471
493
  mapped.args = {
472
494
  todos: mapTodos(display.args.todos ?? mapped.args.todos),
@@ -17,6 +17,7 @@ export type HostToolDialect = {
17
17
  shellTool: HostShellTool;
18
18
  };
19
19
  export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
20
+ export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
20
21
  /** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
21
22
  export declare function opencodePathArg(args: Record<string, unknown> | undefined): string | undefined;
22
23
  /**
@@ -26,7 +27,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
26
27
  export declare function hostToolDialectFromTools(tools: readonly {
27
28
  name?: string;
28
29
  inputSchema?: unknown;
29
- }[]): HostToolDialect;
30
+ }[], defaultDialect?: HostToolDialect): HostToolDialect;
30
31
  export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
31
32
  export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
32
33
  export declare const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
@@ -62,7 +63,8 @@ export declare function resolveToolServerIdentity(opencodeName: string, defaultS
62
63
  * default server (`opencode`). Tools whose prefixes match configured MCP
63
64
  * servers keep those server ids (`github`, …). Composite `name` is
64
65
  * `<server>-<bareTool>`; local execution still uses the full OpenCode id
65
- * reconstructed in `mcpRealToolName`.
66
+ * reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
67
+ * catalog owns canonicalization; sorting here would insert on grow.
66
68
  */
67
69
  export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
68
70
  /**
@@ -17,6 +17,10 @@ export const OPENCODE_1_TOOL_DIALECT = {
17
17
  filePathKey: "filePath",
18
18
  shellTool: "bash",
19
19
  };
20
+ export const OPENCODE_2_TOOL_DIALECT = {
21
+ filePathKey: "path",
22
+ shellTool: "shell",
23
+ };
20
24
  function jsonSchemaProperties(schema) {
21
25
  if (!schema || typeof schema !== "object" || Array.isArray(schema))
22
26
  return undefined;
@@ -24,11 +28,11 @@ function jsonSchemaProperties(schema) {
24
28
  if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
25
29
  return obj.properties;
26
30
  }
27
- const nested = obj.jsonSchema;
31
+ const nested = obj.jsonSchema ?? obj.parameters ?? obj.schema;
28
32
  if (nested && typeof nested === "object" && !Array.isArray(nested)) {
29
- const props = nested.properties;
30
- if (props && typeof props === "object" && !Array.isArray(props)) {
31
- return props;
33
+ const nestedProps = nested.properties;
34
+ if (nestedProps && typeof nestedProps === "object" && !Array.isArray(nestedProps)) {
35
+ return nestedProps;
32
36
  }
33
37
  }
34
38
  return undefined;
@@ -43,7 +47,7 @@ export function opencodePathArg(args) {
43
47
  * Infer the host tool dialect from advertised AI SDK schemas.
44
48
  * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
45
49
  */
46
- export function hostToolDialectFromTools(tools) {
50
+ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL_DIALECT) {
47
51
  let filePathKey;
48
52
  for (const name of ["read", "write", "edit"]) {
49
53
  const tool = tools.find((candidate) => candidate.name === name);
@@ -60,10 +64,12 @@ export function hostToolDialectFromTools(tools) {
60
64
  }
61
65
  }
62
66
  const names = new Set(tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
63
- const shellTool = names.has("shell") && !names.has("bash") ? "shell" : "bash";
67
+ const shellTool = names.has("shell") && !names.has("bash")
68
+ ? "shell"
69
+ : (names.has("bash") ? "bash" : defaultDialect.shellTool);
64
70
  if (!filePathKey) {
65
71
  // OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
66
- filePathKey = shellTool === "shell" ? "path" : "filePath";
72
+ filePathKey = shellTool === "shell" ? "path" : defaultDialect.filePathKey;
67
73
  }
68
74
  return { filePathKey, shellTool };
69
75
  }
@@ -115,12 +121,14 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
115
121
  * default server (`opencode`). Tools whose prefixes match configured MCP
116
122
  * servers keep those server ids (`github`, …). Composite `name` is
117
123
  * `<server>-<bareTool>`; local execution still uses the full OpenCode id
118
- * reconstructed in `mcpRealToolName`.
124
+ * reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
125
+ * catalog owns canonicalization; sorting here would insert on grow.
119
126
  */
120
127
  export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
121
- return [...tools]
122
- .sort((left, right) => left.name.localeCompare(right.name))
123
- .map((t) => {
128
+ // Advertised order is the prefix. The epoch catalog sorts once on first
129
+ // freeze and appends later names at the tail; re-sorting here would insert
130
+ // a new tool in front of `z` and invalidate the tools bytes.
131
+ return tools.map((t) => {
124
132
  const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
125
133
  const collisionSafeAlias = COLLISION_SAFE_ALIASES.has(t.name);
126
134
  return {
@@ -233,11 +241,10 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
233
241
  if (tools.length === 0)
234
242
  return [];
235
243
  const byServer = new Map();
236
- // OpenCode's tool catalog is a set even though the hook exposes it as an
237
- // array. Keep both the server and per-server tool order canonical so a host
238
- // that enumerates the same catalog differently does not invalidate Cursor's
239
- // RequestContext prefix cache.
240
- for (const t of [...tools].sort((left, right) => left.name.localeCompare(right.name))) {
244
+ // Walk advertised order: first-seen server, then append tools onto that
245
+ // server. Same-set host reorder is resolved by `resolveTurnToolState`
246
+ // before encode so this walk stays epoch-stable.
247
+ for (const t of tools) {
241
248
  const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
242
249
  let list = byServer.get(id.server);
243
250
  if (!list) {
@@ -250,14 +257,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
250
257
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
251
258
  });
252
259
  }
253
- const orderedServers = [...byServer.keys()].sort((left, right) => {
254
- if (left === providerIdentifier)
255
- return right === providerIdentifier ? 0 : -1;
256
- if (right === providerIdentifier)
257
- return 1;
258
- return left.localeCompare(right);
259
- });
260
- return orderedServers.map((server) => ({
260
+ return [...byServer.keys()].map((server) => ({
261
261
  server_name: server,
262
262
  server_identifier: server,
263
263
  tools: byServer.get(server),
@@ -872,6 +872,20 @@ function readRequestResultMetadata(raw, mappedArgs) {
872
872
  ...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
873
873
  };
874
874
  }
875
+ /**
876
+ * Cursor's native write executor echoes `WriteArgs.path` on `WriteSuccess.path`.
877
+ * Large GetMcpTools / GetDynamicTools catalogs spill through `write_args` into
878
+ * `agent-tools/<uuid>.txt` and then tell the model `filePath` from that success
879
+ * path. Host write output is free-form prose (often no `<path>` tag), so the
880
+ * request path is the only host-neutral identity we can report.
881
+ */
882
+ function writeRequestResultMetadata(raw, mappedArgs) {
883
+ const requestedPath = str(raw.path)
884
+ ?? str(raw.filePath)
885
+ ?? str(raw.file_path)
886
+ ?? opencodePathArg(mappedArgs);
887
+ return requestedPath ? { path: requestedPath } : undefined;
888
+ }
875
889
  /**
876
890
  * A partial-read notice is for Cursor's model, never for a file. Refuse only
877
891
  * whole-file mutation forms that echo it. A targeted edit or Update File patch
@@ -1049,7 +1063,11 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
1049
1063
  ? shellStreamResultMetadata(rawArgs)
1050
1064
  : execVariant === "read_args" || execVariant === "pi_read_args"
1051
1065
  ? readRequestResultMetadata(rawArgs, mapped.args)
1052
- : undefined;
1066
+ : execVariant === "write_args" || execVariant === "pi_write_args"
1067
+ ? writeRequestResultMetadata(rawArgs, mapped.args)
1068
+ : execVariant === "grep_args"
1069
+ ? grepRequestResultMetadata(rawArgs)
1070
+ : undefined;
1053
1071
  if (resultMetadata
1054
1072
  && resultMetadata.timeout_behavior !== 2
1055
1073
  && typeof resultMetadata.timeout_ms === "number") {
@@ -1166,7 +1184,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1166
1184
  else {
1167
1185
  content = stringValue(cleaned.content)
1168
1186
  ?? stringValue(cleaned.file_text)
1169
- ?? stringValue(cleaned.fileText);
1187
+ ?? stringValue(cleaned.fileText)
1188
+ ?? stringValue(cleaned.contents)
1189
+ ?? stringValue(cleaned.text);
1170
1190
  }
1171
1191
  if (content !== undefined)
1172
1192
  args.content = content;
@@ -1917,6 +1937,15 @@ function toolPathSeparator(filePath) {
1917
1937
  * roots intact, including `..`, and still use `path.resolve` for host paths.
1918
1938
  */
1919
1939
  function joinToolPath(root, relative) {
1940
+ // `path.resolve` drops a trailing separator. Glob and find use that separator
1941
+ // to tell a directory from a file; putting it back keeps the marker.
1942
+ const directory = relative.endsWith("/") || relative.endsWith("\\");
1943
+ const joined = joinResolvedToolPath(root, relative);
1944
+ if (!directory || joined.endsWith("/") || joined.endsWith("\\"))
1945
+ return joined;
1946
+ return `${joined}${toolPathSeparator(joined)}`;
1947
+ }
1948
+ function joinResolvedToolPath(root, relative) {
1920
1949
  if (!isForeignAbsoluteToolPath(root))
1921
1950
  return path.resolve(root, relative);
1922
1951
  const sep = toolPathSeparator(root);
@@ -1986,17 +2015,169 @@ function resolveToolPath(filePath, workspaceRoot) {
1986
2015
  * paths, indented match previews, and prose stay untouched. Grep file headers
1987
2016
  * drop the trailing colon only in the structured file list, not in this text.
1988
2017
  */
2018
+ /**
2019
+ * Longer phrases first. "No files found" is a prefix of the pattern sentence,
2020
+ * and a suffix check must not stop on the shorter one.
2021
+ */
2022
+ const SEARCH_STATUS_MESSAGES = [
2023
+ "No files found matching pattern",
2024
+ "No matches before timeout (scan incomplete)",
2025
+ "No files found",
2026
+ "No matches found",
2027
+ ];
2028
+ /**
2029
+ * A glob/find miss is prose, not a path. Hosts sometimes still carry it as a
2030
+ * path segment (`../../workspace/No files found matching pattern`) or fold that
2031
+ * segment into a `# dir/` header plus a bare sentence. Either shape must stay
2032
+ * the sentence: joining it onto the workspace is what lists a fake file.
2033
+ */
2034
+ function searchStatusMessage(line) {
2035
+ const trimmed = line.trim().replace(/^#+\s+/, "");
2036
+ for (const message of SEARCH_STATUS_MESSAGES) {
2037
+ if (trimmed === message || trimmed.endsWith(`/${message}`) || trimmed.endsWith(`\\${message}`)) {
2038
+ return message;
2039
+ }
2040
+ }
2041
+ return undefined;
2042
+ }
2043
+ function collapseSearchStatus(output) {
2044
+ return output.split("\n").map((line) => searchStatusMessage(line) ?? line).join("\n");
2045
+ }
2046
+ function isSearchStatusReport(output) {
2047
+ const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
2048
+ return lines.length > 0 && lines.every((line) => searchStatusMessage(line) !== undefined);
2049
+ }
2050
+ /**
2051
+ * Cursor's find/glob executor shows one path per line. A directory ends with
2052
+ * `/`; a file does not. Some hosts instead print a folded tree:
2053
+ *
2054
+ * # src/
2055
+ * a.ts
2056
+ * ## components/
2057
+ * button.tsx
2058
+ * # tests/
2059
+ *
2060
+ * A `# dir/` line is a grouping header, not another match. Emitting it lists
2061
+ * the directory beside its own children (and the shared search prefix shows up
2062
+ * as a blank entry once that prefix is the directory being searched). Expand
2063
+ * the tree to the files underneath only. A header with no child is still just
2064
+ * an empty directory in the walk — Cursor find reports files, so leave it out
2065
+ * (same as targeting that empty directory and getting a miss).
2066
+ */
2067
+ function parseGroupedPathListing(output) {
2068
+ const lines = output.replace(/\n+$/, "").split("\n");
2069
+ const events = [];
2070
+ const stack = [];
2071
+ let sawHeader = false;
2072
+ let inNotes = false;
2073
+ for (const line of lines) {
2074
+ if (!line.trim()) {
2075
+ if (sawHeader)
2076
+ inNotes = true;
2077
+ continue;
2078
+ }
2079
+ if (inNotes) {
2080
+ events.push({ kind: "note", text: line });
2081
+ continue;
2082
+ }
2083
+ const status = searchStatusMessage(line);
2084
+ if (status) {
2085
+ events.push({ kind: "note", text: status });
2086
+ continue;
2087
+ }
2088
+ const header = /^(#+)\s+(\S.*?)\s*$/.exec(line);
2089
+ if (header) {
2090
+ const rawName = header[2] ?? "";
2091
+ if (!rawName.endsWith("/") && !rawName.endsWith("\\"))
2092
+ return undefined;
2093
+ const depth = header[1].length - 1;
2094
+ if (depth > stack.length)
2095
+ return undefined;
2096
+ const name = rawName.slice(0, -1);
2097
+ if (!name || name === "." || name === "..")
2098
+ return undefined;
2099
+ stack.length = depth;
2100
+ const parent = depth === 0 ? "" : (stack[depth - 1] ?? "");
2101
+ if (depth > 0 && !parent)
2102
+ return undefined;
2103
+ const full = parent ? joinDisplayPath(parent, name) : name;
2104
+ stack.push(full);
2105
+ events.push({ kind: "dir", depth, path: full });
2106
+ sawHeader = true;
2107
+ continue;
2108
+ }
2109
+ if (line.startsWith(" ") || line.startsWith("\t") || /\s/.test(line) || line.includes("://")) {
2110
+ return undefined;
2111
+ }
2112
+ if (line.includes("/") || line.includes("\\"))
2113
+ return undefined;
2114
+ if (!sawHeader) {
2115
+ events.push({ kind: "file", path: line });
2116
+ continue;
2117
+ }
2118
+ const parent = stack[stack.length - 1];
2119
+ if (!parent)
2120
+ return undefined;
2121
+ events.push({ kind: "file", path: joinDisplayPath(parent, line) });
2122
+ }
2123
+ if (!sawHeader)
2124
+ return undefined;
2125
+ const paths = [];
2126
+ const notes = [];
2127
+ let sawFile = false;
2128
+ for (const event of events) {
2129
+ if (event.kind === "note")
2130
+ notes.push(event.text);
2131
+ else if (event.kind === "file") {
2132
+ sawFile = true;
2133
+ paths.push(event.path);
2134
+ }
2135
+ // Directory headers are structure only — never emit them as matches.
2136
+ }
2137
+ // The header existed only to hold the miss sentence. Emitting it lists a
2138
+ // directory that was never a match, usually the walk back to the workspace.
2139
+ if (!sawFile && notes.some((note) => searchStatusMessage(note)))
2140
+ return { paths: [], notes };
2141
+ return { paths, notes };
2142
+ }
2143
+ function joinDisplayPath(parent, child) {
2144
+ if (!parent || parent === ".")
2145
+ return child;
2146
+ if (parent === "/")
2147
+ return `/${child}`;
2148
+ const sep = parent.includes("\\") && !parent.includes("/") ? "\\" : "/";
2149
+ if (parent.endsWith("/") || parent.endsWith("\\"))
2150
+ return `${parent}${child}`;
2151
+ return `${parent}${sep}${child}`;
2152
+ }
2153
+ function renderGroupedPathListing(listing, workspaceRoot) {
2154
+ const paths = listing.paths.map((entry) => resolveToolPath(entry, workspaceRoot));
2155
+ if (paths.length === 0) {
2156
+ // Grouped output that only had directory headers (empty dirs) is a miss,
2157
+ // same as targeting that empty directory directly.
2158
+ if (listing.notes.length === 0)
2159
+ return "No files found";
2160
+ return listing.notes.join("\n");
2161
+ }
2162
+ if (listing.notes.length === 0)
2163
+ return paths.join("\n");
2164
+ return [...paths, "", ...listing.notes].join("\n");
2165
+ }
1989
2166
  function groundSearchOutput(output, workspaceRoot) {
1990
- if (!workspaceRoot)
1991
- return output;
1992
2167
  const normalized = normalizeToolText(output);
1993
- const first = normalized.split("\n", 1)[0] ?? "";
1994
- const searchShaped = /^Found \d+ matches/.test(first)
1995
- || first === "No matches found"
1996
- || first === "No files found";
1997
- if (!searchShaped && !isBarePathList(normalized))
1998
- return output;
1999
- return normalized.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
2168
+ const grouped = parseGroupedPathListing(normalized);
2169
+ if (grouped)
2170
+ return renderGroupedPathListing(grouped, workspaceRoot);
2171
+ const collapsed = collapseSearchStatus(normalized);
2172
+ if (isSearchStatusReport(collapsed))
2173
+ return collapsed;
2174
+ if (!workspaceRoot)
2175
+ return collapsed;
2176
+ const first = collapsed.split("\n", 1)[0] ?? "";
2177
+ const searchShaped = /^Found \d+ matches/.test(first) || searchStatusMessage(first) !== undefined;
2178
+ if (!searchShaped && !isBarePathList(collapsed))
2179
+ return collapsed;
2180
+ return collapsed.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
2000
2181
  }
2001
2182
  function isBarePathList(output) {
2002
2183
  const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
@@ -2013,8 +2194,9 @@ function isBarePathList(output) {
2013
2194
  function rewriteSearchPathLine(line, workspaceRoot) {
2014
2195
  if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
2015
2196
  return line;
2016
- if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
2017
- return line;
2197
+ const status = searchStatusMessage(line);
2198
+ if (status || line.startsWith("Found "))
2199
+ return status ?? line;
2018
2200
  const header = /^(.*):$/.exec(line);
2019
2201
  if (header && !header[1]?.includes("://")) {
2020
2202
  return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
@@ -2063,22 +2245,99 @@ function rewriteShellPathLine(line, root) {
2063
2245
  return resolveToolPath(trimmed, root);
2064
2246
  return line;
2065
2247
  }
2248
+ const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
2249
+ /**
2250
+ * OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
2251
+ * and indented `Line N: text` previews. A page with no previews is not content.
2252
+ */
2253
+ function parseOpenCodeGrepContent(output, workspaceRoot) {
2254
+ const lines = normalizeToolText(output).split("\n");
2255
+ const first = lines[0]?.trim() ?? "";
2256
+ if (!/^Found \d+ matches\b/.test(first))
2257
+ return undefined;
2258
+ const matches = [];
2259
+ let current;
2260
+ let truncated = /\bmore matches available\b/.test(first);
2261
+ let sawLine = false;
2262
+ for (const raw of lines.slice(1)) {
2263
+ if (!raw.trim())
2264
+ continue;
2265
+ const preview = OPENCODE_GREP_LINE.exec(raw);
2266
+ if (preview) {
2267
+ sawLine = true;
2268
+ if (!current)
2269
+ continue;
2270
+ const lineNumber = Number(preview[1]);
2271
+ if (!Number.isSafeInteger(lineNumber) || lineNumber < 1)
2272
+ continue;
2273
+ current.matches.push({ line_number: lineNumber, content: preview[2] ?? "" });
2274
+ continue;
2275
+ }
2276
+ if (raw.trimStart().startsWith("(")) {
2277
+ if (/truncat/i.test(raw))
2278
+ truncated = true;
2279
+ continue;
2280
+ }
2281
+ if (raw.startsWith(" ") || raw.startsWith("\t"))
2282
+ continue;
2283
+ const header = /^(.*):$/.exec(raw.trim());
2284
+ if (!header || !header[1] || header[1].includes("://"))
2285
+ continue;
2286
+ current = { file: resolveToolPath(header[1], workspaceRoot), matches: [] };
2287
+ matches.push(current);
2288
+ }
2289
+ if (!sawLine)
2290
+ return undefined;
2291
+ const withHits = matches.filter((file) => file.matches.length > 0);
2292
+ if (withHits.length === 0)
2293
+ return undefined;
2294
+ return {
2295
+ matches: withHits,
2296
+ truncated,
2297
+ totalMatchedLines: withHits.reduce((count, file) => count + file.matches.length, 0),
2298
+ };
2299
+ }
2300
+ function grepRequestResultMetadata(raw) {
2301
+ const pattern = str(raw.pattern);
2302
+ const outputMode = str(raw.output_mode);
2303
+ const searchPath = str(raw.path);
2304
+ if (!pattern && !outputMode && !searchPath)
2305
+ return undefined;
2306
+ return {
2307
+ ...(pattern ? { pattern } : {}),
2308
+ ...(outputMode ? { output_mode: outputMode } : {}),
2309
+ ...(searchPath ? { path: searchPath } : {}),
2310
+ };
2311
+ }
2066
2312
  function extractGroundedPaths(output, workspaceRoot) {
2067
2313
  const normalized = normalizeToolText(output);
2068
- const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
2314
+ const grouped = parseGroupedPathListing(normalized);
2315
+ if (grouped) {
2316
+ return grouped.paths.map((entry) => resolveToolPath(entry, workspaceRoot)).slice(0, 2000);
2317
+ }
2318
+ const collapsed = collapseSearchStatus(normalized);
2319
+ if (isSearchStatusReport(collapsed))
2320
+ return [];
2321
+ const directory = parseOpenCode2DirectoryListing(collapsed, workspaceRoot);
2069
2322
  if (directory)
2070
2323
  return directory.entries.slice(0, 2000);
2071
- const first = normalized.split("\n", 1)[0]?.trim() ?? "";
2072
- if (/^Found \d+ matches/.test(first) || first === "No matches found" || first === "No files found") {
2324
+ const first = collapsed.split("\n", 1)[0]?.trim() ?? "";
2325
+ if (/^Found \d+ matches/.test(first) || searchStatusMessage(first)) {
2073
2326
  const paths = [];
2074
- for (const raw of normalized.split("\n").slice(1)) {
2327
+ for (const raw of collapsed.split("\n").slice(1)) {
2075
2328
  const line = raw.trim();
2076
- if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
2329
+ if (!line
2330
+ || line.startsWith("(")
2331
+ || line.startsWith("Line ")
2332
+ || line.startsWith("#")
2333
+ || searchStatusMessage(line)
2334
+ || raw.startsWith(" ")
2335
+ || raw.startsWith("\t")) {
2077
2336
  continue;
2078
2337
  }
2079
2338
  const header = /^(.*):$/.exec(line);
2080
2339
  const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
2081
- if (!candidate || candidate.includes("://"))
2340
+ if (!candidate || candidate.includes("://") || searchStatusMessage(candidate))
2082
2341
  continue;
2083
2342
  paths.push(resolveToolPath(candidate, workspaceRoot));
2084
2343
  }
@@ -2231,21 +2490,50 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2231
2490
  case "grep_result": {
2232
2491
  if (error)
2233
2492
  return { error: { error } };
2234
- // Prefer files_with_matches: OpenCode glob/grep often returns path lists.
2235
- // Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
2236
- const files = extractGroundedPaths(output, resultRoot);
2237
2493
  const cwd = resultRoot ?? "";
2494
+ const pattern = str(resultMetadata?.pattern) ?? "";
2495
+ const requestedPath = str(resultMetadata?.path) ?? cwd;
2496
+ const requestedMode = str(resultMetadata?.output_mode);
2497
+ // OpenCode always returns match previews. Cursor's native Grep defaults
2498
+ // to content; encoding that as files_with_matches drops the previews and
2499
+ // the model only sees paths. Keep files_with_matches for an explicit
2500
+ // files-only request, and for path lists (glob remaps, no matches).
2501
+ const parsedContent = parseOpenCodeGrepContent(output, resultRoot);
2502
+ const content = requestedMode === "files_with_matches"
2503
+ ? undefined
2504
+ : parsedContent;
2505
+ if (content) {
2506
+ return {
2507
+ success: {
2508
+ pattern,
2509
+ path: requestedPath,
2510
+ output_mode: "content",
2511
+ workspace_results: {
2512
+ [cwd]: {
2513
+ content: {
2514
+ matches: content.matches,
2515
+ total_lines: content.totalMatchedLines,
2516
+ total_matched_lines: content.totalMatchedLines,
2517
+ client_truncated: content.truncated,
2518
+ ripgrep_truncated: false,
2519
+ },
2520
+ },
2521
+ },
2522
+ },
2523
+ };
2524
+ }
2525
+ const files = extractGroundedPaths(output, resultRoot);
2238
2526
  return {
2239
2527
  success: {
2240
- pattern: "",
2241
- path: cwd,
2528
+ pattern,
2529
+ path: requestedPath,
2242
2530
  output_mode: "files_with_matches",
2243
2531
  workspace_results: {
2244
2532
  [cwd]: {
2245
2533
  files: {
2246
2534
  files,
2247
2535
  total_files: files.length,
2248
- client_truncated: false,
2536
+ client_truncated: parsedContent?.truncated ?? false,
2249
2537
  },
2250
2538
  },
2251
2539
  },
@@ -2253,8 +2541,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2253
2541
  };
2254
2542
  }
2255
2543
  case "write_result": {
2256
- // `apply_patch` output carries no <path> tag, so prefer the path recorded
2257
- // when the request was remapped away from `write`.
2544
+ // Prefer the path recorded from WriteArgs / a remapped apply_patch write.
2545
+ // Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
2546
+ // spill copies WriteSuccess.path into the model-visible filePath.
2258
2547
  const remappedPath = str(resultMetadata?.path);
2259
2548
  if (error)
2260
2549
  return { error: { path: remappedPath ?? "", error } };
@@ -2782,10 +3071,12 @@ function readFileLineCount(filePath) {
2782
3071
  }
2783
3072
  }
2784
3073
  function extractPathLines(output) {
2785
- const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
2786
- // Prefer absolute / relative path-looking lines; fall back to all non-empty.
2787
- const paths = lines.filter((l) => l.startsWith("/") || l.startsWith("./") || l.includes("/"));
2788
- return (paths.length > 0 ? paths : lines).slice(0, 2000);
3074
+ const lines = collapseSearchStatus(output).split("\n").map((l) => l.trim()).filter(Boolean);
3075
+ // A folded `# dir/` header is not itself a path. Prefer absolute / relative
3076
+ // path-looking lines; fall back to remaining non-status lines.
3077
+ const usable = lines.filter((line) => !searchStatusMessage(line) && !/^#+\s+\S/.test(line));
3078
+ const paths = usable.filter((l) => l.startsWith("/") || l.startsWith("./") || l.includes("/"));
3079
+ return (paths.length > 0 ? paths : usable).slice(0, 2000);
2789
3080
  }
2790
3081
  function countLines(s) {
2791
3082
  if (!s)