cursor-opencode-provider 0.7.2 → 0.7.4

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 (53) 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/opencode2/mcp-direct.d.ts +26 -0
  23. package/dist/opencode2/mcp-direct.js +60 -0
  24. package/dist/opencode2/types.d.ts +24 -0
  25. package/dist/plugin-opencode2.js +24 -4
  26. package/dist/plugin.js +26 -15
  27. package/dist/pricing-data.d.ts +40 -0
  28. package/dist/pricing-data.js +40 -0
  29. package/dist/protocol/ask-question.d.ts +8 -9
  30. package/dist/protocol/ask-question.js +8 -9
  31. package/dist/protocol/conversation-bind.js +18 -8
  32. package/dist/protocol/create-plan.d.ts +6 -7
  33. package/dist/protocol/create-plan.js +4 -5
  34. package/dist/protocol/messages.js +16 -1
  35. package/dist/protocol/struct.js +1 -1
  36. package/dist/protocol/switch-mode.d.ts +3 -4
  37. package/dist/protocol/switch-mode.js +21 -12
  38. package/dist/protocol/tool-call-bridge.d.ts +4 -1
  39. package/dist/protocol/tool-call-bridge.js +27 -5
  40. package/dist/protocol/tools.d.ts +4 -2
  41. package/dist/protocol/tools.js +247 -51
  42. package/dist/session-directory.d.ts +23 -4
  43. package/dist/session-directory.js +36 -4
  44. package/dist/session.d.ts +23 -3
  45. package/dist/session.js +68 -8
  46. package/dist/shared.d.ts +2 -0
  47. package/dist/shared.js +2 -0
  48. package/dist/transport/connect.js +78 -10
  49. package/dist/transport/https-proxy.d.ts +56 -0
  50. package/dist/transport/https-proxy.js +348 -0
  51. package/dist/usage.d.ts +11 -0
  52. package/dist/usage.js +33 -7
  53. package/package.json +1 -1
@@ -249,7 +249,7 @@ export function encodeJsonAsValue(v) {
249
249
  }
250
250
  else if (typeof v === "object") {
251
251
  const st = [];
252
- for (const [key, val] of Object.entries(v).sort(([left], [right]) => left.localeCompare(right))) {
252
+ for (const [key, val] of Object.entries(v).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
253
253
  if (val === undefined)
254
254
  continue;
255
255
  const entry = [];
@@ -30,10 +30,8 @@
30
30
  * - leaving plan mode is the gate the user must actually see, so it falls back
31
31
  * to the host `question` tool and only rejects when that is absent too.
32
32
  *
33
- * Resolution is keyed solely on the advertised catalog under canonical tool
34
- * names — never on host identity or model id. A compatibility layer may restate
35
- * alternate host vocabulary under canonical names before the provider sees the
36
- * catalog, so the bridge itself remains OpenCode-shaped.
33
+ * Resolution is keyed solely on the advertised catalog under canonical
34
+ * OpenCode tool names — never on any external identity or model id.
37
35
  *
38
36
  * CLI reject reason for user declines: "Mode switch rejected by user"
39
37
  * (chunk-7076/dist/ui.js onSwitchModeReject).
@@ -169,6 +167,7 @@ export declare function resetActiveCursorModesForTests(): void;
169
167
  export declare function cursorModeSystemReminder(targetModeId: string, options?: {
170
168
  firstTurn?: boolean;
171
169
  planExitAdvertised?: boolean;
170
+ planStageAdvertised?: boolean;
172
171
  }): string | undefined;
173
172
  /**
174
173
  * Consume the active-mode reminder for this OpenCode session.
@@ -30,10 +30,8 @@
30
30
  * - leaving plan mode is the gate the user must actually see, so it falls back
31
31
  * to the host `question` tool and only rejects when that is absent too.
32
32
  *
33
- * Resolution is keyed solely on the advertised catalog under canonical tool
34
- * names — never on host identity or model id. A compatibility layer may restate
35
- * alternate host vocabulary under canonical names before the provider sees the
36
- * catalog, so the bridge itself remains OpenCode-shaped.
33
+ * Resolution is keyed solely on the advertised catalog under canonical
34
+ * OpenCode tool names — never on any external identity or model id.
37
35
  *
38
36
  * CLI reject reason for user declines: "Mode switch rejected by user"
39
37
  * (chunk-7076/dist/ui.js onSwitchModeReject).
@@ -295,12 +293,15 @@ export function cursorModeSystemReminder(targetModeId, options = {}) {
295
293
  if (!id)
296
294
  return undefined;
297
295
  const first = options.firstTurn !== false;
298
- // Without a host plan_exit, recording the plan is itself the gate: the
299
- // provider persists it and then asks the user whether to start implementing.
300
- // Naming an unavailable tool would strand the model.
301
- const leavePlan = options.planExitAdvertised === false
302
- ? "record the finished plan (Cursor CreatePlan). Writing it needs no approval, and the user is then asked whether to start implementing; if they decline, refine the plan and record it again"
303
- : "record the finished plan, then call OpenCode `plan_exit` so the user can approve leaving plan mode";
296
+ // A host plan-stage tool writes the artifact and its result names the
297
+ // follow-up that requests approval. plan_exit only leaves plan mode.
298
+ // Without either tool, recording the plan is the gate and the provider asks
299
+ // whether to start implementing. Naming an unavailable tool would strand the model.
300
+ const leavePlan = options.planStageAdvertised
301
+ ? "record the finished plan with Cursor CreatePlan. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until the tool returns success"
302
+ : options.planExitAdvertised === false
303
+ ? "record the finished plan (Cursor CreatePlan). Writing it needs no approval, and the user is then asked whether to start implementing; if they decline, refine the plan and record it again"
304
+ : "record the finished plan, then call OpenCode `plan_exit` so the user can approve leaving plan mode";
304
305
  if (id === "plan" || id === "spec") {
305
306
  return wrapReminder(first
306
307
  ? `Plan mode is active. The user does not want execution yet -- you MUST NOT make edits, run non-readonly tools (including changing configs or making commits), or otherwise modify system state. This supersedes any conflicting instruction.
@@ -389,9 +390,12 @@ Your mandate is to convert user intent into a correct, high-quality implementati
389
390
  return wrapReminder(first
390
391
  ? `Agent mode is active (OpenCode build / agents). You have left plan mode and may implement: edit files, run tools, and execute the agreed approach.
391
392
 
393
+ The plan-mode restriction is over. An earlier reminder that forbade edits no longer applies.
394
+
395
+ - Change file contents with the advertised \`write\` and \`edit\` tools.
392
396
  - Prefer making progress with the advertised host tools.
393
397
  - If the task is large or ambiguous again, switch back to plan mode (OpenCode \`plan_enter\` / SwitchMode target plan) before a large rewrite.`
394
- : `Agent mode is still active. Continue implementing with the advertised host tools. Switch back to plan mode only when a new large/ambiguous design decision appears.`);
398
+ : `Agent mode is still active. Continue implementing with the advertised host tools, and change file contents with \`write\` and \`edit\`. Switch back to plan mode only when a new large/ambiguous design decision appears.`);
395
399
  }
396
400
  /**
397
401
  * Consume the active-mode reminder for this OpenCode session.
@@ -417,7 +421,12 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
417
421
  }
418
422
  const reminder = cursorModeSystemReminder(state.modeId, {
419
423
  firstTurn: state.firstTurn,
420
- ...(advertised ? { planExitAdvertised: advertised.has("plan_exit") } : {}),
424
+ ...(advertised
425
+ ? {
426
+ planExitAdvertised: advertised.has("plan_exit"),
427
+ planStageAdvertised: advertised.has("cursor_plan_stage"),
428
+ }
429
+ : {}),
421
430
  });
422
431
  if (state.firstTurn)
423
432
  state.firstTurn = false;
@@ -1,3 +1,4 @@
1
+ import { type HostToolDialect } from "./tools.js";
1
2
  /**
2
3
  * Cursor's interaction_update.tool_call_* carries a typed ToolCall oneof
3
4
  * (shell_tool_call, update_todos_tool_call, mcp_tool_call, …). Some of those
@@ -19,6 +20,8 @@ export type BridgedOpenCodeToolCall = {
19
20
  callId: string;
20
21
  variant: string;
21
22
  };
23
+ /** Display variants Cursor executes itself (mcp_state + optional write spill). */
24
+ export declare function isNativeDisplayToolCall(variant: string): boolean;
22
25
  /**
23
26
  * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
24
27
  * undefined when the input is not a todo array (nothing to learn). Drops the
@@ -55,7 +58,7 @@ export declare function parseDisplayToolCall(callId: string, toolCall: Record<st
55
58
  * Pick an advertised OpenCode tool for a display call, remapping args as needed.
56
59
  * Returns undefined when nothing compatible is advertised this turn.
57
60
  */
58
- export declare function resolveBridgedOpenCodeToolCall(display: DisplayToolCall, advertised: Iterable<string>): BridgedOpenCodeToolCall | undefined;
61
+ export declare function resolveBridgedOpenCodeToolCall(display: DisplayToolCall, advertised: Iterable<string>, dialect?: HostToolDialect): BridgedOpenCodeToolCall | undefined;
59
62
  /** Advertised OpenCode tool ids from Cursor McpToolDefinition descriptors. */
60
63
  export declare function advertisedToolNamesFromDescriptors(descriptors: Array<Record<string, unknown>>): string[];
61
64
  /** Walk a protobuf message and return top-level field numbers present. */
@@ -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,9 +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
- : execVariant === "grep_args"
1053
- ? grepRequestResultMetadata(rawArgs)
1054
- : undefined;
1066
+ : execVariant === "write_args" || execVariant === "pi_write_args"
1067
+ ? writeRequestResultMetadata(rawArgs, mapped.args)
1068
+ : execVariant === "grep_args"
1069
+ ? grepRequestResultMetadata(rawArgs)
1070
+ : undefined;
1055
1071
  if (resultMetadata
1056
1072
  && resultMetadata.timeout_behavior !== 2
1057
1073
  && typeof resultMetadata.timeout_ms === "number") {
@@ -1168,7 +1184,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1168
1184
  else {
1169
1185
  content = stringValue(cleaned.content)
1170
1186
  ?? stringValue(cleaned.file_text)
1171
- ?? stringValue(cleaned.fileText);
1187
+ ?? stringValue(cleaned.fileText)
1188
+ ?? stringValue(cleaned.contents)
1189
+ ?? stringValue(cleaned.text);
1172
1190
  }
1173
1191
  if (content !== undefined)
1174
1192
  args.content = content;
@@ -1919,6 +1937,15 @@ function toolPathSeparator(filePath) {
1919
1937
  * roots intact, including `..`, and still use `path.resolve` for host paths.
1920
1938
  */
1921
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) {
1922
1949
  if (!isForeignAbsoluteToolPath(root))
1923
1950
  return path.resolve(root, relative);
1924
1951
  const sep = toolPathSeparator(root);
@@ -1988,17 +2015,169 @@ function resolveToolPath(filePath, workspaceRoot) {
1988
2015
  * paths, indented match previews, and prose stay untouched. Grep file headers
1989
2016
  * drop the trailing colon only in the structured file list, not in this text.
1990
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
+ }
1991
2166
  function groundSearchOutput(output, workspaceRoot) {
1992
- if (!workspaceRoot)
1993
- return output;
1994
2167
  const normalized = normalizeToolText(output);
1995
- const first = normalized.split("\n", 1)[0] ?? "";
1996
- const searchShaped = /^Found \d+ matches/.test(first)
1997
- || first === "No matches found"
1998
- || first === "No files found";
1999
- if (!searchShaped && !isBarePathList(normalized))
2000
- return output;
2001
- 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");
2002
2181
  }
2003
2182
  function isBarePathList(output) {
2004
2183
  const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
@@ -2015,8 +2194,9 @@ function isBarePathList(output) {
2015
2194
  function rewriteSearchPathLine(line, workspaceRoot) {
2016
2195
  if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
2017
2196
  return line;
2018
- if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
2019
- return line;
2197
+ const status = searchStatusMessage(line);
2198
+ if (status || line.startsWith("Found "))
2199
+ return status ?? line;
2020
2200
  const header = /^(.*):$/.exec(line);
2021
2201
  if (header && !header[1]?.includes("://")) {
2022
2202
  return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
@@ -2131,20 +2311,33 @@ function grepRequestResultMetadata(raw) {
2131
2311
  }
2132
2312
  function extractGroundedPaths(output, workspaceRoot) {
2133
2313
  const normalized = normalizeToolText(output);
2134
- 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);
2135
2322
  if (directory)
2136
2323
  return directory.entries.slice(0, 2000);
2137
- const first = normalized.split("\n", 1)[0]?.trim() ?? "";
2138
- 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)) {
2139
2326
  const paths = [];
2140
- for (const raw of normalized.split("\n").slice(1)) {
2327
+ for (const raw of collapsed.split("\n").slice(1)) {
2141
2328
  const line = raw.trim();
2142
- 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")) {
2143
2336
  continue;
2144
2337
  }
2145
2338
  const header = /^(.*):$/.exec(line);
2146
2339
  const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
2147
- if (!candidate || candidate.includes("://"))
2340
+ if (!candidate || candidate.includes("://") || searchStatusMessage(candidate))
2148
2341
  continue;
2149
2342
  paths.push(resolveToolPath(candidate, workspaceRoot));
2150
2343
  }
@@ -2348,8 +2541,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2348
2541
  };
2349
2542
  }
2350
2543
  case "write_result": {
2351
- // `apply_patch` output carries no <path> tag, so prefer the path recorded
2352
- // 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.
2353
2547
  const remappedPath = str(resultMetadata?.path);
2354
2548
  if (error)
2355
2549
  return { error: { path: remappedPath ?? "", error } };
@@ -2877,10 +3071,12 @@ function readFileLineCount(filePath) {
2877
3071
  }
2878
3072
  }
2879
3073
  function extractPathLines(output) {
2880
- const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
2881
- // Prefer absolute / relative path-looking lines; fall back to all non-empty.
2882
- const paths = lines.filter((l) => l.startsWith("/") || l.startsWith("./") || l.includes("/"));
2883
- 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);
2884
3080
  }
2885
3081
  function countLines(s) {
2886
3082
  if (!s)